【机器人开发六】从零开始创建一个ROS2机器人工程(3):自定义小车和自定义世界做SLAM前的准备工作——从Gazebo Harmonic踩坑,到2026年ROS+Gazebo开发框架详解
ROS2 SLAM 工程搭建实战:从自定义小车到激光雷达避障,再到迷宫探索
——SLAM开始前的准备工作
作者: Kason
日期: 2026年4月
项目地址:https://github.com/kasonhaimen/turtle3-ros2-gazebo-sim
系列回顾
这是本人机器人学习系列的第三篇文章:
- 创建一个ROS2小车,掌握ROS2工程以及消息订阅机制 — 让你学会从0搭建两轮机器人,并用键盘控制机器人
- 激光雷达避障教程 — 为机器人安装激光雷达,让机器人实现自主避障
- 本文 — 为 SLAM(同步定位与建图)做准备工作,打通仿真环境
本文的核心价值:记录了从"遇到问题"到"理解原理"到"总结框架"的思维转变过程,实战深度理解Gazebo Harmonic的设计哲学。文章结构遵循:
踩坑(现象) → 原理(本质) → 框架(升华)
0. 背景:问题和目标
在完成激光雷达避障后,我已经可以让小车主视角的激光雷达数据在 RViz2 中正确显示障碍物了。但当我准备正式启动 SLAM 建图时,噩梦开始了。
我的目标:
- 让小机器人(
my_cool_robot)在一个迷宫世界(maze_world.sdf)中进行探索 - 调通
slam-sim.launch.py,让它能同时运行 Gazebo 仿真 + SLAM 节点 + RViz2 可视化
我遇到的坑:
- WSL2 + RTX 5080 +ROS2 Jazzy + Gazebo Harmonic 组合下,一整天的调试
第一部分:踩坑全记录
1. 项目结构
先看一下我已经调通的代码结构:
my_robot_description/
├── CMakeLists.txt
├── config/
│ ├── bridge_config_sim.yaml # 仿真用桥接配置(empty世界)
│ ├── bridge_config_slam.yaml # SLAM用桥接配置(maze世界)
│ └── mapper_params_online_async.yaml # SLAM参数配置
├── launch/
│ ├── real.launch.py # 真实机器人启动(RSP基础版)
│ ├── sim.launch.py # Gazebo仿真启动脚本(空世界版本)
│ └── slam-sim.launch.py # SLAM专用仿真启动脚本(maze world版)
├── rviz/
│ └── slam.rviz # RViz2 SLAM专用配置
├── scripts/
│ ├── obstacle_avoidance.py # 避障脚本
│ ├── slam_explore.py # SLAM探索脚本
│ └── star_planner_pid.py # 画五角星脚本
├── urdf/
│ └── robot.urdf.xacro # 机器人URDF/XACRO文件
└── worlds/
└── maze_world.sdf # 迷宫世界文件
2. 踩坑记录
坑1:Gazebo 启动直接崩溃(Ogre2 材质冲突)
排查命令:
# 直接看终端输出即可发现问题
ros2 launch my_robot_description slam-sim.launch.py
错误日志:
[gazebo-2] terminate called after throwing an instance of 'Ogre::ItemIdentityException'
[gazebo-2] what(): OGRE EXCEPTION(4:ItemIdentityException): A material datablock with name '[Hash 0x64f0b670]' already exists.
根因:Sensors 插件被加载了两次——一次在 world 文件里,一次在 robot.urdf.xacro 里。
<!-- robot.urdf.xacro 最后有这个: -->
<gazebo>
<plugin filename="gz-sim-sensors-system" name="gz::sim::systems::Sensors">
<render_engine>ogre2</render_engine>
</plugin>
</gazebo>
而在 maze_world.sdf 里也已经加载了同一个插件。在 Gazebo Harmonic 中,Sensors 是全局系统插件,只能加载一次!
解决:删除 URDF 里的 Sensors 插件,只保留 world 文件里的。
坑2:RobotModel 显示 left_wheel/right_wheel 错误
RVIZ2报错:No transform from [left_wheel/right_wheel] to [base_link]
排查命令:
# 第1步:检查 Gazebo 话题,确认 joint_state 的路径
gz topic -l
# 第2步:检查 ROS 话题,确认 /joint_states 是否存在
ros2 topic list
# 第3步:查看 joint_states 数据
ros2 topic echo /joint_states --once
# 如果无输出,说明桥接器没有正确桥接
根因:/joint_states 话题没有数据。
为什么 joint_state 路径会随世界改变?
JointStatePublisher 插件会把机器人模型的所有关节状态发布到 Gazebo 的消息总线。但关键在于:Gazebo 的话题命名遵循 /<world_name>/model/<robot_name>/<topic> 的层次结构。
当你运行 sim.launch.py 加载 empty.sdf 世界时:
/world/empty/model/my_cool_robot/joint_state
当你运行 slam-sim.launch.py 加载 maze_world.sdf 世界时:
/world/maze_world/model/my_cool_robot/joint_state
解法:为每个世界创建独立的桥接配置文件(这是 kason 自己想出来的方法):
config/
├── bridge_config_sim.yaml # empty世界
├── bridge_config_slam.yaml # maze世界
通过为不同的启动场景配置不同的桥接文件,符合了开放封闭原则:
- 对接口开放(配置文件即参数)
- 对修改封闭(无需改动urdf等核心代码)
坑3,坑4,坑5统一的解决方法是在launch文件中加prefix参数。这里我设计了一套动态输入的prefix添加方法,同时适用于real.launch.py和slam-sim.launch.py
坑3:激光雷达数据有,但 RViz2 中看不到红点
排查命令:
# 第1步:确认激光数据能收到
ros2 topic echo /scan --once
# 应该看到 ranges 数组有数据
# 第2步:检查 frame_id 是否正确
# 查看输出的 header.frame_id 字段
# 如果是 my_cool_robot/base_link/laser,说明有问题
现象:
$ ros2 topic echo /scan --once
header:
frame_id: my_cool_robot/base_link/laser # ← 问题在这里!
但 TF 树里雷达坐标系叫 laser_frame,两者不匹配。
根因:Gazebo 自动把传感器路径拼接成了 my_cool_robot/base_link/laser,而 URDF 里定义的帧名字是 laser_frame。
坑4:两棵 TF 树断开的真相(最关键的发现)
排查命令:
# 生成 TF 树可视化(生成 .gv 和 .pdf 文件)
ros2 run tf2_tools view_frames
# 查看两个 frame 之间的变换关系
ros2 run tf2_ros tf2_echo my_cool_robot/odom my_cool_robot/base_link
# 如果报错 " failure at..." 说明这两个 frame 之间没有连通
现象:查看 view_frames 生成的 PDF,发现 TF 树是断的:
一共出现了两颗树,也就是说gazebo和ros根本没有连起来。
# 树1(来自 robot_state_publisher):
base_link → left_wheel
base_link → right_wheel
base_link → chassis → laser_frame
# 树2(来自 Gazebo 桥接):
my_cool_robot/odom → my_cool_robot/base_link ← 与上面的 base_link 对不上!
RViz2 报错:
no transform from [my_cool_robot/base_link] to [base_link]
no transform from [my_cool_robot/odom] to [base_link]
根因:
robot_state_publisher发布的是 裸名:base_link、chassis、laser_frame- Gazebo 的 DiffDrive 插件自动加上模型前缀:
my_cool_robot/
/odom topic 有,但 TF 没有连通! frame 名字必须一致才能连通。
坑5:SLAM 节点没有订阅 /scan 话题
排查命令:
# 第1步:查看 slam_toolbox 的订阅关系
ros2 node info /slam_toolbox
# 检查 Subscribers 部分是否包含 /scan
# 第2步:确认 /scan 话题存在且有数据
ros2 topic list
ros2 topic echo /scan --once
诊断:
$ ros2 node info /slam_toolbox
Subscribers:
/clock: rosgraph_msgs/msg/Clock
/parameter_events: rcl_interfaces/msg/ParameterEvent
# ← 没有 /scan!
根因:mapper_params_online_async.yaml 里的 base_frame 和 odom_frame 需要加前缀 my_cool_robot/。
坑3,坑4,坑5统一的解决方法是在launch文件中加prefix参数。这里我设计了一套动态输入的prefix添加方法,同时适用于real.launch.py和slam-sim.launch.py
第二部分:2026年ROS+Gazebo开发框架详解
3. 核心架构:数据流
Gazebo (仿真环境)
↓ 发布 gz topic
ros_gz_bridge (桥接器)
↓ 转换并发布 ros topic
ROS2 (TF / 传感器 / 控制器)
↓
slam_toolbox / navigation
↓
RViz2 (可视化)
关键理解:
- Gazebo 和 ROS2 是独立运行的进程,通过桥接器通信
- TF 树是广播式的坐标变换,所有节点共享
- 桥接器的职责是在这两套命名空间之间做映射
4. 桥接器的正确理解
4.1 不要混淆 remap 和 bridge
| 功能 | remap | bridge (YAML) |
|---|---|---|
| 修改话题名称 | ✅ | ✅ |
| 指定数据类型转换 | ❌ | ✅ |
修改消息内部的 frame_id |
❌ | ⚠️ 理论可以,实际无效 |
# ❌ remap 只能改话题名,无法改 frame_id
remappings=[
('/world/maze_world/model/my_cool_robot/joint_state', '/joint_states')
]
4.2 为什么 gz_frame_id 在 Gazebo Harmonic 中无效?
ChatGPT 给出了非常明确的解释:
❗
gz_frame_id在 Gazebo Harmonic 中已经不起作用了!
<!-- 这些写法在 Harmonic 中都无效! -->
<frame_id>laser_frame</frame_id> <!-- 对 GPU LiDAR 无效 -->
<ign_frame_id>laser_frame</ign_frame_id> <!-- 只有部分传感器支持 -->
<gz_frame_id>laser_frame</gz_frame_id> <!-- 基本不会被 bridge 使用 -->
最终 ROS2 看到的 frame_id 是 bridge 自动生成的,而不是你在 URDF 中指定的。
4.3 三种解决方案对比
| 方法 | 理论效果 | 实际情况 |
|---|---|---|
gz_frame_id 标签 |
官方推荐 | ❌ Harmonic 中已失效 |
Bridge YAML frame_id 覆盖 |
官方推荐 | ⚠️ 经过实测:无效! |
| 静态 TF 变换桥接 + frame_prefix | 通用方案 | ✅ 实际有效 |
5. 真正有效的方案:frame_prefix + 静态 TF 桥接
5.1 核心思想
- 让整个 TF 树统一加上
my_cool_robot/前缀 - 再用静态 TF 把传感器 frame 映射回 URDF 标准名
5.2 具体实现
第一步:real.launch.py 增加 frame_prefix 参数,并且设置参数为空,因为real世界无需前缀
declare_frame_prefix = DeclareLaunchArgument(
'frame_prefix',
default_value='',
description='Prefix for robot frames (e.g. my_cool_robot/)'
)
frame_prefix = LaunchConfiguration('frame_prefix')
node_robot_state_publisher = Node(
parameters=[{
'robot_description': robot_description_config,
'use_sim_time': use_sim_time,
'frame_prefix': frame_prefix # ← 关键!
}]
)
第二步:slam-sim.launch.py 传入前缀,将前缀改为’my_cool_robot/',这样就和gazebo世界中的命名方式一样了
rsp = IncludeLaunchDescription(
...,
launch_arguments={
'use_sim_time': use_sim_time,
'frame_prefix': 'my_cool_robot/' # ← 统一前缀
}.items()
)
第三步:传感器坐标系桥接
传感器的命名是最特殊的,ros和gazebo重点传感器命名没有一点相同。此时就算补充前缀也没有用。业界的处理方法是发布“语义偏移”静态变换(推荐:最干净、不改代码)。
这是目前业界处理“传感器命名不一致”最通用的做法。我们不强制改变雷达发出的字符串,而是告诉 TF 树:my_cool_robot/base_link/laser 和 my_cool_robot/laser_frame 其实是在同一个位置。
node_stf_laser = Node(
package='tf2_ros',
executable='static_transform_publisher',
arguments=[
'0', '0', '0', '0', '0', '0', # 欧式空间不变
'my_cool_robot/laser_frame', # 父级:URDF 标准名
'my_cool_robot/base_link/laser' # 子级:Gazebo 发出的非标准名
]
)
5.3 最终效果
在把所有bug修复后,所有ros2的话题都对齐到了gazebo的话题。此时继续查看frames。
ros2 run tf2_tools view_frames
my_cool_robot/odom → my_cool_robot/base_link → my_cool_robot/laser_frame
↑
static TF 桥接
my_cool_robot/base_link/laser
pdf内容如下,gazebo的odom已经和baselink联通了。此后运行launch文件,自定义小车在自定义世界中没有报错了。并且可以看到激光雷达的点云。修复后可以为slam做准备了。
6. 为什么这是 2026 年最干净的方案?
6.1 符合"开放-封闭原则"
- 对修改封闭:不需要修改 URDF(保持 base_link 纯净)
- 对扩展开放:通过
frame_prefix参数,YAML配置文件适配不同场景
6.2 职责划分清晰
| 组件 | 职责 | 是否需要改 |
|---|---|---|
| URDF | 定义机器人结构(base_link、laser_frame) | ❌ 不改 |
| real.launch.py | 接收 frame_prefix 参数 | ✅ 小改 |
| slam-sim.launch.py | 传入 ‘my_cool_robot/’ | ✅ 小改 |
| Bridge YAML | ROS ↔ Gazebo 数据桥接 | ❌ 不改 |
6.3 一套代码适配多种场景
# 真实机器人:frame_prefix 为空
ros2 launch my_robot_description real.launch.py
# 仿真环境:frame_prefix 为 my_cool_robot/
ros2 launch my_robot_description slam-sim.launch.py
教训:不要太相信"官方推荐",实际测试才是检验真理的唯一标准。
第三部分:完整配置和调试
7. 完整配置文件解析
7.1 bridge_config_slam.yaml(已验证可用)
# 激光雷达扫描
- ros_topic_name: "/scan"
gz_topic_name: "/scan"
ros_type_name: "sensor_msgs/msg/LaserScan"
gz_type_name: "gz.msgs.LaserScan"
direction: GZ_TO_ROS
publisher_options:
qos:
reliability: "best_effort" # WSL2 必加,解决丢包
# 注意:frame_id: "laser_frame" 这一行经过实测【无效】
# 运动控制指令
- ros_topic_name: "/cmd_vel"
gz_topic_name: "/cmd_vel"
ros_type_name: "geometry_msgs/msg/Twist"
gz_type_name: "gz.msgs.Twist"
direction: ROS_TO_GZ
# 关节状态
- ros_topic_name: "/joint_states"
gz_topic_name: "/world/maze_world/model/my_cool_robot/joint_state"
ros_type_name: "sensor_msgs/msg/JointState"
gz_type_name: "gz.msgs.Model"
direction: GZ_TO_ROS
# 里程计数据
- ros_topic_name: "/odom"
gz_topic_name: "/odom"
ros_type_name: "nav_msgs/msg/Odometry"
gz_type_name: "gz.msgs.Odometry"
direction: GZ_TO_ROS
# 坐标系变换桥接
- ros_topic_name: "/tf"
gz_topic_name: "/model/my_cool_robot/tf"
ros_type_name: "tf2_msgs/msg/TFMessage"
gz_type_name: "gz.msgs.Pose_V"
direction: GZ_TO_ROS
7.2 mapper_params_online_async.yaml(已验证可用)
slam_toolbox:
ros__parameters:
# 坐标系配置(必须与 URDF + frame_prefix 匹配!)
odom_frame: my_cool_robot/odom
map_frame: map
base_frame: my_cool_robot/base_link
scan_topic: /scan
use_map_saver: true
mode: mapping
# 传感器参数
max_laser_range: 12.0
min_laser_range: 0.1
# 优化器
resolution: 0.05 # 地图分辨率 5cm
7.3 slam-sim.launch.py(已验证可用)
import os
from ament_index_python.packages import get_package_share_directory
from launch import LaunchDescription
from launch.actions import IncludeLaunchDescription, DeclareLaunchArgument
from launch.launch_description_sources import PythonLaunchDescriptionSource
from launch_ros.actions import Node
from launch.substitutions import LaunchConfiguration
def generate_launch_description():
pkg_name = 'my_robot_description'
pkg_share = get_package_share_directory(pkg_name)
use_sim_time = LaunchConfiguration('use_sim_time', default='true')
# 1. 机器人状态发布器 (RSP) - 带 frame_prefix
rsp = IncludeLaunchDescription(
PythonLaunchDescriptionSource([os.path.join(
pkg_share, 'launch', 'real.launch.py'
)]),
launch_arguments={
'use_sim_time': use_sim_time,
'frame_prefix': 'my_cool_robot/'
}.items()
)
# 2. Gazebo 仿真环境
world_file = os.path.join(pkg_share, 'worlds', 'maze_world.sdf')
gazebo = IncludeLaunchDescription(
PythonLaunchDescriptionSource([os.path.join(
get_package_share_directory('ros_gz_sim'), 'launch', 'gz_sim.launch.py'
)]),
launch_arguments={
'gz_args': f"-r {world_file}",
'use_sim_time': use_sim_time
}.items()
)
# 3. 桥接器
ros_gz_bridge = Node(
package='ros_gz_bridge',
executable='parameter_bridge',
parameters=[{
'config_file': os.path.join(pkg_share, 'config', 'bridge_config_slam.yaml'),
'use_sim_time': True
}],
output='screen'
)
# 4. 生成机器人实体
spawn_entity = Node(
package='ros_gz_sim',
executable='create',
arguments=[
'-topic', '/robot_description',
'-name', 'my_cool_robot',
'-z', '0.1'
],
parameters=[{'use_sim_time': True}]
)
# 5. SLAM Toolbox 节点
slam_toolbox = Node(
package='slam_toolbox',
executable='async_slam_toolbox_node',
name='slam_toolbox',
parameters=[
os.path.join(pkg_share, 'config', 'mapper_params_online_async.yaml'),
{'use_sim_time': True}
]
)
# 6. RViz2
rviz2 = Node(
package='rviz2',
executable='rviz2',
arguments=['-d', os.path.join(pkg_share, 'rviz', 'slam.rviz')]
)
# 7. 传感器坐标系桥接
node_stf_laser = Node(
package='tf2_ros',
executable='static_transform_publisher',
arguments=[
'0', '0', '0', '0', '0', '0',
'my_cool_robot/laser_frame',
'my_cool_robot/base_link/laser'
],
parameters=[{'use_sim_time': True}]
)
return LaunchDescription([
DeclareLaunchArgument('use_sim_time', default_value='true'),
rsp,
gazebo,
ros_gz_bridge,
spawn_entity,
slam_toolbox,
rviz2,
node_stf_laser
])
8. 调试技巧总结
8.1 数据链路检查
# 检查 ROS 话题
ros2 topic list
# 检查 Gazebo 话题
gz topic -l
# 检查激光数据
ros2 topic echo /scan --once
# 检查 TF 树
ros2 run tf2_tools view_frames
# 检查节点订阅
ros2 node info /slam_toolbox
8.2 SLAM 验证清单
| 检查项 | 命令 | 成功标志 |
|---|---|---|
/scan 有数据 |
ros2 topic echo /scan --once |
有 ranges 数组 |
| TF 树连通 | view_frames |
my_cool_robot/odom → my_cool_robot/base_link |
| SLAM 订阅 scan | ros2 node info /slam_toolbox |
Subscribers 有 /scan |
8.3 RViz2 必要设置
LaserScan 可靠性设置(必设!)
- Reliability Policy → Best Effort(WSL2 必加)
保存 RViz 配置的目的
不用每次重新添加组件和配置:
- RobotModel、LaserScan、TF、Map
- Fixed Frame =
my_cool_robot/odom - TF Prefix =
my_cool_robot
配置方法:File → Save Config As → 保存到 rviz/slam.rviz
5.4.1 LaserScan 可靠性设置(必设!)
在 RViz2 左侧面板找到 LaserScan 组件:
- 展开 Topic → 确认是
/scan - 找到 Reliability Policy → 从
Reliable改为Best Effort
原因:WSL2 的网络环境不稳定,Reliable 模式会因少量丢包而完全丢弃消息。Best Effort 模式允许偶尔丢包,但至少能显示数据。
5.4.2 Fixed Frame 设置
根据你的需求选择:
| 场景 | Fixed Frame 设置 |
|---|---|
| 只看激光雷达 | base_link 或 my_cool_robot/base_link |
| 查看 SLAM 地图 | map |
5.4.3 LaserScan 大小设置
如果红点太小看不见,把 Size (m) 改成 0.05 或更大。
5.4.4 添加显示组件
点击 RViz2 左下角 Add,添加以下组件:
| 组件 | Topic | 用途 |
|---|---|---|
| RobotModel | - | 显示机器人模型 |
| LaserScan | /scan |
显示激光雷达数据 |
| TF | - | 显示坐标变换树 |
| Map | /map |
SLAM 建图(需等地图出现) |
5.4.5 💡 Tips:保存 RViz 配置的目的
保存 RViz 配置的核心目的是不用每次重新添加组件和配置:
手动添加的组件(保存后下次自动加载):
- RobotModel — 显示机器人模型
- LaserScan (/scan) — 显示激光雷达数据
- TF — 显示坐标变换树
- Map (/map) — SLAM 地图
手动设置的参数:
- Fixed Frame =
my_cool_robot/odom - TF Prefix =
my_cool_robot - LaserScan Reliability = Best Effort
- LaserScan Size = 0.05
配置方法:
- 手动添加所有组件并配置好
- File → Save Config As → 保存到
rviz/slam.rviz
使用已保存的配置:
# 通过 launch 文件自动加载
ros2 launch my_robot_description slam-sim.launch.py
# launch 中已指定:arguments=['-d', os.path.join(pkg_share, 'rviz', 'slam.rviz')]
5.4.6 💡 Tips:将 rviz 目录加入 CMakeLists.txt
加入CMakeLists后,该文件夹会安装到install目录,从而能被ros2 run找到。
让 rviz 配置文件可以被 ros2 run 找到:
# CMakeLists.txt
install(DIRECTORY
rviz # ← 这一行让 rviz 文件可被 find_package 找到
launch
urdf
worlds
config
scripts
DESTINATION share/${PROJECT_NAME}
)
主要是为了保存左侧栏中的内容,要不然还得一个一个重新添加。下图为成功运行的机器人仿真。
6. 关键踩坑点回顾
┌─────────────────────────────────────────────────────────────┐
│ 坑1: Gazebo 崩溃 (Ogre2 材质冲突) │
│ ───────────────────────────────────────────────────────── │
│ 原因: Sensors 插件在 world 和 URDF 里各加载了一次 │
│ 教训: Gazebo Harmonic 中 Sensors 是全局插件,只能加载一次 │
│ 解决: 删除 URDF 里的 Sensors 插件 │
├─────────────────────────────────────────────────────────────┤
│ 坑2: RobotModel 轮子显示错误 │
│ ───────────────────────────────────────────────────────── │
│ 原因: joint_state 路径包含世界名(empty vs maze_world) │
│ 教训: Gazebo 话题遵循 /world/<name>/model/<robot>/topic │
│ 解决: 为每个世界创建独立的桥接配置文件 │
│ bridge_config_sim.yaml (empty) │
│ bridge_config_slam.yaml (maze_world) │
├─────────────────────────────────────────────────────────────┤
│ 坑3: 激光雷达红点不显示 │
│ ───────────────────────────────────────────────────────── │
│ 原因: Gazebo 发的 frame_id 与 URDF TF 树不匹配 │
│ 教训: Gazebo 自动拼接模型路径,而 URDF 是相对命名 │
│ 解决: 方案A: Bridge YAML 中覆盖 frame_id (官方推荐) │
│ 方案B: 静态 TF 变换桥接 (本项目采用) │
├─────────────────────────────────────────────────────────────┤
│ 坑4: SLAM 节点不订阅 /scan │
│ ───────────────────────────────────────────────────────── │
│ 原因: base_frame/odom_frame 缺少前缀 + use_sim_time 问题 │
│ 教训: 使用 frame_prefix 时,TF 帧名会带前缀 │
│ 解决: mapper_params_online_async.yaml 中完整配置前缀 │
│ odom_frame: my_cool_robot/odom │
│ base_frame: my_cool_robot/base_link │
├─────────────────────────────────────────────────────────────┤
│ 坑5: 两棵 TF 树断开(frame_prefix 与 Gazebo 不匹配) │
│ ───────────────────────────────────────────────────────── │
│ 原因: RSP 发布 base_link,Gazebo 发布 my_cool_robot/base_link │
│ 教训: /odom topic 有 ≠ TF 连通!frame 名字必须一致 │
│ 解决: real.launch.py 中加 frame_prefix 参数声明和传递 │
│ frame_prefix: my_cool_robot/ (仿真时) │
│ frame_prefix: '' (真实机器人时) │
│ 结果: TF 树完整连通: │
│ my_cool_robot/odom → my_cool_robot/base_link → ... │
└─────────────────────────────────────────────────────────────┘
7. 已验证成果:TF 树链路已连通
本次调试最重要的成果:成功建立了 odom → base_link 的连接。
7.1 SLAM 的 TF 链路解析
SLAM 需要完整的坐标变换链路:
map ──────→ odom ──────→ base_link ──────→ laser_frame
│ │ │ │
│ │ │ │
│ Gazebo发布 robot_state URDF静态
│ (里程计) publisher 定义
│
└────── slam_toolbox 发布(当它真正跑通时)
三个变换的来源:
| 变换 | 发布者 | 说明 |
|---|---|---|
map → odom |
slam_toolbox |
SLAM 算法修正后的全局定位 |
odom → base_link |
gz-sim-diff-drive-system |
轮式里程计(存在累计误差) |
base_link → laser_frame |
robot_state_publisher |
URDF 中的静态几何关系 |
7.2 为什么 odom → base_link 如此重要?
里程计是 SLAM 的"锚点":
- 机器人移动时,
odom → base_link描述了机器人的相对运动 - 但里程计会累计误差(轮子打滑、机械误差等)
- 所以需要 SLAM 发布
map → odom来修正这个误差
连接 odom → base_link 的关键:
这不是简单地"有 /odom topic"就行的。真正的连通需要:
- Gazebo 的
gz-sim-diff-drive-system发布 TF(不是只发 topic) robot_state_publisher的frame_prefix与 Gazebo 的命名空间一致- 两边的 frame 名字都是
my_cool_robot/base_link而不是base_link
你的成果:
- ✅
my_cool_robot/odom → my_cool_robot/base_link已连通 - ✅ TF 树完整闭合:
my_cool_robot/odom → my_cool_robot/base_link → my_cool_robot/chassis → my_cool_robot/laser_frame - ⏳
map → odom等待 SLAM 节点正常工作后自动建立
7.3 下一步:让 SLAM 发布 map → odom
当你看到:
- ✅ RViz2 中激光雷达红点正常显示
- ✅
ros2 topic echo /scan有数据 - ✅
view_frames显示odom → base_link连通(✅ 这个你已经做到了!) - ⏳
/map话题出现
你就可以开始建图了!
# 控制机器人移动
ros2 run teleop_twist_keyboard teleop_twist_keyboard
# 或者使用探索脚本
ros2 run my_robot_description slam_explore.py
当 SLAM 节点正常工作后,view_frames 会自动显示完整的链路:
map → odom → base_link → laser_frame
8. 致谢
感谢 Gemini 和 ChatGPT 在整个调试过程中的耐心协助。特别是在:
- Ogre2 材质冲突的诊断
- Gazebo 桥接器配置的优化
- TF 命名空间问题的解决方案
让我快速掌握了ROS2开发框架
系列文章:
本文档记录于 2026年4月,所有代码均在 ROS2 Jazzy + Gazebo Harmonic 环境下验证通过。
更多推荐

所有评论(0)