用 C 语言写一个最小 HTTP Server 与 Client
如果前面已经学习过 socket、TCP 和 HTTP 的基本结构,那么接下来最自然的一步,就是亲手用 C 语言把一个最小 HTTP 通信过程写出来。
这是一个非常值得做的练习,因为它会让下面这件事变得非常具体:
HTTP 本质上就是在 TCP 连接上收发符合约定格式的文本数据。
很多时候,HTTP 在框架和浏览器里看起来过于"高级",以至于初学者容易忽略它背后的原貌。实际上,当把所有中间层拿掉之后,一个最小的 HTTP 程序并不复杂:
- 服务端监听 TCP 端口
- 客户端建立 TCP 连接
- 客户端发送一段符合 HTTP 语法的请求文本
- 服务端返回一段符合 HTTP 语法的响应文本
本文会用一组最小 C 示例,把这个过程完整打通。
目标包括:
- 用 C 语言实现一个最小 HTTP 服务端
- 用 C 语言实现一个最小 HTTP 客户端
- 观察原始 HTTP 请求和响应到底长什么样
- 理解 HTTP 和 TCP 在代码层面的衔接关系
为了让文章适合直接对外发布,下面的代码全部直接给出,读者不需要依赖额外源码包即可复现。
一、先建立整体模型
这个实验的通信关系非常简单:
C HTTP Server C HTTP Client C HTTP Server C HTTP Client TCP connect HTTP GET /hello HTTP/1.1 200 OK response body close
如果从分层角度看,它又可以理解成这样:
C program
socket API
TCP
HTTP text protocol
也就是说:
socket负责让程序接入网络TCP负责建立连接和可靠传输HTTP负责规定请求和响应长什么样
这也是为什么说,写一个最小 HTTP 程序,其实就是在 TCP 之上拼出正确的文本格式。
二、代码结构与编译方式
建议先新建一个目录,例如 http_examples,然后保存下面 3 个文件:
Makefilehttp_server.chttp_client.c
1. Makefile
makefile
CC := cc
CFLAGS := -Wall -Wextra -Wpedantic -std=c11 -O2
BUILD_DIR := build
TARGETS := $(BUILD_DIR)/http_server $(BUILD_DIR)/http_client
.PHONY: all clean
all: $(TARGETS)
$(BUILD_DIR):
mkdir -p $(BUILD_DIR)
$(BUILD_DIR)/http_server: http_server.c | $(BUILD_DIR)
$(CC) $(CFLAGS) -o $@ $<
$(BUILD_DIR)/http_client: http_client.c | $(BUILD_DIR)
$(CC) $(CFLAGS) -o $@ $<
clean:
rm -rf $(BUILD_DIR)
保存后执行:
bash
make
会生成:
build/http_serverbuild/http_client
三、最小 HTTP 服务端
这个服务端只做几件事:
- 监听指定端口
- 接收客户端连接
- 读取 HTTP 请求
- 根据路径返回固定内容
为了让行为更直观,示例中设计了 3 条路由:
/:返回一个简单 HTML 页面/hello:返回纯文本/json:返回 JSON
这足以帮助初学者观察:
- 请求行如何被解析
- 响应头和响应体如何构造
- 不同
Content-Type会对应什么内容
完整代码如下:
c
#include <arpa/inet.h>
#include <errno.h>
#include <netinet/in.h>
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <unistd.h>
#define BUFFER_SIZE 8192
static void usage(const char *program)
{
fprintf(stderr, "usage: %s <port>\n", program);
}
static int send_all(int fd, const char *buf, size_t len)
{
size_t sent = 0;
while (sent < len) {
ssize_t n = send(fd, buf + sent, len - sent, 0);
if (n < 0) {
return -1;
}
sent += (size_t)n;
}
return 0;
}
static int send_response(
int client_fd,
int status_code,
const char *reason,
const char *content_type,
const char *body)
{
char header[1024];
size_t body_len = strlen(body);
int header_len = snprintf(
header,
sizeof(header),
"HTTP/1.1 %d %s\r\n"
"Content-Type: %s\r\n"
"Content-Length: %zu\r\n"
"Connection: close\r\n"
"\r\n",
status_code,
reason,
content_type,
body_len);
if (header_len < 0 || (size_t)header_len >= sizeof(header)) {
errno = EOVERFLOW;
return -1;
}
if (send_all(client_fd, header, (size_t)header_len) < 0) {
return -1;
}
if (send_all(client_fd, body, body_len) < 0) {
return -1;
}
return 0;
}
static const char *find_header_end(const char *buffer)
{
return strstr(buffer, "\r\n\r\n");
}
static int read_request(int client_fd, char *buffer, size_t buffer_size)
{
size_t total = 0;
while (total < buffer_size - 1) {
ssize_t n = recv(client_fd, buffer + total, buffer_size - 1 - total, 0);
if (n < 0) {
return -1;
}
if (n == 0) {
break;
}
total += (size_t)n;
buffer[total] = '\0';
if (find_header_end(buffer) != NULL) {
break;
}
}
buffer[total] = '\0';
return (int)total;
}
static void parse_request_line(
const char *request,
char *method,
size_t method_size,
char *path,
size_t path_size,
char *version,
size_t version_size)
{
int matched = sscanf(
request,
"%15s %255s %15s",
method,
path,
version);
if (matched != 3) {
snprintf(method, method_size, "UNKNOWN");
snprintf(path, path_size, "/");
snprintf(version, version_size, "HTTP/1.1");
}
}
static void handle_client(int client_fd, const struct sockaddr_in *client_addr)
{
char request[BUFFER_SIZE];
char method[16];
char path[256];
char version[16];
char client_ip[INET_ADDRSTRLEN];
int nread;
nread = read_request(client_fd, request, sizeof(request));
if (nread < 0) {
perror("recv");
return;
}
parse_request_line(request, method, sizeof(method), path, sizeof(path), version, sizeof(version));
if (inet_ntop(AF_INET, &client_addr->sin_addr, client_ip, sizeof(client_ip)) == NULL) {
strncpy(client_ip, "unknown", sizeof(client_ip));
client_ip[sizeof(client_ip) - 1] = '\0';
}
printf("client %s:%d -> %s %s %s\n",
client_ip,
ntohs(client_addr->sin_port),
method,
path,
version);
if (strcmp(path, "/") == 0) {
const char *body =
"<html><body>"
"<h1>Minimal C HTTP Server</h1>"
"<p>Try <code>/hello</code> or <code>/json</code>.</p>"
"</body></html>\n";
if (send_response(client_fd, 200, "OK", "text/html; charset=utf-8", body) < 0) {
perror("send");
}
return;
}
if (strcmp(path, "/hello") == 0) {
const char *body = "hello from minimal c http server\n";
if (send_response(client_fd, 200, "OK", "text/plain; charset=utf-8", body) < 0) {
perror("send");
}
return;
}
if (strcmp(path, "/json") == 0) {
char body[512];
int body_len = snprintf(
body,
sizeof(body),
"{\n"
" \"method\": \"%s\",\n"
" \"path\": \"%s\",\n"
" \"message\": \"hello from c http server\"\n"
"}\n",
method,
path);
if (body_len < 0 || (size_t)body_len >= sizeof(body)) {
const char *fallback = "{ \"error\": \"response too large\" }\n";
if (send_response(
client_fd,
500,
"Internal Server Error",
"application/json; charset=utf-8",
fallback) < 0) {
perror("send");
}
return;
}
if (send_response(client_fd, 200, "OK", "application/json; charset=utf-8", body) < 0) {
perror("send");
}
return;
}
{
char body[512];
int body_len = snprintf(
body,
sizeof(body),
"<html><body><h1>404 Not Found</h1><p>No route for %s</p></body></html>\n",
path);
if (body_len < 0 || (size_t)body_len >= sizeof(body)) {
const char *fallback = "404 not found\n";
if (send_response(client_fd, 404, "Not Found", "text/plain; charset=utf-8", fallback) < 0) {
perror("send");
}
return;
}
if (send_response(client_fd, 404, "Not Found", "text/html; charset=utf-8", body) < 0) {
perror("send");
}
}
}
int main(int argc, char *argv[])
{
int listen_fd;
int port;
struct sockaddr_in server_addr;
if (argc != 2) {
usage(argv[0]);
return 1;
}
port = atoi(argv[1]);
listen_fd = socket(AF_INET, SOCK_STREAM, 0);
if (listen_fd < 0) {
perror("socket");
return 1;
}
{
int opt = 1;
if (setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)) < 0) {
perror("setsockopt");
close(listen_fd);
return 1;
}
}
memset(&server_addr, 0, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = htonl(INADDR_ANY);
server_addr.sin_port = htons((uint16_t)port);
if (bind(listen_fd, (struct sockaddr *)&server_addr, sizeof(server_addr)) < 0) {
perror("bind");
close(listen_fd);
return 1;
}
if (listen(listen_fd, 10) < 0) {
perror("listen");
close(listen_fd);
return 1;
}
printf("HTTP server listening on http://0.0.0.0:%d\n", port);
for (;;) {
int client_fd;
struct sockaddr_in client_addr;
socklen_t client_len = sizeof(client_addr);
client_fd = accept(listen_fd, (struct sockaddr *)&client_addr, &client_len);
if (client_fd < 0) {
perror("accept");
continue;
}
handle_client(client_fd, &client_addr);
close(client_fd);
}
close(listen_fd);
return 0;
}
四、最小 HTTP 客户端
客户端的任务更加直接:
- 连接到指定主机和端口
- 按 HTTP/1.1 格式拼出一段 GET 请求
- 把请求发出去
- 读取并打印服务端响应
完整代码如下:
c
#include <netdb.h>
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <unistd.h>
#define REQUEST_SIZE 4096
#define RESPONSE_CHUNK 2048
static void usage(const char *program)
{
fprintf(stderr, "usage: %s <host> <port> <path>\n", program);
}
static int connect_to_server(const char *host, const char *port)
{
struct addrinfo hints;
struct addrinfo *result = NULL;
struct addrinfo *rp = NULL;
int sockfd = -1;
int rc;
memset(&hints, 0, sizeof(hints));
hints.ai_family = AF_UNSPEC;
hints.ai_socktype = SOCK_STREAM;
rc = getaddrinfo(host, port, &hints, &result);
if (rc != 0) {
fprintf(stderr, "getaddrinfo: %s\n", gai_strerror(rc));
return -1;
}
for (rp = result; rp != NULL; rp = rp->ai_next) {
sockfd = socket(rp->ai_family, rp->ai_socktype, rp->ai_protocol);
if (sockfd < 0) {
continue;
}
if (connect(sockfd, rp->ai_addr, rp->ai_addrlen) == 0) {
break;
}
close(sockfd);
sockfd = -1;
}
freeaddrinfo(result);
return sockfd;
}
static int send_all(int fd, const char *buf, size_t len)
{
size_t sent = 0;
while (sent < len) {
ssize_t n = send(fd, buf + sent, len - sent, 0);
if (n < 0) {
return -1;
}
sent += (size_t)n;
}
return 0;
}
int main(int argc, char *argv[])
{
const char *host;
const char *port;
const char *path;
int sockfd;
char request[REQUEST_SIZE];
if (argc != 4) {
usage(argv[0]);
return 1;
}
host = argv[1];
port = argv[2];
path = argv[3];
sockfd = connect_to_server(host, port);
if (sockfd < 0) {
perror("connect");
return 1;
}
{
int request_len = snprintf(
request,
sizeof(request),
"GET %s HTTP/1.1\r\n"
"Host: %s:%s\r\n"
"User-Agent: minimal-c-http-client/0.1\r\n"
"Accept: */*\r\n"
"Connection: close\r\n"
"\r\n",
path,
host,
port);
if (request_len < 0 || (size_t)request_len >= sizeof(request)) {
fprintf(stderr, "request too large\n");
close(sockfd);
return 1;
}
if (send_all(sockfd, request, (size_t)request_len) < 0) {
perror("send");
close(sockfd);
return 1;
}
}
printf("----- request -----\n%s", request);
printf("----- response -----\n");
for (;;) {
char buffer[RESPONSE_CHUNK];
ssize_t nread = recv(sockfd, buffer, sizeof(buffer) - 1, 0);
if (nread < 0) {
perror("recv");
close(sockfd);
return 1;
}
if (nread == 0) {
break;
}
buffer[nread] = '\0';
fputs(buffer, stdout);
}
close(sockfd);
return 0;
}
五、如何运行这组示例
1. 编译
bash
make
2. 启动服务端
在第一个终端中执行:
bash
./build/http_server 8080
你会看到:
text
HTTP server listening on http://0.0.0.0:8080
3. 启动客户端
在第二个终端中执行:
bash
./build/http_client 127.0.0.1 8080 /hello
客户端会先打印自己发出的原始 HTTP 请求,然后打印服务端返回的原始 HTTP 响应。
你会看到类似输出:
http
----- request -----
GET /hello HTTP/1.1
Host: 127.0.0.1:8080
User-Agent: minimal-c-http-client/0.1
Accept: */*
Connection: close
----- response -----
HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8
Content-Length: 33
Connection: close
hello from minimal c http server
如果把路径改成 /json:
bash
./build/http_client 127.0.0.1 8080 /json
则会看到 JSON 响应:
http
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Content-Length: ...
Connection: close
{
"method": "GET",
"path": "/json",
"message": "hello from c http server"
}
如果请求一个不存在的路径,例如:
bash
./build/http_client 127.0.0.1 8080 /not-found
则可以观察到 404 Not Found 的响应。
六、这段代码真正帮你理解什么
这组代码虽然很短,但足够把 HTTP 最关键的几个事实直接暴露出来。
1. HTTP 请求本质上是一段文本
客户端发送的请求,本质上就是:
http
GET /hello HTTP/1.1
Host: 127.0.0.1:8080
User-Agent: minimal-c-http-client/0.1
Accept: */*
Connection: close
也就是说,所谓"发一个 HTTP 请求",在最底层其实就是把这样一段符合协议格式的字节序列写入 TCP 连接。
2. HTTP 响应同样是一段文本
服务端返回的响应也是按固定结构组织的:
http
HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8
Content-Length: 33
Connection: close
hello from minimal c http server
它可以拆成:
- 状态行
- 响应头
- 空行
- 响应体
3. Content-Length 很重要
在这个最小示例里,服务端显式设置了 Content-Length。
这是因为客户端需要知道响应体有多长,或者至少知道服务端何时结束传输。
在真实系统中,如果长度不正确,很容易导致:
- 客户端等待更多数据
- 响应被截断
- 粘连到下一次请求
4. Connection: close 简化了学习过程
示例中显式使用了:
http
Connection: close
这样做的目的,是让一次请求完成后直接关闭连接,便于初学者观察一来一回的完整过程。
真实 Web 服务中常常会复用连接,但那属于下一阶段需要理解的内容。
七、这不是生产级 HTTP Server,但它足够适合作为学习入口
这组代码有意保持在"最小闭环"范围内,因此它并没有处理很多真实系统中的复杂问题,例如:
- 持续读取超大请求体
- Keep-Alive 长连接复用
- 并发连接模型
- 分块传输编码
- 路由框架
- HTTPS / TLS
- 大文件传输
- 更完整的错误处理
因此,本文的重点不在于提供一个可以直接上线的 HTTP 服务,而在于让读者掌握最本质的一层:
HTTP 在程序里到底是怎么落到 socket 和 TCP 上的。
这层理解一旦建立起来,后续再去看 Python、Go、Node.js、Nginx 或 Web 框架,都会更容易看清本质。
八、推荐的扩展练习
如果这组最小代码已经跑通,接下来最值得做的扩展包括:
- 让客户端支持
POST - 让服务端读取并返回请求体
- 为服务端增加更多路由
- 支持
Content-Type: application/json - 让服务端支持循环读取多个请求
- 再引入
select/poll/epoll处理多个连接
这些练习会逐步把"协议结构理解"推进到"真实服务端工程能力"。
九、结语
从学习路径上看,HTTP 很容易被误认为只能通过浏览器或框架理解。
但对于想真正掌握网络编程的人来说,用 C 语言亲手写一个最小 HTTP Server 和 Client,往往是最直接、最有穿透力的一步。
因为当你亲手把下面这些内容写出来时:
GET /hello HTTP/1.1Host: 127.0.0.1:8080HTTP/1.1 200 OKContent-Length: ...
HTTP 就不再只是"会用的协议",而是真正变成了"看得见、写得出、解释得清"的协议。
对于网络学习来说,这种从抽象到落地的转变非常重要。