ldls: the loader, and what it measured

docs/plan/ldls.md milestone L0. ldls/ko.c indexes the sections of an ELF64
relocatable object, places the allocatable ones, resolves undefined symbols
against a caller's table and applies the relocations. Nothing is executed.

It is freestanding -- no libc, no allocation, no I/O -- so the same source
builds as a host tool now and as the SchOS-side host later. That is the
arrangement mkschfs and the kernel already share, and for the same reason:
the failures at this layer are format failures, and one found on the host
costs a second instead of a boot.

    $ ldls-inspect 8139too.ko
      module    8139too
      license   GPL
      loads to  33988 bytes
      relocations: 1980
        R_X86_64_64 363, PC32 991, PLT32 428, 32S 169, PC64 29
      undefined symbols: 94
      stops at: unresolved: __fentry__

The licence is printed before anything is loaded, because it is the one
property that decides whether the object may be on the machine at all and it
is a plain string in .modinfo. Rule 1 is easier to keep when the answer is on
screen.

94 symbols is the measured API surface of 8139too on 6.18.46 -- the list,
exactly, rather than an estimate -- and it is L1's work queue. It is smaller
than it looks: __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.

Swept over every module in a stock 6.18.46 tree: 2,440 objects, all of them
laid out and relocated to completion, none with a relocation type the loader
does not handle. That sweep is also what retired the fixed section table --
nvidia-modeset has 7,069 sections and 74,305 relocations, against a median of
50 -- so the caller supplies the table and there is no limit to run into
rather than a generous one that fails later.

Loading is not running, and the doc says so: not one symbol is implemented,
and the distance between "relocates" and "transmits a packet" is the rest of
the plan.

No .ko is in this tree and .gitignore now refuses one outright. make
ldls-check runs 18 checks against ldls/test/refmod.c, which is ours, written
to contain one of each thing the loader has to get right.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2
This commit is contained in:
Nexoniarz 2026-09-14 07:45:31 +02:00
commit 1be5f3a704
9 changed files with 1296 additions and 2 deletions

107
tools/python/ldls_check.py Normal file
View file

@ -0,0 +1,107 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: Apache-2.0
#
# Check the LDLS loader against an object we compiled ourselves.
#
# docs/plan/ldls.md milestone L0. The thing being tested is ldls/ko.c; the
# input is ldls/test/refmod.c compiled with -c, which is exactly what a `.ko`
# is. **No kernel module is involved and none is in this tree**, which is rule
# 1 applied to the repository as well as to the image.
#
# This is a test rather than a demo, so it asserts. `ldls-inspect` printing
# something plausible is not evidence; the specific things the loader has to
# get right are listed below and each one is checked by name.
import re
import subprocess
import sys
def fail(msg):
print("ldls-check: FAIL -- %s" % msg)
sys.exit(1)
def main():
if len(sys.argv) < 3:
print("usage: ldls_check.py <ldls-inspect> <object.o>")
return 2
inspect, obj = sys.argv[1], sys.argv[2]
r = subprocess.run([inspect, obj], capture_output=True, text=True)
out = r.stdout
if r.returncode != 0:
fail("ldls-inspect exited %d\n%s%s" % (r.returncode, out, r.stderr))
checks = 0
def need(cond, what):
nonlocal checks
checks += 1
if not cond:
fail("%s\n--- output ---\n%s" % (what, out))
print(" [ok] %s" % what)
# It is recognised as a module at all, and its declared licence is read
# straight out of .modinfo. That is the first question anyone should be
# able to ask about an object before loading it.
need("module refmod" in out, "the module names itself from .modinfo")
need("license Apache-2.0" in out,
"the declared licence is read without loading anything")
# Every kind of section the loader has to place.
for sec, why in (
(".text", "text is laid out"),
(".data", "initialised data is laid out"),
(".bss", "bss is laid out, and zeroed rather than copied"),
):
need(re.search(r"^\s+%s\b" % re.escape(sec), out, re.M) is not None, why)
need("(zeroed)" in out, "bss is marked NOBITS and gets zeroed, not copied")
# The undefined symbols -- L0's entire output, and the work queue for L1.
m = re.search(r"undefined symbols: (\d+)", out)
need(m is not None, "the undefined symbols are counted")
need(int(m.group(1)) >= 4, "it found the object's undefined symbols")
for sym in ("ldls_test_alloc", "ldls_test_free", "ldls_test_print",
"ldls_test_counter"):
need(sym in out, "reports the undefined symbol %s" % sym)
# And the one that matters most: with a resolver that knows nothing, a real
# object cannot relocate, and the loader must say which name stopped it
# rather than quietly producing a broken image.
need("stops at: unresolved: ldls_test_" in out,
"relocation stops at a named unresolved symbol")
need("loads to" in out, "the object reports a load size")
# The relocations are counted by type, and none of them is one the loader
# cannot handle. That check matters more than it looks: an unhandled type
# is invisible until a relocation of that type is reached, which for a real
# driver can be the last one in the file.
m = re.search(r"relocations: (\d+)", out)
need(m is not None and int(m.group(1)) > 0,
"the relocations are counted by type")
need("does not handle" not in out,
"every relocation type in the object is one the loader handles")
# And with everything resolved, relocation must run to the end. This is a
# different question from the list of undefined symbols and it is the one
# that says the loader is finished rather than merely parsing.
r2 = subprocess.run([inspect, "-q", "-r", obj], capture_output=True,
text=True)
need(r2.returncode == 0 and "relocated in full against stubs" in r2.stdout,
"with every symbol resolved, relocation runs to the end")
# A file that is not a relocatable object is refused rather than parsed.
r3 = subprocess.run([inspect, sys.argv[0]], capture_output=True, text=True)
need(r3.returncode != 0, "a non-ELF file is refused, not parsed")
print("ldls-check: %d checks passed" % checks)
return 0
if __name__ == "__main__":
sys.exit(main())