Linux庫——庫的制作和原理(2)_庫的原理

文章目錄

  • 庫的原理
    • 理解目標文件
    • ELF文件
    • 讀取ELF的工具——readelf
    • ELF從形成到加載的輪廓
      • ELF形成可執行文件
      • ELF可執行的加載
    • 理解鏈接與加載
      • 靜態鏈接
      • ELF加載和進程地址空間
        • 虛擬地址 & 邏輯地址
        • 重新理解進程地址空間
      • 動態鏈接和動態庫的加載
        • 進程如何找到動態庫
        • 多個進程之間如何共享動態庫
      • 動態鏈接
        • 編譯器對程序的修改
        • 程序如何跳轉動態庫內執行
        • 動態鏈接——函數重定向的時機和機制
          • 全局偏移量表GOT
          • PLT機制
        • fPIC的解釋
        • 庫間依賴

庫的原理

上一個章節,已經講解了庫的基本制作和使用,同時講解了一些細節。
本片文章,將重點對庫背后相關的原理進行講解。

理解目標文件

在學習gcc/g++編譯器之前,我們大部分人都是使用IDE,如vs 2022進行編寫代碼和編譯代碼。但是,IDE是一個集成環境,很多事情是幫我們做好的,我們并不會過多關心代碼編譯的過程(如預處理、編譯、鏈接,庫加載等)。

但是我們學習了gcc/g++后,我們就對代碼的翻譯流程有了一個基本的認識,明白了背后的過程,已經相應的指令操作。


我們曾經說過一個事情:
即在工程上,編譯代碼的時候,更傾向于的是把所有的.c /.cpp源文件通通編譯成.o目標文件,然后再統一鏈接目標文件和相應的庫。
在這里插入圖片描述
但是,真正的原因是什么呢?這樣做有什么好處嗎?

好處是大大的有:
因為所有的代碼,在鏈接前都是獨立的,并沒有關系!只有在鏈接的時候才會相關聯。

工程上來說,源文件會有很多,數量可達幾萬個或者十幾萬個。那么多個源文件,如果都要一次性的進行編譯,那就很麻煩了。如果說,某一次只對其中某幾個文件的某幾個位置進行了小修改,那就需要把所有的文件重新編譯鏈接。這十分浪費效率。

但是如果全部編成目標文件,那就不用擔心。因為gcc/g++能夠識別到哪些源文件被修改了,需要重新編譯成目標文件,然后編譯完修改后的目標文件后再全部一起鏈接,這提高了效率。

當然,實際上來說,工程上代碼太多,所以會分成若干個模塊,每個模塊都會把所有的文件打包成靜態庫,然后讓執行代碼的模塊統一進行庫的鏈接即可!


目標文件,其實也叫做可重定向目標文件。我們學過文件的重定向,這里重定向目標文件的重定向,其實原理和文件的重定向有些類似,但在實現上和效果上,是有很大的區別。這里我們先不對這個概念解釋,我們留在原理介紹完后再來理解。

我們現在要知道的是:
目標文件是二進制文件,庫是這些二進制目標文件的集合!
也就是說,庫是一些二進制目標文件按照一定格式打包的集合!

所以,這里我們就會猜測,既然滿足一定的打包格式,那么是否是說,這些二進制文件是否滿足一定格式呢?我們來看看:
在這里插入圖片描述
這里我們把目標文件和庫的文件類型用指令file展示出來,我們會發現,這些文件最后都有一個同樣的內容:ELF。雖然靜態庫沒有,但是靜態庫的本質和動態庫是一樣的,都是目標文件的集合。

所以,在這里我們可以猜測:
ELF是二進制目標文件的一種規定格式!

然后通過這一系列的相同格式的目標文件,可以按照一樣的格式進行打包,把相同的內容放在同一個區域內,這就是庫的打包。

ELF文件

其實,??ELF是一種二進制文件格式??,用于規范目標文件(.o)、靜態庫(.a)、動態庫(.so)和可執行文件的存儲結構。也就是說,這些二進制文件會按照ELF規定的格式進行存儲內容。

下面我們一起來看一下ELF格式:

在這里插入圖片描述

我們把二進制文件按照如上的ELF格式進行分區,真正存儲相關數據和信息的位置,是 Sections部分,也稱為節。這是ELF文件中的基本組成單位。
不同的數據會被存儲在不同的節中,如代碼節中存儲了可執行代碼,數據節存儲了全局變量和靜態數據等。


當然,這些節的一些描述信息會存儲在Sections Header Table中,即節頭表。這個表中是用來描述ELF中一個又一個的節的相關信息的。

這里我們只介紹幾個我們需要知道的節:
1.text節,這個是代碼節,用來保存機器指令,是程序的主要執行部分。

2.數據節(.data):保存已初始化的全局變量和局部靜態變量。

3.(.bss節)
這個節我們稍微解釋一下。在我們的c/c++代碼中,有些變量:如未初始化的全局變量、顯式初始化為0的變量,靜態變量。
這些變量其實不是說,一聲明就開辟空間的。

這些變量會通通的把名字記錄在bss段內,雖然它們變量本身是有地址的,但是數據不是獨自的地址。數據是共同指向一個數據空間。即這些變量都指向一個0的數據。直到真的需要用的時候,或者初始化變量的時候才會真正地開辟空間存儲這些變量!所以這樣就解釋了為什么全局數據不初始化默認為0了。

這樣做的好處是:可以極大的減少磁盤中存儲ELF文件時候的空間!因為這些數據在不使用的時候不占據真實的磁盤空間!


