原文作者:Eli Bendersky
原文連結:http://eli.thegreenplace.net/2012/08/13/how-statically-linked-programs-run-on-linux/
原文連結:http://eli.thegreenplace.net/2012/08/13/how-statically-linked-programs-run-on-linux/
在這篇文章中,我想要探討當一個靜態連結的程式在 Linux 上執行時會發生什麼事情。靜態連結指的是一個程式執行時不需要額外的共享物件,即使是無所不在的 libc 也不需要。實際上,我們在 Linux 遇到的絕大多數程式並不是靜態連結的,需要一個或多個共享物件才能夠執行。然而,這類程式的執行流程牽涉較多細節,所以我想先說明靜態連結的程式。這可以為理解整體流程打下好的基礎,並允許我用較少的細節去討論主要的機制。我在未來會有另一篇文章來將動態連結的行程講述清楚。
Linux 核心
程式的執行起始於 Linux 核心中。為了運行一個程式,一個行程會呼叫 exec 系列函式其中之一。這系列的函式都非常相似,差別只在於傳入的程式的引數與環境變數。這些函數最後都會發出 sys_execve 系統呼叫至 Linux 核心。
sys_execve 為了新程式的執行作了許多準備。要全部解釋完畢會超出這篇文章的目的 - 一本好的核心內部機制書籍對於理解這類細節會很有幫助[1]。我將會專住在對目前的討論有用的部份。
核心必須從硬碟讀取程式的可執行檔案至記憶體中並準備其執行。核心知道如何處理許多種類的二進位格式並且試著以相應的處理方式開啟檔案(這在 fs/exec.c 的 search_binary_handler 中發生)。不過,我們此處只先對 ELF 感興趣,這個格式的對應動作在函式 load_elf_binary 中(位於 fs/binfmt_elf.c)。
核心讀取程式的 ELF header,然後檢查 PT_INTERP segment 以確認是否有某個解譯器被指定。這邊就是靜態連結與動態連結開始有差異的地方。對於靜態連結的程式來說,沒有 PT_INTERP segment 。也是此篇文章要涵蓋的範圍。
核心接著根據 ELF program header 中的資訊將程式的 segments mapping 進記憶體中。最後,核心會直接修改 IP 暫存器的值為 ELF header 中的 entry address (e_entry)來執行接續動作。引數則是透過 stack 傳入程式中(負責此部份的程式在 create_elf_tables 中)。以下就是在 x64 中,當程式被喚起時的 stack layout:
在 stack 的頂端是 argc,其值就是命令列上的引數個數。它是在所有的命令列引數之後(每個都是 char *)。之後,則是環境變數列於其後(也都是 char *),以一個 zero pointer 結尾。善於觀察的讀者會發現,這樣的引數排列並不是一般在 main 中所看到的。這是因為 main 並不是程式的真實進入點,我們會在稍候說明這點。
程式的進入點
Linux 核心讀取了 ELF header 裡的程式進入位址。現在就讓我們探索一下這個位址吧。
除非你作了些非常巧妙的事情,否則最終的程式 binary image 會由系統 linker - ld 所產生。 預設情況下,ld 在連結進程式的 object 檔案中搜索一個特殊的符號 _start,然後將進入點設定為該符號的位址。這可藉由一個以組合語言寫成的例子來簡單說明(以下是 NASM 的語法):
section .text
; The _start symbol must be declared for the linker (ld)
global _start
_start:
; Execute sys_exit call. Argument: status -> ebx
mov eax, 1
mov ebx, 42
int 0x80
這是一個非常基本的程式,僅僅傳回 42 。注意 _start 符號有被定義。我們來建構此程式,然後觀察其 ELF header 以及 disassembly:
$ nasm -f elf64 nasm_rc.asm -o nasm_rc.o
$ ld -o nasm_rc64 nasm_rc.o
$ readelf -h nasm_rc64
ELF Header:
Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
Class: ELF64
...
Entry point address: 0x400080
...
$ objdump -d nasm_rc64
nasm_rc64: file format elf64-x86-64
Disassembly of section .text:
0000000000400080 <_start>:
400080: b8 01 00 00 00 mov $0x1,%eax
400085: bb 2a 00 00 00 mov $0x2a,%ebx
40008a: cd 80 int $0x80
如你所見,在 ELF header 中的進入點被設為 0x400080,也就是 _start 的位址。
ld 會以 _start 作為預設值,但是這個行為可以透過命令列選項 --entry 或在 linker script 中以 ENTRY 命令進行修改。
在 C code 裡的進入點
我們通常不會以 assembly 來寫程式。對於 C/C++來說,情況有點不同,因為對我們而言熟悉的進入點是 main 函式,並不是 _start 符號。現在是解釋這兩個東西是如何相關聯的時候了。
讓我們從以下這個簡單的 C 程式開始,其功能如同前述的 assembly:
int main() {
return 42;
}
我會將這段程式編譯為 object 檔,然後透過 ld 去連結,如同在 assembly 作過得一樣:
$ gcc -c c_rc.c
$ ld -o c_rc c_rc.o
ld: warning: cannot find entry symbol _start; defaulting to 00000000004000b0
哇喔! ld 無法找到進入點。它試著用預設符號猜測進入點,但不成功 - 程式運行時會 segfault。ld 顯然需要某些額外的 object 檔以找到進入點。但是這些檔案在哪呢?幸運的是,我們可以透過 gcc 來找出它們。gcc 可以表現的像是一個完整的編譯驅動器,當需要時就會喚起 ld。我們現在就用 gcc 來將我們的 object 檔連結成完整的程式。注意 -static 旗標迫使靜態連結 C 程式庫以及 gcc runtime 程式庫:
$ gcc -o c_rc -static c_rc.o
$ c_rc; echo $?
42
成功了。到底 gcc 是怎麼讓連結正確無誤的呢?我們可以傳進 -Wl,-verbose 旗標給 gcc ,這可以讓 object 檔的清單詳細列出。透過這個動作,我們可以發現額外的檔案,像是 crtl.o 以及整個 libc.a 靜態程式庫(這個程式庫包含了一個 libc-start.o 的檔案)。C 程式並不是憑空蹦出來的。要能夠運行,它需要程式庫的支援,像是 gcc runtime 以及 libc。
既然它被明顯地連結並且運行正確,這個由 gcc 建構出來的程式應該在對的地方要有個 _start 符號。我們來檢查看看[2]:
$ readelf -h c_rc
ELF Header:
Magic: 7f 45 4c 46 02 01 01 03 00 00 00 00 00 00 00 00
Class: ELF64
...
Entry point address: 0x4003c0
...
$ objdump -d c_rc | grep -A15 "<_start"
00000000004003c0 <_start>:
4003c0: 31 ed xor %ebp,%ebp
4003c2: 49 89 d1 mov %rdx,%r9
4003c5: 5e pop %rsi
4003c6: 48 89 e2 mov %rsp,%rdx
4003c9: 48 83 e4 f0 and $0xfffffffffffffff0,%rsp
4003cd: 50 push %rax
4003ce: 54 push %rsp
4003cf: 49 c7 c0 20 0f 40 00 mov $0x400f20,%r8
4003d6: 48 c7 c1 90 0e 40 00 mov $0x400e90,%rcx
4003dd: 48 c7 c7 d4 04 40 00 mov $0x4004d4,%rdi
4003e4: e8 f7 00 00 00 callq 4004e0 <__libc_start_main>
4003e9: f4 hlt
4003ea: 90 nop
4003eb: 90 nop
確實,0x4003c0 就是 _start 的位址並且就是程式的進入點。然而,位於 _start 的程式什麼呢?它是從哪邊來的?它的意義是什麼?
解碼 C 程式的啟動程序
上面的啟動程式是從 glibc 來的 - GNU C 程式庫,對於 x64 ELF 來說,就是 sysdeps/x86_64/start.S [3]。它的目標是要提供函式 _libc_start_main 的參數並且喚起此函式。此函式也是 glibc 的一部分,位於 csu/libc-start.c。是它的 signature ,經過一點編排讓它好看一點,並且加入更多的註解以解釋每個引數的意思:
int __libc_start_main(
/* Pointer to the program's main function */
(int (*main) (int, char**, char**),
/* argc and argv */
int argc, char **argv,
/* Pointers to initialization and finalization functions */
__typeof (main) init, void (*fini) (void),
/* Finalization function for the dynamic linker */
void (*rtld_fini) (void),
/* End of stack */
void* stack_end)
既然知道了 signature 並且有 AMD64 ABI 在我們手上,我們能夠解讀從 _start 傳進 _libc_start_main 的引數了:
main: rdi <-- $0x4004d4
argc: rsi <-- [RSP]
argv: rdx <-- [RSP + 0x8]
init: rcx <-- $0x400e90
fini: r8 <-- $0x400f20
rdld_fini: r9 <-- rdx on entry
stack_end: on stack <-- RSP
你也注意到了,stack 是以 16 bytes aligned 的方式排列,在 rsp 推入前,一些不必要的東西也被推進 stack 的頂端 (rax) 。這與 AMD64 ABI 相符合。也請注意到 hlt 指令在位址 0x4003e9 。這是一個安全保護,避免 __libc_start_main 不存在(我們即將看到,它應該存在)。hlt 不能在 user mode 執行,否則會觸發一個異常,並將行程中止。
觀察 disassembly,很容易就驗證 0x4004d4 就是 main,0x400e90 是 __libc_csu_init,並且 0x400f20 是 __libc_csu_fini。還有一個核心傳給 _start 的引數 - 一個使用在共享中的中止函式(在 rdx)。在此文章中,我們會忽略此中止函數。
C 程式庫的起始函式
現在我們已經知道呼叫的過程了,但是到底 __libc_start_main 做了什麼?將一些對於關心的範圍而言過於特殊的細節濾掉後,以下就是該函式幫一個靜態連結的程式作的事情:
- 找出位於 stack 上的環境變數。
- 如果需要,準備好 auxiliary vector。
- 將 thread-specific 功能初始化(pthread, TLS, 等等)
- 進行一些安全相關的紀錄工作(這並不是單一的步驟,而是散布在整個函式)。
- 將 libc 初始化。
- 透過傳入的指標喚起初始函式(init)。
- 將程式終結函式(fini)註冊,會在程式中止時執行之。
- 喚起 main(argc, argv, envp)
- 喚起 exit,其函式傳入值為 main的傳回值。
中途插花:init 與 fini
某些編程環境(主要是 C++,為了建構與解構 static 與全域物件)需要在 main 與之後運行特殊的的代碼。這是透過編譯器/連結器與 C 程式庫的共同合作來完成的。舉例來說, __libc_csu_init(如同你在前面看到的,這會在 main 之前被呼叫)在被連結器所插入的特殊代碼中呼叫。同樣的,__libc_csu_fini 與 __libc_csu_init 也是類似的狀況。
你也可以要求編譯器執行你註冊為建構與解構類型的函式比如說[4]:
#include <stdio.h>
int main() {
return 43;
}
__attribute__((constructor))
void myconstructor() {
printf("myconstructor\n");
}
myconstructor 將會在 main 之前執行。連結器將此函式的位址擺放在一個特殊的建構函式陣列中,位於 .ctors section。__libc_csu_init 會依序喚起在陣列中的函式。
結論
這篇文章說明了一個靜態連結程式如何被建立然後運行於 Linux 上。以我來看,這是一個非常值得研究的題目,因為它解釋了 Linux 生態系統中的幾個重要元件如何合作,以完成程式的執行程序。在這個例子中,Linux 核心、編譯器與連結器、還有 C 程式庫插了一腳。在之後的文章,我將會說明更複雜的情況,也就是動態連結的程式,那時,另一個角色還會加入這場遊戲 - 動態連結器。不要轉台喔!
[1] | 如果你夠勇敢的話,或可直接閱讀代碼。 |
[2] | 注意,因為我們靜態連結 C runtime 進 c_rc,它非常龐大(我的 64-bit Ubuntu 機器上有 800KB)。因此我們不能只是簡單地觀察整個 disassembly ,必須使用 grep 技巧。 |
[3] | 對於 glibc 2.16 是無誤的。 |
[4] | 注意,在建構函式中執行 printf 。這樣安全嗎?如果你觀察 __libc_start_main 的初始流程,你會發現 C 程式庫是在使用者定義的建構函式前被喚起,安啦!很安全。 |
留言
張貼留言