系统调用实现:从 glibc 到内核态切换

系统调用是用户态与内核态交互的唯一门户。本文从系统调用号的定义与 sys_call_table 出发,深入解析 glibc 封装、INT 0x80 与 SYSENTER/SYSCALL 快速路径的演进,讲解 vDSO 用户态优化、参数传递的寄存器约定以及内核态进出流程中的 pt_regs 结构,最后覆盖系统调用追踪与安全分析工具。

系统调用是操作系统为用户态程序提供内核服务的唯一受控入口。无论是文件读写、进程创建、网络通信还是内存映射,最终都要通过**系统调用(System Call)**穿越用户态与内核态的边界。理解系统调用的完整实现路径,不仅是操作系统原理的基础,也是性能优化、安全加固和内核调试的核心技能。

本文从系统调用号的定义与内核的 sys_call_table 出发,系统梳理 glibc 封装层、INT 0x80 到 SYSENTER/SYSCALL 的演进、vDSO 用户态优化、参数传递的寄存器约定,以及内核态进入和退出时的 pt_regs 结构,最后落脚到 strace、seccomp 等系统调用追踪与安全分析工具。


一、系统调用号与 sys_call_table

1.1 系统调用号的定义

在 Linux 中,每个系统调用都有一个唯一的整数标识——系统调用号(System Call Number)。用户态程序通过寄存器传递这个编号,告知内核需要执行哪个服务。

// Linux x86-64 部分系统调用号(定义于 arch/x86/entry/syscalls/syscall_64.tbl)
#define __NR_read              0
#define __NR_write             1
#define __NR_open              2
#define __NR_close             3
#define __NR_stat              4
#define __NR_fstat             5
#define __NR_lseek             8
#define __NR_mmap              9
#define __NR_mprotect          10
#define __NR_munmap            11
#define __NR_brk               12
#define __NR_rt_sigaction      13
#define __NR_rt_sigreturn      15
#define __NR_ioctl             16
#define __NR_pread64           17
#define __NR_pwrite64          18
#define __NR_readv             19
#define __NR_writev            20
#define __NR_access            21
#define __NR_pipe              22
...
#define __NR_exit              60
#define __NR_wait4             61
#define __NR_kill              62
#define __NR_uname             63

不同架构的系统调用号互不兼容。x86-32、x86-64、ARM64 各有独立的调用号表,这也是跨架构移植项目最容易踩的坑之一。

1.2 sys_call_table

内核中有一张全局的系统调用表(sys_call_table),它是一个函数指针数组,下标即系统调用号,元素指向对应的内核处理函数。所有系统调用的入口都会经过这张表进行分发。

// arch/x86/entry/syscall_64.c 简化示意
// sys_call_table 在编译时由 syscall_64.tbl 自动生成

asmlinkage const sys_call_ptr_t sys_call_table[] = {
    [0]  = sys_read,
    [1]  = sys_write,
    [2]  = sys_open,
    [3]  = sys_close,
    ...
    [60] = sys_exit,
    [61] = sys_wait4,
    ...
};

// 每个系统调用函数的签名遵循统一的约定
// asmlinkage 表示参数通过栈传递(早期 x86 约定)
asmlinkage long sys_read(unsigned int fd, char __user *buf, size_t count);

内核 5.x+ 引入了 SYSCALL_DEFINE 宏来自动生成系统调用的包装,保证参数类型检查与统一的入口行为。


二、glibc 的 syscall 包装

2.1 glibc 如何封装系统调用

用户程序通常不会直接使用 raw syscall,而是通过 glibc 提供的 POSIX API。以 read 为例:

// 应用程序调用
ssize_t n = read(fd, buf, 1024);

// glibc 内部的调用链(极度简化)
// read() -> __libc_read() -> INT $0x80 / syscall 指令

glibc 中系统调用的底层通用封装函数是 syscall():

#include <unistd.h>
#include <sys/syscall.h>

// 直接发起任意系统调用
long syscall(long number, ...);

// 示例:直接使用 syscall 调用 write
char msg[] = "Hello, Kernel!\n";
syscall(__NR_write, STDOUT_FILENO, msg, sizeof(msg) - 1);

glibc 还利用 vDSO(后面详述)绕过了真正的陷入内核,让某些无状态、纯查询的系统调用(如 gettimeofday、clock_gettime、getcpu)完全在用户态完成。

2.2 自实现 syscall 包装

在了解底层细节后,可以用内联汇编实现一个极简的 x86-64 syscall:

// x86-64 Linux syscall 自实现
#include <unistd.h>

