摘要
现场碰到过一个底盘控制问题:车能动,但动作不连贯,设备侧偶尔报控制超时。
刚开始看,很容易怀疑发布频率不够、网络抖动,或者底盘超时保护设得太紧。最后查下来,真正卡住的是 ROS 2 订阅端的 QoS 队列。
当时底盘控制和货叉控制复用了同一个 Topic、同一个消息类型。订阅端 QoS 又配置成了:
auto qos = rclcpp::QoS(rclcpp::KeepLast(1));
KeepLast(1) 用在状态类消息上问题不大,反正只关心最新值。但底盘控制不是状态刷新,它靠连续控制帧维持动作。中间几帧如果被后来的消息顶掉,设备侧看到的就是控制命令间隔变大。间隔一大,超时保护就会触发。
这篇文章记录一次真实排查过程。重点很简单:
底盘能动但不流畅,设备侧又报控制超时,别只盯发布端和网络,订阅端队列也要查。
一、问题现象
当时不是底盘完全没反应,而是这种状态:
- 底盘能按指令动作
- 运动过程不够平滑,偶尔像断了一下
- 设备侧打印控制超时
- 上层看起来还在持续发布控制命令
- 操作频繁,或者底盘和货叉接近同时控制时,更容易复现
底盘侧的超时保护并不知道发布端有没有执行 publish(),它只看自己有没有在规定时间内收到下一帧控制命令。
所以看到“控制超时”,只能先说明一件事:底盘收到的控制流断过。至于断在发布端、网络、订阅队列,还是设备发送线程,这时还不能拍板。
二、先不要急着放宽超时
看到设备报超时,第一反应往往是把底盘保护阈值调大。
这个办法可能会缓解现象,但排障时不建议先这么做。超时保护只是把链路上的间隔问题暴露出来,直接放宽阈值,容易把真正的问题藏起来。
我当时先把控制链路拆开看:
- 发布端是否按预期频率发
- 订阅端回调是否按同样节奏收到
- 回调到设备发送之间有没有阻塞
- 同一个 Topic 里有没有混入其它控制消息
- QoS 的
history / depth / reliability是否符合控制语义
这次真正有用的是后两项。
继续往下看,底盘控制和货叉控制走的是同一个 Topic。
三、同一个 Topic 混了多类控制
这条链路里,底盘和货叉没有拆成两个控制入口,而是共用了:
- 同一个话题名
- 同一个消息类型
- 同一个订阅端 QoS 队列
这就意味着,底盘控制帧和货叉控制帧都会进入同一个消息队列。
如果只是偶尔发一条动作命令,这种设计不一定马上出问题。但底盘控制更敏感,它不是发一条就结束,而是依赖连续控制帧保持运动。
这里怕的不是少一条日志,也不是少一次状态刷新,而是控制帧之间的节奏被打断。
四、问题落在 KeepLast(1)
当时 QoS 配置大致是这样:
auto qos = rclcpp::QoS(rclcpp::KeepLast(static_cast<size_t>(depth)));
实际配置里:
depth = 1;
也就是:
auto qos = rclcpp::QoS(rclcpp::KeepLast(1));
ROS 2 的 QoS 里,Keep last 表示只保存最近 N 个样本,Depth 就是这个队列大小,并且只在 keep last 策略下生效。
所以 KeepLast(1) 的含义很直白:队列里只留最近一条消息。
如果 Topic 传的是当前位置、电量、诊断摘要,这通常能接受。旧状态被新状态覆盖,大多数场景下没问题。
但底盘控制帧不是状态。它是连续控制输入。
中间帧被覆盖后,设备侧看到的不是“少刷新了一次”,而是两帧控制命令之间的间隔变长。只要这个间隔超过底盘保护阈值,超时日志就会出现,运动也会变得不连续。
这次问题基本就是:
底盘控制和货叉控制复用同一个 Topic,订阅端 QoS 又是
KeepLast(1)。多类控制消息短时间进入同一个队列时,前面的底盘控制帧可能还没被消费,就被后面的消息覆盖了。
五、为什么像网络抖动
这个问题容易误判,因为发布端和设备端看到的都不像“队列问题”。
发布端看起来正常:命令确实发出去了。
底盘侧也没报假:它确实没按时收到下一帧。
真正出问题的是中间这段:
如果只看两头,很容易把它归到网络抖动、DDS 丢包,或者发布频率不稳。
但这次节奏丢在订阅端队列里,不在两头。
六、调整 depth 后怎么验证
当时没有先改设备协议,也没有先放宽底盘超时保护,而是先把 QoS 深度调大。
原来是:
auto qos = rclcpp::QoS(rclcpp::KeepLast(1));
改成:
auto qos = rclcpp::QoS(rclcpp::KeepLast(10));
改完以后,底盘控制连续性明显改善,控制超时日志也消失或明显减少。
这个结果基本能说明,问题不是上层完全没发够,也不是底盘保护逻辑写错了。原来的 depth=1 不适合这条控制链路。
不过这里不能把结论说满。把 depth 调大只是给突发消息留出缓冲,让订阅端有机会消费前面的控制帧。如果发布速度长期超过消费速度,或者回调里有阻塞 IO、长时间持锁、同步等待,继续加 depth 也只是把问题推迟。
七、后续怎么改更稳
这件事不要只记成“把 1 改成 10”。
更该回头看 Topic 设计。这个坑之所以能出现,是因为两类控制挤在了一个入口里。
1. 能拆 Topic 就拆
底盘和货叉本来就是两类控制。能拆的话,直接拆开更清楚:
/chassis_cmd/fork_cmd
这样至少不用抢同一个缓存队列。QoS 可以分别调,日志也更好判断。后面再查问题,不用先猜到底是哪类动作把队列顶掉了。
很多项目里,拆 Topic 比继续在一个消息类型里加动作字段更省事。
2. 不能拆,就补队列和确认
如果架构上必须保留统一控制 Topic,那就不能把它当普通状态流用。
至少要考虑这些东西:
- 动作类型
- 命令序号
- 应用层队列
- 顺序处理
- 优先级仲裁
- ACK 或状态回读
- 超时和失败处理
一个 Topic 可以作为统一入口,但不能只靠 KeepLast(1) 和“最后一条消息”承载多类控制。
3. 回调里别做重 IO
还有一个常见坑:订阅回调里直接做串口同步写、CAN 阻塞发送、长时间持锁,甚至 sleep。
这样写起来省事,出问题时很难查。
更稳一点的结构是:
- ROS 回调只接收、校验、入队
- 后台线程负责设备下发
- 控制入口和设备 IO 解耦
这样至少能分清楚:ROS 层有没有收到,应用队列有没有积压,设备发送有没有卡住。
八、控制类 Topic 的 QoS 建议
控制类 Topic 不建议顺手写 depth=1。
至少要根据控制频率、设备超时阈值和订阅端处理耗时,给突发场景留一点余量。比如:
auto qos = rclcpp::QoS(rclcpp::KeepLast(10));
具体是 5、10 还是 20,要看现场频率和消费速度。重点不是固定某个数字,而是别把连续控制当成状态刷新。
如果控制消息不能轻易丢,再考虑 reliable:
auto qos = rclcpp::QoS(rclcpp::KeepLast(10)).reliable();
reliable 不等于设备一定执行成功,也不能替代 ACK。它只是让 ROS 2 通信语义更接近可靠送达。后面仍然要看订阅端是否处理、设备侧是否执行。
停车、升降停止、模式切换、故障恢复这类动作,最好再补应用层确认、状态回读、超时检测和必要的重发。ROS 2 的 QoS 只能解决通信层的一部分问题,不能替你保证设备真的执行了命令。
小结
这次问题表面上是底盘运动不流畅,设备侧报控制超时。看起来像发布频率不够,或者网络抖动。
根因是底盘和货叉共用了同一个 ROS 2 Topic,订阅端 QoS 又用了 KeepLast(1)。多类控制消息短时间进入同一个队列时,底盘控制帧可能还没被消费,就被后面的消息覆盖。
把 depth 从 1 调到 10 后,底盘控制连续性明显改善。这个结果说明,原先“只保留最新一条”的假设不适合这条控制链路。
最后记住两点:
- 底盘连续控制不能简单按状态流配置 QoS
- 看到控制超时,不要只查发布频率和网络,也要查订阅队列
评论区