Nexoniarz is the sole author and SchOS was never published under Apache-2.0, so this is a straight relicence. LICENSE is the FSF GPLv2 text, every first-party SPDX header moves to GPL-2.0-or-later, and NOTICE, README.md, docs/licensing.md and the site say so. projects/schfs-linux follows the rest of the tree. The GPL gives copyleft: a fork has to ship its source. It also lifts the old "Linux drivers, never" rule, since GPL-2.0-only code combines with GPL-2.0-or-later. "Or later" leaves room for GPLv3, but one binary still has to pick one version, so docs/licensing.md now keeps Apache-2.0 and GPLv3 code out of the kernel to keep Linux code possible there. BSD 4-clause code goes from "avoid" to "no", because the advertising clause is GPL-incompatible. LDLS still ships no .ko. That boundary is now project policy rather than a legal requirement, and ldls.md, devices.md and licensing.md section 6 say so. refmod declares license=GPL, and ldls-check passes (27 checks). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
389 lines
19 KiB
Markdown
389 lines
19 KiB
Markdown
# 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:
|
||
|
||
```
|
||
# 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. Nothing GPL ever enters an image we build
|
||
|
||
The driver is **the user's**: obtained by them, loaded on their machine. SchOS
|
||
ships the layer and never the thing it loads. `.gitignore` refuses `*.ko`,
|
||
`make ldls-stage` is the one command that puts a module into a local image and
|
||
is deliberately not part of `all`, and `make ldls-unstage` takes it out again.
|
||
|
||
[../licensing.md](../licensing.md) §6 is the whole argument. The short form is
|
||
that the combination happens in a user process on the user's machine, so no
|
||
combined work is distributed by anyone. Since SchOS moved to GPL-2.0-or-later
|
||
that is no longer the only way it *could* be done — it is the way it is done,
|
||
because a module we never ship is a module we never have to answer for.
|
||
|
||
### 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.
|
||
|
||
### 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 returns `IRQ_NONE`.
|
||
- **Deferred work runs.** `queue_delayed_work_on` keeps the item and
|
||
`kpi_work_run()` calls what is due from the same loop. `jiffies` had to start
|
||
moving for that to mean anything; it comes from `uptime()`, at 100 Hz.
|
||
- **`netif_carrier_on`/`off` write the bit.** `netif_carrier_ok()` is an inline
|
||
that tests `__LINK_STATE_NOCARRIER` in `net_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.
|
||
[hybrid.md](../plan/hybrid.md) 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 now has a `release` hook, and `/dev/pci` calls it before
|
||
handing its device to a process. `docs/plan/hybrid.md` had said the honest
|
||
answer to "may I have the one you are using" is *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:
|
||
|
||
1. quiesce the hardware — mask interrupts, clear `RCTL` and `TCTL`;
|
||
2. **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 with `PCIIOC_IRQ`.
|
||
3. **take the interface out of the stack.** `net_detach()` is the missing half
|
||
of `net_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 a
|
||
`transmit` that 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/ioperm.h](../../include/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](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
|
||
|
||
- **Two drivers, one kernel version, one machine.** `8139too` and `e1000` on
|
||
6.18.46 against QEMU. 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 or on real silicon, and "LDLS runs Linux drivers" is
|
||
still a claim about a category from a sample of two.
|
||
- **No interrupts**, per the polling note above.
|
||
- **`jiffies` is not `HZ`.** It comes from a 100 Hz `uptime()`, which is a
|
||
plausible `HZ` and not necessarily the one the module was built for. A driver
|
||
measuring a timeout against it gets a longer timeout, which is the safe
|
||
direction and is still wrong.
|
||
- **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.NXE` is never set. If that is ever fixed — and it should be — `ldlsd`
|
||
needs a way to ask for executable memory, and `mmap` does 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`,
|
||
so the user brings it with the module, from the kernel the module was built
|
||
for. A kernel built without `CONFIG_DEBUG_INFO_BTF` has neither.
|
||
- **No config-space writes.** `/dev/pci` has 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 module-to-module dependencies, no firmware loading, no sysfs, no
|
||
unload.** `request_firmware` fails, `module_exit` is 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 |
|
||
|---|---|---|
|
||
| `memcpy` | the copy did not happen | frames arrived as the right *length* of zeroes |
|
||
| `_find_next_bit` | "bit 0 is set" | `for_each_cpu` never terminated; `ndo_open` hung |
|
||
| `netif_carrier_off` | the carrier is already up | driver skipped its link-up path; `TCTL.EN` never set |
|
||
| `__x86_indirect_thunk_r8` | the jump was discarded | 255 receive buffers requested, none allocated, `RDT` at 0 |
|
||
| `dma_map_page_attrs` | bus 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.
|