在讨论 ROS 2 实时控制时,很多开发者首先想到的是 CPU 核心隔离、线程优先级、SCHED_FIFO、SCHED_DEADLINE,或者把控制线程绑定到一个独立 CPU 上。这些确实是构建实时系统的重要手段,但如果机器人控制程序已经完成了 CPU 隔离,控制周期依然出现偶发的几十微秒、几百微秒甚至更长时间抖动,那么还应该继续向下排查一个经常被忽视的问题:内存。

为什么内存会影响实时性?

因为对于普通应用程序来说,“申请一块内存花费多少时间”通常不是一个值得特别关注的问题。程序调用 malloc()、C++ 调用 new,申请到内存之后继续运行即可。即使偶尔因为内存分配器、虚拟内存或者系统负载导致执行时间发生变化,对于普通业务程序而言,只要整体吞吐量没有明显下降,问题通常并不严重。

但是机器人控制系统的逻辑完全不同。

假设一个关节控制任务要求以 1 kHz 的频率运行,那么理论上每 1 ms 就需要完成一次控制计算:

传感器数据
    ↓
读取关节状态
    ↓
状态计算
    ↓
控制算法
    ↓
生成控制指令
    ↓
发送给执行器

如果整个控制循环正常情况下只需要 100 μs,那么剩余的 900 μs 看起来非常充裕。但如果某一次控制循环在内存分配、页面访问或者锁竞争上突然多花了 500 μs,下一次周期就可能已经非常接近 deadline。

更麻烦的是,这种问题往往不是每次都会发生。

99.9% 的控制周期可能都运行得很好,只有在某些特定情况下出现一次明显延迟。平均延迟看起来仍然非常漂亮,但是对于实时系统来说,真正值得关注的并不是“平均运行多快”,而是:

最坏情况下,一次控制任务究竟可能被拖慢到什么程度?

这就是实时系统与普通软件系统在内存管理上的一个核心区别。


一、为什么 malloc/new 在实时控制路径中需要谨慎?

先来看一个非常普通的 C++ 程序:

void control_callback()
{
    auto data = new ControlData();

    calculate_control(data);

    delete data;
}

从普通软件开发角度来看,这段代码没有什么问题。

但如果 control_callback() 恰好处于机器人 1 kHz 的实时控制路径中,那么问题就来了。

new 到底需要多长时间?

答案并不是一个固定值。

它可能在某次调用中非常快,也可能因为内存分配器内部状态、线程竞争、内存碎片等因素产生不同的执行时间。

这就产生了一个非常关键的实时系统概念:

动态内存分配具有潜在的不确定性。

这里需要特别说明,并不是说“使用 malloc/new 就一定会产生明显延迟”,也不是说“实时系统绝对不能使用动态内存”。

真正需要避免的是:

在具有严格 deadline 的关键实时路径中,让动态内存分配成为一个不可控因素。

这两种说法的区别非常重要。

例如,一个机器人系统启动的时候,可以动态创建几十个 Node、初始化消息队列、申请通信缓冲区、创建控制器对象。这些操作发生在初始化阶段,一般没有问题。

真正需要关注的是系统进入实时运行状态之后:

初始化阶段
    ↓
创建对象
申请内存
建立通信资源
初始化缓存
加载模型
    ↓
进入实时运行阶段
    ↓
周期性控制循环
    ↓
尽量避免不可预测的动态内存操作

也就是说,实时系统通常不是简单地追求“完全不用动态内存”,而是更加关注:

什么时候分配、在哪里分配、谁负责分配,以及实时运行阶段是否还需要分配。

这也是为什么实时软件经常采用一种思路:

初始化阶段完成资源准备,运行阶段尽可能复用已经准备好的资源。

例如控制器需要 100 个数据对象,与其每次控制周期都:

new Data();
...
delete data;

不如启动阶段一次性申请:

Data Pool
 ├── Data[0]
 ├── Data[1]
 ├── Data[2]
 ├── ...
 └── Data[99]

