SchOS/tools/python/ldls_check.py
Nexoniarz 2d9a0ec6e3 ldls: being the PCI core, with offsets nobody wrote down
docs/plan/ldls.md milestone L2, less the half that needs /dev/pci.

In Linux, __pci_register_driver ends at the PCI core, which matches a
driver's id_table against the bus and calls probe(). There is no PCI core
here, so LDLS keeps the pointer that call was given and plays the part.

To do that it must know where pci_driver.probe is, and it asks the kernel
rather than being told by us. ldls/btf.c reads /sys/kernel/btf/vmlinux --
122,279 types, every struct layout and every function prototype, as generated
data describing the kernel that is installed. Implemented from
Documentation/bpf/btf.rst, which is a published specification.

    $ 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

Cross-checked against pahole on the same file, field for field. No offset in
this is ours, which is rule 3's uncomfortable row disappearing rather than
being argued about.

And then the driver:

      driver name "8139too"
      devices it claims: 10ec:8139 ... and 16 more
      calling probe(pci_dev 10ec:8139, ...)
        1  alloc_etherdev_mqs(0x368, 1, 1)
        2  pci_enable_device / 3 pci_request_regions / 4 pci_set_master
        5  pci_iomap(pdev, 1, 0)
        6..544  ioread8 / iowrite8 -- 0x88, 0x8a, 0x8c, 0x8e, over and over
      545  dev_addr_mod / 547 netif_napi_add_weight_locked
      549  timer_init_key / 550 register_netdev / 551 netdev_info
      probe returned 0 after 552 stub call(s)

10ec:8139 is the real RTL8139 id. It mapped BAR 1, the one marked
IORESOURCE_MEM. Those 538 alternating byte accesses stepping 0x88/0x8a/0x8c/
0x8e are serial EEPROM clocking -- the driver bit-banging the chip for its MAC
address and handing the result to dev_addr_mod. Then NAPI, a watchdog timer,
register_netdev, its "found it" line, and zero.

Fifteen distinct symbols carry that entire probe, and three of them are 540 of
the 552 calls. L1 said 44 symbols carry 110 modules through init; L2 says 15
carry this one through probe. Neither is a number anyone would have guessed
and both came from running the thing.

Recorded as not done, because it is not: probe receives a pci_dev whose shape
is right and whose contents are invented. The BARs are made up, pci_iomap
returns a zeroed page, and 8139too read a MAC address of zeroes and was
perfectly happy about it. The "all through /dev/pci" half of L2 belongs to the
SchOS side. Also recorded: BTF has to come from somewhere, and SchOS has no
/sys/kernel/btf/vmlinux -- the user brings it with the module, from the kernel
the module was built for.

make ldls-check is 27 checks, four of them L2's, and it says so out loud when
it skips them on a machine with no BTF rather than passing silently.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2
2026-09-14 08:14:57 +02:00

169 lines
7.1 KiB
Python

#!/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")
# --- L1: the object is loaded and its init actually runs ----------------
if len(sys.argv) < 4:
print("ldls-check: %d checks passed (L0 only -- no ldls-run given)"
% checks)
return 0
run = sys.argv[3]
r4 = subprocess.run([run, obj], capture_output=True, text=True)
out = r4.stdout
need(r4.returncode == 0, "ldls-run loads the object and returns cleanly")
need("calling init_module" in out, "it finds and calls init_module")
need("init_module returned 0" in out,
"init runs to the end and returns what it meant to")
# The call *order*, which is the whole point of L1: a symbol list is a set
# and says nothing about sequence.
seq = re.findall(r"^\s+\d+\s+(\w+)\(", out, re.M)
need(seq == ["ldls_test_print", "ldls_test_alloc", "ldls_test_free"],
"the stub calls are reported in the order the code makes them")
# Arguments survive the trampoline and the thunk. If the register save were
# wrong this is where it would show, because the first argument is a real
# pointer into the loaded object rather than a small integer.
m = re.search(r"ldls_test_print\((0x[0-9a-f]+)", out)
need(m is not None and int(m.group(1), 16) != 0,
"arguments reach the stub intact, not zeroed by the thunk")
# --- L2: the layouts come from a kernel's own BTF -----------------------
#
# Conditional, and it says so when it skips. The BTF is the *host's*
# kernel type data -- not ours, not shipped, and not present on every
# machine. A check that silently passes when it did not run is worse than
# one that is honest about being unable to.
import os
types = os.path.join(os.path.dirname(inspect), "ldls-types")
btf = "/sys/kernel/btf/vmlinux"
if not (os.path.exists(types) and os.path.exists(btf)):
print("ldls-check: %d checks passed "
"(L2 skipped -- no %s on this machine)" % (checks, btf))
return 0
r5 = subprocess.run([types, btf, "--need"], capture_output=True, text=True)
need(r5.returncode == 0,
"every layout the PCI path needs is readable from the kernel's BTF")
need("all present" in r5.stdout, "none of them is missing")
# The offsets are not ours to have guessed, so the check is that they come
# back at all and are self-consistent -- pci_driver.probe after .id_table
# after .name, which is the order the struct declares them in.
o = {}
for line in r5.stdout.splitlines():
m2 = re.match(r"\s+(\w+)\s+(\w+)\s+offset\s+(\d+)", line)
if m2:
o[(m2.group(1), m2.group(2))] = int(m2.group(3))
need(o.get(("pci_driver", "name")) == 0 and
0 < o.get(("pci_driver", "id_table"), -1) <
o.get(("pci_driver", "probe"), -1),
"the pci_driver offsets come back in declaration order")
need(o.get(("resource", "start")) == 0 and
o.get(("resource", "end"), -1) == 8,
"struct resource reads as the ABI says it is laid out")
print("ldls-check: %d checks passed" % checks)
return 0
if __name__ == "__main__":
sys.exit(main())