4、堆内存管理机制:malloc/free实现原理、碎片化问题、运动控制中堆使用的禁忌
堆内存,说白了就是程序运行时动态申请的那块“自由地”。
你写个嵌入式程序,全局变量和栈上变量的大小,编译时就定死了。但有些数据,比如运动控制中的轨迹缓冲区、动态生成的路径点,运行时才知道多大。这时候就得靠堆。
但堆这东西,用好了是利器,用不好就是定时炸弹。我在运动控制项目里,见过太多次因为堆管理不当导致的系统崩溃。今天咱们就把它掰开揉碎了讲清楚。
4.1 malloc/free 的实现原理
很多工程师把 malloc 和 free 当成黑盒子。其实它的核心逻辑并不复杂。
简单说,malloc 就是从一个大的空闲内存块里,切一块给你。free 就是把你用完的那块,再放回空闲块里。
但具体怎么切、怎么放,就有讲究了。
4.1.1 空闲链表法
最经典的实现方式,是维护一个空闲链表。
每个空闲块的开头,都藏着一个结构体,记录着这块内存的大小、以及指向下一个空闲块的指针。malloc 时,遍历链表,找到一块足够大的空闲块,切一部分给你,剩下的继续挂在链表上。
free 时,把这块内存重新标记为空闲,插回链表。如果相邻的空闲块能合并,就合并成一块更大的。
核心数据结构示意:
typedef struct free_block {
size_t size; // 当前块大小(含头部)
struct free_block *next; // 指向下一个空闲块
} free_block_t;
嗯,这里要注意:你每次 malloc 拿到的地址,其实不是这个结构体的起始地址。结构体在地址的前面,叫“头部”。返回给你的,是头部后面的数据区地址。free 的时候,库函数会根据你传的地址,往前偏移,找到头部,再处理。
所以,如果你把指针乱改,或者越界写,把头部破坏了,free 的时候必崩。我在项目中遇到过,一个同事在缓冲区里写日志,写嗨了,把头部覆盖了,结果 free 时直接 hardfault。查了两天才找到原因。
4.1.2 内存池与伙伴算法
对于实时性要求高的运动控制系统,标准 malloc 的遍历查找太慢了。而且时间不确定。
所以很多 RTOS 或专用库,会采用内存池或伙伴算法。
内存池:预先划分好固定大小的块,比如 16字节、32字节、64字节... 申请时直接拿对应大小的块,释放时直接放回。没有碎片,速度极快。
伙伴算法:把内存按 2 的幂次分成块。比如 1KB、2KB、4KB... 申请 3KB 时,给一个 4KB 的块,剩下的 1KB 再拆成更小的块。释放时,如果相邻的块也是空闲且大小相同,就合并成更大的块。
我的建议:在运动控制的主循环里,尽量不要用标准 malloc/free。用内存池或者静态分配,能省掉很多麻烦。
4.2 碎片化问题
碎片化,是堆管理最头疼的问题。没有之一。
你想想看,系统运行久了,频繁地申请、释放不同大小的内存。空闲块被切得七零八落。明明总空闲内存还有 10KB,但最大连续块只有 2KB。这时候你申请一个 3KB 的缓冲区,malloc 就返回 NULL 了。
这就是外部碎片。
4.2.1 碎片化的成因
- 申请释放顺序不匹配:先申请 A,再申请 B,释放 A,再申请 C。如果 C 比 A 大,A 那块空位就用不上。
- 大小分布太散:一会儿申请 10字节,一会儿申请 1000字节。空闲链表里大小不一,很难配对。
- 频繁操作:每次 malloc/free 都在链表上动刀子,时间长了,链表就乱了。
我曾经在一个多轴插补项目里,每产生一个路径点就 malloc,插补完就 free。跑了半小时,系统突然卡死。一查,堆碎片率超过 70%,最大连续块只剩 128 字节。而下一个路径点需要 256 字节。直接挂了。
4.2.2 碎片化的代价
| 问题 | 后果 |
|---|---|
| 内存利用率下降 | 明明有内存,但申请不到 |
| 分配时间不确定 | 遍历链表越来越长,耗时剧增 |
| 系统稳定性降低 | malloc 返回 NULL,未处理则崩溃 |
4.3 运动控制中堆使用的禁忌
运动控制,讲究的是确定性、实时性、可靠性。堆的很多特性,恰恰与这些要求相悖。
下面这几条,是我用血泪换来的经验。你最好记下来。
4.3.1 禁忌一:在中断服务函数中 malloc/free
绝对不要。
中断服务函数要求快、短、可重入。而 malloc/free 通常不是可重入的。它内部有全局锁。你在中断里调 malloc,万一此时主循环也在调 malloc,就死锁了。
而且 malloc 的执行时间不确定,可能触发看门狗复位。
警告:中断里用 malloc,等于在雷区跳舞。我曾经见过一个伺服驱动项目,中断里申请了一小块内存做日志,结果系统偶尔死机。查了两个月,最后发现是 malloc 不可重入导致的。
4.3.2 禁忌二:高频动态申请释放
运动控制中,很多数据是周期性的。比如每 1ms 产生一个轨迹点,处理完就丢弃。
如果你每周期都 malloc/free,碎片会迅速累积。而且分配耗时也会吃掉你的控制周期。
正确的做法:预先分配好最大数量的缓冲区,用环形队列或双缓冲来管理。只分配一次,反复使用。
推荐做法:
// 预分配 100 个轨迹点缓冲区
#define MAX_PATH_POINTS 100
typedef struct {
float x, y, z;
float speed;
} path_point_t;
static path_point_t point_pool[MAX_PATH_POINTS];
static uint8_t pool_used[MAX_PATH_POINTS] = {0};
// 申请:找一个空闲槽位
path_point_t* alloc_point(void) {
for (int i = 0; i < MAX_PATH_POINTS; i++) {
if (!pool_used[i]) {
pool_used[i] = 1;
return &point_pool[i];
}
}
return NULL; // 池满
}
// 释放:标记为空闲
void free_point(path_point_t *p) {
int idx = p - point_pool;
if (idx >= 0 && idx < MAX_PATH_POINTS) {
pool_used[idx] = 0;
}
}
你看,这样既没有碎片,时间也是确定的。一个 for 循环,最多 100 次,毫秒级就能完成。
4.3.3 禁忌三:忽略 malloc 返回值检查
这个听起来像废话,但很多工程师就是会忘。
malloc 返回 NULL,说明内存不够了。如果你不检查,直接往 NULL 地址写数据,那就是非法访问,系统直接崩。
运动控制中,崩了就是撞机、烧驱动器、甚至伤人。
避坑指南:我曾经在一个焊接机器人项目里,代码 review 时发现,有 3 处 malloc 没检查返回值。其中一处是轨迹插补模块。如果焊接过程中内存不足,机器人会突然失控。想想都后怕。
4.3.4 禁忌四:使用变长数组或大块动态内存
有些工程师喜欢这样写:
void process_data(int len) {
char *buf = (char*)malloc(len);
// ...
free(buf);
}
len 是外部传入的,可能很大,也可能很小。如果 len 是 0 或者负数呢?如果 len 是 1MB 呢?你的堆总共才 64KB。
运动控制中,所有资源都应该是可预测的。变长、大块动态内存,会让你的系统变得不可控。
我的习惯是:所有缓冲区大小,在编译时就定死。如果实在需要动态,也要加一个硬上限,超过就报错,而不是默默申请。
4.4 本章小结
堆内存管理,说难不难,说简单也不简单。
核心就一句话:运动控制中,尽量不用标准堆。用内存池、静态分配、环形队列,都比 malloc/free 靠谱。
如果非要用,记住三条红线:中断里不用、高频申请释放不用、不检查返回值不用。
嗯,这些经验,是我在好几个项目里,用加班和返工换来的。你记住了,就能少走很多弯路。