机器人软件栈全景

机器人软件栈从传感器驱动一路铺到规划控制,真正的难点不在单点算法而在时序、实时性与失效隔离。本文以分层视角拆解感知、定位、规划、控制、执行与运维各层的职责边界和数据契约,给出话题频率、端到端延迟预算、算力分配与日志回放的具体参数,并对比 ROS2 与自研中间件的取舍。

引言

机器人软件栈的复杂度从来不在单个算法。SLAM、运动规划、阻抗控制这些题目各自都有成熟教科书,但把它们拼成一台能在现场连续跑 8 小时不出故障的机器,是另一回事。真正吃掉工程师半年时间的是:点云话题在 20 Hz 和 30 Hz 之间抖动时规划器行为不一致、控制器线程被日志写盘阻塞 3 ms 导致关节抖动、视觉检测延迟 80 ms 让抓取在动态传送带上永远慢半拍。

这类问题的共同点是它们都跨越了「算法层」与「系统层」的边界。一个典型的移动操作平台在运行时至少有六类任务在竞争 CPU:IMU 与轮式里程计的 1 kHz 读取、激光雷达 1020 Hz 的点云预处理、视觉 30 Hz 的推理、全局规划的不定期长耗时搜索、控制器 500 Hz1 kHz 的伺服循环,以及遥测与日志的异步落盘。它们的截止时间从 1 ms 到数秒不等,混在同一个调度器里就会互相伤害。

因此本文不按「感知/规划/控制」的教科书画分,而按数据契约加时间预算来组织:每一层对外承诺什么频率、什么延迟上限、什么坐标系与时间戳语义,以及违反承诺时上游如何降级。这个视角更接近真实工程,也更容易在评审会上达成一致。栈中每一层的算法细节会在同专题其他文章中展开,本文只讲它们在整体中的位置与接口,例如运动学正逆解 与动力学与控制基础 。

读完本文你应当能够:画出自己项目的分层数据流图、为每条边标注频率与延迟预算、判断哪些模块必须放进实时线程、以及决定什么时候该引入 ROS2、什么时候该自己写中间件。

目录

  1. 分层模型与职责边界
  2. 数据契约:频率、时间戳与坐标系
  3. 中间件选型:ROS2、自研与共享内存
  4. 算力拓扑与硬件分工
  5. 时间系统与同步
  6. 行为层:状态机与任务编排
  7. 可观测性:日志、回放与在线诊断
  8. 从原型到量产的差距
  9. 工程化清单与团队分工

1. 分层模型与职责边界

把栈切成七层是最常见也最实用的划分,每一层的输入输出必须能一句话说清:

L0 驱动层      传感器/执行器原始数据,µs~ms 级,通常由内核驱动或厂商 SDK 提供
L1 硬件抽象    统一接口(ros2_control hardware_interface / 自研 HAL),1 kHz
L2 状态估计    里程计、IMU 融合、定位,50~1000 Hz,输出 TF 与位姿
L3 感知        点云分割、目标检测、抓取位姿,10~30 Hz
L4 世界模型    代价地图、语义地图、障碍物轨迹预测,5~10 Hz
L5 规划        全局路径 + 局部轨迹,0.1~10 Hz,输出带时间戳的轨迹
L6 控制        伺服循环,500 Hz~1 kHz,输出力矩/速度/位置指令
L7 行为/任务   状态机、任务编排、人机交互,事件驱动

分层的关键纪律是只允许相邻层或跨层读、不允许反向写。例如 L6 控制器可以订阅 L2 的 TF,但不能直接改 L4 的代价地图。反向写会让数据流变成图,调试时无法回答「这个值是谁改的」。

第二纪律是每层只暴露一个「面向下游」的接口。感知层内部可以有五个模型、三种坐标系,但对外只发布一个 DetectedObjectArray。内部实现迭代时下游零改动,这是保持迭代速度的核心。

第三纪律是明确「谁负责时间」。规划层输出的是带时间戳的轨迹而不是路径点序列,控制器负责按时间采样。如果规划层只给几何路径,控制层就得自己做时间参数化,速度约束会散落在两处,现场调参时会互相打架。

