3. 系统调用表(sys_call_table):结构、索引与处理函数映射

系统调用表是内核将系统调用号(syscall number)映射到对应内核处理函数的核心数据结构。它是一个函数指针数组,数组的索引就是系统调用号,数组元素是对应的内核服务例程(kernel service routine)地址。理解 sys_call_table 的结构与索引机制,是自定义系统调用实现的基础。

核心概念:系统调用表本质上是一个 const void * 类型的函数指针数组,每个元素指向一个 asmlinkage long (*fn)(struct pt_regs *) 类型的函数。系统调用号作为数组下标,直接定位到对应的处理函数入口。

3.1 sys_call_table 的数据结构

在 x86-64 架构(Linux 5.x 内核)中,系统调用表定义在 arch/x86/entry/syscall_64.c 文件中。其核心结构如下:

// arch/x86/entry/syscall_64.c
asmlinkage 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>
};

关键要素说明:

  • sys_call_ptr_t:函数指针类型,定义为 typedef asmlinkage long (*sys_call_ptr_t)(const struct pt_regs *);。
  • __NR_syscall_max:当前架构支持的最大系统调用号,x86-64 上通常为 547(Linux 6.x 内核)。
  • 默认填充:未使用的表项初始化为 __x64_sys_ni_syscall(not implemented),返回 -ENOSYS。
  • asm/syscalls_64.h:由脚本生成的头文件,包含所有已注册系统调用的函数指针初始化项。
架构差异提示:ARM64 架构的 sys_call_table 定义在 arch/arm64/kernel/sys.c,使用 sys_call_table[NR_syscalls] 数组;x86-32 则使用 ia32_sys_call_table 兼容 32 位程序。不同架构的系统调用号范围和处理函数命名规则略有不同。

3.2 系统调用号到处理函数的索引流程

当用户态程序执行 syscall 指令时,内核通过以下步骤完成索引:

  1. 保存寄存器上下文:CPU 自动将系统调用号存入 RAX 寄存器,参数依次存入 RDI、RSI、RDX、R10、R8、R9。
  2. 进入入口函数:entry_SYSCALL_64 汇编入口保存 pt_regs 结构,调用 do_syscall_64()。
  3. 边界检查:检查系统调用号是否在 [0, __NR_syscall_max] 范围内。
  4. 表项索引:以系统调用号为下标,从 sys_call_table 中取出函数指针。
  5. 调用处理函数:通过函数指针调用对应的内核服务例程,传入 pt_regs 指针。
安全边界:系统调用号必须经过严格的范围检查,防止越界访问 sys_call_table 导致内核崩溃或信息泄露。Linux 内核在 do_syscall_64() 中通过 unlikely(nr >= __NR_syscall_max) 进行快速路径检查。

3.3 索引流程的 SVG 架构图

系统调用号索引处理函数流程 系统调用号索引处理函数流程 用户态程序 syscall 指令 RAX = 系统调用号 entry_SYSCALL_64 保存 pt_regs do_syscall_64() 边界检查 nr sys_call_table[nr] 函数指针数组 __x64_sys_xxx() 内核服务例程 返回用户态 索引关键点 • nr = RAX 寄存器值 • 范围: 0 ~ __NR_syscall_max • 越界返回 -ENOSYS • 函数签名统一

3.4 系统调用表的内存布局

sys_call_table 在内存中是连续存储的函数指针数组。以下表格展示了 x86-64 架构下部分系统调用号的映射关系:

系统调用号 系统调用名称 处理函数 功能描述
0 read __x64_sys_read 从文件描述符读取数据
1 write __x64_sys_write 向文件描述符写入数据
2 open __x64_sys_open 打开或创建文件
3 close __x64_sys_close 关闭文件描述符
39 getpid __x64_sys_getpid 获取当前进程 PID
60 exit __x64_sys_exit 终止当前进程
335 statx __x64_sys_statx 获取文件扩展状态
函数命名规则:x86-64 架构的系统调用处理函数统一使用 __x64_sys_<name> 前缀,x86-32 兼容层使用 __ia32_sys_<name>。ARM64 架构则使用 __arm64_sys_<name>。这种命名约定通过 SYSCALL_DEFINEx() 宏自动生成。

3.5 系统调用表的初始化与动态修改

