4、栈与堆管理:Cortex-M堆栈指针(MSP/PSP)、栈溢出检测机制、堆管理算法(First Fit/Best Fit)、malloc/free实现原理

好,咱们今天聊点实在的。栈和堆,这两个东西在嵌入式开发里,可以说是「成也萧何,败也萧何」。我见过太多项目,功能逻辑写得漂漂亮亮,结果一跑起来就莫名其妙地死机、复位。查到最后,十有八九是栈溢出了,或者堆碎片化导致 malloc 返回了 NULL。

你想想看,在 Cortex-M 这种资源受限的 MCU 上,内存总共就那么几百 KB 甚至几十 KB。怎么管好这点家底,是区分「老司机」和「新手」的一道分水岭。今天我就把我在几个量产项目里踩过的坑、总结的经验,掰开了揉碎了讲给你听。

4.1 Cortex-M 的双堆栈指针:MSP 与 PSP

Cortex-M 内核有个很有意思的设计——它给了你两个堆栈指针:主堆栈指针(MSP)进程堆栈指针(PSP)。这可不是多此一举,而是为了支持 RTOS 而生的。

MSP(Main Stack Pointer):默认的堆栈指针。复位后,CPU 用的就是 MSP。所有的异常处理(中断服务函数)都强制使用 MSP。说白了,这是「内核专用」的栈。

PSP(Process Stack Pointer):用于线程模式(Thread Mode)。在跑 RTOS 时,每个任务都有自己的栈空间,PSP 就指向当前正在运行的任务的栈顶。

为什么要这么分?我举个例子。假设你正在跑一个任务,任务栈已经快满了。这时候突然来了个中断,如果中断也往任务栈里压数据,那栈就彻底炸了。有了双堆栈,中断永远用 MSP,任务用 PSP,两者互不干扰。嗯,这个设计确实很巧妙。

关键点:在裸机开发中,我们通常只用 MSP。一旦引入 RTOS,PSP 就派上用场了。切换 PSP 的操作由 RTOS 的上下文切换代码完成,你不需要手动干预。

4.2 栈溢出检测机制

栈溢出是嵌入式开发中最隐蔽的杀手。它不会像 PC 程序那样弹个「Stack Overflow」的对话框,而是直接让你的 MCU 跑飞、进入 HardFault,或者更糟——静默地篡改全局变量。

我个人习惯在项目初期就把栈检测机制加进去。别等到出了问题再查,那时候就晚了。

4.2.1 硬件检测:MPU 与 Stack Guard

Cortex-M3/M4/M7 等内核支持 MPU(内存保护单元)。你可以把栈区域的末尾设置成「不可访问」属性。一旦栈指针越界,CPU 会立即触发 MemManage Fault。这是最可靠的方法。

// 伪代码:配置 MPU 保护栈底
MPU->RNR  = 0;                    // 选择区域 0
MPU->RBAR = (uint32_t)&stack_bottom;  // 栈底地址
MPU->RASR = (0x01 << 0) |        // 使能
            (0x03 << 1) |        // 全权限
            (0x00 << 3) |        // 不可执行
            (0x01 << 4) |        // 共享
            (0x00 << 5) |        // 缓存策略
            (0x1F << 8);         // 区域大小(32字节)

注意:MPU 的粒度通常是 32 字节。你没法保护一个 10 字节的栈。所以,栈空间至少得留出 32 字节的「哨兵区」。我曾经在一个项目中因为 MPU 粒度没算对,导致保护失效,查了整整两天才找到原因。

4.2.2 软件检测:栈填充与水位线

没有 MPU 的芯片怎么办?用软件方法。在系统初始化时,把整个栈空间填充成一个固定模式(比如 0xDEADBEEF)。然后定期检查这个模式被覆盖了多少。

// 栈填充函数
void StackFill(uint32_t *stack_start, uint32_t size_in_words) {
    for (uint32_t i = 0; i < size_in_words; i++) {
        stack_start[i] = 0xDEADBEEF;
    }
}

// 栈使用量检测
uint32_t StackUsed(uint32_t *stack_start, uint32_t size_in_words) {
    uint32_t used = 0;
    for (uint32_t i = size_in_words - 1; i > 0; i--) {
        if (stack_start[i] != 0xDEADBEEF) {
            used = (i + 1) * 4;  // 返回已使用的字节数
            break;
        }
    }
    return used;
}

这个方法我在一个工业控制项目里用过。当时芯片是 Cortex-M0,没有 MPU。我每 100ms 检查一次栈水位,发现有个任务的栈使用率已经达到 95% 了。赶紧把栈空间翻了一倍,才避免了量产后的批量故障。

4.3 堆管理算法:First Fit 与 Best Fit