程序頭表(Program header table)的作用是,記錄一些相關方法。用于指導操作系統和動態鏈接器如何將可執行文件或共享庫(動態庫)??加載到內存并執行??的關鍵結構。我們可以這么理解,即程序頭表是用來記錄可執行文件加載的方法等一系列信息的。


而最后,對于ELF Header,這是一個用來記錄當前文件在ELF格式下的一些相關分區信息的。比如每個節占用多少容量,分區的邊界等。

讀取ELF的工具——readelf

對于上面的信息,我們直接說理論和概念是很難講清楚的。如果能夠看的到里面的對應內容就好了。但是ELF文件都是二進制文件,所以需要一定的工具來進行內容的讀取。這個工具就是readelf,可以通過不同選項來控制讀取ELF中哪個部分的內容:

選項作用
-h查看文件頭
-l查看程序頭表
-S查看節頭表
-s查看符號表
-d查看動態段
-r查看重定位信息
-a顯示所有信息

1.readelf -h xxx,查看xxx的ELF Header
在這里插入圖片描述
果然,ELF Header中確實是記錄了一些相關的分區信息:節的大小,程序表頭的開始,節表頭的開始等…


2.readelf -l xxx,查看xxx的程序頭表(Program header table)。
在這里插入圖片描述
顯出出來的一些信息很難看得懂,但是我們知道這是用來系統加載庫、控制鏈接的手段即可。


3.readelf -S xxx,查看xxx的節頭表,即每個節的相關信息:

[ynp@hcss-ecs-1643 mylib_together]$ readelf -S libmyc.so
There are 28 section headers, starting at offset 0x2b40:Section Headers:[Nr] Name              Type             Address           OffsetSize              EntSize          Flags  Link  Info  Align[ 0]                   NULL             0000000000000000  000000000000000000000000  0000000000000000           0     0     0[ 1] .note.gnu.build-i NOTE             00000000000001c8  000001c80000000000000024  0000000000000000   A       0     0     4[ 2] .gnu.hash         GNU_HASH         00000000000001f0  000001f00000000000000050  0000000000000000   A       3     0     8[ 3] .dynsym           DYNSYM           0000000000000240  0000024000000000000002a0  0000000000000018   A       4     1     8[ 4] .dynstr           STRTAB           00000000000004e0  000004e00000000000000109  0000000000000000   A       0     0     1[ 5] .gnu.version      VERSYM           00000000000005ea  000005ea0000000000000038  0000000000000002   A       3     0     2[ 6] .gnu.version_r    VERNEED          0000000000000628  000006280000000000000020  0000000000000000   A       4     1     8[ 7] .rela.dyn         RELA             0000000000000648  0000064800000000000000c0  0000000000000018   A       3     0     8[ 8] .rela.plt         RELA             0000000000000708  000007080000000000000168  0000000000000018  AI       3    22     8[ 9] .init             PROGBITS         0000000000000870  00000870000000000000001a  0000000000000000  AX       0     0     4[10] .plt              PROGBITS         0000000000000890  000008900000000000000100  0000000000000010  AX       0     0     16[11] .text             PROGBITS         0000000000000990  000009900000000000000586  0000000000000000  AX       0     0     16[12] .fini             PROGBITS         0000000000000f18  00000f180000000000000009  0000000000000000  AX       0     0     4[13] .rodata           PROGBITS         0000000000000f21  00000f21000000000000001d  0000000000000000   A       0     0     1[14] .eh_frame_hdr     PROGBITS         0000000000000f40  00000f400000000000000044  0000000000000000   A       0     0     4[15] .eh_frame         PROGBITS         0000000000000f88  00000f880000000000000104  0000000000000000   A       0     0     8[16] .init_array       INIT_ARRAY       0000000000201df8  00001df80000000000000008  0000000000000008  WA       0     0     8[17] .fini_array       FINI_ARRAY       0000000000201e00  00001e000000000000000008  0000000000000008  WA       0     0     8[18] .jcr              PROGBITS         0000000000201e08  00001e080000000000000008  0000000000000000  WA       0     0     8[19] .data.rel.ro      PROGBITS         0000000000201e10  00001e100000000000000008  0000000000000000  WA       0     0     8[20] .dynamic          DYNAMIC          0000000000201e18  00001e1800000000000001c0  0000000000000010  WA       4     0     8[21] .got              PROGBITS         0000000000201fd8  00001fd80000000000000028  0000000000000008  WA       0     0     8[22] .got.plt          PROGBITS         0000000000202000  000020000000000000000090  0000000000000008  WA       0     0     8[23] .bss              NOBITS           0000000000202090  000020900000000000000008  0000000000000000  WA       0     0     1[24] .comment          PROGBITS         0000000000000000  00002090000000000000002d  0000000000000001  MS       0     0     1[25] .symtab           SYMTAB           0000000000000000  000020c000000000000006c0  0000000000000018          26    45     8[26] .strtab           STRTAB           0000000000000000  0000278000000000000002c5  0000000000000000           0     0     1[27] .shstrtab         STRTAB           0000000000000000  00002a4500000000000000f9  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),l (large), p (processor specific)

比如像這里我們自己寫的動態庫,一共有28個Sections。打印出了每個節的名字、類型屬性,地址,偏移量等信息。

