发布于 

ROS 2 Nav2 自主导航实战

系列导航:

  1. ROS 机器人仿真系统配置
  2. ROS 2 入门与环境搭建
  3. ROS 2 机器人仿真实战
  4. ROS 2 自定义消息与服务实战
  5. ROS 2 Nav2 自主导航实战(本文)
  6. ROS 2 机械臂抓取与视觉感知实战
  7. 外骨骼机器人技术概述
  8. 基于ROS 2 的外骨骼控制系统实战
  9. ROS 2 多机通信与系统部署实战

前言

把 Nav2 想成给机器人配的一位"认路的老司机"。你刚到手一台机器人,它对世界一无所知——墙在哪、门朝哪开,它全不知道。第一步你得带它在环境里逛一圈,画成一张地图(SLAM 建图);地图有了,之后你只要在屏幕上点一个目标点,老司机就自己规划路线、躲开桌椅行人,稳稳开过去。

读完本文,你能让一台差速驱动机器人在 Gazebo 里从零完成"建图 → 定位 → 自主导航到指定点",并且知道每个参数大概管什么、卡住了怎么调。全文以一台差速驱动机器人为例,分四块走:

  1. SLAM 建图 — 带机器人逛一圈,画出环境地图
  2. 导航配置 — 代价地图、规划器、控制器怎么调
  3. 行为树定制 — 给导航加"卡住就换办法"的智能
  4. 实车部署 — 从仿真搬到真机的注意点

先泼盆冷水:Nav2 不是「贴份 yaml 就能跑」的傻瓜相机,而是一整套会互相拉扯的模块——地图不准,定位就飘;代价地图太胖,机器人寸步难行;行为树没配恢复,卡一下就躺平。本文不打算把配置直接甩给你,而是每一步先讲清「这一步在解决什么、哪几个数最要命、改错了会怎样」,让你真机卡住时知道往哪拧。把上面四块吃透,你拿到任何一台机器人都能照葫芦画瓢。

动手前先把术语混个脸熟,后面不再逐个解释:

  • SLAM:同时定位与建图,边走边画地图。
  • 代价地图 (Costmap):标着「撞上去代价」的网格图,规划和控都看它。
  • 全局路径 / 局部路径:全局是从起点到终点的整条线(粗);局部是眼前几米怎么走(细)。
  • planner / controller:规划算线,控制给速度。
  • AMCL:自适应蒙特卡洛定位,用粒子猜自己在地图哪。
  • 行为树 (BT):把「规划→跟随→脱困」编成可失败重试的流程。
  • DWB:本文用的局部控制器,试走一堆轨迹挑最好的。
  • TF:坐标系变换树,把各传感器位置统一到同一个世界。
  • cmd_vel:底盘最终收到的速度指令话题,导航的「输出端」。
  • use_sim_time:是否用仿真时钟,真机一定关。

记住一条主线:地图(建) → 定位(我在哪) → 规划(去哪条线) → 控制(怎么走) → 脱困(卡了咋办),全文都绕这五步。


一、环境准备

1.1 安装 Nav2

# Ubuntu 24.04 + ROS 2 Jazzy
sudo apt install ros-jazzy-nav2-bringup \
  ros-jazzy-nav2-msgs \
  ros-jazzy-nav2-util \
  ros-jazzy-nav2-map-server \
  ros-jazzy-nav2-costmap-2d \
  ros-jazzy-nav2-planner \
  ros-jazzy-nav2-controller \
  ros-jazzy-nav2-behavior-tree \
  ros-jazzy-nav2-recoveries \
  ros-jazzy-slam-toolbox \
  ros-jazzy-gazebo-ros-pkgs

# 验证安装
ros2 pkg list | grep nav2

装完上面一串 nav2-* 包,快速对一下它们是干嘛的,免得报错时不知道去哪找:nav2_bringup(launch 和示例配置,本文直接复用)、nav2_planner / nav2_controller / nav2_recoveries(三个服务器本体)、nav2_bt_navigator(行为树)、nav2_map_server / nav2_amcl(地图与定位)、nav2_costmap_2d(代价地图算法库)、slam_toolbox(建图,不算 Nav2 但导航前必用)。记住「带 nav2_ 前缀的是导航,slam_toolbox 是建图」这条线,看日志报错时一眼知道是哪块。

1.2 项目结构

动手前先看清家底。下面这棵树就是后面所有文件的大本营:description 管机器人长什么样,gazebo 管仿真世界,nav2 管导航的大脑(一堆 config + launch)。你不用现在背下来,只记一个原则——模型、仿真、导航三类东西分包裹,改底盘只动 description,调导航只动 nav2,互不连坐。后面每个小标题对应的文件,都能在这棵树里找到位置。

my_robot_nav/                    # 工作空间
└── src/
    ├── my_robot_description/    # 机器人模型 (URDF/XACRO)
    │   ├── urdf/
    │   │   ├── my_robot.xacro
    │   │   ├── diff_drive.xacro
    │   │   ├── lidar.xacro
    │   │   └── camera.xacro
    │   ├── launch/
    │   │   └── display.launch.py
    │   ├── config/
    │   │   └── gazebo.yaml
    │   └── CMakeLists.txt
    ├── my_robot_gazebo/         # Gazebo 仿真
    │   ├── launch/
    │   │   ├── spawn_robot.launch.py
    │   │   └── empty_world.launch.py
    │   └── worlds/
    │       └── my_world.sdf
    ├── my_robot_nav2/           # 导航配置
    │   ├── config/
    │   │   ├── nav2_params.yaml
    │   │   ├── costmap_common.yaml
    │   │   ├── local_costmap.yaml
    │   │   ├── global_costmap.yaml
    │   │   ├── dwa_controller.yaml
    │   │   └── behavior_tree.xml
    │   ├── launch/
    │   │   ├── slam.launch.py
    │   │   ├── navigation.launch.py
    │   │   └── nav_bringup.launch.py
    │   ├── maps/
    │   │   └── (SLAM 生成的地图存放于此)
    │   └── CMakeLists.txt
    └── my_robot_custom_bt/      # 自定义行为树节点
        ├── include/
        ├── src/
        └── plugins/

导航再聪明,也得先有「身体」。这一章从零把机器人建模:先把骨架和传感器立起来,后面所有导航配置才有挂载的对象。

建模时最该建立的一个直觉是坐标系层级:base_footprint(地面投影点,导航算位置就用它)→ base_link(机器人几何中心)→ odom(里程计累积的临时原点)→ map(建好的地图原点)。它们一层套一层,靠 TF 变换连起来。

怎么确认这层没断?

补一句:TF 不是凭空来的,每个变换都由某个节点发布。robot_state_publisher 发布 URDF 里的关节(base→wheel),amcl 发布 map→odom,控制器 / 里程计发布 odom→base_footprint。所以「变换断了」本质是「某个发布者没起来或话题不对」。排错时顺着这条责任链,比盲改参数快——比如激光在代价地图里不显示,先查是不是 base_link→lidar_link 的 TF 没发(URDF 里雷达 joint 漏了)。
跑起来后执行 ros2 run tf2_tools view_frames,它会生成一张变换树 PDF。正常应该看到 map → odom → base_footprint → base_link → lidar_link 一条链,中间没有断点、也没有「俩父节点」。断在哪一层,哪一层对应的配置(URDF 关节 / 发布 TF 的节点)就出错了。这是排错第一招,比看日志快。
后面但凡看到「机器人走歪」「目标对不上」,第一反应都是「这四层变换哪一层断了」,而不是去改算法。

二、机器人模型

2.1 完整 XACRO 模型

my_robot_description/urdf/my_robot.xacro:

机器人「本体」长这样。URDF 是骨架说明书:几个轮子、雷达装多高、基座多重,全写在这。XACRO 是带变量的 URDF——先把 wheel_radius、base_radius 这些尺寸定义成常量(文件开头那一排 xacro:property),下面到处引用,换机器人只改这几个数。

你不用逐行读它,跑导航时只确认三件事:轮子半径和轮距填对了(决定里程计准不准)、base_footprint 到 base_link 的抬高量对了(决定坐标系对不对)、Gazebo 差速插件接上了(决定能不能动)。这三件错一件,后面全歪。

<?xml version="1.0"?>
<robot xmlns:xacro="https://www.ros.org/wiki/xacro" name="my_robot">

  <!-- ====== 常量定义 ====== -->
  <xacro:property name="PI" value="3.14159265"/>
  <xacro:property name="base_radius" value="0.25"/>
  <xacro:property name="base_height" value="0.12"/>
  <xacro:property name="wheel_radius" value="0.06"/>
  <xacro:property name="wheel_width" value="0.04"/>
  <xacro:property name="wheel_separation" value="0.40"/>
  <xacro:property name="wheel_y_offset" value="${wheel_separation/2}"/>
  <xacro:property name="lidar_height" value="0.10"/>
  <xacro:property name="camera_height" value="0.08"/>

  <!-- ====== 差速驱动插件 ====== -->
  <xacro:include filename="$(find my_robot_description)/urdf/diff_drive.xacro"/>
  <!-- ====== LiDAR ====== -->
  <xacro:include filename="$(find my_robot_description)/urdf/lidar.xacro"/>
  <!-- ====== 相机 ====== -->
  <xacro:include filename="$(find my_robot_description)/urdf/camera.xacro"/>

  <!-- ====== 基座 ====== -->
  <link name="base_footprint"/>

  <link name="base_link">
    <visual>
      <geometry>
        <cylinder radius="${base_radius}" length="${base_height}"/>
      </geometry>
      <material name="blue">
        <color rgba="0.2 0.4 0.8 1.0"/>
      </material>
    </visual>
    <collision>
      <geometry>
        <cylinder radius="${base_radius}" length="${base_height}"/>
      </geometry>
    </collision>
    <inertial>
      <mass value="10.0"/>
      <origin xyz="0 0 ${base_height/2}" rpy="0 0 0"/>
      <cylinder_inertia m="10.0" r="${base_radius}" h="${base_height}"/>
    </inertial>
  </link>

  <joint name="base_footprint_to_base" type="fixed">
    <parent link="base_footprint"/>
    <child link="base_link"/>
    <origin xyz="0 0 ${wheel_radius + 0.01}" rpy="0 0 0"/>
  </joint>

  <!-- ====== 里程计帧 ====== -->
  <link name="odom"/>
  <joint name="base_to_odom" type="fixed">
    <parent link="base_link"/>
    <child link="odom"/>
    <origin xyz="0 0 0" rpy="0 0 0"/>
  </joint>

  <!-- 实例化组件 -->
  <xacro:diff_drive prefix="left" x="0" y="${wheel_y_offset}"/>
  <xacro:diff_drive prefix="right" x="0" y="${-wheel_y_offset}"/>
  <xacro:rplidar parent="base_link" height="${lidar_height}"/>
  <xacro:camera parent="base_link" height="${camera_height}"/>

</robot>

写 URDF 最容易踩的坑,先列出来省你两小时:

  • 单位用米:ROS 里长度全是米、质量全是千克,写 0.25 不是 25cm,写错机器人会「瞬间飞出地图」。
  • joint 的 parent/child 别反:父子关系决定坐标怎么传,反了 TF 树就倒。
  • 质量 / 惯量别留 0:惯量为 0 的刚体在物理引擎里会被当成无穷大质量,Gazebo 里机器人会抖或不动。给个合理值即可。

差速驱动的运动学一句话:左轮快、右轮慢 → 向右转;两轮同速 → 直行。所以「转向」本质是两轮转速差,这也解释了为什么轮距和轮半径填错,里程计必然漂——它直接决定「转一度轮,机器人转多少度」。

2.2 差速驱动 XACRO

urdf/diff_drive.xacro:

轮子怎么转、底盘怎么动,全在这个宏里。它干两件大事:第一,用 type="continuous" 的 joint 定义左右轮旋转关节——差速驱动就是靠两轮转速差来转向;第二,挂上 Gazebo 的差速插件(libgazebo_ros_diff_drive.so),把「左右轮转速」翻译成「机器人实际位移和里程计」。

最该盯的是 wheel_separation 和 wheel_radius(必须和 2.1 里定义的常量一致),以及 odom_frame / robot_base_frame(定位系统就靠这两个坐标系对齐)。这两个填错,典型症状是「转一圈实际没转够」或里程计肉眼可见地飘。

<?xml version="1.0"?>
<robot xmlns:xacro="https://www.ros.org/wiki/xacro">

  <xacro:macro name="diff_drive" params="prefix x y">

    <link name="${prefix}_wheel_link">
      <visual>
        <geometry>
          <cylinder radius="${wheel_radius}" length="${wheel_width}"/>
        </geometry>
        <origin xyz="0 0 0" rpy="${PI/2} 0 0"/>
        <material name="black">
          <color rgba="0.1 0.1 0.1 1.0"/>
        </material>
      </visual>
      <collision>
        <geometry>
          <cylinder radius="${wheel_radius}" length="${wheel_width}"/>
        </geometry>
        <origin xyz="0 0 0" rpy="${PI/2} 0 0"/>
      </collision>
      <inertial>
        <mass value="0.5"/>
        <cylinder_inertia m="0.5" r="${wheel_radius}" h="${wheel_width}"/>
      </inertial>
    </link>

    <joint name="${prefix}_wheel_joint" type="continuous">
      <parent link="base_link"/>
      <child link="${prefix}_wheel_link"/>
      <origin xyz="${x} ${y} ${-base_height/2}" rpy="0 0 0"/>
      <axis xyz="0 1 0"/>
    </joint>

    <!-- Gazebo 差速驱动插件 -->
    <gazebo reference="${prefix}_wheel_link">
      <mu1>1.0</mu1>
      <mu2>1.0</mu2>
      <kp>1000000.0</kp>
      <kd>100.0</kd>
      <material>Gazebo/DarkGrey</material>
    </gazebo>

  </xacro:macro>

  <!-- 差速驱动控制器 -->
  <gazebo>
    <plugin name="differential_drive_controller"
            filename="libgazebo_ros_diff_drive.so">
      <left_joint>left_wheel_joint</left_joint>
      <right_joint>right_wheel_joint</right_joint>

      <!-- 运动学参数 -->
      <wheel_separation>${wheel_separation}</wheel_separation>
      <wheel_radius>${wheel_radius}</wheel_radius>

      <!-- 里程计 -->
      <odometry_frame>odom</odometry_frame>
      <robot_base_frame>base_footprint</robot_base_frame>

      <!-- 速度和加速度限制 -->
      <max_wheel_torque>20.0</max_wheel_torque>
      <max_wheel_acceleration>5.0</max_wheel_acceleration>

      <!-- 发布配置 -->
      <publish_odom>true</publish_odom>
      <publish_odom_tf>true</publish_odom_tf>
      <publish_wheel_tf>true</publish_wheel_tf>

      <odometry_source>world</odometry_source>
    </plugin>
  </gazebo>

  <!-- 里程计 IMU -->
  <gazebo>
    <plugin name="imu_plugin" filename="libgazebo_ros_imu_sensor.so">
      <ros>
        <namespace>/</namespace>
        <remapping>~/out:=imu/data</remapping>
      </ros>
      <initial_orientation_as_reference>false</initial_orientation_as_reference>
    </plugin>
  </gazebo>

</robot>

2.3 LiDAR XACRO

urdf/lidar.xacro:

激光雷达是导航的「眼睛」。这个宏定义一个圆柱当雷达外壳,再用 Gazebo 的 ray 传感器插件模拟 360° 扫描,话题名 scan,类型 LaserScan。

两个地方最影响建图质量:samples(一圈打多少点,越多越密)、range 里的 max(最远测距,要盖过你环境的最大尺度)。noise 那段是仿真噪声,真机不用管;真机换型号时只改 samples / range 和话题名即可,结构不用动。

<?xml version="1.0"?>
<robot xmlns:xacro="https://www.ros.org/wiki/xacro">

  <xacro:macro name="rplidar" params="parent height">

    <link name="lidar_link">
      <visual>
        <geometry>
          <cylinder radius="0.04" length="0.05"/>
        </geometry>
        <material name="red">
          <color rgba="0.8 0.2 0.2 1.0"/>
        </material>
      </visual>
      <collision>
        <geometry>
          <cylinder radius="0.04" length="0.05"/>
        </geometry>
      </collision>
    </link>

    <joint name="lidar_joint" type="fixed">
      <parent link="${parent}"/>
      <child link="lidar_link"/>
      <origin xyz="0 0 ${base_height + height}" rpy="0 0 0"/>
    </joint>

    <!-- Gazebo LiDAR 仿真插件 -->
    <gazebo reference="lidar_link">
      <sensor type="ray" name="lidar_sensor">
        <update_rate>10</update_rate>
        <ray>
          <scan>
            <horizontal>
              <samples>360</samples>
              <resolution>1.0</resolution>
              <min_angle>-3.14159</min_angle>
              <max_angle>3.14159</max_angle>
            </horizontal>
          </scan>
          <range>
            <min>0.12</min>
            <max>12.0</max>
            <resolution>0.015</resolution>
          </range>
          <noise>
            <type>gaussian</type>
            <mean>0.0</mean>
            <stddev>0.01</stddev>
          </noise>
        </ray>
        <plugin name="lidar_plugin" filename="libgazebo_ros_ray_sensor.so">
          <ros>
            <namespace>/</namespace>
            <remapping>~/out:=scan</remapping>
          </ros>
          <output_type>sensor_msgs/LaserScan</output_type>
          <frame_name>lidar_link</frame_name>
        </plugin>
      </sensor>
    </gazebo>

  </xacro:macro>

</robot>

如果你从 ROS 1 过来,一句话对应:本文的 bt_navigator + planner + controller + recoveries 这套,就是 ROS1 里 move_base 的「拆开重做版」。区别是 Nav2 把规划 / 控制 / 脱困拆成独立服务器,用行为树编排,比 move_base 那根写死的 recovery 链条灵活得多,也更好加自己的逻辑。老用户最大的不适应是「找不到 move_base 节点了」——它变成了上面那五个。

三、SLAM 建图

SLAM 听着吓人,拆开就两步。第一步「定位」:机器人每走一点,靠里程计(轮子里程)和 IMU 估个大概位置,但这估法会越走越漂,叫累积误差。第二步「建图」:激光雷达扫到墙,把墙的轮廓和「我刚估的位置」绑在一起,画进地图。麻烦在于这俩互相依赖——位置不准,墙就画歪;墙画歪,又反过来让位置更不准。SLAM 求解器就是在每一帧里把两者一起优化。

你会看到地图「重影」「漂移」,多半是里程计太漂(轮子打滑、轮距填错),求解器太信轮子、不信雷达,墙就被画到错的位置。slam_toolbox 用回环检测(loop closing)来救:

SLAM 内部还分「前端」和「后端」:前端负责每一帧把激光点和上一帧对上(帧间匹配),算出「我刚挪了多少」;后端把一路上的相对位移拼成全局一致的地图,并做回环校正。你调的 odom_alpha* 影响前端信不信轮子,max_laser_range 和噪声影响前端点云质量,do_loop_closing 是后端的杀手锏。理解这层,建图飘时你就知道该拧参数还是该走慢点,而不是瞎改。
当你走回见过的墙,它发现「这墙我见过」,把整张图拉正。所以建图时慢走、多走回头路,比快冲一圈有用得多。

本文用 slam_toolbox 的异步模式:边走边建,还能在导航时继续微调地图。参数都在 3.1 那份 yaml,你只要把住四个旋钮:resolution(精度)、max_laser_range(有效测距)、do_loop_closing(回环开关)、odom_alpha*(信雷达还是信轮子)。

3.1 SLAM 配置文件

my_robot_nav2/config/slam_params.yaml:

上面那段参数直接存成这个文件。建图时你几乎不用改它,唯一常动的是 map_file_name(地图存哪)和 resolution。跑起来后盯 RViz:地图边缘清晰、没有重影就对了;有重影先回头调 odom_alpha* 或走慢点,别急着改别的。

