打算写一点不涉及算法的指南。这是第二篇。讲讲怎么使用 GDB 来调试代码。要调试代码,最简单的方式就是直接打印出来看,但有时候不想打印,这时候就需要依靠一些外部工具来进行调试了。

GDB 全名为 GNU symbolic debugger,是 GNU 开发的一个支持调试多种语言的调试器。

打开 GDB

GDB 是一个命令行的调试器,意味着其没有任何 UI,全部需要通过输入指令来完成调试操作。你应该习惯这种操作,并也习惯用命令行来编译 C++ 程序,而不是使用 IDE 自带的编译。

linux

linux 下你的发行版在安装 gcc 的时候就会附带 gdb,在终端输入 gdb 即可。

Windows

如果你使用的是 MinGW,那么在 bin 文件夹下就有一个 gdb.exe,将这个文件夹加入 PATH 即可(如果你之前就可以用 g++ 来命令行编译程序,那么你应该已经加入了)。如果你使用的是其他编译器,请自行查询。

加入 PATH 之后就可以在命令行直接输入 gdb 来调出程序了。

基础使用

首先,编译你的代码。在编译时要加入 -g 选项,将调试信息也编译进可执行文件中。注意 -g 选项不应该与优化选项同时开启(-O1,-O2,-O3,因为优化选项会修改代码逻辑,无法单步执行)。

然后使用 gdb 可执行文件 来进行调试。(gdb 还有一个其他的使用方法,后文讲到 coredump 的时候会提到)

gdb 执行到一个停顿时,会显示当前的代码行数以及内容。这行代码还没有执行。

来看上图学习一下一些要点:

① 如果不用 -g 编译,就没有调试信息,不能调试。
② (小知识)gdb 的 -q 开关可以取消掉启动时的一些版权信息。
③ 在 gdb 的命令行交互中,输入 q(quit)退出。
④ 使用 -g 才能正常调试。
⑤ 使用 b [函数名/行数] 设置断点。b main 即在 main 函数设置一个断点。我们习惯这么做,来让程序从刚开始运行的时候就停下来。(这些命令下文会再系统叙述一遍)
⑥ 使用 r(run)运行程序。在 gdb 的调试过程中,你可以多次执行程序。)
⑦ 使用 n(next)来下一步,单步执行。
⑧ 这句说明程序执行完了。接下来还可以再输入 r,再执行一遍。

程序执行指令

有关运行程序的指令。

run

简写为 r。开始执行程序。在进入 gdb 的一开始是不会执行程序的。只有输入了 r 才会开始执行。

r 可以用 < 重定向,这样你的程序就可以自动文件输入,而不是从标准输入流获取输入。

break

简写为 b。有三个主要用法

  1. b 行数:在执行到这一行的时候停止执行。
  2. b 函数名:在执行到这个函数的时候停止执行。
  3. b ... if 条件:仅在条件成立的时候停止执行。

tbreak

简写为 tb。和 break 一样,但是触发一次后自动销毁。

continue

简写为 c。在程序暂停后,继续执行(直到下一个断点/阻塞/程序结束)。

next, step

分别简写为 ns。单步执行。

区别为 next 遇到一个函数会直接执行完整个函数,而 step 会跳进这个函数体的内部。

until

简写为 u。执行直到…

用法和 break 一样,即:

  1. u 行数:在执行到这一行的。
  2. u 函数名:在执行到这个函数。
  3. u ... if 条件:仅在条件成立的时候执行到…。

变量查看指令

除了让代码跑起来,我们还要看看这个变量到底是什么。

print

简写为 p

使用方法:p 表达式,查看一个表达式的值。

display

简写为 disp

使用方法:disp 表达式。和 print 不同,程序每次停下来(断点,单步执行)的时候都会显示所有你 disp 的变量。

实操

以这个代码为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include<bits/stdc++.h>
using namespace std;

const int N = 1e5+5;
int fib[N];


int main(){
int n;scanf("%d",&n);
fib[1]=fib[2]=1;
for(int i=3;i<=n;i++){
// 这里大数据会整形溢出,但是请忽略,这不是我们要强调的点
fib[i]=fib[i-1]+fib[i-2];
}
printf("%d\n",fib[n]);
return 0;
}

我们执行了如下调试,请注意我使用的命令。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
(gdb) b main
Breakpoint 1 at 0x11d5: file test.cpp, line 8.
(gdb) r
Starting program: /home/llx/cpp/test
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1".

