1. 程式人生 > >第七章之main函數和啟動例程

第七章之main函數和啟動例程

gcc 清理 其它 運行 start call 返回 argv -a

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執行完整的編譯步驟,直到最後鏈接生成可執行文件為止。

這些選項都可以和-o搭配使用,給輸出的文件重新命名而不使用gcc默認的文件名(xxx.c、xxx.s、xxx.o和a.out),例如gcc main.o -o main將main.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.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 節 “可執行文件”我們看到鏈接器起到重定位的作用,這兩種作用都是通過修改指令中的地址實現的,鏈接器也是一種編輯器,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中,而是在運行時做動態鏈接:

操作系統在加載執行main這個程序時,首先查看它有沒有需要動態鏈接的未定義符號。

如果需要做動態鏈接,就查看這個程序指定了哪些共享庫(我們用-lc指定了libc)以及用什麽動態鏈接器來做動態鏈接(我們用-dynamic-linker /lib/ld-linux.so.2指定了動態鏈接器)。

動態鏈接器在共享庫中查找這些符號的定義,完成鏈接過程。

了解了這些原理之後,現在我們來看_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 <[email protected]>
...
首先將一系列參數壓棧,然後調用libc的庫函數__libc_start_main做初始化工作,其中最後一個壓棧的參數push $0x80483c4是main函數的地址,__libc_start_main在完成初始化工作之後會調用main函數。由於__libc_start_main需要動態鏈接,所以這個庫函數的指令在可執行文件main的反匯編中肯定是找不到的,然而我們找到了這個:

Disassembly of section .plt:
...
080482c4 <[email protected]>:
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,

第七章之main函數和啟動例程