
1. 从一次内存告警说起嵌入式内存管理到底难在哪做嵌入式开发这些年最让我睡不好觉的不是什么复杂算法也不是什么高深架构而是内存。PC上写代码内存不够加根条子就完事服务器上跑服务内存涨了调调JVM参数、扩个容也能扛过去。但在嵌入式设备上RAM就那么多焊死在板子上你没法加。程序跑着跑着崩了串口打印一个HardFault连个像样的堆栈都给你留不下那种感觉才叫绝望。“一堂嵌入式内存课”这个标题我第一眼看到就觉得亲切。因为嵌入式内存管理这件事表面上就是malloc和free两个函数的事但真正踩过坑的人都知道这里面藏着的东西太多了堆和栈怎么划分、内存碎片怎么产生的、RTOS下多任务怎么安全地共享内存、静态分配和动态分配怎么选、内存泄漏怎么查。这些问题每一个都够写一篇长文。这篇文章我想聊的就是围绕嵌入式系统里的内存管理把从裸机到RTOS、从malloc到内存池、从栈溢出到堆碎片的那些事系统地捋一遍。不管你是刚入行的嵌入式新手还是做了几年但一直没把内存这块吃透的老手我都尽量用大白话加上实际代码把这件事讲清楚。核心关键词就几个嵌入式、内存、malloc、free、RTOS。这几个词串起来基本就是嵌入式内存管理的全貌了。先说说为什么嵌入式内存管理跟PC上完全不是一回事。PC上你写个C程序操作系统给你虚拟内存每个进程有独立的地址空间你malloc失败了大不了返回NULL程序还能优雅退出。嵌入式呢很多场景跑的是裸机或者RTOS没有MMU内存管理单元你访问的地址就是物理地址指针飞了就是飞了直接硬件异常。而且嵌入式系统的RAM通常只有几十KB到几MB堆空间可能就几KBmalloc几次就没了。更麻烦的是嵌入式系统往往要求7x24小时不间断运行内存碎片积累到一定程度某个时刻malloc突然失败整个系统就挂了。所以这堂课的核心不是教你怎么调用malloc和free而是教你怎么在设计阶段就把内存这件事想清楚怎么在资源极度受限的环境下让内存用得稳、用得久、用得可预测。2. 嵌入式内存的基本盘栈、堆、静态区到底怎么分2.1 内存布局一张图看懂你的RAM去哪了嵌入式程序的内存布局跟PC上大同小异但因为资源少每一块都金贵。一个典型的嵌入式C程序RAM里主要分这么几块栈Stack存放局部变量、函数参数、返回地址。栈是从高地址往低地址生长的大小在启动文件或链接脚本里指定。栈溢出是嵌入式最常见的崩溃原因之一。堆Heapmalloc和free管理的那块区域。堆是从低地址往高地址生长的和栈相向而行。堆和栈之间的空闲区域就是它们争夺的空间。.bss段存放未初始化的全局变量和静态变量启动时会被清零。.data段存放已初始化的全局变量和静态变量值从Flash里拷贝过来。.rodata段存放常量、字符串字面量通常在Flash里不占RAM。在链接脚本linker script里这些区域的位置和大小都是明确指定的。比如一个STM32的链接脚本可能长这样MEMORY { FLASH (rx) : ORIGIN 0x08000000, LENGTH 512K RAM (xrw) : ORIGIN 0x20000000, LENGTH 128K } _estack ORIGIN(RAM) LENGTH(RAM); /* 栈顶 */栈顶通常设在RAM的最高地址栈往下长。堆的起始地址一般在.bss段之后_sbrk函数负责在堆上分配空间。如果你用的是newlib的malloc它底层就是通过_sbrk来扩展堆的。这里有个关键点栈和堆的大小不是自动调节的。你必须在链接脚本或者启动代码里给它们划定范围。栈给太小递归深一点就溢出堆给太小malloc几次就失败。但RAM总共就那么多给谁多了另一个就少。这个权衡是嵌入式内存设计的第一步。2.2 栈溢出最隐蔽的杀手栈溢出在嵌入式里太常见了而且往往表现为一些莫名其妙的现象程序跑飞、变量值被莫名修改、进入HardFault。因为栈溢出会覆盖相邻区域的数据可能是堆可能是全局变量破坏是随机的。怎么判断栈够不够两个办法。一是静态分析用编译器选项-fstack-usage让GCC输出每个函数的栈使用量然后手动累加调用链。二是运行时检测在栈的边界处填充特定的魔数比如0xDEADBEEF定期检查这个魔数有没有被改写。RTOS通常提供栈使用量的统计功能比如FreeRTOS的uxTaskGetStackHighWaterMark()能告诉你任务栈的历史最小剩余量。我个人的经验是中断服务函数ISR的栈使用要特别小心。很多RTOS里ISR用的是当前任务的栈如果ISR里调用了层层嵌套的函数很容易把任务栈打穿。所以ISR里尽量只做标记把耗时操作丢给任务去处理。2.3 堆的本质一块连续内存的管理艺术堆就是一块连续的内存区域malloc从里面切一块给你free把切出去的块还回来。听起来简单但问题在于切出去的块大小不一还回来的顺序也不固定时间一长堆里就会出现很多小的空闲块它们不连续无法满足大块分配请求。这就是内存碎片。举个例子。假设堆有100字节。你先malloc(30)再malloc(20)再malloc(40)此时堆里剩10字节。然后你free掉中间那个20字节的块。现在堆里有两个空闲块一个20字节中间一个10字节末尾。它们不连续。如果你现在想malloc(25)虽然总空闲有30字节但没有一个连续的25字节块分配失败。这就是嵌入式系统里malloc最要命的地方分配失败不是因为内存不够而是因为内存不连续。而且碎片问题是累积的系统跑得越久碎片越严重最终必然失败。3. malloc和free在嵌入式里的那些坑3.1 malloc的实现原理从sbrk到空闲链表标准库的malloc实现方式有很多种嵌入式里常见的是空闲链表法。每个内存块前面有一个头部header记录块的大小和是否空闲。malloc遍历空闲链表找到第一个足够大的块首次适应或者最合适的块最佳适应把它切分返回用户指针。free则把块标记为空闲并尝试与相邻的空闲块合并。这个头部是有开销的。在32位系统上一个头部通常8到16字节。如果你频繁malloc很小的块比如每次malloc(4)实际消耗可能是20字节开销是需求的5倍。这在RAM只有几十KB的嵌入式系统里是致命的。更麻烦的是标准库的malloc通常是线程不安全的。在RTOS下多个任务同时调用malloc空闲链表会被破坏导致分配出重叠的内存块程序行为完全不可预测。所以RTOS下要么用RTOS自带的内存管理比如FreeRTOS的pvPortMalloc要么给malloc加互斥锁。3.2 一个真实的踩坑案例字符串拼接引发的血案早些年我做过一个项目设备上跑FreeRTOS有个任务负责处理串口命令把收到的命令拼接成一个字符串再解析。代码大概是这样char *buf (char *)malloc(len * sizeof(char)); if (buf NULL) { return ERR_NO_MEM; } strcpy(buf, cmd); /* ... 处理 ... */ free(buf);看起来没问题对吧但这个任务每秒要处理几十条命令每条命令长度不一。跑了大半天之后设备开始随机死机。用调试器一看malloc返回了NULL但代码里没检查直接strcpy到NULL指针HardFault。为什么malloc会失败因为堆碎片了。命令长度从几个字节到几百字节不等频繁分配释放之后堆里全是小空洞没有连续的大块了。总空闲内存其实还有不少但就是分配不出来。这个问题的根本原因是在嵌入式系统里用变长分配本身就是个坏主意。后来我把方案改成了固定大小的环形缓冲区命令来了往缓冲区里写处理完标记为空完全不用malloc。问题彻底消失。3.3 free的陷阱野指针、重复释放、释放栈上内存free比malloc更容易出事。常见的错误有这么几种野指针释放指针指向的内存已经被释放了再次free。这会导致空闲链表被破坏后续malloc可能返回重叠的内存。重复释放同一个指针free两次。后果同上。释放非堆内存free一个指向栈上数组或全局变量的指针。这直接破坏堆的管理结构。释放后继续使用free之后指针没置NULL后续代码还在读写这块内存。这块内存可能已经被分配给别的变量了数据被悄悄改掉。在嵌入式里这些错误往往不会立刻崩溃而是埋下隐患过一段时间才爆发。所以我的习惯是free之后立刻把指针置NULL并且尽量用静态分析工具比如PC-lint、Coverity扫一遍代码。提示在资源紧张的嵌入式系统里能用静态分配就用静态分配能用内存池就用内存池把malloc和free的使用降到最低。这不是保守是血泪教训。4. RTOS下的内存管理多任务环境怎么保证安全4.1 任务栈每个任务都有自己的小天地RTOS下每个任务有独立的栈创建任务时指定栈大小。比如FreeRTOS的xTaskCreatexTaskCreate(task_func, Task1, 256, NULL, 2, task1_handle);这里的256是栈深度单位是字word在32位系统上就是1024字节。这个值给多少合适给少了栈溢出给多了浪费RAM。我的经验是先给一个偏大的值比如512字跑起来之后用uxTaskGetStackHighWaterMark()看历史最小剩余量再逐步调小。任务栈的分配方式有两种静态分配和动态分配。静态分配是在编译期确定数组动态分配是xTaskCreate内部调用pvPortMalloc。在安全关键的系统里我强烈建议用静态分配xTaskCreateStatic因为动态分配可能失败而任务创建失败在启动阶段是很难处理的。4.2 RTOS的堆管理FreeRTOS的heap_1到heap_5FreeRTOS提供了五种堆管理方案从heap_1到heap_5复杂度递增方案特点适用场景heap_1只分配不释放任务创建后不再删除heap_2可释放但不合并相邻空闲块分配大小固定的场景heap_3封装标准库malloc/free需要线程安全的mallocheap_4可释放且合并相邻空闲块通用场景最常用heap_5支持多块不连续内存区域内存分散在不同RAM区heap_4是最常用的它用首次适应算法支持碎片合并。但即便如此长期运行的系统仍然可能碎片化。所以FreeRTOS官方也建议尽量在系统启动阶段完成所有动态分配运行阶段不再malloc/free。4.3 任务间共享内存互斥锁和临界区多任务环境下如果两个任务都要访问同一块内存必须加保护。比如一个任务写一个任务读不加保护就可能读到半截数据。最简单的保护方式是临界区关中断但关中断时间不能太长否则影响系统实时性。更好的方式是互斥锁Mutex。FreeRTOS里用xSemaphoreCreateMutex()创建互斥锁访问共享内存前xSemaphoreTake()访问完xSemaphoreGive()。但互斥锁本身也有坑优先级反转。低优先级任务拿着锁高优先级任务等锁中优先级任务在跑导致高优先级任务被无限期阻塞。FreeRTOS的互斥锁支持优先级继承能缓解这个问题但不能完全消除。所以共享内存的访问时间要尽量短别在持锁期间做耗时操作。5. 内存优化实战从碎片到内存池5.1 内存池把变长分配变成定长分配内存池的核心思想是预先分配好一批固定大小的块分配和释放都在这些块之间进行。因为块大小固定不存在碎片问题因为块是预先分配的分配速度极快就是链表操作因为不涉及堆的合并拆分实现也简单。一个简单的内存池实现大概是这样#define POOL_BLOCK_SIZE 32 #define POOL_BLOCK_COUNT 64 typedef struct block { struct block *next; } block_t; static uint8_t pool_mem[POOL_BLOCK_SIZE * POOL_BLOCK_COUNT]; static block_t *free_list; void pool_init(void) { free_list NULL; for (int i POOL_BLOCK_COUNT - 1; i 0; i--) { block_t *blk (block_t *)(pool_mem i * POOL_BLOCK_SIZE); blk-next free_list; free_list blk; } } void *pool_alloc(void) { if (free_list NULL) return NULL; block_t *blk free_list; free_list free_list-next; return blk; } void pool_free(void *ptr) { if (ptr NULL) return; block_t *blk (block_t *)ptr; blk-next free_list; free_list blk; }这个实现有几个要点块大小要大于等于最大需求块数量要够用分配和释放必须配对。如果块大小有多种需求可以建多个池每个池负责一种大小。内存池的缺点是不够灵活块大小固定如果某个需求突然变大池就不够用了。但在嵌入式系统里需求通常是可预测的所以这个缺点可以接受。5.2 静态分配最稳的方案如果能在编译期确定所有内存需求那就完全不用动态分配。比如static uint8_t rx_buffer[256]; static uint8_t tx_buffer[256]; static command_t cmd_queue[16];这些数组在.bss段启动时清零运行期间地址固定不存在碎片和泄漏。缺点是RAM占用是固定的即使某个缓冲区暂时不用空间也被占着。但在RAM够用的前提下这是最稳的方案。我的原则是能用静态数组就用静态数组能用内存池就用内存池实在不行才用malloc。而且用malloc的地方一定要有失败处理不能假设它一定成功。5.3 内存泄漏排查从代码审查到运行时监控内存泄漏在嵌入式里特别难查因为设备可能跑几天才崩而且崩的时候现场信息很少。排查手段有这么几种代码审查把所有malloc和free配对检查看有没有分支漏了free。计数法在malloc和free里加计数器定期打印当前未释放的块数。如果只增不减就是泄漏了。钩子函数用malloc_hook或者自己封装my_malloc/my_free记录每次分配的地址和大小定期对比。运行时监控RTOS下可以创建一个低优先级任务定期打印堆的使用情况已用、空闲、最大连续空闲块。我常用的一个技巧是在malloc的头部记录分配时的文件名和行号用宏把__FILE__和__LINE__传进去泄漏的时候打印出来直接定位到代码行。6. 常见问题速查与避坑清单6.1 嵌入式内存问题速查表现象可能原因排查方向程序随机HardFault栈溢出、野指针检查栈大小、检查指针使用malloc返回NULL堆碎片、堆太小打印堆统计、改用内存池变量值被莫名修改栈溢出覆盖、缓冲区溢出检查数组越界、检查栈边界系统跑几天后死机内存泄漏、碎片累积加分配计数、定期打印堆状态任务切换后数据错乱共享内存未加锁检查互斥锁使用free时崩溃重复释放、释放非堆内存检查free配对、free后置NULL6.2 我的避坑清单栈大小宁大勿小先给足再用high water mark调小。栈溢出比浪费RAM可怕得多。ISR里别用mallocISR上下文不能阻塞malloc可能加锁会出问题。free后置NULL这是习惯能避免大部分重复释放和野指针问题。启动阶段完成动态分配运行阶段尽量不malloc/free减少碎片。给malloc加失败处理永远不要假设malloc一定成功。用内存池替代变长分配固定大小分配是嵌入式的最佳实践。定期监控堆状态早发现碎片和泄漏别等崩了再查。多任务共享内存必须加锁别省这个锁省出来的性能不够你调试的。6.3 一个容易被忽略的点内存对齐嵌入式系统里某些硬件比如DMA、浮点单元要求内存地址对齐。比如DMA通常要求4字节对齐浮点运算可能要求8字节对齐。如果你malloc返回的地址没对齐硬件访问就会出错。标准库的malloc通常返回对齐的地址8字节或16字节对齐但自己实现的内存池要注意这一点。块大小最好是4的倍数块起始地址也要对齐。在ARM Cortex-M上可以用__attribute__((aligned(8)))来强制对齐。7. 从裸机到RTOS不同阶段的内存管理策略7.1 裸机阶段简单直接但别乱来裸机程序没有任务概念内存管理相对简单。栈就是主栈堆就是那一块。但裸机下更容易出问题因为没有RTOS帮你隔离任务一个指针飞了就是全局崩溃。裸机下的建议尽量不用堆。所有缓冲区用静态数组所有数据结构在编译期确定。如果非要用堆就自己实现一个简单的内存池别用标准库的malloc。7.2 RTOS阶段隔离与保护RTOS下每个任务有独立栈这本身就是一种保护。但堆通常是全局的所有任务共享。所以RTOS下的堆管理要特别注意线程安全。FreeRTOS的heap_4是线程安全的通过挂起调度器实现但性能有开销。如果分配频繁可以考虑每个任务一个独立的内存池减少竞争。7.3 带MMU的嵌入式Linux另一套玩法如果跑的是嵌入式Linux那内存管理就是另一套体系了。有虚拟内存、有页表、有OOM Killer。malloc返回的是虚拟地址物理内存按需分配。这种情况下内存管理的重点变成了控制进程的内存占用、避免OOM、优化页缓存。但即便如此嵌入式Linux的RAM也有限malloc失败仍然可能发生。所以核心原则不变能静态就静态能池化就池化动态分配要有失败处理。8. 写在最后一些个人体会做了这么多年嵌入式我越来越觉得内存管理不是一个技术问题而是一个设计问题。你在设计阶段怎么规划内存决定了你后期调试有多痛苦。那些跑得稳的嵌入式系统往往不是用了什么高级的内存管理算法而是从一开始就把内存需求想清楚了该静态的静态该池化的池化把动态分配控制在最小范围。malloc和free不是不能用而是要知道什么时候用、怎么用、用完怎么查。RTOS提供了工具但工具不能替代设计。栈给多大、堆给多大、任务怎么划分、共享内存怎么保护这些决策在写第一行代码之前就应该想明白。最后分享一个小技巧在系统启动时打印一次内存布局包括栈顶、栈底、堆起始、堆结束、各段大小。这个信息在调试内存问题时非常有用能帮你快速判断是栈溢出还是堆溢出。很多RTOS和链接脚本都支持导出这些符号花十分钟配置一下能省你十个小时的调试时间。内存这件事说到底是嵌入式开发的基本功。基本功扎实了上层应用才能跑得稳。希望这篇内容能帮你把这块知识补全少踩几个我踩过的坑。