可執行檔載入與動態連結 (Executable Loading & Dynamic Linking)

Overview Table

小節 主題 核心概念 書頁
7.8 Executable Object Files ELF 可執行檔結構、segment header table (program header table)、對齊條件 vaddr mod align = off mod align p.731-733
7.9 Loading Executable Object Files loaderexecve 把程式載入記憶體、run-time memory image、_start__libc_start_mainmain p.733-735
7.10 Dynamic Linking with Shared Libraries shared library (.so) 的兩種「共享」、gcc -shared -fpicdynamic linker (ld-linux.so) 在 load time 完成連結 p.734-737
7.11 Loading/Linking from Applications 執行期由程式主動載入:dlopen / dlsym / dlclose / dlerror 介面 p.737-740
7.12 Position-Independent Code (PIC) 免重定位即可載入任意位址的程式碼;資料用 GOT、函式呼叫用 PLT + lazy binding p.740-743

7.8 可執行目的檔 (Executable Object Files) (p.731)

Linker 把多個 relocatable object file 合併成單一二進位可執行檔,內含載入並執行程式所需的全部資訊。格式與 relocatable object file 類似,但:

       0 +---------------------------+
         |        ELF header         | \
         |   Segment header table    |  |  Read-only memory segment
         |          .init            |  |  (code segment)  r-x
         |          .text            |  |
         |         .rodata           | /
         +---------------------------+
         |          .data            | \  Read/write memory segment
         |          .bss             | /  (data segment)   rw-
         +---------------------------+
         |         .symtab           | \
         |          .debug           |  |  符號表與除錯資訊
         |          .line            |  |  「不會」載入記憶體
         |         .strtab           | /
         +---------------------------+
         |   Section header table    |    描述各 section
         +---------------------------+

Program Header Table(Segment Header Table)(p.732)

ELF 可執行檔設計成容易載入:檔案中連續的區塊 (chunks) 映射到連續的記憶體區段 (segments),此映射由 program header table 描述。以範例程式 prog 為例(objdump 輸出):

Segment off vaddr filesz memsz flags 內容
Code segment 0x0 0x400000 0x69c 0x69c r-x ELF header、program header table、.init.text.rodata
Data segment 0xdf8 0x600df8 0x228 0x230 rw- .data(0x228 bytes)+ .bss
vaddrmodalign=offmodalign

其中 off 是該 segment 第一個 section 在檔案中的偏移、align 是 program header 指定的對齊(221=0x200000)。驗證:0x600df8 mod 0x200000 = 0xdf8 = 0xdf8 mod 0x200000


7.9 載入可執行目的檔 (Loading) (p.733)

在 shell 輸入 ./prog,因非內建命令,shell 呼叫 loader(記憶體常駐的 OS 程式碼;任何程式可經 execve 呼叫它)。Loader 把可執行檔的 code 與 data 從磁碟複製到記憶體,然後跳到 entry point 開始執行——此過程稱為 loading

Linux x86-64 Run-time Memory Image (p.734)

 2^48 +------------------------------+
      |        Kernel memory         |  <- user code 不可見
2^48-1+------------------------------+
      |          User stack          |  (run time 建立, 向下成長)
      |             |                |  <- %rsp (stack pointer)
      |             v                |
      +------------------------------+
      |  Memory-mapped region for    |
      |      shared libraries        |
      +------------------------------+
      |             ^                |
      |             |                |  <- brk
      |        Run-time heap         |  (malloc 建立, 向上成長)
      +------------------------------+
      |  Read/write segment          | \
      |  (.data, .bss)               |  |  從可執行檔
      +------------------------------+  |  載入
      |  Read-only code segment      |  |
      |  (.init, .text, .rodata)     | /
0x400000 -----------------------------+
      |          (未使用)             |
    0 +------------------------------+
簡化的例外

圖中 heap/data/code 相鄰、stack 頂在最大位址只是簡化。實際上:(1) code 與 data segment 之間因 .data 對齊需求存在縫隙 (gap);(2) linker 使用 ASLR (address-space layout randomization) 隨機化 stack、shared library、heap 的位址——每次執行位置不同,但相對位置不變(參見 03-Machine-Level-Programs/06-Buffer-Overflow-and-Pointer-Safety)。

