(轉載)Hello World 背後的真實故事

Hello World 背後的真實故事
(至少是大部分故事)

* 原作者:Antônio Augusto M. Fröhlich
* 原文鏈接http://www.lisha.ufsc.br/~guto/teaching/os/exercise/hello.html

* 譯者:楊文博 <http://solrex.cn>
* 譯文鏈接http://share.solrex.cn/os/hello_cn.html
* 最後更新時間: 2008 年 2 月 28 日

我們計算機科學專業的大多數學生至少都接觸過一回著名的 “Hello World” 程序。相比一個典型的應用程序——幾乎總是有一個帶網絡連接的圖形用戶界面,”Hello World” 程序看起來只是一段很簡單無趣的代碼。不過,許多計算機科學專業的學生其實並不瞭解它背後的真實故事。這個練習的目的就是利用對 “Hello World” 的生存週期的分析來幫助你揭開它神祕的面紗。

源代碼

讓我們先看一下 Hello World 的源代碼:

1. #include <stdio.h>
2. int main(void)
3. {
4. printf(”Hello World!/n”);
5. return 0;
6.
7. }

第 1 行指示編譯器去包含調用 C 語言庫(libc)函數 printf 所需要的頭文件聲明。

第 3 行聲明瞭 main 函數,看起來好像是我們程序的入口點(在後面我們將看到,其實它不是)。它被聲明爲一個不帶參數(我們這裏不準備理會命令行參數)且會返回一個整型值給它的父進程(在我們的例子裏是 shell)的函數。順便說一下,shell 在調用程序時對其返回值有個約定:子進程在結束時必須返回一個 8 比特數來代表它的狀態:0 代表正常結束,0~128 中間的數代表進程檢測到的異常終止,大於 128 的數值代表由信號引起的終止。

從第 4 行到第 8 行構成了 main 函數的實現,即調用 C 語言庫函數 printf 輸出 “Hello World!/n” 字符串,在結束時返回 0 給它的父進程。

簡單,非常簡單!

編譯

現在讓我們看看 “Hello World” 的編譯過程。在下面的討論中,我們將使用非常流行的 GNU 編譯器(gcc)和它的二進制輔助工具(binutils)。我們可以使用下面命令來編譯我們的程序:

# gcc -Os -c hello.c

這樣就生成了目標文件 hello.o,來看一下它的屬性:

# file hello.o
hello.o: ELF 32-bit LSB relocatable, Intel 80386, version 1 (SYSV), not stripped

給出的信息告訴我們 hello.o 是個可重定位的目標文件(relocatable),爲 IA-32(Intel Architecture 32) 平臺編譯(在這個練習中我使用了一臺標準 PC),保存爲 ELF(Executable and Linking Format) 文件格式,並且包含着符號表(not stripped)。

順便:

# objdump -hrt hello.o
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 個段:

(譯者注:在下面的解釋中讀者要分清什麼是 ELF 文件中的段(section)和進程中的段(segment)。比如 .text 是 ELF 文件中的段名,當程序被加載到內存中之後,.text 段構成了程序的可執行代碼段。其實有時候在中文環境下也稱 .text 段爲代碼段,要根據上下文分清它代表的意思。)

1. .text: 這是 “Hello World” 編譯生成的可執行代碼,也就是說這個程序對應的 IA-32 指令序列。.text 段將被加載程序用來初始化進程的代碼段。

2. .data:”Hello World” 的程序裏既沒有初始化的全局變量也沒有初始化的靜態局部變量,所以這個段是空的。否則,這個段應該包含變量的初始值,運行前被裝載到進程的數據段。

3. .bss: “Hello World” 也沒有任何未初始化的全局或者局部變量,所以這個段也是空的。否則,這個段指示的是,在進程的數據段中除了上文的 .data 段內容,還有多少字節應該被分配並賦 0。

4. .rodata: 這個段包含着被標記爲只讀 “Hello World!/n” 字符串。很多操作系統並不支持進程(運行的程序)有隻讀數據段,所以 .rodata 段的內容既可以被裝載到進程的代碼段(因爲它是隻讀的),也可以被裝載到進程的數據段(因爲它是數據)。因爲編譯器並不知道你的操作系統所使用的策略,所以它額外生成了一個 ELF 文件段。