系统调用表在编译时通过 asm/syscalls_64.h 头文件静态初始化。该头文件由内核构建系统根据 syscall_64.tbl 表格自动生成。以下是初始化过程的 SVG 示意图:

系统调用表初始化流程 系统调用表初始化流程 syscall_64.tbl 系统调用号映射表 syscalltbl.sh 构建时脚本生成 asm/syscalls_64.h 函数指针初始化项 sys_call_table[] 数组 #include <asm/syscalls_64.h> 默认填充 __x64_sys_ni_syscall 内存中的函数指针数组 只读 .rodata 段 动态修改限制 • 内核 5.x+ 默认只读 • 需修改 CR0 写保护 • 生产环境不推荐 • 推荐使用 kprobe/ftrace

3.6 系统调用表的只读保护

从 Linux 4.17 内核开始,sys_call_table 被放置在只读内存段(.rodata),防止恶意模块或 rootkit 篡改系统调用表。这一保护机制通过以下方式实现:

  • const 限定符:数组声明为 asmlinkage const sys_call_ptr_t sys_call_table[],编译器将其放入 .rodata 段。
  • 页表保护:内核将包含 sys_call_table 的内存页标记为只读,任何写入操作会触发页错误。
  • CR0.WP 位:即使通过 __change_cr0() 临时禁用写保护,也需要谨慎操作,避免竞态条件。
实践建议:在自定义系统调用开发中,不要尝试直接修改 sys_call_table。正确的做法是通过 register_syscall() 或 SYSCALL_DEFINEx() 宏在编译时注册新的系统调用,或者使用 kprobe/ftrace 等安全机制进行动态跟踪。

3.7 多架构系统调用表对比

架构 表名称 最大系统调用号 处理函数前缀 定义文件
x86-64 sys_call_table __NR_syscall_max (约 547) __x64_sys_ arch/x86/entry/syscall_64.c
x86-32 ia32_sys_call_table IA32_NR_syscalls (约 447) __ia32_sys_ arch/x86/entry/syscall_32.c
ARM64 sys_call_table NR_syscalls (约 547) __arm64_sys_ arch/arm64/kernel/sys.c
RISC-V sys_call_table __NR_syscalls (约 547) __riscv_sys_ arch/riscv/kernel/sys_riscv.c
兼容层说明:x86-64 内核通过 ia32_sys_call_table 支持 32 位应用程序的兼容运行。当 32 位程序执行 int 0x80 或 sysenter 指令时,内核使用 ia32_sys_call_table 进行索引,确保系统调用号映射正确。

3.8 系统调用表索引的汇编实现

在 x86-64 入口代码中,系统调用表的索引通过简单的数组寻址完成。以下是 entry_SYSCALL_64 中核心汇编逻辑的简化表示:

// arch/x86/entry/entry_64.S (简化)
ENTRY(entry_SYSCALL_64)
    /* 保存寄存器上下文 */
    swapgs
    movq    %rsp, PER_CPU_VAR(cpu_tss_rw + TSS_sp2)
    
    /* 构建 pt_regs 结构 */
    pushq   $__USER_DS
    pushq   PER_CPU_VAR(cpu_tss_rw + TSS_sp2)
    pushq   %r11
    pushq   $__USER_CS
    pushq   %rcx
    pushq   %rax
    pushq   %rdi
    pushq   %rsi
    pushq   %rdx
    pushq   %r10
    pushq   %r8
    pushq   %r9
    
    /* 调用 do_syscall_64 */
    movq    %rsp, %rdi          /* rdi = pt_regs 指针 */
    call    do_syscall_64
    
    /* 恢复上下文并返回 */
    RESTORE_REGS
    swapgs
    sysretq
ENDPROC(entry_SYSCALL_64)

在 do_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 (unlikely(nr >= __NR_syscall_max))
        return syscall_return_slowpath(regs, -ENOSYS);
    
    /* 从 sys_call_table 索引并调用 */
    regs->ax = sys_call_table[nr](regs);
}
性能关键路径:系统调用入口是内核中最频繁执行的代码路径之一。索引操作被优化为单次内存读取:call *sys_call_table(, %rax, 8)(x86-64 上每个指针 8 字节)。任何对 sys_call_table 的修改都会影响所有系统调用的性能。

3.9 本章小结