ARTICLE DETAIL

资讯详情

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

ROS2 Launch文件完全指南:从节点启动到仿真部署

ROS2 Launch文件完全指南:从节点启动到仿真部署 开头在接触ROS2之前我一度觉得启动一个机器人系统就是打开三个终端分别source一下、再轮流敲rosrun顶多再为每个终端换个配色方便区分。直到开始写真正完整的机器人应用比如同时跑驱动、SLAM、导航、RViz和Gazebo才意识到这种“手工起节点”的方式有多脆弱关错窗口、顺序不对、参数漏传、忘记source任何一步出错都要重来。也就是从那时候起我开始认真研究launch文件这套ROS2自带的启动机制也是这篇笔记要聊的核心内容。这篇笔记对应我自己整理的学习笔记第4.6节主要围绕“使用launch启动脚本”展开适合刚学完ROS2基础通信话题、服务、动作但还没系统接触launch的读者也适合已经写过简单launch文件想搞清楚参数传递、命名空间、条件控制和调试技巧的人。看完这一篇你能明白launch文件为什么是ROS2工程落地时绕不开的一环也能直接照着我给的模板写出一份能用的launch文件省掉在启动环节反复折腾的时间。1. launch文件到底解决了什么问题1.1 没有launch之前是怎么启动节点的我最早用ROS2做小乌龟实验的时候启动方式非常简单先在一个终端里source环境然后跑ros2 run turtlesim turtlesim_node再开一个终端跑ros2 run turtlesim turtle_teleop_key控制窗口就出现了。那时候觉得也没多麻烦两个终端而已。但等到系统稍微复杂一点比如做一台两轮差速小车需要同时启动底盘驱动、激光雷达驱动、里程计发布、串口桥接、RViz可视化、键盘控制哪怕只是验证一个最简单的开环运动也要开五六个终端每个窗口都要手动敲一遍命令。敲错一个包名、少了source、在错误的目录下执行都会报错而且报错信息五花八门光是排查“为什么我这个节点起不来”就要花不少时间。更麻烦的是参数。如果底盘驱动里需要设置串口号、波特率、雷达的frame_id、里程计协方差这些参数用rosrun启动的话要么在命令行后面一个一个传要么就得提前在单独的yaml文件里配置好了再手工加载。每次修改都要重新敲一遍完整命令很容易敲漏。这在开发调试阶段是能忍的但一旦要把这套系统交给别人用或者换一台电脑部署就成了灾难。1.2 launch文件的核心价值launch文件解决的就是这个“启动编排”问题。你可以把多个节点的启动方式、启动顺序、参数配置、命名空间、重映射关系全部写进一个launch文件里然后只需要一行命令ros2 launch 包名 launch文件名系统就会按照你定义好的方式把所有节点拉起来。我自己的体会是launch文件最大的价值不是“少敲几行命令”而是让启动过程变得可复现、可维护、可共享。可复现指的是同样的launch文件在任何一台配置好的机器上启动行为完全一致可维护指的是换传感器、改参数你不需要改动代码只需要改launch文件可共享则是说你可以把launch文件作为包的“使用入口”别人拿到你的包不用问你怎么启动看一眼launch文件就全都明白了。从ROS2的架构设计角度来说launch也是一个独立的子系统它的核心是launch和launch_ros这两个包。launch负责通用的进程管理与事件循环launch_ros则提供了ROS2专属的启动描述实体比如Node、ComposableNode、Parameter。理解了这层拆分的逻辑后面看launch文档和源码就不会一头雾水。2. ROS2 launch系统的核心机制与关键概念2.1 launch文件其实是Python代码ROS2的launch文件支持三种格式Python、XML、YAML。官方推荐且绝大多数开源项目使用的都是Python格式原因很简单Python格式有完整的编程能力变量、循环、函数、条件判断都能用而且调试起来更直观。我第一次接触这个的时候有个认知偏差总以为launch文件是一个“被ROS2解析的配置文件”跟JSON一样。实际上ROS2把launch文件当作Python程序来执行执行的结果是一棵LaunchDescription的实体树。launch系统拿到这棵树之后会遍历里面的action逐步执行。所以你可以在launch文件里写Python的for循环来批量生成节点可以用if句式做条件判断可以把参数组织成字典然后批量塞给节点。这种灵活性是ROS1时代XML格式launch完全比不上的。我建议新学者直接用Python格式不要花时间在XML和YAML格式上除非你要维护旧项目。2.2 Node动作的常用参数在launch文件里往系统里加一个节点靠的是launch_ros.actions.Node这个类。下面是一个最典型的最小写法from launch import LaunchDescription from launch_ros.actions import Node def generate_launch_description(): return LaunchDescription([ Node( packageturtlesim, executableturtlesim_node, namemy_turtle, outputscreen ) ])这里面几个参数需要说清楚。package是功能包名executable是可执行文件的名字注意不是节点名而是你在setup.py或CMakeLists.txt里注册的入口名字。name是给节点起的别名会覆盖节点内部通过init初始化的节点名改成my_turtle后用ros2 node list看到的就是/my_turtle。output有几种常见取值。screen表示把节点的标准输出打印到当前终端不加这个参数基本上看不到节点的printf日志。调试阶段建议都加上。想存日志的话可以用log或者通过log_cmd结合append_env等方式做更复杂的日志重定向。还有arguments用来给可执行文件传命令行参数模拟你在终端里ros2 run后面跟的那些参数。2.3 ExecuteProcess、IncludeLaunchFile与Group光有Node其实已经能解决大部分场景了但launch系统里还有几个重要动作类型分别是ExecuteProcess、IncludeLaunchFile或IncludeLaunchDescription和Group。ExecuteProcess用来执行任意系统命令。比如底层驱动不是ROS节点而是一个普通的可执行程序或者启动时想顺手执行一条cp命令做文件备份这时候就用ExecuteProcess。它的典型用法是传入cmd列表from launch.actions import ExecuteProcess ExecuteProcess( cmd[gnome-terminal, --, bash, -c, echo hello], outputscreen )我常常用它来启动额外的终端窗口比如在launch里开一个窗口单独运行键盘控制节点这样不会和主进程的输出混在一起。IncludeLaunchDescription用来把其他launch文件包含进来相当于把多个launch文件组合成一个更大的launch。比如导航包自带一个很复杂的导航launch你不想改它只想在自己的launch文件里把它引进来同时传几个参数进去那就可以用IncludeLaunchDescription。它在ROS2里的写法比ROS1复杂一些一般配合PathJoinSubstitution使用后面实操部分会给出完整例子。Group用来对节点做分组管理最常用的场景是配合scoped参数给一组节点统一设置命名空间。比如在同一个launch里启动两台机器人它们的节点名可能都是/lidar_driver直接用会冲突就可以用两个Group分别设置不同的命名空间让它们变成/robot1/lidar_driver和/robot2/lidar_driver。3. 手把手写一个launch文件并跑起来3.1 建立项目结构我建议以功能包为单位存放launch文件也就是把launch文件放在你自己的ROS2包内的launch目录里。这样做的原因是launch文件里通常会引用包内的参数文件、模型文件、配置文件放在包内就可以用ament_index_python提供的接口来定位包的路径而不是靠相对路径。先创建一个工作空间和功能包我习惯用ament_python类型的包来做示例因为不需要写C编译代码学launch的时候改动最少mkdir -p ~/ros2_ws/src cd ~/ros2_ws/src ros2 pkg create --build-type ament_python launch_demo cd launch_demo mkdir launch在launch_demo目录下创建launch目录后续launch文件都放在这里。顺便准备一个简单模型我拿一个自定义的Python节点来做示例模拟一个发布话题的传感器节点。3.2 添加依赖与配置setup.py这里有一个关键点ROS2的launch文件默认不会被打包进安装目录。如果你直接运行ros2 launch launch_demo demo.launch.py系统会提示找不到launch文件。这是因为Python包的setup.py里没有把launch目录作为数据文件安装。需要在setup.py里做两处修改。第一处在data_files中加入launch目录的安装规则import os from glob import glob from setuptools import setup package_name launch_demo setup( namepackage_name, version0.0.0, packages[package_name], data_files[ (share/ament_index/resource_index/packages, [resource/ package_name]), (share/ package_name, [package.xml]), # 重点把launch目录安装到share目录下 (os.path.join(share, package_name, launch), glob(launch/*.launch.py)), ], ... )这段代码的作用是用glob把launch目录下所有.launch.py文件安装到prefix/share/launch_demo/launch/目录下。不写这一段源码目录下编译时能找到launch文件但clean之后再运行就找不到了这也是很多人遇到“launch文件找不到”问题的最常见原因。第二处在entry_points里其实不需要专门注册launch文件。这里跟ROS1不一样ROS2的launch文件不要求注册到console_scripts只需要安装在share目录下ros2 launch命令会自动去相应的share目录里查找。3.3 编写第一个launch文件我在launch目录下新建一个demo.launch.py内容是一个仿真传感器节点加一个可视化节点方便验证launch生效from launch import LaunchDescription from launch_ros.actions import Node def generate_launch_description(): # 模拟激光雷达数据发布的节点 lidar_node Node( packagelaunch_demo, executablelidar_node, namelidar_front, outputscreen, parameters[{frame_id: laser_frame}] ) rviz_node Node( packagerviz2, executablerviz2, namerviz2, outputscreen ) return LaunchDescription([ lidar_node, rviz_node, ])这里补充一点parameters可以传列表列表里的元素可以是字典、yaml文件路径也可以是ParameterFile。实际使用时我更喜欢用yaml文件管理大量参数而字典适合调试阶段快速传几个值。你可能会问executablelidar_node对应的可执行文件真的存在吗在纯Python包里还需要在setup.py的entry_points里注册这个命令才能用ros2 run launch_demo lidar_node把它拉起来。如果你暂时没有这个节点可以直接把executable改成系统自带的小乌龟节点来测试比如packageturtlesim, executableturtlesim_node。我用虚拟节点做示例时写的lidar_node入口如下# 放在 launch_demo/lidar_node.py 中 import rclpy from rclpy.node import Node as RclNode class LidarNode(RclNode): def __init__(self): super().__init__(lidar_node) self.declare_parameter(frame_id, laser_frame) self.get_logger().info(lidar_node started, frame_id: %s % self.get_parameter(frame_id).value) def main(argsNone): rclpy.init(argsargs) node LidarNode() rclpy.spin(node) node.destroy_node() rclpy.shutdown() if __name__ __main__: main()在setup.py中注册入口entry_points{ console_scripts: [ lidar_node launch_demo.lidar_node:main, ], },这样整条链路就通了。3.4 编译并运行在launch文件写好之后编译安装包cd ~/ros2_ws colcon build --packages-select launch_demo source install/setup.bash然后运行ros2 launch launch_demo demo.launch.py如果一切正常屏幕上会打印两个节点的日志输出然后你可以再开一个终端用ros2 node list查看节点列表应该能看到/lidar_front和/rviz2两个节点。这里有个小细节Node的name参数如果你给了lidar_front节点在ROS2图里的名字就是/lidar_front如果省略name参数ROS2会用代码里初始化的节点名也就是lidar_node。我建议在一个launch文件管理多个同类型节点时显式给name参数避免节点名混乱。4. 进阶用法参数、命名空间、包含、条件4.1 让launch文件具备外部传参能力实际项目里launch文件不能总是写死参数否则每次换串口号都要改源码。ROS2的launch系统提供了DeclareLaunchArgument和LaunchConfiguration这一对组合专门用来处理外部传参。比如我想让用户通过命令行指定frame_idfrom launch.actions import DeclareLaunchArgument from launch.substitutions import LaunchConfiguration def generate_launch_description(): frame_id LaunchConfiguration(frame_id) return LaunchDescription([ DeclareLaunchArgument( frame_id, default_valuelaser_frame, description激光雷达坐标系名称 ), Node( packagelaunch_demo, executablelidar_node, namelidar_front, parameters[{frame_id: frame_id}] ), ])运行的时候就可以这样覆盖默认值ros2 launch launch_demo demo.launch.py frame_id:scan_frameDeclareLaunchArgument的default_value如果不写参数就是必填的不传会报错。我建议所有参数都给出默认值这样别人直接运行也不会失败。LaunchConfiguration是一个惰性替换对象它真正的取值是在launch真正执行时从启动上下文中读取的。这带来一个有意思的特性你可以把LaunchConfiguration直接传给Node的package参数实现动态指定功能包。4.2 命名空间与重映射多机器人系统或者需要隔离两组相同节点的时候命名空间很有用。还是拿雷达节点举例如果同一台机器上挂了两颗雷达它们的驱动代码一样节点名也一样那就不行了。用Group可以把它们放在不同命名空间下from launch.actions import GroupAction from launch_ros.actions import PushRosNamespace def generate_launch_description(): front_group GroupAction([ PushRosNamespace(front), Node( packagelaunch_demo, executablelidar_node, namelidar, ), ]) rear_group GroupAction([ PushRosNamespace(rear), Node( packagelaunch_demo, executablelidar_node, namelidar, ), ]) return LaunchDescription([front_group, rear_group])这样前雷达发布的话题就是/front/lidar/scan后雷达就是/rear/lidar/scan互不干扰。有些老资料会把PushRosNamespace和Group混着用其实记住一个结论就行PushRosNamespace是放在GroupAction里生效的它改变的是该分组内所有节点和话题的命名空间前缀。4.3 包含其他launch文件当你启动完整机器人系统时大概率需要复用别人的launch文件比如仿真器的启动文件、导航栈的启动文件、底盘驱动的启动文件不应该把这些内容复制粘贴到你自己的launch里否则升级依赖包的时候你的launch就崩了。正确做法是使用IncludeLaunchDescription。ROS2的标准写法是from launch.launch_description_sources import PythonLaunchDescriptionSource from launch.substitutions import ThisLaunchFileDir from launch.actions import IncludeLaunchDescription def generate_launch_description(): gazebo_launch IncludeLaunchDescription( PythonLaunchDescriptionSource([ ThisLaunchFileDir(), /gazebo.launch.py ]), launch_arguments{world: empty.world}.items() ) return LaunchDescription([gazebo_launch])这里用ThisLaunchFileDir()来获取当前launch文件所在的目录然后拼接出子launch的路径。这种方式比写绝对路径可靠得多因为别人下载你的包时目录可能完全不同。你还可以用find_package方式引入其他包里的launchfrom ament_index_python.packages import get_package_share_directory import os turtle_launch_dir get_package_share_directory(turtlesim) turtle_launch_file os.path.join(turtle_launch_dir, launch, turtlesim_node.launch.py)不过这种写法要注意如果turtlesim包本身没有安装launch文件到share目录os.path.join会得到一个不存在的路径此时启动会报错。4.4 条件与循环launch文件本质上是Python代码所以条件判断和循环可以直接写。比如我想根据一个布尔参数决定是否启动RVizfrom launch.actions import DeclareLaunchArgument from launch.substitutions import LaunchConfiguration, BooleanSubstitution from launch.conditions import IfCondition from launch_ros.actions import Node def generate_launch_description(): use_rviz LaunchConfiguration(use_rviz) return LaunchDescription([ DeclareLaunchArgument( use_rviz, default_valuetrue ), Node( packagerviz2, executablerviz2, namerviz2, conditionIfCondition(use_rviz) ), ])IfCondition接收一个LaunchConfiguration或者其他替换对象只有当它解析出的字符串是true时这个节点才会被启动。对应的还有UnlessCondition。BooleanSubstitution常用于需要把字符串转成布尔值的场景比如嵌套条件判断时。循环就更好用了。比如要启动4个话题发送节点可以直接在generate_launch_description里写for循环把Node塞到列表中def generate_launch_description(): nodes [] for i in range(4): nodes.append(Node( packagelaunch_demo, executablelidar_node, nameflidar_{i}, namespacefrobot{i} )) return LaunchDescription(nodes)这种灵活性在ROS1的XML launch里做不到也是我推荐大家直接学Python格式的最重要原因。5. 常见问题与排查技巧实录5.1 launch文件修改了需要重新编译吗这是新手问得最多的问题。你修改了launch文件的内容比如改了一个参数、加了一个节点直接在源码目录下再次运行ros2 launch看到的结果可能是旧的。原因有两层。第一层ros2 launch默认从安装目录install/launch_demo/share/launch_demo/launch/查找launch文件不是从你源码目录找。如果你修改的是源码目录里的launch文件如果没有重新编译安装运行时就找不到或者找到的是上一次编译安装的版本。第二层Python的launch文件不需要编译但需要被复制到安装目录。所以修改launch文件后请执行一次编译安装colcon build --packages-select launch_demo source install/setup.bash如果你觉得每次敲build太麻烦可以在源码目录下运行一个带--symlink-install的编译方式colcon build --symlink-install这个方式会在安装目录里创建符号链接指向源码文件后续改动launch文件只要重新source一下环境即可连build都可以省略。我自己的习惯是个人开发阶段一律用--symlink-installCI或者发布前再改成正常的build。5.2 提示找不到launch文件运行ros2 launch launch_demo demo.launch.py时如果提示找不到文件先不要急着怀疑路径写错。第一步确认包是否已经编译安装ls install/launch_demo/share/launch_demo/launch/看看有没有你的launch文件。第二步确认setup.py里是否加了data_files的glob规则。第三步重新source环境执行ros2 pkg prefix launch_demo看看包路径是不是指向当前工作空间。还有一个容易忽略的问题launch文件的命名必须以.launch.py结尾。ros2 launch在查找时会根据你给的名称自动拼接.launch.py后缀。如果你给的是demo.py它会去找demo.py.launch.py自然找不到。除非你使用绝对路径指定launch文件比如ros2 launch ~/ros2_ws/src/launch_demo/launch/demo.launch.py否则请按规定后缀命名。5.3 节点启动顺序与依赖问题ROS2节点之间是松耦合的launch文件并行启动所有节点理论上不保证哪一个先启动完成。如果你的代码逻辑里A节点必须在B节点话题发布之后才能正常工作你有几种处理方式。第一种代码里做好等待逻辑用rclpy的wait_for_service或订阅回调加超时机制。第二种在launch里使用TimerAction延迟启动某些节点from launch.actions import TimerAction TimerAction( period5.0, actions[Node(...)] )第三种使用OpaqueFunction在运行时主动检查话题、服务等是否就绪。这个方案更灵活但写起来稍复杂。我的建议是能不在launch层面等就不要等尽量让代码本身具备等待机制。launch层面的延迟是“脚本式”的如果启动环境变慢固定延迟可能不够又或者变快延迟浪费了启动时间不是根治方案。5.4 多工作空间source与launch查找顺序ros2 launch命令底层会根据环境变量AMENT_PREFIX_PATH去查找功能包。如果你在多个工作空间里source过不同版本的同名包启动时实际生效的是环境中排在前面那个。这个坑我踩过一次改完代码在A工作空间编译却忘了source A的setup.bash结果一直跑的是B工作空间的旧版本包报错报得莫名其妙。排查方法很简单运行ros2 pkg prefix launch_demo会告诉你当前解析到这个包的绝对路径再看一眼echo $AMENT_PREFIX_PATH里哪些路径在前面心里就有数了。source的顺序、shell的启动脚本比如~/.bashrc里是否自动source了旧工作空间都值得检查一遍。5.5 常见报错信息速查表场景报错信息常见原因解决方案找不到launch文件Package launch_demo not found未source环境或未编译安装colcon build后 source install/setup.bash找不到launch文件The launch file ... does not existsetup.py没有安装launch目录或文件名后缀不对检查data_files和.launch.py后缀找不到可执行文件executable lidar_node not foundsetup.py的entry_points未注册或包名不对检查console_scripts入口节点参数报错parameter frame_id not declared你没有调用declare_parameter在节点代码中声明参数启动卡住没有日志输出output未设置为screen给Node加outputscreen包含launch失败InvalidLaunchFileException被包含文件里没有generate_launch_description检查被包含launch文件本身能否直接运行6. 实战用launch一次启动Gazebo仿真和机器人状态发布6.1 场景描述写到这里我想用一个更贴近真实项目的例子收尾。假设你正在做一个差速小车仿真需要同时启动Gazebo仿真环境、机器人模型描述、状态发布节点和RViz。没有launch的时候你至少需要开4到5个终端。用launch之后一条命令就能全起来。这个实战其实也是我笔记里4.6节最原始的需求当时我在做fishbot仿真每次都要手动启动Gazebo、robot_state_publisher、joint_state_publisher和RViz后来我把这一整套写成了launch爽快多了。6.2 完整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, PathJoinSubstitution from launch_ros.actions import Node from ament_index_python.packages import get_package_share_directory def generate_launch_description(): pkg_share get_package_share_directory(launch_demo) # 声明是否启动Gazebo use_gazebo LaunchConfiguration(use_gazebo, defaulttrue) # 加载机器人模型文件xacro文件 urdf_path os.path.join(pkg_share, urdf, my_robot.urdf) # robot_state_publisher把URDF发布到TF robot_state_publisher Node( packagerobot_state_publisher, executablerobot_state_publisher, namerobot_state_publisher, outputscreen, parameters[{robot_description: urdf_path}] ) # joint_state_publisher发布关节状态 joint_state_publisher Node( packagejoint_state_publisher, executablejoint_state_publisher, namejoint_state_publisher, outputscreen ) # Gazebo启动使用gazebo_ros的gazebo.launch.py gazebo_launch IncludeLaunchDescription( PythonLaunchDescriptionSource(PathJoinSubstitution([ get_package_share_directory(gazebo_ros), launch, gazebo.launch.py ])), launch_arguments{ world: os.path.join(pkg_share, worlds, empty.world) }.items() ) # 将机器人模型写入Gazebo spawn_entity Node( packagegazebo_ros, executablespawn_entity.py, arguments[-topic, robot_description, -entity, my_robot], outputscreen ) # RViz rviz Node( packagerviz2, executablerviz2, namerviz2, outputscreen ) return LaunchDescription([ DeclareLaunchArgument( use_gazebo, default_valuetrue, description是否启动Gazebo仿真环境 ), robot_state_publisher, joint_state_publisher, gazebo_launch, spawn_entity, rviz, ])这段代码里有一个地方需要重点说明robot_description参数为什么会传一个URDF文件的路径在robot_state_publisher节点内部它会读取该参数如果参数是文件路径ROS2会尝试把它作为文件读取但如果传的是字符串内容它就把同步内容当作XML直接解析。这两种方式我在实际项目里都见过。如果希望启动时完全由launch控制可以把文件读成字符串再传with open(urdf_path, r) as f: robot_desc f.read() # 然后 parameters[{robot_description: robot_desc}]两种方式都行区别在于后面一种在launch执行阶段就完成了文件读取启动过程更稳定不容易受当前工作目录影响。7. 我踩过的坑和最终建议最近一次用launch做完整仿真启动我遇到过一个问题launch文件里同时启动了joint_state_publisher和robot_state_publisher但在RViz里怎么都看不到机器人的TF树。排查了一下午才发现joint_state_publisher默认发布的关节消息会被robot_state_publisher覆盖导致TF断断续续。最终解决方式是给joint_state_publisher设置use_sim_time为true并且确保Gazebo里的仿真时间被正确同步也就是在parameters里加上{use_sim_time: True}。这个问题不写出来可能很多人要折腾很久。还有一个体会是launch文件本身也是代码也应该做版本管理、写注释、保持结构清晰。我见过不少项目的launch文件写得像一坨乱麻所有内容全堆在一个巨型文件里几千行都不止。维护起来的痛苦程度不亚于维护一段面条式代码。我自己的原则是一个launch文件只负责一个主题比如robot_bringup.launch.py负责启动整机系统gazebo_sim.launch.py负责仿真多个launch之间用IncludeLaunchDescription串联而不是所有逻辑写在一个文件里。最后如果你刚开始学launch我建议从最小例子开始跑通先写一个只有单个节点的launch文件再逐步加参数、命名空间、包含、条件。不要一上来就抄整车的launch文件里面的OpaqueFunction、RegisterEventHandler、OnProcessExit这些高级特性初学者看两遍基本是懵的。等基础版本的launch文件能顺畅跑起来再去看那些复杂的官方launch就会觉得豁然开朗。launch这套东西一旦上手你会发现开发效率的提升不是一点半点。
返回列表