JiaXin‘s Blog

Back

从零构建 Nav2 仿真机器人:一条命令的背后

前言#

部分 Nav2 教程都是这样开始的:

ros2 launch nav2_bringup tb3_simulation_launch.py headless:=False
bash

一行命令,Gazebo 打开,TurtleBot3 出现,RViz 里点几下,机器人开始导航。十分钟跑通,然后呢?URDF 是什么?odom -> base_link 的 TF 是哪里来的?/cmd_vel 发出去之后发生了什么?

这条命令背后压缩了一整套机器人系统的知识。

本文记录我照着 Nav2 官方文档 “First-Time Robot Setup Guide” 从零构建 sam_bot 的完整过程——不跑现成的 TurtleBot3,手写 URDF、传感器插件、EKF 融合、Nav2 导航栈,把仿真机器人的每一层拆开来看。

环境:Ubuntu 22.04,ROS 2 Humble,Gazebo Classic 11.10.2。


一、环境踩坑 + 第一个 URDF#

1.1 环境踩坑#

第一次跑 TB3 仿真,gzserver 崩溃(exit code 255)。两个原因:残留进程占端口 + models.gazebosim.org 被墙导致 Gazebo 启动卡死,spawn_entity.py 30秒超时退出。清进程加三行环境变量解决:

killall -9 gzserver gzclient
export TURTLEBOT3_MODEL=waffle
export GAZEBO_MODEL_PATH=/opt/ros/humble/share/turtlebot3_gazebo/models:/usr/share/gazebo-11/models
export GAZEBO_MODEL_DATABASE_URI=""
bash

加完 GAZEBO_MODEL_DATABASE_URI="" 后,/spawn_entity 服务从 60+ 秒降到 1 秒出现。

1.2 创建工作区和包#

mkdir -p ~/sam_bot_ws/src && cd ~/sam_bot_ws/src
ros2 pkg create --build-type ament_cmake sam_bot_description
sudo apt install ros-humble-joint-state-publisher-gui ros-humble-xacro
bash

1.3 第一个 URDF:只能看的外壳#

URDF 使用 xacro(XML 宏)定义常量,后面用 ${base_width} 引用,改一个数字自动更新全局:

<xacro:property name="base_width" value="0.31"/>   <!-- 车宽 31cm -->
<xacro:property name="base_length" value="0.42"/>   <!-- 车长 42cm -->
<xacro:property name="base_height" value="0.18"/>   <!-- 车高 18cm -->
<xacro:property name="wheel_radius" value="0.10"/>  <!-- 轮半径 10cm -->
<xacro:property name="wheel_width" value="0.04"/>   <!-- 轮宽 4cm -->
xml

机器人主体——一个青色盒子(0.42m x 0.31m x 0.18m)。三个子标签各司其职:

标签用途
<visual>RViz 显示用的外观(颜色、形状),不影响物理
<collision>Gazebo 碰撞检测的边界盒
<inertial>质量、转动惯量,Gazebo 物理计算用
<link name="base_link">
  <visual>
    <geometry><box size="${base_length} ${base_width} ${base_height}"/></geometry>
    <material name="Cyan"><color rgba="0 1.0 1.0 1.0"/></material>
  </visual>
  <collision>
    <geometry><box size="${base_length} ${base_width} ${base_height}"/></geometry>
  </collision>
</link>
xml

base_footprint 是空链接(无尺寸),垂直投影到地面的机器人中心。Nav2 用它做三件事:

  • 计算避障范围(以 base_footprint 为圆心)
  • 判断机器人是否到达目标
  • 全局规划器中做碰撞检查
<link name="base_footprint"/>
<joint name="base_joint" type="fixed">
  <parent link="base_link"/>
  <child link="base_footprint"/>
  <origin xyz="0.0 0.0 ${-(wheel_radius+wheel_zoff)}" rpy="0 0 0"/>
</joint>
xml

