Gdb 是一个超级强大的工具,经常在 Linux 平台的内核开发与应用程序开发当中看到它的身影。由于它的命令集极其庞大,本文就不针对具体的命令进行介绍,而是根据具体的问题场景进行一次使用总结,我最常用来调试的是段错误一类的场景,这里就取一个例子进行总结。。
段错误类属于泛指,其实包括 SIGBUS、SIGSEGV 等类型的错误,或者是程序运行到某一段之后出现崩溃导致程序退出的问题。通常情况下使用一些 log 工具集成到代码里面也可以查出段错误类发生时刻的函数调用栈,但是大多数情况下不够灵活,比如要实时进行单步调试、查看调用栈里面的函数参数、查看段错误发生的那一刻所处函数的符号变量的值、查看指定寄存器的值、查看指定地址的值等等。
程序示例
一段简单的可以导致段错误的代码如下所示:
运行过程中当然会出现段错误,位置在上面代码的第 16 行,但是没有任何嵌入的 log 系统的话,报错提示只会有一个段错误的提示,并告诉你核心转储(core dump),提示内容类似下面:
如果内核没有开启 core dump 功能的话这段报错就会到此为止,没有任何多余的提示,这样及不利于程序额调试,如果是上面示例程序的错误类型还是比较容易查找的,但是一旦程序的规模大起来,不说代码量的事情,就光是因为多线程同步之类的问题导致的段错误,或者是运行过程中由硬件引入错误值导致的段错误是非常难以查找的,这个时候就有必要求助于 gdb 调试工具了。
使用 gdb 调试工具之前需要先洗手,然后在程序编译生成可执行文件的时候加上 -g 选项,这样才能够生成 gdb 可以识别的调试信息。我在本地的 ubuntu 虚拟机做实验的时候发现有以下几个问题:
- 如果在从 C 源文件生成目标文件的时候没有加上 -g 选项,那么即使链接的时候加上 -g 选项最终也不会生成完整的 gdb 可识别的调试信息,只是部分调试信息。调试信息的打入是在编译阶段产生的。
- -g 与 -Os、-O2 等选项同时存在的时候 -g 会被后者覆盖掉,-Os 是专门针对可执行程序大小进行优化的,-O2 只是优化级别为2,在使用 -g 的时候最好屏蔽后面两个选项以保留最完整的调试信息。
- 不同版本的 gcc 与 gdb 混用也会导致虽然加了 -g 选项,但是在栈回溯的时候仍然看不到函数内部的变量信息,这是因为 gcc 的 dwarf 版本与 gdb 的不匹配。本实验最开始使用 gdb-7.4 和 gcc-4.8 就出现了这种情况。在编译的时候使用 gcc -gdwarf-2 -g 才成功生成 gdb 可以识别的调试信息。
- 使用 -s 选项编译或者使用 strip 进行程序瘦身的时候 gdb 是看不到任何调试信息的。要注意该 -s 选项也会覆盖 -g 选项的。
- 带有链接库的,如果程序死在链接库里面,并且链接库有以上四种情况的任何一种,都会导致在断点那里看不到完整的 gdb 调试信息。
以上几个方面在 gdb 调试之前的准备工作时要额外注意,尤其是第四个,因为在嵌入式设备当中通常会为了节省存储空间而把程序的调试信息全部去掉(这个节省的空间是非常可观的),这个时候在线测试出来是无法看到完整的调试信息的,通常情况下会在送测之前保留一个没有经过 strip 或者其它体积优化措施的可执行文件,在出现错误的时候按照复现步骤再去用保留完整调试信息的程序去调试。或者是直接拿到 core dump 文件进行调试。
调试
在上面的工作全部准备好之后就可以开始正式的调试工作了,使用 gdb ./seg-fault
命令运行程序,如果需要参数的话有两种方法可以指定参数:
gdb --args ./seg-fault args
。gdb ./seg-fault
,然后在进入 gdb 命令行之后执行set args arg
。
进入程序之后执行 r 命令运行程序,我这里的程序执行到段错误的时候会停下来,提示如下面所示:
这里可以非常清晰的看到出错的地方是在 ./seg-fault.c
文件的第 16 行,并且连 C 代码都给打出来了,这种属于非常简单的例子,所以提示信息也是一目了然,我们很自然就可以想到是因为 local_king
或者是 local_king->skill
两个变量中的一个地址给错了,不一定是 NULL,只要是处于非法地址中的任何一个地址都有可能,比如数组越界访问都是有可能的,这里我在再深入探究一下。
然后在这里再次执行命令 bt
,该命令会把函数调用栈打出来,范围是从出错的函数处到 main 函数中的第一层入口调用者,我的程序打印出来就是下面的样子:
可以看到它连函数的地址、函数的参数、调用的位置以及段错误的位置全部都给打出来了,顺序是从里到外,先打印最近一次的函数调用,然后依次回溯。这里先进去第 #0 个函数调用栈内部看一下。使用命令 f 0
,后面接的 0 就是栈回溯的函数调用栈编号,f 0
就是进入第 0 个函数调用栈内部,此时 gdb 的调试上下文就会切换到这里:
使用 info args
查看函数调用的时候传入的参数的值,使用 info local
查看函数内部的变量的值,这个时候好像并没有看出来太离谱的异常,两个有可能出错的变量值之一 local_king
的值也不是 NULL 或者 0xFFFFFFFF 之类的一眼就可以看出异常的值,那就进一步看下另一个参数的值好了。
如上面所示,最终打出来 local_king 的成员的值(set print pretty on
只是为了更好看的输出打印信息),发现 skill
成员的值是 0x0,如果不看代码的话,有可能 skill
是一个 int 类型之类的值,好像也不能说明什么,此时有三种方法可以找到问题:
- 直接看代码,因为源代码有,并且也打出来了出错的位置,直接看就完了。
- 使用 l 命令列出当前所处位置的代码,我执行之后就可以直接看到
skill
的使用方式是下标取值的形式,对一个地址 0 再去取下标肯定是错的,因为程序内存分布中地址 0 不应该存放数据段。 - 直接在 gdb 里面查看
skill
的类型,使用 ptype 命令:12345678910111213141516171819202122232425(gdb) ptype local_kingtype = struct ring_king {int iIdx;char *skill;char name[60];struct ring_king *next;} *(gdb) x/10d local_king0x804a108 <global_kings+72>: 1 0 12875 00x804a118 <global_kings+88>: 0 0 0 00x804a128 <global_kings+104>: 0 0(gdb) x/10s local_king0x804a108 <global_kings+72>: "\001"0x804a10a <global_kings+74>: ""0x804a10b <global_kings+75>: ""0x804a10c <global_kings+76>: ""0x804a10d <global_kings+77>: ""0x804a10e <global_kings+78>: ""0x804a10f <global_kings+79>: ""0x804a110 <global_kings+80>: "K2"0x804a113 <global_kings+83>: ""0x804a114 <global_kings+84>: ""(gdb) x/10x local_king0x804a108 <global_kings+72>: 0x01 0x00 0x00 0x00 0x00 0x00 0x00 0x000x804a110 <global_kings+80>: 0x4b 0x32
可以看到 local_king
内部的成员的类型,很容易知道 skill
是一个指针类型的变量,这个时候就可以基本断定出错的原因了。x/NumType Addr
这个命令是用于查看内存的,上面演示了三种查看的方式,可以看到内存里面的值的内容,如果是字符串类型就可以使用 x/100s Addr
这样的形式去查看里面的内容,这个主要是用于查看某段地址里面的内容是否是非法的。
再次基础上我们还可以根据栈回溯来一层层往上去查看参数传递在哪一层出现了错误赋值,虽然这个过程比较艰难,但是目前我还没有找到一个比较好的办法去断定参数值变态恶化的具体位置。
核心转储
也可以使用 core dump 文件来暂时转储段错误时生成的信息,然后再使用 gdb 进行调试。在 PC 机上面需要进行一些操作:ulimit -c unlimited
。执行该命令之后执行程序出现段错误等可使程序终止的错误时会生成一个 core 文件,里面保存了出错时的调试信息,这个时候直接执行:gdb core_file ./seg-fault
即可进入犯罪现场。接下来的操作就是跟上面的一样了。
为什么要使用核心转储?因为通常情况下测试与开发总是分隔两地,可望不可即,测试也不总是直接带着 gdb 去跑程序,并且某些错误可能会比较难以复现,当测试很轻松的复现之后,拿到开发人员这边使用同样的操作步骤却是死活都整不出来这个错误。这个时候保留犯罪现象就是非常有必要的了,省去了开发这边的辛苦复现工作,直接拿过来用即可。
在嵌入式设备上操作方式与 ubuntu 发行版有所不同,需要自行定制 core dump 功能,在内核选项里面选中该功能并打开它即可使用。通常情况下还可以自行修改核心转储的代码进行一些定制化的调试信息保存或者打印,这个不在本文的讨论范围。
其它类型段错误
造成段错误的原因不止有 NULL 这一种类型,也有可能是内存的越界访问,内存越界问题通常会导致某些值非常奇怪,比如一个数组下标变量可能会变成负值,或者是非常大的一个值,比如说一个亿,这个时候就需要注意它是怎么来的了。
非法的值范围,不仅包括数组下标一种,还有可能是其它的受限值,比如某个系统中只有四个视频输入设备,程序按照本来的设计,理论上智能访问到四个设备的赋值范围,但是在某一刻出现了超出这个范围的访问。一个人怎么也不可能出现三条腿,一旦出现了,那就去查下程序,肯定是某个地方搞错了,跟踪一波。
指令错误,通常发生在硬件修改了内存的内容,应用程序一般也不可能修改到指令段。指令错误可能是未定义的指令,或者非法的指令,比如编译器对于结构体的对齐行为不同导致非法指令,通常发生在动态库代码执行的过程当中。
其它命令
打断点:break
命令用来设置断点,有以下几种情况:
- 多文件。使用 break FileName:LineNum。
- 单文件或者当前代码上下文所处的文件。break LineNum。
- 函数。break FunctionName。
- 指定代码段地址。break CodeAddress。12345678910(gdb) info bNo breakpoints or watchpoints.(gdb) break print_infoBreakpoint 1 at 0x8048412: file ./seg-fault.c, line 12.(gdb) break seg-fault.c:35Breakpoint 2 at 0x8048481: file ./seg-fault.c, line 35.(gdb) info bNum Type Disp Enb Address What1 breakpoint keep y 0x08048412 in print_info at ./seg-fault.c:122 breakpoint keep y 0x08048481 in main at ./seg-fault.c:35
如上图,我打了两个断点,分别使用第一和第三种方法打的,使用 info b
查看断点情况,如果需要禁止断点的话就使用 disable breakpoint 1
/enable breakpoint 1
,如果要删除断点就使用 delete breakpoint 1
,如果不加断点号,都全部默认对所有的断点进行操作。
监听某一个变量:watch
命令用来跟踪某一个变量的变化情况,只要这个变量被读取或者写入都会触发中断:
- watch Var。监听 Var 变量的改变动作,也就是变量 Var 的值每次被改变的时候就会中断。
- rwatch Var。监听 Var 变量的读取动作。
- awatch Var。监听 Var 变量的读取和写入动作。
|
|
如上所示,运行在第二个断点的时候停下,然后使用 watch
命令监听某一个变量,当这个变量的值被修改的时候就会触发一次中断,然后把值改变前后的状态打印出来。使用该命令可以监听某些变量是怎么被修改成非法值的。
End
Gdb 的命令集非常庞大,当我第一次看到的时候就被吓到了,因为其功能太过强大,同时也带来了学习成本,最好不要死记某一些命令,没可能记得全的,根据具体的场景摸索出调试步骤,第一次很困难,但是多试几次之后就会很顺畅了,基于案例的学习效率要比基于命令的学习效率高很多。
Gdb 还有其它很多的功能,本文就只总结下很简单的段错误调试案例。其它还有调试多线程的死锁类型问题,不仅仅是互斥锁,还有可能是其它类型的资源等待,死循环等待也是一种死锁,只不过不涉及到通常概念上的锁操作。其它的情况在以后的文章中会进行总结。
继续分享极客时间的课程,由于一次分享一篇太慢,所以以后每次会分享三篇或者是一整个章节,方便连续阅读,这个是我自行购买的付费课程,但是它有一个免费分享的机制(虽然名额有限),就是得微信客户端打开。(以下链接需要在微信客户端打开,如果是通过公众号【阅读原文】打开的话可以直接点击阅读):
[30 | 图的表示:如何存储微博、微信等社交网络中的好友关系?]
[29 | 堆的应用:如何快速获取到Top 10最热门的搜索关键词?]
[28 | 堆和堆排序:为什么说堆排序没有快速排序快?]
[27 | 递归树:如何借助树来求解递归算法的时间复杂度?]
[26 | 红黑树(下):掌握这些技巧,你也可以实现一个红黑树]
[25 | 红黑树(上):为什么工程中都用红黑树这种二叉树?]
自行评估自己是否需要咯,说实话这个课程看起来还是需要一定基础的,我看到后面也有点吃力了,一起进步吧。留白
