博客 / 詳情

返回

C語言編譯過程 & ELF文件加載過程解析

C語言編譯 - ELF文件加載過程解析

bin 文件通常用於嵌入式裸機程序的燒錄,elf 可執行文件通常運行在操作系統之上。

  • bin 是扁平的二進制文件,沒有任何説明,它假設加載它的環境(如嵌入式引導程序,BootRom)已經預先知道了代碼存放的地址,代碼的入口,數據段,代碼段的地址。大家如果燒錄過嵌入式裸機程序應該有所體會。

  • elf 則是帶有詳細説明和裝配圖的文件,因此 elf 可執行程序的運行是需要對其所包含的信息進行解析並建立執行環境的,這就決定了其不可能作為裸機程序去執行。

一、C語言執行需要的內存環境

一個可執行文件加載過程中,需要創建執行所需要的內存空間。對於操作系統而言,一般指的是每個進程的虛擬地址空間。一個進程的內存空間一般存在四個核心區域,代碼段(.text),數據段(.data),堆(.heap),棧(.stack)。

  • 代碼段,用於存放編譯後的機器指令。
  • 數據段:
    • data 段,已初始化的非零全局變量和靜態變量。
    • bss 段,未初始化或者初始化值為0的全局變量,靜態變量。
    • rodata段,const 關鍵字修飾的常量,只讀。
  • 堆,用於動態空間分配,C語言中需要手動分配和釋放(malloc/free)。
  • 棧,存儲局部變量、函數參數、返回地址,自動分配和釋放,遵循 “先進後出” 規則。

需要注意的是,棧的地址是從高地址到低地址,堆的地址是從低地址到高地址。

二、C語言編譯過程

