Linux / ELF / SysV backend
Fels cross-compiles from Windows to a static, freestanding Linux x86-64 ELF with no libc, and
runs it. I/O, the heap allocator, Vec, strings and formatting work, and so do filesystem,
time, process, net and threads, which go through Linux syscalls. One gap remains: a struct
passed by value to a C function still follows the Win64 rules (§5).
The design sections below (§2 on) are the original plan. The implementation went freestanding with direct syscalls instead of glibc/crt1.o, and the notes point out where it differs from the plan.
0. What works today
Section titled “0. What works today”Use --target linux to cross-compile from a Windows host:
fels build hello.fels -o hello --target linux --llvm-path path/to/llvm# → a static, non-PIE ELF; runs under Linux/WSL with no shared-library deps
fels build app.fels -o app --target linux --libc --sysroot <dir-with-libc.so.6> --llvm-path path/to/llvm# → a normal dynamically-linked glibc ELF (ldd → libc.so.6); _start hands off to# __libc_start_main, which initializes glibc. `#extern("c", …)` now resolves# against libc.so.6 (verified: getpid, and puts via glibc stdio). The whole fels# stdlib still works under either ABI (it uses syscalls either way).# `--target linux-gnu` is sugar for `--target linux --libc`.--libc (or app.link_libc() in build.fels) is the portable “I need libc” switch. It
means the Windows CRT on Windows and glibc on Linux. The std library runs on Linux, with
full parity with Windows: io, heap, time, fs, process (including set_env), net and
thread. The one exception is std.winrt, which is Windows only by design. Both ABIs are
implemented and verified, the static one (linux, the default) and the glibc one
(linux --libc).
This is what works end to end, verified under WSL:
| Area | Status | How |
|---|---|---|
| Codegen / object | done | emit.initTarget(.linux, …) builds an x86_64-unknown-linux-musl TargetMachine (static reloc) and emits an ELF .o |
| Linking | done | link.linkLinux invokes ld.lld -static -e _start (cross-links from Windows, no libc, no crt1.o) |
| Entry point | done | a #naked #entry _start in the runtime: clears %rbp, aligns %rsp, call main, then the exit syscall, with no mainCRTStartup |
| Syscalls | done | inline asm with explicit register constraints ({rax},{rdi},…; r10 for the 4th arg); write, exit, mmap, mprotect, munmap |
I/O (std.io) |
done | write_stdout/write_stderr route through the write syscall (already portable) |
Allocator (std.heap) |
done | Arena reserve/commit/release go through runtime os_reserve/os_commit/os_alloc/os_release, and Linux backs them with mmap(PROT_NONE)/mprotect/mmap(RW)/munmap |
Vec, strings, fmt |
done | fall out of the allocator + I/O above |
__chkstk, _fltused, Windows entry |
gated off | context.applyTargetStubs and llvm.lower emit them only when target_os == .windows |
All of std is ported, apart from the Windows-only std.winrt. std.fs, std.time,
std.process, std.net and std.thread sit behind the same runtime/stdlib seam that the
allocator uses, and the syscall wrappers in src/runtime/linux.fels
back them. The per-module table is in
Targets, ABIs & support tiers §6.
Key implementation notes and gotchas:
- Freestanding, not glibc. The original plan (§4) linked glibc’s
crt1.oand let its_startcallmain. The implementation ships its own#naked _startinstead and talks to the kernel directly. So the output is a single static ELF with zero dynamic dependencies (ldd→ “not a dynamic executable”). This matches how Fels works without libc on Windows. _startmust be a GLOBAL symbol.ld.lld -e _startresolves the entry against global symbols.#keep(external linkage) was not enough on its own, so_startuses#entry, which also keeps it out of internalization, likemain. A local_startlinks “successfully” with entry0x0, and the program segfaults.- Inline-asm template quirks. The Fels asm template does not process
\nescapes, so asm with several instructions uses;separators (a valid x86 GAS statement separator). A literal$in an LLVM asm string is an operand reference, so AT&T immediates are escaped as$$-16/$$60. Integer asm arguments are coerced toi64to match the asm function type. - The platform allocator is a runtime seam, not
#if.std.heapstays OS-agnostic, and the runtime (selected per target) providesos_*. So the standard library has no platform branches of its own, though a program can still use a top-level#if. The runtime-freecompilepath injects a host shim so that the prelude still type-checks (pipeline.prependHeapPrelude).
1. What is already portable
Section titled “1. What is already portable”More than you might expect. The front-end (lexer/parser/sema), the IR and the LLVM IR generation are OS-agnostic, because they describe computation, not a platform. Two facts make the port manageable:
- Object emission already follows the host.
TargetMachine.initNative(src/backend/llvm/emit.zig) builds the machine fromLLVMGetDefaultTargetTriple()and stamps the matching data layout onto the module. When the compiler is built on Linux, LLVM emits a correct ELF.owith the SysV data layout, so emitting the object itself needs no changes. - The ABI boundary is already abstracted. All handling of aggregates passed by value goes
through
Class/ParamAbi/FnAbiand a singleclassify(src/backend/llvm/abi.zig). SysV slots in as a secondclassify, so the call/return lowering needs no rewrite.
So the port is not “make codegen portable”. It is a finite list of platform-specific seams, and everything else falls out of LLVM and the host triple.
2. The platform-specific surface (the actual work)
Section titled “2. The platform-specific surface (the actual work)”| Seam | Windows today | Linux target | Where |
|---|---|---|---|
| Aggregate ABI | Win64 (1/2/4/8 → int reg, else indirect) | SysV eightbyte INTEGER/SSE/MEMORY classification | backend/llvm/abi.zig |
| Object format | COFF (via host triple) | ELF (via host triple, free) | emit.zig (no change) |
| Linker | lld-link COFF, /ENTRY:mainCRTStartup, kernel32.lib |
cc/ld/ld.lld ELF, crt1.o + libc, _start→main |
backend/llvm/link.zig, driver.zig |
| Entry point | mainCRTStartup calls our main |
glibc _start (from crt1.o) calls main |
link.zig, main.zig |
| Stack probe | __chkstk stub for large frames |
not needed (Linux auto-grows the stack) | context.zig module-asm, gate off |
| Comptime FFI | LoadLibrary/GetProcAddress |
dlopen/dlsym (-ldl) |
vm/ffi.zig |
| stdlib OS layer | kernel32/ws2_32/ucrt #externs |
glibc / raw syscalls | lib/std/* (see §6) |
| CLI niceties | SetConsoleOutputCP etc. |
no-ops | main.zig |
3. The core abstraction: a Target
Section titled “3. The core abstraction: a Target”Introduce one enum. It is computed once and passed to every place where the platform branches:
pub const Target = enum { windows_x64, linux_x64 };- Default: the host (
builtin.os.tag), so Windows behavior is unchanged. - Override: a
--target=linux-x64CLI flag (and-Dtarget=for the build), which lays the groundwork for cross-compilation later. - Threaded into:
abi.zig(whichclassify),link.zig(which linker and args),driver.compileWithLlvm(whichlink*Mem), and the#extern/ stdlib-injection decisions.
This keeps the Windows path a literal switch (target) arm. Nothing changes on Windows, and
every Linux branch stays visible and easy to review.
4. An ELF executable that runs (exit 42)
Section titled “4. An ELF executable that runs (exit 42)”The goal: main :: fn() -> i32 { return 42; } builds to an ELF binary that runs on Linux. This
step has no aggregates, no FFI and no stdlib OS calls yet.
- The
Targetenum, host detection and the--targetflag (a no-op on Windows). - Linker: add
LinuxLinkOptionsandbuildArgsLinuxbeside the Windows ones inlink.zig. The easiest correct path is to invoke the systemcc(gcc or clang) as the link driver. It suppliescrt1.o/crti.o/crtn.o, the dynamic loader and libc, and it resolves_start→mainfor free:cc out.o -o out -no-pie(or PIE, see §7). When noccis present, fall back told.lldwith explicit crt objects. - Entry: drop
/ENTRY:mainCRTStartup. Withcc/crt1.o, the program entry is glibc’s_start, which calls ourmain(argc, argv). In Fels,#entry mainalready lowers to a C-callablemainthat returnsi32. That is exactly what_startexpects, so the exit code flows through. - Gate the
__chkstkmodule-asm (context.zig) behindtarget == .windows_x64.
Test: a Linux CI job runs the existing exe fixtures that need no OS calls (arithmetic,
generics, strings, containers) and checks their exit codes. The exe_integration.zig harness
already shells out to the produced binary. It only needs the non-Windows branch enabled instead
of SkipZigTest.
5. The System V AMD64 ABI
Section titled “5. The System V AMD64 ABI”This is the one really intricate piece. SysV classifies an aggregate into eightbytes (8-byte chunks), and each one is INTEGER, SSE or MEMORY on its own:
- Size > 16 bytes, or any unaligned field straddle → MEMORY. It is passed indirectly
(
byvalpointer arg /sretreturn), the same as Win64’s indirect. - Otherwise it is 1–2 eightbytes, and each one is classified by the fields that land in it.
All-float → SSE (passed in an XMM register), anything integer or pointer → INTEGER (a
GP register). Two eightbytes coerce to a
{T0, T1}pair. For example raylib’sVector2is{ float, float }, a single SSE eightbyte, so SysV passes it in one XMM register. Win64 passes it as ani64in a GP register. A{ double, double }is two SSE eightbytes and goes in two XMMs, while Win64 passes it by reference.
Concretely, extend the Class union in abi.zig:
pub const Class = union(enum) { direct, coerce: u16, // Win64: one iN coerce_pair: struct { lo: EightbyteKind, hi: EightbyteKind }, // SysV: {lo, hi} indirect,};pub const EightbyteKind = enum { integer, sse }; // → i64 or doubleclassifySysV(cg, ty) walks the struct’s fields and bins each one into eightbyte 0 or 1 by its
offset. Then it folds them (INTEGER beats SSE in a shared eightbyte) and returns
coerce/coerce_pair/indirect. The call/return lowering builds the coerced LLVM type
(i64, double or a 2-element struct) and bitcasts the aggregate through memory, exactly as
the Win64 path already does. Only the chosen type differs. The float-in-register difference
is the whole reason raylib’s Vector2 and Rectangle need this, because Win64 passes them as
an i64 or by reference. Color is four u8 fields, a single INTEGER eightbyte, so the Win64
coercion to i32 already matches SysV.
This is not built yet. classify in abi.zig has only the Win64
rule, and --target linux uses it too. So today a Vector2 reaches a Linux C function as an
i64 in a GP register, and a 16-byte struct goes on the stack instead of in two registers.
Structs over 16 bytes and integer-only structs of 1, 2, 4 or 8 bytes, like Color, already
match SysV.
The entry-block allocas that avoid Win64’s __chkstk, and the existing sret/byval
attribute plumbing (applyAbiAttrs in abi.zig), are already
target-neutral and carry straight over.
Test: re-run the C-ABI corpus (tests/compiler/new_types_attrs.zig) against a small Linux C
object that takes and returns Color/Vector2/Rectangle by value, and assert the field
values after the round trip. The Win64 suite already has this shape.
6. The stdlib OS layer
Section titled “6. The stdlib OS layer”The pure-logic modules (math, rand, color, bits, mem, serde, strings, vec,
map, fmt, crypto, path) are already portable. Each module that touches the OS needs a
Linux backing:
| Module | Windows | Linux |
|---|---|---|
std.heap |
VirtualAlloc/VirtualFree |
mmap/munmap (MAP_ANON) |
std.time |
kernel32 clocks |
clock_gettime(CLOCK_*) |
std.process |
GetCommandLine, ExitProcess |
__libc_start_main argv, exit |
std.fs |
Win32 file API | open/read/write/close/stat |
std.thread |
CreateThread |
pthread_create/pthread_join (-lpthread) |
std.net |
Winsock2 (ws2_32) |
BSD sockets (socket/bind/…, no WSAStartup) |
The pattern is to split each one into a thin os shim, the way std.net already layers
std.net.os, and to select the shim per target. There are two mechanisms, in order of
preference:
#externagainst libc.mmap,clock_gettime,socketandpthread_*are all libc symbols, so bind them like the Win32 ones. This reuses the entire existing FFI path.- Raw syscalls. For a libc-free build, a
core::syscall(n, …)builtin (an LLVM inline-asmsyscall) letsstd.os.linuxissue syscalls directly. This is heavier, so defer it past first boot.
std.net pays off best. Because of the layered os/socket/tcp/udp split, only net/os.fels
changes. socket.fels/tcp.fels/udp.fels are already written against the shim and need no
edits. (BSD sockets even drop WSAStartup/WSACleanup, so net::init becomes a no-op on
Linux.)
Build-time selection: pick the shim file by target. The simplest way is a std.os facade that
#imports the right submodule for the build’s target. In the shorter term, conditional
injection in the pipeline keyed on Target works too.
7. Comptime FFI, then loose ends
Section titled “7. Comptime FFI, then loose ends”- Comptime FFI (vm/ffi.zig) is gated with
os.tag != .windows → error. Add adlopen/dlsympath (link the compiler itself with-ldl) so that#run/#compilerhooks can call into shared objects on Linux, the way theLoadLibrarypath does on Windows. - PIE: modern Linux toolchains default to position-independent executables. You can build
-no-pie, which is simplest becauseRelocDefaultalready suits it. Or you switch theTargetMachinereloc model toPICfor Linux and link a PIE. Start with-no-pieand revisit it for hardening. felsld: the self-hosted linker is COFF-only and purely an optimization (LLD is always the correctness path). An ELFfelsldwould be a much later, optional extra, and it is out of scope here.
8. Suggested order
Section titled “8. Suggested order”- The
Targetenum and the--targetflag (a no-op on Windows). Small, and it unblocks the rest. - The ELF link path via
cc, plus entry and__chkstkgating →exit 42runs on Linux. - Linux CI running the OS-free exe fixtures.
- SysV ABI
classify→ the C-ABI corpus passes. std.heaponmmap→ arenas work, then everything built on them (vec/map/strings) → most fixtures pass.std.time/std.fs/std.process, thenstd.thread, thenstd.net.- Comptime FFI via
dlopen, then PIE hardening.
You can ship and test each step on its own. The Windows target never regresses, because every
branch is a switch (target) arm that keeps the existing behavior on the windows_x64 side.