操作系统基础 | 3. 系统调用
与内核通信
系统调用(system call)在硬件和用户空间进程之间提供了一层抽象,这一层有三个主要作用:
硬件抽象
系统调用为用户空间提供了统一的硬件接口。例如,应用程序在读写文件时,无需关心底层的磁盘类型、介质类型,甚至文件所在的文件系统类型。系统安全与稳定
有了内核作为中介,内核可以根据权限、用户等标准仲裁资源访问。这防止了应用程序误用硬件、窃取其他进程资源或对系统造成破坏。虚拟化与多任务支持
用户空间与系统其他部分之间有统一的接口,便于实现进程虚拟化和多任务。如果应用能直接访问系统资源,将难以实现多任务和虚拟内存,更无法保证系统的稳定和安全。
在 Linux
中,系统调用是用户空间与内核交互的唯一合法入口(除了异常和陷阱)。即使是设备文件或
/proc
这样的接口,最终也要通过系统调用访问。值得一提的是,Linux
的系统调用数量比大多数系统要少。
API、POSIX 与 C 库
通常,应用程序是基于用户空间的 API(应用程序编程接口)开发的,而不是直接调用系统调用。这很重要,因为 API 与内核实际提供的接口之间不需要一一对应。API 定义了一组供应用程序使用的编程接口,这些接口可以通过一个系统调用实现,也可以通过多个系统调用,甚至完全不依赖系统调用。这样,同样的 API 可以在不同系统上实现,应用程序无需关心底层实现细节。
在 Unix 世界中,最常见的 API 之一是基于 POSIX 标准的。POSIX 是 IEEE 制定的一系列标准,旨在提供基于 Unix 的可移植操作系统标准。Linux 在适用的地方努力兼容 POSIX 和 SUSv3。
POSIX 很好地体现了 API 与系统调用的关系。在大多数 Unix 系统中,POSIX API 与系统调用高度相关,POSIX 标准本身就是参考早期 Unix 系统接口制定的。但有些非 Unix 系统(如 Windows)也提供了 POSIX 兼容库。
C 库的作用
在 Linux 和大多数 Unix 系统中,C 库(如 glibc)部分实现了系统调用接口。C 库不仅实现了标准 C 库,还实现了系统调用接口,是所有 C 程序的基础。由于 C 语言的特性,其他编程语言也可以很方便地调用 C 库。C 库还实现了大部分 POSIX API。
对应用开发者来说,系统调用的细节并不重要,他们只关心 API;而内核只关心系统调用,至于哪些库函数或应用程序会用到这些系统调用,内核并不关心。不过,内核需要保证系统调用的通用性和灵活性,以适应各种用途。
系统调用(Syscalls)
在 Linux 中,系统调用(syscall)通常通过 C 库中定义的函数进行访问。系统调用可以有零个、一个或多个参数(输入),并可能产生一个或多个副作用,例如写文件或将数据复制到指定指针。系统调用还会返回一个 long 类型的值,用于表示成功或错误——通常(但并非总是)负值表示错误,返回值为 0 通常(但也不是总是)表示成功。
当系统调用出错时,C 库会将一个特殊的错误码写入全局变量
errno
。可以通过如 perror()
这样的库函数将
errno
转换为可读的错误信息。
系统调用有明确的行为定义。例如,getpid()
系统调用被定义为返回当前进程的 PID。其内核实现大致如下:
1 | SYSCALL_DEFINE0(getpid) |
注:你可能会好奇为什么
getpid()
返回的是 tgid(线程组ID)?在普通进程中,TGID 等于 PID;而在线程中,同一线程组的所有线程 TGID 相同,这样所有线程调用getpid()
时返回相同的 PID。
需要注意的是,定义只规定了行为,具体实现方式由内核决定,只要结果正确即可。SYSCALL_DEFINE0
是一个宏,用于定义无参数的系统调用(0 表示参数个数)。展开后类似于:
1 | asmlinkage long sys_getpid(void) |
asmlinkage
修饰符告诉编译器只从栈上获取参数,这是所有系统调用都需要的修饰符。- 返回类型为 long,是为了兼容 32 位和 64 位系统。即使用户空间定义为 int,内核中也返回 long。
- 命名约定:内核中系统调用的实现函数名为
sys_xxx()
,如getpid()
对应sys_getpid()
。
系统调用号(System Call Numbers)
在 Linux 中,每个系统调用都有唯一的系统调用号(syscall number),用于标识具体的系统调用。用户空间进程执行系统调用时,实际上是通过系统调用号来指定调用哪个系统调用,而不是用名字。
系统调用号非常重要,一旦分配就不能更改,否则已编译的应用程序会出错。同样,如果某个系统调用被移除,其编号也不能被回收,否则旧程序会调用到错误的系统调用。Linux
提供了一个“未实现”系统调用 sys_ni_syscall()
,它只返回
-ENOSYS(表示无效系统调用),用于填补被移除或不可用的系统调用号。
内核通过系统调用表(sys_call_table
)维护所有已注册的系统调用。在
x86-64 架构下,这个表定义在 arch/i386/kernel/syscall_64.c
文件中,每个有效的系统调用都分配有唯一的编号。
系统调用性能
Linux 的系统调用比许多其他操作系统更快,这部分归功于 Linux 的上下文切换速度快,进入和退出内核的过程非常简洁高效,系统调用处理器和各个系统调用本身也很简单。
系统调用处理器(System Call Handler)
用户空间的应用程序无法直接执行内核代码,也不能直接调用内核空间的方法,因为内核处于受保护的内存空间。如果应用可以直接读写内核地址空间,系统的安全性和稳定性将无法保证。
因此,用户空间的应用程序必须通过某种方式通知内核,让系统切换到内核态,由内核代表应用程序在内核空间执行系统调用。
进入内核的机制
这种通知内核的机制是一种软件中断:即触发一个异常,系统会切换到内核态并执行异常处理程序。在系统调用的场景下,这个异常处理程序就是系统调用处理器(system call handler)。
- 在 x86 架构上,定义的软件中断号为 128,通过
int $0x80
指令触发。这会导致系统切换到内核态,并执行异常向量 128(即系统调用处理器)。 - 系统调用处理器的函数名通常为
system_call()
,它是与架构相关的代码(如 x86-64 下在entry_64.S
汇编文件中实现)。 - 近年来,x86 处理器增加了
sysenter
指令,这是一种比int $0x80
更快、更专用的进入内核执行系统调用的方法。内核很快就支持了这种方式。 - 无论采用哪种方式,核心思想都是:用户空间通过异常或陷阱(trap)进入内核。
指定正确的系统调用
仅仅进入内核空间还不够,因为有很多不同的系统调用,它们都是通过相同的方式进入内核的。因此,必须将系统调用号传递给内核。
在 x86 架构上,系统调用号通过
eax
32位寄存器传递。在触发陷阱进入内核前,用户空间会把所需系统调用的编号写入eax
。系统调用处理器读取
eax
的值,判断其有效性(与NR_syscalls
比较)。如果编号无效,返回-ENOSYS
错误;否则,通过系统调用表调用对应的系统调用函数:> rax和eax均为累加器,区别是rax是64位, eax32位1
call *sys_call_table(,%rax,8)
这里每个表项 8 字节(64 位),所以用 8 乘以系统调用号定位表项(x86-32 下用 4 乘以系统调用号)。
参数传递
除了系统调用号,大多数系统调用还需要传递一个或多个参数。用户空间必须在陷阱捕获过程中时将参数传递给内核。
- 最简单的方式是通过寄存器传递参数。在 x86-32
架构下,
ebx
、ecx
、edx
、esi
、edi
依次存放前五个参数。 - 如果参数超过五个,则用一个寄存器传递指向用户空间参数数组的指针。
- 返回值也通过寄存器传递,x86 下写入
eax
。
调用系统调用处理器并执行系统调用的流程
1
2
3
4
5
6
7
8
9+-------------------+ +-------------------+
| User Space | | Kernel Space |
|-------------------| |-------------------|
| Application | | Syscall Handler |
| call read() | | system_call() |
|-------------------| |-------------------|
| C library | | sys_read() |
| read() wrapper | | |
+-------------------+ +-------------------+
- 应用程序调用
read()
,实际上先调用 C 库的read()
封装函数。 - C 库的
read()
封装函数通过软中断(如int $0x80
或sysenter
)进入内核,调用system_call()
。 system_call()
读取系统调用号和参数,查找并调用内核中的sys_read()
实现。- 执行完毕后,返回值通过寄存器传回用户空间,流程反向返回到应用程序。