目錄
一、什么是庫?
?1. C標準庫(libc)
2. C++標準庫(libstdc++)
?二、靜態庫
1. 靜態庫的生成
2. 靜態庫的使用
三、動態庫
1. 動態庫的生成
2. 動態庫的使用
3.?庫運行的搜索路徑。
(1)原因分析
(2)解決方案
①?設置 LD_LIBRARY_PATH
② 將庫復制到系統路徑
③ 復制.so文件到系統庫目錄
④ 創建軟鏈接到系統庫文件
四、外部庫(補充)
五、目標文件(原理部分)
六、EIL文件
1. ELF文件的四種格式
2. ELF文件的核心結構
(1)ELF Header(ELF頭)
(2)Program Header Table(程序頭表)
(3)Section Header Table(節頭表)
(4)Sections(節)
七、ELF從加載到輪廓
1. ELF形成可執行文件
2. ELF可執行文件加載
八、理解鏈接與加載
1. 靜態鏈接
(1)編譯階段
(2)重定位表
?(3)靜態鏈接階段:地址修正
(4)總結
2. ELF加載和進程地址空間
(1)邏輯地址 / 虛擬地址?
(2)重新理解進程虛擬地址空間
(3)靜態鏈接庫在內存加載?
3. 動態鏈接與動態庫加載
(1)動態庫加載
(2)進程間共享動態庫
(3)動態鏈接
① 動態鏈接概要
② 可執行程序被編譯器動了手腳
③ 動態庫中的相對地址
④ 程序與動態庫映射
⑤ 程序調用庫函數
⑥ 全局偏移量表GOT?
⑦ 動態庫間依賴
4. 總結
一、什么是庫?
庫是寫好的現有的,成熟的,可以復用的代碼。現實中每個程序都要依賴很多基礎的底層庫,不可能每個人的代碼都從零開始,因此庫的存在意義非同尋常。
本質上來說庫是一種可執行代碼的二進制形式,可以被操作系統載入內存執行。
庫有兩種:靜態庫.a[Linux]、.lib[windows];動態庫.so[Linux]、.dll[windows]
?1. C標準庫(libc)
系統 | 動態庫(.so) | 靜態庫(.a) |
---|---|---|
Ubuntu | /lib/x86_64-linux-gnu/libc-2.31.so | /lib/x86_64-linux-gnu/libc.a |
-rwxr-xr-x 1 root root 2029592 May 1 02:20 | -rw-r--r-- 1 root root 5747594 May 1 02:20 | |
CentOS | /lib64/libc-2.17.so | /lib64/libc.a |
-rwxr-xr-x 1 root root 2156592 Jun 4 23:05 | -rw-r--r-- 1 root root 5105516 Jun 4 23 |
2. C++標準庫(libstdc++)
系統 | 動態庫(.so) | 靜態庫(.a) |
---|---|---|
Ubuntu | /usr/lib/gcc/x86_64-linux-gnu/9/libstdc++.so | /usr/lib/gcc/x86_64-linux-gnu/9/libstdc++.a |
符號鏈接→?../../../x86_64-linux-gnu/libstdc++.so.6 | -rw-r--r-- (文件大小未顯示) | |
CentOS | /lib64/libstdc++.so.6 | /usr/lib/gcc/x86_64-redhat-linux/4.8.2/libstdc++.a |
符號鏈接→?libstdc++.so.6.0.19 | -rw-r--r-- 1 root root 2932366 Sep 30 2020 |
?二、靜態庫
? 靜態庫(.a):程序在編譯鏈接的時候把庫的代碼鏈接到可執行文件中,程序運行的時候將不再需要靜態庫。
? 一個可執行程序可能用到許多的庫,這些庫運行有的是靜態庫,有的是動態庫,而我們的編譯默認為動態鏈接庫,只有在該庫下找不到動態.so的時候才會采用同名靜態庫。我們也可以使用 gcc 的 -static 強制設置鏈接靜態庫,一旦 -static 就必須存在對應的靜態庫。
1. 靜態庫的生成
mystdio.c?mymakstring.c是我們自主實現的源文件。現將其編譯為目標文件(.o),再使用ar打包為靜態庫(libmystdio.a)。
output:將頭文件(.h)和靜態庫(.a)打包成 stdc.tgz。步驟:
(1)創建目錄 stdc/include 和 stdc/lib。
(2)復制頭文件到 include/,靜態庫到 lib/。(3)用 tar -czf 打包成 stdc.tgz(gzip 壓縮)。
ar 是歸檔工具,參數說明:
r:替換已存在的文件
c:創建庫(如果不存在)
s:寫入索引(加速鏈接)
t 選項:列出靜態庫中的文件
v 選項:詳細信息(verbose)
[Makefile]
libmystdio.a:mystdio.o mymakstring.o@ar -rc $@ $^@echo "build $^ to $@ ... done"
%.o:%.c@gcc -c $<@echo "compling $< to $@ ... done"
.PHONY:clean
clean:@rm -rf *.a *.o stdc*@echo "clean ... done"
.PHONY:output
output:@mkdir -p stdc/include@mkdir -p stdc/lib@cp -f *.h stdc/include@cp -f *.a stdc/lib@tar -czf stdc.tgz stdc@echo "output stdc ... done"
2. 靜態庫的使用
任意目錄下新建Main.c,使用我們自己實現的庫里的函數調用。
// 再新建目錄lib下的新文件Main.c
#include "mystdio.h"
#include "mystring.h"int main()
{MYFILE *filep = MyFopen("log.txt", "a");if (!filep){printf("MyFopen error!\n");return 1;}// const char *msg = "hello MyFwrite\n"; // 行刷新// MyFwrite(filep, msg, strlen(msg));int cnt = 5;while (cnt--){const char *msg = "hello MyFwrite!"; // 沒有'\n',不滿足刷新條件,待在緩沖區MyFwrite(filep, msg, strlen(msg));// 強制刷新緩沖區MyFflush(filep);printf("buffer:%s\n", filep->outbuffer); // 打印緩沖區內容sleep(1);}MyFcolse(filep);const char *str = "hello!\n";printf("my_strlen: %d\n", my_strlen(str));return 0;
}
場景1:頭文件和庫文件安裝到系統路徑下
$ gcc Main.c -lmystdio場景2:頭文件和庫文件和我們自己的源文件在同一個路徑下
$ gcc Main.c -L. -lmystdio場景3:頭文件和庫文件有自己的獨立路徑
$ gcc Main.c -I頭文件路徑 -L庫文件路徑 -lmystdio
-L:指定庫路徑。
-I:指定頭文件搜索路徑。
-l:指定庫名。
? 測試目標文件生成后,靜態庫刪掉,程序照樣可以運行。
? 關于 -static 選項,稍后介紹。
? 庫文件名稱和引入庫的名稱:去掉前綴 lib,去掉后綴.so、.a,如:libc.so -> c
$ tree .
.
├── lib
│ └── Main.c
├── Makefile
├── mystdio.c
├── mystdio.h
├── mystring.c
├── mystring.h
└── usercode.c
2 directories, 10 files$ make # 制作并打包靜態庫
compling mystdio.c to mystdio.o ... done
compling mystring.c to mystring.o ... done
build mystdio.o mystring.o to libmystdio.a ... done$ cd lib
$ gcc Main.c -I../ -L../ -lmystdio # 鏈接靜態庫
$ ./a.out
buffer:hello MyFwrite!
buffer:hello MyFwrite!
buffer:hello MyFwrite!
^C$ tree ../
../
├── lib
│ ├── a.out
│ └── Main.c
├── libmystdio.a
├── Makefile
├── mystdio.c
├── mystdio.h
├── mystdio.o
├── mystring.c
├── mystring.h
├── mystring.o
└── usercode.c2 directories, 11 files
三、動態庫
? 動態庫(.so):程序在運行的時候才去鏈接動態庫的代碼,多個程序共享使用庫的代碼。
? 一個與動態庫鏈接的可執行文件僅僅包含它用到的函數入口地址的一個表,而不是外部函數所在目標文件的整個機器碼。
? 在可執行文件開始運行以前,外部函數的機器碼由操作系統從磁盤上的該動態庫中復制到內存中,這個過程稱為動態鏈接(dynamic linking)。
? 動態庫可以在多個程序間共享,所以動態鏈接使得可執行文件更小,節省了磁盤空間。操作系統采用虛擬內存機制允許物理內存中的一份動態庫被要用到該庫的所有進程共用,節省了內存和磁盤空間。
1. 動態庫的生成
(1)將 mystdio.o 和 mystring.o 鏈接成動態庫 libmystdio.so。
-shared:生成動態庫/共享庫格式(而不是可執行文件)。
庫名規則:libxxx.so
(2)將 .c 文件編譯為位置無關代碼(PIC)的目標文件(.o)。
-fPIC:生成位置無關代碼(Position-Independent Code),動態庫必需。
$<:當前依賴的源文件(如 mystdio.c)。
(3)將頭文件(.h)和動態庫(.so)打包成 stdc.tgz。
[Makefile]
libmystdio.so:mystdio.o mystring.o@gcc -o $@ $^ -shared@echo "build $^ to $@ ... done"
%.o:%.c@gcc -fPIC -c $< @echo "compling $< to $@ ... done"
.PHONY:clean
clean:@rm -rf *.so *.o stdc*@echo "clean ... done"
.PHONY:output
output: # 把頭文件和動態庫打包壓縮成 stdc.tgz@mkdir -p stdc/include@mkdir -p stdc/lib@cp -f *.h stdc/include@cp -f *.so stdc/lib@tar -czf stdc.tgz stdc@echo "output stdc ... done"
2. 動態庫的使用
場景1:頭文件和庫文件安裝到系統路徑下
$ gcc main.c -lmystdio場景2:頭文件和庫文件和我們自己的源文件在同一個路徑下
$ gcc main.c -L. -lmymath // 從左到右搜索-L指定的目錄場景3:頭文件和庫文件有自己的獨立路徑
$ gcc main.c -I頭文件路徑 -L庫文件路徑 -lmymath
$ gcc usercode.c -I../stdc/include -L../stdc/lib -lmystdio
$ ll
total 28
drwxrwxr-x 2 zyt zyt 4096 May 14 18:47 ./
drwxrwxr-x 4 zyt zyt 4096 May 14 18:45 ../
-rwxrwxr-x 1 zyt zyt 16256 May 14 18:47 a.out*
-rw-rw-r-- 1 zyt zyt 742 May 14 18:29 usercode.c
# 當我們執行代碼時,卻顯示動態庫沒有被加載
$ ./a.out
./a.out: error while loading shared libraries: libmystdio.so: cannot open shared object file: No such file or directory
# 用ldd查看庫或可執行程序的依賴,發現libstdio.so找不到
$ ldd a.outlinux-vdso.so.1 (0x00007ffe3f0f1000)libmystdio.so => not foundlibc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x0000769ff5a00000)/lib64/ld-linux-x86-64.so.2 (0x0000769ff5cf4000)
我們按照方法執行后發現,動態庫沒有被加載。 這是為什么?
這個問題是因為系統在運行時找不到動態庫 libmystdio.so 的位置。雖然我們在編譯時通過 -L 指定了庫的路徑,但 -L 只對編譯時的鏈接器有效,而運行時的動態鏈接器(ld.so)并不知道這個路徑。
3.?庫運行的搜索路徑。
(1)原因分析
對于前面的問題:
$ ldd a.outlinux-vdso.so.1 (0x00007ffe3f0f1000)libmystdio.so => not foundlibc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x0000769ff5a00000)/lib64/ld-linux-x86-64.so.2 (0x0000769ff5cf4000)
? 編譯時:-L../stdc/lib 告訴鏈接器在哪里找 libmystdio.so,因此編譯能成功。
? 運行時:系統默認只在標準路徑(如 /lib、/usr/lib)和 LD_LIBRARY_PATH(環境變量)中搜索動態庫,而你的庫在自定義路徑 ../stdc/lib 中,導致加載失敗。
(2)解決方案
①?設置 LD_LIBRARY_PATH
LD_LIBRARY_PATH是環境變量。作用是將庫所在路徑添加到動態鏈接器的搜索路徑中。
缺點:僅在當前終端會話有效,重啟后需重新設置。
$ echo $LD_LIBRARY_PATH # 初始是空$ export LD_LIBRARY_PATH=${LD_LIBRARY_PATH}:/home/zyt/linux-journey-log/code_25_5_14/dynamiclib/stdc/lib
$ echo ${LD_LIBRARY_PATH}
:/home/zyt/linux-journey-log/code_25_5_14/dynamiclib/stdc/lib
$ ldd a.out
linux-vdso.so.1 (0x00007ffd45df0000)
libmystdio.so => /home/zyt/linux-journey-log/code_25_5_14/dynamiclib/stdc/lib/libmystdio.so (0x00007f8a1b234000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f8a1ae00000)
/lib64/ld-linux-x86-64.so.2 (0x00007f8a1b434000)
② 將庫復制到系統路徑
ldconfig方案:配置/etc/ld.so.conf.d/ 。該目錄包含自定義的庫路徑配置文件,系統啟動時會加載這些路徑到動態鏈接器的緩存中。
# /etc/ld.so.conf.d/下創建一個自定義的配置文件
$ sudo touch /etc/ld.so.conf.d/zyt.conf
[sudo] password for zyt:
$ ll /etc/ld.so.conf.d
total 36
drwxr-xr-x 2 root root 4096 May 14 19:10 ./
drwxr-xr-x 122 root root 12288 May 8 06:37 ../
-rw-r--r-- 1 root root 38 Jan 22 2024 fakeroot-x86_64-linux-gnu.conf
-rw-r--r-- 1 root root 44 Aug 2 2022 libc.conf
-rw-r--r-- 1 root root 100 Mar 30 2024 x86_64-linux-gnu.conf
-rw-r--r-- 1 root root 0 May 14 19:10 zyt.conf
-rw-r--r-- 1 root root 56 Jan 29 01:07 zz_i386-biarch-compat.conf
-rw-r--r-- 1 root root 58 Jan 29 01:07 zz_x32-biarch-compat.conf# 填寫動態庫路徑
$ sudo vim /etc/ld.so.conf.d/zyt.conf
$ cat /etc/ld.so.conf.d/zyt.conf
/home/zyt/linux-journey-log/code_25_5_14/dynamiclib/stdc/lib# 更新庫緩存,重新加載庫搜索路徑,使生效
$ sudo ldconfig
$ ldd a.outlinux-vdso.so.1 (0x00007ffc84daa000)libmystdio.so => /home/zyt/linux-journey-log/code_25_5_14/dynamiclib/stdc/lib/libmystdio.so (0x000071e2461f0000)libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x000071e245e00000)/lib64/ld-linux-x86-64.so.2 (0x000071e2461fc000)
③ 復制.so文件到系統庫目錄
拷貝.so文件到系統共享庫路徑下,一般是/usr/lib,/usr/local/lib,/lib64。(推薦)
# 1. 復制動態庫到/usr/local/lib(需要sudo權限)
$ sudo cp /home/zyt/linux-journey-log/code_25_5_15/dynamiclib/stdc/lib/libmystdio.so /usr/local/lib/
# 2. 設置正確的文件權限
$ sudo chmod 755 /usr/local/lib/libmystdio.so
# 3. 更新動態鏈接器緩存
$ sudo ldconfig
# 4. 驗證是否成功
$ ldconfig -p | grep libmystdio.solibmystdio.so (libc6,x86-64) => /usr/local/lib/libmystdio.so
$ ldd a.outlinux-vdso.so.1 (0x00007ffede579000)libmystdio.so => /usr/local/lib/libmystdio.so (0x00007b98a6af5000)libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007b98a6800000)/lib64/ld-linux-x86-64.so.2 (0x00007b98a6b09000)
④ 創建軟鏈接到系統庫文件
適用于庫文件需要經常更新,不想復制的場景。多個版本共存時,可以通過軟鏈接切換。
# 1. 創建軟鏈接(需要sudo權限)
$ sudo ln -s /home/zyt/linux-journey-log/code_25_5_15/dynamiclib/stdc/lib/libmystdio.so /usr/local/lib/libmystdio.so
[sudo] password for zyt:
# 2. 更新動態鏈接器緩存
$ sudo ldconfig
# 3. 驗證軟鏈接
$ ls -l /usr/local/lib/libmystdio.so
lrwxrwxrwx 1 root root 74 May 15 12:06 /usr/local/lib/libmystdio.so -> /home/zyt/linux-journey-log/code_25_5_15/dynamiclib/stdc/lib/libmystdio.so
$ ldd a.outlinux-vdso.so.1 (0x00007ffdfb1f9000)libmystdio.so => /usr/local/lib/libmystdio.so (0x0000701c1ab07000)libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x0000701c1a800000)/lib64/ld-linux-x86-64.so.2 (0x0000701c1ab1b000)
四、外部庫(補充)
推薦一個好玩的圖形庫ncurses,使用指南:ncurse編程指南_ncurses教程-CSDN博客
// 安裝
// Centos
$ sudo yum install -y ncurses-devel
// ubuntu
$ sudo apt install -y libncurses-dev
五、目標文件(原理部分)
編譯和鏈接這兩個步驟,在Windows下被我們的IDE封裝的很完美,我們一般都是一鍵構建非常方便,但一旦遇到錯誤的時候呢,尤其是鏈接相關的錯誤,很多人就束手無策了。在Linux下,我們之前也學習過如何通過gcc編譯器來完成這一系列操作。
接下來我們深入探討一下編譯和鏈接的整個過程,來更好地理解動靜態庫的使用原理。
先來回顧下什么是編譯呢?編譯的過程其實就是將我們程序的源代碼翻譯成CPU能夠直接運行的機器代碼。關鍵點:每個源文件獨立編譯,生成對應的目標文件。如果函數定義在其他文件中(如 hello.c 調用 code.c 中的 run()),編譯器會暫時 標記未解析的符號(需鏈接階段處理)。使用 -c 選項表示“只編譯不鏈接”。
比如:在一個源文件hello.c里簡單輸出“hello world!”,并且調用一個run函數,而這個函數被定義在另一個原文件code.c中。這里我們就可以調用gcc -c來分別編譯這兩個原文件。
// hello.c
#include <stdio.h>
void run();
int main()
{printf("hello world!\n");run();return 0;
}
// code.c
#include <stdio.h>
void run()
{printf("running...\n");
}$ gcc -c hello.c
$ gcc -c code.c
$ ls
code.c code.o hello.c hello.o
可以看到,在編譯之后會生成兩個擴展名為.o的文件,它們被稱為目標文件。要注意的是如果修改了一個原文件,那么只需要單獨編譯它這一個,而不需要浪費時間重新編譯整個工程。目標文件是一個二進制的文件,文件的格式是ELF,是對二進制代碼的一種封裝。
# file命令用于辨識文件類型
$ file hello.o
hello.o: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped
$ file code.o
code.o: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped
六、EIL文件
1. ELF文件的四種格式
類型 | 說明 | 示例 |
---|---|---|
可重定位文件 | 包含代碼和數據,需鏈接生成可執行文件或共享庫 | .o ?文件(hello.o ) |
可執行文件 | 可直接加載運行的程序,即可執行程序 | ./a.out |
共享目標文件 | 動態鏈接庫,運行時加載 | .so ?文件(libc.so ) |
核心轉儲文件 | 進程崩潰時的內存快照(如?segmentation fault ?生成),存放當前進程上下文,用于dump信號觸發。 | core ?文件 |
2. ELF文件的核心結構
ELF文件由以下四部分組成,可通過 readelf 工具查看。
(1)ELF Header(ELF頭)
描述文件的主要特性。文件的類型(如可執行/共享庫)、目標機器架構(如x86-64)、入口地址、節頭表和程序頭表的位置等。其位于文件的開始位置,它的主要目的是定位文件的其他部分。
$ readelf -h hello.o # 查看目標文件的ELF頭
關鍵字段:
Type: REL(可重定位文件)、EXEC(可執行文件)、DYN(共享庫)。
Entry point address: 可執行文件的入口地址(如 _start)。
(2)Program Header Table(程序頭表)
列舉了所有有效的段(segments)和他們的屬性。表里記著每個段的開始的位置和位移(offset)、長度,畢竟這些段,都是緊密的放在二進制文件中,需要段表的描述信息,才能把他們每個段分割開。作用:指導操作系統如何加載可執行文件或共享庫(如哪些段需加載到內存、權限設置)。
注意:僅存在于可執行文件和共享庫(可重定位文件如 .o 沒有此表)。
$ readelf -l a.out # 查看可執行文件的程序頭表(segment)
關鍵段(Segments):
LOAD: 需加載到內存的代碼段(.text)、數據段(.data、.bss)。
INTERP: 動態鏈接器路徑(如 /lib64/ld-linux-x86-64.so.2)。
(3)Section Header Table(節頭表)
描述所有節(Sections)的信息(位置、大小、類型),供鏈接和調試使用。
$ readelf -S hello.o # 查看目標文件的節頭表
關鍵節(Sections):
.text: 機器指令(代碼),是程序的主要執行部分。
.data: 已初始化的全局/靜態變量。
.bss: 未初始化的全局/靜態變量(在文件中不占空間,預留位置,加載時清零)。
.symtab: 符號表(函數/變量名及其地址的對應關系)。
.rel.text: 重定位信息(需鏈接器修正的代碼地址)。
(4)Sections(節)
ELF文件中的基本組成單位,包含了特定類型的數據。ELF文件的各種信息和數據都存儲在不同的節中,如代碼節存儲了可執行代碼,數據節存儲了全局變量和靜態數據等。
節與段的關系:
鏈接視角:使用節(如 .text、.data)。
執行視角:操作系統按段(如 LOAD)加載,一個段可能包含多個節(如將 .text 和 .rodata 合并到只讀代碼段)。稍后講
七、ELF從加載到輪廓
1. ELF形成可執行文件
Step-1-編譯:將 .c/.cpp 源代碼文件編譯成 可重定位目標文件。(預處理-編譯-匯編)
Step-2-鏈接:將多個 .o 文件合并,生成 可執行文件(a.out) 或 共享庫(.so)。
具體步驟
(1)符號解析(Symbol Resolution)
① 檢查所有 .o 文件的 .symtab,確保每個符號(函數/變量)有且僅有一個定義。
② 如果某個符號未定義(如 printf),鏈接器會去 靜態庫(.a) 或 動態庫(.so) 中查找。
(2)節(Section)合并
將多個 .o 文件的同名節合并:(也會涉及庫的合并)
? 所有 .text → 合并到可執行文件的 .text
? 所有 .data → 合并到可執行文件的 .data
? 所有 .bss → 合并到可執行文件的 .bss
(3)重定位
① 修正代碼和數據中的 地址引用(如 call printf 的真實地址)。
② 使用 .rel.text 和 .rel.data 表計算最終地址。
(4)生成可執行文件
最終生成 可執行 ELF 文件(a.out),包含:
? 程序頭表(Program Header Table):告訴操作系統如何加載程序。
? 段(Segments):如 LOAD(代碼段、數據段)、DYNAMIC(動態鏈接信息)。
2. ELF可執行文件加載
? 一個ELF會有多種不同的Section(節),在加載到內存的時候,也會進行Section合并,形成segment(段)。
? 合并原則:相同屬性,比如:可讀,可寫,可執行,需要加載時申請空間等。【某些 Section(如 .debug_info)僅用于調試,不參與運行,因此不會被映射到任何 Segment。】
權限 | 典型 Section | 合并后的 Segment |
---|---|---|
R E (可讀可執行) | .text 、.plt 、.rodata | LOAD ?代碼段 |
R W (可讀可寫) | .data 、.bss 、.got | LOAD ?數據段 |
R (只讀) | .eh_frame 、.dynstr | 可能合并到代碼段 |
? 這樣,即便是不同的Section,在加載到內存中,可能會以segment的形式,加載到一起。
? 很顯然,這個合并工作也已經在形成ELF的時候,合并方式已經確定了(鏈接器(ld)根據鏈接腳本的規則合并 Section 為 Segment。),具體合并原則被記錄在了ELF的程序頭表(Program header table)中。
$ readelf -S hello.o # 查看可執行程序的section
There are 14 section headers, starting at offset 0x298:Section Headers:[Nr] Name Type Address OffsetSize EntSize Flags Link Info Align[ 0] NULL 0000000000000000 000000000000000000000000 0000000000000000 0 0 0[ 1] .text PROGBITS 0000000000000000 000000400000000000000028 0000000000000000 AX 0 0 1[ 2] .rela.text RELA 0000000000000000 000001c00000000000000048 0000000000000018 I 11 1 8[ 3] .data PROGBITS 0000000000000000 000000680000000000000000 0000000000000000 WA 0 0 1[ 4] .bss NOBITS 0000000000000000 000000680000000000000000 0000000000000000 WA 0 0 1[ 5] .rodata PROGBITS 0000000000000000 00000068000000000000000d 0000000000000000 A 0 0 1[ 6] .comment PROGBITS 0000000000000000 00000075000000000000002c 0000000000000001 MS 0 0 1[ 7] .note.GNU-stack PROGBITS 0000000000000000 000000a10000000000000000 0000000000000000 0 0 1[ 8] .note.gnu.pr[...] NOTE 0000000000000000 000000a80000000000000020 0000000000000000 A 0 0 8[ 9] .eh_frame PROGBITS 0000000000000000 000000c80000000000000038 0000000000000000 A 0 0 8[10] .rela.eh_frame RELA 0000000000000000 000002080000000000000018 0000000000000018 I 11 9 8[11] .symtab SYMTAB 0000000000000000 0000010000000000000000a8 0000000000000018 12 4 8[12] .strtab STRTAB 0000000000000000 000001a80000000000000017 0000000000000000 0 0 1[13] .shstrtab STRTAB 0000000000000000 000002200000000000000074 0000000000000000 0 0 1
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),D (mbind), l (large), p (processor specific)$ readelf -l a.out # 查看section合并后的segmentElf file type is DYN (Position-Independent Executable file)
Entry point 0x1060
There are 13 program headers, starting at offset 64Program Headers:Type Offset VirtAddr PhysAddrFileSiz MemSiz Flags AlignPHDR 0x0000000000000040 0x0000000000000040 0x00000000000000400x00000000000002d8 0x00000000000002d8 R 0x8INTERP 0x0000000000000318 0x0000000000000318 0x00000000000003180x000000000000001c 0x000000000000001c R 0x1[Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]LOAD 0x0000000000000000 0x0000000000000000 0x00000000000000000x0000000000000628 0x0000000000000628 R 0x1000LOAD 0x0000000000001000 0x0000000000001000 0x00000000000010000x0000000000000199 0x0000000000000199 R E 0x1000LOAD 0x0000000000002000 0x0000000000002000 0x00000000000020000x0000000000000124 0x0000000000000124 R 0x1000LOAD 0x0000000000002db8 0x0000000000003db8 0x0000000000003db80x0000000000000258 0x0000000000000260 RW 0x1000DYNAMIC 0x0000000000002dc8 0x0000000000003dc8 0x0000000000003dc80x00000000000001f0 0x00000000000001f0 RW 0x8NOTE 0x0000000000000338 0x0000000000000338 0x00000000000003380x0000000000000030 0x0000000000000030 R 0x8NOTE 0x0000000000000368 0x0000000000000368 0x00000000000003680x0000000000000044 0x0000000000000044 R 0x4GNU_PROPERTY 0x0000000000000338 0x0000000000000338 0x00000000000003380x0000000000000030 0x0000000000000030 R 0x8GNU_EH_FRAME 0x000000000000201c 0x000000000000201c 0x000000000000201c0x000000000000003c 0x000000000000003c R 0x4GNU_STACK 0x0000000000000000 0x0000000000000000 0x00000000000000000x0000000000000000 0x0000000000000000 RW 0x10GNU_RELRO 0x0000000000002db8 0x0000000000003db8 0x0000000000003db80x0000000000000248 0x0000000000000248 R 0x1Section to Segment mapping:Segment Sections...00 01 .interp 02 .interp .note.gnu.property .note.gnu.build-id .note.ABI-tag .gnu.hash .dynsym .dynstr .gnu.version .gnu.version_r .rela.dyn .rela.plt 03 .init .plt .plt.got .plt.sec .text .fini 04 .rodata .eh_frame_hdr .eh_frame 05 .init_array .fini_array .dynamic .got .data .bss 06 .dynamic 07 .note.gnu.property 08 .note.gnu.build-id .note.ABI-tag 09 .note.gnu.property 10 .eh_frame_hdr 11 12 .init_array .fini_array .dynamic .got
為什么要將section合并成segment?
1. 減少內存碎片,提高頁面對齊效率
內存按頁(Page)管理:現代操作系統以固定大小的頁(如 4KB)為單位管理內存。如果多個小 Section 分散加載,會導致內存浪費。例子:
????????.text(代碼段)占用 4097 字節 → 需要 2 頁(4096 + 1)。
????????.rodata(只讀數據)占用 512 字節 → 需要 1 頁。
????????未合并時:共占用 3 頁(實際使用 4097 + 512 = 4609 字節,利用率僅 37.5%)。
????????合并后:.text + .rodata 總大小 4609 字節 → 僅需 2 頁(利用率提升至 56.3%)。
合并策略:將權限相同(如只讀、可執行)的 Section 合并到一個 Segment,使它們在內存中連續存儲,減少碎片。
2. ?統一內存權限,簡化操作系統加載
Section 的權限可能相同:例如 :
????????.text(代碼)、.rodata(只讀數據)都是 R-X(可讀、可執行)。
? ??? ? .data(全局數據)、.bss(未初始化數據)都是 RW-(可讀、可寫)。
????????操作系統按 Segment 設置權限:如果每個 Section 單獨映射,操作系統需要為每個小段設置權限(頻繁的系統調用,效率低)。
合并后:只需為整個 Segment 設置一次權限(例如一個 LOAD Segment 包含所有 R-X 的 Section)。
?3. 提升程序加載速度
減少內存映射次數:????????合并前:操作系統需為每個 Section 單獨調用 mmap(或類似機制)。
????????合并后:只需為少數幾個 Segment 調用 mmap,減少系統開銷。
降低頁表項(PTE)壓力:
每個內存映射需要占用頁表條目,合并后減少條目數量,節省內核資源。
?4. 動態鏈接的優化
動態庫(如 libc.so)的依賴項:動態鏈接器(如 ld-linux.so)需要快速定位程序中的 .dynamic、.got.plt 等關鍵 Section。通過將這些 Section 合并到明確的 Segment(如 DYNAMIC),鏈接器能直接遍歷 Program Header Table,而無需解析所有 Section。
對于程序頭表和節頭表又有什么用呢,其實ELF文件提供了2個不同的視圖/視角來讓我們理解這兩個部分:
鏈接視圖(Linking view) - 對應節頭表 Section header table
文件結構的粒度更細,將文件按功能模塊的差異進行劃分。靜態鏈接分析的時候一般關注的是鏈接視圖,能夠理解ELF文件中包含的各個部分的信息。
為了空間布局上的效率,將來在鏈接目標文件時,鏈接器會把很多節(section)合并,規整成可執行的段(segment)、可讀寫的段、只讀段等。合并了后,空間利用率就高了。否則,很小的一段,未來物理內存頁浪費太大(物理內存頁分配一般都是整數倍一塊給你,比如4k)。所以,鏈接器趁著鏈接就把小塊們都合并了。
執行視圖(Execution view) - 對應程序頭表 Program header table
告訴操作系統,如何加載可執行文件,完成進程內存的初始化。一個可執行程序的格式中,一定有program header table。
說白了就是:一個在鏈接時作用,一個在運行加載時作用。
我們可以在ELF頭中找到文件的基本信息,以及可以看到ELF頭是如何定位程序頭表和節頭表的。例如我們查看下hello.o這個可重定位文件的主要信息:?
# 查看目標文件
$ 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位)Data: 2's complement, little endian # 數據編碼方式(小端序)Version: 1 (current) # ELF版本OS/ABI: UNIX - System V # 操作系統ABI類型ABI Version: 0 # ABI版本Type: REL (Relocatable file) # 文件類型(可重定位文件)Machine: Advanced Micro Devices X86-64 # 機器架構(x86-64)Version: 0x1 # 版本Entry point address: 0x0 # 入口地址(目標文件為0)Start of program headers: 0 (bytes into file) # 程序頭表起始位置(目標文件無)Start of section headers: 728 (bytes into file) # 節頭表起始位置Flags: 0x0 # 處理器特定標志Size of this header: 64 (bytes) # ELF頭大小Size of program headers: 0 (bytes) # 程序頭表條目大小(目標文件無)Number of program headers: 0 # 程序頭表條目數(目標文件無)Size of section headers: 64 (bytes) # 節頭表條目大小Number of section headers: 13 # 節頭表條目數Section header string table index: 12 # 節名稱字符串表索引# 查看可執行程序
$ 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 # ELF文件魔數標識Class: ELF64 # 文件類型(64位)Data: 2's complement, little endian # 數據編碼方式(小端序)Version: 1 (current) # ELF版本OS/ABI: UNIX - System V # 操作系統ABI類型ABI Version: 0 # ABI版本Type: DYN (Shared object file) # 文件類型(動態共享對象)Machine: Advanced Micro Devices X86-64 # 機器架構(x86-64)Version: 0x1 # 版本Entry point address: 0x1060 # 程序入口地址(_start)Start of program headers: 64 (bytes into file) # 程序頭表起始位置Start of section headers: 14768 (bytes into file) # 節頭表起始位置Flags: 0x0 # 處理器特定標志Size of this header: 64 (bytes) # ELF頭大小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 # 節名稱字符串表索引
八、理解鏈接與加載
1. 靜態鏈接
? 無論是自己的.o還是靜態庫中的.o,本質都是把.o文件愛你進行鏈接的過程。
? 研究靜態鏈接本質就是研究.o是如何鏈接的。
$ ls
code.c hello.c
$ gcc -c *.c
$ ls
code.c code.o hello.c hello.o
$ gcc *.o -o main.exe
$ ll
total 40
drwxrwxr-x 2 zyt zyt 4096 May 17 15:57 ./
drwxrwxr-x 6 zyt zyt 4096 May 16 15:54 ../
-rw-rw-r-- 1 zyt zyt 62 May 16 15:55 code.c
-rw-rw-r-- 1 zyt zyt 1496 May 17 15:56 code.o
-rw-rw-r-- 1 zyt zyt 100 May 16 15:55 hello.c
-rw-rw-r-- 1 zyt zyt 1560 May 17 15:56 hello.o
-rwxrwxr-x 1 zyt zyt 16016 May 17 15:57 main.exe*
? objdump -d 命令:查看代碼段(.text)的反匯編?
我們發現call指令轉跳的地址都被設置成了0,這是為什么?
其實在編譯hello.c的時候,編譯器是不知道printf和run函數的存在的,因此,編譯器只能將這兩個函數的轉跳地址先暫時設為0。直到鏈接的時候,為了讓連接器將來在鏈接時,能正確定位到這些被修正的地址,在代碼塊(.data)中還存在一個重定位表,將來鏈接時,就會根據表里記錄的地址將其修正。細節如下:
(1)編譯階段
當編譯器處理 hello.c 時,如果遇到外部函數(如 printf 或 run),它的處理流程如下:
??編譯器僅知道這些符號的名稱(如 printf),但不知道它們的實際地址或代碼內容。因為這些符號可能定義在其他文件(如 libc.so 或 code.o)中。
??生成占位符:對于函數調用(如 call printf),編譯器會生成一個臨時地址 00 00 00 00;對于數據引用(如 lea 0x0(%rip),%rax),同樣填充零偏移。
??保留重定位信息:編譯器在目標文件(.o)中生成 重定位表(Relocation Table),記錄哪些指令需要后續修正。
(2)重定位表
目標文件中包含一個或多個重定位表(如 .rela.text),用于指導鏈接器如何修正占位符。
通過 readelf -r hello.o 可以查看:
條目介紹:
Offset:占位符在 .text 節中的位置(如 0x0f 對應 call 的操作碼位置)。Type:重定位類型(如 R_X86_64_PC32 表示 32 位相對地址調用)。
Sym. Value :顯示了符號的值。Sym. Name:需要解析的符號名(run)。
Addend:修正時的附加偏移(通常為 -4,因為 RIP 相對尋址會指向下一條指令)。
上圖顯示證明:在hello.o的.rela.text節中,puts(就是printf)和run的Sym. Value都是0000000000000000,這表示這些符號在目標文件中尚未解析,即它們的地址被初始化為0。這表明這些符號在當前的目標文件中沒有定義,需要在鏈接時從其他目標文件或庫中解析,以確定它們的最終地址。
?(3)靜態鏈接階段:地址修正
鏈接器(如 ld)在合并所有目標文件時,會完成以下操作:
?? 符號解析(Symbol Resolution)
在全局符號表中查找 printf 和 run 的定義:printf 通常來自 libc.a(靜態庫)或 libc.so(動態庫)。run 來自 code.o。若符號未定義,鏈接器報錯(undefined reference)。
??分配最終地址
合并所有 .text 節,并為函數分配運行時地址。
??修正占位符
根據重定位表,修改代碼中的零地址為實際地址。
查看最終程序的反匯編,就能顯示函數運行時的地址了:
?readelf -s main.exe 查看符號表
兩個.o合并之后,在最終的程序中的符號表中,就找到了run;【0000000000001149】其實是地址,FUNC表示run符號類型是函數;【16】就是run函數所在的section被合并在最終的那一個section中了,16就是下標。
readelf -S main.exe:查看節區頭表(Section Headers)
作用:顯示文件的所有節區(Section)信息,描述各節區的布局和屬性。我們看到 code.o 和 hello.o 的 .text 合并后得到的是 main.exe 的第16個section。
(4)總結
所以鏈接其實就是將編譯之后的所有目標文件連同用到的一些靜態庫運行時庫組合,拼裝成一個獨立的可執行文件。其中就包括我們之前提到的地址修正,當所有模塊組合在一起之后,鏈接器會根據我們的.o文件或者靜態庫中的重定位表找到那些需要被重定位的函數全局變量,從而修正它們的地址。這就是靜態鏈接的過程。
鏈接過程中涉及到對.o中的外部符號進行地址重定位。
2. ELF加載和進程地址空間
(1)邏輯地址 / 虛擬地址?
??一個ELF程序,在沒有被加載到內存的時候,有沒有地址呢?
有邏輯地址(虛擬地址布局),但無物理地址。當代計算機都采用“平坦模式”:現代計算機采用虛擬內存機制,編譯器在生成 ELF 文件時,會按虛擬地址空間對代碼和數據預編址(如 .text 從 0x400000 開始)。下面是objdump -d?main.exe 反匯編后的代碼:
通過 objdump -d 或 readelf -S 看到的地址是邏輯地址(起始地址+偏移量),表示該段代碼/數據在進程虛擬空間中的預期位置。我們通常認為起始地址是0。也就是說,其實虛擬地址在程序還沒有加載到內存的時候,就已經對可執行程序進行了統一編址。這些地址在鏈接階段由鏈接器分配,基于鏈接腳本(Linker Script)的規則。
??進程mm_struct、vm_area_struct在進程剛剛創建的時候,初始化數據從哪里來的?
數據來源:ELF 文件的 Program Header Table(即 Segment 信息)。程序頭表描述了ELF文件中各個段的屬性,包括它們的類型、文件偏移、虛擬地址、物理地址、大小等信息。操作系統利用這些信息來初始化進程的內存布局,包括設置頁表、分配內存區域等。這些結構確保了進程的虛擬地址空間能夠正確映射到物理內存,從而允許進程執行。
??磁盤上的可執行程序,代碼和數據編址其實就是虛擬地址的統一編址!
① 磁盤上的ELF地址是虛擬地址:由鏈接器按進程虛擬地址空間統一分配,保存在文件中。
② OS加載時按此布局映射:將ELF中的虛擬地址映射到進程的虛擬內存,再通過頁表轉為物理地址。
③ 協作機制:
????????編譯器:生成邏輯地址(目標文件)。
????????鏈接器:統一分配虛擬地址(ELF文件)。
????????操作系統:將虛擬地址映射到物理內存(運行時)。
(2)重新理解進程虛擬地址空間
ELF再被編譯好之后,會把自己未來程序的入口地址記錄在ELF header的Entry字段中:
作用:入口地址是操作系統加載程序后,CPU開始執行的第一條指令的虛擬內存地址。該地址通常指向程序初始化代碼(如_start或.text段的起始位置),由鏈接器在靜態鏈接階段確定。
??代碼加載時,內核讀取ELF頭的Entry point address,將程序加載到內存后,跳轉到入口地址執行。
??頁表映射時,內核將文件的.text段映射到物理內存,并建立頁表項(虛擬→物理)。?
??操作系統加載程序時的行為:
當用戶運行程序時(如 ./a.out),操作系統(Linux 內核/Windows Loader)會:① 解析可執行文件頭部:讀取 Entry point address,確定代碼的起始虛擬地址。
② 分配虛擬地址空間:為進程創建頁表,映射代碼段(.text)、數據段(.data)等。
③ 設置程序計數器(PC/IP):將 CPU 的指令指針寄存器(x86: RIP,ARM: PC)指向入口地址。
(3)靜態鏈接庫在內存加載?
靜態鏈接庫在內存中的加載流程本質上是一個偽命題,因為其代碼早已在編譯期融入到了可執行文件之中。運行時,這類代碼與其他自定義邏輯無異,均作為固定的部分參與程序的整體調度和執行。其具體特性如下:
①?靜態鏈接庫的特性
靜態鏈接庫的特點決定了它的加載行為不同于動態鏈接庫。靜態鏈接庫在編譯階段即將庫中的代碼直接嵌入到目標文件中,這意味著最終生成的可執行文件本身已經包含了所有的庫代碼。
②?編譯與鏈接階段
??在編譯過程中,源代碼被轉換為目標文件(.o
?或?.obj
),其中包含匯編指令和符號表。
??鏈接器負責解析未定義的符號,并將對應的實現從靜態庫中提取出來,將其實際代碼復制到最終的可執行文件中。
此過程的關鍵在于,靜態庫中的代碼并非以單獨的形式存在于內存中,而是成為可執行文件的一部分。因此,靜態鏈接庫并沒有傳統意義上的“加載”概念,因為它已經在編譯期間完成了集成。
③?運行時的行為
當程序啟動時,操作系統會為可執行文件分配一段連續的虛擬地址空間。這段地址空間包括以下幾個區域:
??代碼區:存儲程序的機器碼,這部分內容來源于原始的源代碼以及靜態鏈接庫中的代碼片段。
??數據區:分為初始化的數據段(如全局變量)和未初始化的數據段(BSS 段)。
??堆棧區:用于動態分配內存和函數調用時的局部變量存儲。
由于靜態鏈接庫的代碼已經被完全嵌入到可執行文件中,因此在運行時不需要額外的操作系統介入來加載這些庫代碼。換句話說,靜態鏈接庫的代碼已經是可執行文件的一部分,隨同其他代碼一起被映射到進程的地址空間。
④?內存占用分析
盡管靜態鏈接庫避免了運行時依賴問題,但它也帶來了顯著的空間開銷。每當一個新的應用程序使用相同的靜態庫時,該庫的全部代碼會被再次復制到新的可執行文件中。這種機制可能導致多個程序在內存中有重復的庫副本,增加了整體系統的內存消耗。
3. 動態鏈接與動態庫加載
(1)動態庫加載
① 虛擬內存映射:
??當進程A啟動并需要加載動態庫(如XXX.so)時,操作系統不會立即將整個庫加載到物理內存
??而是通過mm_struct(內存描述符)在進程的虛擬地址空間中建立映射關系
② 共享區(Shared Area):
??動態庫被映射到進程虛擬地址空間的"共享區"
??這個區域與進程的"數據區"和"代碼區"是分開的
??多個進程可以共享同一個動態庫的物理內存副本
③ 頁表機制:
??操作系統通過頁表將虛擬地址映射到物理內存
??對于動態庫,這種映射是"按需"建立的 - 只有實際訪問的部分才會被加載到物理內存
(2)進程間共享動態庫
① 虛擬內存映射:
??進程A和進程B各自有獨立的虛擬地址空間
??通過各自的mm_struct(內存描述符),兩個進程都將XXX.so映射到自己的地址空間的"共享區"
??雖然虛擬地址可能不同,但最終指向相同的物理內存區域
② 物理內存共享:
??在物理內存中,XXX.so只有一份副本
??兩個進程的頁表條目都指向這同一塊物理內存區域
??這是通過操作系統的內存管理實現的
(3)動態鏈接
① 動態鏈接概要
我們可以將需要共享的代碼單獨提取出來,保存成一個獨立的動態鏈接庫,等到程序運行的時候再將它們加載到內存,這樣不但可以節省空間,因為同一個模塊在內存中只需要保留一份副本,可以被不同的進程所共享。
動態鏈接實際上是將鏈接的整個過程推遲到了程序加載的時候。比如我們去運行一個程序,操作系統會首先將程序的數據代碼連同它用到的一系列動態庫先加載到內存,其中每個動態庫的加載地址都是不固定的,操作系統會根據當前地址空間的使用情況為它們動態分配一段內存。
當動態庫被加載到內存以后,一旦它的內存地址被確定,我們就可以去修正動態庫中的那些函數跳轉地址了。
② 可執行程序被編譯器動了手腳
在C/C++程序中,當程序開始執行時,它首先并不會直接跳轉到main函數。實際上,程序的入口點是_start,這是一個由C運行時庫(通常是glibc)或鏈接器(如ld)提供的特殊函數。在_start函數中,會執行一系列初始化操作,這些操作包括:
??設置堆棧:為程序創建一個初始的堆棧環境。
??初始化數據段:將程序的數據段(如全局變量和靜態變量)從初始化數據段復制到相應的內存位置,并清零未初始化的數據段。
??動態鏈接:這是關鍵的一步,_start函數會調用動態鏈接器的代碼來解析和加載程序所依賴的動態庫(shared libraries)。動態鏈接器會處理所有的符號解析和重定位,確保程序中的函數調用和變量訪問能夠正確地映射到動態庫中的實際地址。動態鏈接器(如ld-linux.so)負責在程序運行時加載動態庫。
動態連接器:
? 標紅的 /lib64/ld-linux-x86-64.so.2 (0x00007f42c02b6000) 表示動態鏈接器(Dynamic Linker/Loader) 的路徑和內存映射地址。
??內核首先加載動態鏈接器到內存(而非直接加載程序)。
??當程序啟動時,動態鏈接器會解析程序中的動態庫依賴,并加載這些庫到內存中。
??Linux系統通過環境變量(如LD_LIBRARY_PATH)和配置文件(如/etc/ld.so.conf及其子配置文件)來指定動態庫的搜索路徑。
??這些路徑會被動態鏈接器在加載動態庫時搜索。
??為了提高動態庫的加載效率,Linux系統會維護一個名為/etc/ld.so.cache的緩存文件。
??該文件包含了系統中所有已知動態庫的路徑和相關信息,動態鏈接器在加載動態庫時會首先搜索這個緩存文件。
??庫搜索順序:
??調用__libc_start_main:一旦動態鏈接完成,_start函數會調用__libc_start_main(這是glibc提供的一個函數)。__libc_start_main函數負責執行一些額外的初始化工作,比如設置信號處理函數、初始化線程庫(如果使用了線程)等。
??調用main函數:最后,__libc_start_main函數會調用程序的main函數,此時程序的執行控制權才正式交給用戶編寫的代碼。
??處理main函數返回值:當main函數返回時,__libc_start_main會負責處理這個返回值,并最終調用_exit函數來終止程序。
③ 動態庫中的相對地址
??動態庫是采用相對編址(位置無關代碼,PIC)的方案進行編址的,這種機制使得動態庫可以被加載到進程地址空間的任意位置而無需重寫代碼。
??位置無關代碼(PIC):是通過所有地址引用都使用相對偏移量,而非絕對地址實現的,使代碼無論加載到內存哪個位置都能正確執行(而靜態鏈接的程序使用固定絕對地址,加載位置固定)。
??平坦內存模式:采用統一編址,使整個地址空間是一個連續的線性空間。而在動態庫視角,每個庫都"認為"自己從地址0開始,實際通過偏移量計算真實地址。可執行程序(.exe)和動態庫都要遵守“平坦模式”,只不過.exe是直接加載的,動態庫需要動態加載。
④ 程序與動態庫映射
??動態庫也是一個文件,要訪問也是要被先加載,要加載也是要被打開的。
??讓我們的進程找到動態庫的本質:也是文件操作,不過我們訪問庫函數,通過虛擬地址進行跳轉訪問的,所以需要把動態庫映射到進程的地址空間中。
⑤ 程序調用庫函數
已知庫的虛擬起始地址和函數偏移量的情況下,訪問庫中所有方法都需要:函數絕對地址 = 庫虛擬起始地址 + 函數偏移量。并且,整個調用過程是從代碼段轉跳到共享區,調用完畢后返回代碼區,整個過程都在進程地址空間中進行。
⑥ 全局偏移量表GOT
注意:也就是說,我們的程序運行之前,先把所有庫加載并映射,所有庫的起始虛擬地址都應該提前知道,然后對我們加載到內存中的程序的庫函數調用進行地址修改,在內存中二次完成地址設置(這個叫做加載地址重定位) ,?等等,不是說代碼區在進程中是只讀的嗎?怎么修改?能修改嗎?
所以:動態鏈接采用的做法是在 .data(.data是可讀寫的,支持動態修改)中專門預留一片區域用來存放函數的跳轉地址,它也被叫做全局偏移表GOT,表中每一項都是本運行模塊要引用的一個全局變量或函數的地址。代碼段通過相對尋址訪問GOT表項,動態鏈接器在加載時填充GOT表中的實際地址。
??GOT運行工作流程:
首次調用:
* 調用PLT跳轉到動態鏈接器
* 解析符號得到真實地址并填充GOT表
* 跳轉到真實函數地址
后續調用:
* 直接通過GOT表跳轉(無解析開銷)
???GOT表不共享:由于GOT表項必須包含當前進程的絕對地址,并且不同進程的地址空間中,各動態庫的絕對地址、相對位置都不同。
??在單個.so下,由于GOT表與 .text 的相對位置是固定的,我們完全可以利用CPU的相對尋址來找到GOT表。
??這種方式實現的動態鏈接就被叫做 PIC 地址無關代碼 。換句話說,我們的動態庫不需要做任何修改,被加載到任意內存地址都能夠正常運行,并且能夠被所有進程共享,這也是為什么之前我們給編譯器指定-fPIC參數的原因,PIC=相對編址+GOT。
備注:PLT是什么?
PLT(過程鏈接表)是動態鏈接的核心機制之一,主要用于延遲綁定,即在程序運行時按需解析動態庫函數的真實地址,而不是在程序啟動時就解析所有函數。
解決動態庫函數調用問題:動態庫的函數地址在編譯時是未知的(因為庫可能被加載到任意地址),PLT 提供了一種間接跳轉機制,使得程序可以先調用 PLT 條目,再由 PLT 負責跳轉到正確的函數地址。優化后,采用延遲綁定,只有在函數第一次被調用時才會解析其真實地址,后續調用直接跳轉,避免啟動時解析所有符號的開銷。
⑦ 動態庫間依賴
??庫間依賴通過動態鏈接器遞歸加載:動態庫(.so)也可以依賴其他庫,庫間依賴通過動態鏈接器遞歸加載,保證所有庫的 GOT 表被正確填充。
? PIC + GOT/PLT 機制:動態鏈接器遞歸加載,加載 libA.so 時,發現它依賴 libB.so,動態鏈接器會先加載 libB.so 并修正 libA.so 的 GOT 表。如果 libB.so 又依賴 libC.so,則繼續遞歸加載。
??ELF 格式統一:所有庫都是ELF格式,結構一致(都有 .got、.plt、.dynamic),確保動態鏈接器能統一處理。庫的代碼段(.text)仍然是位置無關(PIC),通過 %rip 相對尋址訪問自己的 GOT 表。每個庫的 GOT 表是獨立的,動態鏈接器會分別填充。
?解決依賴關系的時候,就是加載并完善互相之間的GOT表的過程。
總而言之,動態鏈接實際上將鏈接的整個過程,比如符號查詢、地址的重定位從編譯時推遲到了程序的運行時,它雖然犧牲了一定的性能和程序加載時間,但絕對是物有所值的。因為動態鏈接能夠更有效地利用磁盤空間和內存資源,以極大方便了代碼的更新和維護,更關鍵的是,它實現了二進制級別的代碼復用。
4. 總結
? 靜態鏈接的出現,提高了程序的模塊化水平。對于一個大的項目,不同的人可以獨立地測試和開發自己的模塊。通過靜態鏈接,生成最終的可執行文件。
? 我們知道靜態鏈接會將編譯產生的所有目標文件,和用到的各種庫合并成一個獨立的可執行文件,其中我們會去修正模塊間函數的跳轉地址,也被叫做編譯重定位(也叫做靜態重定位)。
? 而動態鏈接實際上將鏈接的整個過程推遲到了程序加載的時候。比如我們去運行一個程序,操作系統會首先將程序的數據代碼連同它用到的一系列動態庫先加載到內存,其中每個動態庫的加載地址都是不固定的,但是無論加載到什么地方,都要映射到進程對應的地址空間,然后通過.GOT方式進行調用(運行重定位,也叫做動態地址重定位)。