Beneath C: A 4,092-Byte Web Server, the Price of Hardening, and Returning Without libc
A note to the reader: this is a long, engineering-heavy article - disassembly, Linux kernel structures, measurements on real hardware.
On dates and numbers: the article is written as a chronicle. The measurements and binary sizes in the text are snapshots taken at the corresponding stage: 3,944 bytes (MIPS, before the security fixes), 4,062 bytes (FASM x86-64, a past branch). Both builds are absent from the current tree. The current state is in the section "Appendix: what is in the tree today" at the end: 4,092 bytes on MIPS and three C profiles. The historical numbers were not rewritten, so as not to destroy the account of how they were obtained.
Prologue: The Illusion of "Portable Assembly" and the Choice of Weapon
By the end of 2026 the industry had fully settled into the idea that a microservice in a container weighs 300 megabytes, and a plain "Hello World" binary eats megabytes of memory at startup. It is taken for granted that memory is cheap, compilers are smarter than humans, and writing low-level code by hand is pointless.
But the picture changes radically when you step down from cloud servers to embedded systems - to the place where hundreds of millions of home routers, IoT devices and industrial controllers run on MIPS and ARM chips with tiny 8-16 MB of flash memory.
C is traditionally called "portable assembly". In practice, however, between a line of C code and the processor's silicon lies a colossal layer of abstractions: calling conventions (ABI), stack frames, locale tables, heap allocators, and the standard library (glibc, musl or uClibc). Even before the first instruction of main() executes, the operating system's dynamic loader unfolds a runtime weighing close to a megabyte into memory.
This research begins with a fundamental question: what lies below C, and what is the real price paid for the abstractions of the standard library?
The reference testing ground chosen is the well-known minimalist web server darkhttpd (written in pure C). The goal of the experiment is to recreate its capabilities in pure assembly, break the physical barrier of a single hardware memory page (Sub-4KB), and then climb back up to academic C, completely free of libc and external dependencies.
The path turns out to be not a straight line but a spiral: starting from C, it will descend to the bottom of assembly, hit a limit from which not a single byte can be squeezed out without losing correctness, and only then return to C - but by a different road.
Part I. The Descent
Act I. Cold Shower on x86-64: When the Prototype Loses to Architecture
In the initial stage, straightforward prototype servers were built in FASM and GAS assembly for Linux x86-64 (using direct syscalls without libc) and benchmarked using wrk (2 threads, 20 connections) against the original C darkhttpd. The prototypes used a primitive linear accept without connection multiplexing, whereas darkhttpd ran on an event-driven poll() loop. This early test measured the vast gap between I/O architectures, rather than the programming languages:
| Parameter |
FASM (v1, raw prototype) |
GAS (v1, no -N and sstrip) |
Native C (darkhttpd.c) |
| Binary size |
7.4 KB |
12 KB |
56 KB (+ glibc) |
| Peak RAM |
~16 KB (4 pages)* |
~16 KB* |
916 KB (VmHWM at idle) |
| RPS (1 KB file) |
5,118 req/s |
5,695 req/s |
10,403 req/s |
| Throughput (256 KB) |
641 MB/s |
638 MB/s |
1,040 MB/s |
The lesson of the first act was unexpected. The raw assembly prototype lost to C by almost a factor of two in speed, while being 7-13 times smaller. And here it is important to name the cause precisely, because it is easy to confuse: it was not the assembly that lost, but the prototype's architecture. It had a linear accept loop with blocking reads, which could not hold twenty concurrent connections as effectively as poll() inside darkhttpd. The language had nothing to do with it: in C with the same linear loop the numbers would have been the same. What darkhttpd had going for it was the architecture, not the compiler.
Two conclusions grew out of this, and both defined all further work:
- Size by itself means nothing. A small binary that cannot hold connections is useless. Sub-4KB without multiplexing would not be worth anything.
- A language's defeat cannot be blamed on the language. The first reaction to the table above is "assembly is slow". The control measurement at the end of this act shows otherwise: when both sides have the same architecture, assembly is on par or faster. The rake was the missing
epoll, and Act II is about stepping off it.
Control measurement of the final builds
The table above is the first measurement of raw prototypes. When the article was being prepared for publication, all three implementations were rebuilt and measured under identical conditions: wrk -t2 -c50 -d10s, keep-alive, 1 KB file, Intel Core i5-3230M, Linux 6.1.0. darkhttpd 1.17 was built with cc -O2 from the sources in old/darkhttpd_c/. Each server was run alone, so the measurements did not compete for cores.
| Implementation |
Size |
RPS (1 KB) |
RPS (256 KB) |
RPS (16 B, close) |
darkhttpd 1.17, C + glibc |
56,256 B |
18,000 (17.7-19.2k) |
2.13 GB/s, 9,131 req/s |
4,039 |
| C profile without libc |
17,008 B |
41,733* (24.6-43.7k) |
1.12 GB/s, 4,694 req/s |
- |
| FASM x86-64 |
4,062 B |
23,000-44,000** |
1.22 GB/s, 5,126 req/s |
830 |
The control measurement closes two questions left by the first act:
- The defeat of Act I was a defeat of architecture, not language. On the small file FASM is twice as fast as
darkhttpd, and the C profile without libc is on par or faster. When both sides have epoll, assembly does not fall behind: the gap from the first table disappeared together with the linear accept loop.
- The size difference does not produce a speed difference on a small file. 4,062 and 56,256 bytes on a 1 KB file hit the same wall - the cost of system calls and parsing the request line. On the large file
darkhttpd is faster: 2.13 GB/s versus 1.22, because it makes fewer system calls per byte of payload. Size wins not in speed but in memory: 916 KB of RAM for darkhttpd versus tens of kilobytes for assembly, and on embedded hardware with 128 MB of RAM that matters more than any throughput percentages.
The lesson of the first act was unexpected - but it was a lesson about architecture, not about languages.
Act II. Triumph on x86-64: Sizecoding, epoll, prefork and TCP_NODELAY
If the diagnosis from Act I is correct and the defeat was in the architecture, then the cure is architecture, not rewriting the loop in a different flavor of assembly instructions. That is what was done: the same mechanisms darkhttpd stood on were ported into the assembly implementations, plus two it did not have. Four key changes:
- Sizecoding and a single RWX segment: all uninitialized buffers moved out of the data section into
.bss (rb in FASM), which zeroed their on-disk size. The linker flag ld -N removed the 4 KB inter-section page alignment, and stripping the section table (sstrip) compressed the binary to 3.9 KB, fitting it entirely into the processor's level-1 instruction cache (L1i).
- Hardware prefork in 14 bytes of code: two consecutive
sys_fork calls (syscall 57 in the x86-64 ABI) right after sys_listen split the server process into 4 parallel workers matching the CPU core count, without the pthread library. Memory works in Copy-on-Write mode (~16-20 KB of physical RAM per worker: the code page, the stack page, and the request buffer pages).
- The kernel multiplexer
sys_epoll: replacing the linear accept loop with the kernel's non-blocking event queue - raw syscalls epoll_create1 (291 in the x86-64 ABI), epoll_ctl (233) and epoll_wait (232), keeping Keep-Alive sessions open.
- Removing Nagle's handbrake (
TCP_NODELAY): disabling Nagle's algorithm via setsockopt eliminated the 40-millisecond TCP Delayed ACK penalty when sending HTTP headers (write) and the file body (sendfile) separately.
The control test results on x86-64 (wrk, keep-alive on):
| Parameter |
FASM (epoll + prefork) |
Native C (darkhttpd) |
Result |
| RPS (1 KB file), original measurement |
27,719 req/s |
11,077 req/s |
Assembly 2.5x faster (+150%) |
| Median latency |
473 us (0.47 ms) |
1.50 ms |
3.1x more responsive |
| Throughput (4.4 KB file), re-measured |
120.9 MB/s (27,372 req/s) |
43.0 MB/s (9,495 req/s) |
Assembly 2.8x faster |
| Binary size |
4,062 bytes (Sub-4KB!), FASM, historical stage |
56 KB |
13.8x smaller |
| RAM (Peak RSS) |
~16-20 KB (per worker) |
916 KB (VmHWM) |
45-55x less memory |
Here assembly took its revenge: 27,719 requests per second against 11,077 for C, with three times lower latency and a fourteen times smaller size. The Sub-4KB barrier was taken on x86-64.
On re-measurement the gap narrowed. darkhttpd 1.17 on the same host in the same mode gives 17.7-19.2k req/s, FASM - from 23 to 44k depending on how the scheduler divides the 4 cores between the server's 4 workers and the wrk threads. That is, assembly is faster, but by 1.3-2.3x, not 2.5x. The value of 11,077 came out lower because in that measurement darkhttpd ran under different conditions. Both numbers are kept: the original as chronicle, the re-measured as verifiable.
Re-measurement during publication prep. The baseline numbers above were captured on unspecified hardware, and one line contained an error: the 1.29 GB/s throughput was measured on a 4.4 KB file rather than 1 KB as the header stated. During preparation, both builds - whitehttpd-fasm-x86_64 (4,062 bytes) from the repository archive and the freestanding C profile - were re-benchmarked on an i5-3230M, 4 cores, Linux 6.1.0, wrk -t2 -c50 -d10s, keep-alive:
| Parameter |
FASM 4,062 |
C profile 17,008 |
Ratio |
| RPS (1 KB file) |
27 466 (repeat 26 752) |
16 962 |
1.6× faster |
| Throughput (4.4 KB) |
99.6 MB/s |
21.7 MB/s |
4.6× faster |
| Latency (75% / median, 1 KB) |
1.82 ms / 0.43 ms |
not measured |
- |
The 27,719 figure was confirmed within one percent. The ratio against C shifted: the original 2.5x was calculated against the original darkhttpd with glibc, whereas the re-measurement gave 1.6x against the freestanding C profile of our own project, which also utilizes epoll and preforking. The throughput on the 4.4 KB file is bounded by loopback rather than server code, so it cannot be used to judge implementation efficiency.
But x86-64 is a server in a VM. The real test waited on real silicon.
Act III. Moving to Real Silicon: MIPS32 Big-Endian and the Battle for Sub-4KB
After the concept was proven on x86-64, development moved to the hardware platform of the home router ASUS RT-AC57U V3 (Qualcomm Atheros QCA9563 SoC, MIPS 74Kc Big-Endian core, Linux 3.3.8). On this architecture the original statically linked C darkhttpd with glibc weighs 927 KB (mipsel-linux-gnu-gcc -static -Os -s); the figure of 119.2 KB from an early draft of this article does not reproduce with any available library and has been replaced here with the measured value. The dynamically linked variant takes 56 KB on x86-64 but requires glibc in the router firmware, which is not guaranteed on OpenWrt or Padavan. It is precisely the size of the static build that is the reason writing without libc makes sense on MIPS.
For MIPS the only real tool is the GNU Assembler (GAS). The project was named whitehttpd.
The first working port in GAS MIPS32 showed a size of 6,608 bytes. The task was set: break the physical barrier of 4,096 bytes (Sub-4KB - exactly one hardware page of virtual memory and one TLB entry), preserving all of the functionality of that moment: IPv6/IPv4 dual-stack, epoll, sendfile, Keep-Alive, urldecode, 301 redirects and the dynamic HTML directory listing (sys_getdents64). The listing would be removed later, when the budget ran out (see the section on the security fix).
Four steps of compression to 3,944 bytes
- The
$gp register and the 64 KB barrier (6,608 -> 6,072 B): in the MIPS RISC architecture every instruction weighs 4 bytes, so loading a 32-bit address with the la pseudo-instruction expands into a lui + addiu pair (8 bytes). Placing string constants and small buffers (< 18 KB) in the .sdata and .sbss sections made it possible to replace two-word loads with single 4-byte instructions with an offset from the Global Pointer register (%gp_rel), saving 536 bytes. The large buffers (dents_buf at 8 KB and page_buf at 64 KB) were moved to .bss and addressed through a single base register $s7.
- The
.set noreorder pipeline and the end-to-end $a0 chain (6,072 -> 4,616 B): manually placing useful instructions into the hardware branch delay slots freed the code from automatic nop padding. At the same time strcpy was redesigned so that after copying, the $a0 register pointed exactly at the written terminal \0, and uint_to_str ended with a tail jump into strcpy. This made it possible to assemble complex HTTP headers in a streaming fashion, completely eliminating the strlen and strcat functions.
- "Brainstorm Ultra" structural synthesis (4,616 -> 4,344 B): merging the
sigpipe_act structure (used once at startup), the socket constants opt_zero (0) and opt_val (1), and the epoll_event structure template (with the EPOLLIN = 1 flag pre-written) into a single 24-byte data array, plus deduplicating the ; charset=utf-8 suffix in the MIME table.
- ELF header cleanup, in-place decoding and elimination of stack frames (4,344 -> 3,944 B): pre-removing the
.MIPS.abiflags and .reginfo service sections from the object file with objcopy -R, cutting off the section table (sstrip), removing stack frames in get_mime_type and send_error, replacing print_str with direct streaming assembly, and inlining URL decoding directly into the path_buf buffer.
The executable took the barrier at 3,944 bytes on disk and 20 KB of physical RAM in production mode (while serving a WebGL 3D application through sendfile, thanks to the kernel's lazy demand paging). On a 20-thread stress test the server showed 309 RPS (8.71 MB/s of payload), hitting the physical limit of the router's 100-megabit port.
That measurement concerns serving a large body. On the re-shoot while preparing this article the same build on a small file gave something else entirely: 1,538 req/s in Keep-Alive mode and 208 KB/s on a 16-byte file. There is no contradiction - these are different questions. 309 RPS at 8.71 MB/s measures how many bytes the network passes, and the bottleneck there is the port. 1,538 RPS on 16 bytes measures how many requests the processor can parse, and the bottleneck there is the server itself. Both numbers are correct and do not contradict each other.
3,944 bytes. One memory page. Twenty kilobytes of RAM. The server holds epoll, IPv6, Keep-Alive, sendfile, the directory listing and 301 redirects. The listing would vanish a few months later, when the symlink root escape came to light; the reader will learn of it in the section on the price of security.
It seemed the goal was reached and all that remained was to write the article about it.
Part II. The Floor
Act IV. The Symlink That Broke Everything
1. The question that should have been asked earlier
The open call had the O_NOFOLLOW flag, and that flag was set on the final component of the path. It gave a warm feeling of safety: a symlink in place of a file is rejected by the kernel.
But O_NOFOLLOW on the last component says nothing about the components before it. A symlink in place of an intermediate directory passes the check unnoticed: the kernel follows it, descends into the target directory, and then opens the file there - honestly, with O_NOFOLLOW, since the final component really is a file.
The question "what if the symlink is not the last component of the path" had to be asked at design time. It was asked later, by an external audit.
2. Reproduction
A directory outside the root was created on the router, and a symlink to it inside the root:
/tmp/outside/secret.txt -> "SECRET_OUTSIDE"
/tmp/www/escdir -> /tmp/outside (a symlink to a directory)
A request through the running server:
GET /escdir/secret.txt -> 200 OK SECRET_OUTSIDE
The server served a file lying outside the document root. This is not a theoretical vulnerability - it reproduced on the production build, which was serving port 80 at that moment.
3. Why the tests were silent
Here begins the most unpleasant part. The tests were green. All of them.
run_all.sh checked the direct file symlink - the one that O_NOFOLLOW correctly blocked. The test passed and created a feeling of safety.
- A symlink to an intermediate directory was checked by nobody. Such a test simply did not exist.
- The project had a file
test_state_machine.c, which re-implemented a copy of the finite state machine inside the test and checked that copy, not the running server. The test could not detect an error in the server by definition: it was testing itself.
The lesson this act is written for: a green test proves nothing if it checks a copy of the logic. It is exactly the same error as "it compiles - therefore it works", one floor up. The compiler checks syntax, a copy-test checks the test author's intent, and neither checks what actually executes.
Hence the rule now in force in the project: a test must talk to a live process through a socket. Not to a function, not to a copy of the state machine, not to a model - to the process that listens on the port.
4. The first repair attempt: a frightening price
The obvious fix is to open the path component by component, applying O_NOFOLLOW at every step:
fd = open(root, O_RDONLY | O_DIRECTORY);
for (each component) {
fd = openat(fd, component, O_RDONLY | O_NOFOLLOW | O_DIRECTORY);
if (fd < 0) return 404;
}
On MIPS this costs a lot: a path-parsing loop, a separate openat for every component, error handling at every step. The first safe build came out at 5,912 bytes instead of 3,944.
Plus 1,968 bytes - almost double. At that moment it seemed that safety and Sub-4KB were incompatible: to close one hole you had to give up the achievement itself.
At the same time it turned out that the directory listing (sys_getdents64, HTML generation, name escaping) would also have to go: it cost about 200 bytes, and the budget was spent. A directory without index.html began returning 403 instead of a file list. This is a deliberate trade: the listing was given up for path safety.
5. Compression: returning to a single page
5,912 bytes is not a sentence, it is an intermediate point. Then began the work that eventually returned the size:
| Step |
Size |
What was done |
| Original vulnerable build |
3,944 |
O_NOFOLLOW on the last component only |
| First safe build |
5,912 |
component-by-component openat, listing removed |
| Compression |
4,064 |
dead listing code, MIME dedup, structure merging |
| Range per RFC 7233 |
4,080 |
three Range fixes + CLI validation |
| Final |
4,092 |
Cache-Control + 416 on overflow |
Compressing from 5,912 to 4,064 gave minus 1,848 bytes - almost as much as safety had cost. The main sources: removing the dead listing strings (dir_head, tag_a and the rest of what remained in the binary after the feature was disabled), MIME type deduplication through a shared string pool and an offset table, merging small data structures.
Then - four Range fixes that cost only 16 bytes:
bytes=-N with N >= file size returned 200 instead of 206 (RFC 7233 section 2.1 requires 206 with the full file). Fixed with a single movz instruction clamping N to the size.
bytes=-0 returned 200 instead of 416. Free: it falls into the existing from >= size check.
- An oversized
last-byte-pos (for example bytes=0-99999999999999999999) returned 200 instead of 206 with clamping. Cause: parse_dec read only 9 digits and did not recognize the rest of the number as a legal continuation.
--port 8080junk was accepted as 8080. parse_uint did not check the end of the string.
Four fixes - sixteen bytes. It turned out cheaper than expected, and it was the first good news of the whole act.
6. The last 12 bytes: caching and overflow
The final two additions required shortening the reason-phrases to fit the page.
Cache-Control: max-age=60 is emitted only on cacheable responses: 200, 206, 301. The check is a single instruction sltiu $t0, $s7, 400: codes below 400 pass, errors do not. This matters: if Cache-Control were put on 404, the browser would cache the error and not see the file that appears for a minute.
Cost: 20 bytes of code + 28 bytes of string, a measured binary growth of +44 bytes.
Why not 304 Not Modified: max-age removes the request itself, saving both latency and traffic. 304 saves only the body; the request still reaches the server, and it costs 100-150 bytes (it needs If-None-Match parsing or HTTP-date formatting). On a 16-byte budget max-age gives more benefit for less money.
Range: first-byte-pos overflow. parse_dec reads at most 9 digits. If another digit follows, the value is necessarily over a billion, which exceeds any file on the target device (128 MB RAM, 61 MB tmpfs). Such a request gets 416:
lbu $t0, 0($a0)
addiu $t1, $t0, -48
sltiu $t1, $t1, 10
bnez $t1, .L_hce_416 # 10th digit -> >= 1G -> 416
nop # delay slot: branching in it is forbidden
bne $t0, $t7, .L_do_mime # otherwise '-' is mandatory
addiu $a0, $a0, 1 # [delay slot]
Cost: 16 bytes.
7. The trap in the delay slot
The first version of the 416 fix crashed the server with SIGILL on the very first request carrying a Range header. The build passed silently; the tests failed obscurely.
The cause: bnez got a bne instruction in its delay slot. On MIPS the delay slot executes before the branch, and if it contains a branch, the processor hits undefined behavior. QEMU honestly raised SIGILL.
Fixed with a single nop. The lesson: after every edit to branching code, check the disassembly, not just a successful build. The assembler does not catch this - it does not know what you put in the delay slot.
8. What it was paid with
16 bytes were free, about 48 were needed. The reason-phrases were shortened - pure cosmetics, clients use the numeric code:
| Code |
Was |
Became |
Saving |
| 206 |
Partial Content |
Part |
11 |
| 301 |
Moved |
Mv |
3 |
| 400 |
Bad Request |
Bad Req |
4 |
| 403 |
Forbidden |
Denied |
3 |
| 404 |
Not Found |
No |
7 |
| 416 |
Range Not Satisfiable |
Range |
16 |
| 501 |
Not Implemented |
Not Impl |
7 |
|
|
total text |
51 |
In the binary this gave minus 48 bytes: three bytes were eaten by string alignment in .sdata.
It is a readability regression: 404 No instead of 404 Not Found. Functionality is untouched; codes and headers are in place. The alternative - removing --daemon (about 61 bytes) - would have preserved the full phrases, but that is losing a function, not cosmetics.
Act V. The Floor: 4,092 Bytes and the Decision to Stop
1. What came out of it
The final build: 4,092 bytes, a 4-byte margin to the page.
| Parameter |
Value |
| Size |
4,092 bytes |
| Margin to 4,096 |
4 bytes |
.text |
3,360 bytes |
.sdata |
572 bytes |
| Data segment on disk |
filesz = 0 |
VmHWM in production |
28 KB |
The full set of checks is in place: GET/HEAD, HTTP/1.0 and 1.1, Keep-Alive, pipelining, index.html, 301 redirect, Range with 206/416, 17 MIME extensions (14 unique types) with charset=utf-8, eight status codes, component-by-component openat with O_NOFOLLOW, traversal protection, dotfiles return 403, Host validation, CRLF injection protection, obs-fold rejection, Transfer-Encoding rejection, epoll, dual-stack IPv6, SIGPIPE ignored, sendfile, Cache-Control.
2. Verified on live hardware
| Check |
Result |
GET /, GET /index.html |
200, body correct, md5 matches the file on disk |
HEAD / |
200, no body |
Range 0-9, -10 |
206, 10 bytes each |
| Range overflow |
416 |
HTTP/1.0 |
version echoed, Connection: close |
| 301 on a directory |
Location with %20 preserved |
| Symlink to a file |
404 |
| Symlink to a directory |
404 |
| Path traversal |
400 |
| Directory listing |
403 (deliberate) |
| 2,000 requests, 64 threads |
2,000 / 2,000 |
| 4 MB transfer |
md5 matched |
VmHWM under load |
28 KB |
| Descriptors after load |
5 |
Separately verified: what the old 3,944-byte server let through and the new one blocks:
| Attack |
3,944 |
4,092 |
| CRLF injection in a header |
200 |
400 |
Missing Host in HTTP/1.1 |
200 |
400 |
Duplicate Host |
200 |
400 |
Transfer-Encoding in GET |
200 |
400 |
Content-Length in GET |
200 |
400 |
// in the path |
200 |
400 |
3. The price: comparison with the version running on the router
Measurements on one file (16 bytes), wrk -t2 -c50, ASUS RT-AC57U V3 router. Three builds at once on different ports, five to seven runs each, runs interleaved so drift would not skew the result:
| Build |
Connection: close |
Keep-Alive |
Against 3,944 |
| 3,944 (vulnerable) |
540 req/s |
1,442 req/s |
- |
| 5,912 (first safe) |
404 req/s |
892 req/s |
-25% / -38% |
| 4,092 (final, on the router) |
524 req/s |
1,498 req/s |
-3% / +4% |
A separate measurement on a single connection (wrk -t1 -c1, keep-alive): 3,944 - 228 req/s, 5,912 - 222 req/s, 4,092 - 232 req/s. All three equal.
The intermediate 5,912 build was noticeably slower under high concurrency: minus a quarter on connection close and minus 38% in keep-alive. These were the numbers previously taken for the price of the checks.
But the single-connection measurement says otherwise: processing one request costs all three the same, which means the checks themselves cost nothing. The difference appears only at fifty concurrent connections - which means 5,912 scaled worse, not computed slower.
The final 4,092 build performs at the level of the vulnerable one: 524 versus 540 on close and 1,498 versus 1,442 in keep-alive, the difference within noise. The compression work from 5,912 to 4,092 delivered both bytes and scalability.
Hence the conclusion that flips the intuition of the sizecoding community, where everything is measured in bytes: the most expensive check is not the one that eats bytes, but the one that eats cycles - and what eats cycles is not the check but its clumsy implementation. The same set of validations cost 38% in one build and zero in another.
4. The real contract: what it can and cannot do
All the numbers above describe size and speed. But the server has one more characteristic that articles about sizecoding usually omit: what it can actually do. Let us list it honestly, because 4,092 bytes impose hard limits, and the reader has the right to know them before deploying the server anywhere.
What it can do
| Capability |
Details |
| Methods |
GET, HEAD. HEAD without a body on all codes, including 400, 403, 404 and 416 |
| Protocol versions |
HTTP/1.0 and HTTP/1.1, the version is echoed in the response |
| Connections |
Keep-Alive, pipelining (several requests from one buffer) |
| Index file |
index.html on a directory request |
| Directory redirect |
301 with Location from the raw URL: %20 and other escapes preserved as received |
| Range |
One range: bytes=A-B, bytes=A-, bytes=-N. Responses 206 and 416 |
| MIME |
17 extensions, 14 unique types. charset=utf-8 only for textual ones |
| Caching |
Cache-Control: max-age=60 on 200, 206, 301 |
| Status codes |
200, 206, 301, 400, 403, 404, 416, 501 |
| Path protection |
Component-by-component openat with O_NOFOLLOW at every step. Traversal -> 400, symlink -> 404 |
| Dotfiles |
Any component starting with a dot -> 403 |
| Request validation |
Host (presence, uniqueness, non-emptiness), CRLF injections, obs-fold, Transfer-Encoding, Content-Length in GET -> 400 |
| Multiplexing |
epoll, one process, no threads |
| Network |
Dual-stack IPv6 + IPv4 on one port (AF_INET6 + IPV6_V6ONLY=0) |
| File serving |
sendfile with a full loop, EINTR and partial send handling |
| Signals |
SIGPIPE ignored, a client dropping the connection does not kill the process |
| CLI |
--port, --daemon |
Verified on live hardware: 2,000 requests at 64 threads without loss, 4 MB through sendfile with matching md5, VmHWM 28 KB, descriptors return to five after load.
What it cannot do
| Missing |
Consequence |
| TLS |
Only behind a proxy. Passwords and tokens in the clear |
| Directory listing |
A directory without index.html -> 403. No browsing files in a browser |
| Multiple ranges |
bytes=0-1,4-5 is ignored entirely, a 200 with the full body is returned |
ETag / Last-Modified |
Only Cache-Control. No conditional requests, no 304 |
If-Modified-Since |
The header is read and ignored, the answer is always 200 |
If-Range |
Ignored: 206 is returned even when the validator does not match |
| Compression |
Accept-Encoding: gzip is ignored, the body is always as-is |
| Authentication |
Neither Basic nor tokens |
| chroot and privilege drop |
Runs with whatever rights it was started with |
| Logs |
Only a startup message. No access log, no error log |
| CGI and any backend |
POST, PUT, OPTIONS -> 501. Nowhere to send forms |
| HTTP/2, WebSocket |
No |
| Config file |
Command-line arguments only |
--addr |
Not supported, listens on all interfaces. Unknown flags are rejected |
| Prefork |
One process, one CPU core |
| slowloris protection |
See below |
A separate word on slowloris
The server does have a garbage collector: epoll_wait is called with a 3-second timeout, and if there are no events, all descriptors from listen_fd+1 to 255 are closed. This is protection against hung connections.
But the trigger condition is zero events. While at least one connection is active, the collector does not run. Which means a client sending one byte at a time holds the connection indefinitely.
Measured: one byte every two seconds, 80 seconds - the connection is alive, the server has not dropped it. A typical request is 51 bytes, so at that pace a single connection is held for over a hundred seconds, not counting the time to answer it.
For a trusted local network this is acceptable. For a public address it is not: a few dozen such clients would exhaust the descriptor limit.
The direction of future work is not to close descriptors but to reuse them: remove from epoll, reset the connection state, return to the free pool. Then the collector could cut off truly dead connections without spending descriptors on repeated accept.
A separate word on dotfiles and ACME
The dotfile ban is protection: .git, .env, .htpasswd are never served. But it also blocks .well-known/acme-challenge/, and without it Let's Encrypt HTTP-01 certificate issuance does not work.
So the server fundamentally cannot obtain a TLS certificate by itself: one protection excludes another. A certificate can be issued on another machine and a proxy placed in front of the server - but that is already external infrastructure.
The bottom line
This implementation is not a general-purpose web server, but a static file server with a hard, limited contract. Everything listed concerns only the assembly version in GAS for MIPS32 Big-Endian.
The C implementation on nolibc closes most of this list: directory listing with escaping, --addr, --workers up to 16, a full non-blocking state machine with EPOLLOUT resume, ETag and 304, If-Modified-Since, If-Range, OPTIONS with Allow, 405 instead of 501, Date, Accept-Ranges, access and error logs, --index, --no-listing, --pidfile. Still outside the contract of both versions: TLS, CGI, WebSocket, HTTP/2, gzip, authentication, chroot, a config file and multiple ranges. That is the boundary between a size reference and a product.
Suitable for: serving static files and a web interface on modern Linux, a trusted host or local network, a separate directory without secrets, an unprivileged user, behind a proxy if TLS is needed.
Not suitable for: replacing nginx or Apache, a file manager, a public address with secrets, anything requiring authentication, form processing or logs.
5. The floor is found
4 bytes of margin. Any further addition requires either cutting an existing feature or compressing the code further.
What has already been given up: the directory listing. What could still be given up: --daemon (61 bytes), the mjs and md MIME types, compressing strstr/strcmp. Every option is losing a feature or risking a bug for a few dozen bytes.
And here it became clear: the assembly path is exhausted. Not because 4,092 is a bad number, but because what follows is no longer engineering but squeezing. Every new check is a battle for sixteen bytes, every edit requires recounting the entire data geometry.
The assembly path demands not just perseverance. It demands giving up much: readability, familiar abstractions, the ability to fix something quickly. When the goal is not merely to write working code but to fit into an artificial one-page memory limit while honestly delivering the declared features, every byte becomes a decision and every decision a compromise. It is a discipline that teaches a lot, but one cannot live in it permanently.
4,092 bytes proved the limit is reachable. Next, a decision had to be made about what to do with that knowledge.
Part III. The Ascent
Act VI. The Move to C: nolibc, a Home-Grown wsock.h, and Porting the MIPS Version
1. Why not assembly
Having hit the floor on MIPS32, the project ran into the problem of scaling protocol logic. The assembly server is blazingly fast and weightless, but what if industrial-grade mechanisms need to be added?
- A full non-blocking finite state machine with
EPOLLOUT resume (so that while serving a large file to a slow client the process does not block in sendfile but asynchronously switches between hundreds of connections);
- HTTP/1.1 pipelining support (batch processing of coalesced requests from one buffer with a
memmove shift);
- A ring timeout collector and slowloris protection;
- Extending the MIME table and new status codes.
Each of these additions in assembly is a battle for bytes that no longer exist. Hand-writing a multi-phase state machine in assembly inevitably duplicates the code base.
2. Is C to blame
And here the circle closes: is the C language itself to blame for bloated binaries?
Not at all. The GCC compiler is by nature a translator from C text to GNU Assembler (GAS) text. All the excess weight and the megabyte memory appetite comes from the standard library libc.
Dropping the standard library entirely (-nostdlib, -fno-builtin) makes it possible to combine pure C (Freestanding C99), the official single-file header nolibc.h from the Linux kernel source tree (tools/include/nolibc, written by HAProxy author Willy Tarreau specifically for building ultra-compact system utilities), and a thin home-grown layer of networking syscalls, wsock.h.
This is not "synthesizing assembly tricks into C". The MIPS version was ported to C, and the network layer, which nolibc lacks, was written separately. A deliberate step: regain the ability to develop the code, paying for it with size.
3. The anatomy of wsock.h: taming MIPS and x86 incompatibilities
Stock nolibc.h has no networking or signal support. The wsock.h header (9 syscalls and 4 structures) isolates all the architectural specifics of porting between x86-64 and MIPS32:
- Inverted socket constants in the MIPS ABI: in MIPS Linux
SOCK_STREAM is 2 (while on x86 and ARM it is 1 - on MIPS a one creates a UDP socket!), SOL_SOCKET is 0xFFFF, and the non-blocking flag O_NONBLOCK is 0x0080.
- Kernel structure geometry: on x86-64
epoll_event is packed to 12 bytes, on MIPS it is aligned to 16. In the MIPS sigaction, sa_flags comes first and the sa_handler pointer second.
- Zero-Stack syscalls for MIPS O32: the standard
nolibc macros reserve 32 bytes of stack before every syscall (addiu $sp, $sp, -32 / +32). Since the Linux kernel does not write into this area of the user stack, replacing them with stackless assembly stubs ws_syscall0..4 saves 2 instructions (8 bytes) on each of the program's 31 calls.
4. The httpd.c architecture: an academic state machine without malloc
On top of wsock.h sits a single cross-platform source httpd.c, compiled without a single #ifdef for both x86-64 and MIPS32 Big-Endian:
- Zero allocation: no
malloc, calloc or free calls at all. All state arrays and buffers are static in .bss, allocated by the kernel on demand (demand paging).
- A safe string builder (
struct buf): the structure { char *p; char *end; } (the high-level analogue of the assembly version's end-to-end $a0 pointer) makes it physically impossible to write past the end of a buffer.
- A non-blocking pipeline: when
sendfile returns EAGAIN, the descriptor is switched to waiting on WS_EPOLLOUT, preserving the body_sent offset.
5. Fixing a protocol bug in Range (206 Partial Content)
An audit of httpd.c found and fixed a subtle bug in building the 206 Partial Content response: the base function build_header() terminated the header with a double CRLF \r\n\r\n, after which the Range handler appended Content-Range: bytes ... \r\n\r\n outside the now-closed header block. The client then read the Content-Range line as the first 35 bytes of the file body.
Rolling the write pointer back 2 bytes before inserting Content-Range gave 100% RFC 7233 compliance:
/* Insert Content-Range strictly before the closing \r\n of the header block */
hb.p = g_hdr[fd] + g_hlen[fd] - 2;
6. What sizecoding means in C
Moving to C does not cancel the size question. It reframes it.
In assembly, size is a property of every line. Want it smaller - write different instructions. In C there is no such direct link: between the source and the machine code stands the optimizer, and it is often smarter than the author. The only way to check was experiment.
The largest function was taken - prepare(), request parsing, 3.8 KB on x86-64 and 4.6 KB on MIPS - and an attempt was made to shrink it six ways.
| Attempt |
Idea |
Result |
| 1 |
A single exit via goto for 15 error_response(400) calls |
+32 |
| 2 |
A switch on the first letter of the header name instead of an iequal chain |
+64 |
| 3 |
Version check via memcmp("HTTP/1.",7) instead of two strcmp |
+32 |
| 4 |
A tchar[128] table instead of strchr for name validation |
+112 |
| 5 |
Moving directory handling into a separate resolve_dir() function |
+96 |
| 6 |
A byte-by-byte memcmp scan at every position -> strstr |
-64 |
Five of the six attempts made the code bigger. The reasons are concrete:
- The single exit was unneeded: GCC already merges identical tails into a common block, and the label added a jump.
- A
switch over five cases generates a jump table, and it costs more than a comparison chain.
- A 128-byte table costs more than the
strchr calls it saves.
- Extracting a function added a prologue and duplication, and the separation gains did not cover the overhead.
The only win came not from replacing a construct with a "cleverer" one but from eliminating redundant work. The end-of-headers search was written like this:
for(i=0;i+3<c->used;i++)if(!memcmp(r+i,"\r\n\r\n",4)){end=r+i;break;}
One memcmp call with four comparisons per request byte. For a 500-byte request that is 500 calls. The buffer is already NUL-terminated after each read, so strstr(r,"\r\n\r\n") is enough. Minus 64 bytes and noticeably less work: throughput rose from 24,700 to 25,200 requests per second.
Hence a conclusion worth remembering for anyone coming to sizecoding from assembly: in C, size is determined not by how cleverly a line is written but by how much needless work the program does. The optimizer beats the author in micro-optimizations almost always. It loses only where the author sees the whole algorithm: an extra pass over a string, an extra call per byte, an extra copy.
Sizecoding in C is not a battle for bytes but a hunt for the unnecessary.
This review also exposed a bug no test had seen: the c->options field was not reset between requests on a keep-alive connection. After an OPTIONS, the next GET was handled as OPTIONS and returned 200 with an empty body. The bug survived several revisions because every test opened a fresh connection and the memset at accept zeroed the structure. It surfaced only when connection state was gathered in one place - and it became visible that one flag was missing there.
7. What the C version can do now
The easiest way to explain the difference: the assembly version answers the question "can a server fit into one memory page". The C version answers "what must a server be so that people will use it".
Here is what it does.
It serves files
This is the foundation. A file request gets an answer with the correct content type. The browser receives text/html for a page, image/png for a picture, application/javascript for a script, application/wasm for WebAssembly. Seventeen extensions in all, and textual types automatically get charset=utf-8 appended, so Russian text does not turn into mojibake.
A directory without a file name serves index.html. If there is no index file, it shows a file list where names are escaped, so a file called <script> in the list is harmless. The listing can be disabled with --no-listing, and then a directory without an index honestly answers 403. The index file can be renamed: --index home.html.
If a directory is requested without a trailing slash, the server answers with a 301 redirect - the browser adds the slash and repeats the request itself. Escapes in the address are preserved as received, so a directory with a space in its name works.
It saves traffic
Three mechanisms, all three matter on a slow link.
Caching. The server tells the browser: this file need not be re-asked for a minute (Cache-Control: max-age=60). Error codes do not carry this header - otherwise the browser would remember a 404 and miss the file that appeared a second later.
Conditional requests. Every file has a fingerprint (ETag) and a modification date (Last-Modified). When the browser returns, it sends the fingerprint, and if the file has not changed the server answers 304 Not Modified - no body. The entire file volume is saved; only headers travel. It works by fingerprint and by date alike: old clients send the date, new ones the fingerprint. If both arrive, the fingerprint wins, as the standard requires.
Resumption. A Range: bytes=1000-2000 request returns exactly that piece with code 206, not the whole file. This is what download managers and video players use when seeking. If the range is impossible (the file is shorter), a 416 arrives with the real size. If the file changed since the download began, the client sends If-Range, and the server sends the whole file instead of the piece - otherwise the client would stitch the old and new versions together.
It does not give away what it should not
Hidden files are closed. Any file or directory starting with a dot answers 403. This protects .git, .env, .htpasswd and everything else that must not leak onto the network. The single exception is /.well-known/, because without it a Let's Encrypt certificate cannot be issued. An escape through that exception is closed too: /.well-known/../.env gets 403.
Escaping the root is closed. The path is opened one component at a time, and following symlinks is forbidden at every step. A symlink to a file, a symlink to a directory, an escape attempt through .. - all of it 403. This is exactly where the hole in the 3,944-byte version was.
Garbage in the request is rejected. The server checks Host (mandatory in HTTP/1.1, must not be duplicated), rejects attempts to smuggle a line break into a header, rejects the obsolete way of folding headers onto the next line, rejects Transfer-Encoding and Content-Length where they must not be. All of that gets 400. This closes attacks that try to mix in their own response through a header.
It works with modern clients
GET and HEAD are normal requests and requests without a body. OPTIONS is the question "what can you do", and the answer lists the allowed methods. POST, PUT, DELETE, PATCH get 405 with the same list: the method is known, but a static server does not perform it. An unknown method gets 501.
HTTP/1.0 and HTTP/1.1, a connection can be kept open (keep-alive) and several requests sent without waiting for answers (pipelining). Works over IPv6 and IPv4 simultaneously on one port; the bind address is set with --addr.
Every response carries Date - without it some proxies and clients behave strangely. There is Accept-Ranges: bytes - from it the client learns that resumption is supported.
It serves many clients
Four processes by default, up to sixteen with --workers. Each holds up to 128 connections, which on four workers is a little over five hundred. Inside is a non-blocking epoll loop: while one client slowly receives a file, the server serves the others instead of waiting.
Why 128 and not more. On a router running kernel 3.3.8, memory for connection buffers is not an abstraction: with a hundred open connections the process takes 488 KB, and with 256 slots it would take 624 KB. A 136 KB difference per worker, times four workers - half a megabyte. Five hundred connections is manifestly enough for a router's web interface, so extra slots are memory given away for nothing.
Three timeouts close hung connections: 15 seconds to receive a request, 30 seconds of inactivity, 5 seconds on keep-alive between requests. A client that opened a connection and went silent is disconnected and does not occupy a slot forever.
It tells you what is going on
The --log FILE flag enables a journal. It is off by default - a router does not need extra writes to flash. When enabled, the journal writes two things: a line per request in the standard format (client address, time, request, code, size) and error messages with a startup version line. The file is rewritten when it exceeds 256 KB, so the space does not grow without bound.
The --pidfile FILE flag writes the process number - startup scripts need it to stop the server cleanly later. --daemon sends the server to the background.
It answers questions about itself
whitehttpd --version prints the version, author, license and project address. whitehttpd --help prints full help with launch examples. Every response header carries Server: whitehttpd/1.0.0.
What it still does not do
An honest list, because it matters more than the list of capabilities.
No TLS - encryption is terminated outside, in stunnel or nginx. No CGI - nowhere to send forms. No WebSocket, no HTTP/2. No on-the-fly compression. No authentication - if a password is needed, a proxy does it. No chroot, no privilege drop. No config file, only command-line arguments. Multiple ranges in one request are not supported, which the standard permits.
This is not a replacement for nginx and not a universal server. It is a static file server that does its job well and predictably.
What it looks like in numbers
|
Assembly version |
C version |
| Size |
4,092 bytes |
17,008 (x86-64) / 20,908 (MIPS) |
| Directory listing |
no |
yes |
Cache-Control |
yes |
yes |
ETag, Last-Modified, 304 |
no |
yes |
Range with 206 and 416 |
yes |
yes |
If-Range |
no |
yes |
OPTIONS and 405 |
no |
yes |
Date and Accept-Ranges |
no |
yes |
| Journal |
no |
yes |
| Index and listing configuration |
no |
yes |
| PID file |
no |
yes |
| IPv6 |
yes |
yes |
| Path protection |
yes |
yes |
| Runs on kernel 3.3.8 |
yes |
yes |
The four-to-five-fold difference in size is the price of the list above. Not "sprawl" but work the server began to do.
Act VII. Detective Debugging of the C Server on a Live Router: Linux 3.3.8 Traps
On an Ubuntu x86-64 workstation the C server httpd.c built in a fraction of a second and passed every test. But cross-compiling for MIPS32 Big-Endian (mipsel-linux-gnu-gcc -EB) and running the binary on an ASUS RT-AC57U V3 running Linux 3.3.8 exposed three fundamental system-level traps.
1. The Bus Error mystery (SIGBUS, code 138)
On the very first launch with --help the process aborted:
admin@RT-AC57U_V3:/tmp# ./httpd.mipsbe --help
Bus error
Disassembling main revealed the cause:
addiu a1, gp, -18404
sw v0, -18408(gp)
When compiled with -G 65536, GCC generated accesses to global variables through the $gp (Global Pointer) register. In ordinary C programs $gp is initialized by the crt1.o startup code from libc. But in a pure freestanding nolibc environment crt1.o is absent, and $gp holds unaligned garbage.
Unlike x86, the MIPS processor, on an attempt to store a 32-bit word (sw) at an address not a multiple of 4, raises a hardware exception and kills the process with SIGBUS.
Fix: compile the C code with -G 0, forcing GCC to use direct 32-bit addressing with lui + addiu pairs.
2. The Linux 4.11 time paradox (sys_statx vs sys_stat64)
With SIGBUS gone the server ran, but returned 404 Not Found for every existing file.
The cause was inside nolibc.h: the library wrapper for stat() unconditionally called sys_statx (number 4366), which appeared in Linux only in version 4.11 (2017). On the router's Linux 3.3.8 (2012) the call returned -ENOSYS (-38). The assembly whitehttpd.s never had the problem, because it used the classic sys_stat64 from the start.
Fix: overriding stat() in wsock.h via sys_stat64 (number 4213), supported by all MIPS Linux kernels since 2.4.
3. The 2038 bug (clock_gettime64 vs gettimeofday)
The time() call in the modern nolibc.h went to the 64-bit sys_clock_gettime64 (number 4403), added in Linux 5.1 to solve the year-2038 problem. On kernel 3.3.8 the call likewise returned -ENOSYS, so time() returned uninitialized stack values and closed connections early by timeout.
Fix: redirecting time() in wsock.h to the classic sys_gettimeofday (number 4078).
After these three fixes in wsock.h, the cross-compiler produced a monolithic static executable of 10,532 bytes (after strip -s), working flawlessly on any generation of Linux kernels.
Act VIII. Field Trials and the Price of Checks
1. The measurement arsenal and methodology
Final verification was performed directly on the ASUS RT-AC57U V3 hardware router, through the physical network segment.
sshpass + scp: automated delivery of builds into the router's RAM-backed tmpfs (/tmp), sparing the NOR flash from wear.
wrk (v4.2.0): a multi-threaded HTTP load generator for measuring peak RPS, latency and channel throughput.
curl (-i, --path-as-is): header validation against RFC 7230 and RFC 7233 (Range 206), checking 301 redirects and blocking path traversal attacks (/../../../../etc/passwd).
python3 (raw socket): testing HTTP pipelining and socket reuse (Keep-Alive).
- Kernel telemetry (
netstat -tln, ps, /proc/$PID/status): monitoring descriptors and measuring real physical memory consumption (VmHWM).
2. wrk field benchmark results
Both servers were launched on the router with an identical document root /tmp/www_test:
- Port 18081: the assembly
whitehttpd (3,944 bytes);
- Port 18082: the C server
nolibc_httpd (10,532 bytes).
Test 1. Peak RPS (index.html file, 25 bytes, 20 connections, 2 threads)
=== Benchmark 1: whitehttpd (GAS MIPS, port 18081) ===
Running 5s test @ http://192.168.5.220:18081/index.html
2 threads and 20 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 12.92ms 7.21ms 79.18ms 70.46%
Req/Sec 794.71 219.57 1.48k 75.00%
7912 requests in 5.00s, 1.12MB read
Requests/sec: 1581.53
Transfer/sec: 228.58KB
=== Benchmark 2: nolibc_httpd (Freestanding C, port 18082) ===
Running 5s test @ http://192.168.5.220:18082/index.html
2 threads and 20 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 12.45ms 6.66ms 49.94ms 79.54%
Req/Sec 825.59 235.14 1.25k 56.00%
8217 requests in 5.00s, 1.16MB read
Requests/sec: 1642.21
Transfer/sec: 237.35KB
Test 2. Pushing a heavy file (big.bin, 1,048,576 bytes, sendfile)
=== Throughput: whitehttpd (GAS MIPS, port 18081) ===
Running 3s test @ http://192.168.5.220:18081/big.bin
2 threads and 4 connections
Requests/sec: 10.32
Transfer/sec: 10.32MB/s (~82.6 Mbit/s)
=== Throughput: nolibc_httpd (Freestanding C, port 18082) ===
Running 3s test @ http://192.168.5.220:18082/big.bin
2 threads and 4 connections
Requests/sec: 11.66
Transfer/sec: 12.15MB/s (~97.2 Mbit/s)
Analysis of the results:
- On requests per second (RPS), pure assembly (1,581 RPS) and freestanding C (1,642 RPS) showed full parity. The slight advantage of the C version comes from its 4 preforked processes and non-blocking
EPOLLOUT resume.
- On transferring the megabyte file, the
nolibc server pushed 12.15 MB/s (97.2 Mbit/s of useful traffic), fully saturating the router's 100BASE-TX physical channel. The 82.6 Mbit/s figure for the assembly version was obtained over a Wi-Fi bridge (where the 802.11 radio overhead shows); on a direct Ethernet cable the kernel's sys_sendfile delivers the same 100 Mbit/s on both implementations.
3. Range header verification (RFC 7233)
Verification of partial-content responses confirmed the precision of the header builder:
$ curl -i -H "Range: bytes=0-4" http://192.168.5.220:18082/hello.txt
HTTP/1.1 206 Partial Content
Server: whitehttpd
Connection: keep-alive
Content-Type: text/plain; charset=utf-8
Content-Length: 5
Content-Range: bytes 0-4/29
Hello
The Content-Range: bytes 0-4/29 header sits strictly inside the HTTP header block, and the body contains exactly the 5 requested bytes (Hello).
4. The price list of checks
Over the course of the work, exact numbers accumulated: what each check costs. This is the most useful table in the article, because it answers the question that usually goes unanswered.
| Check |
Price |
Where measured |
Component-by-component openat instead of one open |
+1,968 |
MIPS, 3,944 -> 5,912 |
| Compression after it (dead listing, MIME dedup) |
-1,848 |
MIPS, 5,912 -> 4,064 |
| Range per RFC 7233 (3 fixes) + CLI validation |
+16 |
MIPS, 4,064 -> 4,080 |
Cache-Control |
+44 |
MIPS, 4,096 -> 4,140 (20 code + 28 string) |
416 on first-byte-pos overflow |
+16 |
MIPS |
| Shortening reason-phrases |
-48 |
MIPS, 4,140 -> 4,092 (51 bytes of text, 3 eaten by alignment) |
openat2 with RESOLVE_BENEATH instead of a manual walk |
-140 |
x86-64, kernel 5.6+ |
Host + CRLF + framing checks |
0 bytes |
MIPS, no cycle cost found in the final build |
Two rows of this table deserve a separate word.
openat2 is 140 bytes cheaper than the manual walk. On x86-64 with kernel 5.6+ the component-by-component opening loop can be replaced with a single call carrying the RESOLVE_BENEATH | RESOLVE_NO_SYMLINKS flags, and the kernel enforces the protection. This is the rare case where the safer solution is also smaller. The price: on kernels older than 5.6 the call returns ENOSYS and every request gets 403. That is why the MIPS profile (kernel 3.3.8) keeps the portable component-by-component walk.
The Host check is free in size. A few string-comparison instructions per request are zero bytes in the binary. In the first safe build the whole validation set cost 38% of throughput, but after the code compression the price vanished: 4,092 performs at the level of 3,944. What eats cycles is not the check as such but its clumsy implementation - and that is worth remembering for anyone who measures only bytes.
5. The Honest Cost: 1-4% in Assembly, 30% in C
Comparing binary sizes before and after the security audit reveals the true price of hardening:
| World |
Before audit |
After audit |
Difference |
Growth |
| FASM x86-64 |
4,062 B |
4,090 B |
+28 B |
+0.7% |
| GAS MIPS |
3,944 B |
4,092 B |
+148 B |
+3.8% |
| nolibc x86-64 |
8,480 B |
11,104 B |
+2,624 B |
+30.9% |
| nolibc MIPS BE |
10,532 B |
13,680 B |
+3,148 B |
+29.9% |
Assembly paid 28 bytes on x86-64 (+0.7%) and 148 bytes on MIPS (+3.8%). Freestanding C paid 2,624 and 3,148 bytes (+30-31% over its original size). The percentage difference is nearly tenfold, revealing a fundamental engineering lesson: in assembly, surgical security checks integrate directly into existing register branches without call-frame overhead, whereas in C, robust portable path isolation (component-by-component traversal with O_NOFOLLOW), strict protocol framing, and header parsing require structured helper functions and stack frames. Furthermore, the C implementation is more feature-complete (directory listing, IPv6 dual-stack, --addr, non-blocking epoll state machine), so the hardening was layered on a richer codebase.
The numbers in the "After audit" column are the state at the moment the vulnerabilities were closed. Afterwards the two products diverged: the assembly version stayed at 4,092 and did not change, while the C version kept developing as a product. The current sizes are in the final comparison below.
The Final Comparison of Worlds
| Characteristic |
1. Classic C |
2. Hand-written FASM |
3. Hand-written GAS MIPS |
4. Freestanding C |
| Directory in the repo |
not in the tree (external darkhttpd) |
not in the tree (historical stage) |
final-4092/ |
whitehttpd_c/ |
| Main source file |
darkhttpd.c (external) |
whitehttpd.asm (lost) |
final-4092/whitehttpd.s |
whitehttpd_c/src/httpd.c + wsock.h |
| Binary name |
darkhttpd (external) |
whitehttpd-fasm-x86_64 (lost) |
whitehttpd-mips32be |
whitehttpd-nolibc-x86_64
whitehttpd-nolibc-mips32be
whitehttpd-nolibc-mips32el |
| Server response header |
Server: darkhttpd |
Server: whitehttpd |
Server: whitehttpd |
Server: whitehttpd/1.0.0 |
| libc dependencies |
Requires glibc / musl |
0 (pure kernel) |
0 (pure kernel) |
0 (pure kernel) |
| On-disk binary size |
56 KB (x86) / 927 KB (MIPS, static) |
4,090 bytes (Sub-4KB, x86, historical) |
4,092 bytes (Sub-4KB, MIPS) |
17.0 KB (x86) / 20.9 KB (MIPS) |
| RAM in production |
128 KB - 916 KB |
~16-20 KB per worker |
28 KB (VmHWM) |
24 KB - 80 KB (per worker) |
| Source portability |
High |
x86-64 only |
MIPS32 BE only |
Absolute (one C source for x86, MIPS, ARM, RISC-V) |
| I/O architecture |
Non-blocking poll |
epoll + prefork (4 workers) |
epoll (1 process) |
Full epoll FSM + prefork (4 workers), pipelining, Range 206, timeouts |
| Directory listing |
yes |
no (403) |
no (403) |
yes, with escaping, disabled by --no-listing |
| Validators and caching |
ETag, Last-Modified |
no |
Cache-Control |
ETag, Last-Modified, 304, If-None-Match, If-Modified-Since, If-Range, Cache-Control |
| Logs |
present |
no |
no |
--log, CLF, access + error |
| Entry and maintenance threshold |
Low |
High |
Extreme (.set noreorder, $gp) |
Low (clean academic C99) |
A note on FASM: the table shows the historical 4,090-byte prototype from Acts I-II. It does not contain the security checks added in Act IV (component-by-component openat, strict Host validation, CRLF, framing) and has no Cache-Control. Porting the final MIPS version to FASM x86-64 is future work. FASM is shown as a historical stage, not a current product.
A note on repository structure: the tree holds two products - final-4092/ (assembly, 4,092 bytes, MIPS32 BE) and whitehttpd_c/ (freestanding C, three binaries: x86-64 and MIPS32 BE/LE). The intermediate GAS x86-64 port (6,023 bytes) discussed in Acts I-II and the FASM prototype are absent from the tree: they are historical stages whose numbers are preserved in the text as chronicle but can no longer be checked against sources.
Epilogue: The Circle Turned Out to Be a Spiral
The research began with the question of what lies at the very bottom of computer architecture - below the C language.
The descent to pure FASM and GAS assembly proved that a full-featured modern web server with epoll, sendfile, Keep-Alive, IPv6 support and protection against attacks can be packed into 4,092 bytes - one hardware memory page of a router, consuming 28 KB of RAM by VmHWM under load.
But on the way something unexpected happened: the smallest binary turned out to be the most wrong one. The 3,944 bytes were faster and lighter, and at the same time they served files outside the document root, passed CRLF injections and did not validate Host. Size and correctness diverged, and a separate act of work was needed to bring them back together.
The half-step ascent back up - to freestanding C on nolibc and wsock.h - closed the architectural circle. But it is not the same circle the research started from.
|
Start |
End |
| Language |
C |
C |
| libc |
yes |
no |
| MIPS |
927 KB (static) |
20.9 KB |
| x86-64 |
56 KB |
17.0 KB |
| How we know what is excess |
guesses |
the descent to the bottom |
The research returned to the C it started from. But it is a different C: the one demanded 927 KB and libc; this one demands 20.9 KB and nothing. The difference came not from the compiler but from the descent to the bottom, from where you can see what can be lived without.
The C version grew from 13.7 to 20.9 KB for a reason. After the audit it stopped being a minimal port and became a product: dotfile blocking, ETag with 304, If-Modified-Since, If-Range, Date, Accept-Ranges, OPTIONS with Allow, 405 instead of 501, a configurable index, a disableable listing, a PID file, access and error logs, MAXFD 128 instead of 64. Every item answers a real need, and every item costs bytes. This is not sprawl but the price of the work the server began to do.
The true code-size difference between hand-written reference assembly and an optimizing C compiler without libc is not 30x, as it seems when looking at standard builds, but 5.1x (4,092 bytes versus 20,908 bytes) on the same MIPS architecture. And that is a fair price for full portability and for a feature set the assembly version does not have.
4,092 bytes proved the limit is reachable. 20,908 proved you do not have to pay it if you intend to live with the code.
The circle turned out to be a spiral: the same point horizontally, a level higher vertically. And now it is visible what lies at the bottom.
Appendix: What Is in the Tree Today
Everything above is chronicle. What follows is the state at the moment of publication, taken from the tree itself rather than from memory. A reader who wants to verify the numbers should look here.
The assembly profile
| Parameter |
Value |
| Directory |
final-4092/ |
| Source |
whitehttpd.s (GNU as, MIPS32r2 Big-Endian) |
| Binary |
whitehttpd-mips32be |
| Size |
4,092 bytes |
Server header |
whitehttpd (no version: /1.0.0 does not fit in the remaining 4 bytes) |
| Kernel requirement |
Linux 3.3.8 and later |
The historical 3,944-byte build is absent here and cannot be restored: it read files outside the root through a symlink to an intermediate directory. The 148-byte difference is the price of path validation. The project considers it mandatory.
The C profiles
| Binary |
Architecture |
Size |
whitehttpd-nolibc-x86_64 |
x86-64 |
17,008 |
whitehttpd-nolibc-mips32be |
MIPS32 BE |
20,892 |
whitehttpd-nolibc-mips32el |
MIPS32 LE |
20,908 |
Directory whitehttpd_c/, build with make build-c (for MIPS - make build-c-mips). This is freestanding C: no libc, no heap, every kernel call through the thin nosyscall layer.
Re-measurement on a live router
The old 3,944-byte build survives in old/whitehttpd_mips/, so both versions could be placed side by side on one router and measured with one instrument. A caveat worth knowing: the 3,944 build does not accept --port or --addr. With --port it listens on loopback only and resets connections; without flags it listens on 8080 on all interfaces. The measurement therefore ran on 8080.
Conditions: ASUS RT-AC57U V3, Linux 3.3.8, MIPS 74Kc, wrk -t2 -c50 -d10s from an external host over the network.
| Scenario |
3,944 bytes |
4,092 bytes |
Change |
| Keep-Alive, 16 B file |
1,538 req/s, 209 KB/s |
1,421 req/s, 230 KB/s |
-8% RPS |
Connection: close, 16 B file |
not measured |
520 req/s |
- |
| 256 KB file |
33.9 req/s, 8.28 MB/s |
39.3 req/s, 9.61 MB/s |
+16% |
The new build is 8% slower in Keep-Alive and 16% faster on the large file. That is exactly the price described in the security fix section: every request gained Host and framing checks plus component-by-component path opening. On a large body, the payoff from the hardened descriptor handling wins instead.
What is verified on hardware
| Check |
Result |
| GET, HEAD, 404, 403 |
correct, body and codes |
| 301 on a directory without a slash |
Location: /space%20dir/ |
| IPv6 dual-stack |
:::port, requests from ::1 and 127.0.0.1 both served |
| Range |
206 with Content-Range, past end of file - 416 |
| Symlink to a file and to a directory |
404 |
Root escape (/../etc/passwd, /%2e%2e/) |
400 |
| Directory listing |
403, absent by contract |
Dotfiles (.htpasswd) |
403 |
Transfer-Encoding, duplicate Content-Length, obs-fold, HTTP/1.1 without Host |
400 |
| Request header limit |
8,192 bytes, above - 400 |
| Idle connection timeout |
3 s |
What is not in the public tree
- The FASM implementation (4,062 bytes) and the intermediate GAS x86-64 port (6,023 bytes) from Acts I-II. The sources of these intermediate stages are preserved in the development archives, but not published in the release tree: the public repository focuses on the final, verified profiles.
- The directory listing in the assembly profile.
- The
Date, ETag, Last-Modified headers in the assembly profile. It serves Server, Connection, Cache-Control, Content-Type, Content-Length.
The current reference for the assembly profile: final-4092/README.md and the man page final-4092/whitehttpd.1.
References and Source Publications
Base projects and tooling
- darkhttpd - the original minimalist C web server (Emil Mikulic): https://unix4lyfe.org/darkhttpd/
- nolibc.h - the Freestanding C header from the Linux kernel tree (Willy Tarreau):
tools/include/nolibc/
- wrk - a modern HTTP benchmark: https://github.com/wg/wrk
- Flat Assembler (FASM): https://flatassembler.net/
- GNU Binutils (GAS, ld, objcopy): https://www.gnu.org/software/binutils/
Classic works on extreme ELF binary shrinking (sizecoding)
- A Whirlwind Tutorial on Creating Really Teensy ELF Executables for Linux (Brian Raiter): https://www.muppetlabs.com/~breadbox/software/tiny/teensy.html
- ELF: Executable and Linkable Format - the specification: https://refspecs.linuxfoundation.org/elf/elf.pdf
- sstrip - an ELF section table stripping utility: https://github.com/BR903/ELFkickers
MIPS architecture, ABI and Linux networking specifications
- MIPS32 Architecture For Programmers - official volumes: https://www.mips.com/products/architectures/
- System V Application Binary Interface - MIPS RISC Processor Supplement: https://refspecs.linuxfoundation.org/abi/
- Linux MIPS syscall table (O32 ABI):
arch/mips/kernel/syscalls/syscall_o32.tbl
- RFC 7230 - HTTP/1.1: Message Syntax and Routing: https://datatracker.ietf.org/doc/html/rfc7230
- RFC 7233 - HTTP/1.1: Range Requests: https://datatracker.ietf.org/doc/html/rfc7233
- epoll(7) - Linux man page: https://man7.org/linux/man-pages/man7/epoll.7.html
- openat2(2) - Linux man page: https://man7.org/linux/man-pages/man2/openat2.2.html
The whitehttpd project is distributed under a dual-licensing model: open-source under AGPLv3 and a commercial OEM license for network hardware manufacturers. Full legal documentation, licensing terms, and the Clean-Room declaration are published in the official GitHub repository.
Нижче мови Сі: вебсервер у 4 092 байти, ціна перевірок і повернення без libc
Примітка читачеві: це довга, суто інженерна стаття - дизасемблер, структури ядра Linux, заміри на реальному залізі.
Про дати й числа: стаття написана як хроніка. Заміри та розміри бінарників у тексті - це знімки на момент відповідного етапу: 3 944 байти (MIPS, до виправлень безпеки), 4 062 байти (FASM x86-64, минула гілка). Обох збірок у поточному дереві немає. Актуальний стан - у розділі «Додаток: що лежить у дереві сьогодні» в кінці: 4 092 байти на MIPS і три профілі на Сі. Історичні цифри не переписані, щоб не руйнувати розповідь про те, як їх було отримано.
Пролог: Ілюзія «портативного асемблера» і вибір зброї
Наприкінці 2026 року індустрія остаточно звикла до того, що мікросервіс у контейнері важить 300 мегабайтів, а простий бінарник «Hello World» з'їдає мегабайти пам'яті на старті. Прийнято вважати, що пам'ять дешева, компілятори розумніші за людину, а писати низькорівневий код вручну безглуздо.
Але картина радикально змінюється при переході з хмарних серверів на рівень вбудованих систем - туди, де працюють сотні мільйонів домашніх роутерів, IoT-пристроїв та промислових контролерів на чіпах MIPS і ARM з крихітними 8-16 МБ флеш-пам'яті.
Мову Сі традиційно називають «портативним асемблером». Однак на практиці між рядком коду на Сі та кремнієм процесора лежить колосальний шар абстракцій: угоди про виклики (ABI), стекові фрейми, таблиці локалей, алокатори купи та стандартна бібліотека (glibc, musl або uClibc). Ще до того, як виконається перша інструкція функції main(), динамічний завантажувач операційної системи розгортає в пам'яті рантайм вагою близько мегабайта.
Це дослідження починається з фундаментального питання: що лежить нижче мови Сі, і яку реальну ціну ми платимо за абстракції стандартної бібліотеки?
Еталонним полігоном обрано відомий мінімалістичний вебсервер darkhttpd (написаний на чистому Сі). Мета експерименту - відтворити його можливості на чистому асемблері, пробити фізичну межу однієї апаратної сторінки пам'яті (Sub-4KB), а потім піднятися назад до академічного Сі, повністю вільного від libc та зовнішніх залежностей.
Шлях виявиться не прямою лінією, а спіраллю: почавши з Сі, він спуститься на дно асемблера, впреться в межу, з якої не можна витиснути жодного байта без втрати коректності, і лише тоді повернеться до Сі - але вже іншою дорогою.
Частина I. Спуск
Акт I. Холодний душ на x86-64: коли прототип програє архітектурі
На першому етапі було створено прямолінійні прототипи на асемблерах FASM та GAS під Linux x86-64 (прямі системні виклики syscall без libc) і протестовано утилітою wrk (2 потоки, 20 з'єднань) проти оригінального darkhttpd на Сі. Прототипи використовували простий лінійний accept без пулу з'єднань, тоді як darkhttpd працював через мультиплексор poll(). Цей перший вимір зафіксував не перевагу мови Сі, а прірву між архітектурами введення-виведення:
| Параметр |
FASM (v1, сирий прототип) |
GAS (v1, без -N і sstrip) |
Нативний Сі (darkhttpd.c) |
| Розмір бінарника |
7.4 КБ |
12 КБ |
56 КБ (+ glibc) |
| Пікове споживання RAM |
~16 КБ (4 сторінки)* |
~16 КБ* |
916 КБ (VmHWM у режимі спокою) |
| RPS (файл 1 КБ) |
5 118 req/s |
5 695 req/s |
10 403 req/s |
| Пропускна здатність (256 КБ) |
641 МБ/с |
638 МБ/с |
1 040 МБ/с |
Урок першого акту виявився неочікуваним. Сирій асемблерний прототип програв Сі майже вдвічі за швидкістю, хоч і був у 7-13 разів меншим. І тут важливо назвати причину точно, бо її легко сплутати: програв не асемблер, а архітектура прототипу. У ньому був лінійний цикл accept з блокуючим читанням, який не міг тримати двадцять одночасних з'єднань так само ефективно, як poll() усередині darkhttpd. Мова тут ні до чого: на Сі з тим самим лінійним циклом цифри були б такими самими. У darkhttpd вигравала архітектура, а не компілятор.
З цього виросли одразу два висновки, і обидва визначили подальший хід роботи:
- Розмір сам по собі нічого не означає. Маленький бінарник, який не вміє тримати з'єднання, безкорисний. Sub-4KB без мультиплексування не варував би нічого.
- Поразку мови не можна списувати на мову. Перша реакція на таблицю вище - «асемблер повільний». Контрольний замір наприкінці цього акту показує інше: коли в обох сторін однакова архітектура, асемблер на рівних або швидший. Граблі були у відсутності
epoll, і Акт II - про те, як з них злізли.
Контрольний замір фінальних збірок
Таблиця вище - перші заміри сирих прототипів. Коли стаття готувалася до публікації, всі три реалізації було зібрано заново і знято в однакових умовах: wrk -t2 -c50 -d10s, keep-alive, файл 1 КБ, Intel Core i5-3230M, Linux 6.1.0. darkhttpd 1.17 зібрано cc -O2 із вихідників в old/darkhttpd_c/. Кожен сервер запускався один, щоб заміри не конкурували за ядра.
| Реалізація |
Розмір |
RPS (1 КБ) |
RPS (256 КБ) |
RPS (16 Б, close) |
darkhttpd 1.17, Сі + glibc |
56 256 Б |
18 000 (17.7-19.2 тис) |
2.13 ГБ/с, 9 131 req/s |
4 039 |
| Сі-профіль без libc |
17 008 Б |
41 733* (24.6-43.7 тис) |
1.12 ГБ/с, 4 694 req/s |
- |
| FASM x86-64 |
4 062 Б |
23 000-44 000** |
1.22 ГБ/с, 5 126 req/s |
830 |
Контрольний замір закриває два питання, залишені першим актом:
- Поразка Акту I була поразкою архітектури, а не мови. На дрібному файлі FASM удвічі швидший за
darkhttpd, а Сі-профіль без libc - на рівних або швидший. Коли в обох сторін є epoll, асемблер не відстає: відставання з першої таблиці зникло разом із лінійним циклом accept.
- Різниця в розмірі не дає різниці в швидкості на дрібному файлі. 4 062 і 56 256 байт на файлі 1 КБ впираються в одне й те саме - у вартість системних викликів і розбір рядка запиту. На великому файлі
darkhttpd швидший: 2.13 ГБ/с проти 1.22, бо в нього менше системних викликів на байт корисного навантаження. Розмір виграє не у швидкості, а в пам'яті: 916 КБ ОЗУ у darkhttpd проти десятків кілобайт в асемблера, і на вбудованому залізі зі 128 МБ ОЗУ це вирішує більше, ніж будь-які відсотки пропускної здатності.
Урок першого акту виявився неочікуваним - але це був урок про архітектуру, а не про мови.
Акт II. Тріумф на x86-64: Sizecoding, epoll, prefork і TCP_NODELAY
Якщо діагноз з Акту I правильний і поразка була в архітектурі, то лікувати треба архітектурою, а не переписувати цикл іншим діалектом асемблерних інструкцій. Так і вчинили: в асемблерні реалізації перенесено ті самі механізми, на яких стояв darkhttpd, плюс два, яких у нього не було. Чотири ключові зміни:
- Sizecoding та єдиний RWX-сегмент: всі неініціалізовані буфери винесено з секції даних у
.bss (rb у FASM), що обнулило їхній розмір на диску. Прапорець компонувальника ld -N усунув міжсекційне вирівнювання сторінок по 4 КБ, а зрізання таблиці секцій (sstrip) стиснуло бінарник до 3.9 КБ, повністю вмістивши його в кеш інструкцій першого рівня (L1i) процесора.
- Апаратний префорк у 14 байт коду: два послідовні виклики
sys_fork (системний виклик 57 в ABI x86-64) одразу після sys_listen розщеплюють процес сервера на 4 паралельні воркери за кількістю ядер CPU без використання бібліотеки pthread. Пам'ять працює в режимі Copy-on-Write (~16-20 КБ фізичної RAM на воркер: сторінка коду, сторінка стеку і сторінки буферів запиту).
- Ядерний мультиплексор
sys_epoll: заміна лінійного циклу прийому з'єднань на неблокуючу чергу подій ядра - прямі системні виклики epoll_create1 (сискол 291 в ABI x86-64), epoll_ctl (233) та epoll_wait (232) зі збереженням відкритих сесій Keep-Alive.
- Зняття стоп-крана Нейгла (
TCP_NODELAY): вимкнення алгоритму Нейгла через setsockopt усунуло 40-мілісекундну затримку TCP Delayed ACK при роздільному надсиланні HTTP-заголовків (write) та тіла файлу (sendfile).
Історичний вимір проміжного прототипу FASM (4 062 байти) на x86-64 (wrk, Keep-Alive увімкнено, 4 воркери):
| Параметр |
FASM (epoll + prefork) |
Нативний Сі (darkhttpd) |
Підсумок |
| RPS (файл 1 КБ), початковий замір |
27 719 req/s |
11 077 req/s |
Асемблер швидший у 2.5 раза (+150%) |
| Медіанна затримка |
473 мкс (0.47 ms) |
1.50 ms |
У 3.1 раза чутливіший |
| Пропускна здатність (файл 4.4 КБ), повторний замір |
120.9 МБ/с (27 372 req/s) |
43.0 МБ/с (9 495 req/s) |
Асемблер швидший у 2.8 раза |
| Розмір бінарника |
4 062 байти (Sub-4KB!), FASM, історичний етап |
56 КБ |
У 13.8 раза менший |
| Споживання ОЗУ (Peak RSS) |
~16-20 КБ (на воркер) |
916 КБ (VmHWM) |
У 45-55 разів менше пам'яті |
Тут асемблер узяв реванш: 27 719 запитів на секунду проти 11 077 у Сі, при затримці у три рази нижчій і розмірі у чотирнадцять разів меншому. Рубіж Sub-4KB було взято на x86-64.
Під час повторних вимірів ця різниця стиснулася. darkhttpd 1.17 на тому самому хості в тому самому режимі дає 17.7-19.2 тис req/s, FASM - від 23 до 44 тис залежно від того, як планувальник поділить 4 ядра між 4 воркерами сервера та потоками wrk. Тобто асемблер швидший, але у 1.3-2.3 раза, а не у 2.5. Значення 11 077 вийшло нижчим, бо в тому замірі darkhttpd працював в інших умовах. Обидві цифри залишено: початкову - як хроніку, повторно зняту - як таку, що перевіряється.
Повторні виміри під час підготовки до публікації. Початкові показники вище знімалися на невідомому залізі, й один рядок у них містив помилку: значення 1.29 ГБ/с було отримано на файлі 4.4 КБ, а не на 1 КБ, як стверджував заголовок. Під час підготовки обидві збірки - whitehttpd-fasm-x86_64 (4 062 байти) з архіву розробки та Сі-профіль - було заново протестовано на i5-3230M, 4 ядра, ядро Linux 6.1.0, wrk -t2 -c50 -d10s, keep-alive:
| Параметр |
FASM 4 062 |
Сі-профіль 17 008 |
Співвідношення |
| RPS (файл 1 КБ) |
27 466 (повтор 26 752) |
16 962 |
1.6× швидше |
| Пропускна здатність (4.4 КБ) |
99.6 МБ/с |
21.7 МБ/с |
4.6× швидше |
| Затримка (75% / медіана, 1 КБ) |
1.82 мс / 0.43 мс |
не знято |
- |
Число 27 719 підтвердилося з точністю до одного відсотка. Співвідношення із Сі змінилося: початкові 2.5 раза були обраховані проти оригінального darkhttpd на Сі з glibc, а повторні виміри дали 1.6 раза проти freestanding Сі-профілю нашого ж проєкту, який також використовує epoll та префорк. Пропускна здатність на файлі 4.4 КБ впирається у loopback, а не в код сервера, тому за нею про різницю між реалізаціями судити не можна.
Але x86-64 - це сервер у віртуалці. Справжня перевірка чекала на реальному кремнії.
Акт III. Перехід на реальний кремній: MIPS32 Big-Endian і битва за Sub-4KB
Після перевірки концепції на x86-64 розробку перенесено на апаратну платформу домашнього роутера ASUS RT-AC57U V3 (процесор Qualcomm Atheros QCA9563, ядро MIPS 74Kc Big-Endian, ОС Linux 3.3.8). На цій архітектурі оригінальний статично злінкований darkhttpd на Сі з glibc важить 927 КБ (mipsel-linux-gnu-gcc -static -Os -s); цифра 119.2 КБ з ранньої редакції статті не відтворюється жодною доступною бібліотекою і тут замінена на виміряну. Динамічно злінкований варіант займає 56 КБ на x86-64, але потребує наявності glibc у прошивці роутера, чого на OpenWrt та Padavan не гарантовано. Саме розмір статичної збірки і є тією причиною, через яку на MIPS має сенс писати без libc.
Для архітектури MIPS безальтернативним інструментом є GNU Assembler (GAS). Проєкт дістав назву whitehttpd.
Перший робочий порт на GAS MIPS32 показав розмір 6 608 байт. Було поставлено завдання: пробити фізичний бар'єр у 4 096 байт (Sub-4KB - рівно одна апаратна сторінка віртуальної пам'яті і один запис у TLB процесора), зберігши весь функціонал того моменту: Dual-Stack IPv6/IPv4, epoll, sendfile, Keep-Alive, urldecode, 301-редиректи та динамічний HTML-лістинг каталогів (sys_getdents64). Лістинг буде прибрано пізніше, коли бюджет вичерпається (див. розділ про виправлення безпеки).
Чотири щаблі стиснення до 3 944 байт
- Регістр
$gp і бар'єр 64 КБ (6 608 -> 6 072 Б): у RISC-архітектурі MIPS кожна інструкція важить 4 байти, тому завантаження 32-бітної адреси псевдоінструкцією la розгортається в пару lui + addiu (8 байт). Розміщення рядкових констант і малих буферів (< 18 КБ) у секціях .sdata та .sbss дозволило замінити двословні завантаження на однословні 4-байтні інструкції зі зміщенням від регістра Global Pointer (%gp_rel), що зекономило 536 байт. Великі буфери (dents_buf на 8 КБ і page_buf на 64 КБ) винесено в .bss і адресовано через єдиний базовий регістр $s7.
- Конвеєр
.set noreorder і наскрізний ланцюг $a0 (6 072 -> 4 616 Б): ручне укладання корисних інструкцій в апаратні слоти затримки переходів (Branch Delay Slots) позбавило код автоматичних порожніх інструкцій nop. Одночасно функцію strcpy перепроєктовано так, щоб після копіювання регістр $a0 вказував строго на записаний термінальний нуль \0, а uint_to_str завершувалася хвостовим переходом у strcpy. Це дозволило збирати складні HTTP-заголовки конвейерно, повністю виключивши функції strlen та strcat.
- Структурний синтез «Brainstorm Ultra» (4 616 -> 4 344 Б): об'єднання структури
sigpipe_act (використовується однократно під час старту), констант сокетів opt_zero (0), opt_val (1) та шаблону структури epoll_event (з попередньо записаним прапорцем EPOLLIN = 1) в єдиний 24-байтний масив даних, а також дедуплікація суфікса ; charset=utf-8 у таблиці MIME.
- Очищення заголовків ELF, in-place декодування і ліквідація стекових кадрів (4 344 -> 3 944 Б): попереднє видалення службових секцій
.MIPS.abiflags та .reginfo з об'єктного файлу через objcopy -R, відсічення таблиці секцій (sstrip), усунення стекових кадрів у get_mime_type, send_error, заміна print_str на пряме потокове збирання та інлайнинг декодування URL прямо в буфер path_buf.
Виконуваний файл узяв рубіж 3 944 байти на диску та 20 КБ фізичної RAM в бойовому режимі (при роздачі 3D-застосунку на WebGL через sendfile завдяки лінивому виділенню сторінок Demand Paging ядром Linux). На стрес-тесті у 20 потоків сервер показав 309 RPS (8.71 МБ/с корисного навантаження), упершись рівно у фізичну межу 100-мегабітного порту роутера.
Цей замір стосується роздачі великого тіла. Під час повторних вимірів при підготовці цієї статті та сама збірка на маленькому файлі дала зовсім інше: 1 538 req/s у режимі Keep-Alive і 208 КБ/с на файлі 16 байт. Суперечності немає - це різні питання. 309 RPS при 8.71 МБ/с вимірює, скільки байт пропускає мережа, і там вузьке місце - порт. 1 538 RPS на 16 байтах вимірює, скільки запитів встигає розібрати процесор, і там вузьке місце - сам сервер. Обидві цифри коректні і не суперечать одна одній.
3 944 байти. Одна сторінка пам'яті. Двадцять кілобайт ОЗУ. Сервер тримає epoll, IPv6, Keep-Alive, sendfile, лістинг каталогів і 301-редиректи. Лістинг зникне за кілька місяців, коли виявиться обхід кореня через симлінк; читач дізнається про це в розділі про ціну безпеки.
Здавалося, мету досягнуто і лишилося тільки написати про це статтю.
Частина II. Дно
Акт IV. Симлінк, який усе зламав
1. Питання, яке треба було поставити раніше
Виклик open мав прапорець O_NOFOLLOW, і цей прапорець стояв на останньому компоненті шляху. Це давало тепле відчуття безпеки: симлінк замість файлу ядро відкидає.
Але O_NOFOLLOW на останньому компоненті нічого не говорить про компоненти до нього. Симлінк замість проміжного каталогу проходить перевірку непоміченим: ядро йде за ним, спускається в цільовий каталог і там відкриває файл - чесно, з O_NOFOLLOW, бо останній компонент справді файл.
Питання «а що, як симлінк - не останній компонент шляху» треба було поставити ще під час проєктування. Його поставили пізніше, і поставив його зовнішній аудит.
2. Відтворення
На роутері створено каталог поза коренем і симлінк на нього всередині кореня:
/tmp/outside/secret.txt -> "SECRET_OUTSIDE"
/tmp/www/escdir -> /tmp/outside (симлінк на каталог)
Запит через робочий сервер:
GET /escdir/secret.txt -> 200 OK SECRET_OUTSIDE
Сервер віддав файл, який лежить поза кореневим каталогом. Це не теоретична вразливість - вона відтворилася на бойовій збірці, яка в той момент працювала на порту 80.
3. Чому тести мовчали
Тут починається найнеприємніша частина. Тести були зелені. Всі.
run_all.sh перевіряв прямий файловий симлінк - той самий, який O_NOFOLLOW коректно блокував. Тест проходив і створював відчуття захищеності.
- Симлінк на проміжний каталог не перевіряв ніхто. Такого тесту просто не існувало.
- У проєкті був файл
test_state_machine.c, який переписував копію скінченного автомата всередині тесту і перевіряв цю копію, а не робочий сервер. Тест не міг виявити помилку в сервері за визначенням: він тестував сам себе.
Урок, заради якого варто писати цей акт: зелений тест нічого не доводить, якщо він перевіряє копію логіки. Це та сама помилка, що «компілюється - отже працює», тільки поверхом вище. Компілятор перевіряє синтаксис, тест-копія перевіряє намір автора тесту, і жоден з них не перевіряє те, що реально виконується.
Звідси правило, яке тепер діє в проєкті: тест зобов'язаний звертатися до живого процесу через сокет. Не до функції, не до копії автомата, не до моделі - до процесу, який слухає порт.
4. Перша спроба полагодити: ціна, яка налякала
Очевидне виправлення - відкривати шлях покомпонентно, застосовуючи O_NOFOLLOW на кожному кроці:
fd = open(root, O_RDONLY | O_DIRECTORY);
for (each component) {
fd = openat(fd, component, O_RDONLY | O_NOFOLLOW | O_DIRECTORY);
if (fd < 0) return 404;
}
На MIPS це коштує дорого: цикл розбору шляху, окремий openat на кожен компонент, обробка помилок на кожному кроці. Перша безпечна збірка показала 5 912 байт замість 3 944.
Плюс 1 968 байт - майже вдвічі. У цей момент здалося, що безпека і Sub-4KB несумісні: щоб закрити одну дірку, треба відмовитися від самого досягнення.
Заодно з'ясувалося, що лістинг каталогів (sys_getdents64, генерація HTML, екранування імен) теж доведеться прибрати: він коштував близько 200 байт, а бюджет був вичерпаний. Каталог без index.html почав повертати 403 замість списку файлів. Це свідомий обмін: лістинг віддано заради безпеки шляхів.
5. Стиснення: повернення до однієї сторінки
5 912 байт - це не вирок, а проміжна точка. Далі почалася робота, яка зрештою повернула розмір:
| Крок |
Розмір |
Що зроблено |
| Вихідна вразлива збірка |
3 944 |
O_NOFOLLOW тільки на останньому компоненті |
| Перша безпечна |
5 912 |
покомпонентний openat, лістинг прибрано |
| Стиснення |
4 064 |
мертвий код лістинга, дедуплікація MIME, злиття структур |
| Range за RFC 7233 |
4 080 |
три виправлення Range + валідація CLI |
| Фінальна |
4 092 |
Cache-Control + 416 на переповнення |
Стиснення з 5 912 до 4 064 дало мінус 1 848 байт - майже стільки, скільки коштувала безпека. Основні джерела: видалення мертвих рядків лістинга (dir_head, tag_a та інше, що лишилося в бінарнику після вимкнення функції), дедуплікація MIME-типів через спільний пул рядків і таблицю зміщень, злиття малих структур даних.
Далі - чотири виправлення Range, які коштували лише 16 байт:
bytes=-N при N >= розміру файлу повертав 200 замість 206 (RFC 7233 розділ 2.1 вимагає 206 з повним файлом). Лікується однією інструкцією movz, що підрізає N до розміру.
bytes=-0 повертав 200 замість 416. Безкоштовно: потрапляє в існуючу перевірку from >= size.
- Завеликий
last-byte-pos (наприклад bytes=0-99999999999999999999) повертав 200 замість 206 з обрізанням. Причина: parse_dec читав лише 9 цифр і не визнавав решту числа законним продовженням.
--port 8080junk приймався як 8080. parse_uint не перевіряв кінець рядка.
Чотири виправлення - шістнадцять байт. Це виявилося дешевшим, ніж очікувалося, і стало першою доброю новиною за весь акт.
6. Останні 12 байт: кешування і переповнення
Фінальні два додавання вимагали скоротити reason-phrase, щоб вміститися в сторінку.
Cache-Control: max-age=60 виводиться лише на кешованих відповідях: 200, 206, 301. Перевірка - одна інструкція sltiu $t0, $s7, 400: коди менші за 400 проходять, помилок немає. Це важливо: якщо поставити Cache-Control на 404, браузер закешує помилку і не побачить файл, що з'явився, протягом хвилини.
Вартість: 20 байт коду + 28 байт рядка, виміряний приріст бінарника +44 байти.
Чому не 304 Not Modified: max-age прибирає сам запит, тобто економить і затримку, і трафік. 304 економить лише тіло; запит усе одно йде на сервер, а коштує 100-150 байт (потрібен розбір If-None-Match або форматування HTTP-дати). При бюджеті в 16 байт max-age дає більше користі за меншу ціну.
Range: переповнення first-byte-pos. parse_dec читає не більше 9 цифр. Якщо після них іде ще цифра, значення завідомо більше мільярда, що перевищує будь-який файл на цільовому пристрої (128 МБ RAM, tmpfs 61 МБ). Такий запит отримує 416:
lbu $t0, 0($a0)
addiu $t1, $t0, -48
sltiu $t1, $t1, 10
bnez $t1, .L_hce_416 # 10-та цифра -> >= 1G -> 416
nop # слот затримки: розгалуження в ньому заборонене
bne $t0, $t7, .L_do_mime # інакше обов'язковий '-'
addiu $a0, $a0, 1 # [delay slot]
Вартість: 16 байт.
7. Пастка в слоті затримки
Перша версія правки 416 уронила сервер із SIGILL на першому ж запиті із заголовком Range. Збірка проходила безшумно, тести падали невиразно.
Причина: bnez отримав у слот затримки інструкцію bne. На MIPS слот затримки виконується до переходу, і якщо в ньому стоїть розгалуження, процесор отримує невизначену поведінку. QEMU чесно видав SIGILL.
Лікується однією інструкцією nop. Урок: після кожної правки в коді з переходами перевіряти дизасемблер, а не лише успішну збірку. Компілятор такого не ловить - він не знає, що ти поклав у слот затримки.
8. Чим заплачено
Вільно було 16 байт, потрібно близько 48. Скорочено reason-phrase - чиста косметика, клієнти використовують числовий код:
| Код |
Було |
Стало |
Економія |
| 206 |
Partial Content |
Part |
11 |
| 301 |
Moved |
Mv |
3 |
| 400 |
Bad Request |
Bad Req |
4 |
| 403 |
Forbidden |
Denied |
3 |
| 404 |
Not Found |
No |
7 |
| 416 |
Range Not Satisfiable |
Range |
16 |
| 501 |
Not Implemented |
Not Impl |
7 |
|
|
разом тексту |
51 |
У бінарнику це дало мінус 48 байт: три байти з'їло вирівнювання рядків у .sdata.
Це компроміс у читабельності: 404 No замість 404 Not Found. Функціональність не зачеплена, коди і заголовки на місці. Альтернатива - видалити --daemon (близько 61 байта) - зберегла б повні фрази, але це втрата функції, а не косметики.
Акт V. Дно: 4 092 байти і рішення зупинитися
1. Що вийшло
Фінальна збірка: 4 092 байти, запас 4 байти до сторінки.
| Параметр |
Значення |
| Розмір |
4 092 байти |
| Запас до 4 096 |
4 байти |
.text |
3 360 байт |
.sdata |
572 байти |
| Сегмент даних на диску |
filesz = 0 |
VmHWM у бою |
28 КБ |
Повний набір перевірок на місці: GET/HEAD, HTTP/1.0 і 1.1, Keep-Alive, pipelining, index.html, 301-редирект, Range з 206/416, 17 MIME-розширень (14 унікальних типів) з charset=utf-8, вісім кодів стану, покомпонентний openat з O_NOFOLLOW, захист від traversal, dotfiles повертають 403, перевірка Host, захист від CRLF-ін'єкцій, відмова obs-fold, відмова Transfer-Encoding, epoll, dual-stack IPv6, ігнорування SIGPIPE, sendfile, Cache-Control.
2. Перевірено на живому залізі
| Перевірка |
Результат |
GET /, GET /index.html |
200, тіло коректне, md5 збігся з файлом на диску |
HEAD / |
200, без тіла |
Range 0-9, -10 |
206, по 10 байт |
| Range переповнення |
416 |
HTTP/1.0 |
версія відображена, Connection: close |
| 301 на каталог |
Location зі збереженим %20 |
| Симлінк на файл |
404 |
| Симлінк на каталог |
404 |
| Path traversal |
400 |
| Лістинг каталогу |
403 (свідомо) |
| 2 000 запитів, 64 потоки |
2 000 / 2 000 |
| 4 МБ передача |
md5 збігся |
VmHWM під навантаженням |
28 КБ |
| Дескриптори після навантаження |
5 |
Окремо перевірено, що старий сервер на 3 944 пропускав, а новий блокує:
| Атака |
3 944 |
4 092 |
| CRLF-ін'єкція в заголовку |
200 |
400 |
Відсутність Host у HTTP/1.1 |
200 |
400 |
Дубль Host |
200 |
400 |
Transfer-Encoding у GET |
200 |
400 |
Content-Length у GET |
200 |
400 |
// у шляху |
200 |
400 |
3. Ціна: порівняння з версією, яка стоїть на роутері
Заміри на одному файлі (16 байт), wrk -t2 -c50, роутер ASUS RT-AC57U V3. Три збірки одночасно на різних портах, по п'ять-сім прогонів на кожну, прогони чергувалися, щоб дрейф не спотворив результат:
| Збірка |
Connection: close |
Keep-Alive |
Проти 3 944 |
| 3 944 (вразлива) |
540 req/s |
1 442 req/s |
- |
| 5 912 (перша безпечна) |
404 req/s |
892 req/s |
-25% / -38% |
| 4 092 (фінальна, на роутері) |
524 req/s |
1 498 req/s |
-3% / +4% |
Окремий замір на одному з'єднанні (wrk -t1 -c1, keep-alive): 3944 - 228 req/s, 5912 - 222 req/s, 4092 - 232 req/s. Всі три рівні.
Проміжна збірка 5 912 виявилася помітно повільнішою при високій конкуренції: мінус чверть на закритті з'єднання і мінус 38% у keep-alive. Саме ці числа раніше приймалися за ціну перевірок.
Але замір на одному з'єднанні говорить інше: обробка одного запиту у всіх трьох коштує однаково, а отже самі перевірки не коштують нічого. Різниця проявляється лише при п'ятдесяти одночасних з'єднаннях - отже 5 912 гірше масштабувалася, а не повільніше рахувала.
Фінальна збірка 4 092 працює на рівні вразливої: 524 проти 540 на закритті і 1 498 проти 1 442 у keep-alive, різниця в межах розкиду. Робота зі стиснення з 5 912 до 4 092 дала і байти, і масштабованість.
Звідси висновок, який перевертає інтуїцію sizecoding-спільноти, де все міряють байтами: найдорожча перевірка не та, що їсть байти, а та, що їсть такти - але такти їсть не перевірка, а її невдала реалізація. Один і той самий набір валідацій коштував 38% в одній збірці і ноль в іншій.
4. Реальний контракт: що вміє і чого не вміє
Усі цифри вище описують розмір і швидкість. Але в сервера є ще одна характеристика, яку зазвичай не пишуть у статтях про sizecoding: що він реально вміє. Перелічимо чесно, бо 4 092 байти накладають жорсткі обмеження, і читач має право знати їх до того, як поставить сервер кудись.
Що вміє
| Можливість |
Деталі |
| Методи |
GET, HEAD. HEAD без тіла на всіх кодах, включно з 400, 403, 404 і 416 |
| Версії протоколу |
HTTP/1.0 і HTTP/1.1, версія відображається у відповіді |
| З'єднання |
Keep-Alive, pipelining (декілька запитів з одного буфера) |
| Індексний файл |
index.html при запиті каталогу |
| Редирект каталогу |
301 з Location із сирого URL: %20 та інше кодування зберігаються як прийшли |
| Range |
Один діапазон: bytes=A-B, bytes=A-, bytes=-N. Відповіді 206 і 416 |
| MIME |
17 розширень, 14 унікальних типів. charset=utf-8 лише для текстових |
| Кешування |
Cache-Control: max-age=60 на 200, 206, 301 |
| Коди стану |
200, 206, 301, 400, 403, 404, 416, 501 |
| Захист шляхів |
Покомпонентний openat з O_NOFOLLOW на кожному кроці. Traversal -> 400, симлінк -> 404 |
| Dotfiles |
Будь-який компонент, що починається з крапки, -> 403 |
| Валідація запиту |
Host (наявність, єдиність, непорожність), CRLF-ін'єкції, obs-fold, Transfer-Encoding, Content-Length у GET -> 400 |
| Мультиплексування |
epoll, один процес, без потоків |
| Мережа |
Dual-stack IPv6 + IPv4 на одному порту (AF_INET6 + IPV6_V6ONLY=0) |
| Віддача файлів |
sendfile з повним циклом, обробка EINTR і часткової відправки |
| Сигнали |
SIGPIPE ігнорується, обрив з'єднання клієнтом не вбиває процес |
| CLI |
--port, --daemon |
Перевірено на живому залізі: 2 000 запитів при 64 потоках без втрат, 4 МБ через sendfile де md5 збігся, VmHWM 28 КБ, дескриптори після навантаження повертаються до п'яти.
Чого не вміє
| Відсутнє |
Наслідок |
| TLS |
Тільки за проксі. Паролі й токени відкритим текстом |
| Лістинг каталогів |
Каталог без index.html -> 403. Браузером по файлах не полазити |
| Множинні діапазони |
bytes=0-1,4-5 ігнорується цілісно, віддається 200 з повним тілом |
ETag / Last-Modified |
Тільки Cache-Control. Умовних запитів немає, 304 не видається |
If-Modified-Since |
Заголовок читається і ігнорується, відповідь завжди 200 |
If-Range |
Ігнорується: 206 видається навіть якщо валідатор не збігся |
| Стиснення |
Accept-Encoding: gzip ігнорується, тіло завжди як є |
| Автентифікація |
Ні Basic, ні токенів |
| chroot і скидання привілеїв |
Працює з тими правами, з якими запущений |
| Логи |
Тільки повідомлення під час старту. Ні access log, ні error log |
| CGI і будь-який бекенд |
POST, PUT, OPTIONS -> 501. Форми відправляти нікуди |
| HTTP/2, WebSocket |
Немає |
| Конфіг-файл |
Тільки аргументи командного рядка |
--addr |
Не підтримується, слухає всі інтерфейси. Невідомі прапорці відкидаються |
| Prefork |
Один процес, одне ядро CPU |
| Захист від slowloris |
Див. нижче |
Окремо про slowloris
У сервері є збирач сміття: epoll_wait викликається з таймаутом 3 секунди, і якщо подій немає, всі дескриптори від listen_fd+1 до 255 закриваються. Це захист від «завислих» з'єднань.
Але умова спрацювання - нуль подій. Поки хоч одне з'єднання активне, збирач не запускається. Отже клієнт, який надсилає по одному байту, тримає з'єднання скільки завгодно.
Виміряно: по одному байту раз на дві секунди, 80 секунд - з'єднання живе, сервер не розірвав його. Типовий запит займає 51 байт, тобто при такому темпі одне з'єднання утримується понад сто секунд, і це без урахування часу на саму відповідь.
Для довіреної локальної мережі це прийнятно. Для публічної адреси - ні: кілька десятків таких клієнтів вичерпають ліміт дескрипторів.
Напрямок майбутньої роботи - не закривати дескриптори, а перевикористовувати: знімати з epoll, скидати стан з'єднання і повертати в пул вільних. Тоді збирач зможе відсікати справді мертві з'єднання, не витрачаючи дескриптори на повторні accept.
Окремо про dotfiles і ACME
Заборона dotfiles - це захист: .git, .env, .htpasswd не віддаються ніколи. Але вона ж блокує .well-known/acme-challenge/, а без нього не працює випуск сертифікатів Let's Encrypt по HTTP-01.
Тобто сервер принципово не може сам отримати TLS-сертифікат: один захист виключає інший. Сертифікат можна випустити на іншій машині і поставити перед сервером проксі - але це вже зовнішня інфраструктура.
Підсумкова формулювання
Ця реалізація - не повноцінний вебсервер загального призначення, а сервер статики з жорстко обмеженим контрактом. Усе перелічене стосується виключно асемблерної версії на GAS для MIPS32 Big-Endian.
Сі-реалізація на nolibc закриває більшу частину цього списку: лістинг каталогів з екрануванням, --addr, --workers до 16, повний неблокувальний автомат з досилкою по EPOLLOUT, ETag і 304, If-Modified-Since, If-Range, OPTIONS з Allow, 405 замість 501, Date, Accept-Ranges, access і error логи, --index, --no-listing, --pidfile. Залишаються за контрактом обох версій: TLS, CGI, WebSocket, HTTP/2, gzip, автентифікація, chroot, конфіг-файл і множинні діапазони. Це і є межа між еталоном розміру і продуктом.
Годиться: роздача статики і вебінтерфейсу на сучасному Linux, довірений хост або локальна мережа, окремий каталог без секретів, непривілейований користувач, за проксі якщо потрібен TLS.
Не годиться: заміна nginx або Apache, файловий менеджер, публічна адреса з секретами, будь-що, що вимагає авторизації, обробки форм або логів.
5. Дно знайдено
4 байти запасу. Будь-яке наступне додавання вимагає або вирізати існуючу функцію, або скорочувати код далі.
Що вже віддано: лістинг каталогів. Що можна віддати ще: --daemon (61 байт), MIME-типи mjs і md, стиснення strstr/strcmp. Кожен варіант - це втрата функції або ризик помилки заради кількох десятків байт.
І тут стало ясно: асемблерний шлях вичерпано. Не тому, що 4 092 - погане число, а тому, що далі починається не інженерія, а витискання. Кожна нова перевірка - бій за шістнадцять байт, кожна правка вимагає перерахунку всієї геометрії даних.
Асемблерний шлях вимагає не просто завзятості. Він вимагає відмови від багатьох: від читабельності, від звичних абстракцій, від можливості швидко щось поправити. Коли мета - не просто написати робочий код, а вписатися в штучне обмеження в одну сторінку пам'яті і при цьому чесно виконувати заявлені функції, кожен байт стає рішенням, а кожне рішення - компромісом. Це дисципліна, яка багато чого навчає, але жити в ній постійно не можна.
4 092 байти довели, що межа досяжна. Далі треба було вирішувати, що робити з цим знанням.
Частина III. Підйом
Акт VI. Перехід на Сі: nolibc, власний wsock.h і перенос MIPS-версії
1. Чому не асемблер
Досягнувши дна на MIPS32, проєкт уперся в проблему масштабування протокольної логіки. Асемблерний сервер блискавичний і невагомий, але що робити, якщо потрібно впровадити механізми промислового класу?
- Повноцінний неблокуючий скінчений автомат з досилкою по
EPOLLOUT (щоб при віддачі важкого файлу повільному клієнту процес не блокувався в sendfile, а асинхронно перемикався між сотнями з'єднань);
- Підтримку HTTP/1.1 Pipelining (пакетна обробка склеєних запитів з одного буфера зі зсувом
memmove);
- Кільцевий збирач таймаутів і захист від slowloris;
- Розширення таблиці MIME і нових кодів стану.
Кожне з цих додавань в асемблері - це бій за байти, якого більше немає. Розробка багатофазного автомата вручну на асемблері неминуче веде до дублювання кодової бази.
2. Чи винен Сі
І тут коло замикається: чи винен у роздуванні бінарників сама мова C?
Зовсім ні. Компілятор GCC за своєю природою є транслятором тексту з мови Сі в текст мовою GNU Assembler (GAS). Усю зайву вагу і мегабайтний апетит пам'яті привносить стандартна бібліотека libc.
Повна відмова від стандартної бібліотеки (-nostdlib, -fno-builtin) дозволяє об'єднати чисту мову C (Freestanding C99), офіційний однофайловий заголовок nolibc.h з дерева вихідників ядра Linux (tools/include/nolibc, створений автором HAProxy Віллі Тарро спеціально для збірки ультракомпактних системних утиліт) і тонкий власний прошарок мережевих системних викликів wsock.h.
Це не «синтез асемблерних прийомів у Сі». MIPS-версію перенесено в Сі, а мережевий шар, якого в nolibc немає, дописано окремо. Свідомий крок: повернути можливість розвивати код, заплативши за це розміром.
3. Анатомія wsock.h: приборкання несумісностей MIPS і x86
У штатному nolibc.h відсутня робота з мережею і сигналами. Розроблений заголовний файл wsock.h (9 системних викликів і 4 структури) ізолює в собі всю архітектурну специфіку портування між x86-64 і MIPS32:
- Перевернуті константи сокетів у MIPS ABI: у MIPS Linux константа
SOCK_STREAM дорівнює 2 (тоді як на x86 і ARM це 1 - на MIPS одиниця створить UDP-сокет!), рівень SOL_SOCKET дорівнює 0xFFFF, а прапорець неблокуючого режиму O_NONBLOCK - 0x0080.
- Геометрія ядерних структур: на x86-64 структура
epoll_event упакована до 12 байт, а на MIPS вирівняна до 16 байт. У структурі sigaction на MIPS першим полем іде sa_flags, а другим - вказівник sa_handler.
- Zero-Stack Syscalls для MIPS O32: стандартні макроси
nolibc резервують на стеку 32 байти перед кожним системним викликом (addiu $sp, $sp, -32 / +32). Оскільки ядро Linux не пише в цю область користувацького стеку, заміна їх на безстекові асемблерні вставки ws_syscall0..4 економить по 2 інструкції (8 байт) на кожному з 31 виклику в програмі.
4. Архітектура httpd.c: академічний автомат без malloc
Поверх wsock.h побудовано єдиний кросплатформний вихідник httpd.c, який компілюється без жодного #ifdef як під x86-64, так і під MIPS32 Big-Endian:
- Zero-Allocation: повна відсутність викликів
malloc, calloc або free. Усі масиви стану і буфери розміщені статично в .bss і виділяються ядром за потребою (Demand Paging).
- Безпечний побудувач рядків (
struct buf): структура { char *p; char *end; } (високорівневий аналог наскрізного вказівника $a0 з асемблерної версії) фізично виключає вихід за межі буфера.
- Неблокуючий конвеєр: у разі повернення
EAGAIN від sendfile дескриптор переводиться в очікування WS_EPOLLOUT зі збереженням зміщення body_sent.
5. Усунення протокольної помилки Range (206 Partial Content)
Під час аудиту httpd.c було виявлено й усунуто тонку помилку формування відповіді 206 Partial Content: базова функція build_header() завершувала заголовок подвійним переносом \r\n\r\n, після чого обробник Range дописував заголовок Content-Range: bytes ... \r\n\r\n вже за межами закритого блоку заголовків. У результаті клієнт сприймав рядок Content-Range як перші 35 байт тіла файлу.
Відкат вказівника запису на 2 байти перед вставкою Content-Range забезпечив 100% відповідність стандарту RFC 7233:
/* Вставка Content-Range строго перед завершальним \r\n блоку заголовків */
hb.p = g_hdr[fd] + g_hlen[fd] - 2;
6. Що таке sizecoding на Сі
Перехід на Сі не скасовує питання розміру. Він його переформульовує.
На асемблері розмір - це властивість кожного рядка. Хочеш менше - пиши інші інструкції. На Сі такого прямого зв'язку немає: між вихідником і машинним кодом стоїть оптимізатор, і він часто розумніший за автора. Перевірити це можна було лише експериментом.
Взяли найбільшу функцію - prepare(), розбір запиту, 3.8 КБ на x86-64 і 4.6 КБ на MIPS - і спробували її стиснути шістьма способами.
| Спроба |
Ідея |
Результат |
| 1 |
Єдиний вихід через goto для 15 викликів error_response(400) |
+32 |
| 2 |
switch по першій букві імені заголовка замість ланцюга iequal |
+64 |
| 3 |
Перевірка версії через memcmp("HTTP/1.",7) замість двох strcmp |
+32 |
| 4 |
Таблиця tchar[128] замість strchr для перевірки імені |
+112 |
| 5 |
Винесення обробки каталогу в окрему функцію resolve_dir() |
+96 |
| 6 |
Побайтовий скан memcmp на кожній позиції -> strstr |
-64 |
П'ять спроб із шести зробили код більшим. Причини конкретні:
- Єдиний вихід не потрібен: GCC уже зливає однакові хвости в спільний блок, а мітка додала перехід.
switch на п'ять case генерує таблицю переходів, і вона дорожча за ланцюг порівнянь.
- Таблиця в 128 байт дорожча, ніж економлять виклики
strchr.
- Винесення функції додало пролог і дублювання, а виграш від розділення не покрив накладних витрат.
Єдиний виграш дала не заміна конструкції на «більш хитру», а усунення зайвої роботи. Пошук кінця заголовків був написаний так:
for(i=0;i+3<c->used;i++)if(!memcmp(r+i,"\r\n\r\n",4)){end=r+i;break;}
На кожен байт запиту - виклик memcmp з чотирма порівняннями. Для 500-байтового запиту це 500 викликів. Буфер уже термінований нулем після кожного read, тому достатньо strstr(r,"\r\n\r\n"). Мінус 64 байти і помітно менше роботи: пропускна здатність зросла з 24 700 до 25 200 запитів на секунду.
Звідси висновок, який варто запам'ятати кожному, хто приходить у sizecoding з асемблера: на Сі розмір визначається не тим, наскільки хитро написаний рядок, а тим, скільки роботи програма робить надаремно. Оптимізатор виграє у автора в мікрооптимізаціях майже завжди. Програє він лише там, де автор бачить алгоритм цілісно: зайвий прохід по рядку, зайвий виклик на кожен байт, зайва копія.
Стиснення на Сі - це не бій за байти, а пошук непотрібного.
Заодно цей розбір викрив помилку, якої не бачив жоден тест: поле c->options не скидалося між запитами на keep-alive з'єднанні. Після OPTIONS наступний GET оброблявся як OPTIONS і повертав 200 з порожнім тілом. Помилка прожила кілька ревізій, бо кожен тест відкривав нове з'єднання, а memset при accept обнуляв структуру. Знайшлася вона лише тоді, коли стан з'єднання зібрали в одному місці - і стало видно, що один прапорець у цьому місці забутий.
7. Що тепер вміє Сі-версія
Найпростіше пояснити різницю так. Асемблерна версія відповідає на питання «чи можна вмістити сервер в одну сторінку пам'яті». Сі-версія відповідає на питання «яким має бути сервер, щоб ним користувалися».
Ось що вона робить.
Віддає файли
Це основа. Запит файлу - відповідь з правильним типом вмісту. Браузер отримує text/html для сторінки, image/png для картинки, application/javascript для скрипта, application/wasm для WebAssembly. Всього 17 розширень, і для текстових типів автоматично додається charset=utf-8, щоб український текст не перетворювався на кракозябри.
Каталог без імені файлу віддає index.html. Якщо індексного файлу немає - показує список файлів, де імена екрановані, тому файл з ім'ям <script> у списку безпечний. Список можна вимкнути прапорцем --no-listing, і тоді каталог без індексу чесно відповідає 403. Індексний файл можна переназвати: --index home.html.
Якщо запитати каталог без завершального слеша, сервер відповідає редиректом 301 - браузер сам додасть слеш і повторить запит. Кодування в адресі зберігається як прийшло, тому каталог із пробілом в імені працює.
Економить трафік
Три механізми, і всі три важливі на повільному каналі.
Кешування. Сервер говорить браузеру: цей файл можна не перепитувати хвилину (Cache-Control: max-age=60). На кодах помилок цього заголовка немає - інакше браузер запам'ятав би 404 і не побачив би файл, який з'явився за секунду.
Умовні запити. У кожного файлу є відбиток (ETag) і дата зміни (Last-Modified). Коли браузер приходить другий раз, він надсилає відбиток, і якщо файл не мінявся, сервер відповідає 304 Not Modified - без тіла. Економиться весь обсяг файлу, передаються лише заголовки. Працює і за відбитком, і за датою: старі клієнти надсилають дату, нові - відбиток. Якщо прийшли обидва, пріоритет у відбитка, як вимагає стандарт.
Докачка. Запит Range: bytes=1000-2000 повертає рівно цей шматок з кодом 206, а не весь файл. Це тим користуються менеджери завантажень і переглядачі відео при перемотуванні. Якщо діапазон неможливий (файл коротший), приходить 416 із зазначенням реального розміру. Якщо файл змінився з моменту початку докачки, клієнт надсилає If-Range, і сервер замість шматка віддає файл цілісно - інакше клієнт склеїв би стару і нову версії.
Не віддає зайвого
Приховані файли закриті. Будь-який файл або каталог, що починається з крапки, відповідає 403. Це захищає .git, .env, .htpasswd і все інше, що не повинно потрапляти в мережу. Єдиний виняток - /.well-known/, бо без нього не випустити сертифікат Let's Encrypt. Обхід через цей виняток закритий: /.well-known/../.env отримує 403.
Вихід за корінь закритий. Шлях відкривається по одному компоненту, і на кожному кроці заборонено йти за символічними посиланнями. Симлінк на файл, симлінк на каталог, спроба вийти через .. - усе це 403. Саме тут була дірка у версії на 3 944 байти.
Сміття в запиті відкидається. Сервер перевіряє Host (обов'язковий у HTTP/1.1, не повинен дублюватися), відкидає спроби підсунути перенос рядка в заголовок, відкидає застарілий спосіб переносу заголовків на наступний рядок, відкидає Transfer-Encoding і Content-Length там, де їх бути не повинно. На все це - 400. Так закриваються атаки, де через заголовок намагаються підмішати свою відповідь.
Працює з сучасними клієнтами
GET і HEAD - звичайні запити і запити без тіла. OPTIONS - питання «що ти вмієш», відповідь містить список дозволених методів. POST, PUT, DELETE, PATCH отримують 405 з тим самим списком: метод відомий, але статичний сервер його не виконує. Невідомий метод - 501.
HTTP/1.0 і HTTP/1.1, з'єднання можна тримати відкритим (keep-alive) і надсилати кілька запитів поспіль, не чекаючи відповідей (pipelining). Працює по IPv6 і IPv4 одночасно на одному порту, адресу прив'язки задається прапорцем --addr.
У кожній відповіді є Date - без нього деякі проксі й клієнти поводяться дивно. Є Accept-Ranges: bytes - за ним клієнт розуміє, що докачка підтримується.
Обслуговує багато клієнтів
Чотири процеси за замовчуванням, до шістнадцяти прапорцем --workers. Кожен тримає до 128 з'єднань, тобто на чотирьох воркерах це п'ятсот з лишком. Всередині - неблокуючий цикл на epoll: поки один клієнт повільно приймає файл, сервер обслуговує інших, а не чекає.
Чому 128, а не більше. На роутері з ядром 3.3.8 пам'ять під буфери з'єднань - це не абстракція: при ста відкритих з'єднаннях процес займає 488 КБ, а при 256 слотах займав би 624 КБ. Різниця в 136 КБ на воркер, при чотирьох воркерах - півмегабайта. П'ятсот з'єднань для вебінтерфейсу роутера завідомо достатньо, тому зайві слоти - це пам'ять, віддана нізащо.
Три таймаути закривають зависші з'єднання: 15 секунд на отримання запиту, 30 секунд простою, 5 секунд на keep-alive між запитами. Клієнт, який відкрив з'єднання і мовчить, буде відключений і не займе слот назавжди.
Розповідає, що відбувається
Прапорець --log FILE вмикає журнал. За замовчуванням він вимкнений - на роутері зайва запись на флеш не потрібна. Увімкнений журнал пише дві речі: рядок на кожен запит у стандартному форматі (адреса клієнта, час, запит, код, розмір) і повідомлення про помилки зі стартовим записом версії. Файл перезаписується при перевищенні 256 КБ, тому місце не росте нескінченно.
Прапорець --pidfile FILE записує номер процесу - це потрібно скриптам автозапуску, щоб потім коректно зупинити сервер. --daemon забирає сервер у фон.
Відповідає на питання про себе
whitehttpd --version друкує версію, автора, ліцензію і адресу проєкту. whitehttpd --help друкує повну довідку з прикладами запуску. В заголовку кожної відповіді стоїть Server: whitehttpd/1.0.0.
Чого вона, як і раніше, не робить
Чесний список, бо він важливіший за список можливостей.
Немає TLS - шифрування термінується зовні, в stunnel або nginx. Немає CGI - форми відправляти нікуди. Немає WebSocket і HTTP/2. Немає стиснення на льоту. Немає автентифікації - якщо треба закрити паролем, це робить проксі. Немає chroot і скидання привілеїв. Немає конфіг-файлу, тільки аргументи командного рядка. Множинні діапазони в одному запиті не підтримуються, що дозволено стандартом.
Це не заміна nginx і не універсальний сервер. Це сервер статики, який робить свою роботу добре і передбачувано.
Як це виглядає в цифрах
|
Асемблерна версія |
Сі-версія |
| Розмір |
4 092 байти |
17 008 (x86-64) / 20 908 (MIPS) |
| Лістинг каталогів |
немає |
є |
Cache-Control |
є |
є |
ETag, Last-Modified, 304 |
немає |
є |
Range з 206 і 416 |
є |
є |
If-Range |
немає |
є |
OPTIONS і 405 |
немає |
є |
Date і Accept-Ranges |
немає |
є |
| Журнал |
немає |
є |
| Налаштування індексу і лістинга |
немає |
є |
| PID-файл |
немає |
є |
| IPv6 |
є |
є |
| Захист шляхів |
є |
є |
| Працює на ядрі 3.3.8 |
є |
є |
Різниця в чотири-п'ять разів за розміром - це і є ціна списку вище. Не «розповзання», а робота, яку сервер почав робити.
Акт VII. Детективне налагодження Сі-сервера на живому роутері: пастки ядра Linux 3.3.8
На робочій станції під керуванням Ubuntu x86-64 Сі-сервер httpd.c збирався за частки секунди і проходив усі тести. Однак крос-компіляція під MIPS32 Big-Endian (mipsel-linux-gnu-gcc -EB) і запуск бінарника на роутері ASUS RT-AC57U V3 з ядром Linux 3.3.8 виявили три фундаментальні пастки системного рівня.
1. Загадка Bus Error (SIGBUS, код 138)
При першому ж запуску з прапорцем --help процес аварійно завершувався:
admin@RT-AC57U_V3:/tmp# ./httpd.mipsbe --help
Bus error
Аналіз дизасембльованого коду функції main виявив причину:
addiu a1, gp, -18404
sw v0, -18408(gp)
Під час компіляції з ключем -G 65536 компілятор GCC генерував звернення до глобальних змінних через регістр $gp (Global Pointer). У стандартних програмах на Сі регістр $gp ініціалізується стартовим кодом crt1.o зі складу libc. Але в чистому freestanding-середовищі nolibc модуль crt1.o відсутній, і в регістрі $gp лишається невирівняне сміття.
На відміну від x86, процесор MIPS при спробі запису 32-бітного слова (sw) за адресою, не кратною 4 байтам, генерує апаратне переривання і знищує процес сигналом SIGBUS.
Рішення: збірка Сі-коду з прапорцем -G 0, що змушує GCC використовувати пряму 32-бітну адресацію парами інструкцій lui + addiu.
2. Часовий парадокс ядра Linux 4.11 (sys_statx vs sys_stat64)
Після усунення SIGBUS сервер запустився, але повертав 404 Not Found на всі існуючі файли.
Причина крилася всередині nolibc.h: бібліотечна обгортка stat() безумовно викликала системний виклик sys_statx (номер 4366), який з'явився в ядрі Linux лише у версії 4.11 (2017 рік). На роутерному ядрі Linux 3.3.8 (2012 рік) цей виклик повертав -ENOSYS (-38). В асемблерному whitehttpd.s цієї проблеми не було, бо від самого початку використовувався класичний sys_stat64.
Рішення: перевизначення stat() в wsock.h через sys_stat64 (номер 4213), який підтримується всіма ядрами MIPS Linux починаючи з версії 2.4.
3. Помилка 2038 року (clock_gettime64 vs gettimeofday)
Виклик time() в сучасному nolibc.h звертався до 64-бітного виклику sys_clock_gettime64 (номер 4403), доданого в Linux 5.1 для розв'язання проблеми 2038 року. На ядрі 3.3.8 виклик також повертав -ENOSYS, через що функція time() повертала неініціалізовані значення зі стеку і передчасно закривала з'єднання за таймаутом.
Рішення: перенаправлення time() в wsock.h на класичний системний виклик sys_gettimeofday (номер 4078).
Після внесення цих трьох правок в wsock.h крос-компілятор видав монолітний статичний виконуваний файл розміром 10 532 байти (після strip -s), що бездоганно працює на будь-якому поколінні ядер Linux.
Акт VIII. Натурні випробування і ціна перевірок
1. Вимірювальний арсенал і методологія
Фінальну верифікацію проводили безпосередньо на апаратному роутері ASUS RT-AC57U V3 через фізичний мережевий сегмент.
sshpass + scp: автоматизована доставка збірок у файлову систему tmpfs в оперативній пам'яті роутера (/tmp), що виключає знос NOR-флеша.
wrk (v4.2.0): багатопотоковий генератор HTTP-навантаження для заміру граничного RPS, затримок (Latency) і пропускної здатності каналу.
curl (-i, --path-as-is): валідація заголовків за стандартами RFC 7230 і RFC 7233 (Range 206), перевірка редиректів 301 і блокування атак Path Traversal (/../../../../etc/passwd).
python3 (сирі сокети socket): тестування пакетної конвеєризації (HTTP Pipelining) і перевикористання сокетів (Keep-Alive).
- Ядерна телеметрія (
netstat -tln, ps, /proc/$PID/status): контроль дескрипторів і замір реального споживання фізичної пам'яті (VmHWM).
2. Результати натурного бенчмарка wrk
Обидва сервери були запущені на роутері з ідентичним коренем документів /tmp/www_test:
- Порт 18081: асемблерний
whitehttpd (3 944 байти);
- Порт 18082: Сі-сервер
nolibc_httpd (10 532 байти).
Тест 1. Максимальний RPS (файл index.html, 25 байт, 20 з'єднань, 2 потоки)
=== Benchmark 1: whitehttpd (GAS MIPS, порт 18081) ===
Running 5s test @ http://192.168.5.220:18081/index.html
2 threads and 20 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 12.92ms 7.21ms 79.18ms 70.46%
Req/Sec 794.71 219.57 1.48k 75.00%
7912 requests in 5.00s, 1.12MB read
Requests/sec: 1581.53
Transfer/sec: 228.58KB
=== Benchmark 2: nolibc_httpd (Freestanding C, порт 18082) ===
Running 5s test @ http://192.168.5.220:18082/index.html
2 threads and 20 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 12.45ms 6.66ms 49.94ms 79.54%
Req/Sec 825.59 235.14 1.25k 56.00%
8217 requests in 5.00s, 1.16MB read
Requests/sec: 1642.21
Transfer/sec: 237.35KB
Тест 2. Прокачування важкого файлу (файл big.bin, 1 048 576 байт, sendfile)
=== Throughput: whitehttpd (GAS MIPS, порт 18081) ===
Running 3s test @ http://192.168.5.220:18081/big.bin
2 threads and 4 connections
Requests/sec: 10.32
Transfer/sec: 10.32MB/s (~82.6 Мбіт/с)
=== Throughput: nolibc_httpd (Freestanding C, порт 18082) ===
Running 3s test @ http://192.168.5.220:18082/big.bin
2 threads and 4 connections
Requests/sec: 11.66
Transfer/sec: 12.15MB/s (~97.2 Мбіт/с)
Аналіз результатів:
- За кількістю запитів на секунду (RPS) чистий асемблер (1 581 RPS) і Freestanding Сі (1 642 RPS) показали повний паритет. Невелика перевага Сі-версії зумовлена наявністю 4 процесів префорка і неблокуючою досилкою по
EPOLLOUT.
- На передачі мегабайтного файлу сервер на базі
nolibc видав 12.15 МБ/сек (97.2 Мбіт/с корисного трафіку), повністю наситивши фізичний канал 100BASE-TX мережевого контролера роутера. Замір у 82.6 Мбіт/с для асемблерної версії був отриманий при прогоні через Wi-Fi міст (де позначається оверхед радіоінтерфейсу 802.11); при прямому підключенні кабелем Ethernet ядро через sys_sendfile видає однакові 100 Мбіт/с на обох реалізаціях.
3. Верифікація заголовка Range (RFC 7233)
Перевірка формування відповіді часткового контенту підтвердила точність роботи побудувача заголовків:
$ curl -i -H "Range: bytes=0-4" http://192.168.5.220:18082/hello.txt
HTTP/1.1 206 Partial Content
Server: whitehttpd
Connection: keep-alive
Content-Type: text/plain; charset=utf-8
Content-Length: 5
Content-Range: bytes 0-4/29
Hello
Заголовок Content-Range: bytes 0-4/29 розташовується строго всередині блоку HTTP-заголовків, а тіло відповіді містить рівно 5 запитаних байт (Hello).
4. Прайс-лист перевірок
За час роботи накопичилися точні цифри: скільки коштує кожна перевірка. Це найкорисніша таблиця статті, бо вона відповідає на питання, яке зазвичай лишається без відповіді.
| Перевірка |
Ціна |
Де виміряно |
Покомпонентний openat замість одного open |
+1 968 |
MIPS, 3 944 -> 5 912 |
| Стиснення після цього (мертвий лістинг, дедуп MIME) |
-1 848 |
MIPS, 5 912 -> 4 064 |
| Range за RFC 7233 (3 виправлення) + валідація CLI |
+16 |
MIPS, 4 064 -> 4 080 |
Cache-Control |
+44 |
MIPS, 4 096 -> 4 140 (20 коду + 28 рядка) |
416 на переповнення first-byte-pos |
+16 |
MIPS |
| Скорочення reason-phrase |
-48 |
MIPS, 4 140 -> 4 092 (51 байт тексту, 3 з'їло вирівнювання) |
openat2 з RESOLVE_BENEATH замість ручного обходу |
-140 |
x86-64, ядро 5.6+ |
Перевірка Host + CRLF + framing |
0 байт |
MIPS, у фінальній збірці ціна в тактах не виявлена |
Два рядки в цій таблиці варти окремої розмови.
openat2 дешевший за ручний обхід на 140 байт. На x86-64 з ядром 5.6+ замість циклу покомпонентного відкриття можна зробити один виклик з прапорцями RESOLVE_BENEATH | RESOLVE_NO_SYMLINKS, і захист забезпечить ядро. Це рідкісний випадок, коли безпечніше рішення водночас і менше. Ціна: на ядрах старших за 5.6 виклик повертає ENOSYS, і будь-який запит отримує 403. Тому в MIPS-профілі (ядро 3.3.8) лишилося портативний покомпонентний обхід.
Перевірка Host безкоштовна за розміром. Кілька інструкцій порівняння рядків на кожен запит - це нуль байт у бінарнику. У першій безпечній збірці весь набір валідацій коштував 38% пропускної здатності, але після стиснення коду ціна зникла: 4 092 працює на рівні 3 944. Такти їсть не перевірка як така, а її невдала реалізація - і це варто пам'ятати кожному, хто міряє лише байти.
5. Чесна ціна: 1-4% в асемблері та 30% у Сі
Зіставлення розмірів бінарників до та після аудиту безпеки показує реальну ціну захищеності:
| Світ |
До аудиту |
Після аудиту |
Різниця |
Приріст |
| FASM x86-64 |
4 062 Б |
4 090 Б |
+28 Б |
+0.7% |
| GAS MIPS |
3 944 Б |
4 092 Б |
+148 Б |
+3.8% |
| nolibc x86-64 |
8 480 Б |
11 104 Б |
+2 624 Б |
+30.9% |
| nolibc MIPS BE |
10 532 Б |
13 680 Б |
+3 148 Б |
+29.9% |
Асемблер заплатив 28 байтів на x86-64 (+0.7%) та 148 байтів на MIPS (+3.8%). Сі без libc заплатив 2 624 та 3 148 байтів (+30-31% від початкового розміру). Різниця у відсотках майже десятикратна, і в цьому головний інженерний урок: в асемблері точкові перевірки безпеки інтегруються безпосередньо в регістрові інструкції без накладних витрат фреймів стека. На Сі надійна переносна ізоляція шляхів (покомпонентний обхід з O_NOFOLLOW), сувора валідація протоколу та розбір заголовків вимагали розгортання структурованих допоміжних функцій. Крім того, Сі-версія має ширший функціонал (лістинг, IPv6 dual-stack, --addr, неблокувальний автомат epoll), тож перевірки лягли на більш розвинену кодову базу.
Числа в стовпці «Після аудиту» - стан на момент закриття вразливостей. Далі два напрями розійшлися: асемблерна версія залишилася на 4 092 і не змінювалася, а версія на C продовжила розвиватися як продукт. Поточні розміри - у фінальному порівнянні нижче.
Фінальне порівняння світів
| Характеристика |
1. Класичний Сі |
2. Ручний FASM |
3. Ручний GAS MIPS |
4. Freestanding Сі |
| Каталог у репозиторії |
немає в дереві (зовнішній darkhttpd) |
немає в дереві (історичний етап) |
final-4092/ |
whitehttpd_c/ |
| Головний вихідний файл |
darkhttpd.c (зовнішній) |
whitehttpd.asm (втрачено) |
final-4092/whitehttpd.s |
whitehttpd_c/src/httpd.c + wsock.h |
| Ім'я бінарника |
darkhttpd (зовнішній) |
whitehttpd-fasm-x86_64 (втрачено) |
whitehttpd-mips32be |
whitehttpd-nolibc-x86_64
whitehttpd-nolibc-mips32be
whitehttpd-nolibc-mips32el |
| Заголовок відповіді Server |
Server: darkhttpd |
Server: whitehttpd |
Server: whitehttpd |
Server: whitehttpd/1.0.0 |
| Залежності від libc |
Потребує glibc / musl |
0 (чисте ядро) |
0 (чисте ядро) |
0 (чисте ядро) |
| Розмір бінарника на диску |
56 КБ (x86) / 927 КБ (MIPS, статично) |
4 090 байт (Sub-4KB, x86, історичний) |
4 092 байти (Sub-4KB, MIPS) |
17.0 КБ (x86) / 20.9 КБ (MIPS) |
| Споживання RAM у бою |
128 КБ - 916 КБ |
~16-20 КБ на воркер |
28 КБ (VmHWM) |
24 КБ - 80 КБ (на воркер) |
| Переносимість вихідника |
Висока |
Тільки x86-64 |
Тільки MIPS32 BE |
Абсолютна (єдиний Сі-код для x86, MIPS, ARM, RISC-V) |
| Архітектура I/O |
Неблокуючий poll |
epoll + prefork (4 воркери) |
epoll (1 процес) |
Повний FSM epoll + prefork (4 воркери), Pipelining, Range 206, Timeouts |
| Лістинг каталогів |
так |
немає (403) |
немає (403) |
так, з екрануванням, вимикається --no-listing |
| Валідатори і кеш |
ETag, Last-Modified |
немає |
Cache-Control |
ETag, Last-Modified, 304, If-None-Match, If-Modified-Since, If-Range, Cache-Control |
| Логи |
є |
немає |
немає |
--log, CLF, access + error |
| Поріг входу і підтримки |
Низький |
Високий |
Екстремальний (.set noreorder, $gp) |
Низький (чистий академічний C99) |
Примітка про FASM: у таблиці стоїть історичний прототип 4 090 байт з Актів I-II. Він не містить перевірок безпеки, доданих в Акті IV (покомпонентний openat, строга перевірка Host, CRLF, framing), і не має Cache-Control. Портування фінальної MIPS-версії на FASM x86-64 - завдання майбутньої роботи. У таблиці FASM показано як історичний етап, а не як актуальний продукт.
Примітка про структуру репозиторію: у дереві два продукти - final-4092/ (асемблер, 4 092 байти, MIPS32 BE) і whitehttpd_c/ (freestanding Сі, три бінарники: x86-64 і MIPS32 BE/LE). Проміжний порт на асемблері GAS під x86-64 (6 023 байти) з Актів I-II і FASM-прототип у дереві відсутні: це історичні етапи, їхні цифри збережено в тексті як хроніку, але перевірити їх за вихідниками вже не можна.
Епілог: Коло виявилося спіраллю
Дослідження почалося з питання про те, що лежить на самому дні комп'ютерної архітектури - нижче мови Сі.
Спуск на рівень чистого асемблера FASM і GAS довів, що повноцінний сучасний вебсервер з epoll, sendfile, Keep-Alive, підтримкою IPv6 і захистом від атак можна запакувати в 4 092 байти - одну апаратну сторінку пам'яті роутера, що споживає 28 КБ ОЗУ за VmHWM під навантаженням.
Але дорогою сталося те, чого не чекали: найменший бінарник виявився найбільш неправильним. 3 944 байти були швидшими і легшими, і водночас віддавали файли поза кореневим каталогом, пропускали CRLF-ін'єкції і не перевіряли Host. Розмір і коректність розійшлися, і знадобився окремий акт роботи, щоб звести їх назад.
Підйом на півкроку вгору - до Freestanding Сі на базі nolibc і wsock.h - замкнув архітектурне коло. Але це не те саме коло, з якого починалося дослідження.
|
Початок |
Кінець |
| Мова |
Сі |
Сі |
| libc |
так |
ні |
| MIPS |
927 КБ (статично) |
20.9 КБ |
| x86-64 |
56 КБ |
17.0 КБ |
| Як визначити, що є зайвим |
здогадки |
спуск на дно |
Дослідження повернулося до Сі, з якої починалося. Але це інша Сі: та вимагала 927 КБ і libc, ця - 20.9 КБ і нічого. Різницю дав не компілятор, а спуск на дно, звідки видно, без чого можна жити.
Сі-версія виросла з 13.7 до 20.9 КБ не випадково. Після аудиту вона перестала бути мінімальним портом і стала продуктом: блокування dotfiles, ETag з 304, If-Modified-Since, If-Range, Date, Accept-Ranges, OPTIONS з Allow, 405 замість 501, настоюваний індекс, вимикаємий лістинг, PID-файл, access і error логи, MAXFD 128 замість 64. Кожен пункт - це відповідь на реальну потребу, і кожен коштує байтів. Це не розповзання, а ціна того, що сервер почав робити роботу, якої від нього чекають.
Справжня різниця в розмірі коду між ручним еталонним асемблером і оптимізуючим компілятором Сі без libc становить не 30 разів, як здається при погляді на стандартні збірки, а 5.1 раза (4 092 байти проти 20 908 байт) на одній і тій самій архітектурі MIPS. І це справедлива ціна за повну портативність і за набір функцій, якого в асемблерної версії немає.
4 092 байти довели, що межа досяжна. 20 908 довели, що за неї не треба платити, якщо збираєшся жити з цим кодом далі.
Коло виявилося спіраллю: та сама точка по горизонталі, але рівень вище по вертикалі. І тепер видно, що лежить на дні.
Додаток: що лежить у дереві сьогодні
Усе вище - хроніка. Далі - стан на момент публікації, знятий з самого дерева, а не з пам'яті. Читач, який захоче перевірити цифри, має дивити сюди.
Асемблерний профіль
| Параметр |
Значення |
| Каталог |
final-4092/ |
| Вихідник |
whitehttpd.s (GNU as, MIPS32r2 Big-Endian) |
| Бінарник |
whitehttpd-mips32be |
| Розмір |
4 092 байти |
Заголовок Server |
whitehttpd (без версії: /1.0.0 не влазить в байти, що лишилися) |
| Вимога до ядра |
Linux 3.3.8 і вище |
Історична збірка в 3 944 байти тут відсутня і відновленню не підлягає: вона читала файли поза коренем через симлінк на проміжний каталог. Різниця в 148 байтах - це ціна перевірки шляху. Проєкт вважає її обов'язковою.
Профілі на Сі
| Бінарник |
Архітектура |
Розмір |
whitehttpd-nolibc-x86_64 |
x86-64 |
17 008 |
whitehttpd-nolibc-mips32be |
MIPS32 BE |
20 892 |
whitehttpd-nolibc-mips32el |
MIPS32 LE |
20 908 |
Каталог whitehttpd_c/, збірка make build-c (для MIPS - make build-c-mips). Це freestanding C: ні libc, ні купи, кожен виклик ядра через тонкий шар nosyscall.
Пересйомка на живому роутері
Стара збірка 3 944 байти збереглася в old/whitehttpd_mips/, тому обидві версії вдалося поставити поруч на один роутер і зняти одним інструментом. Застереження, про яке варто знати: збірка 3 944 не приймає --port і --addr. З --port вона слухає тільки loopback і скидає з'єднання; без прапорців слухає 8080 на всіх інтерфейсах. Замір тому йшов на 8080.
Умови: ASUS RT-AC57U V3, Linux 3.3.8, MIPS 74Kc, wrk -t2 -c50 -d10s із зовнішнього хоста через мережу.
| Сценарій |
3 944 байти |
4 092 байти |
Зміна |
| Keep-Alive, файл 16 Б |
1 538 req/s, 209 КБ/с |
1 421 req/s, 230 КБ/с |
-8% за RPS |
Connection: close, файл 16 Б |
не знято |
520 req/s |
- |
| Файл 256 КБ |
33.9 req/s, 8.28 МБ/с |
39.3 req/s, 9.61 МБ/с |
+16% |
Нова збірка на 8% повільніша в Keep-Alive і на 16% швидша на великому файлі. Це рівно та ціна, яку описує розділ про виправлення безпеки: на кожному запиті додалися перевірки Host і framing плюс покомпонентне відкриття шляху. На великому тілі виграш від посиленого контролю дескриптора.
Що підтверджено на залізі
| Перевірка |
Результат |
| GET, HEAD, 404, 403 |
коректно, тіло і коди |
| 301 на каталог без слеша |
Location: /space%20dir/ |
| IPv6 dual-stack |
:::порт, запити з ::1 і 127.0.0.1 обидва обслуговуються |
| Range |
206 з Content-Range, за межами файлу - 416 |
| Симлінк на файл і на каталог |
404 |
Обхід кореня (/../etc/passwd, /%2e%2e/) |
400 |
| Лістинг каталогів |
403, відсутній за контрактом |
Dotfiles (.htpasswd) |
403 |
Transfer-Encoding, подвійний Content-Length, obs-fold, HTTP/1.1 без Host |
400 |
| Ліміт заголовка запиту |
8 192 байти, вище - 400 |
| Таймаут мовчазного з'єднання |
3 с |
Чого в публічному дереві немає
- FASM-реалізації (4 062 байти) та проміжного порту на GAS x86-64 (6 023 байти) з Актів I-II. Вихідники цих проміжних версій збережені в архіві розробки, але до публічного релізу не включаються: дерево містить лише фінальні, перевірені профілі.
- Лістинга каталогів в асемблерному профілі.
- Заголовків
Date, ETag, Last-Modified в асемблерному профілі. Він віддає Server, Connection, Cache-Control, Content-Type, Content-Length.
Актуальний довідник з асемблерного профілю: final-4092/README.md та man-сторінка final-4092/whitehttpd.1.
Посилання, першоджерела і тематичні публікації
Базові проєкти та інструментарій
- darkhttpd - оригінальний мінімалістичний вебсервер на Сі (Emil Mikulic): https://unix4lyfe.org/darkhttpd/
- nolibc.h - заголовок Freestanding C з дерева ядра Linux (Willy Tarreau):
tools/include/nolibc/
- wrk - сучасний HTTP-бенчмарк: https://github.com/wg/wrk
- Flat Assembler (FASM): https://flatassembler.net/
- GNU Binutils (GAS, ld, objcopy): https://www.gnu.org/software/binutils/
Класичні роботи з екстремального зменшення ELF-бінарників (Sizecoding)
- A Whirlwind Tutorial on Creating Really Teensy ELF Executables for Linux (Brian Raiter): https://www.muppetlabs.com/~breadbox/software/tiny/teensy.html
- ELF: Executable and Linkable Format - специфікація: https://refspecs.linuxfoundation.org/elf/elf.pdf
- sstrip - утиліта відсічення таблиці секцій ELF: https://github.com/BR903/ELFkickers
Специфікації архітектури MIPS, ABI і мережевої підсистеми Linux
- MIPS32 Architecture For Programmers - офіційні томи: https://www.mips.com/products/architectures/
- System V Application Binary Interface - MIPS RISC Processor Supplement: https://refspecs.linuxfoundation.org/abi/
- Linux MIPS syscall table (O32 ABI):
arch/mips/kernel/syscalls/syscall_o32.tbl
- RFC 7230 - HTTP/1.1: Message Syntax and Routing: https://datatracker.ietf.org/doc/html/rfc7230
- RFC 7233 - HTTP/1.1: Range Requests: https://datatracker.ietf.org/doc/html/rfc7233
- epoll(7) - Linux man page: https://man7.org/linux/man-pages/man7/epoll.7.html
- openat2(2) - Linux man page: https://man7.org/linux/man-pages/man2/openat2.2.html
Проєкт whitehttpd поширюється за моделлю подвійного ліцензування: відкритий код під ліцензією AGPLv3 та комерційна OEM-ліцензія для виробників мережевого обладнання. Повна юридична документація, умови ліцензування та декларація Clean-Room опубліковані в офіційному репозиторії на GitHub.
Ниже языка Си: веб-сервер в 4 092 байта, цена проверок и возвращение без libc
Читателю на заметку: статья длинная и инженерная - дизассемблер, структуры ядра Linux, замеры на реальном железе.
О датах и цифрах: статья написана как хроника. Замеры и размеры бинарников в тексте - это снимки на момент соответствующего этапа: 3 944 байта (MIPS, до исправлений безопасности), 4 062 байта (FASM x86-64, прошлая ветка). Обе сборки в текущем дереве отсутствуют. Актуальное состояние - в разделе «Приложение: что лежит в дереве сегодня» в конце статьи: 4 092 байта на MIPS и три профиля на Си. Исторические цифры не переписаны, чтобы не разрушить рассказ о том, как они были получены.
Пролог: Иллюзия «переносимого ассемблера» и выбор оружия
К концу 2026 года индустрия окончательно свыклась с тем, что микросервис в контейнере весит 300 мегабайт, а простой бинарник «Hello World» съедает мегабайты памяти на старте. Принято считать, что память дешевая, компиляторы умнее человека, а писать низкоуровневый код вручную бессмысленно.
Но картина радикально меняется при переходе с облачных серверов на уровень встраиваемых систем - туда, где работают сотни миллионов домашних роутеров, IoT-устройств и промышленных контроллеров на чипах MIPS и ARM с крошечными 8-16 МБ флеш-памяти.
Язык Си традиционно называют «переносимым ассемблером». Однако на практике между строкой кода на Си и кремнием процессора лежит колоссальная прослойка абстракций: соглашения о вызовах (ABI), стековые фреймы, таблицы локалей, аллокаторы кучи и стандартная библиотека (glibc, musl или uClibc). Еще до того, как выполнится первая инструкция функции main(), динамический загрузчик операционной системы разворачивает в памяти рантайм весом под мегабайт.
Данное исследование начинается с фундаментального вопроса: что находится ниже языка Си, и какая реальная цена платится за абстракции стандартной библиотеки?
В качестве эталонного полигона выбран известный минималистичный веб-сервер darkhttpd (написанный на чистом Си). Цель эксперимента - воссоздать его возможности на чистом ассемблере, пробить физический предел одной аппаратной страницы памяти (Sub-4KB), а затем подняться обратно к академическому Си, полностью свободному от libc и внешних зависимостей.
Путь окажется не прямой линией, а спиралью: начав с Си, он спустится на дно ассемблера, упрется в предел, из которого нельзя выжать ни байта без потери корректности, и только тогда вернется к Си - но уже другой дорогой.
Часть I. Спуск
Акт I. Холодный душ на x86-64: когда прототип проигрывает архитектуре
На первом этапе были созданы прямолинейные реализации веб-сервера на ассемблерах FASM и GAS под архитектуру Linux x86-64 (через прямые системные вызовы syscall без единой внешней библиотеки) и выставлены против оригинального darkhttpd на Си под нагрузочное тестирование утилитой wrk (2 потока, 20 одновременных соединений).
Результаты первого замера оказались весьма показательными:
| Параметр |
FASM (v1, сырой прототип) |
GAS (v1, без -N и sstrip) |
Нативный Си (darkhttpd.c) |
| Размер бинарника |
7.4 КБ |
12 КБ |
56 КБ (+ glibc) |
| Пиковое потребление RAM |
~16 КБ (4 страницы)* |
~16 КБ* |
916 КБ (VmHWM в простое) |
| RPS (файл 1 КБ) |
5 118 req/s |
5 695 req/s |
10 403 req/s |
| Пропускная способность (256 КБ) |
641 МБ/с |
638 МБ/с |
1 040 МБ/с |
Контрольный замер финальных сборок
Таблица выше - первые замеры сырых прототипов. Когда статья готовилась к публикации, все три реализации были собраны заново и сняты в одинаковых условиях: wrk -t2 -c50 -d10s, keep-alive, файл 1 КБ, Intel Core i5-3230M, Linux 6.1.0. darkhttpd 1.17 собран cc -O2 из исходников в old/darkhttpd_c/. Каждый сервер запускался один, чтобы замеры не конкурировали за ядра.
| Реализация |
Размер |
RPS (1 КБ) |
RPS (256 КБ) |
RPS (16 Б, close) |
darkhttpd 1.17, Си + glibc |
56 256 Б |
18 000 (17.7-19.2 тыс) |
2.13 ГБ/с, 9 131 req/s |
4 039 |
| Си-профиль без libc |
17 008 Б |
41 733* (24.6-43.7 тыс) |
1.12 ГБ/с, 4 694 req/s |
- |
| FASM x86-64 |
4 062 Б |
23 000-44 000** |
1.22 ГБ/с, 5 126 req/s |
830 |
Контрольный замер закрывает два вопроса, оставленных первым актом:
- Проигрыш был проигрышем архитектуры, а не языка. На мелком файле FASM быстрее
darkhttpd в два раза, а Си-профиль без libc с ним на равных или быстрее. Когда у обеих сторон есть epoll, ассемблер не отстаёт: отставание из первой таблицы исчезло вместе с линейным циклом accept.
- Разница в размере не даёт разницы в скорости на мелком файле. 4 062 и 56 256 байт на файле 1 КБ упираются в одно и то же - в стоимость системных вызовов и разбор строки запроса. На крупном файле
darkhttpd быстрее: 2.13 ГБ/с против 1.22, потому что у него меньше системных вызовов на байт полезной нагрузки. Размер выигрывает не в скорости, а в памяти: 916 КБ ОЗУ у darkhttpd против десятков килобайт у ассемблера, и это на встраиваемом железе с 128 МБ ОЗУ решает больше, чем любые проценты пропускной способности.
Урок первого акта оказался неожиданным. Сырой ассемблерный прототип проиграл Си по скорости почти вдвое, хотя и был в 7-13 раз меньше. И здесь важно назвать причину точно, потому что её легко перепутать: проиграл не ассемблер, а архитектура прототипа. В нём был линейный цикл accept с блокирующим чтением, который не мог держать двадцать одновременных соединений так же эффективно, как poll() внутри darkhttpd. Язык тут ни при чём: на Си с тем же линейным циклом цифры были бы такими же. У darkhttpd выигрывала архитектура, а не компилятор.
Из этого выросли сразу два вывода, и оба определили дальнейший ход работы:
- Размер сам по себе ничего не значит. Маленький бинарник, который не умеет держать соединения, бесполезен. Sub-4KB без мультиплексирования не стоил бы ничего.
- Проигрыш языка нельзя списывать на язык. Первая реакция на таблицу выше - «ассемблер медленный». Контрольный замер в конце этого акта показывает, что это не так: когда у обеих сторон одинаковая архитектура, ассемблер на равных или быстрее. Грабли были в отсутствии
epoll, и Акт II - про то, как с них слезли.
Акт II. Триумф на x86-64: Sizecoding, epoll, prefork и TCP_NODELAY
Если диагноз из Акта I верен и проигрыш был в архитектуре, то лечить надо архитектурой, а не переписывать цикл на другой архитектуре ассемблерных инструкций. Так и поступили: в ассемблерные реализации были перенесены те же механизмы, на которых стоял darkhttpd, плюс два, которых у него не было. Четыре ключевых изменения:
- Sizecoding и единый RWX-сегмент: все неинициализированные буферы вынесены из секции данных в
.bss (rb в FASM), что обнулило их размер на диске. Флаг компоновщика ld -N устранил межсекционное выравнивание страниц в 4 КБ, а срезка таблицы секций (sstrip) сжала бинарник до 3.9 КБ, полностью уместив его в кэш инструкций первого уровня (L1i) процессора.
- Аппаратный префорк в 14 байт кода: два последовательных вызова
sys_fork (системный вызов 57 в ABI x86-64) сразу после sys_listen расщепляют процесс сервера на 4 параллельных воркера по числу ядер CPU без использования библиотеки pthread. Память работает в режиме Copy-on-Write (~16-20 КБ физической RAM на воркер: страница кода, страница стека и страницы буферов запроса).
- Ядерный мультиплексор
sys_epoll: замена линейного цикла принятия соединений на неблокирующую очередь событий ядра: прямые системные вызовы epoll_create1 (сискол 291 в ABI x86-64), epoll_ctl (233) и epoll_wait (232) с сохранением открытых сессий Keep-Alive.
- Снятие стоп-крана Нейгла (
TCP_NODELAY): отключение алгоритма Нейгла через setsockopt устранило 40-миллисекундную задержку наложения TCP Delayed ACK при раздельной отправке HTTP-заголовков (write) и тела файла (sendfile).
Результаты контрольного тестирования на x86-64 (wrk, Keep-Alive включен):
| Параметр |
FASM (epoll + prefork) |
Нативный Си (darkhttpd) |
Итог |
| RPS (файл 1 КБ), исходный замер |
27 719 req/s |
11 077 req/s |
Ассемблер быстрее в 2.5 раза (+150%) |
| Медианная задержка |
473 мкс (0.47 ms) |
1.50 ms |
В 3.1 раза отзывчивее |
| Пропускная способность (файл 4.4 КБ), повторный замер |
120.9 МБ/сек (27 372 req/s) |
43.0 МБ/сек (9 495 req/s) |
ASM быстрее в 2.8 раза |
| Размер бинарника |
4 062 байта (Sub-4KB!), FASM, исторический этап |
56 КБ |
В 13.8 раз меньше |
| Потребление ОЗУ (Peak RSS) |
~16-20 КБ (на воркер) |
916 КБ (VmHWM) |
В 45-55 раз меньше памяти |
Здесь ассемблер взял реванш: 27 719 запросов в секунду против 11 077 у Си, при задержке в три раза ниже и размере в четырнадцать раз меньше.
При пересъёмке эта разница сжалась. darkhttpd 1.17 на том же хосте в том же режиме даёт 17.7-19.2 тыс req/s, FASM - от 23 до 44 тыс в зависимости от того, как планировщик поделит 4 ядра между 4 воркерами сервера и потоками wrk. То есть ассемблер быстрее, но в 1.3-2.3 раза, а не в 2.5. Значение 11 077 получилось ниже потому, что в том замере darkhttpd работал в других условиях. Обе цифры оставлены: исходная - как хроника, переснятая - как проверяемая. Рубеж Sub-4KB был взят на x86-64.
Пересъёмка при подготовке к публикации. Исходные числа выше снимались на неизвестном железе, и одна строка в них была ошибочной: значение 1.29 ГБ/с получено на файле 4.4 КБ, а не на 1 КБ, как гласил заголовок. При пересъёмке обе сборки - whitehttpd-fasm-x86_64 (4 062 байта) из репозитория и Си-профиль - были заново прогнаны на i5-3230M, 4 ядра, ядро Linux 6.1.0, wrk -t2 -c50 -d10s, keep-alive:
| Параметр |
FASM 4 062 |
Си-профиль 17 008 |
Отношение |
| RPS (файл 1 КБ) |
27 466 (повтор 26 752) |
16 962 |
1.6× быстрее |
| Пропускная способность (4.4 КБ) |
99.6 МБ/с |
21.7 МБ/с |
4.6× быстрее |
| Задержка (75% / медиана, 1 КБ) |
1.82 мс / 0.43 мс |
не снято |
- |
Цифра 27 719 подтвердилась с точностью до одного процента. Соотношение с Си изменилось: исходные 2.5 раза были посчитаны против оригинального darkhttpd на Си с glibc, а пересъёмка дала 1.6 раза против freestanding Си-профиля нашего же проекта, который тоже использует epoll и префорк. Пропускная способность на файле 4.4 КБ упирается в loopback, а не в код, поэтому по ней разницу между реализациями судить нельзя.
Но x86-64 - это сервер в виртуалке. Настоящая проверка ждала на реальном кремнии.
Акт III. Переход на реальный кремний: MIPS32 Big-Endian и битва за Sub-4KB
После проверки концепции на x86-64 разработка была перенесена на аппаратную платформу домашнего роутера ASUS RT-AC57U V3 (процессор Qualcomm Atheros QCA9563, ядро MIPS 74Kc Big-Endian, ОС Linux 3.3.8). На этой архитектуре оригинальный darkhttpd на Си с glibc, собранный статически, весит 927 КБ (mipsel-linux-gnu-gcc -static -Os -s); цифра 119.2 КБ из ранней редакции статьи не воспроизводится ни с одной доступной библиотекой и здесь заменена на измеренную. Динамически слинкованный вариант занимает 56 КБ на x86-64, но требует наличия glibc в прошивке роутера, что на OpenWrt и Padavan не гарантировано. Именно размер статической сборки и есть та причина, по которой на MIPS имеет смысл писать без libc.
Для архитектуры MIPS безальтернативным инструментом является GNU Assembler (GAS). Проект получил название whitehttpd.
Первый рабочий порт на GAS MIPS32 показал размер 6 608 байт. Была поставлена задача: пробить физический барьер в 4 096 байт (Sub-4KB - ровно одна аппаратная страница виртуальной памяти и одна запись в TLB процессора), сохранив весь функционал того момента: Dual-Stack IPv6/IPv4, epoll, sendfile, Keep-Alive, urldecode, 301-редиректы и динамический HTML-лістинг каталогов (sys_getdents64). Листинг будет убран позже, когда бюджет исчерпается (см. раздел об исправлении безопасности).
Четыре ступени сжатия до 3 944 байт
- Регистр
$gp и барьер 64 КБ (6 608 -> 6 072 Б): в RISC-архитектуре MIPS каждая инструкция весит 4 байта, поэтому загрузка 32-битного адреса псевдоинструкцией la разворачивается в пару lui + addiu (8 байт). Размещение строковых констант и малых буферов (< 18 КБ) в секциях .sdata и .sbss позволило заменить двусловные загрузки на одиночные 4-байтные инструкции со смещением от регистра Global Pointer (%gp_rel), что сэкономило 536 байт. Крупные буферы (dents_buf на 8 КБ и page_buf на 64 КБ) были вынесены в .bss и адресованы через единый базовый регистр $s7.
- Конвейер
.set noreorder и сквозная цепочка $a0 (6 072 -> 4 616 Б): ручная укладка полезных инструкций в аппаратные слоты задержки переходов (Branch Delay Slots) избавила код от автоматических пустых инструкций nop. Одновременно функция strcpy была перепроектирована так, чтобы по завершении копирования регистр $a0 указывал строго на записанный терминальный нуль \0, а uint_to_str завершалась хвостовым переходом в strcpy. Это позволило собирать сложные HTTP-заголовки конвейерно, полностью исключив функции strlen и strcat.
- Структурный синтез «Brainstorm Ultra» (4 616 -> 4 344 Б): объединение структуры
sigpipe_act (используемой однократно при старте), констант сокетов opt_zero (0), opt_val (1) и шаблона структуры epoll_event (с предзаписанным флагом EPOLLIN = 1) в единый 24-байтный массив данных, а также дедупликация суффикса ; charset=utf-8 в таблице MIME.
- Очистка заголовков ELF, in-place декодирование и ликвидация стековых кадров (4 344 -> 3 944 Б): предварительное удаление служебных секций
.MIPS.abiflags и .reginfo из объектного файла через objcopy -R, отсечение таблицы секций (sstrip), устранение стековых кадров в get_mime_type, send_error, замена print_str на прямую потоковую сборку и инлайнинг декодирования URL прямо в буфер path_buf.
Исполняемый файл взял рубеж 3 944 байта на диске и 20 КБ физической RAM в боевом режиме (при раздаче 3D-приложения на WebGL через sendfile благодаря ленивому выделению страниц Demand Paging ядром Linux). На стресс-тесте в 20 потоков сервер показал 309 RPS (8.71 МБ/с полезной нагрузки), упершись ровно в физический предел 100-мегабитного порта роутера.
Этот замер относится к раздаче крупного тела. На пересъёмке при подготовке статьи та же сборка на маленьком файле дала совсем другое: 1 538 req/s в режиме Keep-Alive и 208 КБ/с на файле 16 байт. Разницы нет - есть разные вопросы. 309 RPS при 8.71 МБ/с измеряют, сколько байт пропускает сеть, и там узкое место в порту. 1 538 RPS на 16 байтах измеряют, сколько запросов успевает разобрать процессор, и там узкое место в самом сервере. Оба числа верны и не противоречат друг другу.
3 944 байта. Одна страница памяти. Двадцать килобайт ОЗУ. Сервер держит epoll, IPv6, Keep-Alive, sendfile, лістинг каталогов и 301-редиректы. Листинг исчезнет через несколько месяцев, когда вскроется обход корня через симлинк; читатель узнает об этом в разделе о цене безопасности.
Казалось, что цель достигнута и осталось только написать об этом статью.
Оставался один вопрос, который следовало задать намного раньше: что именно проверяет O_NOFOLLOW?
Часть II. Дно
Акт IV. Симлинк, который всё сломал
1. Вопрос, который надо было задать раньше
В ассемблерном сервере стояла защита от выхода за корневой каталог. Флаг O_NOFOLLOW при открытии файла запрещал ядру следовать по символической ссылке. Тест это подтверждал: файл-симлинк, указывающий за пределы корня, корректно возвращал 404.
Вопрос возник при чтении собственного кода: O_NOFOLLOW применяется к одному вызову open(). Но путь /a/b/c.txt - это три компонента. Что произойдёт, если символической ссылкой окажется не последний компонент, а промежуточный каталог?
Ответ: open("/root/link/secret.txt", O_NOFOLLOW) проверяет только secret.txt. Ссылка link, указывающая на /etc, будет разыменована ядром молча, потому что O_NOFOLLOW к ней не относится.
2. Воспроизведение
На роутере был создан каталог вне корня и симлинк на него внутри корня:
/tmp/outside/secret.txt -> "SECRET_OUTSIDE"
/tmp/www/escdir -> /tmp/outside (симлинк на каталог)
Запрос через работающий сервер:
GET /escdir/secret.txt -> 200 OK SECRET_OUTSIDE
Сервер отдал файл, лежащий вне корневого каталога. Это не теоретическая уязвимость - она воспроизвелась на боевой сборке, которая в тот момент работала на порту 80.
3. Почему тесты молчали
Здесь начинается самая неприятная часть. Тесты были зелёные. Все.
run_all.sh проверял прямой файловый симлинк - тот самый, который O_NOFOLLOW корректно блокировал. Тест проходил и создавал ощущение защищённости.
- Симлинк на промежуточный каталог не проверял никто. Такого теста просто не существовало.
- В проекте был файл
test_state_machine.c, который переписывал копию конечного автомата внутри теста и проверял эту копию, а не работающий сервер. Тест не мог обнаружить ошибку в сервере по определению: он тестировал сам себя.
Урок, ради которого стоит писать этот акт: зелёный тест ничего не доказывает, если он проверяет копию логики. Это ровно та же ошибка, что «компилируется - значит работает», только этажом выше. Компилятор проверяет синтаксис, тест-копия проверяет намерение автора теста, и ни один из них не проверяет то, что реально исполняется.
Отсюда правило, которое теперь действует в проекте: тест обязан обращаться к живому процессу через сокет. Не к функции, не к копии автомата, не к модели - к процессу, который слушает порт.
4. Первая попытка починки: цена, которая испугала
Очевидное исправление - открывать путь покомпонентно, применяя O_NOFOLLOW на каждом шаге:
fd = open(root, O_RDONLY | O_DIRECTORY);
for (each component) {
fd = openat(fd, component, O_RDONLY | O_NOFOLLOW | O_DIRECTORY);
if (fd < 0) return 404;
}
На MIPS это стоит дорого: цикл разбора пути, отдельный openat на каждый компонент, обработка ошибок на каждом шаге. Первая безопасная сборка показала 5 912 байт вместо 3 944.
Плюс 1 968 байт - почти вдвое. В этот момент показалось, что безопасность и Sub-4KB несовместимы: чтобы закрыть одну дыру, надо отказаться от самого достижения.
Заодно выяснилось, что лістинг каталогов (sys_getdents64, генерация HTML, экранирование имён) тоже придётся убрать: он стоил около 200 байт, а бюджет был исчерпан. Каталог без index.html стал возвращать 403 вместо списка файлов. Это осознанный обмен: лістинг отдан ради безопасности путей.
5. Сжатие: возвращение к одной странице
5 912 байт - это не приговор, а промежуточная точка. Дальше началась работа, которая в итоге вернула размер:
| Шаг |
Размер |
Что сделано |
| Исходная уязвимая сборка |
3 944 |
O_NOFOLLOW только на последнем компоненте |
| Первая безопасная |
5 912 |
покомпонентный openat, лістинг убран |
| Сжатие |
4 064 |
мёртвый код лістинга, дедупликация MIME, слияние структур |
| Range по RFC 7233 |
4 080 |
три исправления Range + валидация CLI |
| Финальная |
4 092 |
Cache-Control + 416 на переполнение |
Сжатие с 5 912 до 4 064 дало минус 1 848 байт - почти столько же, сколько стоила безопасность. Основные источники: удаление мёртвых строк лістинга (dir_head, tag_a и прочее, что осталось в бинарнике после отключения функции), дедупликация MIME-типов через общий пул строк и таблицу смещений, слияние мелких структур данных.
Дальше - четыре исправления Range, которые стоили всего 16 байт:
bytes=-N при N >= размера файла возвращал 200 вместо 206 (RFC 7233 §2.1 требует 206 с полным файлом). Лечится одной инструкцией movz, подрезающей N до размера.
bytes=-0 возвращал 200 вместо 416. Бесплатно: попадает в уже существующую проверку from >= size.
- Слишком большой
last-byte-pos (например, bytes=0-99999999999999999999) возвращал 200 вместо 206 с усечением. Причина: parse_dec читал только 9 цифр и не признавал остаток числа законным продолжением.
--port 8080junk принимался как 8080. parse_uint не проверял конец строки.
Четыре исправления - шестнадцать байт. Это оказалось дешевле, чем ожидалось, и стало первой хорошей новостью за весь акт.
6. Последние 12 байт: кэширование и переполнение
Финальные два добавления потребовали сократить reason-phrase, чтобы уложиться в страницу.
Cache-Control: max-age=60 выводится только на кэшируемых ответах: 200, 206, 301. Проверка - одна инструкция sltiu $t0, $s7, 400: коды меньше 400 проходят, ошибки нет. Это важно: если поставить Cache-Control на 404, браузер закэширует ошибку и не увидит появившийся файл в течение минуты.
Стоимость: 20 байт кода + 28 байт строки, замеренный прирост бинарника +44 байта.
Почему не 304 Not Modified: max-age убирает сам запрос, то есть экономит и задержку, и трафик. 304 экономит только тело, запрос всё равно идёт на сервер, а стоит 100-150 байт (нужен разбор If-None-Match или форматирование HTTP-даты). При бюджете в 16 байт max-age даёт больше пользы за меньшую цену.
Range: переполнение first-byte-pos. parse_dec читает не более 9 цифр. Если после них идёт ещё цифра, значение заведомо больше миллиарда, что превышает любой файл на целевом устройстве (128 МБ RAM, tmpfs 61 МБ). Такой запрос получает 416:
lbu $t0, 0($a0)
addiu $t1, $t0, -48
sltiu $t1, $t1, 10
bnez $t1, .L_hce_416 # 10-я цифра -> >= 1G -> 416
nop # слот задержки: ветвление в нём запрещено
bne $t0, $t7, .L_do_mime # иначе обязателен '-'
addiu $a0, $a0, 1 # [delay slot]
Стоимость: 16 байт.
7. Капкан в слоте задержки
Первая версия правки 416 уронила сервер с SIGILL на первом же запросе с заголовком Range. Сборка проходила молча, тесты падали невнятно.
Причина: bnez получил в слот задержки инструкцию bne. На MIPS слот задержки выполняется до перехода, и если в нём стоит ветвление, процессор получает неопределённое поведение. QEMU честно выдал SIGILL.
Лечится одной инструкцией nop. Урок: после каждой правки в ветвящемся коде проверять дизассемблер, а не только успешную сборку. Компилятор такое не ловит - он не знает, что ты положил в слот задержки.
8. Чем заплачено
Свободно было 16 байт, требовалось около 48. Сокращены reason-phrase - чистая косметика, клиенты используют числовой код:
| Код |
Было |
Стало |
Экономия |
| 206 |
Partial Content |
Part |
11 |
| 301 |
Moved |
Mv |
3 |
| 400 |
Bad Request |
Bad Req |
4 |
| 403 |
Forbidden |
Denied |
3 |
| 404 |
Not Found |
No |
7 |
| 416 |
Range Not Satisfiable |
Range |
16 |
| 501 |
Not Implemented |
Not Impl |
7 |
|
|
итого текста |
51 |
В бинарнике это дало минус 48 байт: три байта съело выравнивание строк в .sdata.
Это регресс по читаемости: 404 No вместо 404 Not Found. Функциональность не затронута, коды и заголовки на месте. Альтернатива - удалить --daemon (около 61 байта) - сохранила бы полные фразы, но это потеря функции, а не косметики.
Акт V. Дно: 4 092 байта и решение остановиться
1. Что получилось
Финальная сборка: 4 092 байта, запас 4 байта до страницы.
| Параметр |
Значение |
| Размер |
4 092 байта |
| Запас до 4 096 |
4 байта |
.text |
3 360 байт |
.sdata |
572 байта |
| Сегмент данных на диске |
filesz = 0 |
VmHWM в бою, wrk -t2 -c50 |
28 КБ |
Полный набор проверок на месте: GET/HEAD, HTTP/1.0 и 1.1, Keep-Alive, pipelining, index.html, 301-редирект, Range с 206/416, 17 MIME-расширений (14 уникальных типов) с charset=utf-8, восемь кодов состояния, покомпонентный openat с O_NOFOLLOW, защита от traversal, dotfiles возвращают 403, проверка Host, защита от CRLF-инъекций, отказ obs-fold, отказ Transfer-Encoding, epoll, dual-stack IPv6, игнорирование SIGPIPE, sendfile, Cache-Control.
2. Проверено на живом железе
| Проверка |
Результат |
GET /, GET /index.html |
200, тело корректное, md5 совпал с файлом на диске |
HEAD / |
200, без тела |
Range 0-9, -10 |
206, по 10 байт |
| Range переполнение |
416 |
HTTP/1.0 |
версия отражена, Connection: close |
| 301 на каталог |
Location с сохранённым %20 |
| Симлинк на файл |
404 |
| Симлинк на каталог |
404 |
| Path traversal |
400 |
| Листинг каталога |
403 (осознанно) |
| 2 000 запросов, 64 потока |
2 000 / 2 000 |
| 4 МБ передача |
md5 совпал |
VmHWM под нагрузкой, wrk -t2 -c50 |
28 КБ |
| Дескрипторы после нагрузки |
5 |
Отдельно проверено, что старый сервер на 3 944 пропускал, а новый блокирует:
| Атака |
3 944 |
4 092 |
| CRLF-инъекция в заголовке |
200 |
400 |
Отсутствие Host в HTTP/1.1 |
200 |
400 |
Дубль Host |
200 |
400 |
Transfer-Encoding в GET |
200 |
400 |
Content-Length в GET |
200 |
400 |
// в пути |
200 |
400 |
3. Цена: сравнение с версией, которая стоит на роутере
Замеры на одном файле (16 байт), wrk -t2 -c50, роутер ASUS RT-AC57U V3. Три сборки одновременно на разных портах, по пять-семь прогонов на каждую, прогоны чередовались, чтобы дрейф не исказил результат:
| Сборка |
Connection: close |
Keep-Alive |
Против 3 944 |
| 3 944 (уязвимая) |
540 req/s |
1 442 req/s |
- |
| 5 912 (первая безопасная) |
404 req/s |
892 req/s |
-25% / -38% |
| 4 092 (финальная, на роутере) |
524 req/s |
1 498 req/s |
-3% / +4% |
Отдельный замер на одном соединении (wrk -t1 -c1, keep-alive): 3944 - 228 req/s, 5912 - 222 req/s, 4092 - 232 req/s. Все три равны.
Промежуточная сборка 5 912 оказалась заметно медленнее при высокой конкуренции: минус четверть на закрытии соединения и минус 38% в keep-alive. Именно эти числа раньше принимались за цену проверок.
Однако замер на одном соединении говорит другое: обработка одного запроса у всех трёх стоит одинаково, а значит сами проверки не стоят ничего. Разница проявляется только при пятидесяти одновременных соединениях - значит 5 912 хуже масштабировалась, а не медленнее считала.
Финальная сборка 4 092 работает на уровне уязвимой: 524 против 540 на закрытии и 1 498 против 1 442 в keep-alive, разница в пределах разброса. Работа по сжатию с 5 912 до 4 092 дала и байты, и масштабирование.
Отсюда вывод, который переворачивает интуицию sizecoding-сообщества, где всё измеряют байтами: самая дорогая проверка не та, что ест байты, а та, что ест такты - но такты ест не проверка, а её неудачная реализация. Один и тот же набор валидаций стоил 38% в одной сборке и ноль в другой.
4. Реальный контракт: что умеет и чего не умеет
Все цифры выше описывают размер и скорость. Но у сервера есть ещё одна характеристика, которую обычно не пишут в статьях о sizecoding: что он реально умеет. Перечислим честно, потому что 4 092 байта накладывают жёсткие ограничения, и читатель имеет право знать их до того, как поставит сервер куда-то.
Что умеет
| Возможность |
Детали |
| Методы |
GET, HEAD. HEAD без тела на всех кодах, включая 400, 403, 404 и 416 |
| Версии протокола |
HTTP/1.0 и HTTP/1.1, версия отражается в ответе |
| Соединения |
Keep-Alive, pipelining (несколько запросов из одного буфера) |
| Индексный файл |
index.html при запросе каталога |
| Редирект каталога |
301 с Location из сырого URL: %20 и прочая кодировка сохраняются как пришли |
| Range |
Один диапазон: bytes=A-B, bytes=A-, bytes=-N. Ответы 206 и 416 |
| MIME |
17 расширений, 14 уникальных типов. charset=utf-8 только для текстовых |
| Кэширование |
Cache-Control: max-age=60 на 200, 206, 301 |
| Коды состояния |
200, 206, 301, 400, 403, 404, 416, 501 |
| Защита путей |
Покомпонентный openat с O_NOFOLLOW на каждом шаге. Traversal -> 400, симлинк -> 404 |
| Dotfiles |
Любой компонент, начинающийся с точки, -> 403 |
| Валидация запроса |
Host (наличие, единственность, непустота), CRLF-инъекции, obs-fold, Transfer-Encoding, Content-Length в GET -> 400 |
| Мультиплексирование |
epoll, один процесс, без потоков |
| Сеть |
Dual-stack IPv6 + IPv4 на одном порту (AF_INET6 + IPV6_V6ONLY=0) |
| Отдача файлов |
sendfile с полным циклом, обработка EINTR и частичной отправки |
| Сигналы |
SIGPIPE игнорируется, обрыв соединения клиентом не убивает процесс |
| CLI |
--port, --daemon |
Проверено на живом железе: 2 000 запросов при 64 потоках без потерь, 4 МБ через sendfile с совпавшим md5, VmHWM 28 КБ, дескрипторы после нагрузки возвращаются к пяти.
Чего не умеет
| Отсутствует |
Следствие |
| TLS |
Только за прокси. Пароли и токены в открытом виде |
| Листинг каталогов |
Каталог без index.html -> 403. Браузером по файлам не полазить |
| Множественные диапазоны |
bytes=0-1,4-5 игнорируется целиком, отдаётся 200 с полным телом |
ETag / Last-Modified |
Только Cache-Control. Условных запросов нет, 304 не выдаётся |
If-Modified-Since |
Заголовок читается и игнорируется, ответ всегда 200 |
If-Range |
Игнорируется: 206 выдаётся даже при несовпавшем валидаторе |
| Сжатие |
Accept-Encoding: gzip игнорируется, тело всегда как есть |
| Аутентификация |
Нет ни Basic, ни токенов |
| chroot и сброс привилегий |
Работает с теми правами, с которыми запущен |
| Логи |
Только сообщение при старте. Ни access log, ни error log |
| CGI и любой бэкенд |
POST, PUT, OPTIONS -> 501. Формы отправлять некуда |
| HTTP/2, WebSocket |
Нет |
| Конфиг-файл |
Только аргументы командной строки |
--addr |
Не поддерживается, слушает все интерфейсы. Неизвестные флаги отвергаются |
| Prefork |
Один процесс, одно ядро CPU |
| Защита от slowloris |
См. ниже |
Отдельно про slowloris
В сервере есть сборщик мусора: epoll_wait вызывается с таймаутом 3 секунды, и если событий нет, все дескрипторы от listen_fd+1 до 255 закрываются. Это защита от «зависших» соединений.
Но условие срабатывания - ноль событий. Пока хотя бы одно соединение активно, сборщик не запускается. Значит клиент, отправляющий по одному байту, держит соединение сколько угодно.
Замерено: по одному байту раз в две секунды, 80 секунд - соединение живо, сервер не разорвал его. Типовой запрос занимает 51 байт, то есть при таком темпе одно соединение удерживается больше ста секунд, и это без учёта времени на сам ответ.
Для доверенной локальной сети это приемлемо. Для публичного адреса - нет: несколько десятков таких клиентов исчерпают лимит дескрипторов.
Направление будущей работы - не закрывать дескрипторы, а переиспользовать: снимать с epoll, сбрасывать состояние соединения и возвращать в пул свободных. Тогда сборщик сможет отсекать действительно мёртвые соединения, не расходуя дескрипторы на повторные accept.
Отдельно про dotfiles и ACME
Запрет dotfiles - это защита: .git, .env, .htpasswd не отдаются никогда. Но он же блокирует .well-known/acme-challenge/, а без него не работает выпуск сертификатов Let's Encrypt по HTTP-01.
То есть сервер принципиально не может сам получить TLS-сертификат: одна защита исключает другую. Сертификат можно выпустить на другой машине и положить перед сервером прокси - но это уже внешняя инфраструктура.
Итоговая формулировка
Эта реализация - не полноценный веб-сервер общего назначения, а сервер статики с жёстко ограниченным контрактом. Всё перечисленное относится исключительно к ассемблерной версии на GAS для MIPS32 Big-Endian.
Си-реализация на nolibc закрывает большую часть этого списка: лістинг каталогов с экранированием, --addr, --workers до 16, полный неблокирующий автомат с досылкой по EPOLLOUT, ETag и 304, If-Modified-Since, If-Range, OPTIONS с Allow, 405 вместо 501, Date, Accept-Ranges, access и error логи, --index, --no-listing, --pidfile. Остаются за контрактом обеих версий: TLS, CGI, WebSocket, HTTP/2, gzip, аутентификация, chroot, конфиг-файл и множественные диапазоны. Это и есть граница между эталоном размера и продуктом.
Годится: раздача статики и веб-интерфейса на современном Linux, доверенный хост или локальная сеть, отдельный каталог без секретов, непривилегированный пользователь, за прокси если нужен TLS.
Не годится: замена nginx или Apache, файловый менеджер, публичный адрес с секретами, что-либо требующее авторизации, обработки форм или логов.
5. Дно найдено
4 байта запаса. Любое следующее добавление требует либо вырезать существующую функцию, либо сокращать код дальше.
Что уже отдано: лістинг каталогов. Что можно отдать ещё: --daemon (61 байт), MIME-типы mjs и md, сжатие strstr/strcmp. Каждый вариант - это потеря функции или риск ошибки ради нескольких десятков байт.
И вот здесь стало ясно: ассемблерный путь исчерпан. Не потому что 4 092 - плохое число, а потому что дальше начинается не инженерия, а выжимание. Каждая новая проверка - бой за шестнадцать байт, каждая правка требует пересчёта всей геометрии данных.
Ассемблерный путь требует не просто усидчивости. Он требует отказа от многого: от читаемости, от привычных абстракций, от возможности быстро что-то поправить. Когда цель - не просто написать работающий код, а вписаться в искусственное ограничение в одну страницу памяти и при этом честно выполнять заявленные функции, каждый байт становится решением, а каждое решение - компромиссом. Это дисциплина, которая многому учит, но жить в ней постоянно нельзя.
4 092 байта доказали, что предел достижим. Дальше надо было решать, что делать с этим знанием.
Часть III. Подъём
Акт VI. Переход на Си: nolibc, собственный wsock.h и перенос MIPS-версии
1. Почему не ассемблер
Достигнув дна на MIPS32, проект упёрся в проблему масштабирования протокольной логики. Ассемблерный сервер молниеносен и невесом, но что делать, если требуется внедрить механизмы промышленного класса?
- Полноценный неблокирующий конечный автомат с досылкой по
EPOLLOUT (чтобы при отдаче тяжелого файла медленному клиенту процесс не блокировался в sendfile, а асинхронно переключался между сотнями соединений);
- Поддержку HTTP/1.1 Pipelining (пакетная обработка склеенных запросов из одного буфера со сдвигом
memmove);
- Кольцевой сборщик таймаутов и защиту от slowloris;
- Расширение таблицы MIME и новых кодов состояния.
Каждое из этих добавлений в ассемблере - это бой за байты, которого больше нет. Разработка многофазного автомата вручную на ассемблере неизбежно ведет к дублированию кодовой базы.
2. Виноват ли Си
И здесь круг замыкается: виноват ли в раздувании бинарников сам язык Си?
Вовсе нет. Компилятор GCC по своей природе является транслятором текста из языка Си в текст на языке GNU Assembler (GAS). Весь лишний вес и мегабайтный расход памяти привносит стандартная библиотека libc.
Полный отказ от стандартной библиотеки (-nostdlib, -fno-builtin) позволяет объединить чистый язык Си (Freestanding C99), официальный однофайловый заголовок nolibc.h из дерева исходников ядра Linux (tools/include/nolibc, созданный автором HAProxy Вилли Тарро специально для сборки ультракомпактных системных утилит) и тонкую собственную прослойку сетевых системных вызовов wsock.h.
Это не «синтез ассемблерных приёмов в Си». MIPS-версия была перенесена в Си, а сетевой слой, которого в nolibc нет, дописан отдельно. Осознанный шаг: вернуть возможность развивать код, заплатив за это размером.
3. Анатомия wsock.h: укрощение несовместимостей MIPS и x86
В штатном nolibc.h отсутствует работа с сетью и сигналами. Разработанный заголовочный файл wsock.h (9 системных вызовов и 4 структуры) изолирует в себе всю архитектурную специфику портирования между x86-64 и MIPS32:
- Перевернутые константы сокетов в MIPS ABI: в MIPS Linux константа
SOCK_STREAM равна 2 (тогда как на x86 и ARM это 1 - на MIPS единица создаст UDP-сокет!), уровень SOL_SOCKET равен 0xFFFF, а флаг неблокирующего режима O_NONBLOCK - 0x0080.
- Геометрия ядерных структур: на x86-64 структура
epoll_event упакована до 12 байт, а на MIPS выровнена до 16 байт. В структуре sigaction на MIPS первым полем идет sa_flags, а вторым - указатель sa_handler.
- Zero-Stack Syscalls для MIPS O32: стандартные макросы
nolibc резервируют на стеке 32 байта перед каждым системным вызовом (addiu $sp, $sp, -32 / +32). Поскольку ядро Linux не пишет в эту область пользовательского стека, замена их на безстековые ассемблерные вставки ws_syscall0..4 экономит по 2 инструкции (8 байт) на каждом из 31 вызова в программе.
4. Архитектура httpd.c: академический автомат без malloc
Поверх wsock.h построен единый кроссплатформенный исходник httpd.c, компилирующийся без единого #ifdef как под x86-64, так и под MIPS32 Big-Endian:
- Zero-Allocation: полное отсутствие вызовов
malloc, calloc или free. Все массивы состояний и буферы размещены статически в .bss и выделяются ядром по требованию (Demand Paging).
- Безопасный построитель строк (
struct buf): структура { char *p; char *end; } (высокоуровневый аналог сквозного указателя $a0 из ассемблерной версии) физически исключает выход за границы буфера.
- Неблокирующий конвейер: при возврате
EAGAIN от sendfile дескриптор переводится в ожидание WS_EPOLLOUT с сохранением смещения body_sent.
5. Устранение протокольной ошибки Range (206 Partial Content)
В ходе аудита httpd.c была выявлена и устранена тонкая ошибка формирования ответа 206 Partial Content: базовая функция build_header() завершала заголовок двойным переносом \r\n\r\n, после чего обработчик Range дописывал заголовок Content-Range: bytes ... \r\n\r\n уже за пределами закрытого блока заголовков. В результате клиент воспринимал строку Content-Range как первые 35 байт тела файла.
Откат указателя записи на 2 байта перед вставкой Content-Range обеспечил 100% соответствие стандарту RFC 7233:
/* Вставка Content-Range строго перед закрывающим \r\n блока заголовков */
hb.p = g_hdr[fd] + g_hlen[fd] - 2;
6. Что такое sizecoding на Си
Переход на Си не отменяет вопрос размера. Он его переформулирует.
На ассемблере размер - это свойство каждой строки. Хочешь меньше - пиши другие инструкции. На Си такой прямой связи нет: между исходником и машинным кодом стоит оптимизатор, и он часто умнее автора. Проверить это можно было только экспериментом.
Взяли самую большую функцию - prepare(), разбор запроса, 3.8 КБ на x86-64 и 4.6 КБ на MIPS - и попробовали её сжать шестью способами.
| Попытка |
Идея |
Результат |
| 1 |
Единый выход через goto для 15 вызовов error_response(400) |
+32 |
| 2 |
switch по первой букве имени заголовка вместо цепочки iequal |
+64 |
| 3 |
Проверка версии через memcmp("HTTP/1.",7) вместо двух strcmp |
+32 |
| 4 |
Таблица tchar[128] вместо strchr для проверки имени |
+112 |
| 5 |
Вынос обработки каталога в отдельную функцию resolve_dir() |
+96 |
| 6 |
Побайтовый скан memcmp на каждой позиции -> strstr |
-64 |
Пять попыток из шести сделали код больше. Причины конкретны:
- Единый выход не нужен: GCC уже сливает одинаковые хвосты в общий блок, а метка добавила переход.
switch на пять case генерирует таблицу переходов, и она дороже цепочки сравнений.
- Таблица в 128 байт дороже, чем экономят вызовы
strchr.
- Вынос функции добавил пролог и дублирование, а выигрыш от разделения не покрыл накладных расходов.
Единственный выигрыш дала не замена конструкции на «более хитрую», а устранение лишней работы. Поиск конца заголовков был написан так:
for(i=0;i+3<c->used;i++)if(!memcmp(r+i,"\r\n\r\n",4)){end=r+i;break;}
На каждый байт запроса - вызов memcmp с четырьмя сравнениями. Для 500-байтного запроса это 500 вызовов. Буфер уже терминирован нулём после каждого read, поэтому достаточно strstr(r,"\r\n\r\n"). Минус 64 байта и заметно меньше работы: пропускная способность выросла с 24 700 до 25 200 запросов в секунду.
Отсюда вывод, который стоит запомнить всякому, кто приходит в sizecoding из ассемблера: на Си размер определяется не тем, насколько хитро написана строка, а тем, сколько работы программа делает зря. Оптимизатор выигрывает у автора в микрооптимизациях почти всегда. Проигрывает он только там, где автор видит алгоритм целиком: лишний проход по строке, лишний вызов на каждый байт, лишняя копия.
Сжатие на Си - это не бой за байты, а поиск ненужного.
Заодно этот разбор вскрыл ошибку, которую не видел ни один тест: поле c->options не сбрасывалось между запросами на keep-alive соединении. После OPTIONS следующий GET обрабатывался как OPTIONS и возвращал 200 с пустым телом. Ошибка прожила несколько ревизий, потому что каждый тест открывал новое соединение, а memset при accept обнулял структуру. Нашлась она только тогда, когда состояние соединения собрали в одном месте - и стало видно, что один флаг в этом месте забыт.
7. Что теперь умеет Си-версия
Проще всего объяснить разницу так. Ассемблерная версия отвечает на вопрос «можно ли уместить сервер в одну страницу памяти». Си-версия отвечает на вопрос «каким должен быть сервер, чтобы им пользовались».
Вот что она делает.
Отдаёт файлы
Это основа. Запрос файла - ответ с правильным типом содержимого. Браузер получает text/html для страницы, image/png для картинки, application/javascript для скрипта, application/wasm для WebAssembly. Всего 17 расширений, и для текстовых типов автоматически добавляется charset=utf-8, чтобы русский текст не превращался в кракозябры.
Каталог без имени файла отдаёт index.html. Если индексного файла нет - показывает список файлов, где имена экранированы, поэтому файл с именем <script> в списке безобиден. Список можно отключить флагом --no-listing, и тогда каталог без индекса честно отвечает 403. Индексный файл можно переназначить: --index home.html.
Если запросить каталог без завершающего слеша, сервер отвечает редиректом 301 - браузер сам добавит слеш и повторит запрос. Кодировка в адресе сохраняется как пришла, поэтому каталог с пробелом в имени работает.
Экономит трафик
Три механизма, и все три важны на медленном канале.
Кэширование. Сервер говорит браузеру: этот файл можно не переспрашивать минуту (Cache-Control: max-age=60). На кодах ошибок этого заголовка нет - иначе браузер запомнил бы 404 и не увидел бы файл, который появился через секунду.
Условные запросы. У каждого файла есть отпечаток (ETag) и дата изменения (Last-Modified). Когда браузер приходит второй раз, он присылает отпечаток, и если файл не менялся, сервер отвечает 304 Not Modified - без тела. Экономится весь объём файла, передаются только заголовки. Работает и по отпечатку, и по дате: старые клиенты присылают дату, новые - отпечаток. Если пришли оба, приоритет у отпечатка, как требует стандарт.
Докачка. Запрос Range: bytes=1000-2000 возвращает ровно этот кусок с кодом 206, а не весь файл. Это то, чем пользуются менеджеры загрузок и просмотрщики видео при перемотке. Если диапазон невозможен (файл короче), приходит 416 с указанием реального размера. Если файл изменился с момента начала докачки, клиент присылает If-Range, и сервер вместо куска отдаёт файл целиком - иначе клиент склеил бы старую и новую версии.
Не отдаёт лишнего
Скрытые файлы закрыты. Любой файл или каталог, начинающийся с точки, отвечает 403. Это защищает .git, .env, .htpasswd и всё остальное, что не должно попадать в сеть. Единственное исключение - /.well-known/, потому что без него не выпустить сертификат Let's Encrypt. Обход через это исключение закрыт: /.well-known/../.env получает 403.
Выход за корень закрыт. Путь открывается по одному компоненту, и на каждом шаге запрещено следовать символическим ссылкам. Симлинк на файл, симлинк на каталог, попытка выйти через .. - всё это 403. Именно здесь была дыра в версии на 3 944 байта.
Мусор в запросе отвергается. Сервер проверяет Host (обязателен в HTTP/1.1, не должен дублироваться), отвергает попытки подсунуть перевод строки в заголовок, отвергает устаревший способ переноса заголовков на следующую строку, отвергает Transfer-Encoding и Content-Length там, где их быть не должно. На всё это - 400. Так закрываются атаки, где через заголовок пытаются подмешать свой ответ.
Работает с современными клиентами
GET и HEAD - обычные запросы и запросы без тела. OPTIONS - вопрос «что ты умеешь», ответ содержит список разрешённых методов. POST, PUT, DELETE, PATCH получают 405 с тем же списком: метод известен, но статический сервер его не выполняет. Неизвестный метод - 501.
HTTP/1.0 и HTTP/1.1, соединение можно держать открытым (keep-alive) и отправлять несколько запросов подряд, не дожидаясь ответов (pipelining). Работает по IPv6 и IPv4 одновременно на одном порту, адрес привязки задаётся флагом --addr.
В каждом ответе есть Date - без него некоторые прокси и клиенты ведут себя странно. Есть Accept-Ranges: bytes - по нему клиент понимает, что докачка поддерживается.
Обслуживает много клиентов
Четыре процесса по умолчанию, до шестнадцати флагом --workers. Каждый держит до 128 соединений, то есть на четырёх воркерах это пятьсот с лишним. Внутри - неблокирующий цикл на epoll: пока один клиент медленно принимает файл, сервер обслуживает остальных, а не ждёт.
Почему 128, а не больше. На роутере с ядром 3.3.8 память под буферы соединений - это не абстракция: при ста открытых соединениях процесс занимает 488 КБ, а при 256 слотах занимал бы 624 КБ. Разница в 136 КБ на воркер, при четырёх воркерах - полмегабайта. Пятьсот соединений для веб-интерфейса роутера заведомо достаточно, поэтому лишние слоты - это память, отданная ни за что.
Три таймаута закрывают зависшие соединения: 15 секунд на получение запроса, 30 секунд простоя, 5 секунд на keep-alive между запросами. Клиент, который открыл соединение и молчит, будет отключён и не займёт слот навсегда.
Рассказывает, что происходит
Флаг --log FILE включает журнал. По умолчанию он выключен - на роутере лишняя запись на флеш не нужна. Включённый журнал пишет две вещи: строку на каждый запрос в стандартном формате (адрес клиента, время, запрос, код, размер) и сообщения об ошибках со стартовой записью версии. Файл перезаписывается при превышении 256 КБ, поэтому место не растёт бесконечно.
Флаг --pidfile FILE записывает номер процесса - это нужно скриптам автозапуска, чтобы потом корректно остановить сервер. --daemon уводит сервер в фон.
Отвечает на вопросы о себе
whitehttpd --version печатает версию, автора, лицензию и адрес проекта. whitehttpd --help печатает полную справку с примерами запуска. В заголовке каждого ответа стоит Server: whitehttpd/1.0.0.
Чего она по-прежнему не делает
Честный список, потому что он важнее списка возможностей.
Нет TLS - шифрование терминируется снаружи, в stunnel или nginx. Нет CGI - формы отправлять некуда. Нет WebSocket и HTTP/2. Нет сжатия на лету. Нет аутентификации - если нужно закрыть паролем, это делает прокси. Нет chroot и сброса привилегий. Нет конфиг-файла, только аргументы командной строки. Множественные диапазоны в одном запросе не поддерживаются, что разрешено стандартом.
Это не замена nginx и не универсальный сервер. Это сервер статики, который делает свою работу хорошо и предсказуемо.
Как это выглядит в цифрах
|
Ассемблерная версия |
Си-версия |
| Размер |
4 092 байта |
17 008 (x86-64) / 20 908 (MIPS) |
| Листинг каталогов |
нет |
да |
Cache-Control |
да |
да |
ETag, Last-Modified, 304 |
нет |
да |
Range с 206 и 416 |
да |
да |
If-Range |
нет |
да |
OPTIONS и 405 |
нет |
да |
Date и Accept-Ranges |
нет |
да |
| Журнал |
нет |
да |
| Настройка индекса и лістинга |
нет |
да |
| PID-файл |
нет |
да |
| IPv6 |
да |
да |
| Защита путей |
да |
да |
| Работает на ядре 3.3.8 |
да |
да |
Разница в четыре-пять раз по размеру - это и есть цена списка выше. Не «расползание», а работа, которую сервер начал делать.
Акт VII. Детективная отладка Си-сервера на живом роутере: ловушки ядра Linux 3.3.8
На рабочей станции под управлением Ubuntu x86-64 Си-сервер httpd.c собирался за доли секунды и проходил все тесты. Однако кросс-компиляция под MIPS32 Big-Endian (mipsel-linux-gnu-gcc -EB) и запуск бинарника на роутере ASUS RT-AC57U V3 под управлением ядра Linux 3.3.8 вскрыли три фундаментальные ловушки системного уровня.
1. Загадка Bus Error (SIGBUS, код 138)
При первом же запуске с флагом --help процесс аварийно завершался:
admin@RT-AC57U_V3:/tmp# ./httpd.mipsbe --help
Bus error
Анализ дизассемблированного кода функции main выявил причину:
addiu a1, gp, -18404
sw v0, -18408(gp)
При компиляции с ключом -G 65536 компилятор GCC генерировал обращения к глобальным переменным через регистр $gp (Global Pointer). В стандартных программах на Си регистр $gp инициализируется стартовым кодом crt1.o из состава libc. Но в чистой freestanding-среде nolibc модуль crt1.o отсутствует, и в регистре $gp остается невыровненный мусор.
В отличие от x86, процессор MIPS при попытке записи 32-битного слова (sw) по адресу, не кратному 4 байтам, генерирует аппаратное прерывание и уничтожает процесс сигналом SIGBUS.
Решение: сборка Си-кода с флагом -G 0, принуждающим GCC использовать прямую 32-битную адресацию парами инструкций lui + addiu.
2. Временной парадокс ядра Linux 4.11 (sys_statx vs sys_stat64)
После устранения SIGBUS сервер запустился, но возвращал 404 Not Found на все существующие файлы.
Причина крылась внутри nolibc.h: библиотечная обертка stat() безусловно вызывала системный вызов sys_statx (номер 4366), который появился в ядре Linux только в версии 4.11 (2017 год). На роутерном ядре Linux 3.3.8 (2012 год) этот вызов возвращал -ENOSYS (-38). В ассемблерном whitehttpd.s этой проблемы не возникало, так как изначально использовался классический sys_stat64.
Решение: переопределение stat() в wsock.h через sys_stat64 (номер 4213), поддерживаемый всеми ядрами MIPS Linux начиная с версии 2.4.
3. Ошибка 2038 года (clock_gettime64 vs gettimeofday)
Вызов time() в современном nolibc.h обращался к 64-битному вызову sys_clock_gettime64 (номер 4403), добавленному в Linux 5.1 для решения проблемы 2038 года. На ядре 3.3.8 вызов также возвращал -ENOSYS, из-за чего функция time() возвращала неинициализированные значения со стека и преждевременно закрывала соединения по таймауту.
Решение: перенаправление time() в wsock.h на классический системный вызов sys_gettimeofday (номер 4078).
После внесения этих трех правок в wsock.h кросс-компилятор выдал монолитный статический исполняемый файл размером 10 532 байта (после strip -s), безупречно работающий на любом поколении ядер Linux.
Акт VIII. Натурные испытания и цена проверок
1. Измерительный арсенал и методология
Финальная верификация проводилась непосредственно на аппаратном роутере ASUS RT-AC57U V3 через физический сетевой сегмент.
sshpass + scp: автоматизированная доставка сборок в файловую систему tmpfs в оперативной памяти роутера (/tmp), исключающая износ NOR-флеша.
wrk (v4.2.0): многопоточный генератор HTTP-нагрузки для замера предельного RPS, задержек (Latency) и пропускной способности канала.
curl (-i, --path-as-is): валидация заголовков по стандартам RFC 7230 и RFC 7233 (Range 206), проверка редиректов 301 и блокировки атак Path Traversal (/../../../../etc/passwd).
python3 (сырые сокеты socket): тестирование пакетной конвейеризации (HTTP Pipelining) и переиспользования сокетов (Keep-Alive).
- Ядерная телеметрия (
netstat -tln, ps, /proc/$PID/status): контроль дескрипторов и замер реального потребления физической памяти (VmHWM).
2. Результаты натурного бенчмарка wrk
Оба сервера были запущены на роутере с идентичным корнем документов /tmp/www_test:
- Порт 18081: ассемблерный
whitehttpd (3 944 байта);
- Порт 18082: Си-сервер
nolibc_httpd (10 532 байта).
Тест 1. Максимальный RPS (файл index.html, 25 байт, 20 соединений, 2 потока)
=== Benchmark 1: whitehttpd (GAS MIPS, порт 18081) ===
Running 5s test @ http://192.168.5.220:18081/index.html
2 threads and 20 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 12.92ms 7.21ms 79.18ms 70.46%
Req/Sec 794.71 219.57 1.48k 75.00%
7912 requests in 5.00s, 1.12MB read
Requests/sec: 1581.53
Transfer/sec: 228.58KB
=== Benchmark 2: nolibc_httpd (Freestanding C, порт 18082) ===
Running 5s test @ http://192.168.5.220:18082/index.html
2 threads and 20 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 12.45ms 6.66ms 49.94ms 79.54%
Req/Sec 825.59 235.14 1.25k 56.00%
8217 requests in 5.00s, 1.16MB read
Requests/sec: 1642.21
Transfer/sec: 237.35KB
Тест 2. Прокачка тяжелого файла (файл big.bin, 1 048 576 байт, sendfile)
=== Throughput: whitehttpd (GAS MIPS, порт 18081) ===
Running 3s test @ http://192.168.5.220:18081/big.bin
2 threads and 4 connections
Requests/sec: 10.32
Transfer/sec: 10.32MB/s (~82.6 Мбит/с)
=== Throughput: nolibc_httpd (Freestanding C, порт 18082) ===
Running 3s test @ http://192.168.5.220:18082/big.bin
2 threads and 4 connections
Requests/sec: 11.66
Transfer/sec: 12.15MB/s (~97.2 Мбит/с)
Анализ результатов:
- По числу обрабатываемых запросов в секунду (RPS) чистый ассемблер (1 581 RPS) и Freestanding Си (1 642 RPS) показали полный паритет. Небольшое преимущество Си-версии обусловлено наличием 4 процессов префорка и неблокирующей докачкой по
EPOLLOUT.
- На передаче мегабайтного файла сервер на базе
nolibc выдал 12.15 МБ/сек (97.2 Мбит/с полезного трафика), полностью насытив физический канал 100BASE-TX сетевого контроллера роутера. Замер в 82.6 Мбит/с для ассемблерной версии был получен при прогоне через Wi-Fi мост (где сказывается оверхед радиоинтерфейса 802.11); при прямом подключении кабелем Ethernet ядро через sys_sendfile выдает одинаковые 100 Мбит/с на обеих реализациях.
3. Верификация заголовка Range (RFC 7233)
Проверка формирования ответа частичного контента подтвердила точность работы построителя заголовков:
$ curl -i -H "Range: bytes=0-4" http://192.168.5.220:18082/hello.txt
HTTP/1.1 206 Partial Content
Server: whitehttpd
Connection: keep-alive
Content-Type: text/plain; charset=utf-8
Content-Length: 5
Content-Range: bytes 0-4/29
Hello
Заголовок Content-Range: bytes 0-4/29 располагается строго внутри блока HTTP-заголовков, а тело ответа содержит ровно 5 запрошенных байт (Hello).
4. Прайс-лист проверок
За время работы накопились точные цифры: сколько стоит каждая проверка. Это самая полезная таблица статьи, потому что она отвечает на вопрос, который обычно остаётся без ответа.
| Проверка |
Цена |
Где измерено |
Покомпонентный openat вместо одного open |
+1 968 |
MIPS, 3 944 -> 5 912 |
| Сжатие после этого (мёртвый лістинг, дедуп MIME) |
-1 848 |
MIPS, 5 912 -> 4 064 |
| Range по RFC 7233 (3 исправления) + валидация CLI |
+16 |
MIPS, 4 064 -> 4 080 |
Cache-Control |
+44 |
MIPS, 4 096 -> 4 140 (20 кода + 28 строки) |
416 на переполнение first-byte-pos |
+16 |
MIPS |
| Сокращение reason-phrase |
-48 |
MIPS, 4 140 -> 4 092 (51 байт текста, 3 съело выравнивание) |
openat2 с RESOLVE_BENEATH вместо ручного обхода |
-140 |
x86-64, ядро 5.6+ |
Проверка Host + CRLF + framing |
0 байт |
MIPS, в финальной сборке цена в тактах не обнаружена |
Две строки в этой таблице стоят отдельного разговора.
openat2 дешевле ручного обхода на 140 байт. На x86-64 с ядром 5.6+ вместо цикла покомпонентного открытия можно сделать один вызов с флагами RESOLVE_BENEATH | RESOLVE_NO_SYMLINKS, и защиту обеспечит ядро. Это редкий случай, когда более безопасное решение одновременно и меньше. Цена: на ядрах старше 5.6 вызов возвращает ENOSYS, и любой запрос получает 403. Поэтому в MIPS-профиле (ядро 3.3.8) остался переносимый покомпонентный обход.
Проверка Host бесплатна по размеру. Несколько инструкций сравнения строк на каждый запрос - это ноль байт в бинарнике. В первой безопасной сборке весь набор валидаций стоил 38% пропускной способности, но после сжатия кода цена исчезла: 4 092 работает на уровне 3 944. Такты ест не проверка как таковая, а её неудачная реализация - и это стоит помнить всякому, кто измеряет только байты.
5. Честная цена: 1-4% в ассемблере и 30% в Си
Сопоставление размеров бинарников до и после аудита безопасности показывает реальную цену защищённости:
| Мир |
До аудита |
После аудита |
Разница |
Прирост |
| FASM x86-64 |
4 062 Б |
4 090 Б |
+28 Б |
+0.7% |
| GAS MIPS |
3 944 Б |
4 092 Б |
+148 Б |
+3.8% |
| nolibc x86-64 |
8 480 Б |
11 104 Б |
+2 624 Б |
+30.9% |
| nolibc MIPS BE |
10 532 Б |
13 680 Б |
+3 148 Б |
+29.9% |
Ассемблер заплатил 28 байт на x86-64 (+0.7%) и 148 байт на MIPS (+3.8%). Си без libc заплатил 2 624 и 3 148 байт (+30-31% от исходного размера). Разница в процентах почти десятикратная, и в этом главный инженерный урок: в ассемблере защита от атак вписывается точечно в существующие регистровые переходы без накладных расходов фреймов стека. На Си полноценная переносимая изоляция путей (покомпонентный обход с O_NOFOLLOW), строгая проверка протокола и разбор заголовков потребовали разворачивания структурированных вспомогательных функций. Кроме того, Си-версия богаче функционально (каталоги, IPv6 dual-stack, --addr, неблокирующий автомат epoll), поэтому проверки легли на более развитую кодовую базу.
Числа в столбце «После аудита» - состояние на момент закрытия уязвимостей. Дальше два продукта разошлись: ассемблерная версия осталась на 4 092 и не менялась, а Си-версия продолжила развиваться как продукт. Текущие размеры - в финальном сравнении ниже.
Финальное сравнение миров
| Характеристика |
1. Классический Си |
2. Ручной FASM |
3. Ручной GAS MIPS |
4. Freestanding Си |
| Каталог в репозитории |
нет в дереве (внешний darkhttpd) |
нет в дереве (исторический этап) |
final-4092/ |
whitehttpd_c/ |
| Главный исходный файл |
darkhttpd.c (внешний) |
whitehttpd.asm (утрачен) |
final-4092/whitehttpd.s |
whitehttpd_c/src/httpd.c + wsock.h |
| Имя бинарника |
darkhttpd (внешний) |
whitehttpd-fasm-x86_64 (утрачен) |
whitehttpd-mips32be |
whitehttpd-nolibc-x86_64
whitehttpd-nolibc-mips32be
whitehttpd-nolibc-mips32el |
| Заголовок ответа Server |
Server: darkhttpd |
Server: whitehttpd |
Server: whitehttpd |
Server: whitehttpd/1.0.0 |
| Зависимости от libc |
Требует glibc / musl |
0 (чистое ядро) |
0 (чистое ядро) |
0 (чистое ядро) |
| Размер бинарника на диске |
56 КБ (x86) / 927 КБ (MIPS, статически) |
4 090 байт (Sub-4KB, x86, исторический) |
4 092 байта (Sub-4KB, MIPS) |
17.0 КБ (x86) / 20.9 КБ (MIPS) |
| Потребление RAM в бою |
128 КБ - 908 КБ |
~16-20 КБ на воркер |
28 КБ (VmHWM) |
24 КБ - 80 КБ (на воркер) |
| Переносимость исходника |
Высокая |
Только x86-64 |
Только MIPS32 BE |
Абсолютная (единый Си-код для x86, MIPS, ARM, RISC-V) |
| Архитектура I/O |
Неблокирующий poll |
epoll + prefork (4 воркера) |
epoll (1 процесс) |
Полный FSM epoll + prefork (4 воркера), Pipelining, Range 206, Timeouts |
| Листинг каталогов |
да |
нет (403) |
нет (403) |
да, с экранированием, отключается --no-listing |
| Валидаторы и кэш |
ETag, Last-Modified |
нет |
Cache-Control |
ETag, Last-Modified, 304, If-None-Match, If-Modified-Since, If-Range, Cache-Control |
| Логи |
есть |
нет |
нет |
--log, CLF, access + error |
| Порог входа и поддержки |
Низкий |
Высокий |
Экстремальный (.set noreorder, $gp) |
Низкий (чистый академический C99) |
Примечание о FASM: в таблице стоит исторический прототип 4 090 байт из Актов I-II. Он не содержит проверок безопасности, добавленных в Акте IV (покомпонентный openat, строгая проверка Host, CRLF, framing), и не имеет Cache-Control. Портирование финальной MIPS-версии на FASM x86-64 - задача будущей работы. В таблице FASM показан как исторический этап, а не как актуальный продукт.
Примечание о структуре репозитория: в дереве два продукта - final-4092/ (ассемблер, 4 092 байта, MIPS32 BE) и whitehttpd_c/ (freestanding Си, три бинарника: x86-64 и MIPS32 BE/LE). Промежуточный порт на GAS под x86-64 (6 023 байта) и FASM-прототип из Актов I-II в дереве отсутствуют: это исторические этапы, их цифры сохранены в тексте как хроника, но проверить их по исходникам сейчас нельзя.
Эпилог: Круг оказался спиралью
Исследование началось с вопроса о том, что лежит на самом дне компьютерной архитектуры - ниже языка Си.
Спуск на уровень чистого ассемблера FASM и GAS доказал, что полноценный современный веб-сервер с epoll, sendfile, Keep-Alive, поддержкой IPv6 и защитой от атак можно упаковать в 4 092 байта - одну аппаратную страницу памяти роутера, потребляющую 28 КБ ОЗУ по VmHWM под нагрузкой.
Но по дороге случилось то, чего не ждали: самый маленький бинарник оказался самым неправильным. 3 944 байта были быстрее и легче, и при этом отдавали файлы вне корневого каталога, пропускали CRLF-инъекции и не проверяли Host. Размер и корректность разошлись, и понадобился отдельный акт работы, чтобы свести их обратно.
Подъём на полшага вверх - к Freestanding Си на базе nolibc и wsock.h - замкнул архитектурный круг. Но это не тот же круг, с которого началось исследование.
|
Начало |
Конец |
| Язык |
Си |
Си |
| libc |
да |
нет |
| MIPS, статическая сборка с glibc |
927 КБ |
20.9 КБ |
| x86-64 |
56 КБ |
17.0 КБ |
| Откуда известно, что лишнее |
догадки |
спуск на дно |
Исследование вернулось к Си, с которой началось. Но это другая Си: та требовала 119 КБ и libc, эта - 20.9 КБ и ничего. Разницу дал не компилятор, а спуск на дно, откуда видно, без чего можно жить.
Си-версия выросла с 13.7 до 20.9 КБ не случайно. После аудита она перестала быть минимальным портом и стала продуктом: блокировка dotfiles, ETag с 304, If-Modified-Since, If-Range, Date, Accept-Ranges, OPTIONS с Allow, 405 вместо 501, настраиваемый индекс, отключаемый лістинг, PID-файл, access и error логи, MAXFD 128 вместо 64. Каждый пункт - это ответ на реальную потребность, и каждый стоит байтов. Это не расползание, а цена того, что сервер начал делать работу, которую от него ждут.
Истинная разница в размере кода между ручным эталонным ассемблером и оптимизирующим компилятором Си без libc составляет не 30 раз, как кажется при взгляде на стандартные сборки, а 5.1 раза (4 092 байта против 20 908 байт) на одной и той же архитектуре MIPS. И это справедливая цена за полную переносимость и за набор функций, которого у ассемблерной версии нет.
4 092 байта доказали, что предел достижим. 20 908 доказали, что за него не надо платить, если собираешься жить с этим кодом дальше.
Круг оказался спиралью: та же точка по горизонтали, но уровень выше по вертикали. И теперь видно, что находится на дне.
Приложение: что лежит в дереве сегодня
Всё выше - хроника. Дальше - состояние на момент публикации, снятое с самого дерева, а не с памяти. Читатель, который захочет проверить цифры, должен смотреть сюда.
Ассемблерный профиль
| Параметр |
Значение |
| Каталог |
final-4092/ |
| Исходник |
whitehttpd.s (GNU as, MIPS32r2 Big-Endian) |
| Бинарник |
whitehttpd-mips32be |
| Размер |
4 092 байта |
Заголовок Server |
whitehttpd (без версии: /1.0.0 не влезает в оставшиеся 4 байта) |
| Требование к ядру |
Linux 3.3.8 и выше |
Историческая сборка в 3 944 байта здесь отсутствует и восстановлению не подлежит: она читала файлы за пределами корня через симлинк на промежуточный каталог. Разница в 148 байтах - это цена проверки пути. Проект считает её обязательной.
Профили на Си
| Бинарник |
Архитектура |
Размер |
whitehttpd-nolibc-x86_64 |
x86-64 |
17 008 |
whitehttpd-nolibc-mips32be |
MIPS32 BE |
20 892 |
whitehttpd-nolibc-mips32el |
MIPS32 LE |
20 908 |
Каталог whitehttpd_c/, сборка make build-c (для MIPS - make build-c-mips). Это freestanding C: ни libc, ни кучи, каждый вызов ядра через тонкий слой nosyscall.
Пересъёмка на реальном роутере
Старая сборка 3 944 байта сохранилась в old/whitehttpd_mips/, поэтому обе версии удалось поставить рядом на один роутер и снять одним инструментом. Оговорка, которую нужно знать: сборка 3 944 не принимает --port и --addr. С --port она слушает только loopback и сбрасывает соединения; без флагов слушает 8080 на всех интерфейсах. Замер поэтому шёл на 8080.
Условия: ASUS RT-AC57U V3, Linux 3.3.8, MIPS 74Kc, wrk -t2 -c50 -d10s с внешнего хоста через сеть.
| Сценарий |
3 944 байта |
4 092 байта |
Изменение |
| Keep-Alive, файл 16 Б |
1 538 req/s, 209 КБ/с |
1 421 req/s, 230 КБ/с |
-8% по RPS |
Connection: close, файл 16 Б |
не снято |
520 req/s |
- |
| Файл 256 КБ |
33.9 req/s, 8.28 МБ/с |
39.3 req/s, 9.61 МБ/с |
+16% |
Новая сборка на 8% медленнее в Keep-Alive и на 16% быстрее на крупном файле. Это ровно та цена, которую описывает раздел об исправлении безопасности: на каждом запросе добавились проверки Host, framing и покомпонентное открытие пути. Зато на крупном теле выигрыш от усиленной проверки дескриптора.
Что подтверждено на железе
Замеры на роутере ASUS RT-AC57U V3 (Linux 3.3.8, MIPS 74Kc BE), сборка в 4 092 байта:
| Проверка |
Результат |
| GET, HEAD, 404, 403 |
корректно, тело и коды |
| 301 на каталог без слэша |
Location: /space%20dir/ |
| IPv6 dual-stack |
:::порт, запросы с ::1 и 127.0.0.1 обслуживаются |
| Range |
206 с Content-Range, за пределами файла - 416 |
| Симлинк на файл и на каталог |
404 |
Обход корня (/../etc/passwd, /%2e%2e/) |
400 |
| Листинг каталогов |
403, отсутствует по контракту |
Dotfiles (.htpasswd) |
403 |
Transfer-Encoding, двойной Content-Length, obs-fold, HTTP/1.1 без Host |
400 |
| Лимит заголовка запроса |
8 192 байта, выше - 400 |
| Таймаут молчащего соединения |
3 с |
Чего в публичном дереве нет
- FASM-реализации (4 062 байта) и промежуточного порта на GAS x86-64 (6 023 байта) из Актов I-II. Исходники этих промежуточных версий сохранены в архиве разработки, но в публичный релиз не включаются: дерево содержит только финальные, проверенные профили.
- Листинга каталогов в ассемблерном профиле.
- Заголовков
Date, ETag, Last-Modified в ассемблерном профиле. Он отдаёт Server, Connection, Cache-Control, Content-Type, Content-Length.
Актуальный справочник по ассемблерному профилю: final-4092/README.md и man-страница final-4092/whitehttpd.1.
Методика замеров
Все переснятые числа получены одинаково, чтобы их можно было повторить:
- Хост: Intel Core i5-3230M, 2.60 ГГц, 4 ядра, 15 ГБ ОЗУ, Linux 6.1.0-37-amd64.
wrk 4.x из дистрибутива.
- Роутер: ASUS RT-AC57U V3, MIPS 74Kc, Linux 3.3.8, 128 МБ ОЗУ, замер с внешнего хоста через сеть.
- Команда:
wrk -t2 -c50 -d10s URL, для режима без Keep-Alive добавлен -H "Connection: close".
- Файлы: 16 Б, 1 КБ, 4.4 КБ и 256 КБ из
/dev/urandom, лежат во временном каталоге, удалены после замера.
- Число повторов: RPS снимался минимум дважды, для FASM приведены оба значения (27 466 и 26 752).
Два правила, без которых числа врут:
- Один замер на прогретом сервере. Первый прогон
wrk на свежем процессе систематически занижен, у FASM он дал 21 715 против 27 466 на втором.
- Пропускная способность и RPS измеряются на разных размерах файла и не смешиваются в одну строку. На крупном теле узкое место - линк, на мелком - процессор.
Ссылки, первоисточники и тематические публикации
Базовые проекты и инструментарий
- darkhttpd - оригинальный минималистичный веб-сервер на Си (Emil Mikulic): https://unix4lyfe.org/darkhttpd/
- nolibc.h - заголовок Freestanding C из дерева ядра Linux (Willy Tarreau):
tools/include/nolibc/
- wrk - современный HTTP-бенчмарк: https://github.com/wg/wrk
- Flat Assembler (FASM): https://flatassembler.net/
- GNU Binutils (GAS, ld, objcopy): https://www.gnu.org/software/binutils/
Классические работы по экстремальному уменьшению ELF-бинарников (Sizecoding)
- A Whirlwind Tutorial on Creating Really Teensy ELF Executables for Linux (Brian Raiter): https://www.muppetlabs.com/~breadbox/software/tiny/teensy.html
- ELF: Executable and Linkable Format - спецификация: https://refspecs.linuxfoundation.org/elf/elf.pdf
- sstrip - утилита отсечения таблицы секций ELF: https://github.com/BR903/ELFkickers
Спецификации архитектуры MIPS, ABI и сетевой подсистемы Linux
- MIPS32 Architecture For Programmers - официальные тома: https://www.mips.com/products/architectures/
- System V Application Binary Interface - MIPS RISC Processor Supplement: https://refspecs.linuxfoundation.org/abi/
- Linux MIPS syscall table (O32 ABI):
arch/mips/kernel/syscalls/syscall_o32.tbl
- RFC 7230 - HTTP/1.1: Message Syntax and Routing: https://datatracker.ietf.org/doc/html/rfc7230
- RFC 7233 - HTTP/1.1: Range Requests: https://datatracker.ietf.org/doc/html/rfc7233
- epoll(7) - Linux man page: https://man7.org/linux/man-pages/man7/epoll.7.html
- openat2(2) - Linux man page: https://man7.org/linux/man-pages/man2/openat2.2.html
Проект whitehttpd распространяется по модели двойного лицензирования: открытый код под лицензией AGPLv3 и коммерческая OEM-лицензия для производителей сетевого оборудования. Полная юридическая документация, условия лицензирования и декларация Clean-Room опубликованы в официальном репозитории на GitHub.