【cpp查漏补缺】4-gdb的使用

【cpp查漏补缺】4-gdb的使用

spiritTrance

为什么要使用GDB,而不是打印大法?

这是来自于ChatGPT的回答:

GDB(GNU Debugger)是一个功能强大的调试工具,相对于简单地通过打印输出来进行调试,它具有以下优势:
1.实时交互性: GDB允许您在程序执行期间随时停止程序的执行,检查变量的值,执行特定的命令(如单步执行、继续执行、设置断点等),并进行实时交互。这可以帮助您更深入地了解程序的内部状态,而不仅仅是静态地查看输出。
2.非侵入性: 使用打印调试时,您需要在代码中插入打印语句,然后重新编译和运行程序。这可能会导致不必要的代码污染和额外的工作。相比之下,GDB允许您在不修改源代码的情况下进行调试。
3.回溯和堆栈跟踪: GDB可以帮助您查找程序中的错误并跟踪它们的根本原因。它提供了堆栈跟踪功能,允许您查看调用堆栈,找出错误发生的位置,以及如何到达错误点的路径。这通常比简单的打印输出更容易找到问题。
4.变量监视: GDB允许您监视变量的值,而不需要在代码中插入打印语句。这对于跟踪特定变量的值在程序执行期间如何变化非常有用。
5.条件断点: GDB允许您设置条件断点,只有当特定条件满足时才会触发断点。这对于跟踪特定情况下的程序行为非常有用。
6.内存检查: GDB可以帮助您检查程序中的内存错误,如缓冲区溢出、内存泄漏等。这些问题很难通过简单的打印调试来发现。
7.多线程调试: 如果程序涉及多线程,GDB能够帮助您调试多个线程之间的交互和竞争条件,以及线程的状态和调用堆栈。
8.脚本支持: GDB支持Python脚本,您可以使用它们来自动化和扩展调试过程。
总之,GDB相对于简单的打印调试提供了更强大、更灵活和更全面的调试工具,可以帮助您更快速、更有效地发现和解决程序中的问题。当您需要深入了解程序执行过程、排除复杂问题或进行更高级的调试时,GDB通常是一个更好的选择。但对于某些简单问题,打印调试仍然可以是一个有效的快速解决方案。

总的来说,最为方便的地方是只用编译一次代码。打印大法确实舒服,但是每添加一个打印语句就需要重新编译一次项目,这对于大项目来说是非常耗时的。除此之外,脚本也是值得留意的。这为进一步提升效率提供了可能。

ELF文件

待补

GDB基本使用

调试信息

首先,编译程序需要开启-g选项,否则不会带上调试信息。

你可以尝试一下readelf -S <目标文件> | grep debug指令,尝试带上-g和不带上-g选项,观察区别。你会发现少了几段。
同时你也可以试一下file <目标文件>,如果提示是stripped,那么说明符号表信息和调试信息被去除,不能调试。但没有去除不代表可以调试(因为除了debug段还有其他段)。

之后,你可以输入gdb <目标程序>,然后输入run,开始运行程序并进行调试。

输入设置

如果需要命令行参数,你可以在run指令后面跟上命令行参数,或者使用set args,后面跟上命令行参数,然后run并执行。注意,set args可以使用重定向,从一个文件读取输入。
如果需要从标准输入里面读取数据,可以使用set args并重定向。

进入调试

直接调试

1
2
3
4
5
6
7
8
9
10
# case 1
> gdb <obj_file>
(gdb) [some command] # You may need to set args, set breakpoints, etc...
(gdb) run

# case 2
> gdb
(gdb) file <obj_file>
(gdb) [some command] # You may need to set args, set breakpoints, etc...
(gdb) run

调试已运行程序

1
2
3
4
5
6
7
8
9
10
11
pidof <obj_file>    # note that <obj_file> is running, using `pidof` to get the pid

# note that we need to debug by attaching the process, so we need to sudo or edit /etc/sysctl.d/10-ptrace.conf:kernel.yama.ptrace_scope to zero
# case 1:
gdb <obj_file> <pid>
# case 2:
gdb <obj_file> --pid <pid>
# case 3:
gdb <obj_file>
(gdb) file <obj_file>
(gdb) attach <pid>

