Skip to content

size: our own calls pass arguments in registers, and the gate that keeps it honest - #121

Merged
tinic merged 1 commit into
mainfrom
size/regparm
Sep 30, 2026
Merged

tinic merged 1 commit into
mainfrom
size/regparm

Conversation

@tinic

@tinic tinic commented Sep 30, 2026 •

Copy link
Copy Markdown
Owner

What this is

-mregparm=3 for the m68k build: our own functions pass their first arguments in
registers instead of on the stack, and the build gate that keeps that honest.

For review, not for merge. It touches how every image in the tree is
compiled, so it needs the CI matrix and the emulator tier green on this exact
tip before anyone decides anything. I am opening it because a branch push runs
no CI at all — ci.yml triggers on push to main, pull_request, and
workflow_dispatch — so codex's "no CI run for this tip" cannot be answered any
other way.

Single commit, rebased onto 6a395286.

Measurement

Against the same tree built with -DAMINETXDUO_REGPARM=0, so the flag is the
only difference. m68k-amigaos-gcc 16.2.0b, -flto, shipping flags, from a
clean tree
. In loaded bytes — CODE + DATA + BSS, what LoadSeg has to
find room for — and in file bytes:

image loaded file
bsdsocket.library (default) 353,404 → 329,464 (−23,940) 370,880 → 346,608
bsdsocket.library (minimal) 231,136 → 215,244 242,640 → 226,580
bsdsocket.library (micro) 198,240 → 184,884 208,144 → 194,640
anxnet.device 43,584 → 40,144 (−3,440) 45,704 → 42,228

The two images a machine keeps resident come to −27,380 loaded. Over every
image both arms link: 137 images 7,917,004 → 7,707,384 loaded (−209,620, −2.65%)
and 6,772,936 → 6,560,528 file (−212,408, −3.14%); 123 minimal −93,460
(−2.11%); 122 micro −87,364 (−2.05%). Five test images grew by 4–20 bytes
(tests/perf/chipscreen +20, tests/perf/n68kmv +16, tests/tools/AamProbe,
ResolveBreak, PtrProbe +4 each); nothing that ships did, and no image is
present in one arm only.

Every figure here is from a clean tree, and the ones this PR first carried
were not.
cmake/AmiNetXDuoGitStamp.cmake appends -dirty to the version
hash whenever git status --porcelain is non-empty, and that hash is inside
every image: an uncommitted edit is 0–8 image bytes, and not the same 0–8 in
both arms
— -dirty added 8 bytes to the rp0 default drawer and 4 to the rp3
one, moving the delta by 4. That is the "±4 byte spread" earlier size work on
this tree put down to LTO relinking. It is also the proof that the two source
changes below cost nothing: two clean arms compared byte for byte across their
CODE hunks differ in exactly 7 bytes, the short hash inside that string.

ABI

Nothing a program can call changed. Every LVO is entered through a register the
NDK's own headers already pin, and libgcc is compiled with the same flag by the
same compiler. A differential over bsdsocket.library's foreign calls reads 207
direct calls at regparm 0 and 201 at regparm 3, and the only sites that push in
neither arm are libgcc's own internal jsr.

What the compiler does not build is pinned where it is declared:

  • main(), which crt0.o and src/tools/tool_startup.S both call by pushing
    argv then argc. Pinned by a force-included header (include/aminetxduo/asm_main.h)
    rather than -Dmain=..., because CMake writes flags into the make recipe as
    raw text and the parentheses would reach /bin/sh unquoted. The macro is
    function-like because 16 files here take main's address.
  • the assembly routines in src/net68k and src/crypto68k, which read 4(sp),
    8(sp), 12(sp): AMIGA_ASM_ARGS on the declaration and on the function
    pointer types that store one.
  • libc.a, amiga.lib and every other archive this tree links.
  • the OS entry points — hooks, interrupt servers, task entries, SetFunction
    patches, RawDoFmt putChProc — none of which take arguments, or whose
    registers the headers pin by name.

