2017年4月15日 星期六

GNU indirect function 的運作機制

有時候為了提高程式的效能,對於同一個函式,根據執行時的環境,我們在實作上可能會有兩種以上的選擇,在傳統的方法中,我們可能會選擇使用函式指標來做這件事,然而,管理這些函式指標通常相當麻煩,尤其是當這個函式還要提供給其他函式庫使用的時候。但在 GNU/Linux 上,我們可以在某些情況,使用另一個更方便的方法——GNU 間接函式(GNU indirect function)

此外,GNU 間接函式也是 GCC 的函式多版本化(function multiversioning)機制的實作基礎,如果想要了解函式多版本化的運作方式,提前弄清楚 GNU 間接函式也是必要的。

在本文中,會以 x86-64 為例,解釋 GNU 間接函式的運作機制。

注意

  • 到目前為止,這個方法仍然有些不穩定,有一些在目前的實作下仍未徹底解決的問題,導致在實際的使用上有許多隱藏的限制。

  • 這篇文章在討論一些實作細節的時候,會假定讀者已然了解在呼叫函式時,跟 GOT 與 PLT 有關的一些重定位(relocation)機制,不熟悉的讀者可以讀一下文章 1文章 2文章 3 所構成的系列文章。

傳統方式

現在,讓我們想像一下,假如我們要寫一個程式,其中有一個函式 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

  1. 避免死鎖(deadlock):
    要避免 ifunc 的解析器函式直接或間接呼叫其它 ifunc 函式所構成的循環,這最終會導致死鎖的產生。

  2. 小心重定位項目處理的順序:
    由於在目前的 ABI 中並沒有明確的規定 STT_GNU_IFUNCR_X86_64_IRELATIVE 相對於其它重定位項目的處理順序,甚至還有 LD_PRELOADLD_BIND_NOW 等可能改變處理順序的載入參數,因此,我們不能保證程式會在何時呼叫解析器函式。

    所以,在解析器函式中,在呼叫其它函式或存取變數時要特別小心(尤其是外部函式和變數),最好是只呼叫靜態函式(包括那些函式再呼叫的函式)。

  3. 小心初始化函式:
    與上一點類似,我們不能保證 ifunc 函式的相關重定位的處理時機會跟 PLT 的惰性繫結(lazy binding)相同,然而有些函式或變數(例如 GNU C 函式庫的函式 getenv())實際上需要依賴於一些初始化函式,來初始化關鍵的資料結構,如果 ifunc 函式的解析器呼叫了這種函式,而 ifunc 函式的重定位卻在執行初始化函式之前進行,那就會出現問題。

    此外,有一些 GCC 的編譯選項會隱性的依賴初始化函式來實現某些機制,例如 stack protector 就需要依賴 TLS(thread local storage),這可能會導致潛藏的問題,在使用時要多加考量。

結語

為了能最佳化程式的效能,有時候我們會需要使用一些特殊的實作方式,GNU 間接函式就是其中的一種,本文介紹的 ifunc 機制,是最直接使用這種方式的方法,藉由剖析這種方法,我們得以了解 STT_GNU_IFUNCR_*_IRELATIVE 的運作過程。

如果只是要根據硬體的類別提供不同的實作方式,後來 GCC 提供了更容易使用的函式多版本化(function multiversioning)機制,而這個機制實際上也是基於 GNU 間接函式實作的,如果想要了解函式多版本化,本文的內容也能為你打下重要的基礎。

本文以 StackEdit 撰寫。

附註:


  1. 我希望能用最少的程式碼來解釋 GNU 間接函式,為了方便之後的對照與解說,這裡將它實做在函式庫裡面,甚至連測試用的函式也是如此,整個實作顯得有些「彆扭」;但我相信在讀完整篇文章後,讀者會有對於一般情況自行解讀的能力。
  2. 關於 GNU 為了 GNU 間接函式所做的更新,實際上都經過了多次的提交(commit)才完成:

    首先,GNU C 函式庫從 2.10 開始,直到 2.11 左右才有一個比較完整的支援,我們從 2.11 的提交紀錄往回查,可以查到這些相關的提交資料。

    而 GNU Binutils 則是在 2.20 完成相關的支援,我們從 2.20 的提交紀錄往回查,可以查到這些相關的提交資料。
  3. 關於這部分,我並沒有找到確切的更新紀錄,但我在 GCC 4.6 的官方手冊發現了關於 ifunc 機制的描述
  4. 實際上,由於多個版本以來,GNU 對間接函式做了許多修正,所以如果只是要做實驗,不考慮在舊環境的執行情況,那使用比較新的版本會比較好。本文的所有例子所使用的環境是,Glibc 2.24、GNU Binutils 2.28 和 GCC 6.3
  5. 事實上,由於 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 機制仍是最保險的選擇。
  6. 實際上,解析器的參數在不同的硬體平台上略有不同,但起碼目前在 x86-32 和 x86-64 上的實作是沒有參數。
  7. 與 x86 相關各種的 System V processor supplement ABI(SysV psABI)可以在這裡找到。
  8. 事實上,GOT 機制並非一定要與 PLT 機制配合使用,如果在編譯時使用了選項 -fno-plt,編譯出來的二進位檔案就不會使用 PLT 機制,而是直接採用間接定址的方式,來使用 GOT 裡面的位址,這一點,對於 GNU 間接函式也是適用的。只不過,為了節省文章的篇幅,這裡就不討論實際範例了。
  9. 如果想進一步了解有關 ifunc 的實作限制,可以參考 glibc wiki 的這篇文章

2 則留言 :