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.

targetstatus
macOS arm64the 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"
WebAssemblyplanned
mc hosted on Linux or Windowsworks 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:

  1. [sysroot].path, resolved against the config's directory — and checked. A directory that does not hold crt1.o and libc.a is mc'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.
  2. 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 own libc.a.
  3. the cache--sysroot-dir DIR (DIR itself), else [sysroot].cache/<os>-<arch>, else ~/.mc/sysroots/<os>-<arch>.
  4. 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:

mcELF
__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 _mainmain — the leading _ the compiler adds is dropped
symbol l_str0.Lstr0 — string labels become assembler temporaries
BRANCH26R_AARCH64_CALL26 (283)
PAGE21R_AARCH64_ADR_PREL_PG_HI21 (275)
PAGEOFF12 on an addR_AARCH64_ADD_ABS_LO12_NC (277)
PAGEOFF12 on an ldr/strR_AARCH64_LDST{8,16,32,64}_ABS_LO12_NC (278/284/285/286), by access width
UNSIGNEDR_AARCH64_ABS64 (257)

On x86-64 the same three columns are shorter, because the instruction set needs fewer kinds:

mcELFaddend
call rel32R_X86_64_PLT32 (4), at instruction + 1−4
lea r, [rip + disp32]R_X86_64_PC32 (2), at instruction + 3−4
UNSIGNEDR_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 #

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.

Edit this page