ROS教程12:ROS消息与驱动数据契约——用差速底盘理解 Twist、JointState、Imu 与 Odometry
摘要:以差速底盘为例,从 Twist 的 v/w、左右轮运动学和真实误差来源,讲到 Odometry 与 6×6 covariance 的含义、测量和工程使用。
@[toc]
阶段 A 已经完成 Topic、Service、Action、CallbackQueue、Spinner、测试和整机启动链。本章开始进入驱动数据,但刻意不展开 TCP、CAN、串口、自定义设备协议,也不讨论如何从 Linux Socket 收字节。
本章只做两件事:
1 | 方向 1:设备数据 -> ROS |
这样可以把注意力放在 ROS Driver 最重要的数据契约上:物理量是什么、单位是什么、坐标系是什么、ROS 消费者期待什么。
本章工程位于:
1 | /workspace/ros_ws/src/ros1_driver_lab |
核心源码只有一个:
1 | ros_ws/src/ros1_driver_lab/src/chassis_driver_node.cpp |
代码里已经加入中文注释,文章中的代码也保留必要注释,便于逐行阅读。
1. 先认识本章使用的 4 组 ROS 标准消息
Dockerfile 中新增了:
1 | ros-noetic-geometry-msgs |
这一章只需要知道它们分别解决什么问题,不展开安装包、生成头文件和上游源码仓库之间的关系。
1.1 geometry_msgs:表达几何量和运动量
本章使用:
1 |
geometry_msgs/Twist 表示一个物体的线速度和角速度:
1 | linear.x |
差速底盘只使用:
1 | linear.x 前进/后退线速度,单位 m/s |
官方定义:
1.2 sensor_msgs:表达传感器和关节状态
本章使用:
1 |
JointState 用来表达左右轮这种关节的状态;Imu 用来表达角速度、线加速度和可选姿态估计。
官方定义:
- https://docs.ros.org/en/noetic/api/sensor_msgs/html/msg/JointState.html
- https://docs.ros.org/en/noetic/api/sensor_msgs/html/msg/Imu.html
1.3 nav_msgs:表达导航相关数据
本章使用:
1 |
nav_msgs/Odometry 同时带:
1 | pose 位置 + 姿态 |
它是移动底盘最常见的数据接口之一。
官方定义:
1.4 diagnostic_msgs:表达设备健康状态
本章使用:
1 |
这里只用它表达最基本的:
1 | 设备数据可用 -> OK |
真正的 watchdog、频率监控、超时和恢复放到第 14 章。
官方定义:
2. 本章为什么把“设备通信”直接省略
真实驱动里,数据可能来自:
1 | TCP |
但无论底层怎么拿数据,进入 ROS 层之后都应该先形成明确的物理量。
本章统一用:
1 | struct ChassisData |
这意味着从这一行开始:
1 | const ChassisData data = device_->readChassisData(dt_s); |
底层通信已经结束。
后面只研究:
ChassisData中这些物理量应该怎样正确进入 ROS 消息。
为了让工程可以独立运行,源码内部有一个极薄的 DemoChassisDevice。它不使用 Socket,不创建第二个进程,也不模拟网络协议,只负责产生教学数据。
真实项目里替换这一层即可:
1 | DemoChassisDevice |
ROS 消息映射部分可以继续保持。
3. /cmd_vel 中的 Twist 到底是什么意思
先查看:
1 | rosmsg show geometry_msgs/Twist |
可以看到:
1 | geometry_msgs/Vector3 linear |
差速底盘通常约定:
1 | linear.x = v |
其中:
1 | v:机器人中心沿自身 X 轴方向的线速度,单位 m/s |
例如:
1 | linear: |
表示:
1 | 机器人向前 0.3 m/s |
对于普通二维差速底盘,下面这些分量没有对应执行能力:
1 | linear.y |
所以代码只消费:
1 | msg->linear.x |
为什么 /cmd_vel 使用 Topic,而不是 Action
因为 /cmd_vel 表达的是连续变化的瞬时速度指令。
例如控制器可能持续输出:
1 | 0.30 m/s |
它不是一个“提交一次然后等完成”的长任务。
所以分层通常是:
1 | 高层目标:到达某个位置 |
因此第 11 章学过的 Action 并不是没用了,而是处在更高层。
4. 差速底盘:为什么一个 v 和 w 能换算成两个轮子的速度
这是本章最重要的算法部分。第一次看下面两个式子:
1 | v_left = v - w * L / 2 |
很容易只把它们当成需要背下来的公式。更好的理解方式是先看机械结构,再从“直行、转弯、原地转”三个运动状态推到一般公式。
4.1 先把机械结构、坐标轴和变量放到一张图里
本章讨论的是最典型的二维差速底盘:左右各有一个独立驱动轮,两轮轴线重合。运动学参考点 C 取在左右轮轴线的中点。
图中的量分别是:
1 | C 左右驱动轮轴线的中点,也是本章的运动学参考点 |
按照右手定则,本章约定:
1 | w > 0 -> 逆时针 -> 向左转 |
这张图最重要的不是记住颜色,而是建立一个机械直觉:v 描述整个车体“向前走多快”,w 描述整个车体“转得多快”,左右轮速度则是实现这两个运动要求的执行量。
4.2 先看纯直线:左右轮为什么相等
如果:
1 | w = 0 |
机器人不旋转,只沿 +X 前进。刚体左右两侧没有额外的旋转速度分量,因此:
1 | v_left = v |
也就是:
1 | 左轮和右轮一样快 |
4.3 再看原地旋转:为什么一边正、一边负
如果:
1 | v = 0 |
参考点 C 本身不向前移动,但整个底盘绕 C 逆时针旋转。
左右轮到 C 的距离都是:
1 | L / 2 |
旋转刚体上某一点的切向速度大小满足:
1 | 线速度 = 角速度 * 到旋转中心的距离 |
所以左右轮速度绝对值都是:
1 | w * L / 2 |
但方向相反:
1 | v_left = -w * L / 2 |
这就是原地左转时“左轮向后、右轮向前”的来源。
4.4 把“向前”和“旋转”叠加起来,就是一般公式
一般运动同时包含:
1 | 整体向前速度 v |
对于左轮:
1 | 向前分量 = v |
所以:
1 | v_left = v - w * L / 2 |
对于右轮:
1 | 向前分量 = v |
所以:
1 | v_right = v + w * L / 2 |
这两个值仍然是轮缘线速度,单位为 m/s。
电机、减速器或轮轴控制接口更常使用角速度 rad/s,因此还要除以车轮半径:
1 | omega_left = v_left / R |
合起来:
1 | omega_left = (v - w * L / 2) / R |
源码对应:
1 | // 逆运动学:先得到左右轮轮缘线速度,再除以车轮半径得到轮轴角速度。 |
4.5 把当前例子完整算一遍
发送:
1 | v = 0.3 m/s |
左轮轮缘线速度:
1 | v_left |
右轮轮缘线速度:
1 | v_right |
再转换成轮轴角速度:
1 | omega_left |
1 | omega_right |
因此应该得到:
1 | left = 2 rad/s |
右轮更快,所以机器人向左转。
4.6 为什么运动学参考点通常选在左右轮中点 C
前面的差速公式有一个经常被忽略的前提:v 和 w 描述的是左右驱动轮轴线中点 C 的运动,而不是车体上任意一点的运动。
如果 base_link、传感器安装点或者上层控制参考点恰好就在 C,事情最简单;如果它们位于车体其他位置,就不能只把同一个 linear.x 原样拿过来使用,而应该先做刚体上不同参考点之间的速度变换。
下面这张图把 C、任意点 P 以及偏移量放到同一张俯视图中:
设点 P 相对 C 的位置为:
1 | r_CP = (a, b) |
对于同一个刚体,两个点的速度满足经典关系:
$$
\mathbf v_P
\mathbf v_C
+
\boldsymbol\omega\times\mathbf r_{CP}
$$
本章只考虑平面运动,因此:
$$
\boldsymbol\omega=(0,0,w)
$$
标准差速底盘在轮轴中点 C 不能主动横移,所以:
$$
\mathbf v_C=(v_C,0)
$$
把叉乘展开以后得到:
$$
v_{P_x}=v_C-wb
$$
$$
v_{P_y}=wa
$$
这两个式子就是“参考点变化”最需要记住的结果。
4.6.1 P 只向左或向右偏:前进速度会变化
假设:
1 | a = 0 |
那么:
$$
v_{P_y}=0
$$
但:
$$
v_{P_x}=v_C-wb
$$
也就是说,点 P 虽然仍然只沿车体前后方向运动,但是转弯时它的前进速度已经和轮轴中点 C 不一样。
例如机器人正在左转:
1 | w > 0 |
如果 P 在左侧:
1 | b > 0 |
则:
1 | v_P_x < v_C |
左侧是转弯内侧,走过的圆弧更短,所以速度更小。
如果 P 在右侧:
1 | b < 0 |
则:
1 | v_P_x > v_C |
右侧是转弯外侧,圆弧半径更大,所以速度更大。
现在反过来考虑更接近工程使用的情况:上层给的是点 P 的速度,而底层差速解算需要 C 点速度。
由前式反推:
$$
v_C=v_{P_x}+wb
$$
例如:
1 | P 在 C 右侧 0.10 m:b = -0.10 m |
则轮轴中点真正应该使用的速度为:
$$
v_C=0.30+0.40\times(-0.10)=0.26\ \text{m/s}
$$
如果轮距仍然是:
1 | L = 0.50 m |
再进入差速公式:
$$
v_L=0.26-0.40\times\frac{0.50}{2}=0.16\ \text{m/s}
$$
$$
v_R=0.26+0.40\times\frac{0.50}{2}=0.36\ \text{m/s}
$$
这就是“先把 P 点速度换算回 C,再做左右轮分解”的完整过程。
4.6.2 P 只向前或向后偏:转弯时会自然产生横向速度
假设:
1 | a != 0 |
此时:
$$
v_{P_x}=v_C
$$
但会出现:
$$
v_{P_y}=wa
$$
例如:
1 | P 在 C 前方 0.20 m:a = 0.20 m |
那么:
$$
v_{P_y}=0.40\times0.20=0.08\ \text{m/s}
$$
也就是说,P 点的真实速度不是:
1 | linear.x = 0.30 |
而是:
1 | linear.x = 0.30 m/s |
这并不表示差速底盘突然获得了横移能力。
真正发生的是:车体整体在旋转,所以位于轮轴中点前方的那个固定点会沿圆弧向侧面扫过。
因此,如果某个上层模块声明:
1 | “这条 Twist 描述的是前方 P 点” |
却同时在转弯时坚持:
1 | linear.y = 0 |
那么这个 Twist 与理想差速刚体运动并不一致。
工程上通常有两种处理方式:
- 统一约定
/cmd_vel的运动学参考点就是轮轴中点C; - 如果业务确实必须以其他点
P为参考,就传递完整平面 Twist,并先做刚体速度变换。
4.6.3 一般情况:P 同时前后、左右都有偏移
如果:
1 | a != 0 |
那么点 P 的速度同时包含两种变化:
$$
\boxed{
\begin{aligned}
v_{P_x} &= v_C-wb \
v_{P_y} &= wa
\end{aligned}}
$$
反过来,从 P 换算回 C:
$$
\boxed{
\begin{aligned}
v_C &= v_{P_x}+wb \
v_{C_y} &= v_{P_y}-wa
\end{aligned}}
$$
对于理想差速底盘,还必须满足:
$$
v_{C_y}=0
$$
所以给定的 P 点 Twist 应满足:
$$
v_{P_y}=wa
$$
这就是为什么“参考点换了,只改一个 linear.x”有时是不够的。
完整流程应当是:
1 | 上层给出 P 点 Twist |
在 ROS 工程中,如果 P 和 C 对应不同 TF frame,而且两者不只是平移、还存在姿态旋转,那么除了上面的“参考点平移”外,还要对速度向量做坐标轴旋转。实际程序通常应使用已经维护好的 TF/tf2 变换,而不是在各个 Driver 中重复手写一套坐标变换公式。
4.6.4 如果只是 Z 方向高度不同呢
假设某个 IMU 安装在车体上方:
1 | r_CP = (a, b, h) |
而机器人仍然只有平面 yaw:
1 | omega = (0, 0, w) |
则:
$$
\boldsymbol\omega\times\mathbf r_{CP}
(-wb,\ wa,\ 0)
$$
可以看到高度 h 不参与结果。
所以,只要仍然是本章的纯二维平面运动:
单纯的 Z 高度偏移不会改变左右轮差速解算。
只有开始考虑 roll、pitch、车体颠簸、悬架运动或者完整三维角速度时,传感器高度才会进入更一般的刚体速度关系。
4.7 三轮、四轮还能直接使用这套公式吗
不能只数轮子数量。真正决定运动学公式的是:
- 哪些轮子主动驱动;
- 哪些轮子可以转向;
- 轮子能否侧向自由滚动;
- 每个轮子的滚动方向和安装角度;
- 底盘是否能够主动产生横向速度
vy。
下面三种结构看起来都可能有 3~4 个轮子,但运动学完全不同:
先用表格建立整体认识:
| 机械结构 | 是否可以沿用本章差速模型 | 真正决定运动的量 |
|---|---|---|
| 2 个主动轮 + 1 个万向脚轮 | 可以 | 左右两个主动轮速度 |
| 2 个主动轮 + 2 个万向脚轮 | 可以 | 左右两个主动轮速度 |
| 左右各 2 个主动轮、同侧等速 | 通常按 skid-steer / 四轮差速近似 | 左、右两侧轮组速度,但存在明显侧滑 |
| 三轮车式单轮转向 | 不可以直接套用 | 驱动速度 + 转向角 |
| 汽车式前轮转向 Ackermann | 不可以直接套用 | 车速 + 前轮转向几何 |
| 4 个麦克纳姆轮 | 不可以直接套用 | vx、vy、w 与四个轮速之间的运动学矩阵 |
| 3/4 个全向轮 | 不可以直接套用 | vx、vy、w 与各轮速度之间的运动学矩阵 |
4.7.1 汽车式前轮转向:为什么不能靠左右轮差速决定转弯
典型汽车式底盘具有:
1 | 前轮:可以改变转向角 |
最简单的入门模型称为 bicycle model(自行车模型):把左右两个前轮等效成前轴中心的一个虚拟轮,把左右两个后轮等效成后轴中心的一个虚拟轮。
定义:
1 | v 车辆纵向速度 |
无侧滑的理想几何关系为:
$$
R_c=\frac{L_a}{\tan\delta}
$$
因此曲率:
$$
\kappa=\frac{1}{R_c}=\frac{\tan\delta}{L_a}
$$
又因为:
$$
w=v\kappa
$$
所以:
$$
\boxed{w=\frac{v\tan\delta}{L_a}}
$$
这和差速底盘的思路完全不同。
差速底盘是:
1 | v + w |
汽车式转向是:
1 | v + 期望曲率 / w |
例如:
1 | v = 1.0 m/s |
需要的曲率:
$$
\kappa=\frac{w}{v}=0.5\ \text{m}^{-1}
$$
因此:
$$
\tan\delta=L_a\kappa=0.5
$$
$$
\delta=\arctan(0.5)\approx26.6^\circ
$$
也就是说,这台车要通过“前轮大约转 26.6°”来获得这个转弯曲率,而不是像差速机器人那样故意让左轮慢、右轮快来转向。
真正的 Ackermann 左右前轮转角为什么还不一样
四轮汽车转弯时,四个轮子绕的是同一个瞬时转弯中心 ICR,但是内侧前轮和外侧前轮到 ICR 的半径不同。
如果:
1 | T = 前轮轮距 |
左转时,内侧前轮转角:
$$
\delta_{in}
\arctan\left(\frac{L_a}{R_c-T/2}\right)
$$
外侧前轮转角:
$$
\delta_{out}
\arctan\left(\frac{L_a}{R_c+T/2}\right)
$$
因此:
1 | delta_in > delta_out |
内侧轮必须转得更“狠”,这样所有轮子的延长线才能尽量交于同一个 ICR,从而降低轮胎横向拖滑。
如果后轮由机械差速器连接,转弯时内、外后轮本来就会有不同转速,机械差速器会允许这种速度差;如果每个轮子由独立电机驱动,则控制器也可以根据各轮转弯半径分别给速度目标。
因此,Ackermann 中的“左右轮速度不同”是转弯几何的结果,不是像差速底盘那样用左右轮速度差直接作为主要转向手段。
4.7.2 4 个麦克纳姆轮:为什么可以直接横移
麦克纳姆轮和普通橡胶轮的关键差别在于:轮胎外圈不是连续橡胶面,而是一圈带角度的自由滚子,常见安装角约为 45°。
普通轮的主要约束是:
1 | 沿轮子滚动方向可以运动 |
而麦克纳姆轮的滚子允许某个斜向分量被“释放”。四个轮子的滚子方向经过特定排列后,各轮产生的速度分量可以组合出:
1 | vx:前后移动 |
所以它的底盘命令天然是三自由度平面 Twist:
$$
\mathbf u=
\begin{bmatrix}
v_x\
v_y\
w
\end{bmatrix}
$$
而不是标准差速底盘只使用:
1 | v_x + w |
对于一种常见的 X 型滚子排列,定义:
1 | FL:左前轮 |
在这一套特定轮子编号、正方向和滚子安装约定下,可以写成:
$$
\begin{bmatrix}
\omega_{FL}\
\omega_{FR}\
\omega_{RL}\
\omega_{RR}
\end{bmatrix}
\frac{1}{r}
\begin{bmatrix}
1 & -1 & -k\
1 & 1 & k\
1 & 1 & -k\
1 & -1 & k
\end{bmatrix}
\begin{bmatrix}
v_x\
v_y\
w
\end{bmatrix}
$$
这个矩阵比记住每一项正负号更重要的,是理解三件事:
- 每个轮子的速度都同时受到
vx、vy、w影响; - 四个轮子的组合可以主动生成
vy,所以麦克纳姆可以横移; - 改变轮子编号、正转方向或滚子
/、\\排列后,矩阵中的正负号会改变。
因此工程中不能从别人的代码复制一个麦克纳姆矩阵就直接使用,必须先确认:
1 | 坐标轴定义是否一样? |
一个简单的自检方法是分别给纯命令:
1 | (vx > 0, vy = 0, w = 0) -> 应该只前进 |
如果实际运动方向不对,优先检查轮序、正方向和滚子布局,而不是先怀疑 ROS Topic。
4.7.3 3/4 个全向轮:为什么也是矩阵,而不是左右轮公式
全向轮(omni wheel)与麦克纳姆轮的共同点是:轮子自身可以沿一个方向主动滚动,同时依靠小滚子在另一个方向近似自由滑动。
如果把多个全向轮按不同角度布置在底盘周围,就可以让这些不同方向的驱动力组合成任意平面速度:
1 | vx |
以三个相隔 120° 安装的全向轮为例:
1 | 轮 1:安装方向 alpha_1 |
每个轮子只能直接约束“沿自己驱动方向的速度分量”。因此第 i 个轮子的角速度可以写成:
$$
\omega_i
\frac{1}{r}
\left(
\sin\alpha_i,v_x
\cos\alpha_i,v_y
R_o,w
\right)
$$
其中:
1 | r 全向轮半径 |
把三个轮子的方程叠在一起,就得到:
$$
\begin{bmatrix}
\omega_1\
\omega_2\
\omega_3
\end{bmatrix}
\frac{1}{r}
\underbrace{
\begin{bmatrix}
\sin\alpha_1 & -\cos\alpha_1 & -R_o\
\sin\alpha_2 & -\cos\alpha_2 & -R_o\
\sin\alpha_3 & -\cos\alpha_3 & -R_o
\end{bmatrix}}
_{H}
\begin{bmatrix}
v_x\
v_y\
w
\end{bmatrix}
$$
这就是一个 3 x 3 的运动学矩阵。
只要三个轮子的安装方向设计得合理,使矩阵 H 满秩,就可以从任意期望的:
1 | (vx, vy, w) |
唯一算出三只轮子的速度。
如果使用 4 个全向轮,就会得到一个 4 x 3 矩阵:
$$
\boldsymbol\omega
\frac{1}{r}H\mathbf u
$$
逆运动学仍然很直接:给定底盘速度,矩阵乘法得到每个轮速。
反过来由四个轮速估计:
1 | vx, vy, w |
由于方程数量多于未知数,通常使用矩阵的伪逆:
$$
\mathbf u
rH^{\dagger}\boldsymbol\omega
$$
多出来的轮子并不是“多余”,它可以带来负载分担、机械布置和一定的冗余,但也意味着实际工程中更需要处理:
- 轮径差异;
- 某个轮子离地或打滑;
- 安装角误差;
- 多轮速度不完全一致时的最小二乘估计。
与麦克纳姆一样,全向轮矩阵中的正负号由坐标系、轮子安装方向和电机正方向决定,不能脱离机械图直接死记。
4.7.4 为什么它们都可以接收 Twist,但 Driver 内部算法完全不同
这是 ROS 初学时很容易混淆的一点。
上层都可能使用类似:
1 | geometry_msgs/Twist |
表达底盘期望运动:
1 | linear.x |
但 Twist 只是在描述:
“希望整个机器人怎样运动。”
它没有规定:
“底盘必须用什么机械机构实现这个运动。”
所以同样一个:
1 | linear.x = 0.5 |
进入不同底盘 Driver 后可能变成完全不同的执行量:
1 | 差速底盘 |
1 | Ackermann |
1 | 麦克纳姆 |
1 | 三轮全向 |
因此应该把 ROS 消息和机械运动学分成两个层次理解:
1 | ROS Twist |
这也是为什么不能仅仅看到大家都订阅 /cmd_vel,就认为底层控制算法也是一样的。
4.8 推荐阅读:想继续看完整运动学推导时看什么
本章只保留 ROS Driver 实际需要的公式和机械直觉。需要继续深入时,可以按主题阅读:
差速底盘完整推导:Introduction to Robotics and Perception — Differential Drive Motion Model
https://www.roboticsbook.org/S52_diffdrive_actions.html刚体上两个点的速度关系:University of Illinois — Rigid bodies
https://mechref.engr.illinois.edu/dyn/rkg.html这正是 4.6 节把任意参考点
P换算到轮轴中点C所使用的基础关系。汽车式 / Ackermann 转向:ros2_control — Ackermann Steering Controller / Steering Controllers Library
https://control.ros.org/master/doc/ros2_controllers/ackermann_steering_controller/doc/userdoc.html
https://control.ros.org/master/doc/ros2_controllers/steering_controllers_library/doc/userdoc.html麦克纳姆轮:WPILib — Mecanum Drive Kinematics
https://docs.wpilib.org/en/stable/docs/software/kinematics-and-odometry/mecanum-drive-kinematics.html三轮/四轮全向轮矩阵:Modern Robotics — Omnidirectional Wheeled Mobile Robots
https://modernrobotics.northwestern.edu/nu-gm-book-resource/13-2-omnidirectional-wheeled-mobile-robots-part-1-of-2/轮式机器人运动学总览:ROS 2 Control — Wheeled Mobile Robot Kinematics
https://control.ros.org/humble/doc/ros2_controllers/doc/mobile_robot_kinematics.html
这些资料有的来自 ROS 2 或其他机器人软件栈,但这里引用的是刚体与轮式机器人运动学本身,与本项目使用 ROS1 Noetic 并不冲突。
5. JointState:左右轮的位置和速度怎么表达
查看:
1 | rosmsg show sensor_msgs/JointState |
结构:
1 | std_msgs/Header header |
5.1 name
本项目:
1 | left_wheel_joint |
代码:
1 | msg.name = {left_joint_name_, right_joint_name_}; |
5.2 position
对于旋转关节:
1 | position 单位 = rad |
示例设备用:
1 | // 角位置 = 对角速度按时间积分。 |
因为:
1 | 角度变化 = 角速度 * 时间 |
例如左轮保持:
1 | 2 rad/s |
持续:
1 | 0.5 s |
累计增加:
1 | 1 rad |
5.3 velocity
对于旋转关节:
1 | velocity 单位 = rad/s |
所以可以直接填:
1 | msg.velocity = { |
5.4 effort 为什么留空
effort 对旋转关节通常表达力矩,单位 N*m。
本章没有力矩传感器,也没有真实电机电流到力矩的标定关系,所以不能因为字段存在就随便填:
1 | 0 |
源码选择留空:
1 | // 当前示例没有力矩传感器,因此 effort 留空,而不是伪造 0 Nm 测量值。 |
这就是数据契约的核心原则:没有数据就不要伪造数据。
6. Imu:角速度、加速度和 orientation 分别是什么
查看:
1 | rosmsg show sensor_msgs/Imu |
主要字段:
1 | orientation |
6.1 angular_velocity
本章只模拟:
1 | angular_velocity.z |
单位:
1 | rad/s |
它表示绕 IMU 自身 Z 轴的角速度。
6.2 linear_acceleration
单位必须是:
1 | m/s^2 |
示例假设 imu_link 使用常见的 x 前、y 左、z 上方向,底盘水平放置且没有额外线加速度,因此:
1 | x = 0 |
这里只是教学数据,不代表真实 IMU 输出一定长这样。
6.3 orientation 没有数据怎么办
本章没有姿态融合算法,因此没有可靠的:
1 | roll |
ROS sensor_msgs/Imu 有明确约定:如果没有某类估计,可以把对应 covariance 的第一个元素设为 -1。
所以源码:
1 | // 当前示例没有姿态解算结果。 |
这里比“随便填一个看起来正常的姿态”更正确。
7. 从左右轮反馈反推出底盘速度
前面做的是逆运动学:
1 | 机器人 v、w |
Odometry 需要反过来:
1 | 左右轮速度 |
已知轮轴角速度:
1 | omega_left |
先乘车轮半径得到轮缘线速度:
1 | v_left = omega_left * R |
底盘中心线速度:
1 | v = (v_right + v_left) / 2 |
底盘角速度:
1 | w = (v_right - v_left) / L |
源码:
1 | // 正运动学:轮轴角速度 * 轮半径 = 轮缘线速度。 |
使用刚才的:
1 | omega_left = 2 rad/s |
得到:
1 | v_left = 0.2 m/s |
再得到:
1 | v = (0.4 + 0.2) / 2 = 0.3 m/s |
和原始 /cmd_vel 一致。
8. Odometry:速度为什么还能变成 x、y、yaw
查看:
1 | rosmsg show nav_msgs/Odometry |
它有两大块:
1 | pose |
本章使用:
1 | header.frame_id = odom |
官方 Odometry.msg 约定:pose 使用 header.frame_id 表达的坐标系,twist 使用 child_frame_id 表达的坐标系。
8.1 先积分 yaw
已知:
1 | w = angular_rad_s |
每个周期:
1 | yaw += w * dt |
8.2 再积分 x 和 y
机器人自身前向速度是 v。
但在 odom 坐标系里,机器人当前已经有一个 yaw,所以要把车体前向速度投影到 odom 的 X/Y 方向:
1 | x_dot = v * cos(yaw) |
最简单的离散积分写成:
1 | x += v * cos(yaw) * dt |
源码:
1 | // 用当前 yaw 把车体前向速度投影到 odom 坐标系,再进行简单欧拉积分。 |
这就是最基础的轮式里程计积分,也叫 dead reckoning(航位推算):当前结果依赖上一次结果,再不断把新的速度积分进去。
8.3 yaw 为什么要变成 Quaternion
Odometry.pose.pose.orientation 不是直接存一个 yaw,而是:
1 | geometry_msgs/Quaternion |
二维底盘只绕 Z 轴旋转,因此:
1 | q.x = 0 |
源码:
1 | // 二维底盘只有 yaw,这里把 yaw 转成绕 Z 轴旋转的四元数。 |
TF 和完整坐标系关系放到后面的阶段 C 再系统学习。
8.4 理想公式为什么到了真实机器人上会越走越偏
前面的公式假设了很多理想条件:轮子纯滚动、参数准确、时间准确、地面平整、传感器没有噪声。
真实机器人并不满足这些条件,因此这条链路中每一层都可能产生误差:
1 | 编码器 |
这也是为什么轮式 Odometry 是估计值,而不是地面真实位置 Ground Truth。
下面把几种最常见的误差和工程处理方式展开。
轮胎打滑
打滑时最典型的问题是:
1 | 编码器看到轮子确实转了 |
例如急加速时驱动轮空转,编码器会认为机器人已经前进,但车体实际位移更小;急转弯、湿滑地面、四轮 skid-steer 原地转向时也很常见。
工程上通常不能只靠修改一个 R 或 L 参数解决,因为打滑是随工况变化的。常见处理包括:
- 限制速度、加速度和 jerk,减少突然打滑;
- 用 IMU、激光定位、视觉、GNSS 等外部信息和轮速里程计融合;
- 比较轮速、IMU 角速度、外部定位之间的一致性,检测疑似打滑;
- 检测到打滑时提高轮式 Odometry 的不确定度,让融合器少信它。
编码器量化
编码器不是连续输出无限精度的角度,而是一格一格的脉冲。
假设每圈只有有限个 tick,那么低速时可能出现:
1 | 这一周期 0 tick |
直接用很短时间窗求速度就会抖得很厉害。
工程上常见做法:
- 使用更高分辨率编码器;
- 中高速时在固定时间窗内计数 tick;
- 很低速时测相邻脉冲的时间间隔;
- 对速度估计做适当低通滤波或滑动平均;
- 增大统计窗口可以减小量化抖动,但同时会增加延迟,因此要折中。
轮径误差
代码里的:
1 | R = 0.100 m |
只是一个模型参数。真实有效轮径可能因为轮胎制造误差、气压、载荷、磨损而变成:
1 | 左轮 0.099 m |
这会导致同样的编码器转角被换算成错误距离。左右轮半径不一致时,即使发直行命令,里程计也会逐渐出现航向偏差。
工程上通常通过定距离直行和多次重复实验标定“有效轮径”,必要时左右轮分别保存参数:
1 | R_left |
轮距误差
公式中的 L 是决定角速度的关键参数:
1 | w = (v_right - v_left) / L |
所以 L 偏小会把角速度估得偏大,L 偏大会把角速度估得偏小。
尤其是四轮 skid-steer 底盘,轮胎转弯时存在明显横向滑动,最合适的运动学参数往往是一个通过实验标定得到的:
1 | effective wheel separation |
它不一定等于拿尺子量出来的几何轮距。
常见标定方式是让机器人多次原地转固定角度、走固定半径圆弧,再根据实际转角与编码器结果反推更合适的有效轮距。
采样时间误差
积分公式里有:
1 | 位置变化 = 速度 * dt |
所以 dt 如果错了,位置一定跟着错。
例如程序“希望”以 20 Hz 运行,并不代表每一帧都严格是:
1 | 0.050000 s |
实际可能是:
1 | 0.048 s |
工程上不应该永远把 dt 写死成 0.05。更可靠的做法是使用真实采样/接收时间戳计算 dt,并对异常过大或过小的 dt 做保护。
这一问题会在第 13 章“时间戳、频率、延迟与同步”继续展开。
地面不平
本章二维模型默认车体始终在同一个水平面运动。但真实地面可能有坡度、台阶、坑洼和轮胎离地。
这时编码器仍然只能描述轮子自身转了多少,不能直接告诉系统:
1 | 车体是不是倾斜了 |
工程上通常结合 IMU 姿态、悬架/接触信息或外部定位;在崎岖路面上也应降低纯轮式 Odometry 的可信度。如果任务本身就是三维地形运动,则需要更完整的 3D 状态估计,而不是继续强行使用纯二维模型解释所有现象。
8.5 工程上通常不是“把公式改得更复杂”就结束
面对这些误差,一个比较实用的工程思路是:
1 | 系统性误差 |
因此下一节的 covariance 不是额外附加的数学概念,而是在回答一个非常现实的问题:
既然 Odometry 一定有误差,ROS 怎么把“这个结果有多不确定”一起传给后面的定位、融合和导航模块?
9. covariance:不仅要发布“估计值”,还要发布“对它有多不确定”
Odometry 中:
1 | pose.covariance |
和:
1 | twist.covariance |
都是:
1 | float64[36] |
第一次看到 36 很容易误以为这是 36 个互不相关、需要一个个手工配置的参数。实际上它只是一个 6 x 6 协方差矩阵按行展开后的存储形式。
9.1 covariance 到底是干什么的
假设 Driver 发布:
1 | x = 5.03 m |
这只回答了:
当前轮式里程计认为机器人在
x = 5.03 m。
但没有回答:
这个 5.03 m 到底有多可靠?
如果另外一个定位来源给出:
1 | x = 5.00 m |
融合器必须知道应该更信哪一个。于是测量除了“值”,还需要携带“不确定度”。
可以先用下面这个入门心智模型理解:
1 | 测量值 |
严格地说,covariance 不是一个“可信度百分比”,但它承担的工程作用确实是描述误差规模以及不同误差之间的关系。
9.2 先理解 variance:一个变量自己的不确定度
假设让机器人重复走同一段 5 m 路程,得到:
1 | 5.01 m |
如果测量结果非常集中,说明误差波动小;如果每次结果差异很大,说明不确定性高。
方差 variance 用来描述这种波动大小。
对于 x:
1 | variance(x) = sigma_x^2 |
标准差和方差的关系:
1 | sigma_x = sqrt(variance(x)) |
例如:
1 | variance(x) = 0.0025 m^2 |
那么:
1 | sigma_x = sqrt(0.0025) |
标准差 0.05 m 比抽象的 0.0025 m^2 更容易建立直觉:误差的典型尺度大约是厘米级,而不是毫米级。
注意单位也会平方:
1 | x / y / z 的单位 m |
9.3 covariance:两个误差是否会一起变化
只有方差还不够。
例如机器人转弯时,如果 yaw 估计偏大,积分到世界坐标系后 x 或 y 的误差也可能跟着变化。
这时需要描述:
1 | x_error 和 yaw_error 是否相关 |
这就是非对角线 covariance 的用途。
可以先这样区分:
1 | variance |
9.4 为什么 ROS 恰好使用 6 x 6 = 36 个数
对于 pose,ROS 使用 6 个自由度描述刚体位姿:
1 | x |
这 6 个变量两两组合,得到一个:
1 | 6 x 6 covariance matrix |
官方 geometry_msgs/PoseWithCovariance.msg 也明确说明,它采用 row-major 的 6x6 covariance matrix,顺序为:
1 | x, y, z, rotation about X, rotation about Y, rotation about Z |
对应二维移动机器人常说的:
1 | x, y, z, roll, pitch, yaw |
矩阵概念上是:
1 | x y z roll pitch yaw |
对角线表示“每个变量自己的方差”;非对角线表示“两个变量误差的协方差”。
官方定义:
9.5 36 个数是怎么放进 float64[36] 的
ROS 不是把二维矩阵直接存进消息,而是采用 row-major(按行优先)展开:
1 | 矩阵第 0 行 -> 数组 [0..5] |
因此对角线索引正好是:
1 | [0] -> x |
源码:
1 | // 6x6 covariance 的顺序是 x, y, z, roll, pitch, yaw。 |
这里之所以只填对角线,是因为当前教学示例只给每个维度一个独立的经验方差,没有建立 x/y/yaw 之间完整的相关误差模型。其余元素保持 0,相当于在这个简化模型中不描述交叉相关性。
twist.covariance 也是 6 x 6,但六个变量对应的是速度:
1 | linear.x |
也就是:
1 | vx, vy, vz, wx, wy, wz |
9.6 这些方差是自己随便填的吗
真实产品里不应该随便填。
本项目配置文件中的数字目前是教学参数,目的是让消息结构和参数使用方式可见,不代表某台真实底盘已经经过统计标定。
工程上常见的来源可以分成四类。
来源一:重复实验统计
这是最直观的方法。
例如需要估计 x 的方差,可以让机器人在相同条件下重复走:
1 | 真实距离 = 5.000 m |
用更可靠的 Ground Truth(例如高精度定位、运动捕捉、标定场地测量等)记录真实值,同时记录轮式 Odometry:
1 | 第 1 次 odom_x = 5.03 m |
先计算每次误差:
1 | error_i = odom_i - ground_truth_i |
再计算误差样本方差:
1 | variance = sum((error_i - mean_error)^2) / (N - 1) |
也可以写成:
1 | s^2 = sum((e_i - e_mean)^2) / (N - 1) |
如果得到:
1 | variance_x = 0.0025 m^2 |
这里还要区分 bias(系统偏差) 和 variance(随机波动)。
如果:
1 | mean_error = +0.08 m |
说明每次结果虽然可能很集中,但整体长期偏大约 8 cm。这类系统偏差首先应该通过轮径、轮距、比例因子等标定去消除,而不是指望 covariance 把错误的均值“修正回来”。Covariance 主要描述的是围绕均值的剩余不确定性。
另外,5 m 直行实验得到的统计量只代表相近工况。距离更长、速度更高、转弯更多或地面条件变化时,误差通常会继续累积,因此成熟系统会按运动模型传播 covariance,而不是把所有工况永久固定成同一个常数。
有了这些前提,这个实验才能给出比“凭感觉写一个 0.01”更有依据的:
1 | msg.pose.covariance[0] = 0.0025; |
yaw 也一样。可以让机器人重复执行固定角度旋转,例如 90°、360°,用外部参考获得真实角度,再统计:
1 | yaw_error = odom_yaw - ground_truth_yaw |
最后得到 yaw 误差的方差。
如果同时记录:
1 | x_error |
还可以进一步统计 cov(x,y)、cov(x,yaw)、cov(y,yaw),而不仅仅是三个对角线方差。
来源二:传感器或执行机构的噪声规格
某些传感器的数据手册会给噪声密度、重复性、分辨率等指标。这些指标可以作为建立输入噪声模型的依据。
对轮式底盘,还可能从:
1 | encoder 分辨率 |
建立输入误差模型。
但数据手册参数通常不能直接原封不动填进 Odometry.pose.covariance,因为中间还经过了运动学换算和积分。
来源三:用误差传播模型算出来
如果已经知道输入变量的 covariance:
1 | P_in |
又知道输出由某个函数:
1 | y = f(x) |
计算得到,那么一阶近似下常使用 Jacobian 做误差传播:
1 | P_out = J * P_in * J^T |
这里:
1 | J = f 对输入变量的 Jacobian |
对于差速底盘,可以把左右轮速度/位移的不确定性传播到:
1 | v |
这已经进入“概率机器人学/状态估计”的内容,本章只建立概念,不要求现在推导 Jacobian。
来源四:状态估计器实时维护
EKF、UKF 等状态估计器内部会实时维护状态 covariance:
1 | P |
随着:
1 | 预测 |
这个 P 会动态变化。
因此成熟系统里的 covariance 不一定是启动时固定写死的常量。
9.7 打滑时为什么可以把 covariance 调大
前面已经看到:轮胎打滑时,编码器和真实车体运动会发生分离。
如果系统能够检测到:
1 | 轮速变化很大 |
就有理由认为当前轮式 Odometry 比正常状态更不可靠。
一种工程策略是动态提高相关维度的 covariance,让下游融合器降低对这一路测量的权重。
例如概念上:
1 | 正常直行 |
注意:具体阈值和增大量必须来自系统实验与融合器设计,不能把某个示例数字复制到所有机器人上。
9.8 为什么 z、roll、pitch 在本示例里给很大的方差
当前轮式 Odometry 主要估计:
1 | x |
仅凭二维轮编码器,不能可靠观测:
1 | z |
因此示例把这些未观测维度设置成一个很大的方差:
1 | 1000000.0 |
它不是说“真实误差一定等于一百万”,而是在当前教学模型里明确表达:
这一路数据源对这些维度几乎没有可信观测,不应该依赖它。
不同下游状态估计器可能有自己的配置和测量选择规则,所以真实项目还需要结合具体融合器文档决定哪些维度启用、哪些维度禁用,而不是只依赖一个“大数字”。
9.9 当前代码里的 covariance 应该怎样理解
因此,看到:
1 | odom.pose.covariance[0] = 0.02; |
应该理解成:
这些是为了演示 ROS 消息和参数配置而给出的教学假设,不是“ROS 官方推荐值”,也不是已经针对某台真实底盘测量出来的结果。
真实产品的参数应尽量来自:
1 | 实际重复实验统计 |
最后可以把这一节压缩成两句话:
1 | Odometry 的数值回答:机器人现在大概在哪里、怎么运动。 |
10. DiagnosticArray 本章只做最小状态表达
本章发布:
1 | /diagnostics |
类型:
1 | diagnostic_msgs/DiagnosticArray |
其中一个 DiagnosticStatus 包含:
1 | level |
示例只表达:
1 | data.online == true |
源码:
1 | status.level = data.online |
这还不是完整的 Driver 健康管理。
第 14 章才会继续增加:
1 | timeout |
11. 构建并运行新的第 12 章
因为新增了标准消息依赖,先在 Host 重新构建 Docker Image:
1 | cd /home/wdfk/share/ros1-docker |
进入 Container:
1 | docker compose exec ros1-dev bash |
构建 package:
1 | cd /workspace/ros_ws |
构建成功后:
1 | source /workspace/ros_ws/devel/setup.bash |
运行:
1 | roslaunch ros1_driver_lab chassis_lab.launch |
这个版本只有一个 C++ Node,不再启动 Python TCP simulator,因此也不存在上一版 argparse 无法识别 __name:=... / __log:=... 的问题。
12. 发送 /cmd_vel,实际观察左右轮结果
另开一个 Container 终端:
1 | source /workspace/ros_ws/devel/setup.bash |
发送:
1 | rostopic pub -r 5 /cmd_vel geometry_msgs/Twist \ |
Driver 日志应该持续显示接近:
1 | v=0.300 m/s |
这正是前面手工推导的结果。
查看左右轮:
1 | rostopic echo /joint_states |
重点观察:
1 | name |
其中 position 会随着时间持续累计。
查看 IMU:
1 | rostopic echo /imu/data_raw |
重点观察:
1 | angular_velocity.z |
查看 Odometry:
1 | rostopic echo /odom |
重点观察:
1 | pose.pose.position.x |
查看 diagnostics:
1 | rostopic echo /diagnostics |
13. 现在应该怎样理解这个 Driver
第 12 章最后只需要建立下面这条主线:
1 | /cmd_vel |
到这里,Driver 最核心的数据契约已经建立。
下一章不再改变这些消息,而是继续追问:
这些数据到底是什么时刻采到的?多久发布一次?延迟和 jitter 怎么判断?编码器和 IMU 的时间能不能直接混在一起?
这就是第 13 章“时间戳、频率、延迟与同步”的入口。













