2、堆与栈:栈空间分配、堆管理机制、malloc/free的底层原理

好,咱们今天聊点实在的。堆和栈,这两个词你肯定听过无数遍了。但说实话,我在面试过不少嵌入式工程师,能把它们讲透的人真不多。很多人背了一堆概念,一问到「栈到底怎么分配的?malloc 底层干了啥?」就卡壳了。

嗯,咱们今天就把它彻底掰开揉碎。

2.1 栈空间分配——编译器替你操了多少心?

先说说栈。栈这东西,说白了就是一块连续的内存,由硬件和编译器共同管理。你写一个函数,局部变量、函数参数、返回地址,全往栈上扔。函数返回了,这些空间自动释放。

我刚开始做 RTOS 移植那会儿,遇到过一个诡异的问题:任务跑着跑着就崩了,查了三天,最后发现是栈溢出了。你想想看,一个任务里定义了一个 512 字节的局部数组,再加上几层函数嵌套调用,栈空间直接爆了。

⚠️ 我曾经踩过的坑: 在 FreeRTOS 中,任务栈大小是以字(word)为单位的,不是字节!我见过有人定义 configMINIMAL_STACK_SIZE = 128,以为有 128 字节,结果实际只有 512 字节(32位机)。嗯,这种低级错误,犯一次就记住了。

栈的分配机制其实很简单:

  • 向下生长:大多数 ARM Cortex-M 系列,栈是从高地址往低地址长的。
  • 自动管理:你不需要手动释放栈空间,函数返回时 SP(栈指针)自动恢复。
  • 连续内存:栈必须是连续的,不能有碎片。

那编译器是怎么分配栈空间的?我举个例子:

void func(void) {
    int a = 10;       // 4 字节
    char buf[64];     // 64 字节
    int b = 20;       // 4 字节
    // 总共约 72 字节栈空间
}

编译器在编译时就算好了这个函数需要多少栈空间。它会在函数入口处一次性调整 SP,把栈指针往下挪 72 字节。函数返回时再恢复。就这么简单。

但要注意,递归函数的栈空间是动态的。你写一个递归,每调用一次就多占一层栈。我曾经见过一个同事,递归深度没控制好,直接栈溢出,系统复位。嗯,那场面,挺尴尬的。

2.2 堆管理机制——谁在管理你的动态内存?

说完栈,咱们聊聊堆。堆和栈最大的区别是什么?栈是自动的,堆是手动的。你 malloc,它就给你一块内存;你 free,它就收回去。

但问题来了:堆是谁在管理?

在裸机环境下,堆管理通常由 C 标准库的 malloc/free 实现。在 RTOS 中,比如 FreeRTOS,它提供了自己的堆管理方案,有 heap_1.c、heap_2.c、heap_3.c、heap_4.c、heap_5.c 五种。

我个人习惯在 RTOS 项目里用 heap_4.c。为什么?因为它支持合并相邻空闲块,能有效减少碎片。我在一个数据采集项目里用过 heap_2.c,跑了三天后,内存碎片化严重,malloc 开始返回 NULL。换成 heap_4.c 后,问题解决了。

堆的核心数据结构: 大多数堆管理算法都维护一个「空闲链表」。每个空闲块头部有一个控制结构,记录块大小和下一个空闲块的地址。malloc 时遍历链表找合适的块,free 时把块插回链表。

常见的堆管理算法有:

算法 特点 适用场景
首次适应(First Fit) 从头遍历,找到第一个够大的块 简单,但碎片较多
最佳适应(Best Fit) 找最接近需求大小的块 碎片少,但速度慢
伙伴系统(Buddy System) 按 2 的幂次分割内存 分配释放快,但内部碎片

在 MCU 上,我建议尽量少用堆。为什么?因为 MCU 资源有限,堆管理有开销,而且碎片问题在长时间运行的系统里很致命。能用静态分配就别用动态分配,这是嵌入式开发的铁律。

2.3 malloc/free 的底层原理——它到底干了什么?

好,咱们来扒一扒 mallocfree 的底裤。你每次调用 malloc(100),它到底干了哪些事?

  1. 检查空闲链表:遍历空闲块链表,找一块大小 >= 100 字节的空闲内存。
  2. 分割块(可选):如果找到的块比 100 大很多,就把它分割成两块。一块给你用,另一块放回空闲链表。
  3. 返回地址:返回给你的是「用户数据区」的起始地址,不是块头的地址。

你可能会问:那 free 的时候怎么知道要释放多大?

答案就在块头里。每个 malloc 返回的地址前面,紧挨着一个控制结构,里面存了块的大小。free 的时候,根据传入的指针往前偏移,找到块头,读取大小,然后把这块内存标记为空闲。

看个简化版的实现:

typedef struct block_header {
    size_t size;           // 块大小(包含头部)
    struct block_header *next;  // 下一个空闲块
    int free;              // 是否空闲
} block_header_t;

void *malloc(size_t size) {
    block_header_t *curr = free_list_head;
    while (curr != NULL) {
        if (curr->free && curr->size >= size + sizeof(block_header_t)) {
            // 找到了!分割块...
            curr->free = 0;
            return (void*)(curr + 1);  // 跳过头部,返回数据区
        }
        curr = curr->next;
    }
    return NULL;  // 没找到合适的块
}

void free(void *ptr) {
    block_header_t *block = (block_header_t*)ptr - 1;  // 往前找块头
    block->free = 1;
    // 合并相邻空闲块...
}

💡 一个小技巧: 在调试内存问题时,可以在 malloc 返回的地址前后加上「哨兵」字节(比如 0xDEADBEEF)。如果 free 时发现哨兵被改写了,说明发生了缓冲区溢出。我自己的项目里一直用这个办法,抓到了不少 bug。

这里有个关键点:malloc 的开销比你想象的大。每次分配,除了你申请的大小,还要额外占用一个块头(通常 8-16 字节)。你申请 4 字节,实际可能消耗 20 字节。在 MCU 上,这种浪费很要命。

另外,malloc 不是线程安全的。在 RTOS 中,如果多个任务同时调用 malloc,必须加锁。FreeRTOS 的 heap_3.c 就是直接调标准库的 malloc,然后用调度锁保护。而 heap_4.c 自己实现了临界区保护。

2.4 实战建议——怎么用好堆和栈?

说了这么多,最后给点实战建议:

  • 栈空间:每个任务分配栈时,留出 20%-30% 的余量。别抠抠搜搜的,栈溢出查起来比内存泄漏还痛苦。
  • 堆空间:能不用就不用。如果非要用,优先考虑 RTOS 自带的堆管理方案,别自己造轮子。
  • 调试工具:用 pvPortGetFreeHeapSize()(FreeRTOS)查看剩余堆空间。定期打印出来,观察是否有内存泄漏。

我记得有一次,一个设备运行两个月后死机了。查到最后,是一个回调函数里每次调用都 malloc 了 32 字节,但忘了 free。两个月下来,堆空间被吃光了。嗯,从那以后,我养成了每个 malloc 必须配对 free 的习惯,代码 review 时重点检查这个。

好了,堆和栈就聊到这儿。下节课咱们讲任务调度,那才是 RTOS 的核心。到时候见。