這里簡單說一下偏移量offset,因為這個涉及到后續對于原理的理解:
我們可以把整個ELF文件想象成一個大的一維數組。所謂文件上內容,其實就是數組里面一個又一個字節罷了。假設每個文件的起始地址都設定為0,那么只需要記錄每個分區,每個節的起始位置相對于開頭的偏移量,和結束位置的偏移量就能知道每個分區的大小。


4.readelf -s xxx,查看xxx的符號表
這里就不進行展示了,我們來簡單說說符號表究竟是什么。

符號表,是ELF文件下用于記錄程序中定義的函數、變量、以及引用的外部符號的名稱、類型、地址等信息的。

ELF從形成到加載的輪廓

ELF形成可執行文件

首先,我們大概可以知道的是,ELF文件時二進制文件的一種格式。

假設當前有多個.c的源代碼,那么只需要把它們通通翻譯成.o的目標文件即可。此時就得到了許多ELF格式的.o目標文件。
我們可以這么理解,所謂生成ELF格式的二進制文件,其實就是把代碼中的相關信息以二級制的方式,根據ELF的格式,寫入到.o目標文件內。

然后,所有庫、目標文件都是ELF格式,庫的制作、可執行文件的鏈接,其實本質上就是把需要鏈接的所有二進制ELF文件的相對應的信息合并在一起:
在這里插入圖片描述
把所有的Sections進行合并到一個ELF格式上,然后更新節表頭的信息,然后根據當前分區情況重新調整ELF Header中的一些相關分區信息。這樣,就完成了有多個ELF文件生成可執行文件了。

ELF可執行的加載

但是,由于可執行文件中合并了多個Sections,會導致Sections過多,系統會自動地將具有一些相同屬性的Sections(如權限可讀、可寫、可執行、又或者運行時是否需要開空間)合并,形成段(sgement)。

這種合并的原則和合并的方式其實也是屬于程序頭表的內容,我們可以使用readelf -l xxx查看對應的segment:
在這里插入圖片描述
在該指令展示的信息的最底下部分,我們可以看到Sections to Segment mapping,即節和段的映射。

我們可以發現,有很多節(以.開頭的名稱),都被合并成了一個段!
其中我們會發現,.data.rel.ro數據節和.bss節,即一些常量數據和全局數據,是被放在了同一個節上。這也就解釋了為什么虛擬地址空間上常量和數據段是放在一起的。

將Sections合并成Sgements的原因:
Section合并的主要原因是為了減少頁面碎片,提高內存使用效率。如果不進行合并,假設頁面大小為4096字節(內存塊基本大小,加載,管理的基本單位),如果.text部分為4097字節,.init部分為512字節,那么它們將占用3個頁面,而合并后,它們只需2個頁面。(因為一個頁面內一般來說只有一個節的內容。)
此外,操作系統在加載程序時,會將具有相同屬性的section合并成一個大的segment,這樣就可以實現不同的訪問權限,從而優化內存管理和權限訪問控制。

理解鏈接與加載

前面講的,都是為了后序理解原理的前置知識。
接下來這個部分,我們將正式進入對庫的加載和運行原理的理解。

靜態鏈接

首先我們來看靜態鏈接的過程。
靜態鏈接指的是:自己寫的.o文件進行鏈接或者和靜態庫進行鏈接。

我們現在來使用一份代碼,來理解一下靜態鏈接的過程:

//hello.c
#include<stdio.h>void run();int main(){printf("hello\n");run();return 0;
}//run.c
#include<stdio.h>void run(){printf("running\n");
}

首先,我們需要把這兩個文件生成目標文件:
在這里插入圖片描述
然后進行鏈接生成可執行文件:
在這里插入圖片描述


這個時候,我們就得到了鏈接前后的ELF文件,我們需要查看它們有什么不同。
所以,我們需要介紹一個工具:objdump,這個可以對代碼進行反匯編。
使用objdump -d 文件 > xxx.s可以把二進制文件反匯編代碼重定向到xxx.s文件。

在這里插入圖片描述
反匯編代碼或許我們看不懂全部,但是一些基礎的操作我們肯定還是知道的。比如調用函數的時候,其實是使用call 函數地址進行調用的。

我們重點觀察上面這個兩個沒有鏈接過的二進制文件:
在這里插入圖片描述
這就再一次證明了我們一直在說的一個結論:
即多個.o文件,在鏈接前,都是獨立的,互不影響的!

讀取一下run.o和hello.o的符號表,符號表內存儲的是ELF文件內相關的代碼、數據、函數、變量、地址等相關信息的。

在這里插入圖片描述
printf的底層實現就是puts,puts還要調用系統調用接口。


我們從符號表讀出來相關的函數信息發現,在沒有進行鏈接前,系統是不需要管是否找的到對應的函數的。通通給上地址0。
我們來驗證一下:
在這里插入圖片描述
我們直接在hello.c加入一個沒有定義的函數,我們嘗試著編譯一下:
在這里插入圖片描述
發現照樣是可以編譯成.o目標文件的!

但是如果加入了一個找不到定義的函數,鏈接的時候就會報錯了!
在這里插入圖片描述


我們把代碼修改成正確版本,然后再來看看生成的可執行文件對應的數據:
readelf -s main.exe

在這里插入圖片描述
objdump -d main.exe > main.s
vim main.s
在這里插入圖片描述我們會發現,run的定義確實給找到了,但是puts,也就是printf仍然是UND狀態!因為printf使用的是libc.so動態庫,需要動態鏈接。這里先不講如何動態鏈接。

