软件性能分析与Profiling工程 - 第3节
3. 系统调用号与系统调用表
系统调用号,说白了就是内核给每个系统调用分配的一个唯一整数。你想想看,用户态程序要请求内核服务,总得有个约定好的“暗号”吧?这个暗号就是系统调用号。
在 x86-64 架构的 Linux 系统里,系统调用号定义在 arch/x86/entry/syscalls/syscall_64.tbl 这个文件里。我刚开始看这个文件时,觉得它就是个简单的表格,后来才发现——嗯,这里面的门道还挺多。
系统调用号的定义方式
每个系统调用号由三部分组成:
- 编号:从 0 开始递增的整数
- ABI:调用约定(如
common、64、x32) - 函数名:内核中对应的实现函数
举个例子,read 系统调用的定义是这样的:
# 在 syscall_64.tbl 中
0 common read sys_read
1 common write sys_write
2 common open sys_open
...
这里的 0 就是 read 的系统调用号。common 表示这个调用在 64 位和 32 位模式下都可用。sys_read 是内核里实际干活的那个函数。
关键点:系统调用号一旦分配,就不会轻易改变。这是为了保证向后兼容性。我见过一些老程序,编译于 20 年前,现在还能跑在新内核上——靠的就是这个稳定性。
系统调用号的分配策略
内核社区对系统调用号的分配有一套不成文的规矩:
- 新系统调用号从当前最大值往后排
- 废弃的系统调用号会保留,但标记为
sys_ni_syscall(空函数) - 不同架构的系统调用号可以不同(比如 ARM 和 x86 就不一样)
我曾经在移植一个驱动时,发现 ARM 上的 openat 系统调用号是 322,而 x86 上是 257。当时排查了半天,最后发现是系统调用号搞混了——这种坑,踩过一次就记住了。
系统调用表:内核的“函数跳转板”
系统调用表,本质上就是一个函数指针数组。每个数组元素指向一个系统调用处理函数。当用户程序发起系统调用时,内核通过系统调用号去查这个表,找到对应的函数来执行。
在 x86-64 架构上,系统调用表定义在 arch/x86/kernel/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,
// ... 其他系统调用
};
你看这个写法,先全部初始化为 sys_ni_syscall(一个只返回 -ENOSYS 的空函数),然后再覆盖具体的系统调用。这样做的好处是:如果某个系统调用号没被分配,调用它就会直接返回“不支持”,不会导致内核崩溃。
系统调用表的组织方式
系统调用表的组织方式,说白了就是一个大数组。但这里有几个细节值得注意:
- 索引即编号:数组下标就是系统调用号,所以查找是 O(1) 的
- 稀疏填充:未使用的槽位填的是
sys_ni_syscall - 架构相关:每个架构都有自己的系统调用表
我画了一张图,帮你理解系统调用表的组织逻辑:
从这张图你可以看到:用户程序带着系统调用号(比如 0)进入内核,内核直接用这个号去查表,找到对应的函数指针,然后跳转执行。整个过程干净利落,没有多余的查找开销。
系统调用表的初始化
系统调用表是在内核启动时初始化的。具体流程是这样的:
- 内核链接器脚本把
sys_call_table放到特定的内存段 - 启动时,内核遍历
syscall_64.tbl中定义的所有系统调用 - 把每个系统调用号对应的函数指针填入数组
- 未使用的槽位保持
sys_ni_syscall
小技巧:你可以用 ausyscall --dump 命令查看当前系统支持的所有系统调用号。我个人习惯在调试时先用这个命令确认系统调用号是否正确。
系统调用号的查找与使用
在实际编程中,我们很少直接操作系统调用号——那是 libc 的事。但理解它的工作原理,对排查问题很有帮助。
用户态视角
在用户态,系统调用号通过寄存器传递。x86-64 架构下,系统调用号放在 rax 寄存器中:
; 汇编层面的系统调用
mov rax, 0 ; 系统调用号 0 = read
mov rdi, 3 ; 第一个参数:文件描述符
mov rsi, buf ; 第二个参数:缓冲区
mov rdx, 1024 ; 第三个参数:读取大小
syscall ; 触发系统调用
你看,这里 rax = 0 就是告诉内核:“我要调用 read 系统调用”。内核拿到这个 0,直接去查 sys_call_table[0],找到 sys_read 函数来执行。
内核态视角
内核处理系统调用的入口在 entry_64.S 中:
// 简化后的系统调用入口
entry_SYSCALL_64:
// 保存寄存器状态
// ...
// 检查系统调用号是否合法
cmpq $__NR_syscall_max, %rax
ja return_error
// 查表并调用
call *sys_call_table(, %rax, 8)
// 返回用户态
// ...
这里的关键是 call *sys_call_table(, %rax, 8) 这条指令。它用 rax 中的系统调用号作为索引,乘以 8(因为每个指针占 8 字节),然后从 sys_call_table 基地址开始偏移,取出函数指针并调用。
注意:系统调用号检查是必须的。如果用户程序传了一个超大的系统调用号,没有检查的话就会访问到数组边界之外的内存——轻则内核崩溃,重则被利用提权。我曾经在代码审查时发现过这种漏洞,还好及时修复了。
系统调用表的动态修改
标准情况下,系统调用表是只读的。但有些场景需要动态修改它:
- 内核模块:某些安全模块会 hook 系统调用
- 调试工具:如
strace通过 ptrace 拦截系统调用 - 恶意软件:rootkit 会修改系统调用表来隐藏自己
现代内核已经禁止直接修改系统调用表了。如果你尝试写 sys_call_table[0] = my_read,内核会报错——因为那个内存页被标记为只读。
我建议你记住一点:系统调用表是内核的“核心资产”,不要轻易动它。真有需求的话,用 kprobes 或 ftrace 这类官方机制,别自己硬改。
不同架构的系统调用号差异
不同 CPU 架构的系统调用号可能完全不同。举个例子:
| 系统调用 | x86-64 编号 | ARM 32 位编号 | RISC-V 64 位编号 |
|---|---|---|---|
| read | 0 | 3 | 63 |
| write | 1 | 4 | 64 |
| open | 2 | 5 | 1024 |
| close | 3 | 6 | 57 |
看到没?同一个 read 系统调用,在不同架构上的编号完全不同。这就是为什么用户程序不能直接硬编码系统调用号——你得通过 libc 来屏蔽这些差异。
我在做嵌入式开发时,经常要在 x86 上写代码,然后交叉编译到 ARM 上跑。如果代码里直接用了 syscall(0, ...) 这种写法,换到 ARM 上就全崩了。所以,老老实实用 libc 提供的接口,别自己折腾系统调用号。
总结一下:系统调用号是用户态和内核态的“通信协议”,系统调用表是内核的“函数调度中心”。理解这两者的工作原理,能帮你更好地理解系统调用的整个流程。下次遇到系统调用相关的 bug,你至少知道该从哪里查起了。
可可精品资料库