2、堆与栈:栈空间分配、堆管理机制、malloc/free的底层原理
好,咱们今天聊点实在的。堆和栈,这两个词你肯定听过无数遍了。但说实话,我在面试过不少嵌入式工程师,能把它们讲透的人真不多。很多人背了一堆概念,一问到「栈到底怎么分配的?malloc 底层干了啥?」就卡壳了。
嗯,咱们今天就把它彻底掰开揉碎。
2.1 栈空间分配——编译器替你操了多少心?
先说说栈。栈这东西,说白了就是一块连续的内存,由硬件和编译器共同管理。你写一个函数,局部变量、函数参数、返回地址,全往栈上扔。函数返回了,这些空间自动释放。
我刚开始做 RTOS 移植那会儿,遇到过一个诡异的问题:任务跑着跑着就崩了,查了三天,最后发现是栈溢出了。你想想看,一个任务里定义了一个 512 字节的局部数组,再加上几层函数嵌套调用,栈空间直接爆了。
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 的底层原理——它到底干了什么?
好,咱们来扒一扒 malloc 和 free 的底裤。你每次调用 malloc(100),它到底干了哪些事?
- 检查空闲链表:遍历空闲块链表,找一块大小 >= 100 字节的空闲内存。
- 分割块(可选):如果找到的块比 100 大很多,就把它分割成两块。一块给你用,另一块放回空闲链表。
- 返回地址:返回给你的是「用户数据区」的起始地址,不是块头的地址。
你可能会问:那 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 的核心。到时候见。