1. 程式人生 > >計算機科學基礎知識(五)動態鏈接

計算機科學基礎知識(五)動態鏈接

offset 格式 分析 目標 修改 block -o obj 重復

一、前言

本文以類似hello world這樣的簡單程序為例,描述了動態連接的概念。第二章描述了整個動態鏈接的大概過程,隨後的兩章解析了程序訪問動態庫中的數據和調用動態庫中函數的過程。

註意:閱讀本文之前需要先了解relocatable object file、靜態鏈接以及動態庫和PIC這些內容。

二、動態鏈接的過程概述

下面的圖展示了動態鏈接的過程:

技術分享圖片

Static Linker(對於本文的場景,它就是arm-linux-ld)接收下面的輸入:

(1)命令行參數

(2)linker script

(3)各種.o文件,這些.o文件有些是你自己的源程序生成的,有些是系統自帶的,例如crt*.o

(4)各種動態庫文件。同樣的,可以自己生成動態庫,也可以是系統自帶的,例如c lib。

Static Linker最終生成了一個ELF格式的可執行程序。要把它變成一個實實在在系統中的進程還需要Dynamic Linker和Loader的協助(對於本文的場景,它應該包括linux kernel、bash以及ld-linux.so)。由於引用了動態庫中的符號,static linker生成的可執行程序只完成了部分符號的relocation(各個.o文件之間的相互引用),有些符號仍然沒有定位(調用動態庫中的符號),這些未定位的符號需要在動態庫加載後(確定運行時地址),使用dynamic linker來進行鏈接、定位,這個過程就是動態鏈接。對於靜態鏈接,一旦完成了鏈接,所有的符號就已經確定了run time address,OS只要按照ELF文件中的信息進行加載就好了。對於動態鏈接的可執行程序,使用static linker進行鏈接的時候僅僅是完成了部分內容,需要動態鏈接的符號都還沒有確定run time address,static linker僅僅是在可執行文件中嵌入了一些“線索”,在loading的時候,dynamic linker會根據這些“線索”完成剩余的鏈接工作。

最後再強調一次:雖然在上面的block diagram中共享庫出現了兩次,但是參與靜態鏈接的時候僅僅是方便static linker進行symbol resolution,在動態鏈接時候,dynamic linker會真正將其mapping到進程的地址空間。

三、可執行程序訪問動態庫中的數據

1、source code

我們寫一個小程序test.c來訪問libfoo.so中的數據(libfoo.so的源代碼參考動態庫和位置無關代碼),代碼如下:

#include <stdio.h>
extern int xxx;
extern int yyy;
int main(char argc, char** argv)
{
foo();
printf("xxx = %x, yyy = %x \n", xxx, yyy);
}

2、運行模塊之間的數據訪問如何實現?

運行模塊內的符號訪問是不需要特別關註的,例如上面的這個程序,如果自己定義的一個全局變量ppp並在main函數中訪問。在這種情況下,編譯成可執行文件的時候,ppp的地址已經確定了,因此可以main函數的尾部定義一個ppp地址的memory(在.text section),然後使用PC-relative類型的訪問獲取ppp地址就OK了,static linker在最後生成可執行的ELF文件的時候,會把ppp的地址寫入code segment,一切都很簡單。但是,運行模塊之間的符號訪問(例如test.c程序訪問libfoo動態庫模塊的xxx全局變量)的情況是怎樣的呢?

我們不看結果,先自己思考一下,然後檢查自己思考的是否正確。

由於xxx符號沒有確定running address,考慮通過GOT來完成對xxx的訪問。static linker生成動態鏈接的test可執行程序的時候,應該可以確定GOT的地址以及xxx符號在GOT中的offset,這時候,static linker會在data segment中創建一個GOT,並且包括一個關於xxx符號的entry(當然,這時候,GOT中的xxx符號的entry不可能寫入正確的xxx地址,都還沒有確定呢)。雖然編譯的時候我們不知道xxx的運行地址(動態庫libfoo可以被加載到任何的地址),但是dynamic linker知道啊,因此,在loading test這個程序的時候,dynamic linker可以改寫GOT中xxx符號的entry,把真實的runtime地址寫入就OK了。

