调试对于任何语言的任何程序员来说都是一项重要的技能。由于 C++ 相对复杂,它可能需要比大多数流行语言更好的调试技能。更重要的是,我们用 C++ 解决的实际问题往往更为复杂,这可能会带来需要分析和调试的意外结果。
程序往往存在错误,而 C++ 可能比大多数其他语言更容易出错。对崩溃、内存损坏、泄漏、悬空指针等问题进行故障排除是任何 C++ 程序员都必须具备的技能。应该使用 C++ 最佳实践来避免错误,但在这篇文章中,我们将假设即使是最优秀的程序员也会偶尔遇到错误。这使得调试 C++ 程序的能力变得至关重要,这就是这篇文章的全部内容。
您可能熟悉为 C++ 调试提供图形界面的 IDE。在后台中,您最喜欢的 IDE 在您的本地计算机或远程计算机上运行 LLVM、GDB、WinDbg 等调试器,并使用用户友好的图形将其完美地包装起来。在很多情况下,仅使用命令行来调查崩溃会更省时——它需要学习语法,但提供了很大的灵活性和可定制性。有些情况下需要进行终端调试,例如在没有 IDE 或不允许远程访问的生产环境中调试代码时。
这篇文章提供了一个详细的逐步演示,旨在让任何程序员能够单独在终端上调试代码,并且比您想象的更容易地做到这一点。即使您有通过命令行进行调试的经验,本指南也有望提供一些有用的技巧。我们还将学习如何打开内核转储或崩溃转储,以查看程序崩溃的位置。通过学习如何在终端中进行调试,您的徒手编程技能将会得到提升。
我们将在这篇文章中介绍以下内容:
- 在 TUI 模式下调试
- 进入一个函数
- 重启程序
- 设置断点和监视点
- 清除断点
- 清理程序打印
- 查看程序崩溃
- 调试多线程
- 打印变量
- 打印堆栈指针及其内容
- 查看汇编
- 查看寄存器
- 内核转储
接下来开始我们的课程。
让我们来看看以下代码:
#include <iostream>
#include <thread>
#include <vector>
void DoSomethingBad() {
while (true)
std::cout << 1 / (rand() % 12) << std::endl;
}
int main() {
unsigned int num_threads = std::thread::hardware_concurrency()
std::vector<std::thread> threads;
std::cout << "Running with " << num_threads << " threads." << std::endl;
for (int i = 0; i < num_threads; ++i)
threads.push_back(std::thread(DoSomethingBad));
for (auto && th : threads)
th.join();
return 0;
}
您可以自己在 Coliru 上使用代码。
我们很容易就能看出有一条有问题的行,在某些可能情况下,它会尝试在第 7 行除以 0。
我们将使用 GDB 调试我们的小 C++ 程序,也就是 GNU Project Debugger。
g++ problematic.cpp -pthread -g -o problematic
我们添加了标志 -g,这是使 GCC 生成调试信息所必需的。这将允许 GDB 调试我们的代码。需要注意的是,对于 GDB 调试,有一个更好的选择,那就是 ggdb,它在有某情况下使用 GDB 时会生成更具表现力的调试,至少是应该与 -g 选项一样好。
如果您正在调试一个优化代码,这当然是通常的方式,可以根据 GCC 的推荐,考虑使用 -Og 执行标准编辑-编译-调试循环(有关 GCC 优化选项,请见:https://gcc.gnu.org/onlinedocs/gcc/Optimize-Options.html#Optimize-Options)。
请注意,我们需要 -pthread 标志来支持 POSIX 线程。
现在我们在生成的可执行文件中有了调试信息,就可以启动 GDB 了。
我们只需运行:
user@mylinux:~/debugging$ gdb problematic
然后得到:
user@mylinux:~/debugging$ gdb problematic
GNU gdb (Ubuntu 9.2-0ubuntu1~20.04) 9.2
<...GDB Copyright Notes …>
For help, type "help".
<...>
Reading symbols from problematic...
(gdb)
GDB 已为我们打开了一个调试会话来调试我们的程序。现在,在我们开始我们的调试会话前,可以知道有一首官方 GDB 歌曲(首次发行于 1988 年):https://www.gnu.org/music/gdb-song.en.html。我们将回到歌曲中提到的一些选项,但您可能想要休息一下来学习歌词。
如果我们不知道在代码中放置断点的位置,我们就使用起始处:
现在让我们在代码起始处放置一个断点
(gdb) start
Temporary breakpoint 3 at 0x140d: file problematic.cpp, line 10.
Starting program: /home/ubuntu/debugging/problematic
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1".
Temporary breakpoint 3, main () at problematic.cpp:10
10 int main() {
(gdb)
由于我们未设置任何断点,GDB 将在 main 处为我们设置一个断点(我们将本文后面展示如何设置断点)。
在 TUI 模式下调试
按 Ctrl + x 然后按 a 进入 TUI(文本用户界面)模式。正如我们将在下面看到的,TUI 将增强我们的调试体验,因此我们强烈推荐它。
现在我们来聊聊 TUI。TUI 在有合适版本的 curses 库的平台上受支持。您还可以通过运行以下命令在 TUI 模式下启动 GDB:
gdb -tui <executable>
在我们使用 n 或 next 来前往下一行。我们逐行执行以下命令,直到第 17 行为止:
进入一个函数
现在,让我们通过使用 s 或 step 进入这个线程构造函数,它允许您进入函数。
我们开始吧!按需进入标准库是一种非常棒的方式!看看效果如何:
要滚动代码,只需使用上下箭头(或使用鼠标/触摸板滚动)。这种模式下的滚动命令历史记录呢?可以使用 Ctrl + p 查询上一个,用 Ctrl + n 查询下一个。
重启程序
我们通过输入 r 来再次调试程序。这将重启程序,同时保留现有断点,这为我们提供了讨论这些断点的机会。
设置断点和监视点
我们使用行号之前的 b 来设置断点,在本例中,我们键入以下内容在第 14 行设置断点:
(gdb) b 14
观察第 14 行旁边的 b+ 指示
现在我们有了一个将来的断点,我们可以通过键入 c 或 continue 使其运行到下一个断点来使程序运行到断点。从下图中我们可以看到第 14 行高亮显示:
断点是指在行被执行前我们停在该行。键入 n 或 next 将执行该行,且我们的程序打印:Running with 8 threads
另一个选项是在函数处设置断点,所以我们在函数 DoSomethingBad 处设置一个断点,并使用可疑名称:
(gdb) b DoSomethingBad
有时我们无法预测哪里会发生变化。我们可以使用监视点来告诉调试器监视表达式,并在何时何地发生此类更改时停止执行。下面是有关设置监视点的一个小演示:
(gdb) watch num_threads
(gdb) c
设置监视点后的结果如下:
有关断点和监视点的更多选项和信息,请见:https://sourceware.org/gdb/current/onlinedocs/gdb/Breakpoints.html
清除断点
使用 clear 命令可以清除断点:
(gdb) clear <line_number>
(gdb) clear <function_namer>
使用 d 可以删除所有断点:
(gdb) d
Delete all breakpoints? (y or n) y
有关更多选项,请见:https://ftp.gnu.org/old-gnu/Manuals/gdb/html_node/gdb_31.html
清理程序打印
您可能已经注意到,程序打印到屏幕可能会扰乱调试会话的显示,因此要清理打印并重新绘制 TUI,此时可使用:Ctrl+l。
查看程序崩溃
好的。就让程序运行吧。我们将使用 c 或 continue 使程序运行至下一个断点,但是由于我们已经没有更多断点了,所以程序会一直运行,直到崩溃,此时我们会得到:
好的,有很多打印,它真的搞砸了我们的调试体验,但是您可以通过 Ctrl + l 来使用之前看到的打印清理和 TUI 重绘,现在我们得到一个更清晰的屏幕:
我们看到了,程序在第 7 行崩溃了。
很明显,这个随机数很有可能被 12 整除,因此我们得到一个被 0 整除的结果。
在这种情况下,我们可以使用 bt 或 backtrace 命令查看堆栈跟踪历史:
有关更多回溯选项,请见:https://sourceware.org/gdb/current/onlinedocs/gdb/Backtrace.html
调试多线程
如要查看所有线程的回溯,请使用 thread apply all 命令,后跟bt或backtrace
(gdb) thread apply all bt <bt_options>
这样您就可以按与其创建顺序相反的顺序向下滚动线程。在这次运行中,我在第 7 行选择了一个明显更大的常数,以确保在程序崩溃之前创建所有额外的线程:
或者,您也可以使用以下命令切换至特定线程
(gdb) thread <thread_number>
然后使用任何命令(比如 bt)来关联此特定线程。
打印变量
无论何时,您都可以通过键入 p 或 print 后跟变量名称来打印变量。在下面的例子中,我们可以看到,在运行开始时(main() 开始时的默认断点),p num_threads 会打印在第 11 行赋值之前存储在其中的值(在本例中,我们得到 1),但是在第 11 行被执行后,我们得到std::thread::hardware_concurrency() 返回的值:
打印堆栈指针及其内容
(gdb) print $sp
$1 = (void *) 0x7fffffffe370
(gdb) print *(long**) 0x7fffffffe370
$2 = (long *) 0x280
(gdb)
查看汇编
如要在 TUI 模式下查看汇编,可在查看源代码时按下 Ctrl + x,然后按 2
通常,您更愿意在不进入汇编的情况下调试代码,但在某些情况下,为了了解某个运行的行为,您会发现检查它很有用。例如,如果优化器从代码中删除了某个分支,将其标记为不可访问——可能是因为所述分支的条件取决于未定义的行为——在查看汇编时,您可能真正了解正在排除的错误(这只是几个工具中的一个,比如使用未定义的行为清理标志编译)。
查看寄存器
如要在 TUI 模式下查看寄存器数据,当您正在查看两个汇编屏幕时(源代码屏幕和汇编屏幕)和底部的 (gdb) 命令提示时,再次按 Ctrl + x,然后按 2。源代码屏幕将被寄存器的查看器取代。如要同时查看寄存器和源代码,可再次按 Ctrl + x,然后按 2。注意下面给出的执行第 11 行之前和之后的 $rax,并且寄存器存储了 hardware_cuncurancy 的值。
第 11 行被执行之前
第 11 行被执行之后
默认情况下,窗口会列出通用寄存器,但是您可以使用以下命令切换到另一组寄存器:
(gdb) tui reg <register_group_name>
(gdb) tui reg <register_group_name>
您可以使用下列命令查看全部寄存器
(gbb) maint print reggroup
在非 TUI 模式下,info registers、info all-registers 和信息寄存器 <register_group_name>将是 gdb 命令行的替代方案。
Quit
如果退出 gdb,只需使用 q 或 quit 即可
内核转储
分析崩溃的另一种方法就是当程序崩溃时,确保您的环境生成内核转储(通常,这需要使内核文件大于特定值,或者通过使用 ulimit -c unlimited 完全删除限制)。
使用前面的例子,假设我们的程序发生问题崩溃并生成了一个核心。我们可以使用 GDB 通过运行这两个文件来查看它崩溃的位置:
加载后,我们就可以看到崩溃发生的位置,并可以使用 backtrace 命令查看调用跟踪。请看下例:
您还可以使用 core <core_file>命令从 gdb 运行中加载内核文件。
ser@mylinux:~/debugging$ gdb problematic core
加载后,我们就可以看到崩溃发生的位置,并可以使用 backtrace 命令查看调用跟踪。请看下例:
您还可以使用 core <core_file>命令从 gdb 运行中加载内核文件。
在 Windows 中,我们使用 Userdump.exe 创建转储 (.dmp) 文件。当然,方法类似。
在许多情况下,发生崩溃之后,内核中的调用堆栈将被破坏,此时您可以使用 GDB 内置的可逆调试。有关其工作原理的演示,我强烈建议观看 Greg Law 的 CppCon 演讲:CppCon 2015: Greg Law ” Give me 15 minutes & I’ll change your view of GDB”:
https://www.youtube.com/watch?v=PorfLSr3DDI&feature=youtu.be
在视频的第 8 分 44 秒处,您将看到如何在程序运行期间启动record命令后,使用reverse-continue命令在完整上下文中启用逐帧倒放。
结语
我们完成了逐步演示,它旨在使程序员在没有 IDE 的情况下单独在终端窗口上调试代码。即使您在命令行编程方面有经验,我希望您至少已经学会了一两个可以改进调试会话的技巧。同时,我还希望我已经展示了在许多情况下没有 IDE 时进行调试是多么容易,您可能会发现这项技能很有用。有关更多技巧和其它 GDB 技能,我建议大家使用下面的资源列表。
References:
- https://sourceware.org/gdb/current/onlinedocs/gdb/
- https://ftp.gnu.org/old-gnu/Manuals/gdb/html_mono/gdb.html
- CppCon 2015: Greg Law ” Give me 15 minutes & I’ll change your view of GDB”
- https://softwareengineering.stackexchange.com/questions/22769/what-programming-language-generates-fewest-hard-to-find-bugs
- https://cs.brown.edu/courses/cs033/docs/guides/gdb.pdf