slam_toolbox:
  ros__parameters:
    # 使用异步模式(推荐,支持在线地图保存)
    mode: mapping

    # 里程计话题
    odom_frame: odom
    map_frame: map
    base_frame: base_footprint
    scan_topic: /scan

    # 地图更新频率
    map_update_interval: 2.0
    resolution: 0.05                # 地图分辨率 5cm/格
    max_laser_range: 12.0
    minimum_time_interval: 0.5
    transform_timeout: 0.2
    tf_buffer_duration: 30.0

    # 相关性搜索(性能优化)
    correlation_search_space_dimension: 0.5
    correlation_search_space_resolution: 0.01
    correlation_search_space_smear_deviation: 0.1

    # 扫描匹配器
    scan_buffer_size: 10
    scan_buffer_maximum_scan_distance: 10.0
    link_match_minimum_response_fine: 0.1
    link_scan_maximum_distance: 1.5

    # 循环闭合检测
    do_loop_closing: true
    loop_match_minimum_chain_size: 10
    loop_match_maximum_variance_coarse: 3.0
    loop_match_minimum_response_coarse: 0.35
    loop_match_minimum_response_fine: 0.45

    # 扫描器设置
    laser_model_type: likelihood_field
    laser_likelihood_max_dist: 2.0
    laser_min_range: 0.12
    laser_max_range: 12.0
    laser_sample_step: 0.05

    # 运动模型
    odom_model_type: diff
    odom_alpha1: 0.2                # 旋转引起的旋转噪声
    odom_alpha2: 0.2                # 平移引起的旋转噪声
    odom_alpha3: 0.2                # 平移引起的平移噪声
    odom_alpha4: 0.2                # 旋转引起的平移噪声
    odom_alpha5: 0.1                # 平移修正

    # 在线异步(支持在导航的同时更新地图)
    use_scan_matching: true
    use_scan_barycenter: true

    # 地图保存路径
    map_file_name: /home/user/my_robot_nav/src/my_robot_nav2/maps/my_map
    map_start_pose: [0.0, 0.0, 0.0]

    debug_logging: false
    throttle_scans: 1

3.2 SLAM Launch 文件

这个 launch 文件一次性把仿真环境、机器人、SLAM 节点和 RViz 全部拉起来。运行它,你会看到 Gazebo 里站着的机器人和 RViz 中逐渐浮现的地图——这就是建图现场。

my_robot_nav2/launch/slam.launch.py:

#!/usr/bin/env python3
"""SLAM 建图 Launch 文件"""

import os
from launch import LaunchDescription
from launch.actions import IncludeLaunchDescription, DeclareLaunchArgument
from launch.launch_description_sources import PythonLaunchDescriptionSource
from launch.substitutions import LaunchConfiguration
from launch_ros.actions import Node
from ament_index_python.packages import get_package_share_directory


def generate_launch_description():
    pkg_nav2 = get_package_share_directory('my_robot_nav2')
    pkg_gazebo = get_package_share_directory('my_robot_gazebo')

    # 启动 Gazebo 仿真环境
    gazebo_launch = IncludeLaunchDescription(
        PythonLaunchDescriptionSource(
            os.path.join(pkg_gazebo, 'launch', 'empty_world.launch.py'))
    )

    # 启动机器人
    spawn_robot = IncludeLaunchDescription(
        PythonLaunchDescriptionSource(
            os.path.join(pkg_gazebo, 'launch', 'spawn_robot.launch.py'))
    )

    # SLAM Toolbox 节点(在线异步建图)
    slam_toolbox = Node(
        package='slam_toolbox',
        executable='async_slam_toolbox_node',
        name='slam_toolbox',
        output='screen',
        parameters=[
            os.path.join(pkg_nav2, 'config', 'slam_params.yaml'),
            # 覆盖地图保存路径
            {'map_file_name': os.path.join(pkg_nav2, 'maps', 'my_map')},
        ],
    )

    # RViz2 建图可视化
    rviz = Node(
        package='rviz2',
        executable='rviz2',
        arguments=['-d', os.path.join(pkg_nav2, 'config', 'slam.rviz')],
        name='rviz2',
    )

    return LaunchDescription([
        gazebo_launch,
        spawn_robot,
        slam_toolbox,
        rviz,
    ])

建图质量 80% 取决于你怎么走,不是参数。三条经验:

  1. 先在原地转一圈:让激光扫满 360°,把初始位姿和周围第一圈墙固定住,后面才不漂。
  2. 慢且匀:0.2-0.3 m/s 匀速推进,急转弯时激光容易丢特征,地图就裂。
  3. 关键区域来回走:走廊、门口、岔路多走两遍,给回环检测更多「我见过了」的证据,地图更闭合。

建完别急着关,先在 RViz 看整张图有没有明显断裂或重影;有的话回到那段重新走,比后面在导航里 debug 省事十倍。

3.3 手动遥控建图

# 终端 1:启动 SLAM
ros2 launch my_robot_nav2 slam.launch.py

# 终端 2:遥控机器人扫描环境
ros2 run teleop_twist_keyboard teleop_twist_keyboard

# 操作技巧:
# 1. 先在原地 360° 旋转一圈,初始化地图
# 2. 以 0.2-0.3 m/s 速度缓慢移动
# 3. 走廊、门口等关键区域要来回走几遍
# 4. 避免急转弯,保持匀速

建图「飘 / 重影 / 建不出」先自查这三点,十有八九中:

  • 地图在转但墙不累积:激光话题名不对(scan_topic 填错),SLAM 收不到点云。ros2 topic echo /scan 看有没有数据。
  • 走直线地图却歪:里程计漂,回 2.2 核轮距 / 轮半径,或走更慢。
  • 回到原点对不上(不闭合):关了回环,把 do_loop_closing 设回 true,或环境特征太少(空白墙)导致无特征可匹配,多加点家具 / 标志物。

补充:激光看不到的东西(玻璃门、纯色反光墙、强日光下的深色面)在建图时会「穿透」,地图出现空洞。对策:环境里加点纹理 / 标志物帮匹配;真机用抗强光雷达。建图时某面墙始终画不上,别硬调参数,先怀疑是传感器物理极限。

确认地图清晰后再保存,不然后面导航全程建立在歪地图上。

还有个隐蔽坑:导航用的地图必须和建图时是同一份、同一坐标系。很多人建图后改了机器人尺寸或雷达高度,没重新建图,结果「地图对、激光不对」——激光打到的墙和地图里的墙差了半米。铁律:动过 URDF 的传感器 / 底盘,重新建图再用。地图文件用 git 管理,每次重建提交一次,出问题能回滚到「那次还能用」的版本。

3.4 保存地图

# 方式 1:使用 slam_toolbox 的服务保存
ros2 service call /slam_toolbox/save_map slam_toolbox/srv/SaveMap \
  "{name: {data: 'my_map'}}"

# 方式 2:使用 map_saver 手动保存
ros2 run nav2_map_server map_saver_cli \
  -f ~/my_robot_nav/src/my_robot_nav2/maps/my_map

# 生成文件:
#   my_map.pgm   (灰度地图图像)
#   my_map.yaml   (地图元数据)

保存地图会落下两个文件:my_map.pgm 是灰度图(白=空、黑=墙、灰=未知),my_map.yaml 是元数据。yaml 里最该懂的是 origin(地图左下角在 map 坐标系的位置,建图起点不对时整图偏移)和 occupied_thresh / free_thresh(黑白判定阈值,环境反光强可调)。导航时只读这个 yaml,pgm 当图片加载,你一般不用手动改 pgm。

3.5 地图 YAML 示例

image: my_map.pgm
resolution: 0.05        # m/pixel
origin: [-10.0, -10.0, 0.0]  # 左下角在 map 坐标系中的位置
negate: 0                # 0=白色=空闲, 1=黑色=空闲
occupied_thresh: 0.65    # > 65% 黑 = 障碍物
free_thresh: 0.25        # < 25% 黑 = 空闲

地图有了,机器人也知道自己在哪了。现在的问题是:给它一个目标点,它怎么安全开过去?这就是 Nav2 的核心——导航配置。

代价地图(Costmap)是什么? 你可以把它想成一张"危险热力图"。地图每个格子标着撞上去的代价:墙边是深红(千万别碰),离墙半米是浅黄(尽量别来),空地是绿色(随便走)。规划器和控制器都盯着这张图决定路线。

四、Nav2 导航配置(核心)

代价地图是导航的「眼睛」,本质是一张「危险热力图」:每个格子标着撞上去的代价,墙边深红(别碰)、离墙半米浅黄(尽量别来)、空地绿色(随便走)。它分三层叠起来:

  • static_layer(静态层):从建好的地图来,墙永久标红。
  • obstacle_layer(障碍层):从实时激光来,动态行人 / 椅子随时标红、走了就清掉。
  • inflation_layer(膨胀层):在红区外面再画一圈「渐变的黄」,离障碍越近代价越高,逼机器人自觉保持距离。

planner 看全局代价地图决定大方向,controller 看局部代价地图躲眼前障碍,两份配置都继承下面这份公共地基。

4.1 公共代价地图参数

config/costmap_common.yaml:

代价地图的「公共地基」,local 和 global 两层都继承它。重点看三块:footprint(机器人轮廓,碰撞判定就靠它,填成你底盘的矩形)、inflation_layer 的 inflation_radius 和 cost_scaling_factor(这俩决定机器人和墙保持多远,调参篇会反复拧)、observation_sources(告诉代价地图「激光从哪来」)。voxel_layer 默认关着,纯 2D 室内用不到,别开。

observation_sources 下的 marking / clearing 两个开关常被忽略:marking=true 表示激光扫到障碍就往地图标红;clearing=true 表示激光「穿过空地」时把原来的红清掉(障碍走了)。两个都得开,否则会出现「人走了墙还在」的鬼打墙。raytrace_range 要比 obstacle_range 大,清障才清得远——这是调静态 / 动态障碍分离的关键。

footprint 不是随便画的:必须比你底盘实际外廓略小或相等,错了有两种后果——画大了,机器人以为自己比实际胖,明明能过的缝不过;画小了,会贴墙蹭甚至卡住。简单机器人用矩形(本文四角坐标),异形底盘用多边形逐点描。实用技巧:RViz 打开 Footprint 图层,看轮廓是不是正好压在模型上,偏了回去改坐标。

进阶一点:如果你的机器人不是方形(比如圆形底盘带突出传感器),用多边形逐点写 footprint 更准——按激光实际能扫到的外缘描点,而不是套个大矩形。代价是点多了计算稍重,但能少撞。反例:圆形扫地机用方形 footprint,四角永远标红,开阔地也绕着走。形状贴近真实,代价地图才贴近真实。

