文章 / 2026.08.12

tiny-httpd:从零实现 Linux 静态 HTTP 服务器

1. 项目定位

tiny-httpd 是一个使用 C 语言独立实现的 Linux 静态 HTTP 服务器。项目面向嵌入式 Linux 用户态开发岗位,重点展示 Linux 系统编程、网络编程、并发控制、守护进程和模块化 C 工程能力。

项目只把 Zaver 等开源服务器作为设计参考,没有复用其源文件。功能范围被有意控制在静态 GET 服务:

  • 支持 HTTP/1.0、HTTP/1.1 和 GET;
  • 解析 HostConnectionIf-Modified-Since
  • 支持 keep-alive 和空闲超时;
  • 返回 200、304、400、403、404、405、414 等状态;
  • 目录 URI 自动追加 index.html
  • 根据扩展名设置 MIME 类型;
  • 使用 epoll + EPOLLONESHOT + 固定线程池 处理并发;
  • 使用最小堆组织超时任务;
  • 使用 sendfile 发送静态文件;
  • 阻止 URI 路径逃逸;
  • 默认以双 fork 守护进程运行,并使用 PID 文件锁防止重复启动。

项目不实现 POST、CGI、FastCGI、反向代理、HTTPS、Range、HTTP/2 和动态业务。


2. 工程目录

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
http_server/
├── CMakeLists.txt
├── README.md
├── 需求.md
├── 工程代码详解.md
├── config/
│ └── server.conf
├── include/
│ ├── build_features.h
│ ├── common.h
│ ├── config.h
│ ├── daemon.h
│ ├── http_protocol.h
│ ├── io.h
│ ├── list.h
│ ├── priority_queue.h
│ ├── server.h
│ ├── static_file.h
│ ├── thread_pool.h
│ └── timer.h
├── src/
│ ├── config.c
│ ├── daemon.c
│ ├── http_protocol.c
│ ├── io.c
│ ├── list.c
│ ├── main.c
│ ├── priority_queue.c
│ ├── server.c
│ ├── static_file.c
│ ├── thread_pool.c
│ └── timer.c
├── tests/
│ ├── CMakeLists.txt
│ ├── test_http_protocol.c
│ ├── test_list.c
│ ├── test_priority_queue.c
│ └── test_thread_pool.c
└── www/
└── index.html

主要模块职责如下:

模块 主要职责
main 参数解析和启动流程编排
config 配置解析、范围校验、路径绝对化
daemon 双 fork、setsid、PID 文件锁和描述符重定向
server Socket、epoll、任务分发和连接生命周期
http_protocol 请求行、请求头、HTTP 日期和 MIME 解析
static_file URI 映射、安全校验、响应构造和文件发送
io 非阻塞完整写和 sendfile
thread_pool 固定工作线程和 FIFO 任务队列
timer 线程安全的超时任务管理
priority_queue 按到期时间排序的二叉最小堆
list 线程池使用的单向链表

3. 总体架构

服务器由一个事件线程和若干工作线程组成。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
flowchart TD
A[读取配置] --> B{运行模式}
B -->|默认| C[双 fork 守护化]
B -->|-f| D[前台运行]
C --> E[初始化服务器]
D --> E
E --> F[监听 Socket]
E --> G[epoll]
E --> H[线程池]
E --> I[定时器最小堆]
F --> J[epoll_wait]
G --> J
J -->|监听 fd| K[accept 新连接]
J -->|客户端 fd| L[提交线程池任务]
L --> M[read 请求]
M --> N[解析 GET]
N --> O[URI 映射]
O --> P[响应头和 sendfile]
P --> Q{keep-alive}
Q -->|是| R[重设超时并 rearm]
Q -->|否| S[关闭连接]
R --> J

完整调用链:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
main
├── http_config_load
├── http_daemonize # -f 前台模式跳过
└── http_server_run
├── install_signal_handlers
├── http_timer_manager_init
├── http_thread_pool_init
├── create_listening_socket
├── epoll_create1
└── epoll_wait
├── accept_connections
│ └── register_connection
│ └── connection_schedule_timeout
└── dispatch_connection
└── http_thread_pool_submit
└── handle_connection_task
├── read
├── http_request_parse
├── process_request
│ ├── http_resolve_static_path
│ └── http_send_static_response
│ ├── http_write_all
│ └── http_send_file
└── connection_rearm 或 connection_close_locked

主线程不解析 HTTP,也不发送文件,只管理事件和超时。工作线程承担可能耗时的请求处理,避免慢客户端阻塞整个事件循环。


4. 启动流程与配置

4.1 main 函数

入口位于 src/main.c。程序使用 getopt_long 支持:

参数 含义
-c FILE 指定配置文件
-f 前台运行,方便调试
-h 输出帮助

启动顺序是:解析参数、读取配置、按需守护化、运行服务器、退出时清理 PID 文件。

