Skip to content

Add a self-hosting AArch64 backend - #334

Merged
jserv merged 6 commits into
masterfrom
aarch64
Sep 8, 2026
Merged

Add a self-hosting AArch64 backend#334
jserv merged 6 commits into
masterfrom
aarch64

Conversation

@jserv

@jserv jserv commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator

Adds AArch64 Linux as a fourth target, generating ELF64 images under AAPCS64 in both static and dynamically linked form, with the stage-1 and stage-2 compilers byte identical. The backend maps the allocator's registers onto x0-x7 and x20-x22, keeps the synthetic global frame in x19, and reaches libc through an eager-bound PLT of ADRP/ADD/LDR/BR entries. Images separate their load segments by 64 KiB so they stay loadable under any of the granules AArch64 Linux may use.

A 64-bit pointer did not survive two assumptions the 32-bit targets had shared. The register allocator now records which operand of an arithmetic instruction is an address, because pointer arithmetic has to widen the int index beside it, and the global-frame slot reserves a full pointer rather than the historic 32-bit word. Both are inert on arm, riscv and x64. Separately, the diagnostic paths now flush every stream before the compiler exits: a dynamically linked build resolves fflush through the PLT to the host libc, but lib/c.h defines stdout as the plain descriptor 1, so the old code handed glibc address 1 to dereference.

The shared suite gains cases for narrow signed values and for pointer truthiness, neither of which it covered before; a pointer has to be tested over its whole value, which only starts to matter once a target holds one in a register wider than an int. README names the new target alongside the other three, and the CI matrix runs it in both link modes.

Verified locally on x86-64: make check passes for arm, riscv, x64 and arm64 statically linked, and for arm64 with DYNLINK=1, each including the stage-1/stage-2 byte-identity check. The AAPCS64 ABI suite reports 12/12 at stage 0 and stage 2 in both link modes.

Two things are deliberately out. QEMU-user on an x86-64 host always presents 4 KiB pages, so neither CI nor the local runs can observe the 64 KiB segment separation actually mattering; that needs real hardware with a larger granule. And writing the array form of the narrow-signed test exposed a defect in the Arm backend, where char elements load zero-extended, so that case stays in the AArch64 gate rather than breaking the shared suite. Fixing the Arm backend belongs in its own change.


Summary by cubic

Adds a self-hosting AArch64 Linux backend that emits static and eager-bound dynamic ELF64 binaries under AAPCS64. This expands support beyond ARMv7, RV32, and x86-64 while keeping stage-1 and stage-2 output byte-identical.

New Features

  • Maps compiler registers to AAPCS64 registers, preserves the global frame in x19, and emits AArch64 PLT entries.
  • Separates load segments by 64 KiB so binaries work with 4 KiB, 16 KiB, and 64 KiB AArch64 Linux translation granules.
  • Adds AArch64 ABI tests and runs both link modes across CI; Linux Arm64 runners execute the target natively, while x86-64 dynamic runs need gcc-aarch64-linux-gnu, qemu-aarch64, and a matching sysroot.

Bug Fixes

  • Tracks pointer and array operands so pointer arithmetic sign-extends integer indices and pointer conditions use all 64 bits.
  • Stores global-frame pointers and GOT entries at the target pointer width.
  • Flushes all streams before compiler diagnostics exit and marks generated output files executable.
  • Keeps the narrow signed array test AArch64-only because the existing Arm backend zero-extends char loads.

Written for commit 83672e1. Summary will update on new commits.

Review in cubic

cubic-dev-ai[bot]

This comment was marked as resolved.