# 公共参数(local 和 global 共享)
costmap_common:
  ros__parameters:
    # 坐标帧
    robot_base_frame: base_footprint
    global_frame: map
    odom_topic: /odom
    use_sim_time: false

    # 代价地图分辨率和尺寸
    resolution: 0.05          # 5cm/格
    footprint: "[[-0.20, -0.20], [0.20, -0.20], [0.20, 0.20], [-0.20, 0.20]]"
    footprint_padding: 0.03   # 膨胀 padding

    # 传感器数据源
    observation_sources: scan

    scan:
      topic: /scan
      data_type: "LaserScan"
      marking: true            # 标记障碍物
      clearing: true           # 清除障碍物
      obstacle_range: 5.0      # 障碍物检测最大距离
      raytrace_range: 8.0      # 射线追踪清除距离
      inf_is_valid: false
      max_obstacle_height: 2.0
      clearing_allowed: true

    # ====== 各层配置 ======
    # 静态地图层
    static_layer:
      enabled: true
      map_subscribe_transient_local: true
      map_topic: /map

    # 障碍物层(激光扫描)
    obstacle_layer:
      enabled: true
      max_obstacle_height: 2.0
      combination_method: 1    # 1=max (取最高代价)

    # 膨胀层(关键调参!)
    inflation_layer:
      enabled: true
      cost_scaling_factor: 3.0    # 代价衰减系数(越小膨胀越大)
      inflation_radius: 0.55      # 膨胀半径(机器人半径 + 安全余量)

    # 体素层(3D 障碍物,可选)
    voxel_layer:
      enabled: false
      voxel_size: 0.05
      origin_z: 0.0
      z_voxels: 16
      max_obstacle_height: 2.0
      mark_threshold: 0
      unknown_threshold: 15

    # Always True 层(强制自由区域)
    always_send_full_costmap: true

local 和 global 两份代价地图不是复制粘贴,而是分工:global 覆盖整张地图、更新慢(1Hz),负责大方向;local 是机器人周围 6m×6m 的滚动窗口、更新快(5Hz),负责躲眼前障碍。

所以你改 inflation_radius 时要分清改哪份:local 的膨胀直接决定「敢不敢贴墙过窄道」,global 的膨胀决定「规划出的线离墙多远」。窄道卡住优先动 local,整体绕远路优先动 global。两份的 resolution 通常一致(5cm),改小更精细也更吃内存。

分辨率怎么选:5cm 是室内甜点区,细节够、内存合理;仓库等大场景用 0.1-0.2m 更省资源,反正墙都大。注意分辨率和膨胀要配套——分辨率粗了(格子大),inflation_radius 也得按格子数相应调,否则「看起来膨胀很小实际贴墙」。改分辨率后整份代价地图参数建议一起复算,别只改一个数。

inflation_layer 的代价公式直觉:离障碍距离 d,代价 ≈ exp(-cost_scaling_factor × (d - 机器人半径)),超过 inflation_radius 就归零(纯绿)。所以 cost_scaling_factor 越大,黄区越薄、机器人越「敢贴墙」;inflation_radius 越大,黄区越厚、离墙越远。调这两个就是调「保守 vs 激进」——室内拥挤环境偏保守(大半径),开阔仓库可偏激进(小半径)。

一个常被问的问题:建图和导航用同一份代价地图吗?不用。建图时只有静态地图(slam 产出);导航时代价地图才叠加上实时激光(obstacle_layer)。所以你会在 global 里开 static_layer、local 里关它——global 看建好的整图,local 看眼前实时障碍。理解这个区别,就不会疑惑「为什么 local 代价地图里地图是空的」。

一句话记住代价地图的「三层两图」:三层是 static / obstacle / inflation,两图是 global(整图 + 静态)和 local(窗口 + 实时)。调参时认准你动的在哪层、作用于哪张图,就不会改了没感觉。这条心法配合第七章的表,基本覆盖九成调参场景——剩下的一成,才是真要去读 Nav2 源码的硬骨头。

4.2 局部代价地图

config/local_costmap.yaml:

local_costmap:
  ros__parameters:
    update_frequency: 5.0          # 5Hz 更新
    publish_frequency: 2.0         # 2Hz 发布
    global_frame: odom             # 局部地图在 odom 帧下
    rolling_window: true           # 滚动窗口
    width: 6                      # 6m x 6m
    height: 6
    resolution: 0.05

    # 插件栈顺序(从下到上叠加)
    plugins: ["voxel_layer", "inflation_layer"]

    # 不使用静态地图层(局部用实时传感器数据)
    static_layer:
      enabled: false
    obstacle_layer:
      enabled: false

4.3 全局代价地图

config/global_costmap.yaml:

global_costmap:
  ros__parameters:
    update_frequency: 1.0         # 1Hz 更新(全局不需要高频)
    publish_frequency: 1.0
    global_frame: map             # 全局地图在 map 帧下
    robot_base_frame: base_footprint
    rolling_window: false         # 不滚动,覆盖整张地图

    # 插件栈
    plugins: ["static_layer", "obstacle_layer", "inflation_layer"]

DWB 是「握方向盘的人」——给定一条全局路径,它算出每瞬间该以多快的速度前进、转多大弯。它不发明路线,只在路径旁边「试走」一堆小轨迹,再用一组**评分器(critics)**打分挑最好的那条。

参数分三群:max_vel_x / max_vel_theta(速度上限,室内别给太大)、sim_time + *_samples(向前模拟多少种走法再挑最好的,越大越聪明也越吃 CPU)、critics(评分器,决定「贴墙走」还是「走中间」,权重越大越被看重)。新手九成调参只动 max_vel_x、sim_time 和 xy_goal_tolerance(到点容差)。

critics 列表里每个带 scale 的项就是一票。打分逻辑是「代价 = 各 critic 输出 × 权重 求和」,分低的轨迹胜出。常用几个:BaseObstacle(离障碍多近,权重越大越怕撞)、PathDist / GoalDist(离全局路径 / 目标多远,越大越贴线)、Oscillation(惩罚左右横跳,专治振荡)。想更贴路径就调大 PathDist,想更敢靠近障碍就调小 BaseObstacle。改一个记一个,别全调。

本文用 DWB,Nav2 还有另一主流局部控制器 TEB(Timed-Elastic-Band)。区别一句话:DWB 是「采样一堆轨迹挑最好的」,简单稳、调参直观,适合差速 / 阿克曼;TEB 是「优化一条带时间的最优带」,更平滑、能处理倒车 / 非完整约束,但配置更繁、更吃算力。新手先用 DWB 跑通,真遇到「必须倒车入库」这类场景再换 TEB,别一上来折腾。

4.4 DWB 控制器

config/dwa_controller.yaml:

这份就是 DWB 的落地配置。

参数怎么被加载的,搞清能少踩坑:launch 里把 params_file(即 nav2_params.yaml)整体喂给每个 Composable 节点,节点再按自己关心的段(如 controller_server 下的 FollowPath)读值。所以同一份 yaml 被所有节点共享,改一个段只影响对应节点。若某参数「改了不生效」,先确认改的段名和节点实际读的段名一致(大小写 / 缩进),再确认 launch 真指向这份文件、没有别处覆盖。很多「不生效」其实是改错段或路径指错。
看的时候抓住三行:max_vel_x(前进上限,室内 0.3-0.5 稳妥)、sim_time(轨迹预测时长,大空间调到 2.0+,小空间降到 1.0 更跟手)、critics 列表里的权重(PathDist / GoalDist 越大越贴路径,BaseObstacle 越大越怕撞)。其余默认即可。

controller_server:
  ros__parameters:
    controller_plugins: ["FollowPath"]

    # 跟随路径控制器
    FollowPath:
      plugin: "dwb_core::DWBLocalPlanner"
      debug_trajectory_details: true
      min_vel_x: 0.0              # 最小前进速度
      max_vel_x: 0.5              # 最大前进速度 (m/s)
      max_vel_y: 0.0              # 差速驱动,y 方向速度为 0
      max_vel_theta: 1.5          # 最大旋转速度 (rad/s)
      min_speed_xy: 0.1           # 低于此速度将停止
      max_speed_xy: 0.5
      min_speed_theta: 0.2

      # 加速度限制
      acc_lim_x: 2.5
      acc_lim_y: 0.0
      acc_lim_theta: 3.2
      decel_lim_x: -2.5
      decel_lim_y: 0.0
      decel_lim_theta: -3.2

      # 轨迹生成参数
      sim_time: 1.7               # 轨迹模拟时间 (s)
      sim_granularity: 0.025      # 轨迹点间距 (m)
      vx_samples: 20              # x 速度采样数
      vy_samples: 1               # y 速度采样数
      vtheta_samples: 40          # 角速度采样数

      # 目标容差
      xy_goal_tolerance: 0.25     # 到达目标的距离容差
      yaw_goal_tolerance: 0.25    # 到达目标的角度容差
      latch_xy_goal_tolerance: false

      # 轨迹评分器(按优先级排列)
      critics: [
        "RotateToGoal", "Oscillation", "BaseObstacle",
        "GoalAlign", "PathAlign", "PathDist", "GoalDist"
      ]

      BaseObstacle.scale: 0.02
      PathAlign.scale: 32.0
      PathAlign.forward_point_distance: 0.1
      GoalAlign.scale: 24.0
      GoalAlign.forward_point_distance: 0.1
      PathDist.scale: 32.0
      GoalDist.scale: 24.0
      RotateToGoal.scale: 32.0
      RotateToGoal.slowing_factor: 5.0
      Oscillation.scale: 1.0

Nav2 不是一个程序,是五个各管一摊的服务器,由行为树当总指挥调度。记住这五个,那一堆 yaml 就不吓人了:

  • planner_server(规划):拿到「我在哪、目标在哪」,算出一条全局路径。像高德给你规划路线。本文用 NavfnPlanner,底层是 Dijkstra 或 A*(use_astar 切换:A* 快但可能不平滑,Dijkstra 稳但慢)。
  • controller_server(控制 / 局部规划):拿全局路径,结合实时障碍算「这一刻给轮子什么速度」。像老司机看眼前路况打方向盘。本文用 DWB。
  • recoveries_server(脱困):卡住时执行旋转、后退、清地图。
  • bt_navigator(行为树):把上面几个编排成「规划→跟随→卡了就脱困」,是导航的大脑。
  • lifecycle_manager(生命周期):统一管理这些节点的启动 / 关闭顺序,免得「地图还没好就让机器人走」。

代价地图是 planner 和 controller 共用的眼睛。理解了这条链,调参时你就知道该动哪份文件。

planner 怎么选:本文用 NavfnPlanner,底层是 Dijkstra(默认,找全局最短、稳但慢)或 A*(use_astar: true,快但路径偏「格子感」、不够平滑)。室内小场景两者差别不大;大 warehouse 用 A* 省时间。想要更平滑的轨迹,Nav2 还有 Smac 规划器(支持圆弧 / 混合 A*),代价是配置更繁。新手先用默认 Dijkstra 跑通,再按需换。

用一张「谁调谁」的图把五个服务器串起来,比记定义更牢:行为树(bt_navigator)是总指挥,它调用 planner 算路、调用 controller 跟线、调用 recoveries 脱困;lifecycle_manager 只管这些节点的生命周期(配置→激活→关闭),不参与算法。map_server 和 amcl 是「基础设施」,给 planner / controller 提供地图和定位。所以行为树一启动就拉起整条链——你点目标,真正干活的是被它调度的那几个服务器,不是行为树自己。

