4. x86_64 架构下的系统调用流程:从用户态调用到内核态入口

系统调用是用户态程序请求内核服务的唯一合法通道。在 x86_64 架构中,从用户态执行 syscall 指令到内核态 entry_SYSCALL_64 入口的完整路径,涉及指令级切换、栈切换、寄存器保存和入口函数分发。理解这一路径是自定义系统调用实现的基础。

核心要点: x86_64 使用 syscall/sysret 指令对替代传统的 int 0x80,性能提升约 2-3 倍。系统调用号通过 rax 传递,参数依次通过 rdi、rsi、rdx、r10、r8、r9 传递。

4.1 用户态调用阶段:libc 封装与 syscall 指令

用户程序通常不直接使用 syscall 指令,而是通过 libc 提供的封装函数(如 write()、read())发起系统调用。libc 封装函数负责:

  1. 参数重排: 将 C 语言调用约定(参数在 rdi、rsi、rdx、rcx、r8、r9)转换为系统调用约定(rdi、rsi、rdx、r10、r8、r9),注意第 4 个参数从 rcx 移到 r10。
  2. 系统调用号加载: 将系统调用号存入 rax 寄存器。
  3. 执行 syscall 指令: 触发从用户态到内核态的切换。
  4. 返回值处理: 从 rax 读取返回值,若为负值则转换为 errno。
; 以 write 系统调用为例的 libc 封装伪代码 (AT&T 语法)
; write(fd, buf, count) -> 系统调用号 1
mov    $1, %rax        ; 系统调用号: write = 1
mov    %rdi, %rdi      ; 参数1: fd (已在 rdi)
mov    %rsi, %rsi      ; 参数2: buf (已在 rsi)
mov    %rdx, %rdx      ; 参数3: count (已在 rdx)
syscall                 ; 触发系统调用
cmp    $-4095, %rax     ; 检查返回值是否在错误范围
ja     error_handler    ; 若 rax > -4095,表示错误
ret                     ; 正常返回
error_handler:
neg    %rax             ; 取绝对值作为 errno
mov    %rax, errno      ; 设置 errno
mov    $-1, %rax        ; 返回 -1
ret
为什么是 r10 而不是 rcx? syscall 指令会隐式地将 rcx 覆盖为返回地址(RIP),将 r11 覆盖为 RFLAGS。因此 libc 必须将第 4 个参数从 rcx 移到 r10,避免参数被破坏。

4.2 syscall 指令的硬件行为

当 CPU 执行 syscall 指令时,硬件自动完成以下关键操作:

操作 描述 影响寄存器/状态
保存返回地址 将下一条指令的 RIP 存入 RCX RCX = 用户态返回地址
保存 RFLAGS 将当前 RFLAGS 存入 R11 R11 = 用户态 RFLAGS
切换栈 将 LSTAR MSR 中的内核栈指针加载到 RSP RSP = 内核栈顶
加载入口地址 将 LSTAR MSR 中的入口地址加载到 RIP RIP = entry_SYSCALL_64
切换特权级 将 CS 段寄存器切换为内核代码段(CPL=0) CS 段选择子更新
禁用中断 清除 IF 标志位(RFLAGS 中) IF = 0
关键区别: 与 int 0x80 不同,syscall 指令不保存用户态栈指针(RSP)到任何寄存器。内核必须在入口处立即保存 RSP,否则无法恢复用户栈。这也是 entry_SYSCALL_64 的第一条指令就是 swapgs 的原因之一。

4.3 内核态入口:entry_SYSCALL_64 的完整路径

entry_SYSCALL_64 是 x86_64 内核中处理所有系统调用的统一入口点,位于 arch/x86/entry/entry_64.S。其执行流程分为四个阶段:

4.3.1 阶段一:GS 基址切换与栈准备

ENTRY(entry_SYSCALL_64)
    swapgs                  ; 交换 GS 基址,获取内核 percpu 数据
    mov    %rsp, %gs:CPU_ENTRY_AREA_tss + TSS_sp2  ; 保存用户 RSP
    mov    %gs:CPU_ENTRY_AREA_tss + TSS_sp0, %rsp  ; 加载内核栈
    pushq  $__USER_DS       ; 推入用户态 SS 段
    pushq  %gs:CPU_ENTRY_AREA_tss + TSS_sp2  ; 推入用户态 RSP
    pushq  %r11             ; 推入用户态 RFLAGS (由 syscall 保存在 r11)
    pushq  $__USER_CS       ; 推入用户态 CS 段
    pushq  %rcx             ; 推入用户态返回地址 (由 syscall 保存在 rcx)
    pushq  $0               ; 推入错误码 (占位)
    pushq  %rax             ; 推入系统调用号
    ; 此时栈布局与中断/异常处理的 pt_regs 结构一致

4.3.2 阶段二:保存通用寄存器

内核需要保存所有被调用者保存的寄存器,以便在系统调用返回时恢复用户态上下文:

    pushq  %rdi             ; 参数1
    pushq  %rsi             ; 参数2
    pushq  %rdx             ; 参数3
    pushq  %r10             ; 参数4 (原 rcx)
    pushq  %r8              ; 参数5
    pushq  %r9              ; 参数6
    pushq  %rbx             ; 被调用者保存
    pushq  %rbp             ; 被调用者保存
    pushq  %r12             ; 被调用者保存
    pushq  %r13             ; 被调用者保存
    pushq  %r14             ; 被调用者保存
    pushq  %r15             ; 被调用者保存
    ; 此时栈上形成完整的 pt_regs 结构

4.3.3 阶段三:系统调用分发