static inline long my_syscall3(long n, long a1, long a2, long a3) {
    long ret;
    __asm__ volatile (
        "mov %1, %%eax\n\t"
        "mov %2, %%edi\n\t"
        "mov %3, %%esi\n\t"
        "mov %4, %%edx\n\t"
        "syscall\n\t"
        "mov %%eax, %0\n\t"
        : "=r" (ret)
        : "r" (n), "r" (a1), "r" (a2), "r" (a3)
        : "rax", "rdi", "rsi", "rdx", "rcx", "r11", "memory"
    );
    return ret;
}

// 用自实现 syscall 写一段字符串
int main() {
    const char msg[] = "Hello from raw syscall!\n";
    my_syscall3(__NR_write, 1, (long)msg, sizeof(msg) - 1);
    my_syscall3(__NR_exit, 0, 0, 0);
    return 0;
}

编译运行:

gcc -o raw_syscall raw_syscall.c -nostdlib && ./raw_syscall

-nostdlib 表示不链接 glibc,全部依赖自实现的极简逻辑。此示例也说明了系统调用是 C 库的最底层根依赖。


三、从 INT 0x80 到 SYSENTER/SYSCALL 快速路径

3.1 传统方式:INT 0x80

早期的 x86 Linux 使用 INT 0x80 软中断指令触发系统调用。它的工作原理是:

  1. CPU 执行 INT 0x80,引发特权级切换(ring 3 -> ring 0)
  2. CPU 查找 IDT(中断描述符表)的第 0x80 项,获取中断处理程序的地址
  3. 保存用户态上下文(寄存器压栈),跳入 system_call 入口
  4. 内核处理完请求后,IRET 指令恢复用户态上下文并返回
; INT 0x80 入口(极度简化)
entry_INT80_32:
    pushl %eax              ; 系统调用号
    SAVE_ALL                ; 保存所有寄存器到栈
    call *sys_call_table(,%eax,4)  ; 调用处理函数
    RESTORE_ALL             ; 恢复寄存器
    iret                    ; 返回用户态

INT 0x80 的问题:每次系统调用都伴随完整的中断门查表(IDT lookup)、段寄存器切换和微码级别的陷入开销,在现代 CPU 上单次调用约需数百个时钟周期。

3.2 快速路径:SYSENTER / SYSCALL

Intel Pentium II 引入了 SYSENTER 指令,AMD64 引入了 SYSCALL。现代 x86-64 Linux 以 SYSCALL 为默认入口。

SYSCALL 的核心优化在于:

  1. 专用 MSR 寄存器保存了内核入口地址(MSR_LSTAR)和段选择子(MSR_STAR),无需查 IDT
  2. 只有最少量的寄存器在硬件层面被保存:RCX 保存用户态返回地址(RIP),R11 保存用户态 RFLAGS
  3. 其余上下文由软件在入口汇编代码中保存到每 CPU 的栈上
; x86-64 SYSCALL 入口(entry_SYSCALL_64)
entry_SYSCALL_64:
    swapgs                   ; 切换到内核 GS 段(per-cpu 数据)
    movq    %rsp, PER_CPU_VAR(rsp_scratch)
    movq    PER_CPU_VAR(cpu_current_top_of_stack), %rsp

    ; 构建 pt_regs 结构体
    pushq   %r11              ; 保存用户态 RFLAGS
    pushq   %rcx              ; 保存用户态返回地址
    pushq   %rax              ; 系统调用号
    pushq   %rdi              ; 参数 1
    pushq   %rsi              ; 参数 2
    pushq   %rdx              ; 参数 3
    pushq   %r10              ; 参数 4(SYSCALL 约定:r10 替代 rcx)
    pushq   %r8               ; 参数 5
    pushq   %r9               ; 参数 6

    call    do_syscall_64     ; C 代码处理

    popq    %r9
    popq    %r8
    popq    %r10
    popq    %rdx
    popq    %rsi
    popq    %rdi
    popq    %rax
    popq    %rcx              ; 恢复用户态 RIP
    popq    %r11              ; 恢复用户态 RFLAGS
    swapgs
    sysretq                   ; 快速返回用户态

单次 SYSCALL 调用的开销约为 INT 0x80 的 1/3 到 1/2。


四、vDSO 用户态优化

4.1 什么是 vDSO

vDSO(Virtual Dynamic Shared Object)是内核映射到每个用户进程地址空间的一段共享内存页。它包含了一些无需陷入内核即可执行的系统调用替代函数。用户态进程可以通过普通的函数调用来访问这些代码,速度比真正的 syscall 快数十倍。

# 查看进程映射中的 vDSO
$ cat /proc/self/maps | grep vdso
7ffc3b4f7000-7ffc3b4f9000 r-xp 00000000 00:00 0 [vdso]

当前常见的 vDSO 函数包括:

  • __vdso_clock_gettime — clock_gettime(2) 的用户态实现
  • __vdso_gettimeofday — gettimeofday(2)
  • __vdso_time — time(2)
  • __vdso_getcpu — getcpu(2)

