4. 启动文件解析:nav2_bringup 启动流程、launch 文件参数传递、多机器人配置

好,我们进入第四章。这一章我打算把 nav2_bringup 的启动流程彻底拆开给你看。

很多朋友拿到 Nav2 第一件事就是 ros2 launch nav2_bringup navigation_launch.py,跑起来了就以为万事大吉。但一旦要改参数、加机器人、或者换传感器,就懵了。说白了,你不懂 launch 文件怎么组织,后面调参就是瞎蒙。

我个人习惯,拿到一个新系统,第一件事就是读它的 launch 文件。这比读管用多了。

4.1 nav2_bringup 的整体启动流程

先看一张图,把整个启动流程串起来。

nav2_bringup 启动流程 入口 launch 文件 加载 YAML 参数文件 启动核心组件(AMCL/Planner/Controller) 生命周期节点配置与激活 TF 树构建 + 传感器数据桥接 导航系统就绪 关键点 • 所有节点都是生命周期节点 • 参数在启动前全部加载 • TF 树必须提前就绪

整个流程其实就六步。但每一步都有坑,我一个个说。

4.2 launch 文件的结构与参数传递

Nav2 的 launch 文件用了 Python launch 格式。为什么不用 XML?因为 Python 可以写逻辑,可以条件判断,可以循环。你想想看,多机器人场景下,你难道要手写 10 个 XML 文件?

来看核心的 navigation_launch.py,我简化一下关键部分:

from launch import LaunchDescription
from launch.actions import DeclareLaunchArgument, IncludeLaunchDescription
from launch.substitutions import LaunchConfiguration, PathJoinSubstitution
from launch_ros.actions import Node

def generate_launch_description():
    # 1. 声明可配置参数
    declared_arguments = []
    declared_arguments.append(
        DeclareLaunchArgument('use_sim_time', default_value='false',
                              description='Use simulation time')
    )
    declared_arguments.append(
        DeclareLaunchArgument('params_file', default_value='nav2_params.yaml',
                              description='Full path to params file')
    )
    
    # 2. 加载参数文件
    params_file = LaunchConfiguration('params_file')
    
    # 3. 启动核心节点
    lifecycle_nodes = ['controller_server', 'planner_server',
                       'recoveries_server', 'bt_navigator',
                       'waypoint_follower', 'velocity_smoother']
    
    # 4. 启动生命周期管理器
    start_lifecycle_manager = Node(
        package='nav2_lifecycle_manager',
        executable='lifecycle_manager',
        name='lifecycle_manager_navigation',
        parameters=[{'use_sim_time': use_sim_time,
                     'autostart': True,
                     'node_names': lifecycle_nodes}]
    )
    
    return LaunchDescription(declared_arguments + [start_lifecycle_manager])
我的习惯: 我从来不在 launch 文件里硬编码参数路径。每次都用 DeclareLaunchArgument 暴露出来,这样换场景只需要改命令行参数,不用改代码。

参数传递的路径是这样的:

层级 传递方式 典型示例
命令行 → launch LaunchConfiguration params_file:=~/my_params.yaml
launch → 节点 Node(parameters=[...]) 直接传入字典或 YAML 路径
launch → 子 launch IncludeLaunchDescription(launch_arguments={...}) 传递 namespace 或 robot_name
YAML 文件 → 节点 ROS2 参数机制 节点启动时自动加载

这里有个容易翻车的地方:参数覆盖顺序。ROS2 的参数优先级是:命令行 > launch 文件内传入 > YAML 文件。我曾经遇到一个情况,在 YAML 里配好了 transform_tolerance: 0.5,但 launch 文件里又传了个 0.3,结果怎么调都不对。排查了半天才发现是 launch 文件里的参数把 YAML 的覆盖了。

避坑指南: 如果你在 launch 文件里用 parameters=[{'key': 'value'}] 这种方式传参,它会完全覆盖 YAML 文件里的同名参数。不是合并,是覆盖。我建议要么全用 YAML,要么全用 launch 传参,不要混用。

4.3 多机器人配置实战

多机器人导航,说白了就是「一套代码,多个命名空间」。每个机器人要有独立的 namespace,独立的 TF 树,独立的参数。

我做过一个项目,三台机器人在同一个仓库里跑。一开始我天真地以为,把 launch 文件复制三份,改改名字就行了。结果 TF 树打架,topic 冲突,AMCL 互相抢数据。后来我总结了一套标准做法。

