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 协议解析中的内存分配策略
搞清楚了三个区域的区别,我们来看看在协议解析中怎么选。我总结了一个简单的策略:
- 能栈上就栈上:解析头部、临时计算,栈上搞定。快,而且不用担心泄漏。
- 栈上不够就堆上:数据包重组、大块数据,堆上分配。但要记得释放。
- 全局只读放静态区:协议常量、配置参数,静态区最合适。
核心原则:协议解析中,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 知识体系图
下面这张图展示了内存分配与协议解析的关系:
这张图很清楚地展示了:协议解析中,不同的内存区域各司其职。栈负责快速处理临时数据,堆负责处理动态大小的数据,静态区负责常驻的只读数据。理解了这三者的关系,你就能在协议解析中做出合理的内存分配决策。
公众号:蓝海资料掘金营,微信deep3321