1.0: the acceptance test drops its reboot clause (a live system is the finish line) and passes. Schcom is kept with a job: the installer's GUI first, SchDE on top later (roadmap section 5). The old 'LDLS ships no Linux code' policy is gone from ldls/devices/licensing/Makefile/.gitignore/ldlsd; NOTICE records the r8169 modules (GPL-2.0-only) and the Realtek firmware. The download button points at the v0.1.0 release asset; the stale 64 MB ISO under docs/Website is removed. mkaroot.sh's -dumbSched comment no longer claims SchOS has no threads. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EVRhxi9zpdW2uDUN4FVcao
23 KiB
LDLS — running somebody else's drivers
SchOS cannot write a driver for every device that exists. No project can. The only route to the hardware support an established system has is to run Linux's drivers, and LDLS is what does that.
It works, and on more than one driver. 8139too and e1000 — unmodified GPL
kernel modules — load into a SchOS process, drive their hardware through
/dev/pci, and carry packets (both under QEMU). A third, Linux's r8169, is
the NIC driver on the ASUS TUF A15 laptop, and it is the first that needs a
set of modules and not one; it is described under A set of modules. It has
registered its interface and read its PHY on that laptop; whether it carries
traffic there has not been confirmed yet.
# ldlsd /lib/modules/8139too.ko /lib/modules/btf &
kpi: the driver read a MAC off the hardware: 52:54:00:ab:cd:ef
ndo_open returned 0
carrying frames. the interface is ldls0.
# ping 10.9.0.2 4
64 bytes from 10.9.0.2: icmp_seq=1 time=30 ms
...
4 packets transmitted, 4 received, 0% packet loss
10.9.0.0/24 is reachable only through ldls0. The address on it came from
DHCP, over the same driver.
The e1000 run is the more interesting of the two, because it is a handover: the kernel was driving that NIC, and gave it up.
net: eth1 is gone; interface 1 removed
e1000: released to userspace; the interface is down
rehomed 23 %gs per-CPU reference(s)
calling probe, with 82 of 155 symbols implemented
kpi: the driver read a MAC off the hardware: 52:54:00:12:34:57
net: ldls0 attached as interface 2, asking for an address
net: ldls0 10.9.0.15/255.255.255.0 via 10.9.0.2
Everything the second driver needed that the first did not is below, and the pattern in it is worth naming up front: none of it was a missing feature. Every one was a call that succeeded, returned zero, and did nothing.
The rules it is built on
Five, and everything else is an implementation of them.
1. Linux's drivers ship in the image
SchOS is GPL-2.0-or-later, and GPL-2.0-only code combines with it, so there is no
licensing reason to keep Linux's drivers out of an image. They are in: the r8169 set
(mdio-bus, libphy, mdio_devres, the Realtek PHY driver and r8169, unmodified) and
the RTL8168 PHY firmware are what the ISO carries for the laptop's network, with their
licence and corresponding source recorded in NOTICE
(../licensing.md §6).
(An earlier version of this rule said the opposite — that LDLS would ship the layer and never the thing it loads. That was written when SchOS was Apache-2.0 and the combination could not be distributed; it stopped being true with the move to the GPL, and the owner has said Linux code and blobs may be shipped.)
What is left is mechanical. The repository holds no *.ko — a binary does not belong in git, so
.gitignore refuses them — and make ldls-r8169 (before make iso) stages the set from a
Linux build, together with its BTF, because a module, its dependencies and the BTF that
describes them have to come from the same build. make ldls-stage KO=… does the same for any
other module, which is then a distribution of that module's code, under its own licence.
2. It runs in a process, never in the kernel
A module linked into our kernel is the strongest derivative-work argument that exists. A module combined with our shim inside a user process is a different thing at arm's length — the position FreeBSD has shipped from for a decade.
And practically: this is foreign code. A driver we did not write, cannot fully audit and did not choose the quality of should not be able to take the machine down.
3. Facts, not expression
A symbol's signature and a struct's field offsets are facts a compiled driver
depends on. Comments, macro bodies and implementations are expression and are
never carried over. Every file in ldls/kpi/ records where its shape came
from.
Mostly the question does not arise, because of rule 3½:
3½. The layouts are not in our source at all
A kernel built with CONFIG_DEBUG_INFO_BTF publishes
/sys/kernel/btf/vmlinux: every struct layout and every function prototype, as
generated type data describing the kernel that is installed. ldls/btf.c reads
it at load time, on the user's machine, from the user's own kernel.
$ ldls-types /sys/kernel/btf/vmlinux --need
pci_driver probe offset 16 size 8
pci_dev resource offset 1000 size 1088
resource start offset 0 size 8
all present
Nothing there is ours to have transcribed, and it is right for the kernel actually present rather than one pinned by guessing.
4. One kernel version at a time
Linux states in writing that it has no stable in-kernel ABI. BTF mostly dissolves this, because it describes the kernel that is there — but a kernel without BTF needs hand-written layouts for a named version, and that is when this rule bites.
5. The unresolved-symbol list is the specification
Do not guess the API surface. Load a real module and let the loader say what is missing. Every symbol LDLS does not implement still resolves, to a stub that reports itself and returns zero, so the driver carries on and the next thing it wants becomes visible. The work queue keeps writing itself.
The four pieces
The loader — ldls/ko.c
A .ko is an ELF64 relocatable object. Index the sections, place the
allocatable ones, resolve the undefined symbols, apply the relocations. Written
from the ELF-64 gABI and the x86-64 psABI, both published specifications, so
there is no licence question in it at all.
Measured over a stock 6.18.46 tree: 2,440 modules, every one lays out and
relocates to completion, five relocation types, nothing unhandled. That sweep
is also what retired the fixed section table — nvidia-modeset has 7,069
sections against a median of 50 — so the caller supplies it and there is no
limit to run into.
It is freestanding: no libc, no allocation, no I/O. The same source builds
as a host tool, where a format failure costs a second, and as the SchOS-side
host. That is the arrangement mkschfs and the kernel already share.
Three things it has to fix in the object
The %gs stack guard. Modules are built -mstack-protector-guard-reg=gs,
so the canary is fetched through the GS segment — an ABI between the kernel and
its own per-CPU area that a host which is not that kernel cannot provide.
ko_defuse_gs() rewrites those references to xor %reg, %reg. There are two
forms: the prologue loads the guard with mov, the epilogue compares it
with sub before a jne. xor is right for both — it zeroes the canary and
sets ZF, so the branch falls through as a passing check does. It checks every
site before patching any and refuses the whole operation on anything it does
not recognise.
Every other per-CPU variable. The guard turned out to be one instance of a
general problem. current is another — mov %gs:const_current_task(%rip), %r8
— and e1000 has 23 such references across four variables. Defusing does not
work here: the guard's value is arbitrary, and current's is not, because
the driver dereferences what it reads.
What does work is that a per-CPU variable in a single-threaded host is just a
variable. ko_fixup_percpu() takes the %gs off, leaving the same opcode
reading the same width in the same direction, from storage the host supplies
per name. Eight bytes become seven and a nop. The displacement is measured
from the end of the shortened instruction, and the displacement is not always
last — cmpb $0x0, %gs:softnet_data+0x75(%rip) has an immediate after it — so
there is just enough of an x86 decoder in there to know how long the
instruction is. Anything it does not recognise is refused with the symbol
named, which is rule 5 again.
ldlsd hands out zeroed blobs, except for current, which points at the rest
of its own: a driver that walks into the task it thinks it has reads zeroes.
The compiler's thunks. __x86_indirect_thunk_rN and __x86_return_thunk
are retpolines, not API, and they are pure control flow — jmp *%r8 and ret.
A stub cannot stand in for one, because a stub's whole behaviour is to log a
name and return, and what a thunk is for is the jump. ldls/kpi/thunk.c is
fifteen lines of assembly and is the single highest-value file in the layer.
That one cost a day. e1000 refills its receive ring through a function pointer and the compiler made it a tail call:
mov 0x2a0(%rbx),%r8 ; adapter->alloc_rx_buf
... ; compute E1000_DESC_UNUSED(ring) == 255
jmp __x86_indirect_thunk_r8
so the driver asked for 255 receive buffers, the shim answered "yes", and the
symptom was a NIC that transmitted perfectly and received nothing with RDT
sitting at zero.
Where it is loaded. -mcmodel=kernel means R_X86_64_32S relocations, so
the object has to live where a signed 32-bit absolute address reaches. A
process's mmap area starts at 0x40000000, which is inside that window; on the
host harness MAP_32BIT reaches it from the other end.
A set of modules — what r8169 needed
Linux's r8169 is not one module. It needs mdio-bus, libphy, mdio_devres,
the Realtek PHY driver, the NIC itself, the RTL PHY firmware, and the BTF of the
kernel they came from. make ldls-r8169 stages all of that from the running
kernel; etc/schinit/ldls-r8169.job starts ldlsd on it at boot, with its output
in /var/log/ldls-r8169.log. The job has KeepAlive = no: a driver that crashes
on real hardware would otherwise be restarted into the same crash every few seconds.
What that added to ldlsd:
- A comma-separated module set, loaded in dependency order, each linking
against the ones before it, with the BTF read before any
init_moduleruns. A library module (libphy) just exports symbols to the next; the PCIproberuns once, at the end, for whichever module registered apci_driver. - A driver core: bus match, driver probe, so the PHY driver binds to the PHY
the way it does in Linux; devres; a kernel
printf; realudelay/msleep/ktime; and 1000 Hzjiffies, because the module'sHZis compiled in and phylib re-arms its link pollHZjiffies out. - Firmware read from
/lib/firmware. - phylib's data, the
link_mode_paramstable and its bitmaps. - The symbols whose stub-zero was a lie: list validity,
try_module_get,dev_err_probe,eth_platform_get_mac_address,napi_consume_skb(as a stub it leaked one buffer per transmitted frame until the DMA heap ran out), and the rest of the table at the end of this document. pdev->busis a realpci_bus;net_device.priv_lenis set from BTF (the driver'smemsetof its ring bookkeeping is bounds-checked against it, and__fortify_panicfired on a length of zero); andcmovccfrom per-CPU memory is rewritten like the other%gsforms.__preempt_countstarts atPREEMPT_ENABLED. mdio-bus keeps the register it has just read inr11acrosspreempt_enable(), which on x86 isdecl %gs:__preempt_count; je, to a call that preserves every register. With the count at zero the call was made, into a logging stub whose syscall leftRFLAGS(0x246) inr11— so every MDIO read returned0x02460246.preempt_scheduleis now a bareret.__fortify_panicnames the module and function (r8169: rtl_open.cold+0x…) so a photograph of the screen is enough, and exits instead of spinning on a core and holding/dev/netdev.
The kernel did two things for it. It turns ASPM off on Realtek NIC links at
boot, as r8169 does through pci_disable_link_state, which a process cannot;
and a network interface that appears after boot starts the stack — with the
native driver out of the registry the laptop boots with no interface at all, and
ldls0 arrives later. ldlsd says which refusal it got when attaching: EBUSY is
another process, anything else is the kernel.
The native drivers/net/r8169/ stays in the tree, with Linux's 8168H setup, as
the fallback; it is out of the registry.
The KPI — ldls/kpi/
The Linux symbols, implemented. Register access to a mapped BAR; the PCI calls
to /dev/pci; allocation to mmap and PCIIOC_DMA; register_netdev to
/dev/netdev. Organised under Linux's own header names so a reader who knows
where something lives in Linux knows where it is here.
kpi_io_ok() bounds every ioread/iowrite against the mapping pci_iomap
returned. A stray access would otherwise be a page fault with no context, and
with no IOMMU a hosted driver's wild writes have to be caught early or not at
all.
Three pieces of it are not register access and are worth naming, because each one is something a driver does that a poll loop does not obviously provide:
- The interrupt handler runs. Not to receive packets — LDLS was already
calling the NAPI poll by hand — but because a driver's handler is where it
does everything else. e1000 queues its watchdog from there, and the
watchdog is what sets
TCTL.EN. A driver whose handler never ran was a driver whose transmitter was never switched on. Calling it from the loop is safe: a handler's first act is to read its cause register, and with nothing pending it returnsIRQ_NONE. - Deferred work runs.
queue_delayed_work_onkeeps the item andkpi_work_run()calls what is due from the same loop.jiffieshad to start moving for that to mean anything; it comes fromuptime(), at 100 Hz. netif_carrier_on/offwrite the bit.netif_carrier_ok()is an inline that tests__LINK_STATE_NOCARRIERinnet_device.state, so a no-op means the driver reads "the carrier is already up" and skips its whole link-up path. The bit number comes from the kernel's BTF (btf_enum_value()), not from us — rule 3½ applied to a constant rather than an offset.
And DMA in small pieces. dma_alloc_coherent maps onto PCIIOC_DMA
exactly; dma_map_single does not. 8139too never needed it, because it copies
each frame into a ring it allocated coherently up front; e1000 hands the device
individual buffers. Since a process cannot know the physical address of an
ordinary page, those buffers come from the arena too, a chunk at a time behind
a bump pointer.
The address arithmetic for that is not a call at all. dma_map_single is a
static inline, so what reaches the shim is already expanded: the module
computes pa = va - page_offset_base, then page = vmemmap_base + (pa >> 12) * 64. Both bases are undefined data symbols — which means LDLS supplies them,
and setting both to zero makes the whole thing reversible with no lookup table.
The back end — /dev/pci
Already built, for vgpud, before any of this existed. The design insisted the
interface be about PCI devices
rather than about a driver model, on the argument that an interface whose first
consumers are native drivers is a general mechanism rather than a hole shaped
like something foreign. A Linux driver's pci_iomap, dma_alloc_coherent and
request_irq land on it unaltered, which is that argument being collected.
Giving a device up — release
A driver in the kernel has a release hook, and /dev/pci calls it before
handing its device to a process. The honest answer to "may I have the one you are
using" was no until a driver could be told to let go; e1000 is the first that can.
Letting go is three things, and the third was missed at first:
- quiesce the hardware — mask interrupts, clear
RCTLandTCTL; - mask the interrupt line. The kernel's handler is gone and the device is
about to be brought up by a driver in a process, so nothing will acknowledge
the cause. The console said what that looks like:
irq 11: 449905 interrupts/second -- a device on this line is not acknowledging it. It comes back when the process asks for it withPCIIOC_IRQ. - take the interface out of the stack.
net_detach()is the missing half ofnet_attach_late(), and it went missing because nothing had ever taken a device back. An interface left behind keeps its address and the route to its subnet, so the stack goes on choosing it, and every frame reaches atransmitthat now correctly refuses. Packets stop and nothing reports an error — which is indistinguishable from a driver in a process that does not work, and was mistaken for exactly that.
Ports — the TSS I/O permission bitmap
A Linux driver does not always ask. ioread32 is a call the KPI can implement;
outl is an instruction compiled into the module, and e1000's reset path
picks it deliberately — "these controllers can't ack the 64-bit write when
issuing the reset, so use IO-mapping as a workaround". On QEMU's 82540EM there
is no route to a working NIC that avoids it.
So a process holding a device gets that device's I/O BARs, and no other
ports. x86 offers two ways to grant this and they are far apart: IOPL=3 is
one flag and hands over all 65536 ports, including the interrupt controller,
the timer and the disk; the TSS I/O permission bitmap is per port. A foreign
driver already has DMA with no IOMMU, and the answer to "this is not a security
boundary" is not to move it further — so it is the bitmap, at 8 KB per CPU of
resident memory. See ../../include/arch/ioperm.h.
Writing it found a latent hole. The TSS's I/O map base is at offset 102 and the
old code wrote offset 64; the field it meant to set stayed 0, and a base of 0
with a limit of 0x67 does not mean "no bitmap" — it means the bitmap starts at
the top of the TSS. The CPU had been checking ports 0–831 against the TSS's own
bytes, which are RSP0: a kernel stack pointer whose zero bits are permission.
The class bridge — /dev/netdev
The one piece the kernel had to grow. netdev_ops is four entries —
transmit, receive, mac, link_up — so a process claims the device,
supplies a MAC, and read()/write() carry the frames. /dev/pci with the
arrows reversed: that hands a device to a process, this lets a process be
one. See devices.md.
lwIP never learns that the thing behind the vtable is not a driver.
What it costs
It polls. A Linux driver expects its interrupt handler to be called from an
interrupt. /dev/pci does deliver them — by blocking read(), which needs a
thread ldlsd does not have — so the handler, the NAPI poll and the driver's
deferred work are all called from one loop instead. The same work, in a context
LDLS can provide, at the cost of latency and a spinning process.
The stack protector is gone, per ko_defuse_gs() above, and so are the
retpolines, per thunk.c. Foreign code, no IOMMU, three hardening measures
fewer.
Per-CPU means per-process. Correct with one thread, and it is the same assumption as the no-op locks below, arrived at from the other side.
A lock is a no-op. In one process with no preemption inside the driver there is nobody to race with. That is correct right up until a driver has an interrupt thread, and it is the deepest unfixed assumption in the layer.
It can be killed
The whole H2 machinery applies to a foreign driver unchanged, which was the point of having built it against our own:
ldlsd running |
ping works; it holds the device and two DMA blocks |
| killed | interface down, device released, bus mastering cleared, DMA reclaimed |
ping |
fails honestly — sendto: -5 |
a second ldlsd |
ping works again, and the arena hands back the same addresses |
pciuser_release_all() and netuser_release_all() sit beside each other in
exit() for the same reason: a process holding a device is one that may crash.
What it does not do
-
Three drivers, one kernel version.
8139tooande1000on 6.18.46 against QEMU, andr8169on that version against QEMU (where it finds no device) and on one laptop (where it gets as far as the PHY). The second driver was worth more than the first: it found the per-CPU problem, the retpoline problem, the port-I/O problem, the interrupt storm and a seven-year-old hole in the TSS. Nothing here has been tried on a second kernel, and "LDLS runs Linux drivers" is still a claim about a category from a sample of three. -
No interrupts, per the polling note above.
-
jiffiesassumesHZis 1000, which is what the kernels LDLS loads from are built with (ldls_load.hzcan say otherwise). It comes from the monotonic clock in milliseconds. -
One work item at a time, on the main loop. A driver whose deferred work blocks stops everything, including the transmit path it is probably waiting for.
-
It is not a security boundary, and here that is worse than usual. There is no IOMMU, so a hosted driver can point its device at any physical address. For SchOS's own drivers that isolates bugs and not intent; for foreign drivers the distinction between bug and intent is one we are in no position to make. This is the strongest argument for an IOMMU the project has produced.
-
It relies on SchOS having no NX. Every mapping is executable because
EFER.NXEis never set. If that is ever fixed — and it should be —ldlsdneeds a way to ask for executable memory, andmmapdoes not offer one. -
Nothing is ever freed. A hosted driver's allocations last as long as the process, which is a leak in the strict sense and the right trade for a program that will be killed to restart it.
-
BTF has to come from somewhere. SchOS has no
/sys/kernel/btf/vmlinux, somake ldls-stage/ldls-r8169copy it from the Linux kernel the module came from. A kernel built withoutCONFIG_DEBUG_INFO_BTFhas neither. -
There is address-space groundwork nothing uses.
kernel/arch/x86_64/vm.cmaps the kernel image a second time atLDLS_KALIAS, in the top 2 GB where a-mcmodel=kernelmodule can reach, with avmemmapand a module area beside it — at every boot — andinclude/ldls/ldls.hdescribes a/dev/ldlscontrol channel to load modules into the kernel. That was the start of an in-kernel loader, contradicting rule 2. Nothing calls either, and it should be finished as a decision or deleted. -
No config-space writes.
/dev/pcihas none, deliberately — a write is how a BAR gets moved out from under the kernel — so a driver that needs one is refused loudly rather than quietly succeeded. -
No sysfs and no unload. Modules depend on each other and firmware is read from
/lib/firmware, butmodule_exitis never called, and the way to stop a driver is to kill the process. -
Some symbols are still logging stubs. Deliberate, per rule 5 — and the single most expensive thing in this layer. A stub that returns zero is not safe when zero is a meaningful answer, and the list of ways that bit is now long enough to be a rule rather than an anecdote:
stub what zero meant what it looked like memcpythe copy did not happen frames arrived as the right length of zeroes _find_next_bit"bit 0 is set" for_each_cpunever terminated;ndo_openhungnetif_carrier_offthe carrier is already up driver skipped its link-up path; TCTL.ENnever set__x86_indirect_thunk_r8the jump was discarded 255 receive buffers requested, none allocated, RDTat 0dma_map_page_attrsbus address 0 the NIC pointed at the bottom of physical memory Not one of these reported an error, and only the last is a fault waiting to happen. A search function, a predicate, a jump and an address are all things for which zero is a specific answer rather than an absent one.