Can a large language model write a working native executable byte by byte — no compiler, no assembler, no linker — and where does that stop being possible?
This repository is a complete empirical experiment, produced entirely inside a single
Claude Code session on an Apple Silicon Mac. Every
machine instruction was hand-encoded from the ARM manual; every byte of the Mach-O container —
headers, load commands, symbol tables, dynamic-linker fixups, and even the cryptographic code
signature — was emitted directly. Python is used only as a hex editor plus a SHA-256
calculator. No clang, as, ld, nasm, or codesign is ever used to build anything.
The one deliberate use of a compiler is in analysis/, where clang serves as a
measurement oracle — the opponent to race against and a ruler to measure the limit with —
never to produce a shipped binary.
Every hand-built binary here actually runs on macOS/arm64. The machine code was verified by
disassembling the emitted bytes (with otool, a viewer, not an assembler) and confirming they
decode to the intended instructions, then executing them.
- Five hand-built arm64 macOS executables that run: hello world, FizzBuzz, a
dynamically-linked program that calls libc
puts/printf, recursive Fibonacci, and a 40-simultaneously-live-value kernel register-allocated by hand. - Two surprises about modern Apple Silicon turn "hello world" from 8 instructions into a
signed, dynamically-linked ELF-equivalent: unsigned binaries are
SIGKILLed, and bare static binaries are rejected atexec. - An empirical answer to "where's the limit?" — it is not the file format (that part is mechanical and machine-checkable). The wall is register allocation and ABI/semantics: silent, and super-linear in effort. There's a spill-rate curve and a head-to-head race to prove it.
- Write the binary directly. Decide every opcode byte and every structure field yourself.
- No compiler, assembler, linker, or code-signing tool in the build path — no
clang,llvm, IR,as,ld,nasm,codesign. - Python is allowed strictly as a byte emitter (a scriptable hex editor) and to compute SHA-256
for the signature. It never translates mnemonics for us — the encoders in
arm64asm.pyare hand-derived and each one is verified against a disassembler.
Built and verified on arm64 (Apple M1 Pro), macOS 26.5, Python 3. The binaries are arm64 Mach-O and require Apple Silicon (macOS 11+); they will not run on Intel or Linux.
| Emitter | Binary | What it demonstrates | Result |
|---|---|---|---|
emit_hello.py |
hello |
First attempt: a static LC_UNIXTHREAD image |
Rejected by the kernel (EBADEXEC → SIGKILL) — see below |
emit_hello_dyld.py |
hello2 |
Hello world via raw svc syscalls, dyld-loaded + signed |
prints Hello, World! |
emit_fizzbuzz.py |
fizzbuzz |
Loops, unsigned div/mod, branches, hand-rolled int→decimal | FizzBuzz 1–100, matches a reference impl exactly |
emit_dynamic.py |
dynamic |
Real dynamic linking: calls libSystem puts/printf |
prints two lines incl. printf() says: 2 + 2 = 4, and 7 * 6 = 42 |
emit_fib.py |
fib |
Recursion + full AAPCS frames (callee-saved regs across calls) | prints 832040 (fib(30)) |
emit_kernel_hand.py |
kernel_hand |
40 live values, register-allocated by hand | matches golden 13514254791259192080 |
make run # rebuild everything from the emitters and run each one
On older systems, a minimal Mach-O is a header plus a few instructions. On Apple Silicon the kernel enforces two gates that must be satisfied entirely by hand:
There is no crash report — the process just dies with exit 137. So the build hand-constructs an
ad-hoc code signature: a CSMAGIC_EMBEDDED_SIGNATURE SuperBlob wrapping a CodeDirectory
(version 0x20400, flags CS_ADHOC) that stores SHA-256 hashes of every 4 KiB page of the file,
with execSegFlags = CS_EXECSEG_MAIN_BINARY. All code-signing structures are big-endian,
unlike the rest of Mach-O. The system's own codesign -v validates the hand-built signature as
valid on disk — but note that passing codesign is necessary, not sufficient: the kernel's
cs_validate is stricter.
Even when correctly signed, the first attempt (emit_hello.py) dies with
EBADEXEC ("Bad executable"). Modern arm64 macOS requires the real loader path. So the working
binaries are full PIE + /usr/lib/dyld + LC_MAIN + a libSystem dependency, with minimal
but valid LC_DYLD_CHAINED_FIXUPS, LC_SYMTAB, LC_DYSYMTAB, and LC_BUILD_VERSION. The code
stays position-independent (adr, raw svc #0x80) so zero fixups are needed for the simple
programs.
make static-rejected reproduces the rejection.
emit_dynamic.py goes further and doesn't cheat with syscalls — it calls
actual libc functions. That required hand-building a __DATA GOT whose 8-byte slots are
chained-fixup "bind" pointers, plus an imports table (lib_ordinal, name_offset) and a
symbol string pool. otool -bind_info reads the hand-written chain and confirms dyld will bind
_puts and _printf at load time. (Apple's arm64 ABI passes printf's varargs on the stack,
not in registers — handled by hand.)
Two small modules are, functionally, the back half of a compiler:
arm64asm.py— an assembler. ~100 lines: hand-derived encoders for ~27 arm64 instruction forms plus a two-pass label resolver for branches and PC-relative loads.machobuild.py— a linker + code signer. ~100 lines: lays out segments, load commands, symbol/fixup tables, hashes the pages, and emits the ad-hoc signature.
The working loop for every binary was: hand-encode → disassemble to verify the bytes decode to the intended instructions → run. The disassembler is the unit test, not the assembler.
That two-module fact is the first empirical result: the moment hand-building needs label resolution and relocations, you build an assembler and a linker. You are already re-implementing the toolchain.
The work splits into three regimes; only one is a real wall.
Mechanical, spec-driven, and every mistake is caught in seconds by a validator: the
disassembler contradicts your intent, or dyld/codesign/the kernel rejects a malformed
structure outright. It scales indefinitely in program size for a fixed ~few-hundred-line toolchain
cost. It just is writing a compiler back-end.
Effort grows super-linearly and — the killer — errors are invisible to validators. A
wrong register reuse or a botched vararg slot produces a perfectly valid, signed, runnable binary
that computes the wrong answer, findable only at runtime. Register allocation is the canonical
compiler-hard problem (graph coloring, NP-hard). Measured with clang -O2 as K (the number of
simultaneously-live values) grows past the ~28 usable arm64 registers
(analysis/pressure_probe.py):
| K live values | total instrs | stack spill/reload ops | spills as % of code |
|---|---|---|---|
| ≤ 8 | — | 0 | 0% — fits in registers, hand-trackable |
| 12 | 65 | 4 | 6% — spilling begins |
| 20 | 132 | 24 | 18% |
| 28 | 185 | 35 | 19% |
| 40 | 275 | 61 | 22% |
| 64 | 506 | 161 | 32% |
| 96 | 782 | 284 | 36% — a third of all code is just shuffling to/from the stack |
The wall is concrete: below ~8 live values, hand allocation is trivial; from ~12 it starts
requiring spill bookkeeping; past ~28 you must implement spilling — i.e., write a register
allocator. By contrast, fib (recursion, frames, callee-saved regs across two calls) worked on
the first try — recursion and calling conventions are mechanical. It is live-range overlap,
not code size, that breaks hand-assembly.
The reason you'd never ship hand-assembly even where you can write it
(analysis/optimization.sh). Same summation loop at -O2: clang
deleted the loop entirely and emitted a closed-form n(n+1)/2 — Gauss's formula, in hardware
(a 128-bit mul/umulh and an extr to halve). No human hand-encodes that.
A deliberately high-pressure kernel keeps 40 values live simultaneously and folds them into a 64-bit result (nonlinear, so the compiler can't algebra it away). arm64 has ~31 GP registers, so 40 values do not fit — someone has to allocate and spill.
- The golden result:
kernel(0x123400005678) = 13514254791259192080, established by a Python reference and independently reproduced byclang -O2(analysis/golden.py). - The hand side (
emit_kernel_hand.py): I hand-authored a fixed home for each value — 24 pinned to registers, 16 to explicit stack slots — and transcribed it. That hand-authored map is a register allocator, done manually.
The bug I hit is the whole point. The first hand build produced 8313107083524001560 — a
valid, signed, running binary with the wrong answer. Diagnosis: at main() entry, x0 holds
argc, not my data, so the binary faithfully computed kernel(1). No disassembler, loader, or
signature check could ever flag it; only the golden oracle did. That is exactly the
invisible-to-validators failure mode that defines Regime B.
| hand-allocated | clang -O2 |
|
|---|---|---|
| instructions | 178 (~157 compute) | 116 |
| stack spill/reload ops | 32 | 12 |
| human debugging iterations | 1 (silent wrong-answer bug) | 0 |
clang produced correct, tighter code with zero human effort; the hand version needed a
careful hand-authored allocation table and a debugging round, and still emits ~35% more compute
and ~2.7× the memory traffic. And it was only findable because a reference oracle existed.
The limit of "writing the binary directly" is the point where you'd have to write a register allocator to keep going — roughly a single function with more than ~28 interacting live values, or any program large enough that a wrong-register bug stops being findable by eye. Below that, you can hand-build all day. Above it, you end up writing a compiler to avoid writing a compiler.
make run # build + run all five working binaries
make static-rejected # build the static binary and watch the kernel reject it
make analysis # the empirical measurements (uses clang only as an oracle)
make clean
arm64asm.py hand-derived arm64 encoders + a two-pass label/relocation resolver (an assembler)
machobuild.py Mach-O layout + hand-built ad-hoc code signature (a linker + signer)
emit_hello.py rung -1: static image the kernel rejects (the cautionary tale)
emit_hello_dyld.py rung 0: hello world, dyld-loaded, signed
emit_fizzbuzz.py rung 1: arithmetic, loops, branches, int→decimal
emit_dynamic.py rung 2: real dynamic linking (hand-built GOT + chained-fixup binds)
emit_fib.py rung 3: recursion + AAPCS stack frames
emit_kernel_hand.py capstone: 40 live values, register-allocated by hand
analysis/golden.py golden reference for the kernel + clang's output on it (the opponent)
analysis/pressure_probe.py register-pressure spill-rate curve
analysis/optimization.sh the optimization axis (loop → closed form)
- Disassembler = unit test, not assembler.
otool -tvwas used only to verify that hand-emitted bytes decode to the intended instructions. clangis only a measurement oracle, used inanalysis/to establish golden values and to quantify what a compiler does for free. It never builds a shipped artifact.- The helper modules are the finding, not a loophole.
arm64asm.py/machobuild.pyare a from-scratch assembler and linker. Needing them is the point: hand-building at any real scale means re-implementing the toolchain. - The signatures are ad-hoc (no certificate); the binaries are arm64 macOS 11+ only.
- The included compiled binaries are regenerable at any time with
make.
Designed, hand-encoded, debugged, and documented by Claude (Anthropic) driving Claude Code, in one interactive session, at the request of a human collaborator. The wrong-answer bug in the capstone was real and is reported as it happened.