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 靠谱。

如果非要用,记住三条红线:中断里不用、高频申请释放不用、不检查返回值不用。

嗯,这些经验,是我在好几个项目里,用加班和返工换来的。你记住了,就能少走很多弯路。


专注资料整理