[翻译] 用GDB学习C语言

原文:https://www.recurse.com/blog/5-learning-c-with-gdb

对于高级语言背景的使用者,就像Ruby, Scheme或者Haskell,学习 C 语言是极具挑战性的。除了要与 C 语言的低级特性,像手动管理内存和指针作斗争外,你还需要在没有交互式编程的环境下,完成整个学习过程。一旦你习惯了在交互式编程环境中的编程之路,你就会对这种写代码-编译-运行的循环过程感到无聊。

最近我想到可以使用GDB当做C的伪交互式编程环境。我一直将使用gdb作为学习C语言的工具,而不仅仅是调试C语言,并且这有很多的乐趣。

我这篇文章的目的,是告诉你,gdb是学习C语言的很好的工具。我将向你介绍一些我喜欢的gdb命令,然后我将演示如何使用gdb来理解,众所周知的C语言的棘手问题:数组和指针的区别。

gdb概述

以下面这小段C程序作为开始,minimal.c

1
2
3
4
5
int main()
{
int i = 1337;
return 0;
}

注意,程序中并没有其他内容,也没有单独的printf语句,看好了,这就是使用gdb学习C语言的勇敢新世界。

用gcc -g 选项对其进行编译,使得目标文件带有gdb调试信息,然后用gdb调试它。

1
2
$ gcc -g minimal.c -o minimal
$ gdb minimal

这下你就能看到一个非常明显的gdb提示符,我之前承诺的交互式编程环境,这就是了:

1
2
(gdb) print 1 + 2
$1 = 3

神奇的地球!

print 是gdb的内置命令,用来打印C语言表达式的赋值结果;

如果你不确定某条gdb命令是干嘛的,就在gdb提示符后面输入 help ‘command’;

接下来是一些比较有趣的例子:

1
2
(gbd) print (int) 2147483648
$2 = -2147483648

这里我将忽略讨论为什么2147483648 == -2147483648,关键点在于即使是C语言也能在算术上玩出花样,而gdb也能深谙其中之道。

现在我们在main函数上设置一个断点,然后启动程序:

1
2
(gdb) break main
(gdb) run

程序现在停在第三行,在 i 变量初始化之前,非常有趣,即使 i 没有初始化,我们也可以用print命令查看i的值

1
2
(gdb) print i
$3 = 32767

在C语言中,一个没有初始化的局部变量的值是未定义的,所以gdb可能会在你的屏幕上输出与我不一样的值;

接下来,可以使用next命令执行当前行:

1
2
3
(gdb) next
(gdb) print i
$4 = 1337

用x命令检查内存

在C语言中,变量存在标签连续的内存块中,一个变量的内存块,由以下两个数字特征来决定:

  1. 内存块中第一个字节的数值地址;
  2. 用字节衡量的内存块大小,变量块的大小由变量类型决定。

C语言有个独特的特性,就是你可以直接访问变量所在的内存块,‘&’操作符将计算出变量的地址,sizeof 操作符将计算出变量在内存中的大小;

你可以用gdb玩一下这两个概念:

1
2
3
4
(gdb) print &i
$5 = (int *) 0x7fff5fbff584
(gdb) print sizeof(i)
$6 = 4

总之,这里的意思是,变量 i 所在内存块起始地址为0x7fff5fbff584,并且占用了4个字节内存。

上面提到的变量在内存中的大小是取决于变量类型的,而且,sizeof操作符确实可以直接对类型进行操作:

1
2
3
4
(gdb) print sizeof(int)
$7 = 4
(gdb) print sizeof(double)
$8 = 8

这就意味着,至少在我的机器上,int 变量将占用4字节空间,而double变量将会占用8字节空间。

gdb随身携带了一个强有力的直接用来检查内存的工具:x 命令。

x 命令从一个特定的地址开始检查内存,它附带了格式化命令,用来准确控制你想要检查的字节数,还有就是你想怎么将其打印显示出来;如果还有什么疑问的话,在gdb提示符下运行 help x。

‘&’ 操作符计算出一个变量的地址,也就是说,我们可以让 x 命令 检查一下 &i,从而就可以看出 i 的值下原始的字节是什么样的:

1
2
(gdb) x/4xb &i
0x7fff5fbff584: 0x39 0x05 0x00 0x00

上面的标志标明我要检查的是4个字节,并格式化为16进制数,每次一个字节;我选择检查4个字节是因为 i变量在内存中占用的大小就是4个字节,打印的结果,显示出了它在内存中原始的字节挨着字节的表示。

有个需要铭记的微妙的地方就是,在英特尔(Intel)处理器的机器上,原始字节是按照“小端”顺序存储的;与人类文字符号不同,它的低位数是存在内存的前面部分的。

一个验证这个问题的方法就是:给i一个特定的值,并重新检查相应的内存块;

1
2
3
(gdb) set var i = 0x12345678
(gdb) x/4xb &i
0x7fff5fbff584: 0x78 0x56 0x34 0x12

用ptype命令检查类型

ptype命令可能是我最喜欢的命令了,它会告诉我一个C表达式的类型(变量,函数等);

1
2
3
4
5
6
(gdb) ptype i
type = int
(gdb) ptype &i
type = int *
(gdb) ptype main
type = int (void)

类型本身在C语言中是挺复杂的,但是ptype会给你一个交互式的查看方式。

