Linux内核分析之基础知识-06
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 行)后,内核的汇编入口代码负责:
1 | SYM_CODE_START(entry_SYSCALL_64) |
这段汇编代码的核心任务是:在内核栈上构建 struct pt_regs 结构体,将用户态的所有通用寄存器值保存在其中,然后调用 C 语言函数 do_syscall_64() 进行后续处理。
第四阶段:系统调用分发
do_syscall_64() 函数(定义在 arch/x86/entry/syscall_64.c 第 87 行)是 x86_64 系统调用的核心分发器:
1 | __visible noinstr bool do_syscall_64(struct pt_regs *regs, int nr) |
其中 do_syscall_x64()(同一文件第 53 行)执行具体的系统调用号查找和分发:
1 | static __always_inline bool do_syscall_x64(struct pt_regs *regs, int nr) |
x64_sys_call() 函数(第 35 行)使用一个巨大的 switch 语句来索引系统调用:
1 | long x64_sys_call(const struct pt_regs *regs, unsigned int nr) |
第五阶段:执行系统调用处理函数
系统调用处理函数最终被执行。以 write 系统调用为例,内核中的实现大致如下(定义在 fs/read_write.c):
1 | SYSCALL_DEFINE3(write, unsigned int, fd, const char __user *, buf, |
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 中,该文件末尾定义了总数量:
1 | /* include/uapi/asm-generic/unistd.h 第 866-867 行 */ |
各架构的实际数量:
| 架构 | 系统调用数量 | 系统调用表定义文件 |
|---|---|---|
| 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 行:
1 | asmlinkage long sys_ni_syscall(void) |
sys_ni_syscall(”ni” 表示 “not implemented”)简单地返回 -ENOSYS 错误码,告诉用户程序该系统调用未实现。kernel/sys_ni.c 文件通过 COND_SYSCALL 宏为所有可选的、未实现的系统调用提供默认的弱符号实现:
1 |
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 行。
宏的层次结构如下:
1 | SYSCALL_DEFINE1(name, ...) ─┐ |
以 SYSCALL_DEFINE3(write, unsigned int, fd, const char __user *, buf, size_t, count) 为例,经过完整的宏展开后会生成以下代码层次:
1 | /* 1. 追踪元数据(当 CONFIG_FTRACE_SYSCALLS 启用时) */ |
这种多层包装设计有三个关键目的:
- 参数符号扩展:
__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:
1 | // arch/x86/kernel/cpu/common.c, 第 2128-2150 行 |
SYSCALL/SYSRET – 64 位主流机制
AMD 在 AMD64 架构中引入了 SYSCALL/SYSRET 指令对,Intel 在其 64 位实现中也采纳了这一方案。这是目前 x86_64 上 Linux 内核使用的首要系统调用机制。其核心设计理念是:硬件以最小代价完成从 Ring 3 到 Ring 0 的切换 – 不压栈、不查表,仅通过 MSR 直接加载新的 CS/RIP/SS,同时将旧值保存到约定寄存器中。一次 SYSCALL 的硬件开销可低至约 30-50 个时钟周期。
三者开销对比(大致量级):
1 | INT 0x80: ~200-300 周期 (需要 IDT 查找、特权级检查、完整压栈) |
7.2.2 SYSCALL 指令的硬件行为
当 CPU 执行 SYSCALL 指令时,硬件自动完成以下操作。这是 CPU 微代码级别的行为,不可由软件修改:
1 | SYSCALL 指令执行流程(硬件行为): |
关键的寄存器映射关系(x86_64 系统调用约定):
1 | +----------+------------------------------------------+ |
注意 R10 被用作第四个参数而非 RCX,因为 RCX 已被硬件用于保存返回地址。但在 x86_64 C 调用约定中,第四个参数应由 RCX 传递,因此内核入口代码需要将 R10 复制到 RCX(PUSH_AND_CLEAR_REGS 宏中处理)。
7.2.3 SYSRET 指令的硬件行为
SYSRET 是 SYSCALL 的逆操作,用于从内核态快速返回到用户态:
1 | SYSRETQ 指令执行流程(64 位模式返回): |
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 中:
1 | // arch/x86/include/asm/msr-index.h |
MSR_STAR(0xC0000081)的位域布局:
1 | 63 48 47 32 31 0 |
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:
1 | // arch/x86/kernel/cpu/common.c, 第 2265-2314 行 |
注意 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 中:
1 | // arch/x86/entry/entry_64.S, 第 87-170 行 |
这段代码的第一步是 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 结构体(进程上下文寄存器帧):
1 | /* Construct struct pt_regs on stack */ |
PUSH_AND_CLEAR_REGS 宏保存其余的通用寄存器到 pt_regs 中,并将 RAX 初始化为 -ENOSYS(无效系统调用号的默认返回值)。pt_regs 结构在内核栈上的布局如下:
1 | 内核栈布局 (从高地址到低地址): |
这段保存完成后,调用实际的 C 分发函数:
1 | /* IRQs are off. */ |
此处 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 中:
1 | // arch/x86/entry/syscall_64.c, 第 87-141 行 |
该函数返回 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):实际的系统调用分发,其实现如下:
1 | // arch/x86/entry/syscall_64.c, 第 53-67 行 |
这里的关键点:
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):
1 | // arch/x86/entry/entry_64.S, 第 130-170 行 |
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 的条件不满足时:
1 | // arch/x86/entry/entry_64.S, 第 559-577 行 |
IRETQ 从栈上依次弹出 RIP、CS、RFLAGS、RSP、SS 五个值,恢复完整的用户态上下文。IRETQ 还会自动恢复 RFLAGS 中的 IF 位,从而重新启用中断。
7.2.8 中断标志(IF)的处理
中断管理是系统调用路径中极其重要的一环。整个流程中 IF(Interrupt Flag)的变化如下:
1 | 时间线: 用户态 --> SYSCALL --> 内核处理 --> 返回 --> 用户态 |
在 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:
1 | // arch/x86/entry/entry_64_compat.S, 第 183-285 行 |
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 返回:
1 | sysret32_from_system_call: |
注意 sysretl 返回到 32 位兼容模式(CS = __USER32_CS),而 sysretq 返回到 64 位模式(CS = __USER_CS)。两者都使用 SWAPGS + CR3 切换 + SYSRET 的模式,但操作的目标模式不同。
7.2.10 完整的系统调用流程图
将上述所有内容整合,一次完整的 64 位系统调用流程如下:
1 | 用户态程序: |
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 处理器硬件会自动执行一系列操作。这些操作完全由微代码实现,不涉及任何软件干预。下面逐步展示硬件的每一步行为:
1 | 用户态 (EL0) 执行 SVC #0 指令后,硬件自动完成: |
ESR_EL1 寄存器详解
ESR_EL1(Exception Syndrome Register)是 ARM64 异常处理的核心寄存器之一。它记录了异常的原因,软件通过解析该寄存器来确定应采取的处理动作。对于 SVC 指令产生的异常,ESR_EL1 的关键字段如下:
1 | ESR_EL1 寄存器布局(SVC 异常时): |
在 Linux 内核源码中,ESR 的 EC 值定义在 arch/arm64/include/asm/esr.h 中:
1 | // arch/arm64/include/asm/esr.h |
内核通过 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”的矩阵排列:
1 | 异常向量表布局(VBAR_EL1 指向基址): |
当 SVC 从 EL0 触发时,CPU 跳转到 VBAR_EL1 + 0x400(Synchronous exception from EL0 using SP0)。这是 ARM64 系统调用的入口点。
Linux 内核中的向量表实现
在 arch/arm64/kernel/entry.S 中,向量表通过宏展开构建:
1 | // arch/arm64/kernel/entry.S (节选) |
kernel_ventry 宏
kernel_ventry 宏是每个向量入口的展开模板,负责在异常入口处分配栈空间并检测栈溢出:
1 | // arch/arm64/kernel/entry.S (节选) |
对于从 EL0 来的 64 位同步异常,kernel_ventry 最终跳转到 el0t_64_sync 标号。
7.3.4 入口路径 —— 从异常到系统调用处理
entry_handler 宏展开
每个异常类型通过 entry_handler 宏定义其处理函数:
1 | // arch/arm64/kernel/entry.S |
这展开为 el0t_64_sync,它调用 kernel_entry 保存上下文,然后跳转到 C 函数 el0t_64_sync_handler。
kernel_entry 宏 —— 保存完整上下文
kernel_entry 宏负责将所有通用寄存器保存到内核栈上的 pt_regs 结构中:
1 | // arch/arm64/kernel/entry.S (节选) |
pt_regs 结构体
pt_regs 是 ARM64 内核中最关键的数据结构之一,它保存在异常入口处被完整保存的处理器状态:
1 | // arch/arm64/include/asm/ptrace.h |
C 层分发:el0t_64_sync_handler
从汇编进入 C 代码后,el0t_64_sync_handler 读取 ESR_EL1 并根据异常类别分发:
1 | // arch/arm64/kernel/entry-common.c |
当 ESR_ELx_EC(esr) == 0x15 时,进入 el0_svc() 函数处理系统调用。
7.3.5 el0_svc_common() —— 系统调用的核心分发
el0_svc 函数
1 | // arch/arm64/kernel/entry-common.c |
do_el0_svc 与 el0_svc_common
1 | // arch/arm64/kernel/syscall.c |
这里 regs->regs[8] 就是用户态通过 x8 寄存器传递的系统调用号。el0_svc_common 是整个系统调用分发的核心:
1 | // arch/arm64/kernel/syscall.c |
invoke_syscall —— 调用具体系统调用
1 | // arch/arm64/kernel/syscall.c |
__invoke_syscall 非常简洁,直接调用系统调用函数,将整个 pt_regs 结构作为参数传入:
1 | static long __invoke_syscall(struct pt_regs *regs, syscall_fn_t syscall_fn) |
7.3.6 ARM64 系统调用表
ARM64 使用 asm-generic 通用系统调用表。系统调用表在 arch/arm64/kernel/sys.c 中定义:
1 | // arch/arm64/kernel/sys.c |
系统调用表的关键设计特点:
- 基于 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 中,例如:
1 | // include/uapi/asm-generic/unistd.h (节选) |
7.3.7 返回路径 —— 从内核回到用户态
系统调用处理完成后,控制流沿以下路径返回用户态:
1 | 系统调用返回路径: |
ret_to_user
1 | // arch/arm64/kernel/entry.S |
kernel_exit 宏
kernel_exit 宏完成恢复上下文并执行异常返回的全部工作:
1 | // arch/arm64/kernel/entry.S (节选) |
ERET 指令
ERET(Exception Return)是 ARM64 异常返回的核心指令。它的硬件行为是 SVC 硬件行为的逆过程:
1 | ERET 指令的硬件行为: |
ERET 指令执行后,处理器自动回到 EL0,使用恢复后的 PSTATE 和用户态栈指针,从 SVC 指令的下一条指令继续执行。
7.3.8 ARM64 系统调用的安全扩展
ARM64 Linux 内核在系统调用路径上实现了多层安全缓解措施:
Spectre BHB(Branch History Injection)缓解
ARM64 内核支持多种 Spectre 变体的缓解。在异常向量入口处,内核实现了分支目标缓冲区(BTB)和分支历史缓冲区(BHB)的清理:
1 | // arch/arm64/kernel/entry.S (节选) |
在 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):
1 | ┌──────────┬──────────────────────────────┬──────────────────────────┐ |
系统调用号约定
- 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 处理器硬件自动执行以下操作:
1 | 用户态 (U-mode) 执行 ecall 指令后,硬件自动完成: |
scause 寄存器的异常码
在 S-mode 下,scause 寄存器定义了所有异常和中断的原因。异常码的最高位(MSB)区分中断 (1) 和异常 (0):
1 | // arch/riscv/include/asm/csr.h |
scause == 8 表示来自 U-mode 的 ecall,这就是用户态系统调用的异常码。
sstatus 寄存器的关键字段
1 | sstatus 寄存器在 ecall 异常时的关键字段: |
在 arch/riscv/include/asm/csr.h 中,这些字段定义为:
1 |
7.4.3 陷阱向量设置
RISC-V 的陷阱向量由 CSR 寄存器 stvec(Supervisor Trap Vector)控制。stvec 有两种工作模式:
1 | stvec 寄存器布局: |
Linux 内核使用 Direct 模式,所有异常(包括 ecall 系统调用)都跳转到同一个入口地址。
陷阱向量的初始化
在内核启动的早期阶段,stvec 被设置为 handle_exception 的地址:
1 | // arch/riscv/kernel/head.S |
这段代码在内核启动(_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 中。下面逐步分析其关键逻辑:
判断来源:用户态还是内核态
1 | // arch/riscv/kernel/entry.S |
这段代码利用了一个精妙的技巧:
- 用户态运行时,
sscratch保存了用户态的tp(thread pointer)值(非零) - 内核态运行时,
sscratch被设为 0 csrrw tp, CSR_SCRATCH, tp原子地交换 tp 和 sscratch 的值- 交换后如果 tp 非零,说明来自用户态;如果为零,说明来自内核态
保存上下文到 pt_regs
1 | // arch/riscv/kernel/entry.S (节选) |
异常分发
保存完上下文后,handle_exception 根据 scause 分发到不同的处理函数:
1 | // arch/riscv/kernel/entry.S (节选) |
异常向量表 (excp_vect_table)
内核使用一个函数指针数组来分发异常,索引为异常原因码:
1 | // arch/riscv/kernel/entry.S (rodata section) |
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 中:
1 | // arch/riscv/kernel/traps.c |
这段代码中有几个关键点值得深入分析:
sepc += 4 —— 手动递增返回地址
1 | regs->epc += 4; |
这是 RISC-V 系统调用路径中最容易忽略但又最关键的步骤。ecall 指令将当前 PC(即 ecall 指令本身)保存到 sepc,而不像 ARM64 的 SVC 那样自动保存 PC+4。如果内核不手动递增 sepc,执行 sret 返回时会重新执行 ecall,导致无限循环。
默认返回 -ENOSYS
1 | regs->a0 = -ENOSYS; |
在查找和调用系统调用之前,先将 a0 设为 -ENOSYS。如果系统调用号无效(越界),a0 将保持此默认值,用户态收到 -ENOSYS 错误。这是一种防御性编程策略。
syscall_handler 内联函数
1 | // arch/riscv/include/asm/syscall.h |
与 ARM64 类似,RISC-V 的系统调用函数也接受 pt_regs 作为参数,从 pt_regs 中提取系统调用参数。
7.4.6 RISC-V 系统调用表
RISC-V 使用 asm-generic 通用系统调用表,定义在 arch/riscv/kernel/syscall_table.c 中:
1 | // arch/riscv/kernel/syscall_table.c |
与 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 中,部分常用系统调用如下:
1 | 系统调用号 名称 函数 |
7.4.7 RISC-V pt_regs 结构体
RISC-V 的 pt_regs 结构体定义在 arch/riscv/include/asm/ptrace.h 中,与 x86_64 和 ARM64 有显著不同:
1 | // arch/riscv/include/asm/ptrace.h |
值得注意的是,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 返回用户态:
1 | // arch/riscv/kernel/entry.S |
sret 指令
sret(Supervisor Return)是 RISC-V 从 S-mode 返回的低特权级的指令。其硬件行为是 ecall 的精确逆过程:
1 | sret 指令的硬件行为: |
LR/SC 预留清除
ret_from_exception 中有一段看似奇怪但至关重要的代码:
1 | REG_L a2, PT_EPC(sp) |
RISC-V 的 LR(Load Reserved)/SC(Store Conditional)指令对用于实现原子操作。如果在内核态执行了 LR,然后通过 ecall 进入内核并返回用户态,挂起的预留可能被用户态的 SC 指令意外利用。因此,内核在返回前执行一个 dummy SC 来清除任何挂起的预留。
7.4.9 RISC-V 系统调用的特殊考量
sepc 的手动递增 —— 三种架构的关键差异
这是理解 RISC-V 系统调用机制最重要的细节。三种架构对返回地址的处理方式完全不同:
1 | 返回地址处理对比: |
RISC-V 的设计选择反映了其”硬件最小化”的哲学:ecall 是通用的特权级切换指令,不仅用于系统调用,也用于 SBI 调用和其他环境调用场景。硬件不假设调用者一定想要跳过 ecall 指令,因此将返回地址递增的责任交给了软件。
sscratch 的巧妙使用
RISC-V 使用 sscratch CSR 寄存器实现了一种高效的用户/内核态判别机制:
1 | 用户态运行时: |
这种设计用一条指令就完成了来源判断,非常高效。
向量扩展状态处理
RISC-V 的 V 扩展(向量扩展)在系统调用路径中需要特殊处理:
1 | // arch/riscv/kernel/traps.c |
向量寄存器的状态可能非常大(例如 VLEN=256 时,32 个向量寄存器共 1KB),为了减少上下文切换开销,内核在系统调用入口丢弃向量状态,仅在需要时才保存/恢复。
7.4.10 寄存器约定总结
RISC-V 系统调用的寄存器约定遵循 RISC-V ABI(即调用约定):
1 | ┌──────────┬──────────────────────────┬──────────────────────────────────┐ |
RISC-V 的系统调用最多支持 7 个参数(a0-a6),比 x86_64(6 个)和 ARM64(6 个)多一个。大多数系统调用只需要 2-4 个参数,a6 只在极少数系统调用中使用。
完整的系统调用流程图
1 | 用户态 (U-mode) 内核态 (S-mode) |
7.4.11 三种架构系统调用机制的综合对比
1 | ┌──────────────────┬──────────────────┬──────────────────┬──────────────────┐ |
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 中。该文件的格式如下:
1 | # <number> <abi> <name> <entry point> [<compat entry point> [noreturn]] |
下面是表中一些典型的条目:
1 | # arch/x86/entry/syscalls/syscall_64.tbl |
关键要点:
- 编号从 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 中的通用定义。该文件通过一系列宏来映射系统调用号和处理函数:
1 | /* include/uapi/asm-generic/unistd.h */ |
其中 __SYSCALL 和 __SC_COMP 是宏,其定义会在包含此头文件之前被覆盖为不同的行为,用于生成系统调用表数组或函数声明。__SC_COMP 用于需要兼容层(compat)的系统调用,它同时指定原生和 32 位兼容版本。
RISC-V:syscall_table.c
RISC-V 架构的系统调用表构建在 arch/riscv/kernel/syscall_table.c 中,同样基于通用定义:
1 | /* arch/riscv/kernel/syscall_table.c */ |
这里使用了一个巧妙的 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 行的宏中体现:
1 | /* arch/x86/include/asm/syscall_wrapper.h 第 56-59 行 */ |
参数从 struct pt_regs 中的对应字段提取。注意 pt_regs 中保存的是 r10 而不是 rcx(因为 rcx 在入口时已被 SYSCALL 覆盖)。
在内核的 entry_64.S 汇编入口中(第 109 行),PUSH_AND_CLEAR_REGS 宏按照 pt_regs 的布局保存所有寄存器:
1 | /* arch/x86/entry/entry_64.S 第 100-109 行 */ |
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 中获取系统调用号和参数:
1 | void do_el0_svc(struct pt_regs *regs) |
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 行)展示了参数的获取方式:
1 | asmlinkage __visible __trap_section __no_stack_protector |
注意第 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 中。例如:
1 | /* 用户态视角 */ |
在 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 行:
1 | /* arch/x86/include/asm/ptrace.h */ |
该结构体的字段排列反映了内核栈上的实际布局。从栈底(高地址)到栈顶(低地址)依次是: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 行:
1 | /* arch/arm64/include/asm/ptrace.h */ |
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 行:
1 | /* arch/riscv/include/asm/ptrace.h */ |
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 行:
1 | /* include/linux/syscalls.h 第 225-236 行 */ |
__SYSCALL_DEFINEx 的通用实现
1 | /* include/linux/syscalls.h 第 246-264 行 */ |
实例展开:SYSCALL_DEFINE3(read, …)
以 read 系统调用为例,假设其定义为:
1 | SYSCALL_DEFINE3(read, unsigned int, fd, char __user *, buf, size_t, count) |
经过宏展开后生成的代码结构如下:
1 | /* === 第 1 层:追踪元数据(CONFIG_FTRACE_SYSCALLS 启用时) === */ |
x86_64 架构特有的包装器
在 x86_64 上,__SYSCALL_DEFINEx 被架构特定的版本覆盖(arch/x86/include/asm/syscall_wrapper.h)。它额外生成了 __x64_sys_read 包装器,从 struct pt_regs 中提取参数:
1 | /* 由 arch/x86 的 __SYSCALL_DEFINEx 额外生成 */ |
这样,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,内容形如:
1 | /* 自动生成 */ |
阶段二:syscall_64.c 使用头文件
arch/x86/entry/syscall_64.c 通过重新定义 __SYSCALL 宏,多次包含上述头文件来实现不同目的:
1 | /* arch/x86/entry/syscall_64.c */ |
值得注意的是,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():
1 | static __always_inline bool do_syscall_x64(struct pt_regs *regs, int nr) |
array_index_nospec() 是一个关键的安全函数——它使用条件移动指令(而不是分支)来确保即使 CPU 分支预测错误,也不会导致越界访问。这是 Spectre variant 1(边界检查绕过)漏洞的缓解措施。
ARM64 的系统调用表构建
ARM64 的系统调用表构建更为简洁(arch/arm64/kernel/sys.c 第 55-63 行):
1 | /* arch/arm64/kernel/sys.c */ |
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 行):
1 | static void invoke_syscall(struct pt_regs *regs, unsigned int scno, |
RISC-V 的系统调用表构建
RISC-V 的方式与 ARM64 非常相似(arch/riscv/kernel/syscall_table.c 第 21-24 行):
1 | void * const sys_call_table[__NR_syscalls] = { |
分发在 do_trap_ecall_u() 中直接完成(arch/riscv/kernel/traps.c 第 342-344 行):
1 | if (syscall >= 0 && syscall < NR_syscalls) { |
compat_sys_call_table:32 位兼容层
在 64 位系统上运行 32 位程序时,需要使用兼容系统调用表。这是因为 32 位程序使用的数据结构布局(如 struct stat)与 64 位不同,需要专门的转换函数。
x86_64 通过 syscall_64.tbl 中的 compat 条目和 CONFIG_IA32_EMULATION 配置来支持 32 位兼容:
1 | # arch/x86/entry/syscalls/syscall_64.tbl |
ARM64 的 32 位兼容表在 arch/arm64/kernel/sys32.c 第 129 行:
1 | const syscall_fn_t compat_sys_call_table[__NR_compat32_syscalls] = { |
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 行:
1 | void * const compat_sys_call_table[__NR_syscalls] = { |
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 行定义:
1 |
|
使用 ftrace 追踪系统调用:
1 | # 列出可用的系统调用追踪点 |
通用的系统调用追踪实现在 kernel/entry/syscall-common.c 第 10-23 行:
1 | /* kernel/entry/syscall-common.c */ |
审计子系统
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 | 1. enter_from_user_mode() -- 上下文追踪、RCU 等 |
这种分层设计确保了各追踪机制按照正确的优先级执行,且代码在不同架构间保持一致。
1 | /* include/linux/entry-common.h 第 179-191 行 */ |
返回时,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() 等高频调用变成了普通的函数调用,完全不需要模式切换。
1 | 性能对比(大致量级): |
对于 gettimeofday() 这种每秒可能被调用百万次的函数,vDSO 带来的性能提升是数量级的。
7.6.2 vsyscall – 遗留的固定地址机制
在 vDSO 出现之前,x86_64 Linux 使用的是 vsyscall 机制。这是一种非常简单粗暴的方案:在固定的虚拟地址 0xffffffffff600000(内核地址空间的最高区域之一)映射一个 4KB 的页面,其中包含几个直接可执行的函数。
vsyscall 页面仅提供三个函数(加上一个已被移除的 vclock_gettime,共四个槽位):
1 | 地址 函数 槽位偏移 |
vsyscall 页面的实际代码非常简单,定义在 arch/x86/entry/vsyscall/vsyscall_emu_64.S 中:
1 | // arch/x86/entry/vsyscall/vsyscall_emu_64.S |
注意这段代码的本质:它只是把系统调用号加载到 RAX,然后执行 syscall 指令进入内核。也就是说,vsyscall 页面中的代码最终还是要走完整的系统调用路径!在旧版本中,这些函数确实包含直接读取内核数据的内联代码,但出于安全原因已被替换为简单的系统调用包装。
vsyscall 的致命缺陷
vsyscall 存在两个根本性的安全问题:
固定地址破坏 ASLR:vsyscall 页面始终位于
0xffffffffff600000,这个地址在所有进程中都完全相同。攻击者可以基于此地址进行 ROP(Return-Oriented Programming)攻击,因为这段代码是固定且可执行的。地址空间布局随机化(ASLR)对此地址完全无效。缺乏扩展性:4KB 的页面只够放四个函数,无法添加新功能。
vsyscall 的模拟模式
由于一些老旧的二进制程序仍然依赖 vsyscall,Linux 7.0.10 不能直接移除它,而是提供了三种模拟模式,通过内核启动参数 vsyscall= 控制:
1 | // arch/x86/entry/vsyscall/vsyscall_64.c, 第 44-51 行 |
- none(默认):vsyscall 页面完全不存在,任何访问都会导致段错误。
- xonly:页面仅可执行不可读,防止攻击者通过读取 vsyscall 页面获取固定地址 gadget。
- emulate:页面完全不存在,当程序尝试执行 vsyscall 地址的代码时,触发页面异常(page fault),内核在 page fault 处理程序中识别出这是 vsyscall 调用,进行模拟执行。
模拟模式的核心函数是 emulate_vsyscall():
1 | // arch/x86/entry/vsyscall/vsyscall_64.c, 第 114-281 行 |
模拟模式下,每次 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 相关区域由以下几部分组成:
1 | 进程地址空间中的 vDSO 布局: |
这些页面的数量和偏移定义在 arch/x86/include/asm/vdso/vsyscall.h 中:
1 | // arch/x86/include/asm/vdso/vsyscall.h |
7.6.4 vDSO 的构建与实现
x86_64 的 vDSO 源码结构
x86_64 的 vDSO 相关源码位于 arch/x86/entry/vdso/ 目录下:
1 | arch/x86/entry/vdso/ |
64 位 vDSO 的实际时间函数代码非常薄,仅是对通用实现的包装:
1 | // arch/x86/entry/vdso/common/vclock_gettime.c |
vDSO 导出的符号在链接脚本中定义:
1 | // arch/x86/entry/vdso/vdso64/vdso64.lds.S |
每个导出的符号都有两个版本:带 __vdso_ 前缀的(供 C 库直接调用)和不带前缀的(作为弱符号覆盖 C 库的同名函数)。
vDSO 的构建过程
vDSO 的构建是一个特殊的编译流程。内核编译系统将 vDSO 源码编译为一个真正的 ELF 共享库 vdso64.so,然后使用 vdso2c 工具将这个 .so 文件转换为 C 数组:
1 | 编译流程: |
vdso2c 工具解析 ELF 文件,提取代码段、符号表地址、替代指令(alternatives)信息等,生成一个 C 结构体。这样,vDSO 的二进制代码就被嵌入到了内核镜像中,内核在创建新进程时将其映射到用户地址空间。
7.6.5 vDSO 数据页 – vvar
vDSO 的核心在于数据页(vvar)。这是内核与用户态共享数据的通道,vDSO 函数通过读取这个页面获取时间、CPU 等信息。
数据页的核心结构定义在 include/vdso/datapage.h 中:
1 | // include/vdso/datapage.h |
序列锁(Seqlock)机制
内核更新 vdso_time_data 时使用序列锁保证原子性。vdso_clock.seq 字段就是序列计数器:
1 | 内核更新流程: |
如果用户态读取过程中 seq 发生了变化(说明内核在此期间进行了更新),则需要重新读取。这种无锁读取机制确保了在不使用任何系统调用的情况下,用户态也能安全地获取一致的时间数据。
时钟源模式
clock_mode 字段指示当前使用的时钟源类型。x86_64 上支持的时钟源定义在 arch/x86/include/asm/vdso/clocksource.h 中:
1 | // arch/x86/include/asm/vdso/clocksource.h |
对于物理机,最常见的是 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:
1 | // arch/x86/entry/vdso/vma.c, 第 232-242 行 |
map_vdso() 负责在进程地址空间中分配一块随机化的地址区域,映射 vvar 数据页和 vdso 代码页:
1 | // arch/x86/entry/vdso/vma.c, 第 133-195 行 |
第二步:vDSO 函数执行
C 库调用 __vdso_gettimeofday(tv, tz),这实际上跳转到 vDSO 映射中的代码。vDSO 中的实现调用通用函数 __cvdso_gettimeofday():
1 | // lib/vdso/gettimeofday.c, 第 362-388 行 |
第三步:高精度时间计算
do_hres() 是 vDSO 时间计算的核心函数:
1 | // lib/vdso/gettimeofday.c, 第 149-187 行 |
vdso_get_timestamp() 读取 TSC 并计算纳秒级时间:
1 | // lib/vdso/gettimeofday.c, 第 91-109 行 |
第四步:读取硬件计数器
__arch_get_hw_counter() 是 x86_64 特定的实现,定义在 arch/x86/include/asm/vdso/gettimeofday.h 中:
1 | // arch/x86/include/asm/vdso/gettimeofday.h, 第 146-170 行 |
rdtsc_ordered() 在用户态编译为 rdtscp 或带有 lfence/mfence 的 rdtsc 指令,直接读取 CPU 的 Time Stamp Counter,无需进入内核。
第五步:纳秒计算
x86_64 使用了定制的 vdso_calc_ns() 函数,考虑了 TSC 跨 socket 可能出现微小偏差的特殊情况:
1 | // arch/x86/include/asm/vdso/gettimeofday.h, 第 211-239 行 |
最终的时间计算公式为:
1 | ns = ((cycles - cycle_last) * mult + basetime_nsec) >> shift |
其中:
cycles是当前 TSC 读数(通过rdtsc获取)cycle_last是内核上次更新时记录的 TSC 基准值mult和shift是 TSC 频率到纳秒的转换参数basetime_nsec和basetime_sec是内核上次更新时的基准时间
整个计算过程完全在用户态完成,没有任何系统调用!
完整流程总结:
1 | 应用程序: |
7.6.7 AT_SYSINFO_EHDR 辅助向量
内核如何通知用户态 vDSO 的位置?答案是通过 ELF 辅助向量(auxiliary vector)。
在 execve() 系统调用执行新程序时,内核在用户态栈上构建一个辅助向量,其中包含各种由内核传递给用户态的信息。AT_SYSINFO_EHDR 条目的值就是 vDSO 的 ELF 头地址。
动态链接器(ld-linux.so)在初始化时扫描辅助向量,发现 AT_SYSINFO_EHDR 后,解析 vDSO 的 ELF 头部结构:
1 | 辅助向量 (位于用户态栈上): |
glibc 在 _dl_vdso_vsym() 函数中使用此地址查找 vDSO 符号。查找过程与加载普通共享库类似:解析 ELF 头 -> 定位动态段 -> 查找符号表 -> 匹配版本信息 -> 返回函数地址。
7.6.8 其他架构的 vDSO
vDSO 不是 x86_64 独有的机制,Linux 在多种架构上都有 vDSO 实现。其核心思想相同,但实现细节因架构而异。
ARM64 的 vDSO
ARM64 的 vDSO 源码位于 arch/arm64/kernel/vdso/ 目录:
1 | arch/arm64/kernel/vdso/ |
ARM64 使用 cntvct_el0(虚拟计数器)作为用户态可读的硬件计数器,等价于 x86 的 TSC。ARM64 的 vDSO 代码通过 isb(指令同步屏障)+ mrs %0, cntvct_el0 读取计数器。
RISC-V 的 vDSO
RISC-V 的 vDSO 源码位于 arch/riscv/kernel/vdso/ 目录:
1 | arch/riscv/kernel/vdso/ |
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:
1 | // arch/x86/entry/vdso/common/vgetcpu.c |
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 数据页:
1 | // lib/vdso/datastore.c |
由于 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 指令:
1 | // arch/x86/include/asm/vdso/gettimeofday.h, 第 57-68 行 |
这些回退函数使用 VDSO_SYSCALL2 宏,它展开为内联的 SYSCALL 指令序列,直接在 vDSO 代码中发起系统调用。
7.6.12 性能对比与总结
综合以上分析,我们可以对三种时间获取机制做一个定量的性能对比:
1 | 机制 延迟(ns) 安全性 ASLR 可扩展性 |
vDSO 的性能优势来源于:
- 零特权级切换(无 SYSCALL/SYSRET 开销)
- 零栈切换(无 pt_regs 构造/恢复)
- 零安全缓解措施(无 IBRS/PTI 等开销)
- 直接的用户态内存读取(rdtsc + 数学运算)
vDSO 是操作系统设计中”用巧妙的机制替代昂贵的通用路径”的经典案例。它通过内核与用户态之间的数据共享,将原本需要特权操作的任务转化为纯用户态计算,在不牺牲安全性的前提下获得了数量级的性能提升。
Linux 7.0.10 的 vDSO 实现已经相当成熟,支持 gettimeofday、clock_gettime、clock_getres、time、getcpu、getrandom(新增)等多种函数。未来随着更多只读内核数据的共享需求出现,vDSO 可能会继续扩展其功能范围。
- Title: Linux内核分析之基础知识-06
- Author: 韩乔落
- Created at : 2026-05-29 10:27:04
- Updated at : 2026-05-29 11:10:39
- Link: https://jelasin.github.io/2026/05/29/Linux内核分析之基础知识-06/
- License: This work is licensed under CC BY-NC-SA 4.0.