--- tags: LINUX KERNEL, LKI --- # vDSO: 快速的 Linux 系統呼叫機制 > 資料整理: [jserv](http://wiki.csie.ncku.edu.tw/User/jserv) ## 前言 [vDSO](https://man7.org/linux/man-pages/man7/vdso.7.html) 的全名是 "virtual dynamic shared object",乍看之下和 Linux 系統呼叫沒有直接關聯,但卻是 Linux 核心處理系統呼叫的特殊機制,並反映出微處理器和作業系統相互影響的歷程。Google 公司發展全新的作業系統 [Fuchsia](https://fuchsia.dev/) 時,還特意從 Linux 核心借鏡 [vDSO](https://man7.org/linux/man-pages/man7/vdso.7.html) 的設計和實作。 > 參見: [Fuchsia](https://fuchsia.dev/) 的文件 [Zircon vDSO ](https://fuchsia.dev/fuchsia-src/concepts/kernel/vdso) 若想探究 [vDSO](https://man7.org/linux/man-pages/man7/vdso.7.html),就要從其前身 [vsyscall](https://lwn.net/Articles/446528/) 談起,中間涉及 `x86`/`x86_64` 的軟體中斷機制、[sysenter 和 sysexit 指令](https://www.intel.com/content/dam/www/public/us/en/documents/manuals/64-ia-32-architectures-software-developer-vol-3a-part-1-manual.pdf)、動態連結函式庫,及資訊安全等議題。 ## Linux 系統呼叫 早期 32 位元的 Intel x86 處理器指令集沒有系統呼叫的專用指令,作業系統透過在使用者模式觸發軟體中斷 (software interrupt) 來達成系統呼叫,此法稱為 [call gate](https://en.wikipedia.org/wiki/Call_gate_(Intel))。在 Linux 核心中,對應到系統呼叫的軟體中斷號碼是 `80H`,即 `int 0x80` 這道指令。 > 此處所指的 call gate 為涉及到特權模式移轉的操作,可以讓使用者在特權等級較低的情況下,跳到較高的特權等級(通常意味著作業系統),而非指 x86 特有的機制 CALL FAR instruction Linux 核心的系統呼叫運作示意如下圖: ![](https://i.imgur.com/cADN8jd.png) 1997 年 Intel Pentium II 引入系統呼叫專用的指令 [sysenter 和 sysexit 指令](https://www.intel.com/content/dam/www/public/us/en/documents/manuals/64-ia-32-architectures-software-developer-vol-3a-part-1-manual.pdf),AMD 稍後也提供對應的指令,但因 `sysenter` 的運作機制與 `int 0x80` 大相徑庭,Linux 核心開發者考慮到當時多款 x86 相容處理器的行為,尚未納入 `sysenter` 的考量。到了 2002 年,郵件論壇的一篇文章 [Intel P6 vs P7 system call performance](https://lwn.net/Articles/18412/) 引發大量討論,因為微架構的落差,實驗指出,當時 Pentium 4 透過 `int 0x80` 處理系統呼叫的成本,會比 Pentium III 慢許多倍,這樣的效能衝擊讓 Linux 核心開發者不得不思考新的機制。 > 延伸閱讀: [Linux 核心設計: 賦予應用程式生命的系統呼叫](https://hackmd.io/@sysprog/linux-syscall) 考慮到 Intel 和 AMD 處理器的指令行為,快速的系統呼叫指令選擇是: * 32 位元的處理器 (即 `IA32`): `sysenter` 和 `sysexit` * 64 位元的處理器 (即 `x86_64`): `syscall` 和 `sysret` 不過考慮到相容模式 (compatibility mode) 後,讓系統呼叫的專用指令選擇上變得更複雜:`x86_64` 相容模式允許作業系統得以在不修改應用程式的前提,讓 32 位元的程式得以執行在 64 位元的作業系統,因此 Linux 核心必須在設計 32 位元程式運作方式時,就設想「一旦執行於 64 位元相容模式會如何」。這意味著,無論使用 `sysenter` 或 `syscall` 指令,都會致使相容性疑慮,畢竟系統呼叫觸發的頻率相當高,若在執行期決定會降低效能。最初「更快的系統呼叫」需求反映在 2002 年釋出的 [Linux v2.5.53](https://www.kernel.org/pub/linux/kernel/v2.5/ChangeLog-2.5.53),後者提出 `vsyscall` 這項新機制,"v" 表示 "virtual",將系統呼叫的行為進行包裝,原本在使用者層級用 `int 0x80` 或 `sysenter` 的指令,就變成 `call` 指令 —— 就跟尋常的函式呼叫相同的指令一樣,但跳躍的地址由 Linux 核心特別指定,最終仍會執行到 `sysenter` 或 `syscall` 指令。 以 32 位元的 x86 來說,定址 (addressing) 空間為 $2^{32} = 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](https://sourceware.org/ml/libc-alpha/2003-03/msg00008.html) 開始配合這套機制。 以下是示範用的程式碼: (檔名 `syscall32.c`) ```cpp= #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](https://en.wikipedia.org/wiki/Exit_(system_call)) 系統呼叫,並傳入 `42` 作為 exit code。 > `42` 這數字是向《[銀河便車指南](https://en.wikipedia.org/wiki/The_Hitchhiker%27s_Guide_to_the_Galaxy)》(The Hitchhiker’s Guide to the Galaxy) 致敬,後者提到一群超空間的智慧生物為了得到終極問題的解答,打造一部超級電腦 "Deep Thought"(深思),用 750 萬年的時間運算,終於得到答案: > 「[生命、宇宙、萬事萬物的終極答案](https://zh.wikipedia.org/wiki/%E7%94%9F%E5%91%BD%E3%80%81%E5%AE%87%E5%AE%99%E4%BB%A5%E5%8F%8A%E4%BB%BB%E4%BD%95%E4%BA%8B%E6%83%85%E7%9A%84%E7%B5%82%E6%A5%B5%E7%AD%94%E6%A1%88)是:`42`」 留意到第 24 行內嵌式組合語言敘述的 `call` 指令,其跳躍的地址就取決於 auxiliary vector。在 GNU/Linux `x86_64` 環境中,編譯和執行上述程式碼: ```shell $ gcc -m32 -g -o syscall32 syscall32.c $ ./syscall32 $ echo $? 42 ``` `echo $?` 可取得 exit code,在 `syscall32` 程式執行後,符合預期地留下 `42` 這數值。注意 `-m32` 表示這段程式碼是針對 32 位元編譯。 接著我們可用 GDB 來觀察: ```shell $ gdb -q ./syscall32 ``` 看到 `(gdb)` 命令提示後,執行以下命令: ```shell (gdb) break 24 (gdb) run (gdb) stepi (gdb) stepi (gdb) stepi (gdb) stepi ``` :::success :information_source: `(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 對該地址進行反組譯: ```cpp (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](https://www.kernel.org/pub/linux/kernel/v2.5/ChangeLog-2.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](https://man7.org/linux/man-pages/man7/vdso.7.html)。 > 關於 ELF 執行檔和動態連結的資訊可見〈[你所不知道的 C 語言:連結器和執行檔資訊](https://hackmd.io/@sysprog/c-linker-loader)〉。 動態連結機制確保 [vDSO](https://man7.org/linux/man-pages/man7/vdso.7.html) 每次所在的地址可以不同,降低有心人攻擊的風險。我們可藉由 [ldd](https://man7.org/linux/man-pages/man1/ldd.1.html) 命令觀察 [vDSO](https://man7.org/linux/man-pages/man7/vdso.7.html): ```shell $ ldd syscall ``` 預期輸出如下: ``` linux-gate.so.1 (0xf7f61000) libc.so.6 => /lib32/libc.so.6 (0xf7d4d000) /lib/ld-linux.so.2 (0xf7f62000) ``` :::success :information_source: 由於 [Address space layout randomization](https://en.wikipedia.org/wiki/Address_space_layout_randomization#Linux) (ASLR) 的存在,每次執行 [ldd](https://man7.org/linux/man-pages/man1/ldd.1.html) 命令,上面 3 個輸出的地址會有不同數值。 ::: 留意到 `linux-gate.so.1`,這由 Linux 核心提供,不需要在檔案系統中存在。稍早提到 Linux 核心會映射頁面到使用者層級,可用以下命令觀察: ```shell $ 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](https://man7.org/linux/man-pages/man7/vdso.7.html) 的存在不僅可解決系統呼叫專用指令的議題,還能加速在意效率的系統呼叫。 2007 年釋出的 [Linux v2.6.23](https://kernelnewbies.org/Linux_2_6_23#head-76e1c403849e69f39fc5af88e9009e60594a889d) 支援高解析度計時器 ([high-resolution timer](https://lwn.net/Articles/167897/)),既然號稱高解析度,那本身的花費時間當然越低越好。Linux 核心開發者在實作 [clock_gettime](https://man7.org/linux/man-pages/man3/clock_gettime.3.html) 對應的系統呼叫時,就運用 [vDSO](https://man7.org/linux/man-pages/man7/vdso.7.html) 機制,連同 [gettimeofday](https://man7.org/linux/man-pages/man2/gettimeofday.2.html) 和 [getcpu](https://man7.org/linux/man-pages/man2/getcpu.2.html) 也是。這樣的改變當然需要 glibc 配合,因此緊隨其後發布的 [glibc 2.7](https://sourceware.org/ml/libc-alpha/2007-10/msg00045.html) 也做對應的改變,開始支援 x86-64 [vDSO](https://man7.org/linux/man-pages/man7/vdso.7.html)。 以 [gettimeofday](https://man7.org/linux/man-pages/man2/gettimeofday.2.html) 為例,[vDSO](https://man7.org/linux/man-pages/man7/vdso.7.html) 的初始化示意如下: ![](https://i.imgur.com/1rtBs8n.png) > 出處: [The vDSO on arm64](https://blog.linuxplumbersconf.org/2016/ocw/system/presentations/3711/original/LPC_vDSO.pdf) (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_getres](https://man7.org/linux/man-pages/man2/clock_getres.2.html) 和 [rt_sigreturn](https://man7.org/linux/man-pages/man2/rt_sigreturn.2.html) 一類的系統呼叫,這些系統呼叫相對單純。針對 [PowerPC](https://en.wikipedia.org/wiki/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](https://nodejs.org/en/),可帶來效能提升。 [vdsotest](https://github.com/nlynch-mentor/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 處理器架構的系統呼叫執行成本比較,如下圖: ![](https://i.imgur.com/9UbtUo0.png) > 出處: [The vDSO on arm64](https://blog.linuxplumbersconf.org/2016/ocw/system/presentations/3711/original/LPC_vDSO.pdf) (2016) ## 追蹤系統呼叫 除了透過 GDB 單步執行和反組譯以外,我們可透過 [perf](http://wiki.csie.ncku.edu.tw/embedded/perf-tutorial) 工具程式來動態分析。以下我們嘗試觀察 [read](https://man7.org/linux/man-pages/man2/read.2.html) 系統呼叫,首先準備程式碼 (檔名: `read.c`): ```cpp #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; } ``` 編譯和執行: ```shell $ gcc -o read read.c $ ./read ``` 預期會遇到短暫的等待,那是對 `/dev/zero` 這個裝置進行 100 萬次讀取。透過 perf 工具來觀察 [read](https://man7.org/linux/man-pages/man2/read.2.html) 系統呼叫的執行: ```shell $ perf stat -e syscalls:sys_enter_read ./read ``` :::success :information_source: 這個命令可能需要超級使用者的權限才可執行,請做好必要的設定 ::: 參考執行輸出: ``` 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](https://man7.org/linux/man-pages/man2/read.2.html) 系統呼叫。 ## 參考資訊 * [The Definitive Guide to Linux System Calls](https://blog.packagecloud.io/eng/2016/04/05/the-definitive-guide-to-linux-system-calls/) * [Linux vsyscall and vDSO](https://www.slideshare.net/vh21/twlkhlinuxvsyscallandvdso) (投影片) * [VDSO As A Potential KASLR Oracle](https://www.longterm.io/vdso_sidechannel.html) * [Implementing virtual system calls](https://lwn.net/Articles/615809/) * [System calls in the Linux kernel: vsyscalls and vDSO](https://0xax.gitbooks.io/linux-insides/content/SysCall/linux-syscall-3.html) * [什麼是 Linux vDSO 與 vsyscall?](https://alittleresearcher.blogspot.com/2017/04/linux-vdso-and-vsyscall-history.html#fn:linux-2623-vdso-vtime)