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,以後還要詳細解釋這兩個函數。