1. 项目定位
tiny-httpd 是一个使用 C 语言独立实现的 Linux 静态 HTTP 服务器。项目面向嵌入式 Linux 用户态开发岗位,重点展示 Linux 系统编程、网络编程、并发控制、守护进程和模块化 C 工程能力。
项目只把 Zaver 等开源服务器作为设计参考,没有复用其源文件。功能范围被有意控制在静态 GET 服务:
- 支持 HTTP/1.0、HTTP/1.1 和 GET;
- 解析
Host、Connection、If-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 | http_server/ |
主要模块职责如下:
| 模块 | 主要职责 |
|---|---|
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 | flowchart TD |
完整调用链:
1 | main |
主线程不解析 HTTP,也不发送文件,只管理事件和超时。工作线程承担可能耗时的请求处理,避免慢客户端阻塞整个事件循环。
4. 启动流程与配置
4.1 main 函数
入口位于 src/main.c。程序使用 getopt_long 支持:
| 参数 | 含义 |
|---|---|
-c FILE |
指定配置文件 |
-f |
前台运行,方便调试 |
-h |
输出帮助 |
启动顺序是:解析参数、读取配置、按需守护化、运行服务器、退出时清理 PID 文件。
main 只负责流程编排,不放网络业务逻辑,使入口函数保持短小。
4.2 配置结构
1 | typedef struct http_server_config { |
配置示例:
1 | root=../www |
http_config_load 会:
- 跳过空行和注释;
- 分割
key=value; - 删除首尾空白;
- 严格检查整数和范围;
- 拒绝未知配置项;
- 验证 root 是有效目录;
- 将 root 和 PID 文件路径转换成绝对路径。
路径绝对化很重要,因为守护进程会执行 chdir("/")。如果保留相对路径,守护化后就无法找到站点目录。
5. 守护进程实现
src/daemon.c 使用经典双 fork:
1 | 原进程 |
第一次 fork 和 setsid 使进程脱离原会话、进程组和控制终端。第二次 fork 使最终进程不再是会话首进程,从而不能重新获得控制终端。
其他守护化操作:
umask(027)限制新文件权限;chdir("/")避免占用启动目录;- 标准输入、输出和错误重定向到
/dev/null; - 创建 PID 文件并使用
flock(LOCK_EX | LOCK_NB)加排他锁。
PID 文件描述符在服务运行期间保持打开,否则文件锁会被释放。第二个实例尝试启动时无法获得锁,从而避免重复启动。
6. Socket 和 epoll
6.1 创建监听 Socket
create_listening_socket 依次执行:
socket(AF_INET, SOCK_STREAM, 0);- 设置
SO_REUSEADDR; - 使用
fcntl添加O_NONBLOCK; - 使用
fcntl添加FD_CLOEXEC; bind到配置端口和INADDR_ANY;listen开始监听。
O_NONBLOCK 使系统调用在暂时无法完成时返回 EAGAIN,不会无限阻塞线程。FD_CLOEXEC 防止描述符被以后执行的其他程序意外继承。
6.2 epoll 注册
监听 fd 使用:
1 | listen_event.events = EPOLLIN; |
客户端 fd 使用:
1 | event.events = EPOLLIN | EPOLLONESHOT | EPOLLRDHUP; |
项目约定 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 | 连接可读 |
因此本项目的线程池并发单位是不同连接,而不是同一连接内的多个线程。
6.5 epoll 与定时器结合
事件循环先调用 http_timer_next_timeout 获取最近定时任务剩余时间,再把它作为 epoll_wait 的 timeout。
这样没有网络事件时,epoll 会在最近连接超时时自动醒来;没有定时任务时 timeout 为 -1,可以无限等待。不需要额外的定时器线程,也不需要固定间隔轮询。
7. 线程池
线程池核心结构:
1 | typedef struct http_thread_pool { |
任务只保存函数指针和上下文:
1 | typedef struct http_task { |
http_thread_pool_submit 在锁内把任务追加到 FIFO 链表,然后通过 pthread_cond_signal 唤醒一个线程。
工作线程等待条件必须写成 while:
1 | while (http_list_size(&pool->tasks) == 0 && !pool->stopping) { |
原因是条件变量可能虚假唤醒,或者任务已经被另一个线程取走。醒来后必须重新检查条件。
任务出队后先解锁,再执行回调。否则一个线程处理网络请求期间会一直占用队列锁,其他线程无法取任务,线程池将失去并发意义。
销毁时设置 stopping、广播唤醒线程,再 pthread_join 等待已入队任务完成。
8. 连接生命周期
这是整个项目最关键的部分。
1 | typedef struct http_connection { |
| 字段 | 作用 |
|---|---|
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 在持锁状态下:
- 检查是否已经关闭;
- 设置
closed; - 增加 generation,使定时任务失效;
- 从 epoll 删除 fd;
- 关闭 Socket。
它不会直接释放连接对象。调用者先解锁,再释放基础引用,否则可能释放 mutex 后又执行 unlock,造成 use-after-free。
8.3 工作线程处理连接
handle_connection_task 的流程:
- 锁定连接;
- 循环
read到EAGAIN; - 调用
http_request_parse; - 数据不完整则保留缓冲区;
- 完整请求调用
process_request; - 使用
memmove删除已经消费的请求; - 缓冲区还有完整请求则继续处理;
- 短连接或错误时关闭;
- 长连接创建新超时任务,并 rearm
EPOLLONESHOT; - 释放任务持有的连接引用。
同一次 read 中存在多个顺序请求时,该循环也能逐个处理。
9. HTTP 解析
解析结果分为:
1 | HTTP_PARSE_COMPLETE |
HTTP_PARSE_INCOMPLETE 不是协议错误,表示请求被 TCP 分段,只到达了一部分。服务器保留数据并等待下一次可读事件。
9.1 请求行
支持的格式:
1 | GET /index.html |
解析器查找两个空格,把请求行分成方法、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 按以下顺序执行:
- 移除查询串;
- 解码
%xx; - 拒绝控制字符、NUL、反斜杠和编码斜杠;
- 与站点根目录拼接;
- 目录目标追加
index.html; - 使用
realpath解析点目录和符号链接; - 验证最终路径仍在站点根目录;
- 使用
stat确认目标是普通文件。
不能只检查 URI 是否包含 ..,因为还存在百分号编码、符号链接和复杂路径组合。项目以 realpath 的最终结果为准。
根目录若是 /srv/www,只允许:
1 | /srv/www |
/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. 信号和异常处理
SIGINT 和 SIGTERM 的处理器只设置 sig_atomic_t 标志。清理资源仍在普通事件循环中执行,因为日志、malloc 和 pthread 锁等操作不是异步信号安全的。
服务器忽略 SIGPIPE。客户端提前断开后,写 Socket 会返回 EPIPE,而不会终止整个服务进程。
关键 errno:
| errno | 含义与处理 |
|---|---|
EINTR |
被信号中断,重试系统调用 |
EAGAIN/EWOULDBLOCK |
非阻塞 fd 暂时无法继续,稍后重试 |
EPIPE |
对端已断开,关闭当前连接 |
ETIMEDOUT |
等待可写超时,终止当前响应 |
14. 构建与测试
构建命令:
1 | cmake -S . -B build |
项目启用:
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。