src/net68k/n68k_memcpy_hook.c is the one place where nothing is pinned and the
convention holds by name recognition instead: under -mregparm=3 the compiler
calls memcpy by name with the arguments on the stack whatever the caller
looks like, and a definition of memcpy reads them off the stack for the same
reason — not because the file says so. <string.h> makes it stated: this
toolchain declares __stdargs void *memcpy(...), the same
__attribute__((__stkparm__)) this tree spells AMIGA_ASM_ARGS, so a later
-fno-builtin cannot flip the definition to d0/a0/a1 while the callers go on
pushing. It costs no bytes, per the clean-tree comparison above.

The gate

A call across one of those with the wrong convention gets its pointer from the
wrong register and returns a wrong number instead of trapping, so the gate ships
with this rather than beside it — and the gate is not a warning about (), it is
the language.

-mregparm=N is per class: N integer registers d0..d(N−1) and N pointer
registers a0..a(N−1), six slots at N=3, and a class that runs out spills into
what the other left. "d0/d1/d2" is the wrong model.

m68k-amigaos-gcc 16.2 compiles as C23 (__STDC_VERSION__ 202311L, no -std on
the command line), where () means (void): f(a, b) through an
unprototyped declaration is "too many arguments to function" — a hard error for
a direct call and for a call through a function pointer alike.
include/aminetxduo/asm_main.h #errors unless that holds, so a later -std, or
a compiler that drops C23, cannot reopen the hole silently.

-Wstrict-prototypes was tried for that and removed, because it is the wrong
tool twice over: it cannot fire where the hazard is (the m68k arm's C23 makes the
call an error first), and where it does fire it cannot be satisfied — a full host
build (gcc 14, C17) reports 26 diagnostics from three sites, none of them ours to
change: the NDK's own VOID (*putChProc)() in exec_protos.h, which forces the
cast at src/bsdsocket/loghook.c:163; the NDK-impersonating
src/netdev/test/shim/exec/interrupts.h; and the vendored
third_party/netxduo/nx_secure/inc/nx_secure_tls_api.h.

What remains is -Wmissing-prototypes with -Werror=implicit-function-declaration,
which close the same hole from the other side, and the gate is global: the hidden
amiga preset every drawing preset inherits sets CMAKE_PROJECT_INCLUDE to
cmake/ci-warnings.cmake, so one of this project's own drawers cannot be
configured without it. The exemptions are rules, not a file list: all of
third_party/ is vendored verbatim, tests/atf/ holds FreeBSD's netinet tests
byte for byte under their own header, and the host tests under
src/<component>/test/ forge exec.library on purpose.

-Wno-cast-function-type used to be among them and is gone as dead: main
replaced all five hook casts with union punning (4607a0c6), no shipping source
ever cast h_Entry — src/ only reads it — and a full host build with the escape
removed reports 0.

Verified on this tip

  • host tier: 434 targets, 0 errors, 0 warnings of any of the four kinds;
    ctest 493 of 493 passed.
  • host32 tier: 24 of 24 passed, duration budget PASS. This is where CI caught
    the one real break in the first push — src/crypto68k/crypto68k.h now includes
    <aminetxduo/asm_abi.h> for the pin, and fuzz_tls_crypto is the single target
    that compiles that header without include/ on its path. The target exists only
    where sizeof(void*) == 4, so no 64-bit host or cross arm could see it.
    tests/fuzz/CMakeLists.txt now lists ${CMAKE_SOURCE_DIR}/include.
  • cross tier: six arms (default/minimal/micro × regparm 0/3), all images
    link; the numbers above are from these six.
  • emulator tier: green on 3af0a0cd — both arms, every test (bracket 247
    checks, mbuf_bpf 172, IoSumDrill 776, soak 96, ipv6 85, lifecycle 20,
    KernelStop, ram_driver 38), EMURC=0, with the green arm skipped by design.
    Nothing this tip has beyond that commit can reach an image: CHANGELOG.md,
    the memcpy include proved byte-neutral above, an include directory in
    tests/fuzz that only a 32-bit host build reaches (no emulator arm builds
    that target), and main's 6a395286, two host test fixtures — a rebase CI
    asked for, since the sanitizer stage ran those two tests here for the first
    time and LeakSanitizer caught their fixtures being kept.

🤖 Generated with Claude Code

