1. 为什么“内存”是嵌入式开发的分水岭
搞嵌入式的人,早晚都会撞上内存这堵墙。你在PC上写程序,malloc失败了顶多返回个NULL,系统该跑还是跑;但在一个RAM只有几十KB、甚至几KB的MCU上,一次内存分配失败,可能就是设备死机、看门狗复位、现场返修。更麻烦的是,这类问题往往不是必现的——它可能连续跑三天没事,第四天凌晨两点在客户现场炸掉,而你连复现都复现不出来。
“一堂嵌入式内存课”这个标题,听起来像是一门课程,但在我看来,它更像是一个分水岭:跨过去的人,能从“能跑就行”的代码水平,进入“知道为什么能跑、什么时候会跑不动”的工程水平;跨不过去的人,会一直停留在“加个延时试试”“重启一下就好了”的阶段。这篇文章不打算写成教科书,而是把我这些年踩过的坑、调过的板子、看过的崩溃现场,按“为什么这么设计”的逻辑重新梳理一遍。核心关键词就几个:嵌入式内存、malloc、free、RTOS、内存分配器、内存泄露。适合谁看?适合已经能点亮LED、跑通串口,但一遇到内存问题就抓瞎的嵌入式开发者;也适合从PC端转过来、习惯了new和delete、对“内存要自己管”这件事还没建立起肌肉记忆的人。
先说一个最容易被忽略的事实:嵌入式系统里的内存,从来不是“一块连续的大空间”。它至少分成几个物理上独立、访问速度差异巨大的区域——片内Flash、片内SRAM、片外SDRAM、外扩PSRAM,有些芯片还有TCM、CCM、DTCM这些特殊RAM。你在链接脚本里写一句RAM,背后可能是好几段地址不连续的存储介质拼起来的。不理解这一点,后面所有关于malloc的讨论都是空中楼阁。
2. 内存布局:链接脚本才是真正的“内存地图”
2.1 从编译产物看内存分区
很多人写STM32或者ESP32,打开IDE新建工程,直接就开始写main函数,从来没看过.map文件。但内存问题的第一手线索,全在.map和链接脚本里。一个典型的嵌入式可执行文件,至少包含这几个段:
.text:代码段,通常放在Flash里,只读。.rodata:常量、字符串字面量,也在Flash。.data:已初始化的全局变量和静态变量,运行时在RAM,但初始值存在Flash,启动时由启动代码拷贝过去。.bss:未初始化或初始化为0的全局/静态变量,运行时在RAM,启动时清零。heap:malloc用的堆区,通常紧挨着.bss往上长。stack:栈区,通常从RAM顶端往下长。
这里有个关键点:.data段的大小直接吃掉RAM,而且它的初始值还占Flash。我见过一个项目,定义了一个const uint8_t logo[65536],本意是放Flash,结果忘了加const,编译器把它扔进了.data,启动时要从Flash拷贝64KB到RAM,而那颗芯片总共才128KB RAM,启动直接HardFault。这种问题,看.map文件一眼就能定位。
2.2 链接脚本里的堆栈设置
以GNU LD链接脚本为例,堆和栈通常是这样定义的:
_estack = ORIGIN(RAM) + LENGTH(RAM); /* 栈顶 */ _Min_Heap_Size = 0x200; /* 最小堆 512 字节 */ _Min_Stack_Size = 0x400; /* 最小栈 1KB */ .heap : { . = ALIGN(8); _sheap = .; . = . + _Min_Heap_Size; . = ALIGN(8); _eheap = .; } > RAM .stack : { . = ALIGN(8); _sstack = .; . = . + _Min_Stack_Size; . = ALIGN(8); _estack = .; } > RAM这段脚本决定了malloc能用的堆有多大、栈有多大。_Min_Heap_Size不是“堆的实际大小”,而是“链接器保证至少留这么多”。实际堆的大小取决于.bss结束到栈底之间还剩多少空间。如果你在.bss里放了一个大数组,堆就被挤小了,malloc可能在运行时才失败。
提示:每次改完全局变量定义,尤其是大数组,务必重新看
.map文件里_sheap和_eheap的地址差。这个差值就是你能用的堆上限。
2.3 栈溢出:嵌入式最隐蔽的杀手
栈溢出比堆溢出更可怕,因为它不会返回NULL,而是直接踩坏相邻内存。栈从RAM顶端往下长,堆从.bss往上长,两者相向而行。如果栈用得太多,先踩到堆,再踩到.bss里的全局变量。症状可能是:某个全局变量莫名其妙变了值、某个函数返回地址被改、程序跳飞到奇怪的地方。
怎么估算栈深度?静态分析靠不住,因为中断嵌套、递归、printf里的浮点格式化都会吃栈。我的做法是:在栈顶附近填一个魔术字(比如0xDEADBEEF),跑一段时间后检查这个字有没有被覆盖,覆盖到哪一层,就知道栈实际用了多少。FreeRTOS里可以用uxTaskGetStackHighWaterMark(),裸机就得自己填。
3. malloc和free在嵌入式里到底能不能用
3.1 标准malloc的三大罪状
在PC上,malloc背后是glibc的ptmalloc,它要处理多线程、要合并空闲块、要用brk和mmap向内核要内存。这套东西搬到嵌入式上,问题就来了:
第一,代码体积大。ptmalloc编译出来几十KB,而很多MCU的Flash总共才64KB。第二,不确定性。malloc的执行时间取决于堆的碎片状态,最坏情况可能是毫秒级,这对实时性要求高的中断服务程序是灾难。第三,碎片化。反复malloc和free不同大小的块,堆里会留下大量无法利用的小空洞,最后明明总空闲内存够,但就是分配不出一个连续的大块。
我做过一个测试:在一块128KB RAM的板子上,交替分配64字节和256字节的块,各分配1000次再全部释放,重复几轮后,最大可分配块从最初的100KB掉到了不到8KB。这就是碎片化的威力。
3.2 什么时候可以用malloc
不是说嵌入式里绝对不能用malloc,而是要用对场景。我的经验是:
- 启动阶段一次性分配:系统初始化时把所有需要的内存都
malloc好,之后再也不free。这样没有碎片问题,因为分配模式是线性的。 - 固定大小的对象池:如果需要动态创建销毁对象,用内存池而不是通用
malloc。比如创建100个固定大小的任务控制块,预先分配好,用的时候从池里取,用完还回去。 - 非实时路径:如果某个功能对时间不敏感,比如处理一条配置命令,偶尔用一次
malloc问题不大。
反过来,中断服务程序里绝对不要malloc,高频循环里不要反复malloc/free,内存紧张的芯片上不要用标准malloc。
3.3 自己写一个极简分配器
如果非要用动态分配,又嫌标准库太重,可以自己写一个。最简单的方案是“首次适应+不合并”的分配器:
typedef struct block_header { size_t size; /* 块大小,含头部 */ uint8_t is_free; /* 是否空闲 */ struct block_header *next; } block_header_t; static uint8_t heap_pool[HEAP_SIZE] __attribute__((aligned(8))); static block_header_t *free_list = NULL; void heap_init(void) { free_list = (block_header_t *)heap_pool; free_list->size = HEAP_SIZE; free_list->is_free = 1; free_list->next = NULL; } void *my_malloc(size_t size) { size = (size + 7) & ~7; /* 8 字节对齐 */ block_header_t *cur = free_list; while (cur) { if (cur->is_free && cur->size >= size + sizeof(block_header_t)) { /* 切分块 */ if (cur->size > size + sizeof(block_header_t) + 16) { block_header_t *new_block = (block_header_t *)((uint8_t *)cur + sizeof(block_header_t) + size); new_block->size = cur->size - size - sizeof(block_header_t); new_block->is_free = 1; new_block->next = cur->next; cur->next = new_block; cur->size = size + sizeof(block_header_t); } cur->is_free = 0; return (uint8_t *)cur + sizeof(block_header_t); } cur = cur->next; } return NULL; /* 分配失败 */ }这个分配器只有几十行,代码体积小,执行时间可预测(最坏情况是遍历整个空闲链表)。缺点是不合并相邻空闲块,碎片化比标准库更严重。但对于“启动时分配、运行时不释放”的场景,完全够用。
注意:
my_malloc返回的指针要8字节对齐,因为Cortex-M的double和某些DMA操作要求8字节对齐。上面的(size + 7) & ~7就是干这个的。
4. RTOS环境下的内存管理:FreeRTOS的heap_1到heap_5
4.1 五种堆方案的取舍
FreeRTOS提供了五种堆管理方案,从heap_1到heap_5,复杂度递增,适用场景完全不同。很多人用FreeRTOS,直接选默认的heap_4,但其实应该根据项目需求来选。
| 方案 | 是否支持free | 是否合并 | 是否支持多区域 | 适用场景 |
|---|---|---|---|---|
| heap_1 | 否 | 否 | 否 | 只创建不删除的任务,最简单 |
| heap_2 | 是 | 否 | 否 | 固定大小分配,已废弃 |
| heap_3 | 是 | 是 | 否 | 包装标准malloc,需要线程安全 |
| heap_4 | 是 | 是 | 否 | 通用场景,最常用 |
| heap_5 | 是 | 是 | 是 | 内存分布在多个不连续区域 |
heap_1最简单,就是一个大数组加一个指针,pvPortMalloc就是把指针往前挪,vPortFree是空函数。代码体积最小,执行时间恒定,适合“任务创建后永不删除”的系统。很多量产产品其实用heap_1就够了,因为任务在启动时全创建好,运行中不动态增删。
heap_4用空闲链表管理,支持合并相邻空闲块,是通用性最好的方案。但它的pvPortMalloc最坏情况要遍历整个空闲链表,时间不确定。如果系统对实时性要求极高,要么用heap_1,要么把configTOTAL_HEAP_SIZE设得足够大,减少碎片。
heap_5允许把堆分散到多个不连续的RAM区域,比如片内SRAM和片外SDRAM各划一块。初始化时要调用vPortDefineHeapRegions()指定每个区域的起始地址和大小。
4.2 任务栈和堆是两回事
新手最容易混淆的一点:FreeRTOS里创建任务时传的栈大小,和pvPortMalloc用的堆,是两套独立的内存。任务栈是从FreeRTOS的堆里分配的(如果用xTaskCreate),或者由用户提供静态数组(如果用xTaskCreateStatic)。任务栈溢出不会影响堆,但会踩坏相邻的任务栈或TCB。
xTaskCreate的usStackDepth参数单位是“字”,不是字节。在32位MCU上,传128表示512字节。我见过有人传128以为就是128字节,结果任务栈不够,一调用printf就崩。
/* 正确:128 字 = 512 字节(32 位 MCU) */ xTaskCreate(task_func, "task", 128, NULL, 1, NULL); /* 错误:以为 128 是字节,实际只有 32 字,肯定不够 */4.3 用uxTaskGetStackHighWaterMark监控栈使用
FreeRTOS提供了一个很有用的API:uxTaskGetStackHighWaterMark()。它返回任务运行过程中栈剩余的最小值(以字为单位)。这个值越接近0,说明栈越紧张。我的习惯是:在调试阶段,每个任务跑一遍完整业务流程后,打印所有任务的高水位线,然后按“高水位线×2”来设置最终栈大小。
void monitor_task(void *pv) { while (1) { vTaskDelay(pdMS_TO_TICKS(5000)); TaskStatus_t tasks[10]; UBaseType_t n = uxTaskGetSystemState(tasks, 10, NULL); for (UBaseType_t i = 0; i < n; i++) { UBaseType_t hw = uxTaskGetStackHighWaterMark(tasks[i].xHandle); printf("%s: stack free = %u words\n", tasks[i].pcTaskName, hw); } } }提示:
uxTaskGetStackHighWaterMark会遍历栈空间找魔术字,执行时间较长,不要在实时任务里频繁调用。调试阶段用用就行,量产固件里可以关掉。
5. 内存泄露:嵌入式里的“慢性病”
5.1 泄露是怎么发生的
内存泄露在PC上可能跑几天才显出来,在嵌入式上可能几小时就崩。因为嵌入式总内存小,泄露速率哪怕只有几字节每秒,几小时后也会耗尽。常见的泄露场景:
malloc了但忘记free,尤其是在错误处理分支里。free了但指针没置NULL,后面又误用或重复free。- 用
realloc扩展内存,但没保存返回的新指针,旧指针丢了。 - RTOS里创建了队列、信号量、任务,删除时只删了任务没删队列。
我调过一个最隐蔽的泄露:一个函数在每次收到串口命令时malloc一个缓冲区,正常路径会free,但有一个if分支直接return了,忘了free。这个分支只在特定命令下触发,测试时没覆盖到,现场跑了三天才崩。
5.2 用钩子函数追踪分配
如果用的是标准malloc,可以用__wrap_malloc和__wrap_free(GCC的-Wl,--wrap=malloc)来包装,记录每次分配的大小和调用地址:
void *__wrap_malloc(size_t size) { void *p = __real_malloc(size); if (p) { record_alloc(p, size, __builtin_return_address(0)); } return p; } void __wrap_free(void *p) { if (p) { record_free(p); } __real_free(p); }record_alloc把指针、大小、返回地址存到一个静态表里,record_free从表里删除。系统跑一段时间后,打印表里剩余的条目,就知道哪些内存没释放、是谁分配的。这个方法不需要额外硬件,只需要在链接选项里加--wrap。
FreeRTOS的heap_4也支持追踪:定义configUSE_MALLOC_FAILED_HOOK和configUSE_HEAP_TRACE,可以在分配失败时回调,或者记录每次分配。
5.3 内存泄露速查表
| 症状 | 可能原因 | 排查方法 |
|---|---|---|
| 运行几小时后死机 | 缓慢泄露 | 定期打印剩余堆大小,看是否单调下降 |
| 分配失败但总空闲够 | 碎片化 | 打印最大可分配块,看是否远小于总空闲 |
| 某任务栈高水位线持续下降 | 栈泄露或递归 | 检查任务内是否有未释放的局部大数组 |
free后程序跑飞 | 重复free或野指针 | 用钩子记录free的指针,检查是否已释放 |
| 中断里malloc后死机 | 中断上下文不安全 | 禁止在ISR里调用malloc |
6. 实战:一个内存受限项目的完整调优过程
6.1 项目背景和初始状态
前年做过一个智能家居面板项目,主控是某国产MCU,128KB Flash、48KB RAM,跑FreeRTOS,带一块单色LCD、一个触摸按键、一个WiFi模组。初始固件编译出来,.bss占了28KB,堆只剩12KB,栈设了4KB。功能跑起来后,WiFi配网时偶尔死机,日志显示pvPortMalloc返回NULL。
6.2 第一步:看map文件找大块
打开.map,按大小排序,发现几个问题:
- 一个
uint8_t lcd_buffer[10240]定义成了全局非const,占了10KB.bss。实际上这个缓冲区只在刷新LCD时用,改成局部变量或者用static加复用。 - 一个日志缓冲区
char log_buf[4096],只在调试时用,量产固件里应该关掉。 - WiFi驱动里有一个
uint8_t wifi_rx_buf[8192],但实际最大包长只有1500字节,改成2048。
这三项改完,.bss从28KB降到14KB,堆从12KB涨到26KB。
6.3 第二步:把heap_4换成heap_1
这个项目的任务是启动时全部创建好,运行中不删除。所以heap_4的free和合并功能根本用不上,反而增加了代码体积和执行时间。换成heap_1后,pvPortMalloc变成简单的指针加法,执行时间恒定,代码体积也小了几KB。
6.4 第三步:任务栈按高水位线重新分配
用uxTaskGetStackHighWaterMark跑了一遍完整业务流程,发现:
- WiFi任务栈高水位线只剩8字(32字节),太危险,从512字加到1024字。
- LCD任务栈高水位线有200字,从512字减到256字。
- 主任务栈高水位线有150字,从512字减到256字。
调整后,总栈占用从2KB降到1.5KB,省下的给堆。
6.5 第四步:用内存池替代动态分配
WiFi驱动里原来每次收包都pvPortMalloc一个缓冲区,处理完vPortFree。改成预分配4个固定大小的缓冲区组成池,收包时从池里取,处理完还回去。这样彻底消除了WiFi路径的碎片化风险,而且分配时间恒定。
#define POOL_SIZE 4 #define BUF_SIZE 2048 static uint8_t pool[POOL_SIZE][BUF_SIZE]; static uint8_t pool_used[POOL_SIZE] = {0}; uint8_t *pool_alloc(void) { for (int i = 0; i < POOL_SIZE; i++) { if (!pool_used[i]) { pool_used[i] = 1; return pool[i]; } } return NULL; } void pool_free(uint8_t *p) { for (int i = 0; i < POOL_SIZE; i++) { if (p == pool[i]) { pool_used[i] = 0; return; } } }6.6 调优结果
最终固件:.bss14KB,堆26KB,栈1.5KB,Flash占用从118KB降到96KB。连续跑72小时压力测试,堆剩余量稳定在20KB以上,没有再出现分配失败。这个项目让我深刻体会到:嵌入式内存优化,八成靠“少用”,两成靠“用好”。先把不必要的内存省掉,再考虑怎么高效管理剩下的。
7. 那些年我踩过的内存坑
7.1 结构体对齐的隐形浪费
C语言的结构体默认按成员最大对齐数对齐。一个看似只有5字节的结构体,可能实际占8字节甚至12字节。比如:
struct bad { uint8_t a; /* 1 字节 */ uint32_t b; /* 4 字节,前面补 3 字节 */ uint8_t c; /* 1 字节,后面补 3 字节 */ }; /* 实际占 12 字节 */改成按大小降序排列:
struct good { uint32_t b; /* 4 字节 */ uint8_t a; /* 1 字节 */ uint8_t c; /* 1 字节,后面补 2 字节 */ }; /* 实际占 8 字节 */如果结构体数组很大,这个差别很可观。1000个bad占12KB,1000个good占8KB,省了4KB。在48KB RAM的芯片上,4KB是10%的RAM。
注意:用
#pragma pack(1)可以取消对齐,但会导致非对齐访问,在Cortex-M0上可能触发HardFault,在M3/M4上有性能损失。除非协议要求,否则不要轻易pack。
7.2 printf的浮点支持吃掉大量栈
printf格式化浮点数时,内部会用到较大的栈空间(可能几百字节)。如果在一个栈只有256字(1KB)的任务里调用printf("%f", x),很可能栈溢出。解决方案:要么不用浮点打印,要么给任务加大栈,要么用整数打印(比如把浮点乘以1000变成整数打印)。
7.3 DMA缓冲区的对齐和位置
DMA对内存有特殊要求:有些DMA控制器要求缓冲区地址对齐到4字节或8字节,有些要求缓冲区不能跨Cache行。如果malloc返回的地址不满足对齐要求,DMA传输会出错或效率降低。所以DMA缓冲区最好用静态数组加__attribute__((aligned(32))),不要用malloc。
7.4 栈上的大数组
在任务函数里定义一个大数组,比如uint8_t buf[2048],这个数组是从任务栈里分配的。如果任务栈只有1KB,直接溢出。这种问题编译器不会报错,运行时才崩。我的习惯是:超过128字节的局部数组,一律改成static或全局,或者从堆/池里分配。
8. 给不同阶段开发者的内存建议
如果你刚开始学嵌入式,先把链接脚本和.map文件看懂,知道你的变量放在哪里、堆栈各有多大。然后养成习惯:每定义一个全局大数组,就问自己“真的需要全局吗?能不能局部?能不能复用?”
如果你已经能跑RTOS,重点理解任务栈和堆的区别,学会用uxTaskGetStackHighWaterMark,学会选heap_1到heap_5。不要默认用heap_4,根据项目需求选最合适的。
如果你在做量产项目,把内存监控做成常态。可以在固件里留一个命令,打印当前堆剩余、各任务栈高水位线、最大可分配块。现场出问题时,让客户敲一下命令,日志发回来,比猜快得多。
内存这件事,说到底就是“知道自己有多少、用了多少、还剩多少”。听起来简单,但真正做到的人不多。我见过太多项目,代码写得漂亮,架构设计得优雅,最后死在内存上。嵌入式开发没有银弹,内存管理就是那道必须自己跨过去的坎。跨过去之后,你会发现,那些曾经让你半夜爬起来改bug的问题,其实都有迹可循。