8.3 系统调用原理
系统调用(System Call)是用户态程序访问操作系统内核服务的唯一接口,用户态程序要访问硬件资源、执行特权操作,都必须通过系统调用进入内核态,由操作系统代为完成。理解系统调用的原理,有助于我们理解程序的运行机制,优化程序性能。
什么是系统调用
操作系统为用户态程序提供了一组标准化的内核服务接口,比如:
- 文件操作:open、read、write、close等
- 进程管理:fork、exec、exit、wait等
- 内存管理:mmap、brk、mprotect等
- 网络操作:socket、connect、send、recv等
- 设备管理:ioctl、mmap设备等
用户态程序不能直接访问内核资源,必须通过系统调用才能访问内核服务,这样保证了系统的安全性和稳定性,避免恶意程序不能随意破坏系统。
为什么需要系统调用
- 安全隔离:用户态程序权限低,不能直接操作内核资源,防止恶意程序破坏系统
- 统一抽象:为上层应用提供统一的接口,屏蔽底层硬件差异,应用程序不需要关心不同硬件的实现细节
- 权限控制:操作系统可以统一管理和控制资源的访问,保证公平性和安全性
- 避免重复实现:通用的功能由操作系统统一实现,应用程序不需要重复造轮子
系统调用的实现机制
系统调用的本质是用户态主动触发一个异常或者中断,让CPU切换到内核态,执行对应的系统调用处理函数,完成后返回用户态。
不同的架构有不同的系统调用触发方式:
- 中断/异常方式:早期的实现方式,通过触发软中断进入内核,比如x86架构的int 0x80中断
- 快速系统调用:现代CPU提供的专门的系统调用指令,性能更高,比如x86的sysenter/sysexit指令,x86_64的syscall/sysret指令,ARM的svc指令
x86_64架构系统调用流程
我们以x86_64架构为例,看一下系统调用的完整流程:
-
**用户态准备参数:
- 系统调用号放在rax寄存器中,比如read的系统调用号是0,write是1
- 参数依次放在rdi、rsi、rdx、r10、r8、r9寄存器中
- 不需要切换到内核栈
-
**执行syscall指令:
- CPU从用户态切换到内核态
- 保存用户态的上下文(寄存器、栈指针等)
- 跳转到内核的系统调用处理函数入口
-
**内核处理系统调用:
- 根据rax中的系统调用号,在系统调用表中找到对应的处理函数
- 执行对应的内核处理函数,完成具体的操作
- 处理完成后,把返回值放在rax寄存器中
-
执行sysret指令返回用户态:
- 恢复用户态上下文
- CPU从内核态切换回用户态
- 用户态程序从syscall指令之后继续执行,rax寄存器中是系统调用的返回值
系统调用表
内核中维护了一个系统调用表,是一个数组,下标是系统调用号,数组元素是对应系统调用处理函数的地址。每个系统调用有唯一的编号,内核根据系统调用号找到对应的处理函数。
比如Linux x86_64的部分系统调用号:
- 0:sys_read
- 1:sys_write
- 2:sys_open
- 3:sys_close
- 57:sys_fork
- 59:sys_execve
- 60:sys_exit
系统调用的开销
系统调用需要在用户态和内核态之间切换,有一定的开销,主要包括:
- 上下文切换开销:保存和恢复寄存器上下文
- **内核态和用户态切换的开销
- **各种检查和安全验证的开销
一次简单的系统调用通常需要几百个CPU周期。虽然单次开销不大,但如果频繁调用系统调用,累积的开销就会很大。
减少系统调用开销的优化方法:
- 批量操作:合并多次小的IO操作合并成一次大的操作,减少系统调用次数,比如批量写入文件时攒够一定数据再调用write,而不是写一个字节调用一次write
- 使用缓存:用户态缓存数据,减少直接操作缓存,必要时才调用系统调用同步到内核,比如标准库的fread/fwrite函数内部有缓冲区,减少实际的read/write系统调用
- 使用更高效的系统调用:比如使用mmap代替read/write,减少系统调用次数和数据拷贝
- 批处理系统调用:比如io_uring,一次提交多个系统调用,减少切换开销
标准库与系统调用的关系
我们平时编程时很少直接调用系统调用,而是通过标准库封装的接口:
应用程序 → C标准库(glibc → 系统调用 → 内核
以C标准库的fopen/fread/fwrite等函数,内部封装了对应的系统调用,并且做了很多优化:
- **用户态缓冲区,减少系统调用次数
- **跨平台封装,屏蔽不同操作系统的系统调用差异
- **错误处理和参数检查
- **额外的易用的易用的接口
比如printf函数,内部会把要输出的内容放到缓冲区,缓冲区满了或者遇到换行符时才调用write系统调用输出,大大减少系统调用次数,提升性能。
直接调用系统调用
某些场景下我们也可以直接调用系统调用,绕过标准库,Linux中可以使用syscall函数直接调用系统调用:
#include <unistd.h>
#include <sys/syscall.h>
#include <stdio.h>
int main() {
const char *str = "Hello World\n";
// 直接调用write系统调用,系统调用号1,标准输出1,字符串地址,长度12
syscall(SYS_write, 1, str, 12);
return 0;
}
直接调用系统调用可以获得更好的性能,但会失去标准库的额外开销,但是代码不可跨平台,不同操作系统的系统调用号和接口不同,一般不推荐直接调用,除非有特殊的性能需求。
常见的系统调用分类
1. 进程控制类
- 进程创建、退出:fork、vfork、clone、exit、wait、execve等
- 进程调度:nice、sched_*等
- 信号相关:kill、sigaction、signal等
2. 文件操作类
- 文件操作:open、read、write、close、lseek、stat等
- 目录操作:mkdir、rmdir、readdir、rename等
- 文件权限:chmod、chown、access等
3. 内存管理类
- 内存分配:mmap、munmap、brk、mprotect等
- 内存同步:msync、madvise等
4. 网络操作类
- 套接字操作:socket、bind、listen、accept、connect、send、recv等
- 网络属性:getsockopt、setsockopt等
5. 设备管理类
- 设备操作:ioctl、mmap设备等
- 挂载操作:mount、umount等
6. 系统信息类
- 获取系统信息:getpid、getuid、getgid、uname、sysinfo等
- 时间相关:gettimeofday、clock_gettime等
现代系统调用优化技术
io_uring
io_uring是Linux 5.1之后引入的新一代异步IO框架,是近年来性能极高的系统调用机制,可以大大减少系统调用开销:
- 应用程序和内核之间共享一个环形队列,应用程序把要执行的系统调用请求放到队列中,内核批量处理,处理完成后通知应用程序,一次提交多个系统调用,只需要一次用户态内核态切换,大大降低了系统调用开销,性能比传统的阻塞IO和IO多路复用还要高很多,是现在高并发IO的新方向。
vDSO(虚拟动态共享对象)
vDSO是内核映射到用户态地址空间的一段内核代码,一些不需要系统调用就可以直接调用某些高频系统调用,比如gettimeofday、time等,不需要进内核态,直接在用户态完成,开销和普通函数调用一样快,大大减少了这些高频系统调用的开销。
思考问题
- 为什么用户态程序不能直接访问内核资源,必须通过系统调用?
- 系统调用和普通函数调用有什么区别?哪个开销更大?为什么?
- 标准库为什么要封装系统调用?直接调用系统调用有什么优缺点?
- 频繁调用系统调用会有什么问题?如何优化?