4.5 Nav2 主参数文件

config/nav2_params.yaml:

这是 Nav2 的「总开关」,把上面五个服务器的参数收在一份里。planner_server / controller_server 大多沿用前面分文件的值;真正要你心里有数的是 plugin_lib_names 那一长串——行为树能调用的「技能清单」,标准件,照抄即可;还有 lifecycle_manager 的 node_names,列了哪些节点一起启动。**这片文件别手敲,复制后只改路径和个别阈值。

行为树里还有个隐藏旋钮 default_server_timeout(4.5 顶部,默认 20s):单个动作(如规划)超过这么久没回,行为树就当失败进脱困。真机算力慢、规划偶尔卡,把它调大(比如 30-60s)能避免「明明在算却被误判失败」。但调太大也会让真卡死时反应迟钝,按真机实测取中值。
**

bt_navigator:
  ros__parameters:
    use_sim_time: false
    global_frame: map
    robot_base_frame: base_footprint
    odom_topic: /odom
    bt_loop_duration: 10          # 行为树循环间隔 (ms)
    default_server_timeout: 20    # Action 超时 (s)

    # 行为树 XML
    default_nav_to_pose_bt_xml: ""
    default_nav_through_poses_bt_xml: ""
    # 下面一长串是行为树可用节点的标准清单,照抄即可,不必逐行理解
    plugin_lib_names:

      - nav2_compute_path_to_pose_action_bt_node
      - nav2_compute_path_through_poses_action_bt_node
      - nav2_smooth_path_action_bt_node
      - nav2_follow_path_action_bt_node
      - nav2_spin_action_bt_node
      - nav2_wait_action_bt_node
      - nav2_assisted_teleop_action_bt_node
      - nav2_back_up_action_bt_node
      - nav2_drive_on_heading_bt_node
      - nav2_clear_costmap_service_bt_node
      - nav2_is_stuck_condition_bt_node
      - nav2_goal_reached_condition_bt_node
      - nav2_goal_updated_condition_bt_node
      - nav2_globally_updated_goal_condition_bt_node
      - nav2_is_path_valid_condition_bt_node
      - nav2_initial_pose_received_condition_bt_node
      - nav2_reinitialize_global_localization_service_bt_node
      - nav2_rate_controller_bt_node
      - nav2_distance_controller_bt_node
      - nav2_speed_controller_bt_node
      - nav2_truncate_path_action_bt_node
      - nav2_truncate_path_local_action_bt_node
      - nav2_goal_updater_node_bt_node
      - nav2_recovery_node_bt_node
      - nav2_pipeline_sequence_bt_node
      - nav2_round_robin_node_bt_node
      - nav2_transform_available_condition_bt_node
      - nav2_time_expired_condition_bt_node
      - nav2_path_expiring_timer_condition_bt_node
      - nav2_distance_traveled_condition_bt_node
      - nav2_single_trigger_bt_node
      - nav2_goal_updated_controller_bt_node
      - nav2_is_battery_low_condition_bt_node
      - nav2_navigate_through_poses_action_bt_node
      - nav2_navigate_to_pose_action_bt_node
      - nav2_remove_passed_goals_action_bt_node
      - nav2_planner_selector_bt_node
      - nav2_controller_selector_bt_node
      - nav2_goal_checker_selector_bt_node
      - nav2_controller_cancel_bt_node
      - nav2_path_longer_on_approach_bt_node
      - nav2_wait_cancel_bt_node
      - nav2_spin_cancel_bt_node
      - nav2_back_up_cancel_bt_node
      - nav2_assisted_teleop_cancel_bt_node
      - nav2_drive_on_heading_cancel_bt_node
      - nav2_is_battery_charging_condition_bt_node

# 规划器服务器
planner_server:
  ros__parameters:
    expected_planner_frequency: 20.0
    use_sim_time: false
    planner_plugins: ["GridBased"]
    GridBased:
      plugin: "nav2_navfn_planner/NavfnPlanner"
      tolerance: 0.5
      use_astar: false            # true=A*, false=Dijkstra
      allow_unknown: true

# 控制器服务器
controller_server:
  ros__parameters:
    use_sim_time: false
    controller_frequency: 20.0
    min_x_velocity_threshold: 0.001
    min_y_velocity_threshold: 0.001
    min_theta_velocity_threshold: 0.001
    progress_checker_plugins: ["progress_checker"]
    goal_checker_plugins: ["general_goal_checker"]

    progress_checker:
      plugin: "nav2_controller::SimpleProgressChecker"
      required_movement_radius: 0.5
      movement_time_allowance: 10.0

    general_goal_checker:
      plugin: "nav2_controller::SimpleGoalChecker"
      xy_goal_tolerance: 0.25
      yaw_goal_tolerance: 0.25
      stateful: true

# 恢复服务器
recoveries_server:
  ros__parameters:
    costmap_topic: local_costmap/costmap_raw
    footprint_topic: local_costmap/published_footprint
    cycle_frequency: 10.0
    recovery_plugins: ["spin", "backup", "wait"]
    spin:
      plugin: "nav2_recoveries/Spin"
    backup:
      plugin: "nav2_recoveries/BackUp"
    wait:
      plugin: "nav2_recoveries/Wait"
    global_frame: odom
    robot_base_frame: base_footprint
    transform_timeout: 0.1
    simulate_ahead_time: 2.0
    max_rotational_vel: 1.0
    min_rotational_vel: 0.4
    rotational_acc_lim: 3.2

# 生命周期节点管理器
lifecycle_manager:
  ros__parameters:
    use_sim_time: false
    autostart: true
    node_names:
      ['controller_server', 'planner_server', 'recoveries_server',
       'bt_navigator', 'waypoint_follower']

行为树(Behavior Tree)是给导航装「遇事不慌的脑子」。它不像普通 if/else 写死流程,而是用节点组合出「先规划、再跟线、卡住就换招」的策略,哪个分支失败就自动滑到下一个,天然适合「失败了怎么办」。

核心节点记三个:PipelineSequence(把「规划→跟随」串成流水线,边走边重规划,不用等走完再算下一段)、RecoveryNode(主流程失败就进恢复分支,重试 N 次)、ReactiveFallback(里面挂清地图、旋转、后退三招)。下面这份 XML 就是一棵自定义树,直接对应这三层。

4.6 自定义行为树

config/navigation_behavior_tree.xml:

照着上面三层写:外层 RecoveryNode 包住「规划+跟随」,内层 ReactiveFallback 挂三种脱困。想加新招(比如「绕行」),往 ReactiveFallback 里再塞一个节点即可,不用动别的。

恢复行为(recoveries)是导航的「安全气囊」。本文默认三个:Spin(原地转一圈,扫掉盲区障碍)、BackUp(后退一点,脱离卡死)、Wait(停一下,等动态障碍走开)。它们挂在行为树的 ReactiveFallback 里,主流程(规划+跟随)一失败就按顺序试。注意恢复也会失败(真被四面围住),这时行为树放弃本次目标并报错——这不是 bug,是「确实过不去」的正常反馈。真机常把 Spin 角度调小,避免大空间里无意义猛转。
注意节点名要和 4.5 里 plugin_lib_names 清单对得上,否则行为树找不到这个技能。

<?xml version="1.0"?>
<!--
  自定义导航行为树
  核心逻辑:规划 → 跟随 → 检查目标 → 恢复(失败时)
-->
<root main_tree_to_execute="MainTree">
  <BehaviorTree ID="MainTree">
    <RecoveryNode number_of_retries="6" name="NavigateRecovery">
      <!-- 尝试规划并跟随路径 -->
      <PipelineSequence name="NavigateWithReplanning">
        <!-- 1. 计算路径 -->
        <ComputePathToPose goal="{goal}" path="{path}" planner_id="GridBased"/>
        <!-- 2. 跟随路径(带速度控制) -->
        <FollowPath path="{path}" controller_id="FollowPath"/>
      </PipelineSequence>

      <!-- 恢复策略(PipelineSequence 失败时触发) -->
      <ReactiveFallback name="RecoveryFallback">
        <!-- 策略 1:清除局部代价地图(处理动态障碍物) -->
        <ClearEntireCostmap name="ClearLocalCostmap"
          service_name="local_costmap/clear_entirely_local_costmap"/>
        <!-- 策略 2:旋转扫描(处理盲区障碍物) -->
        <Spin spin_dist="1.57"/>
        <!-- 策略 3:后退(处理卡死情况) -->
        <BackUp backup_dist="0.3" backup_speed="0.15"/>
      </ReactiveFallback>
    </RecoveryNode>
  </BehaviorTree>
</root>

配置都齐了,但还散着。这一章用一个 launch 把它们「装配成厂」:地图、定位、规划、控制一次性拉起,点目标就能动。

先把整条链路串一遍,卡住时你就知道去哪查:

  1. 你在 RViz 点目标 → bt_navigator 收到 NavigateToPose 目标。
  2. 行为树先调 planner_server 算全局路径(基于全局代价地图 + 地图)。
  3. 再调 controller_server,它每帧看局部代价地图,算速度指令发给底盘(/cmd_vel)。
  4. 底盘动了 → 里程计 + 激光更新 → 代价地图刷新 → 控制器下一帧再算,循环。
  5. amcl 持续把「激光匹配地图」的结果写成 map→odom 变换,让全局路径和真实位置对齐。
  6. 一旦 controller 报告「卡住 / 进度不足」,行为树切到 recoveries_server 脱困,重试。

所以「机器人不动」先问:目标发出去了吗、路径算出来了吗、速度发出来了吗、代价地图有障碍吗、坐标变换断了吗——顺着这条链五分钟定位。

一个新手必踩的坑:use_sim_time。仿真时所有节点要用仿真时钟(Gazebo 发布的 /clock),所以 launch 里 use_sim_time 多为 true;真机没有 /clock,必须全设 false,且所有节点要一致——只要有一个节点还用 sim time,它和其他节点的时间对不上,TF 和定位立刻崩。移植真机第一件事就是把所有 use_sim_time 关掉。

五、导航 Launch 文件

导航模式的总装配车间。

本文的行为树够日常用了,但真机常要加自己的脱困逻辑,比如「检测到前方是人就停」。加法是三步:

  1. 写一个继承行为树节点的 C++/Python 插件,实现 tick() 逻辑(返回成功 / 失败 / 运行中三态之一)。
  2. 把它编进一个包,在 4.5 的 plugin_lib_names 清单里登记名字。
  3. 在 4.6 的 XML 里,往 ReactiveFallback 或主流程塞一个节点标签。

