系统调用是操作系统为用户态程序提供内核服务的唯一受控入口。无论是文件读写、进程创建、网络通信还是内存映射,最终都要通过**系统调用(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 软中断指令触发系统调用。它的工作原理是:
- CPU 执行
INT 0x80,引发特权级切换(ring 3 -> ring 0) - CPU 查找 IDT(中断描述符表)的第 0x80 项,获取中断处理程序的地址
- 保存用户态上下文(寄存器压栈),跳入
system_call入口 - 内核处理完请求后,
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 的核心优化在于:
- 专用 MSR 寄存器保存了内核入口地址(
MSR_LSTAR)和段选择子(MSR_STAR),无需查 IDT - 只有最少量的寄存器在硬件层面被保存:
RCX保存用户态返回地址(RIP),R11保存用户态 RFLAGS - 其余上下文由软件在入口汇编代码中保存到每 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 Number | RAX |
| Arg 1 | RDI |
| Arg 2 | RSI |
| Arg 3 | RDX |
| Arg 4 | R10(注意不是 RCX,因为 RCX 被 SYSCALL 占用了) |
| Arg 5 | R8 |
| Arg 6 | R9 |
返回值通过 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 无侵入地追踪系统调用的进阶方案
延伸阅读
- 《Linux Kernel Development》第 5 章 —— 系统调用设计与实现
- Intel SDM Vol. 2B —— SYSCALL/SYSRET 指令集详细说明
- LWN.net: “A new system call入口 mechanism for x86-64”
- Linux 源码:
arch/x86/entry/目录下的 entry_64.S 与 common.c - 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;
}
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。