4.2 vDSO 的工作原理

vDSO 的实现利用了用户态可读的内核数据结构。以 gettimeofday 为例:

// vDSO 中的 gettimeofday 简化逻辑
int __vdso_gettimeofday(struct timeval *tv, struct timezone *tz) {
    // 直接读取内核每 CPU 共享的时钟页(timekeeper page)
    struct vdso_data *vdata = __arch_get_vdso_data();
    u64 nsec = vdata->xtime_clock_sec * NSEC_PER_SEC
             + vdata->xtime_clock_nsec;
    // 将纳秒转换为 timeval
    tv->tv_sec = nsec / NSEC_PER_SEC;
    tv->tv_usec = (nsec % NSEC_PER_SEC) / 1000;
    return 0;
}

内核在 tick 中断和上下文切换时维护这个共享页的时间戳,用户态只需读取无需陷入,因此时延在 10~20 纳秒级别,比系统调用快 1-2 个数量级。

4.3 vDSO 调用的性能比较

#include <time.h>
#include <stdio.h>
#include <sys/time.h>

int main() {
    struct timespec ts;
    struct timeval tv;

    // vDSO 路径(如果 glibc 使用 vDSO)
    for (int i = 0; i < 1000000; i++)
        clock_gettime(CLOCK_REALTIME, &ts);

    // 传统工艺(syscall)—— glibc 会自动选 vDSO,
    // 但若使用 syscall() 直接调用则是传统路径
    for (int i = 0; i < 1000000; i++)
        syscall(SYS_clock_gettime, CLOCK_REALTIME, &ts);

    return 0;
}

用 perf stat 分析可见:vDSO 路径几乎零系统调用计数,而 syscall() 路径有 100 万次上下文切换。


五、参数传递与 pt_regs

5.1 寄存器传参约定

x86-64 Linux 系统调用的参数通过寄存器传递:

参数位置寄存器
Syscall NumberRAX
Arg 1RDI
Arg 2RSI
Arg 3RDX
Arg 4R10(注意不是 RCX,因为 RCX 被 SYSCALL 占用了)
Arg 5R8
Arg 6R9

返回值通过 RAX 传递。如果返回值为负数(如 -ENOENT),glibc 将其包装为 errno 并返回 -1。

5.2 pt_regs:内核态的上下文快照

当 CPU 从用户态陷入内核时,所有寄存器被保存在当前栈上的一个 struct pt_regs 结构中。这是内核调试、kprobe 和信号处理的基础。

// arch/x86/include/asm/ptrace.h
struct pt_regs {
    unsigned long r15;
    unsigned long r14;
    unsigned long r13;
    unsigned long r12;
    unsigned long rbp;
    unsigned long rbx;
    unsigned long r11;      // RFLAGS 在用户态时的值
    unsigned long r10;
    unsigned long r9;
    unsigned long r8;
    unsigned long rax;      // 系统调用号 / 返回值
    unsigned long rcx;      // 用户态 RIP(被 SYSCALL 覆盖)
    unsigned long rdx;
    unsigned long rsi;
    unsigned long rdi;
    unsigned long orig_rax; // 原始系统调用号
    unsigned long rip;      // 出错指令地址(中断时)
    unsigned long cs;
    unsigned long eflags;
    unsigned long rsp;
    unsigned long ss;
};

在系统调用入口,do_syscall_64 接收的正是 struct pt_regs *:

// arch/x86/entry/common.c
__visible void do_syscall_64(unsigned long nr, struct pt_regs *regs) {
    // nr = regs->orig_rax(系统调用号)
    if (nr < NR_syscalls) {
        regs->ax = sys_call_table[nr](
            regs->di, regs->si, regs->dx,
            regs->r10, regs->r8, regs->r9
        );
    }
}

六、系统调用追踪:strace 与 seccomp

6.1 用 strace 追踪系统调用

strace 是 Linux 下最常用的系统调用追踪工具,它利用 ptrace 系统调用拦截目标进程的每次 syscall 入口和出口。

# 基本用法
$ strace ls /tmp

# 统计每个系统调用的耗时与次数
$ strace -c ls /tmp
% time     seconds  usecs/call     calls    errors syscall
------ ----------- ----------- --------- --------- ----------------
 45.23    0.001234           2       500           mmap
 20.11    0.000549           1       400           openat
...

# 追踪文件操作相关的系统调用
$ strace -e trace=file,desc ls /tmp

# 追踪并输出到文件
$ strace -o trace.log -f -s 1024 ./myprogram

strace 会显著拖慢目标进程(因为它是基于 ptrace 的逐指令追踪),不适合生产环境的持续监控。生产环境更推荐使用 eBPF 工具如 bcc/syscount 或 bpftrace。

6.2 seccomp 安全过滤