把这三条纪律落成一张表,就是评审时最有用的材料:

层输入输出频率延迟预算失败时降级
L1 硬件抽象编码器/CAN 帧JointState1000 Hz0.2 ms报故障码,停止使能
L2 状态估计IMU/轮速/雷达TF + 位姿100~1000 Hz5 ms切纯里程计,标记降级
L3 感知点云/图像目标列表10~30 Hz80 ms保持上帧,超时清空
L4 世界模型目标 + 地图代价地图5~10 Hz200 ms用静态地图兜底
L5 规划目标点 + 地图带时间戳轨迹0.1~10 Hz500 ms输出制动轨迹
L6 控制轨迹 + 状态力矩/速度指令500~1000 Hz1 ms零力矩 + 抱闸
L7 行为任务 + 状态目标/模式切换事件驱动秒级进 SAFE_STOP

2. 数据契约:频率、时间戳与坐标系

每条话题(或自研中间件里的每条通道)都应有一张卡片,至少包含下面五项。缺少任何一项,半年后接手的人就会踩坑。

channel: /lidar/points
type: sensor_msgs/PointCloud2
rate_hz: 10                 # 标称值
rate_tolerance: 0.9~1.1     # 允许波动范围,超范围上游告警
latency_budget_ms: 60       # 从采样到发布的最大延迟
frame_id: lidar_link
timestamp_semantics: 采样时刻(不是发布时刻)
qos: sensor_data            # best_effort, keep_last 5

timestamp_semantics 是最容易被忽略的一项。雷达驱动如果用发布时刻做时间戳,在负载高时点云会被「拉伸」到错误的时间轴上,SLAM 的位姿会系统性漂移,而且这种漂移在静止时看不出来。规范做法是驱动层读取硬件 PPS 或 PTP 时间,写入采样时刻。

坐标系必须遵守 REP-103 / REP-105 的约定:右手系、map 到 odom 到 base_link 到 sensor_link 的树状结构。map 到 odom 由定位模块发布(会跳变),odom 到 base_link 由里程计发布(连续但会漂移)。把这两段合并成一段是常见的错误,会导致定位跳变时控制器输出巨大加速度。

频率不匹配要用显式策略处理,而不是在回调里随手丢弃:

上游频率下游频率推荐策略
1000 Hz100 Hz滑动平均 + 抽取,注意相位延迟
10 Hz100 Hz保持最新值 + 时间戳外推
10 Hz10 Hz时间同步队列,容忍 ±20 ms
30 Hz10 Hz最近邻或双线性插值,禁止丢弃最新帧

频率自检的实现不能靠肉眼观察 ros2 topic hz,要在代码里做滑动窗口统计并主动告警:

class RateMonitor {
 public:
  explicit RateMonitor(double nominal_hz, double tol = 0.2)
      : nominal_(nominal_hz), tol_(tol) {}

  // 在回调入口调用,返回 false 表示频率异常
  bool tick(const rclcpp::Time & t) {
    if (last_.nanoseconds() > 0) {
      double dt = (t - last_).seconds();
      // 指数滑动平均,alpha 取 0.05 对应约 20 帧时间常数
      mean_dt_ = 0.95 * mean_dt_ + 0.05 * dt;
      double hz = 1.0 / mean_dt_;
      if (hz < nominal_ * (1 - tol_) || hz > nominal_ * (1 + tol_)) {
        ++violations_;
        return false;   // 上层据此降级或告警
      }
    }
    last_ = t;
    return true;
  }
  uint64_t violations() const { return violations_; }

 private:
  double nominal_, tol_;
  double mean_dt_ = 0.0;
  rclcpp::Time last_{0, 0, RCL_ROS_TIME};
  uint64_t violations_ = 0;
};

这段代码的关键点是「在回调入口统计」而不是「在发布端统计」。发布端只能看到自己发了多少,接收端的实际速率受 QoS、网络、执行器共同影响,才是真正决定算法行为的量。

3. 中间件选型:ROS2、自研与共享内存

