2、内存分配基础:堆、栈、静态区的区别与协议解析的关系

做协议解析,说白了就是在跟内存打交道。你想想看,一个网络包从网卡收上来,到最终被应用层理解,中间要经过多少次内存的分配、拷贝、释放?我刚开始写协议栈的时候,总觉得只要逻辑对就行,结果线上频频 OOM,查到最后都是内存管理的问题。

今天我们就来聊聊最基础的东西——堆、栈、静态区。这三个区域,搞懂了,协议解析的内存问题你就解决了一半。

2.1 栈:协议解析的“临时工”

栈是函数调用时自动分配的内存区域。它的特点是:快,但小。函数返回时自动释放,你不需要手动管理。

在协议解析中,栈最适合存放什么?临时变量、局部缓冲区。比如解析一个 TCP 头,你只需要 20 字节的临时空间,栈上分配就非常合适。

// 栈上分配——解析 TCP 头
void parse_tcp_header(const uint8_t *raw_packet) {
    // 栈上分配 20 字节,够用
    struct tcphdr *tcp = (struct tcphdr *)raw_packet;
    uint16_t src_port = ntohs(tcp->source);
    uint16_t dst_port = ntohs(tcp->dest);
    // ... 解析逻辑
    // 函数返回,栈空间自动回收
}
我的经验:栈上分配一定要控制大小。我见过有人把整个 MTU(1500 字节)的缓冲区都放在栈上,递归调用几次就栈溢出了。一般建议栈上缓冲区不超过 1KB。

2.2 堆:协议解析的“主力军”

堆是动态分配的内存区域。malloc、free、new、delete 都在这里干活。堆的特点是:大,但慢。而且需要你手动管理,搞不好就内存泄漏。

协议解析中,什么时候必须用堆?

  • 数据包重组:比如 TCP 的乱序重组,你需要把多个分片拼起来,大小不确定,只能用堆。
  • 应用层协议解析:HTTP 的 body 可能几 MB,栈根本放不下。
  • 连接状态存储:每个连接的状态信息,数量不确定,堆是唯一选择。
// 堆上分配——TCP 重组缓冲区
struct tcp_reassembly_buffer {
    uint32_t seq_start;
    uint32_t seq_end;
    uint8_t *data;  // 堆上分配
    size_t len;
};

struct tcp_reassembly_buffer *create_buffer(size_t size) {
    struct tcp_reassembly_buffer *buf = malloc(sizeof(*buf));
    if (!buf) return NULL;
    
    buf->data = malloc(size);  // 堆上分配
    if (!buf->data) {
        free(buf);
        return NULL;
    }
    buf->len = size;
    return buf;
}
我曾经踩过的坑:堆分配一定要检查返回值!有一次我写 DNS 解析器,malloc 返回 NULL 没检查,直接 memcpy,线上服务挂了半小时。从那以后,我所有 malloc 后面必跟 NULL 检查。

2.3 静态区:协议解析的“常驻军”

静态区包括全局变量和 static 变量。程序启动时分配,程序结束时释放。它的特点是:生命周期长,访问快

协议解析中,静态区适合放什么?

  • 协议常量表:比如 HTTP 状态码映射表、协议号到协议名的映射。
  • 全局配置:比如缓冲区大小、超时时间等。
  • 单例对象:比如日志系统、统计计数器。
// 静态区——协议常量表
static const char *http_status_codes[][2] = {
    {"200", "OK"},
    {"404", "Not Found"},
    {"500", "Internal Server Error"},
    // ...
};

// 全局配置
static size_t g_recv_buffer_size = 65536;
static int g_timeout_ms = 5000;
我个人的习惯:静态区尽量只放只读数据。如果放可写数据,多线程环境下要加锁,性能会下降。我一般把静态区当“只读缓存”用。

2.4 三者的对比与选择

特性 静态区
分配速度 极快(几条指令) 慢(系统调用) 极快(编译时确定)
大小限制 小(通常几 MB) 大(取决于系统) 中等(取决于链接)
生命周期 函数调用期间 手动控制 整个程序运行期
适用场景 临时变量、小缓冲区 大块数据、动态大小 全局常量、配置
协议解析典型用途 解析头部字段 重组缓冲区、连接状态 协议常量表

2.5 协议解析中的内存分配策略

搞清楚了三个区域的区别,我们来看看在协议解析中怎么选。我总结了一个简单的策略:

  1. 能栈上就栈上:解析头部、临时计算,栈上搞定。快,而且不用担心泄漏。
  2. 栈上不够就堆上:数据包重组、大块数据,堆上分配。但要记得释放。
  3. 全局只读放静态区:协议常量、配置参数,静态区最合适。

核心原则:协议解析中,80% 的内存分配可以用栈解决,15% 需要用堆,剩下 5% 放静态区。如果你发现堆分配占了大多数,那就要重新审视你的设计——是不是缓冲区复用做得不够好?

2.6 一个完整的例子:HTTP 请求解析

我们来看一个实际的 HTTP 请求解析,看看三个区域是怎么配合的:

// 静态区:HTTP 方法表
static const char *http_methods[] = {
    "GET", "POST", "PUT", "DELETE", "HEAD"
};

// 栈上:解析请求行
int parse_request_line(const char *line, struct http_request *req) {
    char method[8];  // 栈上,够用
    char path[1024]; // 栈上,够用
    
    // 解析 "GET /index.html HTTP/1.1"
    sscanf(line, "%s %s", method, path);
    
    // 查找方法
    for (int i = 0; i < 5; i++) {
        if (strcmp(method, http_methods[i]) == 0) {
            req->method = i;
            break;
        }
    }
    
    // 堆上:复制路径(因为要长期保存)
    req->path = strdup(path);
    return 0;
}

// 堆上:解析 body(大小不确定)
int parse_body(struct http_request *req, const char *body_data, size_t len) {
    req->body = malloc(len + 1);  // 堆上
    if (!req->body) return -1;
    memcpy(req->body, body_data, len);
    req->body[len] = '\0';
    req->body_len = len;
    return 0;
}
避坑指南:我曾经在解析 HTTP 请求时,把 path 也放在栈上,结果函数返回后 path 就没了,后面用的时候全是乱码。记住:栈上数据只在函数内有效,要长期保存必须拷贝到堆上

2.7 知识体系图

下面这张图展示了内存分配与协议解析的关系:

内存分配与协议解析关系图 临时变量 头部解析 小缓冲区 数据重组 连接状态 大块数据 静态区 协议常量 全局配置 只读缓存 协议解析层 TCP/IP 解析 · HTTP 解析 · DNS 解析 数据包重组 · 状态管理 · 协议转换 目标:零拷贝 · 高性能 · 低延迟 栈:快但小 堆:大但慢 静态区:常驻

这张图很清楚地展示了:协议解析中,不同的内存区域各司其职。栈负责快速处理临时数据,堆负责处理动态大小的数据,静态区负责常驻的只读数据。理解了这三者的关系,你就能在协议解析中做出合理的内存分配决策。


公众号:蓝海资料掘金营,微信deep3321