7AA7 Blog

嵌入式 C 的内存管理:栈、堆与静态分配

KernelPan UID:90
2026-09-30 15:08
32 3 0

MCU 上写 C 和写 Linux 应用最大的区别之一是内存是”看得见”的:编译链接完就能算出静态占用多少,剩下的 RAM 全部归栈和堆。搞清楚这五段分别装什么、在哪里定义,是排查内存类故障的前提。

五段布局与链接脚本

一段典型的布局(地址从低到高,具体排列以芯片手册和链接脚本为准):

段 内容 存放位置
.text 代码、常量、中断向量表 flash
.rodata const 数据、字符串字面量 flash
.data 已初始化的全局/静态变量 flash 存初值,启动时拷到 RAM
.bss 未初始化或初始化为 0 的全局/静态变量 RAM,启动时清零
stack / heap 运行时 RAM

.data 的初值要从 flash 搬到 RAM,.bss 要清零,这两件事由启动代码(Reset_Handler)完成,通常汇编写成。如果这段初始化没跑或者跑错了,全局变量会是随机值,表现为”上电第一次读传感器数据很离谱”。

链接脚本(.ld)里定义各段的起始地址和长度,_estack、_sdata、_edata、_sbss、_ebss 这类符号就是在这里指定的。想看实际占用,用 arm-none-eabi-size 或直接读 .map 文件:

.text   42368
.data     512
.bss     8464

.text + .data 是 flash 占用,.data + .bss 是静态 RAM 占用。剩下的 RAM 才是栈和堆能吃的部分。

栈:方向、溢出与检测

栈一般从 RAM 顶部向低地址增长(满递减栈)。栈指针越过预定边界就是溢出,后果不可预测——通常表现为某个全局变量被莫名改写,或者函数返回跳到非法地址。

栈溢出难查是因为它不立刻报错。几种检测手段:

哨兵值(watermark):启动时把栈区填满一个固定图案(比如 0xCDCDCDCD),运行一段时间后从栈底向上扫描,看还剩多少没被改写,算出历史最高水位。这是最廉价可靠的办法。

extern uint32_t _estack, _sstack;

void stack_fill(void) {
    for (uint32_t *p = &_sstack; p < &_estack; p++) *p = 0xCDCDCDCD;
}

uint32_t stack_usage(void) {
    uint32_t *p = &_sstack;
    while (p < &_estack && *p == 0xCDCDCDCD) p++;
    return (uint32_t)(&_estack - p) * sizeof(uint32_t);
}

MPU 保护:把栈底部一小块区域设为不可访问,一旦栈指针越界立刻触发 MemManage 异常,可以在异常处理里打印现场。代价是消耗一个 MPU region,且只能保护对齐后的边界。

编译器选项:-fstack-usage 生成每个函数的栈用量 .su 文件,配合调用图能算出最坏路径的理论栈深。对递归和函数指针调用无能为力。

栈大小的估算方法是最坏调用路径 + 中断嵌套。每层中断都要占用栈(通常压到独立的中断栈或者主栈,取决于架构和配置),N 层优先级嵌套就是 N 份中断上下文。实际做法是在估算值上加 30%~50% 余量,再用哨兵值实测校准。

堆碎片为什么致命

malloc/free 在通用系统上有虚拟内存和内存整理兜底,MCU 上没有。反复申请释放不同大小的内存块,会在堆里留下一堆不连续的小空洞。总量明明够,却再也分配不出一块连续的大内存——这就是碎片。

对长期运行的设备,碎片是时间函数:跑一天没问题,跑三个月就挂。这类故障极难复现,现场往往只看到 malloc 返回 NULL 后程序跑飞。

分配失败的处理也麻烦。很多代码里 malloc 之后不检查返回值,或者检查了也只是打印一句日志。分配不到内存时设备该干什么?降级运行还是复位?这个决策必须提前想好。

为什么很多规范禁止 malloc

MISRA C 和相当多公司的嵌入式编码规范限制或禁止动态分配,理由有三条:

  1. 不确定性:malloc 的执行时间依赖堆的当前状态,无法给出最坏执行时间上界,硬实时系统里不可接受。
  2. 碎片:如上,长期运行的风险无法在测试阶段暴露。
  3. 失败路径:每次分配都要处理失败,代码里会散落大量错误分支,而这些分支几乎从不执行,属于典型的未测试代码。

替代方案是按最坏情况静态分配。系统的行为在上电时就是确定的,不会出现”跑着跑着内存不够了”。

内存池:固定块分配

内存池的思路是:预先划分 N 个同样大小的块,用单向链表串起空闲块。分配就是取链表头,释放就是插回链表头。两个操作都是 O(1),执行时间恒定。

