← board

crtl on arm32: every syscall-guarded body is inert

The measurement

One probe, five targets. Each row is a call whose only failure mode here is a missing number — it is given arguments the kernel refuses cheaply, so a target WITH the table answers some other errno (or 0) and one without it answers ENOSYS. errno is printed, not rc: rc is -1 either way, which is the whole reason this is invisible.

call x86-64 i386 aarch64 riscv32 arm32
sched_getscheduler 0 0 0 0 38 ENOSYS
sched_getparam 0 0 0 0 38
sched_yield 0 0 0 0 38
mlock 0 0 0 0 38
munlock 0 0 0 0 38
acct 1 1 1 1 38
statfs 0 0 0 0 38
flock 9 9 9 9 38
prctl 22 22 22 22 38
readv 9 9 0 0 38

readv differs between the 32- and 64-bit rows (9 against 0) for a reason unrelated to this ticket — a NULL iovec with count 0 is handled differently — and it is left in rather than trimmed, because a table with an unexplained cell quietly removed is worse than one that says which cell it cannot explain.

The population

56 guards, 19 files, 55 distinct SYS_ names

sched.c, unistd.c, fcntl.c, sys/timex.c, sys/prctl.c, sys/select.c, sys/shm.c, sys/reboot.c, sys/file.c, sys/msg.c, linux/capability.c, sys/personality.c, sys/swap.c, sys/uio.c, sys/mman.c, sys/sem.c, sys/mount.c, sys/klog.c, sys/statfs.c.

Every one takes #else errno = ENOSYS; return -1; on arm32.

The header's comment is CORRECT — do not "fix" it

lib/crtl/include/sys/syscall.h says arm32 and xtensa get nothing deliberately: no header on this box gives either table, and "a guessed number is worse than a missing one — a wrong number does not fail, it runs something else." That reasoning is right and this ticket does not dispute it. Three hundred numbers is exactly the population where one recalled digit becomes a different syscall.

Still true today: ~/.cache/pxx-cross/arm32 holds only lib (the loader and libc.so that qemu needs), no asm/unistd.h. /usr/include/asm is this box's x86-64.

The tempting fix, and why it is not one

lib/rtl/platform/posix/platform_backend.pas carries a hand-maintained arm32 table that is use-proven — arm32 tests run through SYS_kill=37, SYS_getpid=20, SYS_rt_sigaction=174 every day. So "copy the numbers from the PAL" looks obvious and cheap.

Measured, it buys three numbers.

crtl guards want          55 distinct SYS_ names
platform_backend arm32    80 numbers
OVERLAP                    3  -- SYS_fork, SYS_ioctl, SYS_ppoll

None of the ten rows in the table above is among them. The two sets barely intersect because they answer different questions: the PAL carries what the RUNTIME needs, and these guards are what a PROGRAM reaches for when the PAL has no entry. So the transcription spends the provenance rule and fixes nothing measurable.

(Count taken under LC_ALL=C. The first run warned "comm: file 1 is not in sorted order" and printed a number anyway — an instrument that answers while telling you it cannot.)

What would actually fix it

A real asm/unistd.h for arm32 EABI, from a cross toolchain or a kernel-headers package, fed to the same generator that produced the x86-64/i386/aarch64/riscv32 arms. Provenance, not transcription. xtensa is the same shape and the same answer.

Acceptance

The table above, with the arm32 column matching i386 row for row — it is the same word size and the same kernel, so a per-target constant is not needed and the assertion is agreement between two targets, which carries no expected value to go stale.

Worth pairing with [[bug-b-crtl-waitpid-returns-enosys-on-riscv32-so-no-program-can-reap-a-child]] — different cause, same symptom shape, and both were found by running a C probe on a cross target rather than by reading the source.

Found by

Following up frankA's "if you hit a fourth private syscall table in crtl, the PAL almost certainly already has the numbers" (c4c5e932b, after deleting pxxcio's two and ansiterm's four). Checked, and crtl's is the opposite shape: its table is the generated, authoritative one and the PAL cannot supply what it is missing. The advice was right to check and wrong for this file, which is itself worth recording.

