SFEP-0056
aarch64-Linux Target Support (Raspberry Pi Install + On-Device Self-Host)
- Status
- Implemented
- Type
- runtime
- Created
- Updated
- Author
- agent:compiler-architect
- Tracking
- SFN-471,SFN-472,SFN-473,SFN-474,SFN-475,SFN-476,SFN-579,SFN-580,SFN-581,SFN-644,SFN-798,SFN-799,SFN-826
SFEP-0056 — aarch64-Linux Target Support (Raspberry Pi Install + On-Device Self-Host)
Design deliverable for the aarch64-Linux target support epic (Build & Toolchain initiative, sibling of Native Windows Self-Host / SFEP-0021). No compiler code is written here — this enumerates the port surface, the arch-detection seam, the bootstrap path, and the ordered, session-sized milestones.
1. Summary
Make the Sailfin compiler build, install, and self-host natively on 64-bit
Raspberry Pi OS (aarch64/arm64 Linux). The end state: a user runs the
curl … | sh installer on a Pi, gets a working sfn, and make compile
self-hosts on-device from a fetched native aarch64 seed. Because the runtime is
already pure Sailfin and the emitted IR is arch-neutral (macOS arm64 already
ships, proving the AArch64 ABI/codegen path), the work is not a codegen
port. It is: (a) a small host-arch detection seam feeding the two
arch-sensitive layout constants the compiler bakes/allocates, (b) an aarch64
bootstrap (the pinned x86_64 seed cannot run natively on arm64), and (c) **CI
- release + installer** plumbing to build, gate, and publish an aarch64 seed asset. Scope is aarch64-Linux only; 32-bit Pi OS (armv7/armhf) is an explicit non-goal.
2. Motivation
The Raspberry Pi is the canonical low-cost aarch64-Linux target and a natural edge/IoT/hobbyist on-ramp for a capability-secure systems language. When this proposal was written, that on-ramp was broken and, worse, falsely advertised:
install.shalready mapsaarch64|arm64 → arm64and constructssailfin_<ver>_linux_arm64.tar.gz, then dies because no such asset is ever published.make fetch-seedroutes throughinstall.sh, so a Pi cannot even fetch a seed.site/.../getting-started/install.mdalready lists “Linux | arm64” as supported — a support claim with no backing asset.dl.astrolists only the three real assets (linux-x86_64, macos-arm64, windows-x86_64).- The client-side arch handling is already correct (
package.sfnmaps Linux aarch64 →linux-arm64;toolchain.sfnmaps aarch64/arm64 → arm64). Only the producer side (CI matrices, release legs, the two arch-sensitive runtime constants, and a bootstrap) is missing.
CI then built exactly three triples from a 2-entry matrix (macos-26 → macos-arm64, ubuntu-24.04 → linux-x86_64, plus a windows cross-build on the
linux leg), mirrored across release-tag.yml, release-branches.yml, ci.yml,
nightly-selfhost.yml, seed-test-bin.yml, benchmark.yml, perf-history.yml,
and build-quality.yml. No aarch64-Linux leg existed at proposal time.
Two concrete on-device correctness hazards make “it’s Linux, it’ll just work” false:
jmp_bufbuffer overrun. (Resolved — both halves now reserve 512.) As written,runtime/sfn/exception.sfnmalloc’d a fixed 256-byte buffer per exception frame, sized for x86_64 (200) / macOS-arm64 (192). The compiler also emits its own stackjmp_bufallocas — one pertryand one in each@mainwrapper — inllvm/lowering/, and it is those stack allocas, not the heap buffer, that compiler-emittedtryactually executes. glibc aarch64jmp_bufis larger (__jmpbuf[22]longs + saved sigset ≈ 312 bytes) →setjmpoverruns the buffer. Hard blocker for anytry/throwon a native aarch64 binary. SFN-471 raised the heap buffer to 512; SFN-644 raised the three stack allocas, which that first fix left at 256 because the heap path is dead for compiler-emittedtry.struct statst_modeoffset.lowering_debug_state.sfn’sstat_st_mode_offset_value()originally keyed the offset on OS only (Linux → 24). glibc aarch64 reordersstruct statsost_modeis at offset 16, not 24, so thefs_get_permssentinel reads the wrong field. The explicit LLVM provider context therefore needs an architecture dimension alongside its resolved target OS.
3. Design
3.1 Scope
In: aarch64-Linux (ubuntu-24.04-arm in CI; 64-bit Raspberry Pi OS as the
user target) — native build, native self-host, published seed asset, installer
support, advisory→blocking CI tier. Out (explicit non-goal):
32-bit Pi OS (armv7/armhf), rejected at install.sh arch detection — it needs
a whole 32-bit ABI/long-width/pointer story and its own epic. This SFEP does
not touch macOS arm64 or Windows.
3.2 The arch-detection seam (resolves deliverable (a))
The compiler already de-shelled host-OS detection (SFN-49):
build/target.sfn probes filesystem markers instead of shelling uname -s, so
a single binary bakes the correct platform legs on whatever host it runs on, and
a SAILFIN_TARGET_OS override wins first for cross-emit. The driver snapshots
that result into the explicit LLVM provider context. We add the arch dimension
to that same snapshot.
Resolve the provider’s target architecture in
build/llvm_provider_context.sfn, using a filesystem-marker probe — the
aarch64 dynamic loader is at a well-known path — with x86_64 as the dominant
default (never wrong on the existing Tier-1 host):
// build/llvm_provider_context.sfn — driver-owned context resolution.// SAILFIN_TARGET_ARCH override wins first (cross-emit + e2e hook), else a// non-shelling loader-path probe. "aarch64" | "x86_64" (uname -m style).fn _resolve_llvm_target_arch() -> string ![io] { let override = _get_env_cmd("SAILFIN_TARGET_ARCH"); if override.length > 0 { return override; } if fs.exists("/lib/ld-linux-aarch64.so.1") { return "aarch64"; } return "x86_64";}Why a loader-path probe (not a new runtime primitive): it is the same
filesystem-marker mechanism host-OS resolution and
rlimit.sfn/sizes_linux.sfn already use, needs
no new FFI (uname(2) binding), and stays correct under emulation — a
qemu-user x86_64 process on an aarch64 host sees the aarch64 host filesystem and
probes aarch64, which is exactly what the bootstrap needs (§3.4). The
SAILFIN_TARGET_ARCH override parallels SAILFIN_TARGET_OS: it is the
cross-emit hook and — critically — the e2e test hook that lets x86_64 CI
verify the aarch64 leg without an arm runner.
The seam has exactly one compiler-baked consumer: key
stat_st_mode_offset_value() on the (os, arch) pair — Darwin → 4,
Linux + aarch64 → 16, else 24. Every other target-baked immediate is
arch-invariant on glibc and needs no change (verified §3.3).
3.3 The full arch-sensitive surface (resolves deliverables (d))
An audit of every target-divergent value the compiler bakes or the runtime allocates:
| Value | Source | x86_64-Linux | aarch64-Linux | Action |
|---|---|---|---|---|
st_mode offset in struct stat |
lowering_debug_state.sfn stat_st_mode_offset_value |
24 | 16 | Key on the provider context’s (os, arch). |
jmp_buf frame buffer size |
runtime/sfn/exception.sfn (was 256, now 512); llvm/lowering/instructions_try.sfn, llvm/lowering/emission.sfn, and llvm/lowering/lowering_core/test_harness.sfn stack allocas (was 256, now 512) |
200 fits | ~312 overruns | Done. Over-allocated to 512 on both the heap buffer (SFN-471) and the three stack allocas (SFN-644) — covers all three targets, needs no arch seam; the stack allocas also carry align 16 for MSVC’s _JUMP_BUFFER (SFN-549). |
errno locator symbol |
errno_locator_symbol |
__errno_location |
__errno_location (glibc-common) |
none |
CLOCK_MONOTONIC id |
clock_monotonic_id_value |
1 | 1 (glibc-common) | none |
_SC_NPROCESSORS_ONLN |
sc_nprocessors_onln_value |
84 | 84 (glibc-defined) | none |
struct stat buffer size |
stat_buf_size_value (160) |
144 | 128 | none (160 over-allocates both) |
dirent.d_name offset |
sizes_linux.sfn |
— | already aarch64-safe | none |
pthread_t/mutex layout |
pthread_layout.sfn |
— | already aarch64-safe | none |
clang -target triple |
build/target.sfn target_clang_triple |
“” (host default) | “” (host default) | none — clang’s native aarch64 default triple (aarch64-unknown-linux-gnu) is correct for a native build |
Net runtime/compiler surface: exactly one baked immediate (st_mode) needs
the arch seam, and one runtime constant (jmp_buf) needs an over-allocation
bump. Both are additive: on x86_64 the arch branch returns 24 (identical to
today), and 512 ≥ 256 is a strictly-larger allocation — so x86_64 self-host is
byte-identical for st_mode and behaviorally unchanged for exceptions. macOS
arm64 is gated by Darwin and is untouched.
3.4 The aarch64 bootstrap (resolves deliverable (b))
Chicken-and-egg: the first native aarch64 self-host needed an aarch64 compiler,
but the pinned seed was x86_64 and could not execute natively on the ARM runner.
SFN-472 crossed that first wall by running the x86_64 seed under qemu-user on
ubuntu-24.04-arm, building an arch-aware compiler A, emitting native pass-1,
then using pass-1 to build pass-2 and asserting their fixed point. That path is
preserved for rebuilding legacy tags whose pinned release predates ARM assets.
The shipped steady state has two optimized consumers of that proof:
- Source PR and merge-queue CI.
build-compiler-linuxfirst builds an arch-aware x86_64 compiler.build-aarch64-cross-vehicleuses an aarch64 sysroot andSAILFIN_TARGET_ARCH=aarch64to cross-emit native pass-1 on the x86_64 runner.build-compiler-aarch64-linuxruns pass-1 natively, builds pass-2, asserts the pass-1/pass-2 fixed point, and runs the smoke probe. This replaced a full qemu compiler build after live CI showed every frontend worker running under TCG without completing compiler A in the useful budget. - Release builds and on-device self-host. Once a release contains both the
native and installer ARM64 payloads, the ARM release leg fetches that native
seed and follows the ordinary
make rebuildpath. v0.9.3 carries the pair, and SFN-799 requires it before release publication or cadence seed pinning.scripts/select-aarch64-seed-mode.shselects qemu only when rebuilding a legacy tag whose pinned release contains neither ARM payload.
Key insight (why this avoids an inter-issue seed cut): the first bring-up
builds the new compiler from source on the old seed, so the §3.3 fixes do
not need to pre-exist in the pinned seed for the aarch64 bring-up to consume
them. They land in a normal PR (x86_64 self-host stays green, §3.3). The only
seed cut is the ordinary release event that first produces the arm64 asset
— and that release is built from main, which already contains the fixes.
There is no separate “pin an aarch64 cross seed” step the way Windows needed
one. The current source-CI cross vehicle and the native-seed release path are
later performance/steady-state refinements; neither changes that original
delivery invariant.
3.5 Target tier (resolves deliverable (c))
SFEP-0037 §3.10 defines Tier 1 = Linux x86_64 (CI-blocking), Tier 2 = supported
native build and test targets, and Tier 3 = best effort. aarch64-Linux entered
as Tier 3: its result was visible but excluded from required-ci while the
native path accumulated evidence.
SFN-476 promoted the target to Tier 2 after every written gate completed:
SFN-581 proved the published installer, native seed, on-device self-host, and a
complete green post-pin cycle; SFN-826 sharded and cached the source suite and
showed the ARM aggregate finishing before the established merge-critical
matrix; SFN-799 made both ARM64 payloads mandatory for release publication and
seed pinning; and v0.9.3 published the complete pair. Source pull requests and
merge queues now feed the cross vehicle, native fixed point and smoke probe,
shard-cover, and all ARM shards through aarch64-linux-result into
required-ci. The scheduled workflow separately fails on any cross-emit,
native fixed-point/probe, or cold --no-test-cache suite failure. The durable
policy and linked promotion evidence live in
docs/conventions/target-tiers.md.
3.6 The direct-ld.lld fast path (resolves deliverable (e))
SFN-473 shipped the aarch64 direct-ld.lld path. resolve_direct_ld_lld
accepts x86_64 or aarch64, reuses the provider target-architecture seam, and
selects the matching dynamic loader, emulation, multiarch library directories,
and GCC CRT directory. A missing target linker or CRT still returns ok=false
and falls back cleanly to the clang-driver link, preserving the original
correctness boundary while removing that fallback from configured ARM hosts.
4. Effect & capability impact
None. Effects and capability enforcement are arch-invariant; this epic is a
backend/runtime-platform port. The driver-side target-architecture resolver is
an ordinary ![io] filesystem probe; the LLVM provider consumes only the pure
resolved context. The three pillars (effects, capabilities, concurrency) are
untouched.
5. Self-hosting impact
Every change is additive and arch-gated, so Linux-x86_64 and macOS-arm64 self-host stay green at every step:
build/llvm_provider_context.sfnresolvesSAILFIN_TARGET_ARCHand passes it into LLVM;llvm/lowering_debug_state.sfnkeysstat_st_mode_offset_valueon(os, arch). The x86_64 branch returns 24 as before → byte-identical emitted IR on Tier 1.runtime/sfn/exception.sfn— thejmp_bufbuffer constant grew 256 → 512 (shipped, SFN-471). Plain Sailfin source; the old x86_64 seed compiling the new source produces a larger, still-correct allocation. No compiler-baked immediate involved.llvm/lowering/instructions_try.sfn,llvm/lowering/emission.sfn, andllvm/lowering/lowering_core/test_harness.sfn— the same 256 → 512 widening for the three compiler-emitted stackjmp_bufallocas (shipped, SFN-644). This is compiler source, so widening it changes emitted IR — but the change is a pure alloca widening with no compiler-baked immediate on the consuming side either, so the old seed compiling this new compiler source is fine and the self-host invariant still holds by construction.build/target.sfn,build/direct_link.sfn,build/clang_argv.sfn— native aarch64 builds use clang’s host-default triple. SFN-473 added the aarch64 dynamic loader, emulation, multiarch directories, and GCC CRT probe to the direct-ld.lldresolver; unsupported or incomplete hosts retain the clang fallback.
The self-host invariant is preserved by construction: the first bring-up built
the new compiler from source via qemu; source CI now cross-emits native pass-1
from an arch-aware compiler and proves pass-1/pass-2 identity on ARM; release
builds use the published native seed, with qemu retained only for legacy tags.
None of these paths requires the aarch fixes to pre-exist in the original
x86_64 seed. Every milestone that could touch Tier-1 code paths was gated by
make check on Linux x86_64 before merge.
6. Alternatives considered
- Compile-time arch constants / a
cfg-style split (per SFEP-0025 §2.9 Q7’s per-target.sfnfiles). Rejected for now: Sailfin has no conditional compilation, and a runtime filesystem-marker probe (matching the shipped host-OS seam) keeps a single binary correct on every host with no new machinery — exactly the trade SFN-49 already made for host-OS. - A
uname(2)/GetNativeSystemInforuntime primitive for arch. Rejected as overkill: it adds an FFI binding for a value a loader-path probe already yields, and the syscall form does not survive emulation-vs-native reasoning as cleanly as a host-filesystem probe. - Arch-aware
jmp_bufsizing (a per-arch constant) instead of a flat over-allocation. Rejected: the heap buffer is malloc’d per frame, so over-allocating it to 512 is harmless, but the compiler-emitted stack allocas (SFN-644) put those same 512 bytes on everytry’s frame — a frame-size cost, not a free one. Still the right trade: it needs no arch seam and removes an entire arch branch from a hot exception path. - Cross-emit-from-x86_64 as the only bootstrap. Rejected for first bring-up because it required an aarch64 sysroot + CRT before the native path had been proven. After SFN-472 established that proof, CI adopted cross-emission as the faster source-validation vehicle. Release and on-device self-host instead use the published native seed, while qemu remains the compatibility fallback for pre-ARM release tags.
- Enter directly as Tier 2 (blocking CI). Rejected: an aarch64-only
miscompile or emulation flake would red-gate
mainbefore the leg has proven stable. Tier 3 → earned Tier 2 mirrors the Windows precedent and SFEP-0037’s own policy. - Include armv7/armhf (32-bit). Rejected as a non-goal: 32-bit needs a
distinct pointer-width/
long/ABI story; it is a separate epic.
7. Stage1 readiness mapping
This epic ports an existing pipeline to a new target rather than adding a language construct; the checklist maps to the port surface:
- Parses — n/a (no new syntax)
- Type-checks / effect-checks — driver target resolvers are ordinary
![io]fns and provider decisions are pure;sfn checkclean - Emits valid
.sfn-asm— n/a (arch-neutral IR unchanged) - Lowers to LLVM IR —
st_modeoffset re-keyed; verified by a forced-arch snapshot (§8) - Regression coverage — arch-seam snapshot (x86_64 CI) + aarch64 self-host +
suite on
ubuntu-24.04-arm - Self-hosts — native pass-1/pass-2 fixed point plus post-pin on-device self-host evidence (SFN-472, SFN-581)
-
sfn fmt --checkclean — on every touched.sfn - Documented —
docs/status.mdtier row,install.mdmade truthful,dl.astroarm64 row,target-tiers.md
8. Test plan
- Arch seam (x86_64 CI, no arm runner needed): a
compiler/tests/e2e*_test.sfnthat emits a fixture exercising thefs_get_permssentinel underSAILFIN_TARGET_ARCH=aarch64and asserts the emitted.llbakes thest_modeoffset 16 (and, unset, still bakes 24). Proves the arch dimension mechanically on Tier-1 CI. jmp_bufregression: existing exception/try-throw e2e coverage runs on the aarch64 leg; the 512-byte buffer is exercised by the first nativetry/throw. A struct-return + try/throw fixture guards the AArch64 aggregate-return legalizer hazard (docs/conventions/runtime-helpers.md) that no aarch64 CI has caught before — a value round-trip equality assert.- Native bootstrap + self-host (aarch64 leg):
--versionandcheck examples/basics/hello-world.sfnexit 0; triple-passpass-1 == pass-2fixed point; the full suite green underubuntu-24.04-arm. - Installer/seed: on the arm64 asset’s first release,
make fetch-seedon an aarch64 host resolves and unpackssailfin_<ver>_linux_arm64.tar.gz; the fetched native seed self-hosts on-device.
9. References
- SFEP-0021 (Native Windows Self-Host) — the structural template: host-detection de-shell (SFN-49), target conditioning (SFN-52), first native build (SFN-53), self-host fixed point (SFN-54), CI leg (SFN-55), seed publish + release + fetch-seed (SFN-57), bootstrap-vehicle reasoning (§4.3).
- SFEP-0037 §3.10 (target tier policy) — Tier 1/2/3 definitions;
docs/conventions/target-tiers.md. - SFEP-0025 §2.9 Q7 / §3.8 (native runtime architecture) — platform-conditional
compilation deferral; the per-target-
.sfnalternative this SFEP declines. - SFEP-0026 /
.claude/rules/seed-dependency.md— bundle-vs-split + seed-cut discipline (§3.4). - Code:
install.sh(arch detection, already arm64-ready),Makefilefetch-seed,runtime/sfn/exception.sfn(jmp_buf),compiler/src/llvm/lowering_debug_state.sfn(stat_st_mode_offset_value, errno/clock/nproc decisions),compiler/src/build/llvm_provider_context.sfn,compiler/src/build/target.sfn/direct_link.sfn/clang_argv.sfn,compiler/src/cli/commands/package.sfnandtoolchain.sfn(client arch maps),.github/workflows/release-tag.yml/release-branches.yml/ci.yml,site/.../getting-started/install.md,site/src/pages/dl.astro.