啟動流程

./prog → shell → execve → loader
   → 依 program header table 複製 chunks 到 code/data segments
   → 跳至 entry point = _start (定義於系統目的檔 crt1.o, 所有 C 程式相同)
   → _start 呼叫 __libc_start_main (定義於 libc.so)
   → 初始化執行環境 → 呼叫使用者的 main → 處理回傳值 → 必要時交還 kernel
Loader 實際運作(Aside, p.735)

「複製」的描述僅是概念性正確。實際上:shell fork 子行程,子行程呼叫 execve 啟動 loader;loader 刪除子行程既有的虛擬記憶體 segments,建立新的 code/data/heap/stack segments(heap、stack 清零),並把 code/data segments 映射 (map) 到可執行檔的 page-size 區塊——除了 header 外,載入時沒有任何磁碟到記憶體的資料複製;複製延遲到 CPU 第一次引用該虛擬頁面時,由 OS 的 paging 機制自動搬入。詳見 08-Exceptional-Control-Flow/04-Process-Control09-Virtual-Memory/04-Memory-Mapping


7.10 共享函式庫與動態連結 (Dynamic Linking with Shared Libraries) (p.734)

Static library 的缺點:(1) 函式庫更新後,程式設計師必須知悉並顯式重新連結;(2) 幾乎每個 C 程式都用 printf/scanf 等標準 I/O,其程式碼在每個執行中行程的 text segment 都有一份複本,在數百行程的系統上嚴重浪費記憶體。

Shared library(共享函式庫) 是一種 object module,可在 run time 或 load time 載入到任意記憶體位址並與記憶體中的程式連結——此過程稱為 dynamic linking(動態連結),由 dynamic linker 執行。Linux 上以 .so 為後綴(又稱 shared objects);Microsoft 系統稱為 DLL

「共享」的兩層意義:

層面 說明 對比 static library
檔案系統 一個函式庫在檔案系統中恰有一個 .so,所有引用它的可執行檔共享其 code/data static library 的內容被複製並嵌入每個可執行檔
記憶體 記憶體中 shared library 的 .text section 單一複本可被多個執行中行程共享 每個行程各有一份複本

建置與連結流程 (p.735-736)

linux> gcc -shared -fpic -o libvector.so addvec.c multvec.c   # 建 shared library
linux> gcc -o prog2l main2.c ./libvector.so                   # 部分連結
  main2.c  vector.h
      |
  Translators (cpp, cc1, as)          libc.so, libvector.so
      |                                       |
   main2.o                            relocation 與 symbol table 資訊
      \___________ Linker (ld) _______________/
                       |
   prog2l  <-- "部分連結" (partially linked) 的可執行檔
                       |
              Loader (execve)                 libc.so, libvector.so
                       |                              |
                       |                        code 與 data
   完全連結的記憶體映像 <-- Dynamic linker (ld-linux.so)

7.11 應用程式在執行期載入共享函式庫 (p.737)

除了 load time,應用程式也可以在執行中請求 dynamic linker 載入並連結任意 shared library,無須在編譯期對這些函式庫連結。實際應用:

