牛骨文教育服务平台(让学习变的简单)

第 19 章 汇编与C之间的关系

目录

上一章我们学习了汇编的一些基础知识,本章我们进一步研究C程序编译之后的汇编是什么样的,C语言的各种语法分别对应什么样的指令,从而更深入地理解C语言。gcc还提供了一种扩展语法可以在C程序中内嵌汇编指令,这在内核代码中很常见,本章也会简要介绍这种用法。

1. 函数调用

我们用下面的代码来研究函数调用的过程。

例 19.1. 研究函数的调用过程

int bar(int c, int d)
{
	int e = c + d;
	return e;
}

int foo(int a, int b)
{
	return bar(a, b);
}

int main(void)
{
	foo(2, 3);
	return 0;
}

如果在编译时加上-g选项(在第 10 章 gdb讲过-g选项),那么用objdump反汇编时可以把C代码和汇编代码穿插起来显示,这样C代码和汇编代码的对应关系看得更清楚。反汇编的结果很长,以下只列出我们关心的部分。

$ gcc main.c -g
$ objdump -dS a.out 
...
08048394 <bar>:
int bar(int c, int d)
{
 8048394:	55                   	push   %ebp
 8048395:	89 e5                	mov    %esp,%ebp
 8048397:	83 ec 10             	sub    $0x10,%esp
	int e = c + d;
 804839a:	8b 55 0c             	mov    0xc(%ebp),%edx
 804839d:	8b 45 08             	mov    0x8(%ebp),%eax
 80483a0:	01 d0                	add    %edx,%eax
 80483a2:	89 45 fc             	mov    %eax,-0x4(%ebp)
	return e;
 80483a5:	8b 45 fc             	mov    -0x4(%ebp),%eax
}
 80483a8:	c9                   	leave  
 80483a9:	c3                   	ret    

080483aa <foo>:

int foo(int a, int b)
{
 80483aa:	55                   	push   %ebp
 80483ab:	89 e5                	mov    %esp,%ebp
 80483ad:	83 ec 08             	sub    $0x8,%esp
	return bar(a, b);
 80483b0:	8b 45 0c             	mov    0xc(%ebp),%eax
 80483b3:	89 44 24 04          	mov    %eax,0x4(%esp)
 80483b7:	8b 45 08             	mov    0x8(%ebp),%eax
 80483ba:	89 04 24             	mov    %eax,(%esp)
 80483bd:	e8 d2 ff ff ff       	call   8048394 <bar>
}
 80483c2:	c9                   	leave  
 80483c3:	c3                   	ret    

080483c4 <main>:

int main(void)
{
 80483c4:	8d 4c 24 04          	lea    0x4(%esp),%ecx
 80483c8:	83 e4 f0             	and    $0xfffffff0,%esp
 80483cb:	ff 71 fc             	pushl  -0x4(%ecx)
 80483ce:	55                   	push   %ebp
 80483cf:	89 e5                	mov    %esp,%ebp
 80483d1:	51                   	push   %ecx
 80483d2:	83 ec 08             	sub    $0x8,%esp
	foo(2, 3);
 80483d5:	c7 44 24 04 03 00 00 	movl   $0x3,0x4(%esp)
 80483dc:	00 
 80483dd:	c7 04 24 02 00 00 00 	movl   $0x2,(%esp)
 80483e4:	e8 c1 ff ff ff       	call   80483aa <foo>
	return 0;
 80483e9:	b8 00 00 00 00       	mov    $0x0,%eax
}
 80483ee:	83 c4 08             	add    $0x8,%esp
 80483f1:	59                   	pop    %ecx
 80483f2:	5d                   	pop    %ebp
 80483f3:	8d 61 fc             	lea    -0x4(%ecx),%esp
 80483f6:	c3                   	ret   
...

要查看编译后的汇编代码,其实还有一种办法是gcc -S main.c,这样只生成汇编代码main.s,而不生成二进制的目标文件。

整个程序的执行过程是main调用foofoo调用bar,我们用gdb跟踪程序的执行,直到bar函数中的int e = c + d;语句执行完毕准备返回时,这时在gdb中打印函数栈帧。

(gdb) start
...
main () at main.c:14
14		foo(2, 3);
(gdb) s
foo (a=2, b=3) at main.c:9
9		return bar(a, b);
(gdb) s
bar (c=2, d=3) at main.c:3
3		int e = c + d;
(gdb) disassemble 
Dump of assembler code for function bar:
0x08048394 <bar+0>:	push   %ebp
0x08048395 <bar+1>:	mov    %esp,%ebp
0x08048397 <bar+3>:	sub    $0x10,%esp
0x0804839a <bar+6>:	mov    0xc(%ebp),%edx
0x0804839d <bar+9>:	mov    0x8(%ebp),%eax
0x080483a0 <bar+12>:	add    %edx,%eax
0x080483a2 <bar+14>:	mov    %eax,-0x4(%ebp)
0x080483a5 <bar+17>:	mov    -0x4(%ebp),%eax
0x080483a8 <bar+20>:	leave  
0x080483a9 <bar+21>:	ret    
End of assembler dump.
(gdb) si
0x0804839d	3		int e = c + d;
(gdb) si
0x080483a0	3		int e = c + d;
(gdb) si
0x080483a2	3		int e = c + d;
(gdb) si
4		return e;
(gdb) si
5	}
(gdb) bt
#0  bar (c=2, d=3) at main.c:5
#1  0x080483c2 in foo (a=2, b=3) at main.c:9
#2  0x080483e9 in main () at main.c:14
(gdb) info registers 
eax            0x5	5
ecx            0xbff1c440	-1074674624
edx            0x3	3
ebx            0xb7fe6ff4	-1208061964
esp            0xbff1c3f4	0xbff1c3f4
ebp            0xbff1c404	0xbff1c404
esi            0x8048410	134513680
edi            0x80482e0	134513376
eip            0x80483a8	0x80483a8 <bar+20>
eflags         0x200206	[ PF IF ID ]
cs             0x73	115
ss             0x7b	123
ds             0x7b	123
es             0x7b	123
fs             0x0	0
gs             0x33	51
(gdb) x/20 $esp
0xbff1c3f4:	0x00000000	0xbff1c6f7	0xb7efbdae	0x00000005
0xbff1c404:	0xbff1c414	0x080483c2	0x00000002	0x00000003
0xbff1c414:	0xbff1c428	0x080483e9	0x00000002	0x00000003
0xbff1c424:	0xbff1c440	0xbff1c498	0xb7ea3685	0x08048410
0xbff1c434:	0x080482e0	0xbff1c498	0xb7ea3685	0x00000001
(gdb)

