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                   ; 進入核心
; 返回值在 rax

syscall 指令做的事:

  1. MSR_LSTAR 讀核心進入點位址,跳過去
  2. 把 RFLAGS 存進 r11、把 RIP 存進 rcx
  3. 切到 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 還要:

  1. include/uapi/asm-generic/unistd.h 或對應架構的 syscall_*.tbl 加編號
  2. include/linux/syscalls.h 宣告原型
  3. userspace 可用 syscall(__NR_my_syscall, ...) 或 glibc wrapper 呼叫

加新 syscall 是一件極為嚴肅的事 ── 一旦進入主線,就是永遠的 ABI 承諾。多數新功能改用 prctlioctlnetlinkbpf 來避免擴張 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 不能改#

概念含義
APIsource-level 介面(標頭檔、函式名、參數)
ABIbinary-level 介面(暫存器約定、結構體 layout、syscall 編號)

「不能改 syscall」指的是 ABI ── 一個 2010 年編譯出來的 binary 必須仍能在今天的核心上跑。所以:

  • 不能修改 syscall 編號
  • 不能修改 syscall 參數順序與型別
  • 不能變更 syscall 的回傳值意義
  • 結構體(如 struct stat)不能隨便加欄位(會改 sizeof)

因此核心常見「statstat64statx」這種一代代加上去的設計 ── 老的不能動,新功能只能新開接口。

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(如 gettimeofdayclock_gettimegetpid)走 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 ls

eBPF 是現代追 syscall 的方式(bpftracebcc 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