首页 / 资讯中心 / 文章详情

为什么安全关键系统禁用动态内存?嵌入式开发必读

为什么安全关键系统禁用动态内存?嵌入式开发必读 ★ FEATURED ARTICLE
1. 从一行代码说起为什么嵌入式圈子里“禁用malloc”是条铁律刚入行那会儿我在一家做工业控制器的公司写固件。有次代码评审我在一个周期性任务里随手写了句buf malloc(len)结果被组长当场打回原话是“你这行代码放在消费电子里顶多是内存泄漏放在我们这种设备上就是现场炸机。”当时我不太服气觉得他小题大做。直到后来自己接手了一个跑在实时操作系统上的项目亲眼看到因为一次free之后没有置空指针导致设备在客户现场连续重启了三天我才真正理解那句话的分量。“给导弹写代码为什么严禁使用动态内存分配”这个标题听起来有点夸张但它精准地戳中了安全关键系统里最核心的一条编码禁忌。这里的“导弹”只是一个符号它代表的是所有一旦出错就无法挽回、无法现场调试、无法重启了事的场景飞行控制系统、医疗呼吸机、汽车刹车控制单元、工业机器人关节控制器、航天器姿态控制模块。这些系统的共同点是——它们对确定性的要求远远高于对开发便利性的要求。动态内存分配也就是我们常说的malloc、free、new、delete这一套东西在通用软件开发里是家常便饭。你写个Web服务、做个桌面应用不用动态内存反而奇怪。但到了安全关键领域它就成了头号公敌。原因不是它“不好用”而是它不可预测。而不可预测在那些场景里就是致命的。这篇文章我想把这件事彻底讲透。不管你是刚接触嵌入式的学生还是已经写了几年业务代码想转行做底层开发的工程师或者只是单纯好奇“为什么航天软件看起来那么保守”的技术爱好者我都会从原理、实操、替代方案、踩坑经验几个维度把动态内存在安全关键系统里的问题掰开揉碎讲清楚。你看完之后至少能明白三件事为什么禁用、禁用了之后怎么写、以及那些“看起来能用”的替代方案到底靠不靠谱。2. 动态内存分配到底“危险”在哪里2.1 先搞清楚malloc和free在底层干了什么很多人用了好几年malloc但对它背后发生的事情其实没什么概念。我们平时写p malloc(100)感觉就是“给我100个字节”。但这100个字节从哪来、怎么来、要花多久完全取决于内存分配器的实现。在典型的Linux或者桌面系统上malloc背后是glibc的ptmalloc2在Windows上是HeapAlloc那一套。这些分配器为了兼顾性能和内存利用率做了大量复杂的事情维护空闲链表、做内存合并、按不同大小分类管理、必要时向操作系统申请新的页。你调用一次malloc它可能只是从空闲链表里摘一个块出来耗时几十纳秒也可能触发一次brk或mmap系统调用耗时几微秒甚至更久。关键就在这里同样是malloc耗时可能差几百倍。在通用软件里这无所谓反正用户感知不到。但在一个要求“每1毫秒必须完成一次控制循环”的系统里某一次循环因为malloc触发了系统调用而多花了50微秒就可能直接导致控制输出延迟进而引发振荡甚至失控。更麻烦的是内存碎片。假设你有一块1MB的堆空间你先分配了A256KB、B256KB、C256KB然后释放了B。现在空闲空间总共是256KBB那块加上剩下的256KB但它们是不连续的。这时候你想分配一个300KB的D就会失败——尽管总空闲内存有512KB。这就是外部碎片。在长时间运行的系统里碎片会越来越严重最终导致明明“还有内存”却分配失败。2.2 确定性安全关键系统的生命线安全关键系统最看重的指标叫最坏情况执行时间英文是 Worst-Case Execution Time简称 WCET。注意不是平均时间不是典型时间是最坏情况。因为你要证明这个系统在任何情况下都能在截止时间前完成任务就必须知道每个操作的最坏耗时。malloc的 WCET 是多少这个问题几乎没法回答。它取决于当前堆的状态、碎片程度、分配器实现、操作系统调度、甚至CPU缓存命中情况。你没法给出一个可靠的数字。而一个for循环遍历固定长度数组的 WCET你可以精确算出来循环次数乘以每次迭代的指令周期数再考虑流水线和缓存的最坏影响能给出一个保守上界。这就是核心矛盾安全关键系统需要可证明的时间上界而动态内存分配给不出这个上界。我举个具体的例子。假设你写的是无人机飞控控制频率是400Hz也就是每2.5毫秒要跑一次姿态解算和控制输出。你的代码里如果有一处malloc那么在最坏情况下这次malloc可能因为碎片整理或者系统调用花掉1毫秒。这1毫秒里飞控没有输出新的控制量电机还在执行上一次的指令。如果这时候无人机正在做快速机动1毫秒的延迟就足以让姿态偏差扩大到危险范围。2.3 内存泄漏和悬空指针在导弹上就是灾难除了时间不确定动态内存还有两个经典问题内存泄漏和悬空指针。内存泄漏在Web服务里大不了重启一下进程。在导弹上你没法重启。导弹飞出去之后代码要连续运行几分钟到几十分钟期间如果每次控制循环泄漏几个字节累积起来可能耗尽整个堆导致后续分配全部失败。而分配失败之后你的代码如果没做好错误处理可能就是空指针解引用直接崩溃。悬空指针更隐蔽。你free(p)之后p 指向的内存被回收了但 p 这个指针变量本身还保留着原来的地址。如果后面不小心又通过 p 写了数据你写进去的可能是另一个变量的内存也可能是分配器的元数据。这种bug在测试环境里可能几个月都不出现一到现场就随机崩溃而且崩溃位置和真正的错误位置往往相隔十万八千里排查起来极其痛苦。我亲身经历过一次一个同事在中断服务程序里free了一块内存然后在主循环里又访问了它。因为中断和主循环的时序关系这个bug只在特定负载下才触发。我们花了整整两周用逻辑分析仪抓时序、用内存保护单元设断点才定位到问题。如果这是导弹代码这两周就是不可能有的奢侈。2.4 分配失败一个你无法优雅处理的错误在通用软件里malloc返回NULL你还可以打印日志、返回错误码、让用户重试。在安全关键系统里分配失败意味着什么意味着你的控制律算不下去了你的通信协议组包组不了了你的传感器融合做不了了。你没有“重试”的选项因为下一个控制周期马上就到你没有时间等内存变得可用。有人会说“那我预留足够大的堆保证不会耗尽不就行了”问题是你没法保证。碎片、对齐要求、分配器开销都会让你的“足够大”变得不确定。而且安全关键系统的认证标准比如航空领域的DO-178C、汽车领域的ISO 26262要求你对所有可能的失败模式都有处理方案。对于动态内存分配失败你很难给出一个既安全又合理的处理方案——因为任何处理方案本身都需要时间和资源而这两样东西在故障时刻恰恰是最紧缺的。3. 禁用动态内存之后代码到底该怎么写3.1 静态分配把“不确定”变成“确定”禁用了malloc最直接的替代方案就是静态分配。所谓静态分配就是在编译期就确定所有内存的位置和大小。全局变量、静态变量、栈上的局部变量都属于这个范畴。比如你要一个能存256个浮点数的缓冲区不要写float *buf malloc(256 * sizeof(float))而是写static float buf[256]。这样编译器在编译期就把这块内存安排好了运行时没有任何分配开销地址固定大小固定WCET 完全可预测。静态分配的好处是显而易见的没有碎片、没有泄漏、没有分配失败、时间确定。但代价是灵活性下降。你必须在设计阶段就想清楚每个缓冲区最大需要多大。如果实际数据超了你没有“扩容”的选项只能丢弃或者截断。这就引出一个重要的设计原则在安全关键系统里内存是设计出来的不是运行时申请出来的。你需要在需求分析阶段就估算出每个模块的内存需求然后做预算分配。这听起来很麻烦但正是这种“麻烦”换来了确定性。3.2 内存池固定大小块的预分配方案静态分配虽然确定但有时候你确实需要“动态”地使用内存——比如一个通信协议栈同时可能有多个连接每个连接需要一块缓冲区连接来了要分配连接走了要释放。这时候完全静态分配就不够灵活了。内存池是这种情况下最常用的方案。它的思路很简单在启动时一次性分配一大块内存然后把它切成固定大小的块。需要内存时从池子里取一块用完了还回去。整个过程不涉及系统调用不涉及碎片整理时间开销是常数。#define POOL_BLOCK_SIZE 128 #define POOL_BLOCK_COUNT 64 static uint8_t pool_memory[POOL_BLOCK_SIZE * POOL_BLOCK_COUNT]; static uint8_t pool_used[POOL_BLOCK_COUNT]; void *pool_alloc(void) { for (int i 0; i POOL_BLOCK_COUNT; i) { if (!pool_used[i]) { pool_used[i] 1; return pool_memory[i * POOL_BLOCK_SIZE]; } } return NULL; /* 池子耗尽 */ } void pool_free(void *p) { if (p NULL) return; int index ((uint8_t *)p - pool_memory) / POOL_BLOCK_SIZE; if (index 0 index POOL_BLOCK_COUNT) { pool_used[index] 0; } }这段代码的 WCET 是可以精确计算的pool_alloc最坏情况遍历64个块每次迭代几条指令总共几百个时钟周期完全可控。而且没有碎片问题因为所有块大小相同。内存池的局限是块大小固定。如果你需要不同大小的内存就得维护多个池子或者设计一个能处理变长请求的池子——但后者又会引入碎片问题。所以实际项目中通常是先分析出有哪几种典型的内存需求然后为每种需求建一个池子。3.3 栈分配被低估的确定性方案很多人一提到“不用malloc怎么动态用内存”第一反应是静态数组。但其实栈分配在很多场景下是更好的选择。函数内的局部数组、alloca谨慎使用、变长数组C99的VLA但很多安全标准禁用都属于栈分配。栈分配的优势是分配和释放就是移动栈指针一条指令的事WCET 极短且确定。而且栈内存是自动回收的函数返回就释放不存在泄漏。但栈分配有几个坑。第一栈大小有限。嵌入式系统的栈通常只有几KB到几十KB你放一个大数组就可能栈溢出。第二栈溢出后果严重。栈溢出可能覆盖其他任务的栈或者全局变量导致的行为完全不可预测。第三递归深度不可控。如果你的代码里有递归最坏递归深度决定了栈的最坏使用量而这个深度有时候很难静态分析。所以在安全关键系统里栈分配可以用但必须配合栈使用分析。你需要用工具比如stack-usage编译选项、静态分析工具算出每个函数的最大栈帧然后沿着调用图算出最坏调用链的栈使用总量确保不超过栈空间。这个分析过程本身就是安全认证的一部分。3.4 编译期确定把运行时决策提前还有一种思路是把运行时的分配决策提前到编译期。比如你有一个模块最多同时处理4个传感器那就直接定义4个传感器结构体的数组而不是运行时malloc。如果实际传感器数量少于4个多出来的槽位就空着浪费一点内存但换来了确定性。这种“浪费”在安全关键系统里是完全可以接受的。因为内存通常不是最紧缺的资源——CPU时间和确定性才是。用一点内存浪费换来确定性这笔买卖很划算。我做过一个项目通信模块最多支持8个并发连接。每个连接需要大约2KB的缓冲区。8个连接就是16KB。实际运行时大多数时候只有2到3个连接活跃剩下5到6个连接的缓冲区是闲置的。但正是这种“闲置”保证了任何时刻新连接到来时都能立即拿到缓冲区不需要等待、不需要分配、不会失败。4. 那些“看起来能用”的替代方案到底靠不靠谱4.1 自定义分配器能解决问题但引入新问题有人会说“标准malloc不可预测那我自己写一个分配器不就行了”确实你可以写一个简单的首次适应或者最佳适应分配器代码完全可控WCET 可以分析。很多嵌入式项目也确实这么做。但自定义分配器并没有消除碎片问题。只要你的分配和释放模式不是完全规则的碎片就会累积。你可能需要额外的碎片整理机制而碎片整理本身又需要时间又引入了不确定性。而且自定义分配器的正确性需要自己保证——标准库的malloc经过了几十年的测试和验证你自己写的分配器有同样的测试覆盖吗在安全认证里每一行代码都要有对应的测试用例和需求追溯自己写分配器的认证成本非常高。所以我的经验是除非你的分配模式极其简单且固定否则不要自己写通用分配器。用内存池或者干脆静态分配。4.2 垃圾回收在实时系统里基本不可行Java、C#这些语言有垃圾回收程序员不用手动free。那安全关键系统能不能用带GC的语言理论上可以但实践中极少。原因是GC的停顿时间不可控。即使是增量GC或者并发GC最坏情况下的停顿时间也可能达到几十毫秒甚至更长。对于控制周期是毫秒级的系统这是不可接受的。而且GC需要额外的内存和CPU开销在资源受限的嵌入式环境里往往不现实。航空领域的DO-178C认证对GC的支持也非常有限。你要证明GC在任何情况下都不会导致违反时间约束这个证明难度极大。所以实际的安全关键系统几乎清一色用C或者C的一个受限子集手动管理内存。4.3 RAII和智能指针C里的折中方案如果你用CRAII资源获取即初始化和智能指针可以在一定程度上缓解内存管理的问题。std::unique_ptr和std::shared_ptr能自动释放内存减少泄漏的风险。但注意智能指针底层还是new和delete还是动态内存分配。它解决的是“忘记释放”的问题没有解决“分配时间不确定”和“碎片”的问题。所以在安全关键系统里智能指针可以用但通常要配合自定义分配器——比如让unique_ptr从一个内存池里分配而不是从全局堆里分配。另外shared_ptr的引用计数操作在多线程环境下需要原子操作这会引入额外的开销和不确定性。所以在实时系统里shared_ptr要慎用unique_ptr相对安全一些。4.4 静态分析工具帮你发现漏网之鱼即使你规定了“禁用动态内存”团队里总有人可能不小心写了malloc。这时候静态分析工具就派上用场了。像Coverity、Polyspace、PC-lint这些工具可以扫描代码标记出所有动态内存分配的使用。在安全认证流程里这通常是强制步骤。你需要证明你的代码里没有任何动态内存分配而工具报告就是证据之一。有些项目还会在编译选项里做手脚比如重定义malloc为一个链接错误的符号这样一旦有人用了malloc链接阶段就会失败根本编不过去。/* 在安全关键项目里常见的做法禁用malloc */ void *malloc(size_t size) { (void)size; /* 触发链接错误或断言失败 */ extern void dynamic_allocation_is_forbidden(void); dynamic_allocation_is_forbidden(); return NULL; }这种做法虽然粗暴但非常有效。它把“规范”变成了“强制”不依赖人的自觉。5. 实操现场一个飞控项目的内存改造记录5.1 改造前的代码到处是malloc我接手过一个飞控项目原来的代码是在Linux上开发的用了大量动态内存。姿态解算模块每次迭代都malloc一个矩阵通信模块每次收到包都malloc一个缓冲区日志模块每次记录都malloc一个字符串。在桌面测试环境里跑得好好的一放到目标板子上就各种问题偶尔卡顿、偶尔崩溃、长时间运行后内存耗尽。5.2 改造过程逐模块替换改造的第一步是盘点。我们用静态分析工具扫了一遍找出所有malloc、calloc、realloc、free的调用点总共37处。然后逐个分析这个分配的大小范围是多少生命周期多长调用频率多高姿态解算模块的矩阵分配大小是固定的比如4x4浮点矩阵生命周期是单次迭代。这种直接改成栈上的局部数组就行。float mat[4][4]简单直接零开销。通信模块的缓冲区大小有上限比如最大包长1024字节生命周期是从收到包到处理完。这种用内存池。建一个1024字节块的内存池池子大小根据最大并发连接数确定。日志模块的字符串这个比较麻烦因为日志长度不固定。我们的方案是改成固定大小的环形缓冲区每条日志最多256字节超出部分截断。环形缓冲区在启动时静态分配写入时覆盖最旧的数据。这样既保证了确定性又不会因为日志写满而阻塞。5.3 改造后的效果从“偶尔崩溃”到“连续运行30天”改造完成后我们在目标板子上做了长时间稳定性测试。连续运行30天控制周期抖动从原来的“偶尔超过1毫秒”降到“稳定在50微秒以内”。内存使用量从“随时间缓慢增长”变成“启动后恒定”。最直观的变化是以前客户现场每隔几天就要重启一次设备改造后再也没出现过因为内存问题导致的重启。这个项目让我深刻体会到动态内存分配的问题不是“会不会出错”而是“什么时候出错”。在测试环境里可能跑一周都没事一到现场就出问题。而静态分配和内存池把这种“随机炸弹”变成了“确定行为”这才是安全关键系统真正需要的。6. 常见问题与排查技巧实录6.1 常见问题速查表问题现象可能原因排查思路解决方案系统运行一段时间后卡顿堆碎片导致malloc耗时增加用内存分析工具监控堆使用改用内存池或静态分配随机崩溃位置不固定悬空指针或堆溢出用MPU或ASan检测内存访问禁用动态内存改用静态分配分配失败但总内存充足外部碎片打印堆的空闲块分布统一块大小用内存池中断里调用malloc导致死锁分配器内部有锁检查中断上下文中的调用中断里禁止任何分配长时间运行后内存耗尽内存泄漏用Valgrind或类似工具检测确保每次malloc都有对应free或改用静态分配多线程环境下随机崩溃分配器线程安全问题检查是否用了线程安全的分配器用线程安全分配器或每线程独立内存池6.2 几个容易踩的坑第一个坑以为“只分配一次”就安全。有人觉得我在初始化的时候malloc一次后面就不分配了这样总安全了吧不一定。如果这次malloc失败了你的初始化就失败了系统可能根本起不来。而且如果这块内存后来被free了又会有悬空指针的风险。所以即使是“只分配一次”也要慎重。第二个坑在中断服务程序里分配内存。中断上下文对时间极其敏感而且很多分配器在中断里调用会死锁因为分配器内部可能用了锁而中断可能打断持锁的代码。所以中断里绝对禁止任何形式的动态分配包括内存池——除非你的内存池是无锁的并且你能证明它的WCET满足中断延迟要求。第三个坑忽略对齐要求。有些硬件比如DMA控制器要求缓冲区按特定边界对齐。malloc返回的地址通常满足基本对齐但不一定满足你的特殊要求。静态分配的话你可以用alignas或者编译器属性来指定对齐。内存池的话你需要确保每个块都按最大对齐要求对齐。第四个坑忘了初始化。malloc返回的内存是未初始化的里面可能是任何数据。静态分配的内存如果是全局变量会被初始化为0如果是栈上的也是未初始化的。所以不管用哪种方式都要记得显式初始化。我见过一个bug就是因为忘了初始化一个静态数组里面残留了上次运行的数据导致控制输出异常。6.3 独家避坑技巧技巧一用链接器脚本控制内存布局。在嵌入式项目里你可以通过链接器脚本精确控制每个段放在哪个内存区域。比如把堆完全去掉把所有静态数据放在内部RAM把大缓冲区放在外部RAM。这样你不仅能禁用动态内存还能优化内存访问速度。技巧二给每个模块做内存预算。在设计阶段就给每个模块分配一个内存上限然后在代码里用静态断言检查。比如_Static_assert(sizeof(module_buf) MODULE_BUF_MAX, module buffer too large)。这样一旦有人不小心把缓冲区改大了编译就会失败。技巧三用内存保护单元兜底。即使你禁用了动态内存代码里还是可能有数组越界。MPU可以给每个内存区域设置访问权限一旦越界就触发异常。这比等到数据被破坏后再排查要高效得多。技巧四定期做堆使用分析。如果你确实没法完全禁用动态内存比如用了某个第三方库那至少要定期做堆使用分析。记录每次分配的大小、位置、时间画出堆使用曲线。如果发现堆使用量随时间增长那就是泄漏如果发现分配耗时波动很大那就是碎片问题。7. 我的个人体会确定性是一种设计哲学写了这么多年安全关键系统的代码我越来越觉得“禁用动态内存”不仅仅是一条编码规范它背后是一种设计哲学把不确定性从系统里赶出去。动态内存分配的本质是“运行时决策”——运行时才知道要分配多少、能不能分配到、要花多久。而安全关键系统要求“设计时决策”——在设计阶段就把所有可能的情况考虑到把所有资源都安排好运行时只做确定的事情。这种哲学不仅适用于内存也适用于其他方面不用递归因为递归深度不确定、不用异常因为异常处理时间不确定、不用动态绑定因为调用目标不确定。所有这些“禁用”都是为了同一个目标让系统的行为可以被分析、被预测、被证明。当然这套哲学不是万能的。在通用软件领域动态内存带来的灵活性远远 outweigh 了它的不确定性。你写个Web应用用malloc天经地义。但如果你写的是要飞上天、要植入人体、要控制高速运动物体的代码那就得换一套思路。我个人的经验是先问清楚你的系统属于哪一类。如果出错后果是“用户刷新一下页面”那动态内存随便用。如果出错后果是“设备损坏”或者“人员伤亡”那就老老实实静态分配。中间地带的情况比如工业控制、车载娱乐可以根据具体的安全等级要求来权衡。最后分享一个我常用的判断方法假设你的代码会在最糟糕的时刻遇到最糟糕的情况——内存最碎片的时候、CPU负载最高的时候、中断最频繁的时候。如果你的代码在这种情况下还能保证正确性和时间约束那就可以用动态内存。如果不能那就别用。这个判断方法虽然保守但在安全关键领域保守从来不是坏事。这个内容后续还可以这样扩展如果你对内存池的具体实现感兴趣可以研究一下TLSFTwo-Level Segregated Fit分配器它在实时系统里用得很多WCET 可以做到常数级。或者如果你关注汽车领域可以看看AUTOSAR标准里对内存管理的规范它定义了一套Mem_Alloc接口本质上就是受控的内存池。这些方向都值得深入。
阅读完成 · 觉得有帮助?
咨询建站