@tinic
tinic force-pushed the size/regparm branch 3 times, most recently from 9bab2f8 to 0fe65b4 Compare September 30, 2026 20:47
…eps it honest

The m68k backend can pass arguments in registers instead of on the stack, and
-mregparm=3 is now what every drawer builds with.  It is per CLASS: N integer
registers d0..d(N-1) and N pointer registers a0..a(N-1), six slots at N=3, and
a class that runs out spills into what the other left -- so "d0/d1/d2" is the
wrong model, and a callee assuming it returns a wrong number, not a crash.

Measured against the same tree built with -DAMINETXDUO_REGPARM=0, so the flag
is the only difference, on m68k-amigaos-gcc 16.2.0b with -flto and the shipping
flags, in LOADED bytes -- CODE + DATA + BSS, what LoadSeg has to find room for
-- and in file bytes.  From a CLEAN tree, which the numbers this message first
carried were not; see the note below the census.

  bsdsocket.library   loaded 353,404 -> 329,464   file 370,880 -> 346,608
                      (code 343,420 -> 319,480; DATA and BSS unchanged)
                      minimal 231,136 -> 215,244   micro 198,240 -> 184,884
  anxnet.device       loaded  43,584 ->  40,144   file  45,704 ->  42,228

so the two images a machine keeps resident come to -27,380 loaded.  Over every
image BOTH arms link: 137 images 7,917,004 -> 7,707,384 loaded (-209,620,
-2.65%) and 6,772,936 -> 6,560,528 file (-212,408, -3.14%), 123 minimal
-93,460 (-2.11%), 122 micro -87,364 (-2.05%).  Five test images grew by 4 to 20
bytes (tests/perf/chipscreen +20, tests/perf/n68kmv +16, tests/tools/AamProbe,
ResolveBreak and PtrProbe +4 each); nothing that ships did, and no image is
present in one arm only.

An uncommitted edit is not size-neutral in this tree, and that is not a
footnote: cmake/AmiNetXDuoGitStamp.cmake appends -dirty to the version hash
whenever `git status --porcelain` is non-empty, and the hash is inside every
image.  It is 0 to 8 bytes per image, and not the same 0 to 8 in both arms:
-dirty added 8 bytes to the rp0 default drawer and 4 to the rp3 one, which
moved the rp3-rp0 delta by 4.  That is the "+-4 byte spread" earlier size work
here put down to LTO relinking.  It is why the figures above differ from the
ones this message first carried, and why every number in it was re-measured
with the tree committed.  The same measurement is the proof that the two source
changes below cost nothing: two clean arms, compared byte for byte across their
CODE hunks, differ in exactly 7 bytes -- the short hash inside that string.

Nothing a program can call changed.  This is how our functions call each
other, not how they are called from outside: every LVO is entered through a
register the NDK's own headers already pin, and libgcc is compiled with the
same flag by the same compiler.  A differential over bsdsocket.library's
foreign calls reads 207 direct calls at regparm 0 and 201 at regparm 3, and
the only sites that push in neither arm are libgcc's own internal jsr.

What the compiler does not build is pinned where it is declared:

  * main(), which crt0.o and src/tools/tool_startup.S both call by pushing
    argv and then argc.  Pinned by a force-included header rather than
    -Dmain=..., because CMake writes flags into the make recipe as raw text:
    the parentheses would reach /bin/sh unquoted and the compiler would never
    run.  The macro is function-like because 16 files here take main's
    address, and it is 87 of the 130 main-bearing images that link crt0.o.
  * the assembly routines in src/net68k and src/crypto68k, which read 4(sp),
    8(sp), 12(sp): AMIGA_ASM_ARGS on the declaration, and on the function
    pointer types that store one.
  * libc.a, amiga.lib and every other archive this tree links.
  * the operating system's entry points -- hooks, interrupt servers, task
    entries, SetFunction patches, RawDoFmt putChProc -- none of which take
    arguments, or whose registers the headers pin by name.

