Skip to content

Repository files navigation

llm-to-native-binary

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.


TL;DR

  1. 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.
  2. 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 at exec.
  3. 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.

The rules of the experiment

  • 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.py are hand-derived and each one is verified against a disassembler.

Environment

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.


What got built

Emitter Binary What it demonstrates Result
emit_hello.py hello First attempt: a static LC_UNIXTHREAD image Rejected by the kernel (EBADEXECSIGKILL) — 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

The two Apple Silicon gates (why "hello world" is hard here)

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:

1. Unsigned binaries are silently SIGKILLed

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.

2. A bare static LC_UNIXTHREAD image is rejected at exec

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.

Bonus: real dynamic symbol binding

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.)


How it works

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.


Where's the limit? (the empirical part)

The work splits into three regimes; only one is a real wall.

Regime A — encoding & file format → never the limit

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.

Regime B — register allocation & ABI → the actual wall

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.

Regime C — optimization → unbounded (a quality gap, not a correctness limit)

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.


The capstone: hand register allocation vs. clang, on the same kernel

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 by clang -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.

Head-to-head (identical kernel, identical golden output)

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 answer, in one line

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.


Reproduce

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

File guide

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)

Honesty & methodology notes

  • Disassembler = unit test, not assembler. otool -tv was used only to verify that hand-emitted bytes decode to the intended instructions.
  • clang is only a measurement oracle, used in analysis/ 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.py are 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.

Provenance

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.

About

An LLM (Claude) hand-writes runnable arm64 macOS Mach-O binaries byte-by-byte — no compiler/assembler/linker — then empirically finds where that breaks (register allocation).

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages