freelang.dev
binary archaeology · freedomlang

Examining the machine code of a 100 KB HTTP server

A 182-line FreedomLang program becomes a 96,250-byte Linux executable, every byte of which the compiler put there itself. Small enough to read, so let's read it.

cris · dosaygojuly 2026x86-64 · linux · elf~15 min

The lead demo in the freelang repo is a little HTTP/1.1 server, examples/httpd.flx, 182 lines of FreedomLang. My compiler is an ordinary JavaScript program, and when it compiles those lines for Linux it writes out a 96,250-byte executable that answers real curls with no libc, no C runtime, and no dynamic loader anywhere in the building.

A binary that small, with nothing smuggled in from shared libraries, is a binary a person can sit down and actaully read, which is a rare pleasure these days, so that is what this post does. We decode the file format by hand, watch the process get born, follow one GET /health through every system call it makes, and then watch the error machinery fire when a client connects and goes quiet. Every trace, listing, and number below came out of commands you can re-run yourself; I put them at the end.

96,250
bytes on disk
21
syscalls, boot -> serve -> exit
0
dynamic libraries
6
lines of /proc/<pid>/maps

Chapter 1The specimen

Here is the part of the source that does the serving. If you have never seen FreedomLang before: op declares an operator, square brackets call one, => returns early, and with chaos { … } is how I model world failures — timeouts, closed peers, the weather of networking — as plain data your code matches on. Hold onto that; in Chapter 5 you get to see it as machine code.

examples/httpd.flxlines 33–53 · the connection handler
op handle_conn [sock] (
  let req = http_read_request[sock, 1024, 3000] with chaos {
    Timeout(_) => -2
    Closed(_) => -3
    _ => -5
  };
  if (req == -2) (
    print "  recv timeout";
    tcp_close[sock] with chaos { _ => 0 };
    => 2;
  )
  if (req == -3) (
    print "  client closed early";
    tcp_close[sock] with chaos { _ => 0 };
    => 3;
  )
  if (req == -5) (
    print "  recv error";
    tcp_close[sock] with chaos { _ => 0 };
    => 5;
  )

Routing reads the same way, match the request bytes, pick a response:

examples/httpd.flxlines 22–30
op pick_response [req] (
  if (http_is_get[req, "/health"] == 1) (
    => http_ok_text_str["ok"];
  )
  if (http_is_get[req, "/"] == 1) (
    => http_ok_html_str[home_body[]];
  )
  => http_not_found_text_str["not found"];
)

Compile it and poke it:

shellUbuntu 24.04 · x86-64
$ node freelang.js examples/httpd.flx httpd.elf --target=linux --emit=bin
$ ./httpd.elf 8080 &
httpd listening on 127.0.0.1:8080
$ curl -i http://127.0.0.1:8080/health
HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8
Connection: close
Content-Length: 2

ok

Watch what that compile step never ran: no as, no ld, no cc. One Node process read the .flx and wrote a finished executable, and how it manages that is a whole story of its own, which I tell in the companion post. Today we read what it wrote.

Chapter 2120 bytes of paperwork, then code

Every Linux executable opens with an ELF header, and most binaries follow it with a small bureaucracy: program headers for a dozen segments, dynamic-linking tables, symbol tables, twenty-odd sections of varying dignity. I kept the paperwork to the legal minimum. Here are the first 208 bytes of the file:

xxd httpd.elffirst 208 bytes
00000000: 7f45 4c46 0201 0100 0000 0000 0000 0000  .ELF............
00000010: 0200 3e00 0100 0000 7800 4000 0000 0000  ..>.....x.@.....
00000020: 4000 0000 0000 0000 af76 0100 0000 0000  @........v......
00000030: 0000 0000 4000 3800 0100 4000 0300 0200  ....@.8...@.....
00000040: 0100 0000 0700 0000 0000 0000 0000 0000  ................
00000050: 0000 4000 0000 0000 0000 4000 0000 0000  ..@.......@.....
00000060: fa77 0100 0000 0000 fa77 0100 0000 0000  .w.......w......
00000070: 0010 0000 0000 0000 488b 0424 4889 05c5  ........H..$H...
00000080: 7001 0048 89c2 488d 4424 0848 8905 be70  p..H..H.D$.H...p
00000090: 0100 4883 c201 48c1 e203 4801 d048 8905  ..H...H...H..H..
000000a0: b470 0100 4889 25d5 7001 00e8 1cc9 0000  .p..H.%.p.......
000000b0: e87c c800 00e8 0c00 0000 4831 ff48 c7c0  .|........H1.H..
000000c0: 3c00 0000 0f05 5548 89e5 4881 ecb0 2900  <.....UH..H...).
Offset 0x00: 7f 45 4c 46 — the ELF magic, then class 2 (64-bit) and endianness 1 (little). Offset 0x18: 78 00 40 00 — the entry point, 0x400078. Offset 0x38: 01 00 — one program header; 40 00 03 00 — three sections.