而上面第一張圖圈出來的地方中,我們會發現有數字16.,意思就是這些代碼最終會被合并到編號為16的Sections:(即代碼節)。
也就是hello.o和run.o的.text節被合并了,是main.exe的第16個Section
在這里插入圖片描述


所以靜態鏈接其實就是將編譯之后的所有目標文件連同用到的一些靜態庫組合,將拼裝成一個獨立的可執行文件。其中就包括我們之前提到的地址修正,當所有模塊組合在一起之后,鏈接器會根據我們的.o文件或者靜態庫中的重定位表找到那些需要被重定位的函數全局變量,從而修正它們的地址。這其實就是靜態鏈接的過程。

所以,.o文件被稱為可重定向目標文件是有原因的!因為鏈接的時候會修改每個函數中沒有詳細定義的函數地址!這就是目標文件內的重定向。
在這里插入圖片描述

注:上述的所有過程中并沒有涉及到動態鏈接的問題!


ELF加載和進程地址空間

在理解動態庫鏈接的原理前,我們需要重談一下進程地址空間的相關內容。

虛擬地址 & 邏輯地址

首先,我們引出一個問題:
即可執行程序沒有被加載到進程上的時候,也就是不被cpu調度執行的時候,是否有地址呢?
答案是:有的。

這個理由很簡單:因為我們能夠在反匯編代碼上看到各個變量最后都是被地址給替代的。最終系統在尋找變量的時候,都是通過尋址查找的。所以,必然是有地址的。


接下來,我們需要來聊聊虛擬地址、邏輯地址、物理地址的相關話題。

首先,我們現在有一個ELF文件,它里面會被分為各種區域和Segments存儲相關數據和信息。這些的內容必然是存儲在磁盤上的。那么,這些數據的分區很可能是在磁盤上不連續的!

在這里插入圖片描述
也就是說,一個ELF文件內,可能每一個區域的數據塊地址在磁盤中的物理地址都是不一樣的。所以,如果某個區域內有數據/函數,想要尋址就得使用公式:
該節的起始地址 + 相對于該節起始地址的偏移量

在這里插入圖片描述
所以邏輯地址,是磁盤內的。早期的系統都是直接把磁盤的邏輯地址拿來編址。但是這樣子計算非常的不方便,因為要算每一個節的起始地址和相對節的起始地址的偏移量。


所以,后來的系統為了解決這個問題,相出了一個平坦編址的方法。
因為整個ELF文件內的所有起始地址都不會重復,也不會有數據的沖突。所以,操作系統干脆做了一層抽象映射,直接把ELF頭部位置的地址設為0。然后其它的數據會按照虛擬地址空間的排布方式來決定平坦編址的時候不同區域數據的相對于ELF頭部的偏移量。
所以,在如今的系統中,每個ELF文件(鏈接前后)都是使用平坦編址的:
在這里插入圖片描述
看第一列數字就知道了。
(雖然每個ELF文件起始地址都是0,但實際上這是平坦編址模式編排過的)。

然后,如果是需要進行多個庫 + 目標文件的鏈接,那么就會把所有的ELF文件重新進行全局的平坦編址:
在這里插入圖片描述
但是地址不再是從0開始了!

因為文件經過鏈接后,會把所有的ELF文件重新進行全局的平坦編址。但是,最終鏈接生成的時可執行程序!可執行程序加載到內存的時候,進程時通過進程地址空間和頁表來索引內存中的相關內容的。

所以,這個鏈接后的可執行文件,會再次進行映射,把重新編好的ELF頭部,地址原本為0,映射到虛擬地址空間,得到該ELF頭部的基址。然后ELF中其余的數據只需要使用基址 + 原來平坦編址下想對于ELF頭部的偏移量就可以得到其余數據的地址!
所以,我們再可執行文件中看到的編址,其實是經過一層映射的!看的其實是該ELF在虛擬地址空間上的各個數據的地址!

而且,ELF映射到的虛擬地址空間,是和進程地址空間相對應的!進程運行前,需要對進程的地址空間進行分區,那么分區的數據怎么來?進程怎么知道每個區多大,有什么數據?
答案是從ELF映射的虛擬地址空間來!

重新理解進程地址空間

所以,通過上述的分析,我們終于知道了進程地址空間的相關分區是如何進行初始化的!

但是,還是有一個問題,即CPU在調度進程的時候,CPU怎么知道從哪里開始執行代碼呢?剛剛的過程只是把可執行程序的相關內容編址后映射到了虛擬地址空間,從而初始化進程地址空間。但是CPU此時不知道該從哪里執行代碼。

這個問題很好解決,在ELF的頭部里面添加一個信息——即編址后代碼開始執行的位置:
在這里插入圖片描述
在ELF Header中,存在著一個信息Entry point addreee,這就是用來標識該可執行文件的開始執行的地址。

CPU執行代碼具體的流程如下所示:
在這里插入圖片描述
CPU從虛擬地址空間內拿到虛擬地址(從Entry point address開始拿),然后在cpu內通過MMU機制查詢頁表,進行虛擬地址向物理地址的轉化,然后執行代碼。

當然,進程地址空間還可以再進行細化,因為虛擬地址空間內只是宏觀描述了數據存儲的位置。但是對于一些容易造成內存片段的區域是需要更加細致的描述vm_area_struct

在這里插入圖片描述
所以,最后我們了解了程序從生成到加載到執行的基本流程(除了動態庫)。
我們也知道一個結論:
虛擬地址機制,不光OS要支持,編譯器也要支持!

動態鏈接和動態庫的加載

