Try   HackMD

Linux 核心設計: System call

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

Overview of system call

System call 是 userspace 和 kernel 進行交互的介面,使得 user program 可以請求 kernel 來進行更高權限的操作,例如硬體相關的操作(e.g. 讀寫檔案)、process 的建立和執行等等。你可以想像成是在 user space 請求執行某個在 kernel 中的函式。

對於每個 system call,它們會有一個獨立的 system call number,放置於特定的 register 中,而可能需要的參數也放置於其他的特定 register。這使得 kernel 可以藉之來查詢 system call table,給予相應的服務。system call number 和參數所對應的 register 的定義在相關的 Application Binary Interface (ABI) 中。

要建立的實際的 system call,硬體需要提供的一個特殊的指令,以產生 trap 而從 userspace 轉入 kernel space。例如 x86_64 的 syscall,arm64 的 svc #0,或者是 RISCV 使用的 ecall。細節上,這個指令會把 program counter 轉向 kernel 所定義的入口點,例如 x86 的入口為 entry_SYSCALL_64,地址在啟動時透過 wrmsrl(MSR_LSTAR, (unsigned long)entry_SYSCALL_64) 儲存於 MSR_LSTAR,後者是一個 Model-specific register)。然後 kernel 就可以透過相應的 register 取得 system call number 和參數,找到相應的 system call handler 給予對應的服務,之後根據 ABI 所定義的 register 設置返回值,最後重設回當初產生 trap 之指令的下個 program conter 位置。

proc file system 提供了 /proc/${pid}/syscall 格式的檔案可以查詢某個 process 正在執行的 system call 和參數內容,例如:

$ sudo cat /proc/1/syscall 

232 0x4 0x564cb86a4aa0 0xb5 0xffffffff 0x0 0x2570 0x7ffe0d5106a0 0x7fa08a7d85ce

可知道使用的 system call 是 232 號的 epoll_wait

How to perform system call?

glibc 等 standard library 對 system call 都做了很好的封裝,例如以下經典的 hello world! 程式:

#include <stdio.h>

int main(){
    printf("hello world!\n");

    return 0;
}

透過下面的指令編譯並透過 ltrace 追蹤可以得到以下結果:

$ gcc <source> -Wl,-z,lazy -o test
$ ltrace ./test
puts("hello world\n"hello world

)                                         = 13
+++ exited (status 0) +++

透過 strace 追蹤則可以看到使用到封裝的 write 進行 system call,其中第一個參數 1 表示的是 STDOUT 的 file descriptor。

write(1, "hello world!\n", 13hello world!
)          = 13
  • gcc 的 linker option 預設為 -Wl,-znow(可透過 --verbose 察看),需更動為 -Wl,-zlazy 才能使得 ltrace 找到使用的 library call

Why does gcc link with '-z now' by default, although lazy binding is the default for ld?

  • 因為 printf 不帶參數,因此 gcc 直接將其替換為 puts

透過這樣的封裝,我們可以不必寫直接寫組語設定相關的 register 並且呼叫特殊的指令產生 trap。這也讓標準函式庫可以針對不同的系統環境實作背後的細節,而應用程式的撰寫者只要使用一致的介面即可。此外,函式庫也可以受益於此避免不必要的 system call,例如 getpid 的結果可以只有第一次真正使用 system call,後續則可以 cache 住該值並且直接返回即可。

Implementation of system call

下面更深入的探討 system call 在 linux 中的實作,以 write 為例:

SYSCALL_DEFINE3(write, unsigned int, fd, const char __user *, buf, size_t, count) { return ksys_write(fd, buf, count); }

SYSCALL_DEFINE3write_ 做合併後,後續再用 SYSCALL_DEFINEx 展開。

#define SYSCALL_DEFINE3(name, ...) SYSCALL_DEFINEx(3, _##name, __VA_ARGS__)

#define SYSCALL_DEFINEx(x, sname, ...)				\
	SYSCALL_METADATA(sname, x, __VA_ARGS__)			\
	__SYSCALL_DEFINEx(x, sname, __VA_ARGS__)