type="fixed" = 刚性连接,无相对运动。z 偏移 = -(0.10+0.05) = -0.15m,即 base_link 中心向 15cm 投影到地面。

1.4 差速驱动轮#

宏定义 + 两次调用 = 左右两个后轮。joint 类型 continuous = 无限旋转关节(没有角度限制),绕 Y 轴转。这就是差速驱动的物理基础:左右轮速不同 → 机器人转弯

x_reflect="-1" 表示轮子在 x 轴负方向(机器人后方),y_reflect="1" 表示在 y 轴正方向(左侧)。

前脚轮是球形、fixed 连接,不做驱动,只是支撑:

<link name="front_caster">
  <visual>
    <geometry><sphere radius="${(wheel_radius+wheel_zoff-(base_height/2))}"/></geometry>
    <material name="Cyan"><color rgba="0 1.0 1.0 1.0"/></material>
  </visual>
</link>
<joint name="caster_joint" type="fixed">
  <parent link="base_link"/>
  <child link="front_caster"/>
  <origin xyz="${caster_xoff} 0.0 ${-(base_height/2)}" rpy="0 0 0"/>
</joint>
xml

1.5 配套文件#

创建 launch 文件。三个 ROS 节点各司其职:

节点作用
robot_state_publisher读取 URDF,发布所有 fixed joint 的 TF(base_link -> base_footprint 等)
joint_state_publisher发布非 fixed joint 的 TF(驱动轮 continuous 旋转)
rviz23D 可视化
robot_state_publisher_node = Node(
    package='robot_state_publisher',
    executable='robot_state_publisher',
    parameters=[{'robot_description': Command(['xacro ', LaunchConfiguration('model')])}]
)
python

配置 RViz,Fixed Frame 设为 base_link,加上 Grid、RobotModel、TF 三个显示项。

package.xml 声明运行时依赖:robot_state_publisherjoint_state_publisher_guirvizxacro

CMakeLists.txtinstall(DIRECTORY src launch rviz DESTINATION share/${PROJECT_NAME}) 把文件复制到安装目录。

1.6 编译运行#

cd ~/sam_bot_ws
colcon build
source install/setup.bash
ros2 launch sam_bot_description display.launch.py
bash

RViz 弹出一个青色盒子的差速驱动机器人,两个灰轮,一个前脚轮。只有视觉外壳,没有任何物理能力。

1.7 的 TF 树#

此时只有 robot_state_publisher 发布的 static transforms:

base_link(青色盒子,42cm x 31cm x 18cm)
  |
  +-- base_footprint(空链接,地面投影,z=-0.15m)
  |     Nav2 用来计算避障圆心
  |
  +-- drivewhl_l_link(左后轮,continuous 旋转)
  |
  +-- drivewhl_r_link(右后轮,continuous 旋转)
  |
  +-- front_caster(前脚轮,fixed)
plaintext

完成了一个能在 RViz 里看的机器人模型。没有驱动、没有传感器、没有定位,它只是一堆坐标系堆出来的几何体。

1.8 项目文件结构#

sam_bot_description/
  CMakeLists.txt                  # 安装规则
  package.xml                     # 依赖声明
  launch/display.launch.py        # 启动 RViz
  rviz/config.rviz                # RViz 布局
  src/description/
    sam_bot_description.urdf      # 机器人模型
plaintext

二、装上引擎、眼睛和平衡感#

Day 1 的 URDF 只能看。要让它在 Gazebo 里”动”起来,需要三个东西:物理属性(质量/惯量/碰撞)、驱动(接收 /cmd_vel 让轮子转)、传感器(感知环境)。

2.1 惯量宏#

三个宏分别计算盒子、圆柱、球体的转动惯量。Gazebo 物理引擎需要这些来做碰撞反弹、加速减速:

没有惯量,机器人会在 Gazebo 里直接穿过障碍物。

2.2 加物理属性#

给 base_link 加碰撞区域,给 base_footprint 加惯性(kdl_parser 不赞成在根 link 加惯性):

