《Linux系统网络协议》用 C 语言写一个最小 HTTP Server 与 Client——网络篇

用 C 语言写一个最小 HTTP Server 与 Client

如果前面已经学习过 socketTCP 和 HTTP 的基本结构,那么接下来最自然的一步,就是亲手用 C 语言把一个最小 HTTP 通信过程写出来。

这是一个非常值得做的练习,因为它会让下面这件事变得非常具体:

HTTP 本质上就是在 TCP 连接上收发符合约定格式的文本数据。

很多时候,HTTP 在框架和浏览器里看起来过于"高级",以至于初学者容易忽略它背后的原貌。实际上,当把所有中间层拿掉之后,一个最小的 HTTP 程序并不复杂:

  • 服务端监听 TCP 端口
  • 客户端建立 TCP 连接
  • 客户端发送一段符合 HTTP 语法的请求文本
  • 服务端返回一段符合 HTTP 语法的响应文本

本文会用一组最小 C 示例,把这个过程完整打通。

目标包括:

  1. 用 C 语言实现一个最小 HTTP 服务端
  2. 用 C 语言实现一个最小 HTTP 客户端
  3. 观察原始 HTTP 请求和响应到底长什么样
  4. 理解 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 个文件:

  • Makefile
  • http_server.c
  • http_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_server
  • build/http_client

三、最小 HTTP 服务端

这个服务端只做几件事:

  1. 监听指定端口
  2. 接收客户端连接
  3. 读取 HTTP 请求
  4. 根据路径返回固定内容

为了让行为更直观,示例中设计了 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 客户端

客户端的任务更加直接:

  1. 连接到指定主机和端口
  2. 按 HTTP/1.1 格式拼出一段 GET 请求
  3. 把请求发出去
  4. 读取并打印服务端响应

完整代码如下:

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 框架,都会更容易看清本质。


八、推荐的扩展练习

如果这组最小代码已经跑通,接下来最值得做的扩展包括:

  1. 让客户端支持 POST
  2. 让服务端读取并返回请求体
  3. 为服务端增加更多路由
  4. 支持 Content-Type: application/json
  5. 让服务端支持循环读取多个请求
  6. 再引入 select/poll/epoll 处理多个连接

这些练习会逐步把"协议结构理解"推进到"真实服务端工程能力"。


九、结语

从学习路径上看,HTTP 很容易被误认为只能通过浏览器或框架理解。

但对于想真正掌握网络编程的人来说,用 C 语言亲手写一个最小 HTTP Server 和 Client,往往是最直接、最有穿透力的一步。

因为当你亲手把下面这些内容写出来时:

  • GET /hello HTTP/1.1
  • Host: 127.0.0.1:8080
  • HTTP/1.1 200 OK
  • Content-Length: ...

HTTP 就不再只是"会用的协议",而是真正变成了"看得见、写得出、解释得清"的协议。

对于网络学习来说,这种从抽象到落地的转变非常重要。

相关推荐
KhalilRuan2 小时前
什么是KCP?QUIC?Websocket?
网络·websocket·网络协议
Arva .2 小时前
RabbitMQ
网络·分布式·rabbitmq
kim_puppy2 小时前
TCP的三次握手,四次挥手
java·网络·tcp
不会写DN2 小时前
让 gRPC 服务同时支持 HTTP/JSON 的gRPC-Gateway
http·json·gateway
深念Y2 小时前
从WebSocket到WebRTC,豆包级实时语音交互背后的技术演进
websocket·网络协议·实时互动·webrtc·语音识别·实时音视频
Rsun045513 小时前
ConfigurableListableBeanFactory跟ApplicationContext作用
网络·网络协议·rpc
弹简特3 小时前
【JavaSE-网络部分06】TCP 纯高性能优化机制:延迟应答・捎带应答【传输层】
网络·tcp/ip·性能优化·捎带应答·延迟应答
MOYIXIAOWEIWEI4 小时前
VMware-centos7更改静态ip
网络·网络协议·tcp/ip
不会写DN4 小时前
使用 sync.Once 解决 Go 并发场景下的重复下线广播问题
开发语言·网络·golang