系统调用:唯一的合法入口
上一章说过,用户态能敲门但不能决定门后的路。这一章就讲敲门这个动作本身——它长什么样、参数怎么递过去、内核收到之后做的第一件事是什么。剧透:内核做的第一件事,是假设你在撒谎。
系统调用不是函数调用
你写 write(1, buf, 6),看起来跟调用任何一个 C 函数没区别。但它和 strlen(buf) 是两种完全不同的东西:
| 普通函数调用 | 系统调用 | |
|---|---|---|
| 控制权去了哪 | 你的代码里的另一个地址 | 内核,另一个特权级 |
| 用什么指令 | call | syscall(陷入指令) |
| 参数怎么传 | 寄存器 + 栈,编译器说了算 | 固定寄存器,内核 ABI 说了算 |
| 用哪个栈 | 你的栈 | 内核栈(换掉了) |
| 参数可信吗 | 可信(同一个程序里的) | 一律不可信,逐项校验 |
| 耗时 | 约 1–2 ns | 约 100 ns 起 |
最后一行的差距是五十到一百倍。这就是为什么 libc 要在中间加一层缓冲——printf 并不是每次都调 write,它先攒在自己的缓冲区里,攒够了或者遇到换行才真的下去一趟。那个缓冲区的全部意义,就是减少过线次数。
参数是怎么递过去的
因为要换栈,参数不能放在栈上——内核换栈之后就找不着了。所以 x86-64 Linux 规定用固定的寄存器:
; write(1, buf, 6) 在汇编层面
mov $1, %rax ; rax = 系统调用号(1 = write)
mov $1, %rdi ; 第 1 个参数:fd
mov buf, %rsi ; 第 2 个参数:缓冲区地址
mov $6, %rdx ; 第 3 个参数:长度
syscall ; ← 过线
; 回来之后,rax 里是返回值(写了几个字节,或者负的错误码)
完整的约定是:rax 装调用号,参数依次放 rdi、rsi、rdx、r10、r8、r9,返回值回到 rax。最多六个参数——超过六个的(比如 mmap 有六个,刚好卡满)只能塞结构体指针。
普通函数调用的第 4 个参数用 rcx,但系统调用用 r10。为什么不一致?
因为 syscall 指令会用 rcx 存返回地址(上一章那五步的第 1 步)。这是硬件行为,抢不过。所以内核 ABI 只好把第 4 个参数挪到 r10。
这是那种「设计上不优雅但现实中无所谓」的妥协——反正没人手写这段汇编,libc 的包装函数替你处理了。但如果你哪天要读内核入口代码或者写 shellcode,这个细节会绊你一下。
内核收到之后,第一件事是不相信你
这是整章最重要的一节。
你递进来的是几个寄存器里的数字。内核凭什么相信这些数字是善意的?它不能相信。因为线上面那个进程可能是恶意的,也可能只是有 bug——两种情况的后果一样严重。
所以内核收到 write(1, buf, 6) 之后,会依次做这几件事:
- 调用号合法吗?
rax是用来查表的下标。你给个 9999,内核直接返回-ENOSYS——绝不能拿它去索引数组,那是越界读。 - fd 合法吗?查你的 fd 表,看第 1 格有没有东西、是不是可写的。你随手传个
777,得到-EBADF。 - 那个地址真的属于你吗?这是最关键的一步。
rsi里是个指针——但内核此刻在 ring 0,它能访问所有内存。如果它不加检查就去读这个地址,你只要传一个内核地址进来……
// 恶意程序 write(1, (void *)0xffff888000000000, 4096); // 传一个内核地址进去
内核在 ring 0,这个地址它读得动。如果它不检查就照办——它会把 4 KB 的内核内存打印到你的屏幕上。里面可能有密码哈希、加密密钥、其他进程的数据。
所以内核必须逐个校验用户传来的指针:这块地址是不是落在用户空间的范围里?它在这个进程的 vma 里吗?权限对吗?这类检查漏一处,就是一个提权漏洞。历史上这样的 CVE 一抓一把。
校验通过之后,内核还不会直接用你的指针,而是调 copy_from_user() 把数据拷到内核自己的缓冲区里再用。
为什么要多这一次拷贝?因为存在 TOCTOU(检查时刻与使用时刻之间的时间差)问题:你在另一个线程里,完全可以在内核「检查完」和「使用」之间把那块内存改掉,甚至 munmap 掉。拷进来就没这个问题了——内核用的是自己那份快照。
这也是第 18 章会看到的那笔「拷贝成本」的来源之一。不信任是要花钱的。
亲眼看看你的程序说了多少句话
下面这台机器摆出三个真实程序的完整系统调用序列。先看 hello world 那一栏——数一数总共几次,再数一数其中有几次是你真正想干的事。
hello world 一共 13 次系统调用,其中只有 1 次(那个 write)是你写在代码里的意图。剩下 12 次全是「把程序准备好」:找动态链接库、映射进地址空间、把重定位表改回只读、问一句「标准输出是不是终端」。
这解释了一个你可能纳闷过的事:为什么一个 C 的 hello world 也要几毫秒才启动。绝大部分时间根本不在你的代码里。(也顺带解释了静态链接为什么启动更快——那 8 次动态链接相关的调用全省了。)
在自己机器上试
$ strace -c ./hello # 按调用统计 $ strace -f ./myserver # -f 跟踪 fork 出来的子进程 $ strace -e trace=write ./a # 只看 write
macOS 上没有 strace,用 dtruss(要关 SIP)或者 sudo dtrace。Android 上可以 adb shell strace -p PID。
一次空系统调用往返:约 100 ns。
这个数字在 2018 年之后变贵了。Meltdown 漏洞的修复方案 KPTI(内核页表隔离)把内核的页表从用户进程里摘了出去,于是每次进出内核都要多换一次页表、多冲一次 TLB。系统调用密集的负载(数据库、网络服务)当年普遍掉了 5%–30% 的性能。
这是安全与性能之间一次极其昂贵的交易,而账单直接记在这一行上。第 11 章讲页表时会再碰到它。
怎么少过几次线
既然每次过线要 100 ns,那优化方向就很明确了:要么少过几次,要么一次多办点事。你会发现整个系统编程史上的很多设计,都是这一条的变体:
- 缓冲:libc 的 stdio 攒够一批再
write。一次系统调用干 4 KB 的活,而不是 1 字节的活。 - 批量接口:
readv/writev一次提交多个缓冲区;sendmmsg一次发多个 UDP 包。 - 把循环推到内核里:
sendfile让内核自己完成「从文件读、往 socket 写」,你只过一次线,而不是每 4 KB 过两次。 - 共享内存队列:
io_uring在用户态和内核之间架两个环形队列,你往里塞请求、内核往外放结果,大部分时候一次线都不用过。这是近年最重要的一次演进。 - vDSO:见下面。
gettimeofday() 和 clock_gettime() 被调用得极其频繁——每条日志、每次埋点、每个超时判断都要问一次时间。如果每次都过线,光看表就能吃掉可观的 CPU。
于是 Linux 搞了 vDSO(virtual dynamic shared object):内核把一小段代码和一页共享数据映射进每个进程的地址空间。这页数据里就有当前时间,由内核持续更新。你调 clock_gettime() 时,实际执行的是这段映射进来的代码,它直接读那页数据——全程在用户态,一次线都不过。
代价从 100 ns 降到 几 ns。你可以在自己机器上验证:cat /proc/self/maps | grep vdso,那一行就是它。
这个思路值得记住——它是「共享内存」而不是「传消息」,和第 20 章讲 IPC 时那个「零拷贝」的道理完全一样。
「为什么按行读文件比按块读慢那么多?」
如果你用一个不带缓冲的接口逐行读一个 100 万行的文件,就是 100 万次系统调用 = 100 ms 纯粹花在过线上,还没算实际读取。换成 128 KB 的缓冲区批量读,同样的文件只需要几百次。
所以各语言的标准库都在这层做文章,而且默认值经常不是你以为的那个:
- Java 的
FileInputStream.read()是不带缓冲的,一次读一个字节就是一次系统调用。必须自己套BufferedInputStream——这就是那个「为什么加一层 Buffered 快了几十倍」的经典现象。 - Kotlin 的
File.forEachLine、bufferedReader()默认带 8 KB 缓冲,所以你平时感觉不到。 - C 的
stdio默认也带缓冲,但 stdout 连到终端时是行缓冲、重定向到文件时是全缓冲——这就是为什么同一个程序,直接跑能看到输出、管道给grep就好像卡住了。你需要stdbuf -oL或者手动fflush。
// 这两行的系统调用次数差三个数量级
File("big.txt").readBytes() // 少数几次大 read
File("big.txt").inputStream().use { ins ->
while (ins.read() != -1) { } // 每字节一次 —— 别这么写
}
这一章的一句话
系统调用是用户态进入内核的唯一合法方式:参数走固定寄存器,控制权落到内核提前铺好的入口。而内核收到之后做的第一件事是假设你在撒谎——逐项校验、拷贝进来再用。这份不信任要花约 100 纳秒,你所有的缓冲和批量优化,都是在省这笔钱。
下一章看另外两种过线方式——它们不是你主动的:中断和异常。而且我们会看到,其中一种正是整个骗局的引擎。