main 只负责流程编排,不放网络业务逻辑,使入口函数保持短小。

4.2 配置结构

1
2
3
4
5
6
7
8
typedef struct http_server_config {
char document_root[PATH_MAX];
char pid_file[PATH_MAX];
int port;
int thread_count;
int idle_timeout_ms;
int listen_backlog;
} http_server_config_t;

配置示例:

1
2
3
4
5
6
root=../www
port=8080
threads=4
idle_timeout_ms=5000
backlog=128
pid_file=../tiny_httpd.pid

http_config_load 会:

  1. 跳过空行和注释;
  2. 分割 key=value
  3. 删除首尾空白;
  4. 严格检查整数和范围;
  5. 拒绝未知配置项;
  6. 验证 root 是有效目录;
  7. 将 root 和 PID 文件路径转换成绝对路径。

路径绝对化很重要,因为守护进程会执行 chdir("/")。如果保留相对路径,守护化后就无法找到站点目录。


5. 守护进程实现

src/daemon.c 使用经典双 fork:

1
2
3
4
5
6
7
8
原进程
└── fork
├── 父进程退出
└── 子进程
├── setsid
└── fork
├── 第一代子进程退出
└── 最终守护进程

第一次 fork 和 setsid 使进程脱离原会话、进程组和控制终端。第二次 fork 使最终进程不再是会话首进程,从而不能重新获得控制终端。

其他守护化操作:

  • umask(027) 限制新文件权限;
  • chdir("/") 避免占用启动目录;
  • 标准输入、输出和错误重定向到 /dev/null
  • 创建 PID 文件并使用 flock(LOCK_EX | LOCK_NB) 加排他锁。

PID 文件描述符在服务运行期间保持打开,否则文件锁会被释放。第二个实例尝试启动时无法获得锁,从而避免重复启动。


6. Socket 和 epoll

6.1 创建监听 Socket

create_listening_socket 依次执行:

  1. socket(AF_INET, SOCK_STREAM, 0)
  2. 设置 SO_REUSEADDR
  3. 使用 fcntl 添加 O_NONBLOCK
  4. 使用 fcntl 添加 FD_CLOEXEC
  5. bind 到配置端口和 INADDR_ANY
  6. listen 开始监听。

O_NONBLOCK 使系统调用在暂时无法完成时返回 EAGAIN,不会无限阻塞线程。FD_CLOEXEC 防止描述符被以后执行的其他程序意外继承。

6.2 epoll 注册

监听 fd 使用:

1
2
listen_event.events = EPOLLIN;
listen_event.data.ptr = NULL;

客户端 fd 使用:

1
2
event.events = EPOLLIN | EPOLLONESHOT | EPOLLRDHUP;
event.data.ptr = connection;

项目约定 data.ptr == NULL 表示监听 fd,其他值直接保存连接对象指针,省去额外的 fd 映射表。

6.3 LT 与 ET

当前客户端事件没有设置 EPOLLET,因此使用水平触发 LT,而不是边缘触发 ET。

选择 LT 的原因:

  • 行为直观,代码容易验证;
  • 数据没有读完时,重新激活后仍可收到通知;
  • 避免 ET 下未读到 EAGAIN 导致通知丢失。

工作线程仍循环读到 EAGAIN,目的是尽量一次取完当前数据、减少事件循环往返,并不代表使用了 ET。

6.4 EPOLLONESHOT

一次事件触发后,EPOLLONESHOT 会暂时禁用这个 fd,直到调用 EPOLL_CTL_MOD 重新激活。

它防止同一客户端连接被主线程重复提交给多个工作线程:

1
2
3
4
5
连接可读
-> epoll 只报告一次
-> 一个工作线程处理
-> 处理完成
-> keep-alive 时重新激活

因此本项目的线程池并发单位是不同连接,而不是同一连接内的多个线程。

6.5 epoll 与定时器结合

事件循环先调用 http_timer_next_timeout 获取最近定时任务剩余时间,再把它作为 epoll_wait 的 timeout。

这样没有网络事件时,epoll 会在最近连接超时时自动醒来;没有定时任务时 timeout 为 -1,可以无限等待。不需要额外的定时器线程,也不需要固定间隔轮询。


7. 线程池

线程池核心结构:

1
2
3
4
5
6
7
8
typedef struct http_thread_pool {
pthread_t *threads;
int thread_count;
int stopping;
pthread_mutex_t mutex;
pthread_cond_t condition;
http_list_t tasks;
} http_thread_pool_t;

任务只保存函数指针和上下文:

1
2
3
4
typedef struct http_task {
void (*function)(void *argument);
void *argument;
} http_task_t;

http_thread_pool_submit 在锁内把任务追加到 FIFO 链表,然后通过 pthread_cond_signal 唤醒一个线程。

工作线程等待条件必须写成 while