运行过程中直接从预先准备好的内存池中取对象。

这样做的价值并不仅仅是“减少 malloc 次数”,更重要的是:

把运行时的不确定性尽可能提前到初始化阶段。

这种设计在机器人控制、工业控制、飞控、汽车控制以及其他实时嵌入式系统中都非常常见。


二、ROS 2为什么会遇到内存实时性问题?

如果只看 ROS 2 的 Node 和 Topic,很容易认为实时性问题主要来自 Executor。

实际上,从一个 ROS 2 消息产生到控制器最终执行,中间可能经历很多层:

传感器
   ↓
驱动程序
   ↓
DDS
   ↓
ROS 2 Topic
   ↓
Executor
   ↓
Callback
   ↓
控制算法
   ↓
ros2_control / 控制器
   ↓
硬件接口

而其中多个环节都可能涉及内存操作。

例如一个消息从发布者发送出去,到订阅者接收到,背后可能涉及:

消息对象
    ↓
序列化
    ↓
DDS缓存
    ↓
网络/共享内存传输
    ↓
反序列化
    ↓
ROS 2消息
    ↓
Callback处理

不同通信方式、DDS实现以及具体配置会导致实际内存行为不同,因此不能简单地说“ROS 2 每次发送消息都会 malloc”。

但从实时系统设计角度,更应该关注另外一个问题:

实时控制路径中,是否存在不可控的动态内存行为?

这比单纯讨论“ROS 2到底会不会动态分配内存”更加有意义。

例如:

void control_callback()
{
    std::vector<double> errors;

    for (auto joint : joints) {
        errors.push_back(calculate_error(joint));
    }

    calculate_control(errors);
}

看起来只是一个普通的 C++ 写法。

但是 std::vector 在容量不足时可能需要重新分配内存:

vector
 ↓
capacity不足
 ↓
申请更大的内存
 ↓
复制/移动原有元素
 ↓
释放旧内存
 ↓
继续执行

如果这段代码位于 1 kHz 控制循环中,那么执行时间就可能出现变化。

一个更适合实时路径的思路是:

std::vector<double> errors;
errors.reserve(MAX_JOINTS);

在初始化阶段提前准备足够容量。

运行阶段:

已有内存
    ↓
写入数据
    ↓
清空逻辑状态
    ↓
再次复用

而不是:

申请
 ↓
扩容
 ↓
复制
 ↓
释放
 ↓
再次申请

这就是实时软件中非常重要的思想:

用空间换确定性,用预分配换运行时可预测性。

同样的思想还可以扩展到消息缓存、环形队列、对象池、固定长度数组、预分配通信缓冲区等。

对于实时控制任务来说,“一次申请,多次复用”通常比“每次需要,每次申请”更加容易控制最坏执行时间。


三、真正容易被忽略的问题:缺页、虚拟内存和Cache

动态内存只是内存实时性问题的一部分。

另一个经常被忽略的问题是:

程序访问的虚拟地址,并不意味着对应的物理内存已经随时准备好了。

现代 Linux 使用虚拟内存机制。

简单理解:

应用程序
   ↓
虚拟地址
   ↓
页表
   ↓
物理内存

如果程序访问某个页面时,系统发现这个页面没有处于当前需要的内存状态,就可能触发 Page Fault。

Page Fault 并不意味着系统一定会发生严重故障。

很多情况下,它只是操作系统完成内存映射的一种正常机制。

但是对于严格实时任务来说,真正的问题在于:

页面访问所产生的额外处理时间并不一定能够简单地用一个固定数值描述。

如果一个实时控制任务要求:

周期 = 1 ms

那么几十微秒甚至更长时间的额外延迟,都可能影响控制周期。

因此,在实时 Linux 系统中,经常会看到类似:

mlockall(MCL_CURRENT | MCL_FUTURE);

这样的内存锁定机制。