<!-- base_link 加碰撞 -->
<collision>
  <geometry><box size="${base_length} ${base_width} ${base_height}"/></geometry>
</collision>

<!-- base_footprint 加惯性 -->
<xacro:box_inertia m="15" w="${base_width}" d="${base_length}" h="${base_height}"/>
xml

给脚轮加低摩擦(mu=0.001),让它在 Gazebo 里被动滚动而不是卡死。

2.3 差速驱动插件 —— “引擎”#

这是最关键的插件——让轮子接收速度指令并转起来,同时发布里程计:

工作原理

Nav2 controller_server 发 /cmd_vel(例:linear.x=0.5m/s, angular.z=0.1rad/s)→ 插件拆成左右轮转速:

左轮转速 = (0.5 - 0.1 x 0.2) / 0.1 = 4.8 rad/s
右轮转速 = (0.5 + 0.1 x 0.2) / 0.1 = 5.2 rad/s
plaintext

转速不同 → Gazebo 物理引擎驱动轮子 → 机器人转弯。

同时反算里程计:轮子转了 X 圈 → 走了 X x 周长 米 → 发布 /odom(nav_msgs/Odometry)。

publish_odom_tf=false 是因为后面 EKF 会统一发布 odom -> base_link 变换,让插件发会冲突。

2.4 IMU 传感器 —— “平衡感”#

轮式里程计有一个致命缺陷:轮子打滑时,轮子转了但机器人没动,里程计漂移。IMU 的角速度不受打滑影响,100Hz 高频采样,短时精度极高:

噪声参数含义:

  • stddev=2e-4:每次读数随机抖动
  • bias_mean=0.0000075:IMU 静止也有非零读数(零偏)

2.5 EKF 融合 —— “定位系统”#

轮子里程计 + IMU → ekf_node(扩展卡尔曼滤波),互补长短:

轮式编码器IMU
优势线速度准确,短距离定位好角速度高频采样,不受打滑影响
劣势打滑漂移,长距累积误差积分漂移
融合策略线速度 X/Y + 角速度 Yaw只融合角速度 Yaw 修正朝向

配置文件 config/ekf.yaml

15 位的 odom0_config 矩阵看着吓人,其实就一句话:线速度信轮子,角速度两个都信,IMU 修正朝向漂移

EKF 还负责发布 odom -> base_link 的 TF(publish_tf: true)——这就是差速驱动设 publish_odom_tf=false 的原因:单点发布避免冲突。

2.6 激光雷达 —— “360 度眼睛”#

发布 /scan(sensor_msgs/LaserScan)。Nav2 用 /scan 做三件事:

  • slam_toolbox:多帧拼接 → 全局地图 /map
  • nav2_amcl:当前扫描 vs 地图 → 粒子滤波定位
  • nav2_costmap_2d:扫描点标记为障碍物 → 局部/全局成本图

2.7 深度摄像头 —— “3D 感知”#

<gazebo reference="camera_link">
  <sensor name="depth_camera" type="depth">
    <camera name="camera">
      <horizontal_fov>1.047198</horizontal_fov>   <!-- 60 度视场角 -->
      <image><width>640</width><height>480</height></image>
      <clip><near>0.05</near><far>3</far></clip>  <!-- 5cm-3m 范围 -->
    </camera>
    <plugin name="depth_camera_controller" filename="libgazebo_ros_camera.so">
      <frame_name>camera_depth_frame</frame_name>
    </plugin>
  </sensor>
</gazebo>
xml

发布 sensor_msgs/PointCloud2(每次 640x480=30 万个 3D 点)。激光雷达只能扫 2D 平面,看不到高于或低于扫描面的物体(如桌子边缘),深度摄像头用 3D 点云补上这个盲区。

2.8 完成后的 TF 树#

加了传感器后,TF 树从 Day 1 的 5 个 link 变成了 9 个:

TF 变换的发布者

  • robot_state_publisher/tf_static:所有 static transforms(URDF 中的 fixed joint)
  • ekf_node/tfodom -> base_link(融合里程计 + IMU)。Day 1 没有这个。

为什么 odom TF 这么重要:Nav2 的 local_costmap 需要 base_link -> odom 变换来知道机器人在里程计坐标系中的位置。没有它,costmap 会反复报:

Timed out waiting for transform from base_link to odom to become available
plaintext

2.9 数据流总结#

Gazebo 仿真侧                            ROS 2 节点侧
=============                           ============

IMU 传感器 --> /demo/imu --+---------> ekf_node --> /odometry/filtered
                           |                        --> /tf: odom -> base_link
差速驱动   --> /demo/odom -+                         --> /accel/filtered

关节发布   --> /joint_states --> robot_state_publisher --> /tf_static (static)

激光雷达   --> /scan  (sensor_msgs/LaserScan)
深度摄像头 --> /depth_camera/points  (sensor_msgs/PointCloud2)
            --> /depth_camera/image_raw  (sensor_msgs/Image)

差速驱动   <-- /demo/cmd_vel <-- Nav2 controller_server
plaintext

三、接入 Nav2 导航栈#

Day 2 的机器人有了感官和动力,但它不知道:环境什么样子(建图)、自己在地图上的位置(定位)、从 A 去 B 怎么走(规划)、路上有障碍怎么避(控制)。Day 3 就是接上 Nav2 解决这四个问题。

3.1 话题名标准化#

Day 2 的 URDF 给差速驱动和 IMU 加了 <namespace>/demo</namespace>,话题都变成了 /demo/odom/demo/cmd_vel/demo/imu。但 Nav2、slam_toolbox 等标准包只认 /odom/cmd_vel/imu

修改:

  1. URDF:删除所有 <namespace>/demo</namespace>
  2. ekf.yamlodom0: odomimu0: imu
话题标准化前标准化后
里程计/demo/odom/odom
IMU/demo/imu/imu
速度指令/demo/cmd_vel/cmd_vel
激光扫描/scan(不变)/scan

3.2 三终端启动架构#

完整的导航系统需要三个角色同时运行:

终端 1:sam_bot + Gazebo(硬件仿真层)
    启动的节点:Gazebo、robot_state_publisher、joint_state_publisher、ekf_node、rviz2
    输出:/odom、/scan、/tf、/tf_static、/depth_camera/points

终端 2:slam_toolbox(感知层)
    启动:ros2 launch slam_toolbox online_async_launch.py use_sim_time:=true
    输入:/scan
    输出:/map(全局栅格地图)+ /tf: map -> odom

终端 3:Nav2 导航栈(决策层)
    启动:ros2 launch nav2_bringup navigation_launch.py params_file:=... use_sim_time:=true
    输入:/map、/odom、/scan、/tf
    输出:/cmd_vel、/plan、/local_plan、/global_costmap/costmap、/local_costmap/costmap
plaintext

3.3 新踩的一个坑:多终端 DDS 发现失败#

三个终端开着,执行 ros2 run tf2_tools view_frames,TF 树为空——frame_yaml='[]'ros2 node list 只能看到当前终端启动的节点。

原因:ROS_LOCALHOST_ONLY=1 只设在了终端 1。ROS 2 默认用 DDS 多播发现节点,它只在本机 127.0.0.1 上工作。终端 2/3 没设这个变量,尝试走网卡多播,但多播被网卡/防火墙拦截,节点互相发现不了。

每个终端都要加 export ROS_LOCALHOST_ONLY=1 推荐直接写入 ~/.bashrc

3.4 slam_toolbox:SLAM 建图 + 定位#

slam_toolbox 订阅 /scan(sensor_msgs/LaserScan,360 度,5Hz),做两件事:

  1. 在线异步建图:每帧新数据通过 scan-to-map matching 融入已有地图 → 地图持续增长
  2. 定位:当前扫描 vs 已有地图 → 匹配最佳位姿 → 发布 map -> odom 变换纠正里程计漂移
