第十章 gdb
程序中除了一目了然的Bug之外都需要一定的调试手段来分析到底错在哪。到目前为止我们的调试手段只有一种:根据程序执行时的出错现象假设错误原因,然后在代码中适当的为止插入printf,执行程序并分析打印结果,如果结果和预期的一样,就基本上证明了自己假设的错误原因,就可以动手修正Bug了,如果结果和预期的不一样,就根据结果做进一步的假设和分析。调试工具gdb的基本思想仍然是“分析现象->假设错误原因->产生新的现象去验证假设”。
1.单步执行和跟踪函数调用
在编译时要加上-g选项,生成的可执行文件才能用gdb进行调试:
-g选项的作用是在可执行 文件中加入源代码的信息,比如可执行文件中第几条机器指令对应源代码的第几行,但并不是把整个源文件嵌入到可执行文件中,所以在调试时必须保证gdb能找到源文件。gdb提供一个类似shell的命令行环境,上面的(gdb)就是提示符,在这个提示符下输入help可以查看命令的类别,可以进一步查看某一类别中有哪些命令,例如查看files类别下有哪些命令可以用:
现在试试用list命令从第一行开始列出源代码:
一次只列10行,如果要从11行开始继续列源代码可以输入
也可以什么都不输直接敲回车,gdb提供了一个很方便的功能,在提示符下直接敲回车表示用适当的参数重复上一条命令。gdb的很多常用命令有简写形式,例如list命令可以写成l,要列一个函数的源代码也可以用函数名做参数:
现在退出gdb的环境:
现在把源代码改名或移到别处再用gdb调试,就列不出源代码了:
可见gcc的-g选项并不是把源代码 嵌入到可执行文件中的,在调试时也需要源文件。现在把源代码恢复原样,我们继续调试。首先用start命令开始执行程序:
这表示停在main函数中变量定义之后的第一条语句处等待我们发命令,gdb列出这条语句表示它还没执行,并且马上要执行。我们可以用next命令(简写为n)控制这些语句一条一条地执行:
用n命令依次执行两行赋值语句和一行打印语句,在执行打印语句时结果立刻打出来了,然后停留在return语句之前等待我们发命令。虽然我们完全控制了程序的执行,但仍然看不出哪里错了,因为错误不在main函数而在add_range函数,现在用start命令重新来过,这次用step命令(简写为s)进入函数中去执行:
这次停在了函数中变量定义之后的第一条语句处。在函数中有几种查看状态的办法,backtrace命令(简写为bt)可以查看函数调用的栈帧:
可见当前的add_range函数是被main函数调用的,main传进来的参数是low=1,high=10。main函数的栈帧编号为1,add_range的栈帧编号为0。现在可以用info命令(简写为i)查看add_range局部变量的值:
如果想查看main函数当前局部变量的值也可以做到,先用frame命令(简写为f)选择1号栈帧后再查看局部变量:
注意到result数组中有很多元素具有杂乱无章的值,我们知道,未经初始化的局部变量具有不确定的值。到目前为止一切正常。用s或n往下走几步,然后用print命令(简写为p)打出变量sum的值:
第一次循环i是1,第二次循环i是2,加起来是3,没错。这里的$1表示gdb保存着这些中间结果,$后面的编号会自动增长,在命令中可以用$1、$2、$3等编号代替相应的值。由于我们本来就知道第一次调用的结果是正确的,再往下跟也没意义了,可以用finish命令让程序一直运行到从当前函数返回为止:
返回值是55,当前正准备执行赋值操作,用s命令赋值,然后查看result数组:
第一个值55确实赋给了result数组的第0个元素。下面用s命令进入第二次add_range掉用,进入之后首先查看参数和局部变量:
由于局部变量i和sum没初始化,所以具有不确定的值,又由于两次调用是挨着的,i和sum正好取了上次调用时的值。i的初值不是0倒没关系,在for循环中会赋值为0的,但sum如果初值不是0,累加得到的结果就错了。好了,我们已经找到错误原因,可以退出gdb修改源代码了。如果我们不想浪费这一次调试机会,可以在gdb中马上把sum的初值改为0继续运行,看看这一处改了之后还有没有别的Bug:
这样结果就对了。修改变量的值除了用set命令之外也可以用print命令,因为print命令后面跟的是表达式,而我们知道赋值和函数调用也都是表达式,所以还可以用print来修改变量的值,或者调用函数:
printf的返回值表示实际打印的字符数,所以$6的结果是13。
前面用到的gdb的基本命令:
2.断点
断点调试实例:
这个程序的作用是:首先从键盘读入一串数字存到字符数组input中,然后转换成整型存到sum中,然后打印出来,一直这样循环下去。scanf(“%s”, input);这个调用功能是等待用户输入一个字符串并回车,scanf把其中第一段非空白(非空格、Tab、换行)的字符串放到input数组中,并自动在末尾添加‘ ’。接下来的循环从左到右扫描字符串并把每个数字累加到结果中,例如输入是“2345”,则循环累加的过程是(((0*10+2)*10+3)*10+4)*10+5=2345。注意字符型的‘2’要减去‘0’的ASCII码才能转换成整数值的2,‘0’的ASCII码是48,而‘ ’的ASCII码是0,二者是不相同的。下面运行程序看问题:
又是这种现象,第一次是对的,第二次不对。而这个程序我们赋了初值,下面调试:
可见,如果变量要赋初值,start不会跳过变量定义语句。
用display命令使得每次停下来的时候都显示当前sum值,然后继续往下走:
用undisplay可以取消对先前设置的那些变量的跟踪。这个循环应该是没有问题的,因为第一次的结果正确。如果不想一步一步走这个循环,可以用break命令(简写为b)在第9行设一个断点(Breakpoint):
break命令的参数也可以是函数名,表示在某一个函数开头设断点。现在用continue命令(简写为c)连续运行而非单步运行,程序到达断点会自动停下来,这样就可以停在下一次循环的开头:
然后输入新的字符串准备转换:
问题暴露出来了,新的转换应该再次从0开始累加,而sum现在已经是123了,原因在于新的循环没有把sum归零。可见断点有助于快速跳过与问题无关的代码,然后在有问题的代码上慢慢走慢慢分析,“断点加单步”是使用调试器的基本方法。至于应该在哪里设置断点,怎么知道哪些代码可以跳过而哪些代码要慢慢走,也要通过对错误现象的分析和假设来确定,就像以前分析确定在哪里插入printf语句一样。一次调试可以设置多个断点,用info命令可以查看已经设置的断点:
每个断点都有一个编号,可以用编号指定删除某个断点:
有时候一个断点暂时不想用可以禁用掉而不必删除,这样以后想用的时候可以直接启用,而不必重新从代码里找应该在哪一行设断点:
gdb的断点功能非常灵活,还可以设置断点在满足某个条件时才激活,例如我们仍然在循环开头设置断点,但是仅当sum不等于0时才中断,然后用run命令(简写为r)重新从程序开头连续执行:
结果是第一次执行scanf之前没有中断,第二次却中断了。
gdb基本命令:
3.观察点
断点是当程序执行到某一代码行时中断,而观察点(Watchpoint)是当程序访问某一存储单元时中断,如果我们不知道某一存储单元时在哪里被改动的,这时候观察点尤其有用。
观察点调试实例:
下面善春原来设的断点,从头执行程序,重复上次的输入,用watch命令设置观察点,跟踪input[4]后面那个字节(input[5]):
gdb基本命令:
4.段错误
在gdb中遇到段错误就会停下来。如scanf输入整型变量就必须要加&,否则就会出段错误,而输出字符串就不要加&。
学习C语言不可能不去了解底层计算机体系结构和操作系统的原理,不了解底层原理一个scanf都用不好,更没办法写出正确的程序。