4、内存管理:静态内存池设计、动态内存分配(malloc/free)的陷阱、零拷贝技术入门
内存管理,说白了就是嵌入式网络协议栈的命根子。你想想看,一个数据包从网卡进来,经过层层协议解析,最后交给应用层,这中间内存要是管不好,系统随时可能崩掉。我这些年做协议栈移植,踩过的坑十有八九都跟内存有关。今天咱们就把这块硬骨头啃下来。
4.1 静态内存池设计
静态内存池,我个人习惯叫它“固定大小内存块池”。为什么不用 malloc?因为嵌入式环境里,内存碎片是最大的敌人。我曾经在一个只有 64KB RAM 的 MCU 上跑轻量级 TCP/IP 栈,用 malloc/free 跑了三天,系统直接挂掉——内存碎成渣了。
静态内存池的思路很简单:
- 预先划分一大块连续内存
- 按固定大小切成多个小块(比如 64 字节、256 字节、1024 字节)
- 每个小块用链表串起来
- 分配时从链表头取一个,释放时放回链表
嗯,这里要注意:不同大小的内存块要分不同的池管理。我一般会设计 3-4 个池,覆盖协议栈里最常见的包大小。
核心要点:静态内存池没有碎片问题,分配和释放都是 O(1) 复杂度,实时性极好。
来看一个简单的实现框架:
/* 内存池控制块 */
struct mem_pool {
void *free_list; /* 空闲块链表头 */
uint32_t block_size; /* 每个块的大小 */
uint32_t total_blocks;/* 总块数 */
uint32_t free_blocks; /* 当前空闲块数 */
};
/* 初始化一个内存池 */
void mem_pool_init(struct mem_pool *pool,
void *mem_start,
uint32_t block_size,
uint32_t block_count) {
uint8_t *p = (uint8_t *)mem_start;
pool->free_list = p;
pool->block_size = block_size;
pool->total_blocks = block_count;
pool->free_blocks = block_count;
/* 把每个块串成链表 */
for (int i = 0; i < block_count - 1; i++) {
*(void **)p = (void *)(p + block_size);
p += block_size;
}
*(void **)p = NULL; /* 最后一个块指向空 */
}
分配和释放的代码就更简单了:
void *mem_pool_alloc(struct mem_pool *pool) {
if (pool->free_list == NULL) {
return NULL; /* 没内存了 */
}
void *block = pool->free_list;
pool->free_list = *(void **)block;
pool->free_blocks--;
return block;
}
void mem_pool_free(struct mem_pool *pool, void *block) {
*(void **)block = pool->free_list;
pool->free_list = block;
pool->free_blocks++;
}
我的经验:每个池的块大小要精心设计。比如以太网 MTU 是 1514 字节,加上头部对齐,我一般设 1536 字节的池。小包用 64 字节池,控制报文用 256 字节池。这样内存利用率最高。
4.2 动态内存分配(malloc/free)的陷阱
说实话,在嵌入式网络协议栈里,我强烈建议你别用标准库的 malloc/free。为什么?陷阱太多了。
陷阱一:碎片化
你分配一个 100 字节的缓冲区,用完释放了。再分配一个 50 字节的,可能就落在刚才那个位置。但如果你分配 120 字节的,就得往后找。时间一长,内存就像瑞士奶酪一样,全是洞。我曾经在一个项目里,系统跑了 72 小时后,malloc 返回 NULL 了,但实际空闲内存还有 30%。
陷阱二:不可重入
标准库的 malloc 大多不是线程安全的。协议栈里中断服务程序和任务都可能分配内存,一个不小心就死锁了。我记得有一次调试,查了整整两天,最后发现是中断里调了 malloc,跟主循环里的 malloc 冲突了。
陷阱三:执行时间不确定
malloc 内部要遍历空闲链表,可能还要做内存合并。最坏情况下执行时间可能差好几个数量级。这对实时性要求高的协议栈来说,简直是灾难。
避坑指南:我曾经在一个工业以太网项目里,因为用了 malloc,导致数据包处理延迟从 50us 跳变到 2ms。后来全部换成静态内存池,延迟稳定在 30us 以内。
如果你实在要用动态分配,我建议这样做:
- 只在初始化阶段用 malloc,运行时不用
- 或者自己实现一个简单的内存分配器,只分配不释放(一次性分配)
- 再或者用固定大小的内存池替代
4.3 零拷贝技术入门
零拷贝,说白了就是数据从网卡到应用层,中间不搬来搬去。你想想看,一个 1500 字节的包,如果每层协议都拷贝一次,三层协议就要拷贝 4500 字节,CPU 全花在 memcpy 上了。
零拷贝的核心思想就一句话:传递指针,而不是数据。
具体怎么做?我一般用两种方式:
方式一:共享缓冲区
整个协议栈共用一块内存池。网卡驱动把数据直接放到池里,IP 层、TCP 层、应用层都操作同一个缓冲区,只传递偏移量和长度。
/* 零拷贝的包描述符 */
struct pkt_desc {
uint8_t *data; /* 指向共享缓冲区 */
uint32_t len; /* 数据长度 */
uint32_t offset; /* 当前层数据的起始偏移 */
uint16_t ref_count; /* 引用计数 */
};
/* 各层只修改偏移量,不拷贝数据 */
void ip_layer_process(struct pkt_desc *pkt) {
/* IP头在 offset 处,处理完后偏移前移 */
pkt->offset += IP_HEADER_LEN;
pkt->len -= IP_HEADER_LEN;
/* 直接传给上层 */
tcp_layer_process(pkt);
}
方式二:分散/聚合 I/O
有些硬件支持多个不连续的内存块拼成一个包。比如协议头在一个块,数据在另一个块,硬件自己会拼好。这样连内存拷贝都省了。
关键点:零拷贝必须配合引用计数。多个层同时引用同一个缓冲区时,要等所有人都用完了才能释放。我一般用原子操作维护 ref_count,避免竞态。
零拷贝的收益有多大?我实测过:
| 场景 | 传统拷贝 | 零拷贝 | 提升 |
|---|---|---|---|
| 100Mbps 小包(64字节) | 85% CPU | 32% CPU | 2.6倍 |
| 100Mbps 大包(1518字节) | 45% CPU | 18% CPU | 2.5倍 |
| 延迟抖动 | ±200us | ±30us | 6.7倍 |
我的建议:零拷贝不是银弹。如果你的 CPU 性能过剩,或者数据包很小(比如几十字节),拷贝的开销可以忽略。但如果你在做千兆以太网或者视频流传输,零拷贝就是必须的。
最后说一句:内存管理没有万能方案。静态池适合确定性场景,动态分配适合灵活场景,零拷贝适合高性能场景。你得根据项目需求选,或者像我一样,把几种方案组合起来用。