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.