2. 系统调用号与系统调用表:系统调用号的概念、系统调用表(sys_call_table)的结构与初始化

系统调用是用户态程序请求内核服务的唯一合法通道。在内核入口函数(如 entry_SYSCALL_64)被触发后,内核需要快速、准确地定位到对应的内核服务函数。这一过程依赖两个核心机制:系统调用号(System Call Number)和系统调用表(sys_call_table)。

2.1 系统调用号:内核服务的唯一标识

每个系统调用在内核中都有一个唯一的整数编号,称为系统调用号。用户态程序通过将系统调用号存入特定寄存器(x86-64 为 rax),告知内核需要执行哪个服务。

关键概念:系统调用号是用户态与内核态之间的“协议编号”。内核不依赖函数名,只依赖这个数字来索引服务。数字的分配一旦发布,必须保持向后兼容。

2.1.1 系统调用号的分配原则

  • 唯一性:每个系统调用拥有全局唯一的编号,从 0 开始递增。
  • 稳定性:已分配的系统调用号不会变更,旧编号不会被新调用复用(即使该调用被废弃)。
  • 架构相关性:不同 CPU 架构(x86-64、ARM64、RISC-V)拥有各自独立的系统调用号空间。

2.1.2 系统调用号的定义位置

在 Linux 内核源码中,系统调用号通过宏定义在架构相关的头文件中。以 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_syscall_max       547  // 当前最大系统调用号
提示:用户态程序通常不直接使用 __NR_xxx 宏,而是通过 glibc 等 C 库的封装函数(如 read()、write())间接传递系统调用号。

2.2 系统调用表:函数指针的索引数组

系统调用表(sys_call_table)是内核中一个核心数据结构,它是一个由函数指针构成的数组。数组的索引就是系统调用号,数组的元素指向对应的内核服务函数。

2.2.1 数据结构定义

在 x86-64 架构下,系统调用表的类型定义如下:

// arch/x86/include/asm/syscall_table.h
typedef asmlinkage long (*sys_call_ptr_t)(const struct pt_regs *);

// 系统调用表声明
extern const sys_call_ptr_t sys_call_table[];
  • sys_call_ptr_t:函数指针类型,指向一个接收 pt_regs 结构体指针并返回 long 的函数。
  • sys_call_table[]:常量指针数组,数组大小由 __NR_syscall_max + 1 决定。

2.2.2 系统调用表的初始化

系统调用表在编译时通过链接器脚本和宏展开进行初始化,而非在运行时动态填充。核心机制是使用 SYSCALL_METADATA 和 __SYSCALL_COMMON 等宏,将分散定义的系统调用函数地址收集到表中。

// include/linux/syscalls.h 中的宏定义示例
#define __SYSCALL_DEFINEx(x, name, ...)                                 \
    static long __se_sys##name(const struct pt_regs *regs);             \
    static long __do_sys##name(__VA_ARGS__);                            \
    static long __se_sys##name(const struct pt_regs *regs)              \
    {                                                                   \
        return __do_sys##name(SC_PT_REGS_ARG##x(regs));                 \
    }                                                                   \
    __visible const sys_call_ptr_t __sys_##name = __se_sys##name;

最终,在 arch/x86/kernel/syscall_64.c 中,通过包含一个由脚本生成的 .c 文件来填充整个表:

// arch/x86/kernel/syscall_64.c
#include <asm/syscall_table.h>

// 由脚本 arch/x86/entry/syscalls/syscalltbl.sh 生成
#define __SYSCALL_COMMON(nr, sym, compat) [nr] = __sys_##sym,

const sys_call_ptr_t sys_call_table[__NR_syscall_max + 1] = {
    [0 ... __NR_syscall_max] = &__x64_sys_ni_syscall,  // 默认填充为未实现
    #include <asm/syscalls_64.h>                       // 覆盖已实现的调用
};
注意:未使用的系统调用号会被填充为 __x64_sys_ni_syscall(返回 -ENOSYS),确保即使传入非法编号,内核也不会访问越界或执行非法代码。

2.3 系统调用表的结构示意图

下图展示了系统调用表作为索引数组的核心结构,以及内核入口函数如何通过系统调用号进行快速查找:

系统调用表结构及查找流程 系统调用表 (sys_call_table) 结构及查找流程 用户态 rax = 2 sys_call_table[ ] [0] [1] [2] __x64_sys_open [3] [4] [5] ... do_sys_open() 内核服务函数 查找流程说明: 1. 用户态将系统调用号(如 2)存入 rax 寄存器,执行 syscall 指令。 2. 内核入口函数从 rax 提取系统调用号,检查是否在有效范围 [0, __NR_syscall_max] 内。 3. 以系统调用号为索引,直接访问 sys_call_table[nr] 获取函数指针。 4. 通过函数指针调用对应的内核服务函数(如 __x64_sys_open)。 5. 函数执行完毕后,返回值通过 rax 寄存器返回用户态。

2.4 系统调用号与系统调用表的关键特性对比

特性 系统调用号 系统调用表
本质 整数常量(枚举值) 函数指针数组
存储位置 头文件(如 unistd_64.h) 内核数据段(.rodata)
初始化时机 编译时(宏定义) 编译时(链接器 + 宏展开)
访问方式 用户态通过寄存器传递 内核态通过数组索引访问
架构相关性 每个架构独立定义 每个架构独立生成
安全性 用户态无法伪造合法编号范围外的值 未使用项填充为 ni_syscall,防止越界

2.5 系统调用表的架构差异

不同 CPU 架构的系统调用表在命名、生成方式和函数签名上存在差异。下表总结了主流架构的特点:

架构 表名称 函数签名 生成脚本
x86-64 sys_call_table asmlinkage long (*)(const struct pt_regs *) arch/x86/entry/syscalls/syscalltbl.sh
ARM64 sys_call_table asmlinkage long (*)(const struct pt_regs *) arch/arm64/kernel/sys.c(直接包含)
RISC-V sys_call_table asmlinkage long (*)(const struct pt_regs *) arch/riscv/kernel/syscall_table.c
核心要点:系统调用号是用户态与内核态之间的“调用协议”,系统调用表是内核内部的“函数索引”。两者共同构成了从用户请求到内核服务的快速映射通道。理解这一映射机制,是分析系统调用完整流程的基础。