mc on a Windows host

Until M38 mc was a macOS and Linux program that could target Windows: M19 taught it to write COFF objects for windows/aarch64, M20 added windows/x86_64, and both times the objects were cross-compiled on macOS and linked and run on a CI runner. Now it is a Windows program too — the same source, the same core, the same fixed point, hosted on windows/arm64 and windows/x86_64. This page is the whole story from a bare Windows machine to make check.

Two things are not portable and never will be.

The seed: stage0/*.c is 2848 lines of C that emit Mach-O and only Mach-O — the frozen oracle every cross-check compares against — so a Windows host does not start from a C compiler. It starts from an mc binary that already exists, either a published release asset or one cross-built on macOS, and after one bootstrap the machine is self-sufficient. That is the same rule 90-linux-host.md states for Linux.

The C runtime: there is none here. mc on Windows calls kernel32.dll and nothing else — no MSVCRT, no Visual Studio redistributable, no Windows SDK. Everything a POSIX program takes from libc is written in mc, in lib/sys_windows.mc and lib/sys_windows_host.mc, and linked next to the compiler as an ordinary object (§ 3).


1. In six commands #

Run everything from Git Bash (the sh that ships with Git for Windows). The scripts are POSIX shell and the Makefile is GNU make.

git config --global core.autocrlf false             # BEFORE cloning: see § 7
git clone https://github.com/schivei/mc && cd mc
choco install make llvm                             # GNU make; lld-link comes with LLVM
scripts/bootstrap-windows.sh                        # fetches the seed, reaches the fixed point
make check                                          # the Windows subset

scripts/bootstrap-windows.sh with no argument looks for build/mc-windows-<target>.exe first (a cross-built compiler, § 5), then for build/mc-windows-<target>.obj, which it links itself — that is what the CI artifact holds — and otherwise downloads a release asset:

https://github.com/schivei/mc/releases/download/v<VER>/mc-<VER>-windows-<arch>.tar.gz
https://github.com/schivei/mc/releases/download/v<VER>/mc-<VER>-windows-<arch>.tar.gz.sha256

with <arch> = arm64 or x86_64. With the GitHub CLI on PATH it uses gh release download; otherwise curl fetches both files and MC_SEED_VERSION says which version. The tarball is unpacked only after its SHA-256 matches the .sha256 file, and a mismatch stops the script.

You can also hand it a binary you already trust:

scripts/bootstrap-windows.sh /c/tools/mc.exe

The seed has to be a compiler for this host, not merely one that runs here — the script asks it:

$ mc --host
os windows
arch aarch64
sys sys_windows

and refuses anything whose answer is not windows/<this machine>.


2. What the host layer is #

src/core.mc is host-neutral. Everything that depends on the operating system the compiler itself runs on lives in one small file the entry point includes before the core:

entry pointhost filewhat it is
src/mc.mcsrc/host_macos.mcmacOS, arm64
src/mc_linux.mcsrc/host_linux_aarch64.mcLinux, arm64
src/mc_linux_x86_64.mcsrc/host_linux_x86_64.mcLinux, x86-64
src/mc_windows.mcsrc/host_windows_aarch64.mcWindows, arm64
src/mc_windows_x86_64.mcsrc/host_windows_x86_64.mcWindows, x86-64

Each of the five is three #include lines. The Windows pair shares src/host_windows.mc, which carries everything that depends on the operating system, and adds the three answers that depend on the architecture — host_arch(), host_machine() ("arm64" or "x86_64-win", the Win64 half of the x86-64 machine) and host_include(). The full interface is ../reference/hooks.md § 6.

What is Windows-specific in it:


3. The runtime object: fifteen names Windows does not have #

The compiler declares fifteen things extern and expects the system to provide them: open, read, write, close, creat, _exit and mmap (src/arena.mc), chmod (src/backend_exe.mc), and posix_spawnp, posix_spawn_file_actions_{init,addopen,destroy}, waitpid, mkdir and unlink (the host file). On macOS every one of them is in libSystem; on Linux every one of them is in musl. On Windows none of them exists.

They cannot be defined inside the compiler either: src/arena.mc declares them extern, and a file cannot both declare a name extern and define it (function declared twice). So they are an ordinary object linked next to the compiler, exactly the way winrt.obj is linked next to every Windows test:

fileobjectwhat it holds
lib/sys_windows.mcwinrt.objthe system layer a program includes: write/read/open/creat/close/exit over kernel32, plus the command-line split
lib/sys_windows_host.mcmcrt.objthat file plus the nine names only the compiler needs
lib/sys_windows_start.mcwinstart.objmc_start, what -entry: names

lib/sys_windows_host.mc is architecture-neutral mc code over kernel32 — not one instruction is written by hand — and is compiled once per architecture. What it does:

The blocker that came first: CreateProcessA takes ten parameters #

MAXPARAMS was 8, "never passes an argument on the stack", and it is enforced at parse time and again in gen_resolve. M38's first step raised it to 12 in src/ and taught all three machines to pass 9..12 on the stack — on AArch64 the caller leaves them at [sp, #0..#24], at the bottom of its own frame, and the callee reads them at [x29 + 16 + 8*(i-8)]; the two x86-64 conventions already had the mechanism. The frozen C seed keeps 8, a documented divergence of the same kind as MAXSTRS/MAXGLOBALS/MAXOPEN (../build.md § limits), because stage0 only ever compiles src/mc.mc and no function there has more than eight parameters. The whole rule is in ../reference/objects.md § 4, asserted instruction by instruction by scripts/check-surface.sh, and tests/mc/080-twelve-params.mc runs it on all five targets.


4. The sysroot is one generated file #

There is nothing to download. A Windows program does not link against a copy of kernel32.dll: it links against an import library, an archive holding one thunk per exported name and no code from the DLL at all. scripts/sysroot-windows.sh writes the list of names and builds kernel32.lib from it with llvm-dlltool — no network, no Windows SDK:

make sysroot-windows              # build/sysroot/windows-aarch64/kernel32.lib
make sysroot-windows-x86_64       # build/sysroot/windows-x86_64/kernel32.lib

The same directory holds the two objects every link line carries, because they are also "everything this link needs that is not the program":

make mcrt-windows                 # winstart.obj + mcrt.obj, arm64
make mcrt-windows-x86_64          # the same, x64

scripts/link-windows.sh is what puts them together; MC_SYSROOT overrides the directory.

scripts/link-windows.sh --arch aarch64 build/hello.exe build/hello.obj

which is

lld-link -machine:arm64 -subsystem:console -entry:mc_start -nodefaultlib \
         -out:build/hello.exe build/hello.obj \
         <sysroot>/winstart.obj <sysroot>/mcrt.obj <sysroot>/kernel32.lib

The dash form is not a style choice. Under Git Bash, MSYS rewrites an argument that looks like a path, so a leading /out: becomes C:/Program Files/Git/out: before the linker sees it. Every lld-link option in this repository is written with -, and the CI jobs also set MSYS2_ARG_CONV_EXCL='*' as a second belt.


5. Cross-building the Windows compiler from macOS #

The compiler for a Windows host is built by the compiler that is running, from the entry point that names the Windows host layer:

make mc-windows                   # build/mc-windows-arm64.exe
make mc-windows-x86_64            # build/mc-windows-x86_64.exe

Both go through mc build with src/mc.windows-<arch>.toml, which compiles with the COFF backend and links with lld-link — so a macOS machine with LLVM installed produces a runnable Windows compiler in one command. Unlike the Linux pair this needs no Docker at all: the whole sysroot is one generated .lib plus two objects mc compiles itself.

The CI split: stop at the object #

GitHub's Windows runners have lld-link and no mc, which is a chicken-and-egg: the compiler they would need is the one being built. So the macOS job stops one step earlier —

make mc-windows-obj               # build/mc-windows-arm64.obj,   kind = "obj"
make mc-windows-x86_64-obj        # build/mc-windows-x86_64.obj
make mcrt-windows                 # and the sysroot for each
make mcrt-windows-x86_64

— and uploads the objects and the two sysroots as one artifact. The Windows job downloads it, runs scripts/link-windows.sh, and from there it is a Windows machine with a compiler on it. The object is byte for byte the one src/mc.windows-<arch>.toml writes on its way to the executable: same entry, same backend, same compiler.


6. What the .exe suffix costs #

Exactly one function and one variable. mc build writes a taught compiler from [compiler].out and then runs it, and on Windows a file that is not called *.exe cannot be launched at all — CreateProcess appends .exe when it searches, and a PE file without the extension is not a program. So host_exe_suffix() joined the host interface ("" on macOS and Linux) and drv_teach appends it to the binary it links and to what it spawns. The generated source keeps the bare name: <out>.mc is a source file on every host.

Paths stay /-only #

path_norm and path_join know one separator. Under Git Bash every path in an mc.toml and on the command line is /-separated, and every Win32 file API accepts / as well as \, so nothing had to change. Two gaps are documented rather than fixed:

Both are invisible for a project whose paths are relative to its mc.toml, which is every project in this repository.


7. CRLF #

The repository has a .gitattributes with * -text: never translate line endings. GitHub's Windows images ship core.autocrlf=true, and this host compiles .mc sources and runs shell scripts straight out of the checkout, where a #!/bin/sh\r is a hard failure. The M19/M20 jobs survived without it because their runner only linked and ran prebuilt objects.

The compiler itself is not the fragile half: the lexer treats \r as whitespace, so a CRLF checkout produces the same tokens and the same object and the golden is safe either way. The scripts are the reason. The CI jobs set git config --global core.autocrlf false before the checkout, for the case where .gitattributes has not been read yet.


8. make check on Windows #

uname -s says MINGW64_NT-... under Git Bash, so the Makefile's host switch is a findstring. REF and MC become build/mc1w.exe and build/mc2w.exe — the two stages of the bootstrap — and check is the subset that can run here:

budget bootstrap-windows check-lex check-ast check-asm check-obj
check-bundle check-mc check-toml check-limits check-skipped

bootstrap-windows is the whole chain and the biggest part of it:

seed  src/mc_windows[_x86_64].mc -> build/mc1w.obj  -> link -> build/mc1w.exe
mc1w  the same source            -> build/mc2w.obj  -> link -> build/mc2w.exe
mc2w  the same source            -> build/mc3w.obj
cmp build/mc2w.obj build/mc3w.obj                   <- the fixed point
sha256 build/mc2w.obj vs tests/golden/mc2-windows-<arch>.sha256
build/mc2w.exe --host
scripts/test-windows.sh --arch <arch> --run-only    <- the suite, natively
build/mc2w.exe --backend=macho src/mc.mc == build/mc2.o   <- the cross proof

That last line is the proof that a Windows-hosted mc is the same compiler: the Mach-O object it writes for src/mc.mc is byte for byte the one macOS writes for itself. It needs build/mc2.o, which the macOS CI job uploads; without it the step says it was skipped and why.

make check-skipped prints one line per target that does not run here, with the reason — stage0/mc0 (the C seed emits Mach-O only), bootstrap (that is the macOS chain), test-exe and check-standalone (the Mach-O direct-executable backend), check-surface, check-build, check-minimal, the cross-compilation suites, the examples (macOS dylibs and --exe) and the documentation and site targets.


9. Portable examples #

Everything in tests/*.mc is portable to Windows as written except 032-svc.mc, which enters the Darwin kernel directly; on windows/x86_64 the three tests whose #opcode words are AArch64 instructions are skipped too. tests/windows/070-kernel32.mc, 071-nested-args.mc and 072-six-params.mc are the Windows-only cases, and tests/mc/080-twelve-params.mc runs the stack-parameter rule here like everywhere else.

A program that wants the system layer writes

#include <sys_windows>
#include <io>

and links with winstart.obj and kernel32.lib and nothing else. <sys_windows_host> is the compiler's own runtime and a program has no reason to include it — but it is bundled, so a program that wants to spawn a process can.


See also #

Two facts about the Windows runners #

Edit this page