5. .comment:這個段包含着 33 字節的註釋。因爲我們在代碼中沒有寫任何註釋,所以我們無法追溯它的來源。不過我們將很快在下面看到它是怎麼來的。

它也給我們展示了一個符號表(symbol table),其中符號 main 的地址被設置爲 00000000,符號 puts 未定義。此外,重定位表(relocation table)告訴我們怎麼樣去在 .text 段中去重定位對其它段內容的引用。第一個可重定位的符號對應於 .rodata 中的 “Hello World!/n” 字符串,第二個可重定位符號 puts,代表了使用 printf 所產生的對一個 libc 庫函數的調用。爲了更好的理解 hello.o 的內容,讓我們來看看它的彙編代碼:

1. # gcc -Os -S hello.c -o -
2. .file “hello.c”
3. .section .rodata.str1.1,”aMS”,@progbits,1
4. .LC0:
5. .string “Hello World!”
6. .text
7. .align 2
8. .globl main
9. .type main,@function
10. main:
11. pushl %ebp
12. movl %esp, %ebp
13. pushl $.LC0
14. call puts
15. xorl %eax, %eax
16. leave
17. ret
18. .Lfe1:
19. .size n,.Lfe1-n
20. .ident “GCC: (GNU) 3.2 20020903 (Red Hat Linux 8.0 3.2-7)”

從彙編代碼中我們可以清楚的看到 ELF 段標記是怎麼來的。比如,.text 段是 32 位對齊的(第 7 行)。它也揭示了 .comment 段是從哪兒來的(第 20 行)。因爲我們使用 printf 來打印一個字符串,並且我們要求我們優秀的編譯器對生成的代碼進行優化(-Os),編譯器用(應該更快的) puts 調用來取代 printf 調用。不幸的是,我們後面將會看到我們的 libc 庫的實現會使這種優化變得沒什麼用。

那麼這段彙編代碼會生成什麼代碼呢?沒什麼意外之處:使用標誌字符串地址的標號 .LCO 作爲參數的一個對 puts 庫函數的簡單調用。

連接

下面讓我們看一下 hello.o 轉化爲可執行文件的過程。可能會有人覺得用下面的命令就可以了:

# ld -o hello hello.o -lc
ld: warning: cannot find entry symbol _start; defaulting to 08048184

不過,那個警告是什麼意思?嘗試運行一下!

是的,hello 程序不工作。讓我們回到那個警告:它告訴我們連接器(ld)不能找到我們程序的入口點 _start。不過 main 難道不是入口點嗎?簡短的來說,從程序員的角度來看 main 可能是一個 C 程序的入口點。但實際上,在調用 main 之前,一個進程已經執行了一大堆代碼來“爲可執行程序清理房間”。我們通常情況下從編譯器或者操作系統提供者那裏得到這些外殼程序(surrounding code,譯者注:比如 CRT)。

下面讓我們試試這個命令:

# 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

現在我們可以得到一個真正的可執行文件了。使用靜態連接(static linking)有兩個原因:一,在這裏我不想深入去討論動態連接庫(dynamic libraries)是怎麼工作的;二,我想讓你看看在我們庫(libc 和 libgcc)的實現中,有多少不必要的代碼將被添加到 “Hello World” 程序中。試一下這個命令:

# find hello.c hello.o hello -printf “%f/t%s/n”
hello.c 84
hello.o 788
hello 445506

你也可以嘗試 “nm hello” 和 “objdump -d hello” 命令來得到什麼東西被連接到了可執行文件中。

想了解動態連接的更多內容,請參考 Program Library HOWTO

裝載和運行

在一個遵循 POSIX(Portable Operating System Interface) 標準的操作系統(OS)上,裝載一個程序是由父進程發起 fork 系統調用來複制自己,然後剛生成的子進程發起 execve 系統調用來裝載和執行要運行的程序組成的。無論何時你在 shell 中敲入一個外部命令,這個過程都會被實施。你可以使用 truss 或者 trace 命令來驗證一下:

# strace -i hello > /dev/null
[????????] execve(”./hello”, [”hello”], [/* 46 vars */]) = 0

[08053d44] write(1, “Hello World!/n”, 13) = 13

[0804e7ad] _exit(0) = ?

除了 execve 系統調用,上面的輸出展示了打印函數 puts 中的 write 系統調用,和用 main 的返回值(0)作爲參數的 exit 系統調用。

爲了解 execve 實施的裝載過程背後的細節,讓我們看一下我們的 ELF 可執行文件:

# readelf -l hello
Elf file type is EXEC (Executable file)
Entry point 0×80480e0
There are 3 program headers, starting at offset 52

Program Headers:
Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align
LOAD 0×000000 0×08048000 0×08048000 0×55dac 0×55dac R E 0×1000
LOAD 0×055dc0 0×0809edc0 0×0809edc0 0×01df4 0×03240 RW 0×1000
NOTE 0×000094 0×08048094 0×08048094 0×00020 0×00020 R 0×4

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 的整體結構。第一個程序頭對應於進程的代碼段,它將從文件偏移 0×000000 處被裝載到映射到進程地址空間的 0×08048000 地址的物理內存中(虛擬內存機制)。代碼段共有 0×55dac 字節大小而且必須按頁對齊(0×1000, page-aligned)。這個段將包含我們前面討論過的 ELF 文件中的 .text 段和 .rodata 段的內容,再加上在連接過程中生成的附加的段。正如我們預期,它被標誌爲:只讀(R)和可執行(X),不過禁止寫(W)。

第二個程序頭對應於進程的數據段。裝載這個段到內存的方式和上面所提到的一樣。不過,需要注意的是,這個段佔用的文件大小是 0×01df4 字節,而在內存中它佔用了 0×03240 字節。這個差異主要歸功於 .bss 段,它在內存中只需要被賦 0,所以不用在文件中出現(譯者注:文件中只需要知道它的起始地址和大小即可)。進程的數據段仍然需要按頁對齊(0×1000, page-aligned)並且將包含 .data 和 .bss 段。它將被標識爲可讀寫(RW)。第三個程序頭是連接階段產生的,和這裏的討論沒有什麼關係。

如果你有一個 proc 文件系統,當你得到 “Hello World” 時停止進程(提示: gdb,譯者注:用 gdb 設置斷點),你可以用下面的命令檢查一下是不是如上所說:

# 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

第一個映射的區域是這個進程的代碼段,第二個和第三個構成了數據段(data + bss + heap),第四個區域在 ELF 文件中沒有對應的內容,是程序棧。更多和正在運行的 hello 進程有關的信息可以用 GNU 程序:time, ps 和 /proc/pid/stat 得到。

程序終止

當 “Hello World” 程序運行到 main 函數中的 return 語句時,它向我們在段連接部分討論過的外殼函數傳入了一個參數。這些函數中的某一個發起 exit 系統調用。這個 exit 系統調用將返回值轉交給被 wait 系統調用阻塞的父進程。此外,它還要對終止的進程進行清理,將其佔用的資源還給操作系統。用下面命令我們可以追蹤到部分過程:

# 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)

結束

這個練習的目的是讓計算機專業的新生注意這樣一個事實:一個 Java Applet 的運行並不是像魔法一樣(無中生有的),即使在最簡單的程序背後也有很多系統軟件的支撐。如果您覺得這篇文章有用並且想提供建議來改進它,請發電子郵件給我

常見問題

這一節是爲了回答學生們的常見問題。

* 什麼是 “libgcc”? 爲什麼它在連接的時候被包含進來?
編譯器內部的函數庫,比如 libgcc,是用來實現目標平臺沒有直接實現的語言元素。舉個例子,C 語言的模運算符 (”%”) 在某個平臺上可能無法映射到一條彙編指令。可能用一個函數調用實現比讓編譯器爲其生成內嵌代碼更受歡迎(特別是對一些內存受限的計算機來說,比如微控制器)。很多其它的基本運算,包括除法、乘法、字符串處理(比如 memory copy)一般都會在這類函數庫中實現。

 
發佈了15 篇原創文章 · 獲贊 1 · 訪問量 4萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章