内核根据 rax 中的系统调用号,通过系统调用表(sys_call_table)进行分发:

    mov    %rax, %rdi       ; 将系统调用号移到 rdi (第一个参数)
    call   do_syscall_64    ; 调用 C 语言分发函数

; do_syscall_64 核心逻辑 (arch/x86/entry/common.c)
void do_syscall_64(struct pt_regs *regs, unsigned long nr) {
    struct task_struct *task = current;
    nr = syscall_enter_from_user_mode(regs, nr);
    if (nr < NR_syscalls) {
        nr = array_index_nospec(nr, NR_syscalls);
        regs->ax = sys_call_table[nr](regs, ...);
    }
}

4.3.4 阶段四:返回用户态

系统调用执行完毕后,内核通过 sysret 指令返回用户态:

    ; 恢复通用寄存器 (逆序)
    popq   %r15
    popq   %r14
    popq   %r13
    popq   %r12
    popq   %rbp
    popq   %rbx
    popq   %r9
    popq   %r8
    popq   %r10
    popq   %rdx
    popq   %rsi
    popq   %rdi
    addq   $8, %rsp         ; 跳过系统调用号
    ; 恢复用户态上下文
    popq   %rcx             ; 恢复用户态 RIP (sysret 会从 rcx 加载)
    popq   %r11             ; 恢复用户态 RFLAGS (sysret 会从 r11 加载)
    popq   %rsp             ; 恢复用户态 RSP
    swapgs                  ; 恢复 GS 基址
    sysretq                 ; 返回用户态
性能优化: 现代内核使用 syscall 的 Fast Path 优化,对于不需要信号处理、ptrace 跟踪等特殊情况的系统调用,可以跳过部分检查,直接返回。这减少了约 30% 的入口开销。

4.4 完整路径 SVG 流程图

x86_64 系统调用完整路径流程图 x86_64 系统调用完整路径:从用户态到内核态 阶段一:用户态调用 用户程序调用 libc 封装 libc 封装函数 参数重排 + 系统调用号加载 执行 syscall 指令 硬件自动切换 阶段二:硬件自动切换(syscall 指令内部) RIP → RCX 保存返回地址 RFLAGS → R11 保存标志位 RSP ← LSTAR 加载内核栈 RIP ← LSTAR 跳转入口地址 阶段三:内核入口 entry_SYSCALL_64 swapgs + 栈切换 保存用户 RSP,加载内核栈 构建 pt_regs 结构 保存所有寄存器到栈 调用 do_syscall_64 通过 sys_call_table 分发 阶段四:返回用户态 恢复通用寄存器 swapgs + 恢复 GS 执行 sysretq 指令 关键寄存器映射:rax=系统调用号,rdi/rsi/rdx/r10/r8/r9=参数1-6,rcx=返回地址,r11=RFLAGS 整个路径从 syscall 到 sysretq 约需 50-100 个 CPU 周期(无缓存未命中时)

4.5 关键数据结构:pt_regs 与系统调用表

在 entry_SYSCALL_64 中,内核在栈上构建的 pt_regs 结构是用户态上下文的完整快照:

偏移量 字段名 保存内容 来源
0x00 r15 用户态 r15 pushq %r15
0x08 r14 用户态 r14 pushq %r14
0x10 r13 用户态 r13 pushq %r13
0x18 r12 用户态 r12 pushq %r12
0x20 rbp 用户态 rbp pushq %rbp
0x28 rbx 用户态 rbx pushq %rbx
0x30 r11 用户态 r9 pushq %r9
0x38 r10 用户态 r8 pushq %r8
0x40 r9 用户态 r10 pushq %r10
0x48 r8 用户态 rdx pushq %rdx
0x50 rdx 用户态 rsi pushq %rsi
0x58 rsi 用户态 rdi pushq %rdi
0x60 rdi 用户态 rax (系统调用号) pushq %rax
0x68 orig_rax 系统调用号副本 pushq $0 (错误码占位)
0x70 rip 用户态返回地址 pushq %rcx
0x78 cs 用户态代码段 pushq $__USER_CS
0x80 eflags 用户态 RFLAGS pushq %r11
0x88 rsp 用户态栈指针 pushq 用户 RSP
0x90 ss 用户态栈段 pushq $__USER_DS
系统调用表(sys_call_table) 是一个函数指针数组,定义在 arch/x86/entry/syscall_64.c 中。数组索引即为系统调用号,每个元素指向对应的内核处理函数。例如 sys_call_table[0] 指向 __x64_sys_read,sys_call_table[1] 指向 __x64_sys_write。自定义系统调用的核心就是在此表中注册新的条目。

4.6 路径总结与性能对比

阶段 主要操作 耗时占比 优化空间
用户态 libc 封装 参数重排、系统调用号加载 ~5% 内联汇编优化
syscall 指令硬件切换 寄存器保存、栈切换、特权级提升 ~15% 硬件固定开销
entry_SYSCALL_64 入口 swapgs、pt_regs 构建、寄存器保存 ~30% Fast Path 跳过检查
do_syscall_64 分发 系统调用号验证、表查找、函数调用 ~10% 间接调用预测
内核处理函数 具体系统调用逻辑执行 ~30% 算法/数据结构优化
返回用户态 寄存器恢复、swapgs、sysretq ~10% 硬件固定开销
与 int 0x80 的对比: 在 x86_64 架构中,int 0x80 仍然可用但已废弃。两者关键差异:int 0x80 通过 IDT 查找入口,需要保存更多寄存器,且不支持 64 位参数;syscall 使用 MSR 直接跳转,路径更短,性能提升约 2-3 倍。现代 x86_64 Linux 内核默认禁用 int 0x80 路径(可通过 kernel.allow_int80_syscall 参数启用)。