我們計算機科學(xué)專(zhuān)業(yè)的大多數學(xué)生至少都接觸過(guò)一回著(zhù)名的 "Hello World" 程序。相比一個(gè)典型的應用程序——幾乎總是有一個(gè)帶網(wǎng)絡(luò )連接的圖形用戶(hù)界面,"Hello World" 程序看起來(lái)只是一段很簡(jiǎn)單無(wú)趣的代碼。不過(guò),許多計算機科學(xué)專(zhuān)業(yè)的學(xué)生其實(shí)并不了解它背后的真實(shí)故事。這個(gè)練習的目的就是利用對 "Hello World" 的生存周期的分析來(lái)幫助你揭開(kāi)它神秘的面紗。
讓我們先看一下 Hello World 的源代碼:
1. | #include <stdio.h> |
第 1 行指示編譯器去包含調用 C 語(yǔ)言庫(libc)函數 printf 所需要的頭文件聲明。
第 3 行聲明了 main 函數,看起來(lái)好像是我們程序的入口點(diǎn)(在后面我們將看到,其實(shí)它不是)。它被聲明為一個(gè)不帶參數(我們這里不準備理會(huì )命令行參數)且會(huì )返回一個(gè)整型值給它的父進(jìn)程(在我們的例子里是 shell )的函數。順便說(shuō)一下,shell 在調用程序時(shí)對其返回值有個(gè)約定:子進(jìn)程在結束時(shí)必須返回一個(gè) 8 比特數來(lái)代表它的狀態(tài):0 代表正常結束,0~128 中間的數代表進(jìn)程檢測到的異常終止,大于 128 的數值代表由信號引起的終止。
從第 4 行到第 8 行構成了 main 函數的實(shí)現,即調用 C 語(yǔ)言庫函數 printf 輸出 "Hello World!\n" 字符串,在結束時(shí)返回 0 給它的父進(jìn)程。
簡(jiǎn)單,非常簡(jiǎn)單!
現在讓我們看看 "Hello World" 的編譯過(guò)程。在下面的討論中,我們將使用非常流行的 GNU 編譯器( gcc )和它的二進(jìn)制輔助工具( binutils )。我們可以使用下面命令來(lái)編譯我們的程序:
這樣就生成了目標文件 hello.o,來(lái)看一下它的屬性:
給出的信息告訴我們 hello.o 是個(gè)可重定位的目標文件(relocatable),為 IA-32(Intel Architecture 32) 平臺編譯(在這個(gè)練習中我使用了一臺標準 PC),保存為 ELF(Executable and Linking Format) 文件格式,并且包含著(zhù)符號表(not stripped)。
順便:
這告訴我們 hello.o 有 5 個(gè)段:
(譯者注:在下面的解釋中讀者要分清什么是 ELF 文件中的段(section)和進(jìn)程中的段(segment)。比如 .text 是 ELF 文件中的段名,當程序被加載到內存中之后,.text 段構成了程序的可執行代碼段。其實(shí)有時(shí)候在中文環(huán)境下也稱(chēng) .text 段為代碼段,要根據上下文分清它代表的意思。)
它也給我們展示了一個(gè)符號表( symbol table ),其中符號 main 的地址被設置為 00000000,符號 puts 未定義。此外,重定位表(relocation table)告訴我們怎么樣去在 .text 段中去重定位對其它段內容的引用。第一個(gè)可重定位的符號對應于 .rodata 中的 "Hello World!\n" 字符串,第二個(gè)可重定位符號 puts,代表了使用 printf 所產(chǎn)生的對一個(gè) libc 庫函數的調用。為了更好的理解 hello.o 的內容,讓我們來(lái)看看它的匯編代碼:
1. | # gcc -Os -S hello.c -o - |
從匯編代碼中我們可以清楚的看到 ELF 段標記是怎么來(lái)的。比如,.text 段是 32 位對齊的(第 7 行)。它也揭示了 .comment 段是從哪兒來(lái)的(第 20 行)。因為我們使用 printf 來(lái)打印一個(gè)字符串,并且我們要求我們優(yōu)秀的編譯器對生成的代碼進(jìn)行優(yōu)化( -Os ),編譯器用(應該更快的) puts 調用來(lái)取代 printf 調用。不幸的是,我們后面將會(huì )看到我們的 libc 庫的實(shí)現會(huì )使這種優(yōu)化變得沒(méi)什么用。
那么這段匯編代碼會(huì )生成什么代碼呢?沒(méi)什么意外之處:使用標志字符串地址的標號 .LC0 作為參數的一個(gè)對 puts 庫函數的簡(jiǎn)單調用。
下面讓我們看一下 hello.o 轉化為可執行文件的過(guò)程??赡軙?huì )有人覺(jué)得用下面的命令就可以了:
不過(guò),那個(gè)警告是什么意思?嘗試運行一下!
是的,hello 程序不工作。讓我們回到那個(gè)警告:它告訴我們連接器(ld)不能找到我們程序的入口點(diǎn) _start。不過(guò) main 難道不是入口點(diǎn)嗎?簡(jiǎn)短的來(lái)說(shuō),從程序員的角度來(lái)看 main 可能是一個(gè) C 程序的入口點(diǎn)。但實(shí)際上,在調用 main 之前,一個(gè)進(jìn)程已經(jīng)執行了一大堆代碼來(lái)“為可執行程序清理房間”。我們通常情況下從編譯器或者操作系統提供者那里得到這些外殼程序(surrounding code,譯者注:比如 CRT)。
下面讓我們試試這個(gè)命令:
現在我們可以得到一個(gè)真正的可執行文件了。使用靜態(tài)連接(static linking)有兩個(gè)原因:一,在這里我不想深入去討論動(dòng)態(tài)連接庫(dynamic libraries)是怎么工作的;二,我想讓你看看在我們庫(libc 和 libgcc)的實(shí)現中,有多少不必要的代碼將被添加到 "Hello World" 程序中。試一下這個(gè)命令:
你也可以嘗試 "nm hello" 和 "objdump -d hello" 命令來(lái)得到什么東西被連接到了可執行文件中。
想了解動(dòng)態(tài)連接的更多內容,請參考 Program Library HOWTO。
在一個(gè)遵循 POSIX(Portable Operating System Interface) 標準的操作系統(OS)上,裝載一個(gè)程序是由父進(jìn)程發(fā)起 fork 系統調用來(lái)復制自己,然后剛生成的子進(jìn)程發(fā)起 execve 系統調用來(lái)裝載和執行要運行的程序組成的。無(wú)論何時(shí)你在 shell 中敲入一個(gè)外部命令,這個(gè)過(guò)程都會(huì )被實(shí)施。你可以使用 truss 或者 strace 命令來(lái)驗證一下:
除了 execve 系統調用,上面的輸出展示了打印函數 puts 中的 write 系統調用,和用 main 的返回值(0)作為參數的 exit 系統調用。
為了解 execve 實(shí)施的裝載過(guò)程背后的細節,讓我們看一下我們的 ELF 可執行文件:
輸出顯示了 hello 的整體結構。第一個(gè)程序頭對應于進(jìn)程的代碼段,它將從文件偏移 0x000000 處被裝載到映射到進(jìn)程地址空間的 0x08048000 地址的物理內存中(虛擬內存機制)。代碼段共有 0x55dac 字節大小而且必須按頁(yè)對齊(0x1000, page-aligned)。這個(gè)段將包含我們前面討論過(guò)的 ELF 文件中的 .text 段和 .rodata 段的內容,再加上在連接過(guò)程中生成的附加的段。正如我們預期,它被標志為:只讀(R)和可執行(X),不過(guò)禁止寫(xiě)(W)。
第二個(gè)程序頭對應于進(jìn)程的數據段。裝載這個(gè)段到內存的方式和上面所提到的一樣。不過(guò),需要注意的是,這個(gè)段占用的文件大小是 0x01df4 字節,而在內存中它占用了 0x03240 字節。這個(gè)差異主要歸功于 .bss 段,它在內存中只需要被賦 0,所以不用在文件中出現(譯者注:文件中只需要知道它的起始地址和大小即可)。進(jìn)程的數據段仍然需要按頁(yè)對齊(0x1000, page-aligned)并且將包含 .data 和 .bss 段。它將被標識為可讀寫(xiě)(RW)。第三個(gè)程序頭是連接階段產(chǎn)生的,和這里的討論沒(méi)有什么關(guān)系。
如果你有一個(gè) proc 文件系統,當你得到 "Hello World" 時(shí)停止進(jìn)程(提示: gdb,譯者注:用 gdb 設置斷點(diǎn)),你可以用下面的命令檢查一下是不是如上所說(shuō):
第一個(gè)映射的區域是這個(gè)進(jìn)程的代碼段,第二個(gè)和第三個(gè)構成了數據段(data + bss + heap),第四個(gè)區域在 ELF 文件中沒(méi)有對應的內容,是程序棧。更多和正在運行的 hello 進(jìn)程有關(guān)的信息可以用 GNU 程序:time, ps 和 /proc/pid/stat 得到。
當 "Hello World" 程序運行到 main 函數中的 return 語(yǔ)句時(shí),它向我們在段連接部分討論過(guò)的外殼函數傳入了一個(gè)參數。這些函數中的某一個(gè)發(fā)起 exit 系統調用。這個(gè) exit 系統調用將返回值轉交給被 wait 系統調用阻塞的父進(jìn)程。此外,它還要對終止的進(jìn)程進(jìn)行清理,將其占用的資源還給操作系統。用下面命令我們可以追蹤到部分過(guò)程:
這個(gè)練習的目的是讓計算機專(zhuān)業(yè)的新生注意這樣一個(gè)事實(shí):一個(gè) Java Applet 的運行并不是像魔法一樣(無(wú)中生有的),即使在最簡(jiǎn)單的程序背后也有很多系統軟件的支撐。如果您覺(jué)得這篇文章有用并且想提供建議來(lái)改進(jìn)它,請 編譯器內部的函數庫,比如 libgcc,是用來(lái)實(shí)現目標平臺沒(méi)有直接實(shí)現的語(yǔ)言元素。舉個(gè)例子,C 語(yǔ)言的模運算符 ("%") 在某個(gè)平臺上可能無(wú)法映射到一條匯編指令??赡苡靡粋€(gè)函數調用實(shí)現比讓編譯器為其生成內嵌代碼更受歡迎(特別是對一些內存受限的計算機來(lái)說(shuō),比如微控制 器)。很多其它的基本運算,包括除法、乘法、字符串處理(比如 memory copy)一般都會(huì )在這類(lèi)函數庫中實(shí)現。
聯(lián)系客服