Breakpoint 1, main () at test.cpp:8
8 int main(){
(gdb) b 11
Breakpoint 2 at 0x555555555215: file test.cpp, line 11.
(gdb) c
Continuing.
15

Breakpoint 2, main () at test.cpp:11
11 for(int i=3;i<=n;i++){
(gdb) disp i
1: i = 32767
(gdb) disp fib[i]
2: fib[i] = 0
(gdb) disp fib[i-1]
3: fib[i-1] = 0
(gdb) n
13 fib[i]=fib[i-1]+fib[i-2];
1: i = 3
2: fib[i] = 0
3: fib[i-1] = 1
(gdb) n
11 for(int i=3;i<=n;i++){
1: i = 3
2: fib[i] = 2
3: fib[i-1] = 1
...
(此处省略一部分)
...
(gdb) n
11 for(int i=3;i<=n;i++){
1: i = 15
2: fib[i] = 610
3: fib[i-1] = 377
4: fib[i-2] = 233
(gdb) n
15 printf("%d\n",fib[n]);
(gdb) p fib
value requires 400020 bytes, which is more than max-value-size
(gdb) p fib[1]@20
$1 = {1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 233, 377, 610, 0, 0, 0, 0, 0}
(gdb)

打印大数组的一部分

注意一下第 50 行 fib[1]@20 这个表达式。有的时候我们数组开的很大,不能直接打印出来(而我们的测试数据是很小的),这个时候可以 数组某一元素@个数 来获取部分数组。(是元素,不是指针)

例:(*arr)@5*(arr+5)@10arr[7]@8

进阶

一般来说,有这些指令已经够你调试代码了,但有些时候还不够。

查看调用栈

1
2
3
4
5
6
7
8
9
10
11
#include<bits/stdc++.h>
using namespace std;

void dfs(){
dfs();
}

int main(){
dfs();
return 0;
}
1
2
3
$ g++ test.cpp -o test -g
$ ./test
Segmentation fault

这时候,你发现遇到了段错误(现在是因为栈溢出了,这是很容易发现的,但段错误还有更多的原因)。遇到段错误,除了先静态差错之外,可以直接 gdb 然后 r,这样程序就会在段错误的时候停下来。

1
2
3
4
5
6
7
8
9
(gdb) r
Starting program: /home/llx/cpp/test
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1".

Program received signal SIGSEGV, Segmentation fault.
dfs () at test.cpp:5
5 dfs();
(gdb)

这时候我们一般操作是先看调用栈(因为这份代码有递归)。

使用 backtrace 命令。简写为 bt。查看调用栈。

一看就发现是栈溢出了。

核心转储

有时候在机器上会看到这样的话:段错误(核心已转储)/ Segmentation fault (core dumped)。

在之前却没有看到这样的话,这是为什么呢?

在某些机器上,默认是不开启核心转储的。使用 ulimit -c 查看目前的状态,使用 ulimit -c unlimited 来开启核心转储。核心转储可以把当前程序崩溃的前一秒的快照保存下来。

看一段程序:

1
2
3
4
5
6
7
8
#include<bits/stdc++.h>
using namespace std;

int main(){
char* a_safe_pointer = 0x0;
*a_safe_pointer = 114;
return 0;
}

这是一个非法内存写入的段错误。崩溃之后,会在当前目录生成一个 core 文件。

1
2
3
4
5
6
7
$ ulimit -c
unlimited
$ g++ test.cpp -o test -g
$ ./test
Segmentation fault (core dumped)
$ ls -l | grep core
-rw------- 1 llx llx 520192 Jun 2 22:26 core

使用 gdb 可执行文件 核心文件 来调试。

1
2
3
4
5
6
7
8
9
10
$ gdb test core
[New LWP 1606]
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1".
Core was generated by `./test'.
Program terminated with signal SIGSEGV, Segmentation fault.
#0 0x0000555daea0317d in main () at test.cpp:6
6 *a_safe_pointer = 114;
(gdb) p a_safe_pointer
$1 = 0x0

可以看到,最后卡在一个指针赋值操作。我们尝试查看这个指针,发现指向 0,因此找出了错误。

结语

为什么要调试代码?因为代码的执行结果不符合预期。

为什么代码执行结果不符合预期?①你的算法本来就是错的。②你的代码写错了。

不谈你的想法就是错的情况(这种情况你可以用纸自己推一遍),那么哪里写错了呢?这就是有很多坑的地方,有的时候就是少初始化了一个变量,有的时候忘记修改一个变量,却调试了很久。

现在你可能对 gdb 的所有在 OI 中常用的功能有所掌握,但重点是自己多调试几次。仅凭几个样例是绝不能说清楚调试的时候所有技巧的,况且我来举例就是已经明确知道代码哪里有问题了的。自己上手调自己写错的代码,才能真正总结经验,避免踩坑。也就是说,本文仅为抛砖引玉,真正有关 gdb 还需要自己再多加练习,有想要做的操作多搜索,多用,就能熟练了。