先把话说在前头:嵌入式开发里最磨人的问题,十有八九出在内存上。我见过凌晨三点还在排查栈溢出的老哥,也见过产品上线三天后因为内存踩踏随机重启的惨案。这堂嵌入式内存课,就是要把这些坑一个一个摊开讲清楚。它适合三类人:刚从单片机转 Linux 开发的工程师,在嵌入式开源项目里被内存问题反复折磨的维护者,以及准备嵌入式面试还差临门一脚的求职者。内容从 MCU 的栈和堆讲起,一路延伸到嵌入式 Linux 的虚拟内存、内核内存管理、内存池设计,最后落到内存泄漏、内存踩踏、栈溢出的排查实录。
1. 为什么嵌入式开发者必须先理解内存
1.1 从“程序能跑”到“程序长时间稳定跑”的差距
很多桌面端开发者刚转到嵌入式的时候,第一个不适应的点就是:代码跑起来不代表没问题,跑一个月不出问题才算大概率没问题。普通应用程序内存吃紧顶多卡顿,但嵌入式设备往往藏在电梯控制器、医疗监护仪、汽车网关这些不能随便宕机的地方,内存一旦出错,轻则复位重启,重则引发安全事故。这也是嵌入式面试里内存题目比重那么高的根本原因——内存直接决定了系统在恶劣环境下的生存能力。
更麻烦的是,嵌入式环境里几乎没有语言级的内存防护。C/C++ 不像 Java、Go 那样有 GC 兜底,也不像 Rust 那样有编译期所有权检查。数组越界不会立刻给你报错,野指针可能只是悄悄改掉一个无关变量的值,然后整个系统在现场跑了一两个月才暴露出来。到了这种时候,日志可能早就被覆盖了,复现又极其困难,只能靠对内存布局的深刻理解一步步推演。这就是为什么我坚持认为:嵌入式内存知识不是“进阶技能”,而是“保命技能”。
1.2 资源受限、实时性、长寿命运行三种压力
嵌入式内存问题的频发,根子在三个特殊约束上。
第一个是资源受限。很多 MCU 的 RAM 总量只有几十 KB,甚至几 KB,Flash 也就几百 KB。栈多大、堆多大、全局变量占多少,这些都必须提前算清楚。链接脚本里多配一点栈,堆就可能不够用;数组多声明一个,链接器直接报错。这种“一分钱掰成两半花”的处境,在 PC 开发里几乎遇不到。
第二个是实时性。嵌入式系统往往要在一个确定时间内完成响应,比如电机控制环路必须每 100 微秒跑完一次。这时候如果用了不确定时间的 malloc,一次分配可能因为内存碎片而遍历大量空闲块,时间抖动直接导致控制失败。实时操作系统里的很多问题,最后都归结为“某个操作的最坏执行时间没有被严格约束”。
第三个是长寿命运行。工业设备、智能家居、车载终端一通电就是几年不关机,内存泄漏和碎片会被时间无限放大。桌面程序内存泄漏可能一个月才占满,嵌入式设备内存泄漏可能三天就触发看门狗复位。我在实际项目里看到过太多“运行 70 小时后必挂”的经典故障,定位到最后基本都是某个模块在循环里申请内存却忘了释放。
1.3 从 MCU 到应用处理器:内存模型差异
嵌入式本身是个很大的谱系。裸机或 RTOS 下的 MCU,通常没有 MMU,CPU 直接访问物理地址,Flash 和 SRAM 都映射在地址空间的固定位置。你写的全局变量、任务栈、动态堆,全都挤在一块连续 RAM 里,一个越界就可能踩到内核数据。
到了带 Linux 的应用处理器,比如树莓派、各种 ARM SoC,MMU 引入了虚拟地址,每个进程都有自己的独立地址空间,进程之间的内存隔离有硬件保证。但代价是引入了页表、TLB、缺页中断、内核态与用户态切换等一系列新机制。很多工程师从 MCU 直接跳到嵌入式 Linux,还在用“裸机思维”处理内存问题,比如试图直接访问物理地址、随意用 malloc 不管碎片,这就是水土不服的根源。
这两种模型的差异直接决定了排查策略。MCU 上内存问题大多靠调试器、栈检查、MPU 防护来定位;Linux 上则要依赖 /proc、valgrind、内核日志等工具。这堂课后面会分别讲清楚。
2. 内存到底分在哪:链接脚本、栈、堆与静态区
2.1 一次编译链接后程序的内存轮廓
先回到最原始的 MCU 工程。一个 ARM Cortex-M 程序编译链接完成后,内存基本分成六大块:代码段(.text)、只读数据段(.rodata)、已初始化数据段(.data)、未初始化数据段(.bss)、堆(heap)和栈(stack)。前四块由链接脚本决定位置,后两块由启动文件里的 Stack_Size 和 Heap_Size 决定大小。
链接脚本(.lds 文件)通常长这样:
MEMORY { FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 512K RAM (xrw) : ORIGIN = 0x20000000, LENGTH = 128K } SECTIONS { .text : { *(.text*) *(.rodata*) } > FLASH .data : { *(.data*) } > RAM AT> FLASH .bss : { *(.bss*) } > RAM }这里的.data段比较特殊,初始值存放在 Flash 里,但运行时要复制到 RAM,所以用了AT> FLASH指定加载地址。.bss段不占 Flash 空间,但在启动时由 C 运行库统一清零。很多人忽略这两步的时间开销:如果初始化数据很大,设备可能在上电瞬间卡顿几十毫秒,这在某些对启动速度有要求的场景里是要命的问题。
我强烈建议每个嵌入式工程师养成一个习惯:每次编译完,运行一下arm-none-eabi-size或查看生成的.map文件,确认.data和.bss的大小是否在预期范围内。很多内存问题的源头,其实就是“某个工程师往全局数组里塞了一张大表”,而他自己完全没意识到这对 RAM 的冲击。
2.2 栈与堆:两个常被混淆的概念
栈(stack)和堆(heap)是嵌入式面试里最常被问,也最容易被混淆的两个概念。简单说:栈由编译器自动管理,用来保存函数调用时的局部变量、返回地址和寄存器现场;堆由程序员用 malloc/free(裸机或 RTOS)或内核分配器来管理,生命周期灵活,但必须手动释放。
在裸机工程里,栈大小由启动汇编文件里的Stack_Size定义,通常默认 0x00000400 甚至更小,也就是 1KB。很多初学者在函数里声明一个 2KB 的局部数组,直接就把栈压爆了。在 FreeRTOS 工程里,情况类似,每个任务创建时都要指定栈大小,比如xTaskCreate(..., 256, ...)中的 256 单位是字(Word),也就是 1KB。任务栈分配在哪?取决于configTOTAL_HEAP_SIZE配置的 FreeRTOS 堆,默认使用heap_4.c实现。
注意一个关键区别:FreeRTOS 说的“堆”是系统用来分配任务栈、队列、信号量等内核对象的“内核堆”,而你业务代码里调的 libcmalloc是另一套堆,两者并不互通。很多新手在这上面踩坑:以为configTOTAL_HEAP_SIZE配大了,业务模块就能随便 malloc,结果业务堆小得可怜,频繁分配失败。排查这类问题,第一件事就是确认你用的到底是哪个堆、多大、谁在消耗它。
2.3 malloc 不是免费的午餐
先给一个直接结论:嵌入式系统里,malloc 并不“免费”,它有三笔隐藏成本:不确定的分配时间、内存碎片、以及失败时缺乏可靠的恢复手段。
malloc 的底层实现一般是自由链表加首次适配或最佳适配算法。分配时它会遍历空闲块列表,找到一块足够大的内存,可能需要拆分;释放时,又要把相邻空闲块合并。这些操作的时间复杂度不是常数,而是跟空闲块数量和碎片程度相关。系统运行越久,碎片越严重,malloc 的耗时就越不可控。这在实时任务里是致命的。
碎片问题更隐蔽。假设你有一块 100 字节的堆,先分配 50 字节,再分配 30 字节,释放 50 字节,这时堆里有 50 字节的空闲块,但如果你要申请 40 字节,它可能无法使用这块不连续(在物理上其实是连续的,但被 30 字节分割成 35+15 这样的两块,无法合并到 40)的空闲区。连续内存碎片化后,明明总空闲内存充足,却分配不出一个中等大小的块。
所以我的经验是:在资源受限的 MCU 上,尽量只在初始化阶段使用 malloc,把堆当成一次性分配池;运行阶段需要动态对象时,用固定大小的内存池替换。如果你必须全程使用 malloc,那就提前做内存预算,统计最大并发对象数量,并加入分配失败日志和看门狗恢复机制。Linux 下的 malloc 虽然背后是 brk 和 mmap,逻辑上更强大,但碎片和不确定性带来的问题本质上依然存在。
3. 结构体、对齐与位域:内存优化第一战场
3.1 结构体对齐规则与隐形浪费
嵌入式工程师写结构体简直像吃饭一样日常,但很少有人真正计算过结构体在内存里到底占多少字节。这里有个隐藏的规则:编译器为了保证 CPU 访问效率,会让每个成员按其对齐值对齐,结构体总大小则对齐到最大成员对齐值的整数倍。
看一个典型例子:
typedef struct { uint8_t tag; // 偏移 0,1 字节 uint32_t value; // 对齐值 4,偏移 4,3 字节被填充 uint8_t ttl; // 偏移 8,1 字节 uint16_t len; // 对齐值 2,偏移 10,1 字节被填充 } msg_t; // 总大小 12(对齐到 4 的倍数)这个结构体里实际数据只有 1+4+1+2=8 字节,但编译器为了对齐,硬生生填充了 4 个字节。如果改成按成员大小降序排列:
typedef struct { uint32_t value; // 偏移 0 uint16_t len; // 偏移 4 uint8_t tag; // 偏移 6 uint8_t ttl; // 偏移 7 } msg_t; // 总大小 8同样逻辑,内存从 12 字节降到 8 字节,而且完全不需要 packed,访问效率依然最高。这个优化思路在嵌入式项目中非常实用,尤其是需要把大量消息结构体放进环形队列或 Flash 日志时,省 30% 存储是常有的事。
接着说说packed。__attribute__((packed))或#pragma pack(1)能让结构体完全按 1 字节对齐,彻底消除填充,比如上面例子变成真实的 8 字节。代价是:某些 ARM 平台上,非对齐访问会触发 HardFault,即使不触发,编译器通常也会生成“逐字节拼装”的代码,访问效率大幅下降。我的经验是:只有结构体需要直接映射到通信协议、Flash 存储布局或寄存器地址时才用 packed,并且每次访问字段时最好通过memcpy拷到本地对齐变量,再正常读取,避免非对齐访问的隐患。
3.2 位域:寄存器与协议头的利器与坑
除了结构体对齐,位域(bit-field)也是嵌入式内存优化的常客。你可能已经写过这样的代码:
typedef struct { uint8_t enable : 1; uint8_t mode : 2; uint8_t level : 4; uint8_t rsv : 1; } ctrl_reg_t;用位域可以非常优雅地把多个标志位挤在一个字节里,尤其适合操作硬件寄存器:寄存器通常按位定义,位域能让你直接按字段名读写,代码可读性提高一个档次。
但位域有三个坑必须记住。第一,位域的内存布局由编译器决定,不同编译器、不同字节序下字段的排布可能不一样,所以拿位域做通信协议很有风险。第二,位域的地址无法通过&获取,你不能把位域指针传给 DMA 或 memcpy,必须连同父结构体的地址一起操作。第三,在多线程或中断上下文里,对位域的“读-改-写”操作不是原子的,容易丢更新。我的建议很明确:对外通信协议里的位域,用显式的位移和掩码来写,别贪图省事;对自己板子上的寄存器,位域随便用,但访问要包一层临界区保护。
3.3 一个真实优化案例
说一个我之前在传感器采集节点上做过的优化。原始代码里定义了这样一个结构体:
typedef struct { uint8_t sensor_id; uint16_t event_type; uint32_t timestamp; uint8_t payload_len; uint8_t channel; uint16_t reserved; } sensor_event_t;当时没有做任何对齐计算,直接把字段按业务顺序排下去。用 sizeof 一量,猜猜多大?20 字节。但实际数据只有 1+2+4+1+1+2=11 字节,也就是有 9 字节是填充。这个节点要缓冲 500 条事件,总浪费 4.5KB,在只有 32KB RAM 的单片机上,这不是小数目。
我做的改动很朴素:把所有大成员放前面,小成员放后面,再调整一下顺序让每个字段都天然对齐,结果结构体变成 12 字节。如果规则允许,再把reserved去掉,甚至可以压到 8 字节。整体内存占用从 10KB 降到 4KB,代码逻辑一行没改,只是调整了成员声明顺序和初始化方式。这种优化方式成本极低、收益直观,比整天琢磨字符串压缩算法靠谱得多。
4. 嵌入式 Linux 内存管理:从虚拟地址到物理内存
4.1 虚拟内存与页表:每个进程的“独立王国”
带 MMU 的嵌入式 Linux 系统和裸机 MCU 有明显不同:每个进程看到的是一个连续的虚拟地址空间,比如 32 位系统上 0x00000000 到 0xFFFFFFFF,但实际上物理内存只有 256MB。中间靠页表(Page Table)和 TLB 做映射。缺页中断负责按需分配物理页,进程访问虚拟地址时如果页表项不存在,CPU 会触发缺页异常,内核再去补映射。
理解这个机制对嵌入式调优很重要。你看到进程 RSS(Resident Set Size)高涨,不一定是泄漏,也可能只是缺页次数变多。虚拟机里跑嵌入式系统、或者容器场景下,页表的开销也不容忽视。排查时先看这些文件:cat /proc/meminfo看整体内存,free -m看内存压力,top按 M 排序看进程真实占用。如果 RSS 持续上涨而 VIRT 不变,基本可以锁定某个模块在积累脏页。
4.2 内核态与用户态:物理内存分配的差异
Linux 把内存分为用户空间和内核空间。用户进程的 malloc 在底层走的是 brk 或 mmap,得到的是虚拟地址,真正的物理页要等访问时才分配。内核空间则复杂得多,有专门的内存分配器:物理连续内存用 buddy 系统和 slab 分配器管理,比如kmalloc分配物理连续内存,vmalloc分配虚拟地址连续但物理页可能不连续的内存。
在嵌入式场景里,要特别注意 32 位系统上的“低端内存/高端内存”划分。内核直接映射区(lowmem)大小有限,一次kmalloc失败不一定是因为物理内存不够,也可能是低端内存地址空间耗尽。此时需要用vmalloc或预留内存方案。这也就是为什么嵌入式 Linux 面试里经常出现“kmalloc 和 vmalloc 区别”“物理内存分配步骤”这类题目——他们考察的其实是你能不能理解两层映射关系。
顺便提一下 Java/Android 嵌入式开发里常见的“堆外内存”概念。JVM 的堆有上限,默认就看-Xmx;但ByteBuffer.allocateDirect()分配的堆外内存不走 JVM 堆,而是直接向操作系统申请。很多嵌入式 Android 设备内存不够,不是 Java 堆满了,而是 native 层堆外内存爆了。排查时不要只看 Java Heap,还要看 RSS 和 Native Heap。
4.3 CMA、内存预留与大内存架构
高算力嵌入式设备上,视频、图像、神经网络推理这些模块往往需要大块物理连续内存,比如摄像头 ISP 需要 4MB 连续缓冲,GPU 纹理、DMA 传输也需要连续内存。通用 buddy 系统在碎片化后没法稳定提供大块连续内存,于是内核引入了 CMA(Contiguous Memory Allocator)机制。
CMA 的思路很巧妙:平时这块区域可以像普通内存一样被分配,但当你真正需要连续大内存时,内核会先回收这块区域里已有的可移动页,再腾出连续的物理块。设备树里常见这样的配置:
reserved-memory { #address-cells = <1>; #size-cells = <1>; ranges; multimedia_reserved: multimedia@10000000 { compatible = "shared-dma-pool"; reg = <0x10000000 0x8000000>; no-map; }; };这里预留了 128MB 给多媒体模块做共享 DMA 池。配置 CMA 或预留内存时,核心要权衡的是:预留太多会挤压系统常规内存,导致 App 可用内存不足;预留太少,又会在高分辨率处理时分配失败。我的做法是先用压力测试确定峰值需求,再留 20% 余量,把剩余内存让给通用分配器。
5. 内存问题排查实录:泄漏、踩踏、栈溢出
5.1 内存泄漏定位:从现象到根因
嵌入式 Linux 上定位内存泄漏,我通常按“三看一测”来走。先看趋势:用free -m或cat /proc/meminfo定时打点,观察 MemFree、Slab、PageTables 有没有持续下滑;再看进程:top或cat /proc/pid/status里的 VmRSS 是否单调上升;然后看内核:/proc/slabinfo和vmstat可以分开用户态与内核态泄漏的范围。
一测是什么意思?就是在设备上挂 valgrind:
valgrind --leak-check=full --show-leak-kinds=all ./your_appvalgrind 会告诉你每一块泄漏内存的分配堆栈,这是最直接的根因证据。如果 valgrind 跑不动(比如嵌入式板子上太慢),退而求其次的做法是:在你的业务代码里给各个模块加分配计数,定期打印“已分配块数、释放块数、差值”。我实际排查过一个网络设备,RSS 每两小时涨 4MB,最后发现是 TCP 连接释放时,某个收包缓冲队列没有清理——每个连接 4KB,连接一多就慢慢漏。这种问题,靠日志统计比靠肉眼盯内存地址靠谱一百倍。
5.2 内存踩踏定位:canary、MPU 与现场表演
内存踩踏(memory corruption)比泄漏更阴险。它通常表现为某个变量莫名被改写、系统随机 HardFault、或者运行几天后行为异常。踩踏的常见来源:数组越界写、指针写错、memset 长度写错、DMA 缓冲区长度不匹配。
我在调试时最常用的手段是“金丝雀法”。在可疑区域前后填充固定模式,比如 0xA5A5A5A5,周期检查这些填充值是否被改写。一旦发现被改,立即查看是哪个地址附近的内容最先发生变化,配合调试器内存断点,一般能快速锁定越界写入的代码。栈保护也有类似思路:在任务栈末尾写几个字节的标记值,定期检查标记是否完好,被破坏说明栈溢出了。
更进一步,使用 MPU(Memory Protection Unit)做硬件防护。Cortex-M 的 MPU 可以把任务栈末尾区域设置成“禁止访问”,一旦代码试图越过边界,直接触发 MemManage Fault。这个手段比软件检查要快得多,因为它在越界的瞬间就打断现场,调试器能抓住最原始的执行栈。
5.3 栈溢出检测:三种征兆与一个可靠习惯
栈溢出是最常见的嵌入式崩溃原因。它的征兆通常有三类:第一类,系统运行一段时间后随机复位,尤其在高层级函数调用较深或局部变量较大时发生;第二类,函数返回地址被破坏,硬件栈指针指向非法区域,调试器里 PC 值一片乱码;第三类,某些全局变量无端改变,因为栈向下增长,溢出的数据覆盖了栈下方的静态区或堆区。
FreeRTOS 自带一个很好用的接口:uxTaskGetStackHighWaterMark(NULL),它能返回任务栈剩余的最小空间,每次任务切换后都会更新。我在所有任务里周期性调用一次,把结果打进日志,观察哪个任务的高水位线最低。如果某个任务空闲时和满负荷运行时高水位相差巨大,那这个任务的栈大小就是重新评估的对象。
另一个好习惯是定期给栈“浇水”。任务创建后,先把整个栈填充成 0xA5,运行一段时间后统计还剩多少 0xA5 没被覆盖,这就是实际栈用量。很多嵌入式调试工具就是这么实现栈占用可视化的。这个习惯成本极低,但对“程序为什么跑着跑着挂了”这类问题,能节省几天的排查时间。
5.4 常见问题速查表
最后整理一份我在项目里反复用到的问题排查速查表:
| 现象 | 可能原因 | 排查手段 |
|---|---|---|
| 系统定时重启,运行时长固定 | 内存泄漏导致分配失败,看门狗复位 | 监控 MemFree/RSS,找到持续增长模块 |
| 局部变量或返回值被改 | 数组越界写、栈溢出 | 金丝雀检查 + MPU 保护 |
| HardFault,PC 指向非法地址 | 返回地址被踩踏、野指针跳转 | 查看 LR/PC 调用栈,开启内存断点 |
| 分配内存后访问变慢 | 堆碎片严重,malloc 遍历变长 | 统计最大耗时,改用固定内存池 |
| 编译时提示 region RAM overflow | 全局数组或堆栈配置过大 | 查看 .map 文件定位大对象,削减配置 |
| Linux 下内存持续下降但进程 RSS 不高 | 内核态泄漏、Slab 异常 | 查 /proc/slabinfo、内核模块引用计数 |
这张表不能解决所有问题,但它能帮你把“感觉是内存问题”转化成“具体是哪种内存问题”,这是排查的第一步,也是最重要的一步。
6. 内存池与分配器:解决内存问题的最终方案
6.1 为什么很多嵌入式项目直接禁掉 malloc
不少嵌入式团队的项目规范里明令禁止运行期间使用 malloc/free,原因我在前面已经反复强调过:不确定时间、碎片、失败处理困难。尤其是在中断服务函数里调用 malloc,如果分配器本身不保证可重入,行为更不可控。
替换方案的核心思路是“固定块内存池”:预先分配一块内存,按固定大小切成多个块,分配时直接从空闲链表中摘一块,释放时再挂回链表。这种方案有两个致命优点:分配和释放都是 O(1) 操作,时间完全确定;由于块大小固定,不会产生外部碎片,最多有固定比例的内部碎片(比如块大小 64,你申请 48,浪费 16 字节,但这是可预算的)。
代价也很明显:如果你同时需要 16 字节和 512 字节的对象,只用一个池子会造成大量浪费。所以一般做法是按对象大小分几个池子,比如 16/64/256/1024 四档,这就是 GNU malloc 里 tcache、Linux slab 分配器的简化版本。要不要往这个方向走,取决于你的对象是否种类多且尺寸跨度大。
6.2 一个轻量级固定块内存池实现
给你一个我在裸机 RTOS 工程里常用的简化实现。它按 64 字节一档管理固定块,支持初始化、分配、释放和统计:
#define POOL_BLOCK_SIZE 64 #define POOL_BLOCK_COUNT 16 typedef struct pool_block { struct pool_block *next; } pool_block_t; static pool_block_t *free_list; static uint8_t pool_mem[POOL_BLOCK_SIZE * POOL_BLOCK_COUNT]; static uint32_t alloc_count; void pool_init(void) { free_list = NULL; for (int i = POOL_BLOCK_COUNT - 1; i >= 0; i--) { pool_block_t *blk = (pool_block_t *)&pool_mem[i * POOL_BLOCK_SIZE]; blk->next = free_list; free_list = blk; } alloc_count = 0; } void *pool_alloc(void) { pool_block_t *blk; if (!free_list) return NULL; blk = free_list; free_list = blk->next; alloc_count++; return (void *)blk; } void pool_free(void *ptr) { pool_block_t *blk = (pool_block_t *)ptr; blk->next = free_list; free_list = blk; alloc_count--; }注意,这个实现没有做对齐保证:pool_mem是uint8_t数组,如果地址本身未对齐到 4 字节,把块当作uint32_t或结构体指针使用时会有风险。稳妥做法是把pool_mem声明成uint32_t pool_mem[...]或使用__attribute__((aligned(4)))。另外,如果在多任务环境使用,要给分配和释放加临界区保护,最简单的是在入口关中断、出口恢复,代码量很小。
6.3 从池到定制分配器的设计要点
如果固定块池已经满足需求,就别上更复杂的方案。但有些场景必须定制分配器,比如需要支持不同大小的临时缓冲,或者需要从特定内存区域(如 DMA 内存、共享内存)分配。这时候可以借鉴两个思路。
第一是 slab 分级。把常用大小分成几档,每档一个池子,分配时就近满足;如果池子空,再从后备区域补。这样既保留了固定块池的确定性,又能应对尺寸变化。第二是预留紧急内存。系统初始化时单独留出一小块内存,平时不参与分配,只在分配失败时启用,专门用于故障日志和恢复例程。这样即使系统崩溃,也能把现场信息写进 Flash,而不是死机到完全无法诊断。
我在做嵌入式 AI 测试设备时还发现,模型的推理阶段经常突发申请大块内存,如果直接用 malloc,推理延迟会明显抖动。后来我把模型需要的中间张量缓冲全部在初始化阶段分配好,推理期间零动态内存,帧率立刻稳定下来。这个经验说明一个统一原则:内存方案的核心目标不是“省内存”,而是“让内存行为可预测、可预算、可诊断”。
最后分享一点我的个人习惯
这堂嵌入式内存课讲到最后,分享一个我在实际工作中坚持多年的习惯:每个新工程项目开工前,先画一张内存预算表,列出代码段、数据段、BSS、堆、栈、每个模块的动态对象数量上限,然后贴在工位旁边。内存问题最好在写代码前就解决,而不是等它变成故障后再去排查。
另一个技巧是给系统做“内存水位日志”,每个小时记录一次空闲内存、任务栈高水位、内存池分配率,长期留存。排查现场问题时,往前翻日志往往一眼就能看出异常拐点,这比死磕代码、反复抓现场高效得多。嵌入式系统的内存问题大多是慢性病,用数据监控去养,比等它急性发作再去抢救,要省心太多。