这里值得提醒的是,第三种方式可以解决已运行程序没有debug段的问题:首先重新编译一个有debug段的程序,然后attach到正在运行的,没有debug段的程序,这样就可以调试已运行的程序。

断点设置

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
(gdb) info breakpoints      # 查看已设置断点相关信息
# 注意:方括号表示可选项
# break - 普通断点
(gdb) break [test.cpp:]9 # 在[test.cpp这个文件的]第 9 行设置断点
(gdb) b func # 在【进入】func函数处设置断点,注意break可以简写为b
(gdb) break * if a==0 # 在 条件表达式 满足时,设置断点, * 为设置的断点
(gdb) condition <num> if a==0 # 为编号为 num 的断点附加条件表达式
#
# rbreak - 正则表达式断点
(gdb) rbreak [test.cpp:]^print* # (regex break) 为所有[test.cpp中的,]以print开头的函数设置断点
(gdb) rbreak [test.cpp:]. # (regex break) 为[test.cpp中的]所有函数设置断点
# tbreak - 临时断点,只会生效一次
tbreak * # 与 break 的用法大同小异
# ignore - 到达该断点时忽略掉
ignore num 30 # 跳过编号为 num 的断点 30 次
# *watch - 观测某个变量,设置断点,注意程序需要在运行中
watch var # 用于监视变量 var 的更改
awatch var # 用于监视变量 var 的读取或写入
rwatch var # 监视特定内存地址
# rwatch *0x12345678
# *able - 禁用或启用断点
enable / enable [num] # 启用所有断点/标号为num的断点
disable / disable [num] # 禁用用所有断点/标号为num的断点
# clear - 清除断点
clear # 删除所有breakpoints
clear [test.cpp:]func # 删除[test.cpp中的]函数名为function处的断点
clear [test.cpp:]lineNum # 删除[test.cpp中的]行号为lineNum处的断点
# delete - 清除断点
delete # 删除所有breakpoints,watchpoints和catchpoints
delete lineNum # 删除断点号为 lineNum 的断点

查看变量

手动查看变量

1
2
3
4
5
6
7
8
9
10
11
p ['file'::]['func'::]var       # 查看[file文件中][func函数中]变量var的值
p ['file'::]['func'::]*var # 查看[file文件中][func函数中]**指针**var所指向的值
p ['file'::]['func'::]*var@10 # 查看[file文件中][func函数中]**指针**var所指向的值,并向后打印10个值(一般是指针指向了数组用)
p ['file'::]['func'::]*var@a # 查看[file文件中][func函数中]**指针**var所指向的值,并向后打印a个值
p $ # $表示上一个查看的变量
set $index=0 # 设置变量
p b[$index++]
# 按照特定格式打印变量:
# x d o t 十六/十/八/二进制显示变量,u 十六进制显示无符号整型
# a 十六进制 c 字符格式 f 浮点数格式
p/x var # 按照十六进制打印变量 var

查看内存内容

1
2
3
4
5
6
7
# n内存单元数
# f打印格式,在上面提过
# u打印的单元长度,有b,h,w,g,分别为一字节,二字节,四字节和八字节
# addr 地址
x/[n][f][u] addr
x/4tb &e # e 变量 打印内容
# 0x7fffffffdbd4: 00000000 00000000 00001000 01000001

自动显示变量

1
2
3
4
5
6
# 程序中断时就会显示 display 定义的变量
display e
info display # 查看相应变量信息
delete display num # num 为前面变量前的编号,不带 num 时清除所有
disable display num
enable display num

查看寄存器内容

1
info registers

源码比对

1
2
3
4
5
6
7
8
l                               # 看 1 - listsize 行
l + # 看 后面的 listsize 行
l - # 看 前面的 listsize 行
l [file:]lineNum # 看 [指定文件file的] lineNum 行 附近的 listsize 行
l [file:]func # 看 [指定文件file的] func 函数 后面的 listsize 行
set listsize 20 # 设置 listsize 行 为 20
show listsize # 查看 listsize
l lineNum_begin,lineNum_end # 查看 lineNum_begin 到 lineNum_end 的内容,如果缺一个,则向前或向后看 listsize 行

调试

基本的步进调试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
gdb gdbStep     # 启动调试
# some breakpoints config
n 9 # 执行 9 次
n # 执行 1 次

