如果你点进这篇文章多半已经受够了那种“框架搭好、回调填完、项目跑通”的开发方式。业务代码写了好几年浏览器地址栏敲下回车之后数据到底在操作系统里经过哪几条路、HTTP 请求是怎么被解析成一个个字段的大多数人其实说不清楚。这个系列就是冲着这个来的从零手写一个现代 C Web 服务器。所谓“现代”不是指功能花哨而是老老实实把 C17 提供的 string_view、optional、RAII、类型安全错误处理用上同时把 TCP、HTTP、事件驱动这些底层骨头啃一遍。Part 1 的目标定得很小不解析完整请求、不做路由、不搞并发只要做到浏览器访问 127.0.0.1:8080 能显示出一句欢迎语并且你知道这背后每一个字节是怎么出去的。别小看这个目标socket 生命周期、TCP 握手、HTTP 报文格式、错误处理、调试工具全部会在这几百行代码里过一遍。适合谁看看过 C 语法但没写过网络程序的初学者或者写过业务但没碰过系统调用的同学这篇文章都能给你一个能跑的起点。1. 为什么还要从零手写 Web 服务器三个比“能跑”更重要的理由1.1 框架替你解决的问题才是你真正该学的问题现成的网络库和 Web 框架把 HTTP 解析、连接管理、线程调度全都封装好了调用方只需要注册一个处理函数看起来一切都很美好。但问题恰好藏在这里你永远不知道 HTTP 请求头里为什么有那么多边界情况不知道为什么一个畸形请求就能让服务器内存翻倍也不知道为什么服务端必须严格区分 Content-Length 和传输结束的关系。手写一遍哪怕只写一个最粗糙的解析器你对这些问题的理解都会完全不一样。你会亲眼看到一个请求不是“一个字符串”而是若干个以 CRLF 结尾的文本行头部字段大小写不敏感但值大小写敏感URL 里的中文必须经过百分号编码请求体可能根本没有长度因为用的是 chunked 编码。这些知识在看文档的时候只是几行字在你亲手写出recv循环、亲手被这些细节坑过之后才会变成真正的判断力。我常跟同事说一个类比会用洗衣机不叫懂洗衣能看懂水管怎么接、排水泵怎么转才算对“洗衣服”这件事有底层认知。Web 框架是洗衣机HTTP 和 TCP 是水管。这个系列不教你怎么按洗衣机按钮教你怎么接水管。1.2 现代 C 的坐标系从“会写语法”到“会用语言”很多人提到 C 写服务器第一反应还是“指针满天飞、内存靠自己”。那是二十年前的印象。现代 C 写网络程序核心变化不是语法变漂亮了而是所有权模型变清晰了socket 文件描述符用 RAII 管理析构函数里保证 close解析头字段用 string_view 做零拷贝切片可失败的解析结果用 optional 呈现而不是传一个 int* 出来再靠判断 errno 猜含义需要表达“多种情况”时用 variant而不是 union 加一个标签位。这些特性不是炫技。服务器代码恰恰是它们最好的应用场景因为服务器的生命周期几乎全部围绕“资源”展开连接要关、内存要释放、缓冲区要复用。RAII 用好了异常安全就是默认行为string_view 用好了长连接上的内存拷贝能省掉一大截。这个系列从 Part 1 起就会刻意用这些特性写每一段代码往后到了解析器和事件循环部分你会感觉到这种写法带来的维护压力有多小。1.3 Part 1 的边界做到浏览器认识你为止老实说Part 1 做完的东西“不能算服务器”只能算一个会说话的 TCP 端口。它不做的事比做的事多不解析请求行、不区分 GET/POST、不支持 Keep-Alive、单线程串行处理、没有日志系统。我们按什么顺序做和一个人学习路径的合理性直接相关先把“网络通路”打通再往通路上叠加“协议理解”最后才谈“并发效率”。本部分做什么本部分不做什么创建并管理 TCP socket解析 HTTP 请求和路由分发接受浏览器/命令行客户端连接多线程与事件驱动模型返回一个写死的 HTTP 响应静态文件服务与 CGI演示标准错误处理与 RAII 封装Keep-Alive 与 chunked 编码讲解 curl 等调试工具的基本用法性能压测与优化这张表也是整个系列的主线。有了这个边界后面每一部分都有明确的增量Part 2 加请求解析和路由Part 3 换成 epoll 事件驱动并引入线程池Part 4 再补超时管理、日志和配置文件。每一步都能独立跑、独立验证。2. 第一版技术选型两个关键决定别一上来就整事件驱动2.1 零第三方依赖用最原始的 POSIX 接口选型第一刀就砍在依赖上只用 C 标准库和 Linux 自带的 POSIX socket API不引任何第三方网络库。原因有两层。第一理解优先。第三方网络库帮你封装了 epoll、封装了缓冲区、封装了连接状态机用起来确实快但你拆不开它出了问题只能去搜别人的 issue。自己写一遍哪怕写得糙你会知道每一次 recv 返回 0 意味着什么EPOLLIN 和 EPOLLOUT 到底分别由什么事件触发。第二标准库在“网络”这件事上帮不了什么忙它只管 string、vector、optional 这些基础设施socket 调用最终还是要落到操作系统接口。既然如此第一部分干脆直接面对系统调用把知识补实。代价也明显代码不长但很容易踩坑比如漏检查返回值、忘设置地址结构体的内存布局、端口被占用时错误处理不当。这些坑在第三、第四节会专门讲因为任何一个都是新手必踩。2.2 先写单线程 accept 循环不为并发提前买单第二个决定更反直觉Part 1 不碰 epoll、不碰多线程老老实实写一个while (true) accept()串行循环。每个连接进来处理完、关闭、再等下一个。这意味着同一时刻只能服务一个客户端可这不重要。整个系列的核心难点本来就排好序了HTTP 协议的理解难度远大于并发模型如果一边学协议一边学事件循环代码出 bug 时你根本分不清是解析错了还是事件状态机错了。串行模型还有一个好处阻塞 I/O 的行为非常直观。accept 会阻塞直到有连接recv 会阻塞直到收到数据send 会阻塞直到数据进入内核缓冲区。你不需要理解“就绪”这个概念代码的执行顺序就是现实世界的处理顺序。等 Part 3 切换到 epoll 时你会主动意识到阻塞模型的瓶颈在哪而不是被动接受“用 epoll 比较好”的结论。顺带说一句很多人一上来就想到“一个连接一个线程”的模型这个模型能跑但在 C10K 语境下并不优雅每个线程默认栈空间 8MB万级连接就是几十 GB 虚拟内存线程之间频繁切换导致缓存颠簸。所以这个系列后续会走“单线程事件循环加线程池”的方向而不是 thread-per-connection。不过那是后话现在先写串行版。3. 项目骨架CMake、目录结构与编译体验3.1 目录结构普通但合理Part 1 代码量很小目录结构不需要设计得太复杂但也不要全堆在一个 cpp 里。我用的结构是这样miniserver/ ├── CMakeLists.txt └── src/ ├── main.cpp ├── server.hpp ├── server.cpp ├── http_response.hpp └── http_response.cppserver负责 TCP 服务端创建 socket、绑定端口、监听、接受连接http_response负责生成 HTTP 响应文本main只做参数读取和启动入口。这个拆分的理由是让每一份代码的职责单一。往后加 HTTP 请求解析器时新代码放在独立的http_request文件里不需要改动 server 的核心逻辑加路由时再新增router组件。服务器项目最容易长成“一颗大树”从一个 main.cpp 开始不断追加逻辑最后几千行没法看。从一开始就把边界划开后面每一部分都能并发推进。3.2 一份克制但有价值的 CMakeListsCMake 是 C 项目的门面也是新手最容易忽略的部分。我这份写得比较克制但每个选项都有明确用途cmake_minimum_required(VERSION 3.16) project(Miniserver CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_EXTENSIONS OFF) add_executable(miniserver src/main.cpp src/server.cpp src/http_response.cpp ) target_include_directories(miniserver PRIVATE src) if(NOT CMAKE_BUILD_TYPE) set(CMAKE_BUILD_TYPE Debug) endif() target_compile_options(miniserver PRIVATE -Wall -Wextra -Wpedantic ) if(CMAKE_BUILD_TYPE STREQUAL Debug) target_compile_options(miniserver PRIVATE -g -fsanitizeaddress,undefined) target_link_options(miniserver PRIVATE -fsanitizeaddress,undefined) endif()注意set(CMAKE_CXX_EXTENSIONS OFF)这一行它保证编译器使用严格的-stdc17而不是带 GNU 扩展的gnu17。如果不开这个选项你可能会无意用到::accept之外的非标准扩展到别的编译器上就编不过。用严格的 ISO 标准能让代码的可移植性从一开始就被约束住。Debug 模式我打开了 AddressSanitizer 和 UndefinedBehaviorSanitizer。前者能在你越界访问、泄漏内存时给出精确的调用栈后者能捕获整数溢出、空指针解引用这类未定义行为。服务器程序长期运行内存问题最隐蔽这两个工具是起步阶段性价比最高的投资。跑一遍测试和调试发现问题的时间几乎为零。3.3 编译命令与预期的输出构建和运行就三条命令cmake -B build -DCMAKE_BUILD_TYPEDebug cmake --build build ./build/miniserver 8080-B build指定构建目录源代码保持干净。第一次拿到 CMakeLists 时我习惯先跑一次编译确认没有任何警告再写业务代码。-Wall -Wextra -Wpedantic三件套会把绝大多数可疑写法暴露出来。我的经验是警告不是用来“忽略”的而是用来“听懂”的服务器代码里一个被忽视的 signed/unsigned 比较在高并发下可能变成诡异的整数回绕。运行后预期输出一行listening on port 8080。如果这里就报错多半是端口被占用或权限问题第四节会带你完整过一遍排查套路。4. 从 socket 到 acceptTCP 服务端的底座4.1 四个系统调用顺序一个都不能错TCP 服务端的生命周期可以压缩成四个调用socket创建套接字文件描述符bind把它绑定到一个地址和端口listen让它进入监听状态并开始接受连接accept从已完成握手的连接队列中取出一条连接。顺序错了函数直接报错。这四个调用分别对应了什么层次我放在一个表里调用作用失败时的典型 errno说明socket创建通信端点EMFILE进程 fd 耗尽返回一个整数 fd之后所有操作都靠它bind分配本地地址和端口EADDRINUSE端口被占用地址必须提前转成网络字节序listen标记被动监听EADDRINUSE / EACCES第二个参数是内核连接队列长度accept提取已连接套接字EINTR被信号打断/ ECONNABORTED阻塞直到有一条连接完成握手新手最容易忽略的是地址结构体。IPv4 的sockaddr_in有三个核心字段sin_family填AF_INETsin_port存端口sin_addr.s_addr存 IP。但这里的数值都不是主机字节序直接填进去的htons和htonl负责把主机字节序转成网络字节序。我见过不少错误都是把8080直接塞进去结果 bind 到的是一个完全不对的端口。记住一句话进入内核的数值一律网络字节序。INADDR_ANY表示监听所有网卡地址这时用127.0.0.1访问没问题用机器的局域网 IP 访问也没问题。如果只监听某个具体 IP就用inet_pton(AF_INET, 127.0.0.1, addr.sin_addr)转换。开发阶段用INADDR_ANY最省事但生产环境一定要显式指定否则等于把服务暴露到所有网络接口上。4.2 端口复用为什么服务重启老报 Address already in use写服务端代码第一个绕不开的坑就是 TIME_WAIT 和 SO_REUSEADDR。TCP 四次挥手之后主动关闭方会进入 TIME_WAIT 状态持续两个 MSL默认约 60 秒。在这个窗口内相同四元组的连接不能用如果服务端主动关闭连接又立刻重启并 bind 原端口就会碰到EADDRINUSE。解决办法是在 bind 之前设置SO_REUSEADDRint reuse 1; setsockopt(listen_fd_, SOL_SOCKET, SO_REUSEADDR, reuse, sizeof(reuse));这个选项告诉内核只要没有活跃连接占用这个端口就允许新监听套接字绑定它。开发服务器时几乎必须加否则每次改代码重启都要等一分钟体验极其痛苦。对调试期的服务器来说这条设置已经是常识级别。注意它和安全性是两码事不要以为设置了就能绕过一些访问控制问题那是另一层的东西。4.3 第一版服务端代码把 fd 的生死管住下面是 Part 1 的核心服务端实现。设计上做了两件事保证优雅用析构函数关闭监听 fd杜绝进程退出时忘关导致的句柄泄漏把所有系统调用的错误都转成异常抛出主函数统一捕获。// server.hpp #pragma once #include cstdint class TcpServer { public: explicit TcpServer(std::uint16_t port); ~TcpServer(); TcpServer(const TcpServer) delete; TcpServer operator(const TcpServer) delete; void run(); private: int listen_fd_ -1; std::uint16_t port_; void create_socket(); void handle_client(int client_fd) const; };// server.cpp #include server.hpp #include arpa/inet.h #include cerrno #include cstring #include iostream #include stdexcept #include sys/socket.h #include unistd.h TcpServer::TcpServer(std::uint16_t port) : port_(port) { create_socket(); } TcpServer::~TcpServer() { if (listen_fd_ 0) { ::close(listen_fd_); } } void TcpServer::create_socket() { listen_fd_ ::socket(AF_INET, SOCK_STREAM, 0); if (listen_fd_ 0) { throw std::runtime_error(std::string(socket: ) std::strerror(errno)); } int reuse 1; if (::setsockopt(listen_fd_, SOL_SOCKET, SO_REUSEADDR, reuse, sizeof(reuse)) 0) { throw std::runtime_error(std::string(setsockopt: ) std::strerror(errno)); } sockaddr_in addr{}; addr.sin_family AF_INET; addr.sin_addr.s_addr htonl(INADDR_ANY); addr.sin_port htons(port_); if (::bind(listen_fd_, reinterpret_castconst sockaddr*(addr), sizeof(addr)) 0) { throw std::runtime_error(std::string(bind: ) std::strerror(errno)); } if (::listen(listen_fd_, 16) 0) { throw std::runtime_error(std::string(listen: ) std::strerror(errno)); } std::cout listening on port port_ std::endl; } void TcpServer::handle_client(int client_fd) const { char buffer[4096] {0}; ::recv(client_fd, buffer, sizeof(buffer) - 1, 0); const std::string body hello from miniserver\n; const std::string response build_http_response(body); std::size_t sent_total 0; while (sent_total response.size()) { const ssize_t n ::send(client_fd, response.data() sent_total, response.size() - sent_total, 0); if (n 0) { std::cerr send failed: std::strerror(errno) std::endl; break; } sent_total static_caststd::size_t(n); } ::close(client_fd); } void TcpServer::run() { while (true) { sockaddr_in client_addr{}; socklen_t addr_len sizeof(client_addr); const int client_fd ::accept4(listen_fd_, reinterpret_castsockaddr*(client_addr), addr_len, SOCK_CLOEXEC); if (client_fd 0) { if (errno EINTR) { continue; } std::cerr accept failed: std::strerror(errno) std::endl; continue; } char ip[INET_ADDRSTRLEN] {0}; ::inet_ntop(AF_INET, client_addr.sin_addr, ip, sizeof(ip)); std::cout connection from ip : ntohs(client_addr.sin_port) std::endl; handle_client(client_fd); } }这里有三处细节值得单独说。第一accept4比accept多了SOCK_CLOEXEC标志含义是执行 exec 时自动关闭这个 fd。如果不加将来服务器引入 fork/exec 子进程时某个连接 fd 会被意外继承造成句柄泄漏。这是 Linux 专门提供的接口如果追求可移植性可以用accept加fcntl(fd, F_SETFD, FD_CLOEXEC)替代。第二recv返回值被忽略只是 Part 1 的权宜之计因为我们尚不解析请求内容读到什么先扔一边到了 Part 2 解析器里这个返回值会成为核心变量。第三send用小循环发送是因为一次 send 未必能把大响应全部写入内核缓冲区必须根据返回值判断本轮写了多少然后移动指针继续直到全部写完。4.4 accept 的阻塞语义与 EINTR 处理accept是阻塞的没有连接时就睡在内核里。这个“睡”是安全的内核会管理等待队列不需要你手动加 sleep。但有一个经典坑进程收到信号时阻塞的系统调用可能被中断返回此时 errno 是EINTR。如果你不处理直接把accept的结果当作致命错误服务器会在收到一个信号后退出。上面的循环遇到EINTR就 continue是网络服务端的标准写法。这个写法还会在以后升级成事件驱动时自然消失因为 epoll_wait 的语义不同。你现在记下这个处理方式之后对比起来能更清楚两种模型的差异。5. 第一份 HTTP 响应让浏览器心甘情愿吐出 200 OK5.1 HTTP 响应到底长什么样浏览器发请求服务端回响应。响应不是随意拼一段文本就行的它有严格的文本协议结构状态行、若干头部、一个空行、然后是消息体。空行前的部分统称为“头部区”每一行都必须以 CRLF 也就是\r\n结尾。HTTP/1.1 200 OK\r\n Content-Type: text/plain; charsetutf-8\r\n Content-Length: 22\r\n Connection: close\r\n \r\n hello from miniserver\n第一行是状态行三部分分别是协议版本、状态码、原因短语。200 OK是最常见的一种。接着是头部字段每行都是 “名字: 空格 值” 的格式。然后一个空行空行之后就是消息体。这里最容易犯的错是拿\n当行结束符虽然很多浏览器和命令行工具会容忍但协议规定的是 CRLF。你没法控制所有客户端的行为服务端应该按标准吐字节。5.2 Content-Length决定响应在哪里结束Content-Length在 Part 1 里就是“凑数”的但它其实是最关键的一个头部。它告诉对端消息体一共多少个字节。对端读到这个长度后就知道响应读完了不需要靠关闭连接来判断。我们发送循环正是基于response.size()来做全量发送而浏览器侧则根据 Content-Length 来判断该显示多少个字节。这里有一个新手坑Content-Length 必须是字节数不是字符数。如果响应体里包含中文std::string::size()返回的是 UTF-8 编码后的字节数一个“你”字占 3 字节如果你手动写死内容长度就会和实际发送字节数不一致。浏览器收到长度不符的数据要么一直转圈要么显示截断。最简单正确的做法就是用std::to_string(body.size())让代码替你数。还有一点因为 Part 1 不打算处理长连接我在响应里写死了Connection: close意思是告诉客户端响应结束后连接会关闭你不需要再等额外数据。这个头部对浏览器特别友好否则它可能在读完 Content-Length 指定的数据后继续挂起等待下一次事件。5.3 响应生成代码刻意做小但结构清晰响应生成逻辑只有不到二十行但我还是把它单独放一个文件目的是让边界清晰。后续解析请求、协商是否 Keep-Alive、根据路径改变 Content-Type全部在这个模块上迭代。// http_response.hpp #pragma once #include string std::string build_http_response(const std::string body);// http_response.cpp #include http_response.hpp std::string build_http_response(const std::string body) { std::string response; response.reserve(128 body.size()); response HTTP/1.1 200 OK\r\n; response Content-Type: text/plain; charsetutf-8\r\n; response Content-Length: std::to_string(body.size()) \r\n; response Connection: close\r\n; response \r\n; response body; return response; }reserve是一次小优化目的是让后面的拼接尽量不触发多次重新分配。字符串拼接在开发服务器场景下不是什么大问题但养成“知道自己可能产生多少数据”的习惯对后续设计缓冲区策略有帮助。再回头看handle_client里的流程先recv拿到请求丢弃内容再生成响应然后循环send最后close。这一条链就是“请求-响应-关闭”的完整闭环。浏览器发来的请求是什么样你可以下一节用命令行工具亲眼看。6. 用命令行工具实测及 Part 1 的三层边界6.1 实测curl 能让你看清每一个字节程序跑起来之后打开另一个终端执行curl -v http://127.0.0.1:8080/-v是关键它会同时打印请求和响应的原始信息。预期看到类似这样的输出 GET / HTTP/1.1 Host: 127.0.0.1:8080 User-Agent: curl/... Accept: */* HTTP/1.1 200 OK Content-Type: text/plain; charsetutf-8 Content-Length: 22 Connection: close hello from miniserver开头的是 curl 发出的请求头开头的是服务器返回的响应头最后一行是响应体。看到200 OK的瞬间你就能确认整条链路通了浏览器请求进了内核、被 accept 接走、服务器拼了一段文本、send 塞回内核、curl 又读了出来。浏览器测试更直观打开浏览器访问http://127.0.0.1:8080/页面显示hello from miniserver。浏览器还会自动带上一堆头部比如Accept-Language、Accept-Encoding这些在 Part 1 里都被忽略了但它们将是你 Part 2 解析器的第一批“真实样本”。如果 curl 只有请求没有响应大概率是响应格式有问题。检查三件事每行是不是以 CRLF 结尾、Content-Length 是否和响应体字节数一致、Connection: close有没有写。多数“浏览器空白页”问题都能归结到这三项。6.2 实测中必经的三个坑第一个坑是端口被占用。报错bind: Address already in use时用这几条命令找出占用者ss -ltnp | grep 8080 lsof -i :8080如果确实是上次的程序残留要么等 TIME_WAIT 结束要么确保你的代码在最开始设置了SO_REUSEADDR。开发期没有这个设置每次改代码重启都是煎熬。如果占用者是别的进程换个端口就行。第二个坑是响应头行尾符号。某人图省事写\n不写\r\n一部分浏览器会宽容但某些客户端会直接解析失败。排查方法就是把响应原样打印出来用十六进制看每一个字节此时curl -v最容易暴露问题。响应里混进一个多余的\r或漏掉一个\n肉眼很难发现。第三个坑是 Content-Length 与发送字节不一致。这往往发生在响应体里有中文或多字节字符时。我的经验是永远不要手算长度用body.size()把它变成系统计算。发送循环也要以response.size()为基准不要忘记某个尾巴字节。6.3 Part 1 的边界哪些能力留到后文Part 1 的服务器其实很脆弱请求内容被完全丢弃任何路径都返回同一个页面连接处理完就关一次只能服务一个客户端。这些在后续部分的顺序大概是Part 2 做请求解析把请求行、头部、URL 路径、查询参数拆出来并加一个最简路由表Part 3 做事件驱动把阻塞 accept 换成 epoll 加线程池让服务器真正能同时扛住几百个连接Part 4 再补静态文件服务、Keep-Alive、日志和配置解析。每一次迭代都不应该推翻前面的底座。Part 1 的handle_client虽然粗糙但它定义了一个清晰的处理边界拿到一个已连接的 fd读请求、写响应、关连接。后续无论底层换不换成 epoll这个边界都成立变化的只是“谁来调用它、如何并发调用它”。写到这里其实最想分享的一点是不要嫌 Part 1 太小。我在实际开发里见过太多人一上来就规划“全功能框架”结果卡在并发模型里整整一周HTTP 响应格式却还没跑通一次。先把一个只包含“收请求、回响应”的最小闭环跑稳再用命令行工具看原始报文所有协议层面的概念都会变得具体。下一部分开始写请求解析器时你会需要亲手去逐行观察浏览器发来的头部——到那时候你会感谢 Part 1 里那个让你看清每一个字节的命令行参数。