在 ROS 2 机器人控制系统中,一个常见的设计思路是:使用 ROS 2 节点接收运动指令、计算目标位置,再通过 EtherCAT 将控制指令发送给伺服驱动器。
这种架构直观、模块化程度高,也方便开发者利用 ROS 2 的通信机制组织机器人软件。但当系统进入毫秒级甚至更严格的实时控制场景时,一个问题就会逐渐暴露出来:如果底层控制周期依赖 ROS 2 回调及时执行,那么当上层节点出现负载波动时,伺服控制周期还能保持稳定吗?
例如,一台六轴机械臂需要每隔 1 ms 更新一次关节目标,并通过 EtherCAT 与伺服驱动器交换过程数据。与此同时,ROS 2 系统还在处理轨迹规划、传感器数据、状态发布、日志记录和用户指令。
如果把所有操作都放在同一条回调链路中,一旦某个回调执行时间增加,或者多个回调竞争执行资源,就可能影响底层控制任务的时序。
这并不意味着 ROS 2 不适合机器人实时控制。真正需要解决的问题是:哪些工作应当由 ROS 2 的通用通信和执行机制负责,哪些工作必须由具有明确周期和截止时间的底层实时线程负责?
本文将围绕 ROS 2 与 IgH EtherCAT Master 的协作架构,分析控制节点与实时线程分离的必要性、数据交接方式、双缓冲设计、目标值过期处理和系统级验证方法,并讨论如何在望获rtLinux 等实时运行平台上评估这类架构。
一、为什么不能简单地让 ROS 2 回调直接承担所有实时控制工作?
要理解线程分离的必要性,首先需要区分 ROS 2 的执行模型和底层周期控制任务的要求。
ROS 2 提供了节点、Topic、Service、Action、Executor 和 Callback Group 等机制,帮助开发者组织分布式或组件化的机器人应用。它们能够很好地支持传感器数据处理、运动规划、任务编排和状态交互。
但这些机制本身并不意味着每个回调都会在指定时刻执行,也不自动保证一条完整的回调链路能够在固定截止时间内完成。
1. ROS 2 Executor 负责执行回调,不是天然的周期控制保证
以一个典型的机器人系统为例:
轨迹规划节点生成关节目标。
控制节点订阅目标数据。
控制节点根据反馈计算新的输出。
EtherCAT 主站发送过程数据。
状态节点发布关节位置和驱动器状态。
如果开发者将这些操作组织在同一条回调链路中,回调的执行时机就可能受到 Executor 调度方式、线程数量、其他可执行实体、回调执行时间和系统负载等因素影响。
例如,某个回调不仅更新目标值,还执行文件写入、复杂数据转换或同步等待。当这个回调占用执行线程时,其他需要同一线程的工作可能无法及时运行。
即使为 Executor 配置了多个线程,也不意味着所有回调之间不存在资源竞争。多个线程可能同时访问共享数据,也可能因为互斥锁、CPU 资源竞争和其他阻塞操作而相互影响。
因此,不能仅仅因为某个 ROS 2 Timer 设置为 1 ms,就认定其回调一定会每隔 1 ms 准时完成。
需要注意,ROS 2 的实际执行行为还与具体发行版、Executor 实现、RMW 中间件、回调组织方式和系统配置有关。是否满足实时要求,必须结合实际环境验证。
2. 1 ms 控制周期意味着什么?
假设某个机械臂的底层控制任务周期为 1 ms,那么理论上每秒需要执行 1000 次周期任务。
每次任务都需要在预定时序下完成必要工作,例如:
获取本周期可用的反馈数据。
检查数据有效性和驱动器状态。
执行控制算法。
更新目标过程数据。
完成 EtherCAT 周期通信流程中的必要操作。
记录执行时间与异常状态。
这里需要区分三个概念:
周期(Period):任务计划多久执行一次。
执行时间(Execution Time):任务实际运行需要多长时间。
截止时间(Deadline):任务最晚必须完成的时刻。
如果周期为 1 ms,并不意味着任务一定拥有完整的 1 ms 计算预算。通信阶段、从站同步时序和应用程序的执行顺序,都可能进一步限制可用时间。
如果一次任务超过截止时间,即使后续任务恢复正常,也不能简单地认为前一次超期没有影响。对于运动控制而言,过期指令、延迟反馈和连续超期都可能改变系统的动态响应。
因此,底层实时线程的设计目标,不只是“尽可能快”,而是让执行路径具有明确的时间预算,并能够检测和处理超期情况。
3. 为什么需要把控制周期从上层回调中解耦?
一种更清晰的设计方式,是将机器人软件分为两个执行层次。
第一层是 ROS 2 应用层,负责轨迹规划、指令接收、状态处理和任务协调。
第二层是底层实时控制层,负责固定周期的反馈采集、控制计算和 EtherCAT 数据交换。
两者通过明确的数据接口交换信息,而不是依赖上层回调每次都准时触发底层控制。
ROS 2 应用层
轨迹规划、目标生成、Topic 通信、状态展示和非关键数据处理。
有界、明确的数据交接
底层实时控制线程
周期唤醒、反馈读取、控制计算、PDO 更新、超期监测。
EtherCAT 主站与伺服设备
周期通信、过程数据交换、驱动器状态与运动执行。
这套结构的价值在于:即使 ROS 2 上层节点短时间内没有生成新目标,底层周期线程仍然可以按照自身的调度机制运行,并依据已经定义好的数据有效性规则处理当前周期。
但要强调,线程分离本身并不自动产生硬实时保证。底层线程依然需要合理配置调度策略、CPU 资源、内存、锁和设备访问路径,并在目标平台上验证。
二、ROS 2 与 EtherCAT 实时线程之间,应该如何设计数据交接?
将上层和底层分开之后,最重要的问题之一就是数据交接。
如果两个线程需要交换关节目标、反馈状态或驱动器状态,那么必须保证数据的一致性,同时避免实时线程因为等待数据而产生不可预测的延迟。
数据交接的方式不只有一种。具体选择取决于数据更新频率、生产者与消费者数量、数据量、是否允许丢弃中间状态,以及应用程序的实时要求。
1. 不要让实时线程同步等待上层节点
假设 ROS 2 轨迹节点生成了新的关节目标,底层控制线程需要使用这个目标。
如果底层线程采用同步请求的方式,等待上层节点处理请求后才继续,那么只要上层回调变慢,底层控制就可能被拖延。
对于需要固定周期运行的控制任务,这种依赖关系通常需要避免。
更合理的方式是让上层将最新的有效目标写入共享数据接口,底层线程在每个周期读取当前可用的数据。如果没有新目标,底层线程根据预先定义的策略处理,而不是无限等待。
这里的“读取最新目标”也有边界:如果系统需要完整保留每一个轨迹采样点,就不能简单地覆盖尚未消费的中间数据;如果系统只需要最新的关节位置或速度设定值,则覆盖旧目标可能是合理的。
因此,设计之前需要先明确数据语义。
2. 区分“最新状态数据”和“必须按顺序处理的数据”
机器人控制系统中的数据并不都适合使用相同的队列策略。
| 数据类型 | 常见处理方式 | 设计重点 |
|---|---|---|
| 最新关节目标 | 最新值覆盖或有界缓冲 | 目标是否过期、更新是否连续 |
| 关节反馈 | 使用一致的当前快照 | 时间戳、采样时刻与数据有效性 |
| 驱动器状态 | 状态快照或事件队列 | 状态转换不能被错误忽略 |
| 轨迹采样点 | 有界队列或专用轨迹缓存 | 保持顺序、处理队列积压 |
| 诊断日志 | 异步队列 | 不阻塞实时线程 |
| 故障与安全相关事件 | 明确的事件传递机制 | 不得因普通数据覆盖而丢失 |
例如,如果上层每 10 ms 发送一次目标位置,而底层每 1 ms 执行一次控制周期,那么底层可能会在两个目标更新之间多次使用同一个目标。
这种行为是否合理,取决于控制模式和接口定义。对于位置设定值,这可能是可接受的;对于轨迹采样点,则可能需要按照顺序消费缓存中的每个点。
数据交接的关键不是采用哪一种容器,而是明确数据应该保留、覆盖、排队还是拒绝。
3. 互斥锁是否一定不能用于实时线程?
并不是所有互斥锁都不能用于实时程序。
问题在于:锁竞争可能使线程等待,而等待时间取决于持锁线程何时释放锁,以及系统中的其他调度和资源竞争情况。
如果底层控制线程在关键周期中获取一把锁,而持锁线程正在执行复杂计算或阻塞式 I/O,那么控制线程的执行时间就可能变得不可预测。
因此,在实时路径中应尽量避免长时间持锁,并且需要评估所有可能的竞争关系。
对于结构简单、读写关系明确的数据,可以考虑使用预分配的固定大小缓冲区、双缓冲或经过验证的无锁数据结构。但无锁并不等于没有风险:错误的内存顺序、读写竞争和对象生命周期问题,同样可能导致数据不一致。
如果采用原子操作,需要根据数据结构、编译器和目标平台确认其行为。不能假定任何 C++ 原子操作在所有架构上都必然无锁,也不能仅凭“使用了无锁队列”就认定程序满足实时要求。
三、双缓冲如何帮助 ROS 2 与实时控制线程交换数据?
双缓冲是一种常见的数据交接思路。它的基本目标是让生产者准备新数据时,消费者仍然能够读取一份完整的数据,而不是读到更新过程中的半成品。
例如,ROS 2 上层需要更新六个关节的目标位置。如果逐个更新共享数组,而底层线程恰好在更新中途读取,就可能出现一部分关节使用新目标、另一部分关节使用旧目标的情况。
对于多轴机器人,这类数据不一致可能破坏同一控制周期内的目标一致性。
双缓冲可以将一组相关数据作为整体发布和读取,从而降低这类风险。
1. 基本工作原理
假设有两个固定大小的缓冲区:
缓冲区 A:当前供实时线程读取。
缓冲区 B:上层线程准备下一组数据。
当上层完成一组数据的写入后,通过经过验证的同步机制发布新数据。实时线程在适当时机获取一个完整的数据版本,而不是逐个读取尚未完成更新的字段。
需要注意,双缓冲只是设计模式,不是一个自动正确的并发算法。具体实现必须确保:
发布操作与读取操作之间具有正确的同步关系。
写线程不会在读线程仍然使用某个缓冲区时覆盖它。
多字段数据不会出现新旧版本混杂。
内存分配和对象管理不会在关键周期内引入不可预测操作。
多生产者或多消费者的情况有明确处理规则。
对于复杂场景,可能需要三缓冲、固定大小队列,或者经过验证的单生产者单消费者队列。
2. 一个简化的数据结构示例
下面的代码展示了可以放入共享缓冲区的目标数据。它只是数据结构示意,并不是完整的双缓冲实现。
C++
#include <array> #include <cstdint> constexpr std::size_t kJointCount = 6; struct JointTarget { std::array<double, kJointCount> position{}; std::array<double, kJointCount> velocity{}; // 由应用定义的单调递增版本号 std::uint64_t sequence = 0; // 目标生成时刻,使用统一约定的单调时钟 std::int64_t timestamp_ns = 0; bool valid = false; };这里使用固定大小的数组,目的是让数据规模明确,避免每次更新目标时都必须动态调整容器容量。
sequence可以帮助系统识别数据是否更新,timestamp_ns可以用于检查数据新鲜度,valid则用于表达当前目标是否满足应用定义的有效性条件。
但这些字段本身不会解决线程安全问题。实际实现仍然需要采用正确的缓冲区发布与读取机制,并定义时间戳使用的时钟来源和单位。
3. 为什么时间戳和序列号很重要?
假设 ROS 2 节点因为计算负载而暂停了 20 ms,恢复后才向底层发布新的目标。
如果底层控制线程只检查“是否存在一个目标”,那么它可能把已经过期的目标误认为有效指令。
通过记录目标生成时刻、序列号和有效状态,底层线程可以根据应用规则判断:
当前目标是否比上一次更新。
数据是否超过允许的有效时间。
是否存在更新中断或队列积压。
是否需要继续执行当前策略,或者进入预定义的降级处理。
时间戳需要特别谨慎。若生产者与消费者使用不同的时钟域,就不能直接比较两者的时间数值。即使都使用单调时钟,也需要确认它们来自同一时钟基准或具有明确的换算关系。
此外,目标数据新鲜度与安全性不是同一个概念。对于不同控制模式,过期数据可能意味着保持最后有效目标、减速、停止接受新指令,或触发受控停止。具体策略应由控制设计和风险分析决定。
四、底层实时线程应该如何组织?从周期唤醒到超期处理
数据接口确定之后,底层控制线程还需要有明确的执行流程。
以 1 ms 控制周期为例,线程应按照规定时序执行必要的 EtherCAT 主站调用、反馈处理和控制计算,而不是依赖 ROS 2 回调的执行时间来决定何时启动。
1. 使用明确的周期唤醒机制
在 Linux 上,可以考虑使用基于单调时钟的绝对时间等待方式,例如clock_nanosleep()配合CLOCK_MONOTONIC和TIMER_ABSTIME。
其主要价值在于使用明确的目标唤醒时刻,避免简单地反复执行“处理任务,然后相对等待 1 ms”所可能造成的周期漂移。
下面是一个简化的示意代码,展示周期调度思路,而不是可以直接用于实际机器人设备的完整 EtherCAT 程序。
C++
#include <cerrno> #include <ctime> constexpr long kPeriodNs = 1'000'000L; // 1 ms static void add_ns(timespec& ts, long ns) { ts.tv_nsec += ns; while (ts.tv_nsec >= 1'000'000'000L) { ts.tv_nsec -= 1'000'000'000L; ++ts.tv_sec; } } void control_loop() { timespec next{}; clock_gettime(CLOCK_MONOTONIC, &next); while (true) { add_ns(next, kPeriodNs); int rc; do { rc = clock_nanosleep( CLOCK_MONOTONIC, TIMER_ABSTIME, &next, nullptr); } while (rc == EINTR); if (rc != 0) { // 实际项目中需要记录并处理等待失败 continue; } // 1. 读取一致的反馈快照 // 2. 获取当前有效的目标数据 // 3. 执行控制计算 // 4. 更新过程数据 // 5. 执行必要的 EtherCAT 周期操作 // 6. 记录执行时间与超期状态 } }这段代码有几个重要限制。
首先,实际应用需要定义线程启动时刻和第一个周期的语义,确保不会意外改变预期周期。
其次,不能只在等待函数返回错误时处理异常。还需要在每个周期检查实际唤醒时间、任务执行时间和截止时间,并明确如何处理周期超期。
最后,EtherCAT 主站 API 的调用顺序应依据实际主站版本、应用架构和从站同步方式确定。示意代码不能替代完整的通信时序设计。
2. 为实时线程准备运行环境
对于目标平台,可以评估以下措施:
使用适当的实时调度策略和优先级。
根据实际 CPU 拓扑配置线程亲和性。
在经过评估后配置核心隔离和 IRQ 分布。
使用
mlockall()等机制评估并减少运行期间的内存换页风险。在进入实时周期前完成必要的内存分配与初始化。
避免在关键周期内进行文件 I/O、复杂日志输出或无界等待。
对互斥锁、原子操作和跨线程数据结构进行专门审查。
这些措施需要结合内核版本、权限配置和应用程序实现验证。它们不是简单地全部启用就能获得更好的结果:错误的优先级安排、过度隔离或不合理的 IRQ 分布,也可能影响整体系统表现。
3. 超期之后不能只靠继续执行来解决问题
假设某个周期任务原本应该在 1 ms 内完成,但由于资源竞争或通信异常,实际用了 1.3 ms。
此时,系统需要区分不同情况:
单次超期还是连续超期。
控制计算超期还是通信过程异常。
反馈数据是否仍然有效。
驱动器是否处于预期状态。
当前目标是否还能安全执行。
如果程序只在循环中继续运行,而没有记录超期事件,也没有定义目标过期或连续超期时的处理方式,就很难判断机器人系统在异常负载下是否仍然可靠。
因此,实时线程应当包含清晰的监测和故障处理机制,并避免将诊断日志的写入过程本身放入关键执行路径。
五、如何验证这套架构在望获rtLinux 上是否有效?
线程分离、双缓冲和周期唤醒机制,都是可以采用的工程设计方法。但它们是否真正改善了机器人控制系统的实时表现,需要通过目标平台的测试来确认。
对于望获rtLinux,建议将验证工作分为操作系统层、线程层、通信层和端到端应用层。
1. 操作系统层:检查基础调度表现
可以使用cyclictest等工具测量指定条件下的线程唤醒延迟,并记录测试时的内核版本、调度策略、CPU 亲和性、负载情况和测试时长。
但测试结果只能说明相应条件下观察到的唤醒行为,不能直接替代真实控制程序的执行时间测量。
2. 线程层:测量每个周期的实际执行时间
在真实控制线程中,记录以下指标:
| 指标 | 说明 |
|---|---|
| 实际唤醒时刻 | 线程实际开始执行的时间 |
| 唤醒延迟 | 实际唤醒时刻相对于预期时刻的偏差 |
| 周期执行时间 | 从开始到完成指定任务所用的时间 |
| 截止时间超期次数 | 超过定义的完成时限的次数 |
| 连续超期长度 | 连续出现超期的周期数量 |
| 目标数据年龄 | 当前使用的目标距离生成时刻有多久 |
| 数据版本变化 | 目标更新频率及可能存在的积压情况 |
这些指标有助于区分“线程启动得晚”和“线程启动后工作做得太慢”这两类不同问题。
3. 通信层:验证 EtherCAT 数据链路
在实际 IgH EtherCAT Master 应用中,还需要检查通信过程、过程数据域状态、工作计数器、从站状态以及相关同步配置。
工作计数器(WKC)可以用于判断相关数据交换是否获得预期的从站处理响应,但它本身不是完整的通信延迟或抖动指标。
如果应用使用分布式时钟(DC),还应单独验证同步配置和实际时序,不能仅根据主站线程周期稳定就推断从站采样与执行时序已经满足要求。
4. 端到端层:从目标生成到实际运动结果
最终还需要观察完整链路:
目标生成 → ROS 2 数据交接 → 实时线程读取 → 控制计算 → EtherCAT 数据交换 → 驱动器处理 → 实际反馈。
这条链路的延迟和波动可能来自多个阶段,单独测量任何一个阶段都不足以证明整体满足要求。
因此,测试应尽量使用统一的时间基准或明确的时间关联方法,记录关键事件,并在真实设备或适当的测试环境中开展验证。
对于实际机器人,还需要在安全可控的条件下测试负载变化、目标更新中断、通信异常和超期处理等情况。
5. 建议形成可复现的测试报告
一份有价值的测试报告,不应只写“最大延迟很低”或“实时性表现良好”,而应至少包含:
目标硬件与 CPU 拓扑。
操作系统、内核及相关配置。
ROS 2 发行版、RMW 实现和 Executor 配置。
IgH EtherCAT Master、网卡驱动及从站配置。
控制周期、截止时间和任务执行流程。
CPU、内存、I/O 与通信负载。
唤醒延迟、周期执行时间及超期统计。
目标数据过期和异常处理结果。
测试时长、重复次数与未覆盖条件。
这样才能让测试结论具有明确的适用范围,并为后续的系统优化提供依据。
总结:让 ROS 2 负责协作,让实时线程负责按时执行
ROS 2 与 EtherCAT 并不是互相替代的技术。ROS 2 适合组织机器人应用、轨迹规划和模块间通信;底层实时线程则可以负责具有明确周期与截止时间的控制工作。
通过将两者分离,采用经过验证的数据交接机制,并结合 PREEMPT_RT、实时调度和核心隔离,可以减少上层回调波动对底层控制周期的直接影响。
但需要记住三点:
线程分离不等于自动获得硬实时保证。 底层线程仍然需要合理的任务设计和运行环境配置。
双缓冲不等于自动实现线程安全。 数据发布、读取和缓冲区生命周期必须经过严格设计。
周期稳定不等于端到端控制稳定。 EtherCAT 通信、驱动器行为与实际运动结果仍然需要独立验证。
对于望获rtLinux,真正有意义的评估方式,是将内核实时能力、核心隔离与真实机器人控制应用结合起来,使用可复现的测试数据判断系统能否满足目标场景的实时性要求。
机器人实时控制的关键,不是让所有软件都以最高优先级运行,而是明确每项任务的职责、时序和依赖关系,让关键任务在规定条件下按时完成。