The entry point is 0x400078, the file loads at virtual address 0x400000, and 0x78 is 120, so the arithmetic tells you everything about the layout: 64 bytes of ELF header, 56 bytes of program header, and then machine code, immediately. Byte 121 of the file is already an instruction. There is no padding, no build-id note, no interpreter path pointing at ld-linux, because nothing here needs interpreting.

readelf confirms how little is going on:

readelf -l httpd.elfcaptured on Ubuntu 24.04
Elf file type is EXEC (Executable file)
Entry point 0x400078
There are 1 program headers, starting at offset 64

Program Headers:
  Type   Offset   VirtAddr           PhysAddr           FileSiz  MemSiz   Flg  Align
  LOAD   0x000000 0x0000000000400000 0x0000000000400000 0x0177fa 0x0177fa RWE  0x1000
One LOAD segment mapping the whole file, and FileSiz equals MemSiz, so the bytes on disk are the bytes in memory.

And the classic litmus test:

shell
$ ldd httpd.elf
    not a dynamic executable
$ file httpd.elf
httpd.elf: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked, stripped
Honest footnote

I will flag this one myself before the comments do: that RWE means the single segment is readable, writable, and executable, all at once. I store a few mutable globals (heap pointers, job state) inside the text segment, so the loader gets asked for all three permissions. Serious toolchains enforce W^X for good reasons, freelang will grow a separate RW segment eventually, and in the meantime I would rather you read the tradeoff here, and in the compiler comment where I wrote it down (companion post, Chapter 7), than discover it yourself and wonder what else I didn't mention.

Chapter 3Birth of a process

With no libc there is no crt0, no __libc_start_main, no constructor tables. When the kernel finishes execve it jumps to 0x400078, and whatever sits there is simply your program's problem. I find that bracing. Here is everything that happens before main:

httpd.elf — disassemblyllvm-objdump, labels restored from the compiler's own map
<_start>:
  400078: 48 8b 04 24                 		movq	(%rsp), %rax
  40007c: 48 89 05 c5 70 01 00        		movq	%rax, 0x170c5(%rip)     # 0x417148 <_argc>
  400083: 48 89 c2                    		movq	%rax, %rdx
  400086: 48 8d 44 24 08              		leaq	0x8(%rsp), %rax
  40008b: 48 89 05 be 70 01 00        		movq	%rax, 0x170be(%rip)     # 0x417150 <_argv>
  400092: 48 83 c2 01                 		addq	$0x1, %rdx
  400096: 48 c1 e2 03                 		shlq	$0x3, %rdx
  40009a: 48 01 d0                    		addq	%rdx, %rax
  40009d: 48 89 05 b4 70 01 00        		movq	%rax, 0x170b4(%rip)     # 0x417158 <_environ>
  4000a4: 48 89 25 d5 70 01 00        		movq	%rsp, 0x170d5(%rip)     # 0x417180 <_stack_base>
  4000ab: e8 1c c9 00 00              		callq	0x40c9cc <_freelang_heap_init>
  4000b0: e8 7c c8 00 00              		callq	0x40c931 <_freelang_init_globals>
  4000b5: e8 0c 00 00 00              		callq	0x4000c6 <main>
  4000ba: 48 31 ff                    		xorq	%rdi, %rdi
  4000bd: 48 c7 c0 3c 00 00 00        		movq	$0x3c, %rax
  4000c4: 0f 05                       		syscall
The kernel leaves argc on top of the stack with argv right behind it. The first ten instructions file them into globals (_argc, _argv, _environ), then two runtime calls, then main, and the return value leaves through syscall 60 — exit.

