Linux Kernel: System Calls
操作系统为用户态进程与硬件设备(如 CPU,磁盘,打印机等)进行交互提供了一组接口。这种方式有以下优点:
- 编程更加容易:用户不需要学习底层硬件设备编程
- 提高了系统安全性:内核在满足某个请求之前,可以检查该请求的正确性
- 使程序更具可移植性:只要内核提供的接口相同,那么在任意内核上都可以正确编译 & 执行程序
Unix 系统通过向内核发出系统调用(system call)实现了用户态进程与硬件设备之间的大部分接口。
1-POSIX API 与系统调用
API 只是一个函数定义,说明了如何获得一个给定的服务;而系统调用是通过软中断向内核态发出一个明确的请求。
libc 标准库定义的一些 API 引用了封装例程(wrapper routine),封装例程的目的就是为了发布系统调用。通常,每个系统调用对应一个封装例程,而封装例程定义了应用程序使用的 API。但是反过来,一个 API 没必要对应一个特定的系统调用。
API 可能直接提供用户态的服务;一个 API 实现可能调用多个系统调用;不同的 API 可能调用了同一个系统调用
POSIX API 标准只是针对 API,而不针对系统调用。从用户角度来看,API 与系统调用没有差别(可能函数名,参数等有所不同);而从内核开发者的角度看,系统调用属于内核,而用户态的库函数不属于内核。
2-系统调用处理程序及服务例程
当用户态进程调用一个系统调用时,CPU 切换到内核态并开始执行一个内核函数;而系统调用最终会跳转到系统调用处理程序(system call handler)的汇编语言语言函数。
每个系统调用都被一个系统调用号(system call number)标识,因此进程在进行系统调用时需要传递该参数以识别所需的系统调用。
系统调用处理程序与其他异常处理程序的结构类似,执行以下操作:
- 在内核态保存大多数寄存器的内容(汇编语言编写,所有系统调用通用操作)
- 调用名为系统调用服务例程(system call service routine)的相应 C 函数来处理系统调用
- 退出系统调用处理程序:用保存在内核栈中的值加载寄存器,CPU 从内核态切换回用户态(汇编语言实现,所有系统调用通用操作)
一般来说,xyz() 系统调用对应的系统调用服务例程的名字通常为 sys_xyz()
上图展示了应用程序,封装例程,系统调用处理程序,系统调用服务例程之间的调用关系。
SYSCALL
与SYSEXIT
是汇编语言指令,分别把 CPU 从用户态切换到内核态和从内核态切换到用户态。
内核中有一个系统调用分派表(dispatch table)用于将系统调用号与相应的服务例程关联起来。
3-进入和退出系统调用
用户态进程有两种方式调用系统调用:
- 执行
int $0x80
汇编指令 - 执行
sysenter
汇编指令
内核也有两种方式从系统调用退出,从而使 CPU 切回用户态:
- 执行
iret
汇编指令 - 执行
sysexit
汇编指令
3-1 通过 int $0x80 指令发出系统调用
当用户态进程发出 int $0x80
指令时,CPU 切换到内核态并开始从地址 system_call
处(如上图)开始执行指令。
system_call()
函数首先将系统调用号与所有可能用到的 CPU 寄存器保存到相应的栈中- 该函数在
ebx
中存放当前进程的thread_info
数据结构的地址 - 检查
thread_info
结构中的相关字段 - 对用户态进程传入的系统调用号进行有效性判断
- 调用系统调用号对应的服务例程
当系统调用服务例程结束时,
system_call()
从eax
寄存器获得服务例程的返回值system_call()
关闭本地中断并检查当前进程的thread_info
结构中的相关标志(为了在返回用户态之前处理相关工作)- 跳转到
restore_all
标记处恢复保存在内核栈中的寄存器的值,并执行iret
汇编指令开始重新执行用户态进程
3-2 通过 sysenter 指令发出系统调用
汇编指令 int
需要执行几个一致性与安全检查,速度相对较慢;而 sysenter
指令被称为快速系统调用,提供了一种从用户态快速切换到内核态的方法。
- 标准库中的封装例程把系统调用号装入
eax
寄存器,并调用__kernel_vsyscall()
函数 - 函数
__kernel_vsyscall()
把ebp, edx, ecx
内容保存到用户态堆栈中(系统调用处理程序将使用这些寄存器),同时把用户栈指针拷贝到ebp
中,之后执行sysenter
指令 - CPU 从用户态切换到内核态,内核开始执行
sysenter_entry()
函数
当系统调用服务例程结束时,sysenter_entry()
函数执行与 system_call()
函数相同的操作。
4-参数传递
与普通函数类似,系统调用也需要传参,可能是实际的值,可能是用户态进程地址空间的变量。
每个系统调用至少包含系统调用号参数
普通函数的传参是把参数值写入程序栈(用户态或者内核态)实现的;而系统调用是一种横跨内核态与用户态的特殊函数,所以既不能使用用户态栈,也不能使用内核态栈。在发出系统调用之前,系统调用参数被写入 CPU 寄存器,之后在调用系统调用服务例程之前,内核再把存放在 CPU 中的参数拷贝到内核态堆栈中。
为什么不直接将参数从用户态栈拷贝到内核态栈?
- 同时操作两个栈比较复杂
- 寄存器的使用使得系统调用处理程序的结构与其他异常处理程序的结构类似
为了使用寄存器传递参数,对参数有些限制:参数长度不能超过寄存器的长度(32 位);参数不能超过 6 个。
用于存放系统调用号与系统调用参数的寄存器分别为:eax
(系统调用号), ebx, ecx, edx, esi, ebp
。sysenter_entry()
与 system_call()
会把这些寄存器的值保存在内核堆栈中。
4-1 验证参数
在内核满足用户请求之前,需要检查所有系统调用参数。尤其对于表示地址的参数,内核必须检查该地址是否在进程的地址空间之内:
- 验证该线性地址是否属于进程的地址空间
- 验证该线性地址是否小于
PAGE_OFFSET
4-2 访问进程地址空间
系统调用服务例程需要频繁读写进程地址空间的数据。Linux 通过 get_user()
和 put_user()
宏来使得访问更加容易:
get_user()
: 从一个地址读取 1, 2 或 4 个连续字节put_user()
: 把这几种大小的内容写入一个地址中
5-内核封装例程
尽管系统调用主要由用户态进程使用,但是也可以被内核线程调用,内核线程不能使用库函数(库函数属于用户态)。为了简化相应封装例程的声明,LInux 定义了 7 个从 _syscall0 到 _syscall6 的一组宏,每个宏对应着系统调用所用的参数个数。