跳到主要內容

中文試譯:Position Independent Code (PIC) in shared libraries on x64

原文作者: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 會比較沒有效率的原因。

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;
}
讓我們看看 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 中的 entry ,將 myglobal 的位址放進 rax 。我們可以看到,它使用了 RIP-relative 定址。由於是相對於下一個指令的相對位址,我們實際上是拿到 0x5fd + 0x2009db = 0x200fd8 。所以存有 myglob 的 GOT entry 的位址是 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 在第三個 entry。我們也可以查看為 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 的最終位址決議後,要將其放進 0x200fd8。所以 myglob 的位址是如何獲得的就很清楚了。在反組譯中下一道指令(0x5fd)接著透過該位址去獲取 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>
 [... 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
發生了什麼事?我們看看 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 的指令。它預期 linker 會以重定位實際 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
[...]
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 的反組譯例子:

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
[...]
注意到重定位的形式變為 R_X86_64_64 ,是一種有著 64 位元的絕對重定位。這就能夠令 linker 接受,它現在會愉悅地同意將 object file 連結成共享庫。

一些批判性的思考或許會令你懷疑,為何編譯器不將 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" 作為解決方案。

結論
透過展示 x64 上 PIC 如何運作,這篇文章補充了之前的說明。這個架構下有新的定址模式來幫助 讓 PIC 代碼更有效率,因此使得這種作法會比在 x86 下更適合作為共享庫的方式,因為 x86 的 PIC 會需要較高的執行代價。既然 x64 目前是在伺服器、桌上機以及筆電上最流行的架構,這變成了重要須知。因此,我試著聚焦在不同的的共享庫建構方式,比如說非 PIC 代碼。如果你對於未來想探索的方向有任何疑問以及(或)建議,請透過回應或 email 讓我知道。

http://eli.thegreenplace.net/wp-content/uploads/hline.jpg
[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 。 

留言

這個網誌中的熱門文章

淺讀Linux root file system初始化流程

在Unix的世界中,file system佔據一個極重要的抽象化地位。其中,/ 所代表的rootfs更是所有後續新增file system所必須依賴前提條件。以Linux為例,黑客 Jserv 就曾經詳細說明過 initramfs的背後設計考量 。本篇文章不再重複背景知識,主要將追蹤rootfs初始化的流程作點整理,免得自己日後忘記。 :-) file system與特定CPU架構無關,所以我觀察的起點從init/main.c的start_kernel()開始,這是Linux作完基本CPU初始化後首先跳進的C function(我閱讀的版本為 3.12 )。跟root file system有關的流程羅列如下: start_kernel()         -> vfs_caches_init_early()         -> vfs_caches_init()                 -> mnt_init()                         -> init_rootfs()                         -> init_mount_tree()         -> rest_init()                 -> kernel_thread(kernel_init,...) 其中比較重要的是mnt_int()中的init_rootfs()與init_mout_tree()。init_rootfs()實作如下: int __init init_rootfs(void) {         int err = register_filesystem(&rootfs_fs_type);         if (err)                 return err;         if (IS_ENABLED(CONFIG_TMPFS) && !saved_root_name[0] &&                 (!root_fs_names || strstr(root_fs_names, "tmpfs"))) {          

誰在呼叫我?不同的backtrace實作說明好文章

今天下班前一個同事問到:如何在Linux kernel的function中主動印出backtrace以方便除錯? 寫過kernel module的人都知道,基本上就是用dump_stack()之類的function就可以作到了。但是dump_stack()的功能是如何作到的呢?概念上其實並不難,慣用手法就是先觀察stack在function call時的變化(一般OS或計組教科書都有很好的說明,如果不想翻書,可以參考 這篇 ),然後將對應的return address一層一層找出來後,再將對應的function名稱印出即可(透過執行檔中的section去讀取函式名稱即可,所以要將KALLSYM選項打開)。在userspace的實作可參考Jserv介紹過的 whocallme 或對岸好手實作過的 backtrace() ,都是針對x86架構的很好說明文章。 不過從前面兩篇文章可以知道,只要知道編譯器的calling convention,就可以實作出backtrace,所以是否GCC有提供現成的機制呢?Yes, that is what __builtin_return_address() for!! 可以參考這篇 文章 。該篇文章還提到了其他可以拿來實作功能更齊全的backtrace的 程式庫 ,在了解了運作原理後,用那些東西還蠻方便的。 OK,那Linux kernel是怎麼做的呢?就是用頭兩篇文章的方式啦~ 每個不同的CPU架構各自手工實作一份dump_stack()。 為啥不用GCC的機制?畢竟...嗯,我猜想,除了backtrace以外,開發者還會想看其他register的值,還有一些有的沒的,所以光是GCC提供的介面是很難印出全部所要的資訊,與其用半套GCC的機制,不如全都自己來~ arm的實作 大致上長這樣,可以看到基本上就只是透過迭代fp, lr, pc來完成: 352 void unwind_backtrace (struct pt_regs * regs , struct task_struct *tsk) 353 { 354 struct stackframe frame ; 355 register unsigned long current_sp asm ( "

kernel panic之後怎麼辦?

今天同事在處理一個陌生的模組時遇到kernel panic,Linux印出了backtrace,同事大致上可以知道是在哪個function中,但該function的長度頗長,短時間無法定位在哪個位置,在這種情況下,要如何收斂除錯範圍呢?更糟的是,由於加入printk會改變模組行為,所以printk基本上無法拿來檢查參數的值是否正常。 一般這樣的問題會backtrace的資訊來著手。從這個資訊我們可以知道在function中的多少offset發生錯誤,以x86為例(從 LDD3 借來的例子): Unable to handle kernel NULL pointer dereference at virtual address 00000000 printing eip: d083a064 Oops: 0002 [#1] SMP CPU:    0 EIP:    0060:[<d083a064>]    Not tainted EFLAGS: 00010246   (2.6.6) EIP is at faulty_write+0x4/0x10 [faulty] eax: 00000000   ebx: 00000000   ecx: 00000000   edx: 00000000 esi: cf8b2460   edi: cf8b2480   ebp: 00000005   esp: c31c5f74 ds: 007b   es: 007b   ss: 0068 Process bash (pid: 2086, threadinfo=c31c4000 task=cfa0a6c0) Stack: c0150558 cf8b2460 080e9408 00000005 cf8b2480 00000000 cf8b2460 cf8b2460        fffffff7 080e9408 c31c4000 c0150682 cf8b2460 080e9408 00000005 cf8b2480        00000000 00000001 00000005 c0103f8f 00000001 080e9408 00000005 00000005 Call Trace:  [<c0150558>] vfs