引言
机器人软件栈的复杂度从来不在单个算法。SLAM、运动规划、阻抗控制这些题目各自都有成熟教科书,但把它们拼成一台能在现场连续跑 8 小时不出故障的机器,是另一回事。真正吃掉工程师半年时间的是:点云话题在 20 Hz 和 30 Hz 之间抖动时规划器行为不一致、控制器线程被日志写盘阻塞 3 ms 导致关节抖动、视觉检测延迟 80 ms 让抓取在动态传送带上永远慢半拍。
这类问题的共同点是它们都跨越了「算法层」与「系统层」的边界。一个典型的移动操作平台在运行时至少有六类任务在竞争 CPU:IMU 与轮式里程计的 1 kHz 读取、激光雷达 1020 Hz 的点云预处理、视觉 30 Hz 的推理、全局规划的不定期长耗时搜索、控制器 500 Hz1 kHz 的伺服循环,以及遥测与日志的异步落盘。它们的截止时间从 1 ms 到数秒不等,混在同一个调度器里就会互相伤害。
因此本文不按「感知/规划/控制」的教科书画分,而按数据契约加时间预算来组织:每一层对外承诺什么频率、什么延迟上限、什么坐标系与时间戳语义,以及违反承诺时上游如何降级。这个视角更接近真实工程,也更容易在评审会上达成一致。栈中每一层的算法细节会在同专题其他文章中展开,本文只讲它们在整体中的位置与接口,例如运动学正逆解 与动力学与控制基础 。
读完本文你应当能够:画出自己项目的分层数据流图、为每条边标注频率与延迟预算、判断哪些模块必须放进实时线程、以及决定什么时候该引入 ROS2、什么时候该自己写中间件。
目录
- 分层模型与职责边界
- 数据契约:频率、时间戳与坐标系
- 中间件选型:ROS2、自研与共享内存
- 算力拓扑与硬件分工
- 时间系统与同步
- 行为层:状态机与任务编排
- 可观测性:日志、回放与在线诊断
- 从原型到量产的差距
- 工程化清单与团队分工
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 帧 | JointState | 1000 Hz | 0.2 ms | 报故障码,停止使能 |
| L2 状态估计 | IMU/轮速/雷达 | TF + 位姿 | 100~1000 Hz | 5 ms | 切纯里程计,标记降级 |
| L3 感知 | 点云/图像 | 目标列表 | 10~30 Hz | 80 ms | 保持上帧,超时清空 |
| L4 世界模型 | 目标 + 地图 | 代价地图 | 5~10 Hz | 200 ms | 用静态地图兜底 |
| L5 规划 | 目标点 + 地图 | 带时间戳轨迹 | 0.1~10 Hz | 500 ms | 输出制动轨迹 |
| L6 控制 | 轨迹 + 状态 | 力矩/速度指令 | 500~1000 Hz | 1 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 Hz | 100 Hz | 滑动平均 + 抽取,注意相位延迟 |
| 10 Hz | 100 Hz | 保持最新值 + 时间戳外推 |
| 10 Hz | 10 Hz | 时间同步队列,容忍 ±20 ms |
| 30 Hz | 10 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/配置中心),不能在代码里硬编码
必须支持现场升级、灰度、回滚
必须有日志分级与远程拉取能力
量化指标也要在架构阶段定下来,否则验收时各说各话:
| 指标 | 原型典型值 | 量产要求 |
|---|---|---|
| 控制周期抖动 p99 | 1 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:便利、跨机 | 共享内存:高频、大消息、同机 |
| 日志 | 全量录:调试期 | 选择性录:量产,控制存储与带宽 |
判断的通用原则是:把复杂度花在不可逆的地方。硬件拓扑、实时边界、消息契约一旦定型改动成本极高,值得在架构阶段多花两周;而上层的行为逻辑与参数可以在迭代中调整,不必过度设计。
常见坑清单
- 用系统时钟测控制周期,NTP 校时后 dt 变成负数,积分器发散——一律改用
steady_clock。 - 传感器时间戳用发布时刻而非采样时刻,静止时看不出,运动时定位漂移——驱动层必须读硬件时间。
- 把
map到base_link作为一段 TF 发布,重定位跳变时控制器输出巨大加速度——必须拆成map→odom与odom→base_link两段。 - 控制线程与推理线程共享内存池,推理加载模型时触发 direct reclaim,控制周期抖动到 10 ms——实时进程
mlockall并预留内存。 - 行为层没有超时,传感器掉线后状态机永远等待——每个状态必须有最大驻留时间。
- 回放时直接驱动真实机器人——回放前必须把控制输出切到仿真/空载通道。
- 话题频率不做校验,上游慢下来下游悄悄用旧值——每条关键话题都要有频率监控与告警。
- 参数硬编码在源码,现场调参要重新编译——参数必须外置并支持运行时重载。
- 忽略浮点累加误差,长时间积分后协方差矩阵失去正定性导致滤波发散——定期做对称化与正定化处理。
- 只在实验室测试 happy path,现场遇到传感器脏污、网络抖动就崩——异常路径必须与正常路径同等测试覆盖。
小结
机器人软件栈的核心竞争力不在某个算法有多新,而在时序契约是否被严格遵守、失效是否被显式处理、问题是否可复现。把每一层当作一个有 SLA 的服务来设计,是本文想传达的最主要的方法论。
下一步建议按两条线深入。第一条是数据线:读本专题的传感器融合与状态估计 、SLAM 与定位建图 ,理解定位这条主干如何从多源数据得到可信位姿。第二条是控制线:读运动学正逆解、动力学与控制基础、运动规划与轨迹优化,理解从目标位姿到关节力矩的完整链路。两条线最终在机械臂控制与抓取规划处汇合,那里会看到本文所有抽象落到具体代码。
如果只能从本文带走一件事,那就是:先画数据流图并标注时间预算,再写第一行代码。这张图会在整个项目周期里反复救你。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。