☰
为什么关键系统禁止动态内存分配:从malloc不可预测性到内存池实践
2026/10/7 1:01:53 网站建设 项目流程

1. 从一行“禁止 malloc”的规范说起

第一次在嵌入式团队里看到编码规范上赫然写着“禁止使用动态内存分配”时,我的反应和大多数人一样:都什么年代了,堆上申请点内存怎么了?PC 上写程序new和malloc用得好好的,服务器上跑几年也没见谁因为malloc崩过。直到后来参与了一个对可靠性要求极高的实时控制项目,被一位老工程师按着头讲了一晚上内存碎片的形成过程,我才真正理解这条规范背后压着多少血泪。

这篇内容想聊的就是这件事:为什么在导弹、航天器、工业控制、汽车电子这类“写错一行就可能出人命”的场景里,动态内存分配会被明令禁止。关键词里的malloc、free、new、delete是每个 C/C++ 程序员再熟悉不过的东西,但恰恰是这几个最熟悉的函数,在关键系统里成了头号公敌。不管你是刚入行的嵌入式新人,还是写了几年业务代码想转高可靠领域的开发者,理解这套逻辑都能帮你建立一种“资源确定性”的思维——这种思维不只用在导弹上,任何对稳定性有要求的系统都用得上。

先把结论摆出来:禁止动态内存分配,核心不是因为它慢,而是因为它“不可预测”。慢可以优化,不可预测没法优化。下面我会从内存碎片的真实成因、实时性约束、故障后果、以及工程上怎么替代这几个角度,把这件事彻底讲透。

2. 动态内存分配到底“不可预测”在哪里

2.1 一次 malloc 背后发生的完整链路

很多人以为malloc就是“从内存里划一块出来”,快得很。实际上在通用操作系统上,一次malloc调用背后可能发生这些事:先在用户态的分配器(比如 glibc 的 ptmalloc、或者 tcmalloc、jemalloc)里查找空闲块,找不到合适的就向内核申请(brk或mmap),内核要更新页表、可能触发缺页中断、甚至触发内存回收。这一整套流程的耗时,从几百纳秒到几毫秒都有可能,跨度达到四个数量级。

在 PC 上这无所谓,你点个按钮卡 3 毫秒人眼根本感觉不到。但在导弹的飞行控制回路里,控制周期可能是 1 毫秒甚至更短,每个周期内必须完成传感器采样、姿态解算、控制律计算、舵机输出这一整套动作。如果某个周期里突然来一次malloc,耗时从平时的 2 微秒暴涨到 500 微秒,这个周期就超时了。控制律没算完,舵机拿到的就是上一周期的旧指令,飞行姿态立刻出现偏差。一次偏差可能还能修正,但如果这种抖动反复出现,累积起来就是失控。

关键点:实时系统怕的不是“平均慢”,而是“最坏情况慢”。malloc的最坏执行时间(WCET)在通用分配器里几乎无法给出上界,这是它被禁的根本原因。

2.2 内存碎片:那个慢慢勒紧脖子的绳索

比单次耗时更阴险的是内存碎片。动态分配的内存反复申请释放之后,堆空间会变得千疮百孔。我举个具体的例子你就明白了。

假设堆总共 100KB。你先申请了 A(30KB)、B(20KB)、C(30KB),此时用了 80KB,剩 20KB 连续空间。然后你释放了 B,现在空闲的是中间那 20KB 加上尾部 20KB,总共 40KB 空闲,但不连续。这时候你想申请一块 35KB 的 D,对不起,申请失败——尽管总空闲有 40KB,但没有一块连续的 35KB。

这就是外部碎片。在长时间运行的系统里,碎片会越来越严重,最终导致明明还有大量空闲内存,却分配失败。对于导弹这种一旦发射就无法重启的系统,运行几分钟到几十分钟,碎片累积到临界点,某次关键分配失败,程序直接崩溃或者进入未定义行为。你没法在飞行中途“重启一下试试”。

2.3 分配失败的处理困境

有人会说,那我判断返回值不就行了?malloc返回 NULL 我就处理。问题在于,在关键路径上,你根本没有“优雅处理分配失败”的余地。

想象一下导弹正在末端制导阶段,图像处理模块需要申请一块缓冲区来存放新一帧的匹配数据,结果malloc返回 NULL。这时候你能怎么办?重试?可能还是失败。降级?降级逻辑本身可能也需要内存。报错退出?导弹还在天上飞。在硬实时系统里,任何“资源申请可能失败”的操作,都必须有确定性的失败处理路径,而动态内存的失败处理路径往往是不存在的——因为你无法预知它什么时候失败、失败时系统处于什么状态。

