syscall 是什麼#
System call 是 userspace 唯一合法進入 kernel 的門。printf ➡️ write ➡️ 進入核心 ➡️ 寫到 tty driver ➡️ 出現在你的螢幕。中間那一步「進入核心」就是 syscall。
從 CPU 視角,syscall 是一個特權等級切換:
User mode (ring 3) Kernel mode (ring 0)
────────────────────── ──────────────────────
prepare arguments
syscall instruction ──► trap to kernel entry
save user regs to kernel stack
look up syscall_table[nr]
dispatch to sys_xxx(...)
sys_xxx returns rax
restore regs
return from syscall ◄── sysret instruction
check return value關鍵性質:
- 單向:使用者程式不能直接呼叫核心函式,只能下「請求單」
- 同步:
syscall指令會等核心做完才回來(除非EAGAIN/EINTR) - 有界:核心透過固定的
syscall_table派發,userspace 不能跳到任意位址
CPU 機制:三種主流方式#
x86-64:syscall / sysret#
; userspace 約定(Linux x86-64 ABI)
mov rax, <syscall_nr> ; 編號
mov rdi, arg1
mov rsi, arg2
mov rdx, arg3
mov r10, arg4 ; 注意:是 r10 不是 rcx,因為 syscall 會用 rcx 存返回 RIP
mov r8, arg5
mov r9, arg6
syscall ; 進入核心
; 返回值在 raxsyscall 指令做的事:
- 從
MSR_LSTAR讀核心進入點位址,跳過去 - 把 RFLAGS 存進 r11、把 RIP 存進 rcx
- 切到 ring 0
ARM64:svc#
mov x8, #<syscall_nr> ; ARM64 用 x8 放編號
mov x0, arg1
mov x1, arg2
; ...
svc #0 ; supervisor call
; 返回值在 x0從 EL0 跳到 EL1。
古老的 x86:int 0x80#
舊 32-bit Linux 用軟中斷:
mov eax, <nr>
int 0x80慢得多,64-bit 上仍可用(為相容),但沒理由用它。
編號表:syscall_table#
每個架構有一張表,編號 ➡️ 函式:
linux/arch/x86/entry/syscalls/syscall_64.tbl
0 common read sys_read
1 common write sys_write
2 common open sys_open
3 common close sys_close
...執行 syscall 後,核心入口(entry_SYSCALL_64)做的事:
// 概念碼
asmlinkage long do_syscall(struct pt_regs *regs)
{
unsigned long nr = regs->orig_rax;
if (nr >= NR_syscalls)
return -ENOSYS;
return sys_call_table[nr](regs);
}sys_call_table 是個函式指標陣列。
核心側:定義一個 syscall#
定義 syscall 用 SYSCALL_DEFINEx,x 是參數個數:
#include <linux/syscalls.h>
SYSCALL_DEFINE2(my_syscall, int, fd, const char __user *, msg)
{
char buf[64];
if (strncpy_from_user(buf, msg, sizeof(buf)) < 0)
return -EFAULT;
pr_info("my_syscall(fd=%d, msg=%s)\n", fd, buf);
return 0;
}展開後大致是 asmlinkage long sys_my_syscall(int fd, const char __user *msg),並帶上各種 audit/tracing 鉤子。
定義新 syscall 還要:
- 在
include/uapi/asm-generic/unistd.h或對應架構的syscall_*.tbl加編號 - 在
include/linux/syscalls.h宣告原型 - userspace 可用
syscall(__NR_my_syscall, ...)或 glibc wrapper 呼叫
加新 syscall 是一件極為嚴肅的事 ── 一旦進入主線,就是永遠的 ABI 承諾。多數新功能改用
prctl、ioctl、netlink、bpf來避免擴張 syscall table。Linus 對「再加一個 syscall」的耐心很有限。
glibc 怎麼包#
// userspace
ssize_t n = read(fd, buf, count);glibc 的 read 大致是:
ssize_t read(int fd, void *buf, size_t count) {
return syscall(__NR_read, fd, buf, count);
// 等同於 inline asm: syscall instruction with rax=0, rdi=fd, ...
}__NR_read = 0(x86-64)── 編譯期常數,由 glibc 從 <asm/unistd.h> 拿到。
ABI vs API:為什麼 syscall 不能改#
| 概念 | 含義 |
|---|---|
| API | source-level 介面(標頭檔、函式名、參數) |
| ABI | binary-level 介面(暫存器約定、結構體 layout、syscall 編號) |
「不能改 syscall」指的是 ABI ── 一個 2010 年編譯出來的 binary 必須仍能在今天的核心上跑。所以:
- 不能修改 syscall 編號
- 不能修改 syscall 參數順序與型別
- 不能變更 syscall 的回傳值意義
- 結構體(如
struct stat)不能隨便加欄位(會改 sizeof)
因此核心常見「stat、stat64、statx」這種一代代加上去的設計 ── 老的不能動,新功能只能新開接口。
strace -c ls # 看一個程式呼叫了哪些 syscall、各幾次進入核心後的安全檢查#
用一個 syscall 例子說明核心要做哪些檢查:
SYSCALL_DEFINE3(read, unsigned int, fd, char __user *, buf, size_t, count)
{
// 1. 把 fd 翻譯成 struct file *(含計數遞增)
struct fd f = fdget(fd);
ssize_t ret = -EBADF;
if (!f.file) return ret;
// 2. 檢查 file 有 read 權限
if (!(f.file->f_mode & FMODE_READ)) goto out;
// 3. 呼叫 fop->read(會做 copy_to_user,自動處理 user pointer)
ret = vfs_read(f.file, buf, count, &f.file->f_pos);
out:
fdput(f);
return ret;
}每次 syscall 都隱含:
- 參數驗證:fd 是否合法、size 是否爆掉
- 權限檢查:能力(capabilities)、SELinux/AppArmor LSM hook
- 資源計數:fd 引用、page 引用
- 使用者指標處理:必經
copy_to/from_user,禁止裸memcpy
任何一個步驟省略都是漏洞。歷年 CVE 一大半是 syscall 邊界檢查不嚴。
快路徑與慢路徑:vDSO#
頻繁的 syscall(如 gettimeofday、clock_gettime、getpid)走 trap 太貴 ── 一次大概 100~500 cycles。Linux 用 vDSO(virtual dynamic shared object) 把幾個極熱的「不需要核心特權」的函式做成共享庫,映射到每個行程的位址空間:
cat /proc/self/maps | grep vdso
# 7ffd...000-7ffd...000 r-xp 00000000 00:00 0 [vdso]當 userspace 呼叫 clock_gettime,glibc 會優先走 vDSO 版本:直接從共享頁讀核心更新的時鐘資料 ── 完全不用切換特權等級。
從 perf 角度,這把幾個常見 syscall 從 ~200ns 壓到 ~10ns。
訊號與 EINTR#
如果 syscall 在阻塞時收到訊號,會被打斷。傳統行為兩種:
| 行為 | 何時發生 |
|---|---|
回傳 -EINTR | 老式:userspace 必須自己重試 |
| 自動重啟 | SA_RESTART 標記的訊號處理器:核心會在 sigreturn 時補做一次 syscall |
寫 robust code 必須記得:
ssize_t n;
do {
n = read(fd, buf, len);
} while (n == -1 && errno == EINTR);觀測 syscall#
strace ls # 看每個 syscall
strace -e openat ls # 只看 openat
strace -c -p $(pidof firefox) # 統計各 syscall 次數與時間perf trace 是更輕量的替代:
sudo perf trace -e read,write lseBPF 是現代追 syscall 的方式(bpftrace、bcc tools):
sudo bpftrace -e 'tracepoint:syscalls:sys_enter_openat { printf("%s %s\n", comm, str(args->filename)); }'io_uring:批次化的未來#
傳統 syscall 一次只能處理一個請求 ── 大量 I/O 場景下,syscall 切換本身就成為瓶頸。io_uring(5.1+)引入兩個共享 ring buffer(submission queue + completion queue),讓 userspace 把多個請求批次提交,核心非同步處理,完成時放進完成佇列。理論上一次 syscall 可提交數千個 I/O 請求。
userspace kernel
───────── ──────
submit ring ────────────► consume requests
write entry
do I/O asynchronously
write completion
complete ring ◄───────────
read entry對極致 I/O 應用(Database、Proxy)這是革命性的;普通 app 不需要。
小結#
- syscall 是 user/kernel 唯一合法邊界,靠 CPU 特權等級保證
- 每個架構有自己的 syscall 指令與寄存器約定
- syscall_table 是核心派發點;
SYSCALL_DEFINEx是寫法 - ABI 是水泥地:寫進去就拔不掉
- 熱 syscall 用 vDSO 規避 trap;批次 I/O 用 io_uring 規避 syscall 開銷
- 觀測工具:strace、perf trace、bpftrace