ROS2 在 Humble(2022)之后已经可以作为产品级中间件,但它不是唯一选择。三条路线各自的适用边界很清晰:

纯 ROS2:
  优势:生态完整(Nav2、MoveIt2、ros2_control)、工具链成熟
  代价:DDS 发现开销、默认 QoS 不实时、序列化有拷贝
  适合:原型、研究、中小规模产品、非硬实时部分

ROS2 + 实时补丁:
  做法:控制回路用 rclcpp 但配 CycloneDDS + 固定线程 + PREEMPT_RT
  优势:保留生态的同时拿到 100 µs 级抖动
  适合:多数商用移动机器人、协作机械臂

自研/混合:
  做法:控制与状态估计走自研共享内存环,感知与任务走 ROS2
  优势:确定性最好,延迟可控到 20 µs
  代价:工具链要自己造,团队规模要求高
  适合:量产型号固定、对成本与确定性极敏感的产品

共享内存是绕开 DDS 序列化开销的常用手段。iceoryx 与 ROS2 的 rmw_iceoryx 可以做到零拷贝发布,1 MB 的点云从 3 ms 降到 200 µs 以内。代价是它依赖共享内存域,跨机器就退化成网络传输,需要在部署时明确哪些话题是进程内的。

一个实用判据:如果一个话题的消费者和生产者永远在同一台机器上,且频率高于 100 Hz 或体积大于 100 KB,就应该考虑零拷贝。低于这个阈值,DDS 的便利性远大于开销。

4. 算力拓扑与硬件分工

把什么放在哪里,是架构阶段影响最大的决定,后期几乎无法更改。常见拓扑有三种:

方案 A:单 SoC 全包(Jetson Orin NX 16GB,100 TOPS 级)
  感知 + 规划 + 控制都在一颗芯片
  优点:成本低、无网络延迟、调试简单
  缺点:控制回路与推理争抢,需 cgroup 隔离与 CPU 亲和性绑定

方案 B:双 SoC 分工(主控 x86 + 实时 MCU/ARM)
  主控:感知、规划、任务(Ubuntu + ROS2)
  实时侧:状态估计 + 控制(FreeRTOS / PREEMPT_RT),1 kHz
  通信:EtherCAT 或 1 Gbps 以太网,周期 1 ms
  优点:实时性隔离彻底,主控重启不影响安全停机
  缺点:跨芯片时间同步与调试复杂度上升

方案 C:集中式服务器 + 边缘(多机器人)
  服务器:全局调度、地图维护、模型推理
  边缘:本地控制与避障,断网可降级运行
  优点:算力可共享,模型可统一升级
  缺点:网络分区处理必须显式设计

CPU 亲和性与实时优先级必须显式配置,不能依赖调度器「恰好」工作:

# 把控制线程绑到隔离核,避免与推理抢 L2 缓存
sudo cset shield --cpu 2-3 --kthread on
taskset -c 2,3 ./robot_control_node --ros-args -p use_sim_time:=false

# 实时优先级(需要 CAP_SYS_NICE 或 rtprio 限制放宽)
chrt -f 80 ./robot_control_node
# /etc/security/limits.conf: robot_user - rtprio 99

内存也要隔离。推理节点在加载模型时可能瞬时分配几百 MB,如果与实时线程共享内存池,会触发直接回收(direct reclaim)导致毫秒级停顿。用 mlockall(MCL_CURRENT | MCL_FUTURE) 锁住实时进程的页,并给它预留独立的内存节点。

用 cgroup v2 把推理进程限制在固定配额内,可以避免它把实时核的带宽吃满:

# 限制推理进程组最多使用 400% CPU(4 核)与 2 GB 内存
sudo cgcreate -g cpu,memory:/robot/inference
echo 400000 | sudo tee /sys/fs/cgroup/robot/inference/cpu.max
echo 2147483648 | sudo tee /sys/fs/cgroup/robot/inference/memory.max
sudo cgexec -g cpu,memory:/robot/inference ./yolo_infer_node