進程如何找到動態庫

我們知道,動態庫的本質其實也是多個目標文件的合并!只不過是,在系統中只需要存在一份,然后使用庫中的方法的時候需要跳轉到動態庫內進行執行。

動態庫存儲在特定的目錄下,本質上也是磁盤的某些位置。但是動態庫不像靜態庫那樣,在鏈接的時候會進行多個ELF的合并。那么,進程要跳轉到動態庫中進行執行代碼,那就必須明白一個問題:進程如何找到動態庫?

在這里插入圖片描述
在進程地址空間中,存在著一個叫做共享區的區域。動態庫的另外一個名字叫做共享庫!

也就是說,可以對動態庫的內容進行平坦編址,編址完成后再映射到虛擬地址空間的共享區即可。進程地址空間的共享區的分區信息就是由這虛擬地址進行轉化的。

也就是說,先在磁盤上進行對動態庫的編址,然后加載到物理內存的同時,把動態庫在虛擬內存中的編址用來初始化進程地址空間的共享區!同時進行虛擬地址和物理地址的映射,完成頁表的填寫。

這樣子,CPU在執行代碼的時候,就能夠找得到動態庫的相關內容了。

多個進程之間如何共享動態庫

我們又知道,動態庫在系統中是只存在一份在指定目錄下供多個進程進行調用的。那多個進程如何同時共享一份動態庫呢?

在這里插入圖片描述
還是一樣的,首先讓動態庫在加載到內存前就完成好平坦編址。然后讓動態庫加載到物理內存的同時,再根據不同進程的進程地址內存分布情況,映射到不同進程對應的共享區位置。

也就是說,很有可能,同一個動態庫在不同進程上看到的虛擬地址是不同的!但是不需要擔心,因為有頁表的存在,頁表會完成從虛擬地址到真實地址的映射。

動態鏈接

首先這里需要輸出一個結論:
和動態庫的鏈接推遲到了程序加載掉用的時候!

因為我們知道,靜態鏈接是直接把靜態庫和目標文件的ELF格式進行合并了,其實就是把靜態庫的內容放到了可執行文件內!

但是我們使用readelf -s 可執行文件查看符號表的時候發現,一個可執行程序,其使用動態庫的狀態仍是UND(undefined)。這其實就是上面結論的驗證!
因為此時可執行程序還沒有運行加載!和動態庫的鏈接需要等到運行程序的時候才會進行!
在這里插入圖片描述
上面是查詢一個未執行的可執行程序的符號表的結果。

我們可以發現,確實是有一部分的函數/內容是UND狀態!因為這些內容都是在動態庫內的!此時還沒有和動態庫進行鏈接,也就導致符號表內部分函數還沒有進行重定位!
這些函數需要等到程序加載到內存上運行的時候,才會和動態庫進行鏈接,從而修改地址!

但是當我們查詢main.exe的反匯編代碼的時候,我們會發現動態庫的函數是有地址的:
在這里插入圖片描述
其實,這個地址是編譯器假定動態庫已經加載到進程地址空間上的。這個地址是假的!
當然,具體要怎么樣修改成真的地址,需要等到后面動態庫加載鏈接的原理來說。

所以,這里再一次證明,動態鏈接是被推遲到程序加載運行的時候!

編譯器對程序的修改

我們可以使用ldd指令查看一下程序依賴的動態庫:
在這里插入圖片描述
我們發現,除了綠色的標識的是標準c庫之外,幾乎所有的指令程序都有依賴一個叫做/lib64/ld-linux-x86-64.so.2的庫。這是用來干什么的呢?


我們曾說過,c語言的入口函數在我們看來,可能是main函數,但是實際上并不是。因為操作系統會默認的幫我們做好一些事情(比如打開stdout,stdin,stderr),所以這必然導致了編譯器在編譯代碼的時候對我們的代碼做了一些修改。

實際上,在Linux系統下,c程序的真正入口是_start函數。這個函數負責做什么?
1.設置堆棧:為程序創建一個初始的堆棧環境。
2. 初始化數據段:將程序的數據段(如全局變量和靜態變量)從初始化數據段復制到相應的內存位置,并清零未初始化的數據段。(其實就是通過平坦編址后的可執行程序的虛擬地址來初始化進程地址空間相應位置)。
3. 動態鏈接:這是關鍵的一步, _start函數會調用動態鏈接器的代碼來解析和加載程序所依賴的動態庫(shared libraries)。動態鏈接器會處理所有的符號解析和重定位,確保程序中的函數調用和變量訪問能夠正確地映射到動態庫中的實際地址。

動態鏈接器??
動態鏈接器(如 ld-linux.so)負責在程序運行時加載動態庫。
當程序啟動時,動態鏈接器會解析程序中的動態庫依賴,并將這些庫加載到內存中。
??環境變量和配置文件??
??環境變量??:
Linux 通過環境變量(如 LD_LIBRARY_PATH)指定動態庫的搜索路徑。
??配置文件??:
系統配置文件(如 /etc/ld.so.conf 及其子配置文件)也用于定義動態庫的搜索路徑。
動態鏈接器在加載庫時會優先檢查這些路徑。
??緩存文件??
為提高加載效率,Linux 維護緩存文件 /etc/ld.so.cache。
該緩存記錄了系統中所有已知動態庫的路徑和元數據,動態鏈接器會優先查詢緩存以加速加載。

然后_start函數在完成了一系列初始化工作后,就會調用main函數。main函數結束后,再調用_exit退出進程。