1
2
3
while (http_list_size(&pool->tasks) == 0 && !pool->stopping) {
pthread_cond_wait(&pool->condition, &pool->mutex);
}

原因是条件变量可能虚假唤醒,或者任务已经被另一个线程取走。醒来后必须重新检查条件。

任务出队后先解锁,再执行回调。否则一个线程处理网络请求期间会一直占用队列锁,其他线程无法取任务,线程池将失去并发意义。

销毁时设置 stopping、广播唤醒线程,再 pthread_join 等待已入队任务完成。


8. 连接生命周期

这是整个项目最关键的部分。

1
2
3
4
5
6
7
8
9
10
typedef struct http_connection {
int fd;
int closed;
uint64_t timer_generation;
atomic_int reference_count;
pthread_mutex_t mutex;
http_server_t *server;
char receive_buffer[HTTP_REQUEST_BUFFER_SIZE];
size_t receive_length;
} http_connection_t;
字段 作用
fd 客户端 Socket
closed 防止重复关闭
timer_generation 使旧超时任务失效
reference_count 保证对象仍被使用时不释放
mutex 保护连接状态和接收缓冲区
server 访问共享服务器对象
receive_buffer 保存未完全解析的数据
receive_length 有效数据长度

8.1 锁与引用计数的区别

  • 互斥锁解决并发读写状态的问题;
  • 引用计数解决对象何时可以释放的问题。

连接可能同时被基础连接、线程池任务和定时任务引用。仅使用互斥锁不能阻止对象在另一个线程使用时被释放。

connection_retain 原子增加引用,connection_release 原子减少引用。引用数归零时才销毁 mutex 和连接对象。

8.2 关闭连接

connection_close_locked 在持锁状态下:

  1. 检查是否已经关闭;
  2. 设置 closed
  3. 增加 generation,使定时任务失效;
  4. 从 epoll 删除 fd;
  5. 关闭 Socket。

它不会直接释放连接对象。调用者先解锁,再释放基础引用,否则可能释放 mutex 后又执行 unlock,造成 use-after-free。

8.3 工作线程处理连接

handle_connection_task 的流程:

  1. 锁定连接;
  2. 循环 readEAGAIN
  3. 调用 http_request_parse
  4. 数据不完整则保留缓冲区;
  5. 完整请求调用 process_request
  6. 使用 memmove 删除已经消费的请求;
  7. 缓冲区还有完整请求则继续处理;
  8. 短连接或错误时关闭;
  9. 长连接创建新超时任务,并 rearm EPOLLONESHOT
  10. 释放任务持有的连接引用。

同一次 read 中存在多个顺序请求时,该循环也能逐个处理。


9. HTTP 解析

解析结果分为:

1
2
3
4
5
HTTP_PARSE_COMPLETE
HTTP_PARSE_INCOMPLETE
HTTP_PARSE_BAD_REQUEST
HTTP_PARSE_METHOD_NOT_ALLOWED
HTTP_PARSE_URI_TOO_LONG

HTTP_PARSE_INCOMPLETE 不是协议错误,表示请求被 TCP 分段,只到达了一部分。服务器保留数据并等待下一次可读事件。

9.1 请求行

支持的格式:

1
GET /index.html HTTP/1.1

解析器查找两个空格,把请求行分成方法、URI 和版本:

  • 方法必须是 GET,否则返回 405;
  • URI 不能为空且不能超过固定容量;
  • 版本只接受 HTTP/1.0 或 HTTP/1.1;
  • HTTP/1.1 默认 keep-alive;
  • HTTP/1.0 默认关闭。

9.2 请求头

解析器查找 CRLF,逐行处理请求头,直到遇到空行。

请求头 行为
Host 保存值;HTTP/1.1 缺少 Host 返回 400
Connection close 或 keep-alive 覆盖默认值
If-Modified-Since 解析 GMT 日期,用于 304

请求头名称不区分大小写。未知请求头被忽略,以兼容浏览器自动携带的其他字段。

9.3 304 缓存

如果请求携带 If-Modified-Since,且文件修改时间 st_mtime 不晚于请求时间,服务器返回 304,Content-Length 为 0,不发送文件实体。

解析 HTTP 日期使用 strptime,转换 UTC 时间使用 timegm,响应日期通过 gmtime_r + strftime 生成。


10. URI 映射和安全

http_resolve_static_path 按以下顺序执行:

  1. 移除查询串;
  2. 解码 %xx
  3. 拒绝控制字符、NUL、反斜杠和编码斜杠;
  4. 与站点根目录拼接;
  5. 目录目标追加 index.html
  6. 使用 realpath 解析点目录和符号链接;
  7. 验证最终路径仍在站点根目录;
  8. 使用 stat 确认目标是普通文件。