seccomp(Secure Computing Mode)是 Linux 内核的沙箱机制,允许进程声明自己只使用白名单内的系统调用。违反白名单的调用会被内核终止进程(SIGKILL)或返回错误。

#include <linux/seccomp.h>
#include <sys/syscall.h>
#include <unistd.h>

static void install_seccomp(void) {
    struct sock_filter filter[] = {
        // 验证架构
        BPF_STMT(BPF_LD | BPF_W | BPF_ABS,
                 (offsetof(struct seccomp_data, arch))),
        BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, AUDIT_ARCH_X86_64, 1, 0),
        BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_KILL),

        // 加载系统调用号
        BPF_STMT(BPF_LD | BPF_W | BPF_ABS,
                 (offsetof(struct seccomp_data, nr))),

        // 允许 read、write、exit、exit_group
        BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_read, 0, 1),
        BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW),
        BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_write, 0, 1),
        BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW),
        BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_exit, 0, 1),
        BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW),
        BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_exit_group, 0, 1),
        BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW),

        // 其余全部拒绝
        BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_KILL),
    };

    struct sock_fprog prog = {
        .len = sizeof(filter) / sizeof(filter[0]),
        .filter = filter,
    };

    prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0);
    syscall(__NR_seccomp, SECCOMP_SET_MODE_FILTER,
            SECCOMP_FILTER_FLAG_TSYNC, &prog);
}

int main() {
    install_seccomp();
    write(1, "Hello (allowed)\n", 16);
    // open 不在白名单中,触发 SIGKILL
    // open("/etc/passwd", O_RDONLY);
    return 0;
}

Docker 默认 seccomp profile 已过滤了 44 个危险系统调用;systemd 也支持 SystemCallFilter= 配置。


相关阅读

  • https://plumephp.com/os-linux-process/ —— Linux 进程管理与进程创建(fork/exec)中涉及的系统调用分析
  • https://plumephp.com/os-linux-signals/ —— 信号机制与系统调用被信号中断的处理逻辑
  • https://plumephp.com/os-ebpf-observability/ —— 用 eBPF 无侵入地追踪系统调用的进阶方案

延伸阅读

  1. 《Linux Kernel Development》第 5 章 —— 系统调用设计与实现
  2. Intel SDM Vol. 2B —— SYSCALL/SYSRET 指令集详细说明
  3. LWN.net: “A new system call入口 mechanism for x86-64”
  4. Linux 源码:arch/x86/entry/ 目录下的 entry_64.S 与 common.c
  5. Manpages: syscall(2), vdso(7), seccomp(2), ptrace(2)

// ============================================================
// 完整可运行示例:自实现 syscall 与 vDSO 调用对比
// x86-64 Linux only
// 编译: gcc -o syscall_demo syscall_demo.c
// ============================================================
#include <stdio.h>
#include <sys/syscall.h>
#include <unistd.h>
#include <sys/time.h>
#include <time.h>

// 自实现 syscall3
static inline long my_syscall3(long n, long a1, long a2, long a3) {
    long ret;
    __asm__ volatile (
        "mov %1, %%eax\n\t"
        "mov %2, %%edi\n\t"
        "mov %3, %%esi\n\t"
        "mov %4, %%edx\n\t"
        "syscall\n\t"
        "mov %%eax, %0\n\t"
        : "=r" (ret)
        : "r" ((int)n), "r" (a1), "r" (a2), "r" (a3)
        : "rax", "rdi", "rsi", "rdx", "rcx", "r11", "memory"
    );
    return ret;
}

int main() {
    const char msg1[] = "[raw syscall] Hello from inline asm!\n";
    const char msg2[] = "[glibc] Hello from write()!\n";

    // 方法 1:自实现 syscall
    my_syscall3(SYS_write, 1, (long)msg1, sizeof(msg1) - 1);

    // 方法 2:glibc 封装(内部可能走 vDSO,但 write 必然 syscall)
    write(STDOUT_FILENO, msg2, sizeof(msg2) - 1);

    // 方法 3:glibc 封装 clock_gettime(走 vDSO,极快)
    struct timespec ts;
    clock_gettime(CLOCK_MONOTONIC, &ts);
    printf("[vDSO] MONOTONIC time: %ld.%09ld\n", ts.tv_sec, ts.tv_nsec);

    // 方法 4:直接 syscall 调用 clock_gettime(不走 vDSO)
    syscall(SYS_clock_gettime, CLOCK_MONOTONIC, &ts);
    printf("[syscall] MONOTONIC time: %ld.%09ld\n", ts.tv_sec, ts.tv_nsec);

    return 0;
}

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「os」更多文章

  1. 内核调试:kgdb、kdump、crash 与动态追踪
  2. cgroups v2 与命名空间底层实现与资源隔离
  3. Linux 设备驱动模型:字符设备、块设备与 sysfs