作者:Eli Bendersky
原文連結:http://eli.thegreenplace.net/2011/11/03/position-indepentent-code-pic-in-shared-libraries/
載入時重定位的一些問題
如同我們在之前文章所看到的,載入時重定位是相當直觀的一個方式,而且的確可以運作。另一方面,PIC是現今較為廣受歡迎的作法,通常也是建造共享庫時的建議作法。怎麼會這樣呢?因為載入時重定位存在著一些問題:它會需要額外的時間,並且使得程式庫的text section無法被共享。
重要觀察 #1 - 在text section與data section間的位移
PIC所倚賴的一個關鍵是text section與data section間的位移,這個資訊可在連結時被連結器獲知。當連結器合併數個物件檔時,它會收集它們的sections(舉例來說,所有的text section會被合併為一個大的text section)。因此,連結器同時知道sections的大小與他們的相對位置。舉個例子,text section會緊跟在data section之後,所以給定一個在text section中的指令,那麼這個指令到data section的開頭的位移就是text section的大小減去指令在text section裡的位移 - 這些數值都會被連結器知道:
在上圖中,code section被載入到某個位址(在連結時是未知的)0xXXXX0000(X-es表示任意值),data section就在它之後的位址0xXXXXF000。如果某個在code section位移處0x80的指令想引用data section裡的東西,那麼連結器就知道相對位移是多少(此例中是0xEF80),並且可以編碼進指令裡。注意,就算有另一個section在code與data section中間,或data section擺在比code section還前面的地方也不會有關係,因為連結器知道所有sections的大小與相對位址,所以這個觀察總是成立。
重要觀察 #2 - 在x86上達到IP-relative的作用
全域位移表格(Global Offset Table, GOT)
有這些觀察在心中,我們終於可以開始看如何在x86上實作與位址無關的資料引用了。主要會藉由一個"全域位移表格",或簡寫為GOT。一個GOT就是一個位址的表格,位於data section中。假設在code section中某個指令想要引用一個變數,它不會直接用絕對位址(這需要重定位),而是會引用GOT中的一個項目。由於GOT位於data section中一個已知的地方,對於連結器來說,這個引用就是相對的並且已知的。而GOT項目會保存該變數的絕對位址:
這段代碼會被編譯成一個共享庫(使用-fpic與-shared旗標),命名為libmlpic_dataonly.so
我們來看看它的反組譯碼,先聚焦在ml_func函式就好:
我們計算一下,檢查看看編譯器的計算是否可正確找到myglob。如前所述,呼叫__i686.get_pc_thunk.cx會將下一個指令的位址放到ecx裡,這個位址是444[2]。下一個位址接著把0x1bb0與它相加,結果就是ecx的值變為0x1ff4。最後,位址就替換為[ecx - 0x10],也就是第一個GOT項目所在之處。
為何還有另一個以.got開頭的section會在後文中解釋[3]。注意到編譯器選擇讓ecx指到GOT後面的位址,然後用負的位移來獲得項目。這蠻好的,只要算術沒算錯就好,而它到目前為止也都算對了。
然而,我們還遺漏了一件事。myglob的位址究竟是怎麼放進GOT在0x1fe4的地方呢?想一下我們之前提過的重定位,我們現在把它找出來:
延遲綁定最佳化
當一個共享庫引用某個函式時,實際的位址不到載入後不會知道。決議這個位址的動作叫作綁定(binding),這是動態載入器在載入共享庫到行程的記憶體空間時所做的一件事。綁定的流程並不那麼直接,因為載入器需要透過一個特殊的表格去查找到函式符號[5]。
所以決議每個函式需要花費時間。並不是很多時間,但由於函式的數量基本上比全域變數的量都要得多,所以會造成影響。另外,大部分的決議都白白的浪費掉了,因為一個典型的程式通常只使用了一部份的程式庫函式(想想看,有許多的函式負責處理錯誤以及特殊情況,基本上根本不會被呼叫到)。
所以,為了加速這個過程,引入了一種聰明的延遲綁定方式。"延遲"是一個通用名稱,表示某類型的電腦程式設計的最佳化手法,需進行的工作會被延遲到它真的需要被執行時才進行。這是為了避免在從來不需執行此項工作的程式中浪費時間。一個很好的例子就是寫入時複製,以及惰性計算。
延遲綁定手法是藉由再多加入一層間接層- PLT,來完成的。
程序鍊結表格(Procedure Linkage Table, PLT)
PLT是text section的一部份,由一些項目所組成(一個項目負責一個共享庫的外部函式呼叫),每個PLT項目是一小段可執行的程式。代碼會透過呼叫某個在PLT中的項目,該項目會負責處理實際需進行的動作,而不是直接呼叫該函式。這種安排方式有時稱為"彈簧墊"("trampoline")。每個PLT項目也在GOT中有對應的項目,該項目會存有該函式的實際位移,但只在動態載入器決議函式後才有效。我知道這樣描述有點令人困惑,不過我希望接下來當我解釋了下圖的細節以後,這個概念將會變得清晰起來。
如同前一小節所提,PLT允許函式的延遲決議。當共享庫被載入後,函式呼叫其實還沒被決議:
解釋:
在代碼(code)中,一個函式被呼叫。編譯器將它轉換為呼叫func@plt,這是PLT中的第N個項目。
PLT有一個特殊的第一個項目,後面則串了一堆同樣結構的項目,每個函式都有一個對應的項目。
除了第一個項目,每個PLT項目都有3個部份:
一個jump到某個地方的動作,"某個地方"由GOT的對應項目指定
準備"決議"程序所需的參數
呼叫決議程序,這個程序藏在PLT的第一個項目
第一個PLT項目則是一個呼叫決議器的動作,該決議器就是動態載入器本身[6]。這個程序決議了函式的實際位址。
在函式實際被決議前,GOT項目只是指向對應的PLT項目中jmp之後的位址。這也是為什麼圖中的指示線以不同色彩來區分 - 它並不真的是一個跳轉,只是一個指標而已。
當func第一次被呼叫時發生了什麼事情呢: PLT[n]被呼叫,然後跳轉到GOT[n]所指向的位址。
這個位址指向PLT[n]本身,指到準備要給決議器參數的程序
決議器被呼叫
決議器進行了func實際位址的決議,將它的實際位址放進GOT[n],然後呼叫func。
當第一次呼叫後,流程則有些微變化:
注意到GOT[n]現在已經指向實際的func,而不是指回PLT嘍[7]。所以當func再次被呼叫:
PLT[n]被呼叫,並跳到GOT[n]指到的地方。
GOT[n]指向func,所以直接將控制轉到了func。
換句話說,現在func沒有透過決議器而被呼叫了,代價就是一個額外的跳轉。全部就是這樣了,真的!!這個機制允許函式的決議被延遲,並且當函式不被呼叫時將完全不會去決議它。
既然絕對位址只讓在data section的GOT有用到,並且GOT會被動態載入器重定位,這也同時讓程式庫的code/text section是完全的與位址無關,。PLT本身也是PIC的,所以它可以存在於唯讀的text section。
我並沒有講太多決議器的細節,因為對於此處的理解並沒有太重要。決議器其實只是載入器中一段低階的代碼,會進行符號決議。在PLT準備的那些參數,以及一個適當的重定位項目會幫助它知道那個符號需要決議,以及哪個GOT項目要被更新。
透過PLT與GOT完成PIC的函式呼叫 - 一個例子
原文連結:http://eli.thegreenplace.net/2011/11/03/position-indepentent-code-pic-in-shared-libraries/
我已經在以前的一篇文章中描述過,要將共享庫載入到行程的位址空間需要一些特殊的處理。簡單來說,當連結器建造出一個共享庫時,它沒有辦法事前就知道程式庫會被載入到那邊。這樣子一來,便產生了要如何引用此程式庫中資料與程式碼的問題,也就是說,到底要如何指向正確的記憶體的問題。
以Linux ELF共享庫來說,有兩個主要的方式來解決這個問題:
1. 載入時重定位
2. 位址無關程式碼 (PIC)
載入時重定位已經解釋過了。在這邊,我想要解釋第2種作法 - PIC。我原本想要在文中同時解釋x86與x64(也就是x86-64),但當文章愈來愈長時,這樣變得很不實際。所以此文只會解釋PIC如何在x86上運作,特別挑選這個架構(而不是x64)是因為它並不是有考慮到PIC在設計的,以致於在其上實作PIC有一點巧妙。未來的文章(希望不會太久)將會處理x64上的PIC議題。
如同我們在之前文章所看到的,載入時重定位是相當直觀的一個方式,而且的確可以運作。另一方面,PIC是現今較為廣受歡迎的作法,通常也是建造共享庫時的建議作法。怎麼會這樣呢?因為載入時重定位存在著一些問題:它會需要額外的時間,並且使得程式庫的text section無法被共享。
首先,效能問題。如果一個共享庫有載入時重定位的項目時,就必須花點時間在應用程式載入時處理這些重定位。你或許會認為這個代價不會很高,畢竟載入器不必掃描整個text section,只需要察看重定位項目即可。但是如果一個複雜的軟體在起始時載入多個大型共享庫,並且每個共享庫必須先將它的重定位項目處理好,這個代價將會逐漸攀高,並造成應用程式明顯的啟動延遲。再來,更嚴重的是text section無法共享的問題。共享庫一開始的首要目標是為了節省記憶體。多個應用程式都會使用一些共同的程式庫。如果共享庫的text section(就是代碼的部份)只需載入一次(然後以map的方式對映到多個行程的虛擬記憶體),就能夠省下可觀的記憶體。但這對載入時重定位是不可能的,因為它使用的方式會去修改text section。因此,每個載入這個共享庫的應用程式都必須將其完整載入到記憶體[1]。不同的應用程式將不能夠真的共享它。更糟糕的是,當text section是可寫的(必須保持是可寫的,這樣才能讓動態載入器去修改它)就會有安全上的風險,容易使得應用程式被窺視。我們將會看到,PIC可以大幅減低這些問題。
PIC - 簡介
PIC背後的想法很簡單 - 為程式碼中的全域資料與函式引用多增加一層的間接層。靠著巧妙的施用一些人造的連結與載入機制,讓共享庫的text section與位址無關是可能的,就是說,可以輕易地將它map到不同的記憶體位址,而不需一丁點的改變。在下列數小節中,我將會詳細解釋如何完成這些技巧。PIC所倚賴的一個關鍵是text section與data section間的位移,這個資訊可在連結時被連結器獲知。當連結器合併數個物件檔時,它會收集它們的sections(舉例來說,所有的text section會被合併為一個大的text section)。因此,連結器同時知道sections的大小與他們的相對位置。舉個例子,text section會緊跟在data section之後,所以給定一個在text section中的指令,那麼這個指令到data section的開頭的位移就是text section的大小減去指令在text section裡的位移 - 這些數值都會被連結器知道:
上面那項觀察僅在我們可以用相對位移來工作時才有用。但是x86的資料引用(mov指令中)需要絕對位址。那麼,我們要怎麼作呢?
如果我們有一個相對位移然後需要其絕對位址,還需要知道的就是instruction poniter的值(畢竟依據定義,此處的相對位移是相對於指令的位移)。在x86上,沒有一個指令可以獲得instruction pointer的值,但我們透過一個簡單的技倆去獲取。這邊是證明這個想法的一點組合語言虛擬碼:
call TMPLABEL
TMPLABEL:
pop ebx
這邊發生的事為:
1. CPU執行了call TMPLABEL,所以會將下一個指令的位址存下到堆疊(下一個指令就是pop ebx),然後跳到標籤處。
2. 既然在標籤處的指令是pop ebx,接下來就是執行它。它會從堆疊pop一個值到ebx。但這個值是這個指令的位址,所以ebx現在就包含了有效的instruction pointer的值嘍。
全域位移表格(Global Offset Table, GOT)
有這些觀察在心中,我們終於可以開始看如何在x86上實作與位址無關的資料引用了。主要會藉由一個"全域位移表格",或簡寫為GOT。一個GOT就是一個位址的表格,位於data section中。假設在code section中某個指令想要引用一個變數,它不會直接用絕對位址(這需要重定位),而是會引用GOT中的一個項目。由於GOT位於data section中一個已知的地方,對於連結器來說,這個引用就是相對的並且已知的。而GOT項目會保存該變數的絕對位址:
我們在這邊用虛擬的組合語言代碼替換掉對絕對地址的引用:
; Place the value of the variable in edx
mov edx, [ADDR_OF_VAR]
透過定址另一個暫存器的方式間接取代:
; 1. Somehow get the address of the GOT into ebx
lea ebx, ADDR_OF_GOT
; 2. Suppose ADDR_OF_VAR is stored at offset 0x10
; in the GOT. Then this will place ADDR_OF_VAR
; into edx.
mov edx, DWORD PTR [ebx + 0x10]
; 3. Finally, access the variable and place its
; value into edx.
mov edx, DWORD PTR [edx]
藉由GOT去引用變數,於是我們就擺脫了code section裡的重定位。但是我們卻在data section中產生了一個需重定位的地方。為啥米?因為GOT依然需要包含變數的絕對位址才能工作,就像我們之前講得那般。那我們這樣有獲得任何東西嗎?獲得的可多嘍~是這樣的,在data section中的重定位比在code section的重定位要來的沒有那麼多麻煩,有兩個理由(也是直接針對文章一開頭所提的載入時重定位的兩個主要問題):
1. 在code section中必須對每一個變數都進行重定位,而GOT只需對一個變數進行。非常有可能有多個對變數的引用,所以會比較有效率。
2. data section是可寫的,並且總是沒有被多個行程所共享,所以在那作重定位沒什麼壞處。將重定位從code section中移除,就可以讓它是唯讀的並且在多個行程間共享。
一個例子 - 透過GOT完成PIC以進行資料引用
我會展現一個完整的例子來說明PIC的機制:int myglob = 42;
int ml_func(int a, int b)
{
return myglob + a + b;
}
我們來看看它的反組譯碼,先聚焦在ml_func函式就好:
0000043c <ml_func>:
43c: 55 push ebp
43d: 89 e5 mov ebp,esp
43f: e8 16 00 00 00 call 45a <__i686.get_pc_thunk.cx>
444: 81 c1 b0 1b 00 00 add ecx,0x1bb0
44a: 8b 81 f0 ff ff ff mov eax,DWORD PTR [ecx-0x10]
450: 8b 00 mov eax,DWORD PTR [eax]
452: 03 45 08 add eax,DWORD PTR [ebp+0x8]
455: 03 45 0c add eax,DWORD PTR [ebp+0xc]
458: 5d pop ebp
459: c3 ret
0000045a <__i686.get_pc_thunk.cx>:
45a: 8b 0c 24 mov ecx,DWORD PTR [esp]
45d: c3 ret
我會透過指令的位址來解說(在反組譯碼的最左邊數字就是指令位址)。這些位址是載入時相對於共享庫的位移:
在43f,藉由"重要觀察 #2"的技巧,下一個指令的位址會被放入ecx中。
在444,一個固定的位移,即從該指令到GOT被載入的位址的距離,被加到ecx,所以ecx現在就等於GOT的base address。
在44a,[ecx - 0x10]是GOT的一個項目,被擺到eax中,這就是myglob的位址。
在450,這個迂迴作法完成了,並且myglob的值被放進eax。
接著,參數a和b就跟myglob相加,然後結果值就回傳了(透過eax)。
我們也可以透過readelf -S來觀察這個共享庫,看看GOT section在哪兒:
Section Headers:
[Nr] Name Type Addr Off Size ES Flg Lk Inf Al
<snip>
[19] .got PROGBITS 00001fe4 000fe4 000010 04 WA 0 0 4
[20] .got.plt PROGBITS 00001ff4 000ff4 000014 04 WA 0 0 4
<snip>
為何還有另一個以.got開頭的section會在後文中解釋[3]。注意到編譯器選擇讓ecx指到GOT後面的位址,然後用負的位移來獲得項目。這蠻好的,只要算術沒算錯就好,而它到目前為止也都算對了。
然而,我們還遺漏了一件事。myglob的位址究竟是怎麼放進GOT在0x1fe4的地方呢?想一下我們之前提過的重定位,我們現在把它找出來:
> readelf -r libmlpic_dataonly.so
Relocation section '.rel.dyn' at offset 0x2dc contains 5 entries:
Offset Info Type Sym.Value Sym. Name
00002008 00000008 R_386_RELATIVE
00001fe4 00000406 R_386_GLOB_DAT 0000200c myglob
<snip>
注意到myglob位於如我們所預期的0x1fe4,它的重定位類型是R_386_GLOB_DAT,動態載入器等於被告知 - "將符號的值(就是它的位址)擺進位移所在處"。於是,所有的事情運作的非常完美。還需要做的部份就只有當程式庫載入時的查詢動作而已。我們可以藉由撰寫一個簡單的"driver"執行檔來檢驗這個事情,該執行檔會與libmlpic_dataonly.so連結並且呼叫ml_func。我們透過GDB來執行它。
我們預期myglob應該就在0x0013300c的地方,來確認一下:
PIC中的函式呼叫
> gdb driver
[...] skipping output
(gdb) set environment LD_LIBRARY_PATH=.
(gdb) break ml_func
[...]
(gdb) run
Starting program: [...]pic_tests/driver
Breakpoint 1, ml_func (a=1, b=1) at ml_reloc_dataonly.c:5
5 return myglob + a + b;
(gdb) set disassembly-flavor intel
(gdb) disas ml_func
Dump of assembler code for function ml_func:
0x0013143c <+0>: push ebp
0x0013143d <+1>: mov ebp,esp
0x0013143f <+3>: call 0x13145a <__i686.get_pc_thunk.cx>
0x00131444 <+8>: add ecx,0x1bb0
=> 0x0013144a <+14>: mov eax,DWORD PTR [ecx-0x10]
0x00131450 <+20>: mov eax,DWORD PTR [eax]
0x00131452 <+22>: add eax,DWORD PTR [ebp+0x8]
0x00131455 <+25>: add eax,DWORD PTR [ebp+0xc]
0x00131458 <+28>: pop ebp
0x00131459 <+29>: ret
End of assembler dump.
(gdb) i registers
eax 0x1 1
ecx 0x132ff4 1257460
[...] skipping output
除錯器進入到了ml_func,並在IP 0x0013144a處停下來。我們可看到ecx存有0x132ff4(就是前面講解過的指令的位址加上0x1bb0)。注意,當在執行期時,共享庫已經被載入到行程的位址空間,所以這些都是絕對位址。於是,myglob的GOT項目是位於[ecx - 0x10]。讓我們看看那邊有啥什麼東西:
(gdb) x 0x132fe4
0x132fe4: 0x0013300c
(gdb) p &myglob
$1 = (int *) 0x13300c
酷啊!!就是它!!
好啦~這就是如何在與位址無關程式碼中引用資料的方式。但是函式呼叫又如何呢?理論上,同樣的作法一樣可以用上去。不要直接去call函式的位址,而是讓一個GOT項目存有該函式位址,然後在載入時設定該項目。但這並不是PIC函式呼叫的方式,實際發生的事情有些巧妙,在我解釋之前,想先說明一下為何需要如此的巧妙。
延遲綁定最佳化
當一個共享庫引用某個函式時,實際的位址不到載入後不會知道。決議這個位址的動作叫作綁定(binding),這是動態載入器在載入共享庫到行程的記憶體空間時所做的一件事。綁定的流程並不那麼直接,因為載入器需要透過一個特殊的表格去查找到函式符號[5]。
所以決議每個函式需要花費時間。並不是很多時間,但由於函式的數量基本上比全域變數的量都要得多,所以會造成影響。另外,大部分的決議都白白的浪費掉了,因為一個典型的程式通常只使用了一部份的程式庫函式(想想看,有許多的函式負責處理錯誤以及特殊情況,基本上根本不會被呼叫到)。
所以,為了加速這個過程,引入了一種聰明的延遲綁定方式。"延遲"是一個通用名稱,表示某類型的電腦程式設計的最佳化手法,需進行的工作會被延遲到它真的需要被執行時才進行。這是為了避免在從來不需執行此項工作的程式中浪費時間。一個很好的例子就是寫入時複製,以及惰性計算。
延遲綁定手法是藉由再多加入一層間接層- PLT,來完成的。
程序鍊結表格(Procedure Linkage Table, PLT)
PLT是text section的一部份,由一些項目所組成(一個項目負責一個共享庫的外部函式呼叫),每個PLT項目是一小段可執行的程式。代碼會透過呼叫某個在PLT中的項目,該項目會負責處理實際需進行的動作,而不是直接呼叫該函式。這種安排方式有時稱為"彈簧墊"("trampoline")。每個PLT項目也在GOT中有對應的項目,該項目會存有該函式的實際位移,但只在動態載入器決議函式後才有效。我知道這樣描述有點令人困惑,不過我希望接下來當我解釋了下圖的細節以後,這個概念將會變得清晰起來。
如同前一小節所提,PLT允許函式的延遲決議。當共享庫被載入後,函式呼叫其實還沒被決議:
在代碼(code)中,一個函式被呼叫。編譯器將它轉換為呼叫func@plt,這是PLT中的第N個項目。
PLT有一個特殊的第一個項目,後面則串了一堆同樣結構的項目,每個函式都有一個對應的項目。
除了第一個項目,每個PLT項目都有3個部份:
一個jump到某個地方的動作,"某個地方"由GOT的對應項目指定
準備"決議"程序所需的參數
呼叫決議程序,這個程序藏在PLT的第一個項目
第一個PLT項目則是一個呼叫決議器的動作,該決議器就是動態載入器本身[6]。這個程序決議了函式的實際位址。
在函式實際被決議前,GOT項目只是指向對應的PLT項目中jmp之後的位址。這也是為什麼圖中的指示線以不同色彩來區分 - 它並不真的是一個跳轉,只是一個指標而已。
當func第一次被呼叫時發生了什麼事情呢: PLT[n]被呼叫,然後跳轉到GOT[n]所指向的位址。
這個位址指向PLT[n]本身,指到準備要給決議器參數的程序
決議器被呼叫
決議器進行了func實際位址的決議,將它的實際位址放進GOT[n],然後呼叫func。
當第一次呼叫後,流程則有些微變化:
PLT[n]被呼叫,並跳到GOT[n]指到的地方。
GOT[n]指向func,所以直接將控制轉到了func。
換句話說,現在func沒有透過決議器而被呼叫了,代價就是一個額外的跳轉。全部就是這樣了,真的!!這個機制允許函式的決議被延遲,並且當函式不被呼叫時將完全不會去決議它。
既然絕對位址只讓在data section的GOT有用到,並且GOT會被動態載入器重定位,這也同時讓程式庫的code/text section是完全的與位址無關,。PLT本身也是PIC的,所以它可以存在於唯讀的text section。
我並沒有講太多決議器的細節,因為對於此處的理解並沒有太重要。決議器其實只是載入器中一段低階的代碼,會進行符號決議。在PLT準備的那些參數,以及一個適當的重定位項目會幫助它知道那個符號需要決議,以及哪個GOT項目要被更新。
透過PLT與GOT完成PIC的函式呼叫 - 一個例子
再一次,我們以一個實際的例子來證實這個不易學習的理論。此處是一個完整的例子,展示出如何使用上述機制完成函式呼叫。我這次將會調快步驟。
這段代碼被編譯為libmlpic.so,然後重點將會放在ml_func中的ml_util_func呼叫。我們來看看ml_func的反組譯:
有趣的部份在ml_func@plt,也要注意到GOT在的位址在ebx裡。這邊則是ml_until_func@plt長的模樣(在一個可執行的section,叫作.plt):
回想一下,每個PLT項目有3個部份:
一個跳轉,由GOT的對應項目指定(這邊是跳轉到[ebx + 0x14])
準備給決議器的參數
呼叫決議器
決議器(在PLT的項目0)位於0x370處,但我們在這裡對它不感興趣。比較有趣的是,GOT項目中有些什東西。我們先作點計算。在ml_func中"獲得IP"的技巧在位址0x483,那邊0x1b71與其相加,所以GOT的基底位址就是0x1ff4。我們可以透過readelf來窺視GOT的內容[8]:
ml_util_unc@plt所看的GOT項目在位移+0x14的地方,也就是0x2008。從上面可以看出,在那邊的值是0x3a6,也就是在ml_util_func@plt裡的push指令的位址。
為了幫助動態載入器完成工作,一個重定位項目也被加入,並指定哪個GOT項目要為了ml_util_func被重定位:
最後一行表示動態載入器需要將符號ml_util_func的位址擺進0x2008裡(也就是這個函式的GOT項目)。
在第一次呼叫後,看看這個GOT項目的修改變化是很有意思的。我們再一次請出GDB來觀察。
我們現在還沒進行ml_util_func的第一次呼叫。回想一下,GOT在ebx裡邊。我們看看什麼鬼在裏面:
然後將項目的位移考慮進來,我們要的東西在[ebx+0x14]:
嘿!0x3a6結尾的耶,看起來對了。現在,讓我們一步一步來觀察,直到呼叫到ml_util_func,然後再來觀察一遍:
在0x13308的值被修改了,因此0x0013146c應該就是ml_util_func的真實位址了,並且已被動態載入器放進了:
如我們所預期。
PIC的代價
這邊是共享庫的代碼:
int myglob = 42;
int ml_util_func(int a)
{
return a + 1;
}
int ml_func(int a, int b)
{
int c = b + ml_util_func(a);
myglob += c;
return b + myglob;
}
00000477 <ml_func>:
477: 55 push ebp
478: 89 e5 mov ebp,esp
47a: 53 push ebx
47b: 83 ec 24 sub esp,0x24
47e: e8 e4 ff ff ff call 467 <__i686.get_pc_thunk.bx>
483: 81 c3 71 1b 00 00 add ebx,0x1b71
489: 8b 45 08 mov eax,DWORD PTR [ebp+0x8]
48c: 89 04 24 mov DWORD PTR [esp],eax
48f: e8 0c ff ff ff call 3a0 <ml_util_func@plt>
<... snip more code>
000003a0 <ml_util_func@plt>:
3a0: ff a3 14 00 00 00 jmp DWORD PTR [ebx+0x14]
3a6: 68 10 00 00 00 push 0x10
3ab: e9 c0 ff ff ff jmp 370 <_init+0x30>
一個跳轉,由GOT的對應項目指定(這邊是跳轉到[ebx + 0x14])
準備給決議器的參數
呼叫決議器
決議器(在PLT的項目0)位於0x370處,但我們在這裡對它不感興趣。比較有趣的是,GOT項目中有些什東西。我們先作點計算。在ml_func中"獲得IP"的技巧在位址0x483,那邊0x1b71與其相加,所以GOT的基底位址就是0x1ff4。我們可以透過readelf來窺視GOT的內容[8]:
> readelf -x .got.plt libmlpic.so
Hex dump of section '.got.plt':
0x00001ff4 241f0000 00000000 00000000 86030000 $...............
0x00002004 96030000 a6030000 ........
為了幫助動態載入器完成工作,一個重定位項目也被加入,並指定哪個GOT項目要為了ml_util_func被重定位:
> readelf -r libmlpic.so
[...] snip output
Relocation section '.rel.plt' at offset 0x328 contains 3 entries:
Offset Info Type Sym.Value Sym. Name
00002000 00000107 R_386_JUMP_SLOT 00000000 __cxa_finalize
00002004 00000207 R_386_JUMP_SLOT 00000000 __gmon_start__
00002008 00000707 R_386_JUMP_SLOT 0000046c ml_util_func
在第一次呼叫後,看看這個GOT項目的修改變化是很有意思的。我們再一次請出GDB來觀察。
> gdb driver
[...] skipping output
(gdb) set environment LD_LIBRARY_PATH=.
(gdb) break ml_func
Breakpoint 1 at 0x80483c0
(gdb) run
Starting program: /pic_tests/driver
Breakpoint 1, ml_func (a=1, b=1) at ml_main.c:10
10 int c = b + ml_util_func(a);
(gdb)
(gdb) i registers ebx
ebx 0x132ff4
(gdb) x/w 0x133008
0x133008: 0x001313a6
(gdb) step
ml_util_func (a=1) at ml_main.c:5
5 return a + 1;
(gdb) x/w 0x133008
0x133008: 0x0013146c
(gdb) p &ml_util_func
$1 = (int (*)(int)) 0x13146c <ml_util_func>
決定是否由載入器完成決議以及何時完成
這裡很適當提及一件事:動態載入器的延遲符號決議過程是可以根據某些環境變數的值來調整的(以及當連結共享庫時的對應ld旗標)。有時候這對特殊的效率要求或除錯有些幫助。當定義LD_BIND_NOW環境變數時,這就是告訴動態載入器總是要為所有的符號在起始時進行符號決議,不要延遲。確認這件事很容易,只要設定了這個環境變數,然後用前一個例子重跑GDB就可以了。你可以發現ml_util_func的GOT項目在還沒進行第一次的呼叫時就有實際的位址了。
相反地,LD_BIND_NOT環境變數告訴動態載入器完全不要更新GOT項目。每次的函式呼叫都會透過動態載入器再去決議一次。動態載入器也可以被其他旗標改變行為,我鼓勵你去看一下man ld.so,那邊有一些有趣的資訊。
PIC的代價
這篇文章以載入時重定位引起的問題開頭,接著則是說明PIC的方式如何修正這些問題。但是PIC也不是沒有問題。一個立即又明顯的問題就是PIC對於資料與程式的外部引用都需要額外的跳轉。也需要為了全域變數及函式的引用增加額外的記憶體。這在實務上會造成多嚴重的問題要取決於編譯器、CPU架構、以及特定的應用程式。
另外,比較不明顯的代價則是PIC的實作對於暫存器的使用增加了不少。為了避免太頻繁地查找GOT,編譯器將GOT的位址放在一個暫存器中是很有道理的(通常是ebx),但這麼一來就因為GOT而減少了一個暫存器可用。在RISC架構下,通常有很多通用暫存器,但這個問題在x86這類架構下會影響到效能,因為x86只有為數不多的暫存器。PIC佔用掉一個暫存器,也就代表會增加透過間接存取記憶體的代價。
結論
這篇文章解釋了什麼是PIC以及它如何幫助共享庫共享出唯讀的text section。在PIC與載入時重定位之間存在一些取捨,其最後的選擇要考量許多因素,像是程式是執行於哪種CPU架構。PIC變得愈來愈受歡迎。一些非Intel的架構,像是SPARC64的共享庫就只有PIC的選項,其他有IP-relative定址的架構(舉例來說,ARM)可以讓PIC更有效率。對於x86的後繼者,x64架構而言,PIC也是正確的選擇。我將會在未來的文章討論x64。這篇文章的焦點並不是效能或架構選擇,我的目的是要解釋當PIC被使用時,它是如何運作的。如果我解釋的還不夠清楚 - 請在回覆中讓我知道,我會試著提供更多訊息。
[1] | 除非全部的應用程式都將這個程式庫載入到同一個虛擬記憶體位址。但在Linux上這是不太可能的。 |
[2] | 0x444(以及在這個計算中所提及的其他位址)是相對於這個共享庫的載入位址,這位址在執行檔還沒載入時是未知的。注意,由於在代碼中它轉化為相對位址,所以這並不重要。 |
[3] | 精明的讀者會奇怪為何.got是另一個section。難道我不是在圖中顯示它是位於data section?在實務上,是的。我不想去區分ELF section與segment的差別,因為會離題太遠。簡單來說,任意多的"data" sections可以被程庫所定義並映設到一個可讀寫的segment。只要ELF正確的被組織,其實這並不很重要。區分data segment為多個不同的邏輯sections提供了好的模組化,並讓連結器的公會容易一些。 |
[4] | 注意到gdb跳過了ecx被設值的部份。這是因為這被認為是函式的開頭初始話的一部份(當然,真實的原因是因為gcc嵌入了除錯訊息的關係)。數個對全域變數以及函式的引用存在於函式中,而只要一個暫存器指向GOT就能夠為它們服務。 |
[5] | ELF共享庫中實際上有一個專為了此目的設計的特殊hash table。 |
[6] | Linux上的動態載入器也是一個共享庫,會被所有執行中的行程載入到位址空間。 |
[7] | 我將func放在另一個code section,雖然理論上跟呼叫func的部份會在同一個section(在同一個共享庫中),在這篇文章中的"額外紅利"部份有關於為何在共享庫中呼叫庫中的內部函式也需要PIC(或重定位)。 |
[8] | 回想一下,在資料引用的那個範例中,我承諾過要解釋為何有兩個GOT sections:.got以及.got.plt。現在這看起來很明白了。它就只是為了方便,需要兩類的GOT項目,一類給全域變數用,另一類給需要PLT的項目使用。這也是為何當一個函式中的GOT位移被算出來後會指向got.plt,這個section會在.got之後。這樣一來,用負的位移可以存取.got,而用正的位移可以存取.got.plt。雖然方便,但這種安排不是必須的,這兩個部份可以擺放在同一個.got section。 |
相關文章:
留言
張貼留言