· 6 min read ·

From printf to ELF64: The Shell Script That Compiles C89

Source: lobsters

The idea of compiling C with a shell script sounds like the kind of thing that only exists as a bet. c89cc.sh is exactly that: a standalone compiler for a subset of C89, written entirely in portable POSIX shell, that outputs valid ELF64 executables. No external assembler, no linker, no C runtime dependency: just sh, printf, and arithmetic.

There are two ways to read this project. One is as a clever hack. The more interesting reading is as a statement about what counts as a minimal trusted computing base, and whether a POSIX shell qualifies as one.

How the Compiler Works

A C-to-ELF64 compiler needs to do three things: parse C source into some representation, generate machine code from that representation, and format the output as a valid ELF binary. c89cc.sh handles all three using only POSIX shell primitives.

Parsing happens through string manipulation and pattern matching, which POSIX sh handles adequately for a restricted C89 grammar. The generated machine code is emitted as raw bytes using printf with octal escape sequences, a portable technique available since the earliest Unix systems. The ELF64 structure is assembled by writing its header fields in the correct order, computing addresses using shell arithmetic, and flushing everything to stdout.

The supported C89 subset is deliberately narrow. A minimal compiler targeting Linux x86-64 needs to handle int and char, pointer arithmetic, if/while/for, function definitions and calls, and global variables. Floats, unions, full preprocessor macros, and variadic functions are optional in the sense that the compiler remains useful without them. The goal is a compiler that can compile useful programs, not one that passes the full ANSI conformance suite.

The ELF64 Format Is More Approachable Than Its Reputation

The ELF64 specification is public, the structures are fixed-size, and a minimal static executable needs only two of them before the code begins.

The ELF header is 64 bytes. It starts with the magic bytes \x7fELF, followed by fields for bitness (2 for 64-bit), endianness (1 for little-endian), OS ABI, file type (2 for executable), machine (0x3E for x86-64), the entry point virtual address, and offsets to the program and section header tables. For a minimal executable, section headers are optional: the kernel only needs program headers to load and run the binary.

One PT_LOAD program header follows at offset 64, another 56 bytes. It tells the kernel to take a region of the file at a given offset and map it into memory at a specified virtual address with read and execute permissions. The code begins at file offset 120 (64 + 56), mapped to something like 0x400078 if the segment loads at 0x400000. Set the entry point to that virtual address, and Linux jumps directly to the first instruction.

ELF header    (64 bytes)
PT_LOAD hdr   (56 bytes)
machine code  (N bytes)

A shell script that writes this layout with field values computed from the actual code size produces a runnable binary. The structures are little-endian on x86-64, and printf with octal escapes handles arbitrary byte values, including null bytes that would truncate a shell string if stored as a variable. Emitting a multi-byte little-endian integer requires extracting each byte with $(( )) arithmetic and emitting them low-to-high:

emit_u32() {
  v=$1
  printf "\\$(printf '%03o' $((v & 0xff)))"
  printf "\\$(printf '%03o' $(((v >> 8) & 0xff)))"
  printf "\\$(printf '%03o' $(((v >> 16) & 0xff)))"
  printf "\\$(printf '%03o' $(((v >> 24) & 0xff)))"
}

It is verbose, correct, and portable to any POSIX-compliant shell without external tools. The inner printf generates the octal digits; the outer printf interprets the resulting \NNN sequence as the corresponding byte.

printf as a Code Generator

x86-64 instruction encoding is genuinely complex, with REX prefixes, ModRM bytes, SIB bytes, and multi-byte opcodes. A C89 compiler targeting a simple calling convention only needs a small instruction vocabulary, and a few dozen encodings cover the vast majority of what a minimal compiler generates:

push rbp         → \x55
mov rbp, rsp     → \x48\x89\xe5
sub rsp, imm8    → \x48\x83\xec followed by one byte
mov eax, imm32   → \xb8 followed by four bytes
syscall          → \x0f\x05
ret              → \xc3
call rel32       → \xe8 followed by four little-endian offset bytes
jz   rel32       → \x0f\x84 followed by four bytes

