Case Study 25-1: A Minimal Web Server in Assembly
A TCP Server with Zero Library Dependencies
The claim that assembly is impractical for real programs dissolves the moment you realize how thin the abstraction between your code and the kernel actually is. A working TCP server requires exactly six system calls: socket, bind, listen, accept, read/write, and close. Nothing in that list requires a C library. This case study builds a complete (though minimal) HTTP server in pure NASM x86-64 assembly.
What the Server Does
The server listens on TCP port 8080 and responds to any HTTP request with:
HTTP/1.0 200 OK
Content-Type: text/plain
Hello from assembly!
It handles one connection at a time (sequential, not concurrent). Every connection is logged to stdout with the number of bytes received.
The Socket API in Raw Assembly
; minhttp.asm — A minimal HTTP server in pure NASM
; Build:
; nasm -f elf64 minhttp.asm -o minhttp.o
; ld minhttp.o -o minhttp
; Run: ./minhttp
; Test: curl http://localhost:8080/
%define SYS_READ 0
%define SYS_WRITE 1
%define SYS_CLOSE 3
%define SYS_SOCKET 41
%define SYS_ACCEPT 43
%define SYS_BIND 49
%define SYS_LISTEN 50
%define SYS_EXIT 60
%define AF_INET 2
%define SOCK_STREAM 1
%define INADDR_ANY 0
section .data
; HTTP response (static)
response db "HTTP/1.0 200 OK", 13, 10
db "Content-Type: text/plain", 13, 10
db "Connection: close", 13, 10
db 13, 10
db "Hello from assembly!", 10
response_len equ $ - response
log_msg db "Connection received, bytes read: "
log_len equ $ - log_msg
newline db 10
; struct sockaddr_in layout:
; uint16_t sin_family (2 bytes)
; uint16_t sin_port (2 bytes, big-endian)
; uint32_t sin_addr (4 bytes, big-endian)
; uint8_t sin_zero[8] (8 bytes, padding)
; Total: 16 bytes
server_addr:
dw AF_INET ; sin_family = 2 (AF_INET)
dw 0x901F ; sin_port = htons(8080) = 0x1F90
dd INADDR_ANY ; sin_addr = 0.0.0.0 (all interfaces)
dq 0 ; sin_zero padding
section .bss
request_buf resb 4096 ; buffer for incoming HTTP request
client_addr resb 16 ; client address (filled by accept)
client_len resd 1 ; length of client address
section .text
global _start
_start:
;=== Step 1: Create a TCP socket ===
; socket(AF_INET, SOCK_STREAM, 0)
mov rax, SYS_SOCKET
mov rdi, AF_INET ; domain
mov rsi, SOCK_STREAM ; type
xor rdx, rdx ; protocol = 0 (auto-select TCP)
syscall
test rax, rax
js .die ; negative return = error
mov r12, rax ; r12 = server socket fd (callee-saved)
;=== Step 2: Set SO_REUSEADDR to avoid "Address already in use" ===
; setsockopt(sockfd, SOL_SOCKET=1, SO_REUSEADDR=2, &opt, sizeof(opt))
mov rax, 54 ; sys_setsockopt
mov rdi, r12 ; sockfd
mov rsi, 1 ; SOL_SOCKET
mov rdx, 2 ; SO_REUSEADDR
push dword 1 ; option value = 1 (enable)
mov r10, rsp ; pointer to option value
mov r8, 4 ; option length
syscall
add rsp, 8 ; restore stack (we pushed 4 bytes, aligned to 8)
;=== Step 3: Bind to port 8080 ===
; bind(sockfd, &server_addr, sizeof(server_addr))
mov rax, SYS_BIND
mov rdi, r12 ; sockfd
mov rsi, server_addr ; struct sockaddr *
mov rdx, 16 ; sizeof(struct sockaddr_in)
syscall
test rax, rax
js .die
;=== Step 4: Listen for incoming connections ===
; listen(sockfd, backlog)
mov rax, SYS_LISTEN
mov rdi, r12 ; sockfd
mov rsi, 5 ; backlog = 5 (queue up to 5 pending connections)
syscall
test rax, rax
js .die
; Print startup message
mov rax, SYS_WRITE
mov rdi, 1
mov rsi, startup_msg
mov rdx, startup_len
syscall
.accept_loop:
;=== Step 5: Accept a connection (blocks until client connects) ===
; accept(sockfd, &client_addr, &client_len)
mov dword [client_len], 16 ; initialize addrlen to sizeof(sockaddr_in)
mov rax, SYS_ACCEPT
mov rdi, r12 ; server sockfd
mov rsi, client_addr ; filled with client's address
mov rdx, client_len ; pointer to addrlen (in/out parameter!)
syscall
test rax, rax
js .accept_loop ; EINTR or other: retry
mov r13, rax ; r13 = client socket fd
;=== Step 6: Read the HTTP request ===
; read(client_fd, buffer, bufsize)
mov rax, SYS_READ
mov rdi, r13 ; client fd
mov rsi, request_buf
mov rdx, 4095 ; leave room for null terminator
syscall
mov r14, rax ; r14 = bytes read
;=== Log the connection ===
mov rax, SYS_WRITE
mov rdi, 1
mov rsi, log_msg
mov rdx, log_len
syscall
; Print r14 as decimal
call print_int64
mov rax, SYS_WRITE
mov rdi, 1
mov rsi, newline
mov rdx, 1
syscall
;=== Step 7: Send the HTTP response ===
; write(client_fd, response, response_len)
mov rax, SYS_WRITE
mov rdi, r13 ; client fd
mov rsi, response
mov rdx, response_len
syscall
;=== Step 8: Close the client connection ===
mov rax, SYS_CLOSE
mov rdi, r13
syscall
jmp .accept_loop ; loop forever
.die:
mov rax, SYS_EXIT
mov rdi, 1
syscall
; print_int64: prints r14 as decimal to stdout
; Clobbers: rax, rbx, rcx, rdx, rdi, rsi
print_int64:
sub rsp, 24 ; local buffer (20 digits max for uint64)
mov rax, r14
mov rcx, 20 ; digit count position
dec rcx
.digit_loop:
xor rdx, rdx
mov rbx, 10
div rbx ; rax = quotient, rdx = remainder (digit)
add dl, '0'
mov [rsp + rcx], dl
dec rcx
test rax, rax
jnz .digit_loop
inc rcx ; rcx points to first digit
; Print from rsp+rcx, length = 20-rcx
mov rsi, rsp
add rsi, rcx
mov rdx, 20
sub rdx, rcx
mov rax, SYS_WRITE
mov rdi, 1
syscall
add rsp, 24
ret
section .data
startup_msg db "minhttp listening on port 8080...", 10
startup_len equ $ - startup_msg
Testing the Server
# Terminal 1: run the server
./minhttp
# Output: minhttp listening on port 8080...
# Terminal 2: connect with curl
curl -v http://localhost:8080/
# Output includes our response headers and body
# Or with raw netcat to see the full exchange
echo -e "GET / HTTP/1.0\r\nHost: localhost\r\n\r\n" | nc localhost 8080
How accept() Actually Works
The accept system call deserves careful attention. Its third argument is a pointer to an integer (the address length), not the integer itself — it is an in/out parameter. Before calling accept, you set *addrlen = sizeof(struct sockaddr_in). After the call, the kernel writes the actual size of the client address into *addrlen. Passing the wrong size will either truncate the address or cause an error.
Why port 8080?
Port 80 requires root privileges (ports below 1024 are "privileged" on Linux). Port 8080 requires no special permissions. The kernel enforces this via a check in the bind syscall handler — if sin_port < 1024 and the process lacks CAP_NET_BIND_SERVICE, bind returns -13 (EACCES).
Performance Characteristics
This server handles one connection at a time. For production, you would use fork or threads after accept, or epoll (sys_epoll_create=213, sys_epoll_ctl=233, sys_epoll_wait=232) for event-driven I/O. The assembly code is genuinely fast — there is no malloc overhead, no printf formatting, no exception handling infrastructure. What you see is what runs.
🔐 Security Note: This server accepts any input and reads up to 4095 bytes into a fixed buffer. There is no HTTP parsing, no path validation, no authentication. It is deliberately minimal. Do not expose it to the internet.