摘 要:本文介紹了動態連接庫的優點,詳細闡述了x86體系結構上Linux系統的編譯器 、連接器、加載器如何使用多種重定位方式來實現該功能 關鍵詞:動態連接庫;Linux;重定位 The Implementation Mechanism of DLL under Linux 【Abstract】In this paper, we discuss the advantage of using dynamic linking . We also demonstrate in detail how compiler, linker and loader implement th is feature by using several kinds of relocations under nowadays Linux system , especially on x86 architectures. 【KeyWords】dynamic link library; DLL; Linux; relocation Linux與Windows的動態連接庫概念相似,但是實現機制不同。它引入了GOT表和PLT表的 概念,綜合使用了多種重定位項,實現了"浮動代碼",達到了更好的共享性能。本文對 這些技術逐一進行了詳細討論。 本文著重討論x86體系結構,這是因為(1)運行Linux的各種體系結構中,以x86最為普及 ;(2)該體系結構上的Windows操作系統廣為人知,由此可以較容易的理解Linux的類似概 念; 下表列出了Windows與Linux的近義詞,文中將不加以區分: Windows Linux 動態連接庫(DLL) Shared Object 目標文件(.obj) 文件名結尾常是 .o 可執行文件(.exe) Executable(文件名無特定標志) 連接器(link.exe) Linker Editor (ld) 加載器(exec/loader) Dynamic Linker (ld-linux.so) 段(segment) 節(section) 一些關鍵字在本文中有特定含義,需要澄清: 編譯單元:一個C語言源文件,經過編譯後將生成一個目標文件 運行模塊:一個動態連接庫或者一個可執行文件。簡稱為模塊 自動變量、函數:C語言auto關鍵字修飾的對象 靜態變量、函數:C語言static關鍵字修飾的對象 全局變量、函數:C語言extern關鍵字修飾的對象 1 動態連接庫的優點 程序編制一般需經編輯、編譯、連接、加載和運行幾個步驟。由於一些公用代碼需要反 復使用,就把它們預先編譯成目標文件並保存在"庫"中。當它與用戶程序的目標文件連 接時,連接器得從庫中選取用戶程序需要的代碼,然後復制到生成的可執行文件中。這 種庫稱為靜態庫,其特點是可執行文件中包含了庫代碼的一份完整拷貝。顯然,當靜態 庫被多個程序使用時,磁盤上、內存中都是多份冗余拷貝。 而使用動態連接庫就克服了這個缺陷。當它與用戶程序的目標文件連接時,連接器只是 作上標記,說明程序需要該動態連接庫,而不真的把庫代碼復制到可執行文件中;僅當 可執行文件運行時,加載器根據這個標記,檢查該庫是否已經被其它可執行文件加載進 內存。如果已存在於內存中,不用再從磁盤上加載,只要共享內存中已有的代碼即可。 這樣磁盤、內存中始終只有一份代碼,較靜態庫為優。 2 Linux動態連接庫的重要特點:浮動代碼 在Windows中,連接生成動態連接庫時要指定一個首地址。應用程序運行時,加載器將盡 可能把動態連接庫裝入到該地址;如果地址已被占用,該動態連接庫只能被加載到其它 地址空間內,這時就要對庫中的代碼和數據進行修補,或叫做重定位。如此一來,庫的 多個實例在內存中經過重定位後,彼此將不盡相同,自然不再能共享了。為了避免這個 缺陷,Windows自帶的庫都指定了互不重疊的地址,盡管如此,其它軟件廠商的產品仍然 不可避免的使用重疊地址,由此部分喪失了使用動態連接庫的好處。 在Linux中,為了達到更好的共享性能,使用了與Windows不一樣的策略:浮動代碼(Po sition Independent Code,簡稱PIC)。具體說,使用的轉移指令都是相對於當前程序 計數器(IP)的偏移量;代碼中引用變量、函數的地址都是相對於某個基地址的偏移量 。總之,從不引用一個絕對地址。這樣,動態連接庫無論被加載到什麼地址空間,不用 修補代碼就可以正常工作。既然只有一份代碼,就容易實現共享了。 值得指出,此處所指的共享,是指為了節省存儲器,多個進程使用動態連接庫代碼段、 只讀數據段在內存中的唯一映像;另一種常用的共享定義,是指多個進程對同一段(可 能是動態分配的)存儲區進行讀寫,實現進程間通信(IPC)。後一種共享定義與本文無 可執行文件運行時,加載器根據這個標記,檢查該庫是否已經被其它可執行文件加載進 內存。如果已存在於內存中,不用再從磁盤上加載,只要共享內存中已有的代碼即可。 這樣磁盤、內存中始終只有一份代碼,較靜態庫為優。 2 Linux動態連接庫的重要特點:浮動代碼 在Windows中,連接生成動態連接庫時要指定一個首地址。應用程序運行時,加載器將盡 可能把動態連接庫裝入到該地址;如果地址已被占用,該動態連接庫只能被加載到其它 地址空間內,這時就要對庫中的代碼和數據進行修補,或叫做重定位。如此一來,庫的 多個實例在內存中經過重定位後,彼此將不盡相同,自然不再能共享了。為了避免這個 缺陷,Windows自帶的庫都指定了互不重疊的地址,盡管如此,其它軟件廠商的產品仍然 不可避免的使用重疊地址,由此部分喪失了使用動態連接庫的好處。 在Linux中,為了達到更好的共享性能,使用了與Windows不一樣的策略:浮動代碼(Po sition Independent Code,簡稱PIC)。具體說,使用的轉移指令都是相對於當前程序 計數器(IP)的偏移量;代碼中引用變量、函數的地址都是相對於某個基地址的偏移量 。總之,從不引用一個絕對地址。這樣,動態連接庫無論被加載到什麼地址空間,不用 修補代碼就可以正常工作。既然只有一份代碼,就容易實現共享了。 值得指出,此處所指的共享,是指為了節省存儲器,多個進程使用動態連接庫代碼段、 只讀數據段在內存中的唯一映像;另一種常用的共享定義,是指多個進程對同一段(可 能是動態分配的)存儲區進行讀寫,實現進程間通信(IPC)。後一種共享定義與本文無 關。 3 Linux動態連接庫的實現機制:重定位 3.1 重定位概述 浮動代碼通過重定位操作得以實現。而重定位可以按多種標准進行分類: -- 按發生的地點,可分成對代碼段(.text)重定位和對數據段(.data)重定位。 -- 按發生的時間,可分成連接時重定位和加載時重定位(加載時重定位也稱為動態重定 位)。但這兩步並不總是必不可少的。例如,要實現浮動代碼就不能對代碼段進行動態 重定位,這時采取的辦法是,把需要動態重定位的項搬到數據段中去,然後在代碼段中 引用這些項。 -- 按重定位項引用的對象,可分成數據引用和函數引用。如果引用的是靜態數據或靜態 函數,連接器會優化生成的代碼,去掉動態重定位項。 -- 從字面上講, x86體系結構上的Linux使用了多種重定位方式,名字前綴以"R_386_" ,後面分別接:32、GOT32、PLT32、COPY、GLOB_DAT、JMP_SLOT、RELATIVE、GOTOFF、 GOTPC。每種方式都有特定的含義。 以上幾種分類中最重要的是按地點分類。而下文也將以它為主線,逐一介紹各種重定位 項。首先,引入兩個關鍵概念:GOT表和PLT表。 3.2 GOT表 GOT(Global Offset Table)表中每一項都是本運行模塊要引用的一個全局變量或函數 的地址。可以用GOT表來間接引用全局變量、函數,也可以把GOT表的首地址作為一個基 准,用相對於該基准的偏移量來引用靜態變量、靜態函數。 由於加載器不會把運行模塊加載到固定地址,在不同進程的地址空間中,各運行模塊的 絕對地址、相對位置都不同。這種不同反映到GOT表上,就是每個進程的每個運行模塊都 有獨立的GOT表,所以進程間不能共享GOT表。 在x86體系結構上,本運行模塊的GOT表首地址始終保存在%ebx寄存器中。編譯器在每個 函數入口處都生成一小段代碼,用來初始化%ebx寄存器。這一步是必要的,否則,如果 對該函數的調用來自另一運行模塊,%ebx中就是調用者模塊的GOT表地址;不重新初始化 %ebx就用來引用全局變量和函數,當然出錯。 3.3 PLT表 PLT(Procedure Linkage Table)表每一項都是一小段代碼,對應於本運行模塊要引用 的一個全局函數。以對函數fun的調用為例,PLT中代碼片斷如下: .PLTfun: jmp *fun@GOT(%ebx) pushl $offset jmp .PLT0@PC 其中引用的GOT表項被加載器初始化為下一條指令(pushl)的地址,那麼該jmp指令相當 於nop空指令。 用戶程序中對fun的直接調用經編譯連接後生成一條call fun@PLT指令,這是一條相對跳 轉指令(滿足浮動代碼的要求!),跳到.PLTfun。如果這是本運行模塊中第一次調用該 函數,此處的jmp等於一個空指令,繼續往下執行,接著就跳到.PLT0。該PLT項保留給編 譯器生成的額外代碼,會把程序流程引入到加載器中去。加載器計算fun的實際入口地址 ,填入fun@GOT表項。圖示如下: user program -------------- call fun@PLT v DLL PLT table loader -------------- -------------- ----------------------- fun: <-- jmp*fun@GOT --> change GOT entry from $loader to $fun, v then jump to there GOT table -------------- fun@GOT:$loader 第一次調用以後,GOT表項已指向函數的正確入口。以後再有對該函數的調用,跳到PLT 表後,不再進入加載器,直接跳進函數正確入口了。從性能上分析,只有第一次調用才 要加載器作一些額外處理,這是完全可以容忍的。還可以看出,加載時不用對相對跳轉 的代碼進行修補,所以整個代碼段都能在進程間共享。 熟悉Windows的程序員很容易注意到,GOT表、PLT表與Windows中的引入表(Import)有 類似之處。其它對應關系還有: Linux的version script與Windows的.DEF文件;Linux 的dynamic symbols section與Windows的輸出表(EXPort)。不再舉更多例子了。 3.4 代碼段重定位 需要說明,由浮動代碼的要求,代碼段內不應該存在重定位項。此處只是借用了"在代碼 段中"這個短語,實際的重定位項還是位於數據段的GOT表內。盡管如此,它與3.5節"數 據段中的重定位"的區別是很明顯的。 a) 裝載GOT表首地址 使用GOT表當然事先要知道它的首地址,然而該首地址會隨運行模塊被加載的首地址不同 而不同。Linux使用了一個技巧在運行時求出正確的GOT表首地址。代碼片斷如下,緊接 其後列出的是對應的目標文件(.o)與動態連接庫(.so)中的重定位項類型: call L1 L1: popl %ebx addl $GOT+[.-.L1], %ebx .o: R_386_GOTPC .so: NULL 如前所述,該代碼片斷存在於每個函數的入口處。程序第一句把當前程序計數器(IP) 值推進堆棧,第二句又把它從堆棧中彈出來,結果相當於movl %eip, %ebx,只不過合法 的x86指令集中不允許%eip作為操作數而已。然後第三句把%ebx加上一個GOT表與IP值的 差,這個差值是個與動態連接庫加載首地址無關的常數,在連接時即可求出。整個過程 用類C語言描述如下: %ebx = %eip; %ebx += ($GOT - %eip) 至此%ebx等於GOT表首地址。 上述過程是編譯、連接相合作的結果。編譯器生成目標文件時,因為此時還不存在GOT表 (每個運行模塊有一個GOT表,一個PLT表,由連接器生成),所以暫時不能計算GOT表與 當前IP間的差值,僅在第三句處設上一個R_386_GOTPC重定位標記而已。然後進行連接。 連接器注意到GOTPC重定位項,於是計算GOT與此處IP的差值,作為addl指令的立即尋址 方式操作數。以後再也不需要重定位了。 b) 引用變量、函數地址 當引用的是靜態變量、靜態函數或字符串常量時,使用R_386_GOTOFF重定位方式。它與 GOTPC重定位方式很相似,同樣首先由編譯器在目標文件中設上重定位標記,然後連接器 計算GOT表與被引用元素首地址的差值,作為leal指令的變址尋址方式操作數。代碼片斷 如下: leal .LC1@GOTOFF(%ebx), %eax .o: R_386_GOTOFF .so: NULL 當引用的是全局變量、全局函數時,編譯器會在目標文件中設上一個R_386_GOT32重定位 標記。連接器會在GOT表中保留一項,注上R_386_GLOB_DAT重定位標記,用於加載器填寫 被引用元素的實際地址。連接器還要計算該保留項在GOT表中的偏移,作為movl指令的變 址尋址方式操作數。代碼片斷如下: movl x@GOT(%ebx), %eax .o: R_386_GOT32 .so: R_386_GLOB_DAT 需要指出,引用全局函數時,由GOT表讀出不是全局函數的實際入口地址,而是該函數在 PLT表中的入口.PLTfun(參見3.3節)。這樣,無論直接調用,還是先取得函數地址再間 接調用,程序流程都會轉入PLT表,進而把控制權轉移給加載器。加載器就是利用這個機 會進行動態連接的。 c) 直接調用函數 如前所述,浮動代碼中的函數調用語句會編譯成相對跳轉指令。首先編譯器會在目標文 件中設上一個R_386_PLT32重定位標記,然後視靜態函數、全局函數不同而連接過程也有 所不同。 如果是靜態函數,調用一定來自同一運行模塊,調用點相對於函數入口點的偏移量在連 接時就可計算出來,作為call指令的相對當前IP偏移跳轉操作數,由此直接進入函數入 口,不用加載器操心。相關代碼片斷如下: call f@PLT .o: R_386_PLT32 .so: NULL 如果是全局函數,連接器將生成到.PLTfun的相對跳轉指令,之後就如3.3節所述,對全 局函數的第一次調用會把程序流程轉到加載器中去,然後計算函數的入口地址,填充fu n@GOT表項。這稱為R_386_JMP_SLOT重定位方式。相關代碼片斷如下: call f@PLT .o: R_386_PLT32 .so: R_386_JMP_SLOT 如此一來,一個全局函數可能有多至兩個重定位項。一個是必需的JMP_SLOT重定位項, 加載器把它指向真正的函數入口;另一個是GLOB_DAT重定位項,加載器把它指向PLT表中 的代碼片斷。取函數地址時,取得的總是GLOB_DAT重定位項的值,也就是指向.PLTfun, 而不是真正的函數入口。 進一步考慮這樣一個問題:兩個動態連接庫,取同一個全局函數的地址,兩個結果進行 比較。由前面的討論可知,兩個結果都沒有指向函數的真正入口,而是分別指向兩個不 同的PLT表。簡單進行比較,會得出"不相等"的結論,顯然不正確,所以要特殊處理。 3.5 數據段重定位 在數據段中的重定位是指對指針類型的靜態變量、全局變量進行初始化。它與代碼段中 的重定位比較起來至少有以下明顯不同:一、在用戶程序獲得控制權(main函數開始執 行)之前就要全部完成;二、不經過GOT表間接尋址,這是因為此時%ebx中還沒有正確的 GOT表首地址;三、直接修改數據段,而代碼段重定位時不能修改代碼段。 如果引用的是靜態變量、函數、串常量,編譯器會在目標文件中設上R_386_32重定位標 記,並計算被引用變量、函數相對於所在段首地址的偏移量。連接器把它改成R_386_RE LATIVE重定位標記,計算它相對於動態連接庫首地址(通常為零)的偏移量。加載器會 把運行模塊真正的首地址(不為零)與該偏移量相加,結果用來初始化指針變量。代碼 片斷如下: .section .rodata .LC0: .string "Ok\n" .data p: .long .LC0 .o: R_386_32 w/ section .so: R_386_RELATIVE 如果引用的是全局變量、函數,編譯器同樣設上R_386_32重定位標記,並且記錄引用的 符號名字。連接器不必動作。最後加載器查找被引用符號,結果用來初始化指針變量。 對於全局函數,查找的結果仍然是函數在PLT表中的代碼片斷,而不是實際入口。這與前 面引用全局函數的討論相同。代碼片斷如下: .data p: .long printf .o: R_386_32 w/ symbol .so: R_386_32 w/ symbol 3.6 總結 下表給出了前面討論得到的全部結果: .o .so ------------------------------------------------------------ 裝載GOT表首地址 R_386_GOTPC NULL 代碼段----------------------------------------------------- 重定位引用變量函數地址 靜態 R_386_GOTOFF NULL 全局 R_386_GOT32 R_386_GLOB_DAT ----------------------------------------------------- 直接調用函數 靜態 R_386_PLT32 NULL 全局 R_386_PLT32 R_386_JMP_SLOT ----------------------------------------------------------- 數據段引用變量函數地址 靜態 R_386_32 w/sec R_386_RELATIVE 重定位 全局 R_386_32 w/sym R_386_32 w/sym ------------------------------------------------------------ 4 結束語 Windows使用PE文件格式,Linux使用ELF文件格式,這是兩種動態連接庫不同的根源。本 文從ELF規范出發,深入討論了Linux動態連接庫的具體實現,目的在於進一步推廣Linu x的研究與應用。 5 附錄:Linux匯編程序語法 x86體系結構上的Linux匯編器兼容於AT&T System V/386匯編器的語法,與常見的Intel 語法頗有不同,如下表: AT&T Intel 常數 前綴$:pushl $4 push 4 寄存器 前綴%:%ebx ebx 跳轉指令(絕對地址) 前綴*:jmp *fun 跳轉指令(相對偏移) 無標記:jmp fun 目的、源操作數的順序 源在前:movl $4,%eax 目的在前:mov eax,4 操作數尺寸 後綴b、w、l:movl 修飾符byte ptr等等 變址尋址 [base+disp] disp(base) 參考文獻 [1] Executable and Linking Format Spec v1.2, TIS Committee, 1995 http://x86.ddj.com/FTP/manuals/tools/elf.pdf [2] GNU Project (gcc, libc, binutils), Free Software Foundation, Inc., 1999 http://www.gnu.org/software/ [3] Solaris 2.5 Linker and Libraries Guide, Sun Microsystems Inc., 1999 http://docs.sun.com/ ftp://192.18.99.138/802-1955/802-1955.pdf [4] SVR4 ABI x86 Supplement, The Santa Cruz Operation, Inc., 1999 http://www.sco.com/developer/devspecs/abx86-4.pdf [5] ELF: From The Programmer's Perspective, H J Lu, 1995 http://metalab.unc.edu/pub/Linux/GCC/elf.ps.gz [6] Using ld: The GNU linker, S Chamberlain, Cygnus Support, 1994 http://www.gnu.org/manual/ld-2.9.1/ps/ld.ps.gz