1. 目標文件
編譯和鏈接這兩個步驟,在Windows下被我們的IDE封裝的很完美,我們?般都是?鍵構建?常?便, 但?旦遇到錯誤的時候呢,尤其是鏈接相關的錯誤,很多?就束??策了。在Linux下,我們之前也學 習過如何通過gcc編譯器來完成這?系列操作。
接下來我們深?探討?下編譯和鏈接的整個過程,來更好的理解動靜態庫的使?原理。
先來回顧下什么是編譯呢?編譯的過程其實就是將我們程序的源代碼翻譯成CPU能夠直接運?的機器
代碼。
?如:在?個源?件 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 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 header) :描述?件的主要特性。其位于?件的開始位置,它的主要?的是定位? 件的其他部分。
? 程序頭表(Program header table) :列舉了所有有效的段(segments)和他們的屬性。表? 記著每個段的開始的位置和位移(offset)、?度,畢竟這些段,都是緊密的放在?進制?件中, 需要段表的描述信息,才能把他們每個段分割開。
? 節頭表(Section header table) :包含對節(sections)的描述。
? 節(Section ):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
查看section合并的segment命令:readelf -l
為什么要將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節 :Symbol Table符號表,就是源碼??那些函數名、變量名和代碼的對應關系。
? .got.plt節 (全局偏移表-過程鏈接表):.got節保存了全局偏移表。.got節和.plt節?起提供了對導?的共享庫函數的訪問??,由動態鏈接器在運?時進?修改。對于GOT的理解,我們后?說。
? 使? readelf 命令查看.so?件可以看到該節。
從執?視圖來看:
? 告訴操作系統哪些模塊可以被加載進內存。
? 加載進內存之后哪些分段是可讀可寫,哪些分段是只讀,哪些分段是可執?的。?
// 查看?標?件
$ 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: 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 # 該程序需要的體系結構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 HEADER 這部分來說,我們只?知道其作?即可,它的主要?的是定位?件的其他部分。?????????
4. 靜態鏈接
> 無論是自己的.o還是靜態庫中的.o,本質都是把.o文件進行合并的過程!
> 所以,研究靜態鏈接,本質是研究.o是如何鏈接的。
下面我們先來做一個小實驗:
以下我們創建了兩個.c文件:
?我們知道這倆個文件要編譯形成可執行就必須先各自形成.o文件,在鏈接形成可執行。
?好,接下來我們來認識一個命令->objdump -d,該命令可以將代碼段進行反匯編查看!????????
?我們可以看到,這兩個文件call指令對應調用printf和run函數,但是,我們會發現,它們對應的跳轉地址都被設成了0。這是為什么呢?
其實,在編譯hell.c和code.c時,編譯器完全不知道printf和run函數的存在,比如它們位于內存的哪個區塊,代碼長什么樣都是不知道的!因此,編譯器只能暫時將它們的跳轉地址設置為0。
而這些地址事實上會在鏈接的時候被修正!? ??
我們也可以通過readelf -s命令來讀取文件的符號表:
printf底層調用的就是puts,我們看到,UND就是undefined,表示在本.o文件中找不到該函數。
我們再看形成的可執行文件的符號表:
?16:就是run函數所在的section被合并最終的那?個section中了,16就是下標。
因此,我們可以得出結論:兩個.O文件的代碼段合并到了一起,并同一進行編址,鏈接的時候,會修正沒有確定的函數地址,在合并完成之后,進行相關call地址,完成函數的調用。
靜態鏈接就是把庫中的.o進行合并,和上述過程?樣。
所以鏈接其實就是將編譯之后的所有目標文件連同用到的?些靜態庫運行時庫組合,拼裝成?個獨立的可執行文件。其中就包括我們之前提到的地址修正,當所有模塊組合在?起之后,鏈接器會根據我們的.o文件或者靜態庫中的重定位表找到那些需要被重定位的函數全局變量,從而修正它們的地址。這其實就是靜態鏈接的過程。
所以,鏈接過程中會涉及到對.o中外部符號進行地址重定位。?【因此,.o文件也叫重定位文件】
5. ELF加載與進程地址空間
5.1 虛擬地址/邏輯地址
> 問:一個ELF程序沒有被加載到內存時,有沒有地址呢?
答:?個ELF程序,在沒有被加載到內存的時候,本來就有地址,當代計算機?作的時候,都采用"平坦模式"進行?作。所以也要求ELF對自己的代碼和數據進行統?編址,下?是 objdump -S 反匯編之后的代碼
最左側的就是ELF的虛擬地址,其實,嚴格意義上應該叫做邏輯地址(起始地址+偏移量),但是我們 認為起始地址是0.也就是說,其實虛擬地址在我們的程序還沒有加載到內存的時候,就已經把可執執行程序進行統?編址了.
> 問:進程mm_struct、vm_area_struct在進程剛剛創建的時候,初始化數據從哪?來的?
答:從ELF各個 segment來,每個segment有自己的起始地址和自己的長度,用來初始化內核結構中的[start,end] 等范圍數據,另外在用詳細地址,填充頁表.
所以:虛擬地址機制,不光光OS要?持,編譯器也要?持.
5.2 重新理解進程虛擬地址空間
可執行程序在加載到物理內存的同時,系統自動創建進程【對應的PCB task_struckt】,其中系統會把可執行程序中的各個segment的邏輯地址來初始化mm_struct中的各區域地址,并且同時初始化頁表中的虛擬地址。在物理內存中,每條代碼和數據都有其物理地址,而這些地址也被用來初始化頁表中的物理地址,從而建立頁表中虛擬地址和物理地址的映射關系!
> 那cpu是如何知道可執行程序的入口呢?
ELF在被編譯好之后,會把自己未來程序的入口地址記錄在ELF header的Entry字段中,cup通過系統自動讀取到程序的入口地址【注意,該字段在ELF header中,是被編譯器處理過的邏輯地址,也就是虛擬地址】,所以cpu還要通過頁表來查找到實際的物理地址,最后再開始執行程序!【所以,cup寄存器中拿到的地址全部都是虛擬地址】。
?結合文件系統部分的知識,先找到磁盤中的文件的整體程序執行邏輯。入下圖所示:
5.3 進程如何看待動態庫
我們之前就知道了,可執行程序在合并時,如果需要用到動態庫,動態庫是不會被一起合并的!所以,如果進程需要找到動態庫,那么動態庫也需要加載到物理內存,庫依舊是ELF文件,其加載過程和上面所述一樣。不同之處在于:進程地址空間中,程序在調用庫函數時,需要從代碼區跳轉到共享區,然后查找頁表找到庫函數。調用結束后,調轉回代碼區!
> 一個進程看待動態庫如此,那2個多個進程又如何呢?
其實也非常簡單,一張圖就能明白。在實際物理內存中,只有一個動態庫被加載進來,但每個進程都可以找到這個動態庫。如此一來就實現了動態庫在物理內存中只有一份,這也就節省了物理內存空間!【這也是動態庫也稱為共享庫的原因】? ? ? ??
6. 動態鏈接?
6.1 動態鏈接到底是如何工作的
?先要交代?個結論,動態鏈接實際上將鏈接的整個過程推遲到了程序加載的時候。比如我們去運行?個程序,操作系統會?先將程序的數據代碼連同它?到的?系列動態庫先加載到內存,其中每個動態庫的加載地址都是不固定的,操作系統會根據當前地址空間的使?情況為它們動態分配?段內存。 當動態庫被加載到內存以后,?旦它的內存地址被確定,我們就可以去修正動態庫中的哪些函數跳轉地址了。
> 我們的可執行程序實際上是被編譯器動了手腳的!!
在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 函數來終?程序。
6.2 程序怎么進行庫函數調用
? 庫已經被我們映射到了當前進程的地址空間中
? 庫的虛擬起始地址我們也已經知道了
? 庫中每?個方法的偏移量地址我們也知道
? 所有:訪問庫中任意方法,只需要知道庫的起始虛擬地址+方法偏移量即可定位庫中的方法
? 而且:整個調用過程,是從代碼區跳轉到共享區,調用完畢在返回到代碼區,整個過程完全在進程地址空間中進行的.
程序運行之前,所有的庫都被加載到內存中,并建立映射關系,所有庫的虛擬地址都是提前知道的【這在編譯時就已經完成,動態庫和ELF文件一樣采用相對編址的方式】,然后,我們對加載到內存中的程序的庫函數調用進行地址修改,在內存中完成二次地址修改【這個過程叫做加載地址重定位】。
?> 但是,代碼區不是只讀的嗎?這又是如何做到修改呢?
所以,動態鏈接采用的方法是:在.data區中,專門預留一塊區域來存放函數的跳轉地址,他也被叫全局偏移表GOT,表中每?項都是本運行模塊要引用的?個全局變量或函數的地址。
至此,程序在進行庫函數調用時,不再關心加載時被修改的函數地址,只需要查表即可。而GOT表在數據區,可以方便二次編址時修改。【GOT表中開始有每個庫函數的和其對應的動態庫,當動態庫加載時,這些庫函數的地址就會被修正】。
> 這種方式實現的動態鏈接就被叫做 PIC 地址無關代碼 。換句話說,我們的動態庫不需要做任何修 改,被加載到任意內存地址都能夠正常運行,并且能夠被所有進程共享,這也是為什么之前我們給編譯器指定-fPIC參數的原因,PIC=相對編址+GOT
7. 總結
? 靜態鏈接的出現,提高了程序的模塊化水平。對于?個大的項目,不同的人可以獨立地測試和開發自己的模塊。通過靜態鏈接,生成最終的可執行文件。
? 我們知道靜態鏈接會將編譯產生的所有目標文件,和用到的各種庫合并成?個獨立的可執行文件, 其中我們會去修正模塊間函數的跳轉地址,也被叫做編譯重定位(也叫做靜態重定位)。
? 而動態鏈接實際上將鏈接的整個過程推遲到了程序加載的時候。比如我們去運行?個程序,操作系統會首先將程序的數據代碼連同它用到的?系列動態庫先加載到內存,其中每個動態庫的加載地址都是不固定的,但是無論加載到什么地方,都要映射到進程對應的地址空間,然后通過.GOT方式進行調用(運行重定位,也叫做動態地址重定位)。