ros2 launch slam_toolbox online_async_launch.py use_sim_time:=true
bash

就绪标志:日志出现 Registering sensor: [Custom Described Lidar]

3.5 Nav2 导航栈启动#

ros2 launch nav2_bringup navigation_launch.py \
  params_file:=/path/to/nav2_params.yaml \
  use_sim_time:=true
bash

这个 launch 文件启动了 Nav2 的所有核心节点:

节点功能
planner_serverNavFn 全局路径规划器(A*/Dijkstra 网格搜索)
controller_serverDWB 局部控制器(改进动态窗口法)
behavior_server恢复行为:spin、backup、wait
bt_navigator行为树导航主逻辑
smoother_server路径平滑
velocity_smoother速度平滑(防急加速/急刹)
waypoint_follower多点导航
global_costmap全局成本图(40x40m 全地图)
local_costmap局部成本图(3x3m 滑动窗口)
lifecycle_manager_navigation自动激活/停用各生命周期节点

3.6 costmap 两层架构#

为什么全局用圆形、局部用矩形:

局部 costmap(矩形)全局 costmap(圆形)
规划器DWB 控制器NavFn(A*)
碰撞检查多边形精确匹配网格单元格圆形近似
是否需要真实形状需要:局部避障要求高不需要:网格只查格点

3.7 规划器和控制器#

全局规划器 NavFn:把全局 costmap 当成带权重的网格,在 (0,0) 到 (目标X, 目标Y) 之间用 Dijkstra/A* 搜索一条代价最低的路径。代价 = 距离 + 障碍物膨胀值。输出 /plan(nav_msgs/Path)。

局部控制器 DWB:收到全局路径后,在 local_costmap(3m x 3m 滑动窗口)上做精细避障:

  1. TrajectoryGenerator 生成候选轨迹——遍历各种 (线速度, 角速度) 组合,模拟每根轨迹前向 2.5s 的走向
  2. 9 个 Critic 插件逐一打分:
Critic评分维度
ObstacleFootprintCritic碰撞风险——离障碍物越远越好
PathAlignCritic与全局路径的偏差
PathDistCritic离全局路径终点的距离
GoalAlignCritic朝向最终目标的角度
GoalDistCritic离最终目标的距离
OscillationCritic防止来回震荡(来回切换方向)
RotateToGoalCritic原地旋转到目标方向
BaseObstacleCritic底座碰撞风险
PreferForwardCritic倾向前进(不倒车)
  1. 总分最高的轨迹 → 发布 /cmd_vel(geometry_msgs/Twist)

3.8 配置文件:nav2_params.yaml#

从 Nav2 默认参数复制并改了三个地方:

局部 costmap(矩形足迹)

local_costmap:
  local_costmap:
    ros__parameters:
      footprint: "[ [0.21, 0.195], [0.21, -0.195], [-0.21, -0.195], [-0.21, 0.195] ]"
yaml

四个点围成的矩形:长 0.42m x 宽 0.39m,贴合 sam_bot 外形。

全局 costmap(圆形足迹)

global_costmap:
  global_costmap:
    ros__parameters:
      robot_radius: 0.3    # 矩形对角线 sqrt(0.21^2+0.195^2) ~ 0.286, 取 0.3 加安全边距
yaml

AMCL

amcl:
  ros__parameters:
    base_frame_id: "base_footprint"
    global_frame_id: "map"
    odom_frame_id: "odom"
    scan_topic: scan
yaml

3.9 让机器人动起来#

在 RViz 里:

  1. 2D Pose Estimate → 在地图上设初始位姿(告诉 AMCL”我在这”)
  2. Navigation2 Goal → 在地图上设目标点(“去那”)
  3. slam_toolbox 一边建图,Nav2 一边在地图已知部分规划路线
  4. 轮子动了——/cmd_vel 从 Nav2 -> 差速驱动插件 -> Gazebo 物理引擎 -> 里程计反馈 -> EKF 更新 -> TF 更新 -> 闭环

