Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

8.3 系统调用原理

系统调用(System Call)是用户态程序访问操作系统内核服务的唯一接口,用户态程序要访问硬件资源、执行特权操作,都必须通过系统调用进入内核态,由操作系统代为完成。理解系统调用的原理,有助于我们理解程序的运行机制,优化程序性能。

什么是系统调用

操作系统为用户态程序提供了一组标准化的内核服务接口,比如:

  • 文件操作:open、read、write、close等
  • 进程管理:fork、exec、exit、wait等
  • 内存管理:mmap、brk、mprotect等
  • 网络操作:socket、connect、send、recv等
  • 设备管理:ioctl、mmap设备等

用户态程序不能直接访问内核资源,必须通过系统调用才能访问内核服务,这样保证了系统的安全性和稳定性,避免恶意程序不能随意破坏系统。

为什么需要系统调用

  1. 安全隔离:用户态程序权限低,不能直接操作内核资源,防止恶意程序破坏系统
  2. 统一抽象:为上层应用提供统一的接口,屏蔽底层硬件差异,应用程序不需要关心不同硬件的实现细节
  3. 权限控制:操作系统可以统一管理和控制资源的访问,保证公平性和安全性
  4. 避免重复实现:通用的功能由操作系统统一实现,应用程序不需要重复造轮子

系统调用的实现机制

系统调用的本质是用户态主动触发一个异常或者中断,让CPU切换到内核态,执行对应的系统调用处理函数,完成后返回用户态。

不同的架构有不同的系统调用触发方式:

  1. 中断/异常方式:早期的实现方式,通过触发软中断进入内核,比如x86架构的int 0x80中断
  2. 快速系统调用:现代CPU提供的专门的系统调用指令,性能更高,比如x86的sysenter/sysexit指令,x86_64的syscall/sysret指令,ARM的svc指令

x86_64架构系统调用流程

我们以x86_64架构为例,看一下系统调用的完整流程:

  1. **用户态准备参数:

    • 系统调用号放在rax寄存器中,比如read的系统调用号是0,write是1
    • 参数依次放在rdi、rsi、rdx、r10、r8、r9寄存器中
    • 不需要切换到内核栈
  2. **执行syscall指令:

    • CPU从用户态切换到内核态
    • 保存用户态的上下文(寄存器、栈指针等)
    • 跳转到内核的系统调用处理函数入口
  3. **内核处理系统调用:

    • 根据rax中的系统调用号,在系统调用表中找到对应的处理函数
    • 执行对应的内核处理函数,完成具体的操作
    • 处理完成后,把返回值放在rax寄存器中
  4. 执行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

系统调用的开销

系统调用需要在用户态和内核态之间切换,有一定的开销,主要包括:

  1. 上下文切换开销:保存和恢复寄存器上下文
  2. **内核态和用户态切换的开销
  3. **各种检查和安全验证的开销

一次简单的系统调用通常需要几百个CPU周期。虽然单次开销不大,但如果频繁调用系统调用,累积的开销就会很大。

减少系统调用开销的优化方法:

  1. 批量操作:合并多次小的IO操作合并成一次大的操作,减少系统调用次数,比如批量写入文件时攒够一定数据再调用write,而不是写一个字节调用一次write
  2. 使用缓存:用户态缓存数据,减少直接操作缓存,必要时才调用系统调用同步到内核,比如标准库的fread/fwrite函数内部有缓冲区,减少实际的read/write系统调用
  3. 使用更高效的系统调用:比如使用mmap代替read/write,减少系统调用次数和数据拷贝
  4. 批处理系统调用:比如io_uring,一次提交多个系统调用,减少切换开销

标准库与系统调用的关系

我们平时编程时很少直接调用系统调用,而是通过标准库封装的接口:

应用程序 → C标准库(glibc → 系统调用 → 内核

以C标准库的fopen/fread/fwrite等函数,内部封装了对应的系统调用,并且做了很多优化:

  1. **用户态缓冲区,减少系统调用次数
  2. **跨平台封装,屏蔽不同操作系统的系统调用差异
  3. **错误处理和参数检查
  4. **额外的易用的易用的接口

比如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等,不需要进内核态,直接在用户态完成,开销和普通函数调用一样快,大大减少了这些高频系统调用的开销。

思考问题

  1. 为什么用户态程序不能直接访问内核资源,必须通过系统调用?
  2. 系统调用和普通函数调用有什么区别?哪个开销更大?为什么?
  3. 标准库为什么要封装系统调用?直接调用系统调用有什么优缺点?
  4. 频繁调用系统调用会有什么问题?如何优化?