前阵子调一台用ESP32做串口桥接的小车,launch文件里挂了七八个节点。跑起来之后终端直接刷屏:slam_toolbox的warn、navigation的info、裁判系统的debug全混在一起,想找一条关键error得靠肉眼在几百行里翻。我第一反应是去改每个节点的源码,后来发现根本不用——ROS2的启动文件本身就带了一整套日志配置能力,只是平时教程很少往这个方向讲。这篇文章就把我在launch文件里做日志配置的完整经验写出来,涵盖日志级别、输出格式、日志落盘和踩坑排查,用的是ROS2 humble及以后版本,适合做机器人或仿真项目时被日志困扰的朋友参考。
1. 为什么日志这种事要拿到launch文件里来管
很多人习惯直接在节点代码里写日志逻辑:RCLCPP_INFO、RCLCPP_WARN满天飞,级别写死,格式默认,输出到终端就完事。单节点demo这么干没问题,一旦上了多节点系统,问题就全冒出来了。
首先是不统一。每个节点默认只输出INFO级别以上的日志,但有人会把RCLCPP_DEBUG埋在核心算法里,有人会习惯把状态变化全用RCLCPP_INFO打出来。项目跑起来,有人想看调试信息得重新编译,有人被无用的info刷屏又不知道怎么临时关。这些在代码里解决特别痛苦,因为每次调整都要改源码、重新build、重新部署。
其次是无心插柳:launch文件是这个系统里唯一一个所有节点启动前都会经过的"装配点"。节点还没起来,你就能在这里注入环境变量、传命令行参数、决定输出去哪。日志配置本质上就是"节点启动前的运行时设置",放在launch里既不用动代码,又能做到全局统一。
我把launch文件里能管的日志相关配置拆成三个控制面,这样理解起来最清晰:
| 控制面 | 具体控制内容 | 配置手段 |
|---|---|---|
| 日志级别 | 每个logger输出debug/info/warn/error/fatal的阈值 | 节点arguments里的__log_levelremap规则 |
| 日志格式 | 每条日志的时间、级别、节点名、文件名、行号等显示方式 | RCUTILS_CONSOLE_OUTPUT_FORMAT等环境变量 |
| 日志去向 | 终端显示、文件落盘、写入/rosout话题 | launch的output选项、重定向、bag记录 |
这三个控制面全部可以在launch文件里完成,而且不需要改一行C++代码。这不只是方便,更重要的是让"日志策略"和"业务逻辑"彻底解耦:代码里只管打日志,怎么显示、显示到哪、显示多详细,全由launch文件在启动时决定。
2. 日志级别是怎么一路传到节点的:一条容易忽略的链路
想用好launch文件里的日志配置,不能只知道"这么写就能行",还得知道这条链路到底怎么走通的。否则遇到"明明设置了debug为什么不生效"时毫无头绪。
先说底层架构。ROS2的日志系统分三层:最上面是rclcpp提供的RCLCPP_INFO这些宏,中间是rcl_logging接口层,默认后端走spdlog,最底层是RCUTILS库。你在代码里写RCLCPP_DEBUG时,宏内部会先检查当前logger的启用阈值,如果信息的级别低于阈值,直接丢弃,连格式化都不做,这是为了减少性能开销。
日志级别在RCUTILS里是整数:
| 日志级别 | 数值 | 说明 |
|---|---|---|
| UNSET | 0 | 未设置,采用默认规则 |
| DEBUG | 10 | 调试信息,默认不显示 |
| INFO | 20 | 普通信息,默认显示 |
| WARN | 40 | 警告 |
| ERROR | 60 | 错误 |
| FATAL | 70 | 致命错误 |
关键点来了:ROS2节点可执行文件在启动阶段会解析命令行参数,其中--ros-args -r后面跟的remap规则会被放进全局的remap表。__log_level是ROS2内置的特殊remap键,节点初始化日志系统时会去查这张表,把对应的日志级别设置到RCUTILS。
这就是很多人容易掉进去的坑:日志级别是通过remap规则传的,不是通过parameter传的。你写Node(parameters=[{'log_level': 'debug'}])一点用都没有,因为普通参数回调是在日志系统初始化之后才触发的,而RCUTILS需要在一开始就知道全局日志阈值。
__log_level的完整语法是__log_level:=[logger:]level,有几种常见用法:
--ros-args -r __log_level:=debug:设置所有logger为debug--ros-args -r __log_level:=my_logger:=warn:只把名为my_logger的logger设为warn--ros-args -r __log_level:=/node_name:=error:按节点的全限定名设置
logger名是有层级结构的。节点的默认logger名通常是节点全限定名,比如/nav2/controller_server。如果你针对/nav2设置级别,理论上它的子logger会继承这个设置。实际开发中我更喜欢显式指定节点名,避免父级logger的匹配规则混乱。
理解了这条链路,再去看launch文件里怎么写,心里就有底了:launch文件中Node的arguments参数,本质上就是拼接到节点启动命令后面的参数列表,语法和命令行完全一致。
3. launch文件里设置日志级别的三种写法
代码示例是这篇的重头戏。不管你是刚接触launch还是已经写过不少launch,下面的写法基本覆盖了日常所有场景。
3.1 硬编码写法:最快看见效果
最粗暴的写法,直接往Node的arguments里塞固定字符串:
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', arguments=['--ros-args', '-r', '__log_level:=debug'], ), ])这种写法的优点是直观,适合临时调试。缺点是完全写死,换个级别就要改文件。而且注意这里只对talker这一个节点生效,其他节点该怎么刷屏还怎么刷屏。我的建议是拿它做最小验证,确认你的日志系统链路没问题再往工程化方向走。
3.2 通过launch参数动态传入:最推荐的工程化写法
核心思路是先用DeclareLaunchArgument声明一个可配置参数,再用LaunchConfiguration动态替换到Node的arguments里:
from launch import LaunchDescription from launch.actions import DeclareLaunchArgument from launch.substitutions import LaunchConfiguration from launch_ros.actions import Node def generate_launch_description(): return LaunchDescription([ DeclareLaunchArgument( 'log_level', default_value='info', description='全局日志级别: debug/info/warn/error/fatal' ), Node( package='demo_nodes_cpp', executable='talker', name='talker', arguments=['--ros-args', '-r', ['__log_level:=', LaunchConfiguration('log_level')]], ), Node( package='demo_nodes_cpp', executable='listener', name='listener', arguments=['--ros-args', '-r', ['__log_level:=', LaunchConfiguration('log_level')]], ), ])这里有个非常容易踩的细节:arguments列表里的元素,不能用f-string去拼接LaunchConfiguration对象。比如f'__log_level:={LaunchConfiguration("log_level")}'这种写法是错的——LaunchConfiguration是一个Substitution对象,它必须等到launch运行时才能求值,f-string在Python解析阶段就把它变成了字符串表示,传进去的是一个无效文本。正确做法是把它们放进一个列表:['__log_level:=', LaunchConfiguration('log_level')],launch系统会把列表当作Substitution的组合,运行时逐个求值拼成一个完整字符串。
启动时这样用:
ros2 launch my_package talker_listener.launch.py log_level:=debug如果想看这个launch文件支持哪些参数,可以直接执行:
ros2 launch my_package talker_listener.launch.py --show-args运行后会列出所有已声明的launch参数、默认值和描述,这个命令在多人协作时特别有用,别人不用读源码就能知道怎么调你的launch。
3.3 差异化设置:不同节点不同级别
实际项目里很少需要所有节点都开debug。比如串口桥接节点要debug看握手报文,导航节点只需要warn以上。这时可以对每个Node分别设置,同时用remap规则的优先级细化某个logger:
Node( package='serial_bridge', executable='serial_bridge', name='serial_bridge', arguments=[ '--ros-args', '-r', '__log_level:=debug', '-r', '__log_level:=serial_driver:=warn', ], ),第二行__log_level:=serial_driver:=warn表示:全局设为debug,但名为serial_driver的logger只输出warn以上。这种"全局粗调+局部细调"的组合用起来很灵活,代码里不需要任何改动就能对不同模块做差异化日志控制。
注意,如果你在代码里手写了rclcpp::Logger::set_level(),那启动参数会被覆盖。这一点我放在后面"排查清单"里详细说,因为它真的是最容易让人怀疑人生的坑。
4. 日志格式和输出通道:RCUTILS_* 环境变量的正确姿势
搞定了级别,下一个问题是格式。ROS2控制日志格式的核心是一组以RCUTILS_开头的环境变量。这些环境变量不依赖代码,可以在launch文件里用SetEnvironmentVariable统一设置。
常用变量如下:
| 环境变量 | 作用 | 常用值 |
|---|---|---|
RCUTILS_CONSOLE_OUTPUT_FORMAT | 控制每条日志的显示格式 | [{severity}] [{name}]: {message} |
RCUTILS_COLORIZED_OUTPUT | 是否启用颜色高亮 | 1启用,0关闭,默认AUTO |
RCUTILS_LOGGING_BUFFERED_STREAM | 是否使用缓冲输出 | 1启用,多线程下防日志穿插 |
RCUTILS_LOGGING_USE_STDOUT | 是否输出到stdout | 1输出到stdout,默认输出到stderr |
RCUTILS_CONSOLE_OUTPUT_FORMAT支持的占位符很全,常用的有:
{time}:时间戳{severity}:日志级别名(DEBUG/INFO/WARN/ERROR/FATAL){name}:logger名{message}:日志正文{function_name}:产生日志的函数名{file_name}:源文件名{line_number}:行号{thread_id}:线程ID
我项目的launch文件里通常这样设置:
from launch.actions import SetEnvironmentVariable SetEnvironmentVariable( 'RCUTILS_CONSOLE_OUTPUT_FORMAT', '[{time}] [{severity}] [{name}]: {message}' ), SetEnvironmentVariable('RCUTILS_COLORIZED_OUTPUT', '1'), SetEnvironmentVariable('RCUTILS_LOGGING_BUFFERED_STREAM', '1'),加上时间戳之后,排查问题时能直接看出某个warning发生的时间点,配合bag回放非常方便。初次排查bug时我会把{file_name}和{line_number}也加进去,定位会快很多,缺点就是每条日志变长,终端刷起来更占空间。
输出到stdout和stderr的区别值得提一下。ROS2默认日志走stderr,好处是终端重定向时可以把正常输出和日志分开。如果你用2>&1这种命令把stderr合并进stdout,那这个变量不用管。但如果你想在日志里过滤出特定级别或者做管道处理,就要清楚这个默认行为。
RCUTILS_COLORIZED_OUTPUT默认是AUTO,只有在检测到终端支持颜色时才启用。如果你把日志重定向到文件,或者通过CI管道跑launch,颜色会被自动关闭,这是正常现象,别误以为配置失效了。我一般在launch文件里显式设成1,因为本地调试多半是交互式终端,显式设置后行为更可预测。
还有一个习惯:在Nodeaction里通过env参数给单个节点配置环境变量,作用范围更精准:
Node( package='my_package', executable='my_node', name='my_node', env={'RCUTILS_COLORIZED_OUTPUT': '0'}, ),如果和全局SetEnvironmentVariable冲突,Node的env会覆盖全局设置。这个局部覆盖的机制很适合那些对格式有特殊要求的节点。
5. 日志落盘:不写代码也能把运行日志保存下来
日志如果不能保存,出了问题只能靠运气。ROS2系统里日志落盘有几个层次,我按从简单到复杂的顺序说一下。
最简单的是在启动命令层面做重定向:
ros2 launch my_package robot.launch.py 2>&1 | tee run.log2>&1把stderr合并到stdout,tee同时输出到终端和文件。缺点是这个文件是纯文本流,节点marker、时间戳都靠自己解析,而且launch被Ctrl+C中断时,tee也可能跟着退出,日志末尾往往会缺失。应急够用,长期不推荐。
第二层是launch自带的日志记录。ROS2 launch本身会在~/.ros/log/目录下留下launch记录,里面对应每次launch任务有一个目录,记录launch进程的stdout和stderr。更重要的是,ExecuteProcess支持output='log'选项:
from launch.actions import ExecuteProcess ExecuteProcess( cmd=['ros2', 'bag', 'record', '/rosout', '-o', 'logs/rosout'], output='log', ),output='screen'是输出到终端,output='log'则是把进程输出写入launch的日志目录。在launch里跑ros2 bag record时尤其有用——你总不希望bag记录时的日志和系统日志混在一起刷屏。
第三层是系统级的/rosout话题。这是我最喜欢的方式:ROS2默认每个节点的日志消息都会以rcl_interfaces/msg/Log消息发布到/rosout话题。这意味着你不需要任何文件,直接用话题机制存档:
ros2 topic echo /rosout或者用rqt_console图形化过滤。最实用的是用ros2 bag record /rosout把所有日志消息连同其他话题一起存成bag文件。系统出问题时,回放bag就能还原当时每个节点的日志顺序,配合时间戳比对传感器数据和算法输出,排查效率提升一个档次。
三种落盘方式对比:
| 方式 | 适用场景 | 缺点 |
|---|---|---|
2>&1 | tee | 临时跑一次,快速留底 | 格式混乱,中断易丢尾部 |
ExecuteProcess output='log' | launch内部子进程落盘 | 需要手动找日志目录位置 |
ros2 bag record /rosout | 长期调试、故障复现 | 需要额外管理bag文件 |
我个人现在的标准做法是:日常开发用/rosout话题加rqt_console,每次联调跑系统时开一个bag只录/rosout,出问题先翻bag再说。等定位到具体节点,再针对那个节点单独开debug并配合文件重定向。
6. 嵌套launch和Group里,日志配置的优先级与继承问题
真实项目里没人只写一个扁平launch,基本都会拆成多个launch文件互相include,或者用GroupAction给一组节点加命名空间和参数。这时候日志配置的继承和优先级就成了大问题。
先说环境变量。launch进程本身是一个Python进程,所有Node子进程都是从这个Python进程fork出去的。所以你在launch文件里用SetEnvironmentVariable设置的环境变量,会传递给之后创建的所有子进程。这个"之后"就是顺序问题:如果你把SetEnvironmentVariable写在某个Node的后面,那这个Node启动时是看不到这个变量的。实际开发中我习惯把所有的环境变量配置都放在LaunchDescription列表的最前面,顺序从源头就保证正确。
嵌套include的场景要特别注意。比如你有一个base.launch.py被多套上层launch include,如果上层launch里设置了RCUTILS_CONSOLE_OUTPUT_FORMAT,而base.launch.py内部自己没设置,那子launch里的Node会继承上层的设置,因为它们是同一个Python进程。但如果base.launch.py内部显式设置了另一个格式,那内部的设置会覆盖外层。覆盖规则是:后执行的SetEnvironmentVariable覆盖先执行的,而不是"外层覆盖内层"这种直觉。
LaunchConfiguration的传递也不复杂。子launch如果要读取上层传入的log_level,需要在子launch里自己DeclareLaunchArgument,并且用相同名字。include时通过launch_arguments传参:
from launch.actions import IncludeLaunchDescription from launch.launch_description_sources import PythonLaunchDescriptionSource IncludeLaunchDescription( PythonLaunchDescriptionSource('path/to/base.launch.py'), launch_arguments={ 'log_level': LaunchConfiguration('log_level'), }.items(), ),如果不知道子launch支持哪些参数,还是老办法:先对子launch跑一次--show-args。
另一个优先级问题是remap设置。多个Node共用一个logger名时,后启动的节点会把前一个节点的日志级别覆盖掉。比如两个节点都是用包默认的logger名,你在第一个Node里设了__log_level:=debug,又在第二个Node里设了__log_level:=warn,那么当第二个节点启动时,全局的__log_levelremap规则被覆盖成warn,第一个节点也受影响。这也是我为什么建议尽量用节点全限定名或者差异化logger名来设置级别,别所有节点都依赖全局__log_level。
最后是代码优先级。任何launch配置都干不过代码里显式的日志级别设置。如果源码里写了:
rclcpp::Logger logger = rclcpp::get_logger("my_logger"); logger.set_level(rclcpp::Logger::Level::Debug);那无论launch里怎么设置,这个logger都会被代码强制改成debug。解决办法是别在代码里写死级别,把级别决策完全交给launch文件。我接手过的不少项目都有这种"配置不生效"的问题,最后查来查去都是代码里set_level或环境变量在作怪。
7. 日志配置不生效的排查清单
配置写了但实际没效果,这是群里被问得最多的问题。我把自己排查这类问题时的完整链路写在这里,按步骤执行基本都能定位到根因。
第一步是确认目标进程真的拿到了你设置的环境变量。别靠猜,直接看进程环境:
ps aux | grep serial_bridge cat /proc/<PID>/environ | tr '\0' '\n' | grep RCUTILS如果RCUTILS_CONSOLE_OUTPUT_FORMAT没出现在输出里,说明launch里的环境变量没传递到这个进程,优先检查SetEnvironmentVariable的位置是否在Node之前,以及Node是否有自己的env参数覆盖了它。
第二步是确认命令行参数真的传进去了。把launch文件里那个Node的arguments单独拿出来,在终端里手动执行一次:
ros2 run serial_bridge serial_bridge --ros-args -r __log_level:=debug如果手动执行能看到debug日志,说明节点本身没问题,问题出在launch的arguments构造上。这时重点检查是不是用了f-string拼接LaunchConfiguration——这个问题我前面专门说过,出错率极高。
第三步是确认目标logger级别有没有被代码覆盖。全局搜一下源码里的set_level、RCLCPP_*_ONCE、logger相关初始化代码,特别是自定义Logger的地方。很多时候不是launch配置问题,是代码里写死了。
第四步是区分颜色和格式。如果你只是改了RCUTILS_COLORIZED_OUTPUT没看到颜色,先确认终端是不是支持彩色输出,可以用echo $TERM看看。如果你改了RCUTILS_CONSOLE_OUTPUT_FORMAT没看到格式变化,确认变量名拼写是否正确,这个环境变量名很长,我至少犯过三次拼写错误。
第五步是确认日志级别的大小写形式。ROS2对日志级别名的解析不区分大小写,debug和DEBUG都能识别。但如果你在DeclareLaunchArgument的default_value里写了Debug这种混合大小写,部分版本可能在参数校验时报错。我的习惯是统一小写:debug/info/warn/error/fatal。
第六步,也是最容易被忽略的:launch文件改完以后,你重启launch了吗?这不是玩笑,launch文件一启动就被加载进Python进程,改文件不重启不会生效。有些开发者改完源码会重新build,但改launch文件却忘了重启launch进程,然后排查半天环境变量。
如果以上步骤都查了还是没效果,还有一个大招:直接用ros2 doctor检查运行环境,它能报告不少ROS2层面的配置问题。虽然不是专门查日志的,但偶尔能发现影响日志系统的底層问题。
8. 一份可以直接抄的多节点日志管理launch示例
最后上一份完整的launch文件,是我做小车项目时的实际结构简化版。把它保存成robot.launch.py,放在你的bringup包里就能用:
from launch import LaunchDescription from launch.actions import DeclareLaunchArgument, ExecuteProcess, SetEnvironmentVariable from launch.substitutions import LaunchConfiguration from launch_ros.actions import Node def generate_launch_description(): return LaunchDescription([ DeclareLaunchArgument( 'log_level', default_value='info', description='日志级别: debug/info/warn/error/fatal' ), DeclareLaunchArgument( 'robot_namespace', default_value='', description='机器人命名空间' ), # 环境变量统一放在最前面,保证所有Node都能继承 SetEnvironmentVariable( 'RCUTILS_CONSOLE_OUTPUT_FORMAT', '[{time}] [{severity}] [{name}]: {message}' ), SetEnvironmentVariable('RCUTILS_COLORIZED_OUTPUT', '1'), SetEnvironmentVariable('RCUTILS_LOGGING_BUFFERED_STREAM', '1'), # 串口桥接节点:调试时需要看底层报文,所以默认级别独立设置 Node( package='serial_bridge', executable='serial_bridge', name='serial_bridge', namespace=LaunchConfiguration('robot_namespace'), arguments=[ '--ros-args', '-r', ['__log_level:=', LaunchConfiguration('log_level')], '-r', '__log_level:=serial_driver:=warn', ], ), # 导航控制器:正式运行时只看warn以上 Node( package='nav2_controller', executable='controller_server', name='controller_server', namespace=LaunchConfiguration('robot_namespace'), output='screen', arguments=[ '--ros-args', '-r', ['__log_level:=', LaunchConfiguration('log_level')], '-r', '__log_level:=local_planner:=warn', ], ), # 把/rosout话题落盘成bag,方便事后排查 ExecuteProcess( cmd=['ros2', 'bag', 'record', '/rosout', '-o', 'logs/rosout'], output='log', ), ])启动方式:
ros2 launch my_robot_bringup robot.launch.py log_level:=debug robot_namespace:='/devbot'调参时不用改文件,直接在命令行覆盖。想看日志实时输出,执行:
ros2 topic echo /rosout想图形化过滤,打开rqt_console。想查历史日志,去logs/rosout目录回放bag。这套组合拳下来,大多数日志相关的开发调试场景都能覆盖。
最后分享一个我自己的习惯。项目初期调试阶段,我会把log_level默认值设成debug,配合[{time}] [{severity}] [{name}]: {message}格式,把所有细节摊开看。等系统跑稳定了,再切回info,把格式里的{function_name}去掉,减少无效信息。真出了问题时,第一件事不是改代码重新编译,而是先打开bag回放/rosout,把出问题前十分钟每个节点的日志顺序捋一遍,往往问题就藏在那几行warning里。这个习惯帮我省了太多冤枉时间,希望对你也一样有用。