ldls: a second driver, and everything it found
e1000 -- Linux's, unmodified -- probes, opens, gets an address by DHCP and
carries ping traffic in a SchOS process. The kernel was driving that NIC and
gave it up, which makes it the first real handover as well as the second
driver.
None of what it needed was a missing feature. Every one was a call that
succeeded, returned zero and did nothing:
_find_next_bit "bit 0 is set" -- for_each_cpu never ended
netif_carrier_off carrier already up -- TCTL.EN never set
__x86_indirect_thunk_r8 the jump discarded -- 255 rx buffers, none made
dma_map_page_attrs bus address 0 -- NIC aimed at physical 0
so the rule from memcpy is now general: a stub returning zero is unsafe
wherever zero is a meaningful answer.
The loader grew ko_fixup_percpu(). The stack guard turned out to be one
per-CPU variable among several; `current` cannot be defused because the driver
dereferences it. Taking the %gs off instead leaves the same opcode reading the
same width from storage the host supplies -- a per-CPU variable in a
single-threaded host is just a variable. Eight bytes become seven and a nop,
and the displacement is not always last, so there is now just enough of an x86
decoder in ko.c to know how long the instruction is.
ldls/kpi/thunk.c is fifteen lines of assembly and the highest-value file here:
retpolines are control flow, and a stub cannot stand in for a jump.
The kernel side:
- ioperm.c, the TSS I/O permission bitmap. A driver's outl is an instruction,
not a call, and e1000's reset path picks it deliberately. IOPL=3 would hand
over the PIC and the disk with it, so it is per-port. Writing it found that
the I/O map base had been written at offset 64 rather than 102, leaving the
CPU checking ports 0-831 against the TSS's own RSP0.
- claim masks the device's IRQ line: 450,000 a second, otherwise.
- net_detach(), the missing half of net_attach_late(). An interface left in
the stack keeps its route and sends every frame to a driver that refuses
it, which looks exactly like a userspace driver that does not work.
- e1000_release() gives up the interface, not just the hardware.
8139too still passes 4/4; ldls-check still passes 27; four CPUs, no panic.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2
This commit is contained in:
parent
f38eb8a7fd
commit
071499a378
26 changed files with 2009 additions and 46 deletions
|
|
@ -4,8 +4,9 @@ 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. `8139too` — an unmodified GPL kernel module — loads into a SchOS
|
||||
process, drives QEMU's rtl8139 through `/dev/pci`, and carries packets:
|
||||
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 &
|
||||
|
|
@ -22,6 +23,23 @@ process, drives QEMU's rtl8139 through `/dev/pci`, and carries packets:
|
|||
`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
|
||||
|
|
@ -112,7 +130,7 @@ 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.
|
||||
|
||||
#### Two things it has to fix in the object
|
||||
#### 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
|
||||
|
|
@ -124,6 +142,44 @@ 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
|
||||
|
|
@ -141,6 +197,38 @@ 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.
|
||||
|
|
@ -150,6 +238,51 @@ 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 —
|
||||
|
|
@ -166,12 +299,16 @@ lwIP never learns that the thing behind the vtable is not a driver.
|
|||
|
||||
**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 driver's NAPI poll is called from a loop
|
||||
instead. The same work, in a context LDLS can provide, at the cost of latency
|
||||
and a spinning process.
|
||||
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. Foreign code, no
|
||||
IOMMU, one hardening measure fewer.
|
||||
**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
|
||||
|
|
@ -198,11 +335,20 @@ point of having built it against our own:
|
|||
|
||||
## What it does not do
|
||||
|
||||
- **One driver, one kernel version, tried once.** `8139too` on 6.18.46 against
|
||||
QEMU's rtl8139. Nothing here has been tried on a second driver, a second
|
||||
kernel, or real silicon, and until it has, "LDLS runs Linux drivers" is a
|
||||
claim rather than a description.
|
||||
- **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*
|
||||
|
|
@ -224,9 +370,19 @@ point of having built it against our own:
|
|||
- **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 — but a
|
||||
driver reaching one gets zero and carries on, and `memcpy` proved how quiet
|
||||
that can be: it was a stub while the receive path was being brought up, so
|
||||
every frame arrived as the right *length* of zeroes and nothing reported an
|
||||
error. **A stub that returns zero is not safe for a function whose whole job
|
||||
is a side effect.**
|
||||
- **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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue