Skip to content

获取 Backtrace

目标: 介绍在 ROS 2 中获取 backtrace(回溯)的多种方法

教程级别: 中级

预计时间: 15 分钟

本节将介绍 ROS 2 用户在遇到问题时如何获取 backtrace。

什么是 Backtrace?

  • 可以把程序想象成一摞煎饼,每一张代表一个正在执行的函数。Backtrace 就像是这摞煎饼倒塌后拍下的照片,记录了它们原本的排列顺序,揭示程序是如何一步步走向崩溃的。
  • 它列出了函数的调用序列,层层嵌套,直到崩溃发生的那个点。

为什么它有用?

  • 精确定位问题: 无需猜测代码哪里出错,backtrace 能直接指出导致崩溃的确切行号。
  • 揭示上下文: 你可以看到导致崩溃的完整调用链(一个函数调用另一个函数),不仅帮你定位出错位置,还能帮你理解出错原因。

直观类比:一摞煎饼

  1. 每张煎饼是一个函数:煎饼堆中的每一张煎饼代表程序正在执行的一个函数。最底部的煎饼是你的 main() 函数,也就是一切的起点。

  2. 叠加煎饼:每当一个函数调用另一个函数,就会有一张新煎饼叠到顶部。

  3. 崩溃:崩溃就像盘子从底部滑出——当前正在执行的函数发生了严重错误。

  4. Backtrace:Backtrace 就像这摞煎饼倒塌后拍下的照片,从上到下展示了煎饼(函数)的顺序,揭示你是如何一步步到达崩溃位置的。

代码示例:

void functionC() {
// Something bad happens here, causing a crash
}
void functionB() {
functionC();
}
void functionA() {
functionB();
}
int main() {
functionA();
return 0;
}

崩溃时的 Backtrace:

Terminal window
#0 functionC() at file.cpp:3 // Crash occurred here
#1 functionB() at file.cpp:8
#2 functionA() at file.cpp:13
#3 main() at file.cpp:18

Backtrace 如何帮助调试:

  • 崩溃来源: 显示 functionC() 中触发崩溃的确切行号。
  • 调用序列: 揭示 main() 调用了 functionA(),后者调用了 functionB(),最终引发 functionC() 中的错误。

通过上面的例子,我们对 backtrace 及其作用有了直观的认识。接下来介绍遇到问题时如何从特定节点获取回溯信息。本教程适用于仿真机器人和实体机器人。

本教程涵盖三种场景:使用 ros2 run 从特定节点获取 backtrace、从单个节点的 launch 文件获取 backtrace,以及从更复杂的多节点 launch 配置中获取 backtrace。学完本教程后,你应该能够在 ROS 2 节点崩溃时获取 backtrace。

GDB 是 Unix 系统上最流行的 C/C++ 调试器,可以用来定位崩溃原因、跟踪线程执行,还可以在代码中设置断点,检查程序在特定位置时内存中的值。

掌握 GDB 是所有 C/C++ 软件开发者的重要技能。许多 IDE 内置了调试器或性能分析器,但了解底层工具的用法、不完全依赖 IDE 非常重要。如果换了工作环境没有 IDE 可用,或者需要通过 SSH 连接到远程设备调试,完全依赖 IDE 就会遇到困难。

幸运的是,掌握了基础知识后,使用 GDB 并不难。以下是确保你的 ROS 2 代码准备好调试的方法:

  • 使用 --cmake-args:添加调试符号最简单的方法是在 colcon build 命令中指定 --cmake-args -DCMAKE_BUILD_TYPE=Debug:
Terminal window
$ colcon build --packages-up-to <package_name> --cmake-args -DCMAKE_BUILD_TYPE=Debug
  • 编辑 CMakeLists.txt:另一种方法是在你要分析或调试的 ROS 包的编译器标志中添加 -g。该标志会生成 GDB 可读取的调试符号,让你知道项目中具体哪一行代码出了问题以及原因。如果不设置此标志,仍然可以获得 backtrace,但不会包含失败位置的行号。

现在你已经准备好调试代码了!如果这是一个非 ROS 项目,你可能会这样做:启动一个 GDB 会话并让程序立即运行。程序崩溃后会返回 GDB 提示符 (gdb),你可以在此查看所需信息。然而,ROS 项目通常涉及大量节点配置和其他设置,这种方式对初学者或不太熟悉命令行操作的人来说并不方便。

Terminal window
$ gdb ex run --args /path/to/exe/program

以下各节描述了基于 ROS 2 的系统中你可能遇到的三种主要场景。请选择最符合你情况的那一节。

要在启动 ROS 2 节点之前设置 GDB 会话,可以使用 --prefix 选项,用法如下:

注意:一个 ROS 2 可执行文件可能包含多个节点。--prefix 方法确保你调试的是进程中的正确节点。

为什么直接使用 GDB 可能会有问题

--prefix 会在 ROS 2 命令之前执行一段代码,允许我们插入调试信息。如果你像前文示例那样直接执行 gdb ex run --args ros2 run <pkg> <node>,会发现 GDB 找不到 ros2 命令。同样,尝试在 GDB 中 source 你的工作空间也会因类似原因而失败。这是因为以这种方式启动的 GDB 缺少让 ros2 命令可用的环境设置。

使用 —prefix 简化流程

与其手动查找可执行文件的安装路径再逐字输入,不如使用 --prefix。这样就能继续使用你习惯的 ros2 run 语法,无需操心 GDB 的细节。

Terminal window
$ ros2 run --prefix 'gdb -ex run --args' <pkg> <node> --all-other-launch arguments

GDB 体验

与之前一样,这个 prefix 会启动一个 GDB 会话,并使用所有额外的命令行参数运行你请求的节点。现在你应该有了运行中的节点,并且可以看到调试输出。

使用 GDB 获取 backtrace 后,以下是解读方法:

  • 从底部开始看:Backtrace 按逆时间顺序列出函数调用,底部的函数是崩溃的源头。

  • 沿着堆栈向上追踪:上面每一行代表调用了下面函数的那个函数。向上追踪直到到达你自己项目中的代码行,这通常能揭示问题的起始点。

  • 调试线索:函数名及其参数可以提供关于问题原因的宝贵线索。

节点崩溃后如何调试

节点崩溃后,你会看到类似下面的提示符。此时你可以获取 backtrace。

Terminal window
(gdb)

在此会话中输入 backtrace,即可获得 backtrace 信息,根据需要复制即可。

Backtrace 示例

Terminal window
(gdb) backtrace
#0 __GI_raise (sig=sig@entry=6) at ../sysdeps/unix/sysv/linux/raise.c:50
#1 0x00007ffff79cc859 in __GI_abort () at abort.c:79
#2 0x00007ffff7c52951 in ?? () from /usr/lib/x86_64-linux-gnu/libstdc++.so.6
#3 0x00007ffff7c5e47c in ?? () from /usr/lib/x86_64-linux-gnu/libstdc++.so.6
#4 0x00007ffff7c5e4e7 in std::terminate() () from /usr/lib/x86_64-linux-gnu/libstdc++.so.6
#5 0x00007ffff7c5e799 in __cxa_throw () from /usr/lib/x86_64-linux-gnu/libstdc++.so.6
#6 0x00007ffff7c553eb in ?? () from /usr/lib/x86_64-linux-gnu/libstdc++.so.6
#7 0x000055555555936c in std::vector<int, std::allocator<int> >::_M_range_check (
this=0x5555555cfdb0, __n=100) at /usr/include/c++/9/bits/stl_vector.h:1070
#8 0x0000555555558e1d in std::vector<int, std::allocator<int> >::at (this=0x5555555cfdb0,
__n=100) at /usr/include/c++/9/bits/stl_vector.h:1091
#9 0x000055555555828b in GDBTester::VectorCrash (this=0x5555555cfb40)
at /home/steve/Documents/nav2_ws/src/gdb_test_pkg/src/gdb_test_node.cpp:44
#10 0x0000555555559cfc in main (argc=1, argv=0x7fffffffc108)
at /home/steve/Documents/nav2_ws/src/gdb_test_pkg/src/main.cpp:25

在这个示例中,你应该从底部开始阅读:

  • 在 main 函数的第 25 行,调用了 VectorCrash 函数。

  • 在 VectorCrash 的第 44 行,调用了 Vector 的 at() 方法(传入值为 100)并在此崩溃。

  • 实际崩溃发生在 STL vector 第 1091 行的 at() 方法中,原因是范围检查失败后抛出了异常。

阅读堆栈跟踪需要一些练习,但总体方法就是从底部开始,沿着堆栈向上追踪直到找到崩溃的那一行,然后推断崩溃原因。使用完 GDB 后,输入 quit 退出会话并终止所有仍在运行的进程。它可能会在最后问你是否要终止某些线程,回答 yes 即可。

与非 ROS 示例类似,我们需要在启动 ROS 2 launch 文件之前设置 GDB 会话。虽然可以通过命令行设置,但也可以利用与 ros2 run 节点示例中相同的机制,直接在 launch 文件中实现。

在你的 launch 文件中,找到你想要调试的节点。本节假设你的 launch 文件只包含单个节点(可能还有其他配置信息)。launch_ros 包中的 Node 函数接受一个 prefix 字段,接收一个 prefix 参数列表,我们就在这里插入 GDB 命令。

