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 封装函数负责:
- 参数重排: 将 C 语言调用约定(参数在
rdi、rsi、rdx、rcx、r8、r9)转换为系统调用约定(rdi、rsi、rdx、r10、r8、r9),注意第 4 个参数从rcx移到r10。 - 系统调用号加载: 将系统调用号存入
rax寄存器。 - 执行 syscall 指令: 触发从用户态到内核态的切换。
- 返回值处理: 从
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 流程图
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 参数启用)。