跳到主要內容

中文試譯:How statically linked programs run on Linux

原文作者:Eli Bendersky
原文連結: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 做了什麼?將一些對於關心的範圍而言過於特殊的細節濾掉後,以下就是該函式幫一個靜態連結的程式作的事情:
  1. 找出位於 stack 上的環境變數。
  2. 如果需要,準備好 auxiliary vector
  3. 將 thread-specific 功能初始化(pthread, TLS, 等等)
  4. 進行一些安全相關的紀錄工作(這並不是單一的步驟,而是散布在整個函式)。
  5. 將 libc 初始化。
  6. 透過傳入的指標喚起初始函式(init)。
  7. 將程式終結函式(fini)註冊,會在程式中止時執行之。
  8. 喚起 main(argc, argv, envp)
  9. 喚起 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 程式庫插了一腳。在之後的文章,我將會說明更複雜的情況,也就是動態連結的程式,那時,另一個角色還會加入這場遊戲 - 動態連結器。不要轉台喔!

http://eli.thegreenplace.net/wp-content/uploads/hline.jpg
[1]如果你夠勇敢的話,或可直接閱讀代碼。
[2]注意,因為我們靜態連結 C runtime 進 c_rc,它非常龐大(我的 64-bit Ubuntu 機器上有 800KB)。因此我們不能只是簡單地觀察整個 disassembly ,必須使用 grep 技巧。
[3]對於 glibc 2.16 是無誤的。
[4]注意,在建構函式中執行 printf 。這樣安全嗎?如果你觀察 __libc_start_main 的初始流程,你會發現 C 程式庫是在使用者定義的建構函式前被喚起,安啦!很安全。

留言

這個網誌中的熱門文章

誰在呼叫我?不同的backtrace實作說明好文章

今天下班前一個同事問到:如何在Linux kernel的function中主動印出backtrace以方便除錯? 寫過kernel module的人都知道,基本上就是用dump_stack()之類的function就可以作到了。但是dump_stack()的功能是如何作到的呢?概念上其實並不難,慣用手法就是先觀察stack在function call時的變化(一般OS或計組教科書都有很好的說明,如果不想翻書,可以參考 這篇 ),然後將對應的return address一層一層找出來後,再將對應的function名稱印出即可(透過執行檔中的section去讀取函式名稱即可,所以要將KALLSYM選項打開)。在userspace的實作可參考Jserv介紹過的 whocallme 或對岸好手實作過的 backtrace() ,都是針對x86架構的很好說明文章。 不過從前面兩篇文章可以知道,只要知道編譯器的calling convention,就可以實作出backtrace,所以是否GCC有提供現成的機制呢?Yes, that is what __builtin_return_address() for!! 可以參考這篇 文章 。該篇文章還提到了其他可以拿來實作功能更齊全的backtrace的 程式庫 ,在了解了運作原理後,用那些東西還蠻方便的。 OK,那Linux kernel是怎麼做的呢?就是用頭兩篇文章的方式啦~ 每個不同的CPU架構各自手工實作一份dump_stack()。 為啥不用GCC的機制?畢竟...嗯,我猜想,除了backtrace以外,開發者還會想看其他register的值,還有一些有的沒的,所以光是GCC提供的介面是很難印出全部所要的資訊,與其用半套GCC的機制,不如全都自己來~ arm的實作 大致上長這樣,可以看到基本上就只是透過迭代fp, lr, pc來完成: 352 void unwind_backtrace (struct pt_regs * regs , struct task_struct *tsk) 353 { 354 struct stackframe frame ; 355 register unsigned long current_sp asm ( "

淺讀Linux root file system初始化流程

在Unix的世界中,file system佔據一個極重要的抽象化地位。其中,/ 所代表的rootfs更是所有後續新增file system所必須依賴前提條件。以Linux為例,黑客 Jserv 就曾經詳細說明過 initramfs的背後設計考量 。本篇文章不再重複背景知識,主要將追蹤rootfs初始化的流程作點整理,免得自己日後忘記。 :-) file system與特定CPU架構無關,所以我觀察的起點從init/main.c的start_kernel()開始,這是Linux作完基本CPU初始化後首先跳進的C function(我閱讀的版本為 3.12 )。跟root file system有關的流程羅列如下: start_kernel()         -> vfs_caches_init_early()         -> vfs_caches_init()                 -> mnt_init()                         -> init_rootfs()                         -> init_mount_tree()         -> rest_init()                 -> kernel_thread(kernel_init,...) 其中比較重要的是mnt_int()中的init_rootfs()與init_mout_tree()。init_rootfs()實作如下: int __init init_rootfs(void) {         int err = register_filesystem(&rootfs_fs_type);         if (err)                 return err;         if (IS_ENABLED(CONFIG_TMPFS) && !saved_root_name[0] &&                 (!root_fs_names || strstr(root_fs_names, "tmpfs"))) {          

kernel panic之後怎麼辦?

今天同事在處理一個陌生的模組時遇到kernel panic,Linux印出了backtrace,同事大致上可以知道是在哪個function中,但該function的長度頗長,短時間無法定位在哪個位置,在這種情況下,要如何收斂除錯範圍呢?更糟的是,由於加入printk會改變模組行為,所以printk基本上無法拿來檢查參數的值是否正常。 一般這樣的問題會backtrace的資訊來著手。從這個資訊我們可以知道在function中的多少offset發生錯誤,以x86為例(從 LDD3 借來的例子): Unable to handle kernel NULL pointer dereference at virtual address 00000000 printing eip: d083a064 Oops: 0002 [#1] SMP CPU:    0 EIP:    0060:[<d083a064>]    Not tainted EFLAGS: 00010246   (2.6.6) EIP is at faulty_write+0x4/0x10 [faulty] eax: 00000000   ebx: 00000000   ecx: 00000000   edx: 00000000 esi: cf8b2460   edi: cf8b2480   ebp: 00000005   esp: c31c5f74 ds: 007b   es: 007b   ss: 0068 Process bash (pid: 2086, threadinfo=c31c4000 task=cfa0a6c0) Stack: c0150558 cf8b2460 080e9408 00000005 cf8b2480 00000000 cf8b2460 cf8b2460        fffffff7 080e9408 c31c4000 c0150682 cf8b2460 080e9408 00000005 cf8b2480        00000000 00000001 00000005 c0103f8f 00000001 080e9408 00000005 00000005 Call Trace:  [<c0150558>] vfs