1. 为什么你需要 Launch:从手动开终端的痛说起
1.1 一个过来人脑中的"标准化痛苦"
刚开始接触 ROS2 的人,大多经历过这样一段蹒跚期:装好了 Humble,跟着教程敲ros2 run turtlesim turtlesim_node,小乌龟出来了,挺高兴。然后教程说,再开一个终端跑ros2 run turtlesim turtle_teleop_key,用键盘控制乌龟。嗯,也行,两个终端嘛。但等到你开始跑真正的机器人仿真,事情就完全变了——robot_state_publisher、joint_state_publisher、rviz2、nav2、gazebo、各种 driver 节点,一开就是七八个终端。每个终端都得先source /opt/ros/humble/setup.bash,接着再source install/setup.bash,然后小心翼翼地输入命令,再传一堆参数。
这还只是启动阶段。节点起来了,你还得给它们统一设置命名空间,免得多个机器人一跑,topic 全冲突;有的节点需要读参数文件,你得先想清楚yaml路径写对没有;哪个节点先启动、哪个后启动也有顺序讲究。手动操作到这里,你大概率已经忘了自己刚才要干什么,只剩下一种"我在给电脑打工"的荒谬感。
Launch 就是来终结这种荒谬的。它是一套 ROS2 官方的启动管理工具,允许你用一个文件把"开终端、source 环境、运行节点、传参、设命名空间、控制启动顺序"全部描述清楚,然后敲一条ros2 launch命令,一次性全部起来。对于稍微复杂一点的机器人项目,Launch 不是"可选项",它是早晚要迈过去的那道坎。
1.2 它到底帮你省了什么
我用一个不太严谨但很容易记住的类比:Launch 文件就像你出门旅行前写的一份"行李清单 + 流程卡"。清单上写着要带哪几件衣服(哪些节点要启动)、每件衣服装在哪个箱子(哪个包、哪个可执行文件)、有没有特殊要求(命名空间、参数、重映射),以及先穿袜子还是先穿鞋(启动顺序、事件依赖)。有了这张卡,你每次出门都不用重新想,照着执行就行,而且绝对不会漏。
具体到日常开发,Launch 至少帮你解决了五个问题:
- 多节点一键启动:一个文件搞定一组节点的同时拉起,省去反复切换终端与复制粘贴命令。
- 参数统一管理:节点需要的参数文件、命令行参数,在 launch 里写清楚,不用每次手动敲。
- 命名空间与重映射:多个同类型节点可以在不同命名空间下运行,互不干扰,topic 的
remap也顺手解决。 - 启动顺序与条件控制:可以用事件机制实现"上一个节点退出后,下一个才启动",也可以按条件决定某些节点起不起。
- 复用与组合:一个项目的 launch 可以 include 另一个项目的 launch,像搭积木一样把整套系统拼出来。
看到这里,你应该已经明白 Launch 的定位了。不过先别急着去抄代码——ROS2 的 Launch 和 ROS1 时代的老 launch 有一个非常本质的区别,不理解这一点,你后面会被各种"奇奇怪怪"的语法折磨。这也是我接下来要重点讲的东西。
2. launch 文件不是"配置文件",而是一段 Python 程序
2.1 一个最小可用的 launch 文件长什么样
很多 ROS1 转过来的老手,第一次打开 ROS2 的.launch.py文件都会愣一下:怎么是 Python 语法?没错,ROS2 Launch 彻底抛弃了 ROS1 里 XML 那套写法,改用 Python。你看到的 launch 文件,本质上不是一份被解析的配置,而是一段会被执行的 Python 代码,代码执行完之后生成一个 launch 描述对象,ROS2 再根据这个对象去做实际的启动工作。
这段话说得还是有点绕,直接看代码。假设你想一次性启动小乌龟模拟器和键盘控制节点,建一个turtlesim_launch.py:
from launch import LaunchDescription from launch_ros.actions import Node def generate_launch_description(): return LaunchDescription([ Node( package='turtlesim', executable='turtlesim_node', name='my_turtle_sim' ), Node( package='turtlesim', executable='turtle_teleop_key', name='my_turtle_teleop', output='screen' ) ])这个文件最关键的地方是必须要有一个generate_launch_description()函数,它返回一个LaunchDescription对象。你不需要自己去调用这个函数,执行ros2 launch的时候,系统会去加载这个 Python 文件,找到该函数并执行,然后把返回的对象当作启动蓝图。
看到这里,你要建立一个最重要的心智模型:launch 文件就是一个"描述启动逻辑的程序"。既然是程序,你就可以在里面写变量、写函数、做判断、循环遍历、执行外部命令,甚至读取环境变量。它远比"配置文件"灵活得多。如果你只会把一堆Node()罗列在 list 里,那 ROS2 Launch 强大的地方你基本没用上。
2.2 Node 的常见参数,和命令行参数怎么对照
新手最容易困惑的一件事:launch 文件里Node()的那些参数,跟我手动ros2 run的语法到底什么关系?其实核心就一句话,Node()就是ros2 run的"结构化版本"。
| ros2 run 命令片段 | launch 里的对应写法 | 作用 |
|---|---|---|
ros2 run <pkg> <exec> | package='<pkg>', executable='<exec>' | 指定功能包和可执行文件 |
--ros-args -r old:=new | remappings=[('old', 'new')] | topic/service 重映射 |
--ros-args -p param:=value | parameters=[{'param': value}] | 设置节点参数 |
--ros-args --params-file xxx.yaml | parameters=['xxx.yaml'] | 加载参数文件 |
/namespace | namespace='/xxx' | 设置命名空间 |
| 重开终端跑节点 | output='screen' | 日志输出到屏幕而不是 log 文件 |
| 节点别名 | name='my_node' | 覆盖原有的节点名称 |
我挑几个容易被坑的细说。
首先是remappings。它接收的是一个元组列表,每个元组左边是"原始名称",右边是"重命名后的名称"。比如你的节点订阅的是/cmd_vel,但你想让它订阅/robot1/cmd_vel,就可以写成:
Node( package='teleop_twist_keyboard', executable='teleop_twist_keyboard_node', remappings=[('/cmd_vel', '/robot1/cmd_vel')] )这个功能在跑多机器人仿真时几乎是标配。你一遍遍 clone 同一个节点,然后用 namespace 或 remap 把它们隔离开来。
其次是parameters。这里有一个所有新手都会踩的坑:这个参数既支持字典,也支持 yaml 文件路径,但如果是里 Python 的str,系统会默认当成文件路径去加载,而不是当作一个字符串参数值。所以初学者如果写:
parameters=[{'robot_name': 'turtle1'}]这是字典,没问题。但如果你写成:
parameters=['robot_name: turtle1'] # 这是错的做法!它不会报错,但会尝试去打开一个名为robot_name: turtle1的 yaml 文件,然后找不到,然后给你一个"参数文件不存在"的诡异报错。这个坑我在后面排错部分还会再提到。
最后说一下output='screen'。别看它不起眼,没有它,你的节点日志会写到 ROS2 的 log 文件里,终端上什么都不显示。你启动完了发现"没反应",其实就是日志被写文件了。尤其是跑那些第五、第六个节点的时候,一定要记得给它们都加上output='screen',不然排查起问题来两眼一抹黑。
2.3 一个隐藏的坑:launch 文件放进包以后没有生效
很多初学者照猫画虎,在自己的功能包my_bringup里建了一个launch目录,放进去一个demo.launch.py,然后运行:
ros2 launch my_bringup demo.launch.py结果系统提示找不到这个 launch 文件。这时候你要意识到一个问题:ROS2 功能包里的 launch 文件并不是"放对了位置就能用"的。如果你用的是ament_python类型的功能包,需要在setup.py里把 launch 目录声明为 data_files,并且安装到share目录下,ros2 launch才能通过包名找到它。
在我的个人经验里,遇到"launch 文件在包里但找不到"的问题,90% 的根因是下面这段配置漏了:
import os from glob import glob from setuptools import setup setup( name='my_bringup', version='0.0.1', packages=['my_bringup'], data_files=[ ('share/ament_index/resource_index/packages', ['resource/my_bringup']), ('share/my_bringup', ['package.xml']), (os.path.join('share', 'my_bringup', 'launch'), glob('launch/*.launch.py')), ], ... )data_files里那行glob('launch/*.launch.py')才是一切的关键。做完了记得重新colcon build,再source install/setup.bash一次,因为data_files的安装发生在构建阶段,不重新 build 就会一直用旧版本。
这里有一个小技巧,如果你只想快速验证 launch 文件语法,不一定要经过包安装流程。你可以直接给ros2 launch传文件路径,比如:
ros2 launch ./launch/demo.launch.py它会直接执行,不需要包名,也要注意路径别写错。验证通顺之后,再把文件挪进包里,也不迟。
3. 把 launch 文件变成可组合的乐高:参数、分组、条件和 include
3.1 用 LaunchConfiguration 让脚本可以传参
默认情况下,一个 launch 文件里面写的值都是"写死"的。比如仿真环境里小车的速度限制、里程计话题名、机器人命名空间,这些参数如果每次都要改 Python 文件,那 launch 文件就退化成"高级配置文件"了。真正的工程做法是把这些可变项声明成 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(): namespace = LaunchConfiguration('namespace', default='robot1') return LaunchDescription([ DeclareLaunchArgument( 'namespace', default_value='robot1', description='机器人命名空间' ), Node( package='my_robot_driver', executable='driver_node', namespace=namespace, name='driver' ) ])这样运行的时候,你就可以用:
ros2 launch my_bringup driver.launch.py namespace:=robot2来覆盖默认值。注意这里有个关键细节:LaunchConfiguration的取值是"惰性"的,它不会在 Python 变量被赋值的那一刻真正变成字符串。它其实是一个 substitution 对象,直到整个 launch 描述真正被执行时,ROS2 才会用实际传入的值去替换。所以不要在generate_launch_description()里想着直接对namespace做字符串拼接、切片之类的操作,它此时还不是一个真正的字符串,你需要用launch.substitutions.PythonExpression或者把拼接逻辑下沉到节点参数里。
查看一个 launch 文件支持哪些命令行参数,可以用:
ros2 launch my_bringup driver.launch.py --show-args它会列出所有已经通过DeclareLaunchArgument声明的参数名、默认值和说明。这是我自己调试 launch 时最常用的命令之一,强烈建议你养成先--show-args再运行的习惯。
3.2 组的妙用:一键切换命名空间
GroupAction是一个容易被低估的功能。它的作用是把一组节点包起来,统一施加一些全局配置。最常见的用法是统一设置命名空间和参数。
举个例子,你要在同一个仿真环境里跑两台一样的机器人,它们的驱动程序除了命名空间不同,其他逻辑完全一样。如果你第一次学会了DeclareLaunchArgument,可能会傻傻地写两份Node,一份 namespace 是robot1,另一份是robot2,还得小心翼翼地不写错参数。但用GroupAction可以这样写:
from launch.actions import GroupAction from launch_ros.actions import PushRosNamespace def robot_group(robot_name: str): return GroupAction( actions=[ PushRosNamespace(robot_name), Node(package='my_robot_driver', executable='driver_node', name='driver'), Node(package='my_robot_localization', executable='ekf_node', name='ekf'), ] ) def generate_launch_description(): return LaunchDescription([ robot_group('robot1'), robot_group('robot2'), ])PushRosNamespace(robot_name)会把它之后的所有节点都放进一个命名空间,相当于给你省掉了在每个节点里重复写namespace=...的麻烦。这个模式在跑多机器人导航、多机编队时非常常见。
GroupAction里面还可以嵌套GroupAction,甚至放IncludeLaunchDescription,所以它不只是"分组好看",还可以帮你把复杂的启动逻辑像俄罗斯套娃一样组织起来。有一点要注意,PushRosNamespace不是简单给节点贴标签,它会真正改变节点内部的 topic、service、param 等所有 ROS 图资源的名称。比如上面例子中,/robot1/driver/cmd_vel和/robot2/driver/cmd_vel是完全不同的两个 topic,互不干扰,这才是多机并行的前提。
3.3 Include 其他 launch:从零散到整体
一个完整机器人系统的 launch 文件通常不是从零写出来的,而是把各个模块的 launch 文件像插件一样拼到一起。这种复用机制在 ROS2 里就是IncludeLaunchDescription。
比如我的一个仿真项目里,底盘驱动、激光雷达驱动、导航栈、Rviz 可视化,各自都有独立的 launch 文件。系统级启动文件就可以这样写:
from launch import LaunchDescription from launch.actions import IncludeLaunchDescription from launch.launch_description_sources import PythonLaunchDescriptionSource from launch_ros.substitutions import FindPackageShare def generate_launch_description(): bringup_dir = FindPackageShare('my_robot_bringup') return LaunchDescription([ IncludeLaunchDescription( PythonLaunchDescriptionSource([ bringup_dir, '/launch/', 'driver.launch.py' ]) ), IncludeLaunchDescription( PythonLaunchDescriptionSource([ bringup_dir, '/launch/', 'lidar.launch.py' ]) ), IncludeLaunchDescription( PythonLaunchDescriptionSource([ find_package_share('nav2_bringup'), '/launch/', 'bringup_launch.py' ]) ), IncludeLaunchDescription( PythonLaunchDescriptionSource([ bringup_dir, '/launch/', 'rviz.launch.py' ]) ), ])FindPackageShare('my_robot_bringup')的作用是去系统安装目录里查找这个包对应的 share 目录,从而定位到 launch 文件绝对路径。很多新手在这里直接写相对路径,比如'launch/driver.launch.py',运行的时候大概率会报找不到文件,原因就是相对路径是相对于当前工作目录的,而 ROS2 安装后的 launch 文件都在install下的 share 目录里。
Include 里面还可以传递参数给被包含的 launch 文件。被包含的文件里声明了namespace,那你就可以这样传:
IncludeLaunchDescription( PythonLaunchDescriptionSource([...]), launch_arguments={'namespace': 'robot2'}.items() )这样整个系统就是一个多层组合的树状结构。根节点是总 launch,子节点是各个模块 launch,叶子节点才是实际的 Node。理解了这个树状模型,你再去看大型 ROS2 项目的 launch 文件时,就不会迷失在一堆括号和参数里了。
4. 排错实录:几个 Launch 高频报错是怎么定位的
4.1 最常见的一类:环境没对导致"找不到包"和"找不到可执行文件"
无论你在哪个社区提问,ROS2 Launch 相关的报错里,"找不到"三兄弟一定排在前列:
Package 'mypkg' not found executable 'my_node' not found launch file 'xxx.launch.py' not found这三个报错出现时,绝大多数情况不是你的 launch 文件本身写错了,而是运行时的环境变量没有生效。具体表现形式是:
- 新开一个终端,直接
ros2 launch mypkg xxx.launch.py,提示找不到包。 - 已经
source过了,但报 executable not found。 - 换了终端以后,之前能跑的命令突然不能跑了。
排查思路,我一般按下面这个顺序走:
- 看当前终端有没有 source 环境。从没 source 过的终端里,你连
ros2这个命令都会找不到。这时候先source /opt/ros/humble/setup.bash。 - 看包有没有编译安装。如果你的包是自建的,必须先
cd到工作空间根目录,执行colcon build,然后source install/setup.bash。注意 install 目录必须存在,而且里面有mypkg文件夹。 - 看安装完后 share 目录里到底有没有对应文件。这一步很多人会漏。执行:
ls install/mypkg/share/mypkg/如果你能看到launch目录和package.xml,说明文件基本装进去了。如果看不到,那就是前面 setup.py 的 data_files 配置有问题,或者是ament_cmake的install(DIRECTORY launch ...)没写。 4.如果报错里关键字是 executable not found,那还要确认你的可执行文件名写没写对。setup.py里entry_points定义的命令名,和你 launch 里executable=的参数必须完全一致,包括下划线。打错一个字符,就会走到"包存在但我找不到入口"这条路上。
还有一个小坑:有时候你改了源码,重新colcon build时没有重启终端,新的环境变量没生效,报错还是很老的版本。我一般习惯性在执行colcon build以后重新开一个终端,或者在当前终端重新 source 一次。这看起来像个洁癖习惯,但它能避免不少莫名其妙的"灵异事件"。
4.2 一个偏门的系统级报错:EXDEV 跨设备链接问题
在社区里偶尔能看到一个比较冷门但非常有代表性的报错:
resolve launch spec failed: exdev: cross-device link not permitted, rename...这个报错我第一眼看到的时候也愣住了。EXDEV是 Linux 下的一个系统错误,表示"跨设备链接不允许"。它的本质是:你试图用rename()把一个文件从一个文件系统移动到另一个文件系统,而 Linux 不允许这样直接操作。ROS2 Launch 在启动某些节点时,会在临时目录里生成一些脚本或者符号链接,如果临时目录和实际工作目录处于不同的文件系统挂载点,就可能触发rename跨设备失败。
什么情况下会出现?比如你的/tmp是独立分区(tmpfs 或单独挂载的 SSD),而工作空间在主目录下,主目录又在另一个分区。某些环境下 ROS2 会在/tmp和~/.ros之间做临时文件操作,一旦它们不在同一个文件系统上,就可能遇到这个错误。
排查思路是这样的:
- 先确认
/tmp的挂载类型:
df -h /tmp如果显示的文件系统和你主目录所在分区不一样,那就基本锁定了问题方向。 2. 尝试把 ROS2 的临时目录指到主目录下。可以在运行前设置环境变量:
export ROS_HOME=~/.ros export TMPDIR=$HOME/tmp mkdir -p $HOME/tmp- 或者,在构建工作空间时使用符号链接安装模式,可以减少一部分临时文件移动操作:
colcon build --symlink-install这个模式还有一个额外好处:源码改了不用重新 build 就能生效,非常适合日常迭代。
这种问题不常见,但一旦遇到了就是彻底摸不着头脑的那类。我把它写出来,是为了让你有个印象:ROS2 Launch 的报错并不都是 launch 语法的问题,系统底层的文件系统、权限、环境变量完全可能以"踩高跷"的方式弄崩你的启动流程。排查的时候,不要只盯着 Python 脚本看,也要抬头看看操作系统环境。
4.3 参数文件路径与"参数文件不存在"陷阱
前面我在讲 Node 的parameters参数时提到过字符串会被当作 yaml 路径解析的问题。这里把完整排错过程展开讲。
我遇到过的一个真实案例:一个同事写 launch,想给节点传一个字符串参数robot_model,他写成了:
parameters=['robot_model: my_robot_v2']结果一运行,ros2 launch立刻报错说找不到参数文件。他懵了,因为他觉得自己明明是在"配参数",怎么就成了找文件。问题就出在parameters对字符串的处理逻辑上:只要是str类型,ROS2 就会把它当作 yaml 参数文件的路径去加载;想传普通的字符串内容,必须写成字典。
正确做法:
parameters=[{'robot_model': 'my_robot_v2'}]或者传一个确确实实存在的 yaml 文件路径:
parameters=[os.path.join(bringup_dir, 'config', 'robot.yaml')]这类问题还有个变种:yaml 文件路径写的是相对路径,比如config/robot.yaml。ROS2 执行 launch 时,它的当前工作目录不一定是你运行ros2 launch的那个目录,因为 launch 内部可能会改变工作目录。最稳妥的办法是用FindPackageShare定位到包的绝对路径,再拼上config子目录,这样不管从哪里运行都不怕。
排查这种"路径类"问题,我有个土办法:在 launch 里临时加一句print()把要传给节点的路径打出来,run 一下看看输出是否符合预期。虽然 print 语句最终要删掉,但在排错阶段它比任何调试器都好用。
5. 进阶小技巧:事件驱动、串行启动与调试 launch 自身
5.1 用事件处理器实现"上一个退出,下一个才启动"
有时候,节点之间的关系不只是"并行启动"这么简单。比如你要先启动一个建图节点,等它跑完退出后,再启动一个保存地图的节点;或者底盘驱动节点中途崩溃了,你想自动把它重新拉起来。这些"以事件为驱动"的启动逻辑,用静态描述就做不到了,需要注册事件处理器。
ROS2 Launch 里最常用的事件是OnProcessExit,它监听某个进程退出事件。我举一个实用例子:先启动一个"一次性任务节点",它跑完就退出。它退出之后,再启动一个汇总结果节点。
from launch import LaunchDescription from launch.actions import RegisterEventHandler, ExecuteProcess from launch.event_handlers import OnProcessExit def generate_launch_description(): task_node = Node( package='my_pkg', executable='data_collector' ) next_node = Node( package='my_pkg', executable='summary_node', output='screen' ) return LaunchDescription([ task_node, RegisterEventHandler( OnProcessExit( target_action=task_node, on_exit=[next_node] ) ) ])RegisterEventHandler有点像给进程"绑了个监听器"。target_action指定监听谁,on_exit是一个动作列表,在被监听的进程退出后执行。注意,target_action必须是你 launch 里定义过的同一个动作对象,不能光写一个包名。很多人在这里写错,把字符串传进去,然后报错找不到目标。
另外还有OnProcessStart,它是在某个进程启动后才触发其他动作,可以用来做"先启动底盘驱动,等它 ready 了再启动导航"这种逻辑。但要注意,"进程启动"不等于"节点初始化完成",很多节点启动后还要花时间加载参数、连接硬件。这种场景下,光靠OnProcessStart不够,还得配合节点内部的"service ready"检测,或者干脆给一个 sleep 时间。启动顺序这个东西,理论上用事件驱动最优雅,实践上还是免不了调参数、试时间,心态放平。
5.2 Timer、递延启动与一些调试 launch 自身的土办法
除了事件,Launch 还支持定时动作。TimerAction可以让你延迟一段时间再启动某些节点:
from launch.actions import TimerAction, LogInfo TimerAction( period=5.0, actions=[ LogInfo(msg='5秒后启动导航'), Node(package='nav2_bringup', executable='bringup_launch') ] )这个功能在等待外部硬件上电、等待仿真环境加载、等待另一个容器就绪时很好用。但我必须提醒一句:用 Timer 做启动顺序控制是"下策",能不用尽量不用。原因很简单,Timer 是"盲等",它不关心你等的那个东西到底好了没有。5 秒不够你就得改 8 秒,8 秒在另一台性能好的机器上又太多。真要讲究,优先用事件驱动,或者写一个"等待 service 出现"的判断逻辑。Timer 只适合做快速 Demo,不适合做严谨的工程。
调试 launch 自身的习惯,我一般有三板斧:
--show-args查看参数声明,快速确认 launch 文件是否被正确加载。ros2 launch ... --debug,会输出更多运行时的调试信息,包括每个实体的加载过程、替换(substitution)结果等。- 在
generate_launch_description()里临时塞print(),把关键变量打出来。不过要注意,由于 substitution 是惰性的,很多LaunchConfiguration的值在函数执行阶段还不能打印出来,得等真正执行时才能拿到。想打印正式运行时的值,可以用LogInfo动作:
from launch.actions import LogInfo from launch.substitutions import LaunchConfiguration LogInfo(msg=['namespace is: ', LaunchConfiguration('namespace')])LogInfo会把你打印的内容输出到启动日志里,而且它是在启动阶段真正执行时才输出,所以能看到替换后的真实值。这个方法比print靠谱得多。
5.3 我的一个实用模板:把一个"可复用 launch"写得像命令行工具
最后分享一个我个人的习惯。我发现很多人写 launch 文件时,只想着"能把节点拉起来就行",很少考虑"下次换个场景我能不能继续用"。结果是每换一个机器人,就要复制一份 launch,改改里面的参数,久而久之整个工作空间里全是面目相似的 launch 文件。
我自己在写可复用 launch 时,会逼迫自己回答三个问题:
- 这个 launch 里有哪些值未来可能变化?——把这些值全部声明成
DeclareLaunchArgument,不写死。 - 哪些模块可能是独立的?——用
IncludeLaunchDescription预留出来,保证复用方可以只启动其中一部分。 - 多实例场景下会不会冲突?——用
GroupAction+PushRosNamespace做隔离,哪怕暂时只跑一台机器人也照做。
照着这个思路写出来的 launch,往往会更像一个"命令行工具"而不是一份一次性脚本。新的机器人来了,我只需要传几个参数,比如robot_name:=my_robot_v2、laser_port:=/dev/ttyUSB1,就能直接跑起来,不用再复制粘贴整个文件。这个习惯帮我省下了大量重复劳动,也希望你能从第一次写 launch 开始就把这个思维带进去。