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 硬件和内核入口代码共同完成。
性能注意:每次系统调用都会刷新 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 |
系统调用号的查找流程如下:
- 用户程序将系统调用号放入
rax寄存器(x86_64) - 执行
syscall指令,CPU 跳转到内核入口entry_SYSCALL_64 - 内核从
rax读取系统调用号,检查是否在有效范围内(0–NR_syscalls) - 以系统调用号为索引,从
sys_call_table数组中取出对应的函数指针 - 调用该函数,传递参数(来自
rdi,rsi,rdx等寄存器)
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 测试用例的前提