我們只需要知道的是,對于一些默認行為(如打開std文件,加載動態庫的事情),是編譯器幫我們完成的,我們的代碼是被修改過的!

程序如何跳轉動態庫內執行

動態庫本質也是ELF文件。和靜態庫其它的ELF文件不同的是,這個文件并不是鏈接的時候才合并到可執行文件內的。而是等到進程運行的時候再來進行鏈接使用。

所以我們觀察到可執行程序的反匯編代碼中,即使庫函數有地址,那也是假的。
現在問題來了,程序是如何與動態庫進行關聯的呢?也就是如何“跳轉到動態庫內”執行代碼?


首先我們知道的是,動態庫也是ELF文件,在磁盤上存儲的時候,里面的數據內容可能是存儲在磁盤上不同的位置,但是具體到ELF內部,也是采用平坦編址的方法進行編址:
在這里插入圖片描述

然后再根據進程地址空間的情況,將動態庫的內容地址分配到進程地址空間上!
此時,動態庫內容的起始地址我們就得到了。
動態庫內剩下的所有內容:由于在ELF內進行了相對編址,所以我們只需要知道記錄每個內容的相對于動態庫頭部的偏移量,就可以使用動態庫起始進程空間地址 + 偏移量來獲取動態庫中的所有進程空間地址,然后通過頁表索引就能找到內存中的動態庫的內容。


動態庫中一般來說就是一些方法的實現和內容的引用。
所以,CPU真正執行的代碼起始還是在代碼區,從程序入口處開始執行。

但是因為在程序運行前,動態庫其實就已經被_start函數調用連接器代碼,加載到內存上了,也分配到了進程地址空間上。也就是在CPU看來,動態庫的內容可以通過進程地址找到。
所以,在代碼區執行代碼的時候,如果是靜態鏈接的部分,那不用擔心,函數早就被重定向了,是可以通過地址來找到對應的函數。

但是如果是使用了動態庫的部分,這也不怕。因為在代碼運行的時候,會對動態庫函數進程重定向,也就是再一次進行地址的修改,把原來假的改成正確的進程地址。同時填充頁表!完成進程虛擬地址到物理內存地址的映射。
(這里對于動態庫函數重定向的時機和機制沒有講清楚!將放在下一個部分進行講解)。

因為知道動態庫的起始進程地址和動態庫中各個位置的偏移量,所以是可以正確修改代碼中的那些函數的調用地址的!

所以,動態鏈接,其實是在進程準備運行的時候,再一次對代碼中用到動態庫中的函數和內容的地址進行重定向!這樣子,CPU在執行到動態庫的函數的時候,就可以跳轉到這個地址,通過頁表所以來運行動態庫的相關內容!調用完后再回到代碼區即可。


下面用一張圖來演示:

在這里插入圖片描述
即可執行程序的代碼和數據加載到內存上后,也完成了進程地址的分配和頁表填充。然后,在代碼區執行代碼的時候,如果碰到了是調用庫函數/內容的地址,就會進行動態的綁定!

而且,程序執行前,動態庫早已被加載到內存上,分配到進程地址空間上了!就是通過文件系統,進行路徑解析,找到對應的文件inode,然后獲取出相關信息,加載到內存上。

我們知道,未動態鏈接前,庫函數地址是假的!其實就是相對于動態庫頭部地址的偏移量。如果運行到了某個庫函數,那么只需要把其調用的地址 + 動態庫在進程地址中的起始地址,就能找到該函數在進程地址空間的位置,從而進行也表映射,執行代碼。

動態鏈接——函數重定向的時機和機制

上面我們是能夠明白函數如何跳轉到動態庫內執行代碼的,就是對用到動態庫的地址進行重定向,修改地址。然后通過頁表索引找到內容執行后,再回到代碼區!

但是,有一個問題:
一旦代碼和數據被加載到了數據區和代碼區上,不是有只讀保護的嗎?也就是說,動態鏈接重定向的時候,是不可以直接把代碼的調用地址給修改的。這怎么辦?

全局偏移量表GOT

代碼確實是不能修改!但是,可以間接來修改!

可以再加上一層映射,即運行到庫函數的地址的時候,這個地址需要重新進行重定向。但是代碼具有只讀性不能修改。所以需要維護一張表:
這個表是庫函數重定向前后地址的映射!

動態鏈接采用的做法是在 .data (可執行程序或者庫自己)中專門預留一片區域用來存放函數
的跳轉地址,它也被叫做全局偏移表GOT,表中每一項都是本運行模塊要引用的?個全局變量或函數的地址。
在這里插入圖片描述

在這里插入圖片描述
且在合并的時候,got節和數據節會被合成一個Segment。所以,會在進程的虛擬地址空間的數據區進行維護!

這個表就是用來維護這個庫函數重定向的映射關系的:
在這里插入圖片描述
也就是說,使用庫函數的時候,并不是真的對地址進行修改了!而是通過GOT表進行重定向后地址的映射,找到重定向的地址后,跳轉到動態庫內。通過該地址索引頁表,找到動態庫真實的內容進行執行!

所以,動態鏈接確實是被延遲到了運行的時候才來進行重定向的!

PLT機制

但是,GOT表是需要在進程運行前就維護起來的。也就是進程一開始的時候,就要把所有的庫函數重定位后填充到GOT表!

但是這個非常耗時!所以系統內采用的是PLT機制。把真正重定向的時機放到了第一次執行庫函數的代碼的時候!也就是:

