操作系统基础 | 3.2 系统调用实现
系统调用的实现
在 Linux 中,实际实现一个系统调用时,不需要关心系统调用处理器(system call handler)的具体行为。因此,向 Linux 添加一个新的系统调用相对容易。难点在于设计和实现系统调用本身,而将其注册到内核则很简单。下面是编写新系统调用的主要步骤。
实现系统调用的步骤
明确目的
首先要定义系统调用的用途。系统调用应该只做一件事。Linux 不鼓励“多路复用”系统调用(即通过一个参数让同一个系统调用做完全不同的事情),如 ioctl() 就是反面教材。参数、返回值和错误码设计
系统调用应有简洁、清晰的接口,参数数量应尽量少。其语义和行为必须稳定,不能随意更改,因为已有应用会依赖这些行为。要有前瞻性,考虑未来是否需要扩展功能,是否能在不破坏兼容性的前提下修复 bug。很多系统调用会设计一个 flag 参数,用于将来扩展功能(不是用来多路复用行为,而是为了兼容性和可扩展性)。接口设计要通用、可移植
不要让接口过于局限当前用途。系统调用的用途可能会变化,但其本质目的应保持不变。要考虑可移植性,不要假设特定架构的字长或字节序。Unix 的设计哲学是“提供机制,不规定策略”。关注可移植性和健壮性
编写系统调用时要考虑未来的可移植性和健壮性。Unix 的基本系统调用经受住了时间考验,几十年后依然适用。
参数校验
系统调用必须严格校验所有参数,确保其有效和合法。系统调用在内核空间运行,如果用户能随意传递无效参数,系统的安全和稳定性会受到威胁。
例如,文件 I/O 系统调用要检查文件描述符是否有效;进程相关函数要检查 PID 是否有效。每个参数都要验证其正确性,防止进程请求访问其无权访问的资源。
指针参数的校验尤为重要。如果进程能传递任意指针给内核,可能会让内核访问本不该访问的数据(如其他进程的数据或内核空间数据)。因此,在内核跟随用户空间指针前,必须确保:
- 指针指向用户空间内存,不能让进程让内核访问内核空间。
- 指针指向的是本进程的地址空间,不能访问其他进程的数据。
- 读操作时内存必须可读,写操作时必须可写,执行操作时必须可执行,不能绕过内存访问权限。
内核提供了两种方法来进行这些检查和数据拷贝,内核代码绝不能直接跟随用户空间指针,必须使用以下两种方法之一:
copy_to_user()
:用于将数据从内核空间写入用户空间。参数分别为用户空间目标地址、内核空间源地址、拷贝字节数。copy_from_user()
:用于从用户空间读取数据到内核空间。参数分别为内核空间目标地址、用户空间源地址、拷贝字节数。
这两个函数在出错时返回未拷贝的字节数,成功时返回 0。系统调用遇到这种错误时,通常返回
-EFAULT
。
下面以一个简单的系统调用 silly_copy()
为例,说明如何在内核中安全地从用户空间读取和写入数据。这个系统调用的功能是:将用户空间
src
指向的数据拷贝到
dst
,中间通过内核缓冲区作为中转。虽然实际用途不大,但有助于理解
copy_from_user()
和 copy_to_user()
的用法。
核心代码片段: 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15SYSCALL_DEFINE3(silly_copy,
unsigned long *, src,
unsigned long *, dst,
unsigned long len)
{
unsigned long buf;
// 从用户空间 src 拷贝 len 字节到内核缓冲区 buf
if (copy_from_user(&buf, src, len))
return -EFAULT;
// 从内核缓冲区 buf 拷贝 len 字节到用户空间 dst
if (copy_to_user(dst, &buf, len))
return -EFAULT;
// 返回拷贝的字节数
return len;
}copy_from_user()
:将用户空间数据拷贝到内核空间,失败时返回未拷贝的字节数,成功返回0。
-
copy_to_user()
:将内核空间数据拷贝到用户空间,失败时返回未拷贝的字节数,成功返回0。
- 如果拷贝失败,系统调用返回 -EFAULT
。
注意:
这两个函数在数据页不在物理内存时可能会阻塞(如数据被换出到磁盘),此时进程会休眠直到页面被调入内存。
权限检查与能力机制
在早期 Linux 版本中,系统调用如果需要超级用户权限,会用
suser()
检查是否为 root。现在,Linux
使用更细粒度的“能力(capabilities)”机制。通过 capable()
函数检查调用进程是否拥有某项能力。例如:
1 | if (!capable(CAP_SYS_BOOT)) |
capable(CAP_SYS_BOOT)
检查调用者是否有重启系统的权限(CAP_SYS_BOOT)。- 超级用户(root)默认拥有所有能力,普通用户默认没有。
reboot() 系统调用部分实现: 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15SYSCALL_DEFINE4(reboot, int magic1, int magic2, unsigned int cmd, void __user *, arg)
{
char buffer[256];
// 只允许超级用户重启系统
if (!capable(CAP_SYS_BOOT))
return -EPERM;
// 检查 magic 参数,防止误操作
if (magic1 != LINUX_REBOOT_MAGIC1 ||
(magic2 != LINUX_REBOOT_MAGIC2 &&
magic2 != LINUX_REBOOT_MAGIC2A &&
magic2 != LINUX_REBOOT_MAGIC2B &&
magic2 != LINUX_REBOOT_MAGIC2C))
return -EINVAL;
// ... 省略后续命令处理 ...
}
能力列表可参考
<linux/capability.h>
,每种能力对应不同的系统资源访问权限。
系统调用上下文(System Call Context)
如第3章所述,在执行系统调用期间,内核处于进程上下文(process
context)。此时,current
指针指向当前任务(即发起系统调用的进程)。
- 在进程上下文中,内核可以休眠(比如系统调用阻塞或显式调用
schedule()
),并且是完全可抢占的。- 能够休眠意味着系统调用可以使用大部分内核功能,这极大简化了内核编程(相比中断处理程序,中断处理程序不能休眠,功能受限)。
- 可抢占意味着当前任务可能被其他任务抢占,新的任务可能会执行同一个系统调用,因此系统调用实现必须可重入,这和多核并发下的同步问题类似。
当系统调用返回时,控制权回到
system_call()
,最终切换回用户空间,继续执行用户进程。
系统调用注册的最后步骤
系统调用代码写好后,将其注册为正式系统调用的过程很简单:
- 在系统调用表中添加条目
- 对于每个支持该系统调用的架构,都要在系统调用表(如 entry.S)末尾添加一项。表中每一项的位置(从0开始)就是系统调用号。例如,第10项的系统调用号是9。
- 表示例(部分):
1
2
3
4
5
6
7
8
9ENTRY(sys_call_table)
.long sys_restart_syscall /* 0 */
.long sys_exit
.long sys_fork
.long sys_read
.long sys_write
.long sys_open /* 5 */
...
.long sys_foo /* 新增的系统调用 */ - 新系统调用自动获得下一个可用的系统调用号(如338)。
- 在 <asm/unistd.h> 中定义系统调用号
- 每个架构都要在对应的
<asm/unistd.h>
文件中添加宏定义。例如:1
- 每个架构都要在对应的
- 将系统调用编译进内核镜像
- 系统调用必须编译进核心内核镜像(不能作为模块)。通常把实现代码放在 kernel/ 目录下相关的文件中,比如 sys.c。如果和调度相关,可以放在 kernel/sched.c。
示例:实现 foo() 系统调用
- 在 kernel/sys.c 中实现:
1
2
3
4
5
6
7
8
/*
* sys_foo – 返回每个进程的内核栈大小
*/
asmlinkage long sys_foo(void)
{
return THREAD_SIZE;
} - 编译并启动新内核后,用户空间即可通过系统调用号调用 foo()。
用户空间访问系统调用
通常,C 标准库(如 glibc)会为系统调用提供支持。用户程序只需包含标准头文件并链接 C 库,就可以直接调用系统调用(或调用库函数间接使用系统调用)。但如果你刚刚实现了一个新的系统调用,glibc 很可能还没有为它提供支持!
幸运的是,Linux
提供了一组宏来帮助用户空间访问系统调用。这些宏会设置好寄存器内容并发出陷阱指令。宏的名字为
_syscalln()
,其中 n 取 0 到
6,表示系统调用参数的个数。宏需要知道参数个数,以便正确地将参数压入寄存器。
例如,open() 系统调用的原型为: 1
long open(const char *filename, int flags, int mode)
1
2
_syscall3(long, open, const char *, filename, int, flags, int, mode)
每个宏的参数为 2 + 2 ×
n:第一个参数是返回类型,第二个是系统调用名,后面依次是每个参数的类型和名字。__NR_open
定义在
<asm/unistd.h>
,表示系统调用号。_syscall3
宏会展开为带有内联汇编的 C
函数,自动完成系统调用号和参数的传递,并发出软中断进入内核。只需在应用中写这个宏,就能直接使用
open() 系统调用。
示例:用户空间调用自定义 foo() 系统调用
1
2
3
4
5
6
7
8
9
_syscall0(long, foo)
int main() {
long stack_size;
stack_size = foo();
printf("The kernel stack size is %ld\n", stack_size);
return 0;
}
为什么不建议随意实现新系统调用
虽然实现新系统调用很容易,但这并不意味着你应该随意添加。实际上,添加新系统调用要非常谨慎。很多情况下,有更合适的替代方案。
实现新系统调用的优点: - 实现简单,使用方便。 - 在 Linux 上系统调用性能很高。
缺点: - 需要分配一个系统调用号,必须官方分配。 - 一旦进入稳定内核版本,接口就不能随意更改,否则会破坏用户空间应用的兼容性。 - 每个架构都要单独注册和支持该系统调用。 - 系统调用不能直接被脚本调用,也不能直接通过文件系统访问。 - 需要分配系统调用号,难以在主线内核树之外维护和使用。 - 对于简单信息交换,系统调用显得过于繁重。
常见替代方案: - 实现一个设备节点,通过 read()/write() 进行数据交换,使用 ioctl() 操作特定设置或获取信息。 - 某些接口(如信号量)可以用文件描述符表示并进行操作。 - 将信息作为文件添加到 sysfs 的合适位置。
对于许多接口,系统调用确实是正确的选择。但 Linux 一直避免为每个新抽象都添加系统调用,这使得系统调用层非常简洁、稳定,很少有废弃接口。新系统调用增加速度慢,说明 Linux 已经相对稳定且功能完善。