它的基本目的,是尽量避免关键进程运行过程中发生不希望出现的内存换出等行为。

但需要特别强调:

mlockall() 本身并不会把一个普通 Linux 程序自动变成实时程序。

它只是实时内存设计中的一个环节。

完整的实时内存设计还需要考虑:

动态内存分配
        ↓
页面访问
        ↓
Page Fault
        ↓
内存锁定
        ↓
预触碰/预分配
        ↓
Cache行为
        ↓
内存带宽竞争
        ↓
最终控制周期抖动

例如程序启动时虽然已经申请了大量内存,但并不意味着所有页面都已经按照运行时访问方式准备完成。

因此一些实时系统会在初始化阶段进行所谓的 prefault / 预触碰,让关键内存区域在真正进入实时循环之前完成必要准备。

其核心思想仍然只有一句话:

不要把可能产生不确定延迟的工作留到最关键的控制周期里。

除了虚拟内存,Cache 同样值得关注。

现代机器人控制平台通常不是简单的 MCU,而可能使用多核 CPU,甚至同时运行:

ROS 2
+
视觉算法
+
AI推理
+
SLAM
+
路径规划
+
控制算法

这些程序都会访问内存。

当 AI 推理或者图像处理任务大量访问内存时,实时控制任务所使用的数据可能受到 Cache 和内存系统竞争影响。

例如:

CPU Core 0~3
AI / Vision
   ↓
大量内存访问
   ↓
Cache / Memory Bus
   ↑
CPU Core 4
   ↑
实时控制任务

即使控制线程已经绑定到 CPU Core 4,也并不意味着它与其他任务完全没有资源竞争。

这就是为什么上一期讨论的“CPU隔离”,还需要继续向“内存资源隔离”深入。

CPU 隔离解决的是谁可以运行在哪里;内存设计解决的是运行过程中访问什么资源、资源如何准备以及资源竞争如何控制。


四、预分配、内存池、Ring Buffer:实时系统如何管理内存?

那么,如果不能依赖实时运行阶段的动态内存分配,机器人系统应该怎么办?

答案并不是简单地“全部改成静态数组”。

实际工程中通常会组合使用多种机制。

第一种就是预分配。

例如机器人有 7 个关节:

JointState
 ├── position[7]
 ├── velocity[7]
 └── effort[7]

这些数据的规模是固定的,就可以在初始化阶段直接建立对应的数据结构。

第二种是内存池。

例如需要频繁创建和释放控制消息,可以提前准备:

Memory Pool
 ┌─────────┐
 │ Block 0 │
 ├─────────┤
 │ Block 1 │
 ├─────────┤
 │ Block 2 │
 ├─────────┤
 │ Block 3 │
 ├─────────┤
 │   ...   │
 └─────────┘

控制任务需要对象时:

Pool → 获取Block

处理完成:

Block → 返回Pool

而不是:

malloc → 使用 → free

第三种是 Ring Buffer,也就是环形缓冲区。

它非常适合处理周期性生产和消费的数据。

例如:

        写指针 →
 ┌────┬────┬────┬────┬────┬────┐
 │ D1 │ D2 │ D3 │ D4 │    │    │
 └────┴────┴────┴────┴────┴────┘
                    ↑
                  读指针

传感器线程持续写入数据,控制线程持续读取数据。

只要设计好并发访问机制,就可以减少大量动态分配。

第四种是对象复用。

例如一个控制周期产生:

SensorData
ControlState
ControlCommand

不一定每次都重新创建对象。

可以:

初始化
 ↓
创建对象
 ↓
控制周期1:写入
 ↓
控制周期2:覆盖
 ↓
控制周期3:覆盖
 ↓
……

这样就把“内存管理问题”从每一个控制周期中拿了出来。

在 ROS 2 体系中,还可以进一步关注 intra-process communication、loaned message、zero-copy 等机制。

它们的核心目标之一,就是减少不必要的数据复制和内存操作。

例如传统数据传递可能类似:

Node A
  ↓
