ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

ROS2 Launch 文件完全指南:从手动多终端到一键启动与参数化复用

ROS2 Launch 文件完全指南:从手动多终端到一键启动与参数化复用 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.pyfrom launch import LaunchDescription from launch_ros.actions import Node def generate_launch_description(): return LaunchDescription([ Node( packageturtlesim, executableturtlesim_node, namemy_turtle_sim ), Node( packageturtlesim, executableturtle_teleop_key, namemy_turtle_teleop, outputscreen ) ])这个文件最关键的地方是必须要有一个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 execpackagepkg, executableexec指定功能包和可执行文件--ros-args -r old:newremappings[(old, new)]topic/service 重映射--ros-args -p param:valueparameters[{param: value}]设置节点参数--ros-args --params-file xxx.yamlparameters[xxx.yaml]加载参数文件/namespacenamespace/xxx设置命名空间重开终端跑节点outputscreen日志输出到屏幕而不是 log 文件节点别名namemy_node覆盖原有的节点名称我挑几个容易被坑的细说。首先是remappings。它接收的是一个元组列表每个元组左边是原始名称右边是重命名后的名称。比如你的节点订阅的是/cmd_vel但你想让它订阅/robot1/cmd_vel就可以写成Node( packageteleop_twist_keyboard, executableteleop_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 文件然后找不到然后给你一个参数文件不存在的诡异报错。这个坑我在后面排错部分还会再提到。最后说一下outputscreen。别看它不起眼没有它你的节点日志会写到 ROS2 的 log 文件里终端上什么都不显示。你启动完了发现没反应其实就是日志被写文件了。尤其是跑那些第五、第六个节点的时候一定要记得给它们都加上outputscreen不然排查起问题来两眼一抹黑。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( namemy_bringup, version0.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 文件变成可组合的乐高参数、分组、条件和 include3.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, defaultrobot1) return LaunchDescription([ DeclareLaunchArgument( namespace, default_valuerobot1, description机器人命名空间 ), Node( packagemy_robot_driver, executabledriver_node, namespacenamespace, namedriver ) ])这样运行的时候你就可以用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(packagemy_robot_driver, executabledriver_node, namedriver), Node(packagemy_robot_localization, executableekf_node, nameekf), ] ) 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( packagemy_pkg, executabledata_collector ) next_node Node( packagemy_pkg, executablesummary_node, outputscreen ) return LaunchDescription([ task_node, RegisterEventHandler( OnProcessExit( target_actiontask_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( period5.0, actions[ LogInfo(msg5秒后启动导航), Node(packagenav2_bringup, executablebringup_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预留出来保证复用方可以只启动其中一部分。多实例场景下会不会冲突——用GroupActionPushRosNamespace做隔离哪怕暂时只跑一台机器人也照做。照着这个思路写出来的 launch往往会更像一个命令行工具而不是一份一次性脚本。新的机器人来了我只需要传几个参数比如robot_name:my_robot_v2、laser_port:/dev/ttyUSB1就能直接跑起来不用再复制粘贴整个文件。这个习惯帮我省下了大量重复劳动也希望你能从第一次写 launch 开始就把这个思维带进去。
返回列表