Spec M11 — direct executable (mc --exe), no ld
Prerequisite: M10 closed. Goal: mc --exe prog.mc -o prog produces an arm64 MH_EXECUTE Mach-O
that the kernel/dyld accept, without ld, with an ad-hoc signature. The .o + ld path
remains the default and the one used for bootstrap; --exe is optional until proven stable.
Layout (one segment per 16 KiB page — arm64's vm_page_size is 16384) #
__PAGEZERO(vmaddr 0, vmsize 0x100000000, no protection, filesize 0)__TEXT(r-x): header + load commands,__text,__stubs,__cstring__DATA_CONST? No —__DATA(rw-):__got(pointers to imported symbols, S_NON_LAZY_SYMBOL_POINTERS),__data,__bss(zerofill at the end)__LINKEDIT(r--): bind opcodes (LC_DYLD_INFO_ONLY), symtab/strtab, signature- Load commands:
LC_SEGMENT_64×4,LC_DYLD_INFO_ONLY(onlybind_off/size),LC_SYMTAB,LC_DYSYMTAB,LC_LOAD_DYLINKER(/usr/lib/dyld),LC_UUID(derived from a SHA-256 of the content — deterministic),LC_BUILD_VERSION,LC_MAIN(entryoffof_main),LC_LOAD_DYLIB(/usr/lib/libSystem.B.dylib, timestamp 2, fixed versions),LC_CODE_SIGNATURE.MH_PIE | MH_TWOLEVEL | MH_NOUNDEFS | MH_DYLDLINK.
External calls #
bl _write in the .o becomes a bl to a stub in __stubs: adrp x16, got@PAGE; ldr x16,
[x16, got@PAGEOFF]; br x16. Every imported symbol has a slot in __got filled in by dyld via
bind opcodes: SET_DYLIB_ORDINAL_IMM 1, SET_SYMBOL_TRAILING_FLAGS_IMM "_write",
SET_TYPE_IMM 1 (pointer), SET_SEGMENT_AND_OFFSET_ULEB seg=__DATA off, DO_BIND, …, DONE.
Internal relocations (adrp/add for strings/globals, UNSIGNED in __data for strings) are
resolved by mc itself into final absolute/relative addresses (the binary is PIE: UNSIGNED in
__data must become a rebase — LC_DYLD_INFO_ONLY.rebase_off with REBASE_TYPE_POINTER
DO_REBASE; without it ASLR breaks pointers in__data).
Ad-hoc signature (LC_CODE_SIGNATURE, at the end of __LINKEDIT) #
CS_SuperBlob (0xfade0cc0) with one CS_BlobIndex (type 0) → CS_CodeDirectory
(0xfade0c02, version 0x20400): flags = CS_ADHOC (2), hashType = 2 (SHA-256),
hashSize = 32, pageSize = 12 (4 KiB), nCodeSlots = ceil(signature_offset / 4096),
codeLimit = signature_offset, identifier = binary name, execSegBase/Limit = __TEXT,
execSegFlags = CS_EXECSEG_MAIN_BINARY (1), nSpecialSlots = 0. Hash each 4 KiB page of the
file up to codeLimit (partial last page). Implement SHA-256 in .mc (and in C for parity:
~120 lines — assess: if it doesn't fit the stage0 budget, --exe exists only in the
self-hosted mc, and stage0 goes without it — acceptable, stage0 is the seed).
Verify with codesign -dvvv and codesign --verify.
Tests #
070-exe-hello.mc (puts) compiled with --exe, run directly; codesign -dvvv shows
flags=0x2(adhoc), hashes=N+0; otool -l shows LC_MAIN, LC_LOAD_DYLIB; ASLR: run 3
times, __data with a pointer to a string (uptr names[] = {"a"}) stays valid (rebase).
mc --exe src/mc.mc -o build/mc-exe and build/mc-exe src/mc.mc -o x.o with cmp x.o build/mc2.o.
Risks #
The kernel rejects (SIGKILL) for: wrong page/alignment, signature in the wrong place, wrong
codeLimit, wrong __PAGEZERO, LC_MAIN without dyld. Debug with otool -l, codesign -dvvv,
dyld with DYLD_PRINT_APIS=1, comparing field by field against a minimal executable produced by
ld (M0's ld is the reference: otool -l build/t).
A nonexistent external symbol fails late. The .o + ld path refuses at link time
(ld: symbol(s) not found for architecture arm64, exit 1); --exe produces a well-formed, signed
binary (codesign --verify passes) and dyld kills the process at load time
(Symbol not found: _name, exit 134 = SIGABRT). The backend emits a stub + bind opcode per
imported symbol without consulting anything, and validating the name would require reading the
SDK's .tbd files (or libSystem itself). Decision: accept the late failure; do not embed a
heuristic symbol list, which would produce both false negatives and false positives and would age
with every macOS release. Anyone who wants the check at build time uses the default path
(.o + ld); --exe is opt-in. Real outputs from both paths are in docs/bootstrap.md § M11
and docs/core-language.md § extern.