关键点:自定义节点必须正确返回三态,行为树靠它决定下一步;返回不对,整棵树会卡死或疯狂重试。

把 4.6 的 XML 节点和密码本对一下:ComputePathToPose = 调规划器算路径;FollowPath = 调控制器跟线;ClearEntireCostmap = 清局部地图(动态障碍卡住时很有用);Spin / BackUp = 两种脱困。这些名字必须和 4.5 的 plugin_lib_names 清单一字不差,否则行为树启动找不到节点直接报错。改 XML 后若启动报「missing node」,第一反应就是去核对这俩地方的名字。

行为树节点的三态是它和状态机最大的不同:RUNNING 表示「还在干」,让父节点保持等待;SUCCESS / FAILURE 才推进分支。正是这个 RUNNING,让「跟随路径」能一边走一边被评价,而不是「要么完要么败」。写自定义节点时,长时间任务要持续返回 RUNNING,干完了再返回 SUCCESS,否则行为树会以为你瞬间完了或瞬间挂了,流程就乱了。

为什么 Nav2 选行为树而不是老式状态机(FSM)?FSM 把「状态」和「转移」写死在代码里,加一个脱困逻辑要改一堆 case;行为树把逻辑变成可组合节点树,加招只需往 XML 塞一个节点,不改框架。这对导航特别重要——真实环境千奇百怪,「卡住怎么办」永远列不全,行为树让你用配置而非改码去扩展。理解这点,你就明白为什么本文 4.6 让你改 XML 而不是改 C++。

第一次建议只加一个「打印日志」的节点练手,确认能挂上再写真逻辑。
地图服务器、AMCL 定位、各个 Nav2 节点都在这儿组装成一个可启动的系统。跑起来后,你在 RViz 里点个目标点,机器人就真的动起来了。

my_robot_nav2/launch/navigation.launch.py:

#!/usr/bin/env python3
"""Nav2 自主导航 Launch 文件"""

import os
from launch import LaunchDescription
from launch.actions import (
    DeclareLaunchArgument, GroupAction, SetEnvironmentVariable
)
from launch.conditions import IfCondition
from launch.substitutions import LaunchConfiguration, TextSubstitution
from launch_ros.actions import Node, LoadComposableNodes
from launch_ros.descriptions import ComposableNode
from ament_index_python.packages import get_package_share_directory


def generate_launch_description():
    pkg_share = get_package_share_directory('my_robot_nav2')
    pkg_desc = get_package_share_directory('my_robot_description')
    pkg_gazebo = get_package_share_directory('my_robot_gazebo')

    use_sim = LaunchConfiguration('use_sim')
    map_yaml = LaunchConfiguration('map')
    params_file = LaunchConfiguration('params_file')
    bt_xml = LaunchConfiguration('bt_xml')
    autostart = LaunchConfiguration('autostart')

    # === 声明启动参数 ===
    declare_use_sim = DeclareLaunchArgument(
        'use_sim', default_value='true',
        description='是否使用仿真时间')

    declare_map_yaml = DeclareLaunchArgument(
        'map', default_value=os.path.join(pkg_share, 'maps', 'my_map.yaml'),
        description='地图文件路径')

    declare_params_file = DeclareLaunchArgument(
        'params_file',
        default_value=os.path.join(pkg_share, 'config', 'nav2_params.yaml'),
        description='Nav2 参数文件')

    declare_bt_xml = DeclareLaunchArgument(
        'bt_xml',
        default_value=os.path.join(pkg_share, 'config',
                                    'navigation_behavior_tree.xml'),
        description='行为树 XML 文件')

    declare_autostart = DeclareLaunchArgument(
        'autostart', default_value='true',
        description='自动启动导航节点生命周期')

    # === 设置 use_sim_time ===
    set_sim_time = SetEnvironmentVariable(
        name='ROS_DOMAIN_ID', value='0')

    # === 地图服务器 ===
    map_server = Node(
        package='nav2_map_server',
        executable='map_server',
        name='map_server',
        output='screen',
        parameters=[{
            'yaml_filename': map_yaml,
            'use_sim_time': use_sim,
        }],
    )

    # === AMCL 定位 ===
    amcl = Node(
        package='nav2_amcl',
        executable='amcl',
        name='amcl',
        output='screen',
        parameters=[{
            'use_sim_time': use_sim,
            'min_particles': 200,
            'max_particles': 2000,
            'update_min_d': 0.2,
            'update_min_a': 0.5,
            'alpha1': 0.2,
            'alpha2': 0.2,
            'alpha3': 0.2,
            'alpha4': 0.2,
            'alpha5': 0.1,
            'kld_err': 0.05,
            'kld_z': 0.99,
            'odom_model_type': 'diff',
            'odom_frame_id': 'odom',
            'base_frame_id': 'base_footprint',
            'global_frame_id': 'map',
            'scan_topic': '/scan',
            'tf_broadcast': True,
        }],
    )

    # === 生命周期管理器 ===
    lifecycle_manager = Node(
        package='nav2_lifecycle_manager',
        executable='lifecycle_manager',
        name='lifecycle_manager_navigation',
        output='screen',
        parameters=[{
            'use_sim_time': use_sim,
            'autostart': autostart,
            'node_names': [
                'map_server', 'amcl',
                'controller_server', 'planner_server',
                'recoveries_server', 'bt_navigator',
            ],
        }],
    )

    # === Nav2 核心节点(composeable) ===
    nav2_composable = LoadComposableNodes(
        target_container='nav2_container',
        composable_node_descriptions=[
            ComposableNode(
                package='nav2_controller',
                plugin='nav2_controller::ControllerServer',
                name='controller_server',
                parameters=[params_file, {'use_sim_time': use_sim}],
            ),
            ComposableNode(
                package='nav2_planner',
                plugin='nav2_planner::PlannerServer',
                name='planner_server',
                parameters=[params_file, {'use_sim_time': use_sim}],
            ),
            ComposableNode(
                package='nav2_recoveries',
                plugin='nav2_recoveries::RecoveryServer',
                name='recoveries_server',
                parameters=[params_file, {'use_sim_time': use_sim}],
            ),
            ComposableNode(
                package='nav2_bt_navigator',
                plugin='nav2_bt_navigator::BtNavigator',
                name='bt_navigator',
                parameters=[params_file, {'use_sim_time': use_sim}],
            ),
            ComposableNode(
                package='nav2_waypoint_follower',
                plugin='nav2_waypoint_follower::WaypointFollower',
                name='waypoint_follower',
                parameters=[params_file, {'use_sim_time': use_sim}],
            ),
        ],
    )

    # === Composable 节点容器 ===
    container = Node(
        package='rclcpp_components',
        executable='component_container_mt',
        name='nav2_container',
        output='screen',
        parameters=[{'use_sim_time': use_sim}],
    )

    return LaunchDescription([
        # 参数
        declare_use_sim,
        declare_map_yaml,
        declare_params_file,
        declare_bt_xml,
        declare_autostart,
        set_sim_time,

        # 节点
        container,
        nav2_composable,
        map_server,
        amcl,
        lifecycle_manager,
    ])

跑 launch 前,先认两个关键角色。map_server 把建好的地图读进系统;amcl 是定位器——它用一堆「粒子」猜机器人可能在哪(粒子越多越准也越吃 CPU),靠激光和里程计不断修正,输出 map→odom 的变换。没有 AMCL,机器人就不知道「我在地图的哪个点」,点了目标也白点。

下面这个 launch 用生命周期管理器

为什么要有 lifecycle(生命周期)这一层?Nav2 的节点启动有顺序依赖:地图服务器得先加载完地图,amcl 才能开始定位;定位没好,规划器算的路径是错的。lifecycle 用「未配置→非活跃→活跃」三态,让 lifecycle_manager 按 node_names 顺序逐个激活,避免「半吊子启动」。真机调试时如果某个节点卡在「非活跃」,就是它前一级没就绪,看它日志比瞎调参数有用。

autostart 这个开关很实用:设 true,lifecycle_manager 拉起后自动把所有节点从「未配置」激活到「活跃」,你不用手动逐个 configure / activate;设 false,则停在计算资源、等你远程激活,适合「先起系统、人到场再开导航」的真机流程。调试时建议 false,能单独激活某个节点看它日志,定位更快。

AMCL 最该懂的两个数:min_particles / max_particles(粒子数上下限)和 alpha*(里程计噪声模型)。粒子少 → 定位快但不准(容易跟丢);粒子多 → 准但吃 CPU。室内小场 200/2000 够用,大 warehouse 加到 500/5000。alpha* 越大表示「越不信任里程计、越信激光」,轮子打滑的机器人在瓷砖上就把这几个调大。定位抖 → 加粒子;定位慢慢飘走 → 调大 alpha*。

为什么 RViz 里「设初始位姿」那么关键?AMCL 启动时粒子默认堆在 (0,0) 或上次位姿附近,你设的 2D Pose Estimate 等于告诉它「我大概在这」,粒子瞬间重撒到那一片。初值差太远(差了半张地图),粒子要花很久才收敛,期间定位是飘的,导航自然歪。所以「先设准初值,再点目标」是铁律;自动化场景由上游(如二维码定位)自动完成,原理一样。

把 map_server、amcl 和 Nav2 各节点串起来一次拉起。注意 Nav2 核心节点走的是 Composable Nodes(component_container_mt)——把多个节点塞进一个进程,省掉进程间通信开销,真机上帧率更稳。你读代码时看到 LoadComposableNodes 就知道这是「合体节点」。

5.1 一键启动完整系统

launch/nav_bringup.launch.py:

这是「总开关」launch,用 mode 一个参数切换建图 / 导航两种模式,再按需 include 对应子 launch。真机部署时你大概率只跑这个文件,传 mode:=nav 就进导航。它用 LaunchConfigurationEquals 按模式条件加载,同一份文件兼顾两种场景,不用维护两份。

#!/usr/bin/env python3
"""一键启动:仿真 + SLAM/导航"""

import os
from launch import LaunchDescription
from launch.actions import (
    DeclareLaunchArgument, IncludeLaunchDescription, LogInfo
)
from launch.conditions import IfCondition, LaunchConfigurationEquals
from launch.substitutions import LaunchConfiguration
from launch.launch_description_sources import PythonLaunchDescriptionSource
from ament_index_python.packages import get_package_share_directory


