☰
硬实时系统为何禁止malloc?安全关键嵌入式开发的确定性内存之道
2026/10/31 10:35:51 网站建设 项目流程

前阵子评审一段飞行控制系统相关代码,新同事用malloc给遥测消息队列动态分配缓冲区。代码逻辑没有问题,在开发板上也跑得通,但我还是按下了"拒绝"。他追出来问:"为什么?这不是很正常的写法吗?"

我当时给了他一个很短的回复:在导弹这类安全关键系统里,动态内存分配是一条红线,而且这不叫风格偏好,叫生存前提。

这不是我一个人的看法。只要你去看导弹、火箭、无人机飞控、汽车刹车控制器、医疗输液泵这些领域的编码规范,几乎都有一个共同条目:任务运行阶段禁止动态内存分配。很多人第一次听到这个规矩会觉得是老派工程师的洁癖,但真正在硬实时系统里写几年代码之后就会发现,这条规矩背后是三个非常具体、非常物理的原因:确定性、碎片化、可证明性。

这篇文章我就把这几个问题掰开讲透——为什么不能malloc,不用malloc之后到底拿什么管理内存,以及这些经验怎么落到每天的代码评审和工作习惯里。无论你是在做嵌入式 RTOS 开发、准备进入航空航天软件领域,还是只是好奇"高端代码为什么这么写",这篇都适合你。

1. 硬实时系统为什么容不下"不确定的时间"

1.1 控制回路的最基本契约:每个周期必须按时完成

先搞清楚我们面对的是一个什么样的软件环境。

导弹飞行控制系统的核心是一个周期性控制回路——陀螺仪和加速度计采集姿态数据,控制器计算舵面偏转指令,舵机执行。这个过程不是"有空就做",而是以固定的采样周期反复执行,常见的是 250Hz、500Hz 甚至 1kHz。也就是说,系统每隔 2~4 毫秒必须完成一轮"采集-计算-输出"的全部工作。

这个周期的每一个任务都有时间预算。比如采集要 200 微秒,控制律计算要 800 微秒,输出要 100 微秒。调度器按这些预算排时间片。你问一句"超时了会怎么样?",答案是:控制律的输出指令到了错误的时刻,飞行器姿态就可能发散。用控制理论的话说,采样周期抖动和任务超时会直接破坏系统的稳定性边界。

在这个语境下,"大多数时候很快"没有任何意义,重要的是"每一次都必须在限期内完成"。系统需要在任何输入组合、任何运行状态下都能严格满足时间约束——这就是硬实时(Hard Real-Time)和普通桌面软件的核心区别。

1.2 malloc 的时间不确定性从哪里来

那么问题来了:malloc的耗时是稳定的吗?答案是不是,而且它的不确定性来源多到让人绝望。

第一,系统调用层面。在很多嵌入式 RTOS 和通用操作系统里,malloc第一次向系统申请大块内存时,会触发brk、sbrk、mmap之类的系统调用,进入内核态执行。系统调用本身就比普通函数调用耗时长,而且可能涉及到虚拟内存页表的分配、页面的建立,这个时间在毫秒级甚至更差。整个过程在高负载下还可能被中断、被更高优先级的任务抢走 CPU。

第二,堆管理器的内部锁。现代 C 运行时库(比如 glibc)的malloc实现为了线程安全,内部有锁或者使用 arena 机制。如果你的控制任务和别的任务共享堆,分配内存时就要等待锁释放。锁等待时间是典型的不可预估因素——你不知道持有锁的线程什么时候被调度走。

第三,空闲块搜索与整理。标准库的堆分配器维护一个空闲链表,分配时需要遍历这些链表找一块足够大的块。找到之后还可能切割,释放之后就合并。这些操作的时间随堆的碎片情况变化,可能很快,也可能遍历上百个节点。

第四,页表与缓存副作用。拿到新内存之后,首次访问虚拟页可能触发缺页中断,这会进一步拖慢任务。

