卷 I · 边界CH 03深度 03/24

系统调用:唯一的合法入口

上一章说过,用户态能敲门但不能决定门后的路。这一章就讲敲门这个动作本身——它长什么样、参数怎么递过去、内核收到之后做的第一件事是什么。剧透:内核做的第一件事,是假设你在撒谎。

syscall寄存器约定参数校验strace

系统调用不是函数调用

你写 write(1, buf, 6),看起来跟调用任何一个 C 函数没区别。但它和 strlen(buf)两种完全不同的东西

普通函数调用系统调用
控制权去了哪你的代码里的另一个地址内核,另一个特权级
用什么指令callsyscall(陷入指令)
参数怎么传寄存器 + 栈,编译器说了算固定寄存器,内核 ABI 说了算
用哪个栈你的栈内核栈(换掉了)
参数可信吗可信(同一个程序里的)一律不可信,逐项校验
耗时1–2 ns100 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 装调用号,参数依次放 rdirsirdxr10r8r9,返回值回到 rax最多六个参数——超过六个的(比如 mmap 有六个,刚好卡满)只能塞结构体指针。

⚠ 一个容易记混的细节

普通函数调用的第 4 个参数用 rcx,但系统调用用 r10为什么不一致?

因为 syscall 指令会rcx 存返回地址(上一章那五步的第 1 步)。这是硬件行为,抢不过。所以内核 ABI 只好把第 4 个参数挪到 r10

这是那种「设计上不优雅但现实中无所谓」的妥协——反正没人手写这段汇编,libc 的包装函数替你处理了。但如果你哪天要读内核入口代码或者写 shellcode,这个细节会绊你一下。

内核收到之后,第一件事是不相信你

这是整章最重要的一节。

你递进来的是几个寄存器里的数字。内核凭什么相信这些数字是善意的?它不能相信。因为线上面那个进程可能是恶意的,也可能只是有 bug——两种情况的后果一样严重。

所以内核收到 write(1, buf, 6) 之后,会依次做这几件事:

  1. 调用号合法吗?rax 是用来查表的下标。你给个 9999,内核直接返回 -ENOSYS——绝不能拿它去索引数组,那是越界读。
  2. fd 合法吗?查你的 fd 表,看第 1 格有没有东西、是不是可写的。你随手传个 777,得到 -EBADF
  3. 那个地址真的属于你吗?这是最关键的一步。rsi 里是个指针——但内核此刻在 ring 0,它能访问所有内存。如果它不加检查就去读这个地址,你只要传一个内核地址进来……
◆ 如果第 3 步不做会怎样
// 恶意程序
write(1, (void *)0xffff888000000000, 4096);   // 传一个内核地址进去

内核在 ring 0,这个地址它读得动。如果它不检查就照办——它会把 4 KB 的内核内存打印到你的屏幕上。里面可能有密码哈希、加密密钥、其他进程的数据。

所以内核必须逐个校验用户传来的指针:这块地址是不是落在用户空间的范围里?它在这个进程的 vma 里吗?权限对吗?这类检查漏一处,就是一个提权漏洞。历史上这样的 CVE 一抓一把。

校验通过之后,内核还不会直接用你的指针,而是调 copy_from_user() 把数据拷到内核自己的缓冲区里再用。

为什么要多这一次拷贝?因为存在 TOCTOU(检查时刻与使用时刻之间的时间差)问题:你在另一个线程里,完全可以在内核「检查完」和「使用」之间把那块内存改掉,甚至 munmap 掉。拷进来就没这个问题了——内核用的是自己那份快照。

这也是第 18 章会看到的那笔「拷贝成本」的来源之一。不信任是要花钱的。

亲眼看看你的程序说了多少句话

▶ 动手 · syscall 追踪器

下面这台机器摆出三个真实程序的完整系统调用序列。先看 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 字节的活。
  • 批量接口readvwritev 一次提交多个缓冲区;sendmmsg 一次发多个 UDP 包。
  • 把循环推到内核里sendfile 让内核自己完成「从文件读、往 socket 写」,你只过一次线,而不是每 4 KB 过两次。
  • 共享内存队列io_uring 在用户态和内核之间架两个环形队列,你往里塞请求、内核往外放结果,大部分时候一次线都不用过。这是近年最重要的一次演进。
  • vDSO:见下面。
✎ 掌故 · 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.forEachLinebufferedReader() 默认带 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 纳秒,你所有的缓冲和批量优化,都是在省这笔钱。

下一章看另外两种过线方式——它们不是你主动的:中断和异常。而且我们会看到,其中一种正是整个骗局的引擎。