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.
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.
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:
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:
$ 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:
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...).
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:
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
And the classic litmus test:
$ 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
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:
<_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
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:
<_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
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:
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]
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:
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:
- Lines 1–2, being born.
execve, then the onemmapfrom Chapter 3. A glibc program spends its first thirty lines here, openingld.so.cache, mapping libc, negotiating thread-local storage. We have nothing to negotiate. - Lines 3–7, becoming a server.
socket -> setsockopt(SO_REUSEADDR) -> bind(127.0.0.1:8081) -> listen(16) -> getsockname. The textbook sequence, one syscall per line, because each one is a FreedomLang intrinsic backed by a hand-written stub. Look at the bind address, too: I compiled the loopback in. You get a public server by editing source, never by fumbling a config. - Lines 8–11, talking.
printiswrite(1, …). There is no stdio buffering layer, so everyprintin the source is exactly onewritein the trace, and you can count my print statements from here without opening the file. - Lines 12–15, the request.
acceptblocks, and the timestamp jump is me typing the curl. Thenpollguards the socket with a 3000 ms budget, andrecvfromcollects all 83 bytes of "GET /health HTTP/1.1…" in one read. - Lines 16–18, the response. Routing happens between the lines, since comparing bytes needs no kernel. One
sendtopushes the whole 100-byte response, flaggedMSG_NOSIGNALbecause a program with no signal handlers would die ofSIGPIPE, and I would rather have the errno. Thenclose. - Lines 19–21, dying. Close the listener, say goodbye,
exit(0).
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:
| syscall | calls | |
|---|---|---|
| write | 7 | one per print |
| close | 2 | connection + listener |
| socket · setsockopt · bind · listen · getsockname | 5 | server setup |
| accept · poll · recvfrom · sendto | 4 | the request itself |
| execve · mmap · exit | 3 | process 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:
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:
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:
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
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:
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
_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:
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)
_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:
| region | bytes | share | what it is |
|---|---|---|---|
| native runtime | 41,568 | 44.0% | allocator, GC, chaos machinery, string/bytes ops, syscall stubs |
| compiled stdlib | 27,963 | 29.6% | f/http, f/net, f/strings… — ordinary .flx, compiled along with the program |
| the program | 24,650 | 26.1% | main + four ops from httpd.flx |
| string literals | 299 | 0.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:
# 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
$ 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
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.