2017年3月7日 星期二

[翻譯] x64 上共享函式庫裡的位址無關程式碼(PIC)

↓↓↓↓↓↓ 正文開始 ↓↓↓↓↓↓

前篇文章解釋了位址無關程式碼(position independent code,PIC)是如何運作的,並以 x86 架構下編譯的程式碼作為範例,我承諾過會在一篇分開的文章中涵蓋 x64〔1〕上的 PIC,所以我們才會在這裡。本文討論的細節會少很多,因為它假定對 PIC 理論上如何運作早已了解,一般來說,用於這兩個平台的構想是很相似的,只不過由於各個架構的獨特特性,所以某些細節會有差異。

RIP 相對定址(RIP-relative addressing)

在 x86 上,儘管函式的參照(用指令 call)使用相對於指令指標(instruction pointer,IP)的相對偏移值,但資料的參照(用指令 mov)只支援絕對位址1,正如前篇文章所見,這讓 PIC 程式碼有點沒效率,因為 PIC 天生需要讓所有偏移值變成是相對於 IP 的;絕對位址和位址無關性是搭不起來的。

伴隨著新的「RIP 相對定址模式」,x64 修正了這件事,該模式對於所有參照到記憶體的 64 位元 mov 指令來說是預設的(也適用於其他指令,如 lea)。一段來自《Intel Architecture Manual vol 2a》的引文提到2

一個新的定址格式,RIP 相對定址(相對於指令指標),實作在 64 位元模式裡,有效位址會藉由把位移值加到下個指令的 64 位元 RIP 上來形成。

用於 RIP 相對定址模式的位移值有著 32 位元的大小,由於它要在正的與負的偏移值下都能用,因此大約 +/- 2GB 左右就是這種定址模式所支援相對於 RIP 的最大偏移值。

x64 PIC 的資料參照——範例

為了方便比較,我將會使用前一篇文章中,做為資料參照範例的相同 C 原始碼:


int myglob = 42;

int ml_func(int a, int b)
{
    return myglob + a + b;
}

讓我們來看看 ml_func 的反組譯:


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

這裡最令人感興趣的指令在 0x5f6,它藉由參照 GOT 中的一筆項目,把 myglobal 的位址放進 rax,如同我們看到的,它用的是 RIP 相對定址,而由於它是相對於下個指令的位址的,所以實際上取得的是 0x5fd + 0x2009db = 0x200fd8,因此存有 myglob 位址的 GOT 項目就會在 0x200fd8,讓我們來確認說不說得通:


$ 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
[...]

GOT 從 0x200fc8 開始,所以 myglob 是其中的第三筆項目,而我們也可以看到為了讓 GOT 參照到 myglob 而安插的重定位項目:


$ 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
[...]

確切地說,用於 0x200fd8 的一筆重定位項目告訴動態連結器,一旦知曉 myglob 的最終位址,就把該符號的位址放進去。

那麼,myglob 的位址在程式碼中如何取得就很清楚了,接著,反組譯之中的下個指令(位在 0x5fd)就會解參考(dereference)該位址,來把 myglob 的值放進 eax〔2〕

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>
 [... 刪剪其他程式碼 ...]

跟之前一樣,該呼叫是對著 ml_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>

因此,持有 ml_util_func 實際位址的 GOT 項目就是在 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

效能影響

在兩個範例中,都能看到 PIC 在 x64 上比 x86 上需要比較少的指令,在 x86 上,GOT 位址會以兩個步驟載入到某個做為基底的暫存器(按慣例是 ebx)——首先以特殊函式取得指令的位址,然後再把與 GOT 相距的偏移值加上去;然而,這兩個步驟在 x64 上都不需要,因為與 GOT 相距的偏移值對連結器來說是已知的,而且還可以利用 RIP 相對定址,將偏移值簡便地編碼進指令本身。

在呼叫函式的當下,也不再需要像 x86 的程式碼所做的,為了該跳板(trampoline)而提前在 ebx 裡準備 GOT 位址,因為跳板會透過 RIP 相對定址直接存取它的 GOT 項目。

即便相較於非 PIC 的程式碼,x64 上的 PIC 仍然需要額外的指令,但其額外代價已經比較小了;試圖少用一個暫存器以做為 GOT 指標的間接導向,並因此造成的花費(x86 上這令人相當痛苦)也不見了,這是因為 RIP 相對定址並不需這樣的暫存器來配合〔3〕,總而言之,x64 PIC 帶來了比 x86 小很多的效能影響,讓它更加的吸引人。事實上,x64 PIC 是如此地引人注目,讓它成為這個架構撰寫共享函式庫的預設方法。

加分題:x64 上的非 PIC 程式碼

在 x64 上,不僅是 gcc 鼓勵在共享函式庫上使用 PIC,而是預設就需要,舉例來說,假如在編譯第一個範例時不帶上 -fpic〔4〕,然後嘗試把它連結成共享函式庫(用 -shared),就會從連結器那得到錯誤訊息,大概像這樣:


/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

這是怎麼回事?來看看 ml_nopic_dataonly.o 的反組譯結果〔5〕


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

注意一下這裡是怎麼存取 myglob 的,是在位址 0xa 處的指令,它預期連結器會在處理一筆重定位的過程中,把 myglob 的實際位址修補進該指令的運算元(所以不需要 GOT 的間接導向):


$ 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
[...]

這就是連結器抱怨的 R_X86_64_PC32 重定位項目,就是因為它無法將帶有這種重定位的目的檔連結進共享函式庫。為什麼呢?是因為該 mov 的位移值(就是要加上 rip 的那部分)必須相合於 32 位元,但在一段程式碼放進共享函式庫的時候,我們不能預先知道 32 位元夠不夠用,畢竟,這是一個完全 64 位元的架構,有著巨大的位址空間,而該符號也許最終會在某個跟進行參照處的距離,比 32 位元所能允許的參照距離還要遠的共享函式庫中找到,這會使 R_X86_64_PC32 的結果變成 x64 上共享函式庫的無效重定位。

不過,我們可不可以在 x64 上用某種方式仍舊產生非 PIC 的程式碼呢?可以的!我們應該透過增加旗標 -mcmodel=large,來指示編譯器使用「大型程式碼模型(large code model)」。關於程式碼模型(code model)的議題相當有趣,但要解釋會讓我們偏離這篇文章的真正目標太遠〔6〕,所以我就簡單說一下,程式碼模型是一種在程式設計師和編譯器之間的協議,相當於程式設計師對關於程式會用到的偏移值大小,向編譯器做出的確切承諾;做為交換,編譯器便能產生更好的程式碼。

結果證明,那個讓編譯器在 x64 上產生非 PIC 程式碼,而實際上是要滿足連結器的方法,只有使用大型程式碼模型才會是恰當的,因為該模型最沒有限制,還記得我是如何解釋為何單純的重定位在 x64 上並不夠合適,在連結時要擔憂偏移值超出 32 位元的嗎?嗯,大型程式碼模型則基本上拋棄了所有偏移值的前提條件,對所有的資料參照都使用最大的 64 位元偏移值,這令載入期重定位永遠是安全的,並且讓 x64 上非 PIC 程式碼的生成變得可行。讓我們來瞧瞧第一個範例在沒有帶 -fpic 且帶著 -mcmodel=large 編譯時的反組譯:


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(譯註:應該是 rax),注意,此時它的引數是 0,這提醒我們預計會有一筆重定位,也要注意它有一個完全 64 位元的位址做引數,還有,該引數是絕對位址而不是 RIP 相對定址〔7〕,再注意一下,這裡為了把 myglob 的值放進 eax,實際上需要兩個指令,這就是為什麼大型程式碼模型比其他替代方案要來得沒效率的原因之一。

現在來看一下重定位項目:


$ 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
[...]

要注意到重定位的型態變成了 R_X86_64_64,是一種能擁有 64 位元值的絕對位址型重定位,而該重定位將為連結器所接受,很樂意地同意將這個目的檔連結到一個共享函式庫。

一些評判性的想法也許會讓你思索為什麼編譯器預設產生的程式碼,會是不適用於載入期重定位的,其答案是簡易性。別忘了,程式碼同時也是傾向於直接連結進可執行檔的,而可執行檔是完全不需要載入期重定位的,因此,編譯器預設會採用小型程式碼模型(small code model)以便生成最有效率的程式碼;如果你知道你的程式碼將會放進一個共享函式庫,而且不想要 PIC,那麼,就只要明確地告訴它使用大型程式碼模型,我認為此處 gcc 的行為是有其道理在的。

另一件要想想的事情,是為什麼使用小型程式碼模型的 PIC 程式碼不會出問題,原因是 GOT 總是與參考到它的程式碼落在同一個共享函式庫,除非單一個共享函式庫就夠大到 32 位元的位址空間,不然以 32 位元 RIP 相對定址的偏移值來定位 PIC 就不會有問題;而那麼巨大的共享函式庫是不大可能的,但萬一你遇到了,AMD64 ABI 有個「大型 PIC 程式碼模型」就是為此而生。

結語

這篇文章藉由展示 PIC 在 x64 架構的運作方式,補足了它的前篇文章,此架構有一種新的定址模式,能幫助 PIC 程式碼變得更快,因而使 PIC 對共享函式庫來說,比在代價較高的 x86 上,更令人滿意。由於 x64 是目前用於伺服器、桌上型電腦和筆記型電腦上最普遍的架構,所以知道這些是很重要的,因此,我試著專注在編譯程式碼到共享函式庫的額外觀點上,例如非 PIC 程式碼。如果你有關於未來探索方向的任何問題和/或建議,請用評論或電子郵件讓我知道。


〔1〕
跟往常一樣,我用 x64 作為方便的簡寫,該架構通常以 x86-64、AMD64 或 Intel 64 為人所知。
〔2〕
放進 eax 而不是 rax,因為 myglob 的型態是 int,這在 x64 上仍是 32 位元。
〔3〕
順便一提,在 x64 上,綁定一個暫存器的作用是比較不「痛苦」的,因為它有 x86 兩倍多的 GPR(general purposes register,通用暫存器)。
〔4〕
這也會發生在藉由傳遞 -fno-picgcc,明確指定我們不想要 PIC 的時候。
〔5〕
注意,不像其他已經在本文與前文中見過的反組譯結果,這次是個目的檔(object file),而不是共享函式庫或可執行檔,因此它會含有給予連結器一些重定位項目。
〔6〕
關於這個主題的有益資訊,可以去看一下 AMD64 ABI,並且 man gcc

(譯註:原作者後來有篇文章講述這個主題。)
〔7〕
有些組譯器會叫這個指令 movabs,來區分它跟其他會接受相對位址引數的 `mov` 指令,然而,Intel 架構手冊維持命名,就只是 mov,它的運算代碼(opcode)格式為 REX.W + B8 + rd

(譯註:例如 GNU 組譯器(GAS)所使用的 AT&T 格式,就使用 movabs。)

↑↑↑↑↑↑ 正文結束 ↑↑↑↑↑↑

本文以 StackEdit 撰寫。

譯註:


  1. 更嚴謹地說,是在 x86 上,指令 mov 沒有辦法使用指令指標 eip 來做為位移定址(displacement addressing)的對象。
  2. 原作者所引用之原文如下:
    A new addressing form, RIP-relative (relative instruction-pointer) addressing, is implemented in 64-bit mode. An effective address is formed by adding displacement to the 64-bit RIP of the next instruction.

沒有留言 :

張貼留言