The first of those runtime calls, _freelang_heap_init, is the entire memory-management setup, and I do mean entire:

httpd.elf — disassembly_freelang_heap_init
<_freelang_heap_init>:
  40c9cc: 55                          		pushq	%rbp
  40c9cd: 48 89 e5                    		movq	%rsp, %rbp
  40c9d0: 48 c7 c0 09 00 00 00        		movq	$0x9, %rax
  40c9d7: 48 31 ff                    		xorq	%rdi, %rdi
  40c9da: 48 c7 c6 00 00 00 10        		movq	$0x10000000, %rsi       # imm = 0x10000000
  40c9e1: 48 c7 c2 03 00 00 00        		movq	$0x3, %rdx
  40c9e8: 49 c7 c2 22 00 00 00        		movq	$0x22, %r10
  40c9ef: 49 c7 c0 ff ff ff ff        		movq	$-0x1, %r8
  40c9f6: 4d 31 c9                    		xorq	%r9, %r9
  40c9f9: 0f 05                       		syscall
  40c9fb: 48 89 05 66 a7 00 00        		movq	%rax, 0xa766(%rip)      # 0x417168 <_heap_base>
  40ca02: 48 89 05 67 a7 00 00        		movq	%rax, 0xa767(%rip)      # 0x417170 <_heap_ptr>
  40ca09: 48 81 c0 00 00 00 10        		addq	$0x10000000, %rax       # imm = 0x10000000
  40ca10: 48 89 05 61 a7 00 00        		movq	%rax, 0xa761(%rip)      # 0x417178 <_heap_limit>
  40ca17: 5d                          		popq	%rbp
  40ca18: c3                          		retq
syscall 9 is mmap, System V argument order: addr=NULL, length=0x10000000 (256 MB), prot=3 (read|write), flags=0x22 (MAP_PRIVATE|MAP_ANONYMOUS). The result becomes _heap_base and _heap_ptr, base plus 256 MB becomes _heap_limit, and from here on allocation is a bump pointer with a GC behind it.

Ask the kernel for one big arena, set three globals, done. And because the address space holds only what the program put there, the memory map of a running web server fits on a business card:

cat /proc/$(pidof httpd.elf)/mapsserver running, mid-request
00400000-00418000 rwxp 00000000 08:01 265681                             /root/capture/httpd.elf
7f5076800000-7f5086800000 rw-p 00000000 00:00 0 
7fff37adb000-7fff37afc000 rw-p 00000000 00:00 0                          [stack]
7fff37b43000-7fff37b47000 r--p 00000000 00:00 0                          [vvar]
7fff37b47000-7fff37b49000 r-xp 00000000 00:00 0                          [vdso]
ffffffffff600000-ffffffffff601000 --xp 00000000 00:00 0                  [vsyscall]
Line 1 is the binary, line 2 is the 256 MB heap arena, then the stack and the three mappings every Linux process receives from the kernel (vvar, vdso, vsyscall). I checked strace for comparison, a modest C utility, and it maps around fifty regions before doing anything at all.

Chapter 4A complete life, in 21 system calls

System calls are the only way any program touches the world, which makes strace the most honest witness there is. So here is the entire trace of the server booting in once mode, serving a single GET /health, and exiting. I have elided nothing:

strace -tt ./httpd.elf once 8081complete, unedited
01:54:46.729351 execve("./httpd.elf", ["./httpd.elf", "once", "8081"], 0x7ffd17e63318 /* 19 vars */) = 0
01:54:46.730250 mmap(NULL, 268435456, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7e6978c00000
01:54:46.730377 socket(AF_INET, SOCK_STREAM, IPPROTO_IP) = 3
01:54:46.730501 setsockopt(3, SOL_SOCKET, SO_REUSEADDR, [1], 4) = 0
01:54:46.730665 bind(3, {sa_family=AF_INET, sin_port=htons(8081), sin_addr=inet_addr("127.0.0.1")}, 16) = 0
01:54:46.730798 listen(3, 16)           = 0
01:54:46.730876 getsockname(3, {sa_family=AF_INET, sin_port=htons(8081), sin_addr=inet_addr("127.0.0.1")}, [16]) = 0
01:54:46.730972 write(1, "httpd listening on 127.0.0.1:808"..., 34) = 34
01:54:46.731083 write(1, "mode: once (one request then exi"..., 35) = 35
01:54:46.731162 write(1, "try: curl -s http://127.0.0.1:80"..., 36) = 36
01:54:46.731256 write(1, "try: curl -s http://127.0.0.1:80"..., 42) = 42
01:54:46.731341 accept(3, {sa_family=AF_INET, sin_port=htons(44748), sin_addr=inet_addr("127.0.0.1")}, [16]) = 4
01:54:47.746008 write(1, "conn from 127.0.0.1:44748\n", 26) = 26
01:54:47.746251 poll([{fd=4, events=POLLIN}], 1, 3000) = 1 ([{fd=4, revents=POLLIN}])
01:54:47.746421 recvfrom(4, "GET /health HTTP/1.1\r\nHost: 127."..., 512, 0, NULL, NULL) = 83
01:54:47.747072 write(1, "  GET /health -> 200\n", 21) = 21
01:54:47.747295 sendto(4, "HTTP/1.1 200 OK\r\nContent-Type: t"..., 100, MSG_NOSIGNAL, NULL, 0) = 100
01:54:47.747523 close(4)                = 0
01:54:47.747622 close(3)                = 0
01:54:47.747700 write(1, "httpd done\n", 11) = 11
01:54:47.747768 exit(0)                 = ?
01:54:47.747937 +++ exited with 0 +++

Read it top to bottom and the program reconstructs itself:

Twenty-one syscalls, birth to death. For scale, /bin/true, a program whose entire job is to do nothing, makes thirty on the same machine, every one of them dynamic-linker overhead. The tally:

syscallcalls
write7one per print
close2connection + listener
socket · setsockopt · bind · listen · getsockname5server setup
accept · poll · recvfrom · sendto4the request itself
execve · mmap · exit3process lifecycle

The absences say as much as the entries. No futex, because there are no threads. No epoll, because this server takes one connection at a time and admits it. No rt_sigaction, no brk, no openat. When people ask me what "no magic" means in practice, this trace is my favorite answer: every line of it maps to a line of source I can show you.

Chapter 5When the peer goes silent: chaos, compiled

FreedomLang's design bet is written on the tin: world failures are data, bugs are fatal. A network timeout is one of the network's normal moods, so freelang hands it to you as a value, a chaos tag, and your code matches on it the way it matches on anything else. In the source:

examples/httpd.flxlines 34–43
  let req = http_read_request[sock, 1024, 3000] with chaos {
    Timeout(_) => -2
    Closed(_) => -3
    _ => -5
  };
  if (req == -2) (
    print "  recv timeout";
    tcp_close[sock] with chaos { _ => 0 };
    => 2;
  )

To watch this fire for real I connected to the serving binary and sent nothing at all for four seconds. The wire view:

strace — serve modeclient connects, stays silent
01:53:39.759577 accept(3, {sa_family=AF_INET, sin_port=htons(55862), sin_addr=inet_addr("127.0.0.1")}, [16]) = 4
01:53:40.066400 write(1, "conn from 127.0.0.1:55862\n", 26) = 26
01:53:40.066572 poll([{fd=4, events=POLLIN}], 1, 3000) = 0 (Timeout)
01:53:43.070051 write(1, "  recv timeout\n", 15) = 15
01:53:43.070418 close(4)          = 0
poll waits out its full 3000 ms and returns 0, which is all the kernel has to say about silence. Everything that turns that bare zero into Timeout is below.

Here is the receiving intrinsic inside the binary, the exact code that ran during that trace. poll is syscall 7:

httpd.elf — disassembly_freelang_net_tcp_recv_timeout · syscall + verdict
  415ad9: 49 89 d6                    		movq	%rdx, %r14
  415adc: 49 d1 fc                    		sarq	%r12
  415adf: 44 89 24 24                 		movl	%r12d, (%rsp)
  415ae3: 66 c7 44 24 04 01 00        		movw	$0x1, 0x4(%rsp)
  415aea: 66 c7 44 24 06 00 00        		movw	$0x0, 0x6(%rsp)
  415af1: 48 c7 c0 07 00 00 00        		movq	$0x7, %rax
  415af8: 48 89 e7                    		movq	%rsp, %rdi
  415afb: 48 c7 c6 01 00 00 00        		movq	$0x1, %rsi
  415b02: 4c 89 f2                    		movq	%r14, %rdx
  415b05: 48 d1 fa                    		sarq	%rdx
  415b08: 0f 05                       		syscall
  415b0a: 48 85 c0                    		testq	%rax, %rax
  415b0d: 0f 88 63 00 00 00           		js	0x415b76 <Lrt_pf_210>
  415b13: 0f 84 13 00 00 00           		je	0x415b2c <Lrt_pto_211>
  415b19: 4c 89 e7                    		movq	%r12, %rdi
  415b1c: 48 d1 e7                    		shlq	%rdi
  415b1f: 4c 89 ee                    		movq	%r13, %rsi
  415b22: e8 e7 fb ff ff              		callq	0x41570e <_freelang_net_tcp_recv>
  415b27: e9 39 03 00 00              		jmp	0x415e65 <Lerr_res_213>
Lrt_pto_211:
  415b2c: 48 8d 3d 71 06 00 00        		leaq	0x671(%rip), %rdi       # 0x4161a4 <_cstr_Timeout_7>
  415b33: 48 c7 c6 07 00 00 00        		movq	$0x7, %rsi
  415b3a: e8 4f c7 ff ff              		callq	0x41228e <_freelang_make_ascii_string>
  415b3f: 48 83 ec 10                 		subq	$0x10, %rsp
  415b43: 48 89 04 24                 		movq	%rax, (%rsp)
  415b47: 48 8d 3d f9 06 00 00        		leaq	0x6f9(%rip), %rdi       # 0x416247 <_cstr_recv_4>
  415b4e: 48 c7 c6 04 00 00 00        		movq	$0x4, %rsi
  415b55: e8 34 c7 ff ff              		callq	0x41228e <_freelang_make_ascii_string>
  415b5a: 48 89 c2                    		movq	%rax, %rdx
  415b5d: 48 8b 3c 24                 		movq	(%rsp), %rdi
A pollfd gets built directly on the stack (fd, events=POLLIN), then syscall, then a three-way verdict on rax: negative (js) means an errno to classify, zero (je) means timeout, positive falls through to the real recvfrom. Those sarq instructions are freelang unboxing its tagged integers — values carry a one-bit type tag that the kernel must never see.

Follow the je branch and you find the timeout being manufactrued, literally, as a value:

httpd.elf — disassemblythe timeout branch
Lrt_pto_211:
  415b2c: 48 8d 3d 71 06 00 00        		leaq	0x671(%rip), %rdi       # 0x4161a4 <_cstr_Timeout_7>
  415b33: 48 c7 c6 07 00 00 00        		movq	$0x7, %rsi
  415b3a: e8 4f c7 ff ff              		callq	0x41228e <_freelang_make_ascii_string>
  415b3f: 48 83 ec 10                 		subq	$0x10, %rsp
  415b43: 48 89 04 24                 		movq	%rax, (%rsp)
  415b47: 48 8d 3d f9 06 00 00        		leaq	0x6f9(%rip), %rdi       # 0x416247 <_cstr_recv_4>
  415b4e: 48 c7 c6 04 00 00 00        		movq	$0x4, %rsi
  415b55: e8 34 c7 ff ff              		callq	0x41228e <_freelang_make_ascii_string>
  415b5a: 48 89 c2                    		movq	%rax, %rdx
  415b5d: 48 8b 3c 24                 		movq	(%rsp), %rdi
  415b61: 48 83 c4 10                 		addq	$0x10, %rsp
Two strings baked into the binary, "Timeout" and "recv", go into _freelang_make_chaos_tag, which heap-allocates a small object: { tag: "Timeout", arg0: "recv" }. The failure now rides back to the caller in rax like any successful result would.

And back in the compiled handle_conn, the with chaos block stands revealed as what it always was, an if-statement:

httpd.elf — disassemblyhandle_conn · the "with chaos" test
  40a270: e8 f1 ec ff ff              		callq	0x408f66 <http_read_request>
  40a275: 48 83 c4 18                 		addq	$0x18, %rsp
  40a279: 48 89 45 f0                 		movq	%rax, -0x10(%rbp)
  40a27d: 48 8b 45 f0                 		movq	-0x10(%rbp), %rax
  40a281: 48 89 85 48 da ff ff        		movq	%rax, -0x25b8(%rbp)
  40a288: 48 8b 45 f0                 		movq	-0x10(%rbp), %rax
  40a28c: 48 89 c7                    		movq	%rax, %rdi
  40a28f: e8 ea 34 00 00              		callq	0x40d77e <_freelang_is_chaos_tag>
  40a294: 48 89 45 d0                 		movq	%rax, -0x30(%rbp)
Call the risky operation, ask _freelang_is_chaos_tag about the result, branch. The handler arms (Timeout(_) => -2, …) become tag-string comparisons down the taken branch, the happy path pays one call and one test, and you will search this binary in vain for unwind tables, landing pads, or an .eh_frame section. The error path is control flow you can point at.

Bugs get the opposite treatment, and this asymmetry is the whole philosophy. Indexing past a byte array, touching a field that does not exist, reaching an impossible state: none of these earn a tag, because a program caught lying to itself must stop, loudly, before the lie spreads. You handle weather. You do not let patient zero keep mingling with the dinner guests.

Chapter 6Where 96,250 bytes go

Since the compiler knows where it put every function, I can bill the binary precisely. The machine code, 94,480 bytes of the file, divides like so:

regionbytessharewhat it is
native runtime41,56844.0%allocator, GC, chaos machinery, string/bytes ops, syscall stubs
compiled stdlib27,96329.6%f/http, f/net, f/strings… — ordinary .flx, compiled along with the program
the program24,65026.1%main + four ops from httpd.flx
string literals2990.3%"Timeout", "recv", log messages…

So a quarter is the program, a third is library code the program credited, and the biggest slice is the runtime standing in for libc. The stdlib costs what you use: credits: <f/http> pulls in the HTTP helpers, and leaving it out leaves them out. There is a fun lesson in the single-file teaching twin, httpd-learn.flx, which skips f/http entirely, hand-writes its responses as byte-array literals, and lands bigger, 122 KB, becuase 192 bytes of HTML encoded as 192 integer literals is a spectacularly inefficient way to store HTML. Educational in more ways than one.

One more property deserves its own paragraph. With no linker in the loop, no timestamps, no embedded build paths, the build has nothing to be nondeterministic with, and while writing this post I compiled the same source on my Mac and on an Ubuntu box and got the same file, bit for bit:

sha256summacOS build vs. Linux build of the same source
# macOS (the compiler runs anywhere Node runs; the output targets Linux)
5fb2047b504f78998c951d343eb4238e623a4fb3934f7397ed694026742c8f7e  httpd.elf
# Ubuntu 24.04
5fb2047b504f78998c951d343eb4238e623a4fb3934f7397ed694026742c8f7e  httpd.elf

Chapter 7Read your own copy

shellNode 22+ · x86-64 Linux (or any host, targeting Linux)
$ git clone https://github.com/DO-SAY-GO/freelang.git && cd freelang
$ node freelang.js examples/httpd.flx httpd.elf --target=linux --emit=bin
$ readelf -hlS httpd.elf          # Chapter 2
$ ./httpd.elf 8080 &              # then: cat /proc/$!/maps   (Chapter 3)
$ strace -tt ./httpd.elf once 8081 &
$ curl -s http://127.0.0.1:8081/health   # Chapter 4
$ sleep 4 | nc 127.0.0.1 8080     # Chapter 5 — watch "recv timeout" appear
Boundaries

Let me draw the box before anyone asks me to. This is a demonstration: x86-64 only, fixed GET routes, one connection at a time, Connection: close, loopback only, no TLS, plus the RWX segment from Chapter 2. What it demonstrates is the part I care about, that the whole stack from language syntax down to the socket can be small enough to read in an afternoon, and honest enough that reading it settles arguments.

The question this post leaves standing is the one from Chapter 1: what kind of compiler writes a finished ELF executable byte by byte from JavaScript, with no assembler and no linker behind it? Mine does, and the companion post walks through exactly how: No assembler, no linker. And if you build your own copy and the disassembly shows you something I got wrong, tell me — the repo is open and I answer.