堆,说白了就是动态内存池。你用 malloc 申请,用 free 释放。但嵌入式环境下的堆管理,比 PC 要复杂得多。因为你没有虚拟内存,没有 MMU,所有内存都是物理连续的。

常见的堆管理算法有两种:First Fit(首次适应)Best Fit(最佳适应)

算法 原理 优点 缺点
First Fit 从堆头开始扫描,找到第一个足够大的空闲块就分配 速度快,实现简单 容易产生碎片,大块内存被切碎
Best Fit 扫描所有空闲块,找到大小最匹配的那个 碎片少,内存利用率高 扫描时间长,分配速度慢

你可能会问:「那到底用哪个?」我的建议是:看你的应用场景

  • 如果你的程序频繁分配和释放大小相近的内存块(比如网络协议栈的缓冲区),Best Fit 更合适。
  • 如果你的程序分配次数少,或者内存块大小差异很大,First Fit 更简单高效。

我的经验:在大多数嵌入式项目中,我倾向于使用 First Fit 的变种——Next Fit。它从上一次分配的位置继续扫描,而不是每次都从头开始。这样既避免了 First Fit 的「头部碎片化」,又比 Best Fit 快得多。

4.4 malloc/free 实现原理

标准库的 malloc/free 实现,其实就是一个「空闲链表」的管理器。我来给你拆解一下它的核心逻辑。

4.4.1 空闲链表结构

堆内存被划分为一个个内存块。每个块的开头有一个「控制头」,里面记录了块的大小和状态。

typedef struct mem_block {
    uint32_t size;           // 块大小(包含控制头)
    uint8_t  free;           // 1=空闲,0=已分配
    struct mem_block *next;  // 指向下一个块
} mem_block_t;

整个堆就是一个单向链表。malloc 时遍历链表找空闲块,free 时把块标记为空闲,并尝试合并相邻的空闲块。

4.4.2 malloc 的实现流程

  1. 用户请求 n 字节。
  2. 实际需要分配的大小 = n + sizeof(mem_block_t)(控制头开销)。
  3. 遍历空闲链表,找到满足条件的块。
  4. 如果块比请求大很多(超过最小碎片阈值),就分裂成两个块。
  5. 返回用户空间的起始地址(控制头之后)。
void *malloc(size_t size) {
    mem_block_t *curr = heap_start;
    mem_block_t *best = NULL;
    
    // 对齐处理(4字节或8字节对齐)
    size = (size + 3) & ~3;
    size += sizeof(mem_block_t);
    
    while (curr != NULL) {
        if (curr->free && curr->size >= size) {
            // First Fit:直接返回
            // Best Fit:继续找更合适的
            if (curr->size >= size + MIN_SPLIT_SIZE) {
                // 分裂块
                SplitBlock(curr, size);
            }
            curr->free = 0;
            return (void *)(curr + 1);
        }
        curr = curr->next;
    }
    return NULL;  // 堆空间不足
}

4.4.3 free 的实现流程

  1. 根据用户指针,回退到控制头位置。
  2. 标记为空闲。
  3. 检查前一个块和后一个块是否空闲,如果是,合并它们。
void free(void *ptr) {
    if (ptr == NULL) return;
    
    mem_block_t *block = (mem_block_t *)ptr - 1;
    block->free = 1;
    
    // 合并后向空闲块
    if (block->next != NULL && block->next->free) {
        block->size += block->next->size;
        block->next = block->next->next;
    }
    
    // 合并前向空闲块(需要双向链表或额外指针)
    // ...
}

避坑指南:我曾经在一个项目中,因为 free 后没有正确合并相邻空闲块,导致堆碎片化严重。运行 72 小时后,malloc 返回 NULL,系统直接死机。后来我加了一个「堆整理」函数,在每次 free 后主动合并所有相邻空闲块,问题才解决。

4.5 实战建议:什么时候用堆,什么时候用栈?

说了这么多,最后给你几条我总结的「铁律」:

  • 能用栈,就别用堆。栈的分配和释放是 O(1) 的,没有碎片问题。堆的开销大,还有碎片风险。
  • 如果必须用堆,尽量在初始化时一次性分配。运行期间不再释放,避免碎片。
  • 永远检查 malloc 的返回值。不要假设它一定成功。我见过太多人写 ptr = malloc(100); ptr[0] = 1;,然后 NULL 指针解引用,直接 HardFault。
  • 考虑使用静态内存池。对于固定大小的对象(如任务控制块、消息队列),用静态数组预分配,比堆更可靠。

嗯,关于栈和堆的管理,今天就聊到这里。下一节我们会深入讲讲「内存对齐与缓存一致性」,那又是一个容易踩坑的地方。到时候见。