No Assembler, No Linker

keepamovin1 pts0 comments

No assembler, no linker — freelang.dev

compiler internals · freedomlang

No assembler, no linker

FreedomLang compiles source to a finished Linux executable inside one JavaScript process — instructions encoded by hand, jumps backpatched, the ELF header written field by field. A walkthrough of a toolchain with no tools.

cris · dosaygojuly 2026x86-64 · elf · codegen~18 min

In the companion post we read the 96 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.

process, source -> executable

external tools invoked

892<br>labels resolved in httpd.elf

361<br>lines in the "linker"

Chapter 1The whole pipeline fits on one screen

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

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

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

if (emit === 'asm') {<br>const asmText = emitX86_64Asm(irBundle);<br>fs.writeFileSync(output, asmText);<br>} else if (emit === 'bin') {<br>const bin = emitX86_64Bin(irBundle);<br>fs.writeFileSync(output, bin);<br>// Ensure generated binaries are executable on Unix-like systems<br>fs.chmodSync(output, 0o755);<br>} else {<br>throw new Error(`Unsupported emit mode: ${emit}`);<br>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<br>// data[0..len(prefix)) equals prefix? Bind each side before compare (\\i clobber).<br>op prefix_eq [data, prefix] (<br>let plen = bytes_len[prefix];<br>let dlen = bytes_len[data];<br>if (dlen => 0;<br>let i = 0;<br>while (i let a = data\\i;<br>let b = prefix\\i;<br>if (a != b) (<br>=> 0;<br>i = i + 1;<br>=> 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<br>function prefix_eq(data, prefix) {<br>entry: entry<br>entry:<br>%t1 = LoadVar slot1<br>%t0 = CallIntrinsic _intrinsic_bytes_len(%t1)<br>StoreVar %t0 -> slot1079<br>%t3 = LoadVar slot0<br>%t2 = CallIntrinsic _intrinsic_bytes_len(%t3)<br>StoreVar %t2 -> slot1080<br>%t4 = LoadVar slot1080<br>%t5 = LoadVar slot1079<br>%t6 = Cmp Lt %t4, %t5<br>BranchIfZero %t6 ? bb153 : bb152<br>bb152:<br>%t7 = ConstInt 0<br>Return %t7<br>bb153:<br>%t8 = ConstInt 0<br>StoreVar %t8 -> slot1081<br>Jump bb154<br>bb154:<br>%t9 = LoadVar slot1081<br>%t10 = LoadVar slot1079<br>%t11 = Cmp Lt %t9, %t10<br>BranchIfZero %t11 ? bb156 : bb155<br>bb155:<br>%t12 = LoadVar slot0<br>%t13 = LoadVar slot1081<br>%t14 = ObjectGet %t12.%t13<br>StoreVar %t14 -> slot1082<br>%t15 = LoadVar slot1<br>%t16 = LoadVar slot1081<br>%t17 = ObjectGet %t15.%t16<br>StoreVar %t17 -> slot1083<br>%t18 = LoadVar slot1082<br>%t19 = LoadVar slot1083<br>%t20 = Cmp Ne %t18,...

loadvar const emit process prefix linker

Related Articles