复制
  ↓
DDS
  ↓
复制
  ↓
Node B

而更高效的机制可能尝试让数据在更少的内存拷贝情况下完成传递:

Node A
   ↓
共享/借用的数据
   ↓
Node B

但这里也需要避免一个误区:

零拷贝并不等于自动实时。

减少 memcpy 可以降低 CPU 开销,但共享数据之后又会带来新的问题:

数据生命周期
线程同步
锁竞争
Cache一致性
所有权管理
并发访问

所以实时系统设计不是简单地追求“拷贝越少越好”。

真正需要优化的是:

整个数据生命周期是否具有可预测性。

这也是为什么实时控制系统经常需要同时考虑:

内存分配
+
内存访问
+
数据复制
+
线程同步
+
Cache
+
CPU调度

而不是只盯着某一个指标。


五、ROS 2实时内存设计:把“实时域”和“非实时域”分开

如果把前面几篇文章串起来,会发现一个非常清晰的逻辑。

最开始讨论 ROS 2 Executor 时,我们关注:

Callback什么时候执行?

然后讨论 Linux Scheduler:

Thread什么时候获得CPU?

再进一步讨论 CPU 核心隔离:

Thread在哪里运行?

接着讨论资源隔离:

实时任务如何避免受到AI、视觉、网络等任务干扰?

现在进入内存层面:

实时任务运行时,
内存是否已经准备好?
是否会动态分配?
是否会发生不可控的页面访问?
是否存在Cache和内存带宽竞争?

最终可以形成一个更加完整的 ROS 2 实时系统模型:

                    ROS 2
                      │
              ┌───────┴───────┐
              │               │
          Executor          DDS/QoS
              │               │
              └───────┬───────┘
                      ↓
                   Thread
                      ↓
                Linux Scheduler
                      ↓
              CPU / Core Isolation
                      ↓
                IRQ / Driver
                      ↓
             Memory / Cache / I/O
                      ↓
                  Hardware

真正的实时性,实际上贯穿了整个链路。

因此,对于一个机器人平台,可以把任务进一步划分成两个世界。

实时域

例如:

关节控制
伺服控制
实时状态更新
硬实时通信
安全相关控制

这些任务更加关注:

确定性
最坏延迟
周期稳定性
deadline
资源隔离

它们的内存策略可以设计为:

初始化阶段
    ↓
预分配
内存池建立
缓冲区建立
对象建立
内存锁定
必要的预触碰
    ↓
进入实时运行阶段
    ↓
尽量避免malloc/new/free
    ↓
复用已有资源

非实时域

例如:

日志
可视化
地图处理
模型加载
文件操作
部分AI推理
后台服务
诊断

这些任务更加关注:

吞吐量
灵活性
开发效率
资源利用率

它们可以使用更加灵活的动态内存机制。

于是整个机器人系统可以形成:

┌──────────────────────────────────────┐
│             ROS 2 Robot              │
│                                      │
│  ┌────────────────┐ ┌─────────────┐ │
│  │   实时控制域   │ │ 非实时计算域 │ │
│  │                │ │             │ │
│  │ Joint Control  │ │ AI          │ │
│  │ Servo          │ │ Vision      │ │
│  │ State Update   │ │ SLAM        │ │
│  │ Safety         │ │ Planning    │ │
│  │                │ │ Logging     │ │
│  └───────┬────────┘ └──────┬──────┘ │
│          │                 │         │
│     资源隔离/通信边界/数据交换       │
└──────────┴─────────────────┴─────────┘

这样的架构并不是要求所有 ROS 2 节点都变成严格实时任务。

恰恰相反,它强调的是:

只有真正需要确定性执行的任务,才应该进入实时域。

这是机器人实时系统设计非常重要的一条原则。

如果把所有任务都强行设置成高优先级、全部绑定到实时 CPU,最终很可能导致系统资源利用率下降,甚至产生新的调度问题。

真正合理的设计应该是:

