2. 系统调用号与表:系统调用号的定义、sys_call_table 结构、系统调用表的初始化
系统调用是用户空间程序请求内核服务的唯一合法入口。在内核内部,每个系统调用都有一个唯一的数字标识符——系统调用号,以及一张用于分发的核心跳转表——sys_call_table。本章深入分析系统调用号的分配机制、sys_call_table 的数据结构定义以及内核初始化阶段如何构建这张表。
2.1 系统调用号的定义与分配
系统调用号是一个无符号整数(通常为 unsigned long 或 int),在 asm/unistd.h 或 uapi/asm-generic/unistd.h 中通过宏定义。每个架构维护自己的编号空间,x86-64 与 ARM64 的编号并不相同。
2.1.1 系统调用号定义示例(x86-64)
// arch/x86/include/generated/uapi/asm/unistd_64.h
#define __NR_read 0
#define __NR_write 1
#define __NR_open 2
#define __NR_close 3
#define __NR_stat 4
#define __NR_fstat 5
// ...
#define __NR_execve 59
#define __NR_exit 60
#define __NR_clone 56
// ...
#define __NR_syscalls_x64 547 // 当前 x86-64 系统调用总数
2.1.2 系统调用号分配规则
| 规则项 | 说明 |
|---|---|
| 唯一性 | 同一架构内,每个系统调用号全局唯一,不可重复。 |
| 稳定性 | 已发布的系统调用号一旦分配,永不改变,保证 ABI 向后兼容。 |
| 预留范围 | 通常 0~399 为通用系统调用,400~499 为架构专用,500+ 为动态或预留。 |
| 架构隔离 | x86-64、ARM64、RISC-V 各有独立编号表,互不干扰。 |
| 废弃复用 | 废弃的系统调用号不再使用,但保留占位,避免后续新调用误用旧号。 |
关键认知: 系统调用号是用户态与内核态之间的“协议编号”。用户程序通过
syscall 指令传入编号,内核据此查表跳转。编号一旦改变,所有已编译的二进制程序都会崩溃。
2.2 sys_call_table 结构
sys_call_table 是内核中一个函数指针数组,数组下标对应系统调用号,数组元素为对应系统调用处理函数的地址。这张表是系统调用分发机制的核心数据结构。
2.2.1 数据结构定义
// arch/x86/entry/syscall_64.c
asmlinkage const sys_call_ptr_t sys_call_table[__NR_syscalls_x64] = {
[0 ... __NR_syscalls_x64 - 1] = &sys_ni_syscall,
#include <asm/syscalls_64.h>
};
关键点解析:
- 数组类型:
sys_call_ptr_t是一个函数指针类型,定义为typedef long (*sys_call_ptr_t)(const struct pt_regs *); - 数组大小:
__NR_syscalls_x64为当前架构支持的系统调用总数。 - 默认填充:所有未使用的表项初始化为
sys_ni_syscall(未实现系统调用桩函数,返回-ENOSYS)。 - 头文件注入:
asm/syscalls_64.h由脚本生成,包含所有系统调用处理函数的 extern 声明和数组初始化项。
2.2.2 sys_call_table 内存布局
2.3 系统调用表的初始化
系统调用表的初始化发生在内核编译阶段和启动早期。整个过程分为三个层次:编译时填充、启动时校验、运行时动态更新(仅限特定架构)。
2.3.1 编译时初始化
内核通过 syscalltbl.sh 脚本从系统调用列表文件(如 syscalls/x86/syscall_64.tbl)自动生成 asm/syscalls_64.h。该头文件包含所有系统调用处理函数的 extern 声明和数组初始化项。
// 生成的 asm/syscalls_64.h 片段(示意)
extern long sys_read(const struct pt_regs *);
extern long sys_write(const struct pt_regs *);
extern long sys_open(const struct pt_regs *);
// ...
[0] = (sys_call_ptr_t) sys_read,
[1] = (sys_call_ptr_t) sys_write,
[2] = (sys_call_ptr_t) sys_open,
// ...
2.3.2 启动时校验
内核启动过程中,start_kernel() 调用 trap_init() 和 early_irq_init() 后,会执行系统调用表完整性检查:
// kernel/sysctl.c 或 arch/x86/kernel/cpu/common.c
void __init syscall_init(void)
{
int i;
// 校验所有表项不为 NULL
for (i = 0; i < __NR_syscalls_x64; i++) {
if (sys_call_table[i] == NULL) {
pr_warn("sys_call_table[%d] is NULL, fixing...\n", i);
sys_call_table[i] = &sys_ni_syscall;
}
}
// 设置 LSTAR MSR(x86-64 专用)
wrmsrl(MSR_LSTAR, (unsigned long) entry_SYSCALL_64);
}
2.3.3 初始化流程总览
2.4 架构差异对比
| 特性 | x86-64 | ARM64 | RISC-V |
|---|---|---|---|
| 系统调用号范围 | 0~546(当前) | 0~434(当前) | 0~256(当前) |
| 表定义文件 | arch/x86/entry/syscall_64.c |
arch/arm64/kernel/sys.c |
arch/riscv/kernel/sys_riscv.c |
| 表类型 | const sys_call_ptr_t[] |
const syscall_fn_t[] |
const sys_call_ptr_t[] |
| 未使用表项处理 | sys_ni_syscall |
sys_ni_syscall |
sys_ni_syscall |
| 表是否可写 | 只读(写保护) | 只读(写保护) | 只读(写保护) |
| 动态追踪支持 | kprobes/ftrace | kprobes/ftrace | kprobes/ftrace |
安全机制: 现代内核将
sys_call_table 放置在只读内存区域(__ro_after_init 或 const),防止内核模块或漏洞通过修改表项劫持系统调用。ARM64 和 x86-64 均支持 CONFIG_STRICT_KERNEL_RWX 选项,确保表页不可写。
2.5 系统调用号与表的关联关系
用户态通过 syscall 指令(x86-64)或 SVC 指令(ARM64)陷入内核时,rax(x86-64)或 x8(ARM64)寄存器中存放的就是系统调用号。内核入口汇编代码直接使用该编号索引 sys_call_table:
// arch/x86/entry/entry_64.S (简化)
ENTRY(entry_SYSCALL_64)
swapgs
movq %rsp, PER_CPU_VAR(cpu_tss_rw + TSS_sp2)
/* 保存寄存器上下文 */
/* ... */
call do_syscall_64
/* 恢复上下文并返回 */
END(entry_SYSCALL_64)
// arch/x86/entry/common.c
static __always_inline void do_syscall_64(struct pt_regs *regs)
{
unsigned long nr = regs->orig_ax; // 从寄存器获取系统调用号
if (nr < __NR_syscalls_x64) {
regs->ax = sys_call_table[nr](regs); // 查表并调用
} else {
regs->ax = -ENOSYS; // 无效系统调用号
}
}
性能关键路径: 系统调用号查表是热路径中的热路径。内核通过以下手段优化:
(1) 数组下标直接寻址,无哈希或二分查找开销;
(2) 表项为函数指针,一次间接跳转即可到达处理函数;
(3) 编号范围检查与查表合并为一条条件分支。
(1) 数组下标直接寻址,无哈希或二分查找开销;
(2) 表项为函数指针,一次间接跳转即可到达处理函数;
(3) 编号范围检查与查表合并为一条条件分支。
2.6 小结
- 系统调用号是用户态与内核态之间的协议编号,每个架构独立维护,分配后永不改变。
- sys_call_table 是一个函数指针数组,数组下标对应系统调用号,数组元素为处理函数地址。
- 初始化过程分为编译时脚本生成、C 编译器填充、链接时合并、启动时校验四个阶段。
- 架构差异主要体现在编号范围、表定义文件和入口汇编代码上,但核心查表机制一致。
- 安全保护通过只读内存映射防止表被篡改,是内核自我防御的重要一环。