【操作系统百科】系统调用 ABI
syscall
是应用和内核唯一正式的接触面。一旦定义下来就不能破坏——Linus
的 “we don’t break userspace” 是整个 Linux
生态存活的根。本文先讲为什么要 ABI 稳定,再讲硬件层的
syscall 指令在各架构如何实现,最后讲 Linux 的 libc/Go/Rust
各自如何把它翻译成 read(fd, buf, n) 这样的 C
函数调用。
在进入寄存器细节之前,先用一张序列图看
read(fd, buf, n)
从用户代码出发经过哪些环节最后回到用户代码:
sequenceDiagram
participant App as 用户应用
participant Libc as glibc / musl
participant VD as vDSO
participant K as 内核 entry
participant Sys as sys_read
App->>Libc: read(fd, buf, n)
Libc->>Libc: 载入 syscall 号与参数
Libc->>K: syscall / svc / ecall
K->>K: SWAPGS / 切栈 / 切 CR3(KPTI)
K->>K: seccomp / ptrace / audit / LSM 钩子
K->>Sys: 分发到 __x64_sys_read
Sys->>Sys: copy_from_user / VFS → FS → block
Sys-->>K: 返回值
K->>K: 退出钩子 / 信号检查 / CR3 还原
K-->>Libc: rax = 返回字节数或 -errno
Libc-->>App: 返回值(errno 已设)
Note over App,VD: clock_gettime 等走 vDSO<br/>不经过内核 entry
图中几条信息值得记住:syscall
的总成本不是单条指令——它包含特权级切换、寄存器保存、KPTI
的页表切换、seccomp/ptrace/LSM
的多轮钩子、最后才是真正的业务逻辑。对于
clock_gettime 这类热点,vDSO
用一条虚线旁路跳过了整个内核
entry。这些细节在下文会逐一展开。
一、什么是 syscall ABI
1.1 ABI vs API
- API(Application Programming
Interface):源码级的接口约定,比如
ssize_t read(int fd, void *buf, size_t count)。编译时 C 头文件里的声明。 - ABI(Application Binary Interface):二进制级的约定——寄存器怎么传参、栈怎么对齐、结构体怎么布局、syscall 号怎么分配。连接器、加载器、glibc、Go runtime 依赖它。
一个 C 程序只管 API;但编译好的 ELF 走的是 ABI。Linux 承诺的是 syscall ABI 稳定,不是 “内核内部函数稳定”(后者叫 Stable / Unstable internal ABI,见 J-86)。
1.2 哪些东西属于 syscall ABI
- syscall 号:
__NR_read = 0在 x86_64 上是承诺的,不会改 - 参数寄存器顺序
- 传入/传出结构体的布局(例如
struct stat) - flags 参数的位定义
- 文件系统上的 ABI
表现(
/proc/self/status的字段格式是 ABI;用户工具在解析它) - sysfs / proc 里的文本格式(某些字段,不是全部)
syscall
号一旦分配,永远不会重用。sys_getpmsg(号
181)虽然废弃了,但号位空着。
1.3 破坏的代价
Linus 最著名的回应之一:
“WE DO NOT BREAK USERSPACE!”
任何内核改动如果破坏了一个现有的用户态程序,都视作 regression。哪怕那个程序”用错了”接口。历史上几个例子:
- 2012 年某个 ext4 的 stat 字段变化破坏了 wine → 回退
- 2018 年 EXT4/XFS 的 delete-time 行为微调 → 反复讨论
- 2021 年 proc 里某字段格式变化让 lxc-attach 失败 → 回退
这份偏执让 Linux 用户敢长期依赖。
二、syscall 指令:硬件层
2.1 x86_64:SYSCALL
AMD 在 2003 年引入 SYSCALL 指令。行为:
- 从用户态跳转到内核态入口(地址存在 MSR_LSTAR)
- 保存 RIP 到 RCX
- 保存 RFLAGS 到 R11
- 根据 MSR_FMASK 清除某些 flag bit
- 切换 CS / SS
约定(Linux x86_64):
- syscall 号:RAX
- 参数 0–5:RDI、RSI、RDX、R10、R8、R9(注意不是 RCX,因为 RCX 被 SYSCALL 占用了)
- 返回值:RAX(负数为
-errno) - clobber:RCX、R11、RAX
; read(fd, buf, count)
mov rax, 0 ; __NR_read
mov rdi, <fd>
mov rsi, <buf>
mov rdx, <count>
syscall
; 返回值在 rax老式的 int 0x80 在 x86_64 上仍然可用(通过
IA-32 compat 层),但慢得多(几百 cycle vs 几十
cycle),已弃用。
2.2 x86_32:SYSENTER 与 int 80
IA-32 syscall 演化三代:
- int 0x80:286 时代引入,经过 IDT 查表,慢
- SYSENTER / SYSEXIT(Pentium II):Intel 引入的 fast syscall
- SYSCALL / SYSRET(K6-2 后 / AMD 方案):AMD 引入,Intel 最终也支持
Linux 运行时选择最快的(vDSO 里的
__kernel_vsyscall 根据 CPU 选择)。
2.3 arm64:SVC
ARMv8-A 的系统调用用 svc #0:
- syscall 号:x8
- 参数 0–5:x0–x5
- 返回值:x0
; read
mov x8, #63 // __NR_read
mov x0, <fd>
mov x1, <buf>
mov x2, <count>
svc #0svc 是 “Supervisor Call”,通过 EL1 的 vector
table 进入内核。没有 x86_64 那样特殊的 MSR;入口地址由
VBAR_EL1 指定。
2.4 RISC-V:ECALL
RISC-V 的 ecall:
- syscall 号:a7
- 参数 0–5:a0–a5
- 返回值:a0
li a7, 63 # __NR_read
mv a0, <fd>
mv a1, <buf>
mv a2, <count>
ecall2.5 Windows NT
Windows 的系统调用号叫 SSN(System Service Number),在 Native API(ntdll.dll)里:
- 入口:
syscall指令(Windows 也用 AMD 的 SYSCALL 机制) - 号码在
eax - 参数:前 4 个 RCX/RDX/R8/R9,其余栈上(Windows x64 calling convention)
- 返回值:
rax(NTSTATUS 码,不是 Linux 那种负 errno)
关键差异:Windows 不把 syscall 号当做稳定 ABI。NtReadFile 在 Windows 10 和 Windows 11 之间号码会变。微软的稳定接口在 ntdll.dll 级别(运行时符号),再上面是 Win32 API(kernel32.dll 等)。这就是为什么 Windows 没法像 Linux 那样简单让静态链接的二进制跑——你必须通过 DLL。
三、Linux 的 “负 errno” 返回约定
Linux syscall 返回值是有符号的 long。按约定:
- 成功:非负(或者已定义的非负含义)
- 失败:返回
-errno(范围在[-4095, -1])
这是”两值”ABI 的一种紧凑形式——不像某些系统用 OUT 参数或者两个返回寄存器。
glibc wrapper 在 C 层负责把 -errno 转成
“返回 -1,errno 全局变量设为对应值”。musl 一样。Go runtime
不用 errno,直接返回两个值 (r, err)。
例外:某些 syscall 真的可能返回
-1 ~ -4095 范围的合法值。例如
ioctl(2) 的某些命令、lseek 的某些
SEEK 形式。这种情况下内核和 glibc
有约定性的特殊处理(long long
扩展、显式错误寄存器)。
四、syscall 表与自动化
Linux 把 syscall 表定义在架构相关的
syscall_NN.tbl:
# arch/x86/entry/syscalls/syscall_64.tbl
0 common read sys_read
1 common write sys_write
2 common open sys_open
...
脚本在构建时生成头文件 unistd_64.h
和入口分发表。新 syscall 的引入流程:
- 在 table 里加号
- 实现
sys_xxx()原型 - 用
SYSCALL_DEFINEn()宏包装(自动生成兼容 stub、参数校验) - 所有架构都要分配号(否则某些 arch 不支持这个 syscall)
4.1 SYSCALL_DEFINEn 的展开
SYSCALL_DEFINE3(read, unsigned int, fd, char __user *, buf, size_t, count)
展开后生成:
__do_sys_read(...)真实实现__se_sys_read(...)sign-extend wrappersys_read符号(供 table 引用)
这么绕是为了处理 compat(32-bit 进程在 64-bit
内核上跑):__se wrapper 把 32-bit
参数零扩展/符号扩展到 64-bit。
4.2 参数污染检查
__user 是一个 sparse
注解,让静态分析工具检测内核代码是否把用户指针当内核指针用(或相反)。copy_from_user()、get_user()
是唯一合法的跨界访问方式,内部在 SMAP 机器上会
stac/clac(见 A-04)。
五、vDSO:看起来像 syscall 的用户态函数
gettimeofday、clock_gettime、getcpu、time——这些高频
syscall 进内核一次太贵。Linux 的
vDSO(virtual Dynamic Shared Object):
- 内核把一段代码映射到每个进程地址空间(典型地址
0x7fff...) - 这段代码可以直接读内核共享的 “tick
区”(
vvar页)计算时间 - 用户调
clock_gettime时,libc 先查 vDSO 里有没有,有就直接调用,无就 fallback 到 syscall
性能对比(典型 x86_64):
clock_gettime走 syscall:~200–400 nsclock_gettime走 vDSO:~10–20 ns
vDSO 不是 syscall,但它在 ABI 层面被视为”内核 ABI 的一部分”——内核保证 vDSO 提供的符号集合和语义稳定。
Linux 的 vDSO 源码在
arch/<arch>/vdso/。J-84 会详细展开。
六、libc 的 syscall stub
glibc、musl、bionic、uClibc 各自实现了 syscall stub。以
musl 的 x86_64 read 为例:
// musl/src/unistd/read.c
ssize_t read(int fd, void *buf, size_t count)
{
return syscall(SYS_read, fd, buf, count);
}syscall() 本身是汇编 stub,按架构不同在
arch/<arch>/syscall_cp.s
或相关文件。glibc 结构更复杂(cancel 点、errno
多线程处理等),但本质一样。
6.1 Go 为什么绕开 libc
Go runtime 直接 syscall 指令,不经过
libc。原因:
- Go 的 goroutine 调度要知道 “线程要进入内核”(阻塞时释放 P)
- libc 的线程栈模型和 Go 的 g0/m0 不兼容
- 静态链接简化部署
代价:
- macOS 上 Apple 不保证 syscall 号稳定(它们的 ABI 在 libSystem.dylib)——Go 在 macOS 上最终改成了 libSystem
- FreeBSD 等平台类似要求用 libc
6.2 Rust 的选择
Rust std 默认用 libc(linux-gnu target)或者
musl(linux-musl target)。nix /
libc crate 暴露 syscall 号和 raw 接口。想直接
syscall 用 syscalls crate 或 inline asm。
七、syscall 的进入与退出钩子
一个 syscall 在 Linux 内的完整生命周期:
- 用户态
syscall指令 - CPU 切换到内核态,跳到
entry_SYSCALL_64(x86_64)或对应入口 - SWAPGS:切换 GS base 到内核 per-CPU 数据
- 保存寄存器到栈
- 切换到内核栈
- (如开启 KPTI)切换 CR3 到内核页表
- enter_from_user_mode():通知追踪、审计、seccomp
- syscall trace / seccomp filter:ptrace 拦截、seccomp-bpf 过滤、audit 记录
- 查 syscall table,分发到
__x64_sys_read - 执行 syscall 逻辑
- audit_syscall_exit
- 恢复寄存器
- exit_to_user_mode():检查是否有 pending signal、reschedule
- (KPTI)切换回用户页表
- SWAPGS 还原
sysretq返回用户态
整个路径的开销约 100–500 cycle(不含 syscall 业务逻辑)。KPTI 打开后延长 100–200 cycle。seccomp-bpf 视规则复杂度增加 50–500 cycle。
7.1 seccomp-bpf 过滤
seccomp(L-96 详述)在 syscall 分发前运行一段 BPF 程序决定允许/拒绝/返回 errno。容器运行时(docker、containerd)默认用白名单 seccomp profile 屏蔽约 60 个高风险 syscall。
八、兼容层:compat_ 和其它
64-bit 内核要能跑 32-bit 进程:
- compat_sys_xxx:32→64 适配 wrapper,处理结构体 layout 差异、指针扩展
- x32 ABI(已被 phased out):64-bit 地址、32-bit 指针的混合模式,曾经存在过
- ia32 compat:传统 IA-32 兼容,依然维护
另外 Linux 的 WoW(WINE) 通过 ptrace
重写 Windows syscall。FreeBSD 的 Linux ABI
兼容层(linux.ko)能直接跑部分 Linux
二进制。
九、syscall 的版本化:如何无损演进
当某 syscall 的语义不够用了,不能改它(违反稳定 ABI)。Linux 的惯例:加一个新的。
例子:
open→openat(加 dirfd)→openat2(加struct open_how以便未来扩字段)mount→fsopen+fsconfig+fsmount+move_mount(拆分的新 API)clone→clone3(struct clone_args,带字段长度)rlimit→prlimit64signal→sigaction
“带长度字段的结构体参数”是现代 syscall
的标准套路。内核根据用户传入的 size
决定知道哪些字段、忽略哪些字段,新旧程序可以共存。这个技巧在
F-48、B-09 会反复出现。
十、跨 OS 视角:Linux vs FreeBSD vs Windows
| 维度 | Linux | FreeBSD | Windows NT |
|---|---|---|---|
| syscall 号稳定? | 是 | 弱(一般稳) | 否,号变 |
| 稳定接口层 | syscall 本身 | libc(强建议) | ntdll.dll + Win32 |
| 错误约定 | -errno in rax | errno + 进位位(C flag) | NTSTATUS(有符号 32-bit) |
| ABI 演进 | 加新号 | 加新号/按版本 | 在 DLL 层加新函数 |
| 静态链接 | 可行 | 可行但不推荐 | 不可行(除非极端) |
FreeBSD 的折衷:syscall 号大致稳定但不是强承诺,官方文档推荐用 libc。它的 Capsicum 系统引入了 “capability-oriented” syscall 子集。
Windows 的设计哲学:把稳定性上移到 DLL——这让 kernel 可以随意重构,但要求所有应用都动态链接。这也是 Windows 应用程序分发从来绕不开 “安装程序” 的一个原因。
十一、给工程师的启示
- 选稳定层:在 Linux 上做系统级工具(strace、ebpf 工具、容器运行时)可以直接走 syscall;其它平台绑 libc。
- ABI 演进 = 加字段,带长度:设计你自己的 RPC、proto、格式时都可以借这个思路。
- 看 entry
代码:
arch/x86/entry/entry_64.S是全内核最 “魔法” 但也最清晰的一段。读它胜过读一本 OS 书。 - 慢 path 的成本:syscall 本身的固定成本是 100–500 ns,这是你设计 hot path 时的 floor——如果你的 syscall 每秒一百万次,光这个 floor 就吃掉一整个核。
- vDSO 是第一优化:时间、getcpu、随机数这些是所有高频应用的常见瓶颈点。知道 vDSO 的存在可以避免很多无谓的优化。
下一篇 A-06
展开内核与用户态的边界:copy_from_user
的语义、SECCOMP 的深度、用户页生命周期、如何想
“这个指针跨界怎么判安全”。
参考文献
- Linux source,
arch/x86/entry/entry_64.S、arch/arm64/kernel/entry.S、arch/riscv/kernel/entry.S - Linux
arch/*/syscalls/syscall_*.tbl - Linux
include/linux/syscalls.h——SYSCALL_DEFINEn宏 - AMD. AMD64 Architecture Programmer’s Manual, Volume 3. SYSCALL / SYSRET 章节
- Intel. Intel 64 and IA-32 Architectures Software Developer’s Manual. Vol 2B SYSENTER / Vol 3A 5.8.7
- ARM. Arm Architecture Reference Manual for A-profile Architecture. Section D1.10 “Exception model”
- RISC-V Foundation. RISC-V Instruction Set Manual, Volume II: Privileged Architecture
- Russinovich, M., Solomon, D. Windows Internals, Part 1, 7th ed., 2017. Chapter 2 “System architecture”
- Drepper, U. “The Cost of System Calls.” LWN, 2005
- Linux Documentation/userspace-api/no_new_privs.rst、seccomp_filter.rst
- Corbet, J. “On the stability of userspace ABI.” LWN.net, multiple articles
工具
strace -f -c:统计 syscall 频率与耗时perf trace:基于 eBPF 的 syscall 追踪,低开销bpftrace:tracepoint:syscalls:sys_enter_*自定义ausyscall:查 syscall 号/proc/kallsyms | grep sys_:查符号man 2 syscalls:综述
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【操作系统百科】稳定与不稳定 ABI
sysctl/sysfs/tracefs 哪些能依赖?syscall 反而最稳定。本文讲 Documentation/ABI 框架、sysfs 规则、debugfs/tracefs 非承诺、Linus 的 never break userspace 规则与例外。
【操作系统百科】vDSO
vDSO 把 gettimeofday/clock_gettime 搬到用户态——零 syscall 读时间。本文讲 vDSO 原理、vvar 共享页、getrandom vDSO、x86/arm64 差异、vsyscall 退役、time namespace 支持。
【操作系统百科】时钟源
TSC 真的稳吗?本文讲 clocksource/clockevents 分离、TSC invariant/unstable 判定、HPET/ACPI PM/arch_timer、watchdog 校准、vDSO gettimeofday、PTP/PHC 硬件时间戳。
【操作系统百科】ARMv8 VMSA 页表
ARMv8-A 翻译体制与 x86 差异很大:两套 TTBR、可选 4K/16K/64K granule、ASID 原生标签、nG/G 分离。本文梳理 VMSAv8-64 核心:TTBR0/1、granule、descriptor、ASID、VHE、MTE、BTI。