核心思路:用 namespace 隔离一切。

# multi_robot_launch.py
from launch import LaunchDescription
from launch.actions import GroupAction
from launch_ros.actions import PushRosNamespace, Node
from launch.substitutions import LaunchConfiguration

def generate_launch_description():
    robots = [
        {'name': 'robot1', 'namespace': 'r1', 'x': 0.0, 'y': 0.0},
        {'name': 'robot2', 'namespace': 'r2', 'x': 2.0, 'y': 1.0},
        {'name': 'robot3', 'namespace': 'r3', 'x': -1.0, 'y': -0.5},
    ]
    
    groups = []
    for robot in robots:
        # 每个机器人一个 GroupAction,带独立 namespace
        group = GroupAction([
            PushRosNamespace(robot['namespace']),
            
            # 启动该机器人的导航栈
            IncludeLaunchDescription(
                'nav2_bringup/launch/navigation_launch.py',
                launch_arguments={
                    'namespace': robot['namespace'],
                    'params_file': f'params_{robot["name"]}.yaml',
                    'use_sim_time': 'true',
                    'autostart': 'true'
                }.items()
            ),
            
            # 启动该机器人的 TF 发布
            Node(
                package='robot_state_publisher',
                executable='robot_state_publisher',
                name='robot_state_publisher',
                parameters=[{'robot_description': robot_description}]
            )
        ])
        groups.append(group)
    
    return LaunchDescription(groups)

这里有几个关键点:

  • 命名空间隔离:每个机器人的所有 topic 都会带上 namespace 前缀,比如 /r1/odom、/r2/odom,不会冲突。
  • 独立参数文件:每个机器人有自己的 YAML 参数,可以单独调优。比如 robot1 是差速底盘,robot2 是阿克曼底盘,参数完全不同。
  • 独立 TF 树:每个机器人的 TF 树在各自的 namespace 下,通过 tf2_ros::Buffer 的 setUsingDedicatedThread() 隔离。
多机器人参数对比表(我常用的配置模板):
参数项 robot1(差速) robot2(阿克曼) 说明
base_frame_id r1/base_link r2/base_link 必须带 namespace 前缀
odom_frame_id r1/odom r2/odom 同上
transform_tolerance 0.2 0.5 阿克曼底盘延迟大,容忍度要高
planner_server.robot_base_frame r1/base_link r2/base_link 规划器也要知道自己的 frame
local_costmap.global_frame r1/odom r2/odom 代价地图的全局坐标系

嗯,这里要注意一个细节:多机器人共享地图的问题。如果多个机器人在同一张地图上导航,你需要一个全局的 map_server,而且所有机器人的 global_frame 都要指向同一个 map 坐标系。但 odom 坐标系必须是独立的,否则里程计会互相干扰。

我当时的做法是:

  1. 启动一个全局的 map_server,不带 namespace,发布 /map topic。
  2. 每个机器人的 global_costmap 设置 global_frame: map,订阅全局 /map。
  3. 每个机器人的 local_costmap 设置 global_frame: r1/odom,用自己独立的里程计。
  4. TF 树里,map → r1/odom 的变换由 AMCL 发布,每个机器人独立维护。

这样既共享了全局地图,又保持了局部控制的独立性。说白了就是「全局统一,局部自治」。

调试小技巧: 多机器人启动后,用 ros2 topic list | grep -E "/(r1|r2|r3)/" 检查 topic 是否隔离。再用 ros2 run tf2_tools view_frames 看 TF 树,确保每个机器人的 frame 都在自己的 namespace 下。

最后说一个我踩过的坑:生命周期节点的同步问题。多机器人场景下,每个机器人都有自己的生命周期管理器。如果所有机器人同时启动,生命周期管理器会同时尝试激活所有节点,导致 CPU 瞬间飙高,甚至节点超时。我的解决方案是在 launch 文件里加一个 startup_delay 参数,每个机器人错开 2-3 秒启动:

from launch.actions import TimerAction

for i, robot in enumerate(robots):
    delayed_group = TimerAction(
        period=2.0 * i,  # 每个机器人延迟 2 秒
        actions=[group]
    )
    groups.append(delayed_group)

这个改动虽然简单,但效果立竿见影。从那以后,我再也没有遇到过多机器人启动时节点崩溃的问题。