s # 进入函数内部,但函数必须有调试信息
finish # 完成函数调用
show step-mode # step-mode 用来表示碰到没有调试信息函数的行为,默认是跳过,即off。如果需要修改,使用set命令即可
si # 执行一条机器指令(stepi)
c # continue,执行到下一个断点
fg # 也是continue
u 29 # until 29,执行到29行停住
skip function add # 跳过add函数
skip file calc.cpp # 跳过calc.cpp中的函数
info skip # 查看跳过信息
skip delete [num]
skip enable [num] # 使能编号为num(或所有)的跳点
skip disable [num]

堆栈调用调试

1
2
3
4
bt              # backtrace,可以查看完整的调用堆栈
frame 2 # 切换到第2个帧
info locals # 查看当前帧本地变量的值
info args # 查看当前帧函数参数的值

指定源码路径

1
2
3
4
5
# 切换至源码移动后的路径
dir new_directory
# 更改内建变量
set substitute-path old_path new_path # old_path 为原来的路径,new_path 为更改后的路径
show substitute-path

源代码比对

1
2
3
4
5
layout src
layout split
ctrl + x -> A # 退出
ctrl + x -> 1 # 一个窗口
ctrl + x -> 2 # 两个窗口

core文件调试

首先,core文件是core dump时产生的。首先需要修改core dump文件的大小:

1
2
3
ulimit -c           # 查看core文件大小,单位为块,一块大小默认为512字节
ulimit -c unlimited # 表示无限制
ulimit -c 1024 # 表示大小最大为 1024 块

core文件的具体使用见【熟悉core文件调试】一节。

然后可以这样调试core文件:

1
gdb program_file core_file

一些示例

熟悉基本操作——并查集

下面是并查集的代码:

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
50
// dsu.cpp
#include <iostream>
#include <vector>
using namespace std;
const int DSU_SIZE = 10;
vector<int> dsu;
int findfa(int a){
return dsu[a] = a == dsu[a] ? a : findfa(dsu[a]);
}
void merge(int a, int b){
int fa = findfa(a);
int fb = findfa(b);
if (fa < fb){
dsu[fb] = fa;
}
else{
dsu[fa] = fb;
}
}
void init(){
for (int i = 0; i<DSU_SIZE; i++){
dsu.push_back(i);
}
}

int main(){
init();
/*
* input format:
* 1 x y: set the smaller one as bigger one's father
* 2 x: query the father of x
*/
int op;
while(cin >> op){
int x, y;
switch (op)
{
case 1:
cin >> x >> y;
merge(x, y);
break;
case 2:
cin >> x;
cout << findfa(x) << endl;
break;
default:
break;
}
}
}

下面是测试用例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
1 4 5
2 4
2 5
1 2 3
2 2
2 3
1 3 4
2 2
2 3
2 4
2 5
1 1 5
2 1
2 2
2 3
2 4
2 5

下面是期望输出:
1
2
3
4
5
6
7
8
9
10
11
12
13
4
4
2
2
2
2
2
2
1
1
1
1
1

现在,我们完成以下任务:
1.发生merge操作时,观察dsu的变化情况:

参考答案
1
2
3
4
5
6
7
8
9
10
gdb dsu
set args < input.in
watch dsu
b merge
b 32 # Note that b 32 is to skip the init(), which would be boring for reallocate vector dsu. You may use disable to disable some breakpoints.
info b
# repeat below for n times
c # You may use `c 10` to skip some allocation for vector
finish # when stop for entering the function merge
p dsu # show the content of dsu array

从这个例子可以看出,gdb所停顿的语句,是暂时没有执行的。这个要注意。其次,在给dsu加上watchpoint后,可以发现在init()函数里面时,都会出现一大堆不可名状的内容。原因在于vector内部的实现,会动态改变vector的大小,并重新分配空间。


2.每次发生findfa时,查看函数调用帧
参考答案
1
2
3
4
5
6
7
b findfa
run
c
bt
frame 1
info local
info args

也许用递归程序能够更好地体现。比较重要的是info local,可以展现所有的局部变量。

熟悉core文件调试

这次是个很简单的例子:

1
2
3
4
5
6
7
#include<iostream>
using namespace std;
int main(){
int* p;
*p = 9961;
return 0;
}

