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 内存布局

sys_call_table 内存布局(x86-64) sys_call_table[0] ... sys_call_table[547] [0] __NR_read [1] __NR_write [2] __NR_open [3] __NR_close [...] 未使用 [547] 边界 sys_read() sys_write() sys_open() sys_close() 数组下标 = 系统调用号,数组元素 = 函数指针 未使用表项指向 sys_ni_syscall(),返回 -ENOSYS

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 初始化流程总览

系统调用表初始化流程 阶段1:编译时 syscalltbl.sh 生成头文件 阶段2:编译时 C 编译器填充数组 阶段3:链接时 生成最终二进制 阶段4:启动时 syscall_init() 校验表项、设置 MSR_LSTAR 阶段5:运行时就绪 系统调用表可被 entry_SYSCALL_64 使用

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) 编号范围检查与查表合并为一条条件分支。

2.6 小结

  • 系统调用号是用户态与内核态之间的协议编号,每个架构独立维护,分配后永不改变。
  • sys_call_table 是一个函数指针数组,数组下标对应系统调用号,数组元素为处理函数地址。
  • 初始化过程分为编译时脚本生成、C 编译器填充、链接时合并、启动时校验四个阶段。
  • 架构差异主要体现在编号范围、表定义文件和入口汇编代码上,但核心查表机制一致。
  • 安全保护通过只读内存映射防止表被篡改,是内核自我防御的重要一环。