操作系统基础 | 3.2 系统调用实现

系统调用的实现

在 Linux 中,实际实现一个系统调用时,不需要关心系统调用处理器(system call handler)的具体行为。因此,向 Linux 添加一个新的系统调用相对容易。难点在于设计和实现系统调用本身,而将其注册到内核则很简单。下面是编写新系统调用的主要步骤。

实现系统调用的步骤

  1. 明确目的
    首先要定义系统调用的用途。系统调用应该只做一件事。Linux 不鼓励“多路复用”系统调用(即通过一个参数让同一个系统调用做完全不同的事情),如 ioctl() 就是反面教材。

  2. 参数、返回值和错误码设计
    系统调用应有简洁、清晰的接口,参数数量应尽量少。其语义和行为必须稳定,不能随意更改,因为已有应用会依赖这些行为。要有前瞻性,考虑未来是否需要扩展功能,是否能在不破坏兼容性的前提下修复 bug。很多系统调用会设计一个 flag 参数,用于将来扩展功能(不是用来多路复用行为,而是为了兼容性和可扩展性)。

  3. 接口设计要通用、可移植
    不要让接口过于局限当前用途。系统调用的用途可能会变化,但其本质目的应保持不变。要考虑可移植性,不要假设特定架构的字长或字节序。Unix 的设计哲学是“提供机制,不规定策略”。

  4. 关注可移植性和健壮性
    编写系统调用时要考虑未来的可移植性和健壮性。Unix 的基本系统调用经受住了时间考验,几十年后依然适用。


参数校验

系统调用必须严格校验所有参数,确保其有效和合法。系统调用在内核空间运行,如果用户能随意传递无效参数,系统的安全和稳定性会受到威胁。

  • 例如,文件 I/O 系统调用要检查文件描述符是否有效;进程相关函数要检查 PID 是否有效。每个参数都要验证其正确性,防止进程请求访问其无权访问的资源。

  • 指针参数的校验尤为重要。如果进程能传递任意指针给内核,可能会让内核访问本不该访问的数据(如其他进程的数据或内核空间数据)。因此,在内核跟随用户空间指针前,必须确保:

    1. 指针指向用户空间内存,不能让进程让内核访问内核空间。
    2. 指针指向的是本进程的地址空间,不能访问其他进程的数据。
    3. 读操作时内存必须可读,写操作时必须可写,执行操作时必须可执行,不能绕过内存访问权限。
  • 内核提供了两种方法来进行这些检查和数据拷贝,内核代码绝不能直接跟随用户空间指针,必须使用以下两种方法之一:

    • 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;
// 从用户空间 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
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;
// 检查 magic 参数,防止误操作
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(),最终切换回用户空间,继续执行用户进程。

系统调用注册的最后步骤

系统调用代码写好后,将其注册为正式系统调用的过程很简单:

  1. 在系统调用表中添加条目
    • 对于每个支持该系统调用的架构,都要在系统调用表(如 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)。
  2. 在 <asm/unistd.h> 中定义系统调用号
    • 每个架构都要在对应的 <asm/unistd.h> 文件中添加宏定义。例如:
      1
      #define __NR_foo 338
  3. 将系统调用编译进内核镜像
    • 系统调用必须编译进核心内核镜像(不能作为模块)。通常把实现代码放在 kernel/ 目录下相关的文件中,比如 sys.c。如果和调度相关,可以放在 kernel/sched.c。

示例:实现 foo() 系统调用

  • 在 kernel/sys.c 中实现:
    1
    2
    3
    4
    5
    6
    7
    8
    #include <asm/page.h>
    /*
    * 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
#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 已经相对稳定且功能完善。