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 和相当多公司的嵌入式编码规范限制或禁止动态分配,理由有三条:
- 不确定性:
malloc的执行时间依赖堆的当前状态,无法给出最坏执行时间上界,硬实时系统里不可接受。 - 碎片:如上,长期运行的风险无法在测试阶段暴露。
- 失败路径:每次分配都要处理失败,代码里会散落大量错误分支,而这些分支几乎从不执行,属于典型的未测试代码。
替代方案是按最坏情况静态分配。系统的行为在上电时就是确定的,不会出现”跑着跑着内存不够了”。
内存池:固定块分配
内存池的思路是:预先划分 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 会省栈,但函数就不可重入、也不适合在中断里调用——取舍时想清楚。
Comments (3)
代码示例能直接跑,这点加分。
我们组正好在讨论这个,直接把链接甩群里了。
有没有考虑过小团队其实用不上这一套?
Please Sign in to join the discussion.