2. Linux 系统调用基础:概念、切换与实现

系统调用(System Call)是用户态程序请求内核服务的唯一合法入口。在 Linux 中,系统调用构成了用户空间与内核空间之间的契约接口。理解系统调用的完整生命周期——从概念定义、特权级切换、到内核中的查找与执行——是编写 KUnit 测试用例的前提。

学习目标:掌握系统调用的概念与分类;理解用户态与内核态的切换机制;熟悉系统调用号的分配规则与内核实现路径。

2.1 系统调用的概念与作用

系统调用是操作系统内核提供给用户程序的受控服务接口。用户程序通过系统调用访问硬件资源、创建进程、操作文件、管理内存等。Linux 提供了约 300–500 个系统调用(随架构和版本变化)。

特性 说明
入口唯一性 用户态不能直接跳转内核代码,必须通过系统调用指令(如 syscall / int 0x80)
特权级隔离 用户态运行在 Ring 3,内核态运行在 Ring 0,系统调用触发特权级切换
参数传递 通过寄存器传递系统调用号和参数,避免用户态直接访问内核内存
返回值约定 成功返回非负值,失败返回负的错误码(如 -EINVAL)
关键区分:系统调用 ≠ 库函数(如 printf 是 C 库函数,内部调用 write 系统调用)。KUnit 测试直接针对内核系统调用实现函数,而非用户态库封装。

2.2 用户态与内核态切换

每次系统调用都涉及一次完整的上下文切换:从用户态陷入内核态,执行内核服务,再返回用户态。切换过程由 CPU 硬件和内核入口代码共同完成。

系统调用:用户态 ↔ 内核态切换流程 用户态 (Ring 3) 应用程序调用系统调用 参数放入寄存器 执行 syscall 指令 内核态 (Ring 0) 入口函数处理 (entry_SYSCALL_64) 保存用户态寄存器 根据系统调用号分发执行 陷入 执行具体系统调用函数 如 sys_read, sys_open 返回用户态 切换开销:约 50–200 ns(取决于 CPU 微架构和 Meltdown 缓解措施)
性能注意:每次系统调用都会刷新 TLB 部分条目、切换栈指针(从用户栈到内核栈)、保存/恢复寄存器。KUnit 测试在纯内核态执行,不涉及用户态切换,因此测试速度远快于真实系统调用。

2.3 系统调用号与查找机制

每个系统调用在 Linux 内核中都有一个唯一的系统调用号(syscall number)。系统调用号是内核查找对应处理函数的索引。不同架构的系统调用号表可能不同(x86_64 与 arm64 的编号不同)。

系统调用 x86_64 编号 arm64 编号 内核函数
read 0 63 ksys_read / __arm64_sys_read
write 1 64 ksys_write / __arm64_sys_write
open 2 56 do_sys_open
close 3 57 ksys_close
mmap 9 222 ksys_mmap_pgoff

系统调用号的查找流程如下:

  1. 用户程序将系统调用号放入 rax 寄存器(x86_64)
  2. 执行 syscall 指令,CPU 跳转到内核入口 entry_SYSCALL_64
  3. 内核从 rax 读取系统调用号,检查是否在有效范围内(0–NR_syscalls)
  4. 以系统调用号为索引,从 sys_call_table 数组中取出对应的函数指针
  5. 调用该函数,传递参数(来自 rdi, rsi, rdx 等寄存器)
系统调用号查找与分发 用户程序 rax = 系统调用号 sys_call_table 处理函数 sys_call_table 结构 [0] → sys_read [1] → sys_write [2] → sys_open ... [NR_syscalls-1] → ...
KUnit 测试切入点:KUnit 可以直接调用内核系统调用实现函数(如 ksys_read),无需经过用户态入口和寄存器传递。测试时只需构造参数并调用函数,验证返回值与副作用即可。

2.4 系统调用的内核实现模式

Linux 内核中系统调用的实现遵循统一的命名和签名模式。从 5.x 内核开始,系统调用实现分为两层:

  • SYSCALL_DEFINE 宏:用于声明系统调用,自动生成入口包装函数。例如 SYSCALL_DEFINE3(read, unsigned int, fd, char __user *, buf, size_t, count) 展开为 sys_read。
  • ksys_* 辅助函数:可被内核其他部分直接调用的内部实现,不包含用户态访问检查。例如 ksys_read 是 sys_read 的核心逻辑。
  • 用户态访问安全:系统调用必须使用 copy_from_user / copy_to_user 等函数访问用户空间指针,防止内核被恶意用户程序破坏。
// 示例:SYSCALL_DEFINE3 展开后的典型结构
// 内核源码 fs/read_write.c

SYSCALL_DEFINE3(read, unsigned int, fd, char __user *, buf, size_t, count)
{
    return ksys_read(fd, buf, count);
}

// ksys_read 是实际实现,KUnit 可直接测试此函数
ssize_t ksys_read(unsigned int fd, char __user *buf, size_t count)
{
    struct fd f = fdget_pos(fd);
    ssize_t ret = -EBADF;

    if (f.file) {
        loff_t pos = file_pos_read(f.file);
        ret = vfs_read(f.file, buf, count, &pos);
        file_pos_write(f.file, pos);
        fdput_pos(f);
    }
    return ret;
}
KUnit 测试策略:优先测试 ksys_* 函数,因为它们不依赖用户态上下文。测试时需模拟文件描述符、inode 等内核对象,这正是 KUnit 的擅长领域。

2.5 系统调用的错误处理与返回值

Linux 系统调用遵循统一的错误返回约定:

情况 内核返回值 用户态看到
成功 非负值(如读取的字节数) 非负值
参数错误 -EINVAL (负值) -1,errno = EINVAL
权限不足 -EPERM (负值) -1,errno = EPERM
资源不可用 -EAGAIN (负值) -1,errno = EAGAIN
中断 -ERESTARTSYS (内部) 自动重试或 -1,errno = EINTR

内核内部使用负的错误码(如 -ENOMEM),用户态通过 C 库的 errno 机制转换为正数。KUnit 测试直接检查内核函数的返回值是否为预期的负错误码。

测试陷阱:不要假设系统调用总是成功。KUnit 测试应覆盖正常路径和所有合理的错误路径(无效 fd、空指针、越界长度等),确保内核的健壮性。

2.6 本章小结

  • 系统调用是用户态与内核态之间的受控接口,通过 CPU 特权级切换实现隔离
  • 系统调用号是查找处理函数的索引,存储在 sys_call_table 数组中
  • 内核实现使用 SYSCALL_DEFINE 宏和 ksys_* 辅助函数,后者是 KUnit 测试的理想目标
  • 错误处理遵循负错误码约定,KUnit 测试应覆盖成功与失败路径
  • 理解系统调用的底层机制是编写有效 KUnit 测试用例的前提