UPDATE 2026-09-04 — the blocker was wrong, and the numbers are measurable here

This ticket said the fix needs "a real asm/unistd.h for arm32 EABI, from a cross toolchain or a kernel-headers package". frankA showed that is not required, and the method is the one platform_backend.pas's xtensa block already documents for its own numbers: qemu-arm -strace, one syscall per process, every argument 2147483647 so the call is inert whatever it turns out to be. It goes number → name, so a sweep over 0..450 yields the whole map from one compile and N runs.

Verified here, by a method that fails differently

frankA's eight were not taken on report. qemu-arm -strace reads QEMU's syscall table — genuinely independent of ours, but an oracle about qemu. The check run here instead makes the call by raw number on arm32 and compares errno against the same call by name on the targets that have a real table. That asks a kernel rather than a name table.

name arm32 # oracle (i386 / x86-64 / aarch64 / riscv32) arm32 by number
sched_getscheduler 157 rc=0 rc=0
sched_getparam 155 -1 EINVAL -1 EINVAL
sched_yield 158 rc=0 rc=0
mlock 150 rc=0 rc=0
munlock 151 rc=0 rc=0
flock 143 -1 EBADF -1 EBADF
prctl 172 -1 EINVAL -1 EINVAL
readv 145 -1 EBADF -1 EBADF
acct 51 -1 EPERM -1 EPERM
statfs 99 -1 ENOENT -1 ENOENT

The expected errnos DIFFER across rows (0, EINVAL, EBADF, EPERM, ENOENT) on purpose — a probe whose rows all expect the same value cannot tell a right number from a wrong one that also fails.

The last two are the pair frankA's sweep produced no line for. The legacy-table values 51 and 99 answer EPERM and ENOENT, matching the oracle on three targets.

readv needed a sharper probe, and that is worth keeping

The first attempt used readv(-1, NULL, 0) and arm32 answered rc=0 where i386 answered EBADF — which reads as a wrong number. It is not: with iovcnt 0 a kernel may return 0 without ever looking at the fd, so that row was not evidence about the number at all. With a real iovec and iovcnt 1, all four tabled targets AND arm32-by-number answer EBADF. An ambiguous argument shape produced a confident wrong reading.

THE CONTROL FAILED TO FAIL, and that is the real limit of this method

The first negative control was 145 + 1. It also answered EBADF — because 146 is writev, and writev(-1, iov, 1) is EBADF too. An adjacent number in the same family is indistinguishable behaviourally.

So the behavioural check proves "this number reaches a call in the right family taking these argument kinds" and cannot discriminate siblings. Controls that do discriminate:

#399 (unassigned)  -> -1 ENOSYS
#20  (getpid)      -> returns a pid, errno 0

qemu-arm -strace does not have this weakness — it names the call — which is exactly why the two methods belong together rather than either alone.

So the fix is a GENERATED header, not a transcription

A full 0..450 sweep, emitted by the same generator that made the other four arms, with both controls asserted inside the generator:

  1. the read/write naming rows frankA already checks (3 and 4 on arm32),
  2. and a sibling row that must DIFFER — otherwise the generator's own guard has the hole this ticket's control just fell into.

Provenance caveat to carry into the header, frankA's and correctly stated (36758dae3): qemu's names come from QEMU's table, so a qemu/kernel divergence is invisible and looks exactly like a correct number. The mitigation for arm32 is that every arm32 test in this tree runs under qemu-arm, so the numbers are right for the entire population that exercises them. Say that in the header rather than "measured".

UPDATE 2026-09-04 (2) — the map arrived, and three rows in it are wrong

devdocs/dev/syscall-maps/arm32.txt, frankA 5fa3e0705, 366 rows, five controls asserted in the generator including the consecutive-triple one this ticket asked for. All ten names this ticket needs are in it and match the independent behavioural verification above. The errors below are elsewhere in the map, and they are the reason the header arm is NOT being generated yet.

map says truth how it was confirmed
29 rt_sigreturn ?noreturn pause SIGALRM handler + alarm(1), then syscall(29) → -1/EINTR on arm32, identical to i386 where 29 is also pause
248 mmap2 exit_group syscall(248,7) exits 7 on arm32; on i386 the same call exits 1, so the probe discriminates
(no row at 2) fork syscall(2) returns twice: child _exit(9), parent reaps code 9 — on arm32 and i386 alike
192 mmap2 mmap2 returns a pointer, so 192 is the correct one of the pair

