Skip to content

Targets, ABIs & support tiers

This page explains how Fels decides what machine it compiles for, what it links against, and how well tested each target is. It is the architecture for cross-compiling beyond the original Windows-only backend.

A target has three orthogonal axes:

arch × os × env (ABI / libc)
┌───────────┐ ┌────────┐ ┌──────────────────────┐
│ x86_64 │ │ windows│ │ msvc (Win runtime) │
│ aarch64 │ │ linux │ │ none (freestanding)│
│ riscv64 │ │ (macos)│ │ musl (static libc) │
│ (wasm32) │ │ (wasi) │ │ gnu (glibc, dyn) │
└───────────┘ └────────┘ └──────────────────────┘

You write a target as an LLVM-style triple, such as x86_64-linux-gnu, x86_64-linux-none or x86_64-windows-msvc. The CLI also accepts shorthands. --target linux means x86_64-linux-none, the static, freestanding default, and --target linux-gnu means x86_64-linux-gnu. Two targets have no operating system under them: --target uefi makes a UEFI application and --target freestanding (or none) an ELF for a kernel or a boot loader (see §7). These two are also built for 64-bit ARM: --target aarch64-uefi and --target aarch64-freestanding (arm64- works too). Every other build is x86_64: --target refuses an aarch64 Linux or Windows triple.

The front-end is target-independent. The lexer, parser, sema, IR, comptime VM and macros describe computation, not a platform, so every language feature works on every target. The tiers (below) are not about language features. They are about how complete the codegen, the runtime and the stdlib are.

2. Where platform differences live: the runtime ABI seam

Section titled “2. Where platform differences live: the runtime ABI seam”

The architectural decision is this: all OS-specific behaviour goes through a small, fixed set of os_* primitives provided by the platform runtime. The standard library is written against that seam and stays OS-agnostic. It uses no #if TARGET.os == …, because the runtime is selected per target, so the seam is the only place where the OS appears in it. (A program can still choose its own declarations by platform with a top-level #if.)

std.io std.heap std.time std.fs std.net … (OS-agnostic)
│ │ │ │ │
└─────────┴────┬─────┴─────────┴────────┘
▼
os_* runtime primitives ← the seam
write/alloc/clock/open/socket/…
┌───────────┼───────────┐
▼ ▼ ▼
windows.fels linux.fels (linux_gnu) ← one impl per (os[,arch,env])
kernel32 syscalls libc

These seam primitives are in place today:

Surface Seam Windows Linux (freestanding)
I/O write_stdout/write_stderr WriteFile write syscall
Heap os_reserve/os_commit/os_alloc/os_release VirtualAlloc/VirtualFree mmap/mprotect/munmap
Time os_wall_nanos/os_monotonic_nanos/os_sleep_nanos kernel32 (FILETIME/QPC/Sleep) clock_gettime/nanosleep
Exit/panic exit/abort/@panic ExitProcess exit syscall

To port a stdlib module to a new OS, you implement its seam primitives in that OS’s runtime. The module itself does not change. std.io, std.heap and std.time already run on Linux this way, and so does everything built on them (Vec, maps, strings, fmt).

Runtime-free path note. The compile() entry, which inline tests and tools use, has no runtime. The heap prelude is injected there automatically, so it ships a tiny host shim for the heap seam (pipeline.prependHeapPrelude). Modules imported from files (std.time and the like) always go through the file path, which prepends the runtime, so they need no shim.

There are two ways to be a Linux binary. Fels supports both, and they differ only in the entry point and the link step, never in the stdlib:

none / musl (freestanding, default) gnu (glibc)
Entry our #naked #entry _start → main our #naked #entry _start → __libc_start_main → main
Link ld.lld -static -e _start ld.lld -e _start with <sysroot>/libc.so.6 and -dynamic-linker /lib64/ld-linux-x86-64.so.2, no crt objects, no -lc
Output static non-PIE ELF, zero deps (ldd → “not a dynamic executable”) dynamically linked against libc.so.6
Needs at link time only ld.lld ld.lld and a sysroot folder that holds libc.so.6
OS seam raw syscalls raw syscalls (unchanged), libc is used only for its loader/CRT

Decision: freestanding is the default and recommended Linux target. It is self-contained and portable across distros (the static-musl/Go/Zig model), and it needs nothing but ld.lld. That is why it cross-compiles cleanly from a Windows host today. gnu is the opt-in for interop with libc-only libraries, or for “a normal dynamically-linked binary.”

