列位安好。简单总结下GDB调试器的使用。
准备
默认情况下,gcc/g++编译的可执行文件是不包含调试信息的,GDB是一个源代码级的调试器,使用GDB调试程序需要程序的源代码、符号及其对应的行号等,其中符号和行号可以是单独的文件,亦可以在编译时嵌入到可执行文件中。使用gcc/g++时使用-g选项即可将必要的调试信息包含到可执行文件中,使用-g3选项还可以将源代码中的宏信息也包含进去。
另外,调试过程中需要随时查看源代码,但源代码并没有包含到可执行文件中。通常GDB在当前目录查找源文件,但某些情况下(比如调试系统命令)需要手动指明源代码的查找目录,directory ~向GDB指明到$HOME下查找源文件。
启动
GDB的启动很灵活,它的各种特性,你可以在Shell下通过选项和参数指定,也可以在GDB启动之后在GDB自己的命令行下使用GDB内置的命令来指定。最常用的是直接使用命令gdb PROGRAM启动,这样gdb自动加载符号表等调试信息。若要向被调试程序传递参数,可以采用gdb –args program ARG1 ARG2的形式,其中–args(或者-args)是必须的,它告诉GDB该选项之后已经没有GDB需要的选项了。另外,还可以直接使用gdb启动,然后使用file program加载调试信息。此时若要设置被调试程序的参数,可以使用set命令的args子命令,如set args ARG1 ARG2. 还有一种传递参数的方法,在下面介绍。
断点
调试程序,就是使用调试器(Debugger)通过检测和改变被调试程序(Debuggee)的状态、控制其执行的方式找出被调试程序中的错误和潜在的bug。调试程序,观察程序当前的行为的前提是让程序在“适当的时候”暂停运,那么什么是适当的时候呢?使用GDB时,让程序暂停运行需要使用断点。具体地,断点又可以分为普通断点(breakpoint以下简称断点),观察点(watchpoint),捕捉点(watchpoint)三类。
普通断点使用break命令(简写为b)设置。break命令的格式为break [bp-spec] [if CONDITION] [thread THREADNUM],bp-spec指明断点设置的位置,可以是行号、函数名或者指令地址,如果bp-spec省略,则断点被设置在程序所要执行的下一行代码(或者指令)上。if CONDITION指明当程序到达bp-spec位置时,只有CONDITION条件成立时程序才会暂停。thread THREADNUM用在多线程程序的调试中,断点只被设置在指定的线程号(GDB内部而不是系统使用的标号)为THREADNUM的线程上,如果THREADNUM为all则所有线程都会被设置断点。补充下bp-spec可以是函数名b FUNCTIONNAME(重载函数名需要使用”包含才能自动补全),可以是行号b LINENUMBER或者b FILENAME:LINENUMBER,还可以是指令地址b *ADDRESS。另外,break命令还有其它一些变种,比如tbreak设置临时断点,被使用一次就会自动删除,rbreak使用正则表达式来指明函数名。
观察点使用watch命令,命令格式与break相同,但它并不是指明断点的位置,而是指明一个表达式,每当该表达式的值改变时,程序便会被暂停。表达式可以是某个变量、由若干变量组成的表达式或者内存地址。
捕捉点是另一种断点,它使用某种事件的发生作为触发条件,命令各式为catch EVENT。这些事件主要包括异常的抛出和捕获(catch throw/catch)和某个系统调用(catch fork/open/exec, catch syscall CALLNUM)。
查看当前的断点设置情况可以使用breakpoints,也可以使用info breakpoints(或者简写为i b)命令,查看某个断点使用breakpoints bpnum,bpnum为断点号。
使用enable/disable bpnum使某个断点生效和失效,delete bpnum删除断点。bpnum还可以是一个范围,以此批量操作断点,比如d 2-6删除断点2到6。
使用ignore bpnum COUNT还可以使某个断点被或略COUNT次,即是说断点bpnum的前COUNT次到达都不会被触发,知道COUNT递减至0。另外,在COUNT递减至0之前,该断点上的条件是不会被考虑的。
CONDITION bpnum [if condition],修改bpnum上的触发条件,若if被省略,则bpnum断点上的条件将被删除。
运行
GDB启动和加载调试信息后,被调试程序并没有运行。使用run/r或者start命令,GDB建立子进程来运行被调试程序。run和start命令稍有不同,即run仅仅加载程序然后运行,而start会在程序的入口函数(main)设置一个临时断点,程序运行到那里就会暂停,临时断点也随即被清除。另外run和start命令后面都可以加上传递给被调试程序的参数,若不加参数则使用GDB启动时传递的参数或者使用set args命令设置的参数。若要清除参数而不退出GDB,使用不带参数的set args即可。
其它运行相关的命令还有
- continue/c,继续运行。
- next/n, 下一行,且不进入函数调用
- stop/s, 下一行,但进入函数调用
- ni或者si, 下一条指令,ni与si的区别同n与s的区别
- finish/fini, 继续运行至当前栈帧/函数刚刚退出
- until/u, 继续运行至某一行,在循环中时,u可以实现运行至循环刚刚退出,但这取决于循环的实现。
查看
调试的主要工作,我想就是检查程序的状态吧,内存的状态,程序的流程,指令的安排。GDB有多个命令来查看程序的状态,最常用的是list/l, print/p和x和disassemble。
- list linenum/function列出第linenum行或者function所在行附近的10行,list *address列出地址address附近的10行。
- list列出上一次list命令列出的代码后面的10行
- list -,列出上一次list命令列出的代码前的10行
- list列出的默认行数可由set listsize size来设置
- p /fmt VARIABLE根据fmt指定的格式打印变量VARIABLE的值,常用的fmt有d(decimal), u(unsigned), x(hex), o(octal), c(character), f(float), s(string)
- x /nfs ADDRESS是我最喜欢的命令,它显示ADDRESS地址开始的内容,形式由nfs指定。其中n为次数(个数),f是格式,除p命令可以使用的格式外,还可以使用i(instruction)打印指令,s为所打印内容的大小,可以是b(byte), h(half word), w(word, 4bytes), g(8bytes). nfs均可省略,若省略则使用最近一次使用x命令时的值,最初nfs是1dw。
- disassemble /[r][m] [ADDRESS]将ADDRESS所在函数进行反汇编,ADDRESS也可以是一个由行号组成的区间。不指定任何选项时disas只简单的列出反汇编指令,指定m(mixed)选项时会对应地列出指令和源代码,指定r(raw)时会打印出指令对应的十六进制编码。
退出
Ctrl-C会终止当前调试的程序(而不是调试器)。q(quit)退出GDB,若退出时被调试程序尚未结束,GDB会提示,请求确认。
其它
help
使用GDB的过程中,如果对某一个命令的用法不清楚,可以随时使用help/h寻求帮助。
info
info/i命令也是一个十分常用的GDB命令,可以查看许多信息。
- i program查看被调试程序的运行状态,如进程号、ip(指令指针)、是否运行、停止原因等。
- i b [bpnum]查看断点
- i f [frame-num]查看当前(或指定)栈帧,i f all还会列出当前栈帧的局部变量
- i line LINENUM查看代码行LINENUM,打印其指令地址,此命令后执行x/i可查看该地址处的指令,然后回车即可继续向后查看接下来的指令
- i reg查看寄存器状态,i all-reg查看包含浮点堆栈寄存器在内的所有寄存器情况
- i args查看当前栈帧的参数
- i s追踪栈帧信息,相当于backtrace/bt命令
- i threads查看当前进程的线程信息
回到过去
跟踪调试程序的过程中,偶尔会错过一些关键点,不得不重新启动程序。如果错过的这些关键点不容易再现,就更令人懊恼了。GDB提供一种机制可以让你将程序向后调整,重新来过。这种机制叫做checkpoint,你可以在程序运行的关键点处执行checkpoint命令,你将得到一个数字(check-num)来标识这个checkpoint。在以后的某个时刻使用restart check-num将程序回滚到设置该checkpoint的时刻,而此时此刻的“内存状态”恰如彼时彼刻,你可以重新调试这段程序(比如设置不同的变量值来测试不同的情况)。但覆水难收的道理你是懂得,回滚的这段程序之间产生的内存之外的效应是无法恢复的,比如写出到文件的数据收不回来了(但文件的指针偏移是可以恢复的,因为它的值保存在内存),通过网络发出的数据就更要不回来了。
使用i checkpoints可以查看checkpoint信息,比如check-num及其所处代码行。
据我揣测,GDB的这种“回到过去”的伎俩并不是逐步撤销之前运行的指令,而是在checkpoint命令执行的时候,把被调试程序fork/clone了。
多线程
调试多线程程序与普通程序没有太大的区别。
- i threads查看当前进程包含的线程的信息,包括线程号及其GDB内部标识号,当前处于前端的线程。
- thread thread-num切换到线程thread-num
- set scheduler-locking [on|off|step],这是一个比较重要的选项,它控制这当前调试线程与其它线程的执行关系。设置为on时,其它线程不会抢占当前线程。设置为off(默认)时,当前线程执行时随时可能被其它线程抢占。设置为step时,在执行step命令时不会被抢占,但使用next跳过函数调用时可以/可能会被抢占,即调度器会被执行。
技巧
回车。在GDB命令行下,简单的回车会执行上一个命令。而且GDB命令中可以使用回车重复执行的都是有记忆的,如果使用回车,该命令就会根据上一次的执行适当地调整参数重复执行。比如使用l func列出函数func附近的10行代码,接着回车就会打印接下来的10行。使用x/16xw ADDRESS以16进制形式查看ADDRESS处的16个字,接着回车就会以同样的格式打印接下来的16个字。如果你已经set $i=0了,那么通过p a[$i++]然后一直回车就可以依次打印数组a的元素了。x/i $eip打印下一条指令,接着回车就会打印下下一条指令,以此类推。但,有的指令是无法使用回车来重复执行的,比如run/start,总之,通常,你觉得不能/不适合重复执行的命令就无法重复执行。
命令简写,有的命令名称较长甚至很长,但GDB允许用户只使用足以区别其它命令的前几个字符来执行命令,当然你也可以使用TAB自动补全。另外一些极为常用的命令有专门的简写形式,通常只有一个字母,例如break/b, list/l, info/i, continue/c, next/n, step/s, nexti/ni, stepi/si, frame/f, print/p等等等等。
在GDB中可以自定义变量(仅供GDB内部使用),很多时候可以方便查看一些表达式的值。有的时候使用变量似乎是必须的,比如x *($esp)是不合法的,因为GDB不允许对寄存器变量进行解引用(dereference, but why?)。这时,设置变量set $p=*($esp),然后x $p就可以了。
这只是一个小小的不完全的总结,如果你从未使用过GDB,推荐使用RMS的《Debugging with GDB》学习,还有一本小书《GDB Pocket Reference》,不妨做你的调试菜谱,如果你喜欢GDB的话。