A shell script represents these as string fragments, combines them with computed operands using shell arithmetic, and writes the result to the output file. The register-to-register moves, stack frame setup, and conditional jumps needed for a C89 program are mechanical enough to express as a collection of shell variables and string concatenations.

Forward references are the main complication: a call instruction needs to know the target function’s address before that function has been emitted. Compilers handle this through two-pass compilation or backpatching. Shell scripts can do both: accumulate code into a temporary file, then patch placeholder addresses using dd with conv=notrunc to overwrite bytes at a specific file offset without truncating the output.

Fabrice Bellard used the single-pass approach in otcc in 2001, a self-compiling C compiler written in 2048 bytes of C that emits i386 machine code directly into a malloc-allocated buffer, with the later otccelf variant adding ELF output to disk. Bellard’s insight, that opcodes can be emitted inline during parsing with no intermediate representation, is exactly what a shell-based compiler must use: maintaining a meaningful IR in shell variables is prohibitively expensive, but writing bytes to a file sequentially is cheap.

Shell as a Trust Root

This project occupies the same conceptual space as the bootstrappable builds project, though it approaches the problem differently. Bootstrappable builds reduces the trusted seed to its absolute minimum: hex0, a roughly 500-byte binary that reads ASCII hex pairs and emits the corresponding bytes. From hex0, you build hex1, then the M0 and M1 macro assemblers, then a C-like compiler, and eventually GCC, with every step auditable by a human reading source. The entire chain is a response to Ken Thompson’s 1984 Turing Award lecture, Reflections on Trusting Trust, which demonstrated that a compiler can contain a hidden backdoor with no trace in the source code.

c89cc.sh uses shell as the seed instead. A POSIX-compliant shell implementation is tens of thousands of lines of C, a larger trust base in raw bytes than hex0. The trade-off is availability: any Unix system, from a fresh Alpine Linux container to a decades-old BSD installation, has /bin/sh. Where hex0 offers minimality of trust, c89cc.sh offers universality of presence.

The bootstrappable builds project maintains a POSIX shell variant of its own bootstrap chain, which demonstrates that these two approaches are not mutually exclusive. The choice between “smallest possible seed” and “most widely available seed” depends on what you are trying to prove, and both proofs are interesting.

Prior Art

The history of minimal C compilers is long. Fabrice Bellard’s tcc, the Tiny C Compiler, grew directly from otcc and remains the standard reference for a production-quality minimal C compiler: it compiles C99 fast enough to be used as a scripting engine, fits in a small binary, and can compile itself. Rui Ueyama’s chibicc is the modern pedagogical reference, building a C11 compiler for x86-64 Linux one feature per commit in a public repository that reads as a step-by-step tutorial. lacc covers similar ground, and cproc uses the QBE backend to keep the frontend small while delegating optimization to a separate, auditable IR compiler.

None of these are written in shell. The compiler design in c89cc.sh draws from the same tradition, particularly the single-pass, no-IR approach that Bellard pioneered. What distinguishes it is the implementation medium and what that implies for the dependency chain.

Why This Is Worth Paying Attention To

Practically, a shell-based C compiler with no dependencies beyond /bin/sh is maximally portable. It runs on Linux, macOS, BSDs, and embedded systems where package managers do not exist and the available toolchain is a shell and nothing else. If you need to compile a small C program on a stripped-down system where GCC or clang is unavailable, a self-contained shell script is a credible option in a way that a binary compiler is not.

The philosophical case is about what “portable” means at the level of trust. We accept large, complex compilers as part of our build chains without auditing them. c89cc.sh asks whether that has to be true, whether there is a path from a universally available, human-readable script to a running binary that bypasses the established toolchain entirely. For a narrow class of programs, the answer is yes.

The Lobsters thread where this surfaced includes a comment noting that shell has no tag despite being used for all sorts of interesting work. That observation is accurate. Shell is not just glue between other tools. It is a programming language with enough expressive power to implement non-trivial programs, including compilers that target real hardware and produce runnable binaries. c89cc.sh is a concrete demonstration of how far that expressive power reaches.

Was this interesting?