def generate_launch_description():
    pkg_nav2 = get_package_share_directory('my_robot_nav2')
    pkg_gazebo = get_package_share_directory('my_robot_gazebo')

    mode = LaunchConfiguration('mode')
    use_rviz = LaunchConfiguration('use_rviz')
    use_sim = LaunchConfiguration('use_sim')

    declare_mode = DeclareLaunchArgument(
        'mode', default_value='slam',
        choices=['slam', 'nav'],
        description='slam=建图模式, nav=导航模式')

    declare_use_rviz = DeclareLaunchArgument(
        'use_rviz', default_value='true')

    declare_use_sim = DeclareLaunchArgument(
        'use_sim', default_value='true')

    # Gazebo 仿真环境
    sim_launch = IncludeLaunchDescription(
        PythonLaunchDescriptionSource(
            os.path.join(pkg_gazebo, 'launch', 'empty_world.launch.py')),
        condition=IfCondition(use_sim),
    )

    # SLAM 模式
    slam_launch = IncludeLaunchDescription(
        PythonLaunchDescriptionSource(
            os.path.join(pkg_nav2, 'launch', 'slam.launch.py')),
        condition=LaunchConfigurationEquals('mode', 'slam'),
    )

    # 导航模式
    nav_launch = IncludeLaunchDescription(
        PythonLaunchDescriptionSource(
            os.path.join(pkg_nav2, 'launch', 'navigation.launch.py')),
        condition=LaunchConfigurationEquals('mode', 'nav'),
    )

    return LaunchDescription([
        declare_mode, declare_use_rviz, declare_use_sim,
        LogInfo(msg='=== 巡检机器人导航系统 ==='),
        LogInfo(msg='模式: %s' % mode),
        sim_launch,
        slam_launch,
        nav_launch,
    ])

系统跑起来后怎么用?这一章给一套可照抄的操作流程。先说 RViz 里你该盯哪两块:

  • 建图时盯 Map 图层——地图边缘清晰、没有重影就对了;再开 LaserScan 看激光是不是贴着真实墙。
  • 导航时盯 Path(全局路径,蓝线)、Costmap(障碍红 / 膨胀黄)、TF(坐标系树不断)。这三块正常,机器人基本就能动。

一个省时间的小技巧:RViz 配置(图层、视角、订阅的话题)可以存成 .rviz 文件,下次直接 rviz2 -d 你的配置.rviz 一键还原,不用每次手动点。本文 launch 里已经传了 slam.rviz,导航版你也可以存一份 nav.rviz。团队协同时把这份文件提交进仓库,所有人视角一致,排错时「你看到的和我看到的一样」能省一堆沟通。

第一次点目标容易犯的错:没先设初始位姿就点。RViz 里一定要先点 2D Pose Estimate,在地图上戳一下机器人真实位置和朝向,让 AMCL 的种子粒子落在正确地方;然后再点 2D Goal Pose。如果设完位姿机器人「啪」地跳一下,说明初始位姿和 AMCL 估的不符,重设一次。目标点也别设在墙里或离墙太近,否则规划器算不出可行路径,行为树会直接进脱困。

第一次成功的长什么样,心里要有数:点目标后,RViz 里蓝线(全局路径)立刻出现并从机器人连到目标;机器人平稳起步、沿蓝线走、遇障碍自然绕;到点后停下不抖;状态从「ACTIVE」变「SUCCEEDED」。只要这四步顺,后面调参只是微调。如果路径出现了但机器人迟迟不动,回到「速度发出来了吗」那一问,八成是 controller 参数卡死。

下面每条命令都标了「终端几、干什么」,照顺序敲。

六、操作指南

6.1 建图流程

# 1. 启动 SLAM 模式
ros2 launch my_robot_nav2 nav_bringup.launch.py mode:=slam

# 2. 在 RViz 中设置初始位姿
#    - 点击 "2D Pose Estimate"
#    - 在地图上点击机器人的大致位置和朝向

# 3. 遥控机器人扫描环境
ros2 run teleop_twist_keyboard teleop_twist_keyboard

# 4. 保存地图
ros2 service call /slam_toolbox/save_map slam_toolbox/srv/SaveMap \
  "{name: {data: 'warehouse_map'}}"

6.2 导航流程

# 1. 启动导航模式
ros2 launch my_robot_nav2 nav_bringup.launch.py mode:=nav \
  map:=$(ros2 pkg prefix my_robot_nav2)/share/my_robot_nav2/maps/my_map.yaml

# 2. 在 RViz 中设置初始位姿(与地图对齐)

# 3. 发送导航目标
#    - RViz: 点击 "2D Goal Pose" → 在地图上点击目标位置
#    - 或命令行:

ros2 action send_goal /navigate_to_pose nav2_msgs/action/NavigateToPose \
  "{pose: {header: {frame_id: 'map'},
    pose: {position: {x: 3.0, y: 2.0, z: 0.0},
           orientation: {w: 1.0}}}}"

多点巡航适合「巡检 / 送货」这类按固定路线跑的场景,本质是把多个 NavigateToPose 串起来顺序执行。两个注意点:航点之间要有合理间隔,别两个点在墙两侧导致规划失败;最后一个点建议回到起点附近,方便循环。断在某个点不走了,先单独 ros2 action send_goal /navigate_to_pose 测那一个点,大概率是该点周围代价地图有问题。

再多一句:航点多了建议写成 yaml 文件用 nav2_msgs 的 WaypointFollower 加载,而不是手敲一长串 goal。文件里每个点带 frame_id(必须是 map)和位姿,提交进仓库就能复现同一条巡检路线。调试时先拿两个点验证链路通,再批量加,别一上来塞二十个点然后不知道卡哪个。

6.3 多点巡航

# 通过 Waypoint Follower 发送多点巡航任务
ros2 action send_goal /follow_waypoints nav2_msgs/action/FollowWaypoints \
  "{waypoints: [
    {header: {frame_id: 'map'}, pose: {position: {x: 2.0, y: 0.0}, orientation: {w: 1.0}}},
    {header: {frame_id: 'map'}, pose: {position: {x: 2.0, y: 3.0}, orientation: {w: 1.0}}},
    {header: {frame_id: 'map'}, pose: {position: {x: 0.0, y: 3.0}, orientation: {w: 1.0}}},
    {header: {frame_id: 'map'}, pose: {position: {x: 0.0, y: 0.0}, orientation: {w: 1.0}}}
  ]}"

前面给的是「能跑」的默认参数。真机一上,八成要调——机器人要么在窄道里来回抖(振荡),要么明明能过却绕大远路,要么动不动就触发脱困。

调参的核心心法是一次只动一个数,看现象再决定下一个,别一口气改五个。下面这张表把「最常拧的数 ↔ 对应症状」列出来,卡住了对号入座;7.1 再给三对典型问题的现成改法。

七、代价地图调参指南

参数 默认值 作用 调参建议
resolution 0.05 地图精度 室内 0.03-0.05,室外 0.1-0.2
inflation_radius 0.55 膨胀半径 机器人半径 + 0.1-0.15m 安全余量
cost_scaling_factor 3.0 膨胀衰减 越小膨胀越"硬"(不容易穿过去)
obstacle_range 5.0 障碍检测距离 室内 3-5m,开阔区域可增大
raytrace_range 8.0 清除障碍距离 应大于 obstacle_range
max_vel_x 0.5 最大前进速度 室内 0.3-0.5,走廊 0.8-1.0
sim_time 1.7 轨迹预测时间 大空间增大(2.0+),小空间减小(1.0)
xy_goal_tolerance 0.25 到达容差 精确定位 0.1,粗略 0.5

三对高频问题的「现象→根因→改法」。记住根因比背改法重要:振荡多半是膨胀太狠或速度太快导致控制器在窄道里左右横跳;绕远路是规划器太保守或代价衰减太软;频繁恢复是目标容差太小、机器人觉得自己「没到」。

7.1 常见问题与调参

光给表不够直观,演示一次完整调参。假设机器人进窄走廊后左右横跳、走不动:

  • 现象:RViz 里局部路径在走廊中线附近剧烈左右摆,cmd_vel 角速度正负跳。
  • 第一反应:膨胀太狠,机器人觉得两边都是墙、没路可走,于是振荡。把 local 的 inflation_radius 从 0.55 降到 0.45,cost_scaling_factor 从 3.0 提到 5.0(膨胀更「软」、更愿意靠近墙)。
  • 没好透:速度上限太高,DWB 在窄道里还敢转大弯。把 max_vel_x 从 0.5 降到 0.3,max_vel_theta 从 1.5 降到 1.0。
  • 仍偶尔卡:目标容差太小,机器人觉得自己「没精确到点」反复微调。xy_goal_tolerance 放到 0.35。
  • 验证:同一段走廊重跑,路径变直、不再摆,到达目标不再原地转。

整个过程「一次动一个数、跑一遍看现象」,十分钟定位,比同时改五个数然后猜是哪个生效靠谱得多。

一个比参数更阴的坑:话题名 / QoS 不匹配。Nav2 内部话题默认「可靠 + 稳定」QoS,如果你的激光驱动用了「最佳努力」且历史不同,代价地图可能收不到数据,表现就是「地图全绿、机器人不动」。同理 scan / odom / cmd_vel 名字必须全局一致——launch 里用 remappings 对齐最稳。所以「改了不生效 / 收不到」先 ros2 topic list + ros2 topic echo 确认数据真在流,再谈调参。

问题:机器人在狭窄通道中不停振荡

# 增大膨胀半径 + 降低最大速度
inflation_radius: 0.65    # 增大
max_vel_x: 0.3            # 降低
max_vel_theta: 1.0        # 降低旋转速度

「频繁触发恢复」和前面「振荡」容易混,但根因不同:振荡是控制器在窄道左右横跳(调速度 / 膨胀);频繁恢复是机器人觉得自己「没在前进」(进度检查器超时),于是反复 Spin / BackUp。先判断属于哪种——看 RViz 里机器人是在抖还是真在原地打转。后者把 movement_time_allowance 调大(允许更久不出 progress),或把目标容差放宽;前者去调控制器。误诊就会南辕北辙。

问题:机器人频繁触发恢复行为

# 放宽目标容差 + 降低卡死检测灵敏度
xy_goal_tolerance: 0.35
yaw_goal_tolerance: 0.35
# progress_checker 中增加等待时间
movement_time_allowance: 15.0

「绕远路」和「振荡」是两种不同病:绕远路是规划器太保守(把大片区域当成障碍)或代价衰减太软(机器人不敢靠近任何东西);振荡是控制器在窄道里左右横跳。验收标准记一条:能走直线就不绕,能一次到就不抖。调完跑同一段已知路线三遍,三次表现一致才算稳,别被「刚好一次成了」骗了。

