本页目录

Linux x86-64 系统调用 ABI

写手工汇编、inline asm、调试 seccomp 或阅读反汇编时,最容易混淆的是三套不同规则:

  1. System V AMD64 C ABI:普通用户态函数怎样传参、返回和保存寄存器。
  2. Linux x86-64 syscall ABI:用户态怎样把系统调用号和最多 6 个参数交给内核。
  3. CPU 的 SYSCALL/SYSRET 指令语义⁠:硬件在特权级切换时实际改了哪些寄存器。

本文把三层分开,并以当前 Linux x86-64 入口代码为基准。内核实现会演进;复制源码片段时应注明版本,而不是把某个版本的细节当成永久 ABI。

1. Linux syscall 寄存器约定

用途寄存器
系统调用号rax
参数 1–3rdi, rsi, rdx
参数 4–6r10, r8, r9
返回值rax
硬件破坏rcx, r11

典型调用:

mov $1, %rax          # __NR_write
mov $1, %rdi          # fd = STDOUT_FILENO
lea message(%rip), %rsi
mov $6, %rdx
syscall

Linux 内核直接返回非负结果或 -errno。glibc 等 libc wrapper 再把 -errno 转换为 -1,并设置线程局部的 errno

为什么第 4 个参数是 r10,而不是 rcx

普通 System V 函数的第 4 个整数参数使用 rcx;但 SYSCALL 指令必须把用户态返回地址写入 rcx。因此 Linux syscall ABI 把第 4 个参数移到 r10

这也是 inline asm 必须声明 rcxr11 clobber 的原因:

static inline long raw_write(int fd, const void *buf, unsigned long count)
{
    register long rax __asm__("rax") = 1;   /* __NR_write */
    register long rdi __asm__("rdi") = fd;
    register long rsi __asm__("rsi") = (long)buf;
    register long rdx __asm__("rdx") = count;

    __asm__ volatile (
        "syscall"
        : "+a"(rax)
        : "D"(rdi), "S"(rsi), "d"(rdx)
        : "rcx", "r11", "memory"
    );
    return rax;
}

对于 4–6 个参数,需要显式绑定 r10r8r9。生产代码通常应优先调用 libc;raw syscall 会绕过取消点、兼容封装和 libc 的错误处理。

2. SYSCALL 指令做什么

在 64 位模式下执行 SYSCALL 时,CPU 主要完成:

RCX    <- 下一条用户态指令地址
R11    <- 用户态 RFLAGS
RIP    <- IA32_LSTAR
RFLAGS <- RFLAGS & ~IA32_FMASK
CS/SS  <- 由 IA32_STAR 派生的内核选择子
CPL    <- 0

两个关键结论:

  • rcxr11 被硬件占用,不能用于保存跨 syscall 的值。
  • SYSCALL 不会保存用户态 rsp,也不会自动切到内核栈⁠。

RFLAGS 并非简单“保持不变”。内核在 IA32_FMASK 中配置要清除的标志位;Linux 的入口代码在建立安全状态后再管理中断。

3. Linux entry_SYSCALL_64

当前 x86-64 入口的核心顺序是:

# arch/x86/entry/entry_64.S(省略安全加固与注解)
entry_SYSCALL_64:
    swapgs
    movq %rsp, PER_CPU_VAR(cpu_tss_rw + TSS_sp2)  # 暂存用户 rsp
    SWITCH_TO_KERNEL_CR3 scratch_reg=%rsp
    movq PER_CPU_VAR(cpu_current_top_of_stack), %rsp

    pushq $__USER_DS
    pushq PER_CPU_VAR(cpu_tss_rw + TSS_sp2)
    pushq %r11
    pushq $__USER_CS
    pushq %rcx
    pushq %rax
    # 继续构造 struct pt_regs,随后调用 do_syscall_64

这里的 tss.sp2 是 Linux 使用的 per-CPU scratch slot,不是传统意义上由硬件自动切栈的 sp0 流程。顺序也很重要:先保存用户 rsp,再切内核页表与内核栈,最后在内核栈上构造 struct pt_regs

