3. 系统调用参数传递机制:参数寄存器约定、参数传递路径、参数类型与大小限制

系统调用是用户态应用程序与内核交互的唯一合法入口。理解参数如何在用户态与内核态之间传递,是进行参数校验的前提。本章深入剖析 x86-64 架构下 Linux 系统调用的参数传递机制,涵盖寄存器约定、传递路径以及类型与大小限制。

3.1 参数寄存器约定

在 x86-64 架构中,系统调用参数通过寄存器传递,而非栈。这种设计减少了内存访问延迟,提升了调用效率。Linux 内核遵循特定的寄存器约定,将参数按顺序放入固定寄存器。

参数序号 寄存器 用途说明
系统调用号 rax 标识具体的系统调用(如 __NR_read)
第 1 个参数 rdi 通用参数传递
第 2 个参数 rsi 通用参数传递
第 3 个参数 rdx 通用参数传递
第 4 个参数 r10 替代 rcx(rcx 被 syscall 指令用于保存返回地址)
第 5 个参数 r8 通用参数传递
第 6 个参数 r9 通用参数传递
返回值 rax 系统调用返回结果或错误码
关键点: 与函数调用约定不同,系统调用使用 r10 而非 rcx 作为第 4 个参数寄存器。这是因为 syscall 指令会隐式使用 rcx 保存返回地址,若继续使用 rcx 传递参数会导致数据被覆盖。

以下 SVG 展示了系统调用参数在寄存器中的映射关系:

系统调用参数寄存器映射 系统调用参数寄存器映射(x86-64) 用户态调用 rax rdi rsi rdx r10 r8 r9 系统调用号 arg1 arg2 arg3 arg4(替代 rcx) arg5 arg6 注意:r10 替代 rcx 因为 syscall 指令隐式使用 rcx

3.2 参数传递路径

系统调用参数从用户态到内核态的传递路径,涉及模式切换、寄存器保存与内核栈操作。理解这一路径有助于定位参数校验的插入点。

  1. 用户态准备: 应用程序将参数填入约定寄存器,并将系统调用号存入 rax,随后执行 syscall 指令。
  2. 模式切换: CPU 切换到内核态,将 rcx(返回地址)和 r11(RFLAGS)保存到内核栈,并跳转到入口点 entry_SYSCALL_64。
  3. 寄存器保存: 内核入口代码将用户态寄存器(包括参数寄存器)压入内核栈上的 pt_regs 结构体。
  4. 参数提取: 内核从 pt_regs 中读取参数,传递给具体的系统调用处理函数。
  5. 校验与处理: 处理函数执行参数校验,然后完成实际功能。

以下 SVG 展示了参数传递的完整路径:

系统调用参数传递路径 系统调用参数传递路径 用户态 填充寄存器 + syscall 模式切换 保存 rcx/r11,跳转入口 内核入口 保存 pt_regs 参数提取 从 pt_regs 读取参数 校验与处理 参数校验 + 功能执行 关键校验点:参数校验应在阶段 5 开始处进行,此时参数已从 pt_regs 提取 但尚未被处理函数使用,确保校验失败时不会产生副作用

3.3 参数类型与大小限制

系统调用参数本质上是 64 位无符号整数(unsigned long),但内核会根据具体系统调用的语义,对参数进行类型解释和大小限制。理解这些限制是编写正确校验逻辑的基础。

3.3.1 基本类型映射

用户态类型 内核接收类型 大小(位) 校验要点
int long 32 → 64 符号扩展可能导致意外值,需检查范围
unsigned int unsigned long 32 → 64 零扩展,高位可能包含无效数据
void * unsigned long 64 必须校验指针合法性(非空、可访问)
size_t unsigned long 64 防止整数溢出,检查是否超过内核限制
struct * unsigned long 64 需使用 copy_from_user 安全拷贝

3.3.2 大小限制与校验策略

  • 整数溢出: 用户提供的 size_t 参数可能为 0 或极大值,导致内核缓冲区溢出。校验时必须检查 size > 0 且 size <= max_size。
  • 指针范围: 用户态指针必须位于用户地址空间(通常为 0x0000000000000000 到 0x00007fffffffffff),且不能指向内核空间。
  • 对齐要求: 某些系统调用要求参数对齐(如 mmap 的地址对齐),未对齐参数应返回 -EINVAL。
  • 负值处理: 用户态传递的 int 类型负值,在寄存器中表现为高位为 1 的 64 位值,内核需正确识别并拒绝。
校验示例: 以下代码展示了对 write 系统调用参数的基本校验逻辑。
// 简化的 write 系统调用参数校验
ssize_t sys_write(unsigned int fd, const char __user *buf, size_t count) {
    // 1. 检查 fd 范围
    if (fd >= NR_OPEN_MAX)
        return -EBADF;

    // 2. 检查 buf 指针合法性
    if (!buf || !access_ok(VERIFY_READ, buf, count))
        return -EFAULT;

    // 3. 检查 count 大小限制
    if (count == 0 || count > MAX_RW_COUNT)
        return -EINVAL;

    // 4. 检查 count 是否导致整数溢出(与文件偏移结合时)
    if (count > (size_t)-1 - offset)
        return -EFBIG;

    // 通过校验,继续执行实际写入操作
    // ...
}

3.4 参数校验的常见陷阱

安全警告: 以下陷阱是内核模块开发中常见的参数校验漏洞,务必在代码审查中重点检查。
  • TOCTOU 竞争: 在第一次校验和第二次使用之间,用户态可能修改参数指向的内存内容。解决方案:使用 copy_from_user 将数据拷贝到内核缓冲区后再校验。
  • 符号扩展混淆: 用户传递 -1 作为 int,寄存器中为 0xFFFFFFFFFFFFFFFF,若内核将其视为 unsigned long 进行比较,可能绕过大小检查。
  • 指针截断: 在 32 位兼容层中,用户态指针为 32 位,但内核以 64 位处理,高位可能包含残留数据,需显式掩码。
  • 空指针解引用: 用户传递 NULL 指针,内核在未校验的情况下直接使用,导致内核崩溃。

以下 SVG 总结了参数校验的常见陷阱与应对策略:

参数校验常见陷阱 参数校验常见陷阱与应对策略 TOCTOU 竞争 先校验后修改 符号扩展混淆 负值绕过检查 指针截断 高位残留数据 空指针解引用 未校验直接使用 copy_from_user 拷贝后校验 显式类型转换 检查符号位 掩码处理 清除高位残留 指针非空校验 access_ok 检查 核心原则:所有用户态传入的参数都必须被视为不可信数据,校验必须在拷贝到内核缓冲区后进行

3.5 本章小结