系统调用是计算机程序在执行的过程中向操作系统内核申请服务的方法,这可能包含硬件相关的服务、新进程的创建和执行以及进程调度,对操作系统稍微有一些了解的人都知道 — 系统调用为用户程序提供了操作系统的接口[^1]。
C 语言的著名的 glibc 封装了操作系统提供的系统调用并提供了定义良好的接口[^2],工程师可以直接使用器中封装好的函数开发上层的应用程序,其他编程语言的标准库也会封装系统调用,它们对外提供语言原生的接口,内部使用汇编语言触发系统调用。 我们在使用标准库时需要经常与系统调用打交道,只是很多时候我们不知道标准库背后的实现,以常见的 Hello World 程序为例,这么简单的几行函数在真正运行时会执行几十次系统调用:
strace 是 Linux 中用于监控和篡改进程与内核之间操作的工具,上述命令会打印出 hello 执行过程中触发系统调用、参数以及返回值等信息。执行 Hello World 程序时触发的多数系统调用都是程序启动触发的,只有 munmap 后的系统调用才是 printf 函数触发的,作为应用程序我们能做的事情非常有限,很多功能都需要依赖操作系统提供的服务。 多数编程语言的函数调用只需要分配新的栈空间、向寄存器写入参数并执行 CALL 汇编指令跳转到目标地址执行函数,在函数返回时通过栈或者寄存器返回参数[^3]。与函数调用相比,系统调用会消耗更多的资源,如下图所示,使用 SYSCALL 指定执行系统调用消耗的时间是 C 函数调用的几十倍[^4]:
上图中的 vDSO 全称是虚拟动态链接对象(Virtual Dynamically Shared Object、vDSO),它可以减少系统调用的消耗的时间,我们会在后面详细分析它的实现原理。 getpid(2) 是一个相对比较快的系统调用,该系统调用不包含任何参数,只会切换到内核态、读取变量并返回 PID,我们可以将它的执行时间当做系统调用的基准测试;除了 getpid(2) 之外,使用 close(999) 系统调用关闭不存在的文件描述符会消耗更少的资源[^5],与 getpid(2) 相比大概会少 20 个 CPU 周期[^6],当然想要实现用于测试额外开销的系统调用,使用自定义的空函数应该是最完美的选择,感兴趣的读者可以自行尝试一下。
从上面的系统调用与函数调用的基准测试中,我们可以发现不使用 vSDO 加速的系统调用需要的时间是普通函数调用的几十倍,为什么系统调用会带来这么大的额外开销,它在内部到底执行了哪些工作呢,本文将介绍 Linux 执行系统调用的三种方法:
中断是向处理器发送的输入信号,它能够表示某个时间需要操作系统立刻处理,如果操作系统接收了中断,那么处理器会暂停当前的任务、存储上下文状态、并执行中断处理器处理发生的事件,在中断处理器结束后,当前处理器会恢复上下文继续完成之前的工作[^7]。
根据事件发出者的不同,我们可以将中断分成硬件和软件中断两种,硬件中断是由处理器外部的设备触发的电子信号;而软件中断是由处理器在执行特定指令时触发的,某些特殊的指令也可以故意触发软件中断[^8]。 在 32 位的 x86 的系统上,我们可以使用 INT 指令来触发软件中断,早期的 Linux 会使用 INT 0x80 触发软件中断、注册特定的中断处理器 entry_INT80_32 来处理系统调用,我们来了解一下使用软件中断执行系统调用的具体过程[^9]:
从上述系统调用的执行过程中,我们可以看到基于软件中断的系统调用是一个比较复杂的流程,应用程序通过软件中断陷入内核态并在内核态查询并执行系统调用表注册的函数,整个过程不仅需要存储寄存器中的数据、从用户态切换至内核态,还需要完成验证参数的合法性,与函数调用的过程相比确实会带来很多的额外开销[^10]。 实际上,使用 INT 0x80 来触发系统调用早就是过去时了,大多数的程序都会尽量避免这种触发方式。然而这一规则也不是通用的,因为 Go 语言团队在做基准测试时发现 INT 0x80 触发系统调用在部分操作系统上与其他方式有着几乎相同的性能[^11],所以在 Android/386 和 Linux/386 等架构上仍然会使用中断来执行系统调用[^12] 汇编指令因为使用软件中断实现的系统调用在 Pentium 4 的处理器上表现非常差[^13]。Linux 为了解决这个问题,在较新的版本使用了新的汇编指令 SYSENTER / SYSCALL,它们是 Intel 和 AMD 上用于实现快速系统调用的指令,我们会在 32 位的操作系统上使用 SYSENTER / SYSEXIT,在 64 位的操作系统上使用 SYSCALL / SYSRET:
上述的几个汇编指令是低延迟的系统调用和返回指令,它们会认为操作系统实现了线性内存模型(Linear-memory Model),极大地简化了操作系统系统调用和返回的过程,其中包括不必要的检查、预加载参数等,与软件中断驱动的系统调用相比,使用快速系统调用指令可以减少 25% 的时钟周期[^13]。 线性内存模型是一种内存寻址的常见范式,在这种模式中,线性内存与应用程序存储在单一连续的空间地址中,CPU 可以不借助内存碎片或者分页技术使用地址直接访问可用的内存地址。 在 64 位的操作系统上,我们会使用 SYSCALL / SYSRET 进入和退出系统调用,该指令会在操作系统最高权限等级中执行。内核在初始化时会调用 syscall_init 函数将 entry_SYSCALL_64 存入 MSR 寄存器(Model Specific Register、MSR)中,MSR 寄存器是 x86 指令集中用于调试、追踪以及性能监控的控制寄存器[^15]:
当内核收到了用户程序触发的系统调用时,它会在 MSR 寄存器中读取需要执行的函数并按照 x86-64 的调用惯例在寄存器中读取系统调用的编号以及参数,你能在 entry_SYSCALL_64 函数的注释中找到相关的调用惯例。 汇编函数 entry_SYSCALL_64 会在执行的过程中调用 do_syscall_64,它的实现与上一节中的 do_int80_syscall_32 有些相似,它们都会在系统调用表中查找函数并传入寄存器中的参数。 与 INT 0x80 通过触发软件中断实现系统调用不同,SYSENTER 和 SYSCALL 是专门为系统调用设计的汇编指令,它们不需要在中断描述表(Interrupt Descriptor Table、IDT)中查找系统调用对应的执行过程,也不需要保存堆栈和返回地址等信息,所以能够减少所需要的额外开销。 vDSO虚拟动态共享对象(virtual dynamic shared object、vDSO)是 Linux 内核对用户空间暴露内核空间部分函数的一种机制[^16],简单来说,我们将 Linux 内核中不涉及安全的系统调用直接映射到用户空间,这样用户空间中的应用程序在调用这些函数时就不需要切换到内核态以减少性能上的损失。 vDSO 使用了标准的链接和加载技术,作为一个动态链接库,它由 Linux 内核提供并映射到每一个正在执行的进程中,我们可以使用如下所示的命令查看该动态链接库在进程中的位置:
因为 vDSO 是由操作系统直接提供的,所以它并不存在对应的文件,在程序执行的过程中我们也能在虚拟内存中看到它加载的位置。vDSO 可以为用户程序提供虚拟的系统调用,它会使用内核提供的数据在用户态模拟系统调用:
系统调用 gettimeofday 是一个非常好的例子,如上图所示,使用 vDSO 的系统调用 gettimeofday 会按照如下所示的步骤进行初始化[^17]:
除了 gettimeofday 之外,多数架构上的 vDSO 还包含 clock_gettime、clock_getres 和 rt_sigreturn 等三个系统调用,这些系统调用完成功能相对来说比较简单,也不会带来安全上的问题,所以将它们映射到用户空间可以明显地提高系统调用的性能,就像我们在图二中看到的,使用 vDSO 可以将上述几个系统调用的时间提高几十倍。 总结当我们在编写应用程序时,系统调用并不是一个离我们很远的概念,一个简单的 Hello World 会在执行时触发几十次系统调用,而在线上出现性能问题时,可能也需要我们与系统调用打交道。虽然程序中的系统调用非常频繁,但是与普通的函数调用相比,它会带来明显地额外开销:
应用程序能够完成的工作相当有限,我们需要使用操作系统提供的服务才能编写功能丰富的用户程序。系统调用作为操作系统提供的接口,它与底层的硬件关系十分紧密,因为硬件的种类繁杂,所以不同架构要使用不同的指令,随着内核的快速演进,想要找到准确的资料也非常困难,不过了解不同系统调用的实现原理对我们认识操作系统也有很大的帮助。到最后,我们还是来看一些比较开放的相关问题,有兴趣的读者可以仔细思考一下下面的问题:
|
微信公众号
手机版