由于作者是边学边做,这里作者遇到了一些问题:作者使用的发行版本为ubuntu22.04,经过查阅资料,发现在/proc/sys/kernel/core_pattern有保存存放地址:|/usr/share/apport/apport -p%p -s%s -c%c -d%d -P%P -u%u -g%g -- %E,经过再进一步的查阅,发现这个地址跟apport这个东西有关。查到的大多数的资料都是修改/etc下面某些文件的配置。我们这里不这样做,而是使用gcore:
1
2
3
4
5
6
7
gdb
(gdb) file segFault
(gdb) run
(gdb) gcore # 注意只有正在运行的进程可以使用 gcore ,如果run之后发生了段错误,是还会继续运行的,可以使用 gcore 生成 core 文件
# (gdb) gcore <target_file> # 生成的文件名为target_file
(gdb) q
gdb segFault core.40540 # 第三个参数是 core 文件

生成core文件并执行后,我们可以看到gdb直接报出错误原因和位置:

1
2
3
Program received signal SIGSEGV, Segmentation fault.
0x0000555555555175 in main () at segFault.cpp:5
5 *p = 9961;

那么core文件是什么呢?core文件就是程序崩溃时的内存快照,记录了崩溃瞬间的内部状态。

为什么会这样?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <bits/stdc++.h>
using namespace std;
int* p = nullptr;
void g1(){
int a = 3;
p = &a;
}
void g2(){
int a = 0;
}
int main(){
g1();
cout << *p <<endl;
g2();
cout << *p <<endl;
}

打印的结果是3和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
watch *p        # 这是最快的做法
c
# Old value = <unreadable>
# New value = 3
# g1 () at test.cpp:7
c
# Old value = 3
# New value = 0
# _dl_runtime_resolve_xsavec () at ../sysdeps/x86_64/dl-trampoline.h:75
c
# Old value = 0
# New value = 21845
# 0x00007ffff7d3d09a in std::ostream& std::ostream::_M_insert<long>(long) () from /lib/x86_64-linux-gnu/libstdc++.so.6
c
# Old value = 21845
# New value = 0
# _dl_runtime_resolve_xsavec () at ../sysdeps/x86_64/dl-trampoline.h:75
c
# Old value = 0
# New value = 21845
# 0x00007ffff7d3d09a in std::ostream& std::ostream::_M_insert<long>(long) () from /lib/x86_64-linux-gnu/libstdc++.so.6
c
# Continuing.
# 0
# [Inferior 1 (process 43738) exited normally]

不难知道这个p所在的地址被反复发生未知读写的原因是g1函数的栈帧释放后,后面调用的函数又使用了这一块内存,导致内容被反复读写。从调试结果可以看出来,这个值的改变有两个来源:一个是来自../sysdeps/x86_64/dl-trampoline.h,一个是来自/lib/x86_64-linux-gnu/libstdc++.so.6。后者结合输出不难猜出是cout的原因。那前者是什么呢?经过查阅资料,发现是管理动态链接库用的。

那么我们继续修改代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
int* p = nullptr;
void g1(){
int a = 3;
p = &a;
}
void g2(){
int a = 9;
p = nullptr;
}
int main(){
g1();
g2();
}

emm,发现还是有../sysdeps/x86_64/dl-trampoline.h参与,不知道是什么原因,动态链接库是哪里来的?是不是跟加载有关?还是?先把疑问留在这里吧。

其他

【编译原理】课程项目——未修复的bug

参考资料

大佬们都是怎么用gdb的?或者用吗? - 张小方的回答 (介绍了GDB在大型项目里面的实战?有空看看。)
GDB调试入门指南

  • Title: 【cpp查漏补缺】4-gdb的使用
  • Author: spiritTrance
  • Created at: 2023-10-03 22:33:07
  • Updated at: 2024-01-06 20:07:30
  • Link: https://spirittrance.github.io/2023/10/03/cpp_4_gdb调试/
  • License: This work is licensed under CC BY-NC-SA 4.0.
推荐阅读
linux基本操作学习 linux基本操作学习 【cpp查漏补缺】2-Makefile入门 【cpp查漏补缺】2-Makefile入门 【MIT6.172】学习笔记(1) 【MIT6.172】学习笔记(1)
 Comments