静态分配就完全没这个问题:所有内存在编译期就确定好了,链接器会告诉你够不够。如果不够,编译就过不了,你在开发阶段就发现了,而不是等到导弹飞到一半才发现。

3. 实时性约束下,内存分配的时间上界为什么给不出来

3.1 通用分配器的设计目标本来就不是实时

glibc 的 ptmalloc、Google 的 tcmalloc、Facebook 的 jemalloc,这些分配器的设计目标是吞吐量和平均性能,不是最坏情况延迟。它们用了大量技巧来加速常见路径:线程本地缓存、size class 分级、延迟合并空闲块等等。这些技巧让平均分配时间降到几十纳秒,但代价是最坏情况完全不可控。

比如 tcmalloc 的线程缓存,当缓存里没有合适块时,需要从中央堆拿,中央堆有锁,多线程竞争时可能阻塞。jemalloc 的 arena 机制在跨 arena 分配时也有类似问题。更别说触发mmap系统调用、缺页中断、内存回收这些内核路径,耗时完全取决于当时系统的整体状态。

3.2 实时系统要的是 WCET,不是平均值

实时系统的核心指标是最坏情况执行时间(Worst-Case Execution Time, WCET)。一个任务能不能在截止时间前完成,取决于它最慢的那一次,而不是平均那一次。你平均 1 微秒、偶尔 10 毫秒,那这个任务的 WCET 就是 10 毫秒,调度器必须按 10 毫秒来安排,整个系统的实时性就被这一次抖动拖垮了。

静态分配的内存访问时间是确定的:要么是栈上的局部变量(栈指针加减,几个时钟周期),要么是全局/静态区的固定地址(直接寻址,也是几个时钟周期)。这些操作的 WCET 可以精确测量、可以写进时序分析报告。而malloc的 WCET,你让最资深的工程师来也给不出一个可信的上界。

3.3 一个具体的时序对比

我做过一个简单的实测,在 ARM Cortex-M7 上跑 168MHz,对比几种内存操作的耗时:

操作类型典型耗时最坏耗时可预测性
栈上局部变量访问1-2 周期2 周期完全确定
全局静态数组访问2-3 周期3 周期完全确定
内存池固定块分配10-20 周期30 周期可给出上界
malloc(首次,触发 brk)数百周期数万周期不可预测
malloc(命中缓存)50-100 周期数千周期不可预测

注意最后两行:即使是“命中缓存”的快路径,最坏情况也可能因为中断、缓存失效等原因暴涨。而内存池方案虽然比栈慢,但它的最坏耗时是可以测量并给出上界的,这就够了。实时系统不要求快,要求的是“我知道它最慢多慢”。

4. 故障后果:为什么这个场景容不下一次分配失败

4.1 从“程序崩溃”到“物理后果”

在 Web 服务里,一次内存分配失败最多导致一个请求 500,用户刷新一下就好。在导弹里,一次分配失败可能意味着:控制律计算中断,舵机保持上一位置,导弹偏离预定弹道。如果发生在末制导阶段,几毫秒的失控就足以让脱靶量从几米变成几百米。

更严重的是,动态内存相关的 bug 往往不是“干净地崩溃”,而是未定义行为。堆被写坏之后,程序可能继续运行,但数据已经错了。姿态解算用到了被踩坏的内存,算出来的姿态角是乱的,控制系统基于错误数据发出错误指令。这种“带病运行”比直接崩溃危险得多,因为操作员可能根本不知道系统已经不可信了。

4.2 内存泄漏在长航时任务里的致命性

导弹飞行时间可能只有几分钟,但有些系统——比如卫星、深空探测器、长航时无人机——需要连续运行数月甚至数年。在这种场景下,哪怕每次循环只泄漏几个字节,累积起来也会耗尽内存。

我见过一个真实案例(细节做了脱敏):某长航时设备因为一个异常分支里忘了free,每小时泄漏约 200 字节。运行 72 小时后,堆耗尽,设备重启。重启后一切正常,所以地面测试根本发现不了,直到实际任务中运行超过 72 小时才暴露。这种 bug 用动态内存分析工具能查出来,但前提是你得想到去查。而如果从一开始就禁止动态分配,这类 bug 根本不会存在。

4.3 认证与合规的硬性要求

在航空、航天、医疗、核电这些领域,软件需要通过严格的认证,比如航空的 DO-178C、医疗的 IEC 62304、工业安全的 IEC 61508。这些标准对动态内存的态度非常谨慎,很多高安全等级(如 DO-178C 的 Level A)明确要求:不得使用动态内存分配,除非能证明其行为完全确定且可分析。

