Linux 系统调用的完整执行原理
下面是一个简单的 C 程序。它调用了 clock_gettime() 三次,然后向标准输出写入五个字节。
#include <stdio.h>
#include <time.h>
#include <unistd.h>
int main(void)
{
struct timespec ts;
for (int i = 0; i < 3; i++)
clock_gettime(CLOCK_MONOTONIC, &ts);
write(1, "done\n", 5);
return 0;
}
这两者看起来都像系统调用。它们都在向内核索要程序自身拿不到的东西:当前时间,以及对文件描述符的访问权限。
现在用 strace 运行它——strace 会报告进程发出的每一个系统调用:
gcc -O0 -o mystery mystery.c
strace ./mystery 2>&1 | grep -c clock_gettime
答案是 0。
write() 立刻出现在输出里,而三次 clock_gettime() 调用完全没有踪影。同一个程序、同一份 libc、同一台机器,其中一次却从未到达内核。
读完这篇文章,你会清楚从 write() 到内核中实际执行代码之间的每一步,明白为什么返回路径比进入路径更诡异,以及 clock_gettime() 为什么能跳过整个过程。
目录
你需要什么
一台运行 Linux 的 x86-64 机器,加上 gcc、strace 和 objdump。在 Debian 或 Ubuntu 上安装 build-essential、strace 和 binutils 即可。你需要能读懂 C 代码。不需要写过内核代码,这里也不会构建或安装内核。
以下内容都在普通用户账号下运行,只有一个可选的追踪步骤需要 sudo。
先说两个范围限定。第一,本文只讲 x86-64。ARM64 做的事一样,但指令集和寄存器规则都不同,如果每句话都兼顾两种架构,篇幅翻倍、清晰度减半。第二,内核内部实现会随版本变化。本文所有实验基于 Linux 5.15(Ubuntu 22.04,Intel Core i7-10750H),新版内核有差异的地方我会特别标注。用 uname -r 查看你自己的内核版本。
从用户态看系统调用长什么样
先纠正一个容易搞错的概念:write() 不是系统调用。它只是 C 库里的一个普通 C 函数,真正替你发系统调用的就是这个函数。这两者的区别,正是大多数人对内核产生困惑的起点。
把 libc 去掉、自己发调用,就能验证这一点。
在 x86-64 上,系统调用有固定约定:把要调用的编号放进 rax,参数依次放进 rdi、rsi、rdx、r10、r8、r9,然后执行一条 syscall 指令。
这些编号不用背,它们就写在你机器上的一个头文件里:
grep -E "__NR_(write|getpid|clock_gettime) " /usr/include/x86_64-linux-gnu/asm/unistd_64.h
在我的机器上:
#define __NR_write 1
#define __NR_getpid 39
#define __NR_clock_gettime 228
所以 write 的调用编号是 1。下面这段手写代码完全绕过了 libc 封装:
static long raw_write(int fd, const void *buf, unsigned long count)
{
long ret;
__asm__ volatile (
"syscall"
: "=a" (ret) /* the result comes back in rax */
: "a" (1L), /* rax = 1, the syscall number for write */
"D" ((long)fd), /* rdi = first argument */
"S" (buf), /* rsi = second argument */
"d" (count) /* rdx = third argument */
: "rcx", "r11", "memory"
);
return ret;
}
编译运行之后,你的数据就会出现在标准输出上,整条路径不经过 libc 的任何代码。
看最后一行,那个 clobber 列表。它告诉编译器 rcx 和 r11 会被破坏。我加这个不是为了保险,而是硬件的既定事实——它也悄悄解释了上面那个调用约定里一个奇怪的地方。
x86-64 上,C 函数用 rcx 传递第四个参数,而系统调用却用 r10。所有解释为“因为约定就是这样”的说法都停在了一个不够深入的层面。真正的原因是 syscall 指令在执行过程中会覆盖 rcx。内核就算想在这里接收第四个参数也做不到,所以 ABI 只能绕开自家硬件的限制。
这是第一个信号:这条边界并不是披着函数调用外衣的东西。机制不同,规则不同,而硬件早就把坑占好了。
你可以在编译后的二进制文件里看到这条指令:
objdump -d --no-show-raw-insn raw_write | grep -B2 -A2 syscall
它就在那里:
118b: mov -0x28(%rbp),%rdx
118f: syscall
1191: mov %rax,-0x8(%rbp)
三行代码:加载一个寄存器,执行一条指令,把返回值存起来。本文接下来要讲的全部内容,都发生在第二行和第三行之间。
跨越边界
当 CPU 执行 syscall 时,它会做一件普通跳转指令做不到的事:改变处理器的特权级。你的代码运行在 x86 所称的 ring 3,而内核代码运行在 ring 0。
这条指令按顺序做三件事:
把程序中下一条指令的地址保存到
rcx。这是返回地址,也是rcx被破坏的原因。把 CPU 标志寄存器保存到
r11。从三个特殊的 CPU 寄存器中加载新的指令指针、代码段和栈段。
第三步才是关键。新的指令指针不来自你的程序,而是来自一个叫 LSTAR 的机器相关寄存器,而这个寄存器是内核在启动时写入的。
这就是整个设计赖以立足的安全属性。用户态只能触发切换,不能决定落在哪里。入口只有一道门,是内核装上去的,通向 arch/x86/entry/entry_64.S 里的 entry_SYSCALL_64。
注意这条指令没有做什么:不查中断描述符表,不压异常帧。老系统通过 int 0x80 进入内核,那是个附带全套机制的软件中断,慢得很。syscall 指令的出现,正是因为这条路径值得为它造专用硬件。
变成内核
到达 entry_SYSCALL_64 并不意味着内核已经准备好执行代码了。到达的那一刻,CPU 确实处于 ring 0,但用的还是你的栈和你的寄存器状态。内核必须先处理这些,才能安全地做任何事。
接下来发生三件事,本质上都是内核在一台它正在运行的机器上重建信任:
1. swapgs
内核把每 CPU 的指针放在 GS 寄存器里,用来定位自身的数据结构。你的程序运行期间,GS 里装的是你的程序放的东西。一条 swapgs 指令就把用户态的值换成内核的。内核文档对这条指令说得异常直白:易碎,嵌套必须完美。搞错了,无论哪个方向,你接下来的"下午"会非常难熬。
2. 切换栈
你的栈指针是程序自己选的值,内核不能信。它把你的 rsp 暂存起来,切到为该线程分配的内核栈。
3. 构造 pt_regs
内核接着按特定顺序把你的寄存器依次压到新栈上,拼成一个 C 结构体 struct pt_regs。pt_regs 就是你的进程,冻住了。检查挂起进程的调试器、修改返回上下文的信号处理器、读取参数的系统调用处理函数,全都从这个结构体里取数据。
可能有第四步。如果你的 CPU 受 Meltdown 影响,内核会在此处切换页表,因为在那类芯片上,内核内存无法在用户代码运行时保持安全映射。
这次切换不是免费的。这就是 2018 年系统调用明显变慢的原因,也是本文后面某些数字在三年前的机器上会不一样的原因。
你可以检查自己的机器是否在为此付出代价:
cat /sys/devices/system/cpu/vulnerabilities/meltdown
本文的测试机器报告的是 Not affected,因为它的芯片代际已在硬件层面修复了该漏洞。一台较老的笔记本则会报告 Mitigation: PTI,它发出的每个系统调用都恰好在这一点上多做了额外工作。
既然已经在那儿了,不妨也看看该目录下的其他文件。每一个都代表这条边界可能正在为之买单的一种缓解措施。
内核内部:查找处理器
此时内核已在自己的栈上运行,你的寄存器已被安全捕获。它调用 C 函数 do_syscall_64,传入两个东西:你的 pt_regs,以及你留在 rax 里的系统调用号。
分发逻辑短到可以完整列出来。内核检查你的编号是否在范围内,将其钳位,然后跳转到对应的处理器:
if (likely(nr < NR_syscalls)) {
nr = array_index_nospec(nr, NR_syscalls);
regs->ax = x64_sys_call(regs, nr);
}
其中有两点需要解释。
array_index_nospec 是一种 Spectre 缓解措施。在推测执行 CPU 上,单纯的范围检查不够用,因为处理器可能在检查出结果之前就超前运行、触及表格末尾之外的内存。这个辅助函数强制以推测无法绕过的方式对索引进行钳位。
x64_sys_call 是许多旧解释出错的地方,包括一些至今还排在搜索结果靠前的内容。它们会说内核通过一个名为 sys_call_table 的函数指针数组来索引。这在很多年前是对的,但现在分发机制已不再是这样。从内核 6.9 起,x64_sys_call 是一个由生成的 switch 语句,直接调用各处理器。
背后是一连串连锁反应。Spectre 缓解措施使得通过函数指针的间接调用代价高昂,因为每次都要经过一个 retpoline。
直接函数调用的 switch 完全避开了这个开销。查找表依然存在,因为追踪工具还要用它,但热点路径已经不再读它了。在我这台 5.15 内核的机器上,旧的基于查找表的分发方式仍在使用,这正是这类文章里写明内核版本很重要的原因。
处理函数从哪里来
write 的处理函数名为 __x64_sys_write,但你在内核源码里找不到这个名字。它是由一个宏生成的:
SYSCALL_DEFINE3(write, unsigned int, fd, const char __user *, buf, size_t, count)
SYSCALL_DEFINE3 的意思是“一个接收三个参数的系统调用”。这个宏会展开成两个函数:真正的实现,以及一个名为 __x64_sys_write 的薄封装,它接收一个 struct pt_regs *,并从中提取出各个参数。
这层间接是刻意的。内核不会轻信用户态恰好留在参数寄存器里的东西,而是从自己构建并冻结的 struct 中解包出它期望的值。这与 array_index_nospec 出于同样的防御本能,只是应用在了函数调用的形态上。
这些内容你不必照单全收。内核自带的追踪器 ftrace 可以让你亲眼看到处理函数的运行过程。
这一步需要进入 root shell,而不是每条命令都加 sudo,因为用来过滤输出(使其可读)的那个过滤器引用的是 shell 自身的进程 ID:
sudo -i
cd /sys/kernel/tracing
echo 0 > tracing_on
echo $$ > set_ftrace_pid # trace only this shell
echo function_graph > current_tracer
echo __x64_sys_write > set_graph_function
echo 1 > tracing_on
echo "trigger a write" > /dev/null # the call we want to catch
echo 0 > tracing_on
head -40 trace
如果没有 set_ftrace_pid 这一行,你会追踪到机器上的每一次 write 调用,在运行中的桌面上,这产生的输出多到根本没法读。
下面是测试系统上的结果,略有删减:
9) | __x64_sys_write() {
9) | ksys_write() {
9) | __fdget_pos() {
9) 0.124 us | __fget_light();
9) 0.363 us | }
9) | vfs_write() {
9) | rw_verify_area() {
9) | security_file_permission() {
9) | apparmor_file_permission() {
9) 0.264 us | aa_file_perm();
9) 0.457 us | }
9) 0.644 us | }
9) 0.857 us | }
9) 0.083 us | write_null();
9) | __fsnotify_parent() {
9) 0.107 us | fsnotify();
9) 1.383 us | }
9) 2.813 us | }
9) 3.449 us | }
9) 3.720 us | }
从外往内读,二十行就看完了整条调用链。
__x64_sys_write 是自动生成的包装层,它调用真正干活的 ksys_write。后者先用 __fdget_pos 查出文件描述符,再交给 vfs_write——虚拟文件系统层,从这层起内核就不再关心你往哪种类型的对象里写。
接着 security_file_permission 会调用 AppArmor,因为这台机器跑的是 Ubuntu;换成 SELinux 系统则换别的模块。不管怎样,都是某个安全模块在裁决你允不允许这么写——每次写都查,一次不落。
write_null 才是最终落点,但它出现在这里纯属巧合:上面那条命令写的是 /dev/null,所以这个驱动的全部职责就是把字节扔掉。换成往磁盘文件写,同一个位置就会变成某个文件系统函数,它上面的调用链不变。
整个过程耗时 3.7 微秒,右侧的时间戳标出了每一段花在了哪里。
用完之后把 tracer 复原:
echo nop > current_tracer
echo > set_graph_function
echo > set_ftrace_pid
如果你的系统上没有 /sys/kernel/tracing,试试 /sys/kernel/debug/tracing。
回程,以及 errno 的真相
处理完,handler 返回一个数字,这个数字写进 rax,而 rax 是你唯一能拿到的返回值。
这就引出了一个很少有人直接问出口的问题:内核只能返回一个值,那它怎么同时告诉你出了什么错、以及确实出了错?
答案是:它没有另开一条通道。内核把错误码作为小负数,放在和正常返回值同一个寄存器里返回。一次成功的 write 写了 24 个字节,就返回 24。往一个已关闭的文件描述符 write,返回 -9,因为 EBADF 对应的错误编号是 9。
再想想 errno 这个东西,你会发现对不上。errno 是你进程里的一个变量,内核不会去改你的变量。
来做个验证。把 errno 置零,发一个必然失败的裸系统调用,然后看看两个值各是什么:
#include <stdio.h> /* fprintf, stderr */
#include <errno.h> /* errno */
/* raw_write() is the function from the previous section */
errno = 0;
long ok = raw_write(1, "written via raw syscall\n", 24);
long bad = raw_write(999, "x", 1); /* not an open descriptor */
fprintf(stderr, "ok = %ld\n", ok);
fprintf(stderr, "bad = %ld\n", bad);
fprintf(stderr, "errno = %d\n", errno);
运行结果:
written via raw syscall
ok = 24
bad = -9
errno = 0
看到了吧。内核返回了 -9,errno 纹丝未动。
errno 是 libc 的发明。调用普通的 write() 时,libc 的包装函数会检查返回值是不是一个小负数。如果是,就取反存入 errno,然后返回 -1。每个 C 程序员都学过的「返回 -1 再查 errno」这套惯例,完全是用户态层面的约定,而底层内核接口的工作方式完全不同。
看清了这一点,一类常见的 bug 就说得通了。errno 只在一次调用失败后紧接着那一下才有意义——它不过是最近一次失败的包装函数顺手写进去的值。
两种返回路径
回到用户态有快路径和慢路径。
快路径是 sysret,与 syscall 对称:它从 rcx 恢复指令指针,从 r11 恢复标志寄存器,几个周期就切回 ring 3。
慢路径是 iret,也就是通用的中断返回指令。它要慢得多,内核只在 sysret 不可信时才使用它。入口代码的注释解释了原因:由于 AMD 和 Intel CPU 都存在的 bug,sysret 处理非规范地址时会出问题,所以只要你的保存状态可能被改动过,内核就会强制走安全路径。最常见的元凶就是调试器通过 ptrace 介入并修改你的寄存器。
在这两条指令执行之前,内核会处理之前搁置的杂务:检查是否有待处理的信号并递交它们;检查调度器是否要收回 CPU,如果要,你的进程就会在此停住,让其他程序运行。
也就是说,系统调用不只是“请求服务”这么简单。它还是你的进程可能被直接暂停运行的主要场所之一。你只是想写五个字节,但返回途中内核会重新审视关于你的一切,包括你是否还有资格继续运行。
从未发生的系统调用
现在回到开头那个谜团。
看看你自己进程的内存映射:
cat /proc/self/maps | tail -4
末尾是这样的:
7fff32f97000-7fff32f9b000 r--p [vvar]
7fff32f9b000-7fff32f9d000 r-xp [vdso]
两个你从未申请过的内存区域。它们既不来自你的程序,也不来自你的库,而是内核放进去的——系统里的每个进程都有。
[vdso] 是 virtual dynamic shared object(虚拟动态共享对象)的缩写。它是一个小型但功能完整的共享库(真正的 ELF 格式,带符号表),由内核映射进每个地址空间。而且由于内核会告诉每个进程它被放在哪里,你可以把自己的副本导出来研究:
#include <stdio.h>
#include <sys/auxv.h> /* getauxval, AT_SYSINFO_EHDR */
#include <unistd.h> /* getpagesize */
int main(void)
{
void *vdso = (void *)getauxval(AT_SYSINFO_EHDR); /* the kernel tells us where */
size_t len = 2 * getpagesize(); /* the mapping is two pages */
FILE *f = fopen("vdso.so", "wb");
fwrite(vdso, 1, len, f);
fclose(f);
printf("vDSO was mapped at %p\n", vdso);
return 0;
}
AT_SYSINFO_EHDR 定义在 <sys/auxv.h> 里。少包含这个头文件,编译器不会友好地提醒你,而是直接报错 AT_SYSINFO_EHDR undeclared,构建中止。
运行后,像读其他库一样查看它的符号表:
./dump_vdso && objdump -T vdso.so | grep __vdso
输出如下:
__vdso_gettimeofday
__vdso_clock_gettime
__vdso_clock_getres
__vdso_time
__vdso_getcpu
答案就在这里。clock_gettime 赫然在列。
调用 clock_gettime() 时,libc 会跳入 vDSO 执行。这段代码由内核开发者编写、随内核一起分发,但运行在 ring 3,属于用户态进程的一部分。它从 [vvar] 页(内核持续维护的只读页)中读出当前时间,然后返回。
没有权限切换,没有 syscall 指令,没有入口点,没有栈切换,也没有 pt_regs。这意味着 strace 什么都抓不到——strace 靠监听内核边界工作,而这次调用根本不会靠近那条边界。
这就是全部窍门。内核挑出少量高频调用、读取不需要任何特权、且只返回内核愿意公开的信息的操作——然后直接把它们"发布"到用户态。
最后那条约束也解释了为什么列表这么短。write() 绝不可能这样工作,因为它必须修改内核持有的状态。读时钟则不需要。所以时钟、系统时间、当前 CPU 编号这些操作被搬到了调用者已经所在的地址空间里。
跨越边界的代价
以上是机制,下面看实测代价。
基准测试对比一次必定陷入内核的调用和一次必定不陷入的调用。前者用 syscall(SYS_getpid),经过这层轻薄的 syscall() 包装函数,保证产生一次真实的模式切换:
/* 摘录。需要 <unistd.h>、<sys/syscall.h> 和 <time.h>,
外加一个返回秒数(double)的 now() 辅助函数,以及前面定义的 ITERATIONS。 */
double a = now();
for (long i = 0; i < ITERATIONS; i++)
sink += syscall(SYS_getpid);
double b = now();
for (long i = 0; i < ITERATIONS; i++)
clock_gettime(CLOCK_MONOTONIC, &ts);
在测试机器上,各跑两百万次:
真实系统调用 (getpid): 106.9 ns/次
vDSO 调用 (clock_gettime): 17.2 ns/次
比值: 6.2 倍
大约六倍。而 getpid 已经是系统调用里最轻的那一档了——读一个字段就返回。也就是说,那 107 纳秒里几乎没有多少花在真正的工作上。耗的是特权级切换、swapgs、栈切换、pt_regs 上去再回来,外加你的 CPU 沿途坚持要做的各种缓解措施。
接下来说个关键前提,因为它比那个数字本身更重要。
107 纳秒已经接近最好情况了。回看这台机器之前报的状态:Meltdown 显示 Not affected,所以它根本不会做页表切换。Spectre 缓解用的是 Enhanced IBRS,在硅片层面处理,不需要软件 retpoline。这个 CPU 直接跳过了跨越中最贵的两项开销。
所以请自己在自己的机器上跑一遍,同时看看你的缓解状态:
grep . /sys/devices/system/cpu/vulnerabilities/*
如果你的输出里写着 Mitigation: PTI,那你每次系统调用的实际开销一定比上面测到的更大,你的数字也会相应更高。老几代硅片的情况可能严重得多。
把比值当作可以跨机器复用的结论,把绝对值当作某台机器上的一次读数。具体数值会随你的 CPU、内核以及你正跑着的那套缓解措施而变化。跑的时候多来几遍。这台笔记本上多次运行的波动大约十五个百分点,这告诉你任何单次数字(包括我的)能信多少。
同一次实验还有另一个结果,纠正了一个流传很广的说法。通过普通 libc 调用 getpid() 的开销和直接用 syscall(SYS_getpid) 一样。glibc 曾经缓存进程 ID 来省掉这次调用,但几年前放弃了,因为要在 fork 和命名空间变化时保证缓存正确,比花那一百纳秒麻烦得多。
六倍这个数字听起来很抽象,直到你把它落到具体场景。一个程序做一百万次小 read() 调用,光是穿越内核边界就要花掉约十分之一秒。这正是现代内核接口设计的核心压力所在:io_uring 之所以存在,就是为了让提交一千个操作的代价是穿越一次边界,而不是一千次。批量执行系统调用、缓冲写操作、用 sendfile() 替代读写循环,本质上都是同一种优化:活没少干,只是少穿了几次边界。
结语
现在你已经能完整跟踪一次系统调用的全过程了。你亲眼看过自己二进制文件里的 syscall 指令,观察过坏文件描述符如何返回 -9 而 errno 保持为零,从自己的地址空间里提取过 vDSO 并读取它的符号表,还在自己的 CPU 上实测了边界穿越的开销。
更有价值的是,你获得了一个会持续产生回报的思维模型。当你读到 io_uring 能降低系统调用开销时,你清楚这里的开销到底指什么。当性能分析显示时间花在 entry_SYSCALL_64 上时,你知道那个函数在做什么。当 strace 什么都看不到时,你会先检查 vDSO,而不是怀疑工具出了问题。
接下来还有几个方向可以深入。跑一遍 ftrace 的实验,跟踪 __x64_sys_write 深入文件系统层。读读 arch/x86/entry/entry_64.S:注释非常详尽,远没有传说中那么难懂。或者在老机器上查看 /sys/devices/system/cpu/vulnerabilities/,算算每个安全缓解措施在这条边界上让你付出了多少代价。
后记
我最近正在试验一种基于 Linux 内核的操作系统设计,希望把 Android 风格的权限与能力模型引入桌面系统,同时保持与 Debian 生态 100% 兼容。这催生了不少有趣的研究,本文就是其中一项成果。
在转向形式化验证之前,我还会继续写一些 Linux 内核的内容。形式化验证对应的是 DO-178C 和 DO-333 那套体系——航空软件不仅要通过检查,还得给做检查的工具本身做认证。通常这就意味着 Viper、Why3、Z3 以及一众同门工具。
与此同时,我还在 thechris.in 上写那些必须经受住现实考验的系统,包括本文的配套文章,讨论在一种成本可度量却永远看不见的抽象层上构建到底意味着什么。