為什麼要一起談組譯與連結#

從 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 19rs1輸入暫存器
Bit 12 - Bit 14funct3觀察 funct 來判斷指令屬於哪個操作
Bit 7 - Bit 11rd目標暫存器
Bit 0 - Bit 6opcode用於辨別 Type

addi 為例:將 rs1 的值加上立即數後存放到 rd。I-Type 的 opcode 統一為 0010011addi 的 funct3 是 000。 再以 xori 為例:將 rs1 與立即數做 XOR 後存到 rd,funct3 為 100

擴展指令集#

擴展指令集的誕生與人類賦予計算機的任務逐漸加重有關。例如處理音頻、影像時,基礎指令一次只能處理一個資料,處理 RGB 或座標問題需要拆成多項指令;因此勢必需要新增特殊指令處理這些問題,這些便是擴展指令集。

由於 RISC-V 屬於精簡指令架構,所以連常見的乘除法操作也是透過擴展指令實作。

RISC-V 中常見的擴展指令集:

擴展指令集指令數描述
M8整數乘法與除法指令
A11儲存器原子操作指令以及 Load-Reserved/Store-Conditional 指令
F26單精度(32 bits)浮點數指令
D26雙精度(64 bits)浮點數指令
C46壓縮指令,指令長度為 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 -> main

ld.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

解析流程#

  1. 程式碼中呼叫 func
  2. 跳到 func@plt
  3. func@plt 跳到 GOT 的 .got.plt 尋找 func 的位置
  4. func 的 id 推入 stack
  5. 由於 func 是第一次呼叫,.got.plt 中尚未存有實際位址,系統就會把 func 的位址寫進 .got.plt
  6. 等到 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 tablelinker editor 用於交叉參照不同二進位檔中變數和函式(符號)的表,是 nlist 的陣列
string table對應符號表的字串

a.out 的變體#

變體特性
OMAGICheader 後緊隨 text 與 data section,kernel 將兩者讀入可讀寫的記憶體。
NMAGIC與 OMAGIC 類似,但 data section 出現在 text section 結束後的下一頁,且 text section 為唯讀。
ZMAGIC加入按需分頁的支援,text 與 data section 的長度需是頁寬的整數倍。
QMAGICbinary file 通常被載入到虛擬位址池底端,以利透過段錯誤擷取對空指標的解除參照。Header 與 text 第一頁合併,可省下一頁記憶體。
CMAGIC舊版的 Linux 用此格式存放核心轉儲。

編譯出 a.out#

若沒有指定 -o,預設情況下 GCC 會直接把 C 語言編譯為 a.out 檔。

ELF:現代主流目的檔格式#

a.out 構造非常簡易,無法支援較複雜的功能(例如動態連結與載入)。目前主流的 UNIX-like 都已改採 ELF(Executable and Linkable Format)作為標準目的檔格式。

ELF 主要區段:

  • .text:放置已編譯的程式碼(組合語言)
  • .rodataro 表示 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