实际上,能证明“完全确定”的通用分配器几乎不存在,所以工程上的做法就是直接禁用。这不是技术做不到,而是认证成本和风险太高。你花大力气证明一个分配器的确定性,不如直接用静态分配,省下的认证精力放在别的地方。

5. 不用 malloc,那内存怎么管

5.1 静态分配:编译期就把账算清楚

最彻底的做法是全静态分配。所有缓冲区、队列、状态变量都在编译期确定大小,放在全局区或栈上。链接的时候,链接器会告诉你 RAM 用了多少、还剩多少。如果超了,编译直接失败,你在开发阶段就发现问题。

这种做法的好处是确定性拉满:没有运行时分配,没有碎片,没有分配失败,WCET 可精确分析。代价是灵活性差,你得提前估算所有缓冲区的大小。但话说回来,在关键系统里,“提前算清楚”本来就是必须做的事,灵活性从来不是首要目标。

5.2 内存池:固定块分配的确定性方案

如果确实需要“运行时获取内存”的灵活性,标准做法是内存池(memory pool)。思路很简单:启动时一次性申请一大块内存(或者直接用静态数组),然后自己管理,把它切成固定大小的块。需要内存时从池里取一块,用完还回去。

#define POOL_BLOCK_SIZE 64 #define POOL_BLOCK_COUNT 128 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* ptr) { if (ptr == NULL) return; int index = ((uint8_t*)ptr - pool_memory) / POOL_BLOCK_SIZE; if (index >= 0 && index < POOL_BLOCK_COUNT) { pool_used[index] = 0; } }

这个简陋的实现有几个关键特性:分配时间有上界(最多遍历 128 个块)、没有碎片(所有块等大)、失败可预测(池耗尽就是耗尽,不会时好时坏)。实际工程里会用位图、空闲链表等结构优化查找速度,但核心思想一样。

注意:内存池的块大小要仔细设计。如果块太大,浪费内存;太小,装不下数据。常见做法是按最大需求设计块大小,或者设计多个不同块大小的池,分别管理。

5.3 环形缓冲区:生产者-消费者场景的利器

对于数据流场景(比如传感器采样、通信收发),环形缓冲区(ring buffer)是最常用的结构。它用一块固定大小的数组,配合读指针和写指针,实现先进先出的数据队列。写入时移动写指针,读取时移动读指针,指针绕回时取模。

#define RING_SIZE 256 typedef struct { uint8_t buffer[RING_SIZE]; volatile uint32_t head; /* 写指针 */ volatile uint32_t tail; /* 读指针 */ } ring_buffer_t; int ring_push(ring_buffer_t* rb, uint8_t data) { uint32_t next = (rb->head + 1) % RING_SIZE; if (next == rb->tail) { return -1; /* 满了,可预测的失败 */ } rb->buffer[rb->head] = data; rb->head = next; return 0; } int ring_pop(ring_buffer_t* rb, uint8_t* data) { if (rb->head == rb->tail) { return -1; /* 空了 */ } *data = rb->buffer[rb->tail]; rb->tail = (rb->tail + 1) % RING_SIZE; return 0; }

环形缓冲区的分配和释放都是 O(1),时间完全确定,没有碎片,非常适合中断和主循环之间的数据传递。head和tail加volatile是因为它们可能被中断服务程序修改,防止编译器优化出错。

5.4 栈分配:最被低估的确定性方案

很多人忘了,栈本身就是一种完美的确定性内存分配。函数调用时栈指针下移,返回时上移,分配和释放都是几个时钟周期,没有碎片,没有失败(只要栈不溢出)。在关键系统里,大量“临时”内存需求其实可以用栈上局部变量解决。

当然栈有大小限制,而且递归深度要控制。但在实时系统里,递归本来就不推荐(因为栈深度难以静态分析),所以这不算大问题。把大缓冲区从堆移到栈上,或者移到全局静态区,是消除动态分配的常见手段。

6. 那些“看起来能用”的替代方案,为什么也不行

6.1 智能指针和 RAII 救不了你

C++ 程序员会说,用std::unique_ptr、std::shared_ptr不就行了,自动释放,不会泄漏。但智能指针解决的是泄漏问题,解决不了分配本身的不可预测性。std::make_unique底层还是new,还是走分配器,还是有碎片,还是有 WCET 问题。RAII 让释放变可靠了,但申请那一下的抖动依然存在。

在关键系统里,智能指针可以用,但前提是底层分配器换成确定性内存池。标准库默认的分配器不行,你得自己写一个符合std::allocator接口的池分配器,然后让容器用它。这是可行的,但复杂度不低,很多团队干脆连 STL 容器都限制使用。

6.2 预分配 + placement new

C++ 里有个技巧叫placement new:在一块已经分配好的内存上构造对象,不额外申请内存。

