Spec M20 -- Windows x64: COFF x64 relocations and the Win64 ABI on the x86-64 machine
Goal: [target] os = "windows", arch = "x86_64" in mc.toml makes mc build emit a COFF .obj
for AMD64, link it with lld-link against kernel32.lib, and the result runs on Windows on x64.
Nothing in stage0/. Depends on M17 (walker + machine table, the x86-64 machine) and M19 (the COFF
writer, the kernel32 layer, the sysroot and test scripts). Acceptance is "same as M19"
(docs/plan.md). Line references below are to the tree at the M19 merge (77c4a62).
What is already done, and what is not #
Three of the five pieces M19 built are already architecture-neutral or already parameterised.
scripts/sysroot-windows.sh:33-37already takes--arch x86_64and maps it tollvm-dlltool -m i386:x86-64. Zero lines change. The seven kernel32 exports (sysroot-windows.sh:65-75) are undecorated on x64 exactly as on ARM64.src/backend_coff.mcis arm64-specific in exactly three places:IMAGE_FILE_MACHINE_ARM64 0xAA64(backend_coff.mc:44, written at:339), the fiveIMAGE_REL_ARM64_*numbers (:71-75) and thecoff_rel_typeclassifier including theldr/strsniff (:175-187). Everything else -- sections,Characteristics, the string table, the 18-byte symbols, the layout,coff_sym_namedropping the leading_-- is format-only and already correct for x64 (x64 Windows has no symbol decoration either).lib/sys_windows.mcis NOT arch-neutral, contrary to the M19 prose:win_call_main(sys_windows.mc:188-191) isreloc(BRANCH26, "_main"); emit(0x94000000);-- a raw AArch64bland a Mach-O/AArch64 relocation kind. Everything above it (the sevenexterns, the wrappers,win_split) is plain mc and reusable as is. Section 3 is about those four lines and nothing else.
Design #
1. The Win64 ABI in src/machine_x86_64.mc #
The SysV machine partitions registers at machine_x86_64.mc:26-41: depths 0..3 in r8..r11
(XREG_BASE 8, XREG_MAX 3, x86_in_reg at :205), scratch rax/rcx/rdx, locals at
[rbp - off], result in rax, callp pointer in rax (x86_callp:458-469). The calling
convention lives in five places and nowhere else:
| where | SysV today | Win64 |
|---|---|---|
x86_argreg[] (:174) | rdi rsi rdx rcx r8 r9 | rcx rdx r8 r9 |
x86_param (:262-266) | i < 6; 7th/8th at [rbp+16], [rbp+24] | i < 4; 5th at [rbp+48], i.e. 16 + 32 + (i-4)*8 |
x86_push_args (:413-427) | pushes args 7..n, pads 8 when the count is odd | the same, plus sub rsp, 32 after the pushes |
x86_reg_args (:432-441) | if (n > 6) n = 6 | if (n > 4) n = 4 |
x86_call/x86_callp | if (back) add rsp, back | unchanged -- back now includes the 32 |
The register partition does not change. rax, rcx, rdx, r8-r11 are volatile in both
ABIs, so depths stay in r8..r11, scratch stays rax/rcx/rdx, and the callp pointer stays in
rax. rdi and rsi become callee-saved on Win64 and the machine simply stops naming them: they
appear only as SysV argument registers 0 and 1, and the Win64 x86_argreg table does not contain
them. rbx, rbp, r12..r15 were already never written.
r8 and r9 being argument registers 3 and 4 on Win64 while also holding depths 0 and 1 is safe,
and the proof is the SysV comment at machine_x86_64.mc:429-431 run the other way:
x86_reg_args writes argreg[i] in ascending i, reading depth dbase + i. argreg[2] is r8,
the register of depth 0, which can only be an argument source at index -dbase <= 0 < 2, so it has
already been consumed. argreg[3] is r9, depth 1, index 1 - dbase <= 1 < 3. For x86_callp
the pointer moves to rax before any argument register is written (:460), so the same argument
holds with dbase + 1. The acceptance section asks for a TEST of this, not only the argument.
Shadow space. Win64 requires the CALLER to reserve 32 bytes below the return address before
every call, and rsp 16-byte aligned at the call. Folding it into x86_push_args' return value
is the smallest change and puts the bytes in the right place by construction: the pushes go first
(so stack argument 5 lands at [rsp+32] once the shadow is subtracted), then sub rsp, 32. The
alignment rule is unchanged -- 8*np + 32 == 0 (mod 16) iff np is even -- so the existing
"pad 8 when the count is odd" branch stays, and if (back) ei(X_SPADD, ..., back) after the call
gives all of it back at once. When na <= 4, np == 0 and back == 32, which is why
x86_push_args must return non-zero even for a call with no stack arguments.
The frame is already 16-aligned (gen_walk.mc:892) and the prologue is already
push rbp; mov rbp, rsp; sub rsp, N, so rsp is 0 mod 16 after the prologue and the call
alignment holds unchanged.
How the two ABIs are selected. Three globals -- x86_nargreg (6 / 4), x86_shadow (0 / 32)
and the argument table pointer -- read by x86_param, x86_push_args and x86_reg_args. They are
set by MTASK_PROLOGUE, which gen_func (gen_walk.mc:876) always runs before the first
MTASK_PARAM and before any MTASK_CALL, so they can never be stale. Registration becomes:
machine("x86_64", m_x86_64); // MTASK_PROLOGUE = x86_prologue (sets SysV)
machine("x86_64-win", m_x86_64_win); // MTASK_PROLOGUE = x86_prologue_win (sets Win64)
m_x86_64_win is a copy of m_x86_64 with one slot replaced; the other thirty entries are the
same &fn. MTASK_INS_SIZE, MTASK_ENCODE, MTASK_DUMP, MTASK_RELOC_KIND and
MTASK_RELOC_OFF are pure functions of the Ins record and are ABI-blind. MAXMACHINES is 8
(hooks.mc:373) and three are in use, so the ceiling holds.
This keeps the M17 rule intact -- the object backend names its machine as its first statement
(docs/reference/machine.md) -- and --machine=x86_64-win makes --dump-asm show the Win64
sequence, which a runtime flag set only by the backend could not.
2. coff-obj-x86_64 in src/backend_coff.mc #
Parameterised the way backend_elf.mc was, not duplicated: an i64 coff_machine global (the exact
counterpart of elf_em, backend_elf.mc:125), set by each entry point before gen_lower.
IMAGE_FILE_MACHINE_AMD64 0x8664instead of0xAA64atbackend_coff.mc:339.coff_rel_type_x86(r):R_X86_PLT32andR_X86_PC32(the machine's own kinds, 17 and 16,machine_x86_64.mc:46-47) both map toIMAGE_REL_AMD64_REL32 0x0004;R_UNSIGNEDwithrel_len == 3maps toIMAGE_REL_AMD64_ADDR64 0x0001-- the number differs from ARM64'sADDR64 0x000E.coff_rel_typedispatches oncoff_machineexactly aself_rel_typedoes atbackend_elf.mc:385. Thepageoffclassifier is only reached on the ARM64 path.- The
-4is not carried anywhere, andREL32_1..5are not needed. ELF needs an explicit addend of-4(elf_rel_addend,backend_elf.mc:377-381) becauseR_X86_64_PC32computesS + A - Pfrom the START of the four-byte field. COFF'sIMAGE_REL_AMD64_REL32is defined as the 32-bit relative address from the byte FOLLOWING the relocation, i.e.S + A - (P + 4), andlld-linkadds the in-place field content as the addend. Both relocated instructions mc emits put their disp32 at the very end:call rel32isE8+ 4 bytes (x86_put:641, reloc offset 1,x86_reloc_off:496) andlea r, [rip+disp32]isREX.W 8D modrm+ 4 bytes (x86_put:621-627, reloc offset 3,:497). The encoder already writesbuf_u32(o, 0)in both. Zero is the correct value; the encoder does not change.REL32_1..5exist for a field followed by 1..5 further bytes (an immediate operand); mc emits no such shape. backend_coff_x86(root, out):machine_use("x86_64-win"); coff_machine = ...AMD64;then the samegen_lower/gen_encode_all/coff_write.backend_coffgains the two matching lines.src/main.mc:backend("coff-obj-x86_64", &backend_coff_x86);next to:131, andtarget("windows", "x86_64", "coff-obj-x86_64", 0);next to:139. Nothing else in the driver: M17's registry already makesos = "windows"require[linker]and already expands{sysroot}.
3. The entry point #
lib/sys_windows.mc:188-191 is the only AArch64 code in the layer, and the reason it is written
that way is stated at :182-187: a file cannot both declare main extern and define it, and the
"self" link mode (tests/windows/070-kernel32.mc) includes the layer into a program that defines
main. It cannot simply be re-encoded for x86-64: emit() writes exactly four bytes
(gen_walk.mc:578-592), a pending reloc() is pinned to the START of that word
(gen_walk.mc:586, :857), and gen_reloc only accepts the four Mach-O kinds
(gen_walk.mc:615). An x86 call rel32 is five bytes with its field one byte in.
The fix removes the raw words instead of doubling them. mc_start moves into its own bundled file,
lib/sys_windows_start.mc (sys_windows_start), compiled once into winstart.obj and linked into
every Windows executable:
extern i64 main(i64 argc, uptr argv);
extern i64 win_setup(); // splits GetCommandLineA(), returns argc
extern uptr win_argv();
extern void ExitProcess(i64 code);
i64 mc_start() { i64 argc = win_setup(); ExitProcess(main(argc, win_argv())); }
lib/sys_windows.mc keeps the wrappers and win_split and gains the two accessors; it loses
win_call_main and mc_start. main is called through the ordinary MTASK_CALL path, so the
relocation is BRANCH26 on ARM64 and R_X86_PLT32 -> IMAGE_REL_AMD64_REL32 on x64, both already
correct. The layer becomes genuinely arch-neutral and tests/windows/070-kernel32.mc needs no
// skip-x86_64:.
lld-link /entry:mc_start on x64 calls the entry point normally, so rsp is 8 mod 16 at its first
instruction -- the same invariant every mc function is compiled against -- and the entry receives no
arguments, which is why the command line is fetched rather than read off the stack.
Link lines become test.obj [+ winrt.obj] + winstart.obj + kernel32.lib; the self mode drops
winrt.obj because the source carries the wrappers, and both modes now carry winstart.obj.
This is a behaviour change for windows/arm64 too, and the existing ARM64 CI leg is its gate.
4. Scripts, tests, Makefile #
scripts/sysroot-windows.sh-- unchanged (already--arch x86_64).scripts/test-windows.sh-- thecase "$arch"at:75-78gainsx86_64) lmachine="x64" ;;; thellvm-readobj --file-headersassertion at:185and:330becomesIMAGE_FILE_MACHINE_AMD64for that arch;skip_reason(:150-152) reads// skip-windows:first and then// skip-$arch:, copyingtest-linux.sh:182-186;winstart.objis built alongsidewinrt.obj(:286-290) and added to both branches oflink_one(:214-218). Today032-svc.mcis the only// skip-windows:and031-opcode.mc/033-reloc.mccarry// skip-x86_64:, so--arch x86_64skips three of the 33 and--arch aarch64keeps skipping one -- no new headers are written. The lld-link options stay in the dash form (-out:,-machine:, ...): the slash form is rewritten into paths by MSYS on the Windows runners (PR #4 lost a run to it).Makefile--sysroot-windows-x86_64andtest-windows-x86_64next to:171and:182, the latter added tocheck(:307) and.PHONY(:318), guarded the same way (nolld-linkorllvm-dlltool-> SKIPPED), and one line incheck-skippedfor the Linux subset.
5. CI #
- macOS job: a
Cross-compile the suite for windows/x86_64step (scripts/test-windows.sh --arch x86_64 --build-only build/windows-objs-x86_64 build/mc1) afterci.yml:110-111, and awindows-x86_64-objectsartifact after:158-162. - A
windows-x86_64job,name: Link and run the suite (windows/x86_64),runs-on: windows-latest,needs: check-- a copy ofwindows-arm64(ci.yml:447-516) with the artifact name changed and the same "Tool facts" pattern.windows-latestships LLVM underC:\Program Files\LLVM, which the existing step at:477-483already probes, so the download branch should not fire; keep it, with the x64 asset (clang+llvm-$LLVM_VERSION-x86_64-pc-windows-msvc.tar.xz, falling back toLLVM-$LLVM_VERSION-win64.exe). The job fails loudly rather than skipping, as the ARM64 one does. docs/plan.md§ Rule for every new target: after the merge the architect addsLink and run the suite (windows/x86_64)to themainbranch protection contexts (docs/ci.md§ Branch protection).release.ymlbuild-future-hostsalready carrieswindows-2025 / windows-x86_64underif: false. M20 does not enable it and ships no Windows-hostedmc. Ahost_windows.mcover kernel32 and the release assets are M38.
6. Bundle and goldens #
tools/bundle.listgains one line,sys_windows_start<TAB>lib/sys_windows_start.mc, placed aftersys_windows-- the manifest must stay sorted by name with unique last path components (tools/bundle.mc:98,108). 42 entries become 43;docs/reference/bundle.mdstates the count.src/bundle_data.mcis regenerated (make bundle, whichcheck-bundleverifies beforebootstrap).src/machine_x86_64.mcandsrc/backend_coff.mcare already bundled; no newmc/*entry.- The compiler changes and the blob changes, so all three goldens are rewritten ONCE, in the same
commit:
tests/golden/mc2.sha256,mc2-linux-arm64.sha256,mc2-linux-x86_64.sha256(tests/golden/README.md; the Linux two throughmake check-linux-host). Review the--dump-asmdiff first: it must be empty for arm64 and empty for--machine=x86_64(SysV), and non-empty only for--machine=x86_64-win.
7. Docs #
docs/reference/objects.md (a coff-obj-x86_64 row in the backend table, a § 4c Win64 contract
mirroring § 4b, and one paragraph on why COFF REL32 needs no addend where ELF needs -4),
docs/reference/machine.md (a Win64 column in the "x86-64 implementation" table and the
x86_64-win machine name), docs/build.md § Windows targets, docs/guide/50-cross-compile.md,
docs/reference/bundle.md, docs/reference/cli.md (--machine=x86_64-win), docs/ci.md,
CLAUDE.md § State, docs/plan.md M20 row.
Out of scope #
- No
.pdata/.xdata, on x64 either. The M19 gap (docs/reference/objects.md§ No.pdata/.xdata) applies unchanged: x64 Windows unwinding is also table-driven, with no frame-pointer fallback, andclang --target=x86_64-windows-msvc -cemits both sections for every non-leaf function. Nothing in the language raises or catches and/nodefaultliblinks no CRT. Record it for x64 in the same section rather than opening a second gap note. - No Windows-hosted
mc(M38). Nomc sysroot fetch windows-*(M25). No SSE2 / float tasks (M24). No 32-bit x86 (M18). No--exeequivalent for COFF;os = "windows"always goes through[linker].
Files (estimated deltas) #
| file | now | delta | what |
|---|---|---|---|
src/machine_x86_64.mc | 775 | +45 | Win64 arg table, x86_nargreg/x86_shadow, x86_prologue_win, the second task table |
src/backend_coff.mc | 380 | +45 | coff_machine, AMD64 + REL32/ADDR64, coff_rel_type_x86, backend_coff_x86 |
src/main.mc | 234 | +2 | one backend(), one target() |
lib/sys_windows.mc | 196 | -8 | drop win_call_main/mc_start, add win_setup/win_argv |
lib/sys_windows_start.mc | new | +30 | mc_start, four externs, the comment that explains the split |
tools/bundle.list | 42 | +1 | sys_windows_start |
scripts/test-windows.sh | 350 | +45 | --arch x86_64, two-level skip_reason, winstart.obj in every link |
scripts/sysroot-windows.sh | 81 | 0 | already parameterised |
Makefile | +14 | sysroot-windows-x86_64, test-windows-x86_64, check, .PHONY, check-skipped | |
.github/workflows/ci.yml | +60 | cross-compile step + artifact + the windows-x86_64 job | |
tests/windows/ | +2 files | the two ABI tests below | |
docs/* | +120 | the pages in § 7 | |
tests/golden/*.sha256 | rewritten | three files, once |
Acceptance (same as M19, plus two tests) #
scripts/test-windows.sh --arch x86_64 --build-only produces objects for every non-skipped test;
llvm-readobj --coff-* --relocs --symbols --sections output matches
clang --target=x86_64-windows-msvc -c of equivalent C for headers, sections, symbols and
relocation types; lld-link -machine:x64 links 001, 013 and 070-kernel32 into .exe files on
macOS; the CI leg Link and run the suite (windows/x86_64) runs the suite on windows-latest with
the expected stdout and exit codes; the existing windows/arm64 leg stays green with winstart.obj
in its link lines; make check green; --dump-asm unchanged for arm64 and for
--machine=x86_64; goldens rewritten once.
Two tests under tests/windows/, portable to both Windows architectures and run by both legs:
071-nested-args.mc: a four-argument function whose arguments 3 and 4 are themselves calls with arguments (f(a, b, g(x, y), h(z)), expected value printed), plus the same throughcallp-- the executable proof that staging intor8/r9never clobbers a depth still in use.072-six-params.mc: a six-parameter function reading all six (the 5th and 6th at[rbp+48],[rbp+56]) and calling a seven-argumentextern(CreateFileA, which then closes the handle) -- the shadow space and the stack-parameter offsets exercised against real kernel32.
Additional verification, copying M17 step B: every distinct instruction the Win64 machine emits
while compiling src/mc.mc fed back through llvm-mc -triple=x86_64-windows-msvc and required
byte-identical.
Risks #
- Shadow space forgotten on one path. A missing
sub rsp, 32corrupts nothing on a call into mc code (mc callees do not spill to their home space) and corrupts the caller's frame only on a call into kernel32 -- so it can pass every test that does not do I/O. Mitigation: the shadow lives inx86_push_argsalone, which bothx86_callandx86_callpgo through, plus test 072. - Stack-parameter offset off by the shadow.
[rbp+48]vs[rbp+16]; only functions with more than four parameters are affected. Test 072. lld-linkonwindows-latestunder Git Bash. The dash form is already used and must stay.- The bundle/golden churn touches three goldens and the blob. Regenerate the bundle before
bootstrapping, or
check-bundlereports a stale bundle as a golden mismatch. - The layer split is a behaviour change for windows/arm64 too. Every existing Windows link line
gains
winstart.obj; the ARM64 CI leg is the regression gate and must be green in the same PR.
Decisions (architect, 2026-09-04) #
- ABI selection: a second machine
x86_64-winregistered from the same file, sharing every encoder, with the three ABI globals set by that machine'sMTASK_PROLOGUE. Not a runtime flag:--machine=x86_64-winmust be able to dump the Win64 sequence. - Shadow space: folded into
x86_push_args' return value. Not reserved in the frame. - Entry shim:
mc_startmoves tolib/sys_windows_start.mcwithextern main; the raw AArch64 words are deleted, not duplicated. Allowing anextern-declared function to be defined later is a language change and is not taken here. - COFF writer: parameterised with
coff_machine, mirroringelf_em. Not duplicated. - Skip headers: reuse
// skip-x86_64:with a two-levelskip_reason. No new header. - Machine name:
x86_64-win. .pdata/.xdata: the same accepted gap, recorded for x64 in the existing section.- CI runner:
windows-latest;release.ymluntouched. - Win64 register contract: documented in
objects.md§ 4c and verified by thellvm-mcsweep and by tests 071/072;check-surfacedoes not grow. - Depth registers:
r8..r11kept. The ascending-order argument is required to be backed by test 071, not asserted alone.