No assembler, no linker — freelang.dev

19 min read Original article ↗

In the companion post we read the 120 KB HTTP server binary that freelang produces. This post is about the stranger fact underneath it, the one I find myself repeating before people believe it: nothing else touched that file. No as, no ld, no LLVM, no system toolchain of any kind. One Node.js process parses .flx source, lowers it to an IR, encodes x86-64 instructions as integers pushed into an array, backpatches its own jumps, writes a 120-byte ELF header in front of the result, and finishes with a chmod 755. When people ask which assembler I use, the truthful answer is a switch statement in mini-asm-x86.js, and the linker is 361 lines of my own bookkeeping.

I should say up front that I am probably not qualified to write a compiler backend, and I wrote one anyway, in ordinray JavaScript, because JS is where I move fastest and because I wanted every layer of this thing to be a file a person can open and argue with. That constraint turns out to buy properties that money has a hard time buying elsewhere, deterministic builds and total introspection among them, and it costs real things too, which I will price honestly as we go. So: a walkthrough of a toolchain with no tools, with the actual source at every stage.

1

process, source -> executable

892

labels resolved in httpd.elf

Chapter 1The whole pipeline fits on one screen

Here is the compiler's driver, all of it:

freelang.jsthe CLI, complete main()
function main() {
  const { input, output, opts } = parseArgs(process.argv.slice(2));
  const defaultTarget = process.platform === 'darwin' ? 'darwin' : (process.platform === 'win32' ? 'windows' : 'linux');
  const target = opts.target || defaultTarget;
  const emit = opts.emit || (target === 'linux' ? 'bin' : 'asm');

  let heapBytes;
  try {
    heapBytes = resolveHeapBytes(target);
  } catch (err) {
    console.error(`error: ${err.message}`);
    process.exit(1);
  }

  const sourceText = fs.readFileSync(input, 'utf8');
  let frontend;
  try {
    frontend = compileSource(sourceText, { entryPath: path.resolve(input), target, emit });
  } catch (err) {
    if (err instanceof CompileError) {
      console.error(err.format());
      process.exit(1);
    }
    throw err;
  }
  const irBundle = lowerToIR(frontend);
  irBundle.options = {
    ...(irBundle.options || {}),
    target,
    emit,
    heapBytes,
  };

  if (emit === 'asm') {
    const asmText = emitX86_64Asm(irBundle);
    fs.writeFileSync(output, asmText);
  } else if (emit === 'bin') {
    const bin = emitX86_64Bin(irBundle);
    fs.writeFileSync(output, bin);
    // Ensure generated binaries are executable on Unix-like systems
    fs.chmodSync(output, 0o755);
  } else {
    throw new Error(`Unsupported emit mode: ${emit}`);
  }
}

Four moves: compileSource (lex, parse, check -> AST), lowerToIR, then one of two exits. macOS and Windows go out through emitX86_64Asm as assembly text for a -nostdlib link, and the Linux path we follow today goes through emitX86_64Bin, whose return value is the finished executable, written straight to disk. Note the last line of that branch — the compiler even does its own chmod +x.

Our running specimen is prefix_eq from examples/httpd-learn.flx, the single-file teaching twin of the HTTP server. It answers one question, whether the request bytes start with "GET /health", and it is small enough that we can push it through every stage of the pipeline and elide nothing:

examples/httpd-learn.flxlines 48–65
// data[0..len(prefix)) equals prefix? Bind each side before compare (\\i clobber).
op prefix_eq [data, prefix] (
  let plen = bytes_len[prefix];
  let dlen = bytes_len[data];
  if (dlen < plen) (
    => 0;
  )
  let i = 0;
  while (i < plen) (
    let a = data\\i;
    let b = prefix\\i;
    if (a != b) (
      => 0;
    )
    i = i + 1;
  )
  => 1;
)

Chapter 2Stage two: an IR with no secrets

Lowering produces basic blocks of a deliberately boring instruction set, virtual registers (%t0…), named storage slots, explicit jumps. You can print it yourself, the inspector ships in the repo:

