从 TF 到 URDF:坐标系与机器人模型学习路线
这篇文档面向刚开始接触 ROS2 坐标系、机器人模型和传感器融合的读者。文章不把 TF 和 URDF 当成两个孤立的知识点,而是把它们放进一条完整的工程链路中:先理解“坐标系之间如何变换”,再理解“机器人由哪些刚体和关节组成”,最后让模型、传感器、里程计、SLAM 和导航在同一棵 TF 树上协同工作。
本文以 ROSLander 多模态机器人为主要场景,围绕其移动底盘、激光雷达、深度相机和 IMU 的坐标关系展开。不同 ROSLander 版本的功能包、命名空间和传感器驱动可能存在差异,因此文中的包名、话题名和 launch 文件应先用 ros2 topic list、ros2 node list 与 TF 图确认,再替换为当前系统实际名称。
1. 先说结论
学习 TF 到 URDF,最重要的不是先背 XML 标签,而是先建立下面四个判断。
-
TF2 解决的是运行时坐标变换问题。
它回答:“在某个时间点,坐标系 A 中的点、姿态或传感器数据,如何转换到坐标系 B 中?”
-
URDF 解决的是机器人本体结构描述问题。
它回答:“机器人有哪些
link,这些刚体通过哪些joint连接,每个部件的外观、碰撞和惯性是什么?” -
Xacro 解决的是 URDF 文件的维护问题。
当机器人包含多个相似部件、许多尺寸参数或多个传感器时,Xacro 可以用属性、宏和文件拆分减少重复代码。
-
robot_state_publisher把模型结构和关节状态转换成 TF。它读取 URDF 或 Xacro 生成的机器人描述,再结合
/joint_states,发布机器人各个 link 之间的固定或动态变换。
可以先记住这条主链路:
坐标系与变换数学
-> TF2:查询、广播、时间戳
-> URDF:link、joint、origin、axis
-> Xacro:参数化和复用
-> robot_state_publisher
-> RViz2:显示模型和 TF
-> 传感器、里程计、SLAM、导航、控制
还要记住一个边界:URDF 不负责替代所有 TF。
URDF 通常负责机器人本体内部的关系,例如:
base_footprint -> base_link -> body_link
body_link -> lidar_link
body_link -> depth_cam_link
body_link -> wheel_front_right_link
而下面这些关系一般由其他节点负责:
map -> odom 由 SLAM 或定位节点发布
odom -> base_footprint 由里程计、底盘或融合节点发布
如果只把 URDF 模型显示出来,却没有真实的 /odom、传感器数据和动态 TF,机器人仍然不能完成定位、建图或导航。
2. 适用平台与软件环境
2.1 适用平台与场景
| 项目 | 示例 |
|---|---|
| 机器人平台 | ROSLander 多模态机器人(Orin Nano 版本) |
| ROS2 任务 | 机器人模型显示、传感器安装关系检查、TF 排查 |
| 典型传感器 | 激光雷达、深度相机、IMU、摄像头 |
| 典型坐标系 | map、odom、base_footprint、base_link、body_link、lidar_link |
| 上层功能 | RViz2、SLAM、Nav2、robot_localization、仿真 |
| 目标 | 能从坐标系图读懂 ROSLander,并能建立可检查的 URDF/Xacro |
ROSLander 的 ROS2 系统需要把底盘运动、里程计、雷达、相机和 IMU 放到同一棵 TF 树中。接入或替换节点时,需要确认 /joint_states、/odom、/scan、图像和 IMU 话题是否存在,并确保消息中的 header.frame_id 与 URDF/Xacro 中的 frame 名称一致。URDF 只描述几何和连接关系,不能自动生成真实电机反馈和里程计。
2.2 软件环境
本文以以下环境为例:
| 项目 | 示例配置 |
|---|---|
| 操作系统 | Ubuntu 22.04 |
| ROS2 | Humble |
| Python | Python 3.10 |
| 模型描述 | URDF、Xacro |
| TF 组件 | tf2_ros、tf2_tools |
| 模型发布 | robot_state_publisher |
| 关节状态 | joint_state_publisher 或底盘控制器 |
| 可视化 | RViz2、rqt_tf_tree |
| 工作空间 | ~/ros2_ws |
如果使用 Iron、Jazzy 或其他 ROS2 发行版,命令结构大体相同,但软件包名称、Python API 和默认启动方式应以当前系统为准。先确认环境已经加载:
echo $ROS_DISTRO
printenv | grep -E 'ROS_VERSION|ROS_DISTRO|RMW_IMPLEMENTATION'
常用依赖可以按当前发行版安装:
sudo apt update
sudo apt install \
ros-${ROS_DISTRO}-tf2-ros \
ros-${ROS_DISTRO}-tf2-tools \
ros-${ROS_DISTRO}-robot-state-publisher \
ros-${ROS_DISTRO}-joint-state-publisher \
ros-${ROS_DISTRO}-joint-state-publisher-gui \
ros-${ROS_DISTRO}-xacro \
ros-${ROS_DISTRO}-rviz2 \
ros-${ROS_DISTRO}-rqt-tf-tree
安装后,可以用下面的命令确认关键软件包是否被 ROS2 找到:
ros2 pkg prefix tf2_ros
ros2 pkg prefix tf2_tools
ros2 pkg prefix robot_state_publisher
ros2 pkg prefix xacro
ros2 pkg prefix rviz2
如果返回了安装目录,说明当前终端能够找到对应的软件包。若提示 Package not found,先处理 ROS2 环境加载或软件包安装问题,不要直接修改 URDF。
3. 为什么要从 TF 学到 URDF
3.1 两者解决的问题不同
| 对比项 | TF2 | URDF |
|---|---|---|
| 主要作用 | 管理运行时坐标变换 | 描述机器人本体结构 |
| 关注对象 | frame、时间戳、变换、广播者 | link、joint、几何、碰撞、惯性 |
| 是否随时间变化 | 可以变化 | 文件本身不随时间变化 |
| 典型输入 | TransformStamped、TF 话题 |
XML 或 Xacro 文件 |
| 典型输出 | /tf、/tf_static |
robot_description 参数 |
| 常见使用者 | RViz、SLAM、Nav2、传感器节点 | robot_state_publisher、仿真、RViz |
| 典型故障 | frame 不存在、时间外推、重复发布 | XML 错误、关节方向错、网格路径错 |
3.2 为什么不能只学 URDF
只会写 URDF,可能会得到一个“看起来像机器人”的模型,但仍然无法解释以下问题:
- 雷达话题明明存在,为什么 RViz 中的激光点跑到了机器人后方?
tf2_echo base_footprint lidar_link为什么查不到变换?- 为什么在
base_link下能看到模型,在map下却报错? - 为什么
/odom正在发布,但导航系统提示缺少odom -> base_footprint? - 为什么同一个
body_link -> lidar_link被两个节点同时发布?
这些问题都属于 TF 运行时问题,不能只靠查看 URDF 文件解决。
3.3 为什么不能只学 TF
只会手动发布静态 TF,也会很快遇到维护问题:
- 轮子、传感器和外壳的关系被分散在多个 launch 文件中。
- 相同的左右轮模型需要复制多份 XML。
- 改一个车体尺寸,要同时修改多个坐标值。
- RViz 中能看到 TF,但没有碰撞模型和惯性属性。
- 后续接入控制器或仿真时,模型结构不完整。
因此,合理的学习方式是:先用 TF2 形成空间关系思维,再用 URDF/Xacro 固化机器人结构。
4. 先把坐标系基础补齐
4.1 frame 是什么
一个 frame 可以理解为一个带有原点和三根坐标轴的局部参考系。机器人上常见的 frame 包括:
| frame | 常见含义 |
|---|---|
map |
全局地图坐标系,定位修正时可能发生跳变 |
odom |
局部里程计坐标系,短时间连续,但允许漂移 |
base_link |
ROSLander 外部主体模型的参考坐标系 |
base_footprint |
将机器人底盘投影到地面的二维参考系,可选 |
body_link |
ROSLander 内部主体模型的参考坐标系 |
lidar_link |
模型中激光雷达的安装坐标系 |
depth_cam_link |
深度相机安装坐标系 |
screen_link |
机身屏幕对应的模型坐标系 |
lidar_frame / imu_frame |
驱动侧可能使用的传感器坐标系,以运行时 TF 为准 |
面对任何一个新 frame,都建议按下面五个问题检查:
- 它的父坐标系是谁?
- 原点位于机器人哪个物理位置?
- X、Y、Z 轴分别指向哪里?
- 这个变换是固定的还是随关节、里程计或定位变化的?
- 当前系统中的哪个节点负责发布它?
4.2 ROS2 中的常用坐标约定
移动机器人主体通常遵循右手坐标系:
X:机器人前方
Y:机器人左方
Z:机器人上方
使用这些约定时,常见的旋转方向可以这样理解:
- 绕 X 轴旋转是 roll。
- 绕 Y 轴旋转是 pitch。
- 绕 Z 轴旋转是 yaw。
ROS2 中长度通常使用米,角度通常使用弧度。下面这些值表示 90 度,而不是 90 弧度:
1.5708 rad ≈ 90 deg
3.1416 rad ≈ 180 deg
不要把毫米直接写进 URDF,也不要把角度制的 90 直接填入 rpy。例如,雷达安装高度 180 mm 应写为:
<origin xyz="0.15 0 0.18" rpy="0 0 0"/>
4.3 变换的数学含义
一个刚体变换可以写成齐次变换矩阵:
T = | R t |
| 0 1 |
其中 R 表示旋转,t 表示平移。如果一个点在子坐标系中的坐标是 p_child,要把它表示到父坐标系中,可以写成:
p_parent = T_parent_child * p_child
实际开发中不需要每天手算矩阵,但要能理解变换的组合关系。例如:
map -> odom -> base_footprint -> base_link -> body_link -> lidar_link
查询 map 到 lidar_link 的关系时,TF2 会把中间的多段变换组合起来。任何一段缺失,整条查询链路都会失败。若当前雷达驱动使用 lidar_frame,应以运行时 TF 图中的名称替换 lidar_link。
4.4 父子方向不能凭感觉判断
在 TF 消息中,通常使用下面的表达方式:
header.frame_id = parent_frame
child_frame_id = child_frame
这表示 child frame 的位姿是相对于 parent frame 描述的。使用命令查询时:
ros2 run tf2_ros tf2_echo base_footprint lidar_link
可以把它理解为“查询 lidar_link 相对于 base_footprint 的变换”,也就是把雷达坐标中的数据转换到机器人底盘参考系时需要使用的关系。若当前驱动消息的 header.frame_id 是 lidar_frame,就将命令中的 lidar_link 替换为 lidar_frame。实际输出中的 target/source 字段以当前 ROS2 版本为准,但排查时一定要明确自己想问的是“谁相对于谁”。
4.5 传感器坐标系的特殊约定
相机经常同时出现两个 frame:
body_link -> depth_cam_link -> depth_cam_optical_frame
depth_cam_link 更接近 ROSLander 主体坐标约定,depth_cam_optical_frame 是否存在要以当前相机驱动为准。若驱动提供光学坐标系,通常遵循:
X:图像右方
Y:图像下方
Z:镜头前方
如果图像投影、点云方向或目标检测结果出现整体旋转 90 度、上下颠倒或前后反向,优先检查驱动使用的是 depth_cam_link 还是光学 frame,不要先修改算法参数。
5. TF2:理解机器人运行时的空间关系
5.1 TF2 的组成
TF2 主要围绕三类对象工作:
- 广播器:发布一个或多个坐标系之间的变换。
- 监听器:查询并缓存变换,在需要时完成坐标转换。
- 缓存与时间系统:根据时间戳保存历史变换,支持查询某个时刻的空间关系。
常见话题是:
| 话题 | 作用 |
|---|---|
/tf |
动态变换,通常持续更新 |
/tf_static |
静态变换,发布后供晚加入节点获取 |
固定安装的雷达、相机和 IMU 可以由 URDF + robot_state_publisher 发布,也可以使用 static_transform_publisher 发布。同一条 parent → child 关系只能保留一个明确的发布者。
5.2 静态变换示例
下面的命令适合做独立的 TF2 练习:将一个名为 lidar_frame 的雷达坐标系放在 base_footprint 前方 0.15 m、上方 0.18 m 的位置。这种命名对应 ROSLander 驱动侧常见的 frame;如果模型已经由 URDF 发布 lidar_link,不要再额外发布同一条安装关系。
ros2 run tf2_ros static_transform_publisher \
0.15 0 0.18 0 0 0 base_footprint lidar_frame
启动后,在另一个终端查询:
ros2 run tf2_ros tf2_echo base_footprint lidar_frame
这个练习的目的,是先确认你理解“父坐标系、子坐标系、平移、旋转”之间的关系。等模型转为 URDF 后,不要继续运行这条命令,否则会和 robot_state_publisher 重复发布同一条静态 TF。
5.3 动态变换来自哪里
动态 TF 的来源通常包括:
- 机械臂关节控制器发布的关节状态。
- 移动底盘根据轮速计算的
odom -> base_footprint。 robot_localization融合里程计和 IMU 后发布的变换。- SLAM 或 AMCL 发布的
map -> odom。 - 仿真系统根据关节和模型状态发布的变换。
robot_state_publisher 自身并不知道机器人实际走了多远。它只根据 URDF 的父子关系和 /joint_states 中的关节位置,计算模型内部的 link 变换。因此不要把 robot_state_publisher 当成里程计节点。
5.4 TF 树必须满足的结构约束
一棵正常的 TF 树通常满足:
- 每个 child frame 最多只有一个父 frame。
- 整棵树不能形成闭环。
- 应该存在一个可以追溯到的根 frame。
- frame 名称区分大小写,
base_link和Base_Link不是同一个 frame。 - 同一条变换不要由多个节点同时发布。
典型错误包括:
body_link -> lidar_link 由 robot_state_publisher 发布
body_link -> lidar_link 又由 static_transform_publisher 发布
这种配置即使短时间内看起来能工作,也可能导致 RViz 中模型抖动、传感器数据跳动或查询结果不稳定。
6. 移动机器人常见 TF 树
6.1 推荐先看这棵树
ROSLander 的模型和移动链路可以先按下面这棵 TF 树理解:
map
└── odom
└── base_footprint
└── base_link
└── body_link
├── wheel_front_left_link
├── wheel_front_right_link
├── wheel_back_left_link
├── wheel_back_right_link
├── lidar_link
├── depth_cam_link
└── screen_link
这棵树可以拆成两层。
第一层是机器人本体内部结构:
base_footprint -> base_link -> body_link
body_link -> wheels / lidar_link / depth_cam_link / screen_link
这部分通常来自 URDF、Xacro 和 robot_state_publisher。
第二层是机器人在外部世界中的位姿:
map -> odom -> base_footprint -> base_link -> body_link
这部分通常由 SLAM、定位、里程计、底盘或状态融合节点负责。
6.2 map、odom、base_link 的职责
| frame | 主要特点 | 常见发布者 |
|---|---|---|
map |
全局参考,定位修正时可能跳变 | SLAM、AMCL、其他定位系统 |
odom |
局部连续,允许长期漂移 | 轮式里程计、视觉里程计、融合节点 |
base_link |
ROSLander 外部主体参考 | 机器人模型中的主体 link |
base_footprint |
地面投影,方便二维导航 | 底盘或模型节点,可选 |
body_link |
机身内部模型参考 | URDF/Xacro 中的固定 link |
通常可以这样理解:
odom -> base_footprint描述机器人在局部连续坐标中的二维运动。base_footprint -> base_link -> body_link描述 ROSLander 本体的固定层级。map -> odom用全局定位结果修正里程计漂移。map -> base_footprint是两段变换组合后的机器人全局位姿。
不要在 URDF 中硬编码 map -> odom。地图和定位会改变,URDF 应该只描述机器人自身的固定结构与关节关系。
6.3 各节点的职责边界
| 节点或组件 | 应该负责什么 | 不应该替代什么 |
|---|---|---|
robot_state_publisher |
根据 URDF 和关节状态发布 link TF | 不负责轮速里程计和全局定位 |
joint_state_publisher |
测试或模拟发布 /joint_states |
不代表真实电机反馈 |
| 底盘驱动 | 电机控制、编码器、里程计 | 不应重复发布模型内部固定 TF |
static_transform_publisher |
独立发布固定安装关系 | 不应与 URDF 重复发布同一条关系 |
| SLAM | 地图构建和 map -> odom |
不应替代底盘里程计 |
| AMCL | 在已有地图中定位 | 不应和 SLAM 同时抢占 map -> odom |
| RViz2 | 可视化和交互检查 | 不负责修复 TF |
6.4 为什么有时用 base_footprint
二维导航通常只关心机器人在地面上的 X、Y 和 yaw。base_footprint 可以理解为 base_link 在地面平面上的投影:
odom -> base_footprint -> base_link -> body_link
也有工程直接使用:
odom -> base_footprint
两种方式都可能是正确的,关键是 Nav2、SLAM、底盘和传感器参数必须使用同一套命名。不要因为其他系统常用 base_footprint,就强行给当前工程增加一条未经验证的 TF。
7. URDF:把机器人结构写成模型
7.1 URDF 的基本结构
URDF 使用 XML 描述机器人模型,最小结构如下:
<?xml version="1.0"?>
<robot name="roslander">
<link name="base_footprint"/>
<link name="base_link"/>
<joint name="base_joint" type="fixed">
<parent link="base_footprint"/>
<child link="base_link"/>
</joint>
</robot>
一个可用的移动机器人模型通常至少包含:
- 一个根 link,例如
base_footprint,以及表示 ROSLander 主体的base_link和body_link。 - 轮子、传感器和外壳对应的其他 link。
- 连接这些 link 的 joint。
visual几何,用于 RViz2 显示。collision几何,用于碰撞检测或仿真。inertial属性,用于动力学计算和仿真。
7.2 link 描述什么
link 表示一个刚体。常用部分如下:
<link name="lidar_link">
<visual>
<origin xyz="0 0 0" rpy="0 0 0"/>
<geometry>
<cylinder radius="0.04" length="0.06"/>
</geometry>
<material name="black"/>
</visual>
<collision>
<origin xyz="0 0 0" rpy="0 0 0"/>
<geometry>
<cylinder radius="0.04" length="0.06"/>
</geometry>
</collision>
</link>
三部分的职责不同:
| 部分 | 作用 | 调整时的重点 |
|---|---|---|
visual |
描述看起来是什么样 | 外观、颜色、网格、显示位置 |
collision |
描述碰撞边界 | 尽量简单、覆盖真实占用空间 |
inertial |
描述质量、质心和惯性 | 仿真和动力学计算必须合理 |
初学阶段可以先完成 visual,但准备接入 Gazebo、Isaac Sim 或控制器时,不能长期缺少 collision 和 inertial。
7.3 joint 描述什么
joint 描述两个 link 之间的连接方式:
<joint name="lidar_joint" type="fixed">
<parent link="body_link"/>
<child link="lidar_link"/>
<origin xyz="0.15 0 0.18" rpy="0 0 0"/>
</joint>
关键字段如下:
| 字段 | 含义 |
|---|---|
name |
关节名称,必须唯一 |
type |
关节类型 |
parent |
父 link |
child |
子 link |
origin |
子关节坐标系相对父关节坐标系的安装位姿 |
axis |
可动关节的运动轴 |
limit |
可动关节的位置、速度和力限制 |
常用关节类型:
| 类型 | 说明 | 典型使用 |
|---|---|---|
fixed |
固定连接,不允许运动 | 雷达、相机、IMU、外壳 |
continuous |
绕一个轴无限旋转 | 轮子、连续旋转轴 |
revolute |
绕一个轴有限角度旋转 | 机械臂关节、舵机 |
prismatic |
沿一个轴平移 | 直线滑台、升降机构 |
planar |
平面内运动 | 特殊机构,使用较少 |
floating |
六自由度浮动 | 特殊仿真或模型场景 |
7.4 origin 是最容易出错的地方
在 joint 中,origin 不是“把模型在 RViz 里随便挪一下”,而是定义父子坐标系之间的安装关系:
<origin xyz="x y z" rpy="roll pitch yaw"/>
例如:
<origin xyz="0.20 -0.16 0.05" rpy="0 0 0"/>
可以读成:子 link 的关节原点位于父 link 坐标系的前方 0.20 m、右方 0.16 m、上方 0.05 m 处。因为 ROS2 的 Y 轴通常指向左侧,所以右侧位置使用负 Y。
排查安装位置时,建议先只改平移,不要同时改旋转:
- 先确认雷达、相机或轮子的 X/Y/Z 位置。
- 再确认坐标轴方向。
- 最后确认 roll、pitch、yaw。
这样可以避免“位置和方向一起错”导致的定位困难。
7.5 axis 决定关节怎么动
轮子关节常见写法是:
<axis xyz="0 1 0"/>
这表示关节围绕 Y 轴旋转。具体使用 X、Y 还是 Z,取决于轮轴在模型中的方向。不要因为“所有轮子都应该写同一个 axis”就机械复制;应先观察轮子模型的局部坐标轴和实际轮轴方向。
axis 写错时,常见现象是:
- 轮子围绕竖直方向旋转,看起来像陀螺。
- 左右轮一个正转、一个反转,模型运动方向相反。
- 仿真中轮子无法产生正确的前进力。
7.6 一个最小 URDF 片段
下面的模型包含底盘和一个固定安装的雷达:
<?xml version="1.0"?>
<robot name="roslander">
<material name="blue">
<color rgba="0.1 0.4 0.8 1.0"/>
</material>
<material name="black">
<color rgba="0.05 0.05 0.05 1.0"/>
</material>
<link name="base_footprint"/>
<link name="base_link"/>
<joint name="base_joint" type="fixed">
<parent link="base_footprint"/>
<child link="base_link"/>
<origin xyz="0 0 0.05" rpy="0 0 0"/>
</joint>
<link name="body_link">
<visual>
<origin xyz="0 0 0.10" rpy="0 0 0"/>
<geometry>
<box size="0.36 0.28 0.18"/>
</geometry>
<material name="blue"/>
</visual>
</link>
<joint name="body_joint" type="fixed">
<parent link="base_link"/>
<child link="body_link"/>
<origin xyz="0 0 0.04" rpy="0 0 0"/>
</joint>
<link name="lidar_link">
<visual>
<geometry>
<cylinder radius="0.04" length="0.06"/>
</geometry>
<material name="black"/>
</visual>
</link>
<joint name="lidar_joint" type="fixed">
<parent link="body_link"/>
<child link="lidar_link"/>
<origin xyz="0.15 0 0.23" rpy="0 0 0"/>
</joint>
</robot>
这个文件只能说明 ROSLander 模型中“雷达安装在机身前方上部”,并不会自动让雷达发布 /scan,也不会自动产生 odom -> base_footprint。要完成定位、建图和导航,还需要接入驱动节点、传感器话题、里程计和 TF 链路。
8. Xacro:从能写模型到能维护模型
8.1 为什么要使用 Xacro
当模型中出现以下情况时,就应该从纯 URDF 过渡到 Xacro:
- 左右轮、前后轮结构相似。
- 车体尺寸需要反复调整。
- 不同 ROSLander 配置版本只差一个传感器或一组尺寸。
- 模型文件已经超过几百行,复制粘贴容易出错。
- 需要把底盘、轮子、相机、雷达拆成多个文件。
Xacro 常用能力包括:
| 能力 | 作用 |
|---|---|
property |
集中管理尺寸和常量 |
macro |
复用轮子、传感器等重复结构 |
include |
拆分底盘、传感器和材质文件 |
| 表达式 | 根据参数计算安装位置 |
| 条件配置 | 根据传感器和配置选项生成不同模型 |
8.2 属性参数化
<xacro:property name="base_length" value="0.40"/>
<xacro:property name="base_width" value="0.30"/>
<xacro:property name="base_height" value="0.20"/>
<xacro:property name="wheel_radius" value="0.05"/>
在几何或关节位置中使用:
<box size="${base_length} ${base_width} ${base_height}"/>
<origin xyz="${base_length / 2 - 0.04} 0 ${wheel_radius}"/>
以后调整车体长度时,只修改属性,不需要逐行搜索多个数字。
8.3 用宏生成相似轮子
<xacro:macro name="wheel" params="prefix x y">
<link name="${prefix}_wheel_link">
<visual>
<origin rpy="${pi / 2} 0 0"/>
<geometry>
<cylinder radius="${wheel_radius}" length="${wheel_width}"/>
</geometry>
<material name="black"/>
</visual>
</link>
<joint name="${prefix}_wheel_joint" type="continuous">
<parent link="base_link"/>
<child link="${prefix}_wheel_link"/>
<origin xyz="${x} ${y} ${wheel_radius}" rpy="0 0 0"/>
<axis xyz="0 1 0"/>
</joint>
</xacro:macro>
调用宏:
<xacro:wheel prefix="front_left" x="0.15" y="0.18"/>
<xacro:wheel prefix="front_right" x="0.15" y="-0.18"/>
<xacro:wheel prefix="rear_left" x="-0.15" y="0.18"/>
<xacro:wheel prefix="rear_right" x="-0.15" y="-0.18"/>
8.4 Xacro 文件拆分建议
一个适合学习和维护的目录可以是:
roslander_description/
├── urdf/
│ ├── roslander.urdf.xacro
│ ├── materials.xacro
│ ├── base.xacro
│ ├── wheels.xacro
│ └── sensors.xacro
├── launch/
│ └── display.launch.py
├── meshes/
├── rviz/
└── package.xml
拆分时要保留一个清晰入口。建议由 roslander.urdf.xacro 负责:
- 声明命名空间和全局参数。
- include 底盘、轮子和传感器文件。
- 定义唯一的根 link。
- 根据参数选择可选传感器。
不要为了拆分而拆分。如果一个只有几十行的模型被拆成十个文件,初学者反而更难追踪一条 joint 的来源。
9. 实战:创建一个可视化移动机器人模型
这一节建立一个最小可运行的 ROS2 Python 功能包,启动 joint_state_publisher、robot_state_publisher 和 RViz2,在 RViz2 中显示 ROSLander 的底盘、四个麦克纳姆轮、激光雷达、深度相机和 IMU 坐标关系。
9.1 创建功能包
先创建工作空间和功能包:
mkdir -p ~/ros2_ws/src
cd ~/ros2_ws/src
ros2 pkg create roslander_description \
--build-type ament_python \
--dependencies rclpy robot_state_publisher \
joint_state_publisher xacro rviz2
创建模型和启动文件目录:
cd ~/ros2_ws/src/roslander_description
mkdir -p urdf launch rviz meshes
最终目录大致如下:
roslander_description/
├── roslander_description/
│ └── __init__.py
├── resource/
│ └── roslander_description
├── urdf/
├── launch/
├── rviz/
├── setup.py
└── package.xml
9.2 编写 Xacro 模型
在 urdf/roslander.urdf.xacro 中写入下面内容:
<?xml version="1.0"?>
<robot xmlns:xacro="http://www.ros.org/wiki/xacro" name="roslander">
<xacro:property name="pi" value="3.141592653589793"/>
<xacro:property name="L" value="0.40"/>
<xacro:property name="W" value="0.30"/>
<xacro:property name="H" value="0.20"/>
<xacro:property name="R" value="0.05"/>
<xacro:property name="wheel_x" value="0.15"/>
<xacro:property name="wheel_y" value="0.18"/>
<link name="base_footprint"/>
<link name="base_link"/>
<joint name="base_joint" type="fixed">
<parent link="base_footprint"/><child link="base_link"/>
<origin xyz="0 0 0.05" rpy="0 0 0"/>
</joint>
<link name="body_link">
<visual><origin xyz="0 0 ${H / 2}"/>
<geometry><box size="${L} ${W} ${H}"/></geometry>
</visual>
</link>
<joint name="body_joint" type="fixed">
<parent link="base_link"/><child link="body_link"/>
<origin xyz="0 0 0.04" rpy="0 0 0"/>
</joint>
<xacro:macro name="wheel" params="prefix x y">
<link name="wheel_${prefix}_link">
<visual><origin rpy="${pi / 2} 0 0"/>
<geometry><cylinder radius="${R}" length="0.04"/></geometry>
</visual>
</link>
<joint name="${prefix}_wheel_joint" type="continuous">
<parent link="body_link"/><child link="wheel_${prefix}_link"/>
<origin xyz="${x} ${y} ${R}"/><axis xyz="0 1 0"/>
</joint>
</xacro:macro>
<xacro:wheel prefix="front_left" x="${wheel_x}" y="${wheel_y}"/>
<xacro:wheel prefix="front_right" x="${wheel_x}" y="-${wheel_y}"/>
<xacro:wheel prefix="rear_left" x="-${wheel_x}" y="${wheel_y}"/>
<xacro:wheel prefix="rear_right" x="-${wheel_x}" y="-${wheel_y}"/>
<link name="lidar_link"/>
<joint name="body_to_lidar" type="fixed">
<parent link="body_link"/><child link="lidar_link"/>
<origin xyz="0.14 0 0.28"/>
</joint>
<link name="depth_cam_link"/>
<joint name="body_to_depth_cam" type="fixed">
<parent link="body_link"/><child link="depth_cam_link"/>
<origin xyz="0.18 0 0.25"/>
</joint>
<link name="depth_cam_optical_frame"/>
<joint name="depth_cam_to_optical" type="fixed">
<parent link="depth_cam_link"/><child link="depth_cam_optical_frame"/>
<origin rpy="-${pi / 2} 0 -${pi / 2}"/>
</joint>
<link name="imu_frame"/>
<joint name="body_to_imu" type="fixed">
<parent link="body_link"/><child link="imu_frame"/>
<origin xyz="0 0 0.24"/>
</joint>
</robot>
这份模型只用于理解坐标关系和验证 TF,不追求外观精度:
base_footprint -> base_link -> body_link是 ROSLander 的主体层级。- 四个轮子通过
continuousjoint 连接。 lidar_link、depth_cam_link和imu_frame通过fixedjoint 安装。depth_cam_link继续连接到depth_cam_optical_frame。- 所有长度使用米,角度使用弧度。
接入仿真或控制前,再为各个 link 补充合理的 collision、inertial、材质和网格模型。
9.3 先把 Xacro 展开成 URDF
进入模型目录执行:
cd ~/ros2_ws/src/roslander_description/urdf
xacro roslander.urdf.xacro > /tmp/roslander.urdf
如果 Xacro 写错,命令会直接输出错误。常见错误包括:
- 标签没有闭合。
- 宏参数名称写错。
- 属性不存在。
- 数学表达式格式错误。
- 同一个 link 或 joint 重复定义。
展开成功后,可以继续检查 URDF:
check_urdf /tmp/roslander.urdf
如果系统没有 check_urdf,可以先安装对应的 URDF 工具,或至少使用 xacro 的错误输出和 robot_state_publisher 启动日志进行检查。
9.4 编写 launch 文件
在 launch/display.launch.py 中写入:
from launch import LaunchDescription
from launch.substitutions import Command, FindExecutable, PathJoinSubstitution
from launch_ros.actions import Node
from launch_ros.substitutions import FindPackageShare
def generate_launch_description():
xacro_file = PathJoinSubstitution([
FindPackageShare('roslander_description'), 'urdf', 'roslander.urdf.xacro'])
description = Command([FindExecutable(name='xacro'), ' ', xacro_file])
params = {'robot_description': description}
return LaunchDescription([
Node(package='joint_state_publisher', executable='joint_state_publisher',
parameters=[params]),
Node(package='robot_state_publisher', executable='robot_state_publisher',
parameters=[params]),
Node(package='rviz2', executable='rviz2')])
桌面端想用滑块时,把第一个节点的包名和可执行文件改为 joint_state_publisher_gui;真实系统则由底盘控制器发布 /joint_states,不要与测试节点重复发布。
9.5 安装、编译并启动
setup.py 必须把 launch/、urdf/ 和 rviz/ 安装到 share/roslander_description,package.xml 至少声明 robot_state_publisher、joint_state_publisher、xacro 和 rviz2 运行依赖。然后执行:
cd ~/ros2_ws
colcon build --symlink-install --packages-select roslander_description
source install/setup.bash
ros2 launch roslander_description display.launch.py
9.6 在 RViz2 中显示模型
启动后按下面顺序配置:
Fixed Frame设为base_footprint。- 添加
RobotModel、TF和Grid。 - 确认机器人描述来自
/robot_description或对应参数。 - 检查底盘高度、轮轴方向和传感器坐标轴。
模型显示阶段不要把 Fixed Frame 改成 map,因为尚未有节点发布 map -> odom。
10. 验证模型与 TF 是否正确
验证不要只看 RViz 中“像不像”。建议按照“文件 → 节点 → 话题 → TF → 实际几何”的顺序检查。
10.1 检查 Xacro 和 URDF 文件
cd ~/ros2_ws/src/roslander_description/urdf
xacro roslander.urdf.xacro > /tmp/roslander.urdf
check_urdf /tmp/roslander.urdf
关注以下结果:
- 是否存在 XML 解析错误。
- 是否存在多个根 link。
- 是否有孤立 link。
- joint 的 parent 和 child 是否都已经定义。
- link、joint 名称是否重复。
10.2 检查节点和参数
ros2 node list
ros2 node info /robot_state_publisher
ros2 param list /robot_state_publisher
如果节点名称被命名空间包裹,例如 /robot1/robot_state_publisher,后面的命令要使用实际节点名。不要直接照抄文档中的绝对节点名。
10.3 检查关节状态
ros2 topic list | grep joint
ros2 topic type /joint_states
ros2 topic echo /joint_states --once
正常情况下,消息类型应为:
sensor_msgs/msg/JointState
如果 /joint_states 不存在:
- 测试模型可能没有启动
joint_state_publisher。 - 真实机器人可能没有启动底盘控制器。
- topic 被放在命名空间中。
- 当前模型全部是 fixed joint,测试节点的行为可能与预期不同。
如果 /joint_states 中的名称和 URDF 中的 joint 名称不一致,robot_state_publisher 无法为对应关节计算正确的动态变换。
10.4 查询一条明确的 TF
先查询固定传感器:
ros2 run tf2_ros tf2_echo base_footprint lidar_link
ros2 run tf2_ros tf2_echo base_footprint depth_cam_link
ros2 run tf2_ros tf2_echo depth_cam_link depth_cam_optical_frame
再查询轮子:
ros2 run tf2_ros tf2_echo body_link wheel_front_left_link
每次只查一条关系,并把输出和 URDF 的 origin 对照。这样比直接盯着整棵图更容易定位具体错误。
10.5 查看完整 TF 图
ros2 run tf2_tools view_frames
该命令会收集当前 TF 并生成一份关系图或对应的描述文件,具体文件名取决于 ROS2 发行版。也可以使用:
ros2 run rqt_tf_tree rqt_tf_tree
重点检查:
- 是否出现两个根 frame。
- 传感器是否挂在预期的
body_link下。 - 是否出现重复或异常的 frame 名称。
map -> odom -> base_footprint -> base_link -> body_link是否完整。- 是否存在不属于当前机器人的旧 frame。
10.6 检查 TF 发布者
ros2 topic info /tf -v
ros2 topic info /tf_static -v
如果一条固定变换在 /tf_static 中出现多个来源,优先检查是否同时启用了:
- URDF +
robot_state_publisher。 - 传感器驱动自带的静态 TF。
- 手工启动的
static_transform_publisher。 - 另一个机器人描述 launch。
10.7 用 RViz2 做几何验证
模型在 RViz2 中显示后,至少检查以下内容:
| 检查对象 | 观察内容 |
|---|---|
| 底盘 | 长宽高与实物比例是否一致 |
| 轮子 | 轮轴方向是否正确,左右位置是否对称 |
| 雷达 | 原点是否在真实安装位置,扫描方向是否朝外 |
| 相机 | depth_cam_link 和光学 frame 是否分工正确 |
| IMU | 轴方向是否与驱动消息约定一致 |
| 地面网格 | 轮子是否接触地面,底盘是否悬空 |
模型比例不对时,先查毫米与米;模型位置不对时,查 joint origin;模型方向不对时,查 rpy 和传感器驱动 frame;TF 查询失败时,查 frame 名称、节点和命名空间。
10.8 进入真实机器人或 SLAM 前的检查
在接入传感器、建图和导航前,至少执行:
ros2 topic list | grep -E 'scan|image|imu|odom|tf|joint'
ros2 topic hz /odom
ros2 topic echo /scan --once
ros2 run tf2_ros tf2_echo odom base_footprint
ros2 run tf2_ros tf2_echo base_footprint lidar_link
实际话题可能是 /scan_filtered、/odometry/filtered 或带机器人名称空间的路径。命令中的名称只是示例,必须先用 ros2 topic list 确认。
11. 从 ROSLander 接入真实系统时要补什么
11.1 URDF 不等于底盘与传感器驱动
ROSLander 的底盘控制节点可以驱动麦克纳姆轮并计算底盘运动,但这并不意味着 ROS2 已经知道机器人当前的位姿。要让 ROS2 中的 SLAM、导航和可视化节点正常工作,还需要建立以下接口:
编码器 / 电机反馈
-> 底盘驱动
-> /joint_states
-> robot_state_publisher
-> 轮子与传感器的动态 TF
底盘速度与编码器
-> /odom
-> odom -> base_footprint
如果只有电机控制,没有编码器反馈或里程计发布,机器人可以移动,但 SLAM、导航和 RViz 中的运动轨迹可能无法正确建立。
11.2 ROSLander 的麦轮底盘和差速底盘不是一回事
URDF 可以描述四个轮子的位置和转轴,但不会自动决定底盘运动学。ROSLander 使用麦克纳姆轮时,底盘运动通常包含:
- 前后运动。
- 左右平移。
- 原地旋转。
这与普通差速底盘的运动约束不同。因此接入控制或里程计时,需要使用与 ROSLander 底盘一致的麦轮运动学,而不是因为模型中有四个轮子就直接套用差速驱动参数。
可以把职责分开:
| 内容 | 由谁负责 |
|---|---|
| 轮子在车体上的位置 | URDF/Xacro |
| 轮子半径、轮距、轮宽 | URDF/Xacro 和控制器参数 |
| 麦轮滚子方向、底盘运动学 | 底盘控制节点 |
| 电机 PWM 或总线控制 | 设备驱动 |
| 编码器到速度和位姿 | 里程计节点 |
| 机器人本体各 link 的姿态 | robot_state_publisher |
| 机器人在地图中的位置 | SLAM 或定位节点 |
11.3 传感器 frame 要和消息一致
以激光雷达为例,常见链路是:
body_link -> lidar_link
雷达发布的消息应包含类似:
header.frame_id: lidar_link
如果实际消息写的是 lidar_frame,而 URDF 只定义了 lidar_link,那么模型和数据并没有真正连接。修复方式有两种:
- 统一命名,让驱动和 URDF 都使用
lidar_link。 - 保留驱动现有名称,在 URDF 中将 child link 命名为
lidar_frame。
不要同时创建两个意义相同但名字不同的 frame,然后希望 RViz 自动猜出它们是同一个传感器。
11.4 实物测量优先于“看着差不多”
传感器安装参数应尽量来自实物测量:
- 相对
body_link的前后距离。 - 左右偏移。
- 安装高度。
- 传感器是否倒装。
- 传感器自身坐标轴方向。
特别是 ROSLander 的雷达和相机,几厘米的平移误差或几度的 yaw 误差,都可能让建图、障碍物投影和导航效果明显变差。模型首先要“坐标正确”,其次才是外观精细。
12. 常见问题与排查
按“文件 → 节点 → 话题 → TF → 几何 → 时间戳”的顺序排查。常用命令:
xacro ~/ros2_ws/src/roslander_description/urdf/roslander.urdf.xacro > /tmp/roslander.urdf
check_urdf /tmp/roslander.urdf
ros2 run tf2_tools view_frames
ros2 run tf2_ros tf2_echo base_footprint lidar_link
ros2 topic info /tf -v
ros2 topic info /tf_static -v
| 症状 | 优先检查 | 处理方向 |
|---|---|---|
RViz 没有 RobotModel |
Xacro、robot_state_publisher、模型描述参数、Fixed Frame |
先单独展开 Xacro,再看启动日志和安装路径 |
Invalid frame ID |
拼写、命名空间、header.frame_id、TF 图 |
先确认 frame 是否存在;map 只有在 map -> odom 存在时才能使用 |
tf2_echo 失败 |
查询方向、静态/动态发布者、时间戳 | 检查 parent -> child、/tf、/tf_static 和仿真时间 |
| 模型或扫描方向反了 | base_link 轴、传感器轴、joint rpy、驱动旋转 |
统一 frame 语义,不要先用数据旋转掩盖模型错误 |
| 比例不对或底盘悬空 | 米/毫米、wheel 半径、origin、网格原点 |
暂时换成 box/cylinder,确认坐标后再使用网格 |
| 模型抖动或 TF 有多个来源 | URDF、传感器静态发布器、手工命令、重复 launch | 每条 parent → child 只保留一个权威发布者 |
| 传感器位置错 | header.frame_id、安装测量值、Fixed Frame、时间戳 |
查询 body_link -> lidar_link 或相机链路并回填真实尺寸 |
| 模型正常但导航失败 | odom -> base_footprint、map -> odom、传感器话题、Nav2 参数 |
模型显示只证明 URDF 可加载,不代表里程计和定位完整 |
ROSLander 的麦轮底盘还要确认控制器使用麦轮运动学,不能仅因模型有四个轮子就套用差速参数。
13. 进阶方向
模型进入真实运行时,继续补齐 ros2_control、joint_state_broadcaster、麦轮底盘控制器、/joint_states、/odom 和 cmd_vel 的关系。仿真还需加入碰撞、惯性、摩擦、传动、传感器插件和 /clock。
SLAM/Nav2 的核心链路是:
map -> odom -> base_footprint -> base_link -> body_link -> lidar_link
确认 /scan、/odom、传感器 TF 和时间戳正常后,再检查代价地图 frame、footprint、定位来源以及 map -> odom 的唯一发布者。多机器人时,为 base_link、lidar_link 和 odom 使用命名空间或 frame 前缀。
14. 总结
最终路线
坐标变换数学
-> 静态 TF2
-> 动态 TF2 与时间戳
-> URDF link/joint
-> Xacro 参数化
-> robot_state_publisher
-> RViz2 验证
-> /joint_states 与控制器
-> 传感器坐标系
-> odom -> base_footprint -> base_link -> body_link
-> map -> odom
-> SLAM、导航与仿真
真正掌握这条路线的标准,不是能背出多少 XML 标签,而是当机器人模型、传感器数据或导航结果出现错位时,你能沿着“消息 frame → TF 链路 → URDF 安装关系 → 节点发布者 → 时间戳”的顺序逐层定位。做到这一点,TF 和 URDF 就不再是零散的配置项,而会变成一套可以支撑机器人建模、感知、控制和导航的工程基础。