Because the seam stays on raw syscalls, gnu is purely a link-mode + entry change. The runtime swaps in a different _start (src/runtime/linux_start_gnu.fels), which hands off to glibc’s __libc_start_main the way crt1.o’s _start does. The link drops -static and names libc.so.6 and the loader path instead. The real dependency is libc.so.6 at link time, and it is not bundled. Pass --sysroot <dir> (or a.sysroot(dir) in build.fels) with a folder that holds it, such as /lib/x86_64-linux-gnu on a Linux host or a copy of that one file on Windows. No crt objects, no libc6-dev and no cc are needed, because ld.lld is the only linker that Fels runs for Linux.

LLVM gives us the back ends for free. The finite work for each arch is the runtime (syscall ABI and entry stub) and the aggregate ABI classifier (backend/llvm/abi.zig, which is already abstracted). Scalars are entirely LLVM’s job, and only by-value aggregates at #extern boundaries need a classifier.

Arch LLVM Syscall ABI (nr / args / insn) Aggregate ABI Status
x86_64 yes rax / rdi rsi rdx r10 r8 r9 / syscall Win64 done, SysV AMD64 not done implemented, except by-value structs at C calls on Linux
aarch64 yes x8 / x0…x5 / svc #0 AAPCS64 UEFI and freestanding implemented; Linux needs the runtime and the classifier
riscv64 yes a7 / a0…a5 / ecall RV calling conv Tier 3, emerging
wasm32 yes no syscalls, WASI imports wasm ABI Tier 3, different seam (WASI), no _start asm

The x86_64 classifier knows only the Win64 rules, and abi.zig applies them on every target. So on Linux, a struct passed by value to an #extern C function, or returned by value from one, does not follow the SysV ABI yet. Scalars and pointers are fine there, since LLVM lowers them. The SysV eightbyte classification is still the plan from 14_linux_elf_backend.

Adding an arch is a checklist, not a rewrite:

  1. Triple and data layout: pass the arch to emit.initTarget.
  2. Syscall wrappers for the arch and a _start stub in a runtime variant (the only asm).
  3. An abi.zig classify arm for that arch’s aggregate rules.
  4. Register the arch in the Target and the runtime selector.

Everything above the seam (the entire stdlib) is already arch-independent.

aarch64-linux is the most valuable next arch, for cloud ARM, Apple Silicon through Linux VMs and containers, and the Raspberry Pi. aarch64/x86_64 macOS is a separate and larger effort (the Mach-O object format, different syscalls and ABI, code-signing), and it is tracked as future Tier 3.

The tiers follow Rust’s, adapted to how Fels keeps platform code behind the runtime seam.

  • Tier 1: tested. The language, codegen and runtime build and run, and the test suite passes in CI on every push. A gap in the stdlib is a bug to fix, and a failing test stops a release.
  • Tier 2: works. The target builds and runs, and its stdlib is complete or nearly so. It is smoke-tested, but not every module runs in CI.
  • Tier 3: experimental. Codegen exists and may build, but the runtime and stdlib are partial or untested.
