docs/plan/ldls.md milestone L1. Every undefined symbol resolves to a
trampoline -- twenty generated bytes that load the symbol's index and jump to
a shared thunk, which saves the argument registers and reports the call. Then
init_module is simply called.
$ ldls-run 8139too.ko
module 8139too
license GPL
wants 94 symbols
calling init_module at 0x416fc020
1 _printk(0x416fb6c0, 0, 0)
| 8139too: 8139too Fast Ethernet driver 0.9.28
2 __pci_register_driver(0x416fb400, 0x416fff80, 0x416fbdbb)
init_module returned 0 after 2 stub call(s)
That second line is the driver's own banner, printed through a _printk that
is ours, from a string in the module's own .rodata that a relocation put
there. There is no stronger evidence that a load worked than the loaded thing
speaking.
Stubs return zero rather than stopping, which departs from what the milestone
originally said and does so deliberately: stopping at the first call tells you
one name, carrying on tells you the sequence, and the sequence is the thing a
symbol table cannot give you. A driver that then dereferences the zero faults,
and that is a result too -- it says how far it got before it needed the kernel
to have really done something.
The first run failed on neither of those. It failed on a relocation:
ldls-run: relocate: a signed 32-bit relocation does not fit
A module is compiled -mcmodel=kernel, so the compiler emits R_X86_64_32S for
anything it believes lives in the top 2 GB, and an ordinary mmap at 0x7f...
has nowhere to put it. Linux gives modules a region within 2 GB of the kernel
text; the harness uses MAP_32BIT to reach the same window from the other end.
On SchOS this costs nothing -- a process's mmap area starts at 0x40000000 and
is already inside it -- but it is a real constraint on where the host may load
a module, and a loader that did not check its own arithmetic would have found
it much later and much more confusingly.
Swept over 500 stock modules: 449 ran init to completion, 22 faulted on a
stub's zero, 29 have no init_module, and zero hit a loader error.
Recorded honestly in "what it will not do": running init is not working. Every
symbol is a stub, so what has been shown is that drivers ask for things in a
readable order, not that anything answers. Also recorded: every undefined
symbol in a module is NOTYPE, so the stubs cannot tell jiffies from
pci_iomap and both get a trampoline -- BTF distinguishes VAR from FUNC and
that is where the fix belongs rather than in a guess.
make ldls-check is 23 checks now, five of them L1's, all against
ldls/test/refmod.c which is ours and now has an init_module of its own.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2
398 lines
19 KiB
Markdown
398 lines
19 KiB
Markdown
# LDLS — the Linux Driver Layer for SchOS
|
|
|
|
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 someone
|
|
else's drivers, and in practice "someone else's drivers" means Linux's.
|
|
|
|
**LDLS itself is Apache-2.0**, like everything else in this tree, and stays that
|
|
way because it never contains a line of Linux. What the *user* combines it with
|
|
on their own machine is a separate question, and
|
|
[../licensing.md](../licensing.md) §6 is where that one is answered.
|
|
|
|
[hybrid.md](hybrid.md) built the mechanisms for this and said so in as many
|
|
words — MMIO, DMA, interrupts and config space to a process, with SchOS's own
|
|
drivers as the first consumers so the interface would be general rather than a
|
|
hole shaped like something foreign. `vgpud` proved it. **LDLS is what those
|
|
mechanisms were built for.**
|
|
|
|
---
|
|
|
|
## The rules
|
|
|
|
These are the spine. Everything below is an implementation of them, and
|
|
anything that violates one is wrong however well it works.
|
|
|
|
### 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. This is not a new position — it
|
|
was decided on 2026-08-08 and is written up in
|
|
[../architecture/devices.md](../architecture/devices.md) and
|
|
[../licensing.md](../licensing.md) §2:
|
|
|
|
> Apache-2.0 code and GPL-2.0-only code cannot be combined into one
|
|
> distributable work. It is not a matter of taste or of how carefully the files
|
|
> are kept apart in the tree.
|
|
|
|
`RAMDISK_EXCLUDE` and `NOTPORTS` are the existing mechanisms that keep this
|
|
true mechanically rather than by intention, and LDLS must stay inside them.
|
|
|
|
### 2. It runs in a process, never in the kernel
|
|
|
|
Two independent reasons, and either alone would decide it.
|
|
|
|
**Legally**, a module linked into our kernel is the strongest derivative-work
|
|
argument that exists — it is the case the FSF has always made. A module
|
|
combined with our shim *inside a user process, on the user's machine, at their
|
|
direction* is a different thing at arm's length, and it is the position
|
|
FreeBSD has shipped from for about a decade: a BSD-licensed `linuxkpi` in base,
|
|
GPL `drm-kmod` installed by the user from ports.
|
|
|
|
**Practically**, this is foreign code. A driver we did not write, cannot fully
|
|
audit and did not choose the quality of, running with the machine's memory in
|
|
reach, is exactly the code that should not be able to take the machine down.
|
|
Crash isolation is not a nicety here; it is the reason.
|
|
|
|
### 3. Facts, not expression
|
|
|
|
The shim has to present Linux's internal API, and that API is expressed in GPL
|
|
headers. The line LDLS holds:
|
|
|
|
| | |
|
|
|---|---|
|
|
| A symbol exists, and takes these arguments | **fact** — reproduce it |
|
|
| A struct has these fields in this order at these offsets | **fact** — a compiled driver indexes them, so there is no other way to express it. And mostly it need not be in our source at all; see BTF below |
|
|
| The comment above it | **expression** — never |
|
|
| The macro body, the inline function body, the implementation | **expression** — never |
|
|
|
|
Every KPI file records where its shape came from. Not decoration: it is the
|
|
difference between a clean-room reimplementation and a transcription, and the
|
|
only time anyone will ask is the time it matters.
|
|
|
|
This is the same discipline [../licensing.md](../licensing.md) §5 already
|
|
describes — *"the xHCI, USB, PCI, fbdev and ELF specifications were all read
|
|
and implemented directly, which is why those drivers have no licence
|
|
entanglement at all"* — applied to a case where the specification is thinner.
|
|
|
|
> **Not legal advice, and it should be checked by someone whose job that is
|
|
> before anything ships.** The reasoning rests on interfaces being outside
|
|
> copyright (EU Software Directive Art. 1(2)) and on *Google v. Oracle* (2021),
|
|
> and neither is a blanket permission.
|
|
|
|
#### The layouts do not have to be ours to write
|
|
|
|
The uncomfortable row above is the struct layouts, and it turns out to be
|
|
largely avoidable. Kernels built with `CONFIG_DEBUG_INFO_BTF` publish
|
|
`/sys/kernel/btf/vmlinux`, which carries the layouts *and* the function
|
|
prototypes as generated binary type data about the kernel that is installed:
|
|
|
|
```
|
|
struct pci_dev {
|
|
struct list_head bus_list; /* 0 16 */
|
|
struct pci_bus * bus; /* 16 8 */
|
|
|
|
[97814] FUNC 'pci_iomap' type_id=97813 linkage=static
|
|
[97815] FUNC_PROTO '(anon)' ret_type_id=108 vlen=4
|
|
```
|
|
|
|
So LDLS reads them at load time, on the user's machine, from the user's own
|
|
kernel. Our source contains neither, and there is nothing to have transcribed.
|
|
It also mostly dissolves rule 4 below, because BTF describes the kernel that is
|
|
actually there rather than one we pinned by guessing.
|
|
|
|
**BTF gives shape, not behaviour.** What `napi_complete_done` *does* is still
|
|
ours to implement from documentation, and that is the whole of the real work.
|
|
A kernel without BTF needs hand-written layouts for a named version, which is
|
|
why this section stays rather than being replaced.
|
|
|
|
### 4. One kernel version at a time, pinned and named
|
|
|
|
*(Mostly superseded by BTF, above — but it still governs any kernel that does
|
|
not carry it.)*
|
|
|
|
Linux states in writing that it has no stable in-kernel ABI —
|
|
`Documentation/process/stable-api-nonsense.rst` exists to say so. Believing
|
|
them is cheaper than being surprised by them.
|
|
|
|
So LDLS targets **one version**, says which, and is re-pinned deliberately.
|
|
FreeBSD's LinuxKPI does exactly this per drm-kmod release. A layer that claims
|
|
to support "Linux drivers" without a version is claiming something nobody has
|
|
ever delivered.
|
|
|
|
### 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.** That list is the work queue, it is exact, and it is available
|
|
before a single line of shim is written.
|
|
|
|
---
|
|
|
|
## What it is made of
|
|
|
|
Four pieces, of which three are ordinary and one is the project.
|
|
|
|
### The loader — ours, clean, and small
|
|
|
|
A `.ko` is a relocatable ELF64 object. Loading one is: lay out the allocatable
|
|
sections, apply the relocations, resolve the undefined symbols against a table,
|
|
and find the entry point. SchOS already parses ELF in `kernel/fs/exec.c`, for a
|
|
different object type but with the same plumbing.
|
|
|
|
There is no licence question here at all — ELF is a published specification and
|
|
so is the relocation set.
|
|
|
|
### The KPI — the shim, and the actual work
|
|
|
|
Our Apache-2.0 reimplementation of the Linux symbols a given driver needs,
|
|
organised under Linux's own header names so it is navigable: `kpi/slab.h`,
|
|
`kpi/pci.h`, `kpi/netdevice.h`. Rule 3 governs every file.
|
|
|
|
### The back end — already built
|
|
|
|
`/dev/pci`: `PCIIOC_INFO`, `PCIIOC_CLAIM`, `PCIIOC_CFG`, `mmap` of a BAR,
|
|
`PCIIOC_DMA`, `PCIIOC_IRQ`. A Linux driver's `pci_iomap`, `dma_alloc_coherent`
|
|
and `request_irq` land on these, which is the whole reason they were specified
|
|
in terms of PCI devices rather than in terms of drivers.
|
|
|
|
### The class bridge — the one new kernel interface
|
|
|
|
This is the piece that does not exist, and it is the interesting one.
|
|
|
|
A hosted driver produces packets in a process. SchOS's network stack consumes
|
|
them through `struct netdev_ops` in the kernel — `transmit`, `receive`, `mac`,
|
|
`link_up`, four entries and nothing else. So what is missing is a **netdev
|
|
whose backend is a process**: the kernel publishes the vtable, the four calls
|
|
are forwarded over a descriptor, and lwIP never learns that the driver is not
|
|
in the kernel.
|
|
|
|
It is the exact analogue of what `display_ops` was for the GPU, and
|
|
`netdev.h`'s own opening comment already anticipates the shape — *"the stack
|
|
should ask for the network interfaces and get whatever is bound … exactly as a
|
|
compositor asks for the display and does not care which driver answers."*
|
|
|
|
One device, one process, claimed and released, on the `/dev/pci` discipline.
|
|
|
|
---
|
|
|
|
## Milestones
|
|
|
|
| | | |
|
|
|---|---|---|
|
|
| **L0** | **The loader.** Parse a real `.ko`, lay it out, relocate it, and **print every unresolved symbol**. Nothing executes. The output is the measured API surface. | **done** — `ldls/ko.c`, `make ldls-check` |
|
|
| **L1** | **Stubs.** Every symbol resolves, to a function that logs its name. `init_module` runs, which yields the *call order* — the second thing you need and the other thing you cannot guess. | **done** — `ldls/stub.c`, `ldls-run` |
|
|
| **L2** | **The hardware.** `probe()` receives a working `struct pci_dev`, maps its BARs, allocates coherent DMA and registers an interrupt, all through `/dev/pci`. The driver reaches its device. | |
|
|
| **L3** | **A packet.** The process-backed netdev, wired to the driver's transmit and receive paths. **`ping` over a Linux driver.** This is the milestone that proves the whole idea and the one that cannot be skipped. | |
|
|
| **L4** | **Kill it.** The host process dies, the kernel clears bus mastering and reclaims the DMA, a new host starts and the interface comes back. | free — H2 built all of this |
|
|
|
|
L4 is listed for completeness rather than as work. `pciuser_stop()`,
|
|
`pciuser_release_all()` and `KeepAlive` already do it, and that they apply
|
|
unchanged to a foreign driver is the point of having built them against our
|
|
own.
|
|
|
|
### The first target: `8139too`, not `r8169`
|
|
|
|
The obvious choice is a Realtek `r8169`, because that silicon is everywhere.
|
|
It is the wrong first choice for one decisive reason: **QEMU cannot emulate
|
|
it**, so L0 through L3 would have no way to be tested, and a driver layer
|
|
developed against hardware you cannot run is a layer nobody can debug.
|
|
|
|
QEMU emulates `rtl8139`. `8139too` is small, self-contained, needs no firmware
|
|
blob, and uses the thin `mii` helpers rather than dragging in phylib — which
|
|
`r8169` does, and which is a substantial surface on its own.
|
|
|
|
So: **`8139too` to build the layer, `r8169` to prove it was general.** The
|
|
second is the interesting one and it is much cheaper once the first works.
|
|
|
|
This is the `/dev/pci` lesson applied backwards. There, a native driver was the
|
|
first consumer so the interface would not be shaped like something foreign.
|
|
Here the interface is fixed by Linux, and what is chosen instead is the
|
|
*smallest real consumer that can be tested* — because the risk is no longer
|
|
designing the wrong interface, it is building a hundred stubs nobody has ever
|
|
run.
|
|
|
|
---
|
|
|
|
## L0, and what it measured
|
|
|
|
`ldls/ko.c` is the loader: index the sections, place the allocatable ones,
|
|
resolve the undefined symbols against a table, apply the relocations. It is
|
|
freestanding — no libc, no allocation, no I/O — so the same source builds as a
|
|
host tool today and as the SchOS-side host later, which is the arrangement
|
|
`mkschfs` and the kernel already share.
|
|
|
|
```
|
|
$ ldls-inspect 8139too.ko
|
|
119288 bytes, 55 sections, 363 symbols
|
|
module 8139too
|
|
license GPL
|
|
describes RealTek RTL-8139 Fast Ethernet driver
|
|
loads to 33988 bytes
|
|
|
|
relocations: 1980
|
|
R_X86_64_64 363
|
|
R_X86_64_PC32 991
|
|
R_X86_64_PLT32 428
|
|
R_X86_64_32S 169
|
|
R_X86_64_PC64 29
|
|
|
|
undefined symbols: 94
|
|
...
|
|
stops at: unresolved: __fentry__
|
|
```
|
|
|
|
**The licence is printed first, before anything is loaded**, because it is the
|
|
one property that decides whether the object may be on this machine at all and
|
|
it is a plain string in `.modinfo`. Rule 1 is easier to keep when the answer is
|
|
on screen.
|
|
|
|
### What the measurement says
|
|
|
|
**94 symbols** is the API surface of `8139too` on Linux 6.18.46. Not an
|
|
estimate — the list, exactly, and it is L1's work queue. It is also smaller
|
|
than it looks, because a good fraction of it is not API at all: `__fentry__`,
|
|
`__stack_chk_fail`, `__ref_stack_chk_guard`, `__x86_return_thunk` and
|
|
`__fortify_panic` are compiler and instrumentation artefacts that stub out in a
|
|
line each.
|
|
|
|
**Five relocation types**, and a corpus run says that is the whole set: over
|
|
**2,440 real modules** from a stock 6.18.46 tree, every one lays out and
|
|
relocates to completion with nothing unhandled. That includes `nvidia-modeset`
|
|
at 7,069 sections and 74,305 relocations, which is what retired the loader's
|
|
fixed section table — the caller supplies it now, so there is no limit to run
|
|
into rather than a generous one.
|
|
|
|
Relocating against stubs is a separate question from listing what is missing,
|
|
and it is the one that says the loader is finished: with nothing resolved, the
|
|
first unknown name stops everything and no relocation type past it is ever
|
|
reached.
|
|
|
|
### Nothing foreign is in this tree
|
|
|
|
`make ldls-check` runs the loader against `ldls/test/refmod.c` — ours, compiled
|
|
with `-c`, written to contain one of each thing the loader has to get right:
|
|
undefined functions *and* undefined data, `.text` with calls, `.data` holding a
|
|
pointer into `.rodata`, a `.bss` that must arrive zeroed, and a `.modinfo`
|
|
shaped the way a module's is. The modules above were read from the host's own
|
|
`/nix/store` and never copied here; `.gitignore` refuses `*.ko` outright so
|
|
that cannot happen by accident.
|
|
|
|
---
|
|
|
|
## L1, and the driver's own voice
|
|
|
|
Every undefined symbol resolves to a trampoline — twenty generated bytes that
|
|
load the symbol's index and jump to a shared thunk, which saves the argument
|
|
registers and reports the call. `init_module` is then simply called.
|
|
|
|
```
|
|
$ ldls-run 8139too.ko
|
|
module 8139too
|
|
license GPL
|
|
wants 94 symbols
|
|
loaded 33988 bytes at 0x416f8000, 94 stubs at 0x40377000
|
|
|
|
calling init_module at 0x416fc020
|
|
|
|
1 _printk(0x416fb6c0, 0, 0)
|
|
| 8139too: 8139too Fast Ethernet driver 0.9.28
|
|
2 __pci_register_driver(0x416fb400, 0x416fff80, 0x416fbdbb)
|
|
|
|
init_module returned 0 after 2 stub call(s)
|
|
```
|
|
|
|
**That second line is the driver's own banner**, printed through a `_printk`
|
|
that is ours, from a string in the module's own `.rodata` that a relocation put
|
|
there. There is no stronger evidence that a load worked than the loaded thing
|
|
speaking.
|
|
|
|
The stubs return zero rather than stopping, which is a deliberate departure
|
|
from what this milestone originally said. Stopping at the first call tells you
|
|
one name; carrying on tells you the *sequence*, and the sequence is the thing
|
|
that cannot be read off a symbol table. A driver that then dereferences the
|
|
zero faults, and that is a result too — it says exactly how far it got before
|
|
it needed the kernel to have really done something.
|
|
|
|
### The constraint nobody would guess
|
|
|
|
The first run failed on a relocation, not on anything to do with stubs:
|
|
|
|
```
|
|
ldls-run: relocate: a signed 32-bit relocation does not fit
|
|
```
|
|
|
|
A module is compiled `-mcmodel=kernel`, so the compiler emits `R_X86_64_32S` —
|
|
a **signed 32-bit absolute address** — for anything it believes lives in the
|
|
top 2 GB. Load the object at an ordinary `mmap` address around `0x7f…` and
|
|
those relocations have nowhere to go. Linux avoids this by giving modules a
|
|
region within 2 GB of the kernel text; the harness uses `MAP_32BIT` to reach
|
|
the same place from the other end.
|
|
|
|
**On SchOS this costs nothing** — a process's mmap area starts at `0x40000000`
|
|
and is already inside the window — but it is a real constraint on where the
|
|
LDLS host may put a module, and it would have been found much later and much
|
|
more confusingly without a loader that checks its own arithmetic.
|
|
|
|
### What running 500 of them says
|
|
|
|
| | |
|
|
|---|---|
|
|
| `init_module` ran and returned | **449** |
|
|
| faulted on a stub's zero | 22 |
|
|
| no `init_module` at all | 29 |
|
|
| **loader errors** | **0** |
|
|
|
|
The 22 are not failures of the loader; they are drivers that needed an
|
|
allocation to come back non-null. The 29 without an entry point are mostly
|
|
library modules that exist to be depended on.
|
|
|
|
---
|
|
|
|
## What it will not do
|
|
|
|
- **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, which
|
|
[hybrid.md](hybrid.md) says at length. For *foreign* drivers the distinction
|
|
between bug and intent is one we are in no position to make at all. This is
|
|
the single strongest argument for an IOMMU that this project has produced,
|
|
and it should be recorded as such rather than waved past.
|
|
- **It implements no licence check, and that is not circumvention.**
|
|
`EXPORT_SYMBOL_GPL` is Linux marking *Linux's own* symbols, and the check it
|
|
drives protects those. Every symbol LDLS provides is ours, reimplemented from
|
|
facts under Apache-2.0, so there is no gate being bypassed because there is
|
|
no Linux symbol being exposed. LDLS **reads** the declared licence and prints
|
|
it before loading anything, because that is the property that decides whether
|
|
an object belongs on a machine at all. See
|
|
[../licensing.md](../licensing.md) §6.
|
|
- **No firmware loading.** `request_firmware` is a stub that fails. Many
|
|
drivers need a blob, most blobs are redistributable only under their own
|
|
terms, and that is a second licensing problem which is not this one.
|
|
- **No module-to-module dependencies.** One object, no `depmod`, no symbol
|
|
exports between hosted modules.
|
|
- **No sysfs, procfs or debugfs.** Drivers that only *report* through them will
|
|
work; drivers that are *configured* through them will not.
|
|
- **No unload.** `module_exit` is never called; the way to stop a driver is to
|
|
kill the process, which H2 made a supported operation rather than a failure.
|
|
- **No sleeping-context fidelity.** Linux drivers assume a kernel's rules about
|
|
what may sleep, what runs in interrupt context and what `spin_lock_irqsave`
|
|
costs. A process has different rules, and where they differ the shim will be
|
|
wrong in ways that show up as rare races rather than as failures to build.
|
|
This is the deepest technical risk in the whole plan.
|
|
- **Running init is not working.** L0 proves 2,440 modules lay out and
|
|
relocate; L1 proves 449 of 500 run their init to completion. Both say the
|
|
*mechanism* is right and neither says a driver would function: every symbol
|
|
is a stub that logs and returns zero, so what has been demonstrated is that
|
|
drivers ask for things in a readable order, not that anything answers them.
|
|
The distance from here to a packet is the rest of this plan.
|
|
- **The stubs cannot tell code from data.** Every undefined symbol in a module
|
|
is `NOTYPE` in the ELF symbol table, so `jiffies` and `pci_iomap` are
|
|
indistinguishable and both get a trampoline. A driver that *reads* `jiffies`
|
|
reads trampoline bytes. BTF distinguishes them — `VAR` against `FUNC` — and
|
|
that is where the fix belongs rather than in a guess.
|
|
- **`ldls-run` is a development harness, not the deliverable.** It runs on the
|
|
build machine so that loader failures cost a second instead of a boot. The
|
|
thing that ships is the SchOS-side host.
|
|
- **It will not support "Linux drivers".** It will support *one driver, on one
|
|
kernel version, that has been tried*. Anything beyond that is a claim, and
|
|
the list of what has actually been run belongs in the architecture document
|
|
when there is one.
|