写ROS2项目最烦什么?对我来说,不是接口对不上,也不是编译报错,而是每次调试都要打开一堆终端:一个跑master(虽然ROS2已经没有中心节点了,但习惯还在)、一个跑节点、一个跑rviz2,手上还得留一个终端随时敲命令查话题。后来学会了launch,这个烦恼直接消失大半。launch是ROS2里用来批量启动节点和配置运行环境的机制,相当于把“手动开十个终端再挨个敲ros2 run”这件事,收敛成一个文件、一条命令。这篇笔记就是围绕ros2 launch的实战记录,适合刚学完节点和话题、准备把手动启动换成脚本启动的朋友,也适合被launch文件里的各种写法绕晕的人。
我自己是用鱼香ROS的教程入的门,再加上啃官方文档,踩了不少坑之后才把launch的套路理顺。这篇文章不打算写成文档翻译,而是按我自己从零开始用launch的路径来写:先理解它到底解决什么问题,然后写第一个launch.py,再把参数、事件、条件这些进阶用法过一遍,最后把常见报错和排查技巧整理成清单。这些都是我在实际工程里验证过的内容,可以直接拿去用。
1. launch是什么,为什么ROS2离不开它
1.1 告别一条条手敲命令
先说说不用launch的原始状态。假设你写了一个机器人底盘驱动节点robot_base,一个激光雷达驱动节点lidar_node,还有一个导航节点navigation,手动启动大概是这样的:
# 终端1 ros2 run my_robot robot_base # 终端2 ros2 run my_robot lidar_node # 终端3 ros2 run nav2_bringup navigation这只是三个节点,如果再加上参数配置、话题重映射、命名空间、条件判断,命令会变得非常长。比如导航节点的启动命令有时能写好几行,每加一个参数都要在原命令后面拼字符串,既不直观也容易漏。
launch文件解决的就是这个问题。它把“启动哪些节点”、“每个节点用什么参数”、“节点之间的启动顺序”、“要不要开rviz2”全部写进一个脚本里。之后你只需要:
ros2 launch my_robot my_robot.launch.py一键完成。而且launch不是简单把命令排在一起,它还能管理生命周期、监听事件、传播参数,是个完整的启动编排系统,不是脚本拼接。
1.2 ROS2 launch与ROS1 launch的差别
如果你是从ROS1过来的,会很容易认为launch文件就是那个XML格式的东西。ROS2里情况变了,官方推荐的launch写法是Python脚本,文件名通常是.launch.py,虽然也支持XML和YAML格式,但社区和官方示例基本都以Python为主。
Python写launch的优势很明显:它是真正的编程语言,可以做条件判断、循环、读环境变量、处理字符串路径,这些在ROS1的XML里做起来很别扭。比如你想根据环境变量决定启动哪个机器人模型,XML里只能堆if语句,Python里一个if os.environ.get(...)就解决了。
ROS2的launch还引入了Event机制。举个例子,你希望节点A退出之后自动启动节点B,或者等某个服务可用了再继续,这些都可以用事件注册来实现。ROS1的launch基本没有这种能力,通常是靠节点自己等待或睡眠来硬撑。
1.3 launch文件放在哪里
新手最容易困惑的一点:launch文件到底应该放到包里的哪个目录?ROS2官方推荐放在包的launch目录下,然后在CMakeLists.txt或setup.py里把它标记为待安装文件。
对于ament_python类型的包,典型的目录结构是:
my_robot/ ├── my_robot/ │ ├── __init__.py │ └── robot_node.py ├── launch/ │ └── my_robot.launch.py ├── package.xml ├── setup.py └── setup.cfg然后在setup.py里,把launch目录加进去:
from setuptools import setup import os from glob import glob setup( name='my_robot', version='0.0.0', packages=['my_robot'], data_files=[ ('share/ament_index/resource_index/packages', ['resource/my_robot']), ('share/my_robot', ['package.xml']), (os.path.join('share', 'my_robot', 'launch'), glob('launch/*.launch.py')), ], ... )如果你是ament_cmake类型的包,则在CMakeLists.txt里安装launch文件:
install(DIRECTORY launch DESTINATION share/${PROJECT_NAME})这一步非常容易忘。忘了之后,直接运行ros2 launch my_robot xxx.launch.py会提示找不到文件,因为ROS2运行时是从install目录读取文件的,不是从源码目录。
2. 写一个最小可用的launch.py
2.1 第一个文件:启动两个节点
先给一个最基础的例子,启动两个节点,一个是发布者,一个是订阅者。
from launch import LaunchDescription from launch_ros.actions import Node def generate_launch_description(): return LaunchDescription([ Node( package='demo_nodes_cpp', executable='talker', name='talker' ), Node( package='demo_nodes_cpp', executable='listener', name='listener' ), ])把这个文件保存为demo.launch.py,放在上面说的launch目录里,编译安装后运行:
ros2 launch <你的包名> demo.launch.py如果包还没编译安装过,也可以直接用绝对路径指定launch文件来测试:
ros2 launch 路径/demo.launch.py这里有两个细节值得注意。第一,generate_launch_description这个名字不能改,ROS2的launch工具会调用这个函数来获取LaunchDescription对象。第二,Node里的name参数可以手动指定节点名,如果不写,就用可执行文件的名字。
每个Node类可以通过parameters参数给节点传参数,写法是:
Node( package='my_robot', executable='robot_base', name='robot_base', parameters=[{'max_speed': 1.0, 'odom_frame': 'odom'}], )这里的参数是发给节点的,最终会通过ROS2参数机制注入到节点里,不需要节点启动后再手动ros2 param set。
2.2 从命令行参数到launch参数
实际使用中,你经常希望同一个launch文件既能启动仿真环境,又能启动真实机器人。这时候就需要launch参数(也就是launch argument,不是节点参数)。
launch参数通过DeclareLaunchArgument声明,用LaunchConfiguration读取。典型写法:
from launch import LaunchDescription from launch.actions import DeclareLaunchArgument from launch.substitutions import LaunchConfiguration from launch_ros.actions import Node def generate_launch_description(): use_sim_time = LaunchConfiguration('use_sim_time', default='true') robot_name = LaunchConfiguration('robot_name', default='turtlebot') return LaunchDescription([ DeclareLaunchArgument( 'use_sim_time', default_value='true', description='Use simulation time or wall clock time' ), DeclareLaunchArgument( 'robot_name', default_value='turtlebot', description='Name of the robot' ), Node( package='my_robot', executable='robot_base', name=robot_name, parameters=[{'use_sim_time': use_sim_time}], ), ])运行时可以这样覆盖默认值:
ros2 launch my_robot robot.launch.py use_sim_time:=false robot_name:=my_robot这里要注意,launch参数的传递语法是参数名:=值,不是:=前带空格,写错会导致launch工具把use_sim_time:=false当成一个未知的action来解析。
我再补充一个实用技巧:如果想在launch内部读取环境变量,可以用EnvironmentVariable代替LaunchConfiguration,比如:
from launch.substitutions import EnvironmentVariable log_level = EnvironmentVariable('ROS_LOG_LEVEL', default_value='info')这种方式适合根据运行环境自动切换调试级别。
2.3 用GroupAction管理命名空间和前缀
当多个节点要统一加命名空间,或者统一加gazebo前缀时,用GroupAction比每个节点单独写namespace更整洁。
from launch.actions import GroupAction from launch_ros.actions import PushRosNamespace def generate_launch_description(): return LaunchDescription([ GroupAction([ PushRosNamespace('robot1'), Node(package='my_robot', executable='robot_base', name='base'), Node(package='my_robot', executable='lidar_node', name='lidar'), ]), ])启动后这两个节点的完整话题名会变成/robot1/base/...和/robot1/lidar/...,相当于把一组节点隔离到了独立命名空间下,非常适合多机器人仿真场景。
GroupAction还能配合条件判断使用,后面在讲条件时会再提到。
3. launch文件修改了需要编译吗?答案和你想的不一样
3.1 Python launch不需要编译的本质
“launch文件修改了需要编译吗”这个问题,我看到很多新手在问,我最早也纠结过。答案分两层。
如果你的launch文件是纯Python逻辑,里面没有引用自定义的消息、服务、动作接口类型,那么它本质是一个Python脚本。Python脚本不需要编译成机器码,直接从源码读取即可。ROS2运行时读取launch文件的逻辑是:launch工具根据包名找到share/目录下安装好的launch文件,然后用Python解释器执行。所以从“代码是否需要编译”这个层面讲,答案是不需要。
但是注意,如果你修改了launch文件之后没有重新安装(也就是没有让install/目录里的副本更新),那运行时读到的还是旧文件。在开发工作区里,你需要重新执行:
colcon build --packages-select my_robot这样才能把修改后的launch文件同步到install目录。如果你嫌每次build慢,也可以只单独安装launch文件,或者用colcon build --packages-select my_robot --symlink-install,用符号链接方式安装,之后修改源码和launch文件都不需要重新build,直接生效,这在开发阶段非常推荐。
3.2 ament_python包怎么处理安装
我见过一种情况:用ament_python创建的包,setup.py里根本没写launch文件的安装规则,结果launch文件虽然存在,但ros2 launch就是找不到。这时候不是编译问题,是打包配置问题。
ament_python包安装文件靠data_files。除了launch目录,通常还需要把resource目录、package.xml加进去。如果你用ros2 pkg create创建的包,会自动生成一份可用的setup.py,但launch目录往往不在其中,需要自己加。这也是为什么很多人把launch文件放在包里却无法启动。
3.3 资源路径问题才是真正的坑
比编译更隐蔽的问题是:launch文件里引用的其他资源路径不对。比如你要在launch里加载URDF文件:
from launch.substitutions import Command, FindExecutable robot_description = Command([ 'xacro ', os.path.join(pkg_share, 'urdf', 'robot.urdf.xacro'), ' is_sim:=', use_sim_time ])这里的pkg_share通常这样获取:
import os from ament_index_python.packages import get_package_share_directory pkg_share = get_package_share_directory('my_robot')这条命令会在install/my_robot/share/my_robot下找资源。如果你的URDF文件没有通过CMake或setup.py安装到那里,运行时就会报文件不存在。这和launch文件是否需要编译完全是两个问题,但报错现象常常被误认为是“没有重新编译”。
排查这类问题时有一个快速判断方法:ros2 pkg prefix my_robot可以查看包的安装前缀,然后手动检查share/my_robot目录下有没有对应文件。如果文件缺失,十有八九是安装规则没写全。
3.4 修改launch文件后如何验证
我自己修改launch之后的常规验证顺序是这样的:
- 打开launch文件,检查语法。可以先单独执行
python3 你的launch文件.py,如果语法有错,会直接报错;需要注意launch文件里用到ROS2相关的导入时,直接执行可能因为环境变量没source而报ImportError,这是正常的。 - 重新编译安装对应包:
colcon build --packages-select my_robot,或者用了--symlink-install就省略这步。 - 重新source环境:
source install/setup.bash。这个也容易漏,不source的话ROS2可能还在用旧环境。 - 执行
ros2 launch my_robot xxx.launch.py --show-args,这条命令只打印launch支持的参数列表,不会真正启动节点。如果这里能正常输出参数,说明launch文件能被正确解析。 - 最后再正式启动,观察日志输出。
4. 让launch更实用:参数、重映射、事件与条件
4.1 参数与重映射
除了节点参数,launch里还经常用到主题重映射。比如某个雷达节点默认发布/scan,但你希望它发布到/robot1/scan,或者你的导航节点订阅的是/scan_filtered,需要把雷达话题映射过去。
Node( package='urg_node', executable='urg_node', name='lidar', remappings=[ ('scan', 'robot1/scan'), ], )重映射的机制是修改节点内部的topic名称映射表,对通信层透明,比在代码里写死话题名灵活得多。多传感器融合时,用launch统一管理重映射,能避免每次改代码重新编译。
参数这块,如果你有大量参数,不建议全部写在launch文件里。官方推荐用YAML参数文件。launch里这样写:
Node( package='robot_navigation', executable='navigation_node', name='navigation', parameters=[os.path.join(pkg_share, 'config', 'nav_params.yaml')], )注意这里的路径必须是安装后的路径。YAML文件同样需要添加到CMake或setup.py的安装规则中。另外,一个节点也可以同时加载多个参数文件,后加载的同名参数会覆盖先加载的,这个顺序特性有时候可以用来做“默认参数+环境覆盖参数”的层级配置。
4.2 事件注册:启动后做什么
ROS2 launch的事件机制一开始不太好懂,但用熟之后非常有用。最常用的场景是:启动几个核心节点之后,再启动rviz2或gazebo,并确保它们在其他节点之后启动更稳妥。
from launch.actions import RegisterEventHandler, ExecuteProcess, TimerAction return LaunchDescription([ ..., TimerAction( period=3.0, actions=[ ExecuteProcess( cmd=['rviz2', '-d', rviz_config_path], output='screen' ) ] ), ])TimerAction是简单粗暴的延迟执行。更精细的做法是用RegisterEventHandler监听节点启动事件:
from launch.event_handlers import OnProcessStart RegisterEventHandler( OnProcessStart( target_action=core_node, on_start=[ ExecuteProcess(cmd=['rviz2', ...], output='screen') ] ) )它的含义是:等core_node这个进程启动成功之后,再去启动rviz2。这种写法比固定延迟更可靠,因为如果节点启动花了5秒,你只延迟3秒,rviz2可能因为话题还没数据而显示空白。
事件机制最复杂的部分在于不同类型事件和handler的配合,但大多数情况下你只需要记住这几种:OnProcessStart(进程启动)、OnProcessExit(进程退出)、TimerAction(定时触发)。能用这三个满足需求,就已经超过大多数launch脚本了。
4.3 条件判断:一个launch适配不同场景
launch里的条件判断用IfCondition和UnlessCondition。典型例子:仿真时启动robot_state_publisher并加载URDF,而不启动真实底盘驱动;在真机上则相反。
from launch.conditions import IfCondition, UnlessCondition from launch.actions import DeclareLaunchArgument from launch.substitutions import LaunchConfiguration use_sim = LaunchConfiguration('use_sim', default='true') ... Node( package='gazebo_ros', executable='spawn_entity.py', arguments=['-topic', 'robot_description', '-entity', 'my_robot'], condition=IfCondition(use_sim) ), Node( package='my_robot', executable='robot_base', condition=UnlessCondition(use_sim) )这里use_sim是字符串形式的launch参数,值为'true'或'false'。IfCondition会把字符串解析成布尔值。
我第一次写条件的时候犯了个错:以为可以用Python的True/False直接判断,结果launch参数传进来的默认是字符串,导致条件永远为真。后来才理解,IfCondition接收的是一个可替换对象或者字符串,不是Python的布尔值。这一点对新手来说是个隐蔽的坑。
5. 常见问题排查与调试经验
5.1 最常见的问题:找不到包、找不到launch文件
launch启动失败,绝大多数报错集中在下面几种:
| 报错信息 | 可能原因 | 解决方法 |
|---|---|---|
Package 'my_robot' not found | 没有编译包,或没有source install目录 | colcon build --packages-select my_robot&&source install/setup.bash |
launch file not found: xxx.launch.py | launch文件没有安装到share目录 | 检查CMakeLists.txt或setup.py是否安装launch目录 |
ModuleNotFoundError | launch文件里import了未安装的Python模块 | 安装对应依赖,或确认环境正确 |
AttributeError: 'NoneType' object has no attribute... | launch文件里获取共享路径时包名写错 | 检查get_package_share_directory的包名是否正确 |
| 程序启动即退出且无错误输出 | 节点运行的依赖环境不完整,或动态库缺失 | 用ros2 run单独运行该节点排查 |
其中第一类“Package not found”最常见的原因不是包不存在,而是当前终端没有source工作区。这个问题在开发多工作区时尤其明显,我自己的习惯是在~/.bashrc里只source一个基础环境,其他工作区需要时再手动source,避免环境变量混乱。
5.2 报错“package not found”怎么办
如果你确认包已经编译,也source了环境,还是报Package not found,那么排查思路是这样:
第一步,用ros2 pkg list | grep my_robot看下当前环境能不能识别到包。如果不能,说明环境变量AMENT_PREFIX_PATH没有包含该工作区的install目录。
第二步,检查echo $AMENT_PREFIX_PATH,确认路径是否指向了你刚编译的工作区。如果指向了别的工作区,或者为空,那问题就出在source顺序或source路径。
第三步,如果ros2 pkg list能看到包,但launch就是找不到,那很可能不是包的问题,而是launch文件路径问题。尝试直接用绝对路径运行:
ros2 launch /path/to/xxx.launch.py如果这样能启动,就说明launch文件本身没问题,问题在于launch文件在包内的安装路径不符合ROS2的搜索规则。ROS2的launch工具默认按包名到share/<package_name>/launch目录下找.launch.py文件,如果你的文件没安装到这个固定位置,它当然找不到。
5.3 source和环境的坑
很多人(包括我)刚开始开发时都经历过这样的困惑:明明已经build成功了,为什么运行时可执行文件还是旧版本?
这里有个容易被忽略的点:colcon build之后,必须重新source环境,尤其是当你修改了包的安装路径或者新增了可执行文件时。source install/setup.bash这条命令不是可选项,它的作用是把当前工作区里的包覆盖到ROS2的搜索路径中。
如果你build完不source,可能会遇到使用了旧的可执行文件,或者根本找不到新包的情况。这种问题在多个工作区重叠时更明显,比如你在基础环境里装了一个旧版本包,又在自己工作区里编译了新版本,如果不source自己工作区,ROS2会优先加载基础环境的旧版本。
调试环境问题的一个实用命令是:
printenv | grep -E "AMENT|ROS|COLCON"它会把当前环境变量中与ROS2相关的所有配置打出来,比一个个echo高效很多。
5.4 GUI程序(rviz2、gazebo)启动失败的典型原因
用launch启动rviz2或gazebo时,很多人会遇到“命令执行了,但界面一闪而过”或者“显示黑屏”的情况。
如果是黑屏,大概率是rviz2启动太早,话题数据还没发布出来。用TimerAction延迟几秒,或者等核心节点完全启动后再启动rviz2,画面就会有内容了。
如果是界面一闪而过,常见原因是display环境变量问题。远程连接或容器环境中,$DISPLAY未设置会导致GUI程序启动失败。另外,在容器里跑带界面的launch时,需要把宿主机的/tmp/.X11-unix挂载进去,还要设置好XAUTHORITY。这是容器化开发中一个很经典的坑。
还有一类情况是launch里用了gazebo_ros的spawn_entity.py,但gazebo实体服务没起来,或者模型文件路径不对。实战中用ros2 service list看一下/spawn_entity服务是否存在,能快速定位。
5.5 快速排查技巧:用好命令行工具
我调试launch时最常用的四条命令:
# 查看launch文件的参数列表,不启动节点 ros2 launch my_robot xxx.launch.py --show-args # 查看launch的完整日志输出 ros2 launch my_robot xxx.launch.py --log-level debug # 单独运行包中的可执行文件,确认节点本身没毛病 ros2 run my_robot robot_base # 查看当前环境识别的包 ros2 pkg list | grep my_robot如果launch启动后节点崩溃,日志里通常会有回溯信息,--log-level debug会让launch系统打印更多内部信息,包括它调用了哪些action、每个action的返回状态。这比自己在launch文件里加print要高效得多,也更符合ROS2的日志规范。
另外,launch的输出日志路径也可以关注下。每次运行launch,它都会提示日志存放位置,像这样:
[INFO] [launch]: All log files can be found below /home/xxx/.ros/log/...如果节点输出信息太多冲掉了关键日志,可以去这个目录找完整的输出文件,或者用--log-level把不需要的模块日志关掉。
6. 一套可直接复用的launch模板
最后分享一个我自己用过很多次的模板,它整合了前面提到的参数、命名空间、条件判断、延迟启动、YAML参数文件、URDF模型加载这些常用功能,可以直接改成你自己的机器人启动脚本。
import os from launch import LaunchDescription from launch.actions import DeclareLaunchArgument, TimerAction, GroupAction from launch.conditions import IfCondition from launch.substitutions import LaunchConfiguration from launch_ros.actions import Node, PushRosNamespace from ament_index_python.packages import get_package_share_directory def generate_launch_description(): pkg_share = get_package_share_directory('my_robot') use_sim_time = LaunchConfiguration('use_sim_time', default='true') namespace = LaunchConfiguration('namespace', default='robot1') start_rviz = LaunchConfiguration('start_rviz', default='true') robot_description_file = os.path.join( pkg_share, 'urdf', 'robot.urdf.xacro' ) robot_state_publisher = Node( package='robot_state_publisher', executable='robot_state_publisher', name='robot_state_publisher', parameters=[{ 'use_sim_time': use_sim_time, 'robot_description': robot_description_file, }], ) core_group = GroupAction([ PushRosNamespace(namespace), Node( package='my_robot', executable='robot_base', name='base', parameters=[os.path.join(pkg_share, 'config', 'base_params.yaml')], ), Node( package='my_robot', executable='lidar_node', name='lidar', remappings=[('scan', 'scan_filtered')], ), ]) rviz2 = Node( package='rviz2', executable='rviz2', name='rviz2', arguments=['-d', os.path.join(pkg_share, 'config', 'display.rviz')], condition=IfCondition(start_rviz), ) return LaunchDescription([ DeclareLaunchArgument( 'use_sim_time', default_value='true', description='Use simulation clock' ), DeclareLaunchArgument( 'namespace', default_value='robot1', description='Namespace prefix for all nodes' ), DeclareLaunchArgument( 'start_rviz', default_value='true', description='Whether to start rviz2' ), robot_state_publisher, core_group, TimerAction(period=3.0, actions=[rviz2]), ])这个模板里的几个设计点值得单独说一下。
robot_state_publisher的robot_description参数直接传了xacro文件路径。这里要注意,ROS2有些版本要求先对xacro做预处理,但大多数情况下robot_state_publisher内部会调用xacro命令解析,前提是你安装了xacro包。如果你遇到URDF解析失败的问题,可以先手动跑一下xacro robot.urdf.xacro看有没有报错。
core_group用PushRosNamespace把两个核心节点都放进了命名空间,这样话题会自动带上前缀。如果你有多台机器人,只要修改namespace参数就能复用同一个launch,非常方便。
TimerAction(period=3.0, actions=[rviz2])是防止rviz2启动太早、导致初始视角没有话题数据。3秒是我在测试平台上调出来的经验值,如果你的节点启动慢,可以适当加大。
我个人在实际调试中还发现一个细节:launch文件里的output='screen'不加的话,节点的printf输出不会到终端,而是会被rosout日志系统捕获。为了方便观察节点日志,我一般都会在每个Node里显式加上output='screen'。
最后再分享一个小技巧。如果你经常要在多个launch之间切换,可以在~/.bashrc里加几个alias,比如:
alias sim_up='ros2 launch my_robot robot.launch.py use_sim_time:=true' alias bot_up='ros2 launch my_robot robot.launch.py use_sim_time:=false start_rviz:=true'调试的时候少敲很多字。launch用顺手之后会形成肌肉记忆,但真正花时间研究它的人并不多。这篇笔记把我自己走过的弯路和积累的技巧都写出来了,希望能帮你省去一些摸索的时间。