一、什么是庫
1、動靜態庫概念
# 庫是寫好的現有的,成熟的,可以復?的代碼。現實中每個程序都要依賴很多基礎的底層庫,不可能每個?的代碼都從零開始,因此庫的存在意義?同尋常。
# 本質上來說庫是?種可執?代碼的?進制形式,可以被操作系統載?內存執?。庫有兩種:
- 在
Linux
當中,以.so
為后綴的是動態庫,以.a
為后綴的是靜態庫。- 在
Windows
當中,以.dll
為后綴的是動態庫,以.lib
為后綴的是靜態庫。
#?問題:動靜態庫里面是否需要包含main函數? 答案是:庫函數中不要包含main函數,我們鏈接過程會把庫函數鏈接,如果庫函數還有main函數,就會導致命名沖突導致鏈接失敗,所以不要!
2、動靜態庫優缺點
# 靜態庫在程序編譯時被鏈接到目標代碼中。一旦鏈接完成,靜態庫的代碼就成為目標程序的一部分。這意味著如果多個程序都使用了同一個靜態庫,那么每個程序都會包含一份該庫的副本,從而導致程序體積較大。
優點:
- 獨立性強,不依賴外部環境,因為庫代碼已經被包含在程序中。
- 運行時加載速度相對較快,因為不需要在運行時進行庫的加載操作。
缺點:
- 生成的程序體積較大。
- 如果靜態庫有更新,需要重新編譯鏈接所有使用該庫的程序
#?動態庫在程序運行時被加載。多個程序可以共享同一個動態庫,只有當程序運行時才會將動態庫加載到內存中。這大大減小了程序的體積,同時也方便了庫的更新和維護。
優點:
- 生成的程序體積較小,因為庫代碼沒有被包含在程序中。
- 庫的更新不影響已編譯的程序,只需要更新動態庫文件即可。
缺點:
- 依賴外部環境,運行時需要確保動態庫存在且路徑正確。
- 加載動態庫可能會帶來一定的時間開銷。
3、動靜態庫原理
# 我們知道,一個源文件變為一個可執行文件將經歷四個步驟:
- 預處理: 完成頭文件展開、去注釋、宏替換、條件編譯等,最終形成xxx.i文件。
- 編譯: 完成詞法分析、語法分析、語義分析、符號匯總等,檢查無誤后將代碼翻譯成匯編指令,最終形成xxx.s文件。
- 匯編: 將匯編指令轉換成二進制指令,最終形成xxx.o文件。
- 鏈接: 將生成的各個xxx.o文件進行鏈接,最終形成可執行程序。
#?比如我們現在有test1.c
,test2.c
,test3.c
,以及main1.c
這四個.c
文件,經過預處理,編譯,匯編之后分別生成test1.o
,test2.o
,test3.o
,以及main1.o
這四個.o
文件。最后經過生成a.out
的可執行文件。
#?但是此時我們的main2.c文件的生成同時也需要依賴test1.c,test2.c,test3.c這三個文件,生成可執行程序的步驟都是一樣的。此時我們就可以選擇將test1.c,test2.c,test3.c這三個文件生成的test1.o,test2.o,test3.o進行打包,之后再使用時,只需要鏈接這個"包"即可,這個"包"其實就是我們常說的庫。
#?所以動靜態庫的本質其實是一堆xxx.o
文件的集合。對于庫的使用,只需要提供頭文件讓使用者了解具體功能的作用。在編譯程序時,通過鏈接指定的庫來實現對庫中功能的調用。
二、靜態庫
1、靜態庫的打包
# 接下來我們以使用之前寫過的庫函數緩沖區代碼為例,講解一下我們如何將我們的文件打包成靜態庫:
#?然后我們需要將mystdio.h
,mystdio.c
,mystring.h
,mystring.c
這4個文件打包成靜態庫。
1. 首先第一步將源文件生成對應.o文件。
2. 第二步使用ar指令打包成對應的靜態庫。
#?其中ar是gun歸檔工具,ar
指令用法為ar 選項 庫名 打包文件名
,其中又兩個關鍵選項:
-r
(replace):若靜態庫文件當中的目標文件有更新,則用新的目標文件替換舊的目標文件-c
(create):建立靜態庫文件
#?其中需要注意的是:動靜態庫真實文件名需要去掉前綴lib
,再去掉后綴.so
或者.a
及其后面的版本號,比如說libc-2.17.so
就是C語言的標準庫,其名為:?c
。
3.?將頭文件和生成的靜態庫組織起來。
# 當把自己的庫提供給他人使用時,通常需要給予兩個文件夾:
- 一個文件夾用于存放頭文件集合。比如,可以將mystdio
.h
和mystring.h
這兩個頭文件放置在名為include
的目錄下。(頭文件本質是對源文件方式的使用說明文檔)- 另一個文件夾用于存放所有的庫文件。例如,把生成的靜態庫文件
libmyc.a
放到名為mylib
的目錄下。
#?最后,將這兩個目錄(include
和mylib
)都放置在lib
目錄下,此時就可以把lib
提供給別人使用了。
4.?將lib打包。
# 此時我們的lib.tgz就相當于一個安裝包了,下載過去就可以使用。
# 為了方便我們處理,我們可以寫一個Makefile:
libmyc.a:mystdio.o mystring.oar -rc $@ $^%.o:%.c #展開所有.c文件生成對應的.o文件gcc -c $<.PHONY:clean
clean:rm -rf ./*.o libmyc.a lib.tgz.PHONY:output #發表庫
output:mkdir -p lib/includemkdir -p lib/mylibcp -f ./*.h lib/includecp -f ./*.a lib/mylibtar czf lib.tgz lib
2、靜態庫的使用
# 首先我們將lib.tgz解壓。
# 我們如果使用我們打包的靜態庫,在使用gcc
編譯時需要帶有以下三個選項:
-I
:指定頭文件搜索路徑。-L
:指定庫文件搜索路徑。-l
:指明需要鏈接庫文件路徑下的哪一個庫。
#?由于在程序執行時,編譯器并不知曉我們所聲明的頭文件以及鏈接庫的具體位置,而且鏈接庫中可能存在不同的庫文件。因此,我們需要在命令行中指定頭文件的搜索路徑,庫文件的搜索路徑,以及具體使用哪個庫。
#?比如我們需要執行main.c
,其中main.c
中使用靜態庫中的函數。
#include "mystdio.h"
#include "mystring.h"#include <stdio.h>int main()
{const char *s = "abcdefg";printf("%s: %d\n", s, my_strlen(s));MyFile *fp = mfopen("./log.txt", "a");if(fp == NULL) return 1;MyFwrite(s, my_strlen(s), fp);MyFwrite(s, my_strlen(s), fp);MyFwrite(s, my_strlen(s), fp);MyFclose(fp);return 0;
}
#?其中需要注意的是,-I
,-L
,-l
這三個選項后面可以加空格,也可以不加空格。
#?那么我們就有個疑問,那就是我們平時使用gcc編譯文件時為什么沒有帶-I
,-L,-l
這三個選項呢?
# 其實很簡單,因為我們之前使用的庫都默認在系統的路徑下,編譯器能準確識別這些存在于配置文件中的路徑,系統搜索頭文件的路徑在/usr/include目錄下,搜索庫文件在/lib.64目錄下。其實如果為了方便我們也可以將頭文件和庫文件拷貝到系統路徑/usr/include,/lib.64下:
- sudo cp lib/include/* /usr/include/
- sudo cp lib/lib/* /lib.64/
# 這時再使用gcc
編譯時就只需要帶-l
選項,指明鏈接庫文件下具體哪個庫。
# 但是實際上,我們并不推薦將自己寫的頭文件和庫文件拷貝到系統路徑下,因為這樣做可能會對系統文件造成污染。
三、動態庫
1、動態庫的打包
#?動態庫的打包相對于靜態庫較為復雜,但大致相同,我們還是利用mystdio.h
,mystdio.c
,mystring.h
,mystring.c
這4個文件進行打包演示:
1. 首先第一步將源文件生成對應.o文件。
# 但是與靜態庫不同的是,需要帶-fPIC
選項,因為動態庫運行時才會被加載。
<font style="color:rgb(28, 31, 35);">-fPIC(position independent code)</font>即產生位置無關碼,作用于編譯階段,其目的是告訴編譯器生成與位置無關的代碼。在這種情況下,所產生的代碼中不存在絕對地址,全部采用相對地址(起始位置加上偏移量)。這使得動態庫被加載器加載到內存的任意位置時都能夠正確執行。倘若不添加該選項,代碼中使用的庫函數在執行時會嘗試調到對應位置執行,但此時可能會因該位置被其他動態庫所占用而找不到該函數。
2. 使用-shared選項將所有目標文件打包為動態庫。
# 生成對應的動態庫并不需要使用ar
指令,還是使用gcc
編譯,只不過需要帶-shared
選項。
3. 將頭文件和生成的動態態庫組織起來。
# 與靜態庫類似,當把自己的庫提供給他人使用時,通常需要給予兩個文件夾:
- 一個文件夾用于存放頭文件集合。比如,可以將mystdio
.h
和mystring.h
這兩個頭文件放置在名為include
的目錄下。- 另一個文件夾用于存放所有的庫文件。例如,把生成的靜態庫文件
libmyc.so
放到名為mylib
的目錄下。
# 最后,將這兩個目錄(include
和mylib
)都放置在lib
目錄下,此時就可以把lib
提供給別人使用了。
#?同樣為了方便管理,我們也可以定義一個makefile
文件。
libmyc.so:mystdio.o mystring.ogcc -shared -o $@ $^%.o:%.c #展開所有.c文件生成對應的.o文件gcc -fPIC -c $<.PHONY:clean
clean:rm -rf ./*.o libmyc.so lib.tgz.PHONY:output #發表庫
output:mkdir -p lib/includemkdir -p lib/mylibcp -f ./*.h lib/includecp -f ./*.so lib/mylibtar czf lib.tgz lib
2、動態庫的使用
#?我們如果使用我們打包的動態庫,使用gcc
編譯時同樣需要帶有以下三個選項:
-I
:指定頭文件搜索路徑。-L
:指定庫文件搜索路徑。-l
:指明需要鏈接庫文件路徑下的哪一個庫。
#?因為在程序執行時,編譯器同樣并不知曉我們所聲明的頭文件以及鏈接庫的具體位置,而且鏈接庫中可能存在不同的庫文件。因此,我們需要在命令行中指定頭文件的搜索路徑,庫文件的搜索路徑,以及具體使用哪個庫。
#?比如我們需要執行main.c
?,其中main.c
中使用動態庫中的函數。
#?但是與靜態庫不同的是,我們并不能直接執行main這個可執行文件。
#?為什么使用了-I
,-L
,-l
這三個選項,還是沒有找到對應的動態庫呢?
這是由于我們使用
-I
,-L
,-l
這三個選項僅僅是在編譯期間向編譯器告知我們所使用的頭文件和庫文件的具體位置以及具體的庫名。然而,當可執行程序生成后,它便與編譯器不再有直接關系。所以,該可執行程序運行起來時,操作系統仍找不到該可執行程序所依賴的動態庫。
# 那么靜態庫為什么沒有這個問題?
因為靜態庫是把庫在鏈接時拷貝到可執行程序里面,只要鏈接編譯成功后,就不需要依賴靜態庫。而動態庫是需要加載程序的同時找到你所依賴的庫!
#?所以其實只需要讓系統可以找到我們可執行程序需要的庫即可,因此這里我們有四種方法:
1. 第一種就是將庫文件拷貝到系統共享的庫路徑下。
sudo cp lib/mylib/libmyc.so /lib64
#?但是這種方法可能會對系統文件造成污染,所以我們一般不采取該方法。
2. 第二種就是給我們的庫路徑建立一個軟鏈接。
ln -s lib/mylib/libmyc.so /lib64/libmyc.so
3. 第三種就是更改環境變量LD_LIBRARY_PATH。
export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:/home/tata/lesson11/my_stdio/lib/mylib(對應動態庫所在路徑)
#?LD_LIBRARY_PATH
是程序運行動態查找庫時所要搜索的路徑,我們只需將動態庫所在的目錄路徑添加到LD_LIBRARY_PATH
環境變量當中,程序運行起來時就能找到對應的路徑下的動態庫。
#?但是我們知道環境變量在重啟時會自動恢復,所以這種方法只在當前狀態下有效,具有臨時性。
4. 第四種就是配置.conf/文件。
# 在系統中,/etc/ld.so.conf.d/是用于搜索動態庫的路徑。此路徑下存放的全是后綴為.conf的配置文件,這些配置文件中所存放的內容都是動態庫的路徑。
# 因此,若將自己庫文件的路徑也放置在該路徑下,那么當可執行程序運行時,系統就能夠找到我們的庫文件。并且這種行為是永久的,并不會隨重啟而改變。
# 首先我們將對應的庫文件所在地址寫入一個.conf文件中,然后將其導入/etc/ld.so.conf.d/路徑,最后使用指令ldconfig更新一下配置文件,最后我們就能執行我們的可執行文件了。
四、動靜態庫的使用
#?在Linux
下,我們可以通過ldd 文件名
來查看一個可執行程序所依賴的庫文件。這其中的libc.so.6
就是該可執行程序所依賴的庫文件,我們通過ls命令可以發現libc.so.6
實際上只是一個軟鏈接。
#?實際上該軟鏈接的源文件libc-2.17.so
和libc.so.6
在同一個目錄下,為了進一步了解,我們可以通過file 文件名
命令來查看libc-2.17.so
的文件類型。
# 如果文件所鏈接的庫中動靜態庫同時存在呢?
#?此時我們鏈接程序可以鏈接成功,也可以正常運行程序,然后我們ldd查看,發現他使用的動態庫,鏈接是采用動態鏈接!
#?通過上圖觀察,我們知道gcc/g++
編譯器默認都是動態鏈接的。
# 如果想使用靜態鏈接,需要在后面加一個-static
。且一旦要靜態鏈接就必須存在靜態庫。如果你并沒有安裝對應的靜態庫的話,可以使用以下指令安裝。
sudo yum install glibc-static?
sudo yum install libstdc++-static
五、目標文件
#?編譯和鏈接這兩個步驟,在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命令?于辨識?件類型
六、ELF文件
1、ELF格式
#?要理解編譯鏈接的細節,我們不得不了解一下ELF文件。其實以下4種文件其實都是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):保存已初始化的全局變量和局部靜態變量。
2、ELF形成可執行
- step-1:將多份 C/C++ 源代碼,翻譯成為目標 .o 文件 + 動靜態庫ELF
- step-2:將多份 .o 文件
section
進行合并
注意:實際合并是在鏈接時進?的,但是并不是這么簡單的合并,也會涉及對庫合并,此處不做
過多追究。
3、ELF可執??件加載
3.1 Section Header Table
# 一個ELF會有多種不同的Section,在加載到內存的時候,也會進行Section合并,形成Segment。
- 合并原則:相同屬性,比如:可讀,可寫,可執行,需要加載時申請空間等。
- 這樣,即便是不同的Section,在加載到內存中,可能會以Segment的形式,加載到一起。
- 很顯然,這個合并工作也已經在形成ELF的時候,合并方式已經確定了,具體合并原則被記錄在了ELF的程序頭表(Program header table)中。將來加載程序時哪些數據節是在一塊加載由這個表指明。
# 我們可以通過readelf命令讀取可執行程序的ELF,-S選項就可以讀取ELF的Section Header Table,從而讀取所有的數據節。這里我們讀取Is命令的ELF:
# 我們看到,Section Header Table就是大小為30的數組,數組里面存儲每個section的信息,而我們的ELF格式就是一個大文件,而我們要定位一個section只需要知道section相對于文件開頭的偏移量+section長度即可。
# 所以我們可以把二進制或文本文件想象為一個一維數組,數組里面的元素就是一個一個的字節,所以我們不管要定位section還是ELF Header,或者是其他區域,我們只要知道相對于文件開頭的偏移量+section長度,即可定位每一個區域。
# 上圖這里的Address和Offset就是偏移量和長度,.text就是我們的代碼段,.data就是我們的全局變量。因為我們的全局變量在加載的時候就要確定好,所以在可執行程序里面就給我們形成了。
3.2 Program Header Table
#?我們可以通過readelf命令讀取合并之后的segment,加上-l選項就可以讀取Program Header Table,這里我們讀取Is命令的:
#?我們可以看到一共有13個segment在文件偏移呈64的位置,LOAD表示將來要加載到內存中的區域。也可以發現.data和.bss都是合并在5號segment中,所以已初始化數據和未初始化數據都加載一塊了,其實我們的.rodata只讀數據區和.text代碼區其實也是加載到一塊的,但是這個機器沒有那么做而已。
#?所以我們把這些數據節和合并為數據段將來進行整體加載,將來加載時操作系統讀取Program Header Table表,根據偏移量位置+長度,找到對應若干個數據節的segment,然后進行加載到空間里此時就完成了加載的過程!
# 結論:所以其實我們ELF已經在鏈接時把如何加載的問題確定。同時我們可以看到這里還有一個FLags,他表示該分段是否可讀寫,所以我們的程序如何知道代碼段是只讀的哪些段是可讀可寫的頁表的權限位信息從哪里來?其實都是操作系統讀取Program Header Table的信息,然后初始化頁表的權限位信息即可。
# 那么 程序頭表 和 節頭表 ?有什么?呢,其實?ELF??件提供 2 個不同的視圖/視?來讓我們理解這兩個部分:
所以:
程序頭表(Program Header Table)的作用:鏈接時把每個.o文件的.data和.text合并為segment,然后更新Section Header Table,表面可執行程序是如何形成的,包含每個節的屬性,如是否可讀寫等,是被鏈接器使用的。
節頭表(Section Header Table)的作用:加載時也會形成Program Header Table,他會告訴操作系統如何加載可執行程序,如何完成內存等初始化,是被加載器使用的。
# 我們鏈接時會把多個.o的.text和.data合并為segment,而真正合并是在加載器加載時完成的!
3.3 ELF Header
# ELF Header保存的是整個ELF的管理信息,例如每個區域的開始和結束位置。
# 我們可以通過readelf指令,-h選項可以查看目標文件的ELF Header信息:
# Magic魔數,文件開頭的一組特定字節序列,通常位于文件的開頭,不同的文件格式都有其特定的魔數,通過檢查文件的魔數,系統可以快速判斷文件的類型。
// 查看?標?件
$ readelf -h hello.o
ELF Header:Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00Class: 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)索引。
七、鏈接與加載
1、靜態鏈接
#??論是??的.o,還是靜態庫中的.o,本質都是把.o?件進?連接的過程。所以:研究靜態鏈接,本質就是研究.o是如何鏈接的。
# 這里我們通過一段代碼來觀察:
// hello.c#include<stdio.h>void run();int main()
{printf("hello tata!\n");run();return 0;
}
// code.c#include<stdio.h>void run()
{printf("running...\n");
}
#?這里我們在hello.c寫了一個main函數,里面使用了run函數,而run函數的實現在code.c文件。然后把這兩個文件編譯成.o文件,鏈接兩個.o文件形成main.exe。他們都使用了printf,因此兩個.o文件都要和C標準庫鏈接,然后.o之間也要相互連接,因為hello.c調用了run函數。
#?因為這兩個.o是合并形成了main.exe,所以動態鏈接只有C標準庫。
#?然后我們可以使用objdump -d 目標文件指令,對目標文件的代碼段(.text)進行返回反匯編。這里我們對code.o和hello.o進行反匯編后寫入.s文件中。
# 通過反匯編我們可以知道call其實就是調用函數,這里兩個call就是調用printf和run。然后call匯編指令轉化為機器碼就是e8 xx xx xx xx ,這個e8就是call命令的機器碼,xx xx xx xx就是調用的函數地址。而此時xx xx xx xx為全0,是由于此時我們函數地址并沒有填充,因為我們只是對hello.o進行反匯編并沒有鏈接,因為我們printf和run要和C標準庫和.o鏈接才可以知道要調用的函數實現,才能得到函數地址,所以我們的code.c的run調用printf的匯編地址也是0,因為也沒有鏈接C標準庫,所以只能暫時設為0。
#?這里我們使用readelf -s 目標文件讀取目標文件的符號表:
# 我們可以看到run和puts,其實printf底層調用的就是puts,我們都看到他們兩個方法都是UND未定義的,code.c調用printf也是一樣,但是code.c是run不是UND未定義的,因為他已經實現了run函數,所以將來鏈接的時候hello.c的run就會去code.c的符號表找到run方法,但是他們的puts的都是未定義,他們又會去C標準庫查找,此時所有調用的方法就都可以找到了,就完成鏈接了。
#?所以查看連接后的可執行程序main.exe的符號表,就發現run的就不是未定義的了,說明連接后就可以找到run方法了,但是因為我們是動態鏈接,所以puts還是未定義的。
# 我們看到run對應的Ndx的值為16,說明多個section合并后是處于第16個section的。
#?后我們又發現main.exe的第16個section就是.text,說明run和main都被合并了代碼段。
#?然后我們反匯編鏈接后的可執行文件main.exe,發現我們的run和puts的地址已經被填充了,run填充call的地址就是我們run函數的地址1149,所以我們的鏈接時就會把我們call的0地址填充,修改call地址。
# 把所有的.oELF的section合并,完成了編址,此時就完成了靜態鏈接。我們把連接過程對.o中,外部符號call后面的地址修改,叫做地址重定位。所以.o文件也叫做重定位目標文件,因為鏈接時地址會被修改。
#?靜態鏈接就是把庫中的.o進?合并,和上述過程?樣。所以鏈接其實就是將編譯之后的所有?標?件連同?到的?些靜態庫運?時庫組合,拼裝成?個獨?的可執??件。其中就包括我們之前提到的地址修正,當所有模塊組合在?起之后,鏈接器會根據我們的.o?件或者靜態庫中的重定位表找到那些需要被重定位的函數全局變量,從?修正它們的地址。這其實就是靜態鏈接的過程。
#?所以,鏈接過程中會涉及到對.o中外部符號進?地址重定位。
2、ELF加載與進程地址空間
2.1?虛擬/邏輯地址(平坦模式編址)
#?問題:一個可執行程序沒有加載到內存里此時他有沒有地址?
其實我們前面看到匯編要,定位一個函數或變量時,其實不是拿著變量名或函數名,而是以地址來定位,所以其實我們的代碼中的變量名編譯好后都變成了地址,同時我們的可執行程序沒有加載到內存,他也有地址。
#?問題:我們的虛擬地址空間每個區域的開始和結束都記錄在了mm_struct和vm_struct里面那他們里面的值從哪里來的?
從可執行程序ELF中每個sgment來,每個sgment都有自己的起始地址和結束地址的邏輯地址,用來初始化內核結構體中的start和end數據。也就是說加載時操作系統會讀取Program Header Table中的相關字段,然后用可執行程序的邏輯地址直接初始化內核數據結構的start和end。
#?所以:虛擬地址空間機制,不光光OS要?持,編譯器也要?持。
2.2?重新理解進程虛擬地址空間
#?我們的可執行程序的每行代碼都有自己的地址,當加載可執行程序時,程序就會變為進程,操作系統就會為他申請task_struct、mm_struct等內核數據結構,而當我們的代碼加載到內存的后,每一行代碼都要占據物理內存空間,所以每一行代碼也一定存在他的物理地址
# mm_struct存在代碼區的start和end,而mm_struct的地址是虛擬地址,所以mm_struct的地址加載到頁表左側,物理地址加載到頁表的右側,此時虛擬到物理地址的映射關系就有了。
# 問題:CPU怎么知道你的可執行程序的其實地址是什么?也就是CPU怎么知道從哪里開始執行呢?
我們的CPU有一個指令寄存器EIP,用來存儲當前執行指令的下一條地址。EIP是Program Header Table的一個字段,這個字段他記錄了一個地址就是程序的入口地址。
#?所以加載的過程中,操作系統直接把我們Entry point address填充到CPU的EIP寄存器中,加載后頁表的虛擬和物理地址也有了,此時CPU開始調度進程了。同時我們CPU還有CR3寄存器,他會指向當前進程的頁表,根據EIP的地址,通過CR3查表就可以找到地址對應代碼的指令,放入CPU中。如果該指令還call其他的虛擬地址,此時CPU就會繼續拿著該地址進行查表繼續上述過程。所以進入CPU的地址都是虛擬地址,此時CPU就不再關心物理地址。
# 現在我們總體上來談可執行程序是如何加載到內存的:
3、動態鏈接與庫加載
3.1 進程如何看到動態庫
#?靜態庫不涉及加載的問題,因為靜態庫和.o鏈接合并形成可執行了,所以靜態庫就是以ELF為載體加載的。
#?而我們的可執行如果是動態鏈接,此時我們的可執行程序和動態庫是兩個獨立文件,所以一旦我們的程序運行起來就需要對動態庫進行查找,因為庫也是一個獨立的文件,所以就需要把動態庫也加載到物理內存中。
# 那么如何讓我們的進程看到動態庫呢(動態庫是如何和我們的可執行程序關聯的)?
庫也要建立頁表的映射關系,經過頁表映射關系映射到一個進程地址空間上的共享區上,此時程序一但調用庫方法,只需要從代碼區跳轉到共享區,完成調用后再返回,即可完成庫函數調用,而以前我們自己的調用就是從代碼區內部跳轉到代碼區即可。
# 庫函數調用步驟:
- 被進程看到 --?動態庫映射到進程地址空間
- 被進程調用 --?在進程的地址空間中進行跳轉
3.2 進程間如何共享庫
#?如果是兩個進程調用庫的話,只需要讓進程B也建立動態庫和共享庫的映射即可。
#?動態庫的本質就是:在系統層面上把公共的部分抽取出來只保存一份,而靜態鏈接則會出現重復代碼,因為靜態庫是拷貝到.o文件中,就會在內存中加載多份。所以動態庫也叫共享庫。
3.3 動態鏈接
3.3.1 動態鏈接如何工作
#?動態鏈接其實遠?靜態鏈接要常?得多。?如我們查看下 main.exe?這個可執?程序依賴的動態庫,會發現它就?到了?個c動態鏈接庫:
#?這?的 libc.so是C語?的運?時庫,??提供了常?的標準輸?輸出?件字符串處理等等這些功能。那為什么編譯器默認不使?靜態鏈接呢?靜態鏈接會將編譯產?的所有?標?件,連同?到的各種庫,合并形成?個獨?的可執??件,它不需要額外的依賴就可以運?。照理來說應該更加?便才對是吧?
#?靜態鏈接最?的問題在于?成的?件體積?,并且相當耗費內存資源。隨著軟件復雜度的提升,我們的操作系統也越來越臃腫,不同的軟件就有可能都包含了相同的功能和代碼,顯然會浪費?量的硬盤空間。
#?這個時候,動態鏈接的優勢就體現出來了,我們可以將需要共享的代碼單獨提取出來,保存成?個獨?的動態鏈接庫,等到程序運?的時候再將它們加載到內存,這樣不但可以節省空間,因為同?個模塊在內存中只需要保留?份副本,可以被不同的進程所共享。
#?動態鏈接到底是如何?作的??
?先要交代?個結論,動態鏈接實際上將鏈接的整個過程推遲到了程序加載的時候。?如我們去運??個程序,操作系統會?先將程序的數據代碼連同它?到的?系列動態庫先加載到內存,其中每個動態庫的加載地址都是不固定的,操作系統會根據當前地址空間的使?情況為它們動態分配?段內存。
當動態庫被加載到內存以后,?旦它的內存地址被確定,我們就可以去修正動態庫中的那些函數跳轉地址了。
3.3.2 動態鏈接器
# 我們還可以發現:無論是我們的可執行程序還是系統的指令除了依賴C標準庫,還都依賴一個linux-x86的動態庫,其實所有的C程序都依賴這個庫。這是為什么呢?
也就是說:鏈接時_start函數會幫我們加載程序所依賴的動態庫,而我們的上面依賴的linux.so就是動態鏈接器,動態鏈接器負責加載動態庫,他通過搜索環境變量LD_LIBRARY_PATH和配置文件/etc/ld.so.conf及其子配置文件)來找到動態庫。
3.3.3 動態庫中的相對地址
#?動態庫為了隨時進?加載,為了?持并映射到任意進程的任意位置,對動態庫中的?法,統?編址,采?相對編址的?案進?編制的(其實可執?程序也?樣,都要遵守平坦模式,只不過exe是直接加載的)。
3.3.4 程序與庫的映射
📌?注意:
- 動態庫也是?個?件,要訪問也是要被先加載,要加載也是要被打開的
- 讓我們的進程找到動態庫的本質:也是?件操作,不過我們訪問庫函數,通過虛擬地址進?跳轉訪問的,所以需要把動態庫映射到進程的地址空間中
3.3.5 庫函數調用原理 -- 加載地址重定位
3.3.6?全局偏移量表GOT(global offset table)
問題;代碼區不是只讀的嗎?怎么可以修改呢?
是的,代碼區(.text)是只讀的,可是我們還是想使用 起始地址+偏移量 的方式完成庫函數調用,所以動態鏈接采用的做法是:在.data(可執行程序或者庫自己)中專門預留一片區域用來存放函數的跳轉地址,它也被叫做全局偏移量表GOT。表中每一項都是本運行模塊要引用的一個全局變量或函數的地址,因為.data區域是可讀寫的,所以可以支持動態進行修改。
#??GOT 表本質:位于?.data
?段的函數指針數組,存儲外部函數/變量的絕對地址。
3.3.7 庫間依賴 -- PLT機制
注意:
- 不僅僅有可執?程序調?庫
- 庫也會調?其他庫!庫之間是有依賴的,如何做到庫和庫之間互相調?也是與地址?關呢?
- 庫中也有.GOT,和可執??樣!這也就是為什么?家為什么都是ELF的格式!