為什麼要一起談組譯與連結#
從 C 原始碼到一支可以在作業系統上跑起來的執行檔,中間經過:前處理 ➡️ 編譯 ➡️ 組譯 ➡️ 連結 ➡️ 載入。本章把組譯器(Assembler)與連結器(Linker)合在一起講,順便看看可執行檔格式(a.out 與 ELF)和 lazy-binding 的運作。
讀完你會掌握:
- RISC-V 基礎指令集與假指令、擴展指令集
- 組譯器與組合語言的關係
- 靜態 vs. 動態連結
- Lazy-binding(PLT / GOT)
- a.out 與 ELF 格式
組譯器#
組譯器(Assembler)能將組合語言轉換為目標碼(機器語言或接近於機器語言的程式碼)。在 Unix 系統中,組譯器的輸入為 .s 後綴的檔案,例如 foo.s。
此外,組譯器還有一項非常重要的功能:它可以將擴展指令(包括假指令)轉為基礎指令。
基礎指令集#
RISC-V 有多個基本指令集:
- RV32I
- RV32E
- RV64I
- RV128I
以 RV32I 來看,該指令集共有 47 個 32 位元指令並支援 32 個通用整數暫存器。
不同 Type 的指令會用不同方式切割 32 個位元。以 I-Type 為例:
| 位元範圍 | 欄位 | 說明 |
|---|---|---|
| Bit 20 - Bit 31 | 立即數 | immediate |
| Bit 15 - Bit 19 | rs1 | 輸入暫存器 |
| Bit 12 - Bit 14 | funct3 | 觀察 funct 來判斷指令屬於哪個操作 |
| Bit 7 - Bit 11 | rd | 目標暫存器 |
| Bit 0 - Bit 6 | opcode | 用於辨別 Type |
以 addi 為例:將 rs1 的值加上立即數後存放到 rd。I-Type 的 opcode 統一為 0010011,addi 的 funct3 是 000。
再以 xori 為例:將 rs1 與立即數做 XOR 後存到 rd,funct3 為 100。
擴展指令集#
擴展指令集的誕生與人類賦予計算機的任務逐漸加重有關。例如處理音頻、影像時,基礎指令一次只能處理一個資料,處理 RGB 或座標問題需要拆成多項指令;因此勢必需要新增特殊指令處理這些問題,這些便是擴展指令集。
由於 RISC-V 屬於精簡指令架構,所以連常見的乘除法操作也是透過擴展指令實作。
RISC-V 中常見的擴展指令集:
| 擴展指令集 | 指令數 | 描述 |
|---|---|---|
| M | 8 | 整數乘法與除法指令 |
| A | 11 | 儲存器原子操作指令以及 Load-Reserved/Store-Conditional 指令 |
| F | 26 | 單精度(32 bits)浮點數指令 |
| D | 26 | 雙精度(64 bits)浮點數指令 |
| C | 46 | 壓縮指令,指令長度為 16 bits |
以上 IMAFD 指令集組合又被稱為通用組合,在英文中以 G (General) 表示,所以 RV32G 等於 RV32IMAFD。
如何解析擴展指令#
要解析擴展指令有兩種直觀做法:
- 設計相關硬體使 CPU 能夠對指令進行硬處理
- 利用多個基礎指令構成新的指令(假指令)
在 RISC 架構的處理器中,多數採用後者,這也呼應到組譯器主要的功能。
組合語言與假指令#
在 RISC-V 中,撰寫組合語言時會在開頭使用組譯指示符(assemble directives),例如 .text、.align、.globl、.section 等。
.text
.align 2
.globl main
main:
addi sp,sp,-16
sw ra,12(sp)
# ...
ret
# ...其中 ret 並不是基礎指令,而是**假指令(pseudo instruction)**的一種,實際上會被組譯器展開成 jalr x0, 0(x1)。
本章並沒有介紹全部的假指令,更多資訊需要讀者自行翻閱 RISC-V 規格書。
一個 Hello World 的旅程#
#include <stdio.h>
int main()
{
printf("Hello, %s\n", "world");
return 0;
}用 RISC-V 的 C 語言編譯器編譯後得到組譯檔案:
.text
.align 2
.globl main
main:
addi sp,sp,-16
sw ra,12(sp)
lui a0,%hi(string1)
addi a0,a0,%lo(string1)
lui a0,%hi(string2)
addi a0,a0,%lo(string2)
call printf
lw ra,12(sp)
addi sp,sp,16
li a0,0
ret
.secton rodata
.balign 4
string1:
.string "Hello, %s!\n"
string2:
.string "world"幾個重點:
lui指令可以將 unsigned 20-bit 放到 rd 暫存器的最高 20-bit,並將剩餘的 12-bit 補 0%hi(string1)用來取string1的前 20 位位址值(共 32 位)- 配合
addi a0, a0, %lo(string1)把低 12 bit 加回去,組成完整的 32-bit 字串位址
把組合語言交給組譯器後,就能得到 RISC-V 的機器碼:
00000000 <main>:
0: ff010113 addi sp,sp,-16
4: 00112623 sw ra,12(sp)
8: 00000537 lui a0,0x0
c: 00050513 mv a0,a0
10: 000005b7 lui a1,0x0
14: 00058593 mv a1,a1
18: 00000097 auipc ra,0x0
1c: 000080e7 jalr ra
20: 00c12083 lw ra,12(sp)
24: 01010113 addi sp,sp,16
28: 00000513 li a0,0
2c: 00008067 ret連結器的用途#
連結器讓我們能對各個獨立檔案進行編譯與組譯。好處顯而易見:當一個專案有多個檔案時,若僅修改一個檔案,不需要重新編譯全部程式碼,只需編譯更動的部分再做連結即可。
靜態連結與動態連結#
靜態連結#
多數人對程式執行的第一個印象是執行 main() 函式,實際不是這麼一回事。假設一支程式以靜態連結編譯,被執行後的步驟大致是:
./program -> fork() -> execve("./program", *argv[], *envp[])執行 execve() 後,執行緒從 user mode 切換至 kernel mode 繼續執行:
sys_execve() -> do_execve() -> search_binary_handler() -> load_elf_binary()載入執行檔的 binary 資料後,再切換回 user mode 繼續執行:
_start -> main靜態連結的缺陷#
- 程式運行前就把所有 Library 連結與載入;若多個程式都用同一個大 Library,產生不小的重複開銷。
- 若靜態連結的 Library 被發現有設計錯誤,即使原作者已修復,使用靜態連結的程式仍綁定舊版 Library,必須重新編譯。
動態連結#
為了解決上述問題,現今系統採用 Dynamic linking:
- 動態連結的 Library 只有在第一次載入時會產生動態開銷,之後採取 fast linking 載入。
- 多個程式依賴同一個 Library 時,這個動態連結庫只會被載入一次。
使用動態連結產生的程式碼與傳統方式沒有太大差異。最大的差別是:跳轉的目標並不是實際的函式,而是帶有三條指令的 Stub function。Stub 會查詢主記憶體中的 Table 找出實際函式的位置再做跳轉。也因為第一次呼叫函式時,該函式並沒有被載入到主記憶體中(Table 裡找不到實體位置),所以第一次調用會產生額外的開銷。
動態連結版的執行流程:
./program -> fork() -> execve(...)
sys_execve() -> do_execve() -> search_binary_handler() -> load_elf_binary()
ld.so -> _start -> libc_start_main() -> _init -> mainld.so 就是動態連結器,它會負責按照可執行檔運作時的需要載入並連結 shared library。
Lazy-binding#
當程式採動態連結時,函式位址會在執行週期才確定。好處顯而易見:程式引入的 library 函式可能有千百個,但執行週期中並不會都用到,呼叫時再去載入就可以大幅提升執行效率。
判別是否為 Lazy-binding 的方法:用逆向工具查看組合語言時,如果出現像
call puts@plt這種形式,就代表該函式會在執行期間才做載入。
GOT 與 PLT#
- GOT(Global Offset Table):存放函式指標的陣列,記錄 ELF file 中用到的 shared library 符號的絕對位址。GOT 主要包含:
.dynamic:動態連結資訊.got:儲存全域變數的位址.got.plt:包含.dynamic位址、link_map(記錄用到的 Library 的鏈結串列)、dl_runtime_resolve(找出函式位址的 routine).data
- PLT(Procedure Linkage Table):每個外部函式對應一個 stub,第一次呼叫時透過
dl_runtime_resolve解析真實位址,並把結果寫回.got.plt。
解析流程#
- 程式碼中呼叫
func - 跳到
func@plt func@plt跳到 GOT 的.got.plt尋找func的位置- 將
func的 id 推入 stack - 由於
func是第一次呼叫,.got.plt中尚未存有實際位址,系統就會把func的位址寫進.got.plt - 等到
func第二次被呼叫時,就可以直接找到其位址
Lazy-binding 帶來的安全性問題#
Lazy-binding 雖能大幅提升執行效率,但因該機制需要 GOT 能被寫入:若有有心人士將 PLT 對應位置改成 system call 的位址,那呼叫一個 PLT 函式就會變成呼叫 system call。CTF 中經常用這個漏洞達成 RCE,需特別注意。
a.out:歷史悠久的目的檔格式#
a.out 是舊版類 Unix 系統中用於執行檔、目的碼和函式庫的一種檔案格式,名稱意為「組譯器輸出(assembler output)」。
a.out 最早可追溯到第一版 UNIX 作業系統(搭載於 PDP-7 與 PDP-11)。
a.out 的結構#
a.out 檔案最多包含 7 個部分,header 結構參考 C 定義:
struct exec {
long a_magic; /* magic number */
unsigned a_text; /* size of text segment */
unsigned a_data; /* size of initialized data */
unsigned a_bss; /* size of uninitialized data */
unsigned a_syms; /* size of symbol table */
unsigned a_entry; /* entry point */
unsigned a_trsize; /* size of text relocation */
unsigned a_drsize; /* size of data relocation */
};七個區塊:
| 區塊 | 說明 |
|---|---|
| header(必備) | 包含核心將二進位檔載入記憶體並執行所需的參數,也包含對動態連結器 ld 的指引。 |
| text section | 放置程式執行時被載入記憶體的機器碼與相關資料。 |
| data section | 已初始化的資料,例如 int a = 0; |
| text relocation | 包含連結編輯器在合併二進位檔時修改 text 段指標的紀錄 |
| data relocation | 與 text relocation 類似,但用於 data 段 |
| symbol table | linker editor 用於交叉參照不同二進位檔中變數和函式(符號)的表,是 nlist 的陣列 |
| string table | 對應符號表的字串 |
a.out 的變體#
| 變體 | 特性 |
|---|---|
| OMAGIC | header 後緊隨 text 與 data section,kernel 將兩者讀入可讀寫的記憶體。 |
| NMAGIC | 與 OMAGIC 類似,但 data section 出現在 text section 結束後的下一頁,且 text section 為唯讀。 |
| ZMAGIC | 加入按需分頁的支援,text 與 data section 的長度需是頁寬的整數倍。 |
| QMAGIC | binary file 通常被載入到虛擬位址池底端,以利透過段錯誤擷取對空指標的解除參照。Header 與 text 第一頁合併,可省下一頁記憶體。 |
| CMAGIC | 舊版的 Linux 用此格式存放核心轉儲。 |
編譯出 a.out#
若沒有指定 -o,預設情況下 GCC 會直接把 C 語言編譯為 a.out 檔。
ELF:現代主流目的檔格式#
a.out 構造非常簡易,無法支援較複雜的功能(例如動態連結與載入)。目前主流的 UNIX-like 都已改採 ELF(Executable and Linkable Format)作為標準目的檔格式。
ELF 主要區段:
- .text:放置已編譯的程式碼(組合語言)
- .rodata:
ro表示 read-only,放置常數 - .data:放置已初始化的全域變數或靜態的區域變數
- .bss:未初始化的全域變數或靜態的區域變數
- .debug:放置除錯資訊,可幫助 GDB 等工具進行程式分析
用 Binutils 查看 ELF#
readelf --help 看選項,常用:
| 短旗標 | 長旗標 | 作用 |
|---|---|---|
-a | --all | 等同 -h -l -s -S -r -d -V -A -I |
-h | --file-header | 查看 ELF header |
-l | --program-headers、--segments | 顯示 program headers |
-S | --section-headers | 顯示 section headers |
用 Binutils 反組譯 ELF#
objdump --help 看選項,主要兩個:
-D:反組譯-S:將 ELF 反組譯並與 C source code 混合輸出
ELF 檔案在執行週期與連結時期會用不同視角存取(program headers vs. section headers)。因為 ELF 非常複雜,深入學習可參考相關教材。
重點整理#
- 組譯器把組合語言(含假指令)翻成機器碼;連結器把多個目的檔組合成可執行檔或函式庫。
- 靜態連結方便但體積大、難更新;動態連結節省空間並支援共享,但首次解析有額外成本。
- Lazy-binding 透過 PLT/GOT 把函式位址解析延後到第一次呼叫;這個機制也是某些 CTF 攻擊面的來源。
- a.out 是 UNIX 老格式,現代主流是 ELF;ELF 同時支援 link view 與 execution view,能描述複雜的執行需求。
原文出處#
- 原書/iThome:https://ithelp.ithome.com.tw/articles/10266534
- 原書/iThome:https://ithelp.ithome.com.tw/articles/10267308