SYSCALL_METADATACONFIG_FTRACE_SYSCALLS 有關,後者表示是否讓 ftrace 追蹤 syscall 的進入和離開事件,如果開啟追蹤,SYSCALL_METADATA 就會初始化相關的結構以供後續的追蹤使用。反之 SYSCALL_METADATA 只會展開為一個空字串而已。

#define __SYSCALL_DEFINEx(x, name, ...)					\
	...
    
	asmlinkage long sys##name(__MAP(x,__SC_DECL,__VA_ARGS__))	\
		__attribute__((alias(__stringify(__se_sys##name))));	\
        
	
	static inline long __do_sys##name(__MAP(x,__SC_DECL,__VA_ARGS__));\
    
	asmlinkage long __se_sys##name(__MAP(x,__SC_LONG,__VA_ARGS__));	\
    
	asmlinkage long __se_sys##name(__MAP(x,__SC_LONG,__VA_ARGS__))	\
	{								\
		long ret = __do_sys##name(__MAP(x,__SC_CAST,__VA_ARGS__));\
		__MAP(x,__SC_TEST,__VA_ARGS__);				\
		__PROTECT(x, ret,__MAP(x,__SC_ARGS,__VA_ARGS__));	\
		return ret;						\
	}								\
								\
	static inline long __do_sys##name(__MAP(x,__SC_DECL,__VA_ARGS__))
#endif /* __SYSCALL_DEFINEx */

__SYSCALL_DEFINEx 則會展開成數個函式:

  • __MAP(x,__SC_DECL,__VA_ARGS__) 會一一的把參數的型別和名稱做結合,最後展開成合法的函式參數形式
  • sys##name 是 syscall handler 的名稱,可以看到透過 alias__se_sys##name 作為別名,__se_sys##name 用以避免 CVE-2009-0029 的問題,內部呼叫的 __do_sys##name 的實作就是使用 __SYSCALL_DEFINEx macro 時後面所接續的內容
ssize_t ksys_write(unsigned int fd, const char __user *buf, size_t count) { struct fd f = fdget_pos(fd); ssize_t ret = -EBADF; if (f.file) { loff_t pos, *ppos = file_ppos(f.file); if (ppos) { pos = *ppos; ppos = &pos; } ret = vfs_write(f.file, buf, count, ppos); if (ret >= 0 && ppos) f.file->f_pos = pos; fdput_pos(f); } return ret; }

接著來看看內部的實作細節:

  • 首先可以看到 buf 中的 __user 的關鍵字,__user 展開為
# define __user __attribute__((noderef, address_space(1)))

通過 Sparse 這個工具進行檢查。

Sparse: a look under the hood

  • 一開始,以 file descriptor fdfdget_pos 的參數,fdget_pos 大致的行為是找到屬於該 process 的 file descriptor table,並透過 file descriptor number 從 table 中得到其應對應的結構 f(f_pos_lock)
static inline loff_t *file_ppos(struct file *file)
{
	return file->f_mode & FMODE_STREAM ? NULL : &file->f_pos;
}
  • 如果 f.file 存在,透過 file_ppos 找到在檔案中的位置,其位置位於 f_pos 欄位,回傳到指標 *ppos
  • 再通過 vfs_writebuf 內容從指定的位置 *ppos 寫入 count 個 byte 到指定的檔案 f.file
  • 如果成功寫入,ret>=0 && ppos 會成立,則更新 f.file->f_pos 為寫完檔案後的新位置 pos

System call handling

Initialization of system call tabel

此前我們提到 kernel 通過 system call number 找到對應的 system call handler,kernel 實際上通過一個名為 system call table 的表來維護此對應。讓我們來看看這個結構: sys_call_table

asmlinkage const sys_call_ptr_t sys_call_table[__NR_syscall_max+1] = {
	[0 ... __NR_syscall_max] = &__x64_sys_ni_syscall,
#include <asm/syscalls_64.h>
};
  • __NR_syscall_max 定義了架構下的 system call 數量
  • sys_call_ptr_t 是 typedef 出來的型別
typedef long (*sys_call_ptr_t)(const struct pt_regs *);
  • 0 ... __NR_syscall_max 語法是 gcc comiler extension 的 Designated Initializers
  • __x64_sys_ni_syscall 是 table 一開始初始化的 function pointer,表示未定義的 system call,其實作直接回傳一個 errno -ENOSYS
SYSCALL_DEFINE0(ni_syscall)
{
	return -ENOSYS;
}
ENOSYS Function not implemented (POSIX.1-2001).

System call entry point

有了 system call table,kernel 知道哪個函式可以處理對應的不同 system call。但是從 user 進入 kernel 的過程還需要另外的準備,例如 push 必要的 register 到 stack 中,因此作業系統還需要初始化相應的進入點來做這些準備。

要設定 system call 的進入點,此前我們有提到需要設定相關的 Model-specific register,這實作在 syscall_init 中,例如 syscall_init 最開始的 MSR 設定為:

wrmsr(MSR_STAR, 0, (__USER32_CS << 16) | __KERNEL_CS);
wrmsrl(MSR_LSTAR, (unsigned long)entry_SYSCALL_64);
  • MSR_STAR 的 63:48 bits 是 user code segment,在 sysret 時被用以載入到 code segment(CS) 和 stack segment(SS) 中,使得可以從 system call 返回 user code。而 47:32 bits 則反之是 kernel code segment,當 syscall 時被用以載入到 CS 和 SS
  • 接著向 MSR_LSTAR 寫入 system call 的進入點

後續還需要根據 CONFIG_IA32_EMULATION config 是否可以在 64 bits kernel 執行 32 bits 的程式,給予相關的 register 相應的內容,最後寫入 MSR_SYSCALL_MASK 去清除 FLAGS register 中相應的 flags。

初始化完成後,當 syscall 被呼叫,程式邏輯就會轉移到 entry_SYSCALL_64

關於 CONFIG_IA32_EMULATION 影響的相關 MSR,以及 entry_SYSCALL_64 中的實作細節,因為自己還弄得不夠清楚所以這裡就暫時不特別寫下了,比較完整的說明可以直接參考 System calls in the Linux kernel. Part 2.

vsyscalls and vDSO

此前,我們已經大致說明了 system call 的內容。對某些 system call 來說,它們本身所需的時間極短,例如只是讀取某些特定的資料,這種情境下,system call 所牽涉的 user 和 kernel space 的轉換反而成為整個 system call 的 bottleneck。而 vsyscall 和 vDSO 就是針對此問題,用以對特定的 system call 流程進行加速的機制。

Introduction to vsyscalls

vsyscal 全名為 virtual system call,是最早在 Linux kernel 中的加速 system call 機制。它的想法是把一個 kernel 中特定的 page mapping 到 user space,page 中包含一些 system call 相關的變數(回傳值)和 system call 的實作。

透過下面的指令可以看到關於這個空間的相關資訊:

sudo cat /proc/1/maps | grep vsyscall
ffffffffff600000-ffffffffff601000 --xp 00000000 00:00 0    [vsyscall]

受益於此,某些 system call 的執行就可以如同一般的函式呼叫,不必切換進 kernel mode 再回來,減少了來一來一往之間的成本。

Mapping 的過程會在初始化時執行 map_vsyscall 完成。首先值得一提的是,根據 config 的不同會影響 map_vsyscall 是否存在實作,且是 "模擬" 的實作。這是因為 vsyscall 是可遺棄的 ABI,只是考慮某些舊的 libc 等程式的使用,而被保留的介面,而原因在於 vsyscall 使用的 page 必須在相同位置,這使得 ASLR 無法作用而導致了安全的問題。

#ifdef CONFIG_X86_VSYSCALL_EMULATION
extern void map_vsyscall(void);
...
#else
static inline void map_vsyscall(void) {}
...
#endif

往下來看 map_vsyscall 的實作。

void __init map_vsyscall(void)
{
	extern char __vsyscall_page;
	unsigned long physaddr_vsyscall = __pa_symbol(&__vsyscall_page);

	/*
	 * For full emulation, the page needs to exist for real.  In
	 * execute-only mode, there is no PTE at all backing the vsyscall
	 * page.
	 */
	if (vsyscall_mode == EMULATE) {
		__set_fixmap(VSYSCALL_PAGE, physaddr_vsyscall,
			     PAGE_KERNEL_VVAR);
		set_vsyscall_pgtable_user_bits(swapper_pg_dir);
	}

	if (vsyscall_mode == XONLY)
		gate_vma.vm_flags = VM_EXEC;

	BUILD_BUG_ON((unsigned long)__fix_to_virt(VSYSCALL_PAGE) !=
		     (unsigned long)VSYSCALL_ADDR);
}
  • __vsyscall_page symbol,可以看到其中包含的三種 system call
    • gettimeofday
    • time
    • getcpu
  • 透過 __pa_symbol 這個 macro 可以得到用來進行 vsyscall 的 page 之 physical address
  • 檢查 vsyscall_mode,如果是 EMULATE 模式下,就建立起 vrirtaul 和 physical 的對應
  • 建立對應的方法是 __set_fixmap__set_fixmap 使得 vsyscall 使用固定的 virtual address 來對應,其三個參數分別是:
    • VSYSCALL_PAGE 是一個 enum,只在 CONFIG_X86_VSYSCALL_EMULATION 被設置時定義,是 fix-mapped address 結構的索引值
    • physaddr_vsyscall 也就是我們得到 physical page,用來對應指定的 virtual page
    • 第3個參數則是 page 操作權限相關的 flag
enum fixed_addresses {
...

#ifdef CONFIG_X86_VSYSCALL_EMULATION
	VSYSCALL_PAGE = (FIXADDR_TOP - VSYSCALL_ADDR) >> PAGE_SHIFT,
#endif
  • set_vsyscall_pgtable_user_bits 調整 swapper_pg_dir,後者是 kernel 在初始階段使用的 page table,之後則作為每個 process 的 page table 在 kernel space 定址空間的 memory mapping 模版(因為所有的 process 的 kernel space mapping 完全相同,但每個 process 有獨立的 page table),使得 VSYSCALL_ADDR 關聯的 page 和 page table entry 允許 user mode 下的存取

32位arm linux,为什么内核在fork进程的时候要复制(memcpy)内核PGD init页表(swapper_pg_dir)?

  • XONLY 模式下,則沒有建立相關的 page 對應
  • BUILD_BUG_ON 額外檢查 VSYSCALL_PAG index 到的 page 是預期的 VSYSCALL_ADDR 位置

vsyscall 可分為三種模式:

  • NONE 模式下,vsyscall 將無法使用
  • EMULATE 模式下,因為 page 被設置為不可執行(這是不是代表 mapping 的內容其實不是很重要?),因此 vsyscall 嘗試去執行 page 時會發生 page fault,page fault handler 通過某些條件得知 page fault 的發生原因是因為 vsyscall 且模式為 EMULATE 時,就會呼叫 emulate_vsyscall 來模擬 vsyscall 的行為
  • XONLY 模式則是考慮到 page 仍存在的可讀性產生的一定的風險,假設 user space 的程式不依賴 page 的內容,要保留的只有對特定位址存取時產生 vsyscall 的處理,則可以採用 XONLY 模式

可參考 kernel-parameters.txt: vsyscall 的說明。

Linux kernel 的變動是很迅速的,相較於 System calls in the Linux kernel. Part 3. 的說明,到我目前所在的版本(5e46d1b)已經刪除了 NATIVE 模式,而有另外的 XONLY 模式。侷限於我有限的知識,不敢妄加對更多的流程細節做解釋,因此更深入的程式碼閱讀暫時就不補充QQ

Introduction to vDSO

vDSO 又名 virtual dynamic shared object,是取代 vsyscall 的新機制。與 vsyscall 不同的是,相比於把 page 映射到固定的 virtual address,vDOS 是以由 kernel 提供的 shared object 的形式,映射到每個 user process 的,也因此得以通過 ASLR 來強化其安全性。

Reference

TODO