GDB(The GNU Project Debugger):GNU项目调试器,允许您查看另一个程序执行时"内部"发生了什么,或者其他程序在崩溃的那一刻在做什么。
安装gdb的方式有很多,并且网上也有很多很详细的安装教程,这里就不再赘述了。下面让我们简单的了解一下gdb的命令。
下面将使用windows PowerShell演示,输入gdb
命令,进入gdb程序。
输入help
命令查看gdb内支持的调试命令。
使用help
+命令 可以查看关于该命令更详细的介绍。
以下将通过调试几个简单的程序,让我们熟悉gdb的使用方式。
先来看一个简单的示例:下列 fib(n) 函数将给出斐波那契数列的第n项元素。
#include
int fib(int n)
{
int a = 0, b = 1, t;
while(n--)
{
t = a + b;
a = b;
b = t;
}
return a;
}
int main()
{
int n = 0;
for ( ; n < 10; n++)
{
printf("斐波那契数列第 %d 项为: %d \n", n ,fib(n));
}
return 0;
}
使用gcc编译并运行,输出如下:
可以看到程序完美的运行,并输出了我们想要的结果。
但是这个程序却存在一个BUG,例如我们修改程序中主函数内 n 的初始值为 -1,则程序会出现死循环。
int n = 0;
for ( ; n < 10; n++)
// ...
而要想弄明白,程序在执行期间究竟发生了什么,我们可以使用GDB进行调试。
在使用gdb调试之前,我们需要知道的是。
gdb可调试的对象必须是在gcc编译时添加-g
参数生成的可执行文件,该可执行程序内含调试所需的信息。
如下图所示,使用-g
参数生成含有调试信息的可执行程序
通过两种方式编译的可执行程序都可以正常执行,而区别在于后者比前者多了一部分调试信息。
我们可以看一下两个文件的大小。
gcc 的-g 选项并不是把源代码嵌入到可执行文件中, 在调试时也需要源文件。
gdb调试命令 list
可简写为 l
是为列出源码,而此命令依赖的是源码文件 test.c 文件。
如果如果我们将 test.c 文件移动到其他目录,l
命令将无法列出源码。
下面我们正式开始调试。
使用gdb
+调试文件 进行调试。
也可以直接使用 l
+函数 列出函数源码
用 start
命令开始执行程序,gdb将停在main 函数中,变量定义之后的第一条语句处,等待我们发命令。
gdb 列出的是即将执行的下一条语句。
使用 next
进行单步调试。简写为n
,执行该命令程序将每次执行一步,并继续列出下一行将要执行的命令。
使用 step
命令将进入当前语句所在的函数中。
可以看到,函数传入的参数 n = -1
。我们使用 l 命令列出当前函数的源码。
backtrace 命令(简写为 bt)可以查看函数调用的栈帧:
上图#0
表示,当前在 fib 函数的栈中,#1
表示当前 fib 函数是由 main 函数调用的。 fib 函数的栈帧编号为 0,main 函数的栈帧编号为 1。
有关 info 命令可参考:gdb info
可以用 info命令(简写为 i)查看fib 函数局部变量的值:
当前准备执行语句 int a = 0, b = 1, t;
但还未执行,因此 a、b、t 的值都是随机值。而执行完此语句后,a 与 b 都被赋予了新的值。
如果我们想查看当前main函数栈帧中的变量,可以使用 frame
命令切换到main函数的栈帧中。
栈帧的编号可以通过 bt
命令查看。
如上图所示,这里我们需要注意两点:
p
命令,$后的编号一直增长。p
命令输出多个变量的话,类似 p a b
或 p a,b
都是不行的,我们应使用 p { a, b}
这种方法。print key=value
等同于 set variable key=value
。此时我们执行了一次循环,通过前边学到的 info
命令与 p
命令 ,我们可以轻易的查看到程序执行到此处时,各变量的状态。
通过上图的结果,我想我们已经知道程序错在哪里了。
错误分析:我们传入的参数 n 为负数时,经过 while(n–)的循环不会停止,而此负数也将会一直的进行自减一的操作,直至负数溢出至0才会结束。
debug:由于我们的程序没有进行参数有效性的检查,和程序逻辑的设计不过严密,而使得程序出现了BUG。
现在将程序修改为以下代码:
#include
int fib(int n)
{
if(n < 0) return 0; // 参数有效性检查 //非负
int a = 0, b = 1, t;
while(n > 0)
{
t = a + b;
a = b;
b = t;
n -= 1; //
}
return a;
}
int main()
{
int n = -1;
for ( ; n < 10; n++)
{
printf("斐波那契数列第 %d 项为: %d \n", n ,fib(n));
}
return 0;
}
将修改后的程序重新编译运行。
至此,我们的第一个gdb调试已经完成。
下面是一个将输入的数字字符转换为整数输出的程序。
#include
int main(void)
{
int sum = 0, i = 0;
char input[5]; // 保存输入字符的数组
while (1)
{
printf("输入:");
fflush(stdout);
// 输入
scanf("%s", input);
// 将输入的字符串转换为数字
for (i = 0; input[i] != '\0'; i++)
{
sum = sum*10 + input[i] - '0';
}
// 打印结果
printf("输出:%d\n", sum);
}
return 0;
}
编译运行后,我们发现第一次运行时的结果正常,而第二次的运行结果却是错误的。
为了解决这个bug,我们再次使用gdb进行调试。
使用run
命令可以让程序自动的向下执行。
而单单只使用 run 命令,我们是无法进行调试的。run 命令等同于直接运行程序,一般配合断点来使用,是程序执行到断点处停下,等待我们的调试。
通过分析程序的功能可知,程序在接收到输入后,将进行一系列操作将字符数组中的数字字符转换成一个整数, 最终存储在 sum 变量中。
因此, sum 变量的值是我们重点观察的对象。
我们可以使用 display
命令使得每次停下来的时候都显示当前 sum 的值。
undisplay
命令可以取消跟踪显示,变量 sum 的编号是 1,可以用 undisplay 1
命令取消它的跟踪显示。
通过之前 run
运行可知第一次的执行结果是正确的。那么我们需要跳过第一次的执行,直接对第二次的执行进行调试。
而通过断点这个功能即可做到。断点可以让程序在自动运行时,达到某种条件后就中断下来,等待我们的调试。
删除断点
如果我们想删除某个断点,只需要使用delete
命令加断点编号即可删除 。delete breakpoints 2
禁用断点
某些时候,我们想临时取消某个断点,但有不想删除这个断点。那么我们可以选择禁用它。使用命令 disable
命令。可简写为dis
条件满足才生效的断点
我们还可以设置断点在满足某个条件时才激活例如我们仍然在循环开头设置断点, 但是仅当 sum 不等于 0 时才中断, 然后用 run 命令(简写为 r)重新从程序开头连续运行:
使用 start
让程序重新开始调试并停在mian函数内首行位置。 添加断点并执行。
可以看到第一次执行并没有触发断点,而第二次执行开始时触发了断点。
第一次执行并没有触发sum != 0
这个断点,而第二次却触发了这个断点。
通过分析源码,我们发现bug产生的原因是 sum 每次执行完都没有重新初始化造成的。
通过之前的断点调试,我们知道需要添加 sum=0
这条语句到 while(1) 之后。将程序修改为如下样式:
#include
int main(void)
{
int sum = 0, i = 0;
char input[5];
while (1)
{
sum = 0;
scanf("%s", input);
for (i = 0; input[i] != '\0'; i++)
{
sum = sum*10 + input[i] - '0';
}
printf("input=%d\n", sum);
}
return 0;
}
我们都知道使用 scanf 函数是不安全的,它在向变量中写入数据时不会检查变量的大小,使用不当就会产生数据溢出。
例如我们当前的程序,在输入字符长度超过input数组的空间时,sacnf也任然会执行。
可以看到当我们输入 12345
时,输出结果竟然是 123407
。看来我们有必要再进行一波调试了。
很明显,input数组的长度只有5个,而这里的字符串“12345”加上末尾的一个’\0’一共占据了6个字节的空间。
关于 x
命令。参考:https://www.jianshu.com/p/589308dd36dc
x/
为examine命令缩写
n:是正整数,表示需要显示的内存单元的个数,即从当前地址向后显示n个内存单元的内容,
一个内存单元的大小由第三个参数u定义。
f:表示addr指向的内存内容的输出格式,s对应输出字符串,此处需特别注意输出整型数据的格式:
x 按十六进制格式显示变量.
d 按十进制格式显示变量。
u 按十进制格式显示无符号整型。
o 按八进制格式显示变量。
t 按二进制格式显示变量。
a 按十六进制格式显示变量。
c 按字符格式显示变量。
f 按浮点数格式显示变量。
u:就是指以多少个字节作为一个内存单元-unit,默认为4。u还可以用被一些字符表示:
如b=1 byte, h=2 bytes,w=4 bytes,g=8 bytes.
:表示内存地址。
x/20xw
显示20个单元,16进制,4字节每单元我们使用 x 命令查看input中的值,这里的x/7bx
。 这里 7 表示连续打印 7 组,b 表示每个字节一组, x 表示按十六进制格式打印
使用断点直接在循环内 i == 4
处进行调试。并实时监视 i、sum、input[i] 的值。
下面我们进行单步调试:
可以看到,在 i == 4
时,sum 计算的到的结果为 12345 。而在 i = 5
时,本来因该是 0x00 的 input[5] 变成了 0x05 。再次查看input中连续的7个内存空间可发现,input[5]的值被修改了。
则表达式 sum = sum*10 + input[i] - '0';
的值为
sum = 12345 * 10 + 5 - 48 = 123450 + (-43) = 123407
那么问题来了,input[5]的位置为什么会变成 0x05 呢?
继续向下执行,每次执行都查看 input 中的值,我们发现 input[5] 位置的数据其实就是 i 变量的值。
由此,我们可以猜想。input[5] 的值其实是被 i 变量修改了,因为他们所占同一个内存地址。使用 p &i
p &input[5]
查看它们各自的地址。
可以看到果然如我们猜想的那样,i 与 input[5] 所占同一片空间。如下图示:
上述我们通过分析代码,猜想input[5]与i共用一片内存空间,使得 i 的值改变从未诱发 input[5] 的值改变。从而加以验证。
而如果代码复杂,我们无法通过肉眼观察得知某个存储单元是在哪里被改动的。这个时候我们就可以使用观察点( Watchpoint)来踪。
使用 watch
命令设置观察点,跟踪 input[5]的位置。
这里我们使用strat重新开始调试:并删除之前的断点和追踪点。
我们继续之前的调试操作,只不过这次我们对 input[5] 添加一个观察点
下面使用c
一直执行,观察 input[5] 的改变情况:
通过观察我们便可发现,每次在for循环开始时,input[5] 的值都会加1,而循环体内的 i 也是每次for循环开始时加1,我们很容易将这两者结合起来。
如上,通过使用观察点我们也能发现这个bug所在的位置。
这里补充一点删除观察点的命令,通过图示我们可以看到,断点和观察点都可以通过info breakpoints
命令查看。
而观察点也可以通过info watchpoints
命令单独查看。
对应的删除方法都可以使用delete
命令
或者直接使用 del
+编号 删除。
debug:修改该bug的方法主要是针对如何避免 input 数组溢出。我们可以使用scanf_s()函数,或者 fgets() 函数进行输入。
#include
#include
int main()
{
int i;
int arr[5];
for( i = 0; i <= 5; i++)
{
arr[i] = 0;
// ...
printf("%d %d\n",i, arr[i]);
Sleep(400);
}
return 0;
}
#include
int main(void)
{
int man = 0;
scanf("%d", man);
return 0;
}
百科给出的段错误定义为:指访问的内存超出了系统所给这个程序的内存空间产生段错误
一旦一个程序发生了越界访问,cpu就会产生相应的保护,于是segmentation fault就出现了。段错误应该就是访问了不可访问的内存,这个内存区要么是不存在的,要么是受到系统保护的,还有可能是缺少文件或者文件损坏。
使用gdb调试可以查找段错误,接上图程序显示产生段错误,我们使用bt
命令列出堆栈信息。
在 #0 栈帧中显示段错误出现在 ungetwc () 函数中,而在 #2 栈帧中显示的是 0x00000000 in ?? ()
。这是什么意思?
我们发现,似乎在使用windows 下的MinGW版的gdb时并不能很好的完成段错误的调试,下面将演示使用Linux环境下的gdb调试。
关于堆栈信息直接显示为 0x000000,在StackOverflow上找到了答案。
这意味着包含调用者返回地址的框架指针或堆栈已损坏,因此GDB无法向后跟踪到调用者。 并且您的程序跳转到0x00000000中的非法地址,然后崩溃了。 您可以运行Valgrind来查看崩溃之前发生的事情。——StackOverflow
另外,在先前的调试中发现进程是由于收到了SIGSEGV信号结束的,而SIGSEGV信号默认handler的动作是打印”段错误"的出错信息,并产生Core文件。因此我们也可以调试Core文件。
下面将采用Linux系统演示使用Core文件进行调试。
在bt
堆栈信息中显示,出现问题是在 #0 的_IO_vfscanf_internal
函数中,而函数的调用是在 #1 堆栈中调用的,在往上可逆推至main函数中。 并且,按照 #2 中显示的在main函数的第6行调用点。通过f 2
查看main函数帧中的第6行,可以发现异常出现在 scanf("%d", man);
语句上。
通过观察第5行的man值初始化为0, 而使用scanf函数时,我们忘记添加 &
符号,使得我们输入的数据向着man保存的地址中写人,即我们向 0x000000 写入了数据从而引发了段错误。
至此我们已经找到了此程序产生bug的原因,而需要注意的是,当前程序的bug其实是可避免的,如果我们在编译时使用-Wall
选项的话。
可以看到不论是Windows下的MinGW还是Linux下的gcc均给出了警告提示
一个好的习惯是打开 gcc 的-Wall 选项,让 gcc 提示所有的警告信息,不管是严重的还是
不严重的,然后把这些问题从代码中全部消灭。——《一站式学习 C 编程》
之前提到可以利用 Core 文件进行调试,下面在Linux系统上演示如何通过Core文件调试程序。
使用cman signal
查看有关signal的信号。有关cman命令请参考:Linux下的cman中文帮助手册配置
ulimit命令 用来限制系统用户对shell资源的访问,其中 -c
参数指定core文件上限(设定core文件的最大值,单位为区块)。
如下图,本机设定的core上限为0,我们修改一下其上限大小为1000 。
紧接着示例三的程序,为了防止输入非数字字母也会进行转换,这里我们进行字符判断。
#include
int main(void)
{
int sum = 0, i = 0;
char input[5];
scanf("%s", input);
for (i = 0; input[i] != '\0'; i++)
{
if (input[i] < '0' || input[i] > '9')
{
printf("Invalid input!\n");
sum = -1;
break;
}
sum = sum*10 + input[i] - '0';
}
printf("input=%d\n", sum);
return 0;
}
这次我们输入一个很长的字符串
这里程序又出现了一个段错误。使用gdb调试,如图,在windows下使用MinGW的gdb似乎无法调试段错误问题,因此接下来将继续使用Linux环境调试。
在Linux下调试显示在main函数第20行出现问题。
l 20
列出第20行的代码,如图,第20行代码为 }
,因此绝不可能是第20行引发的段错误。 那么我们可以看下一行,参考
如果某个函数的局部变量发生访问越界,有可能并不立即产生段错误,而是在函数返回时产生段错误 ——《一站式学习 C 编程》
20行代码即为main函数的最后一行代码,也就是说,这个段错误在main函数执行完返回的时候发生。
这里发生段错误的原因为,输入的数据过大,将input数组溢出,甚至修改了main函数的栈底数据。(函数在被调用时开辟栈帧,在自己开辟的栈帧中 (栈底位置)保留调用方函数的信息(下一行指令),以便当前函数运行结束后能正常回退至上级函数)。
对于scanf的输入问题我们可以使用 fgets() 函数替代(注:scanf_s() 函数是微软实现的函数,gcc上并没有其实现),但fgets()在使用上有一些需要注意的地方。
有关fgets()函数的使用,这里设计了一个简单的程序,我们通过gdb调试查看fgets()函数究竟做了些什么。
#include
int main(void)
{
while(1)
{
char input[10] = "1234567";
fgets(input, 5, stdin);
input[4] = '\0';
printf("%s\n", input);
}
return 0;
}
编译并调试
第一次调试。输入 123456,最终input中被写入 12340670
第二次调试的时候,因为第一没有读取完,输入缓冲区内还有数据,因此无需我们进行输入便自动向下进行。
通过以上调试,我们发现fgets有以下几个特点:
而事实上,fgets()函数,当读取 (n-1) 个字符时,或者读取到换行符时,或者到达文件末尾时,它会停止。
针对以上几点,我们修改代码如下:
#include
#include
int main(void)
{
const int buf_len = 5; // fgets() 接收的长度
while(1)
{
char input[10] = "1234567";
rewind(stdin); // 刷新输入缓冲区
fgets(input, buf_len, stdin); // 最多接收4个输入
// 消除 \n
int len = strlen(input);
if(input[len-1] == '\n') input[len-1] = '\0';
printf("%s\n", input);
}
return 0;
}
命令 | 描述 |
---|---|
backtrace(或 bt) | 查看各级函数调用及参数 |
finish | 连续运行到当前函数返回为止,然后停下来等待命令 |
frame(或 f) | 帧编号 选择栈帧 |
info(或 i) | locals 查看当前栈帧局部变量的值 |
list(或 l) | 列出源代码,接着上次的位置往下列,每次列 10 行 |
list 行号 | 列出从第几行开始的源代码 |
list 函数名 | 列出某个函数的源代码 |
next(或 n) | 执行下一行语句 |
print(或 p) | 打印表达式的值,通过表达式可以修改变量的值或者调用函数 |
quit(或 q) | 退出 gdb 调试环境 |
break(或 b)行号 | 在某一行设置断点 |
break 函数名 | 在某个函数开头设置断点 |
break … if … | 设置条件断点 |
continue(或 c) | 从当前位置开始连续运行程序 |
delete breakpoints 断点号 | 删除断点 |
display 变量名 | 跟踪查看某个变量,每次停下来都显示它的值 |
disable breakpoints 断点号 | 禁用断点 |
enable 断点号 | 启用断点 |
info(或 i) breakpoints | 查看当前设置了哪些断点 |
run(或 r) | 从头开始连续运行程序 |
undisplay | 跟踪显示号 取消跟踪显示 |
start | 开始执行程序,停在 main 函数第一行语句前面等待命令 |
step(或 s) | 执行下一行语句,如果有函数调用则进入到函数中 |
finish | 连续运行到当前函数返回为止,然后停下来等待命令 |
watch | 设置观察点 |
info(或 i) watchpoints | 查看当前设置了哪些观察点 |
x | 从某个位置开始打印存储单元的内容, 全部当成字节来看,而不区分哪个字节属于哪个变量 |
gdb a.out core | 使用core文件调试 |