2. 系统调用表(sys_call_table)概念
系统调用表是 Linux 内核中用于将系统调用号映射到对应内核服务函数的核心数据结构。它是一个函数指针数组,数组的索引就是系统调用号,数组元素是对应的内核处理函数地址。理解系统调用表的定义、内存位置和架构差异,是深入分析系统调用流程的基础。
核心要点:系统调用表本质上是一个
const void * const 类型的函数指针数组,每个条目指向一个 sys_xxx() 或 SyS_xxx() 形式的系统调用处理函数。
2.1 系统调用表的定义
在 Linux 内核源码中,系统调用表的定义位于架构相关的目录下。以 x86 架构为例,其定义在 arch/x86/kernel/syscall_64.c 中(对于 64 位系统):
// arch/x86/kernel/syscall_64.c (Linux 5.x)
asmlinkage const sys_call_ptr_t sys_call_table[__NR_syscall_max+1] = {
[0 ... __NR_syscall_max] = &sys_ni_syscall,
#include <asm/syscalls_64.h>
};
关键点解析:
- sys_call_ptr_t:函数指针类型,定义为
typedef long (*sys_call_ptr_t)(const struct pt_regs *); - __NR_syscall_max:最大系统调用号,由内核配置决定
- sys_ni_syscall:未实现系统调用的默认处理函数(返回 -ENOSYS)
- asm/syscalls_64.h:由脚本自动生成的头文件,包含所有系统调用函数的 extern 声明和表项初始化
提示:系统调用表使用
const 修饰,意味着在正常内核运行期间,表本身是只读的。但在某些调试或安全场景(如 ftrace、kprobe)中,内核会临时修改页表属性以动态替换表项。
下表展示了系统调用表的核心结构字段:
| 字段/属性 | 说明 | 典型值(x86_64) |
|---|---|---|
| 数组类型 | 函数指针数组 | sys_call_ptr_t [] |
| 数组长度 | 最大系统调用号 + 1 | 约 450(Linux 5.x) |
| 元素大小 | 每个函数指针占用的字节数 | 8 字节(64 位) |
| 默认处理函数 | 未实现系统调用的兜底函数 | sys_ni_syscall |
| 内存属性 | 只读(.rodata 段) | 可被 CR0.WP 位控制 |
2.2 系统调用表在内存中的位置
系统调用表被放置在内核镜像的只读数据段(.rodata)中。其具体内存地址可以通过以下方式查看:
# 通过 System.map 查看系统调用表地址
grep sys_call_table /boot/System.map-$(uname -r)
# 输出示例(x86_64):
# ffffffff82000300 R sys_call_table
# 通过 /proc/kallsyms 查看(需要 root 权限)
sudo grep sys_call_table /proc/kallsyms
# 输出示例:
# ffffffff82000300 R sys_call_table
地址中的 R 标志表示该符号位于只读数据段。系统调用表在内存中的布局如下图所示:
2.3 架构差异:x86 vs arm64
x86 和 arm64 架构在系统调用表的实现上存在显著差异,主要体现在调用约定、表结构、系统调用号分配和入口处理流程上。下表对比了关键差异:
| 对比维度 | x86(64 位) | arm64(AArch64) |
|---|---|---|
| 系统调用指令 | syscall(64 位)sysenter(32 位兼容) |
SVC #0(Supervisor Call) |
| 系统调用号传递 | rax 寄存器 | x8 寄存器 |
| 参数传递寄存器 | rdi, rsi, rdx, r10, r8, r9 | x0, x1, x2, x3, x4, x5 |
| 系统调用表定义文件 | arch/x86/kernel/syscall_64.c |
arch/arm64/kernel/sys.c |
| 表类型 | sys_call_ptr_t(函数指针) |
syscall_fn_t(函数指针) |
| 系统调用号范围 | 0 ~ 449(Linux 5.15) | 0 ~ 445(Linux 5.15) |
| 兼容层支持 | ia32 兼容表(32 位系统调用) | compat 表(32 位 AArch32 兼容) |
| 入口函数 | entry_SYSCALL_64 |
el0_svc |
| 表查找方式 | 直接数组索引 | 直接数组索引 |
arm64 架构的系统调用表定义示例:
// arch/arm64/kernel/sys.c (Linux 5.x)
asmlinkage const syscall_fn_t sys_call_table[__NR_syscalls] = {
[0 ... __NR_syscalls-1] = (syscall_fn_t)&sys_ni_syscall,
#include <asm/unistd.h>
};
架构差异注意:arm64 使用
x8 寄存器传递系统调用号,而 x86_64 使用 rax。此外,arm64 的系统调用号范围通常比 x86_64 略小,且 arm64 没有独立的 32 位兼容表(通过 AArch32 状态处理)。
下图展示了 x86 和 arm64 在系统调用表查找流程上的架构差异:
2.4 系统调用表的查找机制
无论是 x86 还是 arm64,系统调用表的查找都遵循以下通用流程:
- 保存上下文:进入内核态后,保存用户态寄存器状态到
pt_regs结构 - 提取系统调用号:从特定寄存器(x86: rax, arm64: x8)读取系统调用号
- 合法性检查:验证系统调用号是否在有效范围内(0 ~ NR_syscalls-1)
- 查表索引:以系统调用号为索引,从
sys_call_table中取出函数指针 - 间接调用:通过函数指针调用对应的系统调用处理函数
- 结果返回:将返回值写入指定寄存器(x86: rax, arm64: x0),恢复上下文并返回用户态
性能优化:现代内核使用
indirect_branch 和 retpoline 技术来缓解 Spectre v2 变种对间接调用的影响。系统调用表的间接调用也受到这些保护机制的影响。
系统调用表查找的核心代码路径(x86_64 汇编入口简化):
// arch/x86/entry/entry_64.S (简化)
ENTRY(entry_SYSCALL_64)
swapgs
movq %rsp, PER_CPU_VAR(cpu_tss_rw + TSS_sp2)
/* 保存寄存器到 pt_regs */
/* ... */
movq %rax, %rdi /* 系统调用号作为第一个参数 */
call do_syscall_64 /* 调用 C 函数 */
/* 恢复寄存器并返回 */
/* ... */
END(entry_SYSCALL_64)
// do_syscall_64() 函数
__visible void do_syscall_64(unsigned long nr, struct pt_regs *regs)
{
struct thread_info *ti;
/* ... */
if (likely(nr < NR_syscalls)) {
nr = array_index_nospec(nr, NR_syscalls);
regs->ax = sys_call_table[nr](regs);
}
}
上述代码中的 array_index_nospec() 是一个关键的安全宏,用于防止 Spectre v1 类型的边界检查绕过攻击,确保即使 CPU 预测执行错误,也不会访问越界的系统调用表条目。
调试技巧:可以通过
/proc/kallsyms 查看系统调用表的实际内存地址,然后使用 gdb 或 crash 工具直接读取该地址处的函数指针数组,验证系统调用号与函数的对应关系。