Hello World 背后的真實(shí)故事(至少是大部分故事)
我們計(jì)算機(jī)科學(xué)專業(yè)的大多數(shù)學(xué)生至少都接觸過(guò)一回著名的 "Hello World" 程序。相比一個(gè)典型的應(yīng)用程序——幾乎總是有一個(gè)帶網(wǎng)絡(luò)連接的圖形用戶界面,"Hello World" 程序看起來(lái)只是一段很簡(jiǎn)單無(wú)趣的代碼。不過(guò),許多計(jì)算機(jī)科學(xué)專業(yè)的學(xué)生其實(shí)并不了解它背后的真實(shí)故事。這個(gè)練習(xí)的目的就是利用對(duì) "Hello World" 的生存周期的分析來(lái)幫助你揭開它神秘的面紗。 源代碼讓我們先看一下 Hello World 的源代碼:
第 1 行指示編譯器去包含調(diào)用 C 語(yǔ)言庫(kù)(libc)函數(shù) printf 所需要的頭文件聲明。
第 3 行聲明了 main 函數(shù),看起來(lái)好像是我們程序的入口點(diǎn)(在后面我們將看到,其實(shí)它不是)。它被聲明為一個(gè)不帶參數(shù)(我們這里不準(zhǔn)備理會(huì)命令行參數(shù))且會(huì)返回一個(gè)整型值給它的父進(jìn)程(在我們的例子里是 shell )的函數(shù)。順便說(shuō)一下,shell 在調(diào)用程序時(shí)對(duì)其返回值有個(gè)約定:子進(jìn)程在結(jié)束時(shí)必須返回一個(gè) 8 比特?cái)?shù)來(lái)代表它的狀態(tài):0 代表正常結(jié)束,0~128 中間的數(shù)代表進(jìn)程檢測(cè)到的異常終止,大于 128 的數(shù)值代表由信號(hào)引起的終止。
從第 4 行到第 8 行構(gòu)成了 main 函數(shù)的實(shí)現(xiàn),即調(diào)用 C 語(yǔ)言庫(kù)函數(shù) printf 輸出 "Hello World!\n" 字符串,在結(jié)束時(shí)返回 0 給它的父進(jìn)程。 簡(jiǎn)單,非常簡(jiǎn)單! 編譯現(xiàn)在讓我們看看 "Hello World" 的編譯過(guò)程。在下面的討論中,我們將使用非常流行的 GNU 編譯器( gcc )和它的二進(jìn)制輔助工具( binutils )。我們可以使用下面命令來(lái)編譯我們的程序:
這樣就生成了目標(biāo)文件 hello.o,來(lái)看一下它的屬性:
hello.o: ELF 32-bit LSB relocatable, Intel 80386, version 1 (SYSV), not stripped 給出的信息告訴我們 hello.o 是個(gè)可重定位的目標(biāo)文件(relocatable),為 IA-32(Intel Architecture 32) 平臺(tái)編譯(在這個(gè)練習(xí)中我使用了一臺(tái)標(biāo)準(zhǔn) PC),保存為 ELF(Executable and Linking Format) 文件格式,并且包含著符號(hào)表(not stripped)。
順便:
hello.o: file format elf32-i386 Sections: Idx Name Size VMA LMA File off Algn 0 .text 00000011 00000000 00000000 00000034 2**2 CONTENTS, ALLOC, LOAD, RELOC, READONLY, CODE 1 .data 00000000 00000000 00000000 00000048 2**2 CONTENTS, ALLOC, LOAD, DATA 2 .bss 00000000 00000000 00000000 00000048 2**2 ALLOC 3 .rodata.str1.1 0000000d 00000000 00000000 00000048 2**0 CONTENTS, ALLOC, LOAD, READONLY, DATA 4 .comment 00000033 00000000 00000000 00000055 2**0 CONTENTS, READONLY SYMBOL TABLE: 00000000 l df *ABS* 00000000 hello.c 00000000 l d .text 00000000 00000000 l d .data 00000000 00000000 l d .bss 00000000 00000000 l d .rodata.str1.1 00000000 00000000 l d .comment 00000000 00000000 g F .text 00000011 main 00000000 *UND* 00000000 puts RELOCATION RECORDS FOR [.text]: OFFSET TYPE VALUE 00000004 R_386_32 .rodata.str1.1 00000009 R_386_PC32 puts 這告訴我們 hello.o 有 5 個(gè)段:
(譯者注:在下面的解釋中讀者要分清什么是 ELF 文件中的段(section)和進(jìn)程中的段(segment)。比如 .text 是 ELF 文件中的段名,當(dāng)程序被加載到內(nèi)存中之后,.text 段構(gòu)成了程序的可執(zhí)行代碼段。其實(shí)有時(shí)候在中文環(huán)境下也稱 .text 段為代碼段,要根據(jù)上下文分清它代表的意思。)
它也給我們展示了一個(gè)符號(hào)表( symbol table ),其中符號(hào) main 的地址被設(shè)置為 00000000,符號(hào) puts 未定義。此外,重定位表(relocation table)告訴我們?cè)趺礃尤ピ?.text 段中去重定位對(duì)其它段內(nèi)容的引用。第一個(gè)可重定位的符號(hào)對(duì)應(yīng)于 .rodata 中的 "Hello World!\n" 字符串,第二個(gè)可重定位符號(hào) puts,代表了使用 printf 所產(chǎn)生的對(duì)一個(gè) libc 庫(kù)函數(shù)的調(diào)用。為了更好的理解 hello.o 的內(nèi)容,讓我們來(lái)看看它的匯編代碼:
從匯編代碼中我們可以清楚的看到 ELF 段標(biāo)記是怎么來(lái)的。比如,.text 段是 32 位對(duì)齊的(第 7 行)。它也揭示了 .comment 段是從哪兒來(lái)的(第 20 行)。因?yàn)槲覀兪褂?printf 來(lái)打印一個(gè)字符串,并且我們要求我們優(yōu)秀的編譯器對(duì)生成的代碼進(jìn)行優(yōu)化( -Os ),編譯器用(應(yīng)該更快的) puts 調(diào)用來(lái)取代 printf 調(diào)用。不幸的是,我們后面將會(huì)看到我們的 libc 庫(kù)的實(shí)現(xiàn)會(huì)使這種優(yōu)化變得沒(méi)什么用。 那么這段匯編代碼會(huì)生成什么代碼呢?沒(méi)什么意外之處:使用標(biāo)志字符串地址的標(biāo)號(hào) .LC0 作為參數(shù)的一個(gè)對(duì) puts 庫(kù)函數(shù)的簡(jiǎn)單調(diào)用。 連接下面讓我們看一下 hello.o 轉(zhuǎn)化為可執(zhí)行文件的過(guò)程??赡軙?huì)有人覺(jué)得用下面的命令就可以了:
ld: warning: cannot find entry symbol _start; defaulting to 08048184 不過(guò),那個(gè)警告是什么意思?嘗試運(yùn)行一下! 是的,hello 程序不工作。讓我們回到那個(gè)警告:它告訴我們連接器(ld)不能找到我們程序的入口點(diǎn) _start。不過(guò) main 難道不是入口點(diǎn)嗎?簡(jiǎn)短的來(lái)說(shuō),從程序員的角度來(lái)看 main 可能是一個(gè) C 程序的入口點(diǎn)。但實(shí)際上,在調(diào)用 main 之前,一個(gè)進(jìn)程已經(jīng)執(zhí)行了一大堆代碼來(lái)“為可執(zhí)行程序清理房間”。我們通常情況下從編譯器或者操作系統(tǒng)提供者那里得到這些外殼程序(surrounding code,譯者注:比如 CRT)。 下面讓我們?cè)囋囘@個(gè)命令: # ld -static -o hello -L`gcc -print-file-name=` /usr/lib/crt1.o /usr/lib/crti.o hello.o /usr/lib/crtn.o -lc -lgcc 現(xiàn)在我們可以得到一個(gè)真正的可執(zhí)行文件了。使用靜態(tài)連接(static linking)有兩個(gè)原因:一,在這里我不想深入去討論動(dòng)態(tài)連接庫(kù)(dynamic libraries)是怎么工作的;二,我想讓你看看在我們庫(kù)(libc 和 libgcc)的實(shí)現(xiàn)中,有多少不必要的代碼將被添加到 "Hello World" 程序中。試一下這個(gè)命令: # find hello.c hello.o hello -printf "%f\t%s\n" hello.c 84 hello.o 788 hello 445506 你也可以嘗試 "nm hello" 和 "objdump -d hello" 命令來(lái)得到什么東西被連接到了可執(zhí)行文件中。 想了解動(dòng)態(tài)連接的更多內(nèi)容,請(qǐng)參考 Program Library HOWTO。 裝載和運(yùn)行在一個(gè)遵循 POSIX(Portable Operating System Interface) 標(biāo)準(zhǔn)的操作系統(tǒng)(OS)上,裝載一個(gè)程序是由父進(jìn)程發(fā)起 fork 系統(tǒng)調(diào)用來(lái)復(fù)制自己,然后剛生成的子進(jìn)程發(fā)起 execve 系統(tǒng)調(diào)用來(lái)裝載和執(zhí)行要運(yùn)行的程序組成的。無(wú)論何時(shí)你在 shell 中敲入一個(gè)外部命令,這個(gè)過(guò)程都會(huì)被實(shí)施。你可以使用 truss 或者 strace 命令來(lái)驗(yàn)證一下: # strace -i hello > /dev/null [????????] execve("./hello", ["hello"], [/* 46 vars */]) = 0 ... [08053d44] write(1, "Hello World!\n", 13) = 13 ... [0804e7ad] _exit(0) = ? 除了 execve 系統(tǒng)調(diào)用,上面的輸出展示了打印函數(shù) puts 中的 write 系統(tǒng)調(diào)用,和用 main 的返回值(0)作為參數(shù)的 exit 系統(tǒng)調(diào)用。 為了解 execve 實(shí)施的裝載過(guò)程背后的細(xì)節(jié),讓我們看一下我們的 ELF 可執(zhí)行文件: # readelf -l hello Elf file type is EXEC (Executable file) Entry point 0x80480e0 There are 3 program headers, starting at offset 52 Program Headers: Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align LOAD 0x000000 0x08048000 0x08048000 0x55dac 0x55dac R E 0x1000 LOAD 0x055dc0 0x0809edc0 0x0809edc0 0x01df4 0x03240 RW 0x1000 NOTE 0x000094 0x08048094 0x08048094 0x00020 0x00020 R 0x4 Section to Segment mapping: Segment Sections... 00 .init .text .fini .rodata __libc_atexit __libc_subfreeres .note.ABI-tag 01 .data .eh_frame .got .bss 02 .note.ABI-tag 輸出顯示了 hello 的整體結(jié)構(gòu)。第一個(gè)程序頭對(duì)應(yīng)于進(jìn)程的代碼段,它將從文件偏移 0x000000 處被裝載到映射到進(jìn)程地址空間的 0x08048000 地址的物理內(nèi)存中(虛擬內(nèi)存機(jī)制)。代碼段共有 0x55dac 字節(jié)大小而且必須按頁(yè)對(duì)齊(0x1000, page-aligned)。這個(gè)段將包含我們前面討論過(guò)的 ELF 文件中的 .text 段和 .rodata 段的內(nèi)容,再加上在連接過(guò)程中生成的附加的段。正如我們預(yù)期,它被標(biāo)志為:只讀(R)和可執(zhí)行(X),不過(guò)禁止寫(W)。 第二個(gè)程序頭對(duì)應(yīng)于進(jìn)程的數(shù)據(jù)段。裝載這個(gè)段到內(nèi)存的方式和上面所提到的一樣。不過(guò),需要注意的是,這個(gè)段占用的文件大小是 0x01df4 字節(jié),而在內(nèi)存中它占用了 0x03240 字節(jié)。這個(gè)差異主要?dú)w功于 .bss 段,它在內(nèi)存中只需要被賦 0,所以不用在文件中出現(xiàn)(譯者注:文件中只需要知道它的起始地址和大小即可)。進(jìn)程的數(shù)據(jù)段仍然需要按頁(yè)對(duì)齊(0x1000, page-aligned)并且將包含 .data 和 .bss 段。它將被標(biāo)識(shí)為可讀寫(RW)。第三個(gè)程序頭是連接階段產(chǎn)生的,和這里的討論沒(méi)有什么關(guān)系。 如果你有一個(gè) proc 文件系統(tǒng),當(dāng)你得到 "Hello World" 時(shí)停止進(jìn)程(提示: gdb,譯者注:用 gdb 設(shè)置斷點(diǎn)),你可以用下面的命令檢查一下是不是如上所說(shuō): # cat /proc/`ps -C hello -o pid=`/maps 08048000-0809e000 r-xp 00000000 03:06 479202 .../hello 0809e000-080a1000 rw-p 00055000 03:06 479202 .../hello 080a1000-080a3000 rwxp 00000000 00:00 0 bffff000-c0000000 rwxp 00000000 00:00 0 第一個(gè)映射的區(qū)域是這個(gè)進(jìn)程的代碼段,第二個(gè)和第三個(gè)構(gòu)成了數(shù)據(jù)段(data + bss + heap),第四個(gè)區(qū)域在 ELF 文件中沒(méi)有對(duì)應(yīng)的內(nèi)容,是程序棧。更多和正在運(yùn)行的 hello 進(jìn)程有關(guān)的信息可以用 GNU 程序:time, ps 和 /proc/pid/stat 得到。 程序終止當(dāng) "Hello World" 程序運(yùn)行到 main 函數(shù)中的 return 語(yǔ)句時(shí),它向我們?cè)诙芜B接部分討論過(guò)的外殼函數(shù)傳入了一個(gè)參數(shù)。這些函數(shù)中的某一個(gè)發(fā)起 exit 系統(tǒng)調(diào)用。這個(gè) exit 系統(tǒng)調(diào)用將返回值轉(zhuǎn)交給被 wait 系統(tǒng)調(diào)用阻塞的父進(jìn)程。此外,它還要對(duì)終止的進(jìn)程進(jìn)行清理,將其占用的資源還給操作系統(tǒng)。用下面命令我們可以追蹤到部分過(guò)程: # strace -e trace=process -f sh -c "hello; echo $?" > /dev/null execve("/bin/sh", ["sh", "-c", "hello; echo 0"], [/* 46 vars */]) = 0 fork() = 8321 [pid 8320] wait4(-1, <unfinished ...> [pid 8321] execve("./hello", ["hello"], [/* 46 vars */]) = 0 [pid 8321] _exit(0) = ? <... wait4 resumed> [WIFEXITED(s) && WEXITSTATUS(s) == 0], 0, NULL) = 8321 --- SIGCHLD (Child exited) --- wait4(-1, 0xbffff06c, WNOHANG, NULL) = -1 ECHILD (No child processes) _exit(0) 結(jié)束這個(gè)練習(xí)的目的是讓計(jì)算機(jī)專業(yè)的新生注意這樣一個(gè)事實(shí):一個(gè) Java Applet 的運(yùn)行并不是像魔法一樣(無(wú)中生有的),即使在最簡(jiǎn)單的程序背后也有很多系統(tǒng)軟件的支撐。如果您覺(jué)得這篇文章有用并且想提供建議來(lái)改進(jìn)它,請(qǐng)發(fā)電子郵件給我。 常見(jiàn)問(wèn)題這一節(jié)是為了回答學(xué)生們的常見(jiàn)問(wèn)題。
編譯器內(nèi)部的函數(shù)庫(kù),比如 libgcc,是用來(lái)實(shí)現(xiàn)目標(biāo)平臺(tái)沒(méi)有直接實(shí)現(xiàn)的語(yǔ)言元素。舉個(gè)例子,C 語(yǔ)言的模運(yùn)算符 ("%") 在某個(gè)平臺(tái)上可能無(wú)法映射到一條匯編指令??赡苡靡粋€(gè)函數(shù)調(diào)用實(shí)現(xiàn)比讓編譯器為其生成內(nèi)嵌代碼更受歡迎(特別是對(duì)一些內(nèi)存受限的計(jì)算機(jī)來(lái)說(shuō),比如微控制 器)。很多其它的基本運(yùn)算,包括除法、乘法、字符串處理(比如 memory copy)一般都會(huì)在這類函數(shù)庫(kù)中實(shí)現(xiàn)。 |
|