土法炼钢兴趣小组的算法知识备份

【操作系统百科】系统调用 ABI:x86_64 / arm64 / riscv / Windows NT 对照

文章导航

分类入口
os
标签入口
#syscall#abi#x86_64#arm64#riscv#windows-nt#vdso#sysenter#linux

目录

【操作系统百科】系统调用 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

一个 C 程序只管 API;但编译好的 ELF 走的是 ABI。Linux 承诺的是 syscall ABI 稳定,不是 “内核内部函数稳定”(后者叫 Stable / Unstable internal ABI,见 J-86)。

1.2 哪些东西属于 syscall ABI

syscall 号一旦分配,永远不会重用。sys_getpmsg(号 181)虽然废弃了,但号位空着。

1.3 破坏的代价

Linus 最著名的回应之一:

“WE DO NOT BREAK USERSPACE!”

任何内核改动如果破坏了一个现有的用户态程序,都视作 regression。哪怕那个程序”用错了”接口。历史上几个例子:

这份偏执让 Linux 用户敢长期依赖。

二、syscall 指令:硬件层

2.1 x86_64:SYSCALL

AMD 在 2003 年引入 SYSCALL 指令。行为:

约定(Linux x86_64):

; 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 演化三代:

Linux 运行时选择最快的(vDSO 里的 __kernel_vsyscall 根据 CPU 选择)。

2.3 arm64:SVC

ARMv8-A 的系统调用用 svc #0

; read
mov x8, #63         // __NR_read
mov x0, <fd>
mov x1, <buf>
mov x2, <count>
svc #0

svc 是 “Supervisor Call”,通过 EL1 的 vector table 进入内核。没有 x86_64 那样特殊的 MSR;入口地址由 VBAR_EL1 指定。

2.4 RISC-V:ECALL

RISC-V 的 ecall

li a7, 63           # __NR_read
mv a0, <fd>
mv a1, <buf>
mv a2, <count>
ecall

2.5 Windows NT

Windows 的系统调用号叫 SSN(System Service Number),在 Native API(ntdll.dll)里:

关键差异:Windows 不把 syscall 号当做稳定 ABI。NtReadFile 在 Windows 10 和 Windows 11 之间号码会变。微软的稳定接口在 ntdll.dll 级别(运行时符号),再上面是 Win32 API(kernel32.dll 等)。这就是为什么 Windows 没法像 Linux 那样简单让静态链接的二进制跑——你必须通过 DLL。

三、Linux 的 “负 errno” 返回约定

Linux syscall 返回值是有符号的 long。按约定:

这是”两值”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 的引入流程:

  1. 在 table 里加号
  2. 实现 sys_xxx() 原型
  3. SYSCALL_DEFINEn() 宏包装(自动生成兼容 stub、参数校验)
  4. 所有架构都要分配号(否则某些 arch 不支持这个 syscall)

4.1 SYSCALL_DEFINEn 的展开

SYSCALL_DEFINE3(read, unsigned int, fd, char __user *, buf, size_t, count)

展开后生成:

这么绕是为了处理 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 的用户态函数

gettimeofdayclock_gettimegetcputime——这些高频 syscall 进内核一次太贵。Linux 的 vDSO(virtual Dynamic Shared Object):

性能对比(典型 x86_64):

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。原因:

代价:

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 内的完整生命周期:

  1. 用户态 syscall 指令
  2. CPU 切换到内核态,跳到 entry_SYSCALL_64(x86_64)或对应入口
  3. SWAPGS:切换 GS base 到内核 per-CPU 数据
  4. 保存寄存器到栈
  5. 切换到内核栈
  6. (如开启 KPTI)切换 CR3 到内核页表
  7. enter_from_user_mode():通知追踪、审计、seccomp
  8. syscall trace / seccomp filter:ptrace 拦截、seccomp-bpf 过滤、audit 记录
  9. 查 syscall table,分发到 __x64_sys_read
  10. 执行 syscall 逻辑
  11. audit_syscall_exit
  12. 恢复寄存器
  13. exit_to_user_mode():检查是否有 pending signal、reschedule
  14. (KPTI)切换回用户页表
  15. SWAPGS 还原
  16. 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 进程:

另外 Linux 的 WoW(WINE) 通过 ptrace 重写 Windows syscall。FreeBSD 的 Linux ABI 兼容层(linux.ko)能直接跑部分 Linux 二进制。

九、syscall 的版本化:如何无损演进

当某 syscall 的语义不够用了,不能改它(违反稳定 ABI)。Linux 的惯例:加一个新的

例子:

“带长度字段的结构体参数”是现代 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 应用程序分发从来绕不开 “安装程序” 的一个原因。

十一、给工程师的启示

  1. 选稳定层:在 Linux 上做系统级工具(strace、ebpf 工具、容器运行时)可以直接走 syscall;其它平台绑 libc。
  2. ABI 演进 = 加字段,带长度:设计你自己的 RPC、proto、格式时都可以借这个思路。
  3. 看 entry 代码arch/x86/entry/entry_64.S 是全内核最 “魔法” 但也最清晰的一段。读它胜过读一本 OS 书。
  4. 慢 path 的成本:syscall 本身的固定成本是 100–500 ns,这是你设计 hot path 时的 floor——如果你的 syscall 每秒一百万次,光这个 floor 就吃掉一整个核。
  5. vDSO 是第一优化:时间、getcpu、随机数这些是所有高频应用的常见瓶颈点。知道 vDSO 的存在可以避免很多无谓的优化。

下一篇 A-06 展开内核与用户态的边界copy_from_user 的语义、SECCOMP 的深度、用户页生命周期、如何想 “这个指针跨界怎么判安全”。


参考文献

工具


上一篇特权级与硬件隔离 下一篇内核与用户态的边界

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。

2026-06-16 · os

【操作系统百科】稳定与不稳定 ABI

sysctl/sysfs/tracefs 哪些能依赖?syscall 反而最稳定。本文讲 Documentation/ABI 框架、sysfs 规则、debugfs/tracefs 非承诺、Linus 的 never break userspace 规则与例外。

2026-06-14 · os

【操作系统百科】vDSO

vDSO 把 gettimeofday/clock_gettime 搬到用户态——零 syscall 读时间。本文讲 vDSO 原理、vvar 共享页、getrandom vDSO、x86/arm64 差异、vsyscall 退役、time namespace 支持。

2026-06-08 · os

【操作系统百科】时钟源

TSC 真的稳吗?本文讲 clocksource/clockevents 分离、TSC invariant/unstable 判定、HPET/ACPI PM/arch_timer、watchdog 校准、vDSO gettimeofday、PTP/PHC 硬件时间戳。

2026-04-22 · os

【操作系统百科】ARMv8 VMSA 页表

ARMv8-A 翻译体制与 x86 差异很大:两套 TTBR、可选 4K/16K/64K granule、ASID 原生标签、nG/G 分离。本文梳理 VMSAv8-64 核心:TTBR0/1、granule、descriptor、ASID、VHE、MTE、BTI。


By .