Skip to content

Reverse-engineering the prebuilt kernel

Large parts of the Amix kernel ship as prebuilt relocatable objects with no .c source — the VFS layer, the syscall trap, the mount path. When you need to know exactly what one of these does (and the in-guest debuggers can't help), the reliable route is to disassemble the shipped objects on the host and, for runtime state, read a running kernel out of an emulator savestate. Everything on this page was reproduced locally ✅ — disassembly of the objects in ~/opt/amix-cross/.../sysroot/usr/sys with the cross objdump, cross-checked against a live kernel image and against an on-box test.

Why: the kernel ships as objects, not source

Amix builds the kernel from per-directory linked export objects, one exp per subsystem, not from a flat pile of .c files (see Kernel architecture → the kernel as object libraries) ✅. The Commodore-authored core — os/, fs/ — is present only as those objects; there is no os/vfs.c / os/trap.c in /usr/sys to read. But the objects are ordinary m68k COFF/relocatable files, so they disassemble cleanly, with symbols and relocations — which means jsr targets show up by name. That makes the disassembly readable as pseudo-source.

Step 1 — find which exp defines a symbol

The staged kernel tree lives under the cross sysroot; use the cross binutils (never the host's) ✅:

SYS=~/opt/amix-cross/m68k-cbm-sysv4/sysroot/usr/sys
NM=~/opt/amix-cross/bin/m68k-cbm-sysv4-nm

# which object defines `mount`, `vfs_getvfssw`, `vfsinit`, `vfssw`?
for f in $(find "$SYS" -name exp -o -name '*.exp'); do
  m=$("$NM" "$f" 2>/dev/null | grep -iwE '_?mount|_?vfs_getvfssw|_?vfsinit|_?vfssw')
  [ -n "$m" ] && echo "### ${f#$SYS/}" && echo "$m"
done

For the mount path this points at two objects ✅:

Symbol Object Offset
mount (the syscall) fs/fs.exp 0x1814
vfs_getvfssw fs/fs.exp 0x2370
vfsinit fs/fs.exp 0x2446
sysent (syscall table) os/exp 0x6f8
systrap (trap dispatcher) os/exp 0x1eaf6

Step 2 — disassemble with relocations

objdump -dr interleaves the relocation records, so every external call is annotated with its target symbol ✅:

OD=~/opt/amix-cross/bin/m68k-cbm-sysv4-objdump
"$OD" -dr "$SYS/fs/fs.exp" > fs_exp_dis.txt      # whole object
# extract one function (mount @0x1814 → next label):
awk '/^0*1814 <mount>:/{f=1} f{ if(/^0*1814/){print;next}
     if(/^[0-9a-f]+ <[^>]+>:/){exit} print }' fs_exp_dis.txt

The R_68K_32 lookupname / R_68K_32 vfs_getvfssw reloc lines under each jsr tell you what is being called without any guessing. This is what makes a source-free object tractable.

The same reloc-annotated disassembly reaches well past the VFS/mount functions tabled above. The in-kernel SCSI path was cracked the same way — sdopen (the sd disk driver's open entry) and getrdb (the RDB / Rigid Disk Block parser) — reading their jsr targets by name straight out of their exp objects ✅. Any source-free subsystem, core or driver, is fair game.

Step 3 — read a running kernel from a savestate

The on-guest debuggers are unreliable here: adb -k /stand/unix /dev/kmem reads garbage because the relocated runtime kernel does not match the /stand/unix namelist (constants like nfstype read as 0), and crash errors out on /dev/mem ✅. The dependable path is a host-side memory dump. That is a stock relocated kernel's problem: on an amigaux 1.1 module kernel the same adb invocation reads live values (below).

Take an emulator snapshot (Amiberry: the SAVESTATE IPC verb) and pull globals out of the RAM chunk. On an A3000-fastmem machine the kernel lives in the A3K1 chunk, identity-mapped at physical base 0x07800000 (runtime VA = 0x07800000 + chunk_offset) ✅. The .uss chunk format is name(4) + len(4 BE) + flags(4 BE) + [uclen(4 BE) if compressed] + zlib data. Find a table by an unambiguous anchor (e.g. the "BADVFS" string that heads vfssw[]) and walk it:

import zlib, struct
d = open("amix.uss","rb").read()
j = d.find(b"A3K1")
ram = zlib.decompressobj().decompress(d[j+16:])[:struct.unpack(">I", d[j+12:j+16])[0]]
BASE = 0x07800000
W = lambda o: struct.unpack(">I", ram[o:o+4])[0]      # runtime VA = BASE + o

This recovered the live vfssw[] (each row {char *vsw_name; int (*vsw_init)(); struct vfsops *vsw_vfsops; long vsw_flag}, 16 bytes) and the int nfstype immediately after it ✅ — see the worked example below.

Grabbing the snapshot — the two-arg SAVESTATE IPC

Amiberry's SAVESTATE verb takes two arguments — a statefile and a configfile. Called with one it errors and saves nothing ✅:

printf 'SAVESTATE\t/tmp/x.uss\t/tmp/x.cfg\n' \
  | socat - UNIX-CONNECT:/run/user/<uid>/amiberry.sock

It works even at a kernel panic screen, so you can snapshot a wedged kernel and read why it wedged ✅.

Locating a global by symbol — nm values are section-relative

Step 3's walk above found vfssw[] by a string anchor ("BADVFS"). When a global has no such anchor, resolve it by symbol with the cross nm on the linked kernel image /usr/sys/relocunix — but its values are section-relative, not absolute ✅. The kernel loads packed as text | data | bss at base 0x07800000, so the byte offset of a symbol into the A3K1 chunk is:

Symbol's section Byte offset into A3K1
bss text_size + data_size + nmval
data text_size + nmval

with text_size / data_size read from size /usr/sys/relocunix (cross size). Taking the raw nmval as the offset — the naïve reading — lands you in kernel code, not the variable ✅.

Validated at the computed offsets: scsicard[] shows the 0xc0de0001 / 0xc0de0002 product codes and the global block shows the UNIX_Root PART record ✅.

Live-probing a running kernel with adb on /dev/kmem — the load-bias trap ✅

The savestate route above reads a frozen kernel. When the box is alive, you can read and write live kernel memory in place with adb on /dev/kmem, turning an instrumented kernel into a re-armable probe with no rebuild/reboot cycle ✅:

echo '<runtime-VA>/D'      | adb - /dev/kmem      # read one long by runtime virtual address
echo '<runtime-VA>/W 0'    | adb -w - /dev/kmem   # write it (adb -w enables writes)

This was proven by zeroing a driver's static log counter mid-session and watching it increment again on the next I/O ✅.

The trap — adb's symbol addresses are FILE-relative, so you must add the kernel's load bias. ✅ Scope: this fix applies to defined symbols only (.text, .data, .bss). A SHN_COMMON symbol — every bare C tentative definition in the kernel — is a different trap the bias cannot fix: its file value is an alignment, not an offset (see the next section) ✅. The reason the earlier adb -k /stand/unix /dev/kmem invocation reads garbage is the same one that bites here: given a namelist (adb /stand/unix /dev/kmem), adb resolves a symbol to the address recorded in the kernel file, which on this relocated kernel is only an offset — reading it directly returns garbage (freemem read back as 0xFFFFFFFF). Drop the namelist and address by runtime VA instead:

🟡 Two open re-checks on this example (flagged 2026-08-27, one measurement each, not yet run): first-party strstat.py classifies freemem as a COMMON symbol — in which case the 0xFFFFFFFF above is an instance of the COMMON trap, and the bias would not have fixed it; and the working combination measured for kernel-mode reads is -k with /dev/mem, whereas the failing invocation above used /dev/kmem — device-versus-bias was not isolated. Until re-run, treat this paragraph's example (not its rule) as provisional. Narrowed 2026-10-31: on an amigaux 1.1 module kernel, whose /stand/unix holds runtime addresses, adb -k /stand/unix /dev/kmem reads freemem correctly ✅ (below), so -k on /dev/kmem works when the namelist is right. Both re-checks on the stock relocated kernel are still open.

runtime VA = (nm/adb symbol value) + kernel_load_base

On the real A4000 + Z3660 box the kernel loads at 0x08000000, verified by hand: z3660_enter at nm offset 54932 (0xD694) is live at 0x0800D694, matching its instruction bytes ✅. (That base is machine/config-specific — it is wherever the bootstrap relocated the kernel; the Amiberry A3000-fastmem savestate above sees the kernel at 0x07800000 instead. Confirm your box's base with a known symbol before trusting any poke.)

For a static-scoped variable with no global symbol: disassemble the function that touches it, read the operand address straight out of the live instruction stream, then poke that address — the same trick that recovers file-scoped statics from the exp objects, applied to the running image ✅.

On an amigaux 1.1 module kernel the namelist is already bound ✅

On a 1.1 module kernel neither trap applies. kbootset writes /stand/unix as the booted generation's namelist: an ET_EXEC symbol table with no contents, each symbol at its address in this boot (the bind address plus its offset in the loader's layout, COMMON symbols included). It rewrites the file on every boot that reaches mark-good, and kbootset status names it ("namelist: /stand/unix is generation N's, bound at 0x08000000"). The symbol values adb takes from it are runtime addresses, so no load-bias arithmetic is needed ✅:

echo "freemem/D\navailrmem/D\nphysmem/D" | adb -k /stand/unix /dev/kmem

Measured on the 1.1 68030 module kernel (emulated A4000 + Z3660 in 68030 mode, 32 MB fast RAM), the values are live and consistent ✅: physmem 16383 (2 KiB pages); freemem 11854 and 11851 idle, 11746 with six extra processes running, 11832 after they exited; availrmem 14229, 14211, 14213.

The other namelist readers do not follow ✅. On the same box sar -r 1 1 fails with sadc cannot copy inode table: No such device or address, and /etc/crash with could not read namelist timestamp. On the 2.1c bench image's kernel (booted kernel not /stand/unix, no symbol table) crash fails with process slot out of bounds and sadc with Not enough space. For live kernel variables on 1.1, use adb.

modadmin -p knows only the exported symbols; the 68040/060 load layout gives the rest ✅

On a test build of the 68040/060 module kernel, modadmin -p kzero prints an address, but modadmin -p sptmap, availrmem or tune answer symbol not found ✅. modadmin resolves the names the DDI exports (the boot's "208 kernel symbols"), not the kernel's whole symbol table. For the rest, use the namelist above or compute the address from the loader's layout ✅:

  1. The ET_REL kernel (/stand/k46/unix) is loaded with .text at 0x08000000.
  2. .data follows .text immediately.
  3. .bss follows .data immediately, with no alignment: the loader puts .bss at the end of .data as it is. The port's tree pads .data to a 4-byte multiple for that reason.
  4. Then come the COMMON symbols, in symbol-table order, each aligned to 4 and sized by its st_size.

That model reproduced ten addresses modadmin printed (five in .data, five COMMON), and lbolt on a build whose .data did not end on an 8-byte boundary ✅. In the ET_REL file itself a COMMON symbol's st_value is its size or alignment, and a .data or .bss value is a section offset, so neither is an address: the trap of the next section and of the avenrun story in quirk 38.

The COMMON-symbol trap: nlist succeeds and lies ✅

The second, distinct reason a symbol lookup against /stand/unix hands you a bad address — and the load-bias fix above cannot touch it. /stand/unix is an ET_REL relocatable object, and every bare C tentative definition in the kernel is SHN_COMMON in it: such a symbol has no address in the file at all. Its n_value is the alignment requirement, and nlist(3) returns success anyway — no error, no zeroed entry. The caller then reads /dev/kmem at a small integer and prints whatever lives there. The address genuinely cannot be recovered from the file: the boot loader's elf2brel conversion allocates COMMON itself at boot and discards the symbol tables, so the placement exists only inside the loaded image ✅.

How to tell in ten seconds ✅: nm shows COMMON in the section column; nlist returns n_scnum = -14; the value is an implausibly small, suspiciously round number. If a "kernel address" comes back as a single-digit number, it is an alignment — nothing lives at 4.

Trap Applies to Symptom Fix
Load bias (previous section) defined symbols value is a real but file-relative offset add the kernel load base
COMMON placement (this section) SHN_COMMON symbols value is an alignment; no address exists in the file resolve against the running image

What works ✅: adb -k <kernel> /dev/mem — the -k kernel mode with the physical-memory device — because adb -k reproduces the boot loader's COMMON placement instead of trusting the file's symbol value. Two load-bearing conditions:

  1. The namelist must be the kernel actually running. A different kernel's namelist resolves to a different, plausible-looking address — measured: the stock 2.1_unix namelist on a relinked running kernel returned convincing zeros ✅.
  2. When the running kernel exists in no file at all (a kernel loaded from a raw slice rather than /stand), there is no namelist to hand adb, and pointing it at /stand/unix fails silently with plausible values — the same trap class this section warns about. The recovery is to replay the loader's COMMON allocation host-side against the running artifact (first-party record, 2026-08-30), then read the computed address; validated on real hardware by an independent clock-tick cross-check ✅.

crash(1M) is not an alternative — it fails on this kernel with process slot out of bounds ✅. And where adb is unavailable, the instruction-stream bootstrap already described above (read the operand address of a function that touches the variable, cross-check a second site) resolves COMMON symbols too — that is precisely how strstat.py finds strst ✅.

The checklist, for any kernel variable on Amix ✅: never trust nlist(3) (success is not evidence); classify the symbol first (nm — COMMON or defined); COMMON → running image (adb -k with the running kernel, or replay the allocation); defined → nm value + load base, base confirmed against a known text symbol; and always sanity-check by moving the machine — a value that never moves with load is a wrong address wearing a convincing disguise. The worked consequence of this whole section is the load-average story: see load averages.

Worked example — how mount(2) and fstype registration actually work

Disassembling the three fs.exp functions above yields the complete, source-accurate picture ✅:

Registration is a compile-time table + an init sweep. Filesystems live in vfssw[] (built from master.d/filesys.c); nfstype is its length. At boot, vfsinit loops for (i=1; i<nfstype; i++) and calls vsw_init(&vfssw[i], i) for each row — so a filesystem's init hook (e.g. s5init, cdfsinit) runs automatically once its row is in the table. Adding a row and relinking is sufficient to register a new FS; there is no separate init-call list to edit ✅. (This resolves a long-standing "how does the kernel call the fs inits" question — it is vfsinit, and it is right there in fs.exp.)

mount(2) never looks at the special/spec argument. The syscall (fs.exp:0x1814) resolves the mount point via lookupname, then selects the fstype, then dispatches — it forwards the spec untouched to the filesystem's own VFS_MOUNT. The fstype selection uses a pointer-vs-integer heuristic ✅:

if ((u_int)uap->fstype < 256) {           /* old SVR3 numeric fstype index */
    if (fstype == 0 || fstype >= nfstype) return EINVAL;   /* <-- errno 22 */
    vfsops = vfssw[fstype].vsw_vfsops;
} else {                                   /* modern string fstype, e.g. "cdfs" */
    copyinstr(uap->fstype, fsname, ...);
    if ((vswp = vfs_getvfssw(fsname)) == NULL) return EINVAL;  /* <-- errno 22 */
    vfsops = vswp->vsw_vfsops;
}

vfs_getvfssw (fs.exp:0x2370) is a plain for (i=1; i<nfstype; i++) strcmp(name, vfssw[i].vsw_name) scan of the same table. The per-FSType user helpers /usr/lib/fs/<fs>/mount — which the generic mount(1M) execs — always pass the fstype string (mount(spec, dir, MS_DATA|MS_RDONLY, "cdfs", 0, 0)), so real mounts take the string branch ✅.

The trap returns the syscall value verbatim. systrap (os/exp:0x1eaf6) stores the syscall's return straight into the user register (movel ...,%a5@(4)) with no & 0xFF mask and no max-errno clamp — only EFBIG/EINTR get special handling ✅. So a filesystem's VFS_MOUNT return reaches errno unchanged; there is no hidden remapping to hunt for. (Amix's highest defined errno is ~151; EINVAL is 22.)

The payoff — an errno 22 from mount -F <fs> means "not live in the booted kernel"

Putting the disassembly together gives a sharp diagnostic ✅. For a string mount of a filesystem on a normal directory, the only pre-dispatch EINVAL(22) reachable is vfs_getvfssw(name) == NULL — i.e. the fstype's row is not in the running kernel's vfssw[]. Every other 22 site is dead for that call (the numeric-index check needs an integer fstype; VNOMOUNT is only ever set on /proc vnodes, not s5/ufs directories; lookupname of an existing dir returns 0/ENOENT/ENOTDIR, never 22).

So when a freshly-built in-kernel filesystem returns errno 22 from mount -F <fs> ... /mnt, it is almost always the classic SVR4 trap: the kernel was patched on disk but not relinked-and-rebooted, or the wrong kernel is booted — not a bug in the filesystem's mount code. Confirm live registration directly with sysfs(GETFSIND, "<fs>") (from <sys/fstyp.h>: GETFSIND=1, GETNFSTYP=3), which scans the same vfssw[]:

#include <sys/fstyp.h>
i = sysfs(GETFSIND, "cdfs");   /* >=1 → registered live; -1/errno 22 → NOT in this boot */

This was verified end-to-end: on a kernel without cdfs compiled in, sysfs(GETFSIND,"cdfs") returns -1 and the string mount returns errno 22; the fix is to relink the fstype into the kernel and boot that image, not to touch the mount path ✅. See Building & installing a kernel for the relink/install/reboot cycle and Filesystems & disks for the /etc/vfstab side.

A throwaway-global diagnostic — the cdfs_diag pattern

When the console is dead but a savestate still works (Step 3), a throwaway global turns the snapshot into a poor-man's printf. cdfs used one, cdfs_diag: a struct filled as a mount/read path advanced — reached-stage, errno, the first bytes read — then dumped afterward out of the .uss ✅. The trick that makes it findable in the packed image: write its magic word 0x0DF50001 last, so the populated struct is the one whose magic is set. One caveat when you scan the A3K1 chunk for that magic — it also occurs coincidentally inside code, so take the 4-aligned hit ✅.

See also

Sources

  • The modadmin -p scope and the 68040/060 load layout (first-party record, 2026-11-01) ✅ — the 68040/060 port's emulator runs on a test build of its module kernel (A4000, 68LC060 on a Z3660): modadmin -p for exported and unexported names, ten printed addresses reproduced from the layout model, and lbolt on a build with an unaligned .data end; port KNOWN-ISSUES.md (2026-11-01) and the port tree's .data padding.
  • The 1.1 namelist reads (first-party record, 2026-10-31) ✅ — the amix-kerntools project's runs on the emulated 1.1 68030 module kernel (A4000 + Z3660 in 68030 mode, 32 MB): the adb -k /stand/unix /dev/kmem values above, kbootset status, and the sar/crash messages there and on the 2.1c bench image's kernel. The namelist's form and writer: amigaux/amix kernel/dlm/docs/spec/40-generations-and-composer.md §4.2.
  • Disassembly of the shipped kernel objects usr/sys/fs/fs.exp (mount @0x1814, vfs_getvfssw @0x2370, vfsinit @0x2446) and usr/sys/os/exp (sysent @0x6f8, systrap @0x1eaf6) via m68k-cbm-sysv4-objdump -dr / -nm, cross toolchain sysroot — reproduced locally ✅.
  • Live vfssw[] / nfstype read from an Amiberry SAVESTATE (A3K1 chunk, identity base 0x07800000), cross-checked against the disassembly ✅.
  • On-box reproduction on Amix 2.1c: sysfs(GETFSIND,"cdfs") and a string-form mount(2) probe, showing errno 22 ⇔ fstype absent from the booted kernel's vfssw[] ✅.
  • <sys/mount.h> (MS_RDONLY=0x01, MS_FSS=0x02, MS_DATA=0x04; int mount(const char *, const char *, int, ...)), <sys/fstyp.h> (GETFSIND=1, GETFSTYP=2, GETNFSTYP=3), <sys/errno.h> (EINVAL=22) from the Amix sysroot ✅.
  • SVR4.0 reference behaviour cross-checked against calmsacibis995/svr4-src uts/i386/fs/vfs.c (the mount()/vfs_getvfssw ordering) — Amix is a straight SVR4.0 port ✅.
  • Two-arg Amiberry SAVESTATE IPC (statefile + configfile; one arg saves nothing, works at a panic screen) — firsthand, amix-cdfs CD-ROM port effort (2026-07-09/10) ✅.
  • Section-relative nm offset model for the linked kernel image /usr/sys/relocunix (bss offset = text_size + data_size + nmval, data offset = text_size + nmval; sizes from size), validated against the scsicard[] 0xc0de0001/0xc0de0002 product codes and the block global's UNIX_Root PART record in the A3K1 dump — firsthand ✅.
  • Same objdump -dr reloc-annotated method extended to the in-kernel SCSI path — sdopen and getrdb (RDB parser) traced by name out of their exp objects — firsthand ✅.
  • cdfs's cdfs_diag throwaway-global diagnostic (magic 0x0DF50001 written last; scan the A3K1 chunk for the 4-aligned hit) — firsthand from the amix-cdfs port ✅.
  • Live adb on /dev/kmem (read with adb - /dev/kmem, write with adb -w; symbol addresses are file-relative so runtime VA = nm value + kernel load base, 0x08000000 on the real A4000 + Z3660, verified z3660_enter nm 0xD694 → live 0x0800D694; poke a static by reading its operand address out of the live instruction stream) — first-party record, 2026-07-12, on real hardware ✅.