因此,用户态 red zone 不会被普通 syscall 入口上的寄存器帧覆盖;帧位于内核栈,而不是“用户 rsp 下方”。

4. 返回:SYSRET 快路径与 IRET 回退

系统调用结束后,Linux 会检查返回上下文是否适合 SYSRET。只有在地址、段寄存器和标志位等条件都安全时才走快路径;否则使用 IRET

这样做不是单纯的性能选择。AMD 和 Intel 的 SYSRET 对非 canonical 地址等边界情况存在难处理的行为,内核必须先验证用户可控的 pt_regs

从用户态观察,两条返回路径都应满足同一 syscall ABI;不能依赖内核一定使用 SYSRET

5. Red zone 与系统调用

System V AMD64 ABI 在用户 rsp 下方定义 128 字节 red zone。叶子函数可以临时使用这块空间而不调整 rsp

需要区分三种情况:

  • 普通 syscall/中断进入内核⁠:Linux 切换到内核栈,入口帧不写用户 red zone。
  • 向用户态递送 signal:内核构造 signal frame 时会为 red zone 留出空间。
  • 内核代码本身⁠:内核以 -mno-red-zone 编译,不能依赖用户态 ABI 的 red zone。

手写 inline asm 时,"memory" clobber 只约束编译器的内存重排;它不代表 syscall 只会访问某个固定内存范围。传给内核的指针指向哪些缓冲区,仍由具体 syscall 定义。

6. vDSO 不是“在用户态执行 syscall”

vDSO 是内核映射进进程地址空间的一小段 ELF 代码。某些 libc API 可以先调用 vDSO,从内核维护的只读数据中计算结果,从而避免真正执行 SYSCALL

常见符号包括:

  • __vdso_clock_gettime
  • __vdso_gettimeofday
  • __vdso_time
  • __vdso_getcpu
  • 新内核上的 __vdso_getrandom

这些符号是否存在、能否完成请求取决于架构和内核版本;libc 必须准备回退到真正的 syscall。

检查当前进程的 vDSO:

grep '\[vdso\]' /proc/self/maps

# glibc 环境可观察实际调用是否仍进入内核
strace -e trace=clock_gettime,gettimeofday /bin/date

7. int 0x80sysenter 与 64 位 syscall

三者属于不同入口机制,不能只用一张固定“周期数”表比较:

机制主要使用场景入口配置
int 0x80传统 i386 ABI;x86-64 下也有兼容入口IDT
sysenter/sysexit32 位快速系统调用SYSENTER MSR
syscall/sysret原生 x86-64 Linux syscall ABISTAR/LSTAR/FMASK MSR

实际延迟受 CPU 微架构、KPTI、缓解措施、虚拟化、频率和内核版本影响。需要性能数据时,应在目标机器上测量,并报告环境:

perf stat -r 20 ./syscall-benchmark

不要把某台旧 CPU 上的 50–70 cycles 当成跨平台事实。

8. 完整汇编示例

.section .rodata
message:
    .ascii "hello\n"

.section .text
.global _start
_start:
    mov $1, %rax          # __NR_write
    mov $1, %rdi          # stdout
    lea message(%rip), %rsi
    mov $6, %rdx
    syscall

    test %rax, %rax
    js .error

    xor %rdi, %rdi
    jmp .exit

.error:
    mov $1, %rdi
.exit:
    mov $60, %rax         # __NR_exit
    syscall

构建与观察:

as -o syscall.o syscall.S
ld -o syscall syscall.o
strace ./syscall
objdump -drwC -Mintel ./syscall

9. 权威资料

阅读顺序建议:先用 syscall(2) 确认 Linux ABI,再用处理器手册确认 SYSCALL/SYSRET 指令语义,最后对照目标内核版本的 entry_64.S。这样不会把 CPU 规则、用户态 C ABI 和某一版内核实现混为一谈。