有時候為了提高程式的效能,對於同一個函式,根據執行時的環境,我們在實作上可能會有兩種以上的選擇,在傳統的方法中,我們可能會選擇使用函式指標來做這件事,然而,管理這些函式指標通常相當麻煩,尤其是當這個函式還要提供給其他函式庫使用的時候。但在 GNU/Linux 上,我們可以在某些情況,使用另一個更方便的方法——GNU 間接函式(GNU indirect function)。
此外,GNU 間接函式也是 GCC 的函式多版本化(function multiversioning)機制的實作基礎,如果想要了解函式多版本化的運作方式,提前弄清楚 GNU 間接函式也是必要的。
在本文中,會以 x86-64 為例,解釋 GNU 間接函式的運作機制。
注意:
傳統方式
現在,讓我們想像一下,假如我們要寫一個程式,其中有一個函式 myfunc
,我們希望它能根據執行時是否具有 root 的身分(也就是 effective uid == 0
)來決定執行的內容,為了這個目的,我們可能會寫出以下的程式碼1:
// tran_method.c
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
static void myfunc_1 (int var){
printf("myfunc_1 is called.\n");
}
static void myfunc_2 (int var){
printf("myfunc_2 is called.\n");
}
void myfunc (int var){
static void (*fun_ptr) (int) = NULL;
if (fun_ptr == NULL)
fun_ptr = (geteuid() > 0? myfunc_1 : myfunc_2);
fun_ptr(var);
}
void test_myfunc(){
myfunc(1);
}
當然,為了這個函式庫,我們還需要一個程式來驅動它:
// driver.c
void test_myfunc();
void main(){
test_myfunc();
}
接著,讓我們來試試看:
$ gcc -shared -fpic tran_method.c -o tran_method.so
$ gcc driver.c tran_method.so -o driver
$ LD_LIBRARY_PATH=. ./driver
myfunc_1 is called
$ sudo LD_LIBRARY_PATH=. ./driver
myfunc_2 is called
在這個例子裡面可以發現,實際上每一次要呼叫 myfunc
的時候,中間都會多一個額外的函式呼叫 fun_ptr()
,那麼,有沒有更快的實作方法呢?有,那就是 GNU 間接函式。
關於 GNU 間接函式
隨著時間的流逝,在同一種的 CPU 家族中(例如:x86),為了讓程式碼能更夠執行的越來越快,新的硬體指令會不斷的推陳出新。然而,對於程式撰寫者來說,為了避免讓程式在舊 CPU 上無法執行,只好避免在產生的指令碼中使用新的指令,這樣的做法固然有其道理,但也讓程式無法發揮出新 CPU 應有的效能。
為了解決這樣的現象,從 2009 年起,GNU C 函式庫(glibc,其中包含了動態連結器)開始在 ELF 格式裡面支援一些新的型態,包括了新的符號類型 STT_GNU_IFUNC
,以及新的重定位類型 R_X86_64_IRELATIVE
(如果是 x86,那就是 R_386_IRELATIVE
,其他硬體平台不在討論範圍);同時,GNU Binutils(包含連結器 ld
和組譯器 as
)也對此進行了支援2。
在 GNU 間接函式的設計中,符號類型 STT_GNU_IFUNC
與符號類型 STT_FUNC
類似,除了一點,就是在使用 STT_GNU_IFUNC
做符號繫結(symbol binding)時,會把 STT_GNU_IFUNC
的符號所指向的位址當成函式,並把呼叫它的結果,用來做為繫結的目標;而重定位類型 R_X86_64_IRELATIVE
的處理也是相似的情況。
當然,在這之後,編譯器 GCC 也在版本 4.6 左右增加了 ifunc
機制3,來實現對 GNU 間接函式的直接支援,並在版本 4.8 提供了內建函式 __builtin_cpu_is()
和 __builtin_cpu_supports()
,來協助程式撰寫者判斷 CPU 的類型。
使用 GNU 間接函式
要使用 GNU 間接函式,首先,必須確認你的開發與執行環境都是基於 GNU 的工具和函式庫4:
- 執行環境方面,需要 GNU C 函式庫,版本要在 glibc 2.11.1 以上。
- 開發環境方面,則需要 GNU Binutils,版本要在 2.20.1 以上,以及 4.6 以上的 GCC 編譯器5。
在 GNU 間接函式的運作機制中,為了目標函式能在函式執行時能動態的選擇需要的實作,我們必須提供一個函式用來決定要使用的函式,供執行時呼叫,為了方便起見,我們稱呼這個函式為解析器。編譯器 GCC 提供了函式屬性(function attribute)ifunc
來指定目標函式的解析器。新的程式碼如下,我們會把它編譯成函式庫 ifunc_method.so
:
// ifunc_method.c
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
static void myfunc_1 (int var){
printf("myfunc_1 is called\n");
}
static void myfunc_2 (int var){
printf("myfunc_2 is called\n");
}
void myfunc (int) __attribute__((ifunc ("resolver")));
static void (*resolver(void))(int){
return (geteuid() > 0? myfunc_1 : myfunc_2);
}
void test_myfunc(){
myfunc(1);
}
為了簡潔起見,我不再列出執行的結果(反正都一樣)。現在,讓我們來看一下這種實作方式:
myfunc
的實作不見了,我們只是單純定義它由解析器函式resolver
來決定它在執行時使用的實作。- 函式
resolver
做為一個解析器,它不接受任何參數6,而回傳值是一個函式指標,型態與myfunc
相同。
在程式執行時,程式會在適當時間點執行解析器(在不同版本的執行環境中時間點可能會不同),決定選用的函式,並以此做為目標函式 myfunc
符號繫結的對象。另外,要提醒一下,如果要在其他檔案或程式呼叫 myfunc
,所需要的函式宣告跟正常情況一樣:
extern void myfunc(int);
實作細節——有符號介入的情況
接下來,我們來看看 GNU 間接函式實際上的實作細節,這部分需要有一些對於重定位機制的認識,一些詳細的定義,讀者可以參考一下 ABI 中的定義7。首先,讓我們先來看看反組譯的結果:
$ objdump -d ifunc_method.so
ifunc_method.so: 檔案格式 elf64-x86-64
[略過一些輸出]
00000000000005e0 <myfunc@plt>:
5e0: ff 25 3a 0a 20 00 jmpq *0x200a3a(%rip) # 201020
5e6: 68 01 00 00 00 pushq $0x1
5eb: e9 d0 ff ff ff jmpq 5c0 <.plt>
[略過一些輸出]
0000000000000744 <resolver>:
744: 55 push %rbp
745: 48 89 e5 mov %rsp,%rbp
748: e8 a3 fe ff ff callq 5f0 <geteuid@plt>
74d: 85 c0 test %eax,%eax
74f: 74 09 je 75a <resolver+0x16>
751: 48 8d 05 b8 ff ff ff lea -0x48(%rip),%rax # 710 <myfunc_1>
758: eb 07 jmp 761 <resolver+0x1d>
75a: 48 8d 05 c9 ff ff ff lea -0x37(%rip),%rax # 72a <myfunc_2>
761: 5d pop %rbp
762: c3 retq
0000000000000763 <test_myfunc>:
763: 55 push %rbp
764: 48 89 e5 mov %rsp,%rbp
767: bf 01 00 00 00 mov $0x1,%edi
76c: e8 6f fe ff ff callq 5e0 <myfunc@plt>
771: 90 nop
772: 5d pop %rbp
773: c3 retq
我們可以看到,由於要顧慮到符號介入(symbol interposition)的緣故,在 0x76c
發起對 myfunc
的呼叫時,實際上呼叫的是 PLT 機制裡的程式碼片段(0x5e0
),而根據反組譯結果我們知道它會使用位在 0x201020
的 GOT 項目,接著,讓我們來看看與 0x201020
相關的重定位資料:
$ readelf -r ifunc_method.so
[略過一些輸出]
重定位區段 '.rela.plt' 位於偏移量 0x558 含有 3 個條目:
偏移量 資訊 類型 符號值 符號名稱 + 加數
000000201018 000200000007 R_X86_64_JUMP_SLO 0000000000000000 puts@GLIBC_2.2.5 + 0
000000201020 000b00000007 R_X86_64_JUMP_SLO myfunc() myfunc + 0
000000201028 000300000007 R_X86_64_JUMP_SLO 0000000000000000 geteuid@GLIBC_2.2.5 + 0
我們可以看到,在對 0x201020
做重定位時,使用的重訂為類型是 R_X86_64_JUMP_SLOT
,與一般的 GOT/PLT 機制相同,它會根據對 myfunc
的符號解析(symbol resolving)來做為重定位的結果。現在,由於我們的程式沒有遇到所謂的符號介入,那重定位就應該會根據 ifunc_method.so
本身所帶的動態符號表的 myfunc
來做重定位。所以讓我們來看看動態符號表:
$ readelf --dyn-syms ifunc_method.so
符號表「.dynsym」含有 15 個條目:
編號: 值 大小 類型 約束 版本 索引 名稱
0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND
1: 0000000000000000 0 NOTYPE WEAK DEFAULT UND _ITM_deregisterTMCloneTab
2: 0000000000000000 0 FUNC GLOBAL DEFAULT UND puts@GLIBC_2.2.5 (2)
3: 0000000000000000 0 FUNC GLOBAL DEFAULT UND geteuid@GLIBC_2.2.5 (2)
4: 0000000000000000 0 NOTYPE WEAK DEFAULT UND __gmon_start__
5: 0000000000000000 0 NOTYPE WEAK DEFAULT UND _Jv_RegisterClasses
6: 0000000000000000 0 NOTYPE WEAK DEFAULT UND _ITM_registerTMCloneTable
7: 0000000000000000 0 FUNC WEAK DEFAULT UND __cxa_finalize@GLIBC_2.2.5 (2)
8: 0000000000201038 0 NOTYPE GLOBAL DEFAULT 23 _edata
9: 0000000000201040 0 NOTYPE GLOBAL DEFAULT 24 _end
10: 0000000000000763 17 FUNC GLOBAL DEFAULT 12 test_myfunc
11: 0000000000000744 31 IFUNC GLOBAL DEFAULT 12 myfunc
12: 0000000000201038 0 NOTYPE GLOBAL DEFAULT 24 __bss_start
13: 00000000000005a0 0 FUNC GLOBAL DEFAULT 9 _init
14: 0000000000000774 0 FUNC GLOBAL DEFAULT 13 _fini
myfunc
是符號表中編號為 11 的條目,我們可以發現它的類型是 IFUNC
(也就是 STT_GNU_IFUNC
),在重定位的過程中,如果遇到了這類型的符號,就會將該符號的值視作函式位址,在呼叫該函式後,將回傳值視為符號要做繫結的位址。以這個例子 來說,重定位時會把 0x744
當成解析器函式的位址,讓我們回頭再看一次反組譯結果:
0000000000000744 <resolver>:
果然就是我們實作的解析器函式。
實作細節——沒有符號介入的情況
前面所討論的,是有符號介入的情況,所以理所當然利用 GOT/PLT 機制來運作,接下來,讓我們試試沒有符號介入的情況,要達到這個條件,最簡單的方法是把 myfunc
變成靜態函式(使用關鍵字 static
),告訴編譯器:「在這個檔案裡呼叫這個函式時,不用考慮符號介入」。我把重新改寫過的檔案編譯成了函式庫 ifunc_method_with_static.so
。
現在,在讓我們看一下反組譯的結果:
$ objdump -d ifunc_method_with_static.so
ifunc_method_with_static.so: 檔案格式 elf64-x86-64
[略過一些輸出]
00000000000005d0 <*ABS*+0x724@plt>:
5d0: ff 25 52 0a 20 00 jmpq *0x200a52(%rip) # 201028
5d6: 68 02 00 00 00 pushq $0x2
5db: e9 c0 ff ff ff jmpq 5a0 <.plt>
[略過一些輸出]
0000000000000724 <resolver>:
724: 55 push %rbp
725: 48 89 e5 mov %rsp,%rbp
728: e8 93 fe ff ff callq 5c0 <geteuid@plt>
72d: 85 c0 test %eax,%eax
72f: 74 09 je 73a <resolver+0x16>
731: 48 8d 05 b8 ff ff ff lea -0x48(%rip),%rax # 6f0 <myfunc_1>
738: eb 07 jmp 741 <resolver+0x1d>
73a: 48 8d 05 c9 ff ff ff lea -0x37(%rip),%rax # 70a <myfunc_2>
741: 5d pop %rbp
742: c3 retq
0000000000000743 <test_myfunc>:
743: 55 push %rbp
744: 48 89 e5 mov %rsp,%rbp
747: bf 01 00 00 00 mov $0x1,%edi
74c: e8 7f fe ff ff callq 5d0 <*ABS*+0x724@plt>
751: 90 nop
752: 5d pop %rbp
753: c3 retq
沒想到,在 0x74c
呼叫 myfunc
時,仍然與先前類似,呼叫的是 PLT 機制裡的程式碼片段(0x5d0
),而根據反組譯結果我們知道它會使用位在 0x201028
的 GOT 項目,接著,讓我們來看看與 0x201028
相關的重定位資料:
$ readelf -r ifunc_method_with_static.so
[略過一些輸出]
重定位區段 '.rela.plt' 位於偏移量 0x538 含有 3 個條目:
偏移量 資訊 類型 符號值 符號名稱 + 加數
000000201018 000200000007 R_X86_64_JUMP_SLO 0000000000000000 puts@GLIBC_2.2.5 + 0
000000201020 000300000007 R_X86_64_JUMP_SLO 0000000000000000 geteuid@GLIBC_2.2.5 + 0
000000201028 000000000025 R_X86_64_IRELATIV 724
這一次,我們可以看到使用的重定位型態是 R_X86_64_IRELATIVE
,這個重定位型態所對應的符號名稱,其實就是解析器函式的位址,程式會據此來進行跟先前符號型態 STT_GNU_IFUNC
類似的重定位。因此這裡的 R_X86_64_IRELATIVE
所對應的 0x724
,應該就是解析器的位址,讓我們再次回頭確認一下反組譯結果:
0000000000000724 <resolver>:
果然就是這樣!不過,按照這樣看起來,動態符號表應該不會再有 myfunc
的條目了吧,讓我們來確認一下:
$ readelf --dyn-syms ifunc_method_with_static.so
符號表「.dynsym」含有 14 個條目:
編號: 值 大小 類型 約束 版本 索引 名稱
0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND
1: 0000000000000000 0 NOTYPE WEAK DEFAULT UND _ITM_deregisterTMCloneTab
2: 0000000000000000 0 FUNC GLOBAL DEFAULT UND puts@GLIBC_2.2.5 (2)
3: 0000000000000000 0 FUNC GLOBAL DEFAULT UND geteuid@GLIBC_2.2.5 (2)
4: 0000000000000000 0 NOTYPE WEAK DEFAULT UND __gmon_start__
5: 0000000000000000 0 NOTYPE WEAK DEFAULT UND _Jv_RegisterClasses
6: 0000000000000000 0 NOTYPE WEAK DEFAULT UND _ITM_registerTMCloneTable
7: 0000000000000000 0 FUNC WEAK DEFAULT UND __cxa_finalize@GLIBC_2.2.5 (2)
8: 0000000000201038 0 NOTYPE GLOBAL DEFAULT 23 _edata
9: 0000000000201040 0 NOTYPE GLOBAL DEFAULT 24 _end
10: 0000000000000743 17 FUNC GLOBAL DEFAULT 12 test_myfunc
11: 0000000000201038 0 NOTYPE GLOBAL DEFAULT 24 __bss_start
12: 0000000000000580 0 FUNC GLOBAL DEFAULT 9 _init
13: 0000000000000754 0 FUNC GLOBAL DEFAULT 13 _fini
如同預期,動態符號表裡面沒有 myfunc
的條目了。
實作細節——總結
現在,讓我們來整理一下 x86-64 上 GNU 間接函式的一些實作方法,經過上面的範例,我們可以發現,由於必須在執行時由解析器函式來決定最終要使用的實作函式,因此在呼叫時,無論會不會受到符號介入的影響,都會利用 GOT 區段來配置一筆項目,用來放置解析器函式回傳的函式位址,之後再以類似 PLT 的機制來實作8。
而在進行重定位的時候,如果目標函式是一個公開函式,要考慮符號介入的可能性,那麼目標函式在檔案裡面就會以符號型態 STT_GNU_IFUNC
來等候使用;如果目標函式是一個靜態函式,不用考慮符號介入,那麼就會使用 R_X86_64_IRELATIVE
型態的重定位,來直接指定呼叫的解析器函式。
GNU 間接函式的限制
在目前最新的 GCC 6.3 手冊當中,對於 ifunc 使用上的限制,只有一條:
… the indirect function needs to be defined in the same translation unit as the resolver function
用本文的範例來看,代表解析器函式的實作(resolver
)跟 myfunc
的 ifunc 定義,也就是
void myfunc (int) __attribute__((ifunc ("resolver")));
這兩者必須處在同一次的轉譯單位之中(不要忘記,C 程式的程式碼是可以切割成多檔案分開編譯的)。
然而,由於 ifunc 機制的特殊性,目前的實作(包括 Glibc、Binutils、GCC)在使用上有一些其它的隱藏限制,儘管這些限制隨著實作的演進,可能會漸漸消失。但是就目前而言,小心避開這些情況仍是必要的,因為如果程式觸發了這些限制,通常並不會直接導致編譯錯誤,所以除錯會變得非常困難。
下面我整理了一些可能的限制9:
避免死鎖(deadlock):
要避免 ifunc 的解析器函式直接或間接呼叫其它 ifunc 函式所構成的循環,這最終會導致死鎖的產生。小心重定位項目處理的順序:
由於在目前的 ABI 中並沒有明確的規定STT_GNU_IFUNC
和R_X86_64_IRELATIVE
相對於其它重定位項目的處理順序,甚至還有LD_PRELOAD
和LD_BIND_NOW
等可能改變處理順序的載入參數,因此,我們不能保證程式會在何時呼叫解析器函式。所以,在解析器函式中,在呼叫其它函式或存取變數時要特別小心(尤其是外部函式和變數),最好是只呼叫靜態函式(包括那些函式再呼叫的函式)。
小心初始化函式:
與上一點類似,我們不能保證 ifunc 函式的相關重定位的處理時機會跟 PLT 的惰性繫結(lazy binding)相同,然而有些函式或變數(例如 GNU C 函式庫的函式getenv()
)實際上需要依賴於一些初始化函式,來初始化關鍵的資料結構,如果 ifunc 函式的解析器呼叫了這種函式,而 ifunc 函式的重定位卻在執行初始化函式之前進行,那就會出現問題。此外,有一些 GCC 的編譯選項會隱性的依賴初始化函式來實現某些機制,例如 stack protector 就需要依賴 TLS(thread local storage),這可能會導致潛藏的問題,在使用時要多加考量。
結語
為了能最佳化程式的效能,有時候我們會需要使用一些特殊的實作方式,GNU 間接函式就是其中的一種,本文介紹的 ifunc 機制,是最直接使用這種方式的方法,藉由剖析這種方法,我們得以了解 STT_GNU_IFUNC
和 R_*_IRELATIVE
的運作過程。
如果只是要根據硬體的類別提供不同的實作方式,後來 GCC 提供了更容易使用的函式多版本化(function multiversioning)機制,而這個機制實際上也是基於 GNU 間接函式實作的,如果想要了解函式多版本化,本文的內容也能為你打下重要的基礎。
本文以 StackEdit 撰寫。
附註:
- 我希望能用最少的程式碼來解釋 GNU 間接函式,為了方便之後的對照與解說,這裡將它實做在函式庫裡面,甚至連測試用的函式也是如此,整個實作顯得有些「彆扭」;但我相信在讀完整篇文章後,讀者會有對於一般情況自行解讀的能力。
↩ - 關於 GNU 為了 GNU 間接函式所做的更新,實際上都經過了多次的提交(commit)才完成:
首先,GNU C 函式庫從 2.10 開始,直到 2.11 左右才有一個比較完整的支援,我們從 2.11 的提交紀錄往回查,可以查到這些相關的提交資料。
而 GNU Binutils 則是在 2.20 完成相關的支援,我們從 2.20 的提交紀錄往回查,可以查到這些相關的提交資料。
↩ - 關於這部分,我並沒有找到確切的更新紀錄,但我在 GCC 4.6 的官方手冊發現了關於
ifunc
機制的描述。
↩ - 實際上,由於多個版本以來,GNU 對間接函式做了許多修正,所以如果只是要做實驗,不考慮在舊環境的執行情況,那使用比較新的版本會比較好。本文的所有例子所使用的環境是,Glibc 2.24、GNU Binutils 2.28 和 GCC 6.3
↩ - 事實上,由於 GNU 組譯器提供了對
STT_GNU_IFUNC
的支援,所以我們可以利用鑲嵌式組合語言來實現 GNU 間接函式,不見得非要使用 GCC 的ifunc
機制,如本文中的範例,我們可以參考這篇文章,改成用以下的程式碼完成一樣的事:
然而,由於實際上間接函式的機制還分成很多種情況,因此,使用// ifunc_asm_method.c #include <stdio.h> #include <unistd.h> #include <sys/types.h> static void myfunc_1 (int var){ printf("myfunc_1 is called\n"); } static void myfunc_2 (int var){ printf("myfunc_2 is called\n"); } // 宣告 myfunc 是一個 GNU indirect function void myfunc (int); __asm__(".type myfunc, %gnu_indirect_function"); static void (*resolver(void))(int) __asm__("myfunc"); static void (*resolver(void))(int){ return (geteuid() > 0? myfunc_1 : myfunc_2); } void test_myfunc(){ myfunc(1); }
ifunc
機制仍是最保險的選擇。
↩ - 實際上,解析器的參數在不同的硬體平台上略有不同,但起碼目前在 x86-32 和 x86-64 上的實作是沒有參數。
↩ - 與 x86 相關各種的 System V processor supplement ABI(SysV psABI)可以在這裡找到。
↩ - 事實上,GOT 機制並非一定要與 PLT 機制配合使用,如果在編譯時使用了選項
-fno-plt
,編譯出來的二進位檔案就不會使用 PLT 機制,而是直接採用間接定址的方式,來使用 GOT 裡面的位址,這一點,對於 GNU 間接函式也是適用的。只不過,為了節省文章的篇幅,這裡就不討論實際範例了。
↩ - 如果想進一步了解有關 ifunc 的實作限制,可以參考 glibc wiki 的這篇文章。
↩
很有用的文章,謝謝!
回覆刪除...
回覆刪除