庫制作與原理 (下)
1. 目標文件
編譯和鏈接這兩個步驟,在 Windows 下被我們的 IDE 封裝的很完美,我們一般都是一鍵構建非常方便,但一旦遇到錯誤的時候呢,尤其是鏈接相關的錯誤,很多人就束手無策了。在 Linux 下,我們之前也學習過如何通過 gcc 編譯器來完成這一系列操作。
接下來我們深入探討一下編譯和鏈接的整個過程,來更好的理解動靜態庫的使用原理。
先來回顧下什么是編譯呢?
編譯的過程其實就是將我們程序的源代碼翻譯成 CPU 能夠直接運行的機器代碼。比如:在一個源文件 hello.c 里簡單輸出 “helloworld!”,并且調用一個 run 函數,而這個函數被定義在另一個源文件 code.c 中。這里我們就可以調用 gcc -c 來分別編譯這兩個源文件。
// hello.c
#include<stdio.h>
// 聲明run函數,告知編譯器該函數在其他文件中定義
void run();
int main()
{printf("hello world!\n"); // 打印"hello world!"run(); // 調用run函數return 0;
}
// code.c
#include<stdio.h>
// 定義run函數,實現打印"running..."的功能
void run()
{printf("running...\n");
}
// 編譯兩個源文件
// gcc -c 選項表示只編譯不鏈接,生成目標文件(.o)
$ gcc -c hello.c
$ gcc -c code.c
$ ls # 查看當前目錄下的文件
code.c code.o hello.c hello.o
可以看到,在編譯之后會生成兩個擴展名為 .o 的文件,它們被稱作目標文件。要注意的是如果我們修改了一個源文件,那么只需要單獨編譯它這一個,而不需要浪費時間重新編譯整個工程。目標文件是一個二進制的文件,文件的格式是 ELF ,是對二進制代碼的一種封裝。
# 使用file命令查看文件類型,這里查看hello.o的類型
$ file hello.o
hello.o: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped
## file命令用于辨識文件類型。
2. ELF 文件
要理解編譯鏈接的細節,我們不得不了解一下 ELF 文件。其實有以下四種文件其實都是 ELF 文件:
- 可重定位文件(Relocatable File):即 xxx.o 文件。包含適合于與其他目標文件鏈接來創建可執行文件或者共享目標文件的代碼和數據。
- 可執行文件(Executable File):即可執行程序。
- 共享目標文件(Shared Object File):即 xxx.so 文件。
- 內核轉儲 (core dumps):存放當前進程的執行上下文,用于 dump 信號觸發。
補充說明:這四種 ELF 文件分別對應程序開發和運行的不同階段,可重定位文件是編譯后的中間產物,可執行文件是最終能運行的程序,共享目標文件是動態庫,內核轉儲用于程序調試分析。
一個 ELF 文件由以下四部分組成:
- ELF 頭 (ELF header):描述文件的主要特性。其位于文件的開始位置,它的主要目的是定位文件的其他部分。
- 程序頭表 (Program header table):列舉了所有有效的段 (segments) 和他們的屬性。表里記著每個段的開始的位置和位移(offset)、長度,畢竟這些段都是緊密的放在二進制文件中,需要段表的描述信息,才能把他們每個段分割開。
- 節頭表 (Section header table):包含對節 (sections) 的描述。
- 節(Section):ELF 文件中的基本組成單位,包含了特定類型的數據。ELF 文件的各種信息和數據都存儲在不同的節中,如代碼節存儲了可執行代碼,數據節存儲了全局變量和靜態數據等。
補充說明:ELF 文件的這四部分相互配合,ELF 頭是 “導航員”,指引找到程序頭表和節頭表;程序頭表和節頭表分別從運行和鏈接的角度描述文件結構;節則是實際存儲數據和代碼的地方。
最常見的節:
- 代碼節(.text):用于保存機器指令,是程序的主要執行部分。
- 數據節(.data):保存已初始化的全局變量和局部靜態變量。
3. ELF 從形成到加載輪廓
3-1 ELF 形成可執行
- step-1:將多份 C/C++ 源代碼,翻譯成為目標 .o 文件
- step-2:將多份 .o 文件的 section 進行合并
注意:
- 實際合并是在鏈接時進行的,但是并不是這么簡單的合并,也會涉及對庫合并,此處不做過多追究
3-2 ELF 可執行文件加載
- 一個 ELF 會有多種不同的 Section,在加載到內存的時候,也會進行 Section 合并,形成 segment;
- 合并原則:相同屬性,比如:可讀,可寫,可執行,需要加載時申請空間等;
- 這樣,即便是不同的 Section,在加載到內存中,可能會以 segment 的形式,加載到一起;
- 很顯然,這個合并工作也已經在形成 ELF 的時候,合并方式已經確定了,具體合并原則被記錄在了 ELF 的程序頭表 (Program header table) 中.
# 查看可執行程序的section(節)信息,使用readelf -S命令
$ readelf -S a.out
There are 31 section headers, starting at offset 0x19d8:
Section Headers:[Nr] Name Type Address OffsetSize EntSize Flags Link Info Align[ 0] NULL 0000000000000000 000000000000000000000000 0000000000000000 0 0 0[ 1] .interp PROGBITS 0000000000400238 00000238000000000000001c 0000000000000000 A 0 0 1[ 2] .note.ABI-tag NOTE 0000000000400254 000002540000000000000020 0000000000000000 A 0 0 4[ 3] .note.gnu.build-i NOTE 0000000000400274 000002740000000000000024 0000000000000000 A 0 0 4[ 4] .gnu.hash GNU_HASH 0000000000400298 00000298000000000000001c 0000000000000000 A 5 0 8[ 5] .dynsym DYNSYM 00000000004002b8 000002b80000000000000048 0000000000000018 A 6 1 8[ 6] .dynstr STRTAB 0000000000400300 000003000000000000000038 0000000000000000 A 0 0 1[ 7] .gnu.version VERSYM 0000000000400338 000003380000000000000006 0000000000000002 A 5 0 2[ 8] .gnu.version_r VERNEED 0000000000400340 000003400000000000000020 0000000000000000 A 6 1 8[ 9] .rela.dyn RELA 0000000000400360 000003600000000000000018 0000000000000018 A 5 0 8[10] .rela.plt RELA 0000000000400378 000003780000000000000018 0000000000000018 AI 5 24 8[11] .init PROGBITS 0000000000400390 00000390...
# 查看section合并成的segment(段)信息,使用readelf -l命令
$ readelf -l a.out
Elf file type is EXEC (Executable file)
Entry point 0x4003e0 # 程序入口地址
There are 9 program headers, starting at offset 64
Program Headers:Type Offset VirtAddr PhysAddrFileSiz MemSiz Flags AlignPHDR 0x0000000000000040 0x0000000000400040 0x00000000004000400x00000000000001f8 0x00000000000001f8 R E 8INTERP 0x0000000000000238 0x0000000000400238 0x00000000004002380x000000000000001c 0x000000000000001c R 1[Requesting program interpreter: /lib64/ld-linux-x86-64.so.2] # 動態鏈接器路徑LOAD 0x0000000000000000 0x0000000000400000 0x00000000004000000x0000000000000744 0x0000000000000744 R E 200000 # 可讀可執行的段LOAD 0x0000000000000e10 0x0000000000600e10 0x0000000000600e100x0000000000000218 0x0000000000000220 RW 200000 # 可讀可寫的段DYNAMIC 0x0000000000000e28 0x0000000000600e28 0x0000000000600e280x00000000000001d0 0x00000000000001d0 RW 8NOTE 0x0000000000000254 0x0000000000400254 0x00000000004002540x0000000000000044 0x0000000000000044 R 4GNU_EH_FRAME 0x00000000000005a0 0x00000000004005a0 0x00000000004005a00x000000000000004c 0x000000000000004c R 4GNU_STACK 0x0000000000000000 0x0000000000000000 0x00000000000000000x0000000000000000 0x0000000000000000 RW 10GNU_RELRO 0x0000000000000e10 0x0000000000600e10 0x0000000000600e100x00000000000001f0 0x00000000000001f0 R 1Section to Segment mapping: # 節到段的映射關系Segment Sections...00 01 .interp 02 .interp .note.ABI-tag .note.gnu.build-id .gnu.hash .dynsym .dynstr
.gnu.version .gnu.version_r .rela.dyn .rela.plt .init .plt .plt.got .text
.fini .rodata .eh_frame_hdr .eh_frame 03 .init_array .fini_array .jcr .dynamic .got .got.plt .data .bss 04 .dynamic 05 .note.ABI-tag .note.gnu.build-id 06 .eh_frame_hdr 07 08 .init_array .fini_array .jcr .dynamic .got
為什么要將 section 合并成為 segment
- Section 合并的主要原因是為了減少頁面碎片,提高內存使用效率。如果不進行合并,假設頁面大小為 4096 字節(內存塊基本大小,加載,管理的基本單位),如果.text 部分為 4097 字節,.init 部分為 512 字節,那么它們將占用 3 個頁面,而合并后,它們只需 2 個頁面。
- 此外,操作系統在加載程序時,會將具有相同屬性的 section 合并成一個大的 segment,這樣就可以實現不同的訪問權限,從而優化內存管理和權限訪問控制。
對于程序頭表和節頭表又有什么用呢,其實 ELF 文件提供 2 個不同的視圖 / 視角來讓我們理解這兩個部分:
- 鏈接視圖 (Linking view) - 對應節頭表 Section header table
- 文件結構的粒度更細,將文件按功能模塊的差異進行劃分,靜態鏈接分析的時候一般關注的是鏈接視圖,能夠理解 ELF 文件中包含的各個部分的信息。
- 為了空間布局上的效率,將來在鏈接目標文件時,鏈接器會把很多節(section)合并,規整成可執行的段(segment)、可讀寫的段、只讀段等。合并了后,空間利用率就高了,否則,很小的一段,未來物理內存頁浪費太大(物理內存頁分配一般都是整數倍一塊給你,比如 4k),所以,鏈接器趁著鏈接就把小塊們都合并了。
- 執行視圖 (execution view) - 對應程序頭表 Program header table
- 告訴操作系統,如何加載可執行文件,完成進程內存的初始化。一個可執行程序的格式中,一定有 program header table 。
- 說白了就是:一個在鏈接時作用,一個在運行加載時作用。
從 鏈接視圖 來看:
命令 readelf -S hello.o 可以幫助查看ELF文件的節頭表。
text節 :是保存了程序代碼指令的代碼節。
.data節 :保存了初始化的全局變量和局部靜態變量等數據。
.rodata節 :保存了只讀的數據,如一行C語言代碼中的字符串。由于.rodata節是只讀的,所以只能存在于一個可執行文件的只讀段中。因此,只能是在text段(不是data段)中找到.rodata 節。
.BSS節 :為未初始化的全局變量和局部靜態變量預留位置
.symtab節 :SymbolTable符號表,就是源碼里面那些函數名、變量名和代碼的對應關系。
.got.plt節 (全局偏移表-過程鏈接表):.got節保存了全局偏移表。.got節和.plt節一起提供 了對導入的共享庫函數的訪問入口,由動態鏈接器在運行時進行修改。對于GOT的理解,我們后面會說。 使用readelf 命令查看.so文件可以看到該節。
從 執行視圖 來看:
告訴操作系統哪些模塊可以被加載進內存。
加載進內存之后哪些分段是可讀可寫,哪些分段是只讀,哪些分段是可執行的。
我們可以在 ELF頭 中找到文件的基本信息,以及可以看到ELF頭是如何定位程序頭表和節頭表的。例 如我們查看下hello.o這個可重定位文件的主要信息:
// 查看目標文件hello.o的ELF頭信息,使用readelf -h命令
$ readelf -h hello.o
ELF Header:Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00 # ELF文件標識Class: ELF64 # 文件類型為64位ELFData: 2's complement, little endian # 數據采用小端字節序Version: 1 (current)OS/ABI: UNIX - System VABI Version: 0Type: REL (Relocatable file) # 指出ELF文件的類型為可重定位文件Machine: Advanced Micro Devices X86-64 # 該程序需要的體系結構為x86-64Version: 0x1Entry point address: 0x0 # 入口點地址(可重定位文件無入口點,為0)Start of program headers: 0 (bytes into file) # 程序頭表起始位置(可重定位文件無程序頭表,為0)Start of section headers: 728 (bytes into file) # 節頭表起始位置(文件偏移量)Flags: 0x0Size of this header: 64 (bytes) # ELF頭大小Size of program headers: 0 (bytes) # 程序頭表條目大小(可重定位文件為0)Number of program headers: 0 # 程序頭表條目數量(可重定位文件為0)Size of section headers: 64 (bytes) # 節頭表條目大小Number of section headers: 13 # 節頭表條目數量Section header string table index: 12 # 節名字符串表在節頭表中的索引// 查看可執行程序a.out的ELF頭信息
$ gcc *.o
$ readelf -h a.out
ELF Header:Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00 Class: ELF64Data: 2's complement, little endianVersion: 1 (current)OS/ABI: UNIX - System VABI Version: 0Type: DYN (Shared object file) # 類型為動態共享對象(可執行文件)Machine: Advanced Micro Devices X86-64Version: 0x1Entry point address: 0x1060 # 程序入口地址(加載后從該地址開始執行)Start of program headers: 64 (bytes into file) # 程序頭表起始位置Start of section headers: 14768 (bytes into file) # 節頭表起始位置Flags: 0x0Size of this header: 64 (bytes)Size of program headers: 56 (bytes) # 程序頭表條目大小Number of program headers: 13 # 程序頭表條目數量Size of section headers: 64 (bytes)Number of section headers: 31 # 節頭表條目數量Section header string table index: 30
對于 ELF HEADER 這部分來說,我們只用知道其作用即可,它的主要目的是定位文件的其他部分。
- 每個 ELF 區域和文件偏移量之間的關系是什么?
ELF 文件中,每個區域(如 ELF 頭、程序頭表、節頭表、節 / 段等)在文件內的位置通過文件偏移量(offset) 確定,偏移量是區域起始位置相對于文件開頭的字節數。
具體關系:
1.ELF 頭固定位于文件起始位置(偏移量 0),其中記錄了程序頭表和節頭表的偏移量(e_phoff和e_shoff),用于定位這兩個表。
2.程序頭表和節頭表的位置由 ELF 頭中的偏移量字段指定,通過這些偏移量可從文件中讀取表的內容。
3.節(Section) 和段(Segment) 的位置由各自的頭表描述:節頭表中的sh_offset字段記錄每個節在文件中的偏移量;程序頭表中的p_offset字段記錄每個段在文件中的偏移量。
簡言之,偏移量是 ELF 文件內各區域的 “坐標”,通過它可準確定位并讀取各部分內容,是 ELF 文件解析和加載的基礎。
4. 理解鏈接與加載
4-1 靜態鏈接
- 無論是自己的.o, 還是靜態庫中的.o,本質都是把.o 文件進行連接的過程
- 所以:研究靜態鏈接,本質就是研究.o 是如何鏈接的
$ ll # 查看當前目錄文件(初始只有源文件)
-rw-rw-r-- 1 whb whb 62 Oct 31 15:36 code.c
-rw-rw-r-- 1 whb whb 103 Oct 31 15:36 hello.c# 編譯所有.c文件生成目標文件(.o)
$ gcc -c *.c# 鏈接目標文件生成可執行文件main.exe
$ gcc *.o -o main.exe$ ll # 查看生成的文件(增加了目標文件和可執行文件)
-rw-rw-r-- 1 whb whb 62 Oct 31 15:36 code.c
-rw-rw-r-- 1 whb whb 1672 Oct 31 15:46 code.o # code.c編譯生成的目標文件
-rw-rw-r-- 1 whb whb 103 Oct 31 15:36 hello.c
-rw-rw-r-- 1 whb whb 1744 Oct 31 15:46 hello.o # hello.c編譯生成的目標文件
-rwxrwxr-x 1 whb whb 16752 Oct 31 15:46 main.exe* # 鏈接生成的可執行文件
查看編譯后的.o 目標文件的反匯編代碼:
# 對code.o進行反匯編,查看.text節(代碼節)的機器指令
$ objdump -d code.o
code.o: file format elf64-x86-64
Disassembly of section .text:
0000000000000000 <run>: # run函數的匯編代碼0: f3 0f 1e fa endbr64 # 指令:增強指令集安全4: 55 push %rbp # 保存基址指針5: 48 89 e5 mov %rsp,%rbp # 設置棧幀8: 48 8d 3d 00 00 00 00 lea 0x0(%rip),%rdi # 加載字符串地址到rdi寄存器(printf的參數)# f <run+0xf>f: e8 00 00 00 00 callq 14 <run+0x14> # 調用printf函數(地址暫為0,待鏈接時修正)14: 90 nop # 空操作15: 5d pop %rbp # 恢復基址指針16: c3 retq # 函數返回# 對hello.o進行反匯編,查看.text節的機器指令
$ objdump -d hello.o
hello.o: file format elf64-x86-64
Disassembly of section .text:
0000000000000000 <main>: # main函數的匯編代碼0: f3 0f 1e fa endbr64 4: 55 push %rbp 5: 48 89 e5 mov %rsp,%rbp 8: 48 8d 3d 00 00 00 00 lea 0x0(%rip),%rdi # 加載"hello world!\n"地址到rdi(printf參數)# f <main+0xf>f: e8 00 00 00 00 callq 14 <main+0x14> # 調用printf(地址暫為0)14: b8 00 00 00 00 mov $0x0,%eax # 置0到eax寄存器19: e8 00 00 00 00 callq 1e <main+0x1e> # 調用run函數(地址暫為0)1e: b8 00 00 00 00 mov $0x0,%eax # 函數返回值023: 5d pop %rbp 24: c3 retq
- objdump -d 命令:將代碼段(.text)進行反匯編查看
- hello.o 中的 main 函數不認識 printf 和 run 函數
$ cat hello.c
#include<stdio.h>
void run(); // 聲明run函數(僅聲明,未定義)
int main()
{printf("hello world!\n"); // 調用printf(來自標準庫,當前未確定地址)run(); // 調用run(在code.c中定義,當前未確定地址)return 0;
}
- code.o 不認識 printf 函數
$ cat code.c
#include<stdio.h>
void run()
{printf("running...\n"); // 調用printf(來自標準庫,當前未確定地址)
}
我們可以看到這里的 call 指令,它們分別對應之前調用的 printf 和 run 函數,但是你會發現他們的跳轉地址都被設成了 0。那這是為什么呢?
其實就是在編譯 hello.c 的時候,編譯器是完全不知道 printf 和 run 函數的存在的,比如他們位于內存的哪個區塊,代碼長什么樣都是不知道的。因此,編譯器只能將這兩個函數的跳轉地址先暫時設為 0。
這個地址會在哪個時候被修正?鏈接的時候!為了讓鏈接器將來在鏈接時能夠正確定位到這些被修正的地址,在代碼塊(.data)中還存在一個重定位表,這張表將來在鏈接的時候,就會根據表里記錄的地址將其修正。
注意:
- printf 涉及到動態庫,這里暫不做說明
整個過程:
# 查看code.o的代碼段反匯編
$ objdump -d code.o
code.o: file format elf64-x86-64
Disassembly of section .text:
0000000000000000 <run>:0: f3 0f 1e fa endbr64 4: 55 push %rbp5: 48 89 e5 mov %rsp,%rbp8: 48 8d 3d 00 00 00 00 lea 0x0(%rip),%rdi # f
<run+0xf>f: e8 00 00 00 00 callq 14 <run+0x14> # 調用printf,地址暫為014: 90 nop15: 5d pop %rbp16: c3 retq # 查看hello.o的代碼段反匯編
$ objdump -d hello.o
hello.o: file format elf64-x86-64
Disassembly of section .text:
0000000000000000 <main>:0: f3 0f 1e fa endbr64 4: 55 push %rbp5: 48 89 e5 mov %rsp,%rbp8: 48 8d 3d 00 00 00 00 lea 0x0(%rip),%rdi # f
<main+0xf>f: e8 00 00 00 00 callq 14 <main+0x14> # 調用printf,地址暫為014: b8 00 00 00 00 mov $0x0,%eax19: e8 00 00 00 00 callq 1e <main+0x1e> # 調用run,地址暫為01e: b8 00 00 00 00 mov $0x0,%eax23: 5d pop %rbp24: c3 retq至此就是之前的結論:多個.o彼此不知道對方的函數地址# 讀取code.o的符號表(.symtab節),使用readelf -s命令
$ readelf -s code.o
Symbol table '.symtab' contains 13 entries:Num: Value Size Type Bind Vis Ndx Name0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND 1: 0000000000000000 0 FILE LOCAL DEFAULT ABS code.c # 關聯的源文件2: 0000000000000000 0 SECTION LOCAL DEFAULT 1 ...10: 0000000000000000 23 FUNC GLOBAL DEFAULT 1 run # run函數(已定義,在節1中)11: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND _GLOBAL_OFFSET_TABLE_ # 未定義符號12: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND puts # puts函數(printf的實現,未定義)# puts是printf的簡化版,這里顯示UND表示在當前.o文件中未定義(需要從其他文件或庫中查找)# 讀取hello.o的符號表
whb@bite:~/test/test/test$ readelf -s hello.o
Symbol table '.symtab' contains 14 entries:Num: Value Size Type Bind Vis Ndx Name0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND 1: 0000000000000000 0 FILE LOCAL DEFAULT ABS hello.c # 關聯的源文件...10: 0000000000000000 37 FUNC GLOBAL DEFAULT 1 main # main函數(已定義,在節1中)11: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND _GLOBAL_OFFSET_TABLE_12: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND puts # puts未定義13: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND run # run未定義(在code.o中定義)# 讀取可執行程序main.exe的符號表
$ readelf -s main.exe
Symbol table '.dynsym' contains 7 entries: # 動態符號表(與動態庫相關)Num: Value Size Type Bind Vis Ndx Name0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND 1: 0000000000000000 0 NOTYPE WEAK DEFAULT UND _ITM_deregisterTMCloneTab2: 0000000000000000 0 FUNC GLOBAL DEFAULT UND puts@GLIBC_2.2.5 (2) # 找到puts(來自glibc)3: 0000000000000000 0 FUNC GLOBAL DEFAULT UND __libc_start_main@GLIBC_2.2.5 (2)...
Symbol table '.symtab' contains 67 entries: # 完整符號表...52: 0000000000001149 23 FUNC GLOBAL DEFAULT 16 run # run函數已找到,地址為0x1149,位于節16...63: 0000000000001160 37 FUNC GLOBAL DEFAULT 16 main # main函數地址為0x1160,位于節16# 兩個.o進行合并之后,在最終的可執行程序中,run和main的地址都已確定
# 0000000000001149是run函數的最終地址,16表示其所在的節在節頭表中的索引# 讀取可執行程序main.exe的所有節清單
$ readelf -S main.exe
There are 31 section headers, starting at offset 0x39b0:
Section Headers:[Nr] Name Type Address OffsetSize EntSize Flags Link Info Align[ 0] NULL 0000000000000000 00000000...[16] .text PROGBITS 0000000000001060 0000106000000000000001a5 0000000000000000 AX 0 0 16 # 合并后的.text節(索引16)...
Key to Flags:W (write), A (alloc), X (execute), M (merge), S (strings), I (info),L (link order), O (extra OS processing required), G (group), T (TLS),C (compressed), x (unknown), o (OS specific), E (exclude),l (large), p (processor specific)# 結論:hello.o和code.o的.text節被合并到了main.exe的第16個節(.text)中# 驗證call指令的地址是否被修正:查看main.exe的反匯編
$ objdump -d main.exe
main.exe: file format elf64-x86-64
...
Disassembly of section .text:
...
0000000000001149 <run>: # run函數的最終地址1149: f3 0f 1e fa endbr64 114d: 55 push %rbp114e: 48 89 e5 mov %rsp,%rbp1151: 48 8d 3d ac 0e 00 00 lea 0xeac(%rip),%rdi # 加載"running...\n"地址1158: e8 f3 fe ff ff callq 1050 <puts@plt> # 調用puts的地址已修正(0x1050)115d: 90 nop115e: 5d pop %rbp115f: c3 retq 0000000000001160 <main>: # main函數的最終地址1160: f3 0f 1e fa endbr64 1164: 55 push %rbp1165: 48 89 e5 mov %rsp,%rbp1168: 48 8d 3d a0 0e 00 00 lea 0xea0(%rip),%rdi # 加載"hello world!\n"地址116f: e8 dc fe ff ff callq 1050 <puts@plt> # 調用puts的地址已修正(0x1050)1174: b8 00 00 00 00 mov $0x0,%eax1179: e8 cb ff ff ff callq 1149 <run> # 調用run的地址已修正(0x1149)117e: b8 00 00 00 00 mov $0x0,%eax1183: 5d pop %rbp1184: c3 retq
...// 最終結論:
// 1. 兩個.o的代碼段合并到了一起,并進行了統一的編址
// 2. 鏈接的時候,會修改.o中沒有確定的函數地址,在合并完成之后,設置正確的call地址,完成代碼調用
靜態鏈接就是把庫中的.o 進行合并,和上述過程一樣。
所以鏈接其實就是將編譯之后的所有目標文件連同用到的一些靜態庫運行時庫組合,拼裝成一個獨立的可執行文件。其中就包括我們之前提到的地址修正,當所有模塊組合在一起之后,鏈接器會根據我們的.o 文件或者靜態庫中的重定位表找到那些需要被重定位的函數全局變量,從而修正它們的地址。這其實就是靜態鏈接的過程。
所以,鏈接過程中會涉及到對.o 中外部符號進行地址重定位。
4-2 ELF 加載與進程地址空間
4-2-1 虛擬地址 / 邏輯地址
問題:
- 一個 ELF 程序,在沒有被加載到內存的時候,有沒有地址呢?
- 進程 mm_struct、vm_area_struct 在進程剛剛創建的時候,初始化數據從哪里來的?
答案:
- 一個 ELF 程序,在沒有被加載到內存的時候,本來就有地址,當代計算機工作的時候,都采用 “平坦模式” 進行工作。所以也要求 ELF 對自己的代碼和數據進行統一編址,下面是 objdump -S 反匯編之后的代碼
最左側的就是 ELF 的虛擬地址,其實,嚴格意義上應該叫做邏輯地址 (起始地址 + 偏移量),但是我們認為起始地址是 0。也就是說,其實虛擬地址在我們的程序還沒有加載到內存的時候,就已經把可執行程序進行統一編址了.
- 進程 mm_struct、vm_area_struct 在進程剛剛創建的時候,初始化數據從哪里來的?從 ELF 各個 segment 來,每個 segment 有自己的起始地址和自己的長度,用來初始化內核結構中的 [start,end] 等范圍數據,另外在使用詳細地址,填充頁表.
所以:虛擬地址機制,不光光 OS 要支持,編譯器也要支持.
4-2-2 重新理解進程虛擬地址空間
ELF 在被編譯好之后,會把自己未來程序的入口地址記錄在 ELFheader 的 Entry 字段中:
$ gcc *.o # 鏈接生成可執行文件a.out
$ readelf -h a.out # 查看a.out的ELF頭
ELF Header:Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00 Class: ELF64Data: 2's complement, little endianVersion: 1 (current)OS/ABI: UNIX - System VABI Version: 0Type: DYN (Shared object file)Machine: Advanced Micro Devices X86-64Version: 0x1Entry point address: 0x1060 # 程序入口地址(加載后從該地址開始執行)Start of program headers: 64 (bytes into file)
補充說明:程序入口地址是操作系統加載程序后開始執行的第一個指令的地址,由編譯器在編譯鏈接時確定,并存儲在 ELF 頭中,操作系統通過讀取該字段來啟動程序執行。
素材1
素材2
4-3動態鏈接與動態庫加載
4-3-1進程如何看到動態庫
4-3-2進程間如何共享庫的
4-3-3 動態鏈接
4-3-3-1 概要
動態鏈接其實遠比靜態鏈接要常用得多。比如我們查看下 hello
這個可執行程序依賴的動態庫,會發現它就用到了一個 C 動態鏈接庫:
$ ldd hellolinux-vdso.so.1 => (0x00007fffeb1ab000)libc.so.6 => /lib64/libc.so.6 (0x00007ff776af5000)/lib64/ld-linux-x86-64.so.2 (0x00007ff776ec3000)# ldd命令?于打印程序或者庫?件所依賴的共享庫列表。
這里的libc.so
是 C 語言的運行時庫,里面提供了常用的標準輸入輸出、文件操作、字符串處理等功能。那為什么編譯器默認不使用靜態鏈接呢?靜態鏈接會將編譯產生的所有目標文件,連同用到的各種庫,合并形成一個獨立的可執行文件,它不需要額外的依賴就可以運行。照理來說應該更加方便才對,是吧?
靜態鏈接最大的問題在于生成的文件體積大,并且相當耗費內存資源。隨著軟件復雜度的提升,操作系統也越來越臃腫,不同的軟件可能都包含了相同的功能和代碼,顯然會浪費大量的硬盤空間。
這個時候,動態鏈接的優勢就體現出來了:我們可以將需要共享的代碼單獨提取出來,保存成一個獨立的動態鏈接庫,等到程序運行的時候再將它們加載到內存。這樣不但可以節省空間(同一模塊在內存中只需保留一份副本,可被不同進程共享),還便于代碼的更新和維護。
動態鏈接到底是如何工作的?
首先要明確一個結論:動態鏈接實際上將鏈接的整個過程推遲到了程序加載的時候。比如我們運行一個程序時,操作系統會首先將程序的代碼、數據連同它用到的一系列動態庫加載到內存,每個動態庫的加載地址不固定(操作系統會根據當前地址空間使用情況動態分配)。當動態庫被加載到內存、地址確定后,就可以修正動態庫中函數的跳轉地址了。
補充說明:動態鏈接的核心是 “延遲鏈接”,通過將鏈接過程從編譯階段推遲到運行階段,實現了代碼共享和靈活更新,這也是現代操作系統中主流的鏈接方式。
4-3-3-2 我們的可執行程序被編譯器動了手腳
# 查看ls命令依賴的動態庫
$ ldd /usr/bin/lslinux-vdso.so.1 (0x00007fffdd85f000)libselinux.so.1 => /lib/x86_64-linux-gnu/libselinux.so.1 (0x00007f42c025a000)libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f42c0068000)libpcre2-8.so.0 => /lib/x86_64-linux-gnu/libpcre2-8.so.0 (0x00007f42bffd7000)libdl.so.2 => /lib/x86_64-linux-gnu/libdl.so.2 (0x00007f42bffd1000)/lib64/ld-linux-x86-64.so.2 (0x00007f42c02b6000)libpthread.so.0 => /lib/x86_64-linux-gnu/libpthread.so.0 (0x00007f42bffae000)# 查看我們自己編譯的程序main.exe依賴的動態庫
$ ldd main.exe linux-vdso.so.1 (0x00007fff231d6000)libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f197ec3b000)/lib64/ld-linux-x86-64.so.2 (0x00007f197ee3e000)
在 C/C++ 程序中,程序開始執行時并不會直接跳轉到main
函數。實際上,程序的入口點是_start
,這是一個由 C 運行時庫(通常是 glibc)或鏈接器(如 ld)提供的特殊函數。
在_start
函數中,會執行一系列初始化操作,包括:
- 設置堆棧:為程序創建初始的堆棧環境,確保函數調用、局部變量等能正常工作。
- 初始化數據段:將程序的數據段(如全局變量、靜態變量)從初始化數據段復制到內存,并清零未初始化的數據段(.bss 段)。
- 動態鏈接:這是關鍵步驟,
_start
會調用動態鏈接器的代碼解析和加載程序依賴的動態庫,完成符號解析和重定位,確保函數調用和變量訪問能映射到動態庫的實際地址。
動態鏈接器:
- 動態鏈接器(如
ld-linux.so
)負責在程序運行時加載動態庫。- 程序啟動時,動態鏈接器會解析程序的動態庫依賴,并將這些庫加載到內存。
環境變量和配置文件:
- Linux 通過環境變量(如
LD_LIBRARY_PATH
)和配置文件(如/etc/ld.so.conf
)指定動態庫的搜索路徑。- 動態鏈接器加載動態庫時會搜索這些路徑。
緩存文件:
- 系統維護
/etc/ld.so.cache
緩存文件,包含所有已知動態庫的路徑和信息,動態鏈接器會優先搜索該緩存以提高效率。
- 調用
__libc_start_main
:動態鏈接完成后,_start
會調用__libc_start_main
(glibc 提供的函數),執行額外初始化(如設置信號處理函數、初始化線程庫等)。 - 調用
main
函數:最后,__libc_start_main
調用程序的main
函數,程序執行控制權正式交給用戶代碼。 - 處理
main
返回值:main
返回后,__libc_start_main
處理返回值并調用_exit
終止程序。
上述過程對大多數程序員是透明的,但了解這些細節有助于理解程序執行流程和調試問題。
補充說明:main
函數并非程序的真正起點,_start
到main
的初始化過程是操作系統和 C 庫為程序運行做的準備工作,確保程序能在正確的環境中執行。
4-3-3-3 動態庫中的相對地址
動態庫為了支持隨時加載并映射到任意進程的任意位置,對庫中的方法采用相對編址方案(可執行程序也遵循類似的 “平坦模式”,只是直接加載)。
# Ubuntu下查看任意一個庫的反匯編(查看庫中函數的匯編實現)
objdump -S /lib/x86_64-linux-gnu/libc-2.31.so | less# CentOS下查看任意一個庫的反匯編
$ objdump -S /lib64/libc-2.17.so | les
4-3-3-4 我們的程序,怎么和庫具體映射起來的
注意:
- 動態庫也是一個文件,訪問前需要被加載和打開。
- 進程找到動態庫的本質:通過文件操作將動態庫映射到進程的地址空間,再通過虛擬地址跳轉訪問庫函數。
下圖解釋了程序與動態庫的映射關系:
4-3-3-6 全局偏移量表 GOT(Global Offset Table)
注意:
- 程序運行前,需先加載并映射所有依賴的庫,且所有庫的起始虛擬地址需提前確定。
- 然后對程序中庫函數調用的地址進行修改(加載地址重定位)。
- 但代碼區在進程中是只讀的,如何修改地址?
動態鏈接的解決方案是:在.data
段(可執行程序或庫自身)中專門預留一片區域存放函數的跳轉地址,即全局偏移表 GOT。表中每一項都是本運行模塊要引用的全局變量或函數的地址。
- 由于
.data
區域是可讀寫的,因此支持動態修改。
# 查看可執行文件a.out的節信息,找到GOT表
$ readelf -S a.out
...[24] .got PROGBITS 0000000000003fb8 00002fb80000000000000048 0000000000000008 WA 0 0 8 # WA表示可寫(W)、已分配(A)
...# 查看程序頭表,可見GOT與.data合并為一個段加載
$ readelf -l a.out
...05 .init_array .fini_array .dynamic .got .data .bss
...
- 由于代碼段只讀,不能直接修改代碼,但有了 GOT 表,代碼可被所有進程共享。不同進程中,動態庫的絕對地址和相對位置不同,因此每個進程的每個動態庫都有獨立的 GOT 表,進程間不能共享 GOT 表。
- 在單個
.so
中,GOT 表與.text
的相對位置固定,可利用 CPU 的相對尋址找到 GOT 表。 - 調用函數時會首先查詢 GOT 表,根據表中的地址跳轉,這些地址在動態庫加載時會被修改為真正的地址。
- 這種方式實現的動態鏈接稱為 PIC(地址無關代碼)。動態庫無需修改即可被加載到任意內存地址運行,并能被所有進程共享,這也是編譯動態庫時需指定
-fPIC
參數的原因(PIC = 相對編址 + GOT)。
# 查看可執行文件a.out的反匯編,觀察GOT的使用
$ objdump -S a.out
...
# puts函數的PLT入口(間接跳轉至GOT表中的地址)
0000000000001050 <puts@plt>:1050: f3 0f 1e fa endbr64 1054: f2 ff 25 75 2f 00 00 bnd jmpq *0x2f75(%rip) # 跳轉到GOT表中0x3fd0位置(puts的實際地址)
3fd0 <puts@GLIBC_2.2.5>
...
...
# main函數中調用puts的指令
0000000000001149 <main>:1149: f3 0f 1e fa endbr64 114d: 55 push %rbp114e: 48 89 e5 mov %rsp,%rbp1151: 48 8d 3d ac 0e 00 00 lea 0xeac(%rip),%rdi # 加載字符串地址到rdi(puts的參數)
2004 <_IO_stdin_used+0x4>1158: e8 f3 fe ff ff callq 1050 <puts@plt> # 調用puts的PLT入口
...
備注:
- PLT(Procedure Linkage Table,過程鏈接表)是與 GOT 配合使用的結構,用于實現延遲綁定(函數第一次被調用時才解析地址),后續會詳細說明。
補充說明:GOT 表是動態鏈接的核心機制,通過將地址存儲在可讀寫的數據段,解決了代碼段只讀無法修改的問題,同時結合相對編址實現了動態庫的位置無關性。
4-3-3-7 庫間依賴
注意:
- 不僅可執行程序會調用庫。
- 庫也會調用其他庫!庫之間存在依賴關系,庫與庫之間的調用同樣通過地址無關的方式實現。
- 庫中也有
.GOT
,與可執行程序的機制相同,這也是所有文件都采用 ELF 格式的原因。
由于 GOT 表中的映射地址會在運行時修改,我們可以通過 gdb 調試觀察 GOT 表的地址變化(有興趣的同學可參考 “使用 gdb 調試 GOT”)。
動態鏈接在程序加載時需要對大量函數進行重定位,這一過程較耗時。為降低開銷,操作系統采用了延遲綁定(PLT,過程連接表)優化:將函數重定位推遲到第一次被調用時(多數動態庫函數可能從未被使用)。
思路是:GOT 中的跳轉地址默認指向一段輔助代碼(樁代碼 /stub)。第一次調用函數時,這段代碼負責查詢真正的函數地址并更新 GOT 表;再次調用時,直接跳轉到動態庫中真正的函數實現。
總而言之,動態鏈接將符號查詢、地址重定位等鏈接過程從編譯時推遲到了程序運行時。雖然犧牲了一定的性能和加載時間,但能更有效地利用磁盤空間和內存資源,極大方便了代碼的更新和維護,更關鍵的是實現了二進制級別的代碼復用。
解析依賴關系的過程,本質上是加載并完善各模塊間 GOT 表的過程。
4-3-4 總結
- 靜態鏈接的出現提高了程序的模塊化水平。對于大型項目,不同開發者可獨立測試和開發自己的模塊,通過靜態鏈接生成最終的可執行文件。
- 靜態鏈接會將所有目標文件和用到的庫合并成一個獨立的可執行文件,期間會修正模塊間函數的跳轉地址(編譯重定位 / 靜態重定位)。
- 動態鏈接將鏈接過程推遲到程序加載時:運行程序時,操作系統先將程序的代碼、數據及依賴的動態庫加載到內存(動態庫加載地址不固定,但會映射到進程地址空間),再通過
.GOT
方式調用(運行重定位 / 動態地址重定位)。
補充說明:靜態鏈接和動態鏈接各有適用場景,靜態鏈接適合對運行環境有嚴格控制、需獨立部署的場景;動態鏈接適合需共享代碼、頻繁更新庫的場景,二者共同構成了現代程序的鏈接機制。