📝前言:
這篇文章我們來講講Linux——動靜態庫鏈接原理
🎬個人簡介:努力學習ing
📋個人專欄:Linux
🎀CSDN主頁 愚潤求學
🌄其他專欄:C++學習筆記,C語言入門基礎,python入門基礎,C++刷題專欄
目錄
- 一,目標文件
- 二,ELF文件
- ELF文件格式的特點
- 1. ELF形成可執行
- 2. ELF可執行加載
- 具體查看
- Section查看
- Segment查看
- ELF Header查看
- 三,理解鏈接與加載
- 1. 靜態鏈接與靜態庫加載
- 查看編譯后的符號表
- 符號表
- 查看反匯編目標文件的內容
- 2. ELF加載與進程地址空間
- 靜態鏈接總結
- 3. 動態鏈接與動態庫加載
- 動態鏈接器
- 庫間的依賴
- PLT
一,目標文件
我們都知道,形成可執行需要經過 編譯 + 鏈接兩個步驟。當.c
文件經過編譯后形成的.o
文件就叫做可重定位/可重定向目標文件。
當我們只有一個.c
文件被修改時,我們只需要對修改的文件進行重新編譯就行了,其他文件不需要。
二,ELF文件
.o
文件,動靜態庫,可執行文件,內核轉儲(core dumps)都是ELF格式的二進制文件。
ELF文件格式的特點
ELF
文件被劃分成很多個節
ELF Header
:描述文件的全局屬性,主要作用是定位?件的其他部分Program Header Table
列舉了所有有效的段(segments
)和他們的屬性。表里記著每個段的開始的位置和位移(offset)、長度。(鏈接階段,有合并以后才會生成Program Headers
)【segments
是什么后面講】- 用來描述整個ELF文件
Section
就是節,不同的數據會被存儲到不同的節中。如代碼節存儲了可執行代碼,數據節存儲了全局變量和靜態數據等Section Header Table
用來描述每個節的信息
1. ELF形成可執行
- 將多份 C/C++ 源代碼,翻譯成為?標
.o
?件 - 將多份
.o
?件section進行合并(合并是:鏈接的過程之一)
簡單來說,就是把多個.o
文件 中具有相同特性的Section
合并成一個大的Segment
2. ELF可執行加載
- 一個ELF文件在加載到內存的時候,也會把這個文件中具有相同特性(比如:把只讀的代碼段和只讀數據合并)的
Section
合并,形成segment
- 這個合并?作也已經在形成ELF的時候,合并?式已經確定了,具體合并原則被記錄在了ELF的 程序頭表(Program header table) 中
為什么要將Section合并?
- 為了減少頁面碎片,提高內存使用效率。如果不進行合并,假設頁面大小為 4096 字節(內存塊基本大小,加載,管理的基本單位),如果
.text
部分為4097字節,.init
部分為 512 字節,那么它們將占用 3 個頁面(.text
兩個 +.init
一個),而合并后,它們只需 2 個頁面。- 將具有相同屬性的
section
合并成?個大的segment
,可以實現不同的訪問權限,從而優化內存管理和權限訪問控制
具體查看
Section查看
查看可執行程序的Section
(我的可執行名稱叫test
):
readelf -S test
我們可以看到Section header table
對每個Section
的描述
查看可執行程序的Segment
:
readelf -l test
在圖片中,我們就可以看到有哪些Section
被合并成了一個Segment
提幾個重要的Section
:
text
節 :保存了程序代碼指令的代碼節。data
節 :保存了初始化的全局變量和局部靜態變量等數據。.rodata
節 :保存了只讀的數據,如一行C語?代碼中的字符串。.bss
節 :為未初始化的全局變量和局部靜態變量預留位置(對于未初始化的全局變量,我們沒必要真正開辟空間,只需要在.bss
里面描述出有多少未初始化的就行).symtab
節 : Symbol Table 符號表,就是源碼里面那些函數名、變量名和代碼的對應關系。.got.plt
節 (全局偏移表 - 過程鏈接表):.got節保存了全局偏移表。.got
節和.plt
節?起提供了對導?的共享庫函數的訪問??,由動態鏈接器在運行時進行修改。
Segment查看
我們還可以看到其他信息:
我們可以看到Program Header table
對每個段的描述
你會不會很好奇,為什么可執行程序既有Section
又有Segment
?
其實這只是ELF 文件提供 2 個不同的視圖/視角來讓我們理解這兩個部分:
Section
是鏈接視圖(Linking View),面向開發者/工具鏈。用于編譯和鏈接階段,供編譯器、鏈接器和調試工具使用Segment
是執行視圖(Execution View),面向操作系統。用于程序加載和運行時,指導操作系統如何將文件映射到內存
ELF Header查看
用命令:
readelf -h test
- 我們可以看到
ELF Header
保存著一些大小 / 入口信息,用于定位?件的其他部分。 - 系統通過
Magic
來判斷文件是不是ELF的格式。Entry point
(標識可執行程序的入口地址【虛擬地址】)
三,理解鏈接與加載
1. 靜態鏈接與靜態庫加載
因為靜態庫就是都是.o
文件打包的,并且靜態庫在形成可執行的時候,會把庫中的函數實現直接拷貝一份到可執行里面。所以研究靜態鏈接,本質上是在研究.o
文件是如何鏈接的。
test.c
文件內容
1 #include "mystring.h" 2 3 int main()4 {5 char* msg = (char*)"hello world\n";6 print(msg); // 調用自定義的print7 return 0;8 }
查看編譯后的符號表
符號表
符號表用于記錄了目標文件中定義和引用的符號相關信息,如:函數名、變量名、全局常量名等。
會用一個長字符串表來存儲,像這樣:
然后通過\0
來劃分他們,通過\0
我們可以記錄每個符號在串中的起始結束下標,就可以很快得到這個符號的名稱。
readelf -s test.o
可以發現print
是UND
的,就是:沒有定義
查看反匯編目標文件的內容
objdump -d test.o
- 這里,調用
print
函數,但是它的跳轉地址被設置成了0
。 - 這是因為:在編譯 test.c 的時候,編譯器知道有
print
函數(因為有聲明)但是不知道具體的實現(即:不知道print
在內存的哪里)。因此,編譯器只能將這兩個函數的跳轉地址先暫時設為0
- 鏈接的時候!
.o
文件被合并,就會修改call
中不確定的地址。(這就是靜態鏈接,也是外部符號的地址重定位步驟)
2. ELF加載與進程地址空間
—個ELF程序,在沒有被加載到內存的時候,有沒有地址呢?
答案:有的,有虛擬地址!
—個ELF程序,在沒有被加載到內存的時候,采用"平坦模式"(就是地址下標從 0 開始連續編址),對自己的代碼和數據進行統?編址
最左側的就是ELF的虛擬地址!嚴格意義上應該叫做邏輯地址(起始地址 + 偏移量)
- 進程的
mm_struct
、vm_area_struct
在進程剛剛創建的時候,就是用ELF的統一編址的信息來初始化的。(每個segment有自己的起始地址和自己的長度,用來初始化內核結構中的[start, end]
等范圍數據) - 同時,記載到內存中的可執行文件也有對應的物理內存地址
- 這樣
mm_struct
的虛擬地址有了,程序的物理內存地址也有了,就可以填寫頁表了!!! - 所以:虛擬地址機制,不光OS要?持,編譯器也要支持
靜態鏈接總結
通過這張圖梳理一遍靜態鏈接:
- 首先,ELF文件在沒有加載到內存時,已經有了統一編址
- 鏈接前,
.o
文件彼此不知道對方,所以沒有辦法call
函數調用的具體地址 - 在鏈接階段,會把可執行程序中需要的靜態庫的庫方法,拷貝一份給可執行程序。(這個時候,方法有了明確的地址,就可以進行地址重定位,把
call
的內容修改成具體的方法地址) - 當程序加載到內存中時,用統一編址初始化
mm_struct
,再結合實際物理內存地址,就可以構建好頁表 - 并將程序的入口
Entry
被傳入到CPU的寄存器EIP
中,就可以拿著EIP
中的Entry
進入程序并執行
也就是說:靜態鏈接在鏈接階段,已經完成了地址重定位操作,運行階段已經不需要靜態庫了,所以是編譯時(鏈接階段)鏈接!
3. 動態鏈接與動態庫加載
- 對于動態鏈接,動態庫并不會直接拷貝到可執行程序的代碼中。
- 所有程序是共用內存中的一份動態庫代碼的
那么,進程之間,又是如何共享庫的呢?
先不挖細節,先說整體輪廓:
- 動態庫也是文件,需要獨立加載到內存中,有自己的內存區域
- 當動態庫加載到內存中時,動態庫的ELF格式會用來初始化進程
mm_struct
的共享區 - 當在運行代碼區的代碼時,遇到了動態庫的方法,就會從代碼區跳轉到共享區,得到對應方法的虛擬地址,然后就可以用虛擬地址通過頁表映射找到內存中的代碼了
看似沒啥問題,但是,我們把目標放在從代碼區跳轉到共享區這一步:
如果要跳轉,則代碼區應該知道對應方法的內存地址。可是,如果動態庫是獨立的文件,只有程序加載的時候,動態庫才能真真被加載ELF的虛擬內存地址里。才能有對應方法的地址。所以動態鏈接,也就被推遲到了加載時。
所以:
- 因為動態庫也是獨立的文件,也要加載到進程的
mm_struct
,但是在加載之前,動態庫還沒有映射到mm_struct
上(即:動態庫的同一編址還沒有用來初始化對應的mm_struct
里面對應的區域) - 所以在編譯鏈接時:可執行程序里面的代碼段,就不知道對應動態庫方法
call
。(無法像靜態鏈接一樣,直接填上方法具體的地址) - 只能等到程序加載到內存里以后,再填上。(這就是加載時鏈接)
- 但是,因為當可執行程序加載到內存中以后,代碼區具有只讀性,無法修改。所以我們需要借助一個中間層,來修改
call
的地址。 - 這個中間層就是GOT(全局偏移量表),我們讓GOT表位于
.data
區(可修改),每一個位置存放著:方法 + 對應方法的庫名稱【本質是:方法在庫中的偏移量 + 庫名稱】 - 而,原來的代碼中
call
:GOT表的起始地址 + 要調用的方法在GOT表中的偏移量 - 當我們加載程序時,動態庫被加載到了
mm_struct
,就知道了動態庫的虛擬起始地址。 - GOT表就會被修改,里面每個位置存儲的(通過:方法在庫中的偏移量 + 庫名稱)就變成了對應方法的絕對虛擬地址【這樣就相當于間接改了代碼區的
call
】,此時頁表也會被填寫 - 這就完成了重定位,完成了動態鏈接
和文件系統關聯起來:
這種?式實現的動態鏈接就被叫做 PIC 地址?關代碼 。換句話說,我們的動態庫不需要做任何修改,被加載到任意內存地址都能夠正常運?,并且能夠被所有進程共享,這也是為什么之前我們給編譯器指定-fPIC參數的原因,PIC=相對編址+GOT
下面在談幾個更細節的知識
動態鏈接器
【以下內容由AI生成】
/lib64/ld - linux - x86 - 64.so.2
這就是動態連接器,加載動態庫、符號解析與重定位、處理庫依賴、初始化庫函數…都是由它完成的。
在C/C++程序中,當程序開始執行時,并不會直接跳轉到 main
函數。實際上,程序的入口是 _start
,這是?個由C運行時庫(通常是glibc
)或鏈接器(如ld
)提供的特殊函數。在 _start
函數中,會執??系列初始化操作,其中就包括動態鏈接:
_start
函數會調?動態鏈接器的代碼來解析和加載程序所依賴的動態庫(shared libraries
)。動態鏈接器會處理所有的符號解析和重定位,確保程序中的函數調?和變量訪問能夠正確地映射到動態庫中的實際地址。
庫間的依賴
庫也會調?其他庫!!庫之間是有依賴的,如何做到庫和庫之間互相調?也是與地址?關的呢?
答:庫中也有.GOT
,和可執行?樣。
PLT
PLT:延遲綁定(Lazy Binding)
作用:
- 避免在程序啟動時解析所有動態庫函數(如果庫函數很多的話,就很浪費時間,因為有些庫函數可能沒被使用)
- 而是在函數首次被調用時才進行地址解析
更具體的比較復雜,就不講述了。
🌈我的分享也就到此結束啦🌈
要是我的分享也能對你的學習起到幫助,那簡直是太酷啦!
若有不足,還請大家多多指正,我們一起學習交流!
📢公主,王子:點贊👍→收藏?→關注🔍
感謝大家的觀看和支持!祝大家都能得償所愿,天天開心!!!