在上一篇文章中,我們介紹了程式如何從原始碼變成可執行檔。現在,我們將重點放在執行檔在 Linux 系統上如何被執行,即如何將這些機器碼載入記憶體並交由 CPU 執行。
本篇文章的架構如下:
本篇內容的範例執行檔為 hello,原始程式為上篇文章中的 hello.c。
編譯指令為 gcc -o hello hello.c,預設為動態連結。
在 Linux 系統中,可執行檔案通常以 ELF(Executable and Linkable Format)格式儲存。ELF 是檔案格式,除了可儲存可執行檔,還用於儲存組譯後的目標檔案(Object File)和共享函式庫(.so
檔案)。
補充 1:
電腦中的每個檔案本質上都是由一連串的二進位數字組成,不同類型的檔案(如 exe、pdf、jpg)會依照特定格式,將重要資訊存放在這串數字中的特定位置,使得用來打開它的軟體能夠知道如何解析和讀取這些檔案的內容。例如,當一個圖片編輯器試圖打開一個 jpg 檔案時,編輯器程式會根據這個格式知道圖片的寬度、高度以及顏色等內容。
補充 2:
許多檔案,如 pdf 檔的檔案儲存格式就是 PDF(Portable Document Format)。然而 Windows 中的可執行檔(exe)並非檔案格式的名稱,exe 檔是用一種叫做 PE (Portable Executable) 的檔案格式儲存。PE 這個檔案格式如同 ELF,除了能儲存執行檔還能儲存組譯後的目標檔案(Object File)和共享函式庫(在 Windows 中為 .dll)
ELF 這種檔案格式可以簡單分為四個區塊,分別為 ELF Header 、Program Headers、Sections、Section Headers。
難以理解檔案格式概念的讀者可以參考以下這張圖:
(Tiny ELF Files: Revisited in 2021)
當我們使用任何可以查看二進制檔案的工具(xxd、hd、HxD)開啟 ELF 格式的檔案後,可以看到一連串以十六進制表現的二進制檔案,可以看到圖中將 ELF 格式分成幾大區塊,這就是我們接下來要介紹的 ELF 格式內容。
註:實際上用 gcc 編譯上篇文章的 hello.c 產成出的執行檔內容跟上圖有些許差異
移除掉二進制內容,僅留下區塊名稱後,整個 ELF 檔架構如下。
ELF Header 包含檔案基本資訊,如檔案類型、目標架構、進入點位址等。此外,還記錄了 Program Headers 和 Section Headers 的位置及大小。
我們可以使用以下指令來查看 hello
的 ELF Header 中較為重要的內容readelf -h hello
可以注意到它告訴我們 Program Headers 的位置從檔案中的 64 bytes 開始(即 0x40
),而 Section Headers 的位置則是從檔案中的 14056 bytes 開始(即 0x3690
),以及在紅框的最後一行可以看到 Entry point address 為 0x1050
,這表示程式在載入到記憶體後會跳轉到此位址開始執行。
補充 1:前面有提到 ELF 格式不只能儲存可執行檔,檔案基本資訊中的 Type 就是用來辨認此份檔案是是什麼類別。然而我們會發現 hello 明明是執行檔但卻不是顯示 EXEC 而是顯示代表共享函式庫的 DYN。這是因為由於 gcc 會預設開啟一種程式保護機制,為了達到此保護機制,它將類型設為 DYN,並且在後面補充他是某種執行檔(Position-Independent Executable file)。可以參考此份回答
補充 2:關於此保護機制與 Position-Independent 的意思會在後續的文章中提到
補充 3:真正的共享函式庫的 Type 欄位為
DYN (Shared object file)
Program Headers 主要用於讓載入器知道如何將此程式載入至記憶體,稍後我們將詳細說明。
ELF 檔案透過名為 Section 的形式將檔案內容區分,一個 ELF 裡有許多不等數量的 Sections。一個 ELF 有多少 Sections 會標示於 ELF Header 裡,以我們的 hello
為例,種共有 31 個 Sections。
我們透過以下命令查看 Section Headersreadelf -S hello
會區分不同的 Sections 主要是編譯時期方便連結器參照的,以下表格列出這些 Sections 的說明。
No | Sections | 說明 |
---|---|---|
1 | .interp | 程式載路器的路徑 |
2、3、4 | .note | 附註資訊 |
5、8、9 | .gun | Symbol Table 會用到的資訊 |
6 | .dynsym | 動態連結用符號表 |
7 | .dynstr | 動態連結用字串表 |
10、11 | .rela | 重定位資訊 |
12、20 | .init、.init_array | 主程式執行前會執行此段落 |
13、14、23、24 | .plt & .got | 用於處理動態連結(註1) |
15 | .text | 程式碼 |
16、21 | .fini、.fini_array | 程式執行後會執行此段落 |
17 | .rodata | 唯獨資料 |
18、19 | .eh_frame_hdr、.eh_frame | 例外處理的資訊 |
22 | .dynamic | 動態連結資訊 |
25 | .data | 已初始化資料 |
26 | .bss | 未初始化的全域和靜態變數 |
27 | .comment | 版本控制資訊 |
28 | .symtal | 符號表(Symbol Table) |
29 | .strtab | 字串表 |
30 | .shstrtab | 儲存 Section 名稱 |
其中,有幾個 Sections 可以直觀的從程式碼中看到,分別是 .text
、.data
、.bss
、.rodata
註1:plt 與 got 的內容牽涉到動態連結機制,將在後續文章中深入討論
當我們在 Shell 中輸入 ./hello
指令時,Shell 會幫我們調用作業系統的系統呼叫 execve()
來執行此程式。作業系統首先會讀取該檔案的 Header 資訊,確認它是 ELF 檔案後,接著讀取 Program Headers,並根據這些資訊將檔案載入記憶體中。
透過下面指令,我們可以看到 Program Headers 的內容readelf -l hello
對於載入器來說,ELF 檔案的重點已不再是 Sections,而是 Segments。Segment 是由多個 Sections 組合而成的單位,從上圖的下半部分可以看到哪些 Sections 被合併到同一個 Segment 中。這些 Segments 對應到上半部分的 Segments 類型,其中我們最關注的是類型為 LOAD 的 Segments,因為它們代表實際會被載入到記憶體的部分。
每個 Segment 都會記錄它在檔案中的位置、大小,以及它應該被載入到記憶體的哪個位置。根據這些資訊,載入器就能將程式載入記憶體,並依照屬性欄位來設置每個 Segment 是否可讀(R)、可寫(W)或可執行(E)。
除了根據 Program Headers 將 Segments 載入到記憶體之外,若程式依賴於動態連結的共享函式庫,還需處理連結的過程。動態連結器會根據 ELF 檔案中的 DYNAMIC Segment 來定位並載入必要的共享函式庫,然後將程式執行所需的函數載入到記憶體中,這樣在執行時程式可以正確呼叫這些外部函數。
程式載入記憶體後,記憶體佈局會依據不同的 Segments 進行分配,如上圖所示,右邊顯示了程式執行後在記憶體中的排列順序。除了先前提到的 Segments 外,還可以看到 Stack(堆疊)和 Heap(堆積)。Stack 和 Heap 分別用於分配暫時性資料和動態資料,這兩個區域會在程式執行過程中動態變化,後續的文章中也會詳細介紹我們會如何依據它的特性做攻擊。
當程式載入記憶體後,它便可以開始執行。由於我們的範例使用了動態連結,程式的起始點(Entry Point)不會立即執行主程式,而是會先進入動態連結器(Dynamic Linker)。動態連結器會根據 DYNAMIC Segment 中的資訊,載入所有依賴的共享函式庫,並做一些初始化動作。
完成動態連結後,程式的控制權會被交還給主程式,從 Entry Point 開始執行。不過,需要注意的是,實際的 Entry Point 並不是我們在程式中編寫的 main()
函數,而是 _start
。
_start
_start
是每個 ELF 可執行檔的真正起點。它負責初始化程式的執行環境,包括設定指標、準備參數等。接著,_start
會呼叫 __libc_start_main()
函數。
__libc_start_main()
__libc_start_main()
是 C 標準函式庫(libc)中的一個核心函數。它會先執行程式中的初始化函數,例如前面提到的 init
Section 中的函數。這些函數通常負責全域變數、靜態物件的初始化,以及其他必要的準備工作。
完成這些初始化動作後,__libc_start_main()
才會將控制權交給我們所編寫的 main()
函數,這時程式的主要邏輯才真正開始執行。
當 main()
函數結束時,__libc_start_main()
會負責清理工作,它會呼叫 fini
Section 中的函數,這些函數專門處理程式結束前的收尾工作,像是釋放資源、關閉文件等。
在下一篇文章中,我們將深入探討程式執行期間 Stack 的作用