根据你的使用场景,选择以下方法之一:

  • 带 GUI 的本地调试: 如果你在本地调试且有可用的 GUI 环境,使用:
prefix=['xterm -e gdb -ex run --args']

这将提供更具交互性的调试体验。以下是基于 'start_sync_slam_toolbox_node' 的调试示例:

start_sync_slam_toolbox_node = Node(
parameters=[
get_package_share_directory("slam_toolbox") + '/config/mapper_params_online_sync.yaml',
{'use_sim_time': use_sim_time}
],
package='slam_toolbox',
executable='sync_slam_toolbox_node',
name='slam_toolbox',
prefix=['xterm -e gdb -ex run --args'], # For interactive GDB in a separate window/GUI
output='screen')
  • 远程调试(无 GUI): 如果在没有 GUI 的情况下调试,省略 xterm -e:
Terminal window
prefix=['gdb -ex run --args']

GDB 的输出和交互将发生在你启动 ROS 2 应用程序的终端会话中。以下是 'start_sync_slam_toolbox_node' 的类似示例:

start_sync_slam_toolbox_node = Node(
parameters=[
get_package_share_directory("slam_toolbox") + '/config/mapper_params_online_sync.yaml',
{'use_sim_time': use_sim_time}
],
package='slam_toolbox',
executable='sync_slam_toolbox_node',
name='slam_toolbox',
prefix=['gdb -ex run --args'], # For GDB within the launch terminal
output='screen')

与之前一样,这个 prefix 会启动一个 GDB 会话(现在在 xterm 中),并运行你请求的 launch 文件以及所有定义的额外 launch 参数。

节点崩溃后,你会看到类似下面的提示符(现在出现在 xterm 窗口中),此时你可以获取 backtrace,然后按照”读取堆栈跟踪”一节中的说明来解读它。

使用包含多个节点的 launch 文件调试略有不同,目的是让你能与 GDB 会话交互而不被同一终端中的其他日志干扰。因此,在处理较大的 launch 文件时,建议把你感兴趣的特定节点单独拿出来启动。

如果你感兴趣的节点是从嵌套的 launch 文件(例如被包含的子 launch 文件)中启动的,你可能需要执行以下操作:

  • 从父 launch 文件中注释掉对子 launch 文件的包含

  • 使用 -g 标志重新编译感兴趣的包以获得调试符号

  • 在一个终端中启动父 launch 文件

  • 按照从 Launch 文件中调试一节中的说明,在另一个终端中启动该节点的 launch 文件。

或者,如果你感兴趣的节点是在这些文件中直接启动的(例如你看到了一个 Node、LifecycleNode,或者在 ComponentContainer 内部),你需要将该节点与其他节点分离:

  • 从父 launch 文件中注释掉对该节点的包含

  • 使用 -g 标志重新编译感兴趣的包以获得调试符号

  • 在一个终端中启动父 launch 文件

  • 按照使用 GDB 调试特定节点一节中的说明,在另一个终端中单独启动该节点。

注意:在这种情况下,如果之前 launch 文件为该节点提供了参数,你可能需要重新映射话题或提供参数文件。通过 --ros-args 可以指定新参数文件的路径、重映射或名称。关于所需的命令行参数,请参阅相关教程。

我们理解这可能比较麻烦,因此建议尽可能将每个节点封装为单独的 launch 文件,以便简化调试。一组示例参数可能是 --ros-args -r __node:=<node_name> --params-file /absolute/path/to/params.yaml(作为模板参考)。

节点崩溃后,你会在该特定节点的终端中看到类似下面的提示符。此时你可以获取 backtrace,然后按照”读取堆栈跟踪”一节中的说明来解读它。

如果 C++ 测试失败,可以直接在构建目录中对测试可执行文件使用 GDB。确保以调试模式构建代码。由于之前的构建类型可能被 CMake 缓存,需要清除缓存后重新构建。

Terminal window
$ colcon build --cmake-clean-cache --mixin debug

为了让 GDB 加载被调用的共享库的调试符号,请确保先 source 你的环境,以正确配置 LD_LIBRARY_PATH。

Terminal window
$ source install/setup.bash

最后,直接通过 GDB 运行测试。例如:

Terminal window
$ gdb -ex run ./build/rcl/test/test_logging

如果代码抛出了未处理的异常,你可以在 gtest 处理它之前,先在 GDB 中捕获它。

Terminal window
$ gdb ./build/rcl/test/test_logging
catch throw
run

backward-cpp 库提供了美观的堆栈跟踪,而 backward_ros 包装器则简化了它的集成。

只需将其添加为依赖项,并在 CMakeLists 中对它调用 find_package,backward 库就会自动注入到你所有的可执行文件和库中。