Comment thread README.md
actual sysroot.
```shell
$ out/shecc --dynlink -o fib tests/fib.c
$ chmod +x fib

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think chmod +x fib should be preserved because the generated ELF file is not executable by default. Therefore, chmod is still necessary.

Otherwise, the following error may occur:

$ qemu-arm -L /usr/arm-linux-gnueabihf/ fib
Error while loading /home/drxiao/workspace/sysprog/shecc/fib: Exec format error


# An Arm64 runner executes the Arm output natively, so this covers the paths
# that the emulator would otherwise stand in for.
host-arm:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the host-arm job could also be extended to validate the AArch64 backend (through native execution).

cubic-dev-ai[bot]

This comment was marked as resolved.

A dynamically linked build resolves fflush through the PLT to the host
libc, but lib/c.h defines stdout as the plain file descriptor 1, which
is not a FILE pointer. Handing that to glibc dereferences address 1.

That build is also the only one whose stdio buffers, so it is the one
that most needs the flush: without it, a diagnostic written to a pipe is
lost when the compiler dies. NULL means every stream to the host libc
and is ignored by the unbuffered embedded one, so it suits both.

Only fatal, usage_error and error_at change here. The eleven other sites
in this file share the hazard and deserve their own look.
Generate ELF64 images for AArch64 Linux under AAPCS64, static and
dynamically linked, with the stage-1 and stage-2 compilers byte
identical. The backend maps the allocator's registers onto x0-x7 and
x20-x22, keeps the synthetic global frame in x19, and reaches libc
through an eager-bound PLT of ADRP/ADD/LDR/BR entries.

Two shared assumptions did not survive a 64-bit pointer. The register
allocator now records which operands of an instruction are addresses,
because pointer arithmetic has to widen the int index beside the address
and a comparison has to read the address whole; an array counts, since
what reaches the instruction is its decayed base. The global-frame slot
reserves a full pointer rather than the historic 32-bit word. Both are
inert on the 32-bit targets.

The output mode is set explicitly once the file is closed, since it
depends on which libc opened it rather than on how the output links: the
embedded lib/c.c passes 0775 to openat, while glibc's fopen yields 0666.
Setting it also needs fchmodat on the targets whose asm-generic table
has no chmod at all, where 90 is capget.

Images separate their load segments by 64 KiB so they stay loadable
under any of the granules AArch64 Linux may use. QEMU-user on an x86-64
host always presents 4 KiB pages and cannot observe this.
Neither had a case in the shared suite. A char or short must stay
negative through promotion and through a store and reload, which an LP64
backend holding it in a 64-bit register can get wrong in either
direction.

A pointer must be tested for truth over its whole value, and that starts
to matter once a target holds one in a register wider than an int. The
case which separates a full-width test from a low-word one cannot be
forced, since pinning a pointer whose low word is zero would need a
64-bit literal, so the test covers agreement between the paths instead:
the backend picks a different width for a branch, for a logical negation
and for a comparison, and each is reached with a null and a non-null
pointer.

The array form of the first belongs here too, but it fails on the Arm
backend, whose char elements load zero-extended, so it stays in the
AArch64 gate until that is fixed.
Name the new target where the other three are listed, and say what its
images require of a loader: they separate the load segments by 64 KiB so
they run under any AArch64 page granule.

Both examples also lose their chmod, since the compiler now marks its
own output executable, and the dynamic one gains the Arm invocation it
had lost alongside the AArch64 one.
Run both link modes for the new target, and install the AArch64 sysroot
the dynamic runs need under QEMU.

This cannot observe everything the target needs: QEMU-user on an x86-64
host always presents 4 KiB pages, so a load-segment separation too small
for a larger granule still passes here.
An Arm64 Linux host runs this target's output itself, so asking QEMU to
stand in for it hides the one thing the emulator cannot show: QEMU-user
maps the image on its own and, on an x86-64 host, always presents 4 KiB
pages, leaving the 64 KiB separation between the load segments
unexercised. The host answers this alone, without the fastfetch probe
the Arm target needs to tell apart boards that can run its 32-bit
output. It has to be the right kernel as well as the right
architecture, since what comes out is an AArch64 Linux ELF.

The runner job grows an architecture dimension, and the assertion that
the output really did run natively now covers both targets rather than
letting an emulated job pass as a native one. With the emulator gone the
dynamic build resolves its interpreter and libc from the running system,
so no cross toolchain is wanted on that host.
@jserv
jserv merged commit 4d3b4c0 into master Sep 8, 2026
40 checks passed
@jserv
jserv deleted the aarch64 branch September 8, 2026 17:24
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.

2 participants