不能只检查 URI 是否包含 ..,因为还存在百分号编码、符号链接和复杂路径组合。项目以 realpath 的最终结果为准。

根目录若是 /srv/www,只允许:

1
2
3
/srv/www
/srv/www/index.html
/srv/www/assets/app.css

/srv/www-secret/file 虽有相同字符串前缀,但前缀后不是 / 或字符串结尾,因此不会被误判为根目录内部。


11. 响应、MIME 和 sendfile

正常响应包含状态行、Date、Server、Connection、Content-Type、Content-Length 和 Last-Modified。

字符串使用有容量限制的 snprintf 构造,并检查是否发生截断。

MIME 表支持 HTML、CSS、JavaScript、TXT、JSON、PNG、JPEG、GIF、SVG、ICO 和 PDF。未知扩展名使用 application/octet-stream

响应头通过 http_write_all 发送,文件实体通过 http_send_file 调用 Linux sendfile

传统文件发送路径通常是:

1
文件 -> 内核页缓存 -> 用户态缓冲区 -> Socket 内核缓冲区

sendfile 避免应用层自行执行 read + write 和管理文件缓冲区,适合静态文件服务,也有利于资源受限设备减少内存复制。

一次 write 或 sendfile 不保证发完全部数据。两个 I/O 函数都会维护剩余长度,并处理:

  • EINTR:重试;
  • EAGAIN:使用 poll(POLLOUT) 等待可写;
  • 部分写:移动游标后继续;
  • 超过 30 秒仍不可写:以 ETIMEDOUT 失败。

12. 定时器和最小堆

定时器使用 CLOCK_MONOTONIC 计算毫秒时间。它不受手工修改系统时间或 NTP 调整影响,适合计算相对超时。

底层优先队列是动态数组实现的二叉最小堆:

  • 堆顶始终是最早到期任务;
  • 插入通过向上调整恢复堆性质;
  • 删除堆顶通过向下调整恢复堆性质;
  • 查看堆顶为 O(1);
  • 插入、删除为 O(log n)。

12.1 generation 机制

每次连接进入新阶段时递增 timer_generation。定时任务保存创建时的 generation,回调执行时只有两者仍相等才关闭连接。

旧任务无需从堆中间查找删除,到期后发现 generation 不匹配便自然失效。代价是旧任务在原到期时间前仍占用少量内存。

12.2 定时任务所有权

http_timer_schedule_owned 接收执行回调和参数清理回调。任务正常执行、取消或管理器销毁时,参数清理回调都会释放上下文。

连接定时任务借此释放自己持有的连接引用,防止引用计数泄漏。


13. 信号和异常处理

SIGINTSIGTERM 的处理器只设置 sig_atomic_t 标志。清理资源仍在普通事件循环中执行,因为日志、malloc 和 pthread 锁等操作不是异步信号安全的。

服务器忽略 SIGPIPE。客户端提前断开后,写 Socket 会返回 EPIPE,而不会终止整个服务进程。

关键 errno:

errno 含义与处理
EINTR 被信号中断,重试系统调用
EAGAIN/EWOULDBLOCK 非阻塞 fd 暂时无法继续,稍后重试
EPIPE 对端已断开,关闭当前连接
ETIMEDOUT 等待可写超时,终止当前响应

14. 构建与测试

构建命令:

1
2
cmake -S . -B build
cmake --build build -j4

项目启用:

1
-Wall -Wextra -Wpedantic -Werror

所有警告都按错误处理,可以尽早发现隐式声明、类型错误和未使用变量。

运行测试:

1
ctest --test-dir build --output-on-failure
测试 验证内容
test_list FIFO 顺序、元素数量和空链表
test_priority_queue 最小堆按到期时间弹出
test_thread_pool 4 个线程完整执行 1000 个任务
test_http_protocol GET、Host、Connection、不完整请求、POST 拒绝

已经完成的集成验证包括 200、304、403、404、405、keep-alive 连接复用、路径逃逸拒绝、守护进程 PID 文件和信号停止清理。


15. 当前限制

该项目适合学习和小型静态服务,还不是完整的生产服务器:

  • HTTP 解析器只覆盖当前需要的语法,不是完整 RFC 实现;
  • 工作线程在发送遇到 EAGAIN 时使用 poll 等待,不是完全事件驱动写;
  • 没有活动连接表,退出时部分空闲连接依赖进程结束由内核回收;
  • 定时任务使用 generation 失效,旧任务到期前仍占内存;
  • 没有请求速率限制、最大连接数和单 IP 限制;
  • 没有日志文件及轮转;
  • 没有 TLS、Range 和动态请求;
  • 尚未提供 ARM 交叉编译工具链和开发板实测数据;
  • 尚未使用 wrk/ab 得到可复现的吞吐和延迟结果。

后续可增加连接注册表、完全事件驱动写、sanitizer 检查、ARM 部署、压力测试和 systemd service。