单步执行和跟踪函数调用
gcc -g
在编译时要加上-g
选项,生成的可执行文件才能用gdb进行源码级调试,-g
选项的作用是在可执行文件中加入源代码的信息,比如可执行文件中第几条机器指令对应源代码的第几行,但并不是把整个源文件嵌入到可执行文件中,所以在调试时必须保证gdb能找到源文件。
$ gcc -g main.c -o main
$ gdb main
gcc
的-g
选项并不是把源代码嵌入到可执行文件中的,在调试时也需要源文件。
gcc
-o
选项用来指定输出文件,如果不使用 -o 选项,那么将采用默认的输出文件。例如默认情况下,生成的可执行文件的名字默认为 a.out
。
gdb help
gdb
提供一个类似Shell的命令行环境,上面的(gdb)
就是提示符,在这个提示符下输入help
可以查看命令的类别:
(gdb) help
List of classes of commands:
aliases -- Aliases of other commands
breakpoints -- Making program stop at certain points
data -- Examining data
files -- Specifying and examining files
internals -- Maintenance commands
obscure -- Obscure features
running -- Running the program
stack -- Examining the stack
status -- Status inquiries
support -- Support facilities
tracepoints -- Tracing of program execution without stopping the program
user-defined -- User-defined commands
Type "help" followed by a class name for a list of commands in that class.
Type "help all" for the list of all commands.
Type "help" followed by command name for full documentation.
Type "apropos word" to search for commands related to "word".
Command name abbreviations are allowed if unambiguous.
也可以进一步查看某一类别中有哪些命令,例如查看files
类别下有哪些命令可用:
(gdb) help files
Specifying and examining files.
List of commands:
add-shared-symbol-files -- Load the symbols from shared objects in the dynamic linker's link map
add-symbol-file -- Load symbols from FILE
add-symbol-file-from-memory -- Load the symbols out of memory from a dynamically loaded object file
cd -- Set working directory to DIR for debugger and program being debugged
core-file -- Use FILE as core dump for examining memory and registers
directory -- Add directory DIR to beginning of search path for source files
edit -- Edit specified file or function
exec-file -- Use FILE as program for getting contents of pure memory
file -- Use FILE as program to be debugged
forward-search -- Search for regular expression (see regex(3)) from last line listed
generate-core-file -- Save a core file with the current state of the debugged process
list -- List specified function or line
...
list
现有源码
#include <stdio.h>
int add_range(int low, int high)
{
int i, sum;
for (i = low; i <= high; i++)
sum = sum + i;
return sum;
}
int main(void)
{
int result[100];
result[0] = add_range(1, 10);
result[1] = add_range(1, 100);
printf("result[0]=%d\nresult[1]=%d\n", result[0], result[1]);
return 0;
}
现在试试用list
命令从第一行开始列出源代码,一次只列10行
(gdb) list 1
1 #include <stdio.h>
2
3 int add_range(int low, int high)
4 {
5 int i, sum;
6 for (i = low; i <= high; i++)
7 sum = sum + i;
8 return sum;
9 }
10
如果要从第11行开始继续列源代码可以输入
(gdb) list # 也可以直接回车,表示重复上一条命令
11 int main(void)
12 {
13 int result[100];
14 result[0] = add_range(1, 10);
15 result[1] = add_range(1, 100);
16 printf("result[0]=%d\nresult[1]=%d\n", result[0], result[1]);
17 return 0;
18
gdb
的很多常用命令有简写形式,例如list
命令可以写成l
,要列一个函数的源代码也可以用函数名做参数:
(gdb) l add_range
1 #include <stdio.h>
2
3 int add_range(int low, int high)
4 {
5 int i, sum;
6 for (i = low; i <= high; i++)
7 sum = sum + i;
8 return sum;
9 }
10
现在退出gdb
的环境:
(gdb) quit
一个调试实例
首先用start
命令开始执行程序:
$ gdb main
...
(gdb) start
Breakpoint 1 at 0x80483ad: file main.c, line 14.
Starting program: /home/akaedu/main
main () at main.c:14
14 result[0] = add_range(1, 10);
(gdb)
gdb
停在main
函数中变量定义之后的第一条语句处等待我们发命令,gdb
列出的这条语句是即将执行的下一条语句。我们可以用next
命令(简写为n
)控制这些语句一条一条地执行:
(gdb) n
15 result[1] = add_range(1, 100);
(gdb) (直接回车)
16 printf("result[0]=%d\nresult[1]=%d\n", result[0], result[1]);
(gdb) (直接回车)
result[0]=55
result[1]=5105
17 return 0;
虽然我们完全控制了程序的执行,但仍然看不出哪里错了,因为错误不在main
函数中而在add_range
函数中,现在用start
命令重新来过,这次用step
命令(简写为s
)钻进add_range
函数中去跟踪执行:
(gdb) start
The program being debugged has been started already.
Start it from the beginning? (y or n) y
Breakpoint 2 at 0x80483ad: file main.c, line 14.
Starting program: /home/akaedu/main
main () at main.c:14
14 result[0] = add_range(1, 10);
(gdb) s
add_range (low=1, high=10) at main.c:6
6 for (i = low; i <= high; i++)
这次停在了add_range
函数中变量定义之后的第一条语句处。在函数中有几种查看状态的办法,backtrace
命令(简写为bt
)可以查看函数调用的栈帧:
(gdb) bt
#0 add_range (low=1, high=10) at main.c:6
#1 0x080483c1 in main () at main.c:14
可见当前的add_range
函数是被main
函数调用的,main
传进来的参数是low=1
, high=10
。main
函数的栈帧编号为1,add_range
的栈帧编号为0。现在可以用info
命令(简写为i)查看add_range
函数局部变量的值:
(gdb) i locals
i = 0
sum = 0
如果想查看main
函数当前局部变量的值也可以做到,先用frame
命令(简写为f)选择1号栈帧然后再查看局部变量:
(gdb) f 1
#1 0x080483c1 in main () at main.c:14
14 result[0] = add_range(1, 10);
(gdb) i locals
result = {
0, 0, 0, 0, 0, 0, 134513196, 225011984, -1208685768, -1081160480,
...
-1208623680}
到目前为止一切正常。用s
或n
往下走几步,然后用print
命令(简写为p
)打印出变量sum
的值:
(gdb) s
7 sum = sum + i;
(gdb) (直接回车)
6 for (i = low; i <= high; i++)
(gdb) (直接回车)
7 sum = sum + i;
(gdb) (直接回车)
6 for (i = low; i <= high; i++)
(gdb) p sum
$1 = 3
$1
表示第一个参数,即sum
;$n表示第
n个参数
由于我们本来就知道第一次调用的结果是正确的,再往下跟也没意义了,可以用finish
命令让程序一直运行到从当前函数返回为止:
(gdb) finish
Run till exit from #0 add_range (low=1, high=10) at main.c:6
0x080483c1 in main () at main.c:14
14 result[0] = add_range(1, 10);
Value returned is $2 = 55
当前正准备执行赋值操作,用s命令赋值,然后查看result
数组:
(gdb) s
15 result[1] = add_range(1, 100);
(gdb) p result
$3 = {
55, 0, 0, 0, 0, 0, 134513196, 225011984, -1208685768, -1081160480,
...
-1208623680}
下面用s命令进入第二次add_range
调用,进入之后首先查看参数和局部变量:
(gdb) s
add_range (low=1, high=100) at main.c:6
6 for (i = low; i <= high; i++)
(gdb) bt
#0 add_range (low=1, high=100) at main.c:6
#1 0x080483db in main () at main.c:15
(gdb) i locals
i = 11
sum = 55
由于局部变量i
和sum
没初始化,所以具有不确定的值,又由于两次调用是挨着的,i
和sum
正好取了上次调用时的值。
好了,我们已经找到错误原因,可以退出gdb
修改源代码了。如果我们不想浪费这次调试机会,可以在gdb
中使用set var sum=0
命令把sum
的初值改为0继续运行,看看这一处改了之后还有没有别的Bug:
(gdb) set var sum=0
(gdb) finish
Run till exit from #0 add_range (low=1, high=100) at main.c:6
0x080483db in main () at main.c:15
15 result[1] = add_range(1, 100);
Value returned is $4 = 5050
(gdb) n
16 printf("result[0]=%d\nresult[1]=%d\n", result[0], result[1]);
(gdb) (直接回车)
result[0]=55
result[1]=5050
17 return 0;
这样结果就对了。
修改变量的值除了用set
命令之外也可以用print
命令,因为print
命令后面跟的是表达式,而我们知道赋值和函数调用也都是表达式,所以也可以用print
命令修改变量的值或者调用函数:
(gdb) p result[2]=33
$5 = 33
(gdb) p printf("result[2]=%d\n", result[2])
result[2]=33
$6 = 13
printf
的返回值表示实际打印的字符数,所以$6
的结果是13。
小结
总结本节使用到的gdb命令,下表是gdb基本命令1表
命令 | 描述 |
---|---|
backtrace(或bt) | 查看各级函数调用及参数 |
finish | 连续运行到当前函数返回为止,然后停下来等待命令 |
frame(或f) 帧编号 | 选择栈帧 |
info(或i) locals | 查看当前栈帧局部变量的值 |
list(或l) | 列出源代码,接着上次的位置往下列,每次列10行 |
list 行号 | 列出从第几行开始的源代码 |
list 函数名 | 列出某个函数的源代码 |
next(或n) | 执行下一行语句 |
print(或p) | 打印表达式的值,通过表达式可以修改变量的值或者调用函数 |
quit(或q) | 退出gdb 调试环境 |
set var | 修改变量的值 |
start | 开始执行程序,停在main 函数第一行语句前面等待命令 |
step(或s) | 执行下一行语句,如果有函数调用则进入到函数中 |
断点
本节看一个断点调试实例,现有程序如下
#include <stdio.h>
int main(void)
{
int sum = 0, i = 0;
char input[5];
while (1) {
scanf("%s", input);
for (i = 0; input[i] != '\0'; i++)
sum = sum*10 + input[i] - '0';
printf("input=%d\n", sum);
}
return 0;
}
这个程序的作用是:首先从键盘读入一串数字存到字符数组input
中,然后转换成整型存到sum
中,然后打印出来,一直这样循环下去。scanf("%s", input);
这个调用的功能是等待用户输入一个字符串并回车,scanf
把其中第一段非空白(非空格、Tab、换行)的字符串保存到input
数组中,并自动在末尾添加'\0'
。接下来的循环从左到右扫描字符串并把每个数字累加到结果中,例如输入是"2345"
,则循环累加的过程是(((0*10+2)*10+3)*10+4)*10+5=2345。注意字符型的'2'
要减去'0'
的ASCII码才能转换成整数值2。
下面编译运行程序看看有什么问题:
$ gcc main.c -g -o main
$ ./main
123
input=123
234
input=123234
(Ctrl-C退出程序)
$
又是这种现象,第一次是对的,第二次就不对。下面来调试:
$ gdb main
...
(gdb) start
Breakpoint 1 at 0x80483b5: file main.c, line 5.
Starting program: /home/akaedu/main
main () at main.c:5
5 int sum = 0, i = 0;
有了上一次的经验,sum
被列为重点怀疑对象,我们可以用display
命令使得每次停下来的时候都显示当前sum
的值,然后继续往下走:
(gdb) display sum
1: sum = -1208103488
(gdb) n
9 scanf("%s", input);
1: sum = 0
(gdb)
123
10 for (i = 0; input[i] != '\0'; i++)
1: sum = 0
undisplay
命令可以取消跟踪显示,变量sum
的编号是1,可以用undisplay 1
命令取消它的跟踪显示。这个循环应该没有问题,因为上面第一次输入时打印的结果是正确的。如果不想一步一步走这个循环,可以用break
命令(简写为b
)在第9行设一个断点(Breakpoint):
(gdb) l
5 int sum = 0, i;
6 char input[5];
7
8 while (1) {
9 scanf("%s", input);
10 for (i = 0; input[i] != '\0'; i++)
11 sum = sum*10 + input[i] - '0';
12 printf("input=%d\n", sum);
13 }
14 return 0;
(gdb) b 9
Breakpoint 2 at 0x80483bc: file main.c, line 9.
break
命令的参数也可以是函数名,表示在某个函数开头设断点。现在用continue
命令(简写为c
)连续运行而非单步运行,程序到达断点会自动停下来,这样就可以停在下一次循环的开头:
(gdb) c
Continuing.
input=123
Breakpoint 2, main () at main.c:9
9 scanf("%s", input);
1: sum = 123
然后输入新的字符串准备转换:
(gdb) n
234
10 for (i = 0; input[i] != '\0'; i++)
1: sum = 123
问题暴露出来了,新的转换应该再次从0开始累加,而sum
现在已经是123了,原因在于新的循环没有把sum
归零。
一次调试可以设置多个断点,用info
命令可以查看已经设置的断点:
(gdb) b 12
Breakpoint 3 at 0x8048411: file main.c, line 12.
(gdb) i breakpoints
Num Type Disp Enb Address What
2 breakpoint keep y 0x080483c3 in main at main.c:9
breakpoint already hit 1 time
3 breakpoint keep y 0x08048411 in main at main.c:12
每个断点都有一个编号,可以用编号指定删除某个断点:
(gdb) delete breakpoints 2
(gdb) i breakpoints
Num Type Disp Enb Address What
3 breakpoint keep y 0x08048411 in main at main.c:12
有时候一个断点暂时不用可以禁用掉而不必删除,这样以后想用的时候可以直接启用,而不必重新从代码里找应该在哪一行设断点:
(gdb) disable breakpoints 3
(gdb) i breakpoints
Num Type Disp Enb Address What
3 breakpoint keep n 0x08048411 in main at main.c:12
(gdb) enable 3
(gdb) i breakpoints
Num Type Disp Enb Address What
3 breakpoint keep y 0x08048411 in main at main.c:12
删除所有断点
(gdb) delete breakpoints
Delete all breakpoints? (y or n) y
(gdb) i breakpoints
No breakpoints or watchpoints.
gdb
的断点功能非常灵活,还可以设置断点在满足某个条件时才激活,例如我们仍然在循环开头设置断点,但是仅当sum
不等于0时才中断,然后用run
命令(简写为r
)重新从程序开头连续运行:
(gdb) break 9 if sum != 0
Breakpoint 5 at 0x80483c3: file main.c, line 9.
(gdb) i breakpoints
Num Type Disp Enb Address What
5 breakpoint keep y 0x080483c3 in main at main.c:9
stop only if sum != 0
(gdb) r
The program being debugged has been started already.
Start it from the beginning? (y or n) y
Starting program: /home/akaedu/main
123
input=123
Breakpoint 5, main () at main.c:9
9 scanf("%s", input);
1: sum = 123
结果是第一次执行scanf
之前没有中断,第二次却中断了。
小结
总结一下本节用到的gdb
命令,小表是gdb基本命令2表
命令 | 描述 |
---|---|
break(或b) 行号 | 在某一行设置断点 |
break 函数名 | 在某个函数开头设置断点 |
break … if … | 设置条件断点 |
continue(或c) | 从当前位置开始连续运行程序 |
delete breakpoints 断点号 | 删除断点 |
display 变量名 | 跟踪查看某个变量,每次停下来都显示它的值 |
disable breakpoints 断点号 | 禁用断点 |
enable 断点号 | 启用断点 |
info(或i) breakpoints | 查看当前设置了哪些断点 |
run(或r) | 从头开始连续运行程序 |
undisplay 跟踪显示号 | 取消跟踪显示 |
观察点
观察点调试实例,现有如下代码
#include <stdio.h>
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
函数是非常凶险的,即使修正了这个Bug也还存在很多问题。如果输入的字符串超长了会怎么样?我们知道数组访问越界是不会检查的,所以scanf
会写出界。现象是这样的:
$ ./main
123
input=123
67
input=67
12345
input=123407
下面用调试器看看最后这个诡异的结果是怎么出来的
$ gdb main
...
(gdb) start
Breakpoint 1 at 0x80483b5: file main.c, line 5.
Starting program: /home/akaedu/main
main () at main.c:5
5 int sum = 0, i = 0;
(gdb) n
9 sum = 0;
(gdb) (直接回车)
10 scanf("%s", input);
(gdb) (直接回车)
12345
11 for (i = 0; input[i] != '\0'; i++)
(gdb) p input
$1 = "12345"
input
数组只有5个元素,写出界的是scanf
自动添的'\0'
,用x
命令看会更清楚一些:
(gdb) x/7b input
0xbfb8f0a7: 0x31 0x32 0x33 0x34 0x35 0x00 0x00
x
命令打印指定存储单元的内容。7b
是打印格式,b
表示每个字节一组,7表示打印7组[22],从input
数组的第一个字节开始连续打印7个字节。前5个字节是input
数组的存储单元,打印的正是十六进制ASCII码的'1'
到'5'
,第6个字节是写出界的'\0'
。根据运行结果,前4个字符转成数字都没错,第5个错了,也就是i
从0到3的循环都没错,我们设一个条件断点从i
等于4开始单步调试:
(gdb) l
6 char input[5];
7
8 while (1) {
9 sum = 0;
10 scanf("%s", input);
11 for (i = 0; input[i] != '\0'; i++)
12 sum = sum*10 + input[i] - '0';
13 printf("input=%d\n", sum);
14 }
15 return 0;
(gdb) b 12 if i == 4
Breakpoint 2 at 0x80483e6: file main.c, line 12.
(gdb) c
Continuing.
Breakpoint 2, main () at main.c:12
12 sum = sum*10 + input[i] - '0';
(gdb) p sum
$2 = 1234
现在sum
是1234没错,根据运行结果是123407我们知道即将进行的这步计算肯定要出错,算出来应该是12340,那就是说input[4]
肯定不是'5'
了,事实证明这个推理是不严谨的:
(gdb) x/7b input
0xbfb8f0a7: 0x31 0x32 0x33 0x34 0x35 0x04 0x00
input[4]
的确是0x35,产生123407还有另外一种可能,就是在下一次循环中123450不是加上而是减去一个数得到123407。可现在不是到字符串末尾了吗?怎么会有下一次循环呢?注意到循环控制条件是input[i] != '\0'
,而本来应该是0x00的位置现在莫名其妙地变成了0x04,因此循环不会结束。继续单步:
(gdb) n
11 for (i = 0; input[i] != '\0'; i++)
(gdb) p sum
$3 = 12345
(gdb) n
12 sum = sum*10 + input[i] - '0';
(gdb) x/7b input
0xbfb8f0a7: 0x31 0x32 0x33 0x34 0x35 0x05 0x00
进入下一次循环,原来的0x04又莫名其妙地变成了0x05,这是怎么回事?这个暂时解释不了,但123407这个结果可以解释了,是12345*10 + 0x05 - 0x30得到的,虽然多循环了一次,但下次一定会退出循环了,因为0x05的后面是'\0'
。
input[4]
后面那个字节到底是什么时候变的?可以用观察点(Watchpoint)来跟踪。我们知道断点是当程序执行到某一代码行时中断,而观察点是当程序访问某个存储单元时中断,如果我们不知道某个存储单元是在哪里被改动的,这时候观察点尤其有用。下面删除原来设的断点,从头执行程序,重复上次的输入,用watch
命令设置观察点,跟踪input[4]
后面那个字节(可以用input[5]
表示,虽然这是访问越界):
(gdb) delete breakpoints
Delete all breakpoints? (y or n) y
(gdb) start
Breakpoint 1 at 0x80483b5: file main.c, line 5.
Starting program: /home/akaedu/main
main () at main.c:5
5 int sum = 0, i = 0;
(gdb) n
9 sum = 0;
(gdb) (直接回车)
10 scanf("%s", input);
(gdb) (直接回车)
12345
11 for (i = 0; input[i] != '\0'; i++)
(gdb) watch input[5]
Hardware watchpoint 2: input[5]
(gdb) i watchpoints
Num Type Disp Enb Address What
2 hw watchpoint keep y input[5]
(gdb) c
Continuing.
Hardware watchpoint 2: input[5]
Old value = 0 '\0'
New value = 1 '\001'
0x0804840c in main () at main.c:11
11 for (i = 0; input[i] != '\0'; i++)
(gdb) c
Continuing.
Hardware watchpoint 2: input[5]
Old value = 1 '\001'
New value = 2 '\002'
0x0804840c in main () at main.c:11
11 for (i = 0; input[i] != '\0'; i++)
(gdb) c
Continuing.
Hardware watchpoint 2: input[5]
Old value = 2 '\002'
New value = 3 '\003'
0x0804840c in main () at main.c:11
11 for (i = 0; input[i] != '\0'; i++)
已经很明显了,每次都是回到for
循环开头的时候改变了input[5]
的值,而且是每次加1,而循环变量i
正是在每次回到循环开头之前加1,原来input[5]
就是变量i
的存储单元,换句话说,i
的存储单元是紧跟在input
数组后面的。
修正这个Bug对初学者来说有一定难度。如果你发现了这个Bug却没想到数组访问越界这一点,也许一时想不出原因,就会先去处理另外一个更容易修正的Bug:如果输入的不是数字而是字母或别的符号也能算出结果来,这显然是不对的,可以在循环中加上判断条件检查非法字符:
while (1) {
sum = 0;
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);
}
然后你会惊喜地发现,不仅输入字母会报错,输入超长也会报错:
$ ./main
123a
Invalid input!
input=-1
dead
Invalid input!
input=-1
1234578
Invalid input!
input=-1
1234567890abcdef
Invalid input!
input=-1
23
input=23
似乎是两个Bug一起解决掉了,但这是治标不治本的解决方法。
小结
结一下本节用到的gdb
命令,如下表gdb基本命令3表:
命令 | 描述 |
---|---|
watch | 设置观察点 |
info(或i) watchpoints | 查看当前设置了哪些观察点 |
x | 从某个位置开始打印存储单元的内容,全部当成字节来看,而不区分哪个字节属于哪个变量 |
参考文章
《Linux C编程一站式学习》 第 10 章 gdb https://docs.huihoo.com/c/linux-c-programming/ch10.html
《Linux C编程一站式学习》 https://docs.huihoo.com/c/linux-c-programming/