# 实时控制组:给足 CPU 权重,禁止被降级
sudo cgcreate -g cpu:/robot/realtime
echo 1000000 | sudo tee /sys/fs/cgroup/robot/realtime/cpu.weight

# 验证抖动是否达标(目标 p99 < 200 µs)
cyclictest -t1 -p80 -n -i1000 -l100000 -m -c2

cyclictest 的 -p80 指定实时优先级,-i1000 指定 1 ms 周期,-l100000 跑十万次。看输出里的 Max 值:裸机 Linux 通常在几十到几百微秒,带 PREEMPT_RT 的 x86 可以稳定在 50 µs 以内,Jetson 等 ARM 平台通常差一个量级,需要把周期放宽到 2 ms 或把控制回路下沉到 MCU。

5. 时间系统与同步

机器人栈里的时间有三个来源:系统时钟(CLOCK_REALTIME)、单调时钟(CLOCK_MONOTONIC)、以及 ROS 时间(可能被 use_sim_time 重定向到仿真时钟)。混用是隐蔽 bug 的高发区。

// 错误:用系统时钟测周期,NTP 校时会得到负的 dt
auto dt = std::chrono::system_clock::now() - last_;

// 正确:控制回路一律用单调时钟
auto now = std::chrono::steady_clock::now();
double dt = std::chrono::duration<double>(now - last_).count();
if (dt <= 0.0 || dt > 0.1) { dt = nominal_dt_; }  // 防跳变

// ROS 时间用于与消息时间戳比较,不能用于测周期
rclcpp::Time stamp = this->now();  // 尊重 use_sim_time

多传感器的时间同步是另一条主线。硬件触发(PPS、GMSL2 触发线)优于软件时间戳,软件时间戳优于「收到就算」。典型做法:IMU 1 kHz 作为时间基准,相机用硬件触发同步到同一 PPS,雷达用 PTP(IEEE 1588)对齐到亚微秒。

若无法硬件同步,退而求其次用近似时间同步器,但要显式设置队列与容差:

message_filters::Synchronizer<ApproximateTime> sync_(
    ApproximateTime(20), image_sub_, cloud_sub_);
sync_.setMaxIntervalDuration(rclcpp::Duration::from_seconds(0.02));  // 20 ms 容差
// 容差大于传感器周期的 1/2 就会开始错配

6. 行为层:状态机与任务编排

行为层决定「机器人现在该干什么」,是唯一需要同时理解业务与硬件的层。它必须是显式状态机,而不是散落在各处的 if-else。

状态:IDLE → LOCALIZING → NAVIGATING → MANIPULATING → RETURNING → CHARGING
异常转移:
  LOCALIZING 失败 → 重定位(最多 3 次)→ 仍失败 → SAFE_STOP
  NAVIGATING 卡住 > 30 s → 局部重规划 → 仍卡住 → 请求人工
  MANIPULATING 抓取失败 → 重试(最多 2 次)→ 放弃并回 NAVIGATING
  任意状态 → 急停触发 → EMERGENCY_STOP(最高优先级,可中断一切)

实践中有两个反模式。一是把行为逻辑写成「订阅所有话题、用标志位判断」,随功能增加迅速变成不可维护的泥球;二是把行为层做成通用工作流引擎,为了灵活性牺牲了可验证性。正确做法是用层级状态机(如 SMACH、YASMIN 或自研),每个状态有明确的进入/退出条件和超时。

超时是行为层最重要的防御机制。每个状态都必须有最大驻留时间,超时后进入可恢复的失败处理。没有超时的状态机在传感器掉线时会永远等待,这是现场最常见的「死机」形态。

状态机的实现要把「状态」与「副作用」分离,状态本身必须是纯数据,便于测试与回放:

struct State {
  enum Id { IDLE, LOCALIZING, NAVIGATING, MANIPULATING, SAFE_STOP } id;
  double entered_at;      // 进入时刻(单调时钟)
  double timeout_s;       // 最大驻留时间
  int retry_count;
};

