Try   HackMD

vDSO: 快速的 Linux 系統呼叫機制

資料整理: jserv

前言

vDSO 的全名是 "virtual dynamic shared object",乍看之下和 Linux 系統呼叫沒有直接關聯,但卻是 Linux 核心處理系統呼叫的特殊機制,並反映出微處理器和作業系統相互影響的歷程。Google 公司發展全新的作業系統 Fuchsia 時,還特意從 Linux 核心借鏡 vDSO 的設計和實作。

參見: Fuchsia 的文件 Zircon vDSO

若想探究 vDSO,就要從其前身 vsyscall 談起,中間涉及 x86/x86_64 的軟體中斷機制、sysenter 和 sysexit 指令、動態連結函式庫,及資訊安全等議題。

Linux 系統呼叫

早期 32 位元的 Intel x86 處理器指令集沒有系統呼叫的專用指令,作業系統透過在使用者模式觸發軟體中斷 (software interrupt) 來達成系統呼叫,此法稱為 call gate。在 Linux 核心中,對應到系統呼叫的軟體中斷號碼是 80H,即 int 0x80 這道指令。

此處所指的 call gate 為涉及到特權模式移轉的操作,可以讓使用者在特權等級較低的情況下,跳到較高的特權等級(通常意味著作業系統),而非指 x86 特有的機制 CALL FAR instruction

Linux 核心的系統呼叫運作示意如下圖:

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →

1997 年 Intel Pentium II 引入系統呼叫專用的指令 sysenter 和 sysexit 指令,AMD 稍後也提供對應的指令,但因 sysenter 的運作機制與 int 0x80 大相徑庭,Linux 核心開發者考慮到當時多款 x86 相容處理器的行為,尚未納入 sysenter 的考量。到了 2002 年,郵件論壇的一篇文章 Intel P6 vs P7 system call performance 引發大量討論,因為微架構的落差,實驗指出,當時 Pentium 4 透過 int 0x80 處理系統呼叫的成本,會比 Pentium III 慢許多倍,這樣的效能衝擊讓 Linux 核心開發者不得不思考新的機制。

延伸閱讀: Linux 核心設計: 賦予應用程式生命的系統呼叫

考慮到 Intel 和 AMD 處理器的指令行為,快速的系統呼叫指令選擇是:

  • 32 位元的處理器 (即 IA32): sysentersysexit
  • 64 位元的處理器 (即 x86_64): syscallsysret

不過考慮到相容模式 (compatibility mode) 後,讓系統呼叫的專用指令選擇上變得更複雜:x86_64 相容模式允許作業系統得以在不修改應用程式的前提,讓 32 位元的程式得以執行在 64 位元的作業系統,因此 Linux 核心必須在設計 32 位元程式運作方式時,就設想「一旦執行於 64 位元相容模式會如何」。這意味著,無論使用 sysentersyscall 指令,都會致使相容性疑慮,畢竟系統呼叫觸發的頻率相當高,若在執行期決定會降低效能。最初「更快的系統呼叫」需求反映在 2002 年釋出的 Linux v2.5.53,後者提出 vsyscall 這項新機制,"v" 表示 "virtual",將系統呼叫的行為進行包裝,原本在使用者層級用 int 0x80sysenter 的指令,就變成 call 指令 —— 就跟尋常的函式呼叫相同的指令一樣,但跳躍的地址由 Linux 核心特別指定,最終仍會執行到 sysentersyscall 指令。

以 32 位元的 x86 來說,定址 (addressing) 空間為 232=4GiB,預設的 Linux 核心組態會進行 3:1 的切割,也就是 3 GiB 的虛擬記憶體定址空間切割給使用者層級,1 GiB 的定址空間切割給 Linux 核心本身,這個分界線是 0xc0000000 (即 3 GiB)。核心定址空間的地址 0xffffe000 會保留一個 vsyscall 區域,並開放其讀取及執行權限給使用者層級,該頁面的內容是 Linux 核心在開機時,根據處理器類型所決定好的一段程式碼,用來觸發系統呼叫,且該程式碼在 vsyscall 區域中的實際位置,會透過 auxiliary vector 的 AT_SYSINFO 項目,提供給應用程式,因此每當要觸發系統呼叫時,程式只要呼叫該段程式即可。GNU C 函式庫 (簡稱 glibc) 自 2003 年釋出的 glibc-2.3.2 開始配合這套機制。

以下是示範用的程式碼: (檔名 syscall32.c)

#include <elf.h> #include <stdlib.h> int main(int argc, char *argv[], char *envp[]) { unsigned int syscall_nr = 1; int exit_status = 42; Elf32_auxv_t *auxv; /* auxilliary vectors are located after the end of the environment * variables. */ while (*envp++) ; /* envp is now pointed at the auxilliary vectors, since we've iterated * through the environment variables. */ for (auxv = (Elf32_auxv_t *) envp; auxv->a_type != AT_NULL; auxv++) { if (auxv->a_type == AT_SYSINFO) break; } asm("movl %0, %%eax \n" "movl %1, %%ebx \n" "call *%2 \n" : /* output parameters, we are not outputting anything, no none */ /* (none) */ : /* input parameters mapped to %0 and %1, repsectively */ "m"(syscall_nr), "m"(exit_status), "m"(auxv->a_un.a_val) : /* registers that we are "clobbering", unneeded since we are calling exit */ "eax", "ebx"); }

這段程式碼將執行 exit 系統呼叫,並傳入 42 作為 exit code。

42 這數字是向《銀河便車指南》(The Hitchhiker’s Guide to the Galaxy) 致敬,後者提到一群超空間的智慧生物為了得到終極問題的解答,打造一部超級電腦 "Deep Thought"(深思),用 750 萬年的時間運算,終於得到答案:
生命、宇宙、萬事萬物的終極答案是:42

留意到第 24 行內嵌式組合語言敘述的 call 指令,其跳躍的地址就取決於 auxiliary vector。在 GNU/Linux x86_64 環境中,編譯和執行上述程式碼:

$ gcc -m32 -g -o syscall32 syscall32.c 
$ ./syscall32
$ echo $?
42

echo $? 可取得 exit code,在 syscall32 程式執行後,符合預期地留下 42 這數值。注意 -m32 表示這段程式碼是針對 32 位元編譯。

接著我們可用 GDB 來觀察:

$ gdb -q ./syscall32

看到 (gdb) 命令提示後,執行以下命令:

(gdb) break 24
(gdb) run
(gdb) stepi
(gdb) stepi
(gdb) stepi
(gdb) stepi

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →
(gdb) run 開頭表示在 GDB 環境所執行的 run 命令,實際只要輸入 run 即可

預期會看到以下輸出:

(gdb) break 24
Breakpoint 1 at 0x1248: file syscall.c, line 24.
(gdb) run
Starting program: /tmp/syscall 

Breakpoint 1, main (argc=1, argv=0xffffd374, envp=0xffffd428) at syscall.c:24
24	    asm("movl %0,  %%eax    \n"
(gdb) stepi
0x5655624c	24	    asm("movl %0,  %%eax    \n"
(gdb) stepi
0x56556250	24	    asm("movl %0,  %%eax    \n"
(gdb) stepi
0x56556254	24	    asm("movl %0,  %%eax    \n"
(gdb) stepi
0xf7fd0b40 in __kernel_vsyscall ()

這個 __kernel_vsyscall 吸引我們的注意,其地址是 0xf7fd0b40,規範在 auxiliary vector。接著我們在 GDB 對該地址進行反組譯:

(gdb) disassemble __kernel_vsyscall
Dump of assembler code for function __kernel_vsyscall:
=> 0xf7fd0b40 <+0>:	push   %ecx
   0xf7fd0b41 <+1>:	push   %edx
   0xf7fd0b42 <+2>:	push   %ebp
   0xf7fd0b43 <+3>:	mov    %ecx,%ebp
   0xf7fd0b45 <+5>:	syscall 
   0xf7fd0b47 <+7>:	int    $0x80
   0xf7fd0b49 <+9>:	pop    %ebp
   0xf7fd0b4a <+10>:	pop    %edx
   0xf7fd0b4b <+11>:	pop    %ecx
   0xf7fd0b4c <+12>:	ret    
End of assembler dump.

不難發現 syscall 這道指令,至此我們可總結,vsyscall 對處理器的系統呼叫專用指令進行包裝,這樣就能針對處理器選出適當的指令,又免去額外的執行時期判斷。

vDSO

一開始設計的 vsyscall 頁面,僅包含二進位機械碼的記憶體區塊,然而,這樣的設計帶來隱憂:對於 Linux 上的除錯器來說,有時開發者會透過程式出錯時產生的核心傾印 (core dump) 檔案來重現出錯情況,但 vsyscall 頁面只是一段程式碼,不利於傾印到檔案之中,缺乏符號表一類的資訊。另外,vsyscall 映射到使用者層級的地址是固定不變的,容易被有心人士利用,致使核心的安全問題。

因此,在 2003 年釋出的 Linux v2.5.69 中,將 x86-32 vsyscall 頁面的內容,由一段程式碼,改變為 ELF 動態函式庫,稱為 vsyscall DSO (dynamic shared object)。編譯 Linux 核心時,會預先編譯出多個 DSO 檔案並嵌入至 Linux 核心中,當 Linux 系統啟動之際,核心將判斷處理器類型,選擇適當的 DSO 並複製到 vsyscall 頁面。於是,一旦應用程式執行,核心就會透過 auxiliary vector 的 AT_SYSINFO_EHDR 項目,將 DSO 的位址提供給應用程式。由於這機制相當於在記憶體中載入一個虛擬的 DSO,開發者將 vsyscall DSO 更換為更一般性的名稱,即 "virtual DSO",簡稱 vDSO

關於 ELF 執行檔和動態連結的資訊可見〈你所不知道的 C 語言:連結器和執行檔資訊〉。

動態連結機制確保 vDSO 每次所在的地址可以不同,降低有心人攻擊的風險。我們可藉由 ldd 命令觀察 vDSO:

$ ldd syscall

預期輸出如下:

	linux-gate.so.1 (0xf7f61000)
	libc.so.6 => /lib32/libc.so.6 (0xf7d4d000)
	/lib/ld-linux.so.2 (0xf7f62000)

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →
由於 Address space layout randomization (ASLR) 的存在,每次執行 ldd 命令,上面 3 個輸出的地址會有不同數值。

留意到 linux-gate.so.1,這由 Linux 核心提供,不需要在檔案系統中存在。稍早提到 Linux 核心會映射頁面到使用者層級,可用以下命令觀察:

$ cat /proc/self/maps | egrep "vdso|vsyscall"

參考執行輸出:

7ffe48937000-7ffe48938000 r-xp 00000000 00:00 0          [vdso]
ffffffffff600000-ffffffffff601000 --xp 00000000 00:00 0  [vsyscall]

vDSO 的存在不僅可解決系統呼叫專用指令的議題,還能加速在意效率的系統呼叫。

2007 年釋出的 Linux v2.6.23 支援高解析度計時器 (high-resolution timer),既然號稱高解析度,那本身的花費時間當然越低越好。Linux 核心開發者在實作 clock_gettime 對應的系統呼叫時,就運用 vDSO 機制,連同 gettimeofdaygetcpu 也是。這樣的改變當然需要 glibc 配合,因此緊隨其後發布的 glibc 2.7 也做對應的改變,開始支援 x86-64 vDSO

gettimeofday 為例,vDSO 的初始化示意如下:

出處: The vDSO on arm64 (2016)

解說:

  1. 核心內部的 ELF 載入器會映射 vDSO 的記憶體頁面並設定 auxiliary vector 中 AT_SYSINFO_EHDR,後者保存 vDSO 的基底地址;
  2. 動態連結器查詢 auxiliary vector 中 AT_SYSINFO_EHDR,若已設定,則會嘗試連結 vDSO;
  3. glibc (或者支援 vDSO 的 libc 實作) 在初始化時,會在 vDSO 中查詢 __vdso_gettimeofday 符號,並連結該符號到全域的表格中;

除了 gettimeofday,許多處理器架構的 vDSO 還包含 clock_getresrt_sigreturn 一類的系統呼叫,這些系統呼叫相對單純。針對 PowerPC 處理器的 Linux 核心提供額外 6 個 vDSO:

  • __kernel_datapage_offset
  • __kernel_get_syscall_map
  • __kernel_get_tbfreq
  • __kernel_sigtramp_rt64
  • __kernel_sync_dicache
  • __kernel_sync_dicache_p5

其中 sync_dicache 的作用是同步 instruction cache (簡稱 I$) 的操作,對於需要在執行時期產生機械碼並執行的程式 (JIT 編譯器),例如 Node.js,可帶來效能提升。

vdsotest 這工具可測量原本的系統呼叫和 vDSO 實作的執行成本。以 AMD Ryzen Threadripper 2990WX 處理器來說,參考執行結果如下: (部分)

getcpu: syscall: 108 nsec/call
getcpu:    libc: 21 nsec/call
getcpu:    vdso: 19 nsec/call
gettimeofday: syscall: 182 nsec/call
gettimeofday:    libc: 27 nsec/call
gettimeofday:    vdso: 27 nsec/call

可見到 vDSO 帶來顯著的效能提升 (gettimeofday 從 182 ns 降到 27 ns),而且 libc (此處為 glibc) 已利用 vDSO。針對 Arm/Aarch64 處理器架構的系統呼叫執行成本比較,如下圖:

出處: The vDSO on arm64 (2016)

追蹤系統呼叫

除了透過 GDB 單步執行和反組譯以外,我們可透過 perf 工具程式來動態分析。以下我們嘗試觀察 read 系統呼叫,首先準備程式碼 (檔名: read.c):

#include <fcntl.h>
#include <unistd.h>

int main()
{
    char c;
    int in = open("/dev/zero", O_RDONLY);                                                                                                                     
    for (int i = 0; i < 1000000; i++)
        read(in, &c, 1);
    return 0;
}

編譯和執行:

$ gcc -o read read.c 
$ ./read 

預期會遇到短暫的等待,那是對 /dev/zero 這個裝置進行 100 萬次讀取。透過 perf 工具來觀察 read 系統呼叫的執行:

$ perf stat -e syscalls:sys_enter_read ./read

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →
這個命令可能需要超級使用者的權限才可執行,請做好必要的設定

參考執行輸出:

 Performance counter stats for './read':
          100,0001      syscalls:sys_enter_read
       0.259709878 seconds time elapsed
       0.044606000 seconds user
       0.214920000 seconds sys

perf 工具告知,上述程式執行過程中有 100 萬 + 1 次 read 系統呼叫。

參考資訊