这里又用到几个新的gdb命令。disassemble可以反汇编当前函数或者指定的函数,单独用disassemble命令是反汇编当前函数,如果disassemble命令后面跟函数名或地址则反汇编指定的函数。以前我们讲过step命令可以一行代码一行代码地单步调试,而这里用到的si命令可以一条指令一条指令地单步调试。info registers可以显示所有寄存器的当前值。在gdb中表示寄存器名时前面要加个$,例如p $esp可以打印esp寄存器的值,在上例中esp寄存器的值是0xbff1c3f4,所以x/20 $esp命令查看内存中从0xbff1c3f4地址开始的20个32位数。在执行程序时,操作系统为进程分配一块栈空间来保存函数栈帧,esp寄存器总是指向栈顶,在x86平台上这个栈是从高地址向低地址增长的,我们知道每次调用一个函数都要分配一个栈帧来保存参数和局部变量,现在我们详细分析这些数据在栈空间的布局,根据gdb的输出结果图示如下[[29](#ftn.id2775282)]

图 19.1. 函数栈帧

函数栈帧

图中每个小方格表示4个字节的内存单元,例如b: 3这个小方格占的内存地址是0xbf822d20~0xbf822d23,我把地址写在每个小方格的下边界线上,是为了强调该地址是内存单元的起始地址。我们从main函数的这里开始看起:

	foo(2, 3);
 80483d5:	c7 44 24 04 03 00 00 	movl   $0x3,0x4(%esp)
 80483dc:	00 
 80483dd:	c7 04 24 02 00 00 00 	movl   $0x2,(%esp)
 80483e4:	e8 c1 ff ff ff       	call   80483aa <foo>
	return 0;
 80483e9:	b8 00 00 00 00       	mov    $0x0,%eax

要调用函数foo先要把参数准备好,第二个参数保存在esp+4指向的内存位置,第一个参数保存在esp指向的内存位置,可见参数是从右向左依次压栈的。然后执行call指令,这个指令有两个作用:

  1. foo函数调用完之后要返回到call的下一条指令继续执行,所以把call的下一条指令的地址0x80483e9压栈,同时把esp的值减4,esp的值现在是0xbf822d18。

  2. 修改程序计数器eip,跳转到foo函数的开头执行。

现在看foo函数的汇编代码:

int foo(int a, int b)
{
 80483aa:	55                   	push   %ebp
 80483ab:	89 e5                	mov    %esp,%ebp
 80483ad:	83 ec 08             	sub    $0x8,%esp

push %ebp指令把ebp寄存器的值压栈,同时把esp的值减4。esp的值现在是0xbf822d14,下一条指令把这个值传送给ebp寄存器。这两条指令合起来是把原来ebp的值保存在栈上,然后又给ebp赋了新值。在每个函数的栈帧中,ebp指向栈底,而esp指向栈顶,在函数执行过程中esp随着压栈和出栈操作随时变化,而ebp是不动的,函数的参数和局部变量都是通过ebp的值加上一个偏移量来访问,例如foo函数的参数ab分别通过ebp+8ebp+12来访问。所以下面的指令把参数ab再次压栈,为调用bar函数做准备,然后把返回地址压栈,调用bar函数:

	return bar(a, b);
 80483b0:	8b 45 0c             	mov    0xc(%ebp),%eax
 80483b3:	89 44 24 04          	mov    %eax,0x4(%esp)
 80483b7:	8b 45 08             	mov    0x8(%ebp),%eax
 80483ba:	89 04 24             	mov    %eax,(%esp)
 80483bd:	e8 d2 ff ff ff       	call   8048394 <bar>

现在看bar函数的指令:

int bar(int c, int d)
{
 8048394:	55                   	push   %ebp
 8048395:	89 e5                	mov    %esp,%ebp
 8048397:	83 ec 10             	sub    $0x10,%esp
	int e = c + d;
 804839a:	8b 55 0c             	mov    0xc(%ebp),%edx
 804839d:	8b 45 08             	mov    0x8(%ebp),%eax
 80483a0:	01 d0                	add    %edx,%eax
 80483a2:	89 45 fc             	mov    %eax,-0x4(%ebp)

这次又把foo函数的ebp压栈保存,然后给ebp赋了新值,指向bar函数栈帧的栈底,通过ebp+8ebp+12分别可以访问参数cdbar函数还有一个局部变量e,可以通过ebp-4来访问。所以后面几条指令的意思是把参数cd取出来存在寄存器中做加法,计算结果保存在eax寄存器中,再把eax寄存器存回局部变量e的内存单元。

gdb中可以用bt命令和frame命令查看每层栈帧上的参数和局部变量,现在可以解释它的工作原理了:如果我当前在bar函数中,我可以通过ebp找到bar函数的参数和局部变量,也可以找到foo函数的ebp保存在栈上的值,有了foo函数的ebp,又可以找到它的参数和局部变量,也可以找到main函数的ebp保存在栈上的值,因此各层函数栈帧通过保存在栈上的ebp的值串起来了。

现在看bar函数的返回指令:

	return e;
 80483a5:	8b 45 fc             	mov    -0x4(%ebp),%eax
}
 80483a8:	c9                   	leave  
 80483a9:	c3                   	ret

bar函数有一个int型的返回值,这个返回值是通过eax寄存器传递的,所以首先把e的值读到eax寄存器中。然后执行leave指令,这个指令是函数开头的push %ebpmov %esp,%ebp的逆操作:

  1. ebp的值赋给esp,现在esp的值是0xbf822d04。

  2. 现在esp所指向的栈顶保存着foo函数栈帧的ebp,把这个值恢复给ebp,同时esp增加4,esp的值变成0xbf822d08。

最后是ret指令,它是call指令的逆操作:

  1. 现在esp所指向的栈顶保存着返回地址,把这个值恢复给eip,同时esp增加4,esp的值变成0xbf822d0c。

  2. 修改了程序计数器eip,因此跳转到返回地址0x80483c2继续执行。

地址0x80483c2处是foo函数的返回指令:

 80483c2:	c9                   	leave  
 80483c3:	c3                   	ret

重复同样的过程,又返回到了main函数。注意函数调用和返回过程中的这些规则:

  1. 参数压栈传递,并且是从右向左依次压栈。

  2. ebp总是指向当前栈帧的栈底。

  3. 返回值通过eax寄存器传递。

这些规则并不是体系结构所强加的,ebp寄存器并不是必须这么用,函数的参数和返回值也不是必须这么传,只是操作系统和编译器选择了以这样的方式实现C代码中的函数调用,这称为Calling Convention,Calling Convention是操作系统二进制接口规范(ABI,Application Binary Interface)的一部分。

习题

1、在第 2 节 “自定义函数”讲过,Old Style C风格的函数声明可以不指定参数个数和类型,这样编译器不会对函数调用做检查,那么如果调用时的参数类型不对或者参数个数不对会怎么样呢?比如把本节的例子改成这样:

int foo();
int bar();

int main(void)
{
	foo(2, 3, 4);
	return 0;
}

int foo(int a, int b)
{
	return bar(a);
}

int bar(int c, int d)
{
	int e = c + d;
	return e;
}

main函数调用foo时多传了一个参数,那么参数ab分别取什么值?多的参数怎么办?foo调用bar时少传了一个参数,那么参数d的值从哪里取得?请读者利用反汇编和gdb自己分析一下。我们再看一个参数类型不符的例子:

#include <stdio.h>

int main(void)
{
	void foo();
	char c = 60;
	foo(c);
	return 0;
}

void foo(double d)
{
	printf("%f
", d);
}

打印结果是多少?如果把声明void foo();改成void foo(double);,打印结果又是多少?


[[29](#id2775282)] Linux内核为每个新进程指定的栈空间的起始地址都会有些不同,所以每次运行这个程序得到的地址都不一样,但通常都是0xbf??????这样一个地址。

2. main函数和启动例程

为什么汇编程序的入口是_start,而C程序的入口是main函数呢?本节就来解释这个问题。在讲例 18.1 “最简单的汇编程序”时,我们的汇编和链接步骤是:

$ as hello.s -o hello.o
$ ld hello.o -o hello

以前我们常用gcc main.c -o main命令编译一个程序,其实也可以分三步做,第一步生成汇编代码,第二步生成目标文件,第三步生成可执行文件:

$ gcc -S main.c
$ gcc -c main.s
$ gcc main.o

-S选项生成汇编代码,-c选项生成目标文件,此外在第 2 节 “数组应用实例:统计随机数”还讲过-E选项只做预处理而不编译,如果不加这些选项则gcc执行完整的编译步骤,直到最后链接生成可执行文件为止。如下图所示。

图 19.2. gcc命令的选项

gcc命令的选项

这些选项都可以和-o搭配使用,给输出的文件重新命名而不使用gcc默认的文件名(xxx.cxxx.sxxx.oa.out),例如gcc main.o -o mainmain.o链接成可执行文件main。先前由汇编代码例 18.1 “最简单的汇编程序”生成的目标文件hello.o我们是用ld来链接的,可不可以用gcc链接呢?试试看。

$ gcc hello.o -o hello
hello.o: In function `_start":
(.text+0x0): multiple definition of `_start"
/usr/lib/gcc/i486-linux-gnu/4.3.2/../../../../lib/crt1.o:(.text+0x0): first defined here
/usr/lib/gcc/i486-linux-gnu/4.3.2/../../../../lib/crt1.o: In function `_start":
(.text+0x18): undefined reference to `main"
collect2: ld returned 1 exit status

提示两个错误:一是_start有多个定义,一个定义是由我们的汇编代码提供的,另一个定义来自/usr/lib/crt1.o;二是crt1.o_start函数要调用main函数,而我们的汇编代码中没有提供main函数的定义。从最后一行还可以看出这些错误提示是由ld给出的。由此可见,如果我们用gcc做链接,gcc其实是调用ld将目标文件crt1.o和我们的hello.o链接在一起。crt1.o里面已经提供了_start入口点,我们的汇编程序中再实现一个_start就是多重定义了,链接器不知道该用哪个,只好报错。另外,crt1.o提供的_start需要调用main函数,而我们的汇编程序中没有实现main函数,所以报错。

如果目标文件是由C代码编译生成的,用gcc做链接就没错了,整个程序的入口点是crt1.o中提供的_start,它首先做一些初始化工作(以下称为启动例程,Startup Routine),然后调用C代码中提供的main函数。所以,以前我们说main函数是程序的入口点其实不准确,_start才是真正的入口点,而main函数是被_start调用的。

我们继续研究上一节的例 19.1 “研究函数的调用过程”。如果分两步编译,第二步gcc main.o -o main其实是调用ld做链接的,相当于这样的命令:

$ ld /usr/lib/crt1.o /usr/lib/crti.o main.o -o main -lc -dynamic-linker /lib/ld-linux.so.2

也就是说,除了crt1.o之外其实还有crti.o,这两个目标文件和我们的main.o链接在一起生成可执行文件main-lc表示需要链接libc库,在第 1 节 “数学函数”讲过-lc选项是gcc默认的,不用写,而对于ld则不是默认选项,所以要写上。-dynamic-linker /lib/ld-linux.so.2指定动态链接器是/lib/ld-linux.so.2,稍后会解释什么是动态链接。

那么crt1.ocrti.o里面都有什么呢?我们可以用readelf命令查看。在这里我们只关心符号表,如果只看符号表,可以用readelf命令的-s选项,也可以用nm命令。

$ nm /usr/lib/crt1.o 
00000000 R _IO_stdin_used
00000000 D __data_start
         U __libc_csu_fini
         U __libc_csu_init
         U __libc_start_main
00000000 R _fp_hw
00000000 T _start
00000000 W data_start
         U main
$ nm /usr/lib/crti.o
         U _GLOBAL_OFFSET_TABLE_
         w __gmon_start__
00000000 T _fini
00000000 T _init

U main这一行表示main这个符号在crt1.o中用到了,但是没有定义(U表示Undefined),因此需要别的目标文件提供一个定义并且和crt1.o链接在一起。具体来说,在crt1.o中要用到main这个符号所代表的地址,例如有一条指令是push $符号main所代表的地址,但不知道这个地址是多少,所以在crt1.o中这条指令暂时写成push $0x0,等到和main.o链接成可执行文件时就知道这个地址是多少了,比如是0x80483c4,那么可执行文件main中的这条指令就被链接器改成了push $0x80483c4。链接器在这里起到符号解析(Symbol Resolution)的作用,在第 5.2 节 “可执行文件”我们看到链接器起到重定位的作用,这两种作用都是通过修改指令中的地址实现的,链接器也是一种编辑器,viemacs编辑的是源文件,而链接器编辑的是目标文件,所以链接器也叫Link Editor。T _start这一行表示_start这个符号在crt1.o中提供了定义,这个符号的类型是代码(T表示Text)。我们从上面的输出结果中选取几个符号用图示说明它们之间的关系:

图 19.3. C程序的链接过程

C程序的链接过程

其实上面我们写的ld命令做了很多简化,gcc在链接时还用到了另外几个目标文件,所以上图多画了一个框,表示组成可执行文件main的除了main.ocrt1.ocrti.o之外还有其它目标文件,本书不做深入讨论,用gcc-v选项可以了解详细的编译过程:

$ gcc -v main.c -o main
Using built-in specs.
Target: i486-linux-gnu
...
 /usr/lib/gcc/i486-linux-gnu/4.3.2/cc1 -quiet -v main.c -D_FORTIFY_SOURCE=2 -quiet -dumpbase main.c -mtune=generic -auxbase main -version -fstack-protector -o /tmp/ccRGDpua.s
...
 as -V -Qy -o /tmp/ccidnZ1d.o /tmp/ccRGDpua.s
...
 /usr/lib/gcc/i486-linux-gnu/4.3.2/collect2 --eh-frame-hdr -m elf_i386 --hash-style=both -dynamic-linker /lib/ld-linux.so.2 -o main -z relro /usr/lib/gcc/i486-linux-gnu/4.3.2/../../../../lib/crt1.o /usr/lib/gcc/i486-linux-gnu/4.3.2/../../../../lib/crti.o /usr/lib/gcc/i486-linux-gnu/4.3.2/crtbegin.o -L/usr/lib/gcc/i486-linux-gnu/4.3.2 -L/usr/lib/gcc/i486-linux-gnu/4.3.2 -L/usr/lib/gcc/i486-linux-gnu/4.3.2/../../../../lib -L/lib/../lib -L/usr/lib/../lib -L/usr/lib/gcc/i486-linux-gnu/4.3.2/../../.. /tmp/ccidnZ1d.o -lgcc --as-needed -lgcc_s --no-as-needed -lc -lgcc --as-needed -lgcc_s --no-as-needed /usr/lib/gcc/i486-linux-gnu/4.3.2/crtend.o /usr/lib/gcc/i486-linux-gnu/4.3.2/../../../../lib/crtn.o

链接生成的可执行文件main中包含了各目标文件所定义的符号,通过反汇编可以看到这些符号的定义:

$ objdump -d main
main:     file format elf32-i386

Disassembly of section .init:

08048274 <_init>:
 8048274:	55                   	push   %ebp
 8048275:	89 e5                	mov    %esp,%ebp
 8048277:	53                   	push   %ebx
...
Disassembly of section .text:

080482e0 <_start>:
 80482e0:	31 ed                	xor    %ebp,%ebp
 80482e2:	5e                   	pop    %esi
 80482e3:	89 e1                	mov    %esp,%ecx
...
08048394 <bar>:
 8048394:	55                   	push   %ebp
 8048395:	89 e5                	mov    %esp,%ebp
 8048397:	83 ec 10             	sub    $0x10,%esp
...
080483aa <foo>:
 80483aa:	55                   	push   %ebp
 80483ab:	89 e5                	mov    %esp,%ebp
 80483ad:	83 ec 08             	sub    $0x8,%esp
...
080483c4 <main>:
 80483c4:	8d 4c 24 04          	lea    0x4(%esp),%ecx
 80483c8:	83 e4 f0             	and    $0xfffffff0,%esp
 80483cb:	ff 71 fc             	pushl  -0x4(%ecx)
...
Disassembly of section .fini:

0804849c <_fini>:
 804849c:	55                   	push   %ebp
 804849d:	89 e5                	mov    %esp,%ebp
 804849f:	53                   	push   %ebx

crt1.o中的未定义符号mainmain.o中定义了,所以链接在一起就没问题了。crt1.o还有一个未定义符号__libc_start_main在其它几个目标文件中也没有定义,所以在可执行文件main中仍然是个未定义符号。这个符号是在libc中定义的,libc并不像其它目标文件一样链接到可执行文件main中,而是在运行时做动态链接:

  1. 操作系统在加载执行main这个程序时,首先查看它有没有需要动态链接的未定义符号。

  2. 如果需要做动态链接,就查看这个程序指定了哪些共享库(我们用-lc指定了libc)以及用什么动态链接器来做动态链接(我们用-dynamic-linker /lib/ld-linux.so.2指定了动态链接器)。

  3. 动态链接器在共享库中查找这些符号的定义,完成链接过程。

了解了这些原理之后,现在我们来看_start的反汇编:

...
Disassembly of section .text:

080482e0 <_start>:
 80482e0:       31 ed                   xor    %ebp,%ebp
 80482e2:       5e                      pop    %esi
 80482e3:       89 e1                   mov    %esp,%ecx
 80482e5:       83 e4 f0                and    $0xfffffff0,%esp
 80482e8:       50                      push   %eax
 80482e9:       54                      push   %esp
 80482ea:       52                      push   %edx
 80482eb:       68 00 84 04 08          push   $0x8048400
 80482f0:       68 10 84 04 08          push   $0x8048410
 80482f5:       51                      push   %ecx
 80482f6:       56                      push   %esi
 80482f7:       68 c4 83 04 08          push   $0x80483c4
 80482fc:       e8 c3 ff ff ff          call   80482c4 <__libc_start_main@plt>
...

首先将一系列参数压栈,然后调用libc的库函数__libc_start_main做初始化工作,其中最后一个压栈的参数push $0x80483c4main函数的地址,__libc_start_main在完成初始化工作之后会调用main函数。由于__libc_start_main需要动态链接,所以这个库函数的指令在可执行文件main的反汇编中肯定是找不到的,然而我们找到了这个:

Disassembly of section .plt:
...
080482c4 <__libc_start_main@plt>:
 80482c4:       ff 25 04 a0 04 08       jmp    *0x804a004
 80482ca:       68 08 00 00 00          push   $0x8
 80482cf:       e9 d0 ff ff ff          jmp    80482a4 <_init+0x30>

这三条指令位于.plt段而不是.text段,.plt段协助完成动态链接的过程。我们将在下一章详细讲解动态链接的过程。

main函数最标准的原型应该是int main(int argc, char *argv[]),也就是说启动例程会传两个参数给main函数,这两个参数的含义我们学了指针以后再解释。我们到目前为止都把main函数的原型写成int main(void),这也是C标准允许的,如果你认真分析了上一节的习题,你就应该知道,多传了参数而不用是没有问题的,少传了参数却用了则会出问题。

由于main函数是被启动例程调用的,所以从main函数return时仍返回到启动例程中,main函数的返回值被启动例程得到,如果将启动例程表示成等价的C代码(实际上启动例程一般是直接用汇编写的),则它调用main函数的形式是:

exit(main(argc, argv));

也就是说,启动例程得到main函数的返回值后,会立刻用它做参数调用exit函数。exit也是libc中的函数,它首先做一些清理工作,然后调用上一章讲过的_exit系统调用终止进程,main函数的返回值最终被传给_exit系统调用,成为进程的退出状态。我们也可以在main函数中直接调用exit函数终止进程而不返回到启动例程,例如:

#include <stdlib.h>

int main(void)
{
	exit(4);
}

这样和int main(void) { return 4; }的效果是一样的。在Shell中运行这个程序并查看它的退出状态:

$ ./a.out 
$ echo $?
4

按照惯例,退出状态为0表示程序执行成功,退出状态非0表示出错。注意,退出状态只有8位,而且被Shell解释成无符号数,如果将上面的代码改为exit(-1);return -1;,则运行结果为

$ ./a.out 
$ echo $?
255

注意,如果声明一个函数的返回值类型是int,函数中每个分支控制流程必须写return语句指定返回值,如果缺了return则返回值不确定(想想这是为什么),编译器通常是会报警告的,但如果某个分支控制流程调用了exit_exit而不写return,编译器是允许的,因为它都没有机会返回了,指不指定返回值也就无所谓了。使用exit函数需要包含头文件stdlib.h,而使用_exit函数需要包含头文件unistd.h,以后还要详细解释这两个函数。

3. 变量的存储布局

首先看下面的例子:

例 19.2. 研究变量的存储布局

#include <stdio.h>

const int A = 10;
int a = 20;
static int b = 30;
int c;

int main(void)
{
	static int a = 40;
	char b[] = "Hello world";
	register int c = 50;

	printf("Hello world %d
", c);

	return 0;
}

我们在全局作用域和main函数的局部作用域各定义了一些变量,并且引入一些新的关键字conststaticregister来修饰变量,那么这些变量的存储空间是怎么分配的呢?我们编译之后用readelf命令看它的符号表,了解各变量的地址分布。注意在下面的清单中我把符号表按地址从低到高的顺序重新排列了,并且只截取我们关心的那几行。

$ gcc main.c -g
$ readelf -a a.out
...
    68: 08048540     4 OBJECT  GLOBAL DEFAULT   15 A
    69: 0804a018     4 OBJECT  GLOBAL DEFAULT   23 a
    52: 0804a01c     4 OBJECT  LOCAL  DEFAULT   23 b
    53: 0804a020     4 OBJECT  LOCAL  DEFAULT   23 a.1589
    81: 0804a02c     4 OBJECT  GLOBAL DEFAULT   24 c
...

变量A用const修饰,表示A是只读的,不可修改,它被分配的地址是0x8048540,从readelf的输出可以看到这个地址位于.rodata段:

Section Headers:
  [Nr] Name              Type            Addr     Off    Size   ES Flg Lk Inf Al
...
  [13] .text             PROGBITS        08048360 000360 0001bc 00  AX  0   0 16
...
  [15] .rodata           PROGBITS        08048538 000538 00001c 00   A  0   0  4
...
  [23] .data             PROGBITS        0804a010 001010 000014 00  WA  0   0  4
  [24] .bss              NOBITS          0804a024 001024 00000c 00  WA  0   0  4
...

它在文件中的地址是0x538~0x554,我们用hexdump命令看看这个段的内容:

$ hexdump -C a.out
...
00000530  5c fe ff ff 59 5b c9 c3  03 00 00 00 01 00 02 00  |...Y[..........|
00000540  0a 00 00 00 48 65 6c 6c  6f 20 77 6f 72 6c 64 20  |....Hello world |
00000550  25 64 0a 00 00 00 00 00  00 00 00 00 00 00 00 00  |%d..............|
...

其中0x540地址处的0a 00 00 00就是变量A。我们还看到程序中的字符串字面值"Hello world %d "分配在.rodata段的末尾,在第 4 节 “字符串”说过字符串字面值是只读的,相当于在全局作用域定义了一个const数组:

const char helloworld[] = {"H", "e", "l", "l", "o", " ",
		 	"w", "o", "r", "l", "d", " ", "%", "d", "
", ""};

程序加载运行时,.rodata段和.text段通常合并到一个Segment中,操作系统将这个Segment的页面只读保护起来,防止意外的改写。这一点从readelf的输出也可以看出来:

 Section to Segment mapping:
  Segment Sections...
   00     
   01     .interp 
   02     .interp .note.ABI-tag .hash .gnu.hash .dynsym .dynstr .gnu.version .gnu.version_r .rel.dyn .rel.plt .init .plt .text .fini .rodata .eh_frame 
   03     .ctors .dtors .jcr .dynamic .got .got.plt .data .bss 
   04     .dynamic 
   05     .note.ABI-tag 
   06     
   07     .ctors .dtors .jcr .dynamic .got 

注意,像A这种const变量在定义时必须初始化。因为只有初始化时才有机会给它一个值,一旦定义之后就不能再改写了,也就是不能再赋值了。

从上面readelf的输出可以看到.data段从地址0x804a010开始,长度是0x14,也就是到地址0x804a024结束。在.data段中有三个变量,aba.1589

a是一个GLOBAL的符号,而bstatic关键字修饰了,导致它成为一个LOCAL的符号,所以static在这里的作用是声明b这个符号为LOCAL的,不被链接器处理,在下一章我们会看到,如果把多个目标文件链接在一起,LOCAL的符号只能在某一个目标文件中定义和使用,而不能定义在一个目标文件中却在另一个目标文件中使用。一个函数定义前面也可以用static修饰,表示这个函数名符号是LOCAL的。

还有一个a.1589是什么呢?它就是main函数中的static int a。函数中的static变量不同于以前我们讲的局部变量,它并不是在调用函数时分配,在函数返回时释放,而是像全局变量一样静态分配,所以用“static”(静态)这个词。另一方面,函数中的static变量的作用域和以前讲的局部变量一样,只在函数中起作用,比如main函数中的a这个变量名只在main函数中起作用,在别的函数中说变量a就不是指它了,所以编译器给它的符号名加了一个后缀,变成a.1589,以便和全局变量a以及其它函数的变量a区分开。

.bss段从地址0x804a024开始(紧挨着.data段),长度为0xc,也就是到地址0x804a030结束。变量c位于这个段。从上面的readelf输出可以看到,.data.bss在加载时合并到一个Segment中,这个Segment是可读可写的。.bss段和.data段的不同之处在于,.bss段在文件中不占存储空间,在加载时这个段用0填充。所以我们在第 4 节 “全局变量、局部变量和作用域”讲过,全局变量如果不初始化则初值为0,同理可以推断,static变量(不管是函数里的还是函数外的)如果不初始化则初值也是0,也分配在.bss段。

现在还剩下函数中的bc这两个变量没有分析。上一节我们讲过函数的参数和局部变量是分配在栈上的,b是数组也一样,也是分配在栈上的,我们看main函数的反汇编代码:

$ objdump -dS a.out
...
        char b[]="Hello world";
 8048430:       c7 45 ec 48 65 6c 6c    movl   $0x6c6c6548,-0x14(%ebp)
 8048437:       c7 45 f0 6f 20 77 6f    movl   $0x6f77206f,-0x10(%ebp)
 804843e:       c7 45 f4 72 6c 64 00    movl   $0x646c72,-0xc(%ebp)
        register int c = 50;
 8048445:       b8 32 00 00 00          mov    $0x32,%eax

        printf("Hello world %d
", c);
 804844a:       89 44 24 04             mov    %eax,0x4(%esp)
 804844e:       c7 04 24 44 85 04 08    movl   $0x8048544,(%esp)
 8048455:       e8 e6 fe ff ff          call   8048340 <printf@plt>
...

可见,给b初始化用的这个字符串"Hello world"并没有分配在.rodata段,而是直接写在指令里了,通过三条movl指令把12个字节写到栈上,这就是b的存储空间,如下图所示。

图 19.4. 数组的存储布局

数组的存储布局

注意,虽然栈是从高地址向低地址增长的,但数组总是从低地址向高地址排列的,按从低地址到高地址的顺序依次是b[0]b[1]b[2]……这样,

数组元素b[n]的地址 = 数组的基地址(b做右值就表示这个基地址) + n × 每个元素的字节数

当n=0时,元素b[0]的地址就是数组的基地址,因此数组下标要从0开始而不是从1开始。

变量c并没有在栈上分配存储空间,而是直接存在eax寄存器里,后面调用printf也是直接从eax寄存器里取出c的值当参数压栈,这就是register关键字的作用,指示编译器尽可能分配一个寄存器来存储这个变量。我们还看到调用printf时对于"Hello world %d "这个参数压栈的是它在.rodata段中的首地址,而不是把整个字符串压栈,所以在第 4 节 “字符串”中说过,字符串在使用时可以看作数组名,如果做右值则表示数组首元素的地址(或者说指向数组首元素的指针),我们以后讲指针还要继续讨论这个问题。

以前我们用“全局变量”和“局部变量”这两个概念,主要是从作用域上区分的,现在看来用这两个概念给变量分类太笼统了,需要进一步细分。我们总结一下相关的C语法。

作用域(Scope)这个概念适用于所有标识符,而不仅仅是变量,C语言的作用域分为以下几类:

  • 函数作用域(Function Scope),标识符在整个函数中都有效。只有语句标号属于函数作用域。标号在函数中不需要先声明后使用,在前面用一个goto语句也可以跳转到后面的某个标号,但仅限于同一个函数之中。

  • 文件作用域(File Scope),标识符从它声明的位置开始直到这个程序文件[[30](#ftn.id2778429)]的末尾都有效。例如上例中main函数外面的Aabc,还有main也算,printf其实是在stdio.h中声明的,被包含到这个程序文件中了,所以也算文件作用域的。

  • 块作用域(Block Scope),标识符位于一对{}括号中(函数体或语句块),从它声明的位置开始到右}括号之间有效。例如上例中main函数里的abc。此外,函数定义中的形参也算块作用域的,从声明的位置开始到函数末尾之间有效。

  • 函数原型作用域(Function Prototype Scope),标识符出现在函数原型中,这个函数原型只是一个声明而不是定义(没有函数体),那么标识符从声明的位置开始到在这个原型末尾之间有效。例如int foo(int a, int b);中的ab

对属于同一命名空间(Name Space)的重名标识符,内层作用域的标识符将覆盖外层作用域的标识符,例如局部变量名在它的函数中将覆盖重名的全局变量。命名空间可分为以下几类:

  • 语句标号单独属于一个命名空间。例如在函数中局部变量和语句标号可以重名,互不影响。由于使用标号的语法和使用其它标识符的语法都不一样,编译器不会把它和别的标识符弄混。

  • structenumunion(下一节介绍union)的类型Tag属于一个命名空间。由于Tag前面总是带structenumunion关键字,所以编译器不会把它和别的标识符弄混。

  • structunion的成员名属于一个命名空间。由于成员名总是通过.-&gt;运算符来访问而不会单独使用,所以编译器不会把它和别的标识符弄混。

  • 所有其它标识符,例如变量名、函数名、宏定义、typedef的类型名、enum成员等等都属于同一个命名空间。如果有重名的话,宏定义覆盖所有其它标识符,因为它在预处理阶段而不是编译阶段处理,除了宏定义之外其它几类标识符按上面所说的规则处理,内层作用域覆盖外层作用域。

标识符的链接属性(Linkage)有三种:

  • 外部链接(External Linkage),如果最终的可执行文件由多个程序文件链接而成,一个标识符在任意程序文件中即使声明多次也都代表同一个变量或函数,则这个标识符具有External Linkage。具有External Linkage的标识符编译后在符号表中是GLOBAL的符号。例如上例中main函数外面的acmainprintf也算。

  • 内部链接(Internal Linkage),如果一个标识符在某个程序文件中即使声明多次也都代表同一个变量或函数,则这个标识符具有Internal Linkage。例如上例中main函数外面的b。如果有另一个foo.c程序和main.c链接在一起,在foo.c中也声明一个static int b;,则那个b和这个b不代表同一个变量。具有Internal Linkage的标识符编译后在符号表中是LOCAL的符号,但main函数里面那个a不能算Internal Linkage的,因为即使在同一个程序文件中,在不同的函数中声明多次,也不代表同一个变量。

  • 无链接(No Linkage)。除以上情况之外的标识符都属于No Linkage的,例如函数的局部变量,以及不表示变量和函数的其它标识符。

存储类修饰符(Storage Class Specifier)有以下几种关键字,可以修饰变量或函数声明:

  • static,用它修饰的变量的存储空间是静态分配的,用它修饰的文件作用域的变量或函数具有Internal Linkage。

  • auto,用它修饰的变量在函数调用时自动在栈上分配存储空间,函数返回时自动释放,例如上例中main函数里的b其实就是用auto修饰的,只不过auto可以省略不写,auto不能修饰文件作用域的变量。

  • register,编译器对于用register修饰的变量会尽可能分配一个专门的寄存器来存储,但如果实在分配不开寄存器,编译器就把它当auto变量处理了,register不能修饰文件作用域的变量。现在一般编译器的优化都做得很好了,它自己会想办法有效地利用CPU的寄存器,所以现在register关键字也用得比较少了。

  • extern,上面讲过,链接属性是根据一个标识符多次声明时是不是代表同一个变量或函数来分类的,extern关键字就用于多次声明同一个标识符,下一章再详细介绍它的用法。

  • typedef,在第 2.4 节 “sizeof运算符与typedef类型声明”讲过这个关键字,它并不是用来修饰变量的,而是定义一个类型名。在那一节也讲过,看typedef声明怎么看呢,首先去掉typedef把它看成变量声明,看这个变量是什么类型的,那么typedef就定义了一个什么类型,也就是说,typedef在语法结构中出现的位置和前面几个关键字一样,也是修饰变量声明的,所以从语法(而不是语义)的角度把它和前面几个关键字归类到一起。

注意,上面介绍的const关键字不是一个Storage Class Specifier,虽然看起来它也修饰一个变量声明,但是在以后介绍的更复杂的声明中const在语法结构中允许出现的位置和Storage Class Specifier是不完全相同的。const和以后要介绍的restrictvolatile关键字属于同一类语法元素,称为类型限定符(Type Qualifier)。

变量的生存期(Storage Duration,或者Lifetime)分为以下几类:

  • 静态生存期(Static Storage Duration),具有外部或内部链接属性,或者被static修饰的变量,在程序开始执行时分配和初始化一次,此后便一直存在直到程序结束。这种变量通常位于.rodata.data.bss段,例如上例中main函数外的Aabc,以及main函数里的a

  • 自动生存期(Automatic Storage Duration),链接属性为无链接并且没有被static修饰的变量,这种变量在进入块作用域时在栈上或寄存器中分配,在退出块作用域时释放。例如上例中main函数里的bc

  • 动态分配生存期(Allocated Storage Duration),以后会讲到调用malloc函数在进程的堆空间中分配内存,调用free函数可以释放这种存储空间。

    • *

[[30](#id2778429)] 为了容易阅读,这里我用了“程序文件”这个不严格的叫法。如果有文件a.c包含了b.hc.h,那么我所说的“程序文件”指的是经过预处理把b.hc.ha.c中展开之后生成的代码,在C标准中称为编译单元(Translation Unit)。每个编译单元可以分别编译成一个.o目标文件,最后这些目标文件用链接器链接到一起,成为一个可执行文件。C标准中大量使用一些非常不通俗的名词,除了编译单元之外,还有编译器叫Translator,变量叫Object,本书不会采用这些名词,因为我不是在写C标准。

4. 结构体和联合体

我们继续用反汇编的方法研究一下C语言的结构体:

例 19.3. 研究结构体

#include <stdio.h>

int main(int argc, char** argv)
{
	struct {
		char a;
		short b;
		int c;
		char d;
	} s;

	s.a = 1;
	s.b = 2;
	s.c = 3;
	s.d = 4;
	printf("%u
", sizeof(s));

	return 0;
}

main函数中几条语句的反汇编结果如下:

        s.a = 1;
 80483d5:       c6 45 f0 01             movb   $0x1,-0x10(%ebp)
        s.b = 2;
 80483d9:       66 c7 45 f2 02 00       movw   $0x2,-0xe(%ebp)
        s.c = 3;
 80483df:       c7 45 f4 03 00 00 00    movl   $0x3,-0xc(%ebp)
        s.d = 4;
 80483e6:       c6 45 f8 04             movb   $0x4,-0x8(%ebp)

从访问结构体成员的指令可以看出,结构体的四个成员在栈上是这样排列的:

图 19.5. 结构体的存储布局

结构体的存储布局

虽然栈是从高地址向低地址增长的,但结构体成员也是从低地址向高地址排列的,这一点和数组类似。但有一点和数组不同,结构体的各成员并不是一个紧挨一个排列的,中间有空隙,称为填充(Padding),不仅如此,在这个结构体的末尾也有三个字节的填充,所以sizeof(s)的值是12。注意,printf%u转换说明表示无符号数,sizeof的值是size_t类型的,是某种无符号整型。

为什么编译器要这样处理呢?有一个知识点我此前一直回避没讲,那就是大多数计算机体系统结构对于访问内存的指令是有限制的,在32位平台上,访问4字节的指令(比如上面的movl)所访问的内存地址应该是4的整数倍,访问两字节的指令(比如上面的movw)所访问的内存地址应该是两字节的整数倍,这称为对齐(Alignment)。以前举的所有例子中的内存访问指令都满足这个限制条件,读者可以回头检验一下。如果指令所访问的内存地址没有正确对齐会怎么样呢?在有些平台上将不能访问内存,而是引发一个异常,在x86平台上倒是仍然能访问内存,但是不对齐的指令执行效率比对齐的指令要低,所以编译器在安排各种变量的地址时都会考虑到对齐的问题。对于本例中的结构体,编译器会把它的基地址对齐到4字节边界,也就是说,ebp-0x10这个地址一定是4的整数倍。s.a占一个字节,没有对齐的问题。s.b占两个字节,如果s.b紧挨在s.a后面,它的地址就不能是两字节的整数倍了,所以编译器会在结构体中插入一个填充字节,使s.b的地址也是两字节的整数倍。s.c占4字节,紧挨在s.b的后面就可以了,因为ebp-0xc这个地址也是4的整数倍。那么为什么s.d的后面也要有填充位填充到4字节边界呢?这是为了便于安排这个结构体后面的变量的地址,假如用这种结构体类型组成一个数组,那么后一个结构体只需和前一个结构体紧挨着排列就可以保证它的基地址仍然对齐到4字节边界了,因为在前一个结构体的末尾已经有了填充字节。事实上,C标准规定数组元素必须紧挨着排列,不能有空隙,这样才能保证每个元素的地址可以按“基地址+n×元素大小”简单计算出来。

合理设计结构体各成员的排列顺序可以节省存储空间,例如上例中的结构体改成这样就可以避免产生填充字节:

struct {
	char a;
	char d;
	short b;
	int c;
} s;

此外,gcc提供了一种扩展语法可以消除结构体中的填充字节:

struct {
	char a;
	short b;
	int c;
	char d;
} __attribute__((packed)) s;

这样就不能保证结构体成员的对齐了,在访问bc的时候可能会有效率问题,所以除非有特别的理由,一般不要使用这种语法。

以前我们使用的数据类型都是占几个字节,最小的类型也要占一个字节,而在结构体中还可以使用Bit-field语法定义只占几个bit的成员。下面这个例子出自王聪的网站(www.wangcong.org):

例 19.4. Bit-field

#include <stdio.h>

typedef struct {
	unsigned int one:1;
	unsigned int two:3;
	unsigned int three:10;
	unsigned int four:5;
	unsigned int :2;
	unsigned int five:8;
	unsigned int six:8;
} demo_type;

int main(void)
{
	demo_type s = { 1, 5, 513, 17, 129, 0x81 };
	printf("sizeof demo_type = %u
", sizeof(demo_type));
	printf("values: s=%u,%u,%u,%u,%u,%u
",
	       s.one, s.two, s.three, s.four, s.five, s.six);

	return 0;
}

s这个结构体的布局如下图所示:

图 19.6. Bit-field的存储布局

Bit-field的存储布局

Bit-field成员的类型可以是intunsigned int,表示有符号数或无符号数,但不表示它像普通的int型一样占4个字节,它后面的数字是几就表示它占多少个bit,也可以像unsigned int :2;这样定义一个未命名的Bit-field,即使不写未命名的Bit-field,编译器也有可能在两个成员之间插入填充位,如上图的fivesix之间,这样six这个成员就刚好单独占一个字节了,访问效率会比较高,这个结构体的末尾还填充了3个字节,以便对齐到4字节边界。以前我们说过x86的Byte Order是小端的,从上图中onetwo的排列顺序可以看出,如果对一个字节再细分,则字节中的Bit Order也是小端的,因为排在结构体前面的成员(靠近低地址一边的成员)取字节中的低位。关于如何排列Bit-field在C标准中没有详细的规定,这跟Byte Order、Bit Order、对齐等问题都有关,不同的平台和编译器可能会排列得很不一样,要编写可移植的代码就不能假定Bit-field是按某一种固定方式排列的。Bit-field在驱动程序中是很有用的,因为经常需要单独操作设备寄存器中的一个或几个bit,但一定要小心使用,首先弄清楚每个Bit-field和实际bit的对应关系。

和前面几个例子不一样,在上例中我没有给出反汇编结果,直接画了个图说这个结构体的布局是这样的,那我有什么证据这么说呢?上例的反汇编结果比较繁琐,我们可以通过另一种手段得到这个结构体的内存布局。C语言还有一种类型叫联合体,用关键字union定义,其语法类似于结构体,例如:

例 19.5. 联合体

#include <stdio.h>

typedef union {
	struct {
		unsigned int one:1;
		unsigned int two:3;
		unsigned int three:10;
		unsigned int four:5;
		unsigned int :2;
		unsigned int five:8;
		unsigned int six:8;
	} bitfield;
	unsigned char byte[8];
} demo_type;

int main(void)
{
	demo_type u = {{ 1, 5, 513, 17, 129, 0x81 }};
	printf("sizeof demo_type = %u
", sizeof(demo_type));
	printf("values: u=%u,%u,%u,%u,%u,%u
",
	       u.bitfield.one, u.bitfield.two, u.bitfield.three,
	       u.bitfield.four, u.bitfield.five, u.bitfield.six);
	printf("hex dump of u: %x %x %x %x %x %x %x %x 
",
	       u.byte[0], u.byte[1], u.byte[2], u.byte[3],
	       u.byte[4], u.byte[5], u.byte[6], u.byte[7]);

	return 0;
}

一个联合体的各个成员占用相同的内存空间,联合体的长度等于其中最长成员的长度。比如u这个联合体占8个字节,如果访问成员u.bitfield,则把这8个字节看成一个由Bit-field组成的结构体,如果访问成员u.byte,则把这8个字节看成一个数组。联合体如果用Initializer初始化,则只初始化它的第一个成员,例如demo_type u = {{ 1, 5, 513, 17, 129, 0x81 }};初始化的是u.bitfield,但是通过u.bitfield的成员看不出这8个字节的内存布局,而通过u.byte数组就可以看出每个字节分别是多少了。

习题

1、编写一个程序,测试运行它的平台是大端还是小端字节序。

5. C内联汇编

用C写程序比直接用汇编写程序更简洁,可读性更好,但效率可能不如汇编程序,因为C程序毕竟要经由编译器生成汇编代码,尽管现代编译器的优化已经做得很好了,但还是不如手写的汇编代码。另外,有些平台相关的指令必须手写,在C语言中没有等价的语法,因为C语言的语法和概念是对各种平台的抽象,而各种平台特有的一些东西就不会在C语言中出现了,例如x86是端口I/O,而C语言就没有这个概念,所以in/out指令必须用汇编来写。

C语言简洁易读,容易组织规模较大的代码,而汇编效率高,而且写一些特殊指令必须用汇编,为了把这两方面的好处都占全了,gcc提供了一种扩展语法可以在C代码中使用内联汇编(Inline Assembly)。最简单的格式是__asm__("assembly code");,例如__asm__("nop");nop 这条指令什么都不做,只是让CPU空转一个指令执行周期。如果需要执行多条汇编指令,则应该用 将各条指令分隔开,例如:

__asm__("movl $1, %eax
	"
	"movl $4, %ebx
	"
	"int $0x80");

通常 C 代码中的内联汇编需要和C的变量建立关联,需要用到完整的内联汇编格式:

__asm__(assembler template 
	: output operands                  /* optional */
	: input operands                   /* optional */
	: list of clobbered registers      /* optional */
	);

这种格式由四部分组成,第一部分是汇编指令,和上面的例子一样,第二部分和第三部分是约束条件,第二部分指示汇编指令的运算结果要输出到哪些C操作数中,C操作数应该是左值表达式,第三部分指示汇编指令需要从哪些C操作数获得输入,第四部分是在汇编指令中被修改过的寄存器列表,指示编译器哪些寄存器的值在执行这条__asm__语句时会改变。后三个部分都是可选的,如果有就填写,没有就空着只写个:号。例如:

例 19.6. 内联汇编

#include <stdio.h>

int main() 
{
        int a = 10, b;

	__asm__("movl %1, %%eax
	"
		"movl %%eax, %0
	"
		:"=r"(b)        /* output */
		:"r"(a)         /* input */
		:"%eax"         /* clobbered register */
		);
	printf("Result: %d, %d
", a, b);
	return 0;
}

这个程序将变量a的值赋给b"r"(a)指示编译器分配一个寄存器保存变量a的值,作为汇编指令的输入,也就是指令中的%1(按照约束条件的顺序,b对应%0a对应1%),至于%1究竟代表哪个寄存器则由编译器自己决定。汇编指令首先把%1所代表的寄存器的值传给eax(为了和%1这种占位符区分,eax前面要求加两个%号),然后把eax的值再传给%0所代表的寄存器。"=r"(b)就表示把%0所代表的寄存器的值输出给变量b。在执行这两条指令的过程中,寄存器eax的值被改变了,所以把"%eax"写在第四部分,告诉编译器在执行这条__asm__语句时eax要被改写,所以在此期间不要用eax保存其它值。

我们看一下这个程序的反汇编结果:

        __asm__("movl %1, %%eax
	"
 80483dc:       8b 55 f8                mov    -0x8(%ebp),%edx
 80483df:       89 d0                   mov    %edx,%eax
 80483e1:       89 c2                   mov    %eax,%edx
 80483e3:       89 55 f4                mov    %edx,-0xc(%ebp)
                "movl %%eax, %0
	"
                :"=r"(b)        /* output */
                :"r"(a)         /* input */
                :"%eax"         /* clobbered register */
                );

可见%0%1都代表edx寄存器,首先把变量a(位于ebp-8的位置)的值传给edx然后执行内联汇编的两条指令,然后把edx的值传给b(位于ebp-12的位置)。

关于内联汇编就介绍这么多,本书不做深入讨论。

6. volatile限定符

现在探讨一下编译器优化会对生成的指令产生什么影响,在此基础上介绍C语言的volatile限定符。看下面的例子。

例 19.7. volatile限定符

/* artificial device registers */
unsigned char recv;
unsigned char send;

/* memory buffer */
unsigned char buf[3];

int main(void)
{
	buf[0] = recv;
	buf[1] = recv;
	buf[2] = recv;
	send = ~buf[0];
	send = ~buf[1];
	send = ~buf[2];

	return 0;
}

我们用recvsend这两个全局变量来模拟设备寄存器。假设某种平台采用内存映射I/O,串口发送寄存器和串口接收寄存器位于固定的内存地址,而recvsend这两个全局变量也有固定的内存地址,所以在这个例子中把它们假想成串口接收寄存器和串口发送寄存器。在main函数中,首先从串口接收三个字节存到buf中,然后把这三个字节取反,依次从串口发送出去[[31](#ftn.id2780312)]。我们查看这段代码的反汇编结果:

        buf[0] = recv;
 80483a2:       0f b6 05 19 a0 04 08    movzbl 0x804a019,%eax
 80483a9:       a2 1a a0 04 08          mov    %al,0x804a01a
        buf[1] = recv;
 80483ae:       0f b6 05 19 a0 04 08    movzbl 0x804a019,%eax
 80483b5:       a2 1b a0 04 08          mov    %al,0x804a01b
        buf[2] = recv;
 80483ba:       0f b6 05 19 a0 04 08    movzbl 0x804a019,%eax
 80483c1:       a2 1c a0 04 08          mov    %al,0x804a01c
        send = ~buf[0];
 80483c6:       0f b6 05 1a a0 04 08    movzbl 0x804a01a,%eax
 80483cd:       f7 d0                   not    %eax
 80483cf:       a2 18 a0 04 08          mov    %al,0x804a018
        send = ~buf[1];
 80483d4:       0f b6 05 1b a0 04 08    movzbl 0x804a01b,%eax
 80483db:       f7 d0                   not    %eax
 80483dd:       a2 18 a0 04 08          mov    %al,0x804a018
        send = ~buf[2];
 80483e2:       0f b6 05 1c a0 04 08    movzbl 0x804a01c,%eax
 80483e9:       f7 d0                   not    %eax
 80483eb:       a2 18 a0 04 08          mov    %al,0x804a018

movz指令把字长较短的值存到字长较长的存储单元中,存储单元的高位用0填充。该指令可以有b(byte)、w(word)、l(long)三种后缀,分别表示单字节、两字节和四字节。比如movzbl 0x804a019,%eax表示把地址0x804a019处的一个字节存到eax寄存器中,而eax寄存器是四字节的,高三字节用0填充,而下一条指令mov %al,0x804a01a中的al寄存器正是eax寄存器的低字节,把这个字节存到地址0x804a01a处的一个字节中。可以用不同的名字单独访问x86寄存器的低8