原文作者:Eli Bendersky
原文連結:http://eli.thegreenplace.net/2011/11/11/position-independent-code-pic-in-shared-libraries-on-x64/
前一篇文章解釋過與位址無關程式碼(position independent code, PIC)如何運作,並使用在 x86 上編譯過的程式作為範例。我承諾過在另一篇文章中涵蓋 x64 上的 PIC ,也就是現在這篇。既然大家都已經了解 PIC 的基本原理,這篇文章將不會牽扯太多細節。一般情況下,概念在不同平台下都是相似的,但細節會因為機器架構而有所不同。
RIP-relative 定址
在 x86 上,函式的呼叫(使用 call 指令)使用與目前指令相對位移的方式定址,資料的取用(使用 mov 指令)只支援絕對定址。既然 PIC 無可避免地需要利用 IP-relative 的定址方式,絕對位址就不容易與 PIC 同時存在,而這也造成我們在前一篇文章看到 PIC 會比較沒有效率的原因。
在 RIP-relative 模式中的位移長度為 32 位元。由於正負值的位移都有需要,所以在 RIP 中的此種模式,支援的最大位移大概有最多 +/- 2GB 的位移。
在 x64 PIC 中取用資料 – 一個例子
作為一個簡單的對照,我會使用跟前篇文章同樣的 C 代碼作為取用資料的例子:
讓我們看看 ml_func 的反組譯:
最令人感興趣的是在 0x5f6 的指令:它透過取用 GOT 中的 entry ,將 myglobal 的位址放進 rax 。我們可以看到,它使用了 RIP-relative 定址。由於是相對於下一個指令的相對位址,我們實際上是拿到 0x5fd + 0x2009db = 0x200fd8 。所以存有 myglob 的 GOT entry 的位址是 0x200fd8。讓我們確認一下:
GOT 從 0x200fc8 開始,所以 myglob 在第三個 entry。我們也可以查看為 GOT 中的 myglob 所產出的重定位項目:
確實,有一個修正 0x200fd8 的重定位項目會告知動態連結器,一旦將 myglob 的最終位址決議後,要將其放進 0x200fd8。所以 myglob 的位址是如何獲得的就很清楚了。在反組譯中下一道指令(0x5fd)接著透過該位址去獲取 myglob 的值,並放進 eax[2] 。
在 x64 PIC 中的函式呼叫 – 一個例子
可能的效能提昇
在這兩個例子中,我們可以看到在 x64 中運作的 PIC 需要的指令比 x86 要少。在 x86 上,GOT 的位址是藉由兩個步驟載入某個基底暫存器(通常是 ebx) - 首先,指令的位址是透過某個特殊的函式去獲得,接著將 GOT 的位移與之相加。這兩個步驟在 x64 上都不需要,因為 linker 知道相對 GOT 的位移,並且能夠輕易地以 RIP-relative 定址模式編碼。
當呼叫一個函式時,就不必要像 x86 那樣,在 ebx 中準備 GOT 的位址來實現 trampoline 了。因為 trmapoline 可以透過 RIP-relative 定址來直接存取 GOT entry。
在 x64 上,與非 PIC 程式相比,PIC 依然需要額外的指令,但代價變小了。間接多使用一個暫存器來作為 GOT 的指標也不再需要了(在 x86 中非常痛苦才能作到這點),因為對於 RIP-relative 定址來說,不需要此類暫存器[3]。總而言之,x64 PIC 將 x86 上的效能損失減少了許多,使得這種作法更具吸引力了。它是如此有吸引力,變成此架構下的共享程式庫的預設建構方式。
額外紅利:在 x64 上的非 PIC 程式
gcc 不只鼓勵你在 x64 上建構共享庫時使用 PIC ,它也將其設定為預設行為。舉例來說,如果我們讓第一個例子編譯時不加入-fpic[4],然後將其連結成共享庫(透過 -shared),我們會發現 linker 在報錯,大概像這樣:
發生了什麼事?我們看看 ml_nopic_dataonly.o 的反組譯碼[5]:
注意到此處 myglob 的存取方式,在位址 0xa 的指令。它預期 linker 會以重定位實際 myglob 位址的方式修補此處的運算元(所以不需要 GOT ):
Linker 會抱怨此處的重定位 R_X86_64_PC32 。它就是無法將此連結進共享庫。為何?因為 mov 指令的位移必須是 32 位元(並且會與 rip 相加),而且當一段代碼成為共享庫,我們無法提前知道 32 位元是否足夠(譯註:如果你開始看不懂,請回頭看這篇文章)。但是,這是完整的 64 位元架構,有著廣大的位址空間。在某個共享庫的符號最終可能被發現在遠大於 32 位元的位址沒有辦法被引用。這使得 R_X86_64_PC32 在 x64 共享庫中是一種無效的的重定位方式。
我們有辦法在 x64 上產出非 PIC 的程式嗎?是的!我們必須指導編譯器使用 "large code model"。透過 -mcmodel=large 旗標。code model 的主題很有趣,但是解釋這件事會讓我們的討論離此篇文章的目標太遠[6]。所以我只會簡單的說:code model 是一種程式設計師與編譯器之間的協議,程式設計師作出某種承諾,讓編譯器知道程式將會使用何種程度的位移。作為回報,編譯器就可以產出更好的代碼。
結果就是,要在 x64 上讓編譯器產出可以使 linker 開心的非 PIC 代碼,只有 large code model 是合適的,因為它是限制最少的。記住我如何解釋簡單的重定位為何無法滿足 x64,因為 linker 擔心在連結時無法獲得高於 32 位元的位址?嗯,large code model 基本上放棄所有的假設,並且對所有的資料取用採用最大的 64 位元位移。這讓 load-time relocation 總是安全的,並且可在 x64 上產生非 PIC 程式。讓我們看看第一個採用 -mcmodel=large 並且沒有 -fpic 的反組譯例子:
注意到重定位的形式變為 R_X86_64_64 ,是一種有著 64 位元的絕對重定位。這就能夠令 linker 接受,它現在會愉悅地同意將 object file 連結成共享庫。
透過展示 x64 上 PIC 如何運作,這篇文章補充了之前的說明。這個架構下有新的定址模式來幫助 讓 PIC 代碼更有效率,因此使得這種作法會比在 x86 下更適合作為共享庫的方式,因為 x86 的 PIC 會需要較高的執行代價。既然 x64 目前是在伺服器、桌上機以及筆電上最流行的架構,這變成了重要須知。因此,我試著聚焦在不同的的共享庫建構方式,比如說非 PIC 代碼。如果你對於未來想探索的方向有任何疑問以及(或)建議,請透過回應或 email 讓我知道。
[1] 如同過去,我使用 x64 作為x86-64, AMD64 或 Intel 64 的方便的簡稱。
原文連結:http://eli.thegreenplace.net/2011/11/11/position-independent-code-pic-in-shared-libraries-on-x64/
前一篇文章解釋過與位址無關程式碼(position independent code, PIC)如何運作,並使用在 x86 上編譯過的程式作為範例。我承諾過在另一篇文章中涵蓋 x64 上的 PIC ,也就是現在這篇。既然大家都已經了解 PIC 的基本原理,這篇文章將不會牽扯太多細節。一般情況下,概念在不同平台下都是相似的,但細節會因為機器架構而有所不同。
RIP-relative 定址
在 x86 上,函式的呼叫(使用 call 指令)使用與目前指令相對位移的方式定址,資料的取用(使用 mov 指令)只支援絕對定址。既然 PIC 無可避免地需要利用 IP-relative 的定址方式,絕對位址就不容易與 PIC 同時存在,而這也造成我們在前一篇文章看到 PIC 會比較沒有效率的原因。
x64 透過一個新的"RIP-relative 定址模式"修正了這點,這是 64 位元的 mov 指令去存去記憶體的預設模式(此模式也在其他指令中使用,像 lea )。根據"英特爾架構手冊第二冊"中的說明:
一個新的定址模式,RIP-relative(相對於 instruction-pointer),在64 位元模式中被實作。一種有效率的方式去增加相對於指向下一個指令的 64 位元 RIP 的位移
在 RIP-relative 模式中的位移長度為 32 位元。由於正負值的位移都有需要,所以在 RIP 中的此種模式,支援的最大位移大概有最多 +/- 2GB 的位移。
在 x64 PIC 中取用資料 – 一個例子
作為一個簡單的對照,我會使用跟前篇文章同樣的 C 代碼作為取用資料的例子:
int myglob = 42;
int ml_func(int a, int b)
{
return myglob + a + b;
}
00000000000005ec <ml_func>:
5ec: 55 push rbp
5ed: 48 89 e5 mov rbp,rsp
5f0: 89 7d fc mov DWORD PTR [rbp-0x4],edi
5f3: 89 75 f8 mov DWORD PTR [rbp-0x8],esi
5f6: 48 8b 05 db 09 20 00 mov rax,QWORD PTR [rip+0x2009db]
5fd: 8b 00 mov eax,DWORD PTR [rax]
5ff: 03 45 fc add eax,DWORD PTR [rbp-0x4]
602: 03 45 f8 add eax,DWORD PTR [rbp-0x8]
605: c9 leave
606: c3 ret
$ readelf -S libmlpic_dataonly.so
There are 35 section headers, starting at offset 0x13a8:
Section Headers:
[Nr] Name Type Address Offset
Size EntSize Flags Link Info Align
[...]
[20] .got PROGBITS 0000000000200fc8 00000fc8
0000000000000020 0000000000000008 WA 0 0 8
[...]
$ readelf -r libmlpic_dataonly.so
Relocation section '.rela.dyn' at offset 0x450 contains 5 entries:
Offset Info Type Sym. Value Sym. Name + Addend
[...]
000000200fd8 000500000006 R_X86_64_GLOB_DAT 0000000000201010 myglob + 0
[...]
在 x64 PIC 中的函式呼叫 – 一個例子
現在讓我們來看函式呼叫時如何在 x64 的 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;
}
反組譯 ml_func,我們獲得:
000000000000064b <ml_func>:
64b: 55 push rbp
64c: 48 89 e5 mov rbp,rsp
64f: 48 83 ec 20 sub rsp,0x20
653: 89 7d ec mov DWORD PTR [rbp-0x14],edi
656: 89 75 e8 mov DWORD PTR [rbp-0x18],esi
659: 8b 45 ec mov eax,DWORD PTR [rbp-0x14]
65c: 89 c7 mov edi,eax
65e: e8 fd fe ff ff call 560 <ml_util_func@plt>
[... snip more code ...]
函式呼叫部份如同以往,會呼叫 m;_util_func@plt。讓我們看看那邊有什麼:
0000000000000560 <ml_util_func@plt>:
560: ff 25 a2 0a 20 00 jmp QWORD PTR [rip+0x200aa2]
566: 68 01 00 00 00 push 0x1
56b: e9 d0 ff ff ff jmp 540 <_init+0x18>
所以某個 GOT entry 存有 ml_util_func 的實際位址,該 GOT entry 在 0x200aa2 + 0x566 = 0x201008。如預期的,同樣有一個對應的重定位項目存在:
$ readelf -r libmlpic.so
Relocation section '.rela.dyn' at offset 0x480 contains 5 entries:
[...]
Relocation section '.rela.plt' at offset 0x4f8 contains 2 entries:
Offset Info Type Sym. Value Sym. Name + Addend
[...]
000000201008 000600000007 R_X86_64_JUMP_SLO 000000000000063c ml_util_func + 0
可能的效能提昇
在這兩個例子中,我們可以看到在 x64 中運作的 PIC 需要的指令比 x86 要少。在 x86 上,GOT 的位址是藉由兩個步驟載入某個基底暫存器(通常是 ebx) - 首先,指令的位址是透過某個特殊的函式去獲得,接著將 GOT 的位移與之相加。這兩個步驟在 x64 上都不需要,因為 linker 知道相對 GOT 的位移,並且能夠輕易地以 RIP-relative 定址模式編碼。
當呼叫一個函式時,就不必要像 x86 那樣,在 ebx 中準備 GOT 的位址來實現 trampoline 了。因為 trmapoline 可以透過 RIP-relative 定址來直接存取 GOT entry。
在 x64 上,與非 PIC 程式相比,PIC 依然需要額外的指令,但代價變小了。間接多使用一個暫存器來作為 GOT 的指標也不再需要了(在 x86 中非常痛苦才能作到這點),因為對於 RIP-relative 定址來說,不需要此類暫存器[3]。總而言之,x64 PIC 將 x86 上的效能損失減少了許多,使得這種作法更具吸引力了。它是如此有吸引力,變成此架構下的共享程式庫的預設建構方式。
額外紅利:在 x64 上的非 PIC 程式
gcc 不只鼓勵你在 x64 上建構共享庫時使用 PIC ,它也將其設定為預設行為。舉例來說,如果我們讓第一個例子編譯時不加入-fpic[4],然後將其連結成共享庫(透過 -shared),我們會發現 linker 在報錯,大概像這樣:
/usr/bin/ld: ml_nopic_dataonly.o: relocation R_X86_64_PC32 against symbol `myglob' can not be used when making a shared object; recompile with -fPIC
/usr/bin/ld: final link failed: Bad value
collect2: ld returned 1 exit status
0000000000000000 <ml_func>:
0: 55 push rbp
1: 48 89 e5 mov rbp,rsp
4: 89 7d fc mov DWORD PTR [rbp-0x4],edi
7: 89 75 f8 mov DWORD PTR [rbp-0x8],esi
a: 8b 05 00 00 00 00 mov eax,DWORD PTR [rip+0x0]
10: 03 45 fc add eax,DWORD PTR [rbp-0x4]
13: 03 45 f8 add eax,DWORD PTR [rbp-0x8]
16: c9 leave
17: c3 ret
$ readelf -r ml_nopic_dataonly.o
Relocation section '.rela.text' at offset 0xb38 contains 1 entries:
Offset Info Type Sym. Value Sym. Name + Addend
00000000000c 000f00000002 R_X86_64_PC32 0000000000000000 myglob - 4
[...]
我們有辦法在 x64 上產出非 PIC 的程式嗎?是的!我們必須指導編譯器使用 "large code model"。透過 -mcmodel=large 旗標。code model 的主題很有趣,但是解釋這件事會讓我們的討論離此篇文章的目標太遠[6]。所以我只會簡單的說:code model 是一種程式設計師與編譯器之間的協議,程式設計師作出某種承諾,讓編譯器知道程式將會使用何種程度的位移。作為回報,編譯器就可以產出更好的代碼。
結果就是,要在 x64 上讓編譯器產出可以使 linker 開心的非 PIC 代碼,只有 large code model 是合適的,因為它是限制最少的。記住我如何解釋簡單的重定位為何無法滿足 x64,因為 linker 擔心在連結時無法獲得高於 32 位元的位址?嗯,large code model 基本上放棄所有的假設,並且對所有的資料取用採用最大的 64 位元位移。這讓 load-time relocation 總是安全的,並且可在 x64 上產生非 PIC 程式。讓我們看看第一個採用 -mcmodel=large 並且沒有 -fpic 的反組譯例子:
0000000000000000 <ml_func>:
0: 55 push rbp
1: 48 89 e5 mov rbp,rsp
4: 89 7d fc mov DWORD PTR [rbp-0x4],edi
7: 89 75 f8 mov DWORD PTR [rbp-0x8],esi
a: 48 b8 00 00 00 00 00 mov rax,0x0
11: 00 00 00
14: 8b 00 mov eax,DWORD PTR [rax]
16: 03 45 fc add eax,DWORD PTR [rbp-0x4]
19: 03 45 f8 add eax,DWORD PTR [rbp-0x8]
1c: c9 leave
1d: c3 ret
在位址 0xa 的指令將 myglob 的位址放進 eax。注意,它的值是 0,也就是預期一個重定位。同時也要注意,這是一個 64 位元的位址參數。並且,此參數是絕對的,不是 RIP-relative[7]。再注意,需要兩個指令才能讓 myglob 的值放進 eax 。這是為何 large code model 會比較沒有效率的一個原因。
現在來看看重定位:
$ readelf -r ml_nopic_dataonly.o
Relocation section '.rela.text' at offset 0xb40 contains 1 entries:
Offset Info Type Sym. Value Sym. Name + Addend
00000000000c 000f00000001 R_X86_64_64 0000000000000000 myglob + 0
[...]
一些批判性的思考或許會令你懷疑,為何編譯器不將 load-time 重定位的代碼產生方式以合適的方式作為預設值。這個問題的答案很簡單。別忘了,這段代碼同時也有可能是要直接連結進一個執行檔,也就完全不要求 load-time 重定位。因此,編譯器預設會以 small code model 作為預設方式以產生較有效率的代碼。如果你知道你的代碼會進入到共享庫,並且你不想要 PIC ,那就明確告知使用 large code model 。我認為 gcc 此處的行為頗有道理的。
另一件值得思考的事,為何 small code model 對於 PIC 代碼不會有問題。理由是, 當代碼要取用符號時,GOT 總是在同一個共享庫中,所以除非單一共享庫大到超過 32 位元的位址空間,不然對於 PIC 採用 32 位元的 RIP-relative offsets就不會有問題。這樣的超大共享庫是不太可能的,但是萬一你在這樣的例外中,AMD64 ABI 有一個 "large PIC code model" 作為解決方案。
結論
[1] 如同過去,我使用 x64 作為x86-64, AMD64 或 Intel 64 的方便的簡稱。
[2] 使用 eax 而不是 rax ,因為 myglob 的型別是 int ,在 x64 上還是 32 位元。
[3] 對了,在 x64 上,對於暫存器的使用也比較不那麼痛苦,因為有比 x86 多兩倍的通用暫存器。
[4] 也同時會發生在我們透過 -fno-pic 明確告知 gcc 我們不需要 PIC。
[5] 注意,不同於此篇與過往文章的反組譯例子,這是一個 object file,不是共享庫或執行檔。所以會帶有一些給 linker 的重定位。
[6] 看看 AMD64 ABI,那邊有一些針對此議題的有用資訊,然後也 man gcc。
[7] 一些組譯器將這個指令稱作 movabs ,用來區分其他的接受相對參數的 mov 指令。然而,在英特爾手冊中,依然使用 mov 作為命名。它的 opcode 形式為 REX.W + B8 +rd 。
留言
張貼留言