我见过最直观的对比是在一颗 ARM Cortex-A 系列处理器上做基准测试:普通数组赋值的时间抖动在几纳秒级,而malloc的单次耗时抖动轻松超过几毫秒。放在 500Hz 的周期里,一次malloc就足以让整个控制任务超出时限。

这还不算最坏情况。实时系统用"最坏情况执行时间"(WCET)来评估任务,而malloc的 WCET 从数学上就很难给上界——你要分析空闲链表在任意历史操作下的形状可能吗?几乎不可能。所以很多实时设计直接选择:把这个不确定源从系统里拿掉。

2. 内存碎片不是概率问题,是统计必然

2.1 堆分配器在长期运行中的"衰老"过程

聊完时间,再聊内存空间本身。

堆内存碎片化是动态分配最无声的杀手。假设你有一个长期运行的任务序列,它不断执行这样的操作:分配一个 2048 字节的大缓冲,再分配一个 300 字节的小缓冲,然后释放大缓冲。如此循环。

一开始堆很整齐,分配器很舒服。但随着时间推移,空闲区域被切成了各种大小不等的碎块:可能有一大块 1970 字节的、一块 300 字节的、几块 128 字节的。当某个任务再次请求 2048 字节的连续内存时,系统明明还有几千字节的空闲总量,却找不到一段连续 2048 字节的区域,于是分配失败。

用代码看更直观:

while (1) { void *big = malloc(2048); void *small = malloc(300); free(big); // 运行越久,空闲区越碎 // 某个时刻,new 一个 2048 就会失败 }

这个过程在普通 PC 上你可能要在跑了几天的服务器上才能观察到,但在嵌入式实时系统里,变长消息、变长协议帧、临时缓冲的频繁分配释放会大大加速碎片化。碎片化不是"运气不好才发生",而是长期运行堆上的统计必然——你用的分配算法只是改变了碎片化的速度,不能从根上消除它。

我记得以前一个 VxWorks 项目里,系统跑了一段时间后malloc开始频繁失败,仔细一查碎片率已经高到 87%。最后把那些 300 字节以下的临时分配统一改成固定大小内存池,问题才消失。那次之后我才真正理解"碎片化是统计必然"这句话的分量。

2.2 泄漏与分配失败:安全关键系统没有"重启"选项

碎片化会带来分配失败,内存泄漏则会把系统推向不可恢复的状态。

在桌面应用里有个共识:偶尔泄漏一点内存没关系,操作系统会兜底,大不了重启进程。但导弹没有"重启进程"这个选项。飞行器在空中运行几十分钟甚至几个小时,每一微秒都在消耗内存资源而没有回收动作。

想想一个很普通的错误分支场景:代码分配了一块缓冲区,然后调用了某个可能返回失败的处理函数。如果失败分支忘了free,下一次操作又分配新的一块,泄漏就这样悄悄累积。安全关键系统里的错误处理和异常分支特别多,这种"漏释放"在测试时几乎不会被发现,但在长航时任务中会逐渐耗尽堆空间。

更麻烦的是分配失败后的处理路径。如果malloc返回NULL,你打算怎么办?打印一行日志?进入异常复位?在普通系统里这也就是程序退出。在导弹飞控里,分配失败意味着系统里已经没有任何可用的连续内存,后续根本没法正常完成控制计算——这是灾难性的。

你也许会说:"我都检查返回值了还不行吗?" 错。在安全关键系统里,"等待失败发生再处理"的设计本身就是失败的。正确设计是让分配不可失败、让内存需求在设计阶段被完全预算掉,而不是让系统在打仗的时候开始"考虑备选方案"。

3. 适航与功能安全标准:这其实是一个认证问题

3.1 DO-178C 和 MISRA 不是在跟你商量

如果你以为"禁用动态内存分配"只是个别团队的内部偏好,那可能低估了这件事的分量。在航空航天、汽车、铁路、医疗等行业,这是被写进安全标准和编码规范里的硬性要求。

先看MISRA C:2012,这是汽车嵌入式领域最知名的编码标准,在航空航天和其他安全关键领域也被广泛引用。它的 Rule 21.3 明确规定:不得使用标准库中的动态内存分配函数(malloc、calloc、realloc、free这一族)。MISRA C++ 同样有类似条款,明确要求"不应使用动态内存分配"。

再看DO-178C,这是机载设备软件适航认证的通用标准。DO-178C 本身不是一句"禁止 malloc"这么简单,它对软件提出了非常严苛的要求:每个软件需求要有可追溯性,每段对象码要有验证证据,要分析数据耦合和控制耦合,要做鲁棒性测试。

然后还有ISO 26262(汽车功能安全)、IEC 61508(电气/电子/可编程电子安全相关系统)、CERT C 规则(如 MEM30-C、MEM31-C),以及安全弱点的CWE-789(未受控的内存分配)。这些体系在大型安全项目里几乎是强制约束。

3.2 动态分配的认证代价为什么高到要直接规避

我这里要澄清一个常见的误解:很多标准并没有白纸黑字写"绝对禁止动态内存分配"。DO-178C 允许你使用malloc,但条件是你必须证明系统在任何情况下都不会因为内存耗尽、碎片化、泄漏而失效。

问题是这个"证明"的成本高得离谱。

要证明动态内存分配是安全的,你需要分析堆的所有历史状态对分配器行为的影响,需要做覆盖所有内存状态的鲁棒性测试,需要在最坏情况下检验碎片化不会导致分配失败。堆的状态空间是完全不可预测的——你没法遍历所有可能的分配释放序列,更没法在代码级上给出一个严格的数学证明。

而静态分配则完全不同:内存地址、大小、生命周期全部在编译和链接阶段确定。链接器可以生成完整的内存映像,每个任务的栈空间、缓冲区、数据结构都能在系统设计阶段被精确审计。测试用例可以覆盖全部内存状态,因为变量数量是确定的、可枚举的。

简单说:动态内存分配让"证明系统不会失效"变得几乎不可能,而静态分配让这个证明变成了一张可填写的表格。

所以你会看到很多真实项目为了满足适航或安全认证,是从架构层面直接砍掉动态分配的——不是他们喜欢"古董风格",是因为认证成本差一个数量级。在工程上,"高成本证明它有可能安全"远不如"设计上天然安全"来得实在。

4. 不用 malloc,我拿什么管理内存

4.1 静态分配:把内存预算提早在编译期解决

聊了那么多为什么,现在落到实际的"那怎么分配内存"。

最常见的替代方案就是静态分配。在定义数据结构的时候,直接把缓冲区用全局数组或static局部数组声明出来,大小在设计阶段就确定。

比如一个消息队列,原来你可能这么写:

typedef struct { char *buffers[QUEUE_DEPTH]; size_t lengths[QUEUE_DEPTH]; uint8_t head, tail; } msg_queue_t;

现在改成这样:

#define QUEUE_DEPTH 16 #define MAX_MSG_LEN 256 typedef struct { uint8_t data[QUEUE_DEPTH][MAX_MSG_LEN]; uint8_t lengths[QUEUE_DEPTH]; uint8_t head, tail; } msg_queue_t;

区别在哪?第一,内存大小在编译期就是常数;第二,链接器能把这个结构体的地址、每个缓冲区的偏移量算得清清楚楚;第三,任何时刻占用的内存是固定的,没有"运行时申请"的概念。

有的朋友会担心:静态分配是不是太浪费?如果一个任务大部分时间只需要 50 字节,但你给了它 256 字节,那确实浪费。但你要算一笔账:浪费 200 字节静态内存,换来的是整个系统的可分析性、可测试性和故障隔离能力。在嵌入式系统里,内存大小是可以提前预算的——把模块的峰值需求加总,再加上安全裕量,就够了。反过来,动态分配"省"下来的内存,最后都变成了评审和认证阶段的无穷无尽的风险。

我在实际项目中还常用一个技巧:"静态数组 + 逻辑长度"模拟变长数据。比如一个协议帧,最大长度是 1024 字节,那就声明uint8_t frame[1024],再用一个uint16_t len记录当前实际长度。上层逻辑看起来像是"动态长度",但底层内存永远是固定块。这兼顾了灵活性和确定性。

4.2 内存池与环形缓冲区:两种最常见的替代

静态分配在结构上最直观,但有些场景确实需要"运行时管理一组类似的对象"——比如多个传感器数据节点、多个任务控制块。这时候内存池就是标准解法。

内存池的本质是:在启动阶段从一块静态数组里预先切出 N 个固定大小的块,用单向链表串起来;分配时取链表头,释放时挂回链表头。分配和释放都是 O(1),而且不会产生碎片——因为每个块大小完全一致。

typedef struct pool_node { struct pool_node *next; } pool_node_t; typedef struct { pool_node_t *free_list; uint8_t blocks[POOL_CAPACITY][POOL_BLOCK_SIZE]; } pool_t; void *pool_alloc(pool_t *pool) { if (!pool->free_list) return NULL; // 池耗尽 void *block = pool->free_list; pool->free_list = ((pool_node_t *)block)->next; return block; } void pool_free(pool_t *pool, void *block) { ((pool_node_t *)block)->next = pool->free_list; pool->free_list = block; }

这种模式在实时系统里太常见了——RTOS 的任务控制块(TCB)、消息邮箱、共享内存区,基本都是这么管理的。它的一个重要好处是分配失败的“外部行为”也是确定的:如果池耗尽,返回NULL,而且这个NULL可以从容地在系统设计时给出对应策略,比如降级模式或停机保护。最重要的一点是,内存池的占用上限是编译期的常数,分配器内部的链表不用动态分配,彻底避免了碎片化。

另一种常用的替代是环形缓冲区,特别适合生产者-消费者模型,比如传感器采集到控制单元的数据流:

typedef struct { uint8_t buf[RING_BUFFER_SIZE]; uint16_t head; // 写入位置 uint16_t tail; // 读取位置 uint16_t count; // 当前有效字节数 } ring_buffer_t;

环形缓冲区的优点是不需要动态分配、不需要释放,天然支持固定大小消息的流式读写。配合中断或 RTOS 信号量,它就能完成任务间的数据传递。注意一点:如果你需要传递"不定长"消息,可以在缓冲区里加一个头部记录长度,就像前面说的"静态数组 + 逻辑长度"模式。

4.3 C++ 项目的自定义分配器

如果你所在的项目是 C++,情况稍微复杂一点,因为 C++ 代码里到处是new、delete和 STL 容器。很多人写惯了std::vector,一进安全关键项目就觉得没法活了。但其实 C++ 有很好的替代方案:自定义分配器。

STL 容器允许你传入自定义 Allocator 模板参数,让容器在底层不从标准堆申请内存,而是从你的静态池里取块。举个例子:

template <typename T> class PoolAllocator { public: using value_type = T; PoolAllocator() = default; template <typename U> PoolAllocator(const PoolAllocator<U>&) {} T* allocate(std::size_t n) { // 从全局静态对象池取 n 块连续内存(或逐块取) return static_cast<T*>(global_pool().alloc(n * sizeof(T))); } void deallocate(T* p, std::size_t n) { global_pool().free(p); } }; using MsgList = std::vector<Msg, PoolAllocator<Msg>>;

这里有个细节容易踩坑:std::vector在扩容时可能一次性申请大块内存,而且新旧内存拷贝耗时不确定。所以我在代码评审里禁止直接用默认版本的 STL 容器,要求容器必须预分配好容量(reserve(MAX_COUNT)),并且使用自定义分配器把内存导向固定池。这样既能用容器带来的开发效率,又把内存行为约束在确定性范围之内。

如果不用 STL,也可以用"静态链表数组"——用一个int next[]数组代替指针,把所有节点的关系用下标表示。这在 C 代码里特别有用,既能动态逻辑组织数据,又没有真正意义上的动态内存分配。

5. 把"禁止动态分配"落到代码评审和工作习惯里

5.1 我最常用的自查清单

规则只有变成流程才有意义。我在日常代码评审里有一套固定的自查清单,分享给大家:

  • 全项目搜索malloc、calloc、realloc、free,一个都不放过。
  • 搜索 C++ 的new、delete,以及std::vector、std::map这类默认分配容器的使用。
  • 检查可变长度数组(VLA,C99 的int arr[n])。VLA 虽然用的是栈不是堆,但它同样引入了"运行时大小不确定"的问题,很多 MISRA 规则和飞控编码规范同样禁止。
  • 检查递归函数。递归的深度是运行时的,栈占用不可预测,安全关键系统里通常也要禁。
  • 检查所有静态数组的定义是否用编译期常量作为维度。如果维度是运行时变量,打回重写。
  • 检查模块初始化和任务开始时是否需要分配内存。如果需要,必须走到静态内存池或预分配路径。

你可以把它做成一个简单的脚本:

grep -rEn '\b(malloc|calloc|realloc|free)\b' src/ grep -rEn '\bnew\b|\bdelete\b' src/

跑出来的每一个命中都要有人解释"为什么这里可以这么用"。不要相信"没问题,我都检查了",在安全关键系统里,每一步都要留下证据。

5.2 链接脚本禁堆:让 malloc 从物理上不可用

评审清单是软的,链接脚本禁堆是硬的。

很多嵌入式项目的链接脚本(Linker Script)会定义堆大小。你可以直接把堆区的大小设成 0,或者干脆不在链接脚本里生成堆段。这样即使某段代码里不小心写了malloc,运行时它也无法获得任何内存,第一时间返回NULL,让你在测试阶段就立刻发现问题。

在 GNU LD 的链接脚本里类似这样:

/* 在链接脚本里把堆大小设为 0,栈后直接是静态数据区 */ __heap_size = 0;

启动代码里如果依赖__heap_start、__heap_end符号初始化 C 运行时堆区,禁掉堆段后运行时库的堆初始化也会失败。这样一来,"动态内存分配"在物理上就不存在了——你的程序压根拿不到堆内存,想犯错误都没有机会。

同理,在 RTOS 配置里,很多任务栈和消息队列是基于静态数组创建的。配置阶段就要把任务栈大小、消息池大小用宏定义写死,不允许用变量动态创建任务。

这个操作在代码评审上特别有用:让"踩红线"的代码跑不起来,比盯着人看更可靠。

5.3 我现在的评审习惯:把 malloc 当成一个信号

最后说一点个人心得。

在做安全关键系统很多年后,我逐渐把"代码里出现 malloc"这件事当成一个信号,而不仅仅是一个错误。什么意思呢?如果有人写malloc,我不会第一时间说他不对,而是追问他一个问题:

你这段代码在运行时的峰值内存需求是多少?最坏情况下它需要多少时间?系统如何证明它不会耗尽内存?

这三个问题对动态内存分配几乎是无解的。这是一个非常好的"灵魂拷问",能让新人立刻理解为什么一个看起来"很正常"的写法在这里行不通。

我还记得一个很典型的例子:同事需要在任务里缓存一批目标特征点,数量不固定,于是他写了一个动态增长的链表。我在评审记录里写了一句:如果目标特征点在某个时刻突然达到理论最大值,你的链表分配第二块节点时刚好堆被别的任务占住了,控制周期还会按时完成吗?

他想了半天,发现根本没有办法回答这个问题。后来他换成最大容量固定的静态结构体数组,数组满了就把最旧的点覆盖掉。什么新问题都没了。

这就是我高强度推荐静态分配的原因——它解决的不只是内存问题,它还逼你把最坏情况想清楚、写明白。只要做到这一点,系统的确定性就有了,后续所有的调度、故障诊断、认证测试都有一个可靠的地基。

如果你也在安全关键系统上写代码,把 malloc 从一个"函数"当一个"信号"去读。它提醒你去重新审视这段代码的设计前提,而不是简单地改一行代码。把这个习惯放进你的评审清单,时间会证明它的价值。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询