指针和数组

C语言的数组是一个神奇且微妙的概念,我们这一节的计划是写个小程序,并在gdb调试中探索下数组的意义;

编写以下代码,arrays.c:

1
2
3
4
5
int main()
{
int a[] = {1,2,3};
return 0;
}

用-g标志编译,用gdb运行起来,然后next到初始化数组那一行;

1
2
3
4
5
$ gcc -g arrays.c -o arrays
$ gdb arrays
(gdb) break main
(gdb) run
(gdb) next

这个时候你就可以打印出a的内容,并检查它的类型:

1
2
3
4
(gdb) print a
$1 = {1, 2, 3}
(gdb) ptype a
type = int [3]

现在我们的程序已经在gdb里正常运行了,现在要做的第一件事就是用x命令查看下a在内存里的情况:

1
2
3
(gdb) x/12xb &a
0x7fff5fbff56c: 0x01 0x00 0x00 0x00 0x02 0x00 0x00 0x00
0x7fff5fbff574: 0x03 0x00 0x00 0x00

这就意味着 a 变量所在内存块起始地址为0x7fff5fbff56c。

前四个字节存储a[0],接下来四个字节存储a[1],最后四个字节存储a[2]。

你的确还可以通过 sizeof 知道 a 在内存中的大小是12个字节:

1
2
(gdb) print sizeof(a)
$2 = 12

这个时候,数组看起来就相当像数组了。他们有自己的类数组类型和存储成员的连续内存块。然而,在现实情况中,数组扮演得更像一些指针。举个例子,我们可以对a做指针运算:

1
2
3
4
= preserve do
:escaped
(gdb) print a + 1
$3 = (int *) 0x7fff5fbff570

也就是说,a+1 指向的是个int指针,指向地址为0x7fff5fbff570。

这个时候,你应该本能地想到将这个指针传递给x命令,然后看看发生了什么:

1
2
3
4
= preserve do
:escaped
(gdb) x/4xb a + 1
0x7fff5fbff570: 0x02 0x00 0x00 0x00

这就可以看出0x7fff5fbff570 这个地址比a的第一个字节地址 0x7fff5fbff56c 大4.

由于给定的int的值是要占据4个字节的,这就意味着a+1指向a[1].

实际上,C语言的数组索引是指针运算的语法糖:a[i]和*(a + i)是相等的。你可以在gdb里试试看:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
= preserve do
:escaped
(gdb) print a[0]
$4 = 1
(gdb) print *(a + 0)
$5 = 1
(gdb) print a[1]
$6 = 2
(gdb) print *(a + 1)
$7 = 2
(gdb) print a[2]
$8 = 3 (gdb)
print *(a + 2)
$9 = 3

我们可以看出,在一些情况下,a扮演者数组的角色,而在另外一些情况下,a又像个指向它第一个元素的指针,这到底怎么回事?

答案就是:在C语言中,当一个变量被命名为数组是,它就退化为指向数组第一个元素的指针。

这个规则有两个例外情况:一个是对数据进行sizeof操作,另一个是对数据进行&操作。

实际上,对a进行&操作时,a并不会退化为指针,这就带来了个有趣的问题:a退化为指针和&a是不是有什么不同?

从数值上看,他们代表的是同样的地址:

1
2
3
4
5
6
= preserve do
:escaped
(gdb) x/4xb a
0x7fff5fbff56c: 0x01 0x00 0x00 0x00
(gdb) x/4xb &a
0x7fff5fbff56c: 0x01 0x00 0x00 0x00

然而,他们的类型是不同,我们已经看过退化为指针的a是个指向a的第一个元素的指针,所以它的类型必定为int*。
至于&a的类型,我们可以直接请教一下gdb:

1
2
3
4
= preserve do
:escaped
(gdb) ptype &a
type = int (*)[3]

也就是说,&a是个指向三个整型数数组的指针。这就说得通了:对a进行&操作,a并不会退化,并且a的类型是int[3]。

通过对a的退化值和&a进行指针运算,你可以观察下两者的行为区别:

1
2
3
4
5
6
= preserve do
:escaped
(gdb) print a + 1
$10 = (int *) 0x7fff5fbff570
(gdb) print &a + 1
$11 = (int (*)[3]) 0x7fff5fbff578

注意到,a+1 就是给指针加4,而给&a + 1是给指针加12!

指向a的指针实际上是退化成&a[0]:

1
2
3
4
= preserve do
:escaped
(gdb) print &a[0]
$11 = (int *) 0x7fff5fbff56c

结论

希望我说服了你,gdb是个学习C语言环境,它简洁而又富有探索意味。你可以打印表达式的值,检查内存的连续字节,用ptype鼓捣一下类型系统。

如果你想知道更多的如何通过gdb学习C语言的内容,以下是我的一些建议:

  1. 使用gdb去通过这个挑战 Ksplice pointer challenge.

  2. 了解一下结构体在内存中是怎么存储的,并拿它与数组进行比较;

  3. 使用gdb的 disassemble命令学习汇编语言,一个特别有趣的练习是了解西函数是怎么调用堆栈进行工作的;

  4. 检查下gdb的 ‘tui’ 模式,它提供一个在常规gdb之上的ncurses图层;在OS X系统中,你可能需要通过源码来安装gdb。