看起來很完美,不過我們還可以進一步思考一下動態庫中的共享情況。多個程序要加載libfoo動態庫的時候,正文段的共享是沒有問題的,因為是read only的,雖然加載到不同程序的不同的虛擬地址上去,但是通過頁表可以mapping到相同的物理地址上,因此,所有進程的libfoo動態庫的code segment只要copy一次就OK了。不過libfoo的data segment是RW的,因此無法在多個進程中共享,怎麽辦?每個進程都會將libfoo的data segment mapping到自己的地址空間,但設定為Read only,在進程修改該memory的內容的時候,產生異常,這時候分配物理內存,copy,建立頁表,也就是是利用linux 的COW(copy-on-write)技術,可以實現各個進程自己特定的動態庫數據區。

3、觀察實際的情況

我們看看在程序中是如何訪問xxx符號的:

000085cc

:
……
85e8: e59f3020 ldr r3, [pc, #32] ; 8610 <.text+0xf4>
85ec: e5932000 ldr r2, [r3]
……
8610: 000107ec .word 0x000107ec
……


看起來這段代碼和我們想像的有些差距,看起來xxx這個符號被安排在本程序的bss section(0x000107ec這個地址屬於bss section),從section table中可以看出來這一點:

[23] .bss NOBITS 000107e8 0007e8 00000c 00 WA 0 0 4

起始地址是0x107e8開始的長度為0xc的區域屬於bss section。我們在上一節中所有美好的想像都崩塌了。難道我們需要對xxx這個符號進行重定位嗎?好吧,我們來看看test的重定位信息,在.rel.dyn section中:

Relocation section ‘.rel.dyn‘ at offset 0x478 contains 3 entries:
Offset Info Type Sym.Value Sym. Name
000107dc 00001415 R_ARM_GLOB_DAT 00000000 __gmon_start__
000107e8 00000114 R_ARM_COPY 000107e8 yyy
000107ec 00000614 R_ARM_COPY 000107ec xxx

R_ARM_COPY這種類型的重定位信息只是用於ELF可執行文件,dynamic linker看到R_ARM_COPY這種類型的重定位信息就知道是在定位動態庫中的一個符號,這時候,dynamic linker會copy指定size(動態連接符號表.dynsym中有該符號的size)的動態庫中的memory到目標地址(對應xxx這個場景就是0x107ec,也就是bss section中的xxx符號)。copy之後,dynamic linker還會做一件事情,就是把所有訪問該符號(包括動態庫)的進行重定位,讓這些代碼使用0x107ec來訪問xxx這個符號。在動態庫和位置無關代碼文檔中,我們知道,動態庫代碼訪問xxx變量也是通過GOT進行的,dynamic linker將xxx符號對應的GOT Entry修改成0x107ec即可。

OK,我們根據實際的觀察可以得出結論:動態庫中的data segemnt中的data和bss section中的數據並不會直接被進程中的代碼訪問,雖然它們被mapping到了進程的地址空間中去,它們的唯一的作用是作為initial data copy,也就是說,每次一個依賴該動態庫的新進程loading,動態庫被mapping到進程,當進程實際訪問動態庫中的data的時候,實際上並沒有直接引用到動態庫data segment mapping的那個虛擬地址上去,實際上,進程也會分配這些內存,但是這些內存的內容會在被訪問之前用動態庫中的initial copy來填充。

上節中我們思考的方法雖然可行,但是用COW技術導致了開銷。

四、可執行程序調用動態庫中的函數

1、引言

源代碼還是上一章的代碼,只不過我們重點關註foo函數的調用。

……
85e4: ebffffc3 bl 84f8 <.text-0x24>
……

我們知道,bl是PC-relative的,代碼執行到這裏會跳轉到.text-0x24這個位置,這是一個什麽樣的神秘東東呢?我們看看test的program header就會明白了:

……
LOAD 0x000000 0x00008000 0x00008000 0x006c0 0x006c0 R E 0x8000 ------code segment
LOAD 0x0006c0 0x000106c0 0x000106c0 0x00128 0x00134 RW 0x8000------data segment
……

Code Segment mapping:
…… .rel.dyn .rel.plt .init .plt .text .fini .rodata .ARM.exidx .eh_frame
……

code segment由若幹個section組成,.text-0x24實際上會涉及.text section前面的那個section,也就是.plt。

2、什麽是PLT(Procedure Linkage Table)?

首先我們先聊一聊為何會有PLT?它的目的是什麽?難道有了GOT還不夠,還要用PLT這樣的概念持續轟炸可憐的碼農?當然,對於PIC code而言,如果訪問本運行模塊內部的函數,那麽僅僅使用GOT而不使用PLT也是OK的。由於是編譯目標是位置無關,因此,傳遞給gcc的參數包含-fPIC這樣的option,這時候,gcc在將一個個.c文件編譯成.o文件的時候,對所有的全局符號(函數和變量)都使用GOT。我們可以用函數調用為例,對這些全局符號進行分類。假設一個動態庫D,其由a.c b.c和c.c三個編譯模塊組成,那麽全局的函數符號調用分成兩種:

(1)該動態庫D內部定義了該函數符號。例如a.c模塊調用了b.c模塊的bb函數

(2)該動態庫D沒有定義該函數符號,該符號來自其他動態庫

對上面兩種符號都只使用GOT,不用PLT也是OK的,方法如下:首先獲取GOT首地址(緊跟代碼,增加一個.word來保存該值,雖然gcc編譯的時候,got首地址還不知道,但是static linker會知道並修改這個值),和該函數在GOT中的偏移(方法同上,也是緊跟代碼,增加一個.word來保存該值),取出該函數地址,將控制權交給該函數。是不是覺得稍微麻煩一些,不如bl那麽直接。但是使用GOT就是這樣,沒有辦法。實際上,對於第一類別的情況,在靜態鏈接階段,static linker實際上知道a.c模塊中調用了bb函數指令和bb函數之間的offset,因此可以直接使用bl,從而省略了上面那麽復雜的過程,但是在編譯成.o文件的時候,gcc哪裏知道bb是一個外部動態庫的符號,還是本動態庫其他編譯模塊的符號呢?也只能是統一處理。

但是,實際的情況不都是動態庫,我們的項目一般是有主程序和多個動態庫模塊組成,對於主程序模塊,都不會編譯成PIC的(一般而言),對於這種non-PIC的場景,我們可以用GOT包打天下嗎?答案是:不行,讓我們來看看這個場景分析。在編譯主程序的各個.c文件的時候,由於沒有-fPIC的參數,因此函數調用都是被編譯成:

ebfffffe bl 0

gcc沒有那麽聰明,它就是按照command line傳遞的參數工作,沒有-fPIC的參數,就一律使用bl。在鏈接的時候,問題來了,如果xxxx函數是一個外部的符號,static linker根本不知道其運行地址是什麽,這時候怎麽辦?bl指令就是跳轉到相對PC的一個地址去繼續執行程序,這時候跳轉到GOT可以嗎?可以是可以,但是dynamic linker不能直接寫入xxxx的地址而是要寫入一段代碼,這樣GOT中的內容就不純粹了,因此這些跳轉的代碼被移除到另外的一個section,用來協同完成動態符號定位,而保存這些代碼的section就是PLT。

OK,雖然對於PIC而言,不使用PLT是OK的,但是有開銷。想像一下:一個動態庫中有80處的函數調用,那麽每個原來可以用1條bl實現函數調用的地方,需要使用4條指令來展開,更重要的是:代碼需要重復80次。因此,實際上,即使gcc知道要編譯成位置無關代碼,但是對於函數調用仍然被編譯成bl這樣的PC-relative指令,在靜態鏈接階段,static linker會把PLT section加上的。

3、到底函數符號是如何定位的呢?

通過第一節的描述,我們知道,在test程序中,跳轉到foo實際上是跳轉到foo對應的PLT entry,代碼如下:

84f4: ……
84f8: e28fc600 add ip, pc, #0 ; 0x0---------ip寄存器保存了當前PC值
84fc: e28cca08 add ip, ip, #32768 ; 0x8000
8500: e5bcf2d0 ldr pc, [ip, #720]!-----------獲取got的地址並跳轉到該處
8504: ……

上面的代碼不是那麽直觀,我們可以直接計算一下看看:0x84f8處的指令執行之後,ip等於當前的PC值,也就是0x84f8+8=0x8500,加上0x8000之後等於0x10500,再加上720(0x2d0)就是0x107d0了,也就是位於.got section:

[21] .got PROGBITS 000107bc 0007bc 000024 04 WA 0 0 4

我們再來看看foo的重定位信息,位於.rel.plt,.rel.plt section的每一個entry對應一個PLT entry:

000107d0 00000e16 R_ARM_JUMP_SLOT 000084f8 foo

foo符號的PLT entry地址是0x84f8(參考上文),foo符號對應的got entry位於0x107d0,重定位的類型是R_ARM_JUMP_SLOT。到底foo對應的got entry上是什麽內容呢?我們來看看:

Disassembly of section .got:

000107bc <_global_offset_table_>:
...
107c8: 000084cc .word 0x000084cc
107cc: 000084cc .word 0x000084cc
107d0: 000084cc .word 0x000084cc

因此,實際上,通過PLT和GOT的協助,程序最終跳轉到0x000084cc執行。而通過section table:

[11] .plt PROGBITS 000084cc 0004cc 000050 04 AX 0 0 4

我們可以看出,跳轉到0x000084cc實際上是跳轉到了PLT section的第一個entry。走了一大圈,又回到了原地,不著急,我們繼續看代碼:

000084cc <.plt>:
84cc: e52de004 str lr, [sp, #-4]!----------------將lr壓入棧
84d0: e59fe004 ldr lr, [pc, #4] ; 84dc <.plt+0x10>-------將0x000082e0賦值給lr
84d4: e08fe00e add lr, pc, lr
84d8: e5bef008 ldr pc, [lr, #8]!----------------跳轉到GOT[2]
84dc: 000082e0 .word 0x000082e0
84e0: ……

看起來代碼沒有那麽直觀,我們還是照舊,直接計算。0x84d4地址的指令執行後lr = 0x000082e0 + 0x84d4 + 8 = 0x107BC,實際上就是.got的首地址,.got section的entry size是4,因此,0x84d8處的指令實際上就是跳轉到GOT[2]處的指令執行。GOT的前三個entry是特殊的entry,GOT[0]是.dynamic segment的地址,dynamic linker需要這個信息進行動態符號定位(例如:找到動態符號表和重定位信息)。GOT[1]中保存的是識別本模塊的信息。GOT[2]中保存了dynamic linker的入口函數地址。

實際上,在靜態鏈接階段,我們是無法確定dynamic linker的入口函數地址的,這時候,static linker只能是填寫全0值,在內核完成將dynamic linker mapping到進程地址空間之後,才能確定該地址,從而寫入GOT[2]。因此,第一次訪問foo符號,實際進入了dynamic linker的代碼執行,這時候dynamic linker當然就是解析出foo的符號地址(這時候,libfoo.so已經loading了),並且把foo的最終的運行地址寫入foo對應的GOT entry(也就是0x107d0,初始化的時候被設定成.plt的首地址0x000084cc,以便去往dynamic linker)。這樣,第二次訪問foo的時候就可以直接去到實際的foo函數,而不必讓dynamic linker對它進行relocation了。這麽做,也就是實現了傳說中的lazy binding。和lazy binding相反的是eager binding,也就是說在進入到程序的第一條指令執行前,所有的沒有重定位的符號(主程序以及各個動態庫)都先由dynamic linker掃描一遍,找到其運行地址並寫入GOT,也就是說,當程序開始執行的時候,所有的符號都已經綁定了最後的running address。和eager binding不同,lazy binding那是相當懶惰,只有在程序執行到該函數的時候,才會調用dynamic linker來重定位該符號。我們知道,其實程序中很多代碼都是出錯處理,很可能整個進程生命期結束了都不會調用到那些代碼,既然如此,為何還需要在一開始就對那些符號進行重定位呢?這不是讓dynamic linker瞎費功夫嘛,這也是lazy binding存在的意義。

計算機科學基礎知識(五)動態鏈接