C語言從源代碼到可執行文件一般需要四個過程,即預處理,編譯,彙編,鏈接

  • 預處理,處理源代碼中的預處理指令(以#開頭),生成純 C 代碼(.i文件)。

    • 宏替換:展開#define定義的宏(如#define PI 3.14替換為實際值)。
    • 文件包含:將#include指令指向的頭文件(如stdio.h)內容插入當前文件。
    • 條件編譯:根據#if#ifdef等指令保留或刪除部分代碼(如調試代碼#ifdef DEBUG ... #endif)。
    • 刪除註釋:移除///* */註釋,不影響代碼邏輯。
  • 編譯,將預處理後的純 C 代碼(.i文件)轉換為彙編代碼(.s文件)。

    • 語法分析:檢查代碼語法是否符合 C 語言規則(如括號匹配、關鍵字使用等),若出錯則終止編譯。
    • 語義分析:驗證代碼邏輯合理性(如變量未聲明就使用、類型不匹配等)。
    • 中間代碼生成:將合法代碼轉換為中間表示(如三地址碼)。
    • 優化:對中間代碼進行優化(如常量摺疊、循環展開),提升執行效率。
    • 彙編生成:將優化後的中間代碼轉換為對應 CPU 架構的彙編指令。
  • 彙編,將彙編代碼(.s文件)轉換為機器指令(二進制目標文件,.o.obj)。

    • 把彙編指令一一對應為 CPU 可識別的二進制 opcode(如call printf轉換為對應的機器碼),生成的目標文件包含:
    • 二進制指令(代碼段)
    • 變量數據(數據段)
    • 符號表(記錄函數、變量的地址信息,供後續鏈接使用)。

    目標文件是 “部分編譯” 的結果,可能包含未解析的外部符號(如printf函數的地址尚未確定)。

  • 鏈接,將多個目標文件(.o)和庫文件(如libc.so)合併,生成可執行文件。

    • 符號解析:找到所有外部符號的實際地址(如printf在 C 標準庫中的地址)。
    • 重定位:調整目標文件中指令的地址(因為多個文件合併後,原地址可能偏移)。
    • 合併段:將多個目標文件的代碼段、數據段等合併為統一的內存佈局。

鏈接又分為靜態鏈接和動態鏈接。Linux 環境下使用 gcc 進行編譯,默認使用動態鏈接。

  • 靜態鏈接將程序依賴的庫函數代碼(如printfmalloc)直接複製到可執行文件中,形成一個獨立的二進制文件。
    • 依賴的庫稱為 “靜態庫”(Windows 下為.lib,Linux 下為.a)。
  • 動態鏈接僅在可執行文件中記錄庫函數的引用信息(如函數名、庫路徑),不復制庫代碼。程序運行時,由操作系統的動態鏈接器(如 Linux 的ld.so,Windows 的ntdll.dll 加載依賴的庫文件到內存,並解析引用,後面會展開講。
    • 依賴的庫稱為 “動態庫”(Windows 下為.dll,Linux 下為.so,macOS 下為.dylib)。
    • Linux默認的動態庫鏈接是/lib/usr/lib
gcc -E main.c -o main.i				# 預處理
gcc -S main.i -o main.s 			# 編譯
gcc -c main.s -o mian.o 			# 彙編
gcc -static main.o -o main 			# 靜態鏈接
gcc main.o -o main 					# 默認動態鏈接

將自己的代碼編譯為動態庫:

# -fPIC為位置無關代碼;-shared指示gcc生成一個共享庫而不是一個可執行文件,共享庫可以被多個程序同時使用節省了內存和磁盤空間。
gcc hello.o -fPIC -shared -o libxxx.so
gcc main.o -L ./ -lhello -o main2	# main 中用到 hello.c 的庫函數
# -lhello -l + 庫的名稱。上面的命令只是告訴動態鏈接庫是誰,並沒有指定動態庫所在的路徑,因此需要添加要使用的動態庫文件路徑,然後執行。
# 或者將庫文件所在路徑添加進環境變量 LD_LIBRARY_PATH

將自己的代碼編譯為靜態庫:

ar crv libhello.a hello.o
gcc main.o -L ./ -lhello -o main3	# 注意:當同一個目錄下既有靜態庫又有動態庫,默認鏈接動態庫。

三、elf 可執行程序

3.1 elf 文件的組成

elf 文件的主要包括ELF頭部,程序頭表,節區,節頭表幾個部分。

  1. ELF頭部

    • 標識這是一個ELF文件,文件類型可重定位文件還是共享庫文件,ARM架構。
    • e_entry:程序的入口地址,第一條指令的虛擬地址。對於動態鏈接的程序,這個地址通常不是 main 函數,而是動態鏈接器 _start 的入口,後面會講。
    • 程序頭表和節頭表的偏移。
  2. 程序頭表

    程序頭表相當於一個加載説明書,告訴操作系統如何將 elf 文件的內容映射到內存中,以創建一個進程。程序頭表在實現上是一個結構體數組,每個結構體(segment)描述了文件中的一塊區域應該如何被映射到內存中。

    1. PT_LOAD代碼段,數據段等需要被加載到內存中的段。
    2. PT_DYNAMIC:包含動態鏈接信息的段。
    3. PT_INTERP:動態鏈接器的路徑(lib/ld-linux.so.2)。

    每個程序頭都包含了該段在文件中的偏移,在內存中的虛擬地址,大小,執行權限等。

  3. 節區

    保存着不同節的具體內容。

    1. .text:存放程序指令(代碼)。
    2. .data:存放已初始化的全局變量和靜態變量。
    3. .bss:存放未初始化的全局變量和靜態變量(此節在文件中不佔空間,只在運行時在內存中分配)。
    4. .rodata:存放只讀數據(如常量字符串)。
    5. .symtab:符號表,記錄了函數和變量的名稱及其地址。
    6. .symtab / .dynsym:符號表,.symtab 包含所有符號(包括本地符號),.dynsym 僅包含動態鏈接所需的符號(如外部庫函數)。
    7. .rel.text / .rel.data:重定位信息,用於鏈接時修正地址。
  4. 節頭表

    所有節的索引目錄。存放一個數組,數組中的每個元素對應一個節,描述了該節的名稱(在.strtab中的索引),類型,在文件中的偏移,大小,鏈接信息等。

3.2 elf 文件的加載過程

  1. shell 調用,shell 會 fork 一個子進程,然後 execve 跳轉到 elf 可執行文件中。
  2. 跳轉到內核(分配各區域虛擬地址空間,創建C語言進程運行環境)
    1. 讀取 elf 頭部。驗證是不是 elf 文件,讀取頭部信息,找到程序頭表。
    2. 解析程序頭表。尋找 PT_LOAD 段,這是唯一需要被實際加載到內存中的段,通常一個 ELF 文件至少有兩個PT_LOAD段,即代碼段和數據段
    3. 在找到 PT_LOAD 段之後,加載器會為當前進程創建一個新的虛擬內存區域,起始地址和大小 PT_LOAD->p_vaddrPT_LOAD->p_memsz 決定。設置權限。
    4. 上面的步驟建立了進程的虛擬內存區域,現在需要完成虛擬內存區域到物理內存的映射。需要注意的是這個映射並不是把磁盤的所有內容都直接複製到內存裏面,而是在 MMU 觸發缺頁中斷的時候才從磁盤中把需要的數據放入內存。
    5. 處理 .bss 段,其在二進制文件中不佔用空間,但是需要在內存中為其分配 p_memsz 的空間。
    6. 尋找程序頭表中的 PT_INTERP 段,其存放着動態鏈接器的路徑,加載器會把這個動態鏈接器也放到進程的虛擬內存中。
    7. 設置棧。內核會在進程空間地址頂端創建一個棧區域,壓入一些數據,傳入參數個數,傳入參數指針,指向各環境變量的字符串等和一些輔助向量。
  3. 從內核跳轉到用户空間(加載動態鏈接庫,重定位)
    1. 內核將指令指針 PC 設置為動態鏈接器的入口。棧指針指向剛剛的棧頂,切換到用户模式。
    2. 動態鏈接器 _start 函數開始運行
    3. 讀取主程序的 .dynamic 節區,找到程序依賴的共享庫列表,加載這些庫。
    4. 上一步動態庫加載了之後就有了地址,此時就需要對庫函數地址(佔位符)進行替換為真實的虛擬地址。
    5. 執行主程序和各個共享庫的初始化代碼。
    6. 跳轉main函數執行。

3.3 符號表,字符串表和重定位

符號表,字符串表,重定位信息都屬於節區。

靜態鏈接

靜態鏈接情況下不存在動態庫,根據上一節所講的 elf 文件加載過程,需要動態庫的可執行文件是運行時加載,然後進行重定位的,因此靜態鏈接的可執行文件在編譯完成的時候,重定位就已經完成,節區中的重定位信息被刪除或者為空,符號表通常會保留,用於 GDB 調試。

符號表 .symtab 與字符串 strtab 表結合作用,符號表中保存着程序中變量,函數,(文件名,節區名)等的名稱(索引 st_name,指向 strtab 中的對應位置),地址st_value,通常是函數,變量的地址或者偏移量),大小(st_size,一個數組或者函數的字節大小),類型(st_info,如 STT_FUNC 函數, STT_OBJECT 對象,綁定屬性:STB_LOCAL:局部符號;STB_GLOBAL:全局符號;STB_WEAK:弱符號;st_shndx:一個索引,指明該符號位於哪個節區。)

為什麼不直接把字符串存在 .symtab 裏呢?因為這樣做效率低下且浪費空間。使用索引的方式,多個符號可以共享同一個字符串(例如,多個文件都引用 printf),並且符號表條目可以保持固定大小,便於快速查找。

重定位表(rel.text/rel.data)要解決的問題:當存在多個 .c 文件時,一個 c 文件使用到另一個 c 文件的函數,在編譯單個 c 文件時,編譯器並不知道調用的這個函數的最終地址,也不知道自己定義的函數或者變量最終會被鏈接到可執行文件的哪個地址。這時彙編器會生成一個重定位條目,並留下一個佔位符,表示這個位置的代碼需要被修正。重定位條目生成在彙編階段,最終地址的確定發生在鏈接階段。每個重定位條目(通常是一個結構體變量,Elf_Rel或Elf_Rela)包含需要被修正的地址在節點中的偏移量 r_offset,r_info,存放符號索引和重定位類型

對某個函數和變量進行重定位,首先是要知道這個需要被重定位的函數變量在哪(r_offset),其次是要知道要填充進這個佔位符的地址是什麼(從符號表中獲得r_info)

兩種重定位表的介紹:

  • .rel.text:包含了對代碼節區 (.text) 的重定位信息。例如,call printf 指令中的 printf 函數地址,在編譯時是未知的,這裏就會生成一個重定位條目。
  • .rel.data:包含了對已初始化數據節區 (.data) 的重定位信息。例如,int *ptr = &global_var; 這行代碼,ptr 變量在 .data 節區,但它存儲的 global_var 的地址在編譯時也是未知的,這裏也會生成一個重定位條目。

對於一個工程文件的編譯流程,預處理,編譯,彙編,鏈接。假設一個簡單的工程文件包括 a.cb.c 包括靜態庫文件 .a。在彙編階段,彙編器會對各個 .c 文件進行彙編,由於這時各個文件中的函數變量在可執行文件中的地址並沒有被確定,會生成很多重定位條目。在鏈接階段,鏈接器 ld 會讀取所有的 .o 文件和靜態庫文件 .a,把所有同類型的節區合併,讀取各個 .o 文件的符號表,創建一個全局符號表,並且在這個過程中進行符號解析。此時整個可執行文件的地址,符號表基本確定,需要根據重定位條目對一些佔位符進行重定位處理。

  1. 根據 r_info 找到符號表中的對應位置,獲取該符號的最終地址。
  2. 分析 r_info 中的重定位類型,計算出需要寫入的值。
  3. 找到 r_offset 指定的需要重定位的佔位符的位置,執行重定位。

鏈接完成之後,.text.data 中的地址引用都是完整的、可以直接運行的。.rel.text.rel.data 節區通常會被丟棄,因為所有重定位工作已經完成,不再需要它們了。.symtab.strtab 可能會被保留(用於調試),或者被 strip 工具移除以減小文件大小。

動態鏈接

首先,根據 elf 加載流程,動態鏈接的地址重定位是在可執行文件執行過程中在內核分配完內存空間和棧空間之後,調用 ld.so 動態連接器,轉到用户空間加載依賴的共享庫,然後進行運行時地址重定位。最終跳轉程序入口,開始執行程序。

鏈接過程:

  1. 處理內部符號的重定位。
  2. 對於外部符號,因為地址並不確定,所以鏈接器並不會解析它的最終地址。
  3. 鏈接器為外部符號生成動態重定位信息,保存在 .rela.plt(函數)和 .rela.dyn(數據)節區中,對應的動態符號表 .dynsym.symtab 的一個子集,只包含用於動態鏈接的全局符號。動態字符串表為.dynstr。

程序加載過程:

  1. 調用動態鏈接器 ld.so,並執行。
  2. 根據程序頭表中的 PT_DYNAMIC 找到 .dynamic 節區,遍歷所有的 DT_NEEDED 條目,加載所有依賴的共享庫文件,此時主程序所用的庫文件都有了自己的地址。然後根據 DT_RELADT_RELASZDT_JMPREL告訴動態鏈接器 .rela.dyn.rela.plt 的位置。
  3. 處理數據重定位 .rela.dyn
  4. 處理函數重定位 .rela.plt 。使用延遲綁定策略,當函數第一次被調用時,控制權會轉到 PLT 中,PLT 代碼會觸發 ld.so 解析真正的函數地址。解析完成後寫入 PLT,後續調用查表即可。

結語

對整個 elf 可執行文件的加載過程,大多數人其實只需要瞭解即可,知道它的流程是怎麼樣的,重定位和鏈接的關係和設計思想。以及,elf 文件加載和 bin 文件的區別。

總結的未免凌亂,不足之處歡迎討論!


Steady progress!

user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.