SFEP-0056
aarch64-Linux Target Support (Raspberry Pi Install + On-Device Self-Host)
- Status
- Accepted
- Type
- runtime
- Created
- Updated
- Author
- agent:compiler-architect
- Tracking
- SFN-471,SFN-472,SFN-473,SFN-474,SFN-475,SFN-476
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. Today that on-ramp is 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 builds 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 exists anywhere.
Two concrete on-device correctness hazards make “it’s Linux, it’ll just work” false:
jmp_bufbuffer overrun.runtime/sfn/exception.sfnmalloc’s a fixed 256-byte buffer per exception frame, sized for x86_64 (200) / macOS-arm64 (192). glibc aarch64jmp_bufis larger (__jmpbuf[22]longs + saved sigset ≈ 312 bytes) →setjmpoverruns the buffer. Hard blocker for anytry/throwon a native aarch64 binary.struct statst_modeoffset.lowering_debug_state.sfn’sstat_st_mode_offset_value()keys 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 host-detection locators (_host_os()) have no arch dimension — that is the seam this SFEP adds.
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): _host_os() in
llvm/lowering_debug_state.sfn (mirrored by _build_host_os() in
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. We add the arch
dimension the exact same way.
Introduce _host_arch() beside _host_os(), 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):
// llvm/lowering_debug_state.sfn — mirrors _host_os() (SFN-49).// SAILFIN_TARGET_ARCH override wins first (cross-emit + e2e hook), else a// non-shelling loader-path probe. "aarch64" | "x86_64" (uname -m style).fn _host_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 byte-for-byte the
same mechanism _host_os() 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: re-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 | Re-key on (os, arch) via _host_arch(). |
jmp_buf frame buffer size |
runtime/sfn/exception.sfn (256) |
200 fits | ~312 overruns | Over-allocate the fixed buffer to 512 (covers all three targets; heap-malloc’d, so oversizing is harmless and needs no arch seam). |
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: native aarch64 self-host needs an aarch64 seed, but the pinned seed is x86_64 (or macOS arm64) and cannot execute natively on an arm64 runner. The Windows epic (SFEP-0021 §4.3) solved the analogous wall with a mingw cross-emit vehicle. Here the cleaner vehicle is qemu-user emulation on a native arm64 runner, because clang on the arm runner is already native aarch64:
Bootstrap sequence (all on ubuntu-24.04-arm):
- Stage 0 — seed under emulation. Register x86_64 binfmt (
qemu-user) and run the pinned x86_64 seed underqemu-x86_64. It orchestrates a build; theclang/linker it spawns are native aarch64. - Stage 1 — arch-aware compiler A (x86_64/emulated). The seed builds the new compiler from source (which carries §3.2/§3.3). Compiler A is an x86_64 binary that runs under qemu but is arch-aware.
- Stage 2 — first native aarch64 compiler B (pass-1). Compiler A (under
qemu) builds the compiler from source targeting the native host. Because A is
arch-aware and its
_host_arch()probes the real aarch64 host filesystem, it bakesst_mode = 16and the runtime source’s 512-bytejmp_buf— so pass-1 is already arch-correct, emitted by an arch-aware compiler. clang compiles it to native aarch64. - Stage 3 — native self-host fixed point. Pass-1 (native) builds pass-2
(seedcheck); assert
pass-1 == pass-2andhello-worldruns. This is the self-host proof. - Stage 4 — publish + pin. The release workflow runs Stages 0–3 on
ubuntu-24.04-armand publishes a nativesailfin_<ver>_linux_arm64.tar.gzseed. The cadence auto-pin bumpsbootstrap.toml [seed].version; from that release onward a Pimake fetch-seeddownloads a native aarch64 seed and on-device self-host no longer needs qemu.
Key insight (why this avoids an inter-issue seed cut): the bootstrap builds
the new compiler from source on the old seed at Stage 1, 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
(Stage 4) — 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 SAILFIN_TARGET_ARCH override (from §3.2) also enables an alternative
cross-emit-from-x86_64 bootstrap (build the new compiler on an x86_64 runner,
cross-emit with SAILFIN_TARGET_ARCH=aarch64 + clang -target aarch64-linux-gnu + an aarch64 sysroot). We prefer the qemu path as primary —
it needs no cross sysroot (clang is native on the arm runner) — and keep the
cross-emit path documented as the mechanism a future first-class sfn build --target=aarch64-linux-gnu generalizes to.
3.5 Target tier (resolves deliverable (c))
SFEP-0037 §3.10 defines Tier 1 = Linux x86_64 (CI-blocking), Tier 2 = macOS
arm64 (builds+tests in CI), Tier 3 = best-effort (Windows enters here).
aarch64-Linux enters as Tier 3: its first CI leg is advisory
(continue-on-error, non-blocking) so a transient emulation flake or an
aarch64-only miscompile does not red-gate main. It earns promotion to Tier
2 by written criteria — a full green cycle of build + suite + triple-pass
self-host on ubuntu-24.04-arm — at which point the leg flips to blocking. The
promotion is a discrete gated event (mirroring Windows’ Tier-3→2 path), so it is
its own milestone (§5, Issue 6), not folded into stand-up. Note: SFEP-0037
§3.10’s docs/conventions/target-tiers.md has not landed yet; the promotion
issue creates it (or appends the aarch64 row if it has by then).
3.6 The direct-ld.lld fast path (resolves deliverable (e))
build/direct_link.sfn’s resolve_direct_ld_lld hard-gates on arch == x86_64 and hardcodes /usr/lib/x86_64-linux-gnu multiarch dirs +
/lib64/ld-linux-x86-64.so.2. On aarch64 it returns ok=false and the driver
falls back to the clang-driver link cleanly — so the fast path is a
performance optimization, not a correctness blocker. It is therefore
deferred, in-scope-but-optional (§5, Issue 5, Performance): teach the arch
gate + aarch64-linux-gnu multiarch dirs + /lib/ld-linux-aarch64.so.1 + an
aarch64 _gcc_crt_dir probe, reusing _host_arch(). It can land any time after
the seam (Issue 1) and does not gate the seed, the self-host, or the release.
4. Effect & capability impact
None. Effects and capability enforcement are arch-invariant; this epic is a
backend/runtime-platform port. _host_arch() and the layout resolvers are
ordinary ![io] filesystem probes, consistent with the existing _host_os()
locators. 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:
llvm/lowering_debug_state.sfn— adds_host_arch()+SAILFIN_TARGET_ARCHand re-keysstat_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 grows 256 → 512. Plain Sailfin source; the old x86_64 seed compiling the new source produces a larger, still-correct allocation. No compiler-baked immediate involved.build/target.sfn,build/direct_link.sfn,build/clang_argv.sfn— the native aarch64 build uses clang’s host-default triple, so no-targetwork is needed for the native path (unlike Windows). Only the optional direct-link fast path (Issue 5) grows an aarch64 arch dimension.
The self-host invariant is preserved by construction: the aarch64 bring-up
builds the new compiler from source via the qemu bootstrap (§3.4), never
requiring the fixes to pre-exist in the pinned seed. Every milestone that could
touch Tier-1 code paths (Issue 1) is 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 buffer is heap-malloc’d per frame, so over-allocating to 512 is harmless, needs no arch seam, and removes an entire arch branch from a hot exception path. - Cross-emit-from-x86_64 as the primary bootstrap. Rejected as primary
(kept as documented alternative): it needs an aarch64 cross sysroot + CRT on
the x86_64 runner, whereas the qemu path reuses the arm runner’s native clang
with zero sysroot plumbing. Cross-emit remains the shape a future
sfn build --targetgeneralizes. - 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 —
_host_arch()and resolvers are ordinary![io]fns;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 — aarch64 triple-pass fixed point (Issue 2/3)
-
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(_host_os,stat_st_mode_offset_value, errno/clock/nproc locators),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.