ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

嵌入式安全关键系统为何禁用动态内存分配?从运行原理到工程替代方案

嵌入式安全关键系统为何禁用动态内存分配?从运行原理到工程替代方案 我记得很清楚一次内部代码评审会上有位刚转来做嵌入式开发的同事提交了一段代码里面在初始化流程里用malloc给一组校准系数分配了缓冲区。当时我直接问了一句这段代码如果要跑在飞行器控制软件里第一轮评审就过不了。他觉得挺冤枉malloc而已桌面程序里天天写能出什么事这个问题这些年我被问过太多次了。动态内存分配在通用软件开发里是基本功但在导弹、运载火箭、无人机飞控、汽车安全系统这类场景里却是公认的高压线。很多人只是听说过不能用动态内存但说不清楚为什么。单纯背结论转过几个项目就忘了遇到新场景还是不知道该不该坚持。这篇文章我从运行环境、时间确定性、内存碎片、失败路径、行业规范到替代方案把这件事掰开揉碎讲清楚希望对正在做或准备做安全关键实时系统的朋友有参考价值。1. 导弹软件的运行逻辑没有重来机会的硬实时世界1.1 桌面软件可以再试一次飞行器没有第二次我们平时写桌面软件、后端服务潜意识里有一套容错逻辑程序崩溃了可以重启服务挂了可以切流量数据坏了可以从备份恢复。这套逻辑在互联网行业被验证了很多年异常处理、故障恢复、灰度发布都是围绕允许出错、快速恢复设计的。但飞行器控制软件完全是另一套逻辑。导弹从发射那一刻起就是一个不可逆的物理过程。控制回路是闭环的传感器采集数据控制律计算输出舵机执行指令每一拍都是实时反馈。如果某个环节因为内存问题导致计算延迟、数据错乱系统不会给你弹出错误提示的机会物理世界直接给出结论。更关键的是飞行器没有任何重启选项——重启意味着任务失败而任务失败在物理上不可挽回。这就引出一条最基本的设计原则安全关键系统的软件行为必须是确定性的。所谓确定性是指给定同样的输入和硬件状态软件必须在可预期的时间和资源范围内产生正确结果。动态内存分配恰恰在最核心的两个维度上破坏确定性时间和内存布局。1.2 硬实时任务截止时间是物理约束不是性能指标再展开一点实时系统的概念。实时系统分软实时和硬实时软实时系统错过一次截止时间比如音视频播放用户会感觉卡顿但不会造成严重后果硬实时系统错过一次截止时间后果就是灾难性的。导弹的姿态控制回路、制导解算、导航数据处理都是典型的硬实时任务。以姿态控制为例控制周期通常在几毫秒到十几毫秒级别每个周期内必须完成传感器采样、姿态解算、控制指令生成、输出到执行机构这一整套流程。任务调度器在运行前就要确认每个任务的最坏情况执行时间是多少、所有任务的优先级和周期怎么排才能保证任何时刻都不会超过截止时间。如果某个任务内部调用了malloc它这条路径的最坏执行时间就无法给出精确上界。那调度的可证明性就出了问题。后面我会详细展开这一点。1.3 资源处处受限还要可见、可预算飞行器电子设备对体积、重量、功耗都有苛刻限制处理器主频和内存容量都不宽裕。更重要的是开发团队在任务设计阶段就要完成资源预算每个任务占多少栈、多少堆、多少全局缓冲区都要一笔一笔算清楚。评审时最常问的一句话是如果所有分支都走极端内存占用峰值是多少用了动态内存分配这个问题就回答不了。不是不好回答而是无法证明准确。你不知道malloc内部空闲链表的状态不知道碎片率不知道某个请求在最坏情况下会拿到多大的块。整个系统的内存占用变成一个黑盒评审和认证就没有办法闭环。2. malloc 的时间账不确定的堆操作对调度意味着什么2.1 一次 malloc 在堆管理器内部到底做了什么要说清楚时间不确定得先明白malloc干了什么。绝大多数RTOS和嵌入式C库里的堆管理器维护一个空闲块链表。malloc(n)的大致流程是遍历空闲链表找到一块足够大的空闲块按请求大小把块切开切割后剩余部分挂回空闲链表然后在块头部写入元数据最后把可用地址返回。free(p)也不简单它要根据元数据找到块的位置把块重新挂回空闲链表还要检查相邻块是否也是空闲的如果是就合并成一个大块。不同分配器实现有差异有的用首次适配first-fit有的用最佳适配best-fit有的用分离空闲链表。但无论哪种单次操作的时间都不是一个固定值。每一次malloc的耗时取决于当前堆的空闲状态空闲块多不多、分布散不散、搜索要来回扫多少节点。这些状态是运行时的函数完全不可预知。2.2 三条可变性来源查找、合并、锁竞争把这条再做细一点。动态内存分配的时间抖动主要来自三个地方第一空闲块查找。首次适配策略下如果堆头部恰好有合适块分配很快如果空闲块散落在堆的各个角落分配器可能要扫一大段链表。最坏情况是扫完整张链表才找到块或者找不到、触发堆扩展。这个时间相差几十倍很正常。第二释放时的合并操作。释放一个块可能要往左合并一次、往右合并一次合并后的大块还要重新组织链表结构。如果恰好释放的块周围全是空闲块合并的时间会比孤零零释放一个块长很多。第三多线程或RTOS环境下的锁竞争。几乎所有的堆管理器在多任务环境下都要加锁保护空闲链表。一个任务在free时持有堆锁另一个任务正在等待malloc进入临界区。如果后者的优先级更高但在等锁就出现优先级反转碰到中断上下文里调用堆操作情况会更糟这个后面单独说。2.3 实时调度要的是 WCET不是平均值实时系统领域有个概念叫WCET即最坏情况执行时间。任务调度时要评估可调度性判据基本都围绕WCET展开高优先级任务的WCET总和是否小于周期、实时任务能否在截止时间之前完成等等。这里有个容易踩的认知误区很多人觉得最快慢平均也就几十微秒没什么关系。但实时系统不看平均值看的是最坏情况。你不能说大概率没问题你要证明任何时候都没问题。malloc的WCET恰恰无法给出可靠上界。如果按保守方式给malloc预留极宽的余量CPU利用率会低到没法用如果按乐观方式预留窄门槛任务超时就是必然超高优先级任务抢占时一瞬间的延迟就可能导致控制律计算晚了一个周期。2.4 中断上下文是真正的坟场看过太多新手写的驱动代码为了省事直接在中断服务程序ISR里调用malloc分配缓冲区。这是所有动态内存滥用里后果最直接、最容易触发死锁的一类。原因在于中断可以打断任意任务。假设低优先级任务正持着堆锁在free此时一个ISR触发ISR里调用malloc它也要尝试获取同一把堆锁但持锁的任务被打断在中间状态锁永远释放不了——死锁。在硬实时系统里死锁意味着整个系统停摆看门狗只能替你重启而对飞行器来说被重启就等于任务失败。我自己的经验是凡是要在中断上下文里取用的缓冲区必须在任务上下文提前准备好或者用预分配的内存池。中断路径在任何情况下都不允许碰堆。这条规矩没什么可商量的余地。3. 内存碎片会随运行时间恶化的慢性病3.1 碎片化是怎么一步步发生的时间不确定性是动态内存最容易被察觉的问题但内存碎片才是更隐蔽的长期杀手。飞行器软件里有大量周期性申请、释放模式传感器数据缓冲、遥测帧打包、计算结果中间缓存。这些请求的块大小并不统一有几百字节的小包也有几KB的大缓冲区。当不同大小的块交替分配、部分释放、再分配时堆的空闲空间会被切碎。原本一大块连续空闲内存被划分为一堆彼此不相邻的小空洞每一个单独看都太小满足不了后续的大块请求。这就是外部碎片。更麻烦的是堆管理器每分配一个块都要留额外的元数据空间通常8到16字节小对象频繁分配时元数据开销占比会很高。这种损耗是隐藏的从上层看是内存还有不少但真正可用的连续空间急剧缩水。3.2 一个简化的碎片算例举个简化算例。假设堆大小16KB任务A依次分配了4个1KB块1个4KB块4个1KB块。此时堆分配得整整齐齐。然后任务A释放了第2和第4个1KB块堆上有两个不相邻的1KB空洞。接着任务B申请一个2KB块——如果两个1KB空洞恰好不相邻这个2KB请求直接失败。虽然剩余总量还有2KB但没有任何一个连续区域能装下2KB。如果把这一套循环跑上几百次、几千次空洞会被切得更加支离破碎。某个大块请求失败是必然结果只是时间早晚的问题。碎片率高的堆可用内存的实际水位和表观水位差距会越拉越大。3.3 虚拟内存和垃圾回收为什么救不了有人会问桌面系统里也有碎片问题不是有虚拟内存分页、有垃圾回收整理内存吗飞行器上为什么不套用原因有两层。第一层很多实时处理器根本不带MMU物理地址直接裸露在程序面前没有页表可以重映射。即便带MMU分页粒度是固定的比如4KB它解决的是逻辑地址到物理地址的映射但对于DMA这类要求物理连续内存的场景分页解决不了连续性问题——DMA设备要的是物理地址上连续的缓冲区。第二层垃圾回收机制的Stop-The-World暂停对硬实时系统是另一种灾难。JVM、Go的GC再优化也没法向调度器承诺这里最多停顿多少微秒。整天研究低延迟GC的人应该能理解硬实时系统对停顿的容忍度为零。3.4 概率性故障最考验人品碎片问题最讨厌的地方在于它是概率性的。同一个软件、同一份二进制可能这次任务全程没事下次任务因为内存分配序列略有变化就在某个临界点触发失败。它不像数组越界那样能当场崩溃也不像死锁那样复现率高而是在特定的分配/释放交错序列下才出现平时测试几乎跑不出来。而飞行器软件恰恰是一旦出故障就要承担最大代价的领域。工程上对这类问题的处理逻辑不是出了问题再修而是从设计上就让这种问题不可能出现。把动态内存从运行阶段彻底删除碎片、堆损坏、分配失败这些概率性问题就从根源上被清零了。4. 更致命的场景申请失败时你根本无路可退4.1 malloc 失败之后怎么办——这个问题没有答案时间问题可以靠预算解决碎片问题可以靠监控恶化趋势但动态内存还有一个反人类的设计它让你在无法回退的处境里面对失败。桌面程序里malloc返回NULL无非是抛个异常、弹个提示、记录日志然后退出。飞行器软件没有任何一个任务可以退出。控制循环必须在硬件复位之前持续运行。假设某个任务在飞行中段申请内存失败它接下来该怎么做不继续执行会失稳继续执行又要依赖那块没申请到的内存。这个问题从设计上就无解。安全关键系统的设计哲学是故障必须是可预测的、可控制的。系统可以进入故障模式但进入路径必须预先定义好。而malloc返回NULL什么时候发生、发生后不具备执行条件这种未知的失败模式无法预先定义。4.2 错误处理路径会爆炸式增长审查根本无法覆盖即便名义上可以处理失败代价也极其高昂。每个malloc之后都要写判空逻辑和错误处理分支。系统里如果有上百个分配点代码里就会塞满几百个错误处理分支。这些错误处理分支恰恰是代码里最危险的部分它们几乎从不执行测试覆盖不到改造时容易被遗忘评审时又必须逐一点检。你没法保证所有失败路径都正确更没法证明它们在极端情况下能互相协作而不引发二次故障。静态分析工具面对大量错误路径时也会非常头疼因为需要分析的路径组合是指数增长的。对比之下静态分配的代码里内存使用在编译期就完全确定根本不存在申请失败这个分支。少一类状态就少一类缺陷。4.3 认证标准为什么死盯这一条航空航天、汽车、轨交、医疗设备这些领域都有强制性的安全认证体系。DO-178C是航空软件安全认证标准ISO 26262是汽车功能安全标准。这些标准对软件开发过程和验证证据有极端严苛的要求。动态内存在这些体系下为什么这么麻烦因为你要为动态提供全生命周期的安全证据你要证明所有分配请求都能得到满足、所有释放都正确配对了、所有失败路径都被定义并验证过。这基本意味着你要把堆的行为分析做到形式上可穷举的程度——那和用静态分配在工程量上完全不是一个数量级。所以MISRA C干脆直接规定不允许调用malloc、free、realloc这类标准内存分配函数。这不是老工程师看新人不顺眼这是认证体系反复博弈后得到的最省力的解。4.4 形式化验证的困扰再往深里说一层。安全关键代码常用模型检验工具比如CBMC、SPIN做验证目标是证明程序在所有输入和状态组合下行为正确。如果内存对象数量和大小在编译期固定程序可达的状态是有限的工具可以穷举验证。一旦引入动态内存堆的状态空间瞬间爆炸。可分配的块数量、块之间的排列组合、每个分配点的失败分支……验证工具要在这些状态的乘积里搜索状态空间增长得快到没法收敛。实际项目中大家都不愿意碰动态内存不是因为老派而是因为拖累验证、拖累认证、拖累保险。5. 行业规范不是教条JPL、MISRA 和认证体系的工程逻辑5.1 NASA JPL 的 Power of Ten 与初始化后不得使用堆业内流传很广的Jet Propulsion Laboratory安全关键代码规则常称为Power of Ten对C语言的安全子集做了严格定义其中专门有一条规则是不使用动态内存分配初始化结束后严禁再碰堆。JPL做的是深空探测器一个探测器从发射到入轨再到深空执行科学任务一跑就是几年没有任何物理维护机会。它们写的代码不允许在运行期出现任何试试看的行为。堆的使用恰恰是试试看每次申请都依赖堆的内部状态而这个状态是运行期累积出来的没人能真正掌握。JPL这条规则的工程逻辑是把所有运行期风险前移到编译期让程序的行为在出厂前就被完全锁定。5.2 MISRA C 与整个汽车产业链的立场MISRA C最初源于汽车行业后来在航空、轨交、医疗器械、工业控制领域被广泛采纳。MISRA C:2012把动态内存分配函数列在禁用清单里直接用规则禁止调用。汽车行业也大量存在电子控制单元ECU从发动机控制到制动系统任何一项误操作都可能造成安全事故。汽车行业对成本又极度敏感不可能让每个ECU都配备复杂内存管理单元和垃圾回收器。最简单可靠的方案就是代码里根本没有堆操作所有内存都在编译期分配好。整个产业链、编译器、工具链、静态分析软件都围绕着这个约束做优化。规范如此一致地在各行业落地说明这不是哪一个公司的偏好而是整个安全关键软件领域沉淀下来的共识。5.3 事故归因里的共同指向干这行久了会看到很多事故复盘报告。凡是涉及内存的疑难杂症归因链条最后大概率指向堆损坏、野指针、缓冲区越界、碎片化。这类问题的共同特点是地面测试跑几千小时模拟都未必复现一旦在特定条件下触发就是整机故障。复盘时查代码最先接受审查的必然是动态内存相关调用。我自己也踩过类似的坑。某次遥感遥测设备做压力测试跑了大约几十个小时后出现偶发性数据异常定位了接近两天最终发现是某驱动在释放缓冲区时指针早已被越界覆盖传进去的地址不是原来malloc返回的合法地址。如果当初老老实实静态分配越界在编译期或首次访问时就能暴露不会拖到几十个小时后变成间歇性故障。那两天我最大的体会就是动态内存省下的开发时间会在排障阶段连本带利还回去而且利息高得吓人。5.4 规范落地三个可执行的实操动作看到这里你可能想知道实际项目中怎么强制落地。我一般推动三件事代码库全量检索把所有malloc、free、realloc、calloc调用点拉出来人工逐一审查归类能静态化的全部静态化。把禁用动态内存分配写进编码规范和评审检查单。评审时只要看到堆函数调用直接打回没有例外通道。在CI流水线里挂静态分析工具出现堆操作调用直接报错中断。让工具当守门员比人盯人靠谱得多。这三个动作下来几个月后再检查新代码里基本不会再出现动态内存分配。老代码可能历史包袱重但至少有明确的整改目标和排期。6. 不用动态分配这些需求在工程上怎么落地6.1 启动阶段全部建好运行阶段零分配飞行器软件最常见的做法是系统上电后在启动阶段把所有的队列、消息邮箱、任务栈、协议缓冲全部静态创建并初始化完毕进入正常运行态之后不再有任何内存获取动作。这个过程相当于把需要多少内存这个不确定性在系统运行之前就消化掉。启动阶段可以用动态分配但更常见的做法是连启动阶段的动态分配都不允许——反正任务数量、队列深度、缓冲区大小在设计阶段是确定的直接用静态数组可以编译期锁定一切。控制逻辑运行的时候内存布局已经是固定的不存在任何运行期涨落。6.2 内存池把动态简化为固定槽位如果确实需要类似动态分配的能力业界标准方案是内存池。它和通用堆的本质区别在于块数量固定、块大小固定、分配位置固定。一个最简单的固定大小内存池实现思路是这样编译期开一片静态数组按固定块大小切成N个槽位用一个freelist维护空闲槽。分配时从freelist取一个槽O(1)操作释放时把槽挂回freelist同样O(1)。没有搜索、没有合并、没有元数据分裂也不会碎片化。C语言示意逻辑大致如下#define POOL_SLOTS 64 #define POOL_BLOCK 256 static uint8_t pool_mem[POOL_SLOTS][POOL_BLOCK]; static uint8_t pool_free[POOL_SLOTS]; void *pool_alloc(void) { for (int i 0; i POOL_SLOTS; i) { if (!pool_free[i]) { pool_free[i] 1; return pool_mem[i][0]; } } return NULL; // 编译期固定槽位理论上不会出现 } void pool_free(void *p) { ptrdiff_t idx (uint8_t *)p - (uint8_t *)pool_mem; idx / POOL_BLOCK; pool_free[idx] 0; }注意判断空池的条件。因为槽位总数是编译期常量池容量是否够用可以在设计期直接验算运行期基本不会真的返回NULL。代价是固定块大小可能造成槽位浪费比如需要257字节时得给它512字节的槽——但这是用空间确定性换时间确定性在安全关键系统里值。cplusplus、VxWorks的memPartLib也提供内存池概念用法大同小异。无论怎么封装只要池子大小、块大小、块数量这三个参数在编译期确定系统行为就是可证明的。6.3 环形缓冲区流式数据的最优解传感器数据、串口数据、遥测流这类生产者-消费者模型最合适的结构是环形缓冲区。数据写入方和读取方共享一段固定大小的内存区域通过读写指针管理数据。环形缓冲区的地址和数据量都是固定上限不存在数据流长度不定所以必须动态分配的借口。在控制回路里双缓冲也是常见手法采集任务写入缓冲区A控制任务读取缓冲区A同时采集任务写入下一帧的缓冲区B一拍一换。双缓冲有清晰的时间边界写入和读取不会互相踩踏内存大小固定是2帧简单得不能再简单。6.4 状态机 静态数据表飞行器软件的主干逻辑本质上是一堆有限状态机上电自检状态、传感器对准状态、任务执行状态、故障处理状态。状态机最大的优点就是状态可穷举、转移条件可穷举、对应动作可穷举。状态转移表完全可以做成const静态数组参数表、校准系数表、配置文件全部预置在Flash里运行时只读。这样需要的内存只有那么几个固定数组数值再复杂也不会成为需要动态分配的理由。我见过不少设计动不动就要动态装载配置其实静态编译进去、通过参数序号查表已经能满足99%的需求代价只是固件体积稍大换来的是无数隐患被消除。6.5 一次实际的去动态化改造流程如果接手的老代码已经大量用了动态内存改造时可以按这个流程走第一步全量盘点所有malloc/free调用点按照启动配置类、周期任务类、中断上下文类分组归档。第二步逐个分组设计替代结构。启动配置类改成静态固定配置周期任务类改成预分配缓冲或内存池中断上下文类修改为任务上下文预取方式。第三步逐模块移植。改一块、测一块保证行为等价不要试图一口气重写整个系统。第四步对改造后的任务重新测量WCET归档内存占用账本。这步不能省因为改造后虽然内存确定性提高了但静态占用可能变大需要确认还在硬件预算内。7. 如果动态内存实在躲不掉底线在哪里7.1 只允许在启动阶段使用运行后冻结有些系统确实需要在启动时根据外部配置调整内存布局比如加载配置文件后才确定板卡数量、通道数量。这种情况下可以用动态分配但严格限定所有堆操作只在初始化阶段执行系统进入就绪状态后统一把堆冻结RLTime后期任何任务都禁止再调用分配或释放。这种伪动态方案好处是把不确定性压缩在启动阶段。启动阶段允许失败失败了可以上报故障、停止初始化不会引发飞行阶段的风险。一旦进入运行态内存布局完全固定后续的调度和时序分析就重新回到可证明的轨道上。7.2 使用专用分配器而不是通用堆如果运行期确实需要分配也请不要用标准库的通用堆而是用自己设计、参数固定、结构简单、可做最坏情况分析的内存池或伙伴系统。固定大小池的时间是O(1)伙伴系统的块合并和分裂规则简单WCET容易估算。但要注意自研分配器也必须在代码里明确池容量、块大小、分配策略这三个参数并且配套一段形式化的容量上界证明。否则就跟通用堆一样不可评估。7.3 中断上下文永远禁止堆操作再强调一次中断服务程序里绝对不允许调用任何堆分配或释放函数。ISR需要缓冲区就应该从预分配的内存池取或者由任务上下文提前准备好。这条无论什么级别的项目都适用属于底线中的底线。7.4 别去挑战认证成本如果项目要走安全认证动态内存的论证材料会让你多消耗一个数量级的人力、时间和文档。你需要写清内存使用上界的证明报告、最坏执行时间分析、每个失败分支的应对验证。这些材料每个都要经过独立审查。现实中除非有绝对无法绕开的理由否则这条高压线最好别碰——不是因为技术上不行而是因为经济上不划算风险上不必要。最后说点个人体会。我刚入行那几年也觉得禁止动态内存分配是一条不近人情的老规矩直到自己排过的堆损坏问题出现才彻底理解这条规矩背后的工程代价。飞行器软件和桌面软件最大的区别不是怕不怕崩溃而是有没有懊悔重来的资格。把内存的每块砖都提前砌好、把每块砖的归属写进图纸这种不自由恰恰是所有安全关键项目最需要的自由。下次看到愿景是动态的设计方案先问一句这块内存能不能在编译期确定能就静态化不能就评估一下换一种架构是否更划算。大多数情况下你会发现静态方案带来的麻烦远小于动态方案秋后算账的麻烦。
返回列表