src/net68k/n68k_memcpy_hook.c is the one place where nothing is pinned and the
convention holds by name recognition instead: the compiler calls memcpy BY NAME
with the arguments on the stack whatever the caller looks like, and a
definition of memcpy reads them off the stack for the same reason and not
because the file says so.  <string.h> makes that stated -- this toolchain
declares `__stdargs void *memcpy(...)', the same __attribute__((__stkparm__))
this tree spells AMIGA_ASM_ARGS -- so a later -fno-builtin cannot flip the
definition to d0/a0/a1 while the callers go on pushing.  It costs no bytes, per
the clean-tree comparison above.  And tests/fuzz/CMakeLists.txt puts
${CMAKE_SOURCE_DIR}/include on _fuzz_tls_crypto_inc, because
src/crypto68k/crypto68k.h now includes <aminetxduo/asm_abi.h> for that same
pin, and fuzz_tls_crypto is the one target that compiles the header without
include/ on its path.  It exists only where sizeof(void*) == 4, so neither the
64-bit host nor any cross arm could see it: CI found it, in host32 and
sanitize32, and both are green on this tip.

A call across one of those with the wrong convention gets its pointer from the
wrong register and returns a wrong number instead of trapping, so the gate
goes with this rather than beside it -- and the gate is not a warning about
`()', it is the language.  m68k-amigaos-gcc 16.2 compiles as C23
(__STDC_VERSION__ 202311L, no -std on the command line), where `()' MEANS
`(void)': f(a, b) through an unprototyped declaration is "too many arguments
to function", a hard error for a direct call and for a call through a function
pointer alike.  include/aminetxduo/asm_main.h #errors unless that holds, so a
later -std, or a compiler that drops C23, cannot reopen the hole silently.

-Wstrict-prototypes was tried for that and removed, because it is the wrong
tool twice over.  It cannot fire where the hazard is: the m68k arm's C23 makes
the call an error first.  And where it does fire it cannot be satisfied -- a
full host build (gcc 14, C17) reports 26 diagnostics from three sites, none of
them ours to change: the NDK's own `VOID (*putChProc)()' in exec_protos.h,
which forces the cast at src/bsdsocket/loghook.c:163; the NDK-impersonating
src/netdev/test/shim/exec/interrupts.h; and the vendored
third_party/netxduo/nx_secure/inc/nx_secure_tls_api.h.

What remains is -Wmissing-prototypes with -Werror=implicit-function-
declaration, which close the same hole from the other side, and the gate is
global: the hidden `amiga' preset every drawing preset inherits sets
CMAKE_PROJECT_INCLUDE to cmake/ci-warnings.cmake, so one of this project's own
drawers cannot be configured without it.  The exemptions are rules, not a file
list: all of third_party/ is vendored verbatim, tests/atf/ holds FreeBSD's
netinet tests byte for byte under their own header, and the host tests under
src/<component>/test/ forge exec.library on purpose.  -Wno-cast-function-type
used to be among them and is gone as dead: main replaced all five hook casts
with union punning (4607a0c), no shipping source ever cast h_Entry -- src/
only reads it -- and a full host build with the escape removed reports 0.

Checked on this tree, 2026-09-30: the host tier builds all 434 targets with 0
errors and 0 warnings of any of the four kinds, and ctest passes 493 of 493;
the 32-bit host tier builds and passes 24 of 24; the emulator tier is green on
3af0a0c -- both arms, every test -- and nothing this tree has that 3af0a0c
does not can reach an image: CHANGELOG.md, the include proved byte-neutral
above, an include directory in tests/fuzz that only a 32-bit host build uses,
and the two host test files of 6a39528, which this is rebased over.  That
rebase is CI's doing: the sanitizer stage ran those two tests for the first
time on this branch and LeakSanitizer caught their fixtures being kept, which
main had already fixed one commit earlier.

Build consequence, and it is the one that bites: an image must come from one
configuration.  CMake does not treat the flags file as a prerequisite of the
object, so a build/ directory made before this change has to have its objects
rebuilt, not relinked -- a relink would mix both conventions in one image.

Co-Authored-By: Claude Code <noreply@anthropic.com>
@tinic
tinic merged commit b6d86db into main Sep 30, 2026
42 of 43 checks passed
@tinic
tinic deleted the size/regparm branch September 30, 2026 21:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant