2015年3月12日 星期四

[翻譯] 系統呼叫(system call)的剖析(下)

  • 原文標題:Anatomy of a system call, part 2
  • 原文網址:http://lwn.net/Articles/604515/
  • 原文作者:David Drysdale
  • 原文發表時間:2014 年 07 月 16 日

譯註:
  • 本文內容與圖片皆自原網址修改。
  • 根據原文使用的參考資料連結,原文應該是使用 3.14 版核心做為依據。

↓↓↓↓↓↓ 正文開始 ↓↓↓↓↓↓

上一篇文章探索了系統呼叫(system call,或 syscall)的核心實作最平凡的形式:一個一般的系統呼叫、並在一個最普遍的架構上:x86_64。現在,用這個基本主軸的一些變體,含括了其他 x86 架構和其他系統呼叫機制,來完結我們在這方面的關注。我們要從探索各種 32 位元 x86 架構的變種開始,一張相關牽涉範圍的地圖也許對此有所幫助,這張地圖在檔案名稱和箭頭線段標籤上,都是可以點擊然後連結到被參考程式碼的:

譯註:參考內文後,此圖所對應之連結有略作修正。

經由 SYSENTER 的 x86_32 系統呼叫調用法


在一個 32 位元 x86_32 系統上系統呼叫的正常調用方法,與 x86_64 系統上,在先前文章中所描述的機制,極為類似。表格 arch/x86/syscalls/syscall_32.tbl 在這裡有一筆為了 sys_read項目
此項目指明給 x86_32 的 read() 具有系統呼叫號碼 3,並伴隨入口點(entry point)為 sys_read() 以及 i386 的呼叫慣例(calling convention),表格的後處理器(post-processor)將寫入一筆巨集呼叫 __SYSCALL_I386(3, sys_read, sys_read),到產生的檔案 arch/x86/include/generated/asm/syscalls_32.h譯註 1;重複此行為,藉以建立系統呼叫表,sys_call_table,跟先前的一樣。

進一步往外層看,sys_call_table 會在 arch/x86/kernel/entry_32.S 中的入口點 ia32_sysenter_target 被存取;然而在這兒,巨集 SAVE_ALL 實際上把不同的暫存器集合(EBX/ECX/EDX/ESI/EDI/EBP,而非 RDI/RSI/RDX/R10/R8/R9)推到堆疊上,反映了在這個平台上,不同的系統呼叫 ABI(application binary interface,應用程式二進位介面)慣例。

入口點 ia32_sysenter_target 在核心啟動時(於 enable_sep_cpu() 中),被寫入一個特定模組暫存器(model-specific register,MSR);在這個情況,我們提到的 MSR 是 MSR_IA32_SYSENTER_EIP(0x176),其用於處理硬體指令 SYSENTER

以上所述,展露了從使用者空間(userspace)的調用路徑,對於 x86_32 程式如何調用系統呼叫,現代的標準 ABI 是把系統呼叫號碼(對於 read() 是 3)放到暫存器 EAX,並把其他參數放到特定的暫存器(對於前三個參數是 EBX、ECX 和 EDX),然後調用硬體指令 SYSENTER

這道指令會導致處理器轉移到 ring 0,並且調用由特定模組暫存器 MSR_IA32_SYSENTER_EIP 所參考指向的程式碼——也就是 ia32_sysenter_targetia32_sysenter_target() 的程式碼會把暫存器推到(核心)堆疊上,並呼叫陣列表 sys_call_table 內第 EAX 項的函式指標——也就是 sys_read(),它是為了真正的實作 SYSC_read(),所做的一個既輕薄,且帶有 asmlinkage 的包裹函式(wrapper)。

經由 INT 0x80x86_32 系統呼叫調用法


陣列表 sys_call_table 同樣也是在 arch/x86/kernel/entry_32.S 中被存取,不過是在入口點 system_call,此入口點也同樣會把暫存器存到堆疊上,然後利用暫存器 EAX 從 sys_call_table 挑選相關的項目,並加以呼叫,這一次,入口點 system_call 是由 trap_init()使用
這會替陷阱(trap,譯註 2syscall_vector 設置其處理函式為 system_call;也就是,將它設置為重要的軟體中斷(software interrupt)INT 0x80 的接收者,以供系統呼叫的調用。

上述為系統呼叫原本的使用者空間之調用路徑,然而,這是現今一般來說避免使用的,因為在現代處理器上,它比專門為系統呼叫的調用所設計的硬體指令(SYSCALLSYSENTER)慢譯註 3

在這個舊式的 ABI 中,程式要調用系統呼叫,是把系統呼叫號碼放到暫存器 EAX,並把其他參數放到特定的暫存器(對於前三個參數是 EBX、ECX 和 EDX),然後調用硬體指令 INT 0x80,這道指令會導致處理器轉移到 ring 0,並且調用供給第 0x80 號軟體中斷的陷阱處理器(trap handler)——也就是 system_callsystem_call 的程式碼會把暫存器推到(核心)堆疊上,接著呼叫陣列表 sys_call_table 內第 EAX 項的函式指標——也就是 sys_read(),它是為了真正的實作 SYSC_read(),所做的一個既輕薄,且帶有 asmlinkage 的包裹函式。這些大部分應該會很熟悉,因為其與使用 SYSENTER 時差不多。

x86 系統呼叫的調用機制


為了提供參照,到目前為止在 x86 上,我們已經見識過的不同的使用者空間系統呼叫調用機制如下:
  • 64 位元程式使用硬體指令 SYSCALL,此指令源自 AMD 的引進,但最後也在 Intel 64 位元處理器上獲得實作,所以對於跨平台的相容性是最佳的選擇。
      
  • 現代 32 位元程式使用硬體指令 SYSENTER,其乃自 Intel 在 IA-32 架構下引進而面世。
      
  • 舊有的 32 位元程式使用硬體指令 INT 0x80,以觸發軟體中斷的處理函式,但在現代處理器上,它比 SYSENTER 慢許多。

在 x86_64 上對 x86_32 系統呼叫的調用法


現在來談談更複雜的情況:如果我們在 x86_64 系統上,跑一個 32 位元的二進位執行檔,會發生什麼呢?從使用者空間的角度,沒什麼不同;事實上,也沒有東西可以不同,因為跑的程式碼一模一樣。

SYSENTER 的案例,x86_64 核心於特定模組暫存器 MSR_IA32_SYSENTER_EIP 中,註冊了不同函式做為處理函式,其與 x86_32 程式碼有著相同的名字(ia32_sysenter_target),但卻有不同的定義式(在 arch/x86/ia32/ia32entry.S 內);尤其是,它推入舊式的暫存器們譯註 4,使用的卻是不同的系統呼叫表,ia32_sys_call_table,此表建立自 32 位元的項目列表;特別是,它會把第 3 筆項目,而不是第 0 筆(0 是在 64 位元系統上用於 read() 的系統呼叫號碼),映射到 sys_read()

INT 0x80 的案例,trap_init() 的程式碼則調用
上面這會把 IA32_SYSCALL_VECTOR仍然是 0x80)映射到 ia32_syscall,而這個組合語言入口點(在 arch/x86/ia32/ia32entry.S 裡)使用的是 ia32_sys_call_table,而不是 64 位元的 sys_call_table

一個更複雜的例子:execve 與 32 位元相容性(32-bit compatibility)的處理


現在,來看看一個有點雜亂的系統呼叫:execve(),我們會再次從系統呼叫之核心實作的外層開始,然後順著這條路徑,探索其與較單純的系統呼叫 read() 之間的差異。同樣的,一張可以點擊的、相關於探索範圍的地圖應該對此有所幫助:

譯註:為了更容易理解,此圖在重繪時有對方塊的位置進行調整;此外,還對連結做了修正。

execve()fs/exec.c 的定義與 read() 的看起來很像,但有個有意思的額外函式的定義緊接其後(至少在 CONFIG_COMPAT 有定義時是這樣):
順著處理的路徑,這兩個實作會在 do_execve_common() 匯合,來執行真正的工作(sys_execve()do_execve()do_execve_common() 相對於 compat_sys_execve()compat_do_execve()do_execve_common()),並在過程中設定 user_arg_ptr 結構。這些結構容納了屬於「指向指標的指標」這種類型的系統呼叫引數,並帶有它們是否來自 32 位元相容性之 ABI 的標示;如果標示為「是」,其所指向的,就是一個 32 位元大小的使用者空間位址,而不是一個 64 位元大小的值,對於自使用者空間,複製引數資料的程式碼來說,這是需要加入考量的。

因此,不像 read(),其系統實作不須分辨 32 位元與 64 位元的呼叫者,因為其引數皆為「指向值的指標」的類型;execve() 則必須對此進行分辨,因為它的某些引數是「指向指標的指標」類型。上述塑造了一個普遍的主題思想——其他 compat_sys_name() 入口點也是為了在那裡處理屬於「指向指標的指標」類型的引數(或者是屬於「指向包含指標之結構的指標」類型的引數,例如 struct iovecstruct aiocb)。

x32 ABI 的支援


為了讓 execve() 提供兩種不同的實作,混雜現象從程式碼到系統呼叫表,四處都是。對於 x86_64 而言,64 位元的表格中有兩個項目皆為了 execve() 而設:
在該 64 位元表格中,系統呼叫號碼 520 處的額外項目是為了 x32 ABI 程式準備的,這樣的程式執行在 x86_64 的處理器,但使用的卻是 32 位元大小的指標譯註 5,由於 64x32 的 ABI 標示,我們得知這兩種 ABI 分別使用位於 sys_call_table 中第 59 項的 stub_execve;以及第 520 項的 stub_x32_execve

儘管這是我們第一次提到 x32 ABI,然而在之前的案例 read() 中,其實就有安靜地把 x32 ABI 相容性包含進去了,這是因為沒有「指向指標的指標」類型位址的轉換需求,所以系統呼叫的調用路徑(以及系統呼叫號碼)可以單純地跟 64 位元版本共享。

stub_execvestub_x32_execve 都定義在 arch/x86/kernel/entry_64.S,他們分別呼叫 sys_execve()compat_sys_execve(),並且也會儲存額外的暫存器(R12~R15、RBX 和 RBP)在核心堆疊上;類似的 stub_* 包裹函式也會出現在 arch/x86/kernel/entry_64.S,來對應一些其他的系統呼叫(rt_sigreturn()clone()fork()vfork()),這些系統呼叫在被調用時,可能需要讓使用者空間的執行環境,重新啟動在不同的位址和/或不同使用者堆疊的情況下。

對於 x86_32,32 位元的表格內,有一筆項目對應 execve(),不過它的格式相較於對應 read() 的項目有點不一樣:
首先,這告訴我們 execve() 在 32 位元系統上的系統呼叫號碼是 11,與在 64 位元系統上的號碼 59(或 520)不同;觀察到更有意思的,是在 32 位元表格中出現了一個額外的欄位,持有一個相容性入口點 stub32_execve,對於原生的 32 位元核心編譯,這個額外的欄位會被忽視,而 sys_call_table 就像一般情況一樣,在第 11 筆項目存放 sys_execve()

可是,對於 64 位元核心編譯,IA-32 相容性的程式碼會把入口點 stub32_execve()插入ia32_sys_call_table 的第 11 筆項目,該入口點定義在 arch/x86/ia32/ia32entry.S,像這樣:
巨集 PTREGSCALL 設置入口點 stub32_execve,用來呼叫 compat_sys_execve()(藉由把位址放到 RAX),並且儲存額外的暫存器(R12~R15、RBX 和 RBP)到核心堆疊上(就像前面的 stub_execve())。

gettimeofday(): vDSO


某些系統呼叫僅僅是從核心讀取少量的資訊,對此而言,ring 轉換的完整過程是很大的負擔,vDSO(Virtual Dynamically-linked Shared Object,虛擬動態連結共享物件)機制經由將含有相關資訊的頁面,利用唯讀的方式映射到使用者空間,獲得了加速某些這類唯讀的系統呼叫的效果,特別地是,該頁面會設置成 ELF 共享函式庫的格式,所以可直截了當地與使用者程式做連結。

對一個正常有使用 glibc 的二進位檔案使用 ldd,會把 vDSO 顯示為對於 linux-vdso.so.1linux-gate.so.1ldd 明顯找不到它們對應的檔案)的相依性;它們也會顯示在運作中之行程(process)的記憶體映射表(memory map)當中(使用 cat /proc/PID/maps 時的 [vdso])。

在歷史上,vsyscall 是比較早期的一個做類似事情的機制,但基於安全性的考量,現在不再建議使用(deprecated),有篇 Johan Petersson 的舊文章說明對於使用者空間,vsyscall 的頁面如何呈現為 ELF 物件(在固定的位置)。

有篇 Linux Journal 的文章探討 vDSO 設置的一些細節(雖然有點過時了),所以在套用到系統呼叫 gettimeofday() 時,我們只在這裡描述一些基本概念。

首先,gettimeofday() 需要存取資料,為此,相關的 vsyscall_gtod_data 結構,會匯出到一個特殊的資料區段(section),叫做 .vvar_vsyscall_gtod_data;接著連結器指令會確保區段 .vvar_vsyscall_gtod_data 連結到核心的區段 __vvar_page譯註 6,並且在核心啟動過程的函式 setup_arch() 中,呼叫 map_vsyscall() 來為 __vvar_page 設置固定映射(fixed mapping)

提供 gettimeofday() 的核心 vDSO 實作的程式碼在 __vdso_gettimeofday(),它標記為 notrace 以防止編譯器對其加入函式側描(function profiling)譯註 7,並且取弱別名(weak alias)為 gettimeofday()。為了確保最終的頁面看起來像一個 ELF 共享物件,檔案 vdso.lds.S 引入了 vdso-layout.lds.S,並對 gettimeofday()__vdso_gettimeofday() 都進行匯出。

為了讓使用者空間的程式能存取這個 vDSO 頁面,在 setup_additional_pages() 的程式碼會在行程啟動時,將 vDSO 頁面座落點,設置於由 vdso_addr() 所選擇的隨機位址,使用隨機位址緩和了早期 vsyscall 實作方法遇到的安全性問題,但這也表示使用者程式需要一個能找到 vDSO 頁面座落點的途徑。這個座落點會做為一個 ELF 輔助值(auxiliary value),來展露(expose)給使用者空間:ELF 格式的二進位載入器(load_elf_binary())使用巨集 ARCH_DLINFO,來設定輔助值 AT_SYSINFO_EHDR;然後,使用者空間的程式就可以用函式 getauxval() 來接收相關的輔助值(雖然實際上函式庫 libc 通常會在幕後處理掉)。

為了完整性,我們也應該講講 vDSO 機制,在 32 位元程式上被使用到的,另一個重要的系統呼叫相關的功能。在開機時,核心會判定哪種可能的 x86_32 系統呼叫調用機制是最好的,並把適當的實作法包裹函式(SYSENTERINT 0x80、甚至是在 AMD 64 位元處理器上的 SYSCALL)放進函式 __kernel_vsyscall,然後,使用者空間的程式就可以調用這個包裹函式,並保證對於他們的系統呼叫,會是以最快的途徑進入核心;參見 Petersson 的文章來取得更多細節。

ptrace():系統呼叫的追蹤


系統呼叫 ptrace() 本身也是以普通方式實作,但是,它跟本文特別有關,因為它會讓受追蹤之核心任務(kernel task)的系統呼叫,呈現不同的行為,尤其是需求(request)PTRACE_SYSCALL 宣稱要「安排受追蹤者在下次進入或離開系統呼叫時停止」。

要求 PTRACE_SYSCALL 會導致在特定於執行緒之資料中(struct thread_info.flags),執行緒資訊旗標(thread information flag)TIF_SYSCALL_TRACE 被設定,其效果是特定於硬體架構的;我們只描述 x86_64 的實作方式。

再仔細看看對於系統呼叫項目的組合語言(在 x86_32x86_64ia32 全部這幾種),我們瞧見一個之前略過的地方:如果執行緒的旗標中有任何的 _TIF_WORK_SYSCALL_ENTRY 旗標(包括 TIF_SYSCALL_TRACE)被設立,系統呼叫的實作程式碼,就會改走另一條調用 syscall_trace_enter() 的路徑(x86_32x86_64ia32)。接著,函式 syscall_trace_enter() 會執行不同的功能,其依據為各個執行緒的旗標值中,包含於 _TIF_WORK_SYSCALL_ENTRY 的旗標:
  • TIF_SINGLESTEP:提供硬體指令的單步執行(single stepping)給 ptrace
      
  • TIF_SECCOMP:對系統呼叫的項目進行安全計算(secure computing,譯註 8)的檢查
      
  • TIF_SYSCALL_EMU:進行系統呼叫模擬譯註 9
      
  • TIF_SYSCALL_TRACE:提供系統呼叫追蹤給 ptrace
      
  • TIF_SYSCALL_TRACEPOINT:提供系統呼叫追蹤給 ftrace
      
  • TIF_SYSCALL_AUDIT:系統呼叫稽核記錄(audit record,譯註 10)的產生

換句話說,syscall_trace_enter 是所有不同之各個系統呼叫攔截功能的集合的控制點——包含系統呼叫追蹤 TIF_SYSCALL_TRACE,它最終會帶有 why=CLD_TRAPPED 來呼叫 ptrace_stop(),其會通知追蹤者程式(經由 SIGCHLD),被追蹤已停止在一個系統呼叫的入口處。

結語


數十年來,系統呼叫已成為使用者程式與 Unix 核心溝通的標準方法,因此,Linux 核心包含了一組的工具,讓它自己能輕易地定義並有效的使用。儘管有跨平台的調用法變體與偶爾的特殊案例,系統呼叫仍是一個明顯同質的機制——這樣的穩定性與同質性允許各種有用工具,從 straceseccomp-bpf,都能各自以通用的方法進行運作。

↑↑↑↑↑↑ 正文結束 ↑↑↑↑↑↑

譯註 1
這樣的後處理器會在編譯核心的過程中被執行,所以其產生的檔案在原始的核心程式碼中是找不到的。如果你使用 LXR 的網頁去尋找,就會發現找不到這樣的檔案。   
譯註 2
這是 x86 硬體的一種機制,可參考這裡。   
譯註 3
關於這個現象,在〈Linux 2.6 对新型 CPU 快速系统调用的支持〉這篇文章中有更多的討論。
〔額外資料〕:而關於其 Linux 核心當初的發展史,在〈How to speed up system calls〉有稍微提到;而關於硬體指令的機制,〈System Call Optimization with the SYSENTER Instruction〉有詳細的介紹。   
譯註 4
此處將暫存器推到堆疊,原因是當核心對於系統呼叫的要求完成時,在返回使用者空間前,必須將暫存器回復原狀,以符合 ABI 的規定(其實也避免了一般程式窺探系統行為的痕跡)。 在進入核心的當下,暫存器是以符合 i386 ABI 的形式存在的,除了 EAX 放置系統呼叫號碼外,EBX、ECX、EDX、ESI、EDI 和 EBP 依序放置第 1 ~ 6 個參數;然而,由於 asmlinkage 此時不對函式呼叫慣例做調整(在 x86_64),因此,根據 x86_64 ABI,第 1 ~ 6 個參數放於 RDI、RSI、RDX、RCX、R8 和 R9。所以,在調用系統呼叫表的項目之前,核心會調用巨集 IA32_ARG_FIXUP 來將「符合 x86_32 ABI 系統呼叫慣例」的暫存器,調整為「符合 x86_64 ABI 函式呼叫慣例」的暫存器。   
譯註 5
x32 ABI 指的是在 64 位元的 Linux 系統上使用的一種 ABI,與先前提到 64 位元系統對於 32 位元系統上的 i386 ABI 的相容是不一樣的。使用 x32 ABI 的程式只可能執行在 64 位元的系統上(不管核心有沒有開支援的編譯選項);然而,使用 i386 ABI 的程式可以執行在 32 和 64 位元的系統上(如果核心有開支援的編譯選項)。   
譯註 6
分析連結器指令後可以得知,區段名稱其實應該是 .vvar,連結器指令真正的意義是把區段 .vvar_vsyscall_gtod_data,併入到最終的核心二進位檔中的區段 .vvar,而 __vvar_page 則是給程式碼存取其開始位置的標示符。這方面的知識,可以參考〈Linux 二進位檔中的特殊區段(section)〉。   
譯註 7
根據我的資料,如果要使用 ftrace,編譯時需加入 CONFIG_FUNCTION_TRACER,此函式會增加 -pg編譯器設定,並因而對所有函式加入 function profiling 的程式碼;然而,vDSO 落於使用者空間,一旦執行了這些程式碼,就會因直接呼叫核心內函式而出錯。〔相關資料〕   
譯註 8
secure computing 是一種限制應用程式能夠調用之系統呼叫的機制,依據我的資料,到本文所探討的 3.14 版核心,Linux 核心總共提供兩種 secure computing(統稱為 seccomp):一種是 Andrea Arcangeli 所提出的 seccomp,核心在 2.6.12 時正式引入[相關文章];另一種則是 Will Drewry 所提出的 seccomp-bpf,核心在 3.5 時正式引入[相關文章]。   
譯註 9
其與 ptrace() 的需求 PTRACE_SYSEMU 有關,為了 UML(User-mode Linux)的加速而提出。[相關 commit]   
譯註 10
這與 Linux 核心提供的稽核框架(audit framework)有關。

1 則留言 :