An LLM can hand-write your binary. Until it can't.
In February, Elon Musk told an xAI all-hands that coding itself is on the way out — that soon “the AI just creates the binary directly,” and more efficiently than any compiler. I wrote at the time why I thought this was wrong. But an argument is just an argument. So I did what I should have done in February: I ran the experiment.
The setup was deliberately the most charitable one I could construct. A
single Claude Code session on an Apple Silicon Mac, with one rule: write
runnable arm64 macOS executables byte by byte. No clang, no as,
no ld, no codesign — nothing in the build path that translates
anything. Python was allowed strictly as a hex editor and a SHA-256
calculator. Every opcode encoding hand-derived from the ARM manual,
every Mach-O header field, every load command, every byte of the code
signature decided directly. otool was permitted only as a
disassembler — a unit test, never a build tool.
The full experiment, including every emitter and the analysis scripts, is at github.com/skelpo/llm-to-native-binary.
I expected this to fail at “hello world.” It did not. That’s the first half of the story, and I want to report it honestly, because it surprised me.
It gets much further than you’d think
Five hand-built binaries run on my machine right now:
- Hello world via raw
svcsyscalls — dyld-loaded, signed. - FizzBuzz — loops, unsigned division, branches, a hand-rolled integer-to-decimal routine.
- A dynamically linked program that calls real libc
putsandprintf— with a hand-built GOT whose slots are chained-fixup bind pointers, a hand-written imports table, and a symbol string pool that dyld actually binds at load time. - Recursive Fibonacci — full AAPCS stack frames, callee-saved registers across two recursive calls. This one worked on the first try.
- A register-pressure capstone with 40 simultaneously live values, allocated by hand.
Getting even the first one to run required solving two problems most
people don’t know exist. Modern Apple Silicon SIGKILLs unsigned
binaries with 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 with
SHA-256 hashes of every 4 KiB page, all in big-endian, unlike the rest of
Mach-O. And a bare static LC_UNIXTHREAD image is rejected at exec
even when correctly signed; modern arm64 macOS demands the real loader
path, so the working binaries are full PIE images with LC_MAIN, a
libSystem dependency, and valid chained-fixups, symtab, and
build-version load commands.
An LLM did all of that, from documentation, in one session. If your intuition was “the file format alone will stop it,” your intuition was wrong, and so was mine. The format is intricate, but it is mechanical: every mistake is caught within seconds, because the disassembler contradicts your intent or the kernel rejects the file outright. Spec-driven work with instant validators is exactly what these models are good at.
So Musk is right? No. Because then came the wall, and the wall is not where the folk intuition puts it.
The wall is not the format. It’s register allocation.
The capstone kernel keeps 40 values live simultaneously and folds them nonlinearly into a 64-bit result — deliberately constructed so a compiler can’t algebra it away, and deliberately over the ~28 usable arm64 general-purpose registers, so someone has to decide what lives where and what spills to the stack.
The hand version required authoring a fixed home for every value: 24 pinned to registers, 16 to explicit stack slots. Note what that is. A hand-authored table mapping live values to storage locations is a register allocator, executed manually. You don’t get to skip the compiler; you become it, one decision at a time.
And here is the part that matters more than the effort curve: the first
hand build produced a wrong answer. 8313107083524001560 instead of
the golden 13514254791259192080. The binary was perfectly valid.
Correctly signed. It disassembled cleanly. It loaded and ran. It was
simply computing kernel(1), because at main() entry x0 holds
argc, not my input — one register assumption, silently wrong.
No disassembler, no loader check, no signature validation, nothing in the entire toolchain could ever have flagged that bug. It was found only because a reference implementation existed to compare against. This is the defining property of the wall: format errors are loud; allocation and ABI errors are silent. They produce valid, signed, runnable binaries that compute the wrong thing.
Measured against clang -O2 on the same kernel: the hand-allocated
version needed 178 instructions and 32 spill/reload ops plus one
debugging round; clang produced 116 instructions with 12 spills, correct
on the first and only attempt, with zero human effort. And that’s before
optimization proper — on a separate summation benchmark, clang deleted
the loop entirely and emitted Gauss’s closed form as a 128-bit multiply.
No one hand-encodes that, human or model.
The objections, taken seriously
Two objections to this framing are good enough that they deserve answers rather than a victory lap.
“Register allocation is only NP-hard if you want it optimal. Spill
everything and keep going.” Correct. There’s a trivial strategy that
hand-assembles arbitrarily large functions with only local reasoning:
give every value a fixed stack slot, load operands before each
operation, store the result after. It works. And the moment you adopt it
systematically, notice what you have built: a naive code generator with a
degenerate allocation policy — output worse than -O0, at
hand-transcription cost. The wall doesn’t say “impossible.” It says the
only way past me is to write a compiler, and a bad one at that. Which is
the thesis.
“An agentic LLM with a test loop would have caught the argc bug.” Also correct — and look at what the loop requires. It requires a golden oracle: a reference implementation whose output defines “correct.” In this experiment that oracle was a Python function. In any real system, the oracle is the source code and its tests. The moment you concede that direct binary generation is only trustworthy when checked against a human-readable reference, you have conceded the whole argument: source code was never the inefficiency to be optimized away. Source code is the contract. The binary is just one derivation of it — and the thing that derives it, deterministically and provably, is called a compiler.
There’s a subtler version of this point in the experiment itself. The moment the hand-building needed label resolution and relocations — around FizzBuzz — the session produced two small Python modules: ~100 lines of hand-derived instruction encoders with a two-pass label resolver, and ~100 lines of Mach-O layout plus code signing. Those are, functionally, an assembler and a linker. Nobody set out to write them. They condensed out of the problem, the way they did historically, for the same reasons. The toolchain is not an accident of computing history. It is the shape of the problem.
What I actually take away
I build a compiler for a living — Perry compiles TypeScript to native
binaries through LLVM — so I’m aware I look like the taxi driver
reviewing the self-driving car. Fine. Judge the experiment, not me;
everything is reproducible with make run.
But my honest takeaway cuts both ways. The model got dramatically further than the confident February takes (mine included) implied it could. Hand-writing a signed, dynamically-linked, chained-fixups Mach-O from documentation is not nothing; the “LLMs can’t even get the format right” objection is dead, and I’m burying it here.
What survives is stronger for it. The limit of writing binaries directly is not knowledge, and it’s not format complexity. It’s the point where you’d have to write a register allocator to keep going — roughly one function past ~28 interacting live values, or any program large enough that a silently-wrong register stops being findable by eye. Below that line you can hand-build all day. Above it, you end up writing a compiler to avoid writing a compiler.
The AI didn’t skip the toolchain. It rediscovered why the toolchain exists — and documented its own wall on the way. That’s a better answer than any thread I could have written in February.