系统调用的实现
在 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 15 SYSCALL_DEFINE3(silly_copy, unsigned long *, src, unsigned long *, dst, unsigned long len) { unsigned long buf; if (copy_from_user(&buf, src, len)) return -EFAULT; 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 2 if (!capable(CAP_SYS_BOOT)) return -EPERM;
capable(CAP_SYS_BOOT)
检查调用者是否有重启系统的权限(CAP_SYS_BOOT)。
超级用户(root)默认拥有所有能力,普通用户默认没有。
reboot() 系统调用部分实现: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 SYSCALL_DEFINE4(reboot, int magic1, int magic2, unsigned int cmd, void __user *, arg) { char buffer[256 ]; if (!capable(CAP_SYS_BOOT)) return -EPERM; if (magic1 != LINUX_REBOOT_MAGIC1 || (magic2 != LINUX_REBOOT_MAGIC2 && magic2 != LINUX_REBOOT_MAGIC2A && magic2 != LINUX_REBOOT_MAGIC2B && magic2 != LINUX_REBOOT_MAGIC2C)) return -EINVAL; }
-
首先检查权限,只有拥有 CAP_SYS_BOOT 能力的进程才能重启系统。 - 然后检查
magic 参数,只有传入特定的“魔数”才允许执行,防止误操作。
能力列表 可参考
<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 9 ENTRY(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>
文件中添加宏定义。例如:
将系统调用编译进内核镜像
系统调用必须编译进核心内核镜像(不能作为模块)。通常把实现代码放在
kernel/ 目录下相关的文件中,比如 sys.c。如果和调度相关,可以放在
kernel/sched.c。
示例:实现 foo() 系统调用
在 kernel/sys.c 中实现: 1 2 3 4 5 6 7 8 #include <asm/page.h> 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 #define __NR_open 5 _syscall3(long , open, const char *, filename, int , flags, int , mode)
这样,应用程序就可以直接调用 open() 了。
每个宏的参数为 2 + 2 ×
n:第一个参数是返回类型,第二个是系统调用名,后面依次是每个参数的类型和名字。__NR_open
定义在
<asm/unistd.h>
,表示系统调用号。_syscall3
宏会展开为带有内联汇编的 C
函数,自动完成系统调用号和参数的传递,并发出软中断进入内核。只需在应用中写这个宏,就能直接使用
open() 系统调用。
示例:用户空间调用自定义 foo() 系统调用
1 2 3 4 5 6 7 8 9 #define __NR_foo 283 _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 已经相对稳定且功能完善。