什么是庫
庫是寫好的現有的,成熟的,可以復用的代碼。現實中每個程序都要依賴很多基礎的底層庫,不可能每個人的代碼都從零開始,因此庫的存在意義非同尋常。
本質上來說庫是一種可執行代碼的二進制形式,可以被操作系統載入內存執行。庫有兩種:
? 靜態庫 .a[Linux]、.lib[windows]
? 動態庫 .so[Linux]、.dll[windows]
靜態庫
?靜態庫(.a):程序在編譯鏈接的時候把庫的代碼鏈接到可執行文件中,程序運行的時候將不再需要靜態庫。
??個可執行程序可能用到許多的庫,這些庫運行有的是靜態庫,有的是動態庫,而我們的編譯默認為動態鏈接庫,只有在該庫下找不到動態.so 的時候才會采用同名靜態庫。我們也可以使用?gcc 的 -static 強轉設置鏈接靜態庫。
靜態庫的制作
在本篇文章中使用只使用下面的.c和.h代碼進行操作
#include "mytring.h"// 計算字符串長度
size_t my_strlen(const char *str) {const char *s = str;while (*s) s++;return s - str;
}// 字符串復制
char *my_strcpy(char *dest, const char *src) {char *d = dest;while ((*d++ = *src++));return dest;
}
#include <stddef.h>// 計算字符串長度
size_t my_strlen(const char *str);// 字符串復制
char *my_strcpy(char *dest, const char *src);
在制作靜態庫的過程中需要將.c文件先編譯成.o文件 。
為什么需要先編譯成.o文件呢?
若直接編譯源文件到庫,每次修改一個源文件都需要重新編譯整個庫。而使用?.o?文件,只需重新編譯修改過的源文件,然后更新靜態庫。
目標文件(.o)是獨立的編譯單元:每個?.c?文件單獨編譯,不依賴其他源文件。例如,一個庫可能包含?string.c、math.c?等多個源文件,編譯后生成?string.o、math.o,可以獨立更新其中一個而不影響其他。
靜態庫是?.o?文件的集合:通過?ar?工具將多個?.o?文件打包成庫,方便復用。例如,多個項目可以共享同一個?libmystring.a。
這里介紹一下ar指令(歸檔指令)
ar [選項] [歸檔文件] [文件...]
歸檔文件(Archive File)是一種通過特定工具將多個文件或目錄打包成一個獨立文件的集合,主要用于存儲、備份、分發或壓縮數據。
靜態庫的本質就是將.o文件進行打包
這里使用ar指令進行歸檔
ar -rcs libmystring.a mytring.o
此時文件中就形成了libmystring.a。但是庫的名字并非是libmystring.a,而是去掉前面的lib和后面的.a,所以庫名字是mystring
假設我們有一個使用該庫的程序main.c,可以這樣進行編譯:
gcc main.c -L. -lmystring -o main
?-L:gcc在鏈接外部的庫時,默認不會在當前目錄下尋找,讓操作系統在-L后面的路徑中搜索庫,所以-L.就表示在當前目錄下搜索庫
-l:鏈接名為mystring的庫文件,-l后面只要庫的真實名字
最后就形成了依賴與libmystring.a的可執行文件main
但是在刪除掉libmystring.a后,main依舊可以運行,這就是靜態庫的特點。
動態庫
? 動態庫(.so):程序在運行的時候才去鏈接動態庫的代碼,多個程序共享使用庫的代碼。
? ?個與動態庫鏈接的可執行文件僅僅包含它用到的函數入口地址的?個表,而不是外部函數所在目標文件的整個機器碼
? 在可執行文件開始運行以前,外部函數的機器碼由操作系統從磁盤上的該動態庫中復制到內存中,這個過程稱為動態鏈接(dynamic linking)
? 動態庫可以在多個程序間共享,所以動態鏈接使得可執行文件件更小,節省了磁盤空間。操作系統采用虛擬內存機制允許物理內存中的?份動態庫被要用到該庫的所有進程共用,節省了內存和磁盤空間
動態庫的制作
動態庫的依舊采用最開始的兩份代碼來實驗,兩份代碼分別是mytring.c和mytring.h
動態庫必須編譯為位置無關代碼(Position Independent Code, PIC),使用 -fPIC 選項:
gcc -fPIC -c mytring.c -o dong.o
為什么需要編譯成位置無關碼?
動態庫的核心特性是可以被加載到內存的任意地址,并被多個程序共享。如果庫代碼中包含固定地址的引用(如絕對跳轉、直接變量地址),則當庫被加載到不同地址時會出錯。位置無關代碼通過特殊機制解決了這個問題。
使用 -shared 選項將目標文件鏈接為動態庫:
gcc -shared -o libdong.so dong.o
最后將main.c文件進行編譯
gcc main.c -L. -ldong -o main
?此時就出現了main可執行文件,但是./運行卻出現提示
為什么能夠形成可執行文件卻無法運行呢??
這是由于我們只把目標文件所依賴的庫告訴了gcc而已,編譯的時候只有gcc在處理,但是在運行的時候就不需要gcc,轉而需要操作系統進行處理,此時所依賴的庫只有gcc直到,但是操作系統并不知道,所以就會報錯
這是由于操作系統找不到文件所依賴的動態庫,但是為什么靜態庫不會出現這個問題呢?
這是由于二者對鏈接方式的差別,靜態庫是直接將庫方法記錄到代碼中的,動態庫是將地址記錄到代碼中
這就是為什么靜態鏈接的文件沒有靜態庫也能夠運行,而動態鏈接的文件缺失動態庫就無法運行了,此時文件就無法通過地址來查找庫方法了?
如何解決?
?1.修改臨時環境變量
只需要將LD_LIBRARY_PATH修改,../dong表示父目錄中的dong
export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:../dong
?2.修改配置文件
將庫復制到系統默認搜索路徑(如 /usr/lib 或 /usr/local/lib)
sudo cp libmath.so /usr/local/lib
sudo ldconfig # 更新動態鏈接器緩存(Linux)
3.使用 rpath 硬編碼路徑
編譯時使用 -Wl,-rpath 選項指定運行時搜索路徑
gcc main.c -L. -lmath -Wl,-rpath=. -o main
-Wl:將后面的參數傳遞給鏈接器(ld)
-rpath:運行時搜索路徑,在可執行文件中硬編碼動態庫的搜索路徑,告訴動態鏈接器(如 Linux 的 ld-linux.so)在程序運行時優先搜索哪些目錄。
總結動靜態庫
gcc/g++默認使用動態庫
非得使用靜態庫只能使用-static,一旦使用-static就必須擁有對應的靜態庫
在Linux系統下,默認安裝的大部分庫,默認都優先安裝動態庫
目標文件
? 編譯和鏈接這兩個步驟,在Windows下被我們的IDE封裝的很完美,我們?般都是?鍵構建非常方便,但?旦遇到錯誤的時候呢,尤其是鏈接相關的錯誤,很多人就束手無策了。在Linux下,我們之前也學習過如何通過gcc編譯器來完成這?系列操作。接下來我們深入探討?下編譯和鏈接的整個過程,來更好的理解動靜態庫的使用原理。
? 先來回顧下什么是編譯呢?編譯的過程其實就是將我們程序的源代碼翻譯成CPU能夠直接運行的機器代碼。
? 比如:在一個源文件 hello.c 里便簡單輸出"hello world!",并且調用?個run函數,而這個函數被定義在另?個原文件 code.c 中。這?我們就可以調用?gcc -c 來分別編譯這兩個原文件。
在編譯之后會生成兩個擴展名為 .o 的文件,它們被稱作目標文件。要注意的是如果我們修改了?個原文件,那么只需要單獨編譯它這?個,而不需要浪費時間重新編譯整個?程。目標文件是?個二進制的文件,文件的格式是 ELF ,是對二進制代碼的?種封裝。
$ file hello.o
hello.o: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped
ELF文件?
? 要理解編譯鏈鏈接的細節,我們不得不了解?下ELF文件。其實有以下四種文件其實都是ELF文件:
? 可重定位文件(Relocatable File) :即 xxx.o 文件。包含適合于與其他目標文件鏈接來創建可執行文件或者共享目標文件的代碼和數據。
? 可執行文件(Executable File) :即可執行程序。
? 共享目標文件(Shared Object File) :即 xxx.so文件。
? 內核轉儲(core dumps) ,存放當前進程的執行上下文,用于dump信號觸發。
? ?個ELF文件由以下四部分組成:
? ELF頭(ELF header) :描述文件的主要特性。其位于文件的開始位置,它的主要目的是定位文件的其他部分。
? 程序頭表(Program header table) :列舉了所有有效的段(segments)和他們的屬性。表里記著每個段的開始的位置和位移(offset)、長度,畢竟這些段,都是緊密的放在?進制文件中,需要段表的描述信息,才能把他們每個段分割開。
? 節頭表(Section header table) :包含對節(sections)的描述。
? 節(Section ):ELF文件中的基本組成單位,包含了特定類型的數據。ELF文件的各種信息和數據都存儲在不同的節中,如代碼節存儲了可執行代碼,數據節存儲了全局變量和靜態數據等。
? 最常見的節:
? 代碼節(.text):?于保存機器指令,是程序的主要執行部分。
? 數據節(.data):保存已初始化的全局變量和局部靜態變量
ELF從形成到加載輪廓
ELF形成可執行
?? step-1:將多份 C/C++ 源代碼,翻譯成為目標 .o 文件
? step-2:將多份 .o 文件section進行合并
實際合并是在鏈接時進行的,但是并不是這么簡單的合并,也會涉及對庫合并
readelf
readelf 是 Linux 系統中用于分析 ELF(Executable and Linkable Format)格式文件的強大工具。ELF 文件包括可執行文件、動態庫(.so)、目標文件(.o)等。通過 readelf,可以查看文件的各種元信息、段表、符號表等,是調試和逆向工程的重要工具。
readelf [選項] <elf-file>
1. 查看文件頭信息(-h)
2.查看段表信息(-S)
3.?查看程序頭信息(-l)
4.?查看動態鏈接信息(-d)
5.查看符號表(-s)
ELF可執行文件加載
? ?個ELF會有多種不同的Section,在加載到內存的時候,也會進行Section合并,形成segment
? 合并原則:相同屬性,比如:可讀,可寫,可執行,需要加載時申請空間等
? 這樣,即便是不同的Section,在加載到內存中,可能會以segment的形式,加載到?起
? 很顯然,這個合并?作也已經在形成ELF的時候,合并方式已經確定了,具體合并原則被記錄在了ELF的程序頭表(Program header table) 中?
可以通過readelf來查看可執行程序的section以及合并的segment
為什么要將section合并成為segment ?
?在文件中與內存的交互基本上是以4kb為基本單位,如果想要存儲數據,不足4kb的內容也是按照4kb來進行存儲的,而section所占空間一般很小,如果單個section進行存儲的話會浪費空間
? 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段)。 ?
?.BSS節:為未初始化的全局變量和局部靜態變量預留位置。 ?
? .symtab節(Symbol Table符號表):保存源碼中函數名、變量名與代碼的對應關系。 ?
? .got.plt節(全局偏移表-過程鏈接表):.got節保存全局偏移表,與.plt節共同提供對導入共享庫函數的訪問入口,由動態鏈接器在運行時修改(后文詳述)。 ?
? 使用 readelf?命令查看.so文件可以看到該節。 ?
? 從執行視圖來看:
?? 告訴操作系統哪些模塊可被加載進內存。 ?
? 定義加載后各分段的權限(可讀、可寫、可執行)。 ?
在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 Class: ELF64 # ?件類型Data: 2s complement, little endian # 指定的編碼?式Version: 1 (current)OS/ABI: UNIX - System VABI Version: 0Type: REL (Relocatable file) # 指出ELF?件的類型Machine: Advanced Micro Devices X86-64 # 該程序需要的體系結構Version: 0x1Entry point address: 0x0 # 系統第?個傳輸控制的虛擬地址,在那啟動進程。假如?件沒有如何關聯的口點,該成員就保持為0。Start of program headers: 0 (bytes into file)Start of section headers: 728 (bytes into file)Flags: 0x0Size of this header: 64 (bytes) # 保存著ELF頭??(以字節計數)Size of program headers: 0 (bytes) # 保存著在?件的程序頭表
(program header table)中?個??的??Number of program headers: 0 # 保存著在程序頭表中??的個數。因此,e_phentsize和e_phnum的乘機就是表的??(以字節計數).假如沒有程序頭表,變量為0。
Size of section headers: 64 (bytes) # 保存著section頭的??(以字節計數)。?個section頭是在section頭表的?個?? Number of section headers: 13 # 保存著在section header table中的??數?。因此,e_shentsize和e_shnum的乘積就是section頭表的??(以字節計數)。
假如?件沒有section頭表,值為0。 Section header string table index: 12 # 保存著跟section名字字符表相關??的section頭表(section header table)
在操作系統中,想要對ELF文件進行解讀,首先需要對文件判斷是否為ELF文件,使用readelf -h查看文件會出現Magic,Magic中存儲的是一段隨機值,操作系統可以根據這段隨機值來判斷文件是否是ELF文件
理解連接與加載
靜態鏈接
? 無論是自己的.o,還是靜態庫中的.o,本質都是把.o文件進行連接的過程
? 所以:研究靜態鏈接,本質就是研究.o是如何鏈接的
那么靜態庫是如何形成可執行程序的
第一段代碼是hello.c,第二段代碼是code.c
#include<stdio.h>
void run();
int main() {printf("hello world!\n");run();return 0;
}#include<stdio.h>
void run() {printf("running...\n");
}
將他們編譯成.o文件后使用objdump進行反匯編
我們能夠發現在hello.o的反匯編中的printf和run對應的call找不到地址,e8后面是8個0
此時我們能夠得出結論:hello.o不認識printf和run,run.o不認識printf。
我們可以看到這里的call指令,它們分別對應之前調用的printf和run函數,但是你會發現它們的跳轉地址都被設成了0。那這是為什么呢? ?
其實就是在編譯 hello.c 的時候,編譯器是完全不知道 printf 和 run 函數的存在的(比如它們位于內存的哪個區塊、代碼長什么樣都是未知的)。因此,編譯器只能將這兩個函數的跳轉地址先暫時設為0。
?這個地址會在什么時候被修正?鏈接的時候!為了讓鏈接器將來在鏈接時能夠正確定位到這些被修正的地址,在代碼塊(.text)中還存在?個重定位表,這張表會在鏈接階段根據表里記錄的地址進行修正。?
將code.o和hello.o文件一起編譯成可執行程序,再次進行反匯編
此時發現了main函數和run函數中的call中的e8都找到了對應的地址,并非是全0了
1.兩個.o的代碼段合并到了?起,并進行了統?的編址
2.鏈接的時候,會修改.o中沒有確定的函數地址,在合并完成之后,進?相關call地址,完成代碼調用
靜態鏈接就是把庫中的.o進行合并,和上述過程?樣
所以鏈接其實就是將編譯之后的所有目標文件連同用到的?些靜態庫運行時庫組合,拼裝成?個獨立的可執行文件。其中就包括我們之前提到的地址修正,當所有模塊組合在?起之后,鏈接器會根據我們的.o文件或者靜態庫中的重定位表找到那些需要被重定位的函數全局變量,從而修正它們的地址。這其實就是靜態鏈接的過程。
ELF加載與進程地址空間
虛擬地址/邏輯地址 ?
問題: ?
? ?個ELF程序,在沒有被加載到內存的時候,有沒有地址呢? ?
? 進程 mm_struct、vm_area_struct 在進程剛剛創建的時候,初始化數據從哪里來的? ?
答案: ?
?一個ELF程序還沒加載到內存中的時候就已經有地址了,ELF程序的地址采用起始地址和偏移量的方式進行計算,目前的計算機體系中,起始地址都是從0開始的,所以起始地址不可能是真實的物理地址,它是虛擬地址。所以磁盤上的可執行程序,代碼,數據編址,起始就是虛擬地址的統一編址
? ?個ELF程序,在沒有被加載到內存的時候,本來就有地址。當代計算機?作時采用"平坦模式",因此ELF需要對自己的代碼和數據進行統?編址。通過 objdump -S?反匯編后的代碼
最左側顯示的是ELF的虛擬地址(嚴格來說是邏輯地址:起始地址+偏移量)。默認起始地址為0,這意味著程序未被加載時已預先完成虛擬地址編址。 ?
? 進程 mm_struct、vm_area_struct 的初始化數據來源:ELF文件的各個 segment 提供起始地址和長度,用于初始化內核結構中的 [start, end] 范圍數據,并通過詳細地址填充頁表。 ?
結論: ?
? 虛擬地址機制不僅需要操作系統?持,也需要編譯器配合。 ?
重新理解進程虛擬地址空間 ?
ELF 被編譯后,會將程序入口地址記錄在ELF header的 Entry 字段中。
?通過查看ELF表頭,能夠發現里面記錄了Entry point address,這就是程序的入口地址?
?在磁盤中,可執行程序的虛擬地址就已經記錄在磁盤中了,當可執行程序加載到物理內存中,進程會創建task_struct,task_struct中的mm_struct會使用磁盤中可執行程序的起始地址到結尾地址進行初始化,代碼加載到物理內存中,代碼也是要占據物理內存的,所以每一行代碼都有自己的物理地址,然后頁表會將虛擬地址加載到頁表的左側,物理地址加載到頁表的右側
現在的問題是:我們想要運行程序,地址的映射關系都建立好了,但是cpu怎么直到起始地址?
cpu中有兩種寄存器,分別是EIP和CR3,EIP負責讀取程序的起始地址,而CR3負責加載起始地址的內容,cpu就是通過這種方法來執行程序的
動態鏈接與動態庫加載
進程如何看到動態庫
當運行進程A的時候,進程A所依賴的庫也會加載到物理內存中,task_struct的頁表就會建立與庫的映射關系,因為庫也是ELF文件,也有自己的虛擬地址空間
當庫函數被調用的時候,程序會從代碼區跳轉到共享區中執行庫函數,執行后便返回到代碼區中
進程間如何共享庫的?
原理與單個進程類似,都是把所依賴的庫加載到物理內存中,不同的進程中的共享區都會在頁表中創建一塊空間指向加載在物理內存中的庫,但是庫只會在內存中出現一次,在內存中并不會出現相同的庫
當運行庫函數時,程序運行會從代碼區跳轉到各自的共享區中,執行完庫函數就返回到各自的代碼區
動態鏈接 ?
動態鏈接其實遠比靜態鏈接要常用得多。比如我們查看下 hello 這個可執行程序依賴的動態庫,會發 ?現它就用到了一個c動態鏈接庫: ?這里的 libc.so 是C語言的運行時庫,里面提供了常用的標準輸入輸出文件字符串處理等等這些功能。 ?
那為什么編譯器默認不使用靜態鏈接呢?靜態鏈接會將編譯產生的所有目標文件,連同用到的各種 庫,合并形成一個獨立的可執行文件,它不需要額外的依賴就可以運行。照理來說應該更加方便才對是吧? ?
靜態鏈接最大的問題在于生成的文件體積大,并且相當耗費內存資源。隨著軟件復雜度的提升,我們 ?的操作系統也越來越臃腫,不同的軟件就有可能都包含了相同的功能和代碼,顯然會浪費大量的硬盤空間。 ?
這個時候,動態鏈接的優勢就體現出來了,我們可以將需要共享的代碼單獨提取出來,保存成一個獨立的動態鏈接庫,等到程序運行的時候再將它們加載到內存,這樣不但可以節省空間,因為同一個模塊在內存中只需要保留一份副本,可以被不同的進程所共享。 ?
動態鏈接到底是如何工作的?? ?
首先要交代?個結論,動態鏈接實際上將鏈接的整個過程推遲到了程序加載的時候。比如我們去運行?個程序,操作系統會首先將程序的數據代碼連同它用到的?系列動態庫先加載到內存,其中每個動態庫的加載地址都是不固定的,操作系統會根據當前地址空間的使用情況為它們動態分配?段內存。
當動態庫被加載到內存以后,?旦它的內存地址被確定,我們就可以去修正動態庫中的那些函數跳轉地址了。
我們的可執行程序被編譯器動了手腳
?在C/C++程序中,當程序開始執行時,它首先并不會直接跳轉到 main 函數。實際上,程序的入口點 ?是 _start ,這是一個由C運行時庫(通常是glibc)或鏈接器(如ld)提供的特殊函數。 ?在 _start 函數中,會執行一系列初始化操作,這些操作包括: ?
1. 設置堆棧:為程序創建一個初始的堆棧環境。 ?
2. 初始化數據段:將程序的數據段(如全局變量和靜態變量)從初始化數據段復制到相應的內存位 ?置,并清零未初始化的數據段。 ?
3. 動態鏈接:這是關鍵的一步, _start 函數會調用動態鏈接器的代碼來解析和加載程序所依賴的 ?
動態庫(shared libraries)。動態鏈接器會處理所有的符號解析和重定位,確保程序中的函數調 ?
用和變量訪問能夠正確地映射到動態庫中的實際地址。 ?
動態鏈接器: ?
? 動態鏈接器(如ld-linux.so)負責在程序運行時加載動態庫。 ?
? 當程序啟動時,動態鏈接器會解析程序中的動態庫依賴,并加載這些庫到內存中。 ?
環境變量和配置文件: ?
? Linux系統通過環境變量(如LD_LIBRARY_PATH)和配置文件(如/etc/ld.so.conf及其子配置 ?
文件)來指定動態庫的搜索路徑。 ?
? 這些路徑會被動態鏈接器在加載動態庫時搜索。 ?
緩存文件: ?
? 為了提高動態庫的加載效率,Linux系統會維護一個名為/etc/ld.so.cache的緩存文件。 ?
? 該文件包含了系統中所有已知動態庫的路徑和相關信息,動態鏈接器在加載動態庫時會首先 ?
搜索這個緩存文件。 ?
4. 調用 __libc_start_main :一旦動態鏈接完成, _start 函數會調用 ?
__libc_start_main (這是glibc提供的一個函數)。 __libc_start_main 函數負責執行 ?
一些額外的初始化工作,比如設置信號處理函數、初始化線程庫(如果使用了線程)等。 ?
5. 調用 main 函數:最后, __libc_start_main 函數會調用程序的 main 函數,此時程序的執 ?
行控制權才正式交給用戶編寫的代碼。 ?
6. 處理 main 函數的返回值:當 main 函數返回時, __libc_start_main 會負責處理這個返回 ?
值,并最終調用 _exit 函數來終止程序。 ?
動態庫中的相對地址 ?
動態庫為了隨時進行加載,為了支持并映射到任意進程的任意位置,對動態庫中的方法,統一編址采用相對編址的方案進行編制的(其實可執行程序也一樣,都要遵守平坦模式,只不過exe直接加載的)。 ?
# ubuntu下查看任意?個庫的反匯編
objdump -S /lib/x86_64-linux-gnu/libc-2.31.so | less# Cetnos下查看任意?個庫的反匯編
$ objdump -S /lib64/libc-2.17.so | less
我們的程序,怎么和庫具體映射起來的 ?
在創建task_struct時,mm_struct 指向 struct file?指向 struct path? 指向 struct dentory 最終指向inode ,通過inode信息就可以找到磁盤文件中的數據塊,這樣就建立了與動態庫的映射關系
注意: ?
? 動態庫也是一個文件,要訪問也是要被先加載,要加載也是要被打開的 ?
? 讓我們的進程找到動態庫的本質:也是文件操作,不過我們訪問庫函數,通過虛擬地址進 ?
行跳轉訪問的,所以需要把動態庫映射到進程的地址空間中 ?
我們的程序,怎么進行庫函數調用 ?
注意: ?
? 庫已經被我們映射到了當前進程的地址空間中 ?
? 庫的虛擬起始地址我們也已經知道了 ?
? 庫中每一個方法的偏移量地址我們也知道 ?
? 所有:訪問庫中任意方法,只需要知道庫的起始虛擬地址+方法偏移量即可定位庫中的方法 ?
? 而且:整個調用過程,是從代碼區跳轉到共享區,調用完畢在返回到代碼區,整個過程完全在進程地址空間中進行的 ?
先從inode獲取庫的位置,再將庫的代碼和數據加載到物理內存中,然后和頁表建立映射關系,最終就得到了庫的起始虛擬地址,在代碼匯編的時候,會將庫函數替換成在庫中的偏移量,最終在建立映射關系之后,庫函數就變成了庫的起始虛擬地址+偏移量
全局偏移量表GOT(global offset table) ?
注意: ?
? 也就是說,我們的程序運行之前,先把所有庫加載并映射,所有庫的起始虛擬地址都應該提前知道 ?
? 然后對我們加載到內存中的程序的庫函數調用進行地址修改,在內存中二次完成地址設置(這個叫做加載地址重定位) ?
? 等等,修改的是代碼區?不是說代碼區在進程中是只讀的嗎?怎么修改?能修改嗎? ?
所以:動態鏈接采用的做法是在 .data (可執行程序或者庫自己)中專門預留一片區域用來存放函數的跳轉地址,它也被叫做全局偏移表GOT,表中每一項都是本運行模塊要引用的一個全局變量或函數的地址。 ?
? 因為.data區域是可讀寫的,所以可以支持動態進行修改? 、
1. 由于代碼段只讀,我們不能直接修改代碼段。但有了GOT表,代碼便可以被所有進程共享。但在不同進程的地址空間中,各動態庫的絕對地址、相對位置都不同。反映到GOT表上,就是每個進程的每個動態庫都有獨立的GOT表,所以進程間不能共享GOT表。 ?
2. 在單個.so下,由于GOT表與 .text 的相對位置是固定的,我們完全可以利用CPU的相對尋址來找到GOT表。 ?
3. 在調用函數的時候會首先查表,然后根據表中的地址來進行跳轉,這些地址在動態庫加載的時候會被修改為真正的地址。 ?
4. 這種方式實現的動態鏈接就被叫做 PIC 地址無關代碼 。換句話說,我們的動態庫不需要做任何修改,被加載到任意內存地址都能夠正常運行,并且能夠被所有進程共享,這也是為什么之前我們給編譯器指定-fPIC參數的原因,PIC=相對編址+GOT。 ?
庫間依賴
注意: ?
? 不僅僅有可執行程序調用庫 ?
? 庫也會調用其他庫!!庫之間是有依賴的,如何做到庫和庫之間互相調用也是與地址無關的呢?? ?
? 庫中也有.GOT,和可執行一樣!這也就是為什么大家都為什么都是ELF的格式!
由于GOT表中的映射地址會在運行時去修改,我們可以通過gdb調試去觀察GOT表的地址變化。
? 由于動態鏈接在程序加載的時候需要對大量函數進行重定位,這一步顯然是非常耗時的。為了進一步降低開銷,我們的操作系統還做了一些其他的優化,比如延遲綁定,或者也叫PLT(過程連接表(Procedure Linkage Table))。與其在程序一開始就對所有函數進行重定位,不如將這個過程推遲到函數第一次被調用的時候,因為絕大多數動態庫中的函數可能在程序運行期間一次都不會被使用到。 ?
思路是:GOT中的跳轉地址默認會指向一段輔助代碼,它也被叫做樁代碼/stup。在我們第一次調用函數的時候,這段代碼會負責查詢真正函數的跳轉地址,并且去更新GOT表。于是我們再次調用函數的時候,就會直接跳轉到動態庫中真正的函數實現。總而言之,動態鏈接實際上將鏈接的整個過程,比如符號查詢、地址的重定位從編譯時推遲到了程序的運行時,它雖然犧牲了一定的性能和程序加載時間,但絕對是物有所值的。因為動態鏈接能夠更有效的利用磁盤空間和內存資源,以極大方便了代碼的更新和維護,更關鍵的是,它實現了二進制級別的代碼復用。解析依賴關系的時候,就是加載并完善互相之間的GOT表的過程。 ?