因为块大小固定,不存在外部碎片——最坏情况就是池子空了。代价是内部碎片:申请 20 字节也占一个 64 字节的块。可以建多个不同块大小的池来缓解。

typedef struct pool {
    void        *mem;      /* 块数组首地址 */
    void        *free_list;/* 空闲块链表头 */
    uint16_t    blk_size;
    uint16_t    blk_cnt;
    uint16_t    used;      /* 峰值统计 */
} pool_t;

int pool_init(pool_t *p, void *mem, uint16_t blk_size, uint16_t blk_cnt) {
    if (!mem || blk_size < sizeof(void *)) return -1;
    p->mem = mem; p->blk_size = blk_size;
    p->blk_cnt = blk_cnt; p->used = 0;

    uint8_t *cur = (uint8_t *)mem;
    for (uint16_t i = 0; i < blk_cnt - 1; i++) {
        *(void **)cur = (void *)(cur + blk_size);  /* 下一块的指针存在块头 */
        cur += blk_size;
    }
    *(void **)cur = NULL;
    p->free_list = mem;
    return 0;
}

void *pool_alloc(pool_t *p) {
    void *blk = p->free_list;
    if (!blk) return NULL;                 /* 池满,由调用方决定降级还是复位 */
    p->free_list = *(void **)blk;
    if (++p->used > p->used_max) p->used_max = p->used;
    return blk;
}

void pool_free(pool_t *p, void *blk) {
    if (!blk) return;
    *(void **)blk = p->free_list;
    p->free_list = blk;
}

几个实现要点:块大小必须 ≥ 指针大小,否则存不下链表指针;块数组用静态数组或编译期对齐的缓冲区提供(__attribute__((aligned(4)))),避免对齐问题;used_max 用来做容量规划,跑一段时间读出来就知道池子该开多大。

释放时不做合法性校验是为了保持 O(1)。如果担心重复释放和野指针,可以在调试版本里加一个块头的 magic 字段校验,发布版本去掉。

静态分配的实践形态

几种常见的替代写法:

编译期数组:static uint8_t rx_buf[256]; 简单直接,缺点是占用固定,不管用不用都占着。

对象池 + 句柄:预先分配 N 个结构体,用 ID 或索引作为句柄对外暴露,避免把裸指针传出去。网络连接的 conn id、定时器 id 都是这个套路。

静态分配的链表节点:节点结构体里内嵌 next 指针,节点本身来自静态数组池。既保留链表的灵活性,又没有动态分配。

对齐、填充与排查手段

结构体成员之间会插入填充字节以满足对齐要求。一个 uint8_t 后面跟 uint32_t,编译器通常会在中间补 3 个字节。成员按从大到小排列,或者用 __attribute__((packed)) 强制紧凑,都能减少浪费。packed 的代价是访问未对齐成员可能触发硬件异常,或者被编译器拆成多次字节访问,代码变大变慢——网络收发的协议结构体用 packed 是对的,内部高频访问的结构体按对齐排列更好。

定位内存问题有几个顺手的手段:链接器加 -Wl,--print-memory-usage 能在构建时直接打印各段占用;把 .map 里占空间最大的几个符号排个序,往往能揪出一个不该存在的全局大数组;带 RTOS 的话,xPortGetMinimumEverFreeHeapSize() 这类接口能给出堆的历史最低水位,用来判断堆有没有被逼到临界。HardFault 之后读栈帧里的 PC 和 LR,配合 .map 的符号地址表能定位到函数。栈溢出导致的 HardFault 现场通常已经被破坏,这时候只能靠哨兵值和 MPU 提前捕获。

容易踩的坑

printf 系列函数会拉进一大坨格式化代码,栈用量也不小,在小栈任务里调用可能直接溢出。用 snprintf 到静态缓冲再输出,或者用轻量级的 printf 实现,都需要留意。

中断服务程序里调用非可重入函数(很多标准库函数都是)是另一个隐患,特别是涉及静态缓冲区的函数。中断只做最少的标记工作,具体处理交给任务。

局部大数组放在栈上是栈溢出的常见来源。uint8_t buf[1024] 在函数里一声明,这个函数就吃掉 1 KB 栈。改成 static 会省栈,但函数就不可重入、也不适合在中断里调用——取舍时想清楚。

636Views
32Likes
3Comments
3Participants
11 minread time

Comments (3)

  • 陈砚舟 · 2026-09-30 17:44

    代码示例能直接跑,这点加分。

    Reply
  • 许清欢 · 2026-10-01 02:08

    我们组正好在讨论这个,直接把链接甩群里了。

    Reply
  • 舒野 · 2026-10-01 11:17

    有没有考虑过小团队其实用不上这一套?

    Reply