关于无 IDE 的 C++ 调试的速成课程——使用 GDB 授权终端!

Blog
Author:
Adam Segoli SchubertAdam Segoli Schubert
Published On:
1月 10, 2022
Estimated reading time:
2 minutes

 

调试对于任何语言的任何程序员来说都是一项重要的技能。由于 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。我们将回到歌曲中提到的一些选项,但您可能想要休息一下来学习歌词。

如果我们不知道在代码中放置断点的位置,我们就使用起始处:

现在让我们在代码起始处放置一个断点

cpp-debugging

(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 将增强我们的调试体验,因此我们强烈推荐它。

debugging-4

现在我们来聊聊 TUI。TUI 在有合适版本的 curses 的平台上受支持。您还可以通过运行以下命令在 TUI 模式下启动 GDB:

gdb -tui <executable>

在我们使用 nnext 来前往下一行。我们逐行执行以下命令,直到第 17 行为止:

debugging-2

进入一个函数

现在,让我们通过使用 s step 进入这个线程构造函数,它允许您进入函数。

我们开始吧!按需进入标准库是一种非常棒的方式!看看效果如何:

debugging-3

要滚动代码,只需使用上下箭头(或使用鼠标/触摸板滚动)。这种模式下的滚动命令历史记录呢?可以使用 Ctrl + p 查询上一个,用 Ctrl + n 查询下一个。

重启程序

我们通过输入 r 来再次调试程序。这将重启程序,同时保留现有断点,这为我们提供了讨论这些断点的机会。

设置断点和监视点

我们使用行号之前的 b 来设置断点,在本例中,我们键入以下内容在第 14 行设置断点:

(gdb) b 14

debugging-4-1

观察第 14 行旁边的 b+ 指示

现在我们有了一个将来的断点,我们可以通过键入 ccontinue 使其运行到下一个断点来使程序运行到断点。从下图中我们可以看到第 14 行高亮显示:

debugging

断点是指在行被执行前我们停在该行。键入 nnext 将执行该行,且我们的程序打印: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

查看程序崩溃

好的。就让程序运行吧。我们将使用 ccontinue 使程序运行至下一个断点,但是由于我们已经没有更多断点了,所以程序会一直运行,直到崩溃,此时我们会得到:

debugging-7-image

好的,有很多打印,它真的搞砸了我们的调试体验,但是您可以通过 Ctrl + l 来使用之前看到的打印清理和 TUI 重绘,现在我们得到一个更清晰的屏幕:

我们看到了,程序在第 7 行崩溃了。

很明显,这个随机数很有可能被 12 整除,因此我们得到一个被 0 整除的结果。

在这种情况下,我们可以使用 btbacktrace 命令查看堆栈跟踪历史:

有关更多回溯选项,请见:https://sourceware.org/gdb/current/onlinedocs/gdb/Backtrace.html

调试多线程

如要查看所有线程的回溯,请使用 thread apply all 命令,后跟 btbacktrace

(gdb) thread apply all bt <bt_options>

这样您就可以按与其创建顺序相反的顺序向下滚动线程。在这次运行中,我在第 7 行选择了一个明显更大的常数,以确保在程序崩溃之前创建所有额外的线程:

或者,您也可以使用以下命令切换至特定线程

(gdb) thread <thread_number>

然后使用任何命令(比如 bt)来关联此特定线程。

打印变量

无论何时,您都可以通过键入 pprint 后跟变量名称来打印变量。在下面的例子中,我们可以看到,在运行开始时(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

debugging-13

通常,您更愿意在不进入汇编的情况下调试代码,但在某些情况下,为了了解某个运行的行为,您会发现检查它很有用。例如,如果优化器从代码中删除了某个分支,将其标记为不可访问——可能是因为所述分支的条件取决于未定义的行为——在查看汇编时,您可能真正了解正在排除的错误(这只是几个工具中的一个,比如使用未定义的行为清理标志编译)。

查看寄存器

如要在 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 registersinfo all-registers 和信息寄存器 <register_group_name> 将是 gdb 命令行的替代方案。

Quit

如果退出 gdb,只需使用 qquit 即可

内核转储

分析崩溃的另一种方法就是当程序崩溃时,确保您的环境生成内核转储(通常,这需要使内核文件大于特定值,或者通过使用 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”

在视频的第 8 分 44 秒处,您将看到如何在程序运行期间启动 record 命令后,使用 reverse-continue 命令在完整上下文中启用逐帧倒放。

结语

我们完成了逐步演示,它旨在使程序员在没有 IDE 的情况下单独在终端窗口上调试代码。即使您在命令行编程方面有经验,我希望您至少已经学会了一两个可以改进调试会话的技巧。同时,我还希望我已经展示了在许多情况下没有 IDE 时进行调试是多么容易,您可能会发现这项技能很有用。有关更多技巧和其它 GDB 技能,我建议大家使用下面的资源列表。

参考:

  1. https://sourceware.org/gdb/current/onlinedocs/gdb/
  2. https://ftp.gnu.org/old-gnu/Manuals/gdb/html_mono/gdb.html
  3. CppCon 2015: Greg Law ” Give me 15 minutes & I’ll change your view of GDB”

4.https://softwareengineering.stackexchange.com/questions/22769/what-programming-language-generates-fewest-hard-to-find-bugs

  1. https://cs.brown.edu/courses/cs033/docs/guides/gdb.pdf