// 转移函数是纯函数:给定当前状态与事件,返回下一个状态
State transition(const State & s, const Event & e, const WorldModel & w) {
  switch (s.id) {
    case State::LOCALIZING:
      if (e.type == Event::LOCALIZED) {
        return {State::NAVIGATING, now(), 60.0, 0};
      }
      if (now() - s.entered_at > s.timeout_s) {
        if (s.retry_count < 3) {
          return {State::LOCALIZING, now(), 30.0, s.retry_count + 1};
        }
        return {State::SAFE_STOP, now(), 0.0, 0};   // 重试耗尽,安全停机
      }
      return s;
    case State::NAVIGATING:
      if (w.obstacle_ahead() && w.stuck_duration() > 30.0) {
        return {State::SAFE_STOP, now(), 0.0, 0};
      }
      // ...
  }
}

纯函数形式的转移逻辑可以在没有硬件的单元测试里跑完所有分支,也能在回放时用录制的 WorldModel 序列验证。相比之下,把转移写在回调里、直接操作成员变量的写法无法测试,是现场 bug 的主要来源。

7. 可观测性:日志、回放与在线诊断

机器人出问题往往在现场、在特定时刻、无法复现。因此回放能力是刚需,不是加分项。

# rosbag2 录制:全量太大,按需选择性录制
ros2 bag record -s mcap \
  --topics /scan /odom /tf /tf_static /cmd_vel /diagnostics \
  --max-bag-size 2147483648 --max-cache-size 67108864 \
  -o /data/bags/$(date +%Y%m%d_%H%M%S)

# 回放时必须关闭实时控制输出,否则会真的驱动机器人
ros2 bag play session.mcap --clock --rate 0.5 \
  --topics /scan /odom /tf /tf_static

MCAP 格式(ros2 bag 的默认后端,自 Humble 起可用)比旧 SQLite3 后端写入快约 2 倍,且支持分块索引,大文件回放时可以随机定位。

在线诊断要分层:进程级(心跳、CPU、内存)、话题级(频率、延迟、丢帧)、算法级(残差、协方差、收敛状态)。diagnostics 包提供了标准框架,把三者统一成 DiagnosticArray 发布出去,上位机即可做统一告警。

# 关键指标必须打点,否则线上无法归因
metrics:
  - /control/loop_jitter_us      # 目标 p99 < 200 µs
  - /localization/pose_cov       # 超过阈值触发降级
  - /perception/infer_latency_ms # p99 < 50 ms
  - /comm/ros_dds_lost_samples   # 丢帧计数,非零即告警

8. 从原型到量产的差距

原型与量产之间隔着一份很长的清单。最容易低估的是三类工作:

第一类:异常路径
  原型只处理 happy path;量产要求每个模块都有失败模式定义
  传感器掉线、数据超时、坐标系丢失、磁盘写满、网络分区

第二类:长时稳定性
  原型跑 10 分钟;量产要跑 8 小时 × 30 天
  内存泄漏 1 KB/s 在 8 小时后就是 28 MB,长跑必崩
  浮点累加误差在长时间积分后可能让协方差矩阵失去正定性

第三类:可维护性
  参数必须外置(YAML/配置中心),不能在代码里硬编码
  必须支持现场升级、灰度、回滚
  必须有日志分级与远程拉取能力

量化指标也要在架构阶段定下来,否则验收时各说各话:

指标原型典型值量产要求
控制周期抖动 p991 ms< 200 µs
端到端感知延迟150 ms< 80 ms
定位重定位成功率70%> 99%(10 次内)
连续运行无故障时间1 小时> 720 小时
崩溃恢复时间手动重启< 5 s 自动恢复

9. 工程化清单与团队分工

一个可交付的机器人软件项目,至少要满足下面的清单。它可以直接当作代码评审或里程碑验收的检查项。

架构
  [ ] 分层数据流图已画出,每条边标注频率与延迟预算
  [ ] 每层对外接口唯一,内部实现可替换
  [ ] 实时/非实时边界明确,有 cgroup 或核隔离配置

接口
  [ ] 消息类型版本化,向后兼容策略明确
  [ ] 坐标系符合 REP-105,TF 树无环且完整
  [ ] 时间戳语义在接口文档中写明