GOT中的跳轉地址默認會指向一段輔助代碼,它也被叫做樁代碼/stup。在我們第一次調用函數的時候,這段代碼會負責查詢真正函數的跳轉地址,并且去更新GOT表。于是我們再次調用函數的時候,就會直接跳轉到動態庫中真正的函數實現。

其實可以理解為緩存。即第一次出現的,使用后加載到一個緩存表中。如果后序使用的在緩存表內,直接在緩存表內索引后映射。反之需要去查找后再緩存。

所以,在ELF的Sections中,存在著一個.got.plt的節。這個節就是負責:
當GOT表中沒有某庫函數重定向的地址映射關系時,通過這個節中的相關方法來去查找該函數在進程地址中的真實位置!并且再加載到GOT表中!

fPIC的解釋

我們前面制作動態庫的時候,用到了一個選項:fPIC,這個是地址無關!

何為地址無關?
?代碼不依賴絕對地址,通過偏移量 + GOT/PLT 間接跳轉實現動態綁定??。

也就是說:
動態庫地址無關的本質就是,所有的動態庫加載到內存上之后,調用代碼時不關心動態庫真正的地址在哪里的。因為知道偏移量和庫起始地址,再通過GOT表和PLT機制進行庫代碼跳轉!

所以,制作動態庫的時候是需要帶上這個選項的!表明動態庫的運行原理。

庫間依賴

但是,庫與庫之間都是會存在依賴的。比如某個庫調用了標準c庫的printf函數。

這些庫都被加載到了內存當中,分配到了進程地址空間上。如果按照前面講的理論:
如果A庫中調用printf,B庫也調用,那么A、B兩個庫和標準c庫之間怎么做到與位置無關呢?
因為A、B兩個庫中重定向前,調用printf函數的假地址可能是不相同的!

這不用怕,所有的庫、可執行文件,都是ELF結構!
所以,每個庫內都會維護一個GOT表,用來表示當前區域下與庫函數的映射關系!
也就是說,A、B兩庫都有獨立的GOT表,再配合PLT機制,就可以查找到真實的位置!

所以,這就保證了庫與庫之間,不同區域之間調用庫代碼的地址無關性!

本文來自互聯網用戶投稿,該文觀點僅代表作者本人,不代表本站立場。本站僅提供信息存儲空間服務,不擁有所有權,不承擔相關法律責任。
如若轉載,請注明出處:http://www.pswp.cn/news/916341.shtml
繁體地址,請注明出處:http://hk.pswp.cn/news/916341.shtml
英文地址,請注明出處:http://en.pswp.cn/news/916341.shtml

如若內容造成侵權/違法違規/事實不符,請聯系多彩編程網進行投訴反饋email:809451989@qq.com,一經查實,立即刪除!

相關文章

Redis C++客戶端——通用命令

目錄 代碼案例 get和set部分 exists部分 del部分 keys部分 expire部分 type部分 本篇文章主要是通過redis-plus-plus庫使用通用命令。 代碼案例 下面用一個代碼演示&#xff1a; #include <sw/redis/redis.h> #include <iostream> #include <vecto…

手機開啟16k Page Size

我買了一個pixel8的手機&#xff0c;系統是Android16,如下操作都是基于這個手機做的。 https://source.android.com/docs/core/architecture/16kb-page-size/16kb-developer-option?hlzh-cn#use_16kb_toggle 使用 16 KB 切換開關 按照開發者選項文檔中的指示啟用開發者選項。…

VLAN的劃分(基于華為eNSP)

VLAN的劃分 前言&#xff1a;為什么VLAN是現代網絡的“隱形骨架”&#xff1f; 當一臺辦公室電腦發送文件給隔壁工位的同事時&#xff0c;數據如何精準抵達目標而不“打擾”其他設備&#xff1f;當企業財務部的敏感數據在網絡中傳輸時&#xff0c;如何避免被其他部門的設備“窺…

從壓縮到加水印,如何實現一站式圖片處理

當你需要對大量圖片進行相同或相似的操作時&#xff08;例如壓縮、裁剪、調整尺寸、添加水印等&#xff09;&#xff0c;逐個處理會非常耗時。批量處理工具可以一次性處理數百張圖片&#xff0c;大大節省了時間。這是一款極致輕巧的圖片處理利器&#xff0c;體積僅有652KB&…

Pythong高級入門Day5

二、面向對象編程面向對象編程&#xff08;Object-Oriented Programming&#xff0c;簡稱OOP&#xff09;是一種通過組織對象來設計程序的編程方法。Python天生就是面向對象的模塊化編程。1. 初識類和對象示意圖&#xff1a;/-------> BYD E6(京A.88888) 實例&#xff0c;對…

C#其他知識點

接口類---interface什么是接口? 在接口當中一般我們認為接口中的成員都是抽象的。接口一般認為是功能的集合。在接口類當中定義的方法都是抽象象方法。(沒有方法體)接口一般我們認為它是一種標準,一種規范,一種約定。給子類或者是派生類制定規范,規定,標準。當子類繼承了該接口…

Maven 環境配置全攻略:從入門到實戰

一、Maven 簡介 Maven 是一個基于項目對象模型 (POM) 的項目管理工具&#xff0c;它可以通過一小段描述信息來管理項目的構建、報告和文檔。 除了強大的程序構建能力外&#xff0c;Maven 還提供了高級項目管理功能。其默認構建規則具有很高的可重用性&#xff0c;通常只需兩三…

