Spec M42 step 2 -- mc --exe for Windows (PE), no external linker
M42 step 1 filled the two Linux executable slots with src/backend_elf_exe.mc: a dynamic ELF64
ET_EXEC written with no ld.lld and no sysroot. It left the two Windows slots at 0, so a
windows/* build still requires [linker] (lld-link) and mc --exe on a Windows host has no
direct executable. § Out of scope of docs/specs/M42.md deferred PE to "step 2, its own spec".
This is that spec. It fills target("windows", "aarch64", "coff-obj-arm64", "pe-exe-arm64") and the
x86_64 pair with a PE32+ writer, src/backend_coff_exe.mc, so mc --exe prog.exe and a
[project].kind = "exe" build produce a runnable Windows executable directly, with no lld-link.
Amendment (owner decision, post-review): windows/aarch64 keeps
[linker]; only windows/x86_64 gets the direct PE. The x86_64 PE writer is validated (the windows/x86_64 CI leg runs it), but an arm64 PE also needsDYNAMICBASEand a.relocbase-relocation table, and those can only be validated on a real Windows-on-ARM loader (Wine lies). The object +lld-linkpath already runs the arm64 suite 67/67, so the arm64 direct-exe is deferred, not chased:src/core_writers.mcregisterspe-exe-x86_64alone, sets the windows/aarch64 exe slot to0, andmc --exe/mc build kind = "exe"on windows/aarch64 is refused withwindows/aarch64 requires a linker: there is no direct executable(single-file CLI) /windows/aarch64 requires [linker]: there is no direct executable(mc build). The refusal messages now name<os>/<arch>, since Windows is split.backend_pe_exe(the arm64 entry) is present but dormant, kept for when the deferred work lands.
What already exists #
src/backend_coff.mc(M19/M20) writes COFF objects for both Windows architectures: the section characteristics, the.text/.rdata/.data/.bssnaming, the leading-underscore drop,coff_sec_size/coff_sec_zf/coff_sec_exec, and the two relocation tables (IMAGE_REL_ARM64_*,IMAGE_REL_AMD64_*). A PE executable is COFF's sections wrapped in an image layout, so this file is the input the writer reuses.src/backend_exe.mc(Mach-O) andsrc/backend_elf_exe.mc(ELF) are the two direct-executable writers this one mirrors: segment/section layout, resolving every relocation in place, a PLT-like stub and a GOT-like slot per import, and a synthesized entry point. Its four arm64 relocation patchers (exe_fix_branch26/exe_fix_page21/exe_fix_pageoff12) and the x86ee_fix_x86_pc32are format-neutral (they encode instructions), and the writer reuses them.lib/sys_windows.mcprovideswrite/open/read/close/creat/exitover seven kernel32externs,win_setup/win_argv(the command-line split), and no crt at all.lib/sys_windows_start.mcprovidesmc_start, the entry the lld-link path uses.scripts/test-windows.shalready cross-compiles the suite to COFF objects on macOS and links + runs them on thewindows-11-arm/windows-2025CI runners. The runtime oracle for behaviour exists.
Design #
1. pe-exe-arm64 and pe-exe-x86_64 #
Two backends over the same gen_lower + gen_encode_all every writer consumes, registered in
src/core_writers.mc and placed in the executable slot the two Windows targets left at 0:
backend("pe-exe-x86_64", &backend_pe_exe_x86); target("windows", "aarch64", "coff-obj-arm64", 0); // no direct exe (deferred) target("windows", "x86_64", "coff-obj-x86_64", "pe-exe-x86_64");
x86_64-win is the Win64 ABI half of the x86-64 machine, the same one coff-obj-x86_64 uses; each
backend names its machine first (machine_use, the M17 step B rule).
2. A fixed ImageBase, relocs stripped -- the ET_EXEC of the PE world #
Two simplifications, both with the M11/M42 precedent of refusing the optional half of a format:
ImageBase = 0x140000000,IMAGE_FILE_RELOCS_STRIPPED, no.reloc, no DYNAMICBASE. Every absolute address in the image is known when the segments are placed, so there is nothing to relocate at load; the loader maps atImageBaseor fails.0x140000000is the standard x64/arm64 executable base and is normally free. The cost is no ASLR, the same costET_EXECpays inbackend_elf_exe.mc, and the same follow-up.- The IAT is the only thing the loader fills, before the entry runs -- the
DT_BIND_NOWof the PE world. A PE with no lazy-load directory binds every import eagerly, so the import thunk is a plain indirect jump and there is no resolver.
No base relocation directory follows: the thunks address the IAT RIP-relative (x64) or adrp/ldr-relative (arm64), the import tables hold RVAs, and the resolved absolute addresses live only in the IAT, which the loader fills. Nothing in the file needs fixing up at load.
3. What the writer emits #
A DOS header (64 bytes, MZ, e_lfanew = 0x40), PE\0\0, the COFF file header (Machine,
SectionCount, TimeDateStamp = 0, no symbol table, SizeOfOptionalHeader = 240,
Characteristics = RELOCS_STRIPPED | EXECUTABLE_IMAGE | LARGE_ADDRESS_AWARE), the PE32+ optional
header (Magic 0x20b, AddressOfEntryPoint, ImageBase, SectionAlignment 0x1000,
FileAlignment 0x200, Subsystem 3 = console, DllCharacteristics = NX_COMPAT,
SizeOfImage/SizeOfHeaders, CheckSum = 0, the 16 data directories -- Import [1] and IAT [12]
populated, the rest zero), the section table, and the sections.
The image sections, in file order:
| section | contents | characteristics |
|---|---|---|
module sections (.text, .rdata, .data, custom), non-zerofill, in creation order | gen_encode_all's bytes | as backend_coff.mc gives them, minus the object-only alignment bits |
.text0 | one import thunk per undefined symbol | CODE | EXECUTE | READ |
.text1 | the synthesized entry, when the program brings none | CODE | EXECUTE | READ |
.idata | import directory + ILT + hint/name + DLL names + IAT | INIT | READ | WRITE |
.bss (module zerofill), last | -- | UNINIT | READ | WRITE |
Each section VA is on SectionAlignment and each file offset on FileAlignment, computed
independently -- a section's raw size is the file alignment of its virtual size, and a zerofill
section has none. .bss is last so it is the gap at the end of the image, as in every other writer.
The import directory groups undefined symbols by DLL (ascending ordinal): ordinal 1 is
kernel32.dll (the default no #dylib and no [externs] pattern claims -- backend_exe.mc's
libSystem, ELF's libc.so), and an ordinal >= 2 is the #dylib at index ordinal - 2. Per DLL: an
IMAGE_IMPORT_DESCRIPTOR (OriginalFirstThunk = its ILT RVA, Name = its DLL-name RVA,
FirstThunk = its IAT RVA, TimeDateStamp/ForwarderChain 0); an ILT and an IAT, each an array of
8-byte entries holding the RVA of an IMAGE_IMPORT_BY_NAME (hint 0 + the name) and terminated by
- The ILT and IAT hold the same content; the loader overwrites the IAT. The IATs are laid out last
and contiguous, so the IAT data directory [12] covers them all. ILT and IAT starts are 8-aligned
so a scaled arm64
ldrcan address each slot.
Relocations are resolved in place, exactly as backend_elf_exe.mc does: a reference to a defined
symbol becomes its final address; a reference to an undefined symbol becomes its thunk address (the
canonical address a linker gives an imported function in a fixed-base image). The four arm64
patchers, the x86 rel32 patcher and R_UNSIGNED (an 8-byte absolute store) are the writer's whole
relocation vocabulary, refusing anything else.
An import thunk, arm64: adrp x16, iat_page ; ldr x16, [x16, #lo12] ; br x16 padded to 16 with a
nop; x64: jmp qword ptr [rip + iat_slot] ; int3 int3, padded to 8 -- the same sizes
backend_elf_exe.mc gives its PLT stubs.
4. The entry point, and why Windows has no bare I/O suite #
The kernel enters mc_start/the entry, not main, and there is no crt1-equivalent. Two cases,
decided by whether the program defines mc_start:
- A program that defines
mc_start(#include <sys_windows_start>, or a wrapper that writes one) keeps it. That is how the command line is split:mc_startcallswin_setup()+win_argv()fromlib/sys_windows.mc. - Anything else gets the synthesized entry -- a small stub the writer emits (20 arm64 bytes /
23 x64 bytes) that zeroes
argc/argv/envp, callsmain, and callsExitProcesswith the result. There is no exit syscall on Windows, soExitProcessis the one import the stub forces (sym_ref("_ExitProcess")), exactly as ELF's synthesized_startforces nothing because it exits bysvc. With neithermainnormc_start:no main and no mc_start: cannot generate an executable.
UNLIKE Linux, there is no static-without-imports form and no bare I/O suite. write/open/etc.
are mc wrappers in lib/sys_windows.mc (over WriteFile/CreateFileA/...), NOT DLL exports. A
portable test declares extern write (or includes the macOS lib/sys.mc); on the lld-link path
that write resolves to a separate winrt.obj. In a single --exe translation unit write must
be DEFINED (include <sys_windows>), and mc treats an extern declaration as a full symbol
definition (def = 1 in func_add, src/gen_resolve.mc), so the test's extern write and
<sys_windows>'s i64 write(){...} are function declared twice. This is the exact inverse of
Linux, where write is a libc.so symbol and the bare test --exes directly through a
JUMP_SLOT.
Consequences, on the record:
- A program whose I/O is written directly against
<sys_windows>(noexternfor the wrapper names) is self-contained and--exes.tests/windows/070/071/072/073are that shape. - A pure-compute program (no I/O at all)
--exes bare through the synthesized entry. - A portable I/O test (declares
extern write, or#includeslib/sys.mc) does NOT--exeon Windows; it stays on the lld-link object path (test-windows, the existing gate), which is unchanged.
So the Windows --exe suite is the self-contained subset, and it is REAL: it builds every
self-mode test plus every pure test through the PE writer and RUNS them on the CI runners.
5. Determinism #
TimeDateStamp = 0, CheckSum = 0, no symbol table, no clock, no path, no pointer order. Two
builds of the same source are cmp-identical.
Registration and the diagnostic that moves #
Filling the two Windows exe slots means windows no longer "requires [linker]" for
kind = "exe". The <os> requires [linker]: there is no direct executable diagnostic
(docs/reference/diagnostics.md) now belongs only to a target whose exe slot is still 0 -- a
target(os, arch, obj, 0) a module registers -- exactly as M42 step 1 retired it for Linux.
scripts/check-build.sh's windows without [linker] case is replaced by a positive PE cross-build.
A windows build that HAS a [linker] still takes the object + linker road unchanged.
Files and deltas #
| file | delta | |
|---|---|---|
src/backend_coff_exe.mc | +~660 | new; the PE writer, both architectures |
src/core_writers.mc | +5 / -2 | two backend() calls, two target() slots filled |
tools/bundle.list | +1 | mc/backend_coff_exe |
scripts/test-windows.sh | + | a --exe mode beside the object mode |
.github/workflows/ci.yml | + | cross-compile the PE --exe suite; run it on the two Windows runners |
scripts/check-build.sh | +/- | the windows diagnostic becomes a PE build |
| docs | objects.md, cli.md, build.md, guide/50, diagnostics.md |
stage0/ untouched. The five/six goldens move once (the bundle grows).
Acceptance #
- It builds. Every self-mode test (
tests/windows/070..073) and every puretests/*.mccompiled forwindows/aarch64andwindows/x86_64through the PE writer, each asserted a PE of the rightMachinewithTimeDateStamp 0. - It runs. The
windows-11-armandwindows-2025CI legs EXECUTE the produced.exes, exit code and stdout compared against the source's header. - The dynamic case is real. A test that imports kernel32 (
013-putnum-style:writethrough<sys_windows>) shows the import directory + IAT (llvm-readobj --coff-imports); a pure test shows only the entry'sExitProcess. - Determinism. Two builds of the same source are
cmp-identical. - macOS/Linux do not move.
check-obj32/32 against the frozen seed,check-inertclean (PE--exeis a new, additive backend), the existingtest-windows(COFF object + lld-link) unchanged. - The Windows diagnostic moves, not disappears: an exe-slot-0 target still says
requires [linker].
Risks #
- The I/O-extern collision (§ 4). Contained by scoping the
--exesuite to the self-contained subset and leaving the portable I/O suite on the lld-link path, documented rather than worked around. - A misaligned IAT silently mis-binds through a scaled arm64
ldr. Found and fixed during the work: the ILT and IAT starts are 8-aligned. Cross-checked withllvm-objdump -dthat the thunk target equalsImportAddressTableRVA. .pdata/.xdatastay the accepted M19/M20 gap: no unwind info, so an unwind THROUGH an mc frame is undefined. Nothing in the suite unwinds.- No local Windows host. Validated on macOS with
llvm-readobj/llvm-objdumpagainstlld-link's output, and RUN underwineindocker --platform linux/amd64for x86_64; arm64 PE execution is the CI runner's job.
Implementation notes (written while building it) #
- The IAT alignment bug (risk 2), found by disassembling the first arm64 build: the IAT started
at offset 0x54 inside
.idata, and the thunk'sldr x16, [x16, #0x50](the immediate is scaled by 8 and truncates) addressed 0x4050 where the slot was at 0x4054.pe_plan_idatanowexe_up(off, 8)before the ILT block and before the IAT block. Every entry is au64and the section VA is page-aligned, so aligning the internal offsets is enough.
callp(&main, ...)is how a wrapper'smc_startcallsmain. A directmain(argc, argv, 0)is arity-checked againstmain's real definition in the same unit (wrong number of argumentswhenmaintakes fewer), becausemc_startandmainare one translation unit here rather than the two objects the lld-link path links.callptakes a pointer and arguments and is not arity-checked, soExitProcess(callp(&main, argc, win_argv(), 0))callsmainwith three arguments inx0/x1/x2andmainreads however many it declares. This is whatscripts/test-windows.sh --exewrites above each self-mode test.
- The synthesized entry is exercised bare. A pure
tests/*.mchas nomc_start, somc --exe pure.mcgenuinely produces a runnable PE through the synthesized stub, whose only import isExitProcess.
-
#include <sys>in a Windows--exewrites an image the loader refuses, and the image is otherwise correct — reported from awindows-latestrunner with the publishedmc-0.16.0-windows-x86_64, reproduced STRUCTURALLY on macOS, and the report's own diagnosis (AddressOfEntryPoint 0x3000outsideBaseOfCode 0x1000+SizeOfCode 1536) refuted by measurement. The whole difference between the two rows is one import:|
i64 main() { return 42; }|--exe| 4 sections, entry0x3000, IAT0x4058, importsExitProcess| runs, 42 | |#include <sys>+ the samemain|--exe| 4 sections, entry0x3000, IAT0x4068, importswrite,ExitProcess| refused |Every other header field is the same; both were dumped here with
llvm-readobj --file-headers --sections --coff-imports.io.mc'sputs/putnumcallwrite, mc emits every defined function, sowriteis undefined; on this road there is no second object, so every undefined symbol is an import of the default DLL — andkernel32.dlldoes not exportwrite(scripts/sysroot-windows.shwrites the whole 19-name list;WriteFileis the export). The loader binds the IAT before the entry runs, so the image never executes an instruction.The entry outside
BaseOfCode + SizeOfCodeis normal for this writer and loads, and the repository's own green CI is the proof:001-return42— which thewindows-2025leg RUNS and which returns 42 on every run — has exactly that configuration (entry0x3000,BaseOfCode0x1000,SizeOfCode1536), and070-kernel32runs with.rdatabetween two code sections and a code section outside the range. Both fields are what PE/COFF defines them to be (SizeOfCodeis the SUM of the code sections' RAW sizes,BaseOfCodethe first one's RVA), and with sections page-aligned in memory and 512-aligned in the file that sum cannot span the virtual range unless all code is one section, which is whatlld-linkproduces by merging.The consumer's third row (
--backend=coff-obj-x86_64+lld-link→ 42) is not the same link:writeis resolved there bywinrt.obj(lib/sys_windows.mcdefines the six over kernel32). Withkernel32.libalonelld-linkrefuses the same object —undefined symbol: write, referenced by puts / putnum— which is the linker's own authority that kernel32 cannot serve it.No compiler change was made. Refusing the import would need a list of the names the default DLL cannot serve, which is the heuristic symbol list the post-M11 batch refused for exactly this trade-off (
docs/bootstrap.md§ M11: a.ois refused at link time, an--exeis built and the loader kills it). The cure is the one the surface already offers:<sys_windows>+<io>for a self-contained PE (070-kernel32.mc), or the object road, where a per-target system layer is picked from outside the source by[include].paths— whatexamples/concandlib/linux/<arch>/sys_arch.mcdo.tests/windows/074-sys-io-exe.mcis that shape, running on both Windows legs throughlld-linkand skipped byscripts/test-windows-exe.shwith the reason.