3. 系统调用表 (sys_call_table)

系统调用表是内核中一个核心跳转结构,它将每个系统调用号映射到对应的内核处理函数。当用户态程序通过 syscall 指令陷入内核时,内核根据 rax 寄存器中的系统调用号,在系统调用表中索引并调用相应的服务例程。本章将深入分析系统调用表的结构、定义位置以及初始化过程。

3.1 系统调用表的结构

在 x86-64 架构中,系统调用表本质上是一个函数指针数组。数组的索引就是系统调用号,数组元素是对应处理函数的地址。其核心数据结构定义如下:

// arch/x86/include/asm/syscall_table.h
typedef void (*sys_call_ptr_t)(void);

extern const sys_call_ptr_t sys_call_table[];

关键特性:

  • 固定长度:数组大小由 __NR_syscall_max 决定,当前 x86-64 支持约 450 个系统调用。
  • 只读属性:系统调用表在初始化后被标记为只读,防止恶意篡改。
  • 稀疏填充:未使用的系统调用号位置填充 sys_ni_syscall(空操作函数),返回 -ENOSYS。
架构差异提示: x86-32 使用 sys_call_table,x86-64 使用 sys_call_table,但 arm64 使用 sys_call_table 和 compat_sys_call_table 两套表。本课程聚焦 x86-64。

3.2 系统调用表的定义位置

系统调用表的具体定义位于 arch/x86/entry/syscall_64.c 文件中。该文件通过宏展开机制,将 SYSCALL_DEFINE 宏定义的函数自动收集到表中。

// arch/x86/entry/syscall_64.c (简化示意)
#include <asm/syscall_table.h>

#define __SYSCALL(nr, func) [nr] = (sys_call_ptr_t)func,
#define __SYSCALL_NI(nr)    [nr] = (sys_call_ptr_t)sys_ni_syscall,

const sys_call_ptr_t sys_call_table[__NR_syscall_max+1] = {
    /*
     * 0 - 100 系统调用
     */
    __SYSCALL(0,  sys_read)
    __SYSCALL(1,  sys_write)
    __SYSCALL(2,  sys_open)
    __SYSCALL(3,  sys_close)
    // ... 中间省略 ...
    __SYSCALL(59, sys_execve)
    __SYSCALL(60, sys_exit)
    // ... 更多系统调用 ...

    /*
     * 未使用的系统调用号填充为 sys_ni_syscall
     */
    [450 ... __NR_syscall_max] = (sys_call_ptr_t)sys_ni_syscall,
};
关键宏解释: __SYSCALL(nr, func) 宏将系统调用号 nr 映射到函数 func 的地址。这种设计使得添加新系统调用时,只需在 syscall_64.c 中添加一行宏调用,无需手动维护数组索引。

3.3 系统调用表的初始化

系统调用表的初始化分为两个阶段:编译时静态初始化和运行时只读保护。

阶段 时机 操作 关键函数/宏
编译时 内核编译链接 通过 __SYSCALL 宏填充数组 syscall_64.c 中的宏展开
运行时 内核启动早期 将系统调用表所在内存页设为只读 mark_rodata_ro()
运行时 模块加载时 动态注册新系统调用(可选) sys_call_table 导出符号

运行时只读保护的关键代码路径:

// arch/x86/mm/init_64.c
void mark_rodata_ro(void)
{
    unsigned long start = (unsigned long)&__start_rodata;
    unsigned long end   = (unsigned long)&__end_rodata;

    // 将系统调用表所在段设置为只读
    set_memory_ro(start, (end - start) >> PAGE_SHIFT);
}
安全要点: 系统调用表是内核攻击面的高价值目标。一旦攻击者能够修改系统调用表,就可以劫持任意系统调用。因此,内核在启动后立即将包含系统调用表的内存页设置为只读,并启用 CONFIG_STRICT_KERNEL_RWX 选项来防止恶意写入。

3.4 系统调用表结构示意图

下图展示了系统调用表在内存中的布局以及从用户态系统调用到内核处理函数的完整路径:

系统调用表结构及调用路径 系统调用表结构及调用路径 用户态程序 syscall 指令 entry_SYSCALL_64 rax = 系统调用号 sys_call_table[] [0] → sys_read [1] → sys_write [2] → sys_open [3] → sys_close [4] → sys_stat [59] → sys_execve [60] → sys_exit [未使用] → sys_ni_syscall 索引 内核处理函数 跳转执行 返回用户态 说明 系统调用表在编译时 静态初始化,运行时 设为只读保护

3.5 系统调用表的导出与模块访问

内核通过 EXPORT_SYMBOL 导出系统调用表,允许内核模块在初始化时动态替换或监控系统调用。但这一机制在现代内核中受到严格限制:

// arch/x86/entry/syscall_64.c
const sys_call_ptr_t sys_call_table[__NR_syscall_max+1] = {
    // ... 初始化 ...
};
EXPORT_SYMBOL_GPL(sys_call_table);  // 仅 GPL 模块可访问
模块访问限制: 自 Linux 4.17 起,内核引入了 CONFIG_ARCH_HAS_SYSCALL_WRAPPER 选项,x86-64 默认启用。这使得系统调用表不再直接包含 sys_* 函数地址,而是包含包装函数地址,增加了模块篡改的难度。

3.6 系统调用表的关键特性总结

特性 描述 安全影响
静态初始化 编译时通过宏展开填充所有表项 避免运行时动态分配带来的安全风险
只读保护 启动后通过 set_memory_ro() 设为只读 防止内存损坏或恶意篡改
稀疏填充 未使用项填充 sys_ni_syscall 确保无效系统调用号不会导致崩溃
GPL 导出 仅对 GPL 许可证模块可见 限制非自由软件对内核的修改
调试技巧: 可以通过 /proc/kallsyms 查看系统调用表的地址:grep sys_call_table /proc/kallsyms。在调试内核模块或分析系统调用劫持攻击时,这个地址是关键的切入点。