dlopen 介面(#include <dlfcn.h>,連結時加 -ldl)

函式 原型 回傳 用途
dlopen void *dlopen(const char *filename, int flag); handle 指標;錯誤回 NULL 載入並連結 shared library
dlsym void *dlsym(void *handle, char *symbol); 符號位址;不存在回 NULL 取得已載入函式庫中符號的位址
dlclose int dlclose(void *handle); 成功 0;錯誤 -1 若無其他函式庫仍在使用,則卸載
dlerror const char *dlerror(void); 錯誤訊息字串;無錯回 NULL 描述最近一次 dlopen/dlsym/dlclose 的錯誤
/* dll.c 典型流程 (p.739) */
handle = dlopen("./libvector.so", RTLD_LAZY);      /* 載入 */
addvec = dlsym(handle, "addvec");                  /* 取函式指標 */
addvec(x, y, z, 2);                                /* 像普通函式一樣呼叫 */
dlclose(handle);                                   /* 卸載 */
/* 編譯: gcc -rdynamic -o prog2r dll.c -ldl */
Java Native Interface (JNI)(Aside, p.740)

JNI 讓 Java 程式呼叫 native C/C++ 函式:把 C 函式 foo 編成 foo.so,Java 直譯器用 dlopen(或類似機制)動態連結載入 foo.so 再呼叫 foo


7.12 位置無關程式碼 (Position-Independent Code, PIC) (p.740)

動機:讓多個行程共享同一份函式庫程式碼。若改用「事先為每個函式庫指派固定位址區塊」的作法會有嚴重問題:位址空間使用低效(行程沒用到也佔位)、難以管理(區塊不得重疊、函式庫改版可能塞不下、新函式庫要找空間)、長期造成位址空間碎片化,且各系統的指派不同,管理惡夢加倍。

解法:把 shared module 的 code segment 編譯成載入到記憶體任何位置都不需 linker 修改的程式碼——可以不經任何 relocation 就載入的程式碼稱為 PIC。以 gcc -fpic 產生;shared library 必須一律以此選項編譯。(當然,每個行程仍有自己的 read/write data segment 複本。)

PIC 資料引用:GOT (Global Offset Table) (p.740-741)

關鍵事實:無論 object module 載到記憶體何處,data segment 與 code segment 的距離恆定——code 中任一指令到 data 中任一變數的距離是 run-time constant,與絕對位址無關。

Data segment
  GOT[0]: ...
  GOT[3]: &addcnt      <-┐
                          |  GOT[3] 與指令距離 0x2008b9
Code segment              |  = run-time constant
  addvec:                 |
    mov 0x2008b9(%rip),%rax   # %rax = *GOT[3] = &addcnt
    addl $0x1,(%rax)          # addcnt++
例外:非必要的 GOT 間接

addcntlibvector.so 自己定義,compiler 本可利用 code/data 距離恆定,直接產生 PC-relative 引用 + relocation 讓 linker 建 shared module 時解決。但若 addcnt 定義在另一個 shared module,經 GOT 的間接存取就是必要的。此例中 compiler 選擇對所有引用都用最通用的 GOT 方案。

PIC 函式呼叫:PLT + Lazy Binding (p.741-743)

呼叫 shared library 定義的函式時,compiler 無法預測其 run-time 位址。若用一般 relocation record 交給 dynamic linker 在載入時修改,會修改到呼叫模組的 code segment,就不是 PIC 了。GNU 系統改用 lazy binding(延遲繫結):把每個程序位址的繫結延遲到第一次呼叫時

結構 位置 形式 特殊 entry
PLT code segment 16-byte 程式碼 entry 的陣列 PLT[0] 跳入 dynamic linker;PLT[1] 呼叫 __libc_start_main;PLT[2] 起為使用者程式呼叫的函式
GOT data segment 8-byte 位址 entry 的陣列 GOT[0](.dynamic 位址)、GOT[1](reloc entries 位址)供 dynamic linker 解析函式位址;GOT[2] 是 dynamic linker(ld-linux.so)的進入點;其餘各對應一個待解析函式,與 PLT entry 配對(如 GOT[4]PLT[2]addvec)
(a) 第一次呼叫 addvec (lazy binding):

  callq 0x4005c0          # 呼叫 addvec -> 其實呼叫 PLT[2]   ... Step 1
       |
       v
  PLT[2]: 4005c0: jmpq *GOT[4]   # GOT[4] 初值 = 0x4005c6
                    |            #  -> 跳回下一條指令          ... Step 2
                    v
          4005c6: pushq $0x1     # 推入 addvec 的 ID          ... Step 3
          4005cb: jmpq 4005a0    # 跳到 PLT[0]
                    |
                    v
  PLT[0]: 4005a0: pushq *GOT[1]  # 推入 dynamic linker 的引數  ... Step 4
          4005a6: jmpq *GOT[2]   # 跳入 dynamic linker
                    |
                    v
  dynamic linker: 用堆疊上兩個 entry 求出 addvec 的 run-time 位址,
                  以其「覆寫 GOT[4]」,再把控制權交給 addvec

(b) 之後每次呼叫 addvec:

  callq 0x4005c0  ->  PLT[2]: jmpq *GOT[4]  ->  直接跳到 addvec
  (僅多 1 條指令 + 1 次記憶體間接引用)

Exam/Test Patterns

情境 / 關鍵字 答案
可執行檔比 relocatable object file 多/少了什麼? 多:entry point(在 ELF header)、program header table、.init;少:.rel sections(已完全重定位)
問哪些 section「不會」載入記憶體 .symtab.debug.line.strtab(符號表與除錯資訊)
給 program header(off/vaddr/filesz/memsz),問 .bss 大小 memsz − filesz(執行時清零、不佔檔案空間)
問 segment 起始位址 vaddr 的合法性 驗證 vaddr mod align = off mod align(align = 221 = 0x200000)
Linux x86-64 code segment 起始位址 0x400000;stack 自 2481 向下成長;248 以上為 kernel
程式的 entry point 是哪個函式?定義在哪? _start,定義於 crt1.o(所有 C 程式相同);它呼叫 libc.so 中的 __libc_start_main,再呼叫 main
問 loading 是否真的「複製」檔案到記憶體 否,只映射 (memory mapping);複製延遲到 CPU 第一次觸及該頁,由 paging 機制搬入
shared library 的兩種「共享」 檔案系統中僅一份 .so;記憶體中 .text 單一複本可供多行程共享
gcc -shared -fpic 各 flag 作用 -fpic 產生 position-independent code;-shared 產生 shared object file
gcc -o prog2l main2.c ./libvector.so 複製了什麼進可執行檔? 不複製任何 code/data section,只複製 relocation 與 symbol table 資訊(部分連結)
loader 如何知道要啟動 dynamic linker? 可執行檔的 .interp section 存有 dynamic linker 路徑名(如 ld-linux.so)
dlopen 的 flag 規則 必含 RTLD_NOW(立即解析)或 RTLD_LAZY(延遲解析)其一,可 OR RTLD_GLOBAL;搭配 -rdynamic 讓可執行檔 global symbols 參與解析
PIC 資料引用的原理 code 與 data segment 距離為 run-time constant → 以 PC-relative 引用 GOT,dynamic linker 在 load time 填入絕對位址
GOT vs PLT 的位置與大小 GOT 在 data segment、8-byte 位址 entries;PLT 在 code segment、16-byte 程式碼 entries
lazy binding 第一次 vs 之後呼叫的流程 第一次:PLT[2] → GOT[4](指回 PLT[2] 第二條指令)→ push ID → PLT[0] → dynamic linker 解析並覆寫 GOT[4] → addvec;之後:PLT[2] → GOT[4] 直達 addvec
GOT entry 初始值指向哪裡? 對應 PLT entry 的第二條指令(造成第一次呼叫「跳回原地」以觸發解析)
為何不能用一般 relocation 處理 shared library 函式呼叫? 那需要修改呼叫模組的 code segment,違反 PIC(無法多行程共享唯讀 code)

Connected Pages
可執行檔載入與動態連結 (Executable Loading & Dynamic Linking)
  • Overview Table
  • 7.8 可執行目的檔 (Executable Object Files) (p.731)
    1. Program Header Table(Segment Header Table)(p.732)
  • 7.9 載入可執行目的檔 (Loading) (p.733)
    1. Linux x86-64 Run-time Memory Image (p.734)
    2. 啟動流程
  • 7.10 共享函式庫與動態連結 (Dynamic Linking with Shared Libraries) (p.734)
    1. 建置與連結流程 (p.735-736)
  • 7.11 應用程式在執行期載入共享函式庫 (p.737) dlopen 介面(#include ,連結時加 -ldl)
  • 7.12 位置無關程式碼 (Position-Independent Code, PIC) (p.740)
    1. PIC 資料引用:GOT (Global Offset Table) (p.740-741)
    2. PIC 函式呼叫:PLT + Lazy Binding (p.741-743)
  • Exam/Test Patterns
  • Related Notes