4. 系统调用表(sys_call_table):结构、存储与定位

系统调用表(sys_call_table)是内核中用于将系统调用号映射到对应内核处理函数的核心数据结构。它本质上是一个函数指针数组,数组的索引就是系统调用号,数组元素指向具体的系统调用服务例程(sys_xxx)。理解 sys_call_table 的结构、存储位置和定位方法,是深入分析系统调用流程、进行内核调试或安全监控的必备技能。

4.1 系统调用表的结构定义

在 Linux 内核源码中,sys_call_table 的定义因架构而异。以 x86_64 架构为例,其定义位于 arch/x86/entry/syscall_64.c 文件中。该表是一个由 sys_call_ptr_t 类型元素组成的数组,sys_call_ptr_t 是一个函数指针类型,指向返回值为 long、参数为 const struct pt_regs * 的函数。

核心数据结构:

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

// arch/x86/entry/syscall_64.c
asmlinkage const sys_call_ptr_t sys_call_table[__NR_syscall_max+1] = {
    [0 ... __NR_syscall_max] = &sys_ni_syscall,
    [0] = sys_read,
    [1] = sys_write,
    [2] = sys_open,
    // ... 后续条目
};

结构要点:

  • 数组索引:索引值对应系统调用号(如 __NR_read 值为 0)。
  • 默认填充:未显式赋值的条目被初始化为 sys_ni_syscall(not implemented),该函数直接返回 -ENOSYS。
  • asmlinkage 修饰:表示函数参数通过栈传递,而非寄存器,这是系统调用入口的特殊约定。
  • const 限定:表本身被标记为 const,防止内核代码意外修改;但在某些安全机制(如 KASLR)下,表可能位于可写映射区域。

架构差异提示: 在 ARM64 架构中,系统调用表定义于 arch/arm64/kernel/sys.c,使用 sys_call_table 数组,但函数指针类型为 syscall_fn_t。在 x86_32 中,表定义于 arch/x86/entry/syscall_32.c,且使用 ia32_sys_call_table 名称。

4.2 系统调用表的存储位置与内存布局

系统调用表在编译时被链接到内核映像的特定段中。在 x86_64 架构下,它通常位于内核的 .data 段或 .rodata 段,具体取决于内核配置是否启用了 CONFIG_HAVE_ARCH_SYSCALL_TABLE_WRITABLE 选项。

以下 SVG 图展示了系统调用表在内核内存中的布局及其与系统调用号、服务例程的映射关系:

系统调用表内存布局与映射关系 系统调用号 0 (read) 1 (write) 2 (open) 3 (close) 4 (stat) ... __NR_syscall_max sys_call_table[] &sys_read &sys_write &sys_open &sys_close &sys_newstat ... &sys_ni_syscall 服务例程 sys_read() sys_write() sys_open() sys_close() sys_newstat() ... sys_ni_syscall() 存储段 .rodata 或 .data (取决于配置) 默认处理 未实现调用 → -ENOSYS

4.3 在内核源码中定位系统调用表

定位系统调用表是内核调试和代码分析的常见需求。以下是针对不同架构和场景的定位方法:

架构/场景 源文件路径 表名称 关键宏/辅助函数
x86_64 (64位) arch/x86/entry/syscall_64.c sys_call_table __NR_syscall_max
x86_32 (32位兼容) arch/x86/entry/syscall_32.c ia32_sys_call_table IA32_NR_syscalls
ARM64 arch/arm64/kernel/sys.c sys_call_table __NR_syscalls
ARM (32位) arch/arm/kernel/entry-common.S sys_call_table NR_syscalls
RISC-V arch/riscv/kernel/syscall_table.c sys_call_table __NR_syscalls

快速定位命令(基于内核源码根目录):

# 查找 x86_64 系统调用表定义
grep -rn "sys_call_table\[" arch/x86/entry/syscall_64.c

# 查找所有架构的系统调用表声明
grep -rn "sys_call_table\[" arch/*/kernel/ --include="*.c" --include="*.S"

# 查找系统调用表的外部引用(用于模块或调试)
grep -rn "extern.*sys_call_table" include/ --include="*.h"

4.4 系统调用表的导出与访问

在早期内核版本中,sys_call_table 是导出的符号,内核模块可以直接访问。但从安全角度考虑,现代内核(2.6 之后)默认不再导出 sys_call_table 符号。这意味着模块无法直接通过符号引用获取其地址。

访问方式对比:

  • 内核模块直接引用:需要内核配置 CONFIG_KALLSYMS 和 CONFIG_KALLSYMS_ALL,通过 kallsyms_lookup_name("sys_call_table") 动态查找。
  • /proc/kallsyms:在系统运行时,可通过 cat /proc/kallsyms | grep sys_call_table 查看地址(需 root 权限)。
  • System.map:编译内核时生成的符号映射文件,包含 sys_call_table 的链接地址。
  • Kprobe/Jprobe:通过内核探测机制间接获取表项内容。

安全警告: 直接修改 sys_call_table 是内核 Rootkit 的常见手法(通过替换表项指向恶意函数)。现代内核引入了 CR0.WP 保护(写保护位)和 CFI(控制流完整性) 等机制来防止此类篡改。在生产环境中,应避免对系统调用表进行运行时修改。

4.5 系统调用表的初始化流程

系统调用表在内核启动早期完成初始化。以下 SVG 图展示了从系统启动到系统调用表就绪的关键步骤:

系统调用表初始化流程 内核解压与入口 start_kernel() trap_init() 系统调用门 sys_call_table 就绪 用户态可调用 初始化关键点 1. 编译时:sys_call_table 数组在 .rodata/.data 段分配并填充默认值 2. 启动时:trap_init() 设置 IDT(中断描述符表),将 0x80 中断或 syscall 指令指向入口函数 3. 入口函数通过系统调用号索引 sys_call_table,完成调用分发

4.6 实战:通过 /proc/kallsyms 定位系统调用表

以下步骤演示如何在运行中的系统上定位 sys_call_table 的地址:

  1. 确认内核符号可见性:确保内核编译时启用了 CONFIG_KALLSYMS 和 CONFIG_KALLSYMS_ALL。
  2. 查看系统调用表地址:
# 查看 sys_call_table 地址(x86_64)
sudo grep "sys_call_table" /proc/kallsyms

# 输出示例:
# ffffffff82001300 R sys_call_table

# 查看 ia32 兼容表地址(32位兼容)
sudo grep "ia32_sys_call_table" /proc/kallsyms
  1. 验证表内容:使用 bpftrace 或内核模块读取指定偏移处的函数指针。

调试技巧: 在 /proc/kallsyms 输出中,地址后的字母表示符号类型:R 表示只读数据(.rodata),D 表示已初始化数据(.data),T 表示代码段。对于 sys_call_table,通常显示为 R 或 D。

4.7 系统调用表与系统调用号的关系

系统调用号与表项之间的映射关系由 __NR_xxx 宏定义。这些宏定义在 include/uapi/asm-generic/unistd.h 或架构特定的 asm/unistd.h 中。以下表格展示了 x86_64 架构下部分系统调用号的映射:

系统调用号 宏名称 表项索引 对应函数 功能描述
0 __NR_read sys_call_table[0] sys_read 从文件描述符读取数据
1 __NR_write sys_call_table[1] sys_write 向文件描述符写入数据
2 __NR_open sys_call_table[2] sys_open 打开或创建文件
39 __NR_getpid sys_call_table[39] sys_getpid 获取当前进程 ID
60 __NR_exit sys_call_table[60] sys_exit 终止当前进程