庫用於將相似函數打包在一個單元中。然後這些單元就可為其他開發人員所共用,並因此有了模組化編程這種說法 — 即,從模組中構建程式。Linux 支援兩種類型的庫,每一種庫都有各自的優缺點。靜態庫包含在編譯時間靜態繫結到一個程式的函數。動態庫則不同,它是在載入應用程式時被載入的,而且它與應用程式是在運行時綁定的。圖 1 展示了 Linux 中的庫的階層。
圖 1. Linux 中的庫階層
使用共用庫的方法有兩種:您既可以在運行時動態連結程式庫,也可以動態載入庫並在程式控制之下使用它們。本文對這兩種方法都做了探討。
靜態庫較適宜於較小的應用程式,因為它們只需要最小限度的函數。而對於需要多個庫的應用程式來說,則適合使用共用庫,因為
它們可以減少應用程式對記憶體(包括運行時中的磁碟佔用和記憶體佔用)的佔用。這是因為多個應用程式可以同時使用一個共用庫;因此,每次只需要在記憶體上複製一
個庫。要是靜態庫的話,每一個啟動並執行程式都要有一份庫的副本。
GNU/Linux 提供兩種處理共用庫的方法(每種方法都源於 Sun Solaris)。您可以動態地將程式和共用庫連結並讓 Linux 在執行時載入庫(如果它已經在記憶體中了,則無需再載入)。另外一種方法是使用一個稱為動態載入的
過程,這樣程式可以有選擇地調用庫中的函數。使用動態載入過程,程式可以先載入一個特定的庫(已載入則不必),然後調用該庫中的某一特定函數(圖 2
展示了這兩種方法)。這是構建支援外掛程式的應用程式的一個普遍的方法。我稍候將在本文探討並示範該API(API)。
圖 2. 靜態連結與動態連結
用 Linux 進行動態連結
現在,讓我們深入探討一下使用 Linux
中的動態連結的共用庫的過程。當使用者啟動一個應用程式時,它們正在調用一個可執行和連結格式(Executable and Linking
Format,ELF)映像。核心首先將 ELF 映像載入到使用者空間虛擬記憶體中。然後核心會注意到一個稱為 .interp
的 ELF 部分,它指明了將要被使用的動態連結器(/lib/ld-linux.so),如清單 1 所示。這與 UNIX 中的指令檔的解譯器定義(#!/bin/sh)很相似:只是用在了不同的上下文中。
清單 1. 使用 readelf 來顯示程式標題
mtj@camus:~/dl$ readelf -l dlElf file type is EXEC (Executable file)Entry point 0x8048618There are 7 program headers, starting at offset 52Program Headers: Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align PHDR 0x000034 0x08048034 0x08048034 0x000e0 0x000e0 R E 0x4 INTERP 0x000114 0x08048114 0x08048114 0x00013 0x00013 R 0x1 [Requesting program interpreter: /lib/ld-linux.so.2] LOAD 0x000000 0x08048000 0x08048000 0x00958 0x00958 R E 0x1000 LOAD 0x000958 0x08049958 0x08049958 0x00120 0x00128 RW 0x1000 DYNAMIC 0x00096c 0x0804996c 0x0804996c 0x000d0 0x000d0 RW 0x4 NOTE 0x000128 0x08048128 0x08048128 0x00020 0x00020 R 0x4 GNU_STACK 0x000000 0x00000000 0x00000000 0x00000 0x00000 RW 0x4 ...mtj@camus:~dl$ |
注意,ld-linux.so 本身就是一個 ELF
共用庫,但它是靜態編譯的並且不具備共用庫依賴項。當需要動態連結時,核心會引導動態連結(ELF
解譯器),該連結首先會初始化自身,然後載入指定的共用對象(已載入則不必)。接著它會執行必要的再定位,包括目標共用對象所使用的共用對象。LD_LIBRARY_PATH
環境變數定義尋找可用共用對象的位置。定義完成後,控制權會被傳回到初始程式以開始執行。
再定位是通過一個稱為 Global Offset Table(GOT)和 Procedure Linkage Table(PLT)
的間接機制來處理的。這些表格提供了 ld-linux.so
在再定位過程中載入的外部函數和資料的地址。這意味著無需改動需要間接機制(即,使用這些表格)的代碼:只需要調整這些表格。一旦進行載入,或者只要需要
給定的函數,就可以發生再定位(稍候在 用 Linux 進行動態載入 小節中會看到更多的差別)。
再定位完成後,動態連結器就會允許任何載入的共用程式來執行可選的初始化代碼。該函數允許庫來初始化內部資料並備之待用。這個代碼是在上述 ELF 映像的 .init
部分中定義的。在卸載庫時,它還可以調用一個終止函數(定義為映像的 .fini
部分)。當初始化函數被調用時,動態連結器會把控制權轉讓給載入的原始映像。
用 Linux 進行動態載入
Linux 並不會自動為給定程式載入和連結庫,而是與應用程式本身共用該控制權。這個過程就稱為動態載入。使用動態載入,應用程式能夠先指定要載入的庫,然後將該庫作為一個可執行檔來使用(即調用其中的函數)。但是正如您在前面所瞭解到的,用於動態載入的共用庫與標準共用庫(ELF 共用對象)無異。事實上,ld-linux
動態連結器作為 ELF 載入器和解譯器,仍然會參與到這個過程中。
動態載入(Dynamic Loading,DL)API 就是為了動態載入而存在的,它允許共用庫對使用者空間程式可用。儘管非常小,但是這個 API 提供了所有需要的東西,而且很多困難的工作是在後台完成的。表 1 展示了這個完整的 API。
表 1. Dl API
函數 |
描述 |
dlopen |
使對象檔案可被程式訪問 |
dlsym |
擷取執行了 dlopen 函數的對象檔案中的符號的地址 |
dlerror |
返回上一次出現錯誤的字串錯誤 |
dlclose |
關閉目標檔案 |
該過程首先是調用 dlopen
,提供要訪問的檔案對象和模式。調用 dlopen
的結果是稍候要使用的對象的控制代碼。mode
參數通知動態連結器何時執行再定位。有兩個可能的值。第一個是 RTLD_NOW
,它表明動態連結器將會在調用 dlopen
時完成所有必要的再定位。第二個可選的模式是 RTLD_LAZY
,它只在需要時執行再定位。這是通過在內部使用動態連結器重新導向所有尚未再定位的請求來完成的。這樣,動態連結器就能夠在請求時知曉何時發生了新的引用,而且再定位可以正常進行。後面的調用無需重複再定位過程。
還可以選擇另外兩種模式,它們可以按位 OR
到 mode
參數中。RTLD_LOCAL
表明其他任何對象都無法使載入的共用對象的符號用於再定位過程。如果這正是您想要的的話(例如,為了讓共用的對象能夠調用原始進程映像中的符號),那就使用 RTLD_GLOBAL
吧。
dlopen
函數還會自動解析共用庫中的依賴項。這樣,如果您開啟了一個依賴於其他共用庫的對象,它就會自動載入它們。函數返回一個控制代碼,該控制代碼用於後續的 API 呼叫。dlopen
的原型為:
#include <dlfcn.h>void *dlopen( const char *file, int mode ); |
有了 ELF 對象的控制代碼,就可以通過調用 dlsym
來識別這個對象內的符號的地址了。該函數採用一個符號名稱,如對象內的一個函數的名稱。傳回值為對象符號的解析地址:
void *dlsym( void *restrict handle, const char *restrict name ); |
如果調用該 API 時發生了錯誤,可以使用 dlerror
函數返回一個表示此錯誤的人類可讀的字串。該函數沒有參數,它會在發生前面的錯誤時返回一個字串,在沒有錯誤發生時返回 NULL:
最後,如果無需再調用共用對象的話,應用程式可以調用 dlclose
來通知作業系統不再需要控制代碼和對象引用了。它完全是按引用來計數的,所以同一個共用對象的多個使用者相互間不會發生衝突(只要還有一個使用者在使用它,它就會待在記憶體中)。任何通過已關閉的對象的 dlsym
解析的符號都將不再可用。
char *dlclose( void *handle ); |
動態載入樣本
瞭解了 API 之後,下面讓我們來看一看 DL API 的例子。在這個應用程式中,您主要實現了一個
shell,它允許操作員來指定庫、函數和參數。換句話說,也就是使用者能夠指定一個庫並調用該庫(先前未連結於該應用程式的)內的任意一個函數。首先使用
DL API 來解析該庫中的函數,然後使用使用者定義的參數(用來發送結果)來調用它。清單 2 展示了完整的應用程式。
清單 2. 使用 DL API 的 Shell
#include <stdio.h>#include <dlfcn.h>#include <string.h>#define MAX_STRING 80void invoke_method( char *lib, char *method, float argument ){ void *dl_handle; float (*func)(float); char *error; /* Open the shared object */ dl_handle = dlopen( lib, RTLD_LAZY ); if (!dl_handle) { printf( "!!! %s\n", dlerror() ); return; } /* Resolve the symbol (method) from the object */ func = dlsym( dl_handle, method ); error = dlerror(); if (error != NULL) { printf( "!!! %s\n", error ); return; } /* Call the resolved method and print the result */ printf(" %f\n", (*func)(argument) ); /* Close the object */ dlclose( dl_handle ); return;}int main( int argc, char *argv[] ){ char line[MAX_STRING+1]; char lib[MAX_STRING+1]; char method[MAX_STRING+1]; float argument; while (1) { printf("> "); line[0]=0; fgets( line, MAX_STRING, stdin); if (!strncmp(line, "bye", 3)) break; sscanf( line, "%s %s %f", lib, method, &argument); invoke_method( lib, method, argument ); }} |
要構建這個應用程式,需要通過 GNU Compiler Collection(GCC)使用如下的編譯行。選項 -rdynamic
用來通知連結器將所有符號添加到動態符號表中(目的是能夠通過使用 dlopen
來實現向後跟蹤)。-ldl
表明一定要將 dllib
連結於該程式。
gcc -rdynamic -o dl dl.c -ldl |
再回到 清單 2,main
函數僅充當解譯器,解析來自輸入行的三個參數(庫名、函數名和浮點參數)。如果出現 bye
的話,應用程式就會退出。否則的話,這三個參數就會傳遞給使用 DL API 的 invoke_method
函數。
首先調用 dlopen
來訪問目標檔案。如果返回 NULL 控制代碼,表示無法找到對象,過程結束。否則的話,將會得到對象的一個控制代碼,可以進一步詢問對象。然後使用 dlsym
API 函數,嘗試解析新開啟的對象檔案中的符號。您將會得到一個有效指向該符號的指標,或者是得到一個 NULL 並返回一個錯誤。
在 ELF
對象中解析了符號後,下一步就只需要調用函數。要注意一下這個代碼和前面討論的動態連結的差別。在這個例子中,您強行將目標檔案中的符號地址用作函數指
針,然後調用它。而在前面的例子是將對象名作為函數,由動態連結器來確保符號指向正確的位置。雖然動態連結器能夠為您做所有麻煩的工作,但這個方法會讓您
構建出極其動態應用程式,它們可以再運行時被擴充。
調用 ELF 對象中的目標函數後,通過調用 dlclose
來關閉對它的訪問。
清單 3 展示了一個如何使用這個測試程式的例子。在這個例子中,首先編譯器而後執行它。接著調用了 math
庫(libm.so)中的幾個函數。完成示範後,程式現在能夠用動態載入來調用共用對象(庫)中的任意函數了。這是一個很強大的功能,通過它還能夠給程式
擴充新的功能。
清單 3. 使用簡單的程式來調用庫函數
mtj@camus:~/dl$ gcc -rdynamic -o dl dl.c -ldlmtj@camus:~/dl$ ./dl> libm.so cosf 0.0 1.000000> libm.so sinf 0.0 0.000000> libm.so tanf 1.0 1.557408> byemtj@camus:~/dl$ |
工具
Linux 提供了很多種查看和解析 ELF 對象(包括共用庫)的工具。其中最有用的一個當屬 ldd
命令,您可以使用它來發送共用庫依賴項。例如,在 dl
應用程式上使用 ldd
命令會顯示如下內容:
mtj@camus:~/dl$ ldd dl linux-gate.so.1 => (0xffffe000) libdl.so.2 => /lib/tls/i686/cmov/libdl.so.2 (0xb7fdb000) libc.so.6 => /lib/tls/i686/cmov/libc.so.6 (0xb7eac000) /lib/ld-linux.so.2 (0xb7fe7000)mtj@camus:~/dl$ |
ldd
所告訴您的是:該 ELF 映像依賴於 linux-gate.so(一個特殊的共用對象,它處理系統調用,它在檔案系統中無關聯檔案)、libdl.so(DL API)、GNU C
庫(libc.so)以及 Linux 動態載入器(因為它裡面有共用庫依賴項)。
readelf
命令是一個有很多特性的公用程式,它讓您能夠解析和讀取 ELF 對象。readelf
有一個有趣的用途,就是用來識別對象內可再定位的項。對於我們這個簡單的程式來說(清單 2 展示的程式),您可以看到需要再定位的符號為:
mtj@camus:~/dl$ readelf -r dlRelocation section '.rel.dyn' at offset 0x520 contains 2 entries: Offset Info Type Sym.Value Sym. Name08049a3c 00001806 R_386_GLOB_DAT 00000000 __gmon_start__08049a78 00001405 R_386_COPY 08049a78 stdinRelocation section '.rel.plt' at offset 0x530 contains 8 entries: Offset Info Type Sym.Value Sym. Name08049a4c 00000207 R_386_JUMP_SLOT 00000000 dlsym08049a50 00000607 R_386_JUMP_SLOT 00000000 fgets08049a54 00000b07 R_386_JUMP_SLOT 00000000 dlerror08049a58 00000c07 R_386_JUMP_SLOT 00000000 __libc_start_main08049a5c 00000e07 R_386_JUMP_SLOT 00000000 printf08049a60 00001007 R_386_JUMP_SLOT 00000000 dlclose08049a64 00001107 R_386_JUMP_SLOT 00000000 sscanf08049a68 00001907 R_386_JUMP_SLOT 00000000 dlopenmtj@camus:~/dl$ |
從這個列表中,您可以看到各種各樣的需要再定位(到 libc.so)的 C
庫調用,包括對 DL API(libdl.so)的調用。函數
__libc_start_main
是一個
C
庫函數,它優先於程式的
main
函數(一個提供必要初始化的 shell)而被調用。
其他動作對象檔案的公用程式包括:objdump
,它展示了關於對象檔案的資訊;nm
,它列出來自對象檔案(包括調試資訊)的符號。還可以將 EFL 程式作為參數,直接調用 Linux 動態連結器,從而手動開機映像:
mtj@camus:~/dl$ /lib/ld-linux.so.2 ./dl> libm.so expf 0.0 1.000000> |
另外,可以使用 ld-linux.so 的 --list
選項來羅列 ELF 映像的依賴項(ldd
命令也如此)。切記,它僅僅是一個使用者空間程式,是由核心在需要時引導的。
結束語
本文只涉及到了動態連結器功能的皮毛而已。在下面的 參考資料 中,您可以找到對 ELF 映像格式和過程或符號再定位的更詳細的介紹。而且和 Linux 其他所有工具一樣,你也可以下載動態連結器的原始碼(參見 參考資料)來深入研究它的內部。