3.10 Day 3 完成后的完整 TF 树#

这条变换链缺失任何一环,Nav2 全部报 “Timed out waiting for transform”。

3.11 RViz 可视化清单#

元素添加方式Fixed Frame含义
/mapBy topic -> MapmapSLAM 建立的实时栅格地图
/global_costmap/costmapBy topic -> Mapmap全局成本图(障碍物膨胀后)
/local_costmap/costmapBy topic -> Map,Color Scheme: costmapodom局部 3x3m 滑动成本图
/local_costmap/published_footprintBy topic -> Polygonodom矩形足迹(0.42x0.39m)
/global_costmap/published_footprintBy topic -> Polygonmap圆形足迹(半径 0.3m)
/planBy topic -> Pathmap全局路径(蓝色粗线)
/local_planBy topic -> Pathodom局部轨迹(绿色细线)
TFDisplays -> TF所有 link 坐标轴
RobotModelDisplays -> RobotModelbase_link机器人 3D 模型
/scanBy topic -> LaserScanodom激光雷达点云

3.12 项目文件结构(最终)#


实物对照#

仿真跑通后回头看,搞实物只要做好三件事,其余代码和仿真完全一样

仿真做的事实物要做的事
libgazebo_ros_diff_drive.so 虚拟驱动调通底盘串口/CAN -> 发布 /odom + 接收 /cmd_vel
libgazebo_ros_ray_sensor.so 虚拟激光雷达 SDK -> 发布 sensor_msgs/LaserScan
libgazebo_ros_imu_sensor.so 虚拟 IMUIMU SDK -> 发布 sensor_msgs/Imu
URDF 手写尺寸量实物,改 URDF 数字
ekf.yamlnav2_params.yaml完全不变

和 TurtleBot3 的关系#

学到这里,我已经可以用下面这条命令一键跑 TB3 仿真了:

export TURTLEBOT3_MODEL=waffle
ros2 launch nav2_bringup tb3_simulation_launch.py headless:=False slam:=True
bash

现在再看这条命令弹出的每个节点、每个话题、每个 TF 变换——我能说出来源了。不是因为看了文档,是因为自己一行行写过。

tb3_simulation_launch.py 压缩的那几百行 launch 代码、几千行 URDF + 插件配置,sam_bot 就是它的拆解版。以后学习 TB3 的调参、行为树、写自定义插件,模型层面不用再花时间——因为底层原理已经在 sam_bot 上走了一遍。

写在最后#

从一条跑不通的 ros2 launch,到 Gazebo 世界里一个会建图、会规划、会避障的自主导航机器人,三天做的事情其实就一个:

理解自主导航机器人的七层架构

第 7 层:执行层    差速驱动 -> 轮子转 -> 里程计反馈 -> 闭环
第 6 层:决策层    costmap(环境建模)-> planner(全局路线)-> controller(局部避障)-> /cmd_vel
第 5 层:感知层    slam_toolbox -> 建图 + 定位
第 4 层:融合层    轮式里程计 + IMU -> EKF -> 平滑定位 + odom -> base_link TF
第 3 层:传感器层  激光雷达 + 深度摄像头 + IMU -> 发布标准化消息
第 2 层:硬件抽象  URDF + robot_state_publisher -> TF 变换树
第 1 层:仿真层    Gazebo 物理引擎 + 传感器噪声模型
plaintext

很多 ROS 教程追求”十分钟跑通”的快感,但跑通之后面对报错一无所知。如果你也想真正理解 Nav2,建议别只跑那一条命令——拆开来看,每一层都值得。


参考:Nav2 官方文档 Setup Guide for Gazebo Classic

从零构建 Nav2 仿真机器人:一条命令的背后
https://jiaxin404.top/tech/sam-bot-from-scratch/
Author 嘉心糖
Published at 2026年6月29日
Comment seems to stuck. Try to refresh?✨