前言
在編寫代碼時我們經常會用到第三方提供的函數接口,這些函數一般是以庫的形式提供的,常見的庫有兩種形式,靜態庫和動態庫。
靜態庫與動態庫
在介紹庫之前,先簡單介紹一下目標文件。目標文件常常按照特定格式來組織,在linux下,它是ELF格式(Executable Linkable Format,可執行可鏈接格式),而在windows下是PE(Portable Executable,可移植可執行)。
而通常目標文件有三種形式:
- 可執行目標文件。即我們通常所認識的,可直接運行的二進制文件。
- 可重定位目標文件。包含了二進制的代碼和數據,可以與其他可重定位目標文件合並,並創建一個可執行目標文件-一般為.o文件。
- 共享目標文件。它是一種在加載或者運行時進行鏈接的特殊可重定位目標文件-一般為.so文件。
靜態庫
將上述提到的可重定位目標文件打包成一個單獨的文件(一般為.a文件),這個.a文件就成為靜態庫。其實靜態庫就是.o文件的集合,使用ar打包命令來生成靜態庫。eg. ar rcs mylib.a 1.o 2.o 3.o
鏈接過程
ld最基本的鏈接單位是.o文件,ld鏈接器主要解析object文件內的以下內容:
- 輸出符號OS: 可供外界使用的符號
- 未定義符號US: 需要從外部提供的符號
鏈接器在鏈接時,會維護以下三個集合:
- U: 當前所有未定義的引用符號的集合
- D: 當前所有已知定義的符號集合
- E:組成可執行文件目標文件集合
鏈接過程就是ld鏈接器按照命令順序讀取.o和.a文件。
鏈接.o文件有以下幾個解析規則:
- 將該.o文件加入目標文件集合E中
- 首先將obj文件的所有輸出符號加入集合D,如果obj文件內的輸出符號和集合D中的符號沖突,ld就會報多重定義錯誤。
- 若集合U中的某一符號可以obj的輸出符號匹配上,則將該符號從集合U中去除
- 用集合D中的已定義符號去匹配obj文件內的未定義符號,最后將obj內的無法和集合D中匹配上的未定義符號加入未定義符號集合U中。
若遇到.a文件,ld會掃描.a文件內部包含的所有.o並按照以下規則進行解析:
- ld會解析當前.o的輸出符號是否可以減少集合U中的未定義符號,若無法提供集合U中的符號,該.o文件就會丟棄,不會加入鏈接
- 若.o文件的輸出符號可以提供集合U中的未定義符號,那么鏈接器就會將.o文件加入鏈接,和上述.o文件的解析過程一致
- 如果.a文件中的某個.o加入鏈接,而這個.o文件引入了新的未定義符號,那么ld會從頭掃描一遍.a里的.o文件,試圖找出新的未定義符號所在的.o文件並將該.o文件加入鏈接,這個過程會一直重復直到沒有引入新的未定義符號
根據以上規則我們可以知道,如果鏈接時只包含.o文件時,鏈接結果不會受.o文件排列順序影響,並且同一個靜態庫.a文件內的.o文件不受鏈接順序的影響,但是只要鏈接跨越靜態庫邊界,鏈接順序就會是一個問題。下面我們通過一個例子來解釋上面的規則,並闡明鏈接順序對鏈接結果的影響。
callee.c
void callee(void)
{
printf("callee");
}
caller.c
void caller(void)
{
callee();
}
main.c
int main(void)
{
caller();
return 0;
}
我們callee.c,caller.c,main.c三個文件,如果我們只通過.o文件進行鏈接,那么順序不會造成影響,以下鏈接命令均可以正常執行
1. gcc -g -o app main.o caller.o callee .o
2. gcc -g -o app caller.o callee.o main.o
3. gcc -g -o app callee.o caller.o main.o
但是如果我們將caller.c 和 callee.c打包成靜態庫,那么鏈接順序就會是個問題
#ok
gcc -static -o app main.o -lcaller -lcallee
# fail1: undefined reference to 'callee'
gcc -static -o app main.o -lcallee -lcaller
# fail2: undefined reference to 'caller'
gcc -static -o app -lcaller -lcallee main.o
fail1: main.o引入了caller未知符號,解析callee.a時,未定義符號集合U中只有caller,calee.a無法提供,所以calee.a被略過,caller.a可以提供,所以caller.o被加入鏈接,但是caller.o引入的未知符號callee卻再也無法獲得,因為鏈接器默認不會再去從頭查找該符號,最終報錯無法找到callee符號
fail2:剛開始,未定義符號集合U中為空,所以callee.a和caller.a都被略過,導致最后main所需的caller符號無法找到,報錯。
我們一般在鏈接的時候都應遵循的法則是:
如果一個靜態庫A需要依賴靜態庫B,在鏈接命令中A應該要放在B之前
如果A和B互相依賴呢?即A中調用了B的函數,B中調用了A的函數,那么就形成了循環依賴
改變預設行為的參數
如果鏈接器ld的預設行為沒有辦法搞定編譯,那么可以改變一些ld配置參數來達到目的
-start-group 和 -end-group
通過該ld選項可以指定多個靜態庫為同一群組,ld在遇到未定義符號時,ld會將掃描范圍擴大至同一群組里的所有object文件。可以認為把多個靜態庫當做一個大的靜態庫來做鏈接,當這些靜態庫里出現未定義符號,將從頭在這個”靜態庫組“里重新搜索一遍以期望找到該未定義符號。
由於掃描的范圍變大,並且object數目變大,在比較極端的情況下會使鏈接速度明顯變慢
--whole-archive 和 --no-whole-archive
該ld選項可以強制將包含在--whole-archive 和 --no-whole-archive中間的靜態庫的所有object文件全部鏈接進來,不管靜態庫中的個別object文件是否實際被使用到。該選項的缺點是會把一些無用的object文件鏈接進程序,可能導致最后的可執行程序變得很大。
特性:
- 由於每個使用靜態庫的應用程序都需要拷貝所用函數的代碼,所以靜態鏈接的生成的可執行文件會比較大,多個程序運行時占用內存空間比較大(每個程序在內存中都有一份重復的靜態庫代碼)
- 由於運行的時候不用從外部動態加載額外的庫了,速度會比共享庫快一些
- 更換一個靜態庫或者修改一個靜態庫后,需要重新編譯應用程序
動態庫
動態庫使用了PIC技術使代碼和數據的引用與地址無關,也稱“位置無關代碼”,程序可以被加載到地址空間的任意位置,這就可以使得動態庫具備動態加載的功能。它並不在鏈接時將需要的二進制代碼都“拷貝”到可執行文件中,而是僅僅“拷貝”一些重定位和符號表信息,這些信息可以在程序運行時完成真正的鏈接過程。
特性:
- 應用程序在運行的時候加載共享庫
- 減少了依賴共享庫的模塊的大小,因為它們不必把共享庫提供的功能的實現代碼靜態編譯到自己的模塊代碼中。
- 運行多個程序時占用內存空間比也比靜態庫方式鏈接少(因為內存中只有一份共享庫代碼的拷貝)
- 由於有一個動態加載的過程所以速度稍慢
- 更換動態庫不需要重新編譯程序,只需要更換相應的庫即可
動態庫的使用
編譯時使用-shared -fPIC參數產生動態庫,eg. gcc -shared -fPIC -o libtest.so test.c
使用時加上-ltest
將libtest.so鏈接到可執行文件中
共享庫的命名
- 每個動態庫都有一個以"lib"為前綴且以".so.x"為結尾的被稱為soname的特定名稱,其中x為主版本號,soname命名格式通常為libxxx.so.x
- 每個動態庫還有一個包含了真正的庫代碼的文件名,通常被稱為庫的realname,與soname相比,它增加了副版本號(minor number)和發行版本號(release number),命名格式通常為libxxx.so.x.y.z,其中so后綴中的x為主版本號,y為副版本號,z為發行版本號。
- 鏈接或啟動依賴了共享庫的應用模塊時,鏈接器(linker)或loader只認不帶任何版本號的共享庫名,可以把供linker/loader用的庫名稱作"linker name"。也即,某個依賴了zlib庫的模塊在鏈接或啟動時,linker或loader只會查找名為libz.so的共享庫,查找不到就會報錯,gcc的-L選項應該指定linker name所在的目錄。有的linker name是庫文件的一個符號鏈接,有的linker name是一段鏈接腳本。
上面提到的realname/soname/linker name這3個命名約定是linux系統管理共享庫的關鍵,具體而言:
- 當庫開發者創建共享庫時,通常以realname為該庫命名
- 該共享庫的某個版本被安裝時,安裝腳本通常會下載對應版本命名為realname的庫文件,然后調用linux系統內置的ldconfig工具為名為realname的庫文件生成名為soname的軟鏈且把該軟鏈關系更新至/etc/ld.so.cache中
- 安裝腳本創建一個不帶版本號的庫名(即共享庫的linker name),它是一個指向該庫soname的symbolic link
- 更新新版共享庫時,安裝腳本重復上述第2步
當然,我們完全可以手動完成上述步驟中的兩次軟鏈設定。還以我的linux系統機器上zlib共享庫為例,它有一個供linker在鏈接時查找用的名為libz.so的庫名,該庫名是一個指向libz.so.1的軟鏈,而libz.so.1是一個指向libz.so.1.2.8的軟鏈。
兩層軟鏈的部署約定為同一系統下同一個共享庫不同版本間的共存或共享庫升級提供了方便:依賴了某共享庫的上層模塊無需關心當前系統下該共享庫的最新版本是多少,只要最新版本已成功安裝且soname指向了最新版本的realname,則上層模塊下次啟動時會由loader自動加載最新版本的共享庫。
共享庫搜索路徑
共享庫的搜索路徑由動態鏈接器決定,從ld.so(8)的Man Page可以查到共享庫路徑的搜索順序:
- 首先在環境變量LD_LIBRARY_PATH所記錄的路徑中查找。
- 然后從緩存文件/etc/ld.so.cache中查找。這個緩存文件由ldconfig命令讀取配置文件/etc/ld.so.conf之后生成,稍后詳細解釋。
- 如果上述步驟都找不到,則到默認的系統路徑中查找,先是/usr/lib然后是/lib。
所以如果在運行時報找不到共享庫的錯誤我們可以通過以下幾種方法來解決:
- 通過環境變量LD_LIBRARY_PATH把當前目錄添加到共享庫的搜索路徑
LD_LIBRARY_PATH=. ./main
這種方法只適合在開發中臨時用一下,通常LD_LIBRARY_PATH是不推薦使用的,盡量不要設置這個環境變量 - 把libxxx.so所在目錄的絕對路徑(比如/home/somedir)添加到/etc/ld.so.conf中(該文件中每個路徑占一行),然后運行ldconfig。ldconfig命令除了處理/etc/ld.so.conf中配置的目錄之外,還處理一些默認目錄,如/lib、/usr/lib等,處理之后生成/etc/ld.so.cache緩存文件,動態鏈接器就從這個緩存中搜索共享庫
- 把libxxx.so拷到/usr/lib或/lib目錄,這樣可以確保動態鏈接器能找到這個共享庫
- 其實還有第四種方法,在編譯可執行文件main的時候就把libstack.so的路徑寫死在可執行文件中:
$ gcc main.c -g -L. -lstack -Istack -o main -Wl,-rpath,/home/somedir
,當然rpath這種辦法也是不推薦的,把共享庫的路徑定死了,失去了靈活
動態鏈接
動態鏈接可以按以下兩種方式進行:
-
在第一次加載並運行時(load-time linking)
Linux通常由動態鏈接器(ld-linux.so)自動處理;
標准C庫(libc.so)通常是按照這種方式被動態鏈接的。 -
在程序已經開始運行后進行(run-time linking)
在Linux中,通過調用dlopen()等接口來實現;
上面提到位置無關代碼(PIC,全稱:Position-Independent Code),這是動態鏈接中一個重要的概念:PIC 在代碼中的跳轉和分支指令不使用絕對地址。PIC 在 ELF 可執行映像的數據段.data段中建立一個存放所有全局變量指針(指針數組)的全局偏移量表 GOT。
動態鏈接的符號引用有如下4種情況:
- 模塊內的過程調用、跳轉,采用PC相對偏移尋址;
- 模塊內的數據訪問,如模塊內的全局變量和靜態變量
- 模塊外的過程調用、跳轉 【要生成PIC代碼來解決】
- 模塊外的數據訪問,如外部變量的訪問【要生成PIC代碼來解決】
對於本模塊內的靜態變量和靜態函數,用 GOT 表的首地址作為一個基准,用相對於該基准的偏移量來引用,因為不論程序被加載到何種地址空間,模塊內的靜態變量和靜態函數與 GOT 的距離是固定的,並且在鏈接階段就可知曉其距離的大小。這樣,PIC 使用 GOT 來引用變量和函數的絕對地址,把位置獨立的引用重定向到絕對位置。下圖可以更加直觀的展現這種通過固定偏移來重定位地址的過程
對於模塊外部引用的全局變量和全局函數,用 GOT 表的表項內容作為地址來間接尋址
- 模塊外數據的引用-利用GOT來完成重定位
- 模塊間的函數調用或跳轉 —— 利用GOT完成重定位
- 模塊間的函數調用或跳轉 —— 利用GOT/PLT完成重定位
PLT表
過程鏈接表PLT位於.text段,每個動態鏈接的可執行程序和共享庫都有一個PLT,PLT表的每一項存放的是一小段跳轉代碼,對應於本運行模塊要引用的一個全局函數,eg.調用printf函數,對應的在PLT表中有一項printf@plt。程序對某個函數的訪問都被調整為對 PLT 入口的訪問。PLT 的第 1 個入口 PLT0 是一段訪問動態鏈接器的特殊代碼,程序對 PLT 表的第 1 次訪問都會跳轉到了 PLT0。下圖為動態鏈接的簡要示意圖
延遲重定位
當需要對一個函數進行調用時,他的匯編代碼call首先會掉用PLT表,然后PLT再通過調用GOT與動態庫實現重定位連接,這樣函數調用動態庫時便類似於間接 jmp+地址。
但是如果當一個文件中存在大量的函數時,如果在程序運行前就重定位好所有的函數調用的話雖然會減輕函數調用的時間,但是會大大增加程序的啟動時間,是整個程序變得很慢。因此Linux便產生了延遲重定位:也就是當你調用函數的時候函數才開始執行重定位和地址解析工作。
因此便形成了以下代碼來實現延遲定位:
GOT表
全局偏移表(Global Offset Table,GOT)能夠把位置無關的地址定位到絕對地址,GOT表里存放的是函數的絕對地址。
動態鏈接過程
程序運行時,首先將解釋器程序即動態鏈接器ld.so
映射到一個合適的地址,然后啟動ld.so
。ld.so
先完成自己的初始化工作,再從可執行文件的動態庫依賴表中指定的路徑名查找所需要的庫,將其加載映射到內存。動態庫的加載映射過程主要分 3 步:
- 動態鏈接器調用 __mmap 函數對動態庫的所有PT_LOAD 可加載段進行整體映射
- 共享文件映射完畢,動態鏈接器處理共享庫的PT_DYNAMIC 動態段,將各項動態鏈接信息主要是哈希表、符號表、字符串表、重定位表、PLT 重定位項表等地址填寫到 link_map(Linux用一個全局的庫映射信息結構 struct link_map鏈表來管理和控制所有動態庫的加載,結構 struct link_map 描述共享目標文件的加載映射信息) 的 l_info 數組結構中。l_info 是 link_map 最重要的字段之一,幾乎所有與動態鏈接管理相關的內容都與 l_info數組有關。動態鏈接器還要加載處理當前共享庫的所有依賴庫。
- 設置動態庫的第1 個和第 2 個 GOT 表項:
Elf32_Addr *got =(Elf32_Addr *) lmap->l_info[DT_PLTGOT].d_un.d_ptr;
got[0] = got;
got[1]=lmap;
got[2]=&_dl_runtime_resolve;
下圖展示了動態延遲重定位執行過程
在調用共享庫的函數時,都會先跳轉到PLT表中對應的函數跳轉項,對應上圖的標號1,如果是第一次調用該函數,則會跳轉至PLT[0],最后跳入 GOT[2]存儲的地址執行符號解析函數。待完成符號解析后,將符號的實際地址存入相應的 GOT 項,這樣以后調用函數時可直接跳到實際的函數地址,不必再執行符號解析函數(這個過程其實就是延遲重定位)。如果已經調用過該函數,那么可以通過PLT表項直接跳轉到該函數的絕對地址,即直接調用該函數。
參考文章:
程序的鏈接(五):共享庫和動態鏈接
共享庫
細說linux系統下共享庫的命名規范和使用方法
linux elf格式 全局指針表got call跳轉表plt 簡介
全局偏移表(GOT)和過程鏈接表(PLT)