Each has an explainable generator cause

THE FREE CONTROL THE GENERATOR IS MISSING

No two numbers may name the same call. Over the whole map that is one sort | uniq -d, and it flags exactly two names:

rt_sigreturn   at 29 and 173
mmap2          at 192 and 248

Both duplicates are the two misnamed rows, and neither of the five existing controls can see them: the consecutive triple, the unassigned probe, the getpid row and the refuse-without-a-table guard are all about numbers the tool chose to check, and these errors are in rows it produced. A duplicate is a property of the OUTPUT, which is why it catches what an input-side control cannot.

It does not catch fork. An absence has no name to collide with — that one wants a different guard, e.g. asserting that the control table's own entries all appear, or treating a probe whose child count changed as a distinct outcome rather than as no row.

Why the header is held rather than written with three fixes applied

Because this ticket exists for the sentence "a wrong number does not fail — it runs something else", and applying three hand-corrections to a generated artefact makes it a transcription again, with the error rate unknown outside the rows anyone happened to look at. Three errors surfaced from examining ~52 rows closely. The right move is a regenerated map with the duplicate-name control in the tool, not a patched copy of this one.

Everything else stands: the method works, no toolchain is needed, and the ten numbers this ticket measures are confirmed twice by instruments that fail differently.

FIXED — 2026-09-04, franks-ab

The census this ticket opened with

target    the ten rows
x86-64    errno 0,0,0,0,0,1,0,9,22,9
i386      identical
arm32     identical   <- was ENOSYS on all ten
aarch64   identical
riscv32   identical

readv's row changed shape on the way: the original probe used readv(-1, NULL, 0), which answers 0 on some kernels and EBADF on others because with iovcnt 0 the fd need never be looked at. That ambiguity read as a wrong syscall number for an hour. With a real iovec and iovcnt 1, EBADF is the only correct answer anywhere, and all five agree.

The fix is generated, not transcribed

tools/gen_crtl_arm32_syscalls.py emits the #elif defined(__arm__) block from devdocs/dev/syscall-maps/arm32.txt — 369 numbers. It refuses to write at all if any control fails, and a consumer that trusts its input has no guard:

Both refusal paths were demonstrated before use: a map with an injected duplicate, and one with an audited row changed, are each rejected with the reason named.

The table is partial, and the header says so

It holds what the sweep established, not the kernel's full table. ?unnamed rows are skipped (arm32 90 / old_mmap: assigned, returns, and qemu prints no name for it). A number the sweep never saw is absent, and naming its SYS_* is still a compile error — the same answer this header gave for arm32 when it had no table at all, and the right one. SYS_pselect6_time64 is the one name crtl guards on that the map does not carry; that guard still takes its ENOSYS arm, correctly and visibly.

The header's original reasoning is intact and was never the thing that was wrong: "a guessed number does not fail — it runs something else." These are not guessed.

The test asserts a RELATION, not constants

test/c_crtl_syscall_guarded_bodies.c carries no expected values. The Makefile compares arm32, aarch64 and riscv32 each against i386's output, so the assertion is agreement between two independent builds and there is no per-target literal to go stale. It prints errno and not rc, which is the only reason it can see anything at all.

Positive control, run live rather than recalled: restoring the committed header with git checkout -- (never a file copy — a restored copy has no merge step to fail at) and rebuilding gives 10 of 10 rows differing from i386; regenerating gives 0.

Provenance, carried rather than summarised

The map is an oracle about QEMU, not about a kernel on real hardware. Every arm32 test in this tree runs under qemu-arm, so the numbers are right for the whole population that exercises them; a first run on hardware is where they would be falsified. That sentence is stamped in the generated block, because a number copied out from under it stops carrying it — "measured" on its own overstates what was checked.

Gates

make compiler/pascal26 converged, tools/gate.sh quick GREEN with the FPC seed canary PASS, crtl-reachability OK at 133 headers.