現代 C++ 開發工作流(VSCode / Cursor)

? 推薦的現代 C 開發工作流&#xff08;含 VSCode / Cursor 插件配置&#xff09;&#x1f9f0; 一、環境要求 C 編譯器&#xff08;如 g 或 clang&#xff09;CMake&#xff08;建議 ≥ 3.16&#xff09;clangd&#xff08;建議 ≥ 14&#xff0c;最好用系統包管理器安裝&…

[SAP ABAP] ALV報表練習4

SO銷售訂單明細報表業務目的&#xff1a;根據選擇屏幕的篩選條件&#xff0c;使用ALV報表顯示銷售訂單詳情(Sales Order、Material、現有Qty、已開立數量以及剩余數量等)信息效果展示我們在銷售訂單欄位輸入需要查詢的SO單號&#xff0c;這里我們以SO單號0000000221為例&#x…

《設計模式之禪》筆記摘錄 - 10.裝飾模式

裝飾模式的定義裝飾模式(Decorator Pattern)是一種比較常見的模式&#xff0c;其定義如下&#xff1a;Attach additional responsibilities to an object dynamically keeping the same interface. Decorators provide a flexible alternative to subclassing for extending fu…

[AI8051U入門第十步]W5500-客戶端

學習目標: 1、認識W5500模塊 2、驅動W5500靜態獲取ip 3、獲取全球唯一碼作為mac地址 4、拔出網線重插網線自動獲取IP 5、編寫W5500作為客戶端進行TCP/IP代碼一、W5500介紹 W5500 是一款由韓國 WIZnet 公司推出的高性能 硬件 TCP/IP 嵌入式以太網控制器,專為嵌入式系統設計,…

UNETR++: Delving Into Efficient and Accurate 3D Medical Image Segmentation

摘要得益于Transformer模型的成功&#xff0c;近期研究開始探索其在3D醫學分割任務中的適用性。在Transformer模型中&#xff0c;自注意力機制是核心構建模塊之一&#xff0c;與基于局部卷積的設計相比&#xff0c;它致力于捕捉長距離依賴關系。然而&#xff0c;自注意力操作存…

Kotlin Flow 在 Jetpack Compose 中的正確打開方式:SharedFlow vs StateFlow 與 LaunchedEffect

在 Jetpack Compose 中&#xff0c;Kotlin Flow 是處理異步數據流的核心工具&#xff0c;而 SharedFlow 和 StateFlow 是最常用的兩種 Flow 類型。但很多開發者對它們的適用場景、如何與 LaunchedEffect 配合使用存在困惑。本文將深入探討它們的區別&#xff0c;并給出最佳實踐…

嵌入式——C語言:指針①

一、指針特點1.讓代碼更加簡潔高效2.提供直接訪問內存的操作3.利用指針可以直接操作硬件二、指針概念&#xff08;一&#xff09;地址&#xff1a;為了區分內存中不同字節的編號&#xff08;0到2^16-1&#xff09;&#xff08;二&#xff09;指針&#xff1a;指針就是地址&…

RabbitMQ—HAProxy負載均衡

上篇文章&#xff1a; RabbitMQ—仲裁隊列https://blog.csdn.net/sniper_fandc/article/details/149312579?fromshareblogdetail&sharetypeblogdetail&sharerId149312579&sharereferPC&sharesourcesniper_fandc&sharefromfrom_link 目錄 1 HAProxy安裝…

QT中啟用VIM后粘貼復制快捷鍵失效

當在QT中啟用FakeVim之后&#xff0c;Ctrl C 和 Ctrl V 快捷鍵就變成 Vim 的快捷鍵了&#xff0c;我希望它還是原來的復制粘貼功能&#xff0c;打開&#xff1a;編輯 > Preferences…&#xff0c;然后勾選 “Pass control keys”即可&#xff0c;如下&#xff1a;

TCP三次握手與四次揮手全解析

&#x1f30a; TCP三次握手與四次揮手全解析&#xff08;含序列號動態追蹤&#xff09;&#x1f511; TCP 協議核心機制 序列號 (seq)&#xff1a;數據字節流的唯一標識&#xff08;32位循環計數器&#xff09;確認號 (ack)&#xff1a;期望接收的下一個序列號&#xff08;ack …

7月26號打卡

作業&#xff1a;題目1&#xff1a;計算圓的面積 任務&#xff1a; 編寫一個名為 calculate_circle_area 的函數&#xff0c;該函數接收圓的半徑 radius 作為參數&#xff0c;并返回圓的面積。圓的面積 π * radius (可以使用 math.pi 作為 π 的值)要求&#xff1a;函數接收一…

C++/CLI與標準C++的語法差異(一)

&#x1f30c; C/CLI與標準C的語法差異&#xff08;一&#xff09;&#x1f52c; 第一章&#xff1a;類型系統革命 - 徹底解構三語言范式 &#x1f9ea; 1.1 類型聲明語義差異矩陣 #mermaid-svg-L5kQ3iy05pKo4vIj {font-family:"trebuchet ms",verdana,arial,sans-se…

輸電線路微氣象在線監測裝置:保障電網安全的科技屏障

在電力傳輸網絡中&#xff0c;輸電線路微氣象在線監測裝置通過集成專業傳感器與智能分析技術&#xff0c;實現對線路周邊環境參數的實時采集與動態分析&#xff0c;為電網運行安全提供數據支撐。該設備針對輸電線路特殊工況設計&#xff0c;具備高適應性、高可靠性特點。工作原…