Linux x86-64 系统调用 ABI

本页目录

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

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

本文分别解释这三层,讨论原生 LP64 ABI,不包含 x32 ABI,并以 Linux v6.18 的 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

对这里的 write,成功返回写入字节数,失败返回 -errno。原生 Linux ABI 用 -4095 到 -1 表示错误;不能把所有系统调用的成功结果一概解释为字节数。glibc 包装函数(包括公共的 syscall())会把内核错误转换为 -1,并设置线程局部的 errno。

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

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

这也是 inline asm 必须声明 rcx 和 r11 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 个参数,需要显式绑定 r10、r8、r9。生产代码通常应优先调用 libc;raw syscall 会绕过取消点、兼容封装和 libc 的错误处理。

六参数 mmap:C 的调用规则需要转换为系统调用规则 普通 C 调用 第 4 参数:rcx 第 5/6:r8 / r9 syscall 的第 4 参数:r10 此例是 MAP_PRIVATE | MAP_ANONYMOUS r8 = -1,r9 = 0 fd 与 offset rax = SYS_mmap rcx 被指令写入返回 RIP;r11 被写入标志信息,不能留作跨调用临时值。 图示限原生 Linux x86-64 LP64;不是其他架构或 x32 的通用封装。

完整六参数例子:申请一页匿名内存再释放

保存为 raw-mmap.c,在原生 Linux x86-64 上使用 GCC 或 Clang 编译。此例保留 libc 用于打印和查询页大小,但 mmap/munmap 通过内联 syscall 执行。SYS_* 宏来自本机目标头文件,避免把某个调用号误搬到另一种 ABI。

#include <errno.h>
#include <stdio.h>
#include <sys/mman.h>
#include <sys/syscall.h>
#include <unistd.h>

static long raw6(long number, long a1, long a2, long a3,
                 long a4, long a5, long a6) {
    register long r10 __asm__("r10") = a4;
    register long r8 __asm__("r8") = a5;
    register long r9 __asm__("r9") = a6;
    long result;
    __asm__ volatile("syscall"
        : "=a"(result)
        : "0"(number), "D"(a1), "S"(a2), "d"(a3),
          "r"(r10), "r"(r8), "r"(r9)
        : "rcx", "r11", "memory");
    return result;
}
static int raw_error(long result) {
    return (unsigned long)result >= (unsigned long)-4095L;
}
int main(void) {
    long size = sysconf(_SC_PAGESIZE);
    if (size <= 0) return 1;
    long address = raw6(SYS_mmap, 0, size, PROT_READ | PROT_WRITE,
                       MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
    if (raw_error(address)) {
        fprintf(stderr, "mmap error=%ld\n", -address);
        return 1;
    }
    unsigned char *page = (unsigned char *)address;
    page[0] = 7;
    page[size - 1] = 9;
    int valid = page[0] == 7 && page[size - 1] == 9;
    long released = raw6(SYS_munmap, address, size, 0, 0, 0, 0);
    if (raw_error(released) || !valid) return 1;

    errno = 0;
    long bad = raw6(SYS_mmap, 0, 0, PROT_READ,
                   MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
    int unchanged = errno == 0;
    if (bad != -EINVAL || !unchanged) return 1;
    printf("page checked; raw error=%ld; errno unchanged=%d\n",
           -bad, unchanged);
    return 0;
}
cc -std=gnu11 -Wall -Wextra -O2 raw-mmap.c -o raw-mmap
./raw-mmap
objdump -d -Mintel raw-mmap

第一次调用的 flags 是第 4 参数,必须进入 r10;fd=-1 和 offset=0 分别在 r8、r9。匿名映射返回零初始化内存,本例写入首尾字节后释放。长度为零的第二次 mmap 应返回 -EINVAL,并且不替调用者设置 errno。mmap(2)

错误检测先转换为 unsigned long,再与 -4095 的无符号表示比较,把顶端保留区间解释为错误。不要把地址结果在检查前当成有效指针解引用,也不要认为 errno 会被内核自动填写;那是 libc 包装层的工作。raw6 只是本例同步返回调用的演示,不可不加修改地用于改变栈/控制流的 clone、rt_sigreturn 等接口。syscall(2)

局部 register 变量配合扩展 asm 约束,把输入安排在指定寄存器中;声明本身不代表永久独占寄存器。因此先完成参数计算,再紧接着执行 asm,不能在中间插入可能覆盖调用者保存寄存器的函数调用。GCC 局部寄存器变量

返回值还要按具体调用解释

原始结果对应情境调用者下一步
mmap 返回非错误地址建立了映射按申请长度与权限使用
read 返回 0对普通文件/字节流可能是 EOF结束读取,不能当成 -errno
write 返回正数但小于请求已提交部分字节推进指针和剩余长度
-EINTR此次调用被中断并报告错误按该接口语义决定重试
-EAGAIN当前无法立即完成等待就绪或交还调度,避免忙循环

例如要写 6 字节,依次返回 2、-EINTR、4,第二次不能把指针再推进 2:只有正返回值确认了本次进展。本文末尾的汇编循环保留 rsi/rdx,遇到 -EINTR 重试当前剩余部分,短写才更新这两个寄存器。

rcx 的两种身份不能同时保留

这里不需要背诵两张寄存器表:让第四参数沿着 rcx→r10 的路径移动,再观察硬件对 rcx 的覆盖,就能看见两种 ABI 为什么必须衔接。RFLAGS* 表示指令规定处理后的标志值,RF 的细节见下一节。

正在呈现知识画面
rcx 的两种身份不能同时保留

C ABI 用 rcx 传第四参数,SYSCALL 覆盖 rcx,Linux 从 r10 读取第四参数。

2. SYSCALL 指令做什么

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

SYSCALL:硬件入口与软件建栈是两步 用户态执行 SYSCALL 参数已按 Linux ABI 就位 保存返回信息 RCX ← 下一条 RIP;R11 ← RFLAGS 进入配置好的内核入口 RIP ← LSTAR;CS/SS 来自 STAR;CPL=0 屏蔽指定标志位 RFLAGS ← RFLAGS & ~FMASK 以上硬件动作不保存 RSP,也不自动建立内核栈上的 pt_regs。

两个关键结论:

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

上述图省略了 RF 的特殊处理:硬件先清除 RF,再把相应标志状态保存到 R11。RFLAGS 并非简单“保持不变”。内核在 IA32_FMASK 中配置要清除的标志位;Linux 的入口代码在建立安全状态后再管理中断。

3. Linux entry_SYSCALL_64

Linux v6.18 的 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:

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

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

7. int 0x80、sysenter 与 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:
    lea message(%rip), %rsi
    mov $6, %rdx
.write:
    mov $1, %rax          # __NR_write
    mov $1, %rdi          # stdout
    syscall
    cmp $-4, %rax        # -EINTR:本次没有写入,重试
    je .write
    test %rax, %rax
    jle .error           # 负值或无进展;此例不等待 EAGAIN
    add %rax, %rsi
    sub %rax, %rdx
    jnz .write           # 短写:继续写剩余部分
    xor %rdi, %rdi
    jmp .exit
.error:
    mov $1, %rdi
.exit:
    mov $60, %rax        # __NR_exit:退出当前线程
    syscall
.section .note.GNU-stack,"",@progbits

示例是没有 libc 的单线程程序;原始 exit 退出当前线程,退出整个线程组是 exit_group。若 stdout 是已关闭读端的管道,默认的 SIGPIPE 也可能直接终止进程。

构建与观察:

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 和某一版内核实现混为一谈。