以下是对 The Linux Programming Interface「3.1 系统调用」章节的摘抄,这本书的中文版和英文版我有在对照着看。
系统调用是受控的内核入口,借助这一机制,进程可以请求内核以自己的名义去执行某些动作。内核以 API 的形式提供了一系列服务供程序访问,包括创建进程、执行 I/O 任务,以及为进程间通信创建管道等。手册也 syscalls(2) 列出了 Linux 的系统调用。)
在深入系统调用的运作方式之前,一定要明确一下几点:
- 系统调用将 CPU 从用户态切换到核心态,以便 CPU 可以访问受保护的内核内存。
- 系统调用的数量是固定的,每个系统调用通过一个唯一的数字来标识。(但程序是通过名称来标识系统调用的,这套数字标识对程序是不可见的。)
- 系统调用可以通过一套参数来指定哪些用户空间的信息(比如进程的虚拟地址空间)需要读入内核空间,反之亦可。
从编程的角度来看,系统调用与 C 语言的函数调用很相似。然而,执行系统调用会经历诸多步骤。以 x86-32 硬件平台为例,按时间顺序会发生以下事情:
- 应用程序通过调用 C 语言函数库中的包装(wrapper)函数来发起系统调用。
- 包装函数必须保证所有系统调用的参数对系统调用中断处理程序(稍候介绍)是可用的,这些参数传递给包装函数时是通过栈(stack)来实现的,但内核需要这些参数位于寄存器中,所以包装函数需要把参数从栈复制到寄存器里。
- 由于所有系统调用进入内核的方式相同,所以内核需要设法区分每个系统调用。因此,包装函数会将系统调用的编号复制到 CPU 的 %eax 寄存器中。
- 包装函数还会执行一个中断指令(int 0x80),此指令会让 CPU 从用户态切换到核心态,并执行系统中断 0x80 的中断矢量所指向的代码。(较新的 x86-32 硬件平台实现了 sysenter 指令,可以更快地进入内核。)
- 为了响应中断 0x80,内核会调用 system_call() 程序来处理此次中断,具体步骤如下:
- 在内核栈中保存寄存器的值。
- 检查系统调用编号的有效性。
- 搜索 sys_call_table,找到对应的调用服务程序进行调用。如果调用需要参数,则检查参数的有效性。最后该服务程序会将结果返回给 system_call() 程序。
- 从内核栈中回复各寄存器的值,并将系统调用返回值置于栈中。
- 返回值包装函数,同时将 CPU 切回用户态。
- 如果返回值表明调用出错,包装函数会将返回值设置到全局变量 errno,然后包装函数会将一个整数返回给调用程序,以表明调用是否成功。
如果只是为了掌握本书的后续内容,这里的论述的确有些小题大做。但重点在于阐明即使是一个简单的系统调用,也需要完成非常多的工作,因此系统调用的开销虽然小,但对比起普通的函数调用,这些开销不容忽视。
在坐着的系统上,调用 getppid() 获取父进程的 ID,执行一千万次大约需要 2.2 秒钟;但在统一系统上,调用某个普通的 C 语言函数一千万次,只需要 0.11 秒钟,约为调用 getppid() 耗时的 5%。而大多数系统调用的开销都明显高于 getppid()。