上一篇讲了GDB的基础操作,这篇来点硬核的。

机器人程序几乎都是多线程的。传感器读取一个线程,控制循环一个线程,通信一个线程,可能还有个单独的线程跑路径规划。线程一多,问题就复杂了。最典型的就是:程序偶尔崩溃,但不知道哪个线程出的事;或者程序没崩,但某个线程卡死了,整个系统不动了。

这类问题用printf基本没戏。你得用GDB的多线程调试能力。

多线程调试基础

先用GDB attach到一个正在运行的进程:

gdb -p $(pgrep robot_node)

或者启动时就带上:

gdb ./robot_node
(gdb) run

进入调试状态后,按Ctrl+C暂停程序,查看所有线程:

(gdb) info threads
  Id   Target Id         Frame
* 1    Thread 0x7f...    0x00007f... in __poll ()
  2    Thread 0x7f...    0x00007f... in pthread_cond_wait ()
  3    Thread 0x7f...    0x00007f... in processLidarData ()
  4    Thread 0x7f...    0x00007f... in controlLoop ()

星号标记的是当前线程。你可以切换线程查看:

(gdb) thread 3
[Switching to thread 3]
(gdb) bt
#0  processLidarData () at lidar.cpp:142
#1  0x00007f... in std::thread::_impl ()

这样就能看到线程3当前执行到哪一行代码了。

最实用的命令是thread apply all bt,一次性打印所有线程的调用栈。程序卡死的时候,这条命令能帮你看清每个线程在干什么。哪个线程在等锁,哪个线程在跑计算,哪个线程已经退出了,一目了然。

死锁排查

死锁是多线程程序的经典问题。两个线程互相持有对方需要的锁,谁都动不了。

有一次我们团队的机器人跑着跑着就不动了。传感器数据不更新了,控制指令也不发了。ssh到机器人上一看,进程还在,但CPU占用是0。用GDB attach上去,thread apply all bt一看:

线程A在等mutex_A,而mutex_B被线程B持有;线程B在等mutex_B,而mutex_A被线程A持有。经典死锁。

排查死锁的关键是看每个线程的调用栈里,卡在哪个mutex上。GDB会显示pthread_mutex_lock的调用,往上翻一层就能看到是在哪个函数里加的锁,锁的名字是什么。

预防死锁的办法说起来简单:所有线程按相同顺序获取锁。比如规定先拿sensor_mutex再拿data_mutex,所有地方都遵守这个顺序,就不会死锁。但实际项目中,代码量一大,很难保证每个人都遵守。所以有些团队用std::scoped_lock同时获取多个锁,由标准库来保证不出现死锁。

core dump分析

core dump是程序崩溃时的内存快照。它记录了崩溃那一刻程序的所有状态:寄存器值、内存内容、调用栈。

首先要确保系统允许生成core文件:

ulimit -c unlimited

然后设置core文件的生成路径,默认在當前目录,容易被覆盖:

echo "/tmp/core_%e_%p" | sudo tee /proc/sys/kernel/core_pattern

程序崩溃后,用GDB加载core文件:

gdb ./robot_node /tmp/core_robot_node_12345

进去之后程序是停在崩溃那一刻的状态。你可以:

(gdb) bt                    # 查看崩溃时的调用栈
(gdb) info registers        # 查看寄存器状态
(gdb) info locals           # 查看局部变量
(gdb) p *this               # 查看当前对象的成员
(gdb) frame 3               # 切换到第3层栈帧
(gdb) list                  # 查看崩溃位置附近的源码

core dump分析最厉害的地方在于,你不需要能复现问题。机器人现场崩溃了一次,只要拿到了core文件,就能在开发机上反复分析。这在排查偶发问题时特别有用。

机器人崩溃的常见原因

在机器人项目中,崩溃的原因通常就这几类。

空指针解引用。这是最常见的。比如传感器数据还没来,你就去访问数据指针。或者动态分配的内存失败了,返回nullptr,你没检查就用。排查方法很简单:bt找到崩溃行,p那个指针看看是不是0x0。

数组越界。点云数据有10000个点,你访问第10001个。或者栅格地图的坐标算出来是负数,直接当数组索引用。这类问题有时候不崩溃,只是数据错了,更难排查。用AddressSanitizer能帮你抓住这类问题:

g++ -fsanitize=address -g -o robot_node robot_node.cpp

运行后一旦越界,程序会立刻停下来并报告具体位置。

内存泄漏导致的延迟崩溃。每次循环都new一块内存但忘了delete,跑几个小时后内存耗尽,程序被系统kill掉。这类问题用valgrind排查:

valgrind --leak-check=full ./robot_node

valgrind会报告每一处未释放的内存分配,包括是在哪个文件的哪一行new出来的。不过valgrind会让程序慢10到20倍,只适合在开发机上测试。

Use-After-Free。内存释放了之后还继续访问。在机器人项目里,典型场景是:一个线程释放了数据缓冲区,另一个线程还在读。这类问题用AddressSanitizer也能抓到。

实战排查流程

总结一下遇到机器人程序崩溃时的排查流程。

第一步,看有没有core文件。有的话直接GDB加载,bt看调用栈,定位崩溃位置。

第二步,如果没有core文件,用GDB重新跑程序,看能不能复现。能复现的话,在可疑的地方设断点,单步走。

第三步,如果偶发崩溃难以复现,加上AddressSanitizer重新编译跑。如果怀疑内存泄漏,用valgrind。

第四步,如果以上都搞不定,在关键位置加日志。不是printf,是用spdlog或者ROS2的rclcpp_logging,带时间戳和线程信息。

第五步,分析日志,缩小范围,再回到GDB精确定位。

这个流程不是万能的,但大部分问题都能搞定。关键是别慌,一步一步来。

面试中怎么聊

面试官问调试经验,讲具体案例最加分。比如:"有一次机器人运行时导航节点崩溃了,我拿到core文件用GDB分析,bt发现崩溃在点云回调里,检查发现是点云为空的时候没做判空就访问了。修复后加了防御性检查和单元测试。"

这种回答好在:有场景、有方法、有结果。面试官听完就知道你真正干过这事。

多线程调试与core dump分析

多线程程序的调试是GDB的强项。用info threads查看所有线程,用thread N切换到指定线程,在每个线程上都可以独立设断点。core dump分析是排查崩溃问题的关键技能:首先用ulimit -c unlimited开启core dump,崩溃后用gdb加载core文件,然后用bt full查看完整的调用栈和变量值。面试时如果能描述一个实际的core dump排查案例,会非常有说服力。

还有一个重要技巧:用AddressSanitizer(编译时加-fsanitize=address)可以在运行时检测内存错误,比如缓冲区溢出、使用已释放内存等。很多难以复现的bug用这个工具一跑就能定位。

给你的建议

学会用AddressSanitizer。编译时加一个-fsanitize=address就能在运行时检测大部分内存问题,几乎零学习成本。很多开源项目默认在CI里开着ASAN,你开发的时候也应该开。

core dump分析一定要会。面试的时候如果问到"程序崩溃了怎么排查",你说core dump加GDB,比说加printf强太多。

最后,养成看调用栈的习惯。不管是GDB的bt还是日志里的stack trace,调用栈是定位问题最快的线索。从崩溃点往上看,一层一层找到你的代码,问题通常就在那里。另外Valgrind也是排查内存问题的利器,和AddressSanitizer互补使用效果更好。


上一篇:第101篇 GDB调试基础——断点、单步、内存查看

下一篇预告:第103篇 性能分析工具——perf/flamegraph定位机器人性能瓶颈

Logo

DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。

更多推荐