Cross-compiling
mc runs on macOS arm64 and produces binaries for macOS arm64, Linux arm64, Linux x86-64,
Windows on ARM and Windows x64. Each path is a backend, a system layer and four lines of mc.toml;
x86-64 adds one more file, a machine — the instruction selection behind a target-independent
walker (../reference/machine.md). Nothing in the compiler's C seed
knows about any of it.
| target | status |
|---|---|
| macOS arm64 | the host, and the default |
| Linux arm64 (ELF64) | works: [target] os = "linux" |
| Linux x86-64 (ELF64) | works: [target] os = "linux", arch = "x86_64" |
| Windows arm64 (COFF) | works: [target] os = "windows" |
| Windows x64 (COFF) | works: [target] os = "windows", arch = "x86_64" |
| WebAssembly | planned |
mc hosted on Linux or Windows | works since M37/M38 (90-linux-host.md, 95-windows-host.md) |
Linux arm64 #
One thing changes: the file format. Since M42 mc writes the executable too, so the whole
config is six lines and nothing outside the compiler is involved — no linker, no crt objects, no
sysroot.
[project]
entry = "hello.mc"
out = "build/hello"
[target]
os = "linux"
arch = "aarch64"
$ mc build . --config linux.toml
compile hello.mc -> build/hello
$ docker run --rm --platform linux/arm64 -v "$PWD":/w -w /w alpine:3 /w/build/hello
hello
What came out is a dynamic ELF64 ET_EXEC at 0x400000, DT_BIND_NOW, one PLT stub and one GOT
slot per imported symbol, PT_GNU_STACK RW:
$ llvm-readelf -l -d build/hello | grep -E 'INTERP|NEEDED|GNU_STACK'
INTERP ... [Requesting program interpreter: /lib/ld-musl-aarch64.so.1]
GNU_STACK 0x000000 ... RW 0x10
0x0000000000000001 (NEEDED) Shared library: [libc.so]
It targets musl by default. A Linux target has two axes — which libc and how it is linked — and one word says the first of them:
[target]
os = "linux"
arch = "aarch64"
libc = "gnu" # or "musl", the default
link = "dynamic" # or "static"
libc is a family, not a soname: gnu picks /lib/ld-linux-aarch64.so.1 (or
/lib64/ld-linux-x86-64.so.2) and libc.so.6 together, and musl picks
/lib/ld-musl-<arch>.so.1 and libc.so. The single-file CLI says the same two things with
--libc=gnu|musl and --link=dynamic|static, so a one-file program never has to become a project
to name its libc:
$ mc --exe --libc=gnu hello.mc -o hello
A program that imports nothing — anything on <sys_linux>, which is raw svc #0 syscalls —
comes out static, with no PT_INTERP and no PT_DYNAMIC at all. That is not a switch: the
writer counts imports. link = "static" does not create that case, it requires it — a program
that does import anything is refused (static link with imports needs [linker]: see
docs/build.md -- static linking (M46)) rather than handed a dynamic binary, because mc has no
archive linker. The full matrix is ../build.md § The matrix.
The object backend is also reachable from the single-file CLI, which is useful when you only want to look at what came out:
#include <sys_linux>
i64 main() {
write(1, "hello\n", 6);
return 0;
}
$ mc --backend=elf-obj hello.mc -o hello.o
$ llvm-readobj --file-headers hello.o | head -5
--libc, --link and --interp do not come along on that road: an object has no
PT_INTERP and no DT_NEEDED, so nothing would read them, and since the post-M42 review they are
refused there (mc: --libc applies to an executable: use --exe) instead of accepted and ignored.
They belong to a run that writes an executable — --exe, or a --backend= some target() names
in its exe slot (../reference/cli.md).
Linux x86-64 #
One line changes:
[target]
os = "linux"
arch = "x86_64" # instead of aarch64
That swaps the object backend for elf-obj-x86_64 and, behind it, the machine: the part of
the code generator that chooses instructions. Everything above the machine — the parser, the
resolver, the walk that turns the AST into frames, depths, labels and calls — is the same code
that produces AArch64. What changes is a register partition (depths in r8..r11, four instead of
seven, because x86-64 has fewer caller-saved registers to spare), an ABI (rdi rsi rdx rcx r8 r9,
then the stack), and thirty-odd encoders. See ../reference/machine.md.
$ mc build . --config linux-x64.toml
compile hello.mc -> build/hello
$ docker run --rm --platform linux/amd64 -v "$PWD":/w -w /w alpine:3 /w/build/hello
hello
The executable backend follows the same swap: elf-exe-x86_64 instead of elf-exe, with the
x86-64 PLT stub (jmp qword ptr [rip+got]) and R_X86_64_JUMP_SLOT. If you do use [linker]
for a static link, the sysroot is a separate directory (build/sysroot/linux-x86_64), because the
crt objects and libc.a are x86-64 code:
make sysroot-linux-x86_64
What does not port is anything that writes instructions by hand — #opcode, emit() and
reloc(). Three of the suite's tests do, and they say so in a header:
29/29 tests passed on linux/x86_64
skipped (not portable to this target):
031-opcode — the #opcode templates are AArch64 words (movz/add); the x86-64 machine emits its own instruction set
032-svc — lib/sys_svc.mc has the Darwin syscall numbers in x16 and svc #0x80; the Linux equivalent is lib/sys_linux.mc
033-reloc — the raw word is an AArch64 `bl` and BRANCH26 is a Mach-O/AArch64 relocation; x86-64 calls are R_X86_64_PLT32
070-nolibc — lib/sys_linux.mc encodes the syscalls and _start as AArch64 `svc #0` words; the x86-64 equivalent would be `syscall`
<sys_linux> is in that list: its syscalls are AArch64 svc #0 words, so an x86-64 Linux program
links against musl (<sys>) rather than going libc-free. To look at what the x86-64 machine
selects without producing a file:
$ mc --dump-asm --machine=x86_64 hello.mc | head
_main:
push rbp
mov rbp, rsp
...
The sysroot — when you still need one #
A dynamic Linux executable needs no sysroot. That is the point of M42: a dynamic binary needs
names — the interpreter path, the DT_NEEDED soname, the symbol names — and a name is not a
file to download. mc build with no [linker] writes it, and nothing has to be installed.
You need a sysroot when you want a static link against a real libc, which is the [linker]
road and the only route to one:
[sysroot]
path = "build/sysroot/linux-aarch64" # what {sysroot} expands to
[linker]
cmd = "ld.lld"
args = ["-o", "{out}",
"{sysroot}/crt1.o", "{sysroot}/crti.o",
"{obj}", "{libs}",
"{sysroot}/libc.a", "{sysroot}/crtn.o"]
That is what examples/api-style projects use to bring a library in statically, and what a binary
that has to run on a machine with no dynamic libc at all needs. Running on a glibc system is
not that case: it is libc = "gnu", one word and no files.
A Linux link needs musl's crt1.o, crti.o, crtn.o and libc.a.
scripts/sysroot-linux.sh [--arch aarch64|x86_64] fills build/sysroot/linux-<arch> by running
apk add musl-dev inside a throwaway Alpine container of the matching platform and copying the
four files out. On an Apple Silicon host the linux/amd64 container is emulated, which is slower
but only happens once.
make sysroot-linux # populate the aarch64 cache
make sysroot-linux-x86_64 # and the x86-64 one
It is a cache: with the four files already present it does nothing, so repeated runs pull no
image. scripts/test-linux.sh calls it by itself whenever a file is missing, so a half-populated
sysroot is repaired instead of failing every test.
You do not have to assemble this by hand #
{sysroot} used to be one key. Since M25 it is a chain, and [sysroot].path is only its first
step:
[sysroot].path, resolved against the config's directory — and checked. A directory that does not holdcrt1.oandlibc.aismc's own diagnostic now, not a puzzle from the linker halfway through the build. An explicit path that is wrong stops the chain: nothing else is tried after it.- the running system, but only when the host is the target:
/usr/lib/<arch>-linux-musl,/usr/lib/musl/lib,/usr/lib. A cross build never picks up the host's ownlibc.a. - the cache —
--sysroot-dir DIR(DIR itself), else[sysroot].cache/<os>-<arch>, else~/.mc/sysroots/<os>-<arch>. - the message, naming every directory it looked at and the command that would produce one, at exit code 2.
So an mc.toml with no [sysroot] at all is normal now: point --sysroot-dir at a directory
you have, or let the cache answer. mc build never downloads — the whole chain only looks at
directories. ../reference/sysroot.md is the full story: the markers,
the probes, the cache layout and every message.
mc sysroot — and you do not have to find the files either #
mc sysroot list # every target and where its files come from
mc sysroot path linux-aarch64 # where they are on THIS machine, or exit 2
mc sysroot fetch linux-aarch64 --yes # download them, checked, into the cache
fetch prints its plan first — url, size, sha256, destination — and does nothing without
--yes. It downloads by spawning curl (or wget, or curl.exe), verifies the archive with
mc's own SHA-256, unpacks it with one tar and writes a manifest.toml beside the files. The
sources are pinned by version and by hash in src/sysroots.mc, so two people who run it get
the same bytes.
$ mc sysroot fetch linux-aarch64 --yes --sysroot-dir build/sysroot/linux-aarch64
fetch linux-aarch64
url https://dl-cdn.alpinelinux.org/alpine/v3.22/main/aarch64/musl-dev-1.2.5-r12.apk
size 2556920 bytes
sha256 576f4aabcfa01d10d6baa2d5d87de436b76e58ae76eedf9db7627051365e1fe3
into build/sysroot/linux-aarch64
sysroot linux-aarch64 -> build/sysroot/linux-aarch64
With no network, or with no curl and no wget, that same command prints the URL, the hash and
the exact curl/tar lines to run by hand, and exits 2. scripts/sysroot-linux.sh (Docker,
apk add musl-dev) is still there and still works — it is the road that needs no CDN.
Linking on macOS with no SDK #
Read this only if you are on the .o + ld road. mc --exe writes and signs the Mach-O
executable itself and needs no SDK, no linker and no sysroot at all — on macOS that is what an
mc.toml with no [linker] uses, and none of this stands between you and a binary.
If you do link, mc can write the import files for you. It knows every symbol your program
declared extern and which library each one belongs to, and a library is, to a linker, exactly a
name plus a symbol list:
mc sysroot stub . # writes build/stubs/libSystem.tbd, and one per [libs] entry
Or let the build do it: {stubs} in [linker].args expands to that directory and writes the
files on first use, the same way {sdk} runs xcrun on first use.
[linker]
cmd = "ld64.lld"
args = ["-arch", "arm64", "-platform_version", "macos", "13.0", "13.0",
"-e", "_main", "-o", "{out}", "{obj}",
"{stubs}/libSystem.tbd", "{stubs}/libsqlite3.tbd"]
No -syslibroot, no -lSystem, no xcrun. dyld_stub_binder goes into the libSystem stub
automatically — you never declare it, and every lazily-bound image needs it. The same machinery
writes .def files and runs llvm-dlltool over them for a Windows target, which is how a program
reaches user32 or ws2_32 with nothing downloaded. What it cannot do — data exports,
frameworks, symbols from third-party objects mc never saw — is listed in
../reference/sysroot.md § 9.
No libc at all #
<sys_linux> is <sys_svc>'s Linux sibling: open/creat/read/write/close/fchmod/
exit as raw svc #0 with the call number in x8 (openat 56, close 57, read 63, write 64,
fchmod 52, exit_group 94; AT_FDCWD is -100, written as movn x0, #99). It also supplies
_start, which reads argc/argv off the entry stack, calls main and exits — so the link
needs no crt objects and no libc:
[linker]
cmd = "ld.lld"
args = ["-nostdlib", "-e", "_start", "-o", "{out}", "{obj}"]
tests/linux/070-nolibc.mc is exactly that case.
The O_RDONLY/O_WRONLY/O_CREAT/O_TRUNC constants live in each system layer rather than in
<io>, because they are per-system values: O_CREAT is 0x200 on macOS and 0x40 on Linux.
Windows #
Same two changes as Linux, in another format: the object is a COFF .obj and [linker] is
required. On arch = "aarch64" the machine does not change either — Windows on ARM is AArch64, and
it is the file that is new.
[project]
entry = "hello.mc"
out = "build/hello.exe"
[target]
os = "windows"
arch = "aarch64"
[sysroot]
path = "build/sysroot/windows-aarch64"
[linker]
cmd = "lld-link"
args = ["/machine:arm64", "/subsystem:console", "/entry:mc_start", "/nodefaultlib",
"/out:{out}", "{obj}", "build/winstart.obj", "{sysroot}/kernel32.lib"]
x64: the same file, another calling convention #
arch = "x86_64" writes the same COFF with IMAGE_FILE_MACHINE_AMD64 and the AMD64 relocation
numbers, over x86_64-win — the Win64 half of the x86-64 machine. It is the same instruction
selection linux/x86_64 uses, with one task replaced: arguments in rcx rdx r8 r9, the fifth and
later at [rbp+48] and up, and 32 bytes of shadow space reserved by the caller below every call
(../reference/objects.md § 4c). Change three lines:
[target]
os = "windows"
arch = "x86_64"
[sysroot]
path = "build/sysroot/windows-x86_64"
[linker]
cmd = "lld-link"
args = ["-machine:x64", "-subsystem:console", "-entry:mc_start", "-nodefaultlib",
"-out:{out}", "{obj}", "build/winstart.obj", "{sysroot}/kernel32.lib"]
lld-link takes its options with either prefix. Under Git Bash on a Windows runner MSYS rewrites a
leading /out: into a path before the linker sees it, so the scripts here use the dash form.
build/winstart.obj in both link lines is the entry point, and you build it the same way you build
anything else — a second mc build with entry = "<the bundled file>" and kind = "obj":
[project]
entry = "start.mc" # one line: #include <sys_windows_start>
out = "build/winstart.obj"
kind = "obj"
scripts/test-windows.sh does exactly that, once per architecture, before it builds any test.
The sysroot and the layer #
The sysroot is one file and there is nothing to download: a Windows program links against an
import library, an archive of thunks generated from a list of exported names, so
scripts/sysroot-windows.sh writes kernel32.def and builds kernel32.lib from it with
llvm-dlltool -m arm64 (or -m i386:x86-64; the seven exports are undecorated on both). Its
--arch picks which. make sysroot-windows and make sysroot-windows-x86_64 run it, and it is a
cache like the musl one.
<sys_windows> is the system layer, and it is the one with no syscall instruction anywhere:
Windows has no stable system-call numbers, so the layer is ordinary mc code over seven kernel32
externs — the same file for both architectures. The entry point is next to it rather than in it:
<sys_windows_start> is mc_start, which calls win_setup() (the GetCommandLineA() split) and
then main, and it is a file of its own because it is the one place main is named as an
extern, which a layer a program includes cannot do. Compile it alone into winstart.obj and
put it in every link line; that is what -entry:mc_start -nodefaultlib points at, and there is no
C runtime in the link at all.
It is also the one layer that does not pull in <io>: on Windows it is linked as an object
next to the program rather than taken out of an archive, so a second copy of
strlen/puts/putnum would be a duplicate symbol. Include them yourself:
#include <sys_windows>
#include <io>
tests/windows/070-kernel32.mc is that case. Everything else in the suite links against the layer
the way it links against musl on Linux — the compiled lib/sys_windows.mc is one more object on
the command line.
scripts/test-windows.sh [--arch aarch64|x86_64] cross-compiles the suite here and the
windows-11-arm and windows-2025 CI jobs link and run it; make test-windows and
make test-windows-x86_64 do the local half (objects, llvm-readobj on each, three real
lld-link links) and skip themselves without lld-link or llvm-dlltool. docs/build.md
§ Windows targets has the full field-by-field mapping.
What the ELF writer does #
gen_lower and gen_encode_all are format-neutral: the same sections, symbols and relocations
that feed the Mach-O writer feed the ELF one. The translation:
| mc | ELF |
|---|---|
__TEXT,__text | .text, SHT_PROGBITS, AX, align 4 |
__TEXT,__cstring | .rodata, SHT_PROGBITS, A, align 1 |
__DATA,__data | .data, SHT_PROGBITS, WA, align 16 |
__DATA,__bss | .bss, SHT_NOBITS, WA, align 16 |
#section SEG SECT | .seg.sect — leading underscores dropped, lowercased (__TEXT,__hot → .text.hot) |
symbol _main | main — the leading _ the compiler adds is dropped |
symbol l_str0 | .Lstr0 — string labels become assembler temporaries |
BRANCH26 | R_AARCH64_CALL26 (283) |
PAGE21 | R_AARCH64_ADR_PREL_PG_HI21 (275) |
PAGEOFF12 on an add | R_AARCH64_ADD_ABS_LO12_NC (277) |
PAGEOFF12 on an ldr/str | R_AARCH64_LDST{8,16,32,64}_ABS_LO12_NC (278/284/285/286), by access width |
UNSIGNED | R_AARCH64_ABS64 (257) |
On x86-64 the same three columns are shorter, because the instruction set needs fewer kinds:
| mc | ELF | addend |
|---|---|---|
call rel32 | R_X86_64_PLT32 (4), at instruction + 1 | −4 |
lea r, [rip + disp32] | R_X86_64_PC32 (2), at instruction + 3 | −4 |
UNSIGNED | R_X86_64_64 (1) | 0 |
The addend is −4 because a rel32 counts from the end of its own field. Both halves — where the
field starts inside the instruction, and what the addend is — match clang
--target=x86_64-linux-musl -c of the same constructs.
Symbols come out in the same stable partition Mach-O needs — locals, defined globals, undefined —
because that is also what ELF requires for sh_info. Relocations are sorted by ascending offset,
the ELF convention, and every r_addend is 0: the encoder leaves the relocated immediate zeroed,
so there is no implicit addend to carry.
Every field was verified against clang --target=aarch64-linux-musl -c of equivalent C, with
llvm-readobj --all and llvm-objdump -dr. The one difference that is not the writer's: mc
always materialises a global's address with adrp + add, so it asks for ADD_ABS_LO12_NC
where clang folds the offset into the load and asks for LDST64_ABS_LO12_NC.
Running the suite on Linux #
scripts/test-linux.sh cross-compiles every test, links each one with ld.lld, and runs it
inside docker run --rm --platform linux/arm64 -v <repo>:/w -w /w alpine:3, comparing exit code
and stdout against the same // expect-exit: and // expect-stdout: headers the macOS suites
use. The repository root is the mount and the working directory, because a test may open its own
source by a relative path.
32/32 tests passed on linux/arm64
skipped (macOS only):
032-svc — lib/sys_svc.mc has the Darwin syscall numbers in x16 and svc #0x80; the Linux equivalent is lib/sys_linux.mc
Exactly one test carries a // skip-linux: header. Everything else is portable as written,
including the custom #sections, the hand-written #opcode encodings, and the
reloc(BRANCH26, "_helper") whose symbol name loses its _ on the way into ELF exactly like
the definition's.
make test-linux is inside make check but guarded: without ld.lld in PATH, or with Docker
not running, it prints test-linux: SKIPPED (...) and the build stays green.
Portability checklist for your own code #
- Pick the system layer per target.
<sys>(libSystem) and<sys_svc>(Darwin syscalls) are macOS;<sys_linux>is Linux;<sys_windows>is Windows.<io>'sstrlen/puts/putnumare written in the language and work on all three — but<sys_windows>is the one that does not include them for you. #opcodeis architecture-specific by nature. A source full of hand-encoded AArch64 words is portable to Linux arm64 and to nothing else.emit()andreloc()are the same story. This is the one place the language stops being portable, and it is deliberate: it is the escape hatch.- Divide by zero is the hardware's answer, not the language's.
x / 0,x % 0andINT64_MIN / -1give0,xandINT64_MINon AArch64, whosesdiv/udivnever trap, and raiseSIGFPEon x86-64, whoseidiv/divdo — one source, two behaviours, with no guard emitted on either side. Constants are still caught at compile time (division by zero). Test a divisor that can be zero yourself; see ../core-language.md § "Division by zero, andINT64_MIN / -1". #dylibis a Mach-O mechanism. On Linux and Windows, name libraries in[linker].argsinstead.- Syscall numbers differ, which is the entire reason
<sys_svc>,<sys_linux>and<sys_windows>are three files rather than one with an#ifdef— there is no#ifdef, and there is not going to be one. Windows does not even have numbers: its boundary is a DLL.
Next #
Two complete programs that use everything so far: 60-examples.md.
Hosting mc on Linux #
Everything above is cross-compilation: a macOS mc writing Linux objects. Since M37 mc also
runs on Linux — same source, same fixed point, src/host_linux.mc instead of
src/host_macos.mc. Cross-building the Linux compiler, the seed, the Linux bootstrap chain and
what make check covers there are in 90-linux-host.md.