可靠性
  [ ] 每个状态有超时与失败处理
  [ ] 关键传感器掉线有降级路径(不是直接停机)
  [ ] 看门狗覆盖所有实时线程

可观测
  [ ] 关键指标有打点,阈值有告警
  [ ] 支持选择性录制与离线回放
  [ ] 参数全部外置,支持运行时重载

交付
  [ ] 有 CI 构建、有版本号、有升级与回滚流程
  [ ] 有安全评估(见 ISO 10218 / ISO/TS 15066)

团队分工上,常见配置是 1 名系统架构师(负责分层、时间预算、中间件选型)+ 感知、定位、规划、控制各 1~2 人 + 1 名仿真/测试工程师 + 1 名运维/工具链工程师。人数少于 5 人时,优先砍掉自研中间件和自研仿真,把预算投在测试与可观测性上,收益更高。

权衡取舍

决策点选 A 当…选 B 当…
中间件ROS2:团队小、要生态、非硬实时自研:量产固定、延迟敏感、有系统工程师
算力拓扑单 SoC:成本敏感、负载可预测双芯片:硬实时隔离、安全认证要求
时间源系统时钟:单机、无协同PTP/PPS:多传感器、多机协同
行为层自研状态机:逻辑清晰、可验证工作流引擎:任务频繁变更、需可视化
数据通路DDS:便利、跨机共享内存:高频、大消息、同机
日志全量录:调试期选择性录:量产,控制存储与带宽

判断的通用原则是:把复杂度花在不可逆的地方。硬件拓扑、实时边界、消息契约一旦定型改动成本极高,值得在架构阶段多花两周;而上层的行为逻辑与参数可以在迭代中调整,不必过度设计。

常见坑清单

  1. 用系统时钟测控制周期,NTP 校时后 dt 变成负数,积分器发散——一律改用 steady_clock。
  2. 传感器时间戳用发布时刻而非采样时刻,静止时看不出,运动时定位漂移——驱动层必须读硬件时间。
  3. 把 map 到 base_link 作为一段 TF 发布,重定位跳变时控制器输出巨大加速度——必须拆成 map→odom 与 odom→base_link 两段。
  4. 控制线程与推理线程共享内存池,推理加载模型时触发 direct reclaim,控制周期抖动到 10 ms——实时进程 mlockall 并预留内存。
  5. 行为层没有超时,传感器掉线后状态机永远等待——每个状态必须有最大驻留时间。
  6. 回放时直接驱动真实机器人——回放前必须把控制输出切到仿真/空载通道。
  7. 话题频率不做校验,上游慢下来下游悄悄用旧值——每条关键话题都要有频率监控与告警。
  8. 参数硬编码在源码,现场调参要重新编译——参数必须外置并支持运行时重载。
  9. 忽略浮点累加误差,长时间积分后协方差矩阵失去正定性导致滤波发散——定期做对称化与正定化处理。
  10. 只在实验室测试 happy path,现场遇到传感器脏污、网络抖动就崩——异常路径必须与正常路径同等测试覆盖。

小结

机器人软件栈的核心竞争力不在某个算法有多新,而在时序契约是否被严格遵守、失效是否被显式处理、问题是否可复现。把每一层当作一个有 SLA 的服务来设计,是本文想传达的最主要的方法论。

下一步建议按两条线深入。第一条是数据线:读本专题的传感器融合与状态估计 、SLAM 与定位建图 ,理解定位这条主干如何从多源数据得到可信位姿。第二条是控制线:读运动学正逆解、动力学与控制基础、运动规划与轨迹优化,理解从目标位姿到关节力矩的完整链路。两条线最终在机械臂控制与抓取规划处汇合,那里会看到本文所有抽象落到具体代码。

如果只能从本文带走一件事,那就是:先画数据流图并标注时间预算,再写第一行代码。这张图会在整个项目周期里反复救你。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「机器人」更多文章

  1. 机器人实时控制与嵌入式
  2. ROS 2 通信与 QoS
  3. 足式与人形机器人运动控制