ARTICLE DETAIL

资讯详情

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

ROS 2实时控制为什么需要内存隔离?动态内存分配为什么可能成为机器人实时系统的隐患?

ROS 2实时控制为什么需要内存隔离?动态内存分配为什么可能成为机器人实时系统的隐患? 在讨论 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::vectordouble errors; for (auto joint : joints) { errors.push_back(calculate_error(joint)); } calculate_control(errors); }看起来只是一个普通的 C 写法。但是std::vector在容量不足时可能需要重新分配内存vector ↓ capacity不足 ↓ 申请更大的内存 ↓ 复制/移动原有元素 ↓ 释放旧内存 ↓ 继续执行如果这段代码位于 1 kHz 控制循环中那么执行时间就可能出现变化。一个更适合实时路径的思路是std::vectordouble 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 SchedulerThread什么时候获得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调度器决定线程如何获得CPUCPU和IRQ隔离解决计算资源干扰内存设计则进一步解决运行过程中资源访问的确定性。当这些层次逐渐串起来以后才真正能够理解为什么一个看起来只是“1 kHz 控制循环”的机器人程序背后实际上涉及整个操作系统和硬件平台。而下一步还可以继续向通信链路深入。如果内存分配会影响实时性那么数据复制同样值得研究ROS 2 消息为什么需要序列化DDS到底复制了几次数据共享内存通信和 Zero-Copy 到底解决了什么问题为什么“零拷贝”减少了开销却不代表系统天然具备实时性这将进入 ROS 2 实时控制的另一个核心问题《ROS 2实时控制为什么需要零拷贝从DDS、共享内存到实时通信机制解析》从这里继续就可以把 ROS 2 的Executor → 调度 → CPU隔离 → 资源隔离 → 内存 → 零拷贝 → ros2_control整条技术链串起来。
返回列表