# 第?19?章?匯編與C之間的關系
**目錄**
+ [1\. 函數調用](ch19s01.html)
+ [2\. `main`函數和啟動例程](ch19s02.html)
+ [3\. 變量的存儲布局](ch19s03.html)
+ [4\. 結構體和聯合體](ch19s04.html)
+ [5\. C內聯匯編](ch19s05.html)
+ [6\. volatile限定符](ch19s06.html)
上一章我們學習了匯編的一些基礎知識,本章我們進一步研究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_](ch10.html#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`調用`foo`,`foo`調用`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`的輸出結果圖示如下<sup>[[29](#ftn.id2775282)]</sup>:
**圖?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`函數的參數`a`和`b`分別通過`ebp+8`和`ebp+12`來訪問。所以下面的指令把參數`a`和`b`再次壓棧,為調用`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+8`和`ebp+12`分別可以訪問參數`c`和`d`。`bar`函數還有一個局部變量`e`,可以通過`ebp-4`來訪問。所以后面幾條指令的意思是把參數`c`和`d`取出來存在寄存器中做加法,計算結果保存在`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 %ebp`和`mov %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?節 “自定義函數”](ch03s02.html#func.ourfirstfunc)講過,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`時多傳了一個參數,那么參數`a`和`b`分別取什么值?多的參數怎么辦?`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\n", d);
}
```
打印結果是多少?如果把聲明`void foo();`改成`void foo(double);`,打印結果又是多少?
* * *
<sup>[[29](#id2775282)]</sup> Linux內核為每個新進程指定的棧空間的起始地址都會有些不同,所以每次運行這個程序得到的地址都不一樣,但通常都是0xbf??????這樣一個地址。
## 2.?`main`函數和啟動例程
為什么匯編程序的入口是`_start`,而C程序的入口是`main`函數呢?本節就來解釋這個問題。在講[例?18.1 “最簡單的匯編程序”](ch18s01.html#asm.simpleasm)時,我們的匯編和鏈接步驟是:
```
$ 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?節 “數組應用實例:統計隨機數”](ch08s02.html#array.statistic)還講過`-E`選項只做預處理而不編譯,如果不加這些選項則`gcc`執行完整的編譯步驟,直到最后鏈接生成可執行文件為止。如下圖所示。
**圖?19.2.?gcc命令的選項**

這些選項都可以和`-o`搭配使用,給輸出的文件重新命名而不使用`gcc`默認的文件名(`xxx.c`、`xxx.s`、`xxx.o`和`a.out`),例如`gcc main.o -o main`將`main.o`鏈接成可執行文件`main`。先前由匯編代碼[例?18.1 “最簡單的匯編程序”](ch18s01.html#asm.simpleasm)生成的目標文件`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 “研究函數的調用過程”](ch19s01.html#asmc.func)。如果分兩步編譯,第二步`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?節 “數學函數”](ch03s01.html#func.mathfunc)講過`-lc`選項是`gcc`默認的,不用寫,而對于`ld`則不是默認選項,所以要寫上。`-dynamic-linker /lib/ld-linux.so.2`指定動態鏈接器是`/lib/ld-linux.so.2`,稍后會解釋什么是動態鏈接。
那么`crt1.o`和`crti.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?節 “可執行文件”](ch18s05.html#asm.executable)我們看到鏈接器起到重定位的作用,這兩種作用都是通過修改指令中的地址實現的,鏈接器也是一種編輯器,`vi`和`emacs`編輯的是源文件,而鏈接器編輯的是目標文件,所以鏈接器也叫Link Editor。`T _start`這一行表示`_start`這個符號在`crt1.o`中提供了定義,這個符號的類型是代碼(T表示Text)。我們從上面的輸出結果中選取幾個符號用圖示說明它們之間的關系:
**圖?19.3.?C程序的鏈接過程**

其實上面我們寫的`ld`命令做了很多簡化,`gcc`在鏈接時還用到了另外幾個目標文件,所以上圖多畫了一個框,表示組成可執行文件`main`的除了`main.o`、`crt1.o`和`crti.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`中的未定義符號`main`在`main.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 $0x80483c4`是`main`函數的地址,`__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\n", c);
return 0;
}
```
我們在全局作用域和`main`函數的局部作用域各定義了一些變量,并且引入一些新的關鍵字`const`、`static`、`register`來修飾變量,那么這些變量的存儲空間是怎么分配的呢?我們編譯之后用`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\n"`分配在`.rodata`段的末尾,在[第?4?節 “字符串”](ch08s04.html#array.string)說過字符串字面值是只讀的,相當于在全局作用域定義了一個`const`數組:
```
const char helloworld[] = {'H', 'e', 'l', 'l', 'o', ' ',
'w', 'o', 'r', 'l', 'd', ' ', '%', 'd', '\n', '\0'};
```
程序加載運行時,`.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`段中有三個變量,`a`,`b`和`a.1589`。
`a`是一個`GLOBAL`的符號,而`b`被`static`關鍵字修飾了,導致它成為一個`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?節 “全局變量、局部變量和作用域”](ch03s04.html#func.localvar)講過,全局變量如果不初始化則初值為0,同理可以推斷,`static`變量(不管是函數里的還是函數外的)如果不初始化則初值也是0,也分配在`.bss`段。
現在還剩下函數中的`b`和`c`這兩個變量沒有分析。上一節我們講過函數的參數和局部變量是分配在棧上的,`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\n", 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\n"`這個參數壓棧的是它在`.rodata`段中的首地址,而不是把整個字符串壓棧,所以在[第?4?節 “字符串”](ch08s04.html#array.string)中說過,字符串在使用時可以看作數組名,如果做右值則表示數組首元素的地址(或者說指向數組首元素的指針),我們以后講指針還要繼續討論這個問題。
以前我們用“全局變量”和“局部變量”這兩個概念,主要是從作用域上區分的,現在看來用這兩個概念給變量分類太籠統了,需要進一步細分。我們總結一下相關的C語法。
作用域(Scope)這個概念適用于所有標識符,而不僅僅是變量,C語言的作用域分為以下幾類:
* 函數作用域(Function Scope),標識符在整個函數中都有效。只有語句標號屬于函數作用域。標號在函數中不需要先聲明后使用,在前面用一個`goto`語句也可以跳轉到后面的某個標號,但僅限于同一個函數之中。
* 文件作用域(File Scope),標識符從它聲明的位置開始直到這個程序文件<sup>[[30](#ftn.id2778429)]</sup>的末尾都有效。例如上例中`main`函數外面的`A`、`a`、`b`、`c`,還有`main`也算,`printf`其實是在`stdio.h`中聲明的,被包含到這個程序文件中了,所以也算文件作用域的。
* 塊作用域(Block Scope),標識符位于一對{}括號中(函數體或語句塊),從它聲明的位置開始到右}括號之間有效。例如上例中`main`函數里的`a`、`b`、`c`。此外,函數定義中的形參也算塊作用域的,從聲明的位置開始到函數末尾之間有效。
* 函數原型作用域(Function Prototype Scope),標識符出現在函數原型中,這個函數原型只是一個聲明而不是定義(沒有函數體),那么標識符從聲明的位置開始到在這個原型末尾之間有效。例如`int foo(int a, int b);`中的`a`和`b`。
對屬于同一命名空間(Name Space)的重名標識符,內層作用域的標識符將覆蓋外層作用域的標識符,例如局部變量名在它的函數中將覆蓋重名的全局變量。命名空間可分為以下幾類:
* 語句標號單獨屬于一個命名空間。例如在函數中局部變量和語句標號可以重名,互不影響。由于使用標號的語法和使用其它標識符的語法都不一樣,編譯器不會把它和別的標識符弄混。
* `struct`,`enum`和`union`(下一節介紹`union`)的類型Tag屬于一個命名空間。由于Tag前面總是帶`struct`,`enum`或`union`關鍵字,所以編譯器不會把它和別的標識符弄混。
* `struct`和`union`的成員名屬于一個命名空間。由于成員名總是通過`.`或`->`運算符來訪問而不會單獨使用,所以編譯器不會把它和別的標識符弄混。
* 所有其它標識符,例如變量名、函數名、宏定義、`typedef`的類型名、`enum`成員等等都屬于同一個命名空間。如果有重名的話,宏定義覆蓋所有其它標識符,因為它在預處理階段而不是編譯階段處理,除了宏定義之外其它幾類標識符按上面所說的規則處理,內層作用域覆蓋外層作用域。
標識符的鏈接屬性(Linkage)有三種:
* 外部鏈接(External Linkage),如果最終的可執行文件由多個程序文件鏈接而成,一個標識符在任意程序文件中即使聲明多次也都代表同一個變量或函數,則這個標識符具有External Linkage。具有External Linkage的標識符編譯后在符號表中是`GLOBAL`的符號。例如上例中`main`函數外面的`a`和`c`,`main`和`printf`也算。
* 內部鏈接(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類型聲明”](ch16s02.html#op.sizeoftypedef)講過這個關鍵字,它并不是用來修飾變量的,而是定義一個類型名。在那一節也講過,看`typedef`聲明怎么看呢,首先去掉`typedef`把它看成變量聲明,看這個變量是什么類型的,那么`typedef`就定義了一個什么類型,也就是說,`typedef`在語法結構中出現的位置和前面幾個關鍵字一樣,也是修飾變量聲明的,所以從語法(而不是語義)的角度把它和前面幾個關鍵字歸類到一起。
注意,上面介紹的`const`關鍵字不是一個Storage Class Specifier,雖然看起來它也修飾一個變量聲明,但是在以后介紹的更復雜的聲明中`const`在語法結構中允許出現的位置和Storage Class Specifier是不完全相同的。`const`和以后要介紹的`restrict`和`volatile`關鍵字屬于同一類語法元素,稱為類型限定符(Type Qualifier)。
變量的生存期(Storage Duration,或者Lifetime)分為以下幾類:
* 靜態生存期(Static Storage Duration),具有外部或內部鏈接屬性,或者被`static`修飾的變量,在程序開始執行時分配和初始化一次,此后便一直存在直到程序結束。這種變量通常位于`.rodata`,`.data`或`.bss`段,例如上例中`main`函數外的`A`,`a`,`b`,`c`,以及`main`函數里的`a`。
* 自動生存期(Automatic Storage Duration),鏈接屬性為無鏈接并且沒有被`static`修飾的變量,這種變量在進入塊作用域時在棧上或寄存器中分配,在退出塊作用域時釋放。例如上例中`main`函數里的`b`和`c`。
* 動態分配生存期(Allocated Storage Duration),以后會講到調用`malloc`函數在進程的堆空間中分配內存,調用`free`函數可以釋放這種存儲空間。
* * *
<sup>[[30](#id2778429)]</sup> 為了容易閱讀,這里我用了“程序文件”這個不嚴格的叫法。如果有文件`a.c`包含了`b.h`和`c.h`,那么我所說的“程序文件”指的是經過預處理把`b.h`和`c.h`在`a.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\n", 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;
```
這樣就不能保證結構體成員的對齊了,在訪問`b`和`c`的時候可能會有效率問題,所以除非有特別的理由,一般不要使用這種語法。
以前我們使用的數據類型都是占幾個字節,最小的類型也要占一個字節,而在結構體中還可以使用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\n", sizeof(demo_type));
printf("values: s=%u,%u,%u,%u,%u,%u\n",
s.one, s.two, s.three, s.four, s.five, s.six);
return 0;
}
```
`s`這個結構體的布局如下圖所示:
**圖?19.6.?Bit-field的存儲布局**

Bit-field成員的類型可以是`int`或`unsigned int`,表示有符號數或無符號數,但不表示它像普通的`int`型一樣占4個字節,它后面的數字是幾就表示它占多少個bit,也可以像`unsigned int :2;`這樣定義一個未命名的Bit-field,即使不寫未命名的Bit-field,編譯器也有可能在兩個成員之間插入填充位,如上圖的`five`和`six`之間,這樣`six`這個成員就剛好單獨占一個字節了,訪問效率會比較高,這個結構體的末尾還填充了3個字節,以便對齊到4字節邊界。以前我們說過x86的Byte Order是小端的,從上圖中`one`和`two`的排列順序可以看出,如果對一個字節再細分,則字節中的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\n", sizeof(demo_type));
printf("values: u=%u,%u,%u,%u,%u,%u\n",
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 \n",
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空轉一個指令執行周期。如果需要執行多條匯編指令,則應該用`\n\t`將各條指令分隔開,例如:
```
__asm__("movl $1, %eax\n\t"
"movl $4, %ebx\n\t"
"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\n\t"
"movl %%eax, %0\n\t"
:"=r"(b) /* output */
:"r"(a) /* input */
:"%eax" /* clobbered register */
);
printf("Result: %d, %d\n", a, b);
return 0;
}
```
這個程序將變量`a`的值賦給`b`。`"r"(a)`指示編譯器分配一個寄存器保存變量`a`的值,作為匯編指令的輸入,也就是指令中的`%1`(按照約束條件的順序,`b`對應`%0`,`a`對應`1%`),至于`%1`究竟代表哪個寄存器則由編譯器自己決定。匯編指令首先把`%1`所代表的寄存器的值傳給`eax`(為了和`%1`這種占位符區分,`eax`前面要求加兩個`%`號),然后把`eax`的值再傳給`%0`所代表的寄存器。`"=r"(b)`就表示把`%0`所代表的寄存器的值輸出給變量`b`。在執行這兩條指令的過程中,寄存器`eax`的值被改變了,所以把`"%eax"`寫在第四部分,告訴編譯器在執行這條`__asm__`語句時`eax`要被改寫,所以在此期間不要用`eax`保存其它值。
我們看一下這個程序的反匯編結果:
```
__asm__("movl %1, %%eax\n\t"
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\n\t"
:"=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;
}
```
我們用`recv`和`send`這兩個全局變量來模擬設備寄存器。假設某種平臺采用內存映射I/O,串口發送寄存器和串口接收寄存器位于固定的內存地址,而`recv`和`send`這兩個全局變量也有固定的內存地址,所以在這個例子中把它們假想成串口接收寄存器和串口發送寄存器。在`main`函數中,首先從串口接收三個字節存到`buf`中,然后把這三個字節取反,依次從串口發送出去<sup>[[31](#ftn.id2780312)]</sup>。我們查看這段代碼的反匯編結果:
```
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位、次低8位、低16位或者完整的32位,以`eax`為例,`al`表示低8位,`ah`表示次低8位,`ax`表示低16位,如下圖所示。
**圖?19.7.?eax寄存器**

但如果指定優化選項`-O`編譯,反匯編的結果就不一樣了:
```
$ gcc main.c -g -O
$ objdump -dS a.out|less
...
buf[0] = recv;
80483ae: 0f b6 05 19 a0 04 08 movzbl 0x804a019,%eax
80483b5: a2 1a a0 04 08 mov %al,0x804a01a
buf[1] = recv;
80483ba: a2 1b a0 04 08 mov %al,0x804a01b
buf[2] = recv;
80483bf: a2 1c a0 04 08 mov %al,0x804a01c
send = ~buf[0];
send = ~buf[1];
send = ~buf[2];
80483c4: f7 d0 not %eax
80483c6: a2 18 a0 04 08 mov %al,0x804a018
...
```
前三條語句從串口接收三個字節,而編譯生成的指令顯然不符合我們的意圖:只有第一條語句從內存地址0x804a019讀一個字節到寄存器`eax`中,然后從寄存器`al`保存到`buf[0]`,后兩條語句就不再從內存地址0x804a019讀取,而是直接把寄存器`al`的值保存到`buf[1]`和`buf[2]`。后三條語句把`buf`中的三個字節取反再發送到串口,編譯生成的指令也不符合我們的意圖:只有最后一條語句把`eax`的值取反寫到內存地址0x804a018了,前兩條語句形同虛設,根本不生成指令。
為什么編譯器優化的結果會錯呢?因為編譯器并不知道0x804a018和0x804a019是設備寄存器的地址,把它們當成普通的內存單元了。如果是普通的內存單元,只要程序不去改寫它,它就不會變,可以先把內存單元里的值讀到寄存器緩存起來,以后每次用到這個值就直接從寄存器讀取,這樣效率更高,我們知道讀寄存器遠比讀內存要快。另一方面,如果對一個普通的內存單元連續做三次寫操作,只有最后一次的值會保存到內存單元中,所以前兩次寫操作是多余的,可以優化掉。訪問設備寄存器的代碼這樣優化就錯了,因為設備寄存器往往具有以下特性:
* 設備寄存器中的數據不需要改寫就可以自己發生變化,每次讀上來的值都可能不一樣。
* 連續多次向設備寄存器中寫數據并不是在做無用功,而是有特殊意義的。
用優化選項編譯生成的指令明顯效率更高,但使用不當會出錯,為了避免編譯器自作聰明,把不該優化的也優化了,程序員應該明確告訴編譯器哪些內存單元的訪問是不能優化的,在C語言中可以用`volatile`限定符修飾變量,就是告訴編譯器,即使在編譯時指定了優化選項,每次讀這個變量仍然要老老實實從內存讀取,每次寫這個變量也仍然要老老實實寫回內存,不能省略任何步驟。我們把代碼的開頭幾行改成:
```
/* artificial device registers */
volatile unsigned char recv;
volatile unsigned char send;
```
然后指定優化選項`-O`編譯,查看反匯編的結果:
```
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 15 19 a0 04 08 movzbl 0x804a019,%edx
80483b5: 88 15 1b a0 04 08 mov %dl,0x804a01b
buf[2] = recv;
80483bb: 0f b6 0d 19 a0 04 08 movzbl 0x804a019,%ecx
80483c2: 88 0d 1c a0 04 08 mov %cl,0x804a01c
send = ~buf[0];
80483c8: f7 d0 not %eax
80483ca: a2 18 a0 04 08 mov %al,0x804a018
send = ~buf[1];
80483cf: f7 d2 not %edx
80483d1: 88 15 18 a0 04 08 mov %dl,0x804a018
send = ~buf[2];
80483d7: f7 d1 not %ecx
80483d9: 88 0d 18 a0 04 08 mov %cl,0x804a018
```
確實每次讀`recv`都從內存地址0x804a019讀取,每次寫`send`也都寫到內存地址0x804a018了。值得注意的是,每次寫`send`并不需要取出`buf`中的值,而是取出先前緩存在寄存器`eax`、`edx`、`ecx`中的值,做取反運算然后寫下去,這是因為`buf`并沒有用`volatile`限定,讀者可以試著在`buf`的定義前面也加上`volatile`,再優化編譯,再查看反匯編的結果。
`gcc`的編譯優化選項有`-O0`、`-O`、`-O1`、`-O2`、`-O3`、`-Os`幾種。`-O0`表示不優化,這是缺省的選項。`-O1`、`-O2`和`-O3`這幾個選項一個比一個優化得更多,編譯時間也更長。`-O`和`-O1`相同。`-Os`表示為縮小目標文件的尺寸而優化。具體每種選項做了哪些優化請參考`gcc(1)`的Man Page。
從上面的例子還可以看到,如果在編譯時指定了優化選項,源代碼和生成指令的次序可能無法對應,甚至有些源代碼可能不對應任何指令,被徹底優化掉了。這一點在用`gdb`做源碼級調試時尤其需要注意(做指令級調試沒關系),在為調試而編譯時不要指定優化選項,否則可能無法一步步跟蹤源代碼的執行過程。
有了`volatile`限定符,是可以防止編譯器優化對設備寄存器的訪問,但是對于有Cache的平臺,僅僅這樣還不夠,還是無法防止Cache優化對設備寄存器的訪問。在訪問普通的內存單元時,Cache對程序員是透明的,比如執行了`movzbl 0x804a019,%eax`這樣一條指令,我們并不知道`eax`的值是真的從內存地址0x804a019讀到的,還是從Cache中讀到的,如果Cache已經緩存了這個地址的數據就從Cache讀,如果Cache沒有緩存就從內存讀,這些步驟都是硬件自動做的,而不是用指令控制Cache去做的,程序員寫的指令中只有寄存器、內存地址,而沒有Cache,程序員甚至不需要知道Cache的存在。同樣道理,如果執行了`mov %al,0x804a01a`這樣一條指令,我們并不知道寄存器的值是真的寫回內存了,還是只寫到了Cache中,以后再由Cache寫回內存,即使只寫到了Cache中而暫時沒有寫回內存,下次讀0x804a01a這個地址時仍然可以從Cache中讀到上次寫的數據。然而,在讀寫設備寄存器時Cache的存在就不容忽視了,如果串口發送和接收寄存器的內存地址被Cache緩存了會有什么問題呢?如下圖所示。
**圖?19.8.?串口發送和接收寄存器被Cache緩存會有什么問題**

如果串口發送寄存器的地址被Cahce緩存,CPU執行單元對串口發送寄存器做寫操作都寫到Cache中去了,串口發送寄存器并沒有及時得到數據,也就不能及時發送,CPU執行單元先后發出的1、2、3三個字節都會寫到Cache中的同一個單元,最后Cache中只保存了第3個字節,如果這時Cache把數據寫回到串口發送寄存器,只能把第3個字節發送出去,前兩個字節就丟失了。與此類似,如果串口接收寄存器的地址被Cache緩存,CPU執行單元在讀第1個字節時,Cache會從串口接收寄存器讀上來緩存,然而串口接收寄存器后面收到的2、3兩個字節Cache并不知道,因為Cache把串口接收寄存器當作普通內存單元,并且相信內存單元中的數據是不會自己變的,以后每次讀串口接收寄存器時,Cache都會把緩存的第1個字節提供給CPU執行單元。
通常,有Cache的平臺都有辦法對某一段地址范圍禁用Cache,一般是在頁表中設置的,可以設定哪些頁面允許Cache緩存,哪些頁面不允許Cache緩存,MMU不僅要做地址轉換和訪問權限檢查,也要和Cache協同工作。
除了設備寄存器需要用`volatile`限定之外,當一個全局變量被同一進程中的多個控制流程訪問時也要用`volatile`限定,比如信號處理函數和多線程。
* * *
<sup>[[31](#id2780312)]</sup> 實際的串口設備通常有一些標志位指示是否有數據到達以及是否可以發送下一個字節的數據,通常要先查詢這些標志位再做讀寫操作,在這個例子中我們抓主要矛盾,忽略這些細節。
- Linux C編程一站式學習
- 歷史
- 前言
- 部分?I.?C語言入門
- 第?1?章?程序的基本概念
- 第?2?章?常量、變量和表達式
- 第?3?章?簡單函數
- 第?4?章?分支語句
- 第?5?章?深入理解函數
- 第?6?章?循環語句
- 第?7?章?結構體
- 第?8?章?數組
- 第?9?章?編碼風格
- 第?10?章?gdb
- 第?11?章?排序與查找
- 第?12?章?棧與隊列
- 第?13?章?本階段總結
- 部分?II.?C語言本質
- 第?14?章?計算機中數的表示
- 第?15?章?數據類型詳解
- 第?16?章?運算符詳解
- 第?17?章?計算機體系結構基礎
- 第?18?章?x86匯編程序基礎
- 第?19?章?匯編與C之間的關系
- 第?20?章?鏈接詳解
- 第?21?章?預處理
- 第?22?章?Makefile基礎
- 第?23?章?指針
- 第?24?章?函數接口
- 第?25?章?C標準庫
- 第?26?章?鏈表、二叉樹和哈希表
- 第?27?章?本階段總結
- 部分?III.?Linux系統編程
- 第?28?章?文件與I/O
- 第?29?章?文件系統
- 第?30?章?進程
- 第?31?章?Shell腳本
- 第?32?章?正則表達式
- 第?33?章?信號
- 第?34?章?終端、作業控制與守護進程
- 第?35?章?線程
- 第?36?章?TCP/IP協議基礎
- 第?37?章?socket編程
- 附錄?A.?字符編碼
- 附錄?B.?GNU Free Documentation License Version 1.3, 3 November 2008
- 參考書目
- 索引