本节摘要:系统调用是用户程序与内核之间的唯一正式通道:参数按调用约定放进寄存器、系统调用号放进 rax、一条 syscall 指令完成特权级提升与入口跳转,内核办事后经 sysret 回到下一条指令。本节拆解这条通道的硬件机制与软件约定,手写一个完全不经过 C 库的系统调用程序,并教你看懂 strace 的输出。读完你能解释"printf 里藏着几次 write"、能不依赖任何库直接向内核要服务。
第 3 章立过规矩:用户态碰特权指令直接异常。但用户程序天然需要内核的服务——读文件、申请内存、收发网络包。矛盾怎么解?硬件提供了一扇专用的门:syscall 指令。它从设计上就是给 ring 3 使用的合法指令,一次执行完成三件事:特权级提升到 ring 0、跳到内核预先登记的入口地址、保存返回点以便办完事回来。对应地,内核办完事执行 sysret,恢复用户态特权级并跳回调用者——一进一出,特权边界管理的全部日常。
这扇门不是免费的。一次系统调用的成本包含特权级切换、上下文保存(用户态寄存器组存进内核栈)、入口处的安全检查,合计数百周期起步——比一次函数调用贵三个数量级。这个成本解释了大量系统设计的"为什么":为什么 C 库要缓冲(攒一批数据一次 write,而不是每次都问内核)、为什么 vDSO 把时间读取优化成用户态直接查表(把每次读时钟的系统调用降为一次内存访问)、为什么高性能网络库执着于批量提交(一次 syscall 办多件事)。
穿过门的"手续"是一套寄存器约定。Linux x86-64 的规定:系统调用号放 rax(每个内核服务一个编号,如 read 为 0、write 为 1、exit 为 60)、参数依次放 rdi、rsi、rdx、r10、r8、r9、破坏性返回值放 rax(负数表示出错码)。与 5.2 节的函数调用约定几乎同构,只有一处刻意的不同:第四个参数用 r10 而不是 rcx——因为 syscall 指令硬件上会用 rcx 保存返回地址,rcx 被征用了。
对照表把两套约定放在一起看:
| 项 | 函数调用(System V) | 系统调用(Linux x86-64) |
|---|---|---|
| 参数寄存器 | rdi rsi rdx rcx r8 r9 | rdi rsi rdx r10 r8 r9 |
| 编号或地址 | 直接 call 地址 | 调用号放 rax |
| 穿越指令 | call(同特权级) | syscall(跨特权级) |
| 成本量级 | 数周期 | 数百周期 |
| 出错表示 | 由调用方约定 | rax 为负值即出错码 |
第三行是本质区别:call 在同一特权级内"平移",syscall 是"升级"。理解了这一点,再看内核入口代码的开场一串压栈就不奇怪——用户态的全部寄存器现场都必须先抢救进内核栈,内核才有干净的工作台;sysret 前再逐一恢复,用户程序才察觉不到刚才的惊险穿越。

约定背完就实战:写一个不链接任何库、直接向内核要服务的程序——向标准输出写一行字再退出。这要求绕开 C 运行时,用纯汇编走 _start:
; rawsyscall.asm —— 无库系统调用:write + exit section .rodata msg: db "no libc, no problem", 10 ; 10 是换行符 section .text global _start _start: mov eax, 1 ; 调用号 1:write mov edi, 1 ; 文件描述符 1:标准输出 lea rsi, [rel msg] ; 缓冲区地址(rel 为位置无关写法) mov edx, 20 ; 长度 syscall ; 穿越内核,返回值(写入数)在 rax mov edi, eax ; 顺手把 write 的返回值当退出码 mov eax, 60 ; 调用号 60:exit syscall
$ nasm -f elf64 rawsyscall.asm -o rawsyscall.o $ ld -o raw rawsyscall.o ; 注意:不链 libc,直接 ld $ ./raw no libc, no problem $ echo $? 20
运行、验证退出码是 20(write 返回了写入的字节数,我们把它转手当退出码)。这个程序的每个细节都是本节知识的应用:rax 装调用号、rdi rsi rdx 排参数、syscall 穿越、rax 收返回值。它也是最小可能的"打印程序"——没有 printf、没有缓冲、没有 C 运行时的初始化与清理,你和内核之间只隔着一条指令。
再往前一步用 strace 观察"库函数背后的系统调用真相":
$ strace -c ./a.out # 任何普通 hello world 程序 % time calls syscall ... ... ... 0.00 1 write 0.00 1 execve ... 多行 —— 一个 hello world 背后几十次系统调用
-c 统计模式下一个 hello world 的背后站着几十次系统调用(启动时的内存映射、动态链接、标准库初始化……)。C 库不是内核的代言,是一层厚厚的缓冲与簿记——看穿这层,"性能问题出在库还是内核"这类问题就有了判别工具。
系统调用的出错约定要单独记:返回负值即失败,绝对值是错误码(如 -9 表示 EBADF,坏文件描述符)。与 C 库"返回 -1 并设 errno"的包装不同,裸系统调用把错误码直接放 rax——写零库代码时判断 rax < 0 即可,别去找 errno。至于老亲戚 int 0x80(32 位时代的穿越指令)与 sysenter(syscall 的前身),当代 x86-64 代码里已属遗迹,但逆向老二进制与分析兼容层时会遇到:它们的寄存器约定与 syscall 完全不同,看到 int 0x80 应立刻切换到 32 位约定读参数(eax 装调用号,ebx、ecx、edx 排参数)。
⚠️ 常见坑:把系统调用号当成跨平台常量硬编码。号表是内核的内部约定,同一份代码在不同系统(Linux 与其他系统)号表完全不同,同一系统的不同架构(x86-64 与其 32 位兼容层)号表也不同——零库代码必须声明目标平台,可移植性由条件汇编或构建期选择实现。
💡 关键直觉:系统调用是" pricey 的服务窗口"——每次都要取号(rax)、排队(穿越)、办完回来。所有系统级性能技巧(缓冲、批量、vDSO)本质上都是同一句话的变奏:少去窗口,一次多办点事。
与内核的边界走通了。最后一节把时间倒回操作系统出现之前:上电的第一毫秒,机器在执行谁的代码。