问题:路径规划绕远路

# 切换为 A*(比 Dijkstra 快但路径可能不够平滑)
GridBased:
  use_astar: true
# 或降低代价衰减(让机器人更愿意靠近障碍物)
cost_scaling_factor: 5.0

仿真里跑通了,最后一步是搬到真机。真机和仿真最大的区别是:传感器是真的、物理是真的、bug 也是真的。

真机比仿真多出三座大山,提前知道能省一天:

  • 坐标系对不齐:base_footprint / base_link / odom / map 四层变换少一层,机器人就「地图在地图上、机器人在机器上」。用 ros2 run tf2_tools view_frames 看变换树,断在哪一层一眼看出。
  • 传感器时间不同步:激光和里程计时间对不上,AMCL 会把你定位到墙里。真机务必统一时间源,且各驱动 use_sim_time 一致(通常都设 false)。
  • 里程计太漂:轮子里程在打滑地面(瓷砖、地毯)误差极大,AMCL 粒子会散开。解决是加 IMU + robot_localization 做融合,或上视觉 / 激光里程计。

移植顺序建议:先 use_sim:=false 把仿真底盘换成真驱动

真机传感器三类最常见:激光雷达(主力,给 2D 障碍)、深度相机(能给 3D 体素,但价格高、怕强光)、IMU(补姿态,防打滑漂移)。室内平整地面用单激光 + IMU 足够;室外或不平整地面必须加 IMU 甚至视觉里程计。换传感器只动两处:URDF 里的传感器插件(话题名 / 参数)和代价地图的 observation_sources(告诉它数据从哪来)。
,确认 /cmd_vel 能让人动;再调 AMCL 让定位稳;最后才动代价地图参数。别一上来全改。

八、实车部署要点

8.1 硬件适配清单

真机部件 需要修改的配置
LiDAR 型号不同 lidar.xacro 中 sensor 参数 + scan_topic
里程计源不同 替换 Gazebo 插件为实际驱动(如 ros2_control)
底盘不同 diff_drive.xacro 中 wheel_separation / wheel_radius
IMU robot_localization 包融合里程计 + IMU

真机第一个坑几乎都是里程计漂,而里程计准不准首先看你填的轮距对不对。wheel_separation(左右轮中心距)和 wheel_radius 必须量实物、别抄示例:用尺量两轮触地点间距、量轮直径,填进 2.1 / 2.2。

填错 1cm,走 10m 就能偏半米,AMCL 再强也救不回。验证方法:让机器人直行 2m,用尺量实际走了多远、偏了多少;差太多就回炉改轮距。这一步不做,后面所有「定位飘」的锅都会错扣在 AMCL 头上。

8.2 仿真到真机移植步骤

# 1. 替换 Gazebo 插件为 ros2_control
#    (参考上一篇 "ROS 2 机器人仿真实战")

# 2. 启动真实传感器驱动
#    如 RPLidar A3:
ros2 launch sllidar_ros2 sllidar_a3_launch.py

# 3. 使用 robot_localization 融合里程计+IMU
ros2 launch robot_localization ekf.launch.py

# 4. 导航时关闭仿真时间
ros2 launch my_robot_nav2 navigation.launch.py use_sim:=false

真机算力紧时,三处最吃资源:

  • 局部代价地图更新频率:降到 3Hz 通常无感,但 CPU 省一截。改 local_costmap.yaml 的 update_frequency。
  • DWB 的轨迹采样数:vx_samples / vtheta_samples 越大越聪明也越慢,室内降到 15 / 30 够用。
  • Composable Nodes:本文已用 component_container_mt 把节点合进一个进程,减少通信开销;别拆回多个独立 Node,否则单板电脑容易掉帧。

调性能的原则是「先降频率、再降采样、最后动算法」,别一上来换控制器。NAV2_USE_SIMD 这类 SIMD 加速开关,支持的平台打开能再快些。

8.3 性能优化

# GPU 加速代价地图计算(如果支持)
export NAV2_USE_SIMD=true

# 降低局部代价地图更新频率(节省 CPU)
# local_costmap.yaml: update_frequency: 3.0

# 使用 Composable Nodes 减少进程间通信开销
# (已在本文 launch 文件中使用 component_container_mt)

真机上线前,三条保命习惯:

  1. 先低速、再加急停:max_vel_x 真机首跑给 0.2,人在旁随时按急停(键盘或物理按钮),确认不撞再提速。
  2. 先在空场跑:别一上来进满屋家具,空地先验证「能走直线、能到点、能避静态障碍」三件事。
  3. 膨胀宁大勿小:第一次真机把 inflation_radius 调大些,宁可绕远也别贴墙刮花,稳了再收紧。

导航出事几乎都发生在「提速太快 + 环境太杂 + 没人盯」三者叠加,避开它就避开九成事故。

急停怎么接才靠谱:真机务必在硬件层留一个物理急停(断开电机使能),别只靠软件停 cmd_vel——软件卡死时它不保证停。软件层再加一道:导航 launch 里监听一个急停话题,收到就停发速度。两层都有的机器人才算「能让人放心站旁边」。第一次真机跑,人在 1 米内、手搭急停,是铁律。

三个真实翻车现场,对应前面知识点:

  • 案例 A:机器人到点后疯狂原地转。根因 xy_goal_tolerance 太小 + 地面打滑导致永远「差一点没到」。改大到 0.35 解决。
  • 案例 B:RViz 里路径是直的,机器人却贴墙走。根因 local inflation_radius 太大,控制器不敢走中线。调小解决。
  • 案例 C:建图清楚,导航却总撞同一面墙。根因建图后改了雷达高度没重建设图,激光和地图差半米。重建设图解决。

共同点:都是「一个参数 / 一份地图」的锅,没有一个是算法坏了。按本文链路和速查卡对号入座,半小时都能定位。

排错锦囊:导航不动先查这五处

导航「点了目标不动 / 乱走 / 飘」,按这个顺序查,九成能定位:

  1. 目标真发出去了吗? 看 RViz 有没有蓝色箭头,或 ros2 topic echo /goal_pose。没发出去,先设好初始位姿(2D Pose Estimate)——AMCL 不知道你在哪,目标会被当无效。
  2. 全局路径算出来了吗? ros2 topic echo /plan 看有没有路径点。空的,多半是代价地图没障碍信息(激光没进来)或起点被当成障碍。ros2 topic echo /scan 确认激光有数。
  3. 速度指令发出来了吗? ros2 topic echo /cmd_vel 看线 / 角速度。有路径没速度,问题在 controller(DWB 参数卡死,如 max_vel_x 太小或 sim_time 太长算不出轨迹)。
  4. 代价地图有障碍吗? RViz 勾 Costmap 图层,墙是不是标红了。全绿说明激光没进代价地图(话题名 / observation_sources 不对)。
  5. 坐标变换断了吗? ros2 run tf2_tools view_frames 看 map→odom→base_footprint 是否连续。断一层,全局路径和真实位置就对不上,表现为「地图对但走歪」。

这五步顺着「目标→路径→速度→地图→坐标」往下捅,和第五章那条链路一一对应。

走到这,你已经能让一台差速机器人在仿真和真机里自主导航。下一步建议:把行为树换成官方 Nav2 默认更复杂版本,体验 docking / 巡线等高级行为;或接深度相机做动态行人避障;再进一步就是多机协同(系列后续会讲)。

把本文的「五步主线」刻进脑子——地图 → 定位 → 规划 → 控制 → 脱困——后面所有 Nav2 玩法都是在这上面做加法,而不是推倒重来。

学完自测:能不翻文档回答下面问题,就算真懂了——

  1. 代价地图三层分别来自哪、干嘛用?
  2. planner 和 controller 各管什么,差在哪?
  3. 行为树里 RecoveryNode / ReactiveFallback 分别在什么时机触发?
  4. 机器人不动,五步排查顺序是什么?
  5. 轮距填错会导致什么现象?

答不上哪个,回对应小节再看一遍,比直接上手调参稳。

上线前做一遍回归,确认没被你某次改动搞坏:

  1. 重新建图,看地图清晰、无重影。
  2. 设初值 → 点目标,机器人能到点且不抖。
  3. 故意挡一下激光,看它绕行而非硬撞。
  4. 多点巡航跑完不卡。
  5. 关掉再启动,lifecycle 能自动拉起全部节点。

五条全绿,这版配置就能提交。任何一条翻车,回到对应小节,别带着「差不多」的配置上真机。

速查卡:把全文收成一张表

下面这张表不是结语,是速查——卡在哪个环节,直接翻对应参数去拧,不用重读全文。

补一张「一页纸参数速查」,卡住时直接对:

  • 定位飘 → AMCL 粒子数 / alpha*,或回 2.2 核轮距。
  • 撞墙 → inflation_radius 调大 / cost_scaling_factor 调小。
  • 窄道振荡 → inflation_radius 调小、max_vel_x 降、xy_goal_tolerance 放宽。
  • 绕远路 → use_astar: true、cost_scaling_factor 调大。
  • 不动 / 卡 → 五步排查(目标→路径→速度→地图→坐标)。
  • 掉帧 → 降 update_frequency / *_samples,开 Composable。

把这张和第五章那条链路一起看,你已经是能独立救场的人了。

顺手存一组最常用命令,调试时直接抄:

  • 看 TF:ros2 run tf2_tools view_frames
  • 看代价地图:ros2 topic echo /local_costmap/costmap
  • 强制重定位:ros2 topic pub /initialpose geometry_msgs/PoseWithCovarianceStamped ...
  • 单独测规划:ros2 action send_goal /compute_path_to_pose ...
  • 看节点是否都起:ros2 node list

把这几条设成 shell 别名,排错速度翻倍。

环节 工具/技术 关键配置
建图 SLAM Toolbox (异步模式) mode: mapping, loop closing
定位 AMCL (蒙特卡洛) min/max_particles, KLD采样
规划 NavfnPlanner (A*/Dijkstra) use_astar, tolerance
控制 DWB LocalPlanner max_vel_x, sim_time, critics
避障 代价地图 (膨胀层) inflation_radius, cost_scaling_factor
行为树 BehaviorTree.CPP 自定义恢复策略
多点巡航 WaypointFollower FollowWaypoints action

下一篇我们将进入 ROS 2 机械臂抓取与视觉感知实战,结合深度相机和 MoveIt2 实现完整的 Pick & Place 工作流。