任务分类
   ↓
实时性需求分析
   ↓
确定deadline
   ↓
确定CPU资源
   ↓
确定内存策略
   ↓
确定调度策略
   ↓
确定IRQ与驱动策略
   ↓
最终形成实时域

而这也意味着,实时操作系统或者实时 Linux 的价值,并不仅仅是“让程序跑得更快”。

更重要的是,它能够为实时任务提供更加明确的调度、资源管理和隔离基础。

对于需要更强硬实时能力、确定性和资源隔离能力的机器人、工业控制、运动控制等系统,可以进一步考虑将 ROS 2 部署在具备相应实时能力的操作系统环境中,例如望获rtLinux。

但需要再次强调:

换成实时操作系统并不会自动解决所有实时问题。

如果应用程序依然在控制循环中频繁进行动态内存分配,如果数据结构设计存在严重共享竞争,如果 IRQ、驱动、I/O、DDS、Executor 没有合理配置,那么仅仅更换底层操作系统也不能保证控制周期一定稳定。

真正的实时系统应该是:

ROS 2架构
    +
Executor设计
    +
线程调度
    +
CPU核心隔离
    +
IRQ隔离
    +
内存管理
    +
资源隔离
    +
驱动与硬件
    +
实时操作系统

共同形成的结果。


如何验证:不要只看CPU利用率,要真正测“最坏情况”

最后还有一个非常关键的问题:

如果我们已经做了预分配、CPU隔离、内存锁定和实时调度,怎么知道系统到底有没有改善?

不能只看:

CPU利用率:30%
内存使用率:40%
平均延迟:20 μs

这些指标对于实时系统来说远远不够。

更应该关注:

Average Latency
Worst-case Latency
Jitter
Deadline Miss
Page Fault
Callback Start Delay
Control Cycle Time

例如:

控制周期:
1 ms

正常:
998 μs
1001 μs
999 μs
1000 μs
1002 μs

异常:
1450 μs

平均值可能依然接近 1 ms。

但对于实时控制系统来说,真正值得关注的是:

为什么出现了这一次 1450 μs?

继续追踪:

Callback延迟
    ↓
Thread调度延迟?
    ↓
CPU竞争?
    ↓
IRQ?
    ↓
锁?
    ↓
内存分配?
    ↓
Page Fault?
    ↓
Cache / Memory竞争?
    ↓
驱动?

这才是实时系统真正的排查方式。

因此,当 ROS 2 机器人系统开始从 Demo 走向真实控制场景时,实时性优化也应该从:

“程序能不能运行”

逐步升级为:

“程序能不能稳定运行”

再进一步升级为:

“程序在最坏情况下,
能不能仍然满足时间约束?”

这三个问题看起来非常接近,实际上对应的是完全不同的软件工程阶段。

ROS 2解决的是机器人软件模块如何组织和协作;Executor决定回调如何组织执行;Linux调度器决定线程如何获得CPU;CPU和IRQ隔离解决计算资源干扰;内存设计则进一步解决运行过程中资源访问的确定性。

当这些层次逐渐串起来以后,才真正能够理解为什么一个看起来只是“1 kHz 控制循环”的机器人程序,背后实际上涉及整个操作系统和硬件平台。

而下一步,还可以继续向通信链路深入。

如果内存分配会影响实时性,那么数据复制同样值得研究:ROS 2 消息为什么需要序列化?DDS到底复制了几次数据?共享内存通信和 Zero-Copy 到底解决了什么问题?为什么“零拷贝”减少了开销,却不代表系统天然具备实时性?

这将进入 ROS 2 实时控制的另一个核心问题:

《ROS 2实时控制为什么需要零拷贝?从DDS、共享内存到实时通信机制解析》

从这里继续,就可以把 ROS 2 的 Executor → 调度 → CPU隔离 → 资源隔离 → 内存 → 零拷贝 → ros2_control 整条技术链串起来。

Logo

DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。

更多推荐