tools/print-ir.js examples/httpd-learn.flxprefix_eq, complete
function prefix_eq(data, prefix) {
  entry: entry
  entry:
    %t1 = LoadVar slot1
    %t0 = CallIntrinsic _intrinsic_bytes_len(%t1)
    StoreVar %t0 -> slot1132
    %t3 = LoadVar slot0
    %t2 = CallIntrinsic _intrinsic_bytes_len(%t3)
    StoreVar %t2 -> slot1133
    %t4 = LoadVar slot1133
    %t5 = LoadVar slot1132
    %t6 = Cmp Lt %t4, %t5
    BranchIfZero %t6 ? bb271 : bb270
  bb270:
    %t7 = ConstInt 0
    Return %t7
  bb271:
    %t8 = ConstInt 0
    StoreVar %t8 -> slot1134
    Jump bb272
  bb272:
    %t9 = LoadVar slot1134
    %t10 = LoadVar slot1132
    %t11 = Cmp Lt %t9, %t10
    BranchIfZero %t11 ? bb274 : bb273
  bb273:
    %t12 = LoadVar slot0
    %t13 = LoadVar slot1134
    %t14 = ObjectGet %t12.%t13
    StoreVar %t14 -> slot1135
    %t15 = LoadVar slot1
    %t16 = LoadVar slot1134
    %t17 = ObjectGet %t15.%t16
    StoreVar %t17 -> slot1136
    %t18 = LoadVar slot1135
    %t19 = LoadVar slot1136
    %t20 = Cmp Ne %t18, %t19
    BranchIfZero %t20 ? bb276 : bb275
  bb275:
    %t21 = ConstInt 0
    Return %t21
  bb276:
    %t22 = LoadVar slot1134
    %t23 = ConstInt 1
    %t24 = CallOp __vsop_plus__dispatch(%t22, %t23)
    StoreVar %t24 -> slot1134
    Jump bb272
  bb274:
    %t25 = ConstInt 1
    Return %t25\n}

The guard clause reads straight down: two bytes_len intrinsic calls, a Cmp Lt, a branch to a block that returns 0. The while loop became bb272 (test) and bb273 (body), and each data\\i byte access became an ObjectGet.

Nothing optimizes this before it becomes machine code, so what you read here is what you will meet in the disassembly. I know exactly what that costs, and I chose boring on purpose: an IR I can eyeball, and that an AI agent can eyeball, is worth more to me than one that runs some percent faster after a pass pipeline nobody can audit. Freelang is a bet that high-integrity beats high-throughput for the tools I want to exist, and this IR is where the bet starts showing.

Chapter 3Stage three: codegen without a register allocator

The Linux backend, src/backend/x86_64-linux.js, announces its strategy in the file header, and I admire its bluntness even though I wrote it:

src/backend/x86_64-linux.jsthe file docblock
* Register Allocation Strategy:
* - All virtual registers (%t0, %t1, ...) allocated on stack
* - Stack layout: rbp-8, rbp-16, ... for %t0, %t1, ...
* - User slots (slot0, slot1, ...) after virtual registers
* - rax is the working register for all operations
* 
* Calling Convention:
* - Standard System V AMD64 ABI for syscalls
* - Internal functions use stack-based parameter passing:
*   - Arguments are pushed right-to-left (last argument pushed first)
*   - After call, stack: [return_addr][arg0][arg1][arg2]...
*   - Parameters accessed at rbp+16, rbp+24, rbp+32, ... (after prologue)
*   - Return value passed in rax
*   - Caller responsible for stack cleanup after call
* 

Every virtual register lives on the stack and rax does all the work. That costs load/store traffic, which you will see plainly below, and buys total predictability: IR instruction n maps to an adjacent run of machine instructions, every time, and a person debugging output can navigate by that.

Machine code accumulates by pushing integers into an array. Here is the compiler's entire model of an object file:

src/backend/x86_64-linux.jsclass CodeEmitter
class CodeEmitter {
  constructor() {
    this.code = [];
    this.labels = new Map();
    this.patches = [];
  }

  emit(...bytes) {
    for (const b of bytes) {
      this.code.push(b & 0xff);
    }
  }

