调试嵌入式ROS2系统的难点在于:节点一旦崩溃,你连打印都来不及看到。我在调试一台搭载STM32H7的微ROS节点时遇到过——rcl_publish 调用后系统直接硬fault,板子复位,串口只留下半行乱码。后来才发现是消息缓冲区溢出了栈。从那以后我意识到,日志系统不是"锦上添花",而是嵌入式ROS2开发的最后一道防线。
想象你在厨房做一道菜,同时给朋友打电话口述步骤。日志系统就像那个电话——你每切一个菜、放一次盐都报出来,朋友那边可以选择只听关键步骤(警告级别),也可以要求你说出每个细节(调试级别)。如果朋友在电话那头录了音,那就是日志持久化;如果他根据你的描述立刻给出建议,那就是日志回调。
在ROS2中,日志系统由RCUTILS库提供底层支持,所有日志输出统一经过格式化、分级过滤,再分发到控制台、文件或用户自定义的回调函数。
上层应用调用 RCLCPP_INFO 或 RCLCPP_DEBUG 等宏,这些宏经过 rcl_logging 接口转发到底层RCUTILS。RCUTILS根据当前设置的日志级别做过滤,只有级别不低于阈值的日志才会继续往下走,最终写入输出后端。整个过程不需要用户手动管理缓冲区,但理解这条链路对排查日志丢失问题很关键。
| 日志级别 | 数值 | 典型用途 | 嵌入式建议 |
|---|---|---|---|
| DEBUG | 10 | 详细诊断信息,如每个循环的传感器值 | 发布前关闭,避免拖慢主循环 |
| INFO | 20 | 普通流程提示,如节点启动完成 | 保留少量关键状态打印 |
| WARN | 30 | 非致命异常,如通信超时后重试 | 建议保留,可作为健康指标 |
| ERROR | 40 | 致命错误,如驱动初始化失败 | 必须保留,触发错误处理逻辑 |
| FATAL | 50 | 不可恢复的系统崩溃 | 无条件输出,通常配合系统复位 |
优先级从DEBUG到FATAL递增。嵌入式环境下,建议将默认日志级别设为WARN,只在调试阶段临时降到DEBUG。我遇到过的一个坑:在循环中加了一行 RCLCPP_DEBUG 用于监视变量,结果导致控制台输出过多,DMA缓冲区溢出,最终看门狗复位——而复位前最后一条日志恰好是那条DEBUG信息,容易误导排查方向。
--ros-args --log-level debugtarget_compile_definitions(... PRIVATE RCLCPP_LOG_MIN_SEVERITY=RCLCPP_LOG_MIN_SEVERITY_WARN)运行时配置灵活但不节省资源——所有日志宏仍然被编译进固件,只是运行时不输出。编译时配置能真正剔除低级别日志代码,对于仅有256KB Flash的MCU来说,差别可能在20KB以上。我的建议是:开发阶段用运行时配置,发布前切到编译时配置并设置级别为WARN。
RCLCPP_DEBUG(rclc:get_logger("my_node"), "data: %s", complex_string_serialize(large_msg)),这里的 complex_string_serialize 每次都会执行,白白消耗CPU。正确做法是将计算移到 if 块内。RCLCPP_* 与 printf——printf 直接输出到标准输出,不经过日志系统,无法被 ros2 doctor 等工具捕获,且格式不统一。嵌入式平台上的 printf 还可能占用不同的硬件串口,导致调试信息散落。rcl_logging_register_output_handler,传入自定义回调函数,在回调中把格式化后的字符串通过DMA发送出去。这样日志输出不占用CPU,即使主线程卡死,最后几行日志也能完整送出。但要注意回调函数必须是线程安全的——加一个简单的环形缓冲区就能避免锁竞争。
嵌入式场景下,你往往需要把日志输出到自己的调试通道,而不是标准控制台。这时可以用RCUTILS提供的回调注册机制。
// 自定义日志输出回调函数
void my_log_handler(const rcutils_log_location_t *location,
int severity, const char *name,
const char *timestamp, const char *msg) {
// 将日志格式化为协议帧,通过UART DMA发送到调试上位机
static char buf[256];
int len = snprintf(buf, sizeof(buf), "[%s][%s] %s\r\n",
timestamp, name, msg);
dma_uart_send(buf, len);
}
// 在main函数中注册
rcutils_logging_set_output_handler(my_log_handler);
这段代码注册了一个全局输出处理器,此后所有日志(包括来自rcl和rclc内部库的)都会经过 my_log_handler 输出。参数中的 severity 是原始整数级别,你可以在回调里再做一次过滤,比如只把ERROR和FATAL级别的日志通过更高优先级的通道发送。
ROS2支持按日志名称做细粒度过滤。每个节点或模块可以拥有独立的日志器名称,名称用点号分隔形成层级关系。例如:
RCLCPP_INFO(rclc:get_logger("sensor.lidar"), "scan started");
RCLCPP_WARN(rclc:get_logger("sensor.imu"), "calibration offset high");
通过命令行 --log-level sensor.lidar:=debug 可以只打开某个子模块的调试日志,而不影响其他模块。这个特性在定位某个特定功能模块的问题时特别有用——不用在一堆日志里大海捞针。
ros2 doctor 做健康检查:该工具会检查节点、话题、服务、参数等状态,输出结构化的诊断报告ros2 topic echo 与日志:当怀疑某个话题数据异常时,同时开启话题监听和对应节点的DEBUG日志,将数据流与内部状态对照分析日志输出只是第一步,如何从海量日志中快速定位问题才是核心。嵌入式ROS2环境下推荐以下工具链组合:
我通常的做法是:在PC端用Python脚本实时读取UART日志流,按时间戳和日志级别分别存入CSV文件。然后结合 ros2 bag 记录的rosbag数据做时间戳对齐——把日志中的事件点与话题数据的变化曲线画在同一张图上,一眼就能看出因果关系。
在现有的嵌入式ROS2节点中添加自定义日志输出回调:
main.c 中实现一个简单的回调函数,将日志输出到一个GPIO控制的LED上(ERROR级别闪烁两次,FATAL级别常亮)rcutils_logging_set_output_handler 注册这个回调RCLCPP_FATAL 日志,观察LED行为WARN,再次触发,验证FATAL日志仍然正常输出提示:在嵌入式平台上,printf 是阻塞的,不要在回调中使用 printf;用GPIO翻转代替。
RCLCPP_DEBUG 宏即使在日志级别设为WARN时也会执行参数表达式。sensor.lidar 模块的调试日志?RCLCPP_LOG_MIN_SEVERITY--ros-args --log-level sensor.lidar:=debugrclc:get_logger("sensor.lidar").set_level(rclc:Logger::Level::Debug)
RCLCPP_ERROR 输出日志。