Target Tier Notes
x86_64-windows-msvc 1 original host target, full stdlib
x86_64-linux-none (static) 1 Cross-compiles from Windows, and builds on Linux itself. Language, codegen and every std module work on it (see §6). By-value structs at C calls do not follow SysV yet (see §4)
x86_64-linux-gnu (glibc) 2 implemented: --target linux-gnu + --sysroot. Our _start → __libc_start_main, links libc.so.6 directly. Needs a sysroot with libc.so.6 at link time
x86_64-uefi 2 --target uefi: a PE32+ EFI application. Boots in QEMU’s UEFI firmware (tests/uefi_qemu_test.py, not in CI). See §7
x86_64-none-elf 2 --target freestanding: a static ELF with no os. Boots in QEMU as a multiboot kernel (tests/freestanding_qemu_test.py, not in CI). See §7
aarch64-uefi 2 --target aarch64-uefi: an ARM64 PE32+ EFI application. Boots in QEMU’s AArch64 UEFI firmware (tests/uefi_qemu_test.py --arch aarch64, not in CI). See §7
aarch64-none-elf 2 --target aarch64-freestanding: a static AArch64 ELF with no os. Boots on QEMU’s virt machine (tests/freestanding_aarch64_qemu_test.py, not in CI). See §7
aarch64-linux 3 not implemented yet. Planned for Tier 2 once the aarch64 Linux runtime + ABI arm exist
riscv64-linux, wasm32-wasi 3 LLVM-ready, runtime/seam not yet written
*-macos 3 Mach-O + Apple ABI, a larger effort
Module Status Seam needed on Linux
std.io ported write (done)
std.heap ported mmap/mprotect/munmap (done)
std.time ported clock_gettime/nanosleep (done)
std.fs ported openat/read/write/close/lseek/newfstatat/unlink/getdents64 (done)
std.process (all, incl. set_env) ported getpid + /proc/self/{cmdline,environ} + fork/execve(/bin/sh -c)/wait4/kill; set_env/unset_env via a runtime env overlay (done)
std.net ported socket syscalls (41/42/43/44/45/48/49/50/51/54) + SOL_SOCKET option translation + fcntl (done)
std.thread ported clone (allocated child stack, asm child-entry) + wait4 join + sched_getaffinity/sched_yield (done)
std.sync ported futex (syscall 202, shared FUTEX_WAIT/FUTEX_WAKE) under a three-state mutex and a sequence-number condition variable (done)

Every std module runs on Linux, in full parity with Windows. The static x86_64-linux-none target has a complete stdlib, so it is Tier 1 in full.

Note: mutable globals and the env overlay. command_line/get_env read /proc/self/{cmdline,environ}, so no captured argv/envp pointer is needed. set_env/unset_env were blocked at first, because Fels had no mutable top-level globals to anchor a process-wide environ. That language feature has since been added (name: T = init;, see 01_syntax), so the runtime now keeps an env overlay. get_env consults it first, and spawn merges it into the child’s environment. No parity gap remains.

7. No operating system: UEFI and freestanding

Section titled “7. No operating system: UEFI and freestanding”

Both targets keep the runtime seam: the runtime gives write_stdout, the os_* heap calls and exit, so println, zones, strings, Vec, maps and fmt work. Neither has files, processes, threads, the network, time or #extern, since nothing under the program provides them.

uefi freestanding
Triple x86_64-pc-windows-msvc, Windows calling convention; aarch64-pc-windows-msvc x86_64-unknown-none-elf; aarch64-unknown-none-elf
File name.efi: PE32+, subsystem EFI_APPLICATION, base relocations name.elf: a static ELF; --link-arg takes a linker script or --oformat=binary
Entry efi_main(image, system_table), made for main; main’s result is the EFI status _start, made for main: calls it, then halts. Without main, the program’s own #entry
Printing the firmware’s console (UTF-8 turned into UCS-2) whatever std.freestanding.set_output names, e.g. a serial port
Heap the firmware’s AllocatePages a free list: the memory std.freestanding.add_memory gives, and a 1 MiB static heap in .bss when that runs out; a zone gives its memory back when it ends
Interrupts the firmware’s x86-64: #interrupt functions in an x86::Idt; aarch64: aarch64::set_exception_handler with an ordinary function
exit, @panic the boot services’ Exit interrupts off and halted for good (cli; hlt, msr daifset; wfi)
Std std.uefi: the system table, stall, keys, clear, the framebuffer, shutdown, reboot std.freestanding (set_output, halt, add_memory, free_memory), std.x86 or std.aarch64

A freestanding program is built with noredzone on every function: an interrupt pushes onto the stack below the stack pointer, where the red zone would keep a function’s locals. Code starts in 64-bit mode at the entry; a kernel booted from 32-bit mode (multiboot) brings its own start in core::asm, as examples/kernel does. The ELF starts at the program’s own #entry; without one, at its linker script’s ENTRY, or at _start.

On aarch64 a freestanding program makes no unaligned memory access (+strict-align): until a kernel turns the MMU on, the CPU treats memory as device memory and faults on one. Fels code uses the FP and SIMD registers, so a kernel turns them on (CPACR_EL1.FPEN) before its first Fels call, as examples/kernel_aarch64 does; on x86-64 it turns SSE on. examples/uefi, examples/kernel and examples/kernel_aarch64 show the targets, with the QEMU commands to boot them; examples/uefi builds for aarch64-uefi as it is.