  emit32(val) {
    this.emit(val & 0xff, (val >> 8) & 0xff, (val >> 16) & 0xff, (val >> 24) & 0xff);
  }

  emit64(val) {
    const big = BigInt(val);
    for (let i = 0; i < 8; i++) {
      this.emit(Number((big >> BigInt(8 * i)) & 0xffn));
    }
  }

  pos() {
    return this.code.length;
  }

emit() appends opcode bytes, setLabel() records that a name means the current offset, and patchRel32() fills a jump distance in after the target becomes known. An assembler's output stage, its symbol table, and its relocation engine, in 25 lines.

Run prefix_eq through it and here is the loop body in the finished binary, the ObjectGet from the IR above fetching data\\i:

httpd-learn.elf — disassemblyprefix_eq_bb273 · one byte access
prefix_eq_bb273:
  40c6c4: 48 8b 85 28 ff ff ff        		movq	-0xd8(%rbp), %rax
  40c6cb: 48 89 45 98                 		movq	%rax, -0x68(%rbp)
  40c6cf: 48 8b 85 b8 db ff ff        		movq	-0x2448(%rbp), %rax
  40c6d6: 48 89 45 90                 		movq	%rax, -0x70(%rbp)
  40c6da: 48 8b 45 98                 		movq	-0x68(%rbp), %rax
  40c6de: 50                          		pushq	%rax
  40c6df: 48 8b 45 90                 		movq	-0x70(%rbp), %rax
  40c6e3: 48 89 c1                    		movq	%rax, %rcx
  40c6e6: 58                          		popq	%rax
  40c6e7: 48 a8 01                    		testb	$0x1, %al
  40c6ea: 0f 84 44 35 00 00           		je	0x40fc34 <_freelang_slot_not_object> <.text+0xfbbc>
  40c6f0: 48 83 e0 fe                 		andq	$-0x2, %rax
  40c6f4: 48 8b 10                    		movq	(%rax), %rdx
  40c6f7: 48 83 fa 01                 		cmpq	$0x1, %rdx
  40c6fb: 0f 84 4c 00 00 00           		je	0x40c74d <.text+0xc6d5>
  40c701: 48 83 fa 03                 		cmpq	$0x3, %rdx
  40c705: 0f 84 42 00 00 00           		je	0x40c74d <.text+0xc6d5>
  40c70b: 48 83 fa 07                 		cmpq	$0x7, %rdx
  40c70f: 0f 84 4f 00 00 00           		je	0x40c764 <.text+0xc6ec>
  40c715: 48 83 fa 08                 		cmpq	$0x8, %rdx
  40c719: 0f 8c 15 35 00 00           		jl	0x40fc34 <_freelang_slot_not_object> <.text+0xfbbc>
  40c71f: 48 83 c8 01                 		orq	$0x1, %rax

Two things reward staring. First the movq traffic, slot to rax to slot, exactly as the docblock promised. Second, lines 11 onward: freelang values carry a one-bit tag (testb $0x1 asks whether this is a heap pointer) and heap objects carry a shape word that gets dispatched (cmpq $1/$3/$7/$5 — array, object, byte array…). The failure branch jumps to _freelang_slot_not_object, which never returns. Indexing something that cannot be indexed means the program is lying to itself, and I terminate liars on the spot; only the world gets the gentle treatment (Chapter 6).

Chapter 4Stage four: the assembler is a switch statement

The runtime routines, the allocator and GC and string ops and network stubs, are written inside the compiler as AT&T-flavored pseudo-assembly, and a helper class named MiniAsm encodes each mnemonic to bytes. Encoding is the part gas normally does where you cannot watch, so here is what movq with an immediate actually takes, REX prefix and ModRM and the imm32-versus-imm64 decision included:

src/backend/mini-asm-x86.jsencoding movq $imm, %reg
    // movq
    if (mnemonic === 'movq') {
      const imm = parseImm(a);
      // movq $imm, %reg
      if (imm !== null && isReg(b)) {
        const r = REG[b];
        // mov r64, imm32 sign-extended if fits, else imm64
        if (imm >= -0x80000000n && imm <= 0x7fffffffn) {
          this.emitter.emit(rex(1, 0, 0, r >= 8 ? 1 : 0));
          this.emitter.emit(0xc7, 0xc0 | (r & 7));
          this.emitter.emit32(Number(imm & 0xffffffffn));
        } else {
          this.emitter.emit(rex(1, 0, 0, r >= 8 ? 1 : 0));
          this.emitter.emit(0xb8 | (r & 7));
          this.emitter.emit64(imm);
        }
        return;
      }

rex(1,0,0,…) builds the 64-bit-operand prefix, which is that 48 recurring through every hexdump of x86-64 code you have ever squinted at, and c7 c0|r is "mov r/m64, imm32, register form". An immediate that fits sign-extended 32 bits gets the 7-byte encoding, anything bigger gets the 10-byte b8+r imm64 form. Multiply this by the addressing modes and you have an assembler.

Forward jumps get the classic two-pass treatment, minus the second pass. A jump to a label the emitter has not seen yet writes four zero bytes and files an IOU, and the IOUs get paid the moment the label is defiend:

src/backend/mini-asm-x86.jslabel definition + backpatching
  label(name) {
    const pos = this.emitter.pos();
    this.emitter.setLabel(name);
    this._localLabels.set(name, pos);
    this._resolvePending(name, pos);
  }\n  _resolvePending(name, target) {
    const left = [];
    for (const p of this._pending) {
      if (p.label !== name) {
        left.push(p);
        continue;
      }
      if (p.size === 1) {
        const rel = target - (p.pos + 1);
        if (rel < -128 || rel > 127) {
          throw new Error(`MiniAsm: short jump to ${name} out of range (${rel})`);
        }
        this.emitter.code[p.pos] = rel & 0xff;
      } else {
        const rel = target - (p.pos + 4);
        this.emitter.code[p.pos] = rel & 0xff;
        this.emitter.code[p.pos + 1] = (rel >> 8) & 0xff;
        this.emitter.code[p.pos + 2] = (rel >> 16) & 0xff;
        this.emitter.code[p.pos + 3] = (rel >> 24) & 0xff;
      }
    }
    this._pending = left;
  }

_pending holds {pos, label} records; label() resolves every hole that names it. Anything still pending when the build finishes throws — I refuse to ship a binary with a dangling jump in it, on principle and also because I would be the one debugging it.

Chapter 5Stage five: the standard library is emitted, not linked

There is no libc to call into, so every syscall wrapper gets generated into every binary by functions like this one. Readers of the companion post have already met its output on the wire, this is the timeout machinery, poll standing guard over recv:

src/backend/net-linux.jsemitTcpRecvTimeout — compiler source
function emitTcpRecvTimeout(asm) {
  asm.label('_freelang_net_tcp_recv_timeout');\n  // …prologue: save registers…\n  asm.instr('movq', '%rdi', '%r12');
  asm.instr('movq', '%rsi', '%r13');
  asm.instr('movq', '%rdx', '%r14');
  asm.instr('sarq', '$1', '%r12');

  asm.instr('movl', '%r12d', '(%rsp)');
  asm.instr('movw', '$1', '4(%rsp)');
  asm.instr('movw', '$0', '6(%rsp)');

  asm.instr('movq', `$${SYS.poll}`, '%rax');
  asm.instr('movq', '%rsp', '%rdi');
  asm.instr('movq', '$1', '%rsi');
  asm.instr('movq', '%r14', '%rdx');
  asm.instr('sarq', '$1', '%rdx');
  asm.instr('syscall');
  const pfail = asm.newLabel('Lrt_pf');
  const pto = asm.newLabel('Lrt_pto');
  asm.instr('testq', '%rax', '%rax');
  asm.instr('js', pfail);
  asm.instr('jz', pto);

  asm.instr('movq', '%r12', '%rdi');
  asm.instr('shlq', '$1', '%rdi');
  asm.instr('movq', '%r13', '%rsi');
  asm.instr('callq', '_freelang_net_tcp_recv');
  const ret = asm.newLabel('Lrt_ret');
  asm.instr('jmp', ret);

  asm.label(pto);
  tag1Op(asm, 'Timeout', 'recv');
  asm.instr('jmp', ret);

  asm.label(pfail);
  negErrno(asm, '%rax', '%rbx');
  emitNetErrnoTag(asm, 'poll', '%rbx', '%r12', '%r12', '%r12');

  asm.label(ret);
  asm.instr('addq', '$40', '%rsp');

Compiler code and machine code in a single gestalt: asm.instr('sarq', '$1', '%r12') unboxes the tagged fd, a pollfd gets built at (%rsp), SYS.poll is 7, and the three-way verdict on the result, negative, zero, positive, becomes js / jz / fall-through. Hold this next to the disassembly in the companion post's Chapter 5 and you can match it line for line.

When a syscall fails, Linux hands back -errno in rax, and the translation into freelang's chaos tags is a table plain enough to recite:

src/backend/net-linux.jserrno -> chaos tag
function emitNetErrnoTag(asm, opName, errnoReg, hostReg, portReg, sockReg) {
  // Unique labels per call site
  const resolved = asm.newLabel('Lerr_res');
  const checks = [
    [E.ECONNREFUSED, () => tag2Value(asm, 'Refused', hostReg, portReg)],
    [E.ETIMEDOUT, () => tag1Op(asm, 'Timeout', opName)],
    [E.ECONNRESET, () => tag1Op(asm, 'Reset', opName)],
    [E.EPIPE, () => tag1Op(asm, 'Closed', opName)],
    [E.ENOTCONN, () => tag1Value(asm, 'NotConnected', sockReg)],
    [E.EADDRINUSE, () => tag1Value(asm, 'AddrInUse', portReg)],
    [E.EACCES, () => tag1Op(asm, 'PermissionDenied', opName)],
    [E.EPERM, () => tag1Op(asm, 'PermissionDenied', opName)],
    [E.EINVAL, () => tag1Op(asm, 'InvalidArg', opName)],
  ];
  for (const [num, emit] of checks) {
    const next = asm.newLabel('Lerr_n');
    asm.instr('cmpq', `$${num}`, errnoReg);
    asm.instr('jne', next);
    emit();
    asm.instr('jmp', resolved);
    asm.label(next);
  }
  tagOpErrno(asm, opName, errnoReg);
  asm.label(resolved);
}

ECONNREFUSED becomes Refused carrying host and port, ETIMEDOUT becomes Timeout carrying the operation name, and anything I failed to anticipate falls through to a generic tag that still carries the raw errno, because losing information about a failure is how you end up debugging ghosts.

Chapter 6Case study: compiling with chaos

So what does the error-handling syntax lower to? In the HTTP server, handle_conn wraps its read in handlers:

examples/httpd.flxlines 34–38
  let req = http_read_request[sock, 1024, 3000] with chaos {
    Timeout(_) => -2
    Closed(_) => -3
    _ => -5
  };

The IR gives the whole trick away. A with chaos block compiles to a call, a type test, and a match ladder:

tools/print-ir.js examples/httpd.flxhandle_conn, the with-chaos ladder
function handle_conn(sock) {
  entry: entry
  entry:
    Jump bb631
  bb631:
    %t2 = LoadVar slot0
    %t3 = ConstInt 1024
    %t4 = ConstInt 3000
    %t1 = CallOp http_read_request(%t2, %t3, %t4)
    StoreVar %t1 -> slot1278
    %t5 = CallRuntime chaos_is_tag(%t1)
    BranchIfZero %t5 ? bb632 : bb635
  bb632:
    %t6 = LoadVar slot1278
    StoreVar %t6 -> slot1277
    Jump bb633
  bb635:
    %t7 = LoadVar slot1278
    %t8 = ConstString [166]
    %t9 = CallRuntime chaos_tag_matches(%t7, %t8)
    BranchIfZero %t9 ? bb636 : bb638
  bb638:
    %t10 = LoadVar slot1278
    %t11 = ConstInt 2
    %t13 = ConstInt 0
    %t12 = BinOp Sub %t13, %t11
    StoreVar %t12 -> slot1277
    Jump bb633
  bb636:
    %t14 = LoadVar slot1278
    %t15 = ConstString [2]
    %t16 = CallRuntime chaos_tag_matches(%t14, %t15)
    BranchIfZero %t16 ? bb637 : bb639
  bb639:
    %t17 = LoadVar slot1278
    %t18 = ConstInt 3
    %t20 = ConstInt 0
    %t19 = BinOp Sub %t20, %t18
    StoreVar %t19 -> slot1277
    Jump bb633
  bb637:
    %t21 = LoadVar slot1278
    Jump bb640
  bb640:
    %t22 = LoadVar slot1278
    %t23 = ConstInt 5
    %t25 = ConstInt 0
    %t24 = BinOp Sub %t25, %t23
    StoreVar %t24 -> slot1277
    Jump bb633

chaos_is_tag asks whether the world misbehaved. If it did not, bb208 stores the result and life goes on. If it did, chaos_tag_matches compares tag names in declaration order, Timeout -> -2, Closed -> -3, wildcard -> -5, and every handler arm is an ordinary block computing an ordinary value into the same slot the happy path uses.

In the binary, the ladder costs the happy path one call and one testq:

httpd.elf — disassemblyhandle_conn · the test the happy path pays
  40d9c5: e8 93 eb ff ff              		callq	0x40c55d <http_read_request> <.text+0xc4e5>
  40d9ca: 48 83 c4 18                 		addq	$0x18, %rsp
  40d9ce: 48 89 45 f0                 		movq	%rax, -0x10(%rbp)
  40d9d2: 48 8b 45 f0                 		movq	-0x10(%rbp), %rax
  40d9d6: 48 89 85 c0 d4 ff ff        		movq	%rax, -0x2b40(%rbp)
  40d9dd: 48 8b 45 f0                 		movq	-0x10(%rbp), %rax
  40d9e1: 48 89 c7                    		movq	%rax, %rdi
  40d9e4: e8 f7 38 00 00              		callq	0x4112e0 <_freelang_is_chaos_tag> <.text+0x11268>
  40d9e9: 48 89 45 d0                 		movq	%rax, -0x30(%rbp)

I want to linger on what is missing here, because I hated exceptions for years before I got the chance to omit them. There is no unwinder in this binary, no landing pads, no .eh_frame, no exception ABI to keep three compilers agreeing about. Once failures are values, error handling stops needing machinery, and the entire language feature compiles down to branches you can single-step. Bugs, meanwhile, get no ladder at all: a bad index or an impossible state jumps to a terminator like the _freelang_slot_not_object we met in Chapter 3, and the job dies there. Two kinds of failure, two compilation strategies, and the line between them drawn where I think reality draws it.

Chapter 7Stage six: the linker is 361 lines

What remains is turning a byte array into a file the kernel will exec, and this is the part where most toolchains hand off to ld and its decades of accumulated ceremony. The whole of freelang's ELF emission lives in src/backend/elf-writer.js, and its heart is bookkeeping you can check against the ELF spec with a hex editor open:

src/backend/elf-writer.jsthe ELF header, field by field
  // ========== ELF HEADER ==========
  let header = Buffer.alloc(ELF_TOTAL_HEADER_SIZE, 0);
  
  // ELF magic
  header[0] = 0x7f;
  header.write('ELF', 1, 3);
  header[4] = 2;  // 64-bit
  header[5] = 1;  // little-endian
  header[6] = 1;  // ELF version
  
  writeU16LE(header, 16, 2);        // e_type = ET_EXEC
  writeU16LE(header, 18, 0x3e);     // e_machine = x86_64
  writeU32LE(header, 20, 1);        // e_version
  writeU64LE(header, 24, entryVA);  // e_entry
  writeU64LE(header, 32, BigInt(ELF_HEADER_SIZE)); // e_phoff
  writeU64LE(header, 40, BigInt(shoff));           // e_shoff
  writeU32LE(header, 44, 0);                       // e_flags
src/backend/elf-writer.jsthe program header — comment included
  // ========== PROGRAM HEADER ==========
  // Single PT_LOAD segment covering entire file
  // CRITICAL: p_flags = 7 (R|W|X) is required because we store
  // mutable globals (_current_job_ptr) inside the text segment.
  let pHeader = header.slice(ELF_HEADER_SIZE);
  writeU32LE(pHeader, 0, 1);        // p_type = PT_LOAD
  writeU32LE(pHeader, 4, 7);        // p_flags = R|W|X
  writeU64LE(pHeader, 8, 0n);       // p_offset
  writeU64LE(pHeader, 16, baseVA);  // p_vaddr
  writeU64LE(pHeader, 24, baseVA);  // p_paddr
  writeU64LE(pHeader, 32, BigInt(totalFileSize)); // p_filesz
  writeU64LE(pHeader, 40, BigInt(totalFileSize)); // p_memsz
  writeU64LE(pHeader, 48, 0x1000n); // p_align

There it is, the RWX segment from the companion post, with my own justification sitting in the comment: mutable globals live inside the text segment, so the one PT_LOAD asks for read, write, and execute together. A grown-up toolchain would split globals into an RW segment and keep text RX, freelang will do the same eventually, and until then the tradeoff stays written down in a file too short for anything to hide in.

The full file layout: 64-byte ELF header, 56-byte program header, 119,715 bytes of code (string literals ride at the tail of .text), some heap-global and shape-table scaffolding, then three section headers (NULL, .text, .shstrtab, enough for objdump to find the code). 122,633 bytes, each one accounted for.

Chapter 8What you get for giving up the toolchain

Two properties fall out of this design that I have found genuinely hard to buy any other way.

Determinism by construction. The usual enemies of reproducible builds, timestamps, build paths, linker fingerprints, layout randomness, are all features of tools this pipeline does not contain. While writing these posts I compiled the same source on my Mac and on an Ubuntu box, and the hashes agreed to the last bit:

sha256sum httpd.elfmacOS-built vs. Ubuntu-built
6636d0a2027b9a3b56d889f86c2485605f10553cccf2e65e07196a3434a9ced1  (compiled on macOS)
6636d0a2027b9a3b56d889f86c2485605f10553cccf2e65e07196a3434a9ced1  (compiled on Ubuntu 24.04)

Total introspection. The compiler knows the offset of every label it ever emitted, since CodeEmitter.labels is a Map sitting right there in memory at build time. The shipped binary is stripped, and every annotated listing in these two posts came from a thirty-line script that re-runs the backend and simply asks it:

labelmap.jsthe script behind every annotated listing in these posts
// Re-run the backend, but keep the label map it normally discards.
const src = fs.readFileSync('src/backend/x86_64-linux.js', 'utf8');
const patched = src.replace(
  'text: emitter.getBuffer(),',
  'text: emitter.getBuffer(),\n    labels: emitter.labels,'
);
const m = new Module(backendPath);
m._compile(patched, backendPath);                 // load, patched, in memory
const { text, labels } = m.exports.buildFromIR({ irProgram: ir.ir, strings, options });

const CODE_VA = 0x400078;                          // base 0x400000 + 120-byte headers
for (const [name, off] of labels) console.log((CODE_VA + off).toString(16), name);

892 labels for httpd.elf, every op, every basic block, every runtime routine, addressed. Merge that into objdump output and the stripped binary narrates itself.

The costs are on the table too: no optimizer, stack-slot codegen, one architecture, the RWX segment, and a runtime I maintain by hand down to the REX prefixes. Whether the trade is worth it is the whole freelang bet, that the tools we trust most should be the ones a single person can audit from syntax to socket, and I am aware it is a bet. What I can report after two posts spent reading this compiler's output is the feeling of asking "what wrote this byte?" and getting an answer every single time, and I am not ready to give that feeling up.

Start from the artifact side if you haven't: Examining the machine code of a 120 KB HTTP server. And if reading compiler internals is your idea of a fine evening, the repo is open, there is real work waiting (a real register allocator, W^X segments, more net intrinsics), and I would love the company. Come aboard.