#include <new> static alignas(MyClass) uint8_t buffer[sizeof(MyClass)]; void init() { MyClass* obj = new (buffer) MyClass(); /* 在 buffer 上构造,不分配内存 */ /* ... 使用 obj ... */ obj->~MyClass(); /* 显式析构 */ }

这确实避免了运行时分配,但代价是你得手动管理对象的生命周期,析构函数要显式调用,对象复用要小心。而且buffer的大小在编译期就固定了,本质上还是静态分配。placement new 只是让静态内存能承载 C++ 对象,没有改变“内存必须预先确定”这个事实。

6.3 为什么“限制分配次数”也不够

有人想折中:我不完全禁止,但限制分配次数,比如启动时分配好,运行中不再分配。这其实就是预分配思路,是可行的。但问题在于,一旦你允许了“运行中偶尔分配”,代码里就会慢慢长出越来越多的分配点,最后失控。

工程上的经验是:规范要一刀切,不能留口子。你说“紧急情况下可以分配”,那什么是紧急情况?谁来判定?代码评审时怎么检查?一刀切禁止,评审时 grep 一下malloc、new就完事,简单可靠。留了口子,就得靠人的自觉,而人的自觉在长期项目里是靠不住的。

7. 从导弹到日常:这套思维怎么迁移

7.1 不是只有导弹才需要确定性

你可能会说,我不写导弹代码,这套东西跟我没关系。但仔细想想,很多场景其实有类似的约束:

  • 汽车电子:刹车控制、安全气囊触发,同样是硬实时,同样禁动态分配。
  • 工业 PLC:产线控制器要连续运行几年,碎片和泄漏都是致命的。
  • 医疗设备:呼吸机、输液泵,分配失败可能直接危及患者。
  • 金融交易系统:高频交易对延迟极其敏感,很多团队用预分配的内存池来避免 GC 和分配抖动。
  • 游戏引擎:帧率要求稳定,运行中new导致的卡顿是玩家最讨厌的,所以游戏引擎普遍用对象池。

这些场景的共同点是:对延迟或可靠性有硬性要求,不能接受“偶尔慢一下”或“偶尔失败一次”。只要符合这个特征,静态分配和内存池的思路就适用。

7.2 日常开发里可以借鉴的做法

即使你写的是普通业务代码,也可以借鉴几个习惯:

第一,在性能敏感的热路径上避免动态分配。比如一个每秒调用百万次的函数,里面每次new一个临时对象,累积起来就是可观的抖动。改成栈上对象或者复用缓冲区,性能会稳定很多。

第二,用对象池管理频繁创建销毁的对象。数据库连接池、线程池、网络连接池,本质都是内存池思想。与其每次用时创建、用完销毁,不如维护一个池子循环使用。

第三,关注分配器的选择。如果你的程序确实需要大量动态分配,选一个适合场景的分配器(tcmalloc、jemalloc)能显著改善性能。但记住,它们改善的是平均性能,不是最坏情况。

7.3 一个我踩过的坑

早年做一个网络服务,为了“优雅”,每次请求都new一个上下文对象,请求结束delete。压测时 QPS 上不去,用性能分析工具一看,大量时间花在分配器上。改成对象池之后,QPS 直接翻倍,而且延迟曲线变得非常平稳,P99 延迟从 80ms 降到 15ms。

那次之后我才真正理解:动态分配的开销不只是“分配那一下”,还有它对缓存局部性的破坏、对分配器内部状态的扰动、以及在高并发下的锁竞争。这些问题在低负载时看不出来,一上量就全暴露了。

8. 写在最后的一点个人体会

关于动态内存分配这件事,我最大的体会是:它不是一个“好”或“坏”的问题,而是一个“合不合适”的问题。在 PC 软件、Web 服务、脚本工具里,动态分配是利器,让程序员不用操心内存布局,专注业务逻辑。但在关键系统里,它就成了不可控因素,必须被排除。

判断标准其实很简单:问自己一句,这个系统能接受“偶尔失败一次”或“偶尔慢一下”吗?如果能,动态分配随便用;如果不能,就得老老实实做静态分配或者内存池。导弹显然属于后者,而且是极端严格的那一类。

我见过太多团队在项目初期图省事,大量使用动态分配,等到后期发现稳定性问题再回头改,成本高得吓人。所以如果你正在做一个对可靠性有要求的项目,从第一天就把“禁止动态分配”写进编码规范,比事后补救划算得多。前期多花点时间设计内存布局,后期能省下无数个加班的夜晚。

这套思维不只适用于写代码。做任何资源受限、后果严重的系统设计时,“确定性优先于灵活性”都是一条值得记住的原则。

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

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

立即咨询