Linux内核分析之基础知识-06
This language version is unavailable; showing the other language.
7.1 系统调用原理与分类
7.1.1 什么是系统调用
系统调用是操作系统内核向用户态程序暴露的一组受控入口点(controlled entry points),是用户程序请求内核服务的标准接口。每一个系统调用都对应着内核中一个明确的功能函数,用户程序通过预定的调用约定触发这些函数,从而在内核特权级别下执行那些需要更高权限的操作。
从应用程序的视角来看,系统调用就像是一组特殊的"函数调用"——只是这些函数运行在内核空间,调用过程伴随着特权级别的切换。Linux 标准C库(glibc)将系统调用封装为用户友好的 API,使开发者通常无需直接面对底层的系统调用接口。例如,当程序调用 printf() 向终端输出文字时,glibc 最终会通过 write 系统调用(系统调用号 1)将数据传递给内核。
从硬件机制的角度来看,系统调用的本质是:用户程序通过执行一条特殊的 CPU 指令主动触发一个受控的异常(synchronous exception),CPU 响应此异常后将执行流从低特权级别切换到高特权级别,并跳转到内核预设的处理入口开始执行。这一过程完全由 CPU 硬件保障其安全性——用户态代码无法伪造系统调用入口,也无法绕过特权检查。
7.1.2 为什么需要系统调用
要理解系统调用的必要性,必须首先理解直接硬件访问的危险性。在一个没有特权级别保护的系统中,任何用户程序都可以:
- 直接读写 I/O 端口或 MMIO 寄存器:恶意程序可以让磁盘控制器写入任意扇区,摧毁文件系统;可以让网卡控制器发送任意数据包,冒充其他主机。
- 直接修改页表:程序可以修改页表映射,读取或篡改其他进程的内存数据,甚至覆盖内核代码段。
- 直接操作中断控制器:程序可以屏蔽时钟中断使系统失去调度能力,也可以伪造中断向量使 CPU 跳转到恶意代码。
- 直接执行特权指令:如
HLT停机、INVD使缓存失效、WRMSR修改 CPU 模式寄存器等。
为了防止这些灾难性后果,现代 CPU 硬件引入了特权级别机制。以 x86_64 为例,CPU 支持四个特权级别(Ring 0 到 Ring 3),其中 Ring 0 拥有最高权限,Ring 3 最为受限。Linux 只使用 Ring 0(内核态)和 Ring 3(用户态)两级。当 CPU 运行在 Ring 3 时,任何尝试访问 Ring 0 才能使用的资源(I/O 端口、控制寄存器、特定 CPU 指令等)都会触发通用保护异常(General Protection Fault,#GP)。
ARM64 使用 Exception Level(EL)机制实现类似的功能:EL0 对应用户态,EL1 对应内核态,EL2 对应虚拟机监控器(Hypervisor),EL3 对应安全监控器(Secure Monitor)。
RISC-V 则使用特权模式(Privilege Mode)机制:U-mode(User)为最低特权级别,S-mode(Supervisor)为内核运行的级别,M-mode(Machine)为最高特权级别。
在硬件特权保护的基础上,系统调用提供了一种"受控的门(controlled gate)"机制,允许用户程序在严格的约束下请求内核执行特定操作。这种"受控"体现在以下几个方面:
- 入口点固定:用户程序只能通过 CPU 预设的系统调用入口进入内核,无法跳转到内核代码的任意位置。例如 x86_64 的
SYSCALL指令会跳转到MSR_LSTAR寄存器中预设的内核入口地址。 - 参数可控:系统调用处理函数会严格校验所有来自用户态的参数,包括指针的有效性、文件描述符的合法性、缓冲区的大小等。
- 能力受限:每个系统调用只提供特定的功能,用户程序无法通过系统调用执行内核定义范围之外的操作。
- 审计追踪:内核可以对系统调用进行追踪和审计,记录每个进程的系统调用行为。
7.1.3 系统调用的完整生命周期
一次系统调用的执行过程跨越用户态和内核态两个世界,涉及 CPU 硬件、汇编入口代码、通用内核框架和具体处理函数等多个层次。下面以 x86_64 架构为例,逐步剖析完整的生命周期。
第一阶段:用户态准备
在执行系统调用之前,用户程序(通常是 glibc 的封装函数)需要完成以下准备工作:
- 将系统调用号存入
rax寄存器。例如,write系统调用的编号为 1,因此rax = 1。 - 将参数按约定依次存入参数寄存器。x86_64 的系统调用约定与普通函数调用约定(System V AMD64 ABI)略有不同,使用以下寄存器传递参数:
| 参数序号 | 寄存器 | 说明 |
|---|---|---|
| 第 1 个参数 | rdi |
|
| 第 2 个参数 | rsi |
|
| 第 3 个参数 | rdx |
|
| 第 4 个参数 | r10 |
注意:不是 rcx(rcx 被 SYSCALL 指令用于保存返回地址) |
| 第 5 个参数 | r8 |
|
| 第 6 个参数 | r9 |
- 执行
SYSCALL指令。
ARM64 的调用约定:系统调用号在 x8 寄存器中,参数在 x0 到 x5 寄存器中,触发指令为 SVC #0。
RISC-V 的调用约定:系统调用号在 a7 寄存器中,参数在 a0 到 a5 寄存器中,触发指令为 ecall。
第二阶段:CPU 硬件级特权切换
SYSCALL 指令的执行会触发 CPU 硬件执行一系列原子操作(参见 x86_64 架构手册):
- 保存返回地址:将
RIP(下一条指令地址)保存到RCX,以便后续SYSRET指令能够返回到正确的用户态代码位置。 - 保存 RFLAGS:将
RFLAGS保存到R11,因为SYSCALL会修改标志寄存器。 - 加载新的 RIP:从
MSR_LSTAR(Model Specific Register)加载内核入口地址到RIP。Linux 内核在启动时将entry_SYSCALL_64的地址写入此 MSR。 - 切换特权级别:将
CPL(Current Privilege Level)从 3(Ring 3)切换为 0(Ring 0)。 - 切换栈:从
MSR_IA32_KERNEL_GS_BASE加载内核栈指针(通过swapgs指令完成 GS 段寄存器的切换)。
这一系列操作在硬件层面完成,确保了特权切换的原子性和安全性。
第三阶段:内核汇编入口
CPU 跳转到 entry_SYSCALL_64(定义在 arch/x86/entry/entry_64.S 第 87 行)后,内核的汇编入口代码负责:
SYM_CODE_START(entry_SYSCALL_64)
ENDBR
swapgs /* 切换 GS 段寄存器到内核 */
movq %rsp, PER_CPU_VAR(cpu_tss_rw + TSS_sp2) /* 保存用户态栈指针 */
SWITCH_TO_KERNEL_CR3 scratch_reg=%rsp /* 切换页表到内核 */
movq PER_CPU_VAR(cpu_current_top_of_stack), %rsp /* 加载内核栈 */
/* 在内核栈上构建 struct pt_regs */
pushq $__USER_DS /* pt_regs->ss */
pushq PER_CPU_VAR(cpu_tss_rw + TSS_sp2) /* pt_regs->sp */
pushq %r11 /* pt_regs->flags (原 RFLAGS) */
pushq $__USER_CS /* pt_regs->cs */
pushq %rcx /* pt_regs->ip (返回地址) */
pushq %rax /* pt_regs->orig_ax (系统调用号) */
PUSH_AND_CLEAR_REGS rax=$-ENOSYS /* 保存其余寄存器并清空 */
movq %rsp, %rdi /* 第一个参数: pt_regs 指针 */
movslq %eax, %rsi /* 第二个参数: 系统调用号 */
call do_syscall_64 /* 调用 C 函数分发系统调用 */
这段汇编代码的核心任务是:在内核栈上构建 struct pt_regs 结构体,将用户态的所有通用寄存器值保存在其中,然后调用 C 语言函数 do_syscall_64() 进行后续处理。
第四阶段:系统调用分发
do_syscall_64() 函数(定义在 arch/x86/entry/syscall_64.c 第 87 行)是 x86_64 系统调用的核心分发器:
__visible noinstr bool do_syscall_64(struct pt_regs *regs, int nr)
{
add_random_kstack_offset();
nr = syscall_enter_from_user_mode(regs, nr);
instrumentation_begin();
if (!do_syscall_x64(regs, nr) && !do_syscall_x32(regs, nr) && nr != -1) {
/* 无效系统调用号 */
regs->ax = __x64_sys_ni_syscall(regs);
}
instrumentation_end();
syscall_exit_to_user_mode(regs);
/* 检查是否可以使用 SYSRET 快速返回 */
if (cpu_feature_enabled(X86_FEATURE_XENPV))
return false;
if (unlikely(regs->cx != regs->ip || regs->r11 != regs->flags))
return false;
if (unlikely(regs->cs != __USER_CS || regs->ss != __USER_DS))
return false;
if (unlikely(regs->ip >= TASK_SIZE_MAX))
return false;
if (unlikely(regs->flags & (X86_EFLAGS_RF | X86_EFLAGS_TF)))
return false;
return true; /* true = 使用 SYSRET 返回 */
}
其中 do_syscall_x64()(同一文件第 53 行)执行具体的系统调用号查找和分发:
static __always_inline bool do_syscall_x64(struct pt_regs *regs, int nr)
{
unsigned int unr = nr;
if (likely(unr < NR_syscalls)) {
unr = array_index_nospec(unr, NR_syscalls);
regs->ax = x64_sys_call(regs, unr);
return true;
}
return false;
}
x64_sys_call() 函数(第 35 行)使用一个巨大的 switch 语句来索引系统调用:
long x64_sys_call(const struct pt_regs *regs, unsigned int nr)
{
switch (nr) {
#include <asm/syscalls_64.h>
default: return __x64_sys_ni_syscall(regs);
}
}
第五阶段:执行系统调用处理函数
系统调用处理函数最终被执行。以 write 系统调用为例,内核中的实现大致如下(定义在 fs/read_write.c):
SYSCALL_DEFINE3(write, unsigned int, fd, const char __user *, buf,
size_t, count)
{
return ksys_write(fd, buf, count);
}
SYSCALL_DEFINE3 宏展开后会产生一系列包装函数,包括参数类型检查、符号扩展等。实际的写入操作由 ksys_write() 完成。
第六阶段:返回用户态
系统调用处理函数返回后,do_syscall_64() 将返回值保存在 regs->ax 中,然后调用 syscall_exit_to_user_mode() 进行返回前的清理工作。最后,根据返回条件判断是否可以使用 SYSRET 指令快速返回用户态(SYSRET 比 IRET 快,因为它不需要完整地恢复所有标志位和段寄存器)。
7.1.4 Linux 系统调用的分类
Linux 7.0.10 内核为 x86_64 架构定义了 472 个系统调用号(参见 include/uapi/asm-generic/unistd.h 第 866-867 行),但实际上并非所有编号都已实现。这些系统调用按功能可以分为以下几个主要类别:
进程管理
进程的创建、执行、终止和状态查询是操作系统最基本的功能。
| 系统调用 | 编号 | 功能描述 |
|---|---|---|
fork |
57 | 创建子进程(复制父进程的地址空间) |
clone |
56 | 创建子进程(精细控制共享资源) |
clone3 |
435 | clone 的扩展版本,使用 struct clone_args 传递参数 |
vfork |
58 | 创建子进程(父进程阻塞直到子进程调用 exec 或 exit) |
execve |
59 | 执行新程序 |
exit |
60 | 终止当前进程(注意:此系统调用标记为 noreturn) |
exit_group |
231 | 终止进程的所有线程 |
wait4 |
61 | 等待子进程状态改变 |
waitid |
247 | wait4 的扩展版本 |
getpid |
39 | 获取进程 ID |
getppid |
110 | 获取父进程 ID |
gettid |
186 | 获取线程 ID |
pidfd_open |
434 | 获取进程的文件描述符(pidfd) |
pidfd_send_signal |
424 | 通过 pidfd 向进程发送信号 |
在现代 Linux 中,fork 实际上是通过调用 clone 实现的,而 clone3 则提供了更灵活的进程创建接口。exit 系统调用在系统调用表中被标记为 noreturn(参见 arch/x86/entry/syscalls/syscall_64.tbl 第 72 行),意味着执行后不会返回调用者。
文件操作
文件操作是最常用的系统调用类别之一,涵盖文件的打开、读写和关闭等基本操作。
| 系统调用 | 编号 | 功能描述 |
|---|---|---|
open |
2 | 打开文件 |
openat |
257 | 相对于目录打开文件 |
openat2 |
437 | openat 的扩展版本,支持额外标志 |
read |
0 | 从文件描述符读取数据 |
write |
1 | 向文件描述符写入数据 |
close |
3 | 关闭文件描述符 |
close_range |
436 | 批量关闭文件描述符范围 |
lseek |
8 | 设置文件偏移量 |
pread64 |
17 | 在指定偏移量处读取 |
pwrite64 |
18 | 在指定偏移量处写入 |
readv |
19 | 分散读(iovec 数组) |
writev |
20 | 集中写(iovec 数组) |
mmap |
9 | 将文件或设备映射到内存 |
munmap |
11 | 解除内存映射 |
dup |
32 | 复制文件描述符 |
dup2 |
33 | 复制文件描述符到指定编号 |
dup3 |
292 | dup2 的扩展版本 |
ioctl |
16 | 设备特定的控制操作 |
sendfile |
40 | 在文件描述符之间零拷贝传输数据 |
注意系统调用号 0 是 read,这是 x86_64 架构系统调用表的第一个条目。在不同架构上,系统调用号的分配可能不同。
文件系统操作
| 系统调用 | 编号 | 功能描述 |
|---|---|---|
stat |
4 | 获取文件状态 |
fstat |
5 | 通过文件描述符获取文件状态 |
lstat |
6 | 获取符号链接本身的状态 |
statx |
332 | 获取文件状态的扩展版本 |
mkdir |
83 | 创建目录 |
rmdir |
84 | 删除空目录 |
unlink |
87 | 删除文件 |
link |
88 | 创建硬链接 |
rename |
82 | 重命名文件 |
chmod |
90 | 修改文件权限 |
chown |
92 | 修改文件所有者 |
mount |
165 | 挂载文件系统 |
umount2 |
166 | 卸载文件系统 |
statfs |
137 | 获取文件系统统计信息 |
getcwd |
79 | 获取当前工作目录 |
chdir |
80 | 改变当前工作目录 |
网络(Socket)操作
Linux 将网络操作统一在文件描述符框架下,socket 相关的系统调用包括:
| 系统调用 | 编号 | 功能描述 |
|---|---|---|
socket |
41 | 创建套接字 |
bind |
49 | 绑定地址到套接字 |
listen |
50 | 开始监听连接 |
accept |
43 | 接受新连接 |
connect |
42 | 发起连接 |
sendto |
44 | 发送数据(指定目标地址) |
recvfrom |
45 | 接收数据(获取来源地址) |
sendmsg |
46 | 发送消息(分散/聚集 I/O) |
recvmsg |
47 | 接收消息 |
shutdown |
48 | 关闭套接字的读/写通道 |
setsockopt |
54 | 设置套接字选项 |
getsockopt |
55 | 获取套接字选项 |
socketpair |
53 | 创建一对已连接的套接字 |
进程间通信(IPC)
| 系统调用 | 编号 | 功能描述 |
|---|---|---|
pipe |
22 | 创建管道 |
pipe2 |
293 | 创建管道(带标志) |
shmget |
29 | 获取共享内存段 |
shmat |
30 | 连接共享内存段 |
shmdt |
67 | 断开共享内存段 |
shmctl |
31 | 共享内存控制 |
semget |
64 | 获取信号量集 |
semop |
65 | 信号量操作 |
semctl |
66 | 信号量控制 |
msgget |
68 | 获取消息队列 |
msgsnd |
69 | 发送消息 |
msgrcv |
70 | 接收消息 |
msgctl |
71 | 消息队列控制 |
信号处理
| 系统调用 | 编号 | 功能描述 |
|---|---|---|
kill |
62 | 向进程发送信号 |
tgkill |
234 | 向指定线程发送信号 |
tkill |
200 | 向线程发送信号(旧版本) |
rt_sigaction |
13 | 设置信号处理函数 |
rt_sigprocmask |
14 | 修改信号屏蔽字 |
rt_sigreturn |
15 | 从信号处理函数返回 |
rt_sigsuspend |
130 | 临时替换信号屏蔽字并暂停 |
rt_sigpending |
127 | 查询待处理信号 |
内存管理
| 系统调用 | 编号 | 功能描述 |
|---|---|---|
brk |
12 | 修改堆的大小(数据段末尾) |
mmap |
9 | 内存映射(文件映射或匿名映射) |
munmap |
11 | 解除内存映射 |
mprotect |
10 | 设置内存区域的保护属性 |
mremap |
25 | 重新映射虚拟内存地址 |
madvise |
28 | 给出内存使用建议 |
msync |
26 | 同步映射内存到文件 |
mincore |
27 | 查询页面是否在物理内存中 |
process_madvise |
440 | 对另一个进程给出内存建议 |
mseal |
462 | 密封内存映射(防止修改属性) |
用户与凭证
| 系统调用 | 编号 | 功能描述 |
|---|---|---|
getuid |
102 | 获取真实用户 ID |
geteuid |
107 | 获取有效用户 ID |
getgid |
104 | 获取真实组 ID |
getegid |
108 | 获取有效组 ID |
setuid |
105 | 设置用户 ID |
setgid |
106 | 设置组 ID |
getgroups |
113 | 获取补充组 ID 列表 |
setgroups |
116 | 设置补充组 ID 列表 |
capget |
125 | 获取进程能力(capabilities) |
capset |
126 | 设置进程能力 |
时间操作
| 系统调用 | 编号 | 功能描述 |
|---|---|---|
gettimeofday |
96 | 获取当前时间(微秒精度) |
clock_gettime |
228 | 获取指定时钟的时间 |
clock_settime |
227 | 设置指定时钟的时间 |
nanosleep |
35 | 纳秒级休眠 |
clock_nanosleep |
230 | 基于指定时钟的纳秒级休眠 |
timer_create |
222 | 创建 POSIX 定时器 |
timer_settime |
223 | 设置定时器 |
timer_gettime |
224 | 获取定时器剩余时间 |
系统信息与控制
| 系统调用 | 编号 | 功能描述 |
|---|---|---|
uname |
63 | 获取系统信息(内核版本等) |
sysinfo |
99 | 获取系统统计信息(内存、交换区等) |
reboot |
169 | 重启系统 |
syslog |
103 | 读取内核日志 |
prctl |
157 | 进程级控制操作 |
getrlimit |
97 | 获取资源限制 |
setrlimit |
160 | 设置资源限制 |
ptrace |
101 | 进程追踪(调试器使用) |
现代 Linux 新增系统调用
Linux 7.0.10 中引入了一些较新的系统调用,反映了现代操作系统的发展趋势:
| 系统调用 | 编号 | 功能描述 |
|---|---|---|
io_uring_setup |
425 | 创建 io_uring 实例(高性能异步 I/O) |
io_uring_enter |
426 | 提交和等待 io_uring 请求 |
io_uring_register |
427 | 注册 io_uring 的文件或缓冲区 |
clone3 |
435 | 扩展的进程创建接口 |
openat2 |
437 | 带解析限制的文件打开 |
close_range |
436 | 批量关闭文件描述符 |
pidfd_open |
434 | 进程文件描述符(避免 PID 回收竞争) |
pidfd_send_signal |
424 | 通过 pidfd 发送信号 |
futex_waitv |
449 | 多 futex 等待 |
cachestat |
451 | 查询页面缓存状态 |
fchmodat2 |
452 | 扩展的权限修改 |
map_shadow_stack |
453 | 映射影子栈(硬件 CET 支持) |
mseal |
462 | 密封内存映射 |
statmount |
457 | 获取挂载点信息 |
listmount |
458 | 列出挂载点 |
rseq_slice_yield |
471 | rseq 切片让出 CPU |
7.1.5 Linux 7.0 系统调用数量
Linux 7.0.10 中系统调用的数量因架构而异。通用系统调用定义在 include/uapi/asm-generic/unistd.h 中,该文件末尾定义了总数量:
/* include/uapi/asm-generic/unistd.h 第 866-867 行 */
#undef __NR_syscalls
#define __NR_syscalls 472
各架构的实际数量:
| 架构 | 系统调用数量 | 系统调用表定义文件 |
|---|---|---|
| x86_64 | 472(编号 0-471) | arch/x86/entry/syscalls/syscall_64.tbl |
| ARM64 | 472(使用通用定义) | include/uapi/asm-generic/unistd.h |
| RISC-V | 472(使用通用定义) | include/uapi/asm-generic/unistd.h |
| x86 (32-bit) | ~400 | arch/x86/entry/syscalls/syscall_32.tbl |
需要注意的是,并非所有编号都有对应的已实现系统调用。未实现的系统调用条目会指向 sys_ni_syscall,该函数定义在 kernel/sys_ni.c 第 20-23 行:
asmlinkage long sys_ni_syscall(void)
{
return -ENOSYS;
}
sys_ni_syscall("ni" 表示 "not implemented")简单地返回 -ENOSYS 错误码,告诉用户程序该系统调用未实现。kernel/sys_ni.c 文件通过 COND_SYSCALL 宏为所有可选的、未实现的系统调用提供默认的弱符号实现:
#define COND_SYSCALL(name) cond_syscall(sys_##name)
7.1.6 POSIX 标准与 Linux 扩展
系统调用的设计并非无章可循。IEEE POSIX(Portable Operating System Interface)标准定义了一组操作系统应提供的基本接口,Linux 的系统调用在很大程度上遵循了 POSIX 规范,但也做了大量扩展。
POSIX 标准定义的核心接口包括:
- 文件 I/O:
open,read,write,close,lseek - 文件系统:
mkdir,rmdir,link,unlink,rename,stat - 进程管理:
fork,exec,wait,exit,getpid - 信号:
kill,sigaction,sigprocmask - 管道:
pipe - 目录操作:
opendir,readdir(通过getdents系统调用实现)
Linux 特有的扩展包括:
epoll系列(epoll_create1,epoll_ctl,epoll_pwait,epoll_pwait2):高效的 I/O 多路复用机制io_uring系列:基于共享环形缓冲区的高性能异步 I/O 框架signalfd,timerfd,eventfd:将信号、定时器、事件转换为文件描述符pidfd系列:基于文件描述符的进程管理,避免了传统 PID 的竞争问题clone3:比clone更灵活的进程创建接口bpf:eBPF 程序加载和管理landlock系列:沙箱安全模块mseal:内存映射密封,防止恶意修改
这些扩展使得 Linux 拥有比标准 POSIX 更丰富的系统能力,但也意味着使用这些接口的程序不可移植到其他 Unix 系统。
7.1.7 SYSCALL_DEFINEx 宏体系
Linux 内核通过一套精心设计的宏体系来定义系统调用,确保类型安全、参数校验和追踪支持。这套宏定义在 include/linux/syscalls.h 第 225-264 行。
宏的层次结构如下:
SYSCALL_DEFINE1(name, ...) ─┐
SYSCALL_DEFINE2(name, ...) ─┤
SYSCALL_DEFINE3(name, ...) ─┼── SYSCALL_DEFINEx(x, sname, ...)
SYSCALL_DEFINE4(name, ...) ─┤ ├── SYSCALL_METADATA(sname, x, ...)
SYSCALL_DEFINE5(name, ...) ─┤ └── __SYSCALL_DEFINEx(x, name, ...)
SYSCALL_DEFINE6(name, ...) ─┘
以 SYSCALL_DEFINE3(write, unsigned int, fd, const char __user *, buf, size_t, count) 为例,经过完整的宏展开后会生成以下代码层次:
/* 1. 追踪元数据(当 CONFIG_FTRACE_SYSCALLS 启用时) */
static struct syscall_metadata __syscall_meta__write = {
.name = "sys_write",
.syscall_nr = -1,
.nb_args = 3,
.types = { "unsigned int", "const char __user *", "size_t" },
.args = { "fd", "buf", "count" },
...
};
/* 2. sys_write 作为 __se_sys_write 的别名 */
asmlinkage long sys_write(unsigned int fd, const char __user *buf, size_t count)
__attribute__((alias("__se_sys_write")));
/* 3. __se_sys_write 负责参数的符号扩展 */
asmlinkage long __se_sys_write(__typeof(0L) fd, __typeof(0L) buf, __typeof(0L) count)
{
long ret = __do_sys_write((unsigned int) fd, (const char __user *) buf, (size_t) count);
/* 编译时类型检查 */
(void)BUILD_BUG_ON_ZERO(sizeof(unsigned int) > sizeof(long));
...
return ret;
}
/* 4. __do_sys_write 是实际执行逻辑 */
static inline long __do_sys_write(unsigned int fd, const char __user *buf, size_t count)
{
return ksys_write(fd, buf, count);
}
这种多层包装设计有三个关键目的:
- 参数符号扩展:
__se_sys_write层将 32 位参数(如unsigned int)正确地扩展为long类型,防止用户态传入负数时因高位未扩展而导致的安全问题。 - 编译时类型检查:
__SC_TEST宏在编译时检查每个参数的类型大小不超过sizeof(long),对于 64 位系统上的 64 位参数(如loff_t)则允许通过。 - 追踪支持:
SYSCALL_METADATA宏生成 ftrace 追踪点所需的元数据,使tracefs可以记录系统调用的进入和退出事件。
在 x86_64 架构中,__SYSCALL_DEFINEx 被架构特定的实现覆盖(定义在 arch/x86/include/asm/syscall_wrapper.h),该实现将系统调用参数从 struct pt_regs 中提取出来,而不是直接使用 C 调用约定的参数传递。这种设计确保了用户态传入的寄存器值不会泄漏到内核调用链的深处。
7.1.8 系统调用开销分析
系统调用并非免费的——每一次系统调用都需要经历特权级切换、上下文保存/恢复、可能的 TLB 刷新和缓存污染等开销。一次系统调用的典型开销包括:
- 特权级切换开销:
SYSCALL/SYSRET指令本身的执行时间(约 10-50 个 CPU 时钟周期)。 - 寄存器保存/恢复:在内核栈上构建和恢复
pt_regs结构体(x86_64 约需保存 21 个 64 位寄存器)。 - 内核栈切换:从用户栈切换到内核栈,可能导致 TLB miss。
- 内核入口/出口处理:包括
syscall_enter_from_user_mode()中的 seccomp 检查、ptrace 追踪、审计等。 - 缓存污染:进入内核后会使用内核代码和数据,可能导致用户态的 L1/L2 缓存被驱逐。
- 间接分支预测:系统调用分发使用间接跳转,可能导致分支预测 miss。
在现代 x86_64 硬件上,一次"空"系统调用的总开销(不含实际处理逻辑)通常在 100-300 纳秒之间。这意味着:
- 每秒可以执行约 300-1000 万次系统调用(空调用)
- 对于需要频繁与内核交互的 I/O 密集型应用,系统调用开销可能成为瓶颈
为了减少系统调用开销,Linux 引入了几种优化机制:
- vDSO(Virtual Dynamic Shared Object):将
gettimeofday、clock_gettime等只读系统调用映射到用户地址空间,完全避免特权级切换。 - io_uring:通过共享内存环形缓冲区批量提交 I/O 请求,显著减少系统调用次数。
- 批量操作:如
readv/writev允许一次系统调用完成多次 I/O 操作。 SYSRET快速返回:x86_64 使用SYSRET替代IRET进行返回,减少约 30-50 个时钟周期的开销。
理解系统调用的开销特性,有助于在系统编程中做出合理的架构决策——例如,在可能的情况下使用缓冲来减少 write 系统调用的次数,或使用 mmap 替代 read/lseek 来避免频繁的文件 I/O 系统调用。
7.2 x86_64 系统调用 -- SYSCALL/SYSRET 与 MSR
7.2.1 x86 系统调用指令的演进
x86 架构在漫长的历史中先后引入了三种系统调用机制,每一种都比前一种更快速、更精简。理解这一演进历程,有助于我们深刻理解当前 Linux 7.0.10 内核中系统调用路径的设计哲学。
INT 0x80 -- 传统的软件中断方式
在早期 x86(包括 16 位和 32 位时代),系统调用通过 int 0x80 软件中断指令实现。其工作原理是:CPU 执行 int 0x80 时,触发 IDT(中断描述符表)中第 0x80 号门描述符所指向的内核入口。这一机制虽然简单,但开销极大 -- 它需要依次完成权限检查(CPL 从 3 变为 0)、压栈保存用户态 SS/ESP/EFLAGS/CS/EIP(至少 5 次内存写操作)、查找 IDT 表项等步骤。一次 INT 0x80 系统调用的纯切换开销约为 200-300 个时钟周期。在 Linux 7.0.10 中,INT 0x80 仅在 32 位兼容模式(compat mode)下作为遗留接口保留,入口为 do_int80_emulation。
SYSENTER/SYSEXIT -- 32 位快速系统调用
Intel 在 Pentium II 中引入了 SYSENTER/SYSEXIT 指令对,专门用于加速 32 位系统调用。这对指令通过预编程的 MSR(Model Specific Register)来避免 IDT 查找,直接跳转到内核入口。SYSENTER 从 MSR_IA32_SYSENTER_CS、MSR_IA32_SYSENTER_ESP、MSR_IA32_SYSENTER_EIP 读取目标 CS、ESP 和 EIP,大幅减少了开销。但它有严重的设计缺陷:不保存返回地址(需要由内核或 vDSO 代码自行保存)、不保存 EFLAGS、不支持 64 位模式。在 Linux 7.0.10 的源码中可以看到,enable_sep_cpu() 函数(arch/x86/kernel/cpu/common.c)负责初始化这些 MSR:
// arch/x86/kernel/cpu/common.c, 第 2128-2150 行
#ifdef CONFIG_X86_32
void enable_sep_cpu(void)
{
struct tss_struct *tss;
int cpu;
if (!boot_cpu_has(X86_FEATURE_SEP))
return;
cpu = get_cpu();
tss = &per_cpu(cpu_tss_rw, cpu);
tss->x86_tss.ss1 = __KERNEL_CS;
wrmsrq(MSR_IA32_SYSENTER_CS, tss->x86_tss.ss1);
wrmsrq(MSR_IA32_SYSENTER_ESP, (unsigned long)(cpu_entry_stack(cpu) + 1));
wrmsrq(MSR_IA32_SYSENTER_EIP, (unsigned long)entry_SYSENTER_32);
put_cpu();
}
#endif
SYSCALL/SYSRET -- 64 位主流机制
AMD 在 AMD64 架构中引入了 SYSCALL/SYSRET 指令对,Intel 在其 64 位实现中也采纳了这一方案。这是目前 x86_64 上 Linux 内核使用的首要系统调用机制。其核心设计理念是:硬件以最小代价完成从 Ring 3 到 Ring 0 的切换 -- 不压栈、不查表,仅通过 MSR 直接加载新的 CS/RIP/SS,同时将旧值保存到约定寄存器中。一次 SYSCALL 的硬件开销可低至约 30-50 个时钟周期。
三者开销对比(大致量级):
INT 0x80: ~200-300 周期 (需要 IDT 查找、特权级检查、完整压栈)
SYSENTER: ~80-120 周期 (MSR 直跳,但不保存返回地址和标志)
SYSCALL: ~30-50 周期 (MSR 直跳,硬件自动保存关键寄存器)
7.2.2 SYSCALL 指令的硬件行为
当 CPU 执行 SYSCALL 指令时,硬件自动完成以下操作。这是 CPU 微代码级别的行为,不可由软件修改:
SYSCALL 指令执行流程(硬件行为):
1. RCX <-- RIP // 保存返回地址到 RCX
// (指向 SYSCALL 之后的那条指令)
2. R11 <-- RFLAGS // 保存当前标志寄存器到 R11
3. RFLAGS <-- RFLAGS AND NOT MSR_SYSCALL_MASK
// 用 MSR_SYSCALL_MASK 屏蔽部分标志
// 其中包括 IF 位,故中断被关闭
4. RIP <-- MSR_LSTAR // 从 MSR_LSTAR 加载内核入口点
// Linux 中设为 entry_SYSCALL_64
5. CS <-- MSR_STAR[32:47] // 加载内核代码段
// 即 __KERNEL_CS
6. SS <-- MSR_STAR[32:47] + 8 // 加载内核数据段
// 即 __KERNEL_DS
7. CPL <-- 0 // 特权级切换到 Ring 0
注意: RSP 不改变!
SYSCALL 不做任何栈操作!
这是与 INT/IRET 机制的本质区别!
关键的寄存器映射关系(x86_64 系统调用约定):
+----------+------------------------------------------+
| 寄存器 | 角色 |
+----------+------------------------------------------+
| RAX | 系统调用号 (syscall number) |
| RCX | 返回地址 (由硬件保存 RIP) |
| R11 | 保存的 RFLAGS (由硬件保存) |
| RDI | 第 1 个参数 (arg0) |
| RSI | 第 2 个参数 (arg1) |
| RDX | 第 3 个参数 (arg2) |
| R10 | 第 4 个参数 (arg3) |
| R8 | 第 5 个参数 (arg4) |
| R9 | 第 6 个参数 (arg5) |
+----------+------------------------------------------+
注意 R10 被用作第四个参数而非 RCX,因为 RCX 已被硬件用于保存返回地址。但在 x86_64 C 调用约定中,第四个参数应由 RCX 传递,因此内核入口代码需要将 R10 复制到 RCX(PUSH_AND_CLEAR_REGS 宏中处理)。
7.2.3 SYSRET 指令的硬件行为
SYSRET 是 SYSCALL 的逆操作,用于从内核态快速返回到用户态:
SYSRETQ 指令执行流程(64 位模式返回):
1. RIP <-- RCX // 从 RCX 恢复返回地址
2. RFLAGS <-- R11 // 从 R11 恢复标志寄存器
// 这将恢复 IF 位等所有标志
3. CS <-- MSR_STAR[48:63] + 16 // 用户代码段
// 即 __USER_CS (32 位部分 + 16)
4. SS <-- MSR_STAR[48:63] + 8 // 用户数据段
// 即 __USER_DS (32 位部分 + 8)
5. CPL <-- 3 // 特权级切换回 Ring 3
注意: SYSRET 也不操作栈!
用户态的 RSP 需要内核在返回前手动恢复!
SYSRET 相比 IRETQ 的优势在于:不需要从栈上弹出 SS/RSP/EFLAGS/CS/RIP 五个值,直接从寄存器恢复即可。但 SYSRET 有严格的使用前提:
- RCX 必须等于目标 RIP
- R11 必须等于目标 RFLAGS
- RIP 必须是规范地址(canonical address),不能超过 TASK_SIZE_MAX
- CS 和 SS 必须与 MSR_STAR 中预设的值一致
- RFLAGS 中不能有 TF(单步)或 RF 位
如果这些条件不满足,内核必须使用更慢但更安全的 IRETQ 指令返回。
7.2.4 MSR 配置与内核初始化
SYSCALL/SYSRET 机制依赖四个关键 MSR(Model Specific Register),它们在内核启动阶段由 syscall_init() 函数配置。这些 MSR 的地址定义在 arch/x86/include/asm/msr-index.h 中:
// arch/x86/include/asm/msr-index.h
#define MSR_STAR 0xc0000081 /* legacy mode SYSCALL target */
#define MSR_LSTAR 0xc0000082 /* long mode SYSCALL target */
#define MSR_CSTAR 0xc0000083 /* compat mode SYSCALL target */
#define MSR_SYSCALL_MASK 0xc0000084 /* EFLAGS mask for syscall */
MSR_STAR(0xC0000081)的位域布局:
63 48 47 32 31 0
+------------------+------------------+---------------------+
| SYSRET CS/SS | SYSCALL CS | 保留 |
| [48:63] = USER | [32:47] = KERNEL| |
| __USER32_CS | __KERNEL_CS | |
+------------------+------------------+---------------------+
MSR_LSTAR(0xC0000082):64 位 SYSCALL 的入口地址,Linux 设为 entry_SYSCALL_64。
MSR_CSTAR(0xC0000083):32 位兼容模式 SYSCALL 的入口地址,Linux 设为 entry_SYSCALL_compat。如果 32 位兼容模式未启用,则设为 entry_SYSCALL32_ignore,该入口仅返回 -ENOSYS。
MSR_SYSCALL_MASK(0xC0000084):指定 SYSCALL 进入时需要清除的 RFLAGS 位。
内核初始化的实际代码如下,位于 arch/x86/kernel/cpu/common.c:
// arch/x86/kernel/cpu/common.c, 第 2265-2314 行
static inline void idt_syscall_init(void)
{
wrmsrq(MSR_LSTAR, (unsigned long)entry_SYSCALL_64);
if (ia32_enabled()) {
wrmsrq_cstar((unsigned long)entry_SYSCALL_compat);
/*
* This only works on Intel CPUs.
* On AMD CPUs these MSRs are 32-bit, CPU truncates MSR_IA32_SYSENTER_EIP.
* This does not cause SYSENTER to jump to the wrong location, because
* AMD doesn't allow SYSENTER in long mode (either 32- or 64-bit).
*/
wrmsrq_safe(MSR_IA32_SYSENTER_CS, (u64)__KERNEL_CS);
wrmsrq_safe(MSR_IA32_SYSENTER_ESP,
(unsigned long)(cpu_entry_stack(smp_processor_id()) + 1));
wrmsrq_safe(MSR_IA32_SYSENTER_EIP, (u64)entry_SYSENTER_compat);
} else {
wrmsrq_cstar((unsigned long)entry_SYSCALL32_ignore);
wrmsrq_safe(MSR_IA32_SYSENTER_CS, (u64)GDT_ENTRY_INVALID_SEG);
wrmsrq_safe(MSR_IA32_SYSENTER_ESP, 0ULL);
wrmsrq_safe(MSR_IA32_SYSENTER_EIP, 0ULL);
}
/*
* Flags to clear on syscall; clear as much as possible
* to minimize user space-kernel interference.
*/
wrmsrq(MSR_SYSCALL_MASK,
X86_EFLAGS_CF|X86_EFLAGS_PF|X86_EFLAGS_AF|
X86_EFLAGS_ZF|X86_EFLAGS_SF|X86_EFLAGS_TF|
X86_EFLAGS_IF|X86_EFLAGS_DF|X86_EFLAGS_OF|
X86_EFLAGS_IOPL|X86_EFLAGS_NT|X86_EFLAGS_RF|
X86_EFLAGS_AC|X86_EFLAGS_ID);
}
/* May not be marked __init: used by software suspend */
void syscall_init(void)
{
/* The default user and kernel segments */
wrmsr(MSR_STAR, 0, (__USER32_CS << 16) | __KERNEL_CS);
if (!cpu_feature_enabled(X86_FEATURE_FRED))
idt_syscall_init();
}
注意 syscall_init() 同时还处理了 MSR_STAR 的设置。wrmsr(MSR_STAR, 0, (__USER32_CS << 16) | __KERNEL_CS) 中:高 32 位(EDX)为 0,低 32 位(EAX)中 [16:31] 是 __KERNEL_CS(用于 SYSCALL 的 CS),[48:63] 实际上由 EDX=0 提供,SYSRET 使用的用户态段选择子从 SYSRET 指令的固定偏移计算得出。
MSR_SYSCALL_MASK 的值非常激进 -- 它清除了几乎所有标志位(CF/PF/AF/ZF/SF/TF/IF/DF/OF/IOPL/NT/RF/AC/ID),这意味着 SYSCALL 进入内核后,RFLAGS 被重置为几乎全零的状态。其中最重要的是 IF 位被清除,确保进入内核后中断处于关闭状态。
7.2.5 内核入口路径 -- entry_SYSCALL_64
当用户态程序执行 SYSCALL 指令后,CPU 跳转到 MSR_LSTAR 所指向的 entry_SYSCALL_64。这是整个 64 位系统调用路径的核心入口点,定义在 arch/x86/entry/entry_64.S 中:
// arch/x86/entry/entry_64.S, 第 87-170 行
SYM_CODE_START(entry_SYSCALL_64)
UNWIND_HINT_ENTRY
ENDBR
swapgs
/* tss.sp2 is scratch space. */
movq %rsp, PER_CPU_VAR(cpu_tss_rw + TSS_sp2)
SWITCH_TO_KERNEL_CR3 scratch_reg=%rsp
movq PER_CPU_VAR(cpu_current_top_of_stack), %rsp
这段代码的第一步是 swapgs 指令,这是系统调用进入内核的最关键步骤之一。SWAPGS 交换当前 GS 段基址与 MSR_KERNEL_GS_BASE 中的值。在用户态,GS 指向用户空间的 Thread Local Storage (TLS);交换后,GS 指向内核的 per-CPU 数据区。这使得内核可以通过 PER_CPU_VAR() 宏(底层使用 %%gs: 前缀)高效地访问当前 CPU 的专属数据。
接下来,将用户态 RSP 暂存到 TSS(Task State Segment)的 sp2 字段作为临时暂存,然后切换页表(SWITCH_TO_KERNEL_CR3,用于 KPTI/PTI 安全特性),最后将 RSP 加载为内核栈指针 cpu_current_top_of_stack。
接着构造 pt_regs 结构体(进程上下文寄存器帧):
/* Construct struct pt_regs on stack */
pushq $__USER_DS /* pt_regs->ss */
pushq PER_CPU_VAR(cpu_tss_rw + TSS_sp2) /* pt_regs->sp (用户态栈指针) */
pushq %r11 /* pt_regs->flags (R11 中是硬件保存的 RFLAGS) */
pushq $__USER_CS /* pt_regs->cs */
pushq %rcx /* pt_regs->ip (RCX 中是硬件保存的返回地址) */
pushq %rax /* pt_regs->orig_ax (系统调用号) */
PUSH_AND_CLEAR_REGS rax=$-ENOSYS
PUSH_AND_CLEAR_REGS 宏保存其余的通用寄存器到 pt_regs 中,并将 RAX 初始化为 -ENOSYS(无效系统调用号的默认返回值)。pt_regs 结构在内核栈上的布局如下:
内核栈布局 (从高地址到低地址):
+-------------------+
| SS (用户态) | <-- 用户态栈段
+-------------------+
| RSP (用户态) | <-- 用户态栈指针
+-------------------+
| RFLAGS (用户态) | <-- 保存的标志寄存器 (来自 R11)
+-------------------+
| CS (用户态) | <-- 用户态代码段
+-------------------+
| RIP (用户态) | <-- 返回地址 (来自 RCX)
+-------------------+
| orig_rax | <-- 系统调用号
+-------------------+
| R15 |
| R14 |
| R13 |
| R12 |
| RBP |
| RBX |
| R11 |
| R10 |
| R9 |
| R8 |
| RAX | <-- 初始化为 -ENOSYS
| RCX |
| RDX |
| RSI |
| RDI | <-- RSP 指向此处
+-------------------+
这段保存完成后,调用实际的 C 分发函数:
/* IRQs are off. */
movq %rsp, %rdi /* 第一个参数: pt_regs 指针 */
/* Sign extend the lower 32bit as syscall numbers are treated as int */
movslq %eax, %rsi /* 第二个参数: 符号扩展的系统调用号 */
/* clobbers %rax, make sure it is after saving the syscall nr */
IBRS_ENTER
UNTRAIN_RET
CLEAR_BRANCH_HISTORY
call do_syscall_64 /* 调用 C 层系统调用分发函数 */
此处 movq %rsp, %rdi 将栈顶指针(即 pt_regs 的地址)作为第一个参数传入。movslq %eax, %rsi 将系统调用号从 32 位符号扩展到 64 位。中间的 IBRS_ENTER、UNTRAIN_RET、CLEAR_BRANCH_HISTORY 是针对 Spectre 等侧信道攻击的缓解措施。
7.2.6 do_syscall_64() -- C 层系统调用分发
do_syscall_64() 是系统调用从汇编世界进入 C 世界的第一个函数,定义在 arch/x86/entry/syscall_64.c 中:
// arch/x86/entry/syscall_64.c, 第 87-141 行
__visible noinstr bool do_syscall_64(struct pt_regs *regs, int nr)
{
add_random_kstack_offset();
nr = syscall_enter_from_user_mode(regs, nr);
instrumentation_begin();
if (!do_syscall_x64(regs, nr) && !do_syscall_x32(regs, nr) && nr != -1) {
/* Invalid system call, but still a system call. */
regs->ax = __x64_sys_ni_syscall(regs);
}
instrumentation_end();
syscall_exit_to_user_mode(regs);
/* XEN PV guests always use the IRET path */
if (cpu_feature_enabled(X86_FEATURE_XENPV))
return false;
/* SYSRET requires RCX == RIP and R11 == EFLAGS */
if (unlikely(regs->cx != regs->ip || regs->r11 != regs->flags))
return false;
/* CS and SS must match the values set in MSR_STAR */
if (unlikely(regs->cs != __USER_CS || regs->ss != __USER_DS))
return false;
if (unlikely(regs->ip >= TASK_SIZE_MAX))
return false;
if (unlikely(regs->flags & (X86_EFLAGS_RF | X86_EFLAGS_TF)))
return false;
/* Use SYSRET to exit to userspace */
return true;
}
该函数返回 bool 值,指示调用者(entry_64.S)是否可以使用 SYSRET 快速返回。函数逻辑分析:
-
add_random_kstack_offset():为安全目的在栈上增加随机偏移,增加栈喷射攻击的难度。 -
syscall_enter_from_user_mode(regs, nr):这是通用入口框架(generic entry)的核心函数,处理 ptrace、seccomp、审计等系统调用进入时的钩子。 -
do_syscall_x64(regs, nr):实际的系统调用分发,其实现如下:
// arch/x86/entry/syscall_64.c, 第 53-67 行
static __always_inline bool do_syscall_x64(struct pt_regs *regs, int nr)
{
unsigned int unr = nr;
if (likely(unr < NR_syscalls)) {
unr = array_index_nospec(unr, NR_syscalls);
regs->ax = x64_sys_call(regs, unr);
return true;
}
return false;
}
这里的关键点:
array_index_nospec()是 Spectre v1 缓解措施,防止 CPU 利用乱序执行越过边界检查。x64_sys_call(regs, unr)是通过switch-case生成的系统调用分发函数,每个系统调用号对应一个 case,调用对应的__x64_sys_xxx处理函数。- 返回值保存在
regs->ax中,后续 SYSRET/IRET 会将其恢复到 RAX。
值得注意的是,Linux 7.0.10 已经不再使用传统的函数指针跳转表 sys_call_table[] 来进行系统调用分发。取而代之的是编译器生成的 switch-case 语句(x64_sys_call()),这可以利用编译器的优化(如分支预测提示、跳转表内联等)提高性能。sys_call_table[] 数组仍然存在,但仅用于 ftrace 等跟踪工具。
7.2.7 返回路径 -- 从内核回到用户态
do_syscall_64() 返回后,entry_64.S 中的代码根据返回值决定走快速路径(SYSRET)还是慢速路径(IRETQ):
// arch/x86/entry/entry_64.S, 第 130-170 行
ALTERNATIVE "testb %al, %al; jz swapgs_restore_regs_and_return_to_usermode", \
"jmp swapgs_restore_regs_and_return_to_usermode", X86_FEATURE_XENPV
/*
* We win! This label is here just for ease of understanding
* perf profiles. Nothing jumps here.
*/
syscall_return_via_sysret:
IBRS_EXIT
POP_REGS pop_rdi=0 /* 恢复所有通用寄存器,但保留 RDI */
/*
* Now all regs are restored except RSP and RDI.
* Save old stack pointer and switch to trampoline stack.
*/
movq %rsp, %rdi
movq PER_CPU_VAR(cpu_tss_rw + TSS_sp0), %rsp
UNWIND_HINT_END_OF_STACK
pushq RSP-RDI(%rdi) /* RSP */
pushq (%rdi) /* RDI */
STACKLEAK_ERASE_NOCLOBBER
SWITCH_TO_USER_CR3_STACK scratch_reg=%rdi
popq %rdi
popq %rsp
SYM_INNER_LABEL(entry_SYSRETQ_unsafe_stack, SYM_L_GLOBAL)
swapgs
CLEAR_CPU_BUFFERS
sysretq
SYSRET 快速返回路径的关键步骤:
-
恢复通用寄存器:
POP_REGS从 pt_regs 中弹出所有通用寄存器。此时 RCX 被恢复为用户态返回地址,R11 被恢复为用户态 RFLAGS。 -
切换到蹦床栈(trampoline stack):当启用了 KPTI(Kernel Page Table Isolation,页表隔离)时,不能在内核栈上做用户态页表切换,因此需要先切换到蹦床栈。这个栈是 TSS 中的 sp0 字段指向的 per-CPU 入口栈。
-
切换页表:
SWITCH_TO_USER_CR3_STACK将页表从内核页表切换回用户态页表(KPTI 安全措施)。 -
SWAPGS:将 GS 基址从内核 per-CPU 数据区切换回用户态 TLS。
-
SYSRETQ:CPU 从 RCX 恢复 RIP,从 R11 恢复 RFLAGS,CS/SS 从 MSR_STAR 恢复,CPL 回到 3。
慢速路径(IRETQ) -- 当 SYSRET 的条件不满足时:
// arch/x86/entry/entry_64.S, 第 559-577 行
SYM_INNER_LABEL(swapgs_restore_regs_and_return_to_usermode, SYM_L_GLOBAL)
IBRS_EXIT
STACKLEAK_ERASE
POP_REGS
add $8, %rsp /* orig_ax */
UNWIND_HINT_IRET_REGS
.Lswapgs_and_iret:
swapgs
CLEAR_CPU_BUFFERS
testb $3, 8(%rsp) /* 确保 IRET 返回到用户态 */
jnz .Lnative_iret
ud2 /* 如果不是用户态,触发异常 */
IRETQ 从栈上依次弹出 RIP、CS、RFLAGS、RSP、SS 五个值,恢复完整的用户态上下文。IRETQ 还会自动恢复 RFLAGS 中的 IF 位,从而重新启用中断。
7.2.8 中断标志(IF)的处理
中断管理是系统调用路径中极其重要的一环。整个流程中 IF(Interrupt Flag)的变化如下:
时间线: 用户态 --> SYSCALL --> 内核处理 --> 返回 --> 用户态
IF状态: 1 --> 0 --> 0 --> 1 --> 1
详细过程:
1. 用户态: IF=1 (中断开启)
2. SYSCALL 指令: IF 被 MSR_SYSCALL_MASK 清除 (IF=0)
3. 内核入口 (entry_SYSCALL_64): 中断关闭
4. syscall_enter_from_user_mode(): 开启中断 (raw_local_irq_enable)
5. do_syscall_x64(): 中断开启,可被抢占
6. syscall_exit_to_user_mode(): 关闭中断
7. SYSRET/IRETQ: 恢复用户态 RFLAGS (含 IF=1)
在 entry_SYSCALL_64 的注释中明确写道 "IRQs are off",这意味着从 SYSCALL 进入直到 syscall_enter_from_user_mode() 调用 raw_local_irq_enable() 之前,中断都是关闭的。这段时间虽然很短(只有几条汇编指令),但足以确保 pt_regs 构造的原子性。
SYSRET 恢复 IF 的机制是:R11 中的 RFLAGS(包含原始 IF=1)被硬件恢复到 RFLAGS 寄存器。IRETQ 则从栈上的 pt_regs->flags 字段恢复 IF。
7.2.9 IA32 兼容模式系统调用路径
Linux 7.0.10 运行在 64 位内核上时,需要支持 32 位应用程序(通过 IA32 兼容模式)。这些程序有三种系统调用入口:
- SYSCALL (32-bit compat) -- MSR_CSTAR 指向
entry_SYSCALL_compat - SYSENTER -- MSR_IA32_SYSENTER_EIP 指向
entry_SYSENTER_compat - INT 0x80 -- 通过 IDT 的 0x80 号中断入口
entry_SYSCALL_compat 定义在 arch/x86/entry/entry_64_compat.S:
// arch/x86/entry/entry_64_compat.S, 第 183-285 行
SYM_CODE_START(entry_SYSCALL_compat)
UNWIND_HINT_ENTRY
ENDBR
/* Interrupts are off on entry. */
swapgs
/* Stash user ESP */
movl %esp, %r8d
/* Use %rsp as scratch reg. User ESP is stashed in r8 */
SWITCH_TO_KERNEL_CR3 scratch_reg=%rsp
/* Switch to the kernel stack */
movq PER_CPU_VAR(cpu_current_top_of_stack), %rsp
/* Construct struct pt_regs on stack */
pushq $__USER_DS /* pt_regs->ss */
pushq %r8 /* pt_regs->sp */
pushq %r11 /* pt_regs->flags */
pushq $__USER32_CS /* pt_regs->cs */
pushq %rcx /* pt_regs->ip */
movl %eax, %eax /* discard orig_ax high bits */
pushq %rax /* pt_regs->orig_ax */
PUSH_AND_CLEAR_REGS rcx=%rbp rax=$-ENOSYS
IBRS_ENTER
UNTRAIN_RET
CLEAR_BRANCH_HISTORY
movq %rsp, %rdi
call do_fast_syscall_32
32 位兼容模式的特殊之处在于:
- 参数通过 EAX/EBX/ECX/EDX/ESI/EDI/EBP 传递(而非 64 位的 RDI/RSI/RDX/R10/R8/R9)
- 系统调用号是 32 位值(需要从
ia32_sys_call_table查找) - 栈指针是 32 位的,需要暂存(
movl %esp, %r8d) - 返回使用
sysretl(32 位 SYSRET)而非sysretq
do_fast_syscall_32() 最终调用 do_syscall_32_irqs_on(),通过 ia32_sys_call() 函数进行分发,其结构与 x64_sys_call() 类似,但使用 32 位系统调用号。
返回路径 sysret32_from_system_call 恢复 32 位寄存器并通过 sysretl 返回:
sysret32_from_system_call:
/* ... 寄存器恢复 ... */
movq EFLAGS(%rsp), %r11 /* pt_regs->flags (in r11) */
movq RIP(%rsp), %rcx /* pt_regs->ip (in rcx) */
SWITCH_TO_USER_CR3_NOSTACK scratch_reg=%r8 scratch_reg2=%r9
xorl %r8d, %r8d
xorl %r9d, %r9d
xorl %r10d, %r10d
swapgs
CLEAR_CPU_BUFFERS
sysretl
注意 sysretl 返回到 32 位兼容模式(CS = __USER32_CS),而 sysretq 返回到 64 位模式(CS = __USER_CS)。两者都使用 SWAPGS + CR3 切换 + SYSRET 的模式,但操作的目标模式不同。
7.2.10 完整的系统调用流程图
将上述所有内容整合,一次完整的 64 位系统调用流程如下:
用户态程序:
mov $sysno, %rax
syscall ─┐
│ 硬件自动:
用户态挂起 │ RCX <-- RIP
(等待内核返回) │ R11 <-- RFLAGS
│ RFLAGS <-- RFLAGS & ~SFMASK (IF=0)
│ RIP <-- MSR_LSTAR
│ CS <-- __KERNEL_CS
│ CPL <-- 0
─┘
内核态 entry_SYSCALL_64:
swapgs // GS 指向内核 per-CPU 数据
保存用户 RSP 到 TSS.sp2
SWITCH_TO_KERNEL_CR3 // 切换到内核页表
加载内核栈 RSP
构造 pt_regs (SS/RSP/FLAGS/CS/IP/ORIG_RAX + 通用寄存器)
mov %rsp, %rdi // pt_regs 作为第一个参数
movslq %eax, %rsi // 系统调用号作为第二个参数
call do_syscall_64 // 进入 C 代码
do_syscall_64():
syscall_enter_from_user_mode() // ptrace/seccomp/审计
x64_sys_call(regs, nr) // 调用具体系统调用处理函数
--> __x64_sys_xxx(regs)
<-- 返回值存入 regs->ax
syscall_exit_to_user_mode() // 信号处理/抢占检查
return (能否使用 SYSRET?)
返回到 entry_SYSCALL_64:
if (可使用 SYSRET):
恢复通用寄存器
切换到蹦床栈
SWITCH_TO_USER_CR3 // 切换回用户页表
swapgs // GS 恢复为用户 TLS
sysretq ─┐
│ 硬件自动:
│ RIP <-- RCX
用户态恢复执行 │ RFLAGS <-- R11
│ CS <-- __USER_CS
│ CPL <-- 3
─┘
else:
恢复所有寄存器
swapgs
iretq // 从栈上恢复完整上下文
7.2.11 安全缓解措施与系统调用路径
现代 x86_64 系统调用路径中包含了大量针对侧信道攻击的缓解措施。在 Linux 7.0.10 中,我们可以看到以下关键措施被集成在系统调用入口路径中:
- IBRS_ENTER/IBRS_EXIT:间接分支限制推测(Indirect Branch Restricted Speculation),防止 Spectre v2 攻击。
- UNTRAIN_RET:训练返回指令预测器,防止 Retpoline 被绕过。
- CLEAR_BRANCH_HISTORY:清除分支历史记录,防止 BHI(Branch History Injection)攻击。
- SWITCH_TO_KERNEL_CR3 / SWITCH_TO_USER_CR3:页表隔离(KPTI/PTI),防止 Meltdown 攻击。
- array_index_nospec():防止 Spectre v1(边界检查绕过)。
- add_random_kstack_offset():随机化内核栈偏移,增加栈喷射攻击难度。
- STACKLEAK_ERASE:擦除栈上遗留数据,防止信息泄露。
这些措施使系统调用路径比单纯的硬件切换开销要大得多。在现代系统中,一次完整系统调用的总开销(包括安全缓解)大约在 100-200 纳秒之间,而纯硬件 SYSCALL/SYSRET 的开销仅约 20-40 纳秒。这是安全性向性能做出的必要妥协。
7.3 ARM64 系统调用 —— SVC 异常与异常级别切换
7.3.1 ARM64 系统调用机制概述
ARM64 (AArch64) 架构的系统调用采用与 x86_64 截然不同的设计哲学。x86_64 为系统调用专门设计了 SYSCALL/SYSRET 指令对,以及配套的 MSR 寄存器组 (STAR、LSTAR、CSTAR、SFMASK)。ARM64 则没有专用的"快速系统调用"指令——它使用统一的异常机制:用户态程序执行 SVC(Supervisor Call)指令触发同步异常,CPU 从 EL0(用户态)切换到 EL1(内核态),进入统一的异常处理流程。
这一设计的关键特征在于:
- SVC 指令触发同步异常:
SVC #imm指令会立即产生一个同步异常,CPU 硬件自动完成特权级切换 - CPU 从 EL0 到 EL1:异常级别 (Exception Level) 从 EL0 提升到 EL1,获得内核态的全部权限
- 统一异常机制:系统调用、缺页异常、未定义指令、断点等所有事件共用同一套异常向量表和入口框架
- 硬件自动保存上下文:PSTATE、返回地址、异常原因等信息由硬件自动写入系统寄存器
这种设计虽然不如 x86_64 的 SYSCALL 指令在微观上"快速",但它带来了架构上的简洁性:同一套代码可以处理所有类型的异常,降低了硬件设计的复杂度,也使得内核的异常入口代码更加统一和易于维护。
7.3.2 SVC 指令的硬件行为
当用户态程序执行 SVC #0 指令时,ARM64 处理器硬件会自动执行一系列操作。这些操作完全由微代码实现,不涉及任何软件干预。下面逐步展示硬件的每一步行为:
用户态 (EL0) 执行 SVC #0 指令后,硬件自动完成:
┌──────────────────────────────────────────────────────────┐
│ SVC 指令触发的硬件行为(自动完成,不可中断) │
├──────────────────────────────────────────────────────────┤
│ │
│ ① PSTATE 变化: │
│ EL ← EL1 // 异常级别切换到 EL1 │
│ SPsel ← 1 // 选择 SP_EL1(内核栈) │
│ DAIF ← 全部屏蔽 // 禁止中断和异步异常 │
│ │
│ ② 保存处理器状态: │
│ SPSR_EL1 ← PSTATE // 保存被中断的处理器状态 │
│ ELR_EL1 ← PC + 4 // 保存返回地址(SVC 的下一条指令) │
│ │
│ ③ 记录异常信息: │
│ ESR_EL1 ← 异常综合征 │
│ .EC (bits [31:26]) = 0x15 // SVC64 │
│ .IL (bit 25) = 1 // 有效长度 32 位 │
│ .ISS (bits [24:0]) = imm // SVC 的立即数 │
│ │
│ ④ PC 跳转到异常向量: │
│ PC ← VBAR_EL1 + 0x400 + 0x00 │
│ ╰──╯ ╰───╯ ╰─╯ │
│ 向量基址 EL0同步 offset 0 │
│ │
└──────────────────────────────────────────────────────────┘
ESR_EL1 寄存器详解
ESR_EL1(Exception Syndrome Register)是 ARM64 异常处理的核心寄存器之一。它记录了异常的原因,软件通过解析该寄存器来确定应采取的处理动作。对于 SVC 指令产生的异常,ESR_EL1 的关键字段如下:
ESR_EL1 寄存器布局(SVC 异常时):
┌────────┬───┬──┬─────────────────────────────────┐
│ EC[5:0]│IL│ │ ISS[24:0] │
│ 0x15 │ 1│ │ SVC 的立即数 imm │
└────────┴───┴──┴─────────────────────────────────┘
bits31:26 25 bits 24:0
EC (Exception Class) = 0x15 → ESR_ELx_EC_SVC64
在 Linux 内核源码中,ESR 的 EC 值定义在 arch/arm64/include/asm/esr.h 中:
// arch/arm64/include/asm/esr.h
#define ESR_ELx_EC_SVC64 UL(0x15) // AArch64 SVC 指令异常
#define ESR_ELx_EC_SHIFT (26)
#define ESR_ELx_EC_MASK (UL(0x3F) << ESR_ELx_EC_SHIFT)
#define ESR_ELx_EC(esr) (((esr) & ESR_ELx_EC_MASK) >> ESR_ELx_EC_SHIFT)
内核通过 ESR_ELx_EC(esr) 宏提取 EC 字段,然后根据其值分发到不同的处理函数。
ELR_EL1 与 SPSR_EL1
- ELR_EL1(Exception Link Register):保存了异常返回后应执行的指令地址。对于 SVC 指令,硬件自动将其设为
PC + 4(SVC 指令的下一条指令),这意味着返回时不会重新执行 SVC 本身。 - SPSR_EL1(Saved Program Status Register):保存了异常发生时的 PSTATE 值,包括条件标志位 (NZCV)、异常级别 (EL0)、中断屏蔽位 (DAIF) 等全部处理器状态。
7.3.3 异常向量表的构建
ARM64 的异常向量表 (Exception Vector Table) 由 16 个入口组成,每个入口占 128 字节(0x80)。向量表的基地址由系统寄存器 VBAR_EL1(Vector Base Address Register)指定。
向量表结构
16 个入口按照"异常类型 × 目标 SP"的矩阵排列:
异常向量表布局(VBAR_EL1 指向基址):
偏移量 EL0 SP0 EL0 SPx EL1 SP0 EL1 SPx
─────── ─────────── ──────────── ──────────── ────────────
0x000 EL1t sync (EL1) (EL1) (EL1)
0x080 EL1t irq
0x100 EL1t fiq
0x180 EL1t error
0x200 EL1h sync
0x280 EL1h irq
0x300 EL1h fiq
0x380 EL1h error
0x400 EL0 sync ← SVC 从 EL0 来到这里
0x480 EL0 irq
0x500 EL0 fiq
0x580 EL0 error
0x600 EL0 32-bit sync (AArch32 兼容)
0x680 EL0 32-bit irq
0x700 EL0 32-bit fiq
0x780 EL0 32-bit error
当 SVC 从 EL0 触发时,CPU 跳转到 VBAR_EL1 + 0x400(Synchronous exception from EL0 using SP0)。这是 ARM64 系统调用的入口点。
Linux 内核中的向量表实现
在 arch/arm64/kernel/entry.S 中,向量表通过宏展开构建:
// arch/arm64/kernel/entry.S (节选)
.align 11
SYM_CODE_START(vectors)
kernel_ventry 1, t, 64, sync // Synchronous EL1t
kernel_ventry 1, t, 64, irq // IRQ EL1t
kernel_ventry 1, t, 64, fiq // FIQ EL1t
kernel_ventry 1, t, 64, error // Error EL1t
kernel_ventry 1, h, 64, sync // Synchronous EL1h
kernel_ventry 1, h, 64, irq // IRQ EL1h
kernel_ventry 1, h, 64, fiq // FIQ EL1h
kernel_ventry 1, h, 64, error // Error EL1h
kernel_ventry 0, t, 64, sync // Synchronous 64-bit EL0 ← SVC 入口
kernel_ventry 0, t, 64, irq // IRQ 64-bit EL0
kernel_ventry 0, t, 64, fiq // FIQ 64-bit EL0
kernel_ventry 0, t, 64, error // Error 64-bit EL0
kernel_ventry 0, t, 32, sync // Synchronous 32-bit EL0
kernel_ventry 0, t, 32, irq // IRQ 32-bit EL0
kernel_ventry 0, t, 32, fiq // FIQ 32-bit EL0
kernel_ventry 0, t, 32, error // Error 32-bit EL0
SYM_CODE_END(vectors)
kernel_ventry 宏
kernel_ventry 宏是每个向量入口的展开模板,负责在异常入口处分配栈空间并检测栈溢出:
// arch/arm64/kernel/entry.S (节选)
.macro kernel_ventry, el:req, ht:req, regsize:req, label:req
.align 7
.Lventry_start\@:
.if \el == 0
/*
* 对于 EL0 入口,清除 tpidrro_el0 作为
* trampoline vectors 的标记
*/
b .Lskip_tramp_vectors_cleanup\@
.if \regsize == 64
mrs x30, tpidrro_el0
msr tpidrro_el0, xzr
.else
mov x30, xzr
.endif
.Lskip_tramp_vectors_cleanup\@:
.endif
sub sp, sp, #PT_REGS_SIZE // 在内核栈上分配 pt_regs 空间
// 栈溢出检测(利用 SP 对齐特性)
add sp, sp, x0
sub x0, sp, x0
tbnz x0, #THREAD_SHIFT, 0f // 检测溢出
sub x0, sp, x0
sub sp, sp, x0
b el\el\ht\()_\regsize\()_\label // 跳转到具体处理函数
0:
// 栈溢出处理...
.endm
对于从 EL0 来的 64 位同步异常,kernel_ventry 最终跳转到 el0t_64_sync 标号。
7.3.4 入口路径 —— 从异常到系统调用处理
entry_handler 宏展开
每个异常类型通过 entry_handler 宏定义其处理函数:
// arch/arm64/kernel/entry.S
.macro entry_handler el:req, ht:req, regsize:req, label:req
SYM_CODE_START_LOCAL(el\el\ht\()_\regsize\()_\label)
kernel_entry \el, \regsize
mov x0, sp
bl el\el\ht\()_\regsize\()_\label\()_handler
.if \el == 0
b ret_to_user
.else
b ret_to_kernel
.endif
SYM_CODE_END(el\el\ht\()_\regsize\()_\label)
.endm
// 展开的 EL0 64 位同步异常处理器
entry_handler 0, t, 64, sync
这展开为 el0t_64_sync,它调用 kernel_entry 保存上下文,然后跳转到 C 函数 el0t_64_sync_handler。
kernel_entry 宏 —— 保存完整上下文
kernel_entry 宏负责将所有通用寄存器保存到内核栈上的 pt_regs 结构中:
// arch/arm64/kernel/entry.S (节选)
.macro kernel_entry, el, regsize = 64
// 保存 x0-x29 到 pt_regs
stp x0, x1, [sp, #16 * 0]
stp x2, x3, [sp, #16 * 1]
stp x4, x5, [sp, #16 * 2]
stp x6, x7, [sp, #16 * 3]
stp x8, x9, [sp, #16 * 4]
stp x10, x11, [sp, #16 * 5]
stp x12, x13, [sp, #16 * 6]
stp x14, x15, [sp, #16 * 7]
stp x16, x17, [sp, #16 * 8]
stp x18, x19, [sp, #16 * 9]
stp x20, x21, [sp, #16 * 10]
stp x22, x23, [sp, #16 * 11]
stp x24, x25, [sp, #16 * 12]
stp x26, x27, [sp, #16 * 13]
stp x28, x29, [sp, #16 * 14]
.if \el == 0
clear_gp_regs // 将 x0-x29 清零(防止内核使用用户数据)
mrs x21, sp_el0 // 保存用户态 SP
ldr_this_cpu tsk, __entry_task, x20
msr sp_el0, tsk // SP_EL0 指向当前 task_struct
// 禁用单步调试
ldr x19, [tsk, #TSK_TI_FLAGS]
disable_step_tsk x19, x20
// Spectre v4 缓解(SSBD)
apply_ssbd 1, x22, x23
.endif
// 保存 ELR_EL1(返回地址)和 SPSR_EL1(处理器状态)
mrs x22, elr_el1
mrs x23, spsr_el1
stp lr, x21, [sp, #S_LR]
stp x22, x23, [sp, #S_PC]
// 默认标记为非系统调用
.if \el == 0
mov w21, #NO_SYSCALL
str w21, [sp, #S_SYSCALLNO]
.endif
.endm
pt_regs 结构体
pt_regs 是 ARM64 内核中最关键的数据结构之一,它保存在异常入口处被完整保存的处理器状态:
// arch/arm64/include/asm/ptrace.h
struct pt_regs {
union {
struct user_pt_regs user_regs;
struct {
u64 regs[31]; // x0-x30
u64 sp; // 用户态栈指针
u64 pc; // 返回地址(来自 ELR_EL1)
u64 pstate; // 处理器状态(来自 SPSR_EL1)
};
};
u64 orig_x0; // 原始 x0 值(系统调用入口处保存)
s32 syscallno; // 系统调用号
u32 pmr; // 中断优先级屏蔽寄存器
u64 sdei_ttbr1;
struct frame_record_meta stackframe;
};
C 层分发:el0t_64_sync_handler
从汇编进入 C 代码后,el0t_64_sync_handler 读取 ESR_EL1 并根据异常类别分发:
// arch/arm64/kernel/entry-common.c
asmlinkage void noinstr el0t_64_sync_handler(struct pt_regs *regs)
{
unsigned long esr = read_sysreg(esr_el1);
switch (ESR_ELx_EC(esr)) {
case ESR_ELx_EC_SVC64: // EC = 0x15: SVC 指令
el0_svc(regs);
break;
case ESR_ELx_EC_DABT_LOW: // 数据异常
el0_da(regs, esr);
break;
case ESR_ELx_EC_IABT_LOW: // 指令异常
el0_ia(regs, esr);
break;
case ESR_ELx_EC_FP_ASIMD: // 浮点/NEON 访问
el0_fpsimd_acc(regs, esr);
break;
// ... 其他异常类型
case ESR_ELx_EC_BRK64: // BRK 断点
el0_brk64(regs, esr);
break;
default:
el0_inv(regs, esr);
}
}
当 ESR_ELx_EC(esr) == 0x15 时,进入 el0_svc() 函数处理系统调用。
7.3.5 el0_svc_common() —— 系统调用的核心分发
el0_svc 函数
// arch/arm64/kernel/entry-common.c
static void noinstr el0_svc(struct pt_regs *regs)
{
arm64_enter_from_user_mode(regs); // 上下文跟踪
cortex_a76_erratum_1463225_svc_handler(); // Cortex-A76 硬件勘误处理
fpsimd_syscall_enter(); // FPSIMD/SVE/SME 状态处理
local_daif_restore(DAIF_PROCCTX); // 恢复中断
do_el0_svc(regs); // ← 实际的系统调用处理
arm64_exit_to_user_mode(regs); // 返回用户态准备
fpsimd_syscall_exit();
}
do_el0_svc 与 el0_svc_common
// arch/arm64/kernel/syscall.c
void do_el0_svc(struct pt_regs *regs)
{
el0_svc_common(regs, regs->regs[8], __NR_syscalls, sys_call_table);
}
这里 regs->regs[8] 就是用户态通过 x8 寄存器传递的系统调用号。el0_svc_common 是整个系统调用分发的核心:
// arch/arm64/kernel/syscall.c
static void el0_svc_common(struct pt_regs *regs, int scno, int sc_nr,
const syscall_fn_t syscall_table[])
{
unsigned long flags = read_thread_flags();
regs->orig_x0 = regs->regs[0]; // 保存原始 x0(第一个参数)
regs->syscallno = scno; // 记录系统调用号
// 处理异步 MTE 错误
if (unlikely(flags & _TIF_MTE_ASYNC_FAULT)) {
syscall_set_return_value(current, regs, -ERESTARTNOINTR, 0);
return;
}
// 检查是否有 ptrace 或其他跟踪需求
if (has_syscall_work(flags)) {
if (scno == NO_SYSCALL)
syscall_set_return_value(current, regs, -ENOSYS, 0);
scno = syscall_trace_enter(regs);
if (scno == NO_SYSCALL)
goto trace_exit;
}
// 实际调用系统调用
invoke_syscall(regs, scno, sc_nr, syscall_table);
// 检查是否需要跟踪退出
if (!has_syscall_work(flags) && !IS_ENABLED(CONFIG_DEBUG_RSEQ)) {
flags = read_thread_flags();
if (!has_syscall_work(flags) && !(flags & _TIF_SINGLESTEP))
return;
}
trace_exit:
syscall_trace_exit(regs);
}
invoke_syscall —— 调用具体系统调用
// arch/arm64/kernel/syscall.c
static void invoke_syscall(struct pt_regs *regs, unsigned int scno,
unsigned int sc_nr,
const syscall_fn_t syscall_table[])
{
long ret;
add_random_kstack_offset(); // 内核栈随机化(安全缓解)
if (likely(scno < sc_nr)) {
syscall_fn_t syscall_fn;
// array_index_nospec 防止 Spectre 变体 1(边界检查旁路)
syscall_fn = syscall_table[array_index_nospec(scno, sc_nr)];
ret = __invoke_syscall(regs, syscall_fn);
} else {
ret = do_ni_syscall(regs, scno); // 未实现的系统调用
}
// 将返回值写入 regs->regs[0](即 x0)
syscall_set_return_value(current, regs, 0, ret);
choose_random_kstack_offset(get_random_u16());
}
__invoke_syscall 非常简洁,直接调用系统调用函数,将整个 pt_regs 结构作为参数传入:
static long __invoke_syscall(struct pt_regs *regs, syscall_fn_t syscall_fn)
{
return syscall_fn(regs);
}
7.3.6 ARM64 系统调用表
ARM64 使用 asm-generic 通用系统调用表。系统调用表在 arch/arm64/kernel/sys.c 中定义:
// arch/arm64/kernel/sys.c
typedef long (*syscall_fn_t)(const struct pt_regs *regs);
#define __SYSCALL_WITH_COMPAT(nr, native, compat) __SYSCALL(nr, native)
#undef __SYSCALL
#define __SYSCALL(nr, sym) asmlinkage long __arm64_##sym(const struct pt_regs *);
#undef __SYSCALL
#define __SYSCALL(nr, sym) [nr] = __arm64_##sym,
const syscall_fn_t sys_call_table[__NR_syscalls] = {
[0 ... __NR_syscalls - 1] = __arm64_sys_ni_syscall, // 默认:未实现
#include <asm/syscall_table_64.h> // 展开所有系统调用
};
系统调用表的关键设计特点:
- 基于 pt_regs 的原型:每个系统调用的函数签名统一为
long fn(const struct pt_regs *regs),参数从regs中提取。这与 x86_64 直接传参的方式不同。 - 全表初始化为 ni_syscall:未实现的槽位指向
__arm64_sys_ni_syscall,返回-ENOSYS。 - 包含
asm/syscall_table_64.h:该头文件由工具链从syscall_64.tbl自动生成,通过宏展开填充系统调用号到函数指针的映射。 - 名称修饰:所有系统调用函数名被加上
__arm64_前缀,避免命名冲突。
ARM64 的通用系统调用号定义在 include/uapi/asm-generic/unistd.h 中,例如:
// include/uapi/asm-generic/unistd.h (节选)
#define __NR_io_setup 0
#define __NR_io_destroy 1
#define __NR_io_submit 2
#define __NR_io_cancel 3
#define __NR_setxattr 5
#define __NR_getxattr 8
#define __NR_read 63
#define __NR_write 64
#define __NR_openat 56
#define __NR_close 57
#define __NR_getpid 172
7.3.7 返回路径 —— 从内核回到用户态
系统调用处理完成后,控制流沿以下路径返回用户态:
系统调用返回路径:
el0_svc_common() 返回
→ el0_svc() 返回
→ el0t_64_sync_handler() 返回
→ el0t_64_sync (汇编) 返回
→ ret_to_user
→ kernel_exit 0
→ ERET 指令 ← 硬件自动回到 EL0
ret_to_user
// arch/arm64/kernel/entry.S
SYM_CODE_START_LOCAL(ret_to_user)
ldr x19, [tsk, #TSK_TI_FLAGS] // 重新读取线程标志
enable_step_tsk x19, x2 // 恢复单步调试
#ifdef CONFIG_KSTACK_ERASE
bl stackleak_erase_on_task_stack // 擦除内核栈敏感数据
#endif
kernel_exit 0 // 执行返回操作
SYM_CODE_END(ret_to_user)
kernel_exit 宏
kernel_exit 宏完成恢复上下文并执行异常返回的全部工作:
// arch/arm64/kernel/entry.S (节选)
.macro kernel_exit, el
// ... 中断优先级恢复 ...
ldp x21, x22, [sp, #S_PC] // 加载 ELR_EL1 和 SPSR_EL1
.if \el == 0
ldr x23, [sp, #S_SP] // 加载用户态 SP
msr sp_el0, x23 // 恢复 SP_EL0
// MTE 相关清理、指针认证、Spectre 缓解...
scs_save tsk // 保存影子调用栈
.endif
msr elr_el1, x21 // 设置返回地址
msr spsr_el1, x22 // 设置返回的处理器状态
// 恢复所有通用寄存器
ldp x0, x1, [sp, #16 * 0]
ldp x2, x3, [sp, #16 * 1]
ldp x4, x5, [sp, #16 * 2]
// ... x6-x29 ...
ldp x28, x29, [sp, #16 * 14]
ldr lr, [sp, #S_LR]
add sp, sp, #PT_REGS_SIZE // 释放 pt_regs 空间
eret // 异常返回
sb // 推测屏障
.endm
ERET 指令
ERET(Exception Return)是 ARM64 异常返回的核心指令。它的硬件行为是 SVC 硬件行为的逆过程:
ERET 指令的硬件行为:
① PC ← ELR_EL1 // 恢复被中断的指令地址(SVC 的下一条指令)
② PSTATE ← SPSR_EL1 // 恢复处理器状态
→ EL ← EL0 // 切换回用户态
→ 恢复 DAIF 状态 // 恢复中断屏蔽设置
→ SPsel 恢复 // 切回 SP_EL0(用户栈)
ERET 指令执行后,处理器自动回到 EL0,使用恢复后的 PSTATE 和用户态栈指针,从 SVC 指令的下一条指令继续执行。
7.3.8 ARM64 系统调用的安全扩展
ARM64 Linux 内核在系统调用路径上实现了多层安全缓解措施:
Spectre BHB(Branch History Injection)缓解
ARM64 内核支持多种 Spectre 变体的缓解。在异常向量入口处,内核实现了分支目标缓冲区(BTB)和分支历史缓冲区(BHB)的清理:
// arch/arm64/kernel/entry.S (节选)
#define BHB_MITIGATION_NONE 0
#define BHB_MITIGATION_LOOP 1 // 软件循环清除
#define BHB_MITIGATION_FW 2 // 固件调用(SMC/HVC)
#define BHB_MITIGATION_INSN 3 // 硬件指令(clearbhb)
在 KPTI(Kernel Page Table Isolation)启用的系统中,EL0 的异常入口使用独立的 trampoline 向量表(tramp_vectors),在跳转到主向量表之前进行 BHB 清理。
内核栈随机化
ARM64 使用 add_random_kstack_offset() 在系统调用入口处随机化内核栈指针,增加栈溢出攻击的难度。由于 AAPCS64 要求 16 字节栈对齐,实际提供约 6 位熵(SP[9:4])。
Speculative Barrier
在 ERET 之后紧跟 SB(Speculative Barrier)指令,防止推测执行在异常返回时泄漏内核信息。
7.3.9 寄存器约定总结
ARM64 系统调用的寄存器约定遵循 AAPCS64(ARM Architecture Procedure Call Standard for the 64-bit Architecture):
┌──────────┬──────────────────────────────┬──────────────────────────┐
│ 寄存器 │ 系统调用中的用途 │ 说明 │
├──────────┼──────────────────────────────┼──────────────────────────┤
│ x8 (w8) │ 系统调用号 │ 用户态写入,内核读取 │
│ x0 │ 参数 1 / 返回值 │ 入口: arg1, 出口: retval │
│ x1 │ 参数 2 │ 保存或按 AAPCS64 约定 │
│ x2 │ 参数 3 │ 保存或按 AAPCS64 约定 │
│ x3 │ 参数 4 │ 保存或按 AAPCS64 约定 │
│ x4 │ 参数 5 │ 保存或按 AAPCS64 约定 │
│ x5 │ 参数 6 │ 保存或按 AAPCS64 约定 │
│ x6-x7 │ 未使用(保留) │ 系统调用不使用 │
│ x9-x15 │ 临时寄存器 │ 被内核破坏 │
│ x16-x17 │ IP0/IP1 │ 被内核破坏(过程间调用) │
│ x18 │ 平台寄存器 │ 被内核破坏 │
│ x19-x28 │ 被调用者保存 │ 内核必须保存/恢复 │
│ x29 │ 帧指针 (FP) │ 内核必须保存/恢复 │
│ x30 (LR) │ 链接寄存器 │ 内核必须保存/恢复 │
│ SP │ 栈指针 │ 自动切换 SP_EL0/SP_EL1 │
└──────────┴──────────────────────────────┴──────────────────────────┘
系统调用号约定
- 64 位 AArch64 程序:系统调用号放在 x8 寄存器
- 32 位 AArch32 兼容程序:系统调用号放在 x7 寄存器(参见
do_el0_svc_compat中regs->regs[7])
与 x86_64 的对比
| 特性 | x86_64 | ARM64 |
|---|---|---|
| 触发指令 | SYSCALL | SVC #0 |
| 返回指令 | SYSRET / IRET | ERET |
| 系统调用号寄存器 | rax | x8 |
| 参数寄存器 | rdi, rsi, rdx, r10, r8, r9 | x0-x5 |
| 返回值寄存器 | rax | x0 |
| 特权级切换 | CPL 3→0 (Ring 切换) | EL0→EL1 (异常级别切换) |
| 返回地址保存 | RCX ← RIP | ELR_EL1 ← PC+4 |
| 状态保存 | R11 ← RFLAGS | SPSR_EL1 ← PSTATE |
| 入口地址 | IA32_LSTAR MSR | VBAR_EL1 + 偏移量 |
| 专用快速路径 | 是 (SYSCALL/SYSRET) | 否 (统一异常机制) |
| 硬件自动保存寄存器 | 仅 RCX/R11 | 无(软件保存全部) |
ARM64 的设计虽然使用统一的异常机制(没有专用快速系统调用指令),但现代 ARM64 处理器的异常处理流水线已经高度优化,SVC/ERET 的开销在实践中与 x86_64 的 SYSCALL/SYSRET 相当。更重要的是,统一异常机制简化了内核代码,减少了特殊情况的处理,使系统更易于验证和维护。
7.4 RISC-V 系统调用 —— ecall 与特权级切换
7.4.1 RISC-V 系统调用机制概述
RISC-V 架构秉承"精简指令集"的设计哲学,其系统调用机制是三种主流架构(x86_64、ARM64、RISC-V)中最为简洁的。RISC-V 没有专门的"系统调用"指令,而是使用 ecall(Environment Call)指令完成所有特权级之间的转换。
这一设计的核心思想是"统一机制":
- ecall 是通用的特权级切换指令:无论是 U-mode 到 S-mode(用户系统调用)、还是 U-mode 到 M-mode(SBI 固件调用),都使用同一条
ecall指令 - U-mode → S-mode:用户态程序执行
ecall产生异常,从用户模式 (U-mode) 进入管理模式 (S-mode),这是 Linux 内核处理系统调用的路径 - U-mode → M-mode:S-mode 执行
ecall进入 M-mode,用于 SBI(Supervisor Binary Interface)固件调用,这不是系统调用的范畴 - RISC-V 不区分系统调用和异常:ecall 产生的就是"环境调用异常",内核通过异常原因码 (scause == 8) 判断这是来自用户态的系统调用
这种极简设计意味着 RISC-V 不需要像 x86_64 那样维护专用的 SYSCALL/SYSRET 指令和配套的 MSR 寄存器组,也不需要像 ARM64 那样在 ESR 中编码异常子类型。RISC-V 的异常处理逻辑更加直接:硬件提供异常原因码,软件根据原因码分发处理。
但这也带来了一个重要的设计后果:ecall 指令本身不会自动递增返回地址。与 ARM64 的 SVC(硬件自动设置 ELR_EL1 = PC + 4)和 x86_64 的 SYSCALL(硬件自动将 RIP + 指令长度 保存到 RCX)不同,RISC-V 的 ecall 只是简单地将当前 PC 保存到 sepc,内核必须在软件中手动将 sepc 加 4,以避免返回时无限循环执行 ecall。
7.4.2 ecall 指令的硬件行为
当用户态程序执行 ecall 指令时,RISC-V 处理器硬件自动执行以下操作:
用户态 (U-mode) 执行 ecall 指令后,硬件自动完成:
┌────────────────────────────────────────────────────────────┐
│ ecall 指令触发的硬件行为(原子操作,不可中断) │
├────────────────────────────────────────────────────────────┤
│ │
│ ① 保存返回地址: │
│ sepc ← PC // 保存 ecall 指令本身的地址 │
│ 注意: 不是 PC+4! // 这是与 ARM64 的重要区别 │
│ │
│ ② 记录异常原因: │
│ scause ← 8 // ECALL_FROM_UMODE (环境调用) │
│ // 值 8 表示"来自 U-mode 的 ecall"│
│ │
│ ③ 保存当前特权级: │
│ sstatus.SPP ← 0 // 0 表示来自 U-mode │
│ // 1 表示来自 S-mode │
│ │
│ ④ 保存中断使能状态: │
│ sstatus.SPIE ← sstatus.SIE // 保存当前中断使能位 │
│ sstatus.SIE ← 0 // 禁用 S-mode 中断 │
│ │
│ ⑤ PC 跳转到陷阱处理程序: │
│ PC ← stvec // stvec 为陷阱向量基地址 │
│ // Direct 模式: 所有异常同一入口 │
│ │
│ ⑥ 特权级切换: │
│ 当前特权级: U-mode → S-mode │
│ │
└────────────────────────────────────────────────────────────┘
与 ARM64 SVC 和 x86_64 SYSCALL 的关键区别:
┌──────────────────┬─────────────────┬──────────────────┬─────────────────┐
│ 行为 │ x86_64 SYSCALL │ ARM64 SVC │ RISC-V ecall │
├──────────────────┼─────────────────┼──────────────────┼─────────────────┤
│ 返回地址保存 │ RCX ← next RIP │ ELR_EL1 ← PC+4 │ sepc ← PC │
│ │ (自动 + 指令长度)│ (自动 + 4) │ (不自动 + 4!) │
│ 软件需要手动递增? │ 否 │ 否 │ 是 │
│ 异常原因记录 │ 无专用机制 │ ESR_EL1 (EC=0x15)│ scause = 8 │
│ 特权级记录 │ 隐含 (CPL) │ SPSR_EL1 中 EL │ sstatus.SPP │
└──────────────────┴─────────────────┴──────────────────┴─────────────────┘
scause 寄存器的异常码
在 S-mode 下,scause 寄存器定义了所有异常和中断的原因。异常码的最高位(MSB)区分中断 (1) 和异常 (0):
// arch/riscv/include/asm/csr.h
#define CAUSE_IRQ_FLAG (_AC(1, UL) << (__riscv_xlen - 1))
/* 异常原因码 (scause 的低有效位) */
#define EXC_INST_MISALIGNED 0 // 指令地址不对齐
#define EXC_INST_ACCESS 1 // 指令访问异常
#define EXC_INST_ILLEGAL 2 // 非法指令
#define EXC_BREAKPOINT 3 // 断点
#define EXC_LOAD_MISALIGNED 4 // Load 地址不对齐
#define EXC_LOAD_ACCESS 5 // Load 访问异常
#define EXC_STORE_MISALIGNED 6 // Store 地址不对齐
#define EXC_STORE_ACCESS 7 // Store 访问异常
#define EXC_SYSCALL 8 // ← 来自 U-mode 的 ecall(系统调用)
#define EXC_HYPERVISOR_SYSCALL 9 // 来自 VS-mode 的 ecall
#define EXC_SUPERVISOR_SYSCALL 10 // 来自 S-mode 的 ecall
#define EXC_INST_PAGE_FAULT 12 // 指令缺页
#define EXC_LOAD_PAGE_FAULT 13 // Load 缺页
#define EXC_STORE_PAGE_FAULT 15 // Store 缺页
scause == 8 表示来自 U-mode 的 ecall,这就是用户态系统调用的异常码。
sstatus 寄存器的关键字段
sstatus 寄存器在 ecall 异常时的关键字段:
┌──────────┬───────────┬──────────────────────────────────────────┐
│ 字段 │ 位位置 │ 说明 │
├──────────┼───────────┼──────────────────────────────────────────┤
│ SIE │ [1] │ S-mode 中断使能(ecall 后被清零) │
│ SPIE │ [5] │ ecall 前的 SIE 值保存在此 │
│ SPP │ [8] │ ecall 前的特权级(0=U-mode, 1=S-mode) │
│ SUM │ [18] │ S-mode 是否可访问 U-mode 内存 │
│ FS │ [14:13] │ 浮点单元状态 │
│ VS │ [10:9] │ 向量扩展状态 │
└──────────┴───────────┴──────────────────────────────────────────┘
在 arch/riscv/include/asm/csr.h 中,这些字段定义为:
#define SR_SIE _AC(0x00000002, UL) /* Supervisor Interrupt Enable */
#define SR_SPIE _AC(0x00000020, UL) /* Previous Supervisor IE */
#define SR_SPP _AC(0x00000100, UL) /* Previously Supervisor */
#define SR_SUM _AC(0x00040000, UL) /* Supervisor User Memory Access */
7.4.3 陷阱向量设置
RISC-V 的陷阱向量由 CSR 寄存器 stvec(Supervisor Trap Vector)控制。stvec 有两种工作模式:
stvec 寄存器布局:
┌────────────────────────────────────┬──────┐
│ BASE [MXLEN-1:2] │MODE │
│ 陷阱处理程序基地址 │[1:0] │
└────────────────────────────────────┴──────┘
MODE:
00 = Direct 模式: 所有异常/中断跳转到 BASE
01 = Vectored 模式: 异常跳转到 BASE,中断跳转到 BASE + 4*cause
Linux 内核使用 Direct 模式,所有异常(包括 ecall 系统调用)都跳转到同一个入口地址。
陷阱向量的初始化
在内核启动的早期阶段,stvec 被设置为 handle_exception 的地址:
// arch/riscv/kernel/head.S
.align 2
.Lsetup_trap_vector:
/* Set trap vector to exception handler */
la a0, handle_exception
csrw CSR_TVEC, a0 // stvec ← handle_exception 地址
/*
* Set sup0 scratch register to 0, indicating to exception vector
* that we are presently executing in kernel.
*/
csrw CSR_SCRATCH, zero // sscratch ← 0(标记当前在内核态)
ret
这段代码在内核启动(_start_kernel)和从属 CPU 启动(smp_callin)时都会被调用。将 sscratch(CSR_SCRATCH)设为 0 是一个关键的设计决策——异常入口代码通过检查 sscratch 的值来判断异常来自用户态还是内核态。
7.4.4 入口路径 —— handle_exception 详解
handle_exception 是 RISC-V Linux 内核中所有异常和中断的统一入口点,定义在 arch/riscv/kernel/entry.S 中。下面逐步分析其关键逻辑:
判断来源:用户态还是内核态
// arch/riscv/kernel/entry.S
SYM_CODE_START(handle_exception)
/*
* 如果来自用户态,sscratch 中保存着用户态的 tp 值(非零),
* csrrw 交换后 tp 变为用户态 tp(非零),跳转到 .Lsave_context。
* 如果来自内核态,sscratch 为 0,交换后 tp 为 0,走内核路径。
*/
csrrw tp, CSR_SCRATCH, tp // tp ↔ sscratch
bnez tp, .Lsave_context // tp != 0 → 来自用户态
这段代码利用了一个精妙的技巧:
- 用户态运行时,
sscratch保存了用户态的tp(thread pointer)值(非零) - 内核态运行时,
sscratch被设为 0 csrrw tp, CSR_SCRATCH, tp原子地交换 tp 和 sscratch 的值- 交换后如果 tp 非零,说明来自用户态;如果为零,说明来自内核态
保存上下文到 pt_regs
// arch/riscv/kernel/entry.S (节选)
.Lsave_context:
REG_S sp, TASK_TI_USER_SP(tp) // 保存用户态 SP 到 thread_info
REG_L sp, TASK_TI_KERNEL_SP(tp) // 加载内核栈 SP
addi sp, sp, -(PT_SIZE_ON_STACK) // 在内核栈上分配 pt_regs 空间
// 保存通用寄存器到 pt_regs
REG_S x1, PT_RA(sp) // ra (x1)
REG_S x3, PT_GP(sp) // gp (x3)
REG_S x5, PT_T0(sp) // t0 (x5)
save_from_x6_to_x31 // 保存 x6-x31(宏展开)
// 禁用 U-mode 内存访问和 FPU/Vector
li t0, SR_SUM | SR_FS_VS
#ifdef CONFIG_64BIT
li t1, SR_ELP
or t0, t0, t1
#endif
// 读取并保存关键 CSR
REG_L s0, TASK_TI_USER_SP(tp)
csrrc s1, CSR_STATUS, t0 // 读取 sstatus 并清除 SUM/FS/VS
save_userssp s2, s1 // 保存用户影子栈指针(如果启用 ZicFISS)
csrr s2, CSR_EPC // 读取 sepc(ecall 指令地址)
csrr s3, CSR_TVAL // 读取 stval
csrr s4, CSR_CAUSE // 读取 scause
// 保存到 pt_regs
REG_S s0, PT_SP(sp) // 用户态 SP
REG_S s1, PT_STATUS(sp) // sstatus
REG_S s2, PT_EPC(sp) // sepc
REG_S s3, PT_BADADDR(sp) // stval
REG_S s4, PT_CAUSE(sp) // scause
// 设置 sscratch = 0(标记现在在内核态)
csrw CSR_SCRATCH, x0
// 加载内核全局指针
load_global_pointer
move a0, sp // a0 = pt_regs 指针
异常分发
保存完上下文后,handle_exception 根据 scause 分发到不同的处理函数:
// arch/riscv/kernel/entry.S (节选)
/*
* scause 的最高位区分中断和异常:
* MSB = 1 → 中断
* MSB = 0 → 异常
*/
bge s4, zero, 1f // scause >= 0 → 异常(最高位为 0)
/* Handle interrupts */
call do_irq
j ret_from_exception
1:
/* Handle other exceptions - 使用异常向量表分发 */
slli t0, s4, RISCV_LGPTR // t0 = scause * sizeof(void*)
la t1, excp_vect_table // 异常处理函数表
la t2, excp_vect_table_end
add t0, t1, t0 // t0 = &excp_vect_table[scause]
bgeu t0, t2, 3f // 越界检查
REG_L t1, 0(t0) // 加载处理函数指针
2:
jalr t1 // 调用处理函数
j ret_from_exception
3:
la t1, do_trap_unknown // 未知异常
j 2b
异常向量表 (excp_vect_table)
内核使用一个函数指针数组来分发异常,索引为异常原因码:
// arch/riscv/kernel/entry.S (rodata section)
.section ".rodata"
.align LGREG
SYM_DATA_START_LOCAL(excp_vect_table)
RISCV_PTR do_trap_insn_misaligned // cause 0: 指令地址不对齐
ALT_INSN_FAULT(RISCV_PTR do_trap_insn_fault) // cause 1: 指令访问异常
RISCV_PTR do_trap_insn_illegal // cause 2: 非法指令
RISCV_PTR do_trap_break // cause 3: 断点
RISCV_PTR do_trap_load_misaligned // cause 4: Load 地址不对齐
RISCV_PTR do_trap_load_fault // cause 5: Load 访问异常
RISCV_PTR do_trap_store_misaligned // cause 6: Store 地址不对齐
RISCV_PTR do_trap_store_fault // cause 7: Store 访问异常
RISCV_PTR do_trap_ecall_u // cause 8: U-mode ecall ← 系统调用
RISCV_PTR do_trap_ecall_s // cause 9: S-mode ecall
RISCV_PTR do_trap_unknown // cause 10: 保留
RISCV_PTR do_trap_ecall_m // cause 11: M-mode ecall
ALT_PAGE_FAULT(RISCV_PTR do_page_fault) // cause 12: 指令缺页
RISCV_PTR do_page_fault // cause 13: Load 缺页
RISCV_PTR do_trap_unknown // cause 14: 保留
RISCV_PTR do_page_fault // cause 15: Store 缺页
// ... 更多保留/未知 ...
SYM_DATA_END_LABEL(excp_vect_table, SYM_L_LOCAL, excp_vect_table_end)
scause == 8 时,跳转到 do_trap_ecall_u 函数,这就是系统调用的 C 语言入口。
7.4.5 系统调用分发 —— do_trap_ecall_u
do_trap_ecall_u 是 RISC-V 系统调用的核心 C 语言处理函数,定义在 arch/riscv/kernel/traps.c 中:
// arch/riscv/kernel/traps.c
void do_trap_ecall_u(struct pt_regs *regs)
{
if (user_mode(regs)) {
long syscall = regs->a7; // 从 a7 读取系统调用号
regs->epc += 4; // ★ 关键: 手动递增 sepc
// ecall 不自动递增返回地址!
regs->orig_a0 = regs->a0; // 保存原始 a0(第一个参数)
regs->a0 = -ENOSYS; // 默认返回 -ENOSYS
riscv_v_vstate_discard(regs); // 丢弃向量寄存器状态
syscall = syscall_enter_from_user_mode(regs, syscall);
add_random_kstack_offset(); // 内核栈随机化
if (syscall >= 0 && syscall < NR_syscalls) {
syscall = array_index_nospec(syscall, NR_syscalls);
syscall_handler(regs, syscall); // 调用系统调用处理函数
}
choose_random_kstack_offset(get_random_u16());
syscall_exit_to_user_mode(regs);
} else {
// 如果不是来自用户态(不应发生),报告非法指令
irqentry_state_t state = irqentry_nmi_enter(regs);
do_trap_error(regs, SIGILL, ILL_ILLTRP, regs->epc,
"Oops - environment call from U-mode");
irqentry_nmi_exit(regs, state);
}
}
这段代码中有几个关键点值得深入分析:
sepc += 4 —— 手动递增返回地址
regs->epc += 4;
这是 RISC-V 系统调用路径中最容易忽略但又最关键的步骤。ecall 指令将当前 PC(即 ecall 指令本身)保存到 sepc,而不像 ARM64 的 SVC 那样自动保存 PC+4。如果内核不手动递增 sepc,执行 sret 返回时会重新执行 ecall,导致无限循环。
默认返回 -ENOSYS
regs->a0 = -ENOSYS;
在查找和调用系统调用之前,先将 a0 设为 -ENOSYS。如果系统调用号无效(越界),a0 将保持此默认值,用户态收到 -ENOSYS 错误。这是一种防御性编程策略。
syscall_handler 内联函数
// arch/riscv/include/asm/syscall.h
static inline void syscall_handler(struct pt_regs *regs, ulong syscall)
{
syscall_t fn;
#ifdef CONFIG_COMPAT
if ((regs->status & SR_UXL) == SR_UXL_32)
fn = compat_sys_call_table[syscall];
else
#endif
fn = sys_call_table[syscall];
regs->a0 = fn(regs); // 调用并将返回值存入 a0
}
与 ARM64 类似,RISC-V 的系统调用函数也接受 pt_regs 作为参数,从 pt_regs 中提取系统调用参数。
7.4.6 RISC-V 系统调用表
RISC-V 使用 asm-generic 通用系统调用表,定义在 arch/riscv/kernel/syscall_table.c 中:
// arch/riscv/kernel/syscall_table.c
#include <linux/linkage.h>
#include <linux/syscalls.h>
#include <asm-generic/syscalls.h>
#include <asm/syscall.h>
#define __SYSCALL_WITH_COMPAT(nr, native, compat) __SYSCALL(nr, native)
#undef __SYSCALL
#define __SYSCALL(nr, call) asmlinkage long __riscv_##call(const struct pt_regs *);
#undef __SYSCALL
#define __SYSCALL(nr, call) [nr] = __riscv_##call,
void * const sys_call_table[__NR_syscalls] = {
[0 ... __NR_syscalls - 1] = __riscv_sys_ni_syscall, // 默认未实现
#include <asm/syscall_table.h> // 展开所有系统调用
};
与 ARM64 的系统调用表类似,RISC-V 的设计也遵循相同的模式:
- 全表初始化为 ni_syscall:未实现的系统调用号返回
-ENOSYS - 名称修饰:所有系统调用函数加上
__riscv_前缀 - 使用 asm-generic 通用定义:RISC-V 的系统调用号与 asm-generic 完全一致
- 基于 pt_regs 的函数原型:每个系统调用函数接受
const struct pt_regs *
RISC-V 的系统调用号定义在 include/uapi/asm-generic/unistd.h 中,部分常用系统调用如下:
系统调用号 名称 函数
───────── ────────── ──────────────
0 io_setup sys_io_setup
1 io_destroy sys_io_destroy
63 read sys_read
64 write sys_write
56 openat sys_openat
57 close sys_close
172 getpid sys_getpid
174 getuid sys_getuid
175 geteuid sys_geteuid
160 uname sys_uname
99 sched_yield sys_sched_yield
131 sigaction sys_rt_sigaction
7.4.7 RISC-V pt_regs 结构体
RISC-V 的 pt_regs 结构体定义在 arch/riscv/include/asm/ptrace.h 中,与 x86_64 和 ARM64 有显著不同:
// arch/riscv/include/asm/ptrace.h
struct pt_regs {
unsigned long epc; // 异常返回地址(sepc)
unsigned long ra; // x1: 返回地址
unsigned long sp; // x2: 栈指针
unsigned long gp; // x3: 全局指针
unsigned long tp; // x4: 线程指针
unsigned long t0; // x5: 临时寄存器
unsigned long t1; // x6
unsigned long t2; // x7
unsigned long s0; // x8: 帧指针 / 保存寄存器
unsigned long s1; // x9
struct_group(a_regs,
unsigned long a0; // x10: 参数 0 / 返回值
unsigned long a1; // x11: 参数 1
unsigned long a2; // x12: 参数 2
unsigned long a3; // x13: 参数 3
unsigned long a4; // x14: 参数 4
unsigned long a5; // x15: 参数 5
unsigned long a6; // x16: 参数 6
unsigned long a7; // x17: 参数 7 / 系统调用号
);
unsigned long s2; // x18
unsigned long s3; // x19
unsigned long s4; // x20
unsigned long s5; // x21
unsigned long s6; // x22
unsigned long s7; // x23
unsigned long s8; // x24
unsigned long s9; // x25
unsigned long s10; // x26
unsigned long s11; // x27
unsigned long t3; // x28
unsigned long t4; // x29
unsigned long t5; // x30
unsigned long t6; // x31
/* Supervisor/Machine CSRs */
unsigned long status; // sstatus
unsigned long badaddr; // stval
unsigned long cause; // scause
/* a0 value before the syscall */
unsigned long orig_a0; // 系统调用前的原始 a0 值
};
值得注意的是,RISC-V 的 pt_regs 将 epc(返回地址)放在结构体的最前面,这与 ARM64(pc 在结构体中部)不同。此外,RISC-V 使用 orig_a0 保存系统调用入口处的 a0 值,这与 ARM64 的 orig_x0 起相同的作用——当 ptrace 等工具修改了 a0 后,仍然可以通过 orig_a0 获取原始的参数值。
7.4.8 返回路径 —— sret 回到用户态
系统调用处理完成后,控制流通过 ret_from_exception 返回用户态:
// arch/riscv/kernel/entry.S
SYM_CODE_START_NOALIGN(ret_from_exception)
// 检查返回目标是用户态还是内核态
REG_L s0, PT_STATUS(sp)
andi s0, s0, SR_SPP // 检查 sstatus.SPP 位
bnez s0, 1f // SPP=1 → 返回内核态
// 返回用户态的准备工作
#ifdef CONFIG_KSTACK_ERASE
call stackleak_erase_on_task_stack
#endif
// 更新 thread_info 中的内核栈指针
addi s0, sp, PT_SIZE_ON_STACK
REG_S s0, TASK_TI_KERNEL_SP(tp)
// 保存影子调用栈指针
scs_save_current
// 设置 sscratch = tp,为下一次异常做准备
csrw CSR_SCRATCH, tp
1:
// 恢复用户影子栈指针(如果启用 ZicFISS)
REG_L a0, PT_STATUS(sp)
restore_userssp s3, a0
// 清除 LR/SC 预留(防止用户态利用内核的预留)
REG_L a2, PT_EPC(sp)
REG_SC x0, a2, PT_EPC(sp) // dummy SC 清除预留
// 恢复 CSR
csrw CSR_STATUS, a0 // 恢复 sstatus
csrw CSR_EPC, a2 // 恢复 sepc(已 +4)
// 恢复通用寄存器
REG_L x1, PT_RA(sp)
REG_L x3, PT_GP(sp)
REG_L x4, PT_TP(sp)
REG_L x5, PT_T0(sp)
restore_from_x6_to_x31
REG_L x2, PT_SP(sp) // 最后恢复 SP
sret // ★ 返回用户态
SYM_CODE_END(ret_from_exception)
sret 指令
sret(Supervisor Return)是 RISC-V 从 S-mode 返回的低特权级的指令。其硬件行为是 ecall 的精确逆过程:
sret 指令的硬件行为:
① PC ← sepc // 跳转到 ecall 的下一条指令(已由软件 +4)
② sstatus.SIE ← sstatus.SPIE // 恢复中断使能状态
③ sstatus.SPIE ← 1 // SPIE 设为 1(约定)
④ sstatus.SPP ← 0 // 清除之前特权级标记(回到 U-mode)
⑤ 特权级: S-mode → U-mode // 根据 SPP 恢复到之前的特权级
LR/SC 预留清除
ret_from_exception 中有一段看似奇怪但至关重要的代码:
REG_L a2, PT_EPC(sp)
REG_SC x0, a2, PT_EPC(sp) // dummy SC: 清除任何挂起的 LR 预留
RISC-V 的 LR(Load Reserved)/SC(Store Conditional)指令对用于实现原子操作。如果在内核态执行了 LR,然后通过 ecall 进入内核并返回用户态,挂起的预留可能被用户态的 SC 指令意外利用。因此,内核在返回前执行一个 dummy SC 来清除任何挂起的预留。
7.4.9 RISC-V 系统调用的特殊考量
sepc 的手动递增 —— 三种架构的关键差异
这是理解 RISC-V 系统调用机制最重要的细节。三种架构对返回地址的处理方式完全不同:
返回地址处理对比:
x86_64 SYSCALL:
硬件自动: RCX ← RIP (syscall 指令地址)
RIP ← IA32_LSTAR (内核入口)
硬件自动: RFLAGS ← R11 (保存标志)
返回时 SYSRET: RIP ← RCX (已指向 syscall 下一条)
ARM64 SVC:
硬件自动: ELR_EL1 ← PC + 4 (自动 +4!)
返回时 ERET: PC ← ELR_EL1 (已指向 SVC 下一条)
RISC-V ecall:
硬件自动: sepc ← PC (ecall 指令本身!)
软件 must: sepc += 4 (在 do_trap_ecall_u 中手动完成)
返回时 sret: PC ← sepc (已指向 ecall 下一条)
RISC-V 的设计选择反映了其"硬件最小化"的哲学:ecall 是通用的特权级切换指令,不仅用于系统调用,也用于 SBI 调用和其他环境调用场景。硬件不假设调用者一定想要跳过 ecall 指令,因此将返回地址递增的责任交给了软件。
sscratch 的巧妙使用
RISC-V 使用 sscratch CSR 寄存器实现了一种高效的用户/内核态判别机制:
用户态运行时:
sscratch = 用户态 tp 值 (非零)
tp = 用户态线程指针
异常入口 (csrrw tp, CSR_SCRATCH, tp):
交换后: tp = 原来在 sscratch 中的用户 tp (非零) → 保存用户寄存器
sscratch = 原来在 tp 中的值(可以是任意值)
内核态运行时:
sscratch = 0
异常入口 (csrrw tp, CSR_SCRATCH, tp):
交换后: tp = 0 → 内核态异常,不保存用户上下文
sscratch = 内核 tp 值
这种设计用一条指令就完成了来源判断,非常高效。
向量扩展状态处理
RISC-V 的 V 扩展(向量扩展)在系统调用路径中需要特殊处理:
// arch/riscv/kernel/traps.c
riscv_v_vstate_discard(regs); // 在系统调用入口丢弃向量状态
向量寄存器的状态可能非常大(例如 VLEN=256 时,32 个向量寄存器共 1KB),为了减少上下文切换开销,内核在系统调用入口丢弃向量状态,仅在需要时才保存/恢复。
7.4.10 寄存器约定总结
RISC-V 系统调用的寄存器约定遵循 RISC-V ABI(即调用约定):
┌──────────┬──────────────────────────┬──────────────────────────────────┐
│ 寄存器 │ 系统调用中的用途 │ 说明 │
├──────────┼──────────────────────────┼──────────────────────────────────┤
│ a7 (x17) │ 系统调用号 │ 用户态写入,内核读取 │
│ a0 (x10) │ 参数 1 / 返回值 │ 入口: arg1, 出口: retval │
│ a1 (x11) │ 参数 2 │ 可作为参数使用 │
│ a2 (x12) │ 参数 3 │ 可作为参数使用 │
│ a3 (x13) │ 参数 4 │ 可作为参数使用 │
│ a4 (x14) │ 参数 5 │ 可作为参数使用 │
│ a5 (x15) │ 参数 6 │ 可作为参数使用 │
│ a6 (x16) │ 参数 7(部分系统调用使用) │ 可作为参数使用 │
│ t0-t6 │ 临时寄存器 │ 被内核破坏 │
│ ra (x1) │ 返回地址 │ 内核保存/恢复 │
│ sp (x2) │ 栈指针 │ 自动切换用户/内核栈 │
│ gp (x3) │ 全局指针 │ 内核保存/恢复 │
│ tp (x4) │ 线程指针 │ 通过 sscratch 交换保存 │
│ s0-s11 │ 保存寄存器 │ 内核必须保存/恢复 │
└──────────┴──────────────────────────┴──────────────────────────────────┘
RISC-V 的系统调用最多支持 7 个参数(a0-a6),比 x86_64(6 个)和 ARM64(6 个)多一个。大多数系统调用只需要 2-4 个参数,a6 只在极少数系统调用中使用。
完整的系统调用流程图
用户态 (U-mode) 内核态 (S-mode)
──────────────── ────────────────
用户程序:
li a7, __NR_write // 系统调用号 → a7
mv a0, fd // 参数 1 → a0
mv a1, buf // 参数 2 → a1
mv a2, count // 参数 3 → a2
ecall ──────┐
│ 硬件: sepc←PC, scause←8
│ 硬件: SPP←0, SPIE←SIE, SIE←0
│ 硬件: PC←stvec
↓
handle_exception (entry.S)
│ csrrw tp, sscratch, tp
│ bnez tp, .Lsave_context
↓
保存上下文到 pt_regs
│ 保存 x1-x31, sp, sstatus, sepc, scause
│ sscratch ← 0 (标记内核态)
↓
异常分发 (excp_vect_table)
│ scause == 8 → do_trap_ecall_u
↓
do_trap_ecall_u (traps.c)
│ epc += 4 ★ 手动递增返回地址
│ orig_a0 = a0
│ a0 = -ENOSYS
│ syscall_enter_from_user_mode()
│ syscall_handler(regs, syscall)
↓
syscall_handler (syscall.h)
│ fn = sys_call_table[syscall]
│ a0 = fn(regs)
↓
syscall_exit_to_user_mode()
↓
ret_from_exception (entry.S)
│ 恢复 sstatus, sepc
│ 恢复 x1-x31
│ sscratch ← tp (为下次准备)
│ sret
↓ ──────┐
← a0 = 返回值 硬件: PC←sepc
(继续执行 ecall 后的下一条指令) 硬件: SIE←SPIE, SPP←0
硬件: 特权级→U-mode
7.4.11 三种架构系统调用机制的综合对比
┌──────────────────┬──────────────────┬──────────────────┬──────────────────┐
│ 特性 │ x86_64 │ ARM64 │ RISC-V │
├──────────────────┼──────────────────┼──────────────────┼──────────────────┤
│ 触发指令 │ SYSCALL │ SVC #0 │ ecall │
│ 专用性 │ 专用系统调用指令 │ 通用异常指令 │ 通用环境调用指令 │
│ 返回指令 │ SYSRET / IRET │ ERET │ sret │
│ 返回地址自动+4? │ 是 (RCX←next) │ 是 (ELR←PC+4) │ 否 (需手动) │
│ 系统调用号寄存器 │ rax │ x8 │ a7 │
│ 参数寄存器 │ rdi,rsi,rdx, │ x0-x5 │ a0-a5 (最多 a6) │
│ │ r10,r8,r9 │ │ │
│ 返回值寄存器 │ rax │ x0 │ a0 │
│ 异常原因编码 │ 无专用机制 │ ESR_EL1.EC=0x15 │ scause=8 │
│ 特权级模型 │ Ring 0/3 │ EL0/EL1 │ U-mode/S-mode │
│ 入口地址 │ IA32_LSTAR MSR │ VBAR_EL1+0x400 │ stvec (Direct) │
│ 硬件自动保存 │ RCX,R11,RFLAGS │ SPSR,ELR,ESR │ sepc,scause, │
│ │ │ │ sstatus.SPP/SPIE │
│ 软件保存其他寄存器 │ 全部通用寄存器 │ 全部通用寄存器 │ 全部通用寄存器 │
│ 专用快速路径 │ 是 │ 否 │ 否 │
│ 向量表大小 │ 1 个入口 │ 16 个入口 │ 1 个入口(Direct) │
│ 安全缓解复杂度 │ 最高 (复杂) │ 中等 │ 较低 │
│ 设计哲学 │ 性能优先 │ 统一机制 │ 极简主义 │
└──────────────────┴──────────────────┴──────────────────┴──────────────────┘
RISC-V 的独特优势与局限
优势:
- ecall 是所有特权级切换的统一机制,学习成本低
- 硬件实现简单,有利于验证和形式化证明
- 向量表只需要一个入口(Direct 模式),减少了微代码开销
- 未来 RISC-V 可能会添加专用的快速系统调用扩展(如 Ssccfg 或类似提案),保持前向兼容性
局限:
- ecall 不是为系统调用优化的"快速"指令,每次系统调用都需要走完整的异常处理流程
- 手动递增 sepc 是一个常见的错误来源(忘记加 4 会导致无限循环)
- Direct 模式下所有异常共享同一个入口,需要软件额外分发,增加了入口延迟
总体而言,RISC-V 的系统调用机制体现了该架构"简单即正确"的设计理念。虽然在绝对性能上可能不如 x86_64 的专用 SYSCALL 指令,但其简洁性使得内核实现更加清晰,也更容易进行形式化验证——这对于安全关键场景尤为重要。
7.5 系统调用表与参数传递
系统调用表是内核中将系统调用号映射到对应处理函数的核心数据结构。理解系统调用表的构建方式、参数传递约定、返回值处理以及 pt_regs 结构体的设计,对于深入掌握系统调用机制至关重要。本章将从跨架构的视角全面解析这些机制。
7.5.1 系统调用编号分配
每个系统调用都有一个唯一的编号(syscall number),用户程序通过此编号告诉内核需要调用哪个服务。系统调用编号的分配遵循一定的规则,不同架构有不同的分配方式。
x86_64:syscall_64.tbl 格式
x86_64 架构的系统调用号定义在 arch/x86/entry/syscalls/syscall_64.tbl 中。该文件的格式如下:
# <number> <abi> <name> <entry point> [<compat entry point> [noreturn]]
#
# abi 字段可以是:
# "common" - 64 位和 x32 ABI 共用
# "64" - 仅 64 位原生 ABI
# "x32" - 仅 x32 ABI(使用 64 位寄存器的 32 位指针)
下面是表中一些典型的条目:
# arch/x86/entry/syscalls/syscall_64.tbl
0 common read sys_read
1 common write sys_write
2 common open sys_open
3 common close sys_close
9 common mmap sys_mmap
12 common brk sys_brk
39 common getpid sys_getpid
56 common clone sys_clone
57 common fork sys_fork
59 64 execve sys_execve
60 common exit sys_exit - noreturn
335 common openat2 sys_openat2
435 common clone3 sys_clone3
462 common mseal sys_mseal
471 common rseq_slice_yield sys_rseq_slice_yield
关键要点:
- 编号从 0 开始,连续分配,到 471 结束,共计 472 个系统调用号。
- abi 字段标识该系统调用适用的 ABI 类型。
common表示 x86_64 和 x32 都使用相同的入口函数;64表示仅用于原生 64 位 ABI。 - entry point 列指定内核中的处理函数名称,实际生成的包装函数名为
__x64_sys_xxx。 - noreturn 标记(如
exit系统调用)表示此函数不会返回,编译器可以进行特殊优化。 - x32 ABI 的系统调用号从 512 开始(参见该文件第 400-404 行的历史设计说明),这是为了避免与 64 位系统调用号冲突。
ARM64:通用 unistd.h
ARM64 架构没有独立的 tbl 文件,而是直接使用 include/uapi/asm-generic/unistd.h 中的通用定义。该文件通过一系列宏来映射系统调用号和处理函数:
/* include/uapi/asm-generic/unistd.h */
#define __NR_io_setup 0
__SC_COMP(__NR_io_setup, sys_io_setup, compat_sys_io_setup)
#define __NR_io_destroy 1
__SYSCALL(__NR_io_destroy, sys_io_destroy)
...
#define __NR_rseq_slice_yield 471
__SYSCALL(__NR_rseq_slice_yield, sys_rseq_slice_yield)
#undef __NR_syscalls
#define __NR_syscalls 472
其中 __SYSCALL 和 __SC_COMP 是宏,其定义会在包含此头文件之前被覆盖为不同的行为,用于生成系统调用表数组或函数声明。__SC_COMP 用于需要兼容层(compat)的系统调用,它同时指定原生和 32 位兼容版本。
RISC-V:syscall_table.c
RISC-V 架构的系统调用表构建在 arch/riscv/kernel/syscall_table.c 中,同样基于通用定义:
/* arch/riscv/kernel/syscall_table.c */
#define __SYSCALL_WITH_COMPAT(nr, native, compat) __SYSCALL(nr, native)
#undef __SYSCALL
#define __SYSCALL(nr, call) asmlinkage long __riscv_##call(const struct pt_regs *);
#include <asm/syscall_table.h>
#undef __SYSCALL
#define __SYSCALL(nr, call) [nr] = __riscv_##call,
void * const sys_call_table[__NR_syscalls] = {
[0 ... __NR_syscalls - 1] = __riscv_sys_ni_syscall,
#include <asm/syscall_table.h>
};
这里使用了一个巧妙的 C 语言技巧:先 #include 头文件来生成函数声明(__SYSCALL 宏被定义为声明),然后重新定义 __SYSCALL 宏为初始化器,再次 #include 同一头文件来填充数组。所有未显式初始化的条目都被设为 __riscv_sys_ni_syscall(第 22 行的 GCC 扩展语法 [0 ... __NR_syscalls - 1] 实现了这一点)。
7.5.2 参数传递约定
由于系统调用需要跨越用户态和内核态的边界,参数传递不能使用普通的 C 函数调用约定。各架构都定义了自己的系统调用参数传递规则,核心区别在于使用哪些寄存器来传递系统调用号和参数。
x86_64 参数传递
x86_64 的系统调用参数传递约定与 System V AMD64 ABI 的函数调用约定有重要差异。以下是完整对照:
| 传递内容 | 系统调用约定 | 普通函数调用约定 (System V AMD64 ABI) |
|---|---|---|
| 系统调用号 | rax |
- |
| 第 1 个参数 | rdi |
rdi |
| 第 2 个参数 | rsi |
rsi |
| 第 3 个参数 | rdx |
rdx |
| 第 4 个参数 | r10 |
rcx |
| 第 5 个参数 | r8 |
r8 |
| 第 6 个参数 | r9 |
r9 |
关键差异:第 4 个参数使用 r10 而非 rcx。 这是因为 SYSCALL 指令会将返回地址保存到 rcx 中(硬件行为,无法改变),因此 rcx 不能用于传递参数。这一约定在 arch/x86/include/asm/syscall_wrapper.h 第 56-59 行的宏中体现:
/* arch/x86/include/asm/syscall_wrapper.h 第 56-59 行 */
#define SC_X86_64_REGS_TO_ARGS(x, ...) \
__MAP(x,__SC_ARGS \
,,regs->di,,regs->si,,regs->dx \
,,regs->r10,,regs->r8,,regs->r9)
参数从 struct pt_regs 中的对应字段提取。注意 pt_regs 中保存的是 r10 而不是 rcx(因为 rcx 在入口时已被 SYSCALL 覆盖)。
在内核的 entry_64.S 汇编入口中(第 109 行),PUSH_AND_CLEAR_REGS 宏按照 pt_regs 的布局保存所有寄存器:
/* arch/x86/entry/entry_64.S 第 100-109 行 */
pushq $__USER_DS /* pt_regs->ss */
pushq PER_CPU_VAR(...) /* pt_regs->sp */
pushq %r11 /* pt_regs->flags */
pushq $__USER_CS /* pt_regs->cs */
pushq %rcx /* pt_regs->ip (返回地址) */
pushq %rax /* pt_regs->orig_ax (系统调用号) */
PUSH_AND_CLEAR_REGS rax=$-ENOSYS /* 保存 r15~r8, ax, cx, dx, si, di 等 */
ARM64 参数传递
ARM64 的参数传递遵循 AAPCS64(ARM Architecture Procedure Call Standard for 64-bit)约定:
| 传递内容 | 寄存器 |
|---|---|
| 系统调用号 | x8 |
| 第 1 个参数 | x0 |
| 第 2 个参数 | x1 |
| 第 3 个参数 | x2 |
| 第 4 个参数 | x3 |
| 第 5 个参数 | x4 |
| 第 6 个参数 | x5 |
| 返回值 | x0 |
ARM64 的系统调用分发代码(arch/arm64/kernel/syscall.c 第 149-151 行)直接从 pt_regs 中获取系统调用号和参数:
void do_el0_svc(struct pt_regs *regs)
{
el0_svc_common(regs, regs->regs[8], __NR_syscalls, sys_call_table);
}
regs->regs[8] 对应 x8 寄存器。参数则通过 regs->regs[0] 到 regs->regs[5] 获取。由于 ARM64 的系统调用处理函数统一接收 struct pt_regs * 作为参数,各寄存器可以直接从 pt_regs 中索引。
RISC-V 参数传递
RISC-V 的参数传递遵循 RISC-V ABI 调用约定:
| 传递内容 | 寄存器 | ABI 名称 |
|---|---|---|
| 系统调用号 | a7 (x17) |
a7 |
| 第 1 个参数 | a0 (x10) |
a0 |
| 第 2 个参数 | a1 (x11) |
a1 |
| 第 3 个参数 | a2 (x12) |
a2 |
| 第 4 个参数 | a3 (x13) |
a3 |
| 第 5 个参数 | a4 (x14) |
a4 |
| 第 6 个参数 | a5 (x15) |
a5 |
| 返回值 | a0 |
a0 |
RISC-V 的系统调用入口函数 do_trap_ecall_u()(arch/riscv/kernel/traps.c 第 327 行)展示了参数的获取方式:
asmlinkage __visible __trap_section __no_stack_protector
void do_trap_ecall_u(struct pt_regs *regs)
{
if (user_mode(regs)) {
long syscall = regs->a7; /* 从 a7 获取系统调用号 */
regs->epc += 4; /* 跳过 ecall 指令 */
regs->orig_a0 = regs->a0; /* 保存原始 a0 值 */
regs->a0 = -ENOSYS; /* 预设错误返回值 */
syscall = syscall_enter_from_user_mode(regs, syscall);
if (syscall >= 0 && syscall < NR_syscalls) {
syscall = array_index_nospec(syscall, NR_syscalls);
syscall_handler(regs, syscall);
}
...
}
}
注意第 334 行 regs->a0 = -ENOSYS 的操作:在执行真正的系统调用处理函数之前,先将 a0(返回值寄存器)设为 -ENOSYS。如果系统调用号无效,这个默认值就会保留,用户态将收到 ENOSYS 错误。
跨架构参数传递约定对比表
下表汇总了三大架构的系统调用参数传递约定:
| 项目 | x86_64 | ARM64 | RISC-V |
|---|---|---|---|
| 系统调用号寄存器 | rax |
x8 |
a7 |
| 参数 1 | rdi |
x0 |
a0 |
| 参数 2 | rsi |
x1 |
a1 |
| 参数 3 | rdx |
x2 |
a2 |
| 参数 4 | r10(非 rcx) |
x3 |
a3 |
| 参数 5 | r8 |
x4 |
a4 |
| 参数 6 | r9 |
x5 |
a5 |
| 返回值 | rax |
x0 |
a0 |
| 触发指令 | SYSCALL |
SVC #0 |
ecall |
| 返回指令 | SYSRET |
ERET |
sret |
| 最大参数个数 | 6 | 6 | 6 |
所有架构都限制系统调用最多传递 6 个参数。超过 6 个参数的情况极为罕见(Linux 目前没有超过 6 个参数的系统调用),如果需要传递更多数据,通常通过将指向结构体的指针作为参数传入来解决。
7.5.3 返回值约定
系统调用的返回值遵循统一的约定,但在内核内部和用户态看到的表示方式有所不同。
成功返回
当系统调用成功完成时,处理函数返回一个非负值,具体含义取决于系统调用:
- 对于
read/write:返回实际读写的字节数 - 对于
open/socket等创建资源的调用:返回文件描述符(非负整数) - 对于
mmap/brk:返回内存地址 - 对于大多数无数据返回的调用(如
close、chmod):返回 0
返回值存放在架构指定的返回值寄存器中(x86_64 的 rax,ARM64 的 x0,RISC-V 的 a0)。
错误返回
当系统调用失败时,内核返回一个负的错误码(negative errno)。例如,如果权限不足,内核返回 -EACCES(即 -13)。
但在用户态看到的并不相同:C 库(glibc)将负的错误码转换为 -1 的返回值,并将正的 errno 值存储在线程局部变量 errno 中。例如:
/* 用户态视角 */
int fd = open("/etc/shadow", O_RDONLY);
if (fd == -1) {
/* open 系统调用在内核中返回 -EACCES (-13) */
/* glibc 将返回值转为 -1,设置 errno = EACCES (13) */
printf("Error: %s\n", strerror(errno)); /* 输出: Permission denied */
}
在 pt_regs 中,x86_64 的返回值保存在 regs->ax(参见 arch/x86/entry/syscall_64.c 第 63 行 regs->ax = x64_sys_call(regs, unr)),ARM64 保存在 regs->regs[0](参见 arch/arm64/include/asm/ptrace.h 中的 regs_return_value() 函数),RISC-V 保存在 regs->a0(参见 arch/riscv/include/asm/ptrace.h 第 112-115 行)。
特殊返回值:-ERESTARTSYS 系列
内核系统调用处理函数可能返回一类特殊的负值,这些值不会传递到用户态,而是由内核的信号处理框架内部使用:
| 返回值 | 含义 |
|---|---|
-ERESTARTSYS |
如果没有未处理信号,自动重启系统调用;否则返回 -EINTR 给用户态 |
-ERESTARTNOINTR |
总是自动重启系统调用(即使有信号) |
-ERESTARTNOHAND |
如果没有信号处理器,自动重启;否则返回 -EINTR |
-ERESTART_RESTARTBLOCK |
使用自定义的重启函数重启(用于 nanosleep 等可重启的阻塞调用) |
这些特殊返回值的处理发生在 syscall_exit_to_user_mode() 路径中的信号交付逻辑中。当系统调用被信号中断时,内核需要决定是重启系统调用还是返回 -EINTR 给用户态。这个决策过程是 Linux 信号处理和系统调用交互的核心部分。
以 ARM64 为例,el0_svc_common() 函数(arch/arm64/kernel/syscall.c 第 73 行)在调用 invoke_syscall() 后,通过 syscall_set_return_value() 设置返回值。后续的 syscall_trace_exit() 和信号处理逻辑会检查并处理 -ERESTART* 类返回值。
7.5.4 pt_regs 结构体 —— 用户寄存器的保存
当 CPU 从用户态切换到内核态时,需要保存用户态的所有寄存器状态,以便系统调用返回后能正确恢复执行。这个保存结构就是 struct pt_regs(process trace registers),其名称源于 ptrace 调试机制,但它是系统调用和异常处理的核心数据结构。
x86_64 的 pt_regs
x86_64 的 struct pt_regs 定义在 arch/x86/include/asm/ptrace.h 第 103-170 行:
/* arch/x86/include/asm/ptrace.h */
struct pt_regs {
/*
* C callee-saved 寄存器。
* 仅当系统调用需要完整的 pt_regs 时才保存。
*/
unsigned long r15;
unsigned long r14;
unsigned long r13;
unsigned long r12;
unsigned long bp;
unsigned long bx;
/* C callee-clobbered 寄存器。进入内核时总是保存。 */
unsigned long r11;
unsigned long r10;
unsigned long r9;
unsigned long r8;
unsigned long ax;
unsigned long cx;
unsigned long dx;
unsigned long si;
unsigned long di;
/*
* orig_ax 用于:
* - 系统调用号(syscall, sysenter, int80)
* - CPU 在陷阱和异常中存储的错误码
* - 设备中断的中断号
*/
unsigned long orig_ax;
/* IRETQ 返回帧从这里开始 */
unsigned long ip;
union { u16 cs; u64 csx; struct fred_cs fred_cs; };
unsigned long flags;
unsigned long sp;
union { u16 ss; u64 ssx; struct fred_ss fred_ss; };
};
该结构体的字段排列反映了内核栈上的实际布局。从栈底(高地址)到栈顶(低地址)依次是:callee-saved 寄存器、callee-clobbered 寄存器、orig_ax、返回帧(ip、cs、flags、sp、ss)。
orig_ax 字段是一个关键字段:对于系统调用入口,它保存系统调用号;对于硬件异常,它保存 CPU 推入的错误码。这也是为什么在 entry_64.S 中系统调用号被保存在 orig_ax 而非 ax 中的原因——ax 需要保存用户态的 rax 值,而系统调用号有专门的字段存储。
ARM64 的 pt_regs
ARM64 的 struct pt_regs 定义在 arch/arm64/include/asm/ptrace.h 第 156-172 行:
/* arch/arm64/include/asm/ptrace.h */
struct pt_regs {
union {
struct user_pt_regs user_regs;
struct {
u64 regs[31]; /* x0 ~ x30 */
u64 sp;
u64 pc;
u64 pstate;
};
};
u64 orig_x0; /* 系统调用时 a0 的原始值 */
s32 syscallno; /* 系统调用号(-1 表示非系统调用) */
u32 pmr; /* 中断优先级屏蔽寄存器 */
u64 sdei_ttbr1; /* SDEI 事件处理时的 TTBR1_EL1 */
struct frame_record_meta stackframe;
};
ARM64 的 pt_regs 有几个特殊字段:
regs[31]:存储 x0 到 x30 共 31 个通用寄存器。其中 x0-x5 用于传递系统调用参数,x8 存储系统调用号,x30(LR)存储返回地址。orig_x0:保存系统调用时 x0 的原始值。因为 x0 同时用于第一个参数和返回值,在系统调用处理过程中 x0 可能被修改(如预设为-ENOSYS),orig_x0保留了原始的第一个参数值。syscallno:显式记录系统调用号。如果值为NO_SYSCALL(-1,定义在同一文件第 95 行),则表示当前上下文不是系统调用入口(而是普通异常或中断)。这个字段使得 ARM64 可以在任何时刻判断当前是否在系统调用上下文中。
RISC-V 的 pt_regs
RISC-V 的 struct pt_regs 定义在 arch/riscv/include/asm/ptrace.h 第 15-56 行:
/* arch/riscv/include/asm/ptrace.h */
struct pt_regs {
unsigned long epc; /* 程序计数器 (mepc/sepc) */
unsigned long ra; /* x1, 返回地址 */
unsigned long sp; /* x2, 栈指针 */
unsigned long gp; /* x3, 全局指针 */
unsigned long tp; /* x4, 线程指针 */
unsigned long t0; /* x5 */
unsigned long t1; /* x6 */
unsigned long t2; /* x7 */
unsigned long s0; /* x8, 帧指针 */
unsigned long s1; /* x9 */
struct_group(a_regs,
unsigned long a0; /* x10, 参数0/返回值 */
unsigned long a1; /* x11, 参数1 */
unsigned long a2; /* x12, 参数2 */
unsigned long a3; /* x13, 参数3 */
unsigned long a4; /* x14, 参数4 */
unsigned long a5; /* x15, 参数5 */
unsigned long a6; /* x16 */
unsigned long a7; /* x17, 系统调用号 */
);
unsigned long s2; /* x18 */
...(更多 callee-saved 寄存器)
unsigned long t3; /* x28 */
unsigned long t4; /* x29 */
unsigned long t5; /* x30 */
unsigned long t6; /* x31 */
/* Supervisor/Machine CSRs */
unsigned long status; /* sstatus/mstatus */
unsigned long badaddr; /* 导致异常的地址 */
unsigned long cause; /* 异常原因 */
unsigned long orig_a0; /* 系统调用前 a0 的值 */
};
RISC-V 的 pt_regs 设计有几个显著特点:
struct_group(a_regs):将a0到a7寄存器组织为一个命名组,便于批量操作参数寄存器。epc在最前面:与 x86 和 ARM64 不同,RISC-V 将程序计数器放在结构体的起始位置。这是因为ecall指令会将返回地址保存在sepcCSR 中,内核入口代码首先将sepc保存到栈上。orig_a0:与 ARM64 的orig_x0类似,保存系统调用前a0的原始值。在do_trap_ecall_u()中(arch/riscv/kernel/traps.c第 333 行),regs->orig_a0 = regs->a0在修改a0之前保存其原始值。
跨架构 pt_regs 对比
| 特征 | x86_64 | ARM64 | RISC-V |
|---|---|---|---|
| 定义文件 | arch/x86/include/asm/ptrace.h |
arch/arm64/include/asm/ptrace.h |
arch/riscv/include/asm/ptrace.h |
| 寄存器保存方式 | 按栈布局排列 | regs[31] 数组 |
按寄存器名排列 |
| 系统调用号字段 | orig_ax |
syscallno |
a7(参数寄存器中) |
| 原始参数0保存 | 无(从 di 获取) | orig_x0 |
orig_a0 |
| PC/IP 位置 | 结构体中部(返回帧起始) | 中部(在 regs[31] 之后) | 结构体起始 |
| 特权级状态 | cs 段选择子中的 CPL |
pstate 中的模式位 |
status 中的 SPP 位 |
7.5.5 SYSCALL_DEFINEx 宏的完整展开
SYSCALL_DEFINEx 宏是连接系统调用表条目和实际处理逻辑的桥梁。它的设计经历了多次演进,当前版本的目的是确保类型安全、参数正确性检查和追踪支持。
宏定义层次
定义在 include/linux/syscalls.h 第 225-264 行:
/* include/linux/syscalls.h 第 225-236 行 */
#define SYSCALL_DEFINE1(name, ...) SYSCALL_DEFINEx(1, _##name, __VA_ARGS__)
#define SYSCALL_DEFINE2(name, ...) SYSCALL_DEFINEx(2, _##name, __VA_ARGS__)
#define SYSCALL_DEFINE3(name, ...) SYSCALL_DEFINEx(3, _##name, __VA_ARGS__)
#define SYSCALL_DEFINE4(name, ...) SYSCALL_DEFINEx(4, _##name, __VA_ARGS__)
#define SYSCALL_DEFINE5(name, ...) SYSCALL_DEFINEx(5, _##name, __VA_ARGS__)
#define SYSCALL_DEFINE6(name, ...) SYSCALL_DEFINEx(6, _##name, __VA_ARGS__)
#define SYSCALL_DEFINEx(x, sname, ...) \
SYSCALL_METADATA(sname, x, __VA_ARGS__) \
__SYSCALL_DEFINEx(x, sname, __VA_ARGS__)
__SYSCALL_DEFINEx 的通用实现
/* include/linux/syscalls.h 第 246-264 行 */
#define __SYSCALL_DEFINEx(x, name, ...) \
__diag_push(); \
__diag_ignore(GCC, 8, "-Wattribute-alias", \
"Type aliasing is used to sanitize syscall arguments");\
asmlinkage long sys##name(__MAP(x,__SC_DECL,__VA_ARGS__)) \
__attribute__((alias(__stringify(__se_sys##name)))); \
ALLOW_ERROR_INJECTION(sys##name, ERRNO); \
static inline long __do_sys##name(__MAP(x,__SC_DECL,__VA_ARGS__));\
asmlinkage long __se_sys##name(__MAP(x,__SC_LONG,__VA_ARGS__));\
asmlinkage long __se_sys##name(__MAP(x,__SC_LONG,__VA_ARGS__))\
{ \
long ret = __do_sys##name(__MAP(x,__SC_CAST,__VA_ARGS__));\
__MAP(x,__SC_TEST,__VA_ARGS__); \
__PROTECT(x, ret,__MAP(x,__SC_ARGS,__VA_ARGS__)); \
return ret; \
} \
__diag_pop(); \
static inline long __do_sys##name(__MAP(x,__SC_DECL,__VA_ARGS__))
实例展开:SYSCALL_DEFINE3(read, ...)
以 read 系统调用为例,假设其定义为:
SYSCALL_DEFINE3(read, unsigned int, fd, char __user *, buf, size_t, count)
经过宏展开后生成的代码结构如下:
/* === 第 1 层:追踪元数据(CONFIG_FTRACE_SYSCALLS 启用时) === */
static const char *types__read[] = {
"unsigned int", "char __user *", "size_t"
};
static const char *args__read[] = {
"fd", "buf", "count"
};
static struct syscall_metadata __syscall_meta__read = {
.name = "sys_read",
.syscall_nr = -1, /* 引导时填充 */
.nb_args = 3,
.types = types__read,
.args = args__read,
.enter_event = &event_enter__read,
.exit_event = &event_exit__read,
};
/* === 第 2 层:sys_read === 别名指向 __se_sys_read */
asmlinkage long sys_read(unsigned int fd, char __user *buf, size_t count)
__attribute__((alias("__se_sys_read")));
ALLOW_ERROR_INJECTION(sys_read, ERRNO);
/* === 第 3 层:__se_sys_read === 参数符号扩展 */
static inline long __do_sys_read(unsigned int fd, char __user *buf, size_t count);
asmlinkage long __se_sys_read(long fd, long buf, long count);
asmlinkage long __se_sys_read(long fd, long buf, long count)
{
long ret = __do_sys_read(
(unsigned int) fd, /* __SC_CAST: 转回原始类型 */
(char __user *) buf,
(size_t) count
);
/* __SC_TEST: 编译时类型大小检查 */
(void)BUILD_BUG_ON_ZERO(!__TYPE_IS_LL(unsigned int) && sizeof(unsigned int) > sizeof(long));
(void)BUILD_BUG_ON_ZERO(!__TYPE_IS_LL(char __user *) && sizeof(char __user *) > sizeof(long));
(void)BUILD_BUG_ON_ZERO(!__TYPE_IS_LL(size_t) && sizeof(size_t) > sizeof(long));
return ret;
}
/* === 第 4 层:__do_sys_read === 实际逻辑 */
static inline long __do_sys_read(unsigned int fd, char __user *buf, size_t count)
{
/* 实际的 read 实现代码在这里 */
...
}
x86_64 架构特有的包装器
在 x86_64 上,__SYSCALL_DEFINEx 被架构特定的版本覆盖(arch/x86/include/asm/syscall_wrapper.h)。它额外生成了 __x64_sys_read 包装器,从 struct pt_regs 中提取参数:
/* 由 arch/x86 的 __SYSCALL_DEFINEx 额外生成 */
long __x64_sys_read(const struct pt_regs *regs)
{
return __se_sys_read(regs->di, regs->si, regs->dx);
}
这样,x64_sys_call() 的 switch-case 会调用 __x64_sys_read(regs),后者从 pt_regs 中提取 rdi、rsi、rdx 作为参数传递给 __se_sys_read(),完成从寄存器到 C 函数参数的转换。
7.5.6 内核侧系统调用分发的完整流程
系统调用表是一个函数指针数组,系统调用号作为索引。下面分析各架构如何构建和使用这个表。
x86_64 的系统调用表构建
x86_64 的系统调用表构建过程涉及多个阶段:
阶段一:.tbl 文件生成头文件
构建系统通过脚本 arch/x86/entry/syscalls/syscalltbl.sh 将 syscall_64.tbl 转换为 arch/x86/include/generated/asm/syscalls_64.h,内容形如:
/* 自动生成 */
__SYSCALL(0, sys_read)
__SYSCALL(1, sys_write)
__SYSCALL(2, sys_open)
...
__SYSCALL(471, sys_rseq_slice_yield)
阶段二:syscall_64.c 使用头文件
arch/x86/entry/syscall_64.c 通过重新定义 __SYSCALL 宏,多次包含上述头文件来实现不同目的:
/* arch/x86/entry/syscall_64.c */
/* 第一次包含:生成函数声明 */
#define __SYSCALL(nr, sym) extern long __x64_##sym(const struct pt_regs *);
#include <asm/syscalls_64.h>
/* 第二次包含:构建传统的 sys_call_table 数组(用于追踪等) */
#define __SYSCALL(nr, sym) __x64_##sym,
const sys_call_ptr_t sys_call_table[] = {
#include <asm/syscalls_64.h>
};
/* 第三次包含:生成 switch-case 分发(现代方法,利用 C 编译器优化) */
#define __SYSCALL(nr, sym) case nr: return __x64_##sym(regs);
long x64_sys_call(const struct pt_regs *regs, unsigned int nr)
{
switch (nr) {
#include <asm/syscalls_64.h>
default: return __x64_sys_ni_syscall(regs);
}
}
值得注意的是,syscall_64.c 第 23-31 行的注释说明 sys_call_table[] 数组在现代内核中已不再用于实际的系统调用分发(改用 x64_sys_call() 的 switch-case),但 kernel/trace/trace_syscalls.c 等追踪子系统仍需要知道系统调用的地址。
阶段三:实际分发
do_syscall_64() 函数(第 87 行)在完成入口处理后调用 do_syscall_x64():
static __always_inline bool do_syscall_x64(struct pt_regs *regs, int nr)
{
unsigned int unr = nr;
if (likely(unr < NR_syscalls)) {
unr = array_index_nospec(unr, NR_syscalls); /* 防止 Spectre 攻击 */
regs->ax = x64_sys_call(regs, unr);
return true;
}
return false;
}
array_index_nospec() 是一个关键的安全函数——它使用条件移动指令(而不是分支)来确保即使 CPU 分支预测错误,也不会导致越界访问。这是 Spectre variant 1(边界检查绕过)漏洞的缓解措施。
ARM64 的系统调用表构建
ARM64 的系统调用表构建更为简洁(arch/arm64/kernel/sys.c 第 55-63 行):
/* arch/arm64/kernel/sys.c */
#undef __SYSCALL
#define __SYSCALL(nr, sym) [nr] = __arm64_##sym,
const syscall_fn_t sys_call_table[__NR_syscalls] = {
[0 ... __NR_syscalls - 1] = __arm64_sys_ni_syscall,
#include <asm/syscall_table_64.h>
};
ARM64 使用 syscall_fn_t 作为统一的函数指针类型(定义为 typedef long (*syscall_fn_t)(const struct pt_regs *)),所有系统调用包装器都遵循这个签名。系统调用表首先将所有条目初始化为 __arm64_sys_ni_syscall,然后通过包含头文件覆盖已实现的条目。
分发逻辑在 el0_svc_common() 中(arch/arm64/kernel/syscall.c 第 38-52 行):
static void invoke_syscall(struct pt_regs *regs, unsigned int scno,
unsigned int sc_nr,
const syscall_fn_t syscall_table[])
{
long ret;
add_random_kstack_offset();
if (likely(scno < sc_nr)) {
syscall_fn_t syscall_fn;
syscall_fn = syscall_table[array_index_nospec(scno, sc_nr)];
ret = __invoke_syscall(regs, syscall_fn);
} else {
ret = do_ni_syscall(regs, scno);
}
syscall_set_return_value(current, regs, 0, ret);
choose_random_kstack_offset(get_random_u16());
}
RISC-V 的系统调用表构建
RISC-V 的方式与 ARM64 非常相似(arch/riscv/kernel/syscall_table.c 第 21-24 行):
void * const sys_call_table[__NR_syscalls] = {
[0 ... __NR_syscalls - 1] = __riscv_sys_ni_syscall,
#include <asm/syscall_table.h>
};
分发在 do_trap_ecall_u() 中直接完成(arch/riscv/kernel/traps.c 第 342-344 行):
if (syscall >= 0 && syscall < NR_syscalls) {
syscall = array_index_nospec(syscall, NR_syscalls);
syscall_handler(regs, syscall);
}
compat_sys_call_table:32 位兼容层
在 64 位系统上运行 32 位程序时,需要使用兼容系统调用表。这是因为 32 位程序使用的数据结构布局(如 struct stat)与 64 位不同,需要专门的转换函数。
x86_64 通过 syscall_64.tbl 中的 compat 条目和 CONFIG_IA32_EMULATION 配置来支持 32 位兼容:
# arch/x86/entry/syscalls/syscall_64.tbl
5 common fsetxattr sys_fsetxattr
...
# 32 位兼容系统调用在单独的 syscall_32.tbl 中定义
ARM64 的 32 位兼容表在 arch/arm64/kernel/sys32.c 第 129 行:
const syscall_fn_t compat_sys_call_table[__NR_compat32_syscalls] = {
[0 ... __NR_compat32_syscalls - 1] = __arm64_sys_ni_syscall,
#include <asm/syscall_table_32.h>
};
do_el0_svc_compat() 函数(arch/arm64/kernel/syscall.c 第 155 行)处理来自 32 位程序的 SVC 调用,使用 regs->regs[7](而非 regs->regs[8])获取系统调用号(ARM32 约定使用 r7)。
RISC-V 的兼容表在 arch/riscv/kernel/compat_syscall_table.c 第 22 行:
void * const compat_sys_call_table[__NR_syscalls] = {
[0 ... __NR_syscalls - 1] = __riscv_sys_ni_syscall,
#include <asm/syscall_table_32.h>
};
7.5.7 系统调用追踪
Linux 内核提供了多种机制来追踪和监控系统调用的执行,这些机制在调试、性能分析和安全审计中发挥重要作用。
ptrace 系统调用追踪
ptrace(系统调用号 101)是最基础的系统调用追踪机制,被 strace、gdb 等调试工具使用。通过设置 PTRACE_SYSCALL 选项,调试器可以在每次系统调用入口和出口处暂停被追踪进程。
追踪流程:
- 调试器(tracer)调用
ptrace(PTRACE_SYSCALL, pid, ...)附加到目标进程。 - 目标进程执行系统调用时,内核在
syscall_enter_from_user_mode()中检查_TIF_SYSCALL_TRACE标志。 - 如果设置了此标志,内核在系统调用入口和出口各发送
SIGTRAP信号给 tracer。 - tracer 可以通过
PTRACE_GETREGS读取或修改pt_regs中的寄存器值,甚至可以改变系统调用号或参数。
ftrace 系统调用追踪点
内核通过 SYSCALL_METADATA 宏为每个系统调用定义了两个 tracepoint:sys_enter_<name> 和 sys_exit_<name>。这些追踪点在 include/linux/syscalls.h 第 146-176 行定义:
#define SYSCALL_TRACE_ENTER_EVENT(sname) \
static struct trace_event_call __used \
event_enter_##sname = { \
.class = &event_class_syscall_enter, \
{ .name = "sys_enter"#sname }, \
...
};
#define SYSCALL_TRACE_EXIT_EVENT(sname) \
static struct trace_event_call __used \
event_exit_##sname = { \
.class = &event_class_syscall_exit, \
{ .name = "sys_exit"#sname }, \
...
};
使用 ftrace 追踪系统调用:
# 列出可用的系统调用追踪点
ls /sys/kernel/tracing/events/syscalls/
# 追踪特定系统调用的进入和退出
echo 1 > /sys/kernel/tracing/events/syscalls/sys_enter_write/enable
echo 1 > /sys/kernel/tracing/events/syscalls/sys_exit_write/enable
cat /sys/kernel/tracing/trace
通用的系统调用追踪实现在 kernel/entry/syscall-common.c 第 10-23 行:
/* kernel/entry/syscall-common.c */
long trace_syscall_enter(struct pt_regs *regs, long syscall)
{
trace_sys_enter(regs, syscall);
/*
* tracepoint 或 BPF hook 可能修改了系统调用号。
* 重新读取。
*/
return syscall_get_nr(current, regs);
}
void trace_syscall_exit(struct pt_regs *regs, long ret)
{
trace_sys_exit(regs, ret);
}
审计子系统
Linux 审计子系统(audit subsystem)可以在系统调用入口和出口处记录审计事件。这在安全敏感环境中用于追踪关键系统调用(如文件访问、权限变更等)。
审计入口在 include/linux/entry-common.h 中的 SYSCALL_WORK_SYSCALL_AUDIT 标志控制下工作。当审计功能启用时,syscall_enter_audit() 和 syscall_exit_audit() 函数会在每个系统调用前后被调用。
seccomp-BPF 过滤
seccomp(Secure Computing Mode)是一个强大的安全机制,允许用户通过 BPF 程序来限制进程可以调用的系统调用集合。seccomp-BPF 过滤器在系统调用入口处执行,可以:
- 允许:正常执行系统调用
- 拒绝:返回错误码(如
EPERM) - 陷阱:发送
SIGSYS信号给进程 - 追踪:通知 seccomp 代理进程(user notification 机制)
seccomp 检查发生在 syscall_enter_from_user_mode_work() 函数(include/linux/entry-common.h 第 151 行)中,优先级高于 ptrace 追踪。
入口框架的统一处理
所有追踪和检查机制都通过统一的入口框架 include/linux/entry-common.h 来管理。syscall_enter_from_user_mode() 函数按以下优先级依次处理:
1. enter_from_user_mode() -- 上下文追踪、RCU 等
2. seccomp 过滤 -- 安全策略(最先执行)
3. ptrace 入口追踪 -- 调试器通知
4. ftrace tracepoint -- 追踪点触发
5. audit 入口记录 -- 审计记录
这种分层设计确保了各追踪机制按照正确的优先级执行,且代码在不同架构间保持一致。
/* include/linux/entry-common.h 第 179-191 行 */
static __always_inline long syscall_enter_from_user_mode(struct pt_regs *regs, long syscall)
{
long ret;
enter_from_user_mode(regs); /* 基本上下文切换 */
instrumentation_begin();
local_irq_enable(); /* 启用中断 */
ret = syscall_enter_from_user_mode_work(regs, syscall); /* 追踪/审计/seccomp */
instrumentation_end();
return ret;
}
返回时,syscall_exit_to_user_mode() 执行对应的退出处理,包括信号交付、seccomp 通知、审计记录等,然后执行 SYSRET/ERET/sret 返回用户态。
这套统一的入口框架是在 Linux 5.x 系列引入的,它将各架构原本分散的入口代码抽象为通用的可组合模块,大大简化了架构特定代码的维护,同时确保了安全检查和追踪逻辑的一致性。
7.6 VDSO 与 vsyscall -- 加速系统调用的用户态捷径
7.6.1 为什么需要 VDSO
在上一节中我们详细分析了 x86_64 系统调用的完整路径:SYSCALL 指令、MSR 配置、swapgs、栈切换、pt_regs 构造、系统调用分发、安全缓解措施、SYSRET 返回。即便在最快的情况下,一次完整的系统调用也需要约 100-200 纳秒的开销。
然而,有一类系统调用被极其频繁地调用,却并不真正需要内核的特权操作。典型的例子包括:
- gettimeofday() -- 获取当前时间
- clock_gettime() -- 获取高精度时钟
- getcpu() -- 获取当前 CPU 编号
- time() -- 获取秒级时间戳
这些操作本质上只需要读取内核维护的时间数据或 CPU 信息,并不涉及任何需要 Ring 0 特权的操作(如修改内核数据结构、访问设备、进行进程调度等)。如果每次调用都要走一遍完整的 SYSCALL 进入内核再 SYSRET 返回,那纯粹是浪费。
VDSO(Virtual Dynamic Shared Object,虚拟动态共享对象)正是为解决这一矛盾而生。其核心思想是:内核将一个精心编写的共享库映射到每个用户进程的地址空间中,该库包含可以直接在用户态执行的函数,这些函数通过读取内核预先映射的数据页(vvar)来获取所需信息。如此一来,gettimeofday() 等高频调用变成了普通的函数调用,完全不需要模式切换。
性能对比(大致量级):
传统系统调用 (SYSCALL): ~100-200 ns
vsyscall (遗留机制): ~20 ns (但不安全)
vDSO 函数调用: ~20-40 ns (安全且灵活)
对于 gettimeofday() 这种每秒可能被调用百万次的函数,vDSO 带来的性能提升是数量级的。
7.6.2 vsyscall -- 遗留的固定地址机制
在 vDSO 出现之前,x86_64 Linux 使用的是 vsyscall 机制。这是一种非常简单粗暴的方案:在固定的虚拟地址 0xffffffffff600000(内核地址空间的最高区域之一)映射一个 4KB 的页面,其中包含几个直接可执行的函数。
vsyscall 页面仅提供三个函数(加上一个已被移除的 vclock_gettime,共四个槽位):
地址 函数 槽位偏移
0xffffffffff600000 vgettimeofday() 0x000
0xffffffffff600400 vtime() 0x400
0xffffffffff600800 vgetcpu() 0x800
0xffffffffff600c00 (曾为 vclock_gettime, 已废弃)
vsyscall 页面的实际代码非常简单,定义在 arch/x86/entry/vsyscall/vsyscall_emu_64.S 中:
// arch/x86/entry/vsyscall/vsyscall_emu_64.S
__vsyscall_page:
mov $__NR_gettimeofday, %rax
syscall
ret
int3
.balign 1024, 0xcc
mov $__NR_time, %rax
syscall
ret
int3
.balign 1024, 0xcc
mov $__NR_getcpu, %rax
syscall
ret
int3
.balign 4096, 0xcc
注意这段代码的本质:它只是把系统调用号加载到 RAX,然后执行 syscall 指令进入内核。也就是说,vsyscall 页面中的代码最终还是要走完整的系统调用路径!在旧版本中,这些函数确实包含直接读取内核数据的内联代码,但出于安全原因已被替换为简单的系统调用包装。
vsyscall 的致命缺陷
vsyscall 存在两个根本性的安全问题:
-
固定地址破坏 ASLR:vsyscall 页面始终位于
0xffffffffff600000,这个地址在所有进程中都完全相同。攻击者可以基于此地址进行 ROP(Return-Oriented Programming)攻击,因为这段代码是固定且可执行的。地址空间布局随机化(ASLR)对此地址完全无效。 -
缺乏扩展性:4KB 的页面只够放四个函数,无法添加新功能。
vsyscall 的模拟模式
由于一些老旧的二进制程序仍然依赖 vsyscall,Linux 7.0.10 不能直接移除它,而是提供了三种模拟模式,通过内核启动参数 vsyscall= 控制:
// arch/x86/entry/vsyscall/vsyscall_64.c, 第 44-51 行
static enum { EMULATE, XONLY, NONE } vsyscall_mode __ro_after_init =
#ifdef CONFIG_LEGACY_VSYSCALL_NONE
NONE;
#elif defined(CONFIG_LEGACY_VSYSCALL_XONLY)
XONLY;
#else
#error VSYSCALL config is broken
#endif
- none(默认):vsyscall 页面完全不存在,任何访问都会导致段错误。
- xonly:页面仅可执行不可读,防止攻击者通过读取 vsyscall 页面获取固定地址 gadget。
- emulate:页面完全不存在,当程序尝试执行 vsyscall 地址的代码时,触发页面异常(page fault),内核在 page fault 处理程序中识别出这是 vsyscall 调用,进行模拟执行。
模拟模式的核心函数是 emulate_vsyscall():
// arch/x86/entry/vsyscall/vsyscall_64.c, 第 114-281 行
bool emulate_vsyscall(unsigned long error_code,
struct pt_regs *regs, unsigned long address)
{
// ... 安全检查 ...
if (vsyscall_mode == NONE) {
warn_bad_vsyscall(KERN_INFO, regs,
"vsyscall attempted with vsyscall=none");
return false;
}
vsyscall_nr = addr_to_vsyscall_nr(address);
// ... seccomp 检查 ...
switch (vsyscall_nr) {
case 0:
ret = __x64_sys_gettimeofday(regs);
break;
case 1:
ret = __x64_sys_time(regs);
break;
case 2:
ret = __x64_sys_getcpu(regs);
break;
}
regs->ax = ret;
regs->ip = caller; // 模拟 ret 指令
regs->sp += 8;
return true;
}
模拟模式下,每次 vsyscall 调用实际上触发一次 page fault(性能开销约 1-2 微秒),比直接系统调用还慢。它的存在仅仅是为了兼容老程序。
7.6.3 vDSO 机制 -- 现代的解决方案
vDSO(Virtual Dynamic Shared Object)是 vsyscall 的现代替代方案。它解决了 vsyscall 的所有问题:
- 地址随机化:vDSO 的加载地址在每次 execve 时随机化,完美支持 ASLR。
- 完整 ELF 格式:vDSO 是一个真正的 ELF 共享库(文件名为
linux-vdso.so.1),具有完整的符号表和版本信息。 - 灵活可扩展:可以轻松添加新函数,不受固定页面大小限制。
- 真正零系统调用:vDSO 函数直接在用户态读取数据,不需要 SYSCALL 指令。
vDSO 的内存布局
每个进程的地址空间中,vDSO 相关区域由以下几部分组成:
进程地址空间中的 vDSO 布局:
低地址 高地址
+----------+----------+----------+----------+----------+----------+
| vvar | vvar | vvar | vvar | (可选) | vdso |
| 数据页 | timens | rng | arch | vclock | 代码页 |
| (时间) | (时间ns) | (随机) | (架构) | (虚拟化) | (函数) |
+----------+----------+----------+----------+----------+----------+
|<-------------- __VDSO_PAGES (= 6) ------------->| | vclock | image
各页面偏移定义:
- VDSO_TIME_PAGE_OFFSET = 0 (vdso_u_time_data)
- VDSO_TIMENS_PAGE_OFFSET = 1 (时间命名空间)
- VDSO_RNG_PAGE_OFFSET = 2 (getrandom 状态)
- VDSO_ARCH_PAGES_START = 3 (架构特定数据)
- vdso 代码页紧接在 vvar 页面之后
这些页面的数量和偏移定义在 arch/x86/include/asm/vdso/vsyscall.h 中:
// arch/x86/include/asm/vdso/vsyscall.h
#define __VDSO_PAGES 6
#define VDSO_NR_VCLOCK_PAGES 2
#define VDSO_VCLOCK_PAGES_START(_b) ((_b) + (__VDSO_PAGES - VDSO_NR_VCLOCK_PAGES) * PAGE_SIZE)
#define VDSO_PAGE_PVCLOCK_OFFSET 0
#define VDSO_PAGE_HVCLOCK_OFFSET 1
7.6.4 vDSO 的构建与实现
x86_64 的 vDSO 源码结构
x86_64 的 vDSO 相关源码位于 arch/x86/entry/vdso/ 目录下:
arch/x86/entry/vdso/
+-- common/
| +-- vclock_gettime.c // 时间获取的核心实现
| +-- vgetcpu.c // getcpu 的实现
| +-- vdso-layout.lds.S // 链接脚本布局
| +-- note.S // ELF 注释段
+-- vdso64/
| +-- vclock_gettime.c // #include "common/vclock_gettime.c"
| +-- vgetcpu.c // #include "common/vgetcpu.c"
| +-- vgetrandom.c // getrandom() vDSO 实现
| +-- vdso64.lds.S // 64 位链接脚本
| +-- vsgx.S // SGX enclave 入口
+-- vdso32/
| +-- vclock_gettime.c // 32 位兼容版本
| +-- vgetcpu.c
| +-- vdso32.lds.S
+-- vma.c // 内核侧 vDSO 映射管理
+-- extable.c // vDSO 异常表
64 位 vDSO 的实际时间函数代码非常薄,仅是对通用实现的包装:
// arch/x86/entry/vdso/common/vclock_gettime.c
#include <linux/time.h>
#include <linux/kernel.h>
#include <linux/types.h>
#include <vdso/gettime.h>
#include "lib/vdso/gettimeofday.c"
int __vdso_gettimeofday(struct __kernel_old_timeval *tv, struct timezone *tz)
{
return __cvdso_gettimeofday(tv, tz);
}
int __vdso_clock_gettime(clockid_t clock, struct __kernel_timespec *ts)
{
return __cvdso_clock_gettime(clock, ts);
}
vDSO 导出的符号在链接脚本中定义:
// arch/x86/entry/vdso/vdso64/vdso64.lds.S
VERSION {
LINUX_2.6 {
global:
clock_gettime;
__vdso_clock_gettime;
gettimeofday;
__vdso_gettimeofday;
getcpu;
__vdso_getcpu;
time;
__vdso_time;
clock_getres;
__vdso_clock_getres;
getrandom;
__vdso_getrandom;
local: *;
};
}
每个导出的符号都有两个版本:带 __vdso_ 前缀的(供 C 库直接调用)和不带前缀的(作为弱符号覆盖 C 库的同名函数)。
vDSO 的构建过程
vDSO 的构建是一个特殊的编译流程。内核编译系统将 vDSO 源码编译为一个真正的 ELF 共享库 vdso64.so,然后使用 vdso2c 工具将这个 .so 文件转换为 C 数组:
编译流程:
vclock_gettime.c + vgetcpu.c + ...
|
v
vdso64.so (ELF 共享库)
|
v
vdso2c 工具转换
|
v
vdso-image-64.c (包含 const struct vdso_image vdso64_image)
|
v
编译进内核镜像
vdso2c 工具解析 ELF 文件,提取代码段、符号表地址、替代指令(alternatives)信息等,生成一个 C 结构体。这样,vDSO 的二进制代码就被嵌入到了内核镜像中,内核在创建新进程时将其映射到用户地址空间。
7.6.5 vDSO 数据页 -- vvar
vDSO 的核心在于数据页(vvar)。这是内核与用户态共享数据的通道,vDSO 函数通过读取这个页面获取时间、CPU 等信息。
数据页的核心结构定义在 include/vdso/datapage.h 中:
// include/vdso/datapage.h
struct vdso_clock {
u32 seq; // 序列锁计数器
s32 clock_mode; // 时钟源模式
u64 cycle_last; // 时钟源基准周期
u64 mask; // 时钟源掩码
u32 mult; // 乘法因子
u32 shift; // 位移量
union {
struct vdso_timestamp basetime[VDSO_BASES]; // 各时钟基准时间
struct timens_offset offset[VDSO_BASES]; // 时间命名空间偏移
};
};
struct vdso_time_data {
struct arch_vdso_time_data arch_data;
struct vdso_clock clock_data[CS_BASES]; // CS_HRES_COARSE, CS_RAW
struct vdso_clock aux_clock_data[MAX_AUX_CLOCKS];
s32 tz_minuteswest; // 时区偏移
s32 tz_dsttime; // 夏令时
u32 hrtimer_res; // 高精度定时器分辨率
u32 __unused;
} ____cacheline_aligned;
序列锁(Seqlock)机制
内核更新 vdso_time_data 时使用序列锁保证原子性。vdso_clock.seq 字段就是序列计数器:
内核更新流程:
1. seq++ (变为奇数, 表示正在更新)
2. 写入新的时间数据 (cycle_last, mult, shift, basetime 等)
3. seq++ (变为偶数, 表示更新完成)
用户态读取流程:
do {
seq = vdso_read_begin(vc); // 读取 seq 值
// 使用 smp_rmb() 确保读序
if (!vdso_get_timestamp(vd, vc, clk, &sec, &ns))
return false; // 时钟源不可用
} while (vdso_read_retry(vc, seq)); // 检查 seq 是否变化
如果用户态读取过程中 seq 发生了变化(说明内核在此期间进行了更新),则需要重新读取。这种无锁读取机制确保了在不使用任何系统调用的情况下,用户态也能安全地获取一致的时间数据。
时钟源模式
clock_mode 字段指示当前使用的时钟源类型。x86_64 上支持的时钟源定义在 arch/x86/include/asm/vdso/clocksource.h 中:
// arch/x86/include/asm/vdso/clocksource.h
#define VDSO_ARCH_CLOCKMODES \
VDSO_CLOCKMODE_TSC, \ // TSC (Time Stamp Counter)
VDSO_CLOCKMODE_PVCLOCK, \ // 半虚拟化时钟 (KVM/Xen)
VDSO_CLOCKMODE_HVCLOCK // Hyper-V 时钟
对于物理机,最常见的是 VDSO_CLOCKMODE_TSC,直接读取 CPU 的 TSC 寄存器。
7.6.6 gettimeofday() 的 vDSO 实现详解
让我们完整追踪一次通过 vDSO 执行的 gettimeofday() 调用,从用户态 C 库到最终的时间计算。
第一步:C 库解析 vDSO 符号
当应用程序调用 gettimeofday() 时,glibc/musl 等 C 库首先查找 vDSO 中的 __vdso_gettimeofday 符号。C 库通过以下步骤发现 vDSO:
- 内核在
execve()创建新进程时,通过arch_setup_additional_pages()将 vDSO 映射到进程地址空间。 - 内核在进程的辅助向量(auxiliary vector)中设置
AT_SYSINFO_EHDR条目,其值为 vDSO 的 ELF 头地址。 - 动态链接器(ld-linux.so)读取
AT_SYSINFO_EHDR,找到 vDSO 的 ELF 头。 - 解析 vDSO 的动态符号表和版本脚本,找到
__vdso_gettimeofday的地址。
映射 vDSO 的内核代码位于 arch/x86/entry/vdso/vma.c:
// arch/x86/entry/vdso/vma.c, 第 232-242 行
int arch_setup_additional_pages(struct linux_binprm *bprm, int uses_interp)
{
if (IS_ENABLED(CONFIG_X86_64)) {
if (!vdso64_enabled)
return 0;
return map_vdso(&vdso64_image, 0);
}
return load_vdso32();
}
map_vdso() 负责在进程地址空间中分配一块随机化的地址区域,映射 vvar 数据页和 vdso 代码页:
// arch/x86/entry/vdso/vma.c, 第 133-195 行
static int map_vdso(const struct vdso_image *image, unsigned long addr)
{
struct mm_struct *mm = current->mm;
unsigned long text_start;
int ret = 0;
if (mmap_write_lock_killable(mm))
return -EINTR;
addr = get_unmapped_area(NULL, addr,
image->size + __VDSO_PAGES * PAGE_SIZE, 0, 0);
// addr 是随机分配的地址
text_start = addr + __VDSO_PAGES * PAGE_SIZE;
// 映射 vdso 代码页 (可读可执行)
vma = _install_special_mapping(mm, text_start, image->size,
VM_READ|VM_EXEC|VM_MAYREAD|VM_MAYWRITE|VM_MAYEXEC|
VM_SEALED_SYSMAP,
&vdso_mapping);
// 映射 vvar 数据页 (仅可读)
vma = vdso_install_vvar_mapping(mm, addr);
// 映射 vclock 虚拟化时钟页
vma = _install_special_mapping(mm,
VDSO_VCLOCK_PAGES_START(addr),
VDSO_NR_VCLOCK_PAGES * PAGE_SIZE,
VM_READ|VM_MAYREAD|VM_IO|VM_DONTDUMP|
VM_PFNMAP|VM_SEALED_SYSMAP,
&vvar_vclock_mapping);
current->mm->context.vdso = (void __user *)text_start;
current->mm->context.vdso_image = image;
// ...
}
第二步:vDSO 函数执行
C 库调用 __vdso_gettimeofday(tv, tz),这实际上跳转到 vDSO 映射中的代码。vDSO 中的实现调用通用函数 __cvdso_gettimeofday():
// lib/vdso/gettimeofday.c, 第 362-388 行
static __maybe_unused int
__cvdso_gettimeofday_data(const struct vdso_time_data *vd,
struct __kernel_old_timeval *tv, struct timezone *tz)
{
const struct vdso_clock *vc = vd->clock_data;
if (likely(tv != NULL)) {
struct __kernel_timespec ts;
if (!do_hres(vd, &vc[CS_HRES_COARSE], CLOCK_REALTIME, &ts))
return gettimeofday_fallback(tv, tz);
tv->tv_sec = ts.tv_sec;
tv->tv_usec = (u32)ts.tv_nsec / NSEC_PER_USEC;
}
if (unlikely(tz != NULL)) {
// ... 时区信息处理 ...
}
return 0;
}
第三步:高精度时间计算
do_hres() 是 vDSO 时间计算的核心函数:
// lib/vdso/gettimeofday.c, 第 149-187 行
static __always_inline
bool do_hres(const struct vdso_time_data *vd, const struct vdso_clock *vc,
clockid_t clk, struct __kernel_timespec *ts)
{
u64 sec, ns;
u32 seq;
if (!__arch_vdso_hres_capable())
return false;
do {
while (unlikely((seq = READ_ONCE(vc->seq)) & 1)) {
// 如果 seq 为奇数,说明内核正在更新
// 检查是否是时间命名空间特殊情况
if (IS_ENABLED(CONFIG_TIME_NS) &&
vc->clock_mode == VDSO_CLOCKMODE_TIMENS)
return do_hres_timens(vd, vc, clk, ts);
cpu_relax(); // 短暂等待
}
smp_rmb(); // 读内存屏障
if (!vdso_get_timestamp(vd, vc, clk, &sec, &ns))
return false;
} while (unlikely(vdso_read_retry(vc, seq)));
vdso_set_timespec(ts, sec, ns);
return true;
}
vdso_get_timestamp() 读取 TSC 并计算纳秒级时间:
// lib/vdso/gettimeofday.c, 第 91-109 行
static __always_inline
bool vdso_get_timestamp(const struct vdso_time_data *vd, const struct vdso_clock *vc,
unsigned int clkidx, u64 *sec, u64 *ns)
{
const struct vdso_timestamp *vdso_ts = &vc->basetime[clkidx];
u64 cycles;
if (unlikely(!vdso_clocksource_ok(vc)))
return false;
cycles = __arch_get_hw_counter(vc->clock_mode, vd);
if (unlikely(!vdso_cycles_ok(cycles)))
return false;
*ns = vdso_calc_ns(vc, cycles, vdso_ts->nsec);
*sec = vdso_ts->sec;
return true;
}
第四步:读取硬件计数器
__arch_get_hw_counter() 是 x86_64 特定的实现,定义在 arch/x86/include/asm/vdso/gettimeofday.h 中:
// arch/x86/include/asm/vdso/gettimeofday.h, 第 146-170 行
static inline u64 __arch_get_hw_counter(s32 clock_mode,
const struct vdso_time_data *vd)
{
if (likely(clock_mode == VDSO_CLOCKMODE_TSC))
return (u64)rdtsc_ordered() & S64_MAX;
// 其他时钟源 (pvclock, hvclock) 仅在虚拟化环境中使用
// ...
}
rdtsc_ordered() 在用户态编译为 rdtscp 或带有 lfence/mfence 的 rdtsc 指令,直接读取 CPU 的 Time Stamp Counter,无需进入内核。
第五步:纳秒计算
x86_64 使用了定制的 vdso_calc_ns() 函数,考虑了 TSC 跨 socket 可能出现微小偏差的特殊情况:
// arch/x86/include/asm/vdso/gettimeofday.h, 第 211-239 行
static __always_inline u64 vdso_calc_ns(const struct vdso_clock *vc,
u64 cycles, u64 base)
{
u64 delta = cycles - vc->cycle_last;
if (unlikely(delta > vc->max_cycles)) {
if (delta & (1ULL << 62))
return base >> vc->shift; // 负向偏移: 使用基准值
// 乘法溢出: 使用多精度运算
return mul_u64_u32_add_u64_shr(delta & S64_MAX, vc->mult, base, vc->shift);
}
return ((delta * vc->mult) + base) >> vc->shift;
}
最终的时间计算公式为:
ns = ((cycles - cycle_last) * mult + basetime_nsec) >> shift
时间(秒) = basetime_sec + ns / 1000000000
其中:
cycles是当前 TSC 读数(通过rdtsc获取)cycle_last是内核上次更新时记录的 TSC 基准值mult和shift是 TSC 频率到纳秒的转换参数basetime_nsec和basetime_sec是内核上次更新时的基准时间
整个计算过程完全在用户态完成,没有任何系统调用!
完整流程总结:
应用程序:
gettimeofday(&tv, NULL);
C 库 (glibc/musl):
通过 AT_SYSINFO_EHDR 找到 vDSO
解析 ELF 符号表找到 __vdso_gettimeofday
直接调用 vDSO 函数 (普通函数调用,无模式切换)
vDSO (__vdso_gettimeofday):
__cvdso_gettimeofday()
--> do_hres()
--> seq = vc->seq (读取序列锁)
--> cycles = rdtsc_ordered() (读 TSC, 纯用户态指令)
--> ns = vdso_calc_ns(vc, cycles, basetime_nsec)
= ((cycles - cycle_last) * mult + base) >> shift
--> sec = basetime_sec
--> 检查 seq 是否变化 (确保一致性)
--> tv->tv_sec = sec
--> tv->tv_usec = ns / 1000
return 0;
整个过程: 零系统调用! 纯用户态执行!
7.6.7 AT_SYSINFO_EHDR 辅助向量
内核如何通知用户态 vDSO 的位置?答案是通过 ELF 辅助向量(auxiliary vector)。
在 execve() 系统调用执行新程序时,内核在用户态栈上构建一个辅助向量,其中包含各种由内核传递给用户态的信息。AT_SYSINFO_EHDR 条目的值就是 vDSO 的 ELF 头地址。
动态链接器(ld-linux.so)在初始化时扫描辅助向量,发现 AT_SYSINFO_EHDR 后,解析 vDSO 的 ELF 头部结构:
辅助向量 (位于用户态栈上):
AT_PHDR : 程序头表地址
AT_PHENT : 程序头条目大小
AT_PHNUM : 程序头条目数
AT_PAGESZ : 页面大小 (4096)
AT_ENTRY : 程序入口点
AT_BASE : 解释器 (ld-linux.so) 基地址
AT_SYSINFO_EHDR : vDSO ELF 头地址 <-- 关键!
...
glibc 在 _dl_vdso_vsym() 函数中使用此地址查找 vDSO 符号。查找过程与加载普通共享库类似:解析 ELF 头 -> 定位动态段 -> 查找符号表 -> 匹配版本信息 -> 返回函数地址。
7.6.8 其他架构的 vDSO
vDSO 不是 x86_64 独有的机制,Linux 在多种架构上都有 vDSO 实现。其核心思想相同,但实现细节因架构而异。
ARM64 的 vDSO
ARM64 的 vDSO 源码位于 arch/arm64/kernel/vdso/ 目录:
arch/arm64/kernel/vdso/
+-- vgettimeofday.c // 时间获取实现
+-- vgetrandom.c // getrandom 实现
+-- vdso.lds.S // 链接脚本
+-- sigreturn.S // 信号返回
+-- note.S
ARM64 使用 cntvct_el0(虚拟计数器)作为用户态可读的硬件计数器,等价于 x86 的 TSC。ARM64 的 vDSO 代码通过 isb(指令同步屏障)+ mrs %0, cntvct_el0 读取计数器。
RISC-V 的 vDSO
RISC-V 的 vDSO 源码位于 arch/riscv/kernel/vdso/ 目录:
arch/riscv/kernel/vdso/
+-- vgettimeofday.c // 时间获取
+-- getcpu.S // getcpu (使用 rdtime 指令)
+-- hwprobe.c // 硬件特性探测
+-- vdso.lds.S // 链接脚本
+-- rt_sigreturn.S // 信号返回
RISC-V 使用 rdtime 指令读取 time CSR(控制状态寄存器),获取高精度时间戳。
架构间共享的通用代码
各架构的 vDSO 时间函数实现都共享 lib/vdso/gettimeofday.c 中的通用代码。这个文件包含了 do_hres()、do_coarse()、__cvdso_gettimeofday()、__cvdso_clock_gettime() 等核心函数的实现。各架构只需要提供:
__arch_get_hw_counter()-- 如何读取硬件计数器vdso_calc_ns()-- 如何从硬件周期计算纳秒(可选,有默认实现)clock_gettime_fallback()等 -- 回退到系统调用的函数- 链接脚本和 vvar 页面定义
7.6.9 getcpu() 的 vDSO 实现
除了时间函数外,getcpu() 也是 vDSO 加速的重要函数。它用于获取当前线程运行的 CPU 编号和 NUMA 节点号,在 NUMA 优化中非常关键。
x86_64 的 vDSO getcpu 实现位于 arch/x86/entry/vdso/common/vgetcpu.c:
// arch/x86/entry/vdso/common/vgetcpu.c
notrace long
__vdso_getcpu(unsigned *cpu, unsigned *node, void *unused)
{
vdso_read_cpunode(cpu, node);
return 0;
}
vdso_read_cpunode() 是一个高效的实现,利用 x86 的 rdtscp 指令或 lsl(Load Segment Limit)指令直接从硬件获取 CPU 编号,无需系统调用:
- 如果 CPU 支持
rdtscp,则通过rdtscp指令的第三输出寄存器(通常为 RCX)获取MSR_TSC_AUX中的 CPU/节点编码。内核在setup_getcpu()中为每个 CPU 设置了这个 MSR。 - 如果 CPU 不支持
rdtscp,则通过lsl指令读取 GDT 中GDT_ENTRY_CPUNODE段描述符的 limit 字段,其中编码了 CPU/节点信息。
7.6.10 vDSO 数据页的内核更新
vDSO 能在用户态直接读取时间数据的前提是内核必须定期更新 vvar 数据页。这一更新操作由时间保持(timekeeping)子系统负责。
内核中维护了 vdso_k_time_data 指针,指向内核空间中的 vvar 数据页:
// lib/vdso/datastore.c
static union {
struct vdso_time_data data;
u8 page[PAGE_SIZE];
} vdso_time_data_store __page_aligned_data;
struct vdso_time_data *vdso_k_time_data = &vdso_time_data_store.data;
由于 vvar 页面在用户态和内核态使用相同的物理页帧(通过 PFN 映射),内核写入 vdso_k_time_data 的数据会立即反映到用户态的 vvar 映射中。更新的关键步骤:
- 递增序列锁(
seq++,变为奇数) - 更新
cycle_last(当前 TSC 值) - 更新
basetime[](各时钟的基准秒数和纳秒数) - 更新
mult和shift(TSC 到纳秒的转换参数) - 递增序列锁(
seq++,变为偶数)
这个更新通常在定时器中断(tick)处理中进行,更新频率约为每秒数次到数百次(取决于配置和时钟源精度)。
7.6.11 vDSO 的回退机制
vDSO 并非在所有情况下都能成功。以下情况需要回退到真正的系统调用:
-
时钟源不可用:如果
clock_mode == VDSO_CLOCKMODE_NONE(例如时钟源尚未初始化或被禁用),vDSO 函数无法工作,需要通过gettimeofday_fallback()发起真正的 SYSCALL。 -
序列锁持续争用:如果内核持续更新 vvar(极端情况),用户态可能反复读取到奇数 seq。虽然实际上几乎不会发生,但回退机制确保了正确性。
-
虚拟化环境:在某些虚拟化环境中,TSC 可能不可靠,内核会切换到 pvclock 或 hvclock 模式。vDSO 可以直接读取这些虚拟化时钟的数据页,但如果数据页不可用,则回退到系统调用。
-
时间命名空间:如果进程位于非初始时间命名空间中,vvar 页面会被特殊处理,vDSO 函数检测到后从命名空间专用的数据页读取。
回退函数的实现使用了精心设计的内联 SYSCALL 指令:
// arch/x86/include/asm/vdso/gettimeofday.h, 第 57-68 行
static __always_inline
long clock_gettime_fallback(clockid_t _clkid, struct __kernel_timespec *_ts)
{
return VDSO_SYSCALL2(clock_gettime,64,_clkid,_ts);
}
static __always_inline
long gettimeofday_fallback(struct __kernel_old_timeval *_tv,
struct timezone *_tz)
{
return VDSO_SYSCALL2(gettimeofday,,_tv,_tz);
}
这些回退函数使用 VDSO_SYSCALL2 宏,它展开为内联的 SYSCALL 指令序列,直接在 vDSO 代码中发起系统调用。
7.6.12 性能对比与总结
综合以上分析,我们可以对三种时间获取机制做一个定量的性能对比:
机制 延迟(ns) 安全性 ASLR 可扩展性
─────────────────────────────────────────────────────────────────
INT 0x80 系统调用 ~200-300 安全 N/A 好
SYSCALL 系统调用 ~100-200 安全 N/A 好
vsyscall (固定地址) ~20-50 不安全 无 差 (4个函数)
vsyscall (emulate 模式) ~1000-2000 安全 无 差
vDSO 函数调用 ~20-40 安全 有 好
─────────────────────────────────────────────────────────────────
vDSO 的性能优势来源于:
- 零特权级切换(无 SYSCALL/SYSRET 开销)
- 零栈切换(无 pt_regs 构造/恢复)
- 零安全缓解措施(无 IBRS/PTI 等开销)
- 直接的用户态内存读取(rdtsc + 数学运算)
vDSO 是操作系统设计中"用巧妙的机制替代昂贵的通用路径"的经典案例。它通过内核与用户态之间的数据共享,将原本需要特权操作的任务转化为纯用户态计算,在不牺牲安全性的前提下获得了数量级的性能提升。
Linux 7.0.10 的 vDSO 实现已经相当成熟,支持 gettimeofday、clock_gettime、clock_getres、time、getcpu、getrandom(新增)等多种函数。未来随着更多只读内核数据的共享需求出现,vDSO 可能会继续扩展其功能范围。