第 4 章 软硬件协同

作为软硬件的界面,指令系统结构不仅包含指令和相关硬件资源的定义,还包含有关资源的使用方式约定。与二进制程序相关的约定被称为ABI(Application Binary Interface,应用程序二进制接口)。ABI定义了应用程序二进制代码中相关数据结构和函数模块的格式及其访问方式,它使得不同的二进制模块之间的交互成为可能。本章首先讲述ABI的基本概念和具体组成,并举例说明了其中一些比较常见的内容。

在软硬件之间合理划分界面是指令系统设计的一项关键内容。计算机完成一项任务所需要的某个工作常常既可以选择用软件实现也可以选择用硬件实现,设计者需要进行合理的权衡。第3章中LoongArch指令系统的TLB管理就是一个很好的软硬件协同实现案例。本章继续讲述对理解计算机系统的工作过程比较重要的一些软硬件协同案例,包括函数调用、异常与中断、系统调用、进程、线程和虚拟机等六种不同的上下文切换场景,以及同步机制的实现。 不另加说明的情况下,本章的案例采用LoongArch指令系统。

4.1 应用程序二进制接口

ABI定义了应用程序二进制代码中数据结构和函数模块的格式及其访问方式,它使得不同的二进制模块之间的交互成为可能。硬件上并不强制这些内容,因此自成体系的软件可以不遵循部分或者全部ABI约定。但通常来说,应用程序至少会依赖操作系统以及系统函数库,因而必须遵循相关约定。

ABI包括但不限于如下内容:

  • 处理器基础数据类型的大小、布局和对齐要求等;
  • 寄存器使用约定。它约定通用寄存器的使用方法、别名等;
  • 函数调用约定。它约定参数如何传递给被调用的函数、结果如何返回、函数栈帧如何组织等;
  • 目标文件和可执行文件格式;
  • 程序装载和动态链接相关信息;
  • 系统调用和标准库接口定义;
  • 开发环境和执行环境等相关约定。

关心ABI细节的主要是编译工具链、操作系统和系统函数库的开发者,但如果用到汇编语言或者想要实现跨语言的模块调用,普通开发者也需要对它有所了解。从以上内容也可以看出,了解ABI有助于深入理解计算机系统的工作原理。

同一个指令系统上可能存在多种不同的ABI。导致ABI差异的原因之一是操作系统差异。例如,对于X86指令系统,UNIX类操作系统普遍遵循System V ABI,而Windows则有它自己的一套ABI约定。导致ABI差异的原因之二是应用领域差异,有时针对不同的应用领域定制ABI可以达到更好的效果。例如,ARM、PowerPC和MIPS都针对嵌入式领域的需求定义了EABI(Embedded Application Binary Interface),它和通用领域的ABI有所不同。导致ABI差异的另外一种常见原因是软硬件的发展需要。例如,MIPS早期系统多数采用O32 ABI,它定义了四个寄存器用于函数调用参数,后来的软件实践发现更多的传参寄存器有利于提升性能,这促成了新的N32/N64 ABI的诞生。而指令集由32位发展到64位时,也需要新的ABI。X86-64指令系统上有三种Sytem V ABI的变种,分别是:兼容32位X86的i386 ABI,利用了64位指令集的寄存器数量等优势资源但保持使用32位指针的X32 ABI,以及指针和数据都用64位的X86-64 ABI。操作系统可以只选择支持其中一种ABI,也可以同时支持多种ABI。此外,ABI的定义相对来说不如指令集本身完整和规范,一个指令系统的ABI规范可能有很完备的、统一的文档描述,也可能是依赖主流软件的事实标准,由多个来源的非正式文档构成。

下面我们以一些具体的例子来说明ABI中一些比较常见的内容。

4.1.1 寄存器约定

本节列举MIPS和LoongArch指令系统的整数寄存器约定(浮点寄存器也有相应约定,在此不做讨论),并对它们进行了简单的比较和讨论。MIPS和LoongArch都有32个整数通用寄存器,除了0号寄存器始终为0外,其他31个寄存器物理上没有区别。但系统人为添加了一些约定,给了它们特定的名字和使用方式。

MIPS指令系统的流行ABI主要有以下三种:

1)O32。来自传统的MIPS约定,仍广泛用于嵌入式工具链和32位Linux中。

2)N64。在64位处理器编程中使用的新的正式ABI,指针和long型整数的宽度扩展为64位,并改变了寄存器使用的约定和参数传递的方式。

3)N32。在64位处理器上执行的32位程序,与N64的区别在于指针和long型整数的宽度为32位。

4.1给出了MIPS O32和N32/N64对整数(或称为定点)通用寄存器的命名和使用约定。

这三个ABI中,O32用一种寄存器约定,N32/N64用另一种。可以看到,两种寄存器约定的大部分内容是相同的,主要差别在于O32只用了四个寄存器作为参数传递寄存器,而N32/N64则用了八个,相应地减少了暂存器。原因是现代程序越来越复杂,很多函数的参数超过四个,在O32中需要借助内存来传递多出的参数,N32/N64的约定有助于提升性能。对参数少于八个的函数,剩余的参数寄存器仍然可以当作暂存器使用,不会浪费。为了和普通变量名区分,这些助记符在汇编源代码中会加’$’前缀,例如$sp或者$r29表示29号寄存器。但在一些源代码(如Linux内核源代码)中也可能会看到直接使用不加$前缀的助记符的情况,这是因为相关头文件用宏定义了这个名字,如#define a0 $r4。

LoongArch定义了三个ABI:指针和数据都是64位的LP64,指针32位、数据64位的LPX32,指针和数据都是32位的LP32。但它们的寄存器约定都是一致的。对比表4.1和表4.2,我们可以看到LoongArch的约定比MIPS要更规整和简洁些,主要有如下差别:

  • 取消了汇编暂存器($at)。MIPS的一些汇编宏指令用多条硬件指令合成,汇编暂存器用于数据周转。LoongArch指令系统的宏指令可以不用周转寄存器或者显式指定周转寄存器,因而不再需要汇编暂存器。这可以增加编译器可用寄存器的数量。

  • 取消了预留给内核的专用寄存器($k0/$k1)。MIPS预留两个寄存器的目的是支持高效异常处理,在希望异常处理过程尽量快的时候可以用这两个寄存器,省去保存上下文到内存中的开销。LoongArch指令系统提供了便签寄存器来高效暂存数据,可以在不预留通用寄存器的情况下保持高效实现,给编译器留下了更多的可用寄存器。

  • 取消了$gp寄存器。MIPS中用$gp寄存器指向GOT(Global Offset Table)表以协助动态链接器计算可重定位的代码模块的相关符号位置。LoongArch指令集支持基于PC的运算指令,能够用其他高效的方式实现动态链接,不再需要额外花费一个通用寄存器。

  • 复用参数寄存器和返回值寄存器,参数寄存器$a0/$a1也被用作返回值寄存器。这也是现代指令系统比较常见的做法,它进一步增加了通用暂存器的数量。

  • 增加了线程指针寄存器$tp,用于高效支持多线程实现。$tp总是指向当前线程的TLS(Thread Local Storage)区域。

以上几点都有助于提升编译器生成的代码的性能。曾有实验表明,在完全相同的微结构和外部配置环境下,LoongArch指令系统的SPEC CPU 2006基准程序平均性能比MIPS高15%左右,其中部分性能来自指令集的优化,部分性能来自更高效的ABI。

4.1.2 函数调用约定

LoongArch的函数调用规范如下(略去了少量过于复杂且不常用的细节)。

整型调用规范

1.基本整型调用规范提供了8个参数寄存器$a0-$a7用于参数传递,前两个参数寄存器$a0和$a1也用于返回值。

2.若一个标量宽度至多XLEN位(对于LP32 ABI,XLEN=32,对于LPX32/LP64,XLEN=64),则它在单个参数寄存器中传递,若没有可用的寄存器,则在栈上传递。若一个标量宽度超过XLEN位,不超过2*XLEN位,则可以在一对参数寄存器中传递,低XLEN位在小编号寄存器中,高XLEN位在大编号寄存器中;若没有可用的参数寄存器,则在栈上传递标量;若只有一个寄存器可用,则低XLEN位在寄存器中传递,高XLEN位在栈上传递。若一个标量宽度大于2*XLEN位,则通过引用传递,并在参数列表中用地址替换。用栈传递的标量会对齐到类型对齐(Type Alignment)和XLEN中的较大者,但不会超过栈对齐要求。当整型参数传入寄存器或栈时,小于XLEN位的整型标量根据其类型的符号扩展至32位,然后符号扩展为XLEN位。当浮点型参数传入寄存器或栈时,比XLEN位窄的浮点类型将被扩展为XLEN位,而高位为未定义位。

3.若一个聚合体(Struct或者Array)的宽度不超过XLEN位,则这个聚合体可以在寄存器中传递,并且这个聚合体在寄存器中的字段布局同它在内存中的字段布局保持一致;若没有可用的寄存器,则在栈上传递。若一个聚合体的宽度超过XLEN位,不超过2*XLEN位,则可以在一对寄存器中传递,若只有一个寄存器可用,则聚合体的前半部分在寄存器中传递,后半部分在栈上传递;若没有可用的寄存器,则在栈上传递聚合体。由于填充(Padding)而未使用的位,以及从聚合体的末尾至下一个对齐位置之间的位,都是未定义的。若一个聚合体的宽度大于2*XLEN位,则通过引用传递,并在参数列表中被替换为地址。传递到栈上的聚合体会对齐到类型对齐和XLEN中的较大者,但不会超过栈对齐要求。

4.对于空的结构体(Struct)或联合体(Union)参数或返回值,C编译器会认为它们是非标准扩展并忽略;C++编译器则不是这样,C++编译器要求它们必须是分配了大小的类型(Sized Type)。

5.位域(Bitfield)以小端顺序排列。跨越其整型类型的对齐边界的位域将从下一个对齐边界开始。例如:

  1. struct {int x:10; int y:12;}是一个32位类型,x9-0位,y21-10位,31-22位未定义。
  2. struct {short x:10 ; short y:12;}是一个32位类型,x9-0位,y27-16位,31-28位和15-10位未定义。

6.通过引用传递的实参可以由被调用方修改。

7.浮点实数的传递方式与相同大小的聚合体相同,浮点型复数的传递方式与包含两个浮点实数的结构体相同。(当整型调用规范与硬件浮点调用规范冲突时,以后者为准。)

8.在基本整型调用规范中,可变参数的传递方式与命名参数相同,但有一个例外。2*XLEN位对齐的可变参数和至多2*XLEN 位大小的可变参数通过一对对齐的寄存器传递(寄存器对中的第一个寄存器为偶数),如果没有可用的寄存器,则在栈上传递。当可变参数在栈上被传递后,所有之后的参数也将在栈上被传递(此时最后一个参数寄存器可能由于对齐寄存器对的规则而未被使用)。

9.返回值的传递方式与第一个同类型命名参数(Named Value)的传递方式相同。如果这样的实参是通过引用传递的,则调用者为返回值分配内存,并将其地址作为隐式的第一个参数传递。

10.栈向下增长(朝向更低的地址),栈指针应该对齐到一个16字节的边界上作为函数入口。在栈上传递的第一个实参位于函数入口的栈指针偏移量为零的地方,后面的参数存储在更高的地址中。

11.在标准ABI中,栈指针在整个函数执行过程中必须保持对齐。非标准ABI代码必须在调用标准ABI过程之前重新调整栈指针。操作系统在调用信号处理程序之前必须重新调整栈指针;因此,POSIX信号处理程序不需要重新调整栈指针。在服务中断的系统中使用被中断对象的栈,如果连接到任何使用非标准栈对齐规则的代码,中断服务例程必须重新调整栈指针。但如果所有代码都遵循标准ABI,则不需要重新调整栈指针。

12.函数所依赖的数据必须位于函数栈帧范围之内。

13.被调用的函数应该负责保证寄存器$s0-$s8的值在返回时和入口处一致。

硬件浮点调用规范

1.浮点参数寄存器共8个,为$fa0-$fa7,其中$fa0和$fa1也用于传递返回值。需要传递的值在任何可能的情况下都可以传递到浮点寄存器中,与整型参数寄存器$a0-$a7是否已经用完无关。

2.本节其他部分仅适用于命名参数,可变参数根据整型调用规范传递。

3.在本节中,FLEN指的是ABI中的浮点寄存器的宽度。ABI的FLEN宽度不能比指令系统的标准宽。

4.若一个浮点实数参数不超过FLEN位宽,并且至少有一个浮点参数寄存器可用,则将这个浮点实数参数传递到浮点参数寄存器中,否则,它将根据整型调用规范传递。当一个比FLEN位更窄的浮点参数在浮点寄存器中传递时,它从1扩展到FLEN位。

5.若一个结构体只包含一个浮点实数,则这个结构体的传递方式同一个独立的浮点实数参数的传递方式一致。若一个结构体只包含两个浮点实数,这两个浮点实数都不超过FLEN位宽并且至少有两个浮点参数寄存器可用(寄存器不必是对齐且成对的),则这个结构体被传递到两个浮点寄存器中,否则,它将根据整型调用规范传递。若一个结构体只包含一个浮点复数,则这个结构体的传递方式同一个只包含两个浮点实数的结构体的传递方式一致,这种传递方式同样适用于一个浮点复数参数的传递。若一个结构体只包含一个浮点实数和一个整型(或位域),无论次序,则这个结构体通过一个浮点寄存器和一个整型寄存器传递的条件是,整型不超过XLEN位宽且没有扩展至XLEN位,浮点实数不超过FLEN位宽,至少一个浮点参数寄存器和至少一个整型参数寄存器可用,否则,它将根据整型调用规范传递。

6.返回值的传递方式与传递第一个同类型命名参数的方式相同。

7.若浮点寄存器$fs0-$fs11的值不超过FLEN位宽,那么在函数调用返回时应该保证它们的值和入口时一致。

可以看到,函数调用约定包含许多细节。为了提高效率,LoongArch的调用约定在参考MIPS的基础上做了较多优化。例如,它最多能同时用8个定点和8个浮点寄存器传递16个参数,而MIPS中能用定点或者浮点寄存器来传递的参数最多为8个。

我们来看几个例子。图4.1的程序用gcc -O2 fun.c -S得到汇编文件(见图4.2,略有简化,下同)。可以看到,对于第9个浮点参数,已经没有浮点参数寄存器可用,此时根据浮点调用规范第4条,剩下的参数按整型调用规范传递。因此,a9、a10、a11和a12分别用$a0-$a3这四个定点寄存器来传递,虽然这段代码引用的a9和a11实际上是浮点数。

fun.c源代码

图 4.1: fun.c源代码

fun.c对应的LoongArch汇编代码

图 4.2: fun.c对应的LoongArch汇编代码

这个程序在MIPS N64 ABI下的参数传递方式则有所不同。按MIPS ABI规则,前八个参数仍然会使用浮点参数寄存器传递,但是后四个参数将通过栈上的内存空间传递,因此a9和a11会从栈中获取,如图4.3所示。

fun.c对应的MIPS汇编代码

图 4.3: fun.c对应的MIPS汇编代码

对于可变数量参数的情况,图4.4给出了一个测试案例,表4.3是对应的参数传递表。可以看到,第一个固定参数是浮点参数,用$fa0,后续的可变参数根据浮点调用规范第2条全部按整型调用规范传递,因此不管是浮点还是定点参数,都使用定点寄存器。

varg.c源代码

图 4.4: varg.c源代码

4.1.3 进程虚拟地址空间

虚拟存储管理为每个进程提供了一个独立的虚拟地址空间,指令系统、操作系统、工具链和应用程序会互相配合对其进行管理。首先,指令系统和OS会决定哪些地址空间用户可以访问,哪些只能操作系统访问,哪些是连操作系统也不能访问的保留空间。然后工具链和应用程序根据不同的需要将用户可访问的地址空间分成几种不同的区域来管理。图4.5展示了一个典型C程序运行时的用户态虚拟内存布局。

C程序的典型虚拟内存布局

图 4.5: C程序的典型虚拟内存布局

可以看到,C程序的典型虚拟内存布局包括如下几部分:

  • 应用程序的代码、初始化数据和未初始化数据
  • 函数库的代码、初始化数据和未初始化数据

应用程序的代码来自应用程序的二进制文件。工具链在编译链接应用程序时,会将代码段地址默认设置为一个相对较低的地址(但这个地址一般不会为0,地址0在多数操作系统中都会被设为不可访问的地址,以便捕获空指针访问)。运行程序时操作系统中的装载器根据程序文件记录的内存段信息把代码和数据装入相应的虚拟内存地址。有初始值的全局变量和静态变量存放在文件的数据段中。未初始化的变量只需要在文件中记录其大小,装载器会直接给它分配所需的内存空间,然后清零。未初始化数据段之上是堆空间。堆用于管理程序运行过程中动态分配的内存,C程序中用malloc分配的内存由堆来管理。接近用户最高可访问地址的一段空间被用作进程的栈。栈向下增长,用先进后出的方式分配和释放。栈用作函数的临时工作空间,存储C程序的局部变量、子函数参数和返回地址等函数执行完就可以抛弃的数据(栈的详细管理情况参见下节)。堆需要支持任意时刻分配和释放不同大小的内存块,需要比较复杂的算法支持,因此相应的分配和释放开销也比较大。而栈的分配和释放实质上只是调整一个通用寄存器$sp,开销很小,但它只能按先进后出的分配次序操作。应用程序用到的动态函数库则由动态链接程序在空闲空间中寻找合适的地址装入,通常是介于栈和堆之间。

4.6是64位Linux系统中一个简单C程序(程序名为hello)运行时的虚拟内存布局的具体案例。它基本符合上述典型情况。栈之上的三段额外空间是现代Linux系统的一些新特性引入的,有兴趣的读者可以自行探究。

一个简单C程序的虚拟内存布局

图 4.6: 一个简单C程序的虚拟内存布局

需要说明的是,一般来说ABI并不包括进程地址空间的具体使用约定。事实上,进程虚拟内存布局一般也不影响应用程序的功能。我们可以通过一些链接器参数来改变程序代码段的默认装载地址,让它出现在更高的地址上;也可以在任意空闲用户地址空间内映射动态链接库或者分配内容。这里介绍一些典型的情况是为了让读者更好地理解软硬件如何协同实现程序的数据管理及其装载和运行。

4.1.4 栈帧布局

像C/C++这样的高级语言通常会用栈来管理函数运行过程使用的一些信息,包括返回地址、参数和局部变量等。栈是一个大小可以动态调整的空间,在多数指令系统中是从高地址向下增长。如图4.7所示,栈被组织成一个个栈帧(一段连续的内存地址空间),每个函数都可以有一个自己的栈帧。调用一个子函数时栈增大,产生一个新的栈帧,函数返回时栈减小,释放掉一个栈帧。栈帧的分配和释放在有些ABI中由调用函数负责,在有些ABI中由被调用者负责。

我们以LoongArch LP64为例看看具体的案例。图4.7是最完整的情况,它同时利用了$sp和$fp两个寄存器来维护栈帧。$sp寄存器指向栈顶,$fp寄存器指向当前函数的栈帧开始处。编译器为函数在入口处生成一个函数头(Prologue),在返回处生成一个函数尾(Epilogue),它们负责调整$sp和$fp寄存器以生成新的栈帧或者释放一个栈帧,并生成必要的寄存器保存和恢复代码。

使用帧指针寄存器的栈帧布局

图 4.7: 使用帧指针寄存器的栈帧布局

4.8的简单函数用gcc -O2 -fno-omit-frame-pointer -S来编译,会产生图4.9这样的汇编代码(为清晰起见,将形如$rxx的寄存器名替换为约定的助记符,下同)。

一个简单的simple函数

图 4.8: 一个简单的simple函数

simple函数的汇编代码

图 4.9: simple函数的汇编代码

前3条指令属于函数头,第一条指令设立了一个16字节的栈帧(LP64要求栈帧以16字节对齐),第二条指令在偏移8的位置保存了$fp寄存器,第三条指令则把$fp指向刚进入函数时的$sp。第4条和第7条指令属于函数尾,分别负责恢复$fp和释放栈帧。当然,很容易看到,对这么简单的情况,维护栈帧完全是多余的,因此如果不加-fno-omit-frame-pointer强制使用$fp的话,gcc -O2 -S生成的代码将会如图4.10所示,整个函数不再产生和释放栈帧。

simple函数不保留栈帧指针的编译结果

图 4.10: simple函数不保留栈帧指针的编译结果

大部分函数可以只用$sp来管理栈帧。如果在编译时能够确定函数的栈帧大小,编译器可以在函数头分配所需的栈空间(通过调整$sp),这样在函数栈帧里的内容都有一个编译时确定的相对于$sp的偏移,也就不需要帧指针$fp了。例如图4.11中的normal函数,用gcc -O2 -S编译的结果如图4.12所示。normal函数调用了一个有9个整数参数的外部函数,这样它必须有栈帧来为调用的子函数准备参数。可以看到,编译器生成了一个32字节的栈帧,把最后一个参数9保存到偏移0,把返回地址$ra保存到偏移24。

normal函数代码

图 4.11: normal函数代码

normal函数的gcc -O2编译结果

图 4.12: normal函数的gcc -O2编译结果

但有时候可能无法在编译时确定一个函数的栈帧大小。在某些语言中,可以在运行时动态分配栈空间,如C程序的alloca调用,这会改变$sp的值。这时函数头会使用$fp寄存器,将其设置为函数入口时的$sp值,函数的局部变量等栈帧上的值则用相对于$fp的常量偏移来表示。图4.13中的函数用alloca动态分配栈空间,导致编译器生成带栈帧指针的代码。如图4.14所示,$fp指向函数入口时$sp的值,$sp则先减32字节留出调用子函数的参数空间以及保存$fp和$ra的空间,然后再为alloca(64)减去64以动态分配栈空间。

dynamic函数源代码

图 4.13: dynamic函数源代码

dynamic函数的汇编代码

图 4.14: dynamic函数的汇编代码

4.2 六种常见的上下文切换场景

CPU运行指令的过程中,根据应用或者操作系统的需要,经常会改变指令的执行流,同时根据需要在不同的上下文之间切换。本节讲述指令系统如何实现函数调用、中断与异常、系统调用、进程、线程以及虚拟机等上下文切换场景。

4.2.1 函数调用

函数调用是用户主动发起的指令流和上下文改变。普通的转移指令只改变指令流不改变上下文,函数调用则通过ABI约定实现了一定的上下文变化。函数调用通常伴随着栈帧的变化,此外部分寄存器也会发生变化。根据ABI的约定,像$s0-$s8这样约定由被调用者保存(Callee Save)的寄存器在函数调用前后保持不变,而通用暂存器、参数寄存器等则不保证维持调用前的值。

不同指令系统实现函数调用的方式有所不同。LoongArch采用比较典型的RISC做法,硬件仅仅提供一个机制(bl或者jirl指令),用于在改变指令流的同时保存一个返回地址到通用寄存器,其余的都由软件来约定和实现。X86指令系统中则有比较复杂的硬件支持,其函数调用指令call指令有多种形式,硬件可以执行权限检查、保存返回地址到栈上、修改CS和IP寄存器、设置标志位等处理逻辑,但是参数的传递方式还是由软件约定。Sparc指令系统则为了减少函数调用时寄存器准备的开销,引入了体系结构可见的寄存器窗口机制。它的通用寄存器包括8个全局寄存器和2-32个窗口,每个窗口包括16个寄存器。任意时刻,指令可以访问8个全局寄存器、8个输入寄存器、8个局部寄存器、8个输出寄存器,其中前两个由当前窗口提供,输出寄存器由相邻窗口的输入寄存器提供。Sparc提供专门的save和restore指令来移动窗口,调用函数执行save指令,让当前函数的输出寄存器变成被调用函数的输入寄存器,消除了多数情况下准备调用参数的过程,函数返回时则执行restore指令恢复原窗口。这个技术看起来非常巧妙,然而它会给寄存器重命名等现代流水线技术带来很大的实现困难,现在常常被人们当作指令系统过度优化的反面案例。

4.2.2 异常和中断

上一章已经介绍了异常和中断的概念及其常规处理流程。通常异常和中断的处理对用户程序来说是透明的,相关软硬件需要保证处理前后原来执行中的代码看到的CPU状态保持一致。这意味着开始异常和中断处理程序之前需要保存所有可能被破坏的、原上下文可见的CPU状态,并在处理完返回原执行流之前恢复。需要保存的上下文包括异常处理代码的执行可能改变的寄存器(如Linux内核自身不用浮点部件,因此只需要处理通用整数寄存器而无须处理浮点寄存器)、发生异常的地址、处理器状态寄存器、中断屏蔽位等现场信息以及特定异常的相关信息(如触发存储访问异常的地址)。异常和中断的处理代码通常在内核态执行,如果它们触发前处理器处于用户态,硬件会自动切换到内核态。这种情况下通常栈指针也会被重新设置为指向内核态代码所使用的栈,以便隔离不同特权等级代码的运行信息。

对于非特别高频的异常或者中断,操作系统往往会统一简化处理,直接保存所有可能被内核修改的上下文状态,然后调用相应的处理函数,最后再恢复所有状态。因为大部分情况下处理函数的逻辑比较复杂,所以算起开销比例来这么做的代价也可以接受。例如,3A5000处理器的Linux内核中,所有中断都采用统一的入口处理代码,它的主要工作就是保存所有的通用整数寄存器和异常现场信息,除此之外只有少量指令用于切换中断栈、调用实际中断处理函数等代码。入口处理的指令总共只有几十条,而一个有实际用处的中断处理过程一般至少有数百条指令,其中还包括一些延迟比较长的IO访问。例如,看上去很简单的键盘中断处理,在把输入作为一个事件报告到Linux内核的输入子系统之前,就已经走过了如图4.15所示那么多的函数。

键盘输入的中断处理部分路径

图 4.15: 键盘输入的中断处理部分路径

except_vec_vi是Linux/LoongArch内核的向量中断入口处理代码,之后它会用USB键盘对应的中断号为参数调用do_IRQ函数,do_IRQ再经过一系列中断框架处理后调用usb的中断处理函数usb_hcd_irq,读入相应的键码,最后用input_event报告给输入子系统,输入子系统再负责把输入事件传递给适当的应用程序。感兴趣的读者可以阅读Linux内核相关代码以更深入地理解这个过程,在此不再展开。

对于发生频率很高的异常或者中断,我们希望它的处理效率尽量高。从异常和中断处理的各个环节都可以设法降低开销。例如,可以通过专用入口或者向量中断技术来降低确定异常来源和切换指令流的开销。此外,不同的指令系统用不同的方法来降低上下文保存恢复的开销。例如TLB管理,上一章中我们介绍了LoongArch中TLB重填的做法:设置专门的异常入口,利用便签寄存器来快速获得可用的通用寄存器,以及提供两个专门的指令(lddir和ldpte)来进一步加速从内存页表装入TLB表项的过程。X86指令系统选择完全用硬件来处理,成功的情况不会发出异常。MIPS指令系统则采用预留两个通用寄存器的办法。TLB重填异常处理只用这两个寄存器,因此没有额外的保存恢复代价(但所有的应用程序都牺牲了两个宝贵的通用寄存器)。

4.2.3 系统调用

系统调用是操作系统内核为用户态程序实现的子程序。系统调用的上下文切换场景和函数调用比较类似,和普通调用相比主要多了特权等级的切换。Linux操作系统中的部分系统调用如表4.4所示。一些系统调用(如gettimeofday系统调用)只返回一些内核知道但用户程序不知道的信息。系统调用要满足安全性和兼容性两方面的要求。安全性方面,在面对错误甚至恶意的应用时,内核应该是健壮的,应能保证自身的安全;兼容性方面,操作系统内核应该能够运行已有的应用程序,这也要求系统调用应该是兼容的,轻易移除一个系统调用是无法接受的。

Linux内核中,每个系统调用都被分配了一个整数编号,称为调用号。调用号的定义与具体指令系统相关,X86和MIPS对同一函数的调用号可能不同。Linux/LoongArch系统的调用号定义可以从内核源码include/uapi/asm-generic/unistd.h获得。

因为涉及特权等级的切换,系统调用通常被当作一种用户发起的特殊异常来处理。例如在LoongArch指令系统中,执行SYSCALL指令会触发系统调用异常。异常处理程序通过调用号查表找到内核中相应的实现函数。与所有异常一样,系统调用在返回时使用ERTN指令来同时完成跳转用户地址和返回用户态的操作。

类似于一般的函数调用,系统调用也需要进行参数的传递。应该尽可能使用寄存器进行传递,这可以避免在核心态空间和用户态空间之间进行不必要的内容复制。在LoongArch指令系统中,系统调用的参数传递有以下约定:

1)调用号存放在$a7寄存器中。

2)至多7个参数通过$a0~$a6寄存器进行传递。

3)返回值存放在$a0/$a1寄存器。

4)系统调用保存$s0-$s8寄存器的内容,不保证保持参数寄存器和暂存寄存器的内容。

为了保障安全性,内核必须对用户程序传入的数组索引、指针和缓冲区长度等可能带来安全风险的参数进行检查。从用户空间复制数据时,应用程序提供的指针可能是无效的,直接在内核使用可能导致内核崩溃。因此,Linux内核使用专用函数copy_to_user()和copy_from_user()来完成与用户空间相关的复制操作。它们为相应的访存操作提供了专门的异常处理代码,避免内核因为用户传入的非法值而发生崩溃。

4.16展示了一个汇编语言编写的write系统调用的例子。用gcc编译运行,它会在屏幕上输出“Hello World!”字符串。当然,通常情况下应用程序不用这样使用系统调用,系统函数库会提供包装好的系统调用函数以及更高层的功能接口。比如,glibc库函数write包装了write系统调用,C程序直接用write(1,“Hello World!\n”,14)或者用更高层的功能函数printf(“Hello World\n”)就可以实现同样的功能。

调用write系统调用输出字符串

图 4.16: 调用write系统调用输出字符串

4.2.4 进程

为了支持多道程序并发执行,操作系统引入了进程的概念。进程是程序在特定数据集合上的执行实例,一般由程序、数据集合和进程控制块三部分组成。进程控制块包括很多信息,它记录每个进程运行过程中虚拟内存地址、打开文件、锁和信号等资源的情况。操作系统通过分时复用、虚拟内存等技术让每个进程都觉得自己拥有一个独立的CPU和独立的内存地址空间。切换进程时需要切换进程上下文。进程上下文包括进程控制块记录的各种信息。

进程的上下文切换主要由软件来完成。发生切换的时机主要有两种,一是进程主动调用某些系统调用时因出现无法继续运行的情况(如等待IO完成或者获得锁)而触发切换,二是进程分配到的时间片用完了或者有更高优先级的就绪进程要抢占CPU导致的切换。切换工作的实质是实现对CPU硬件资源的分时复用。操作系统把当前进程的运行上下文信息保存到内存中,再把选中的下一个进程的上下文信息装载到CPU中。特定时刻只能由一个进程使用的处理器状态信息,包括通用寄存器、eflags等用户态的专有寄存器以及当前程序计数器(PC)、处理器模式和状态、页表基址(例如X86指令系统的CR3寄存器和LoongArch的PGD寄存器)等控制信息,都需要被保存起来,以便下次运行时恢复到同样的状态。如果一些不支持共享的硬件状态信息在内存里有最新备份,切换时可以采用直接丢弃的方法。例如,有些指令系统的TLB不能区分不同进程的页表项(早期的X86指令系统就是如此),那么在进程切换时需要把已有的表项设为无效,避免被新的进程错误使用。而可以共享的硬件状态信息(如Cache等),以及用内存保存的上下文信息(如页表等),则不需要处理。由于篇幅限制,这里不展开讨论具体的进程切换细节,感兴趣的读者可以通过阅读Linux内核源代码或者相关操作系统书籍来进一步了解。

不同的硬件支持可能导致不同的效率。TLB是否可以区分来自不同进程的页表项就是一个例子。不能区分时,每次切换进程的时候必须使所有的硬件TLB表项无效,每次进程开始运行时都需要重新从内存获取页表项。而LoongArch等指令系统的TLB则支持用某种进程标记(LoongArch中是ASID)来区分不同进程的页表项,可以避免这种开销。随着指令系统的发展,需要切换的信息也在增加,引发了一些新的硬件支持需求。例如,除了常规的整数和浮点通用寄存器,很多现代处理器增加了数十个位宽很大(X86 AVX扩展可达512位)的向量寄存器。由于无条件保存所有寄存器的代价比较大,操作系统常常会采用某种按需保存的优化,比如不为没有用到向量的进程保存向量状态。但这需要指令系统提供一定的支持。在MIPS和LoongArch指令系统中,浮点和向量部件都可以通过控制寄存器来关闭,在关闭部件后使用相关指令会触发异常,这样操作系统就能有效地实现按需加载。

历史上也有些指令系统曾尝试为进程切换提供更多硬件支持。例如,X86指令系统提供了专门的TS(Task State)段和硬件自动保存进程上下文的机制,适当设置之后进程切换可以由硬件完成。但由于硬件机制不够灵活而且性能收益不明显,包括Linux和Windows在内的多数操作系统都没有使用这个机制。

4.2.5 线程

线程是程序代码的一个执行路径。一个进程可以包含多个线程,这些线程之间共享内存空间和打开文件等资源,但逻辑上拥有独立的寄存器状态和栈。现代系统的线程一般也支持线程私有存储区(Thread Local Storage,简称TLS)。例如,GCC编译器支持用__thread int number;这样的语句来定义一个线程私有的全局变量,不同线程看到的number地址是不一样的。

线程可以由操作系统内核管理,也可以由用户态的线程库管理,或者两者混合。线程的实现方式对切换开销有很大的影响。例如,Linux系统中最常用的线程库NPTL( POSIX Thread Library)采用内核和用户1:1的线程模型,每个用户级线程对应一个内核线程。除了不切换地址空间,线程的切换和进程的大部分流程一致,都需要进入和退出核心态,经历至少两次用户态和核心态上下文的切换。因此,对一些简单测试来说,Linux中进程和线程切换的速度差异可能不太明显。而Go语言提供的goroutines可以被看作一种用户级实现的轻量级线程,它的切换不需要通过内核,一些测试表明,其切换开销可比NPTL小一半以上9。当然,进程和线程切换不仅仅有执行切换代码的直接开销,还有因为TLB、Cache等资源竞争导致的间接开销,在数据集比较大的时候,进程和线程的实际切换代价差异也可能较大。

同样,适当的硬件支持也有助于提升线程切换效率。例如,LoongArch的ABI将一个通用寄存器用作专门的$tp寄存器,用来高效访问TLS空间。切换线程时只需要将$tp指向新线程的TLS,访问TLS的变量时用$tp和相应的偏移就能实现访问每个线程一份的变量。相比之下,Linux/MIPS系统则依赖系统调用set_thread_area来设置当前线程的TLS指针,将它保存到内核的线程数据结构中;用户程序用rdhwr指令来读取当前的线程指针,这个指令会产生一个异常来陷入内核读取TLS指针。相比之下,这样的实现效率会低很多。

4.2.6 虚拟机

线程把一份CPU计算资源虚拟成多份独立的CPU计算资源,进程把CPU和物理内存的组合虚拟成多份独立的虚拟CPU和虚拟内存组合。更进一步,我们可以把一台物理计算机虚拟成多台含CPU、内存和各种外设的虚拟计算机。虚拟机可以更好地隔离不同的服务运行环境,更充分地利用越来越丰富的物理机资源,更方便地迁移和管理,因此得到了广泛的应用,成为云计算的基础技术。

虚拟机的运行上下文包括CPU、内存和外设的状态。在虚拟机内部会发生函数调用、中断和异常、线程和进程等各种内部的上下文切换,它们的处理和物理机的相应场景类似。但在虚拟机无法独立处理的情况下会退出虚拟机运行状态,借助宿主机的虚拟化管理软件来完成任务。虚拟机和宿主机之间的切换需要保存和恢复所有可能被修改的虚拟机相关状态信息。例如对于CPU的状态信息,之前几种场景需要保存恢复的主要是用户可访问的寄存器,而虚拟机切换时可能还需要保存各种特权态资源,包括众多控制寄存器。如果系统支持在一台物理计算机上虚拟化出多个虚拟机,在物理资源少于虚拟机个数的时候,只能通过保存和恢复相关资源来维持每个虚拟机都独占资源的效果。

虚拟机可以完全由软件实现。例如,开源的QEMU虚拟机软件能够虚拟出各种架构的CPU和众多设备,如在一台龙芯电脑上虚拟出一台X86 PC设备并运行Windows操作系统。在宿主机指令系统和被模拟的客户机指令系统不同时,QEMU采用二进制翻译技术把客户机应用动态翻译成等价功能的宿主机指令。不过,这种情况下QEMU虚拟的客户机运行速度比较低,一般不到宿主机的10%。

在客户机和宿主机指令系统相同时,已经有一些成熟的技术可通过适当的硬件支持来大大提升虚拟化效率。龙芯和大部分现代的高性能处理器都支持虚拟机扩展,在处理运行模式、系统态资源、内存虚拟化和IO虚拟化等方面提供硬件支持,使得虚拟机可以实现和物理机相似的性能。例如,关于处理器运行模式,LoongArch引入一个客户机模式(Guest Mode)和一个主机模式(Host Mode)以区分当前CPU是在运行客户机还是宿主机。这两个模式和特权等级模式PV0-3是正交的,也就是说客户机模式和主机模式下都有PV0-3四个特权等级。关于系统态资源,如果只有一套,那么在客户机和主机模式之间切换时就得通过保存恢复这些资源来复用。为了提高效率,硬件上可以复制相关资源,让客户机模式和主机模式使用专属的特权态资源(如控制寄存器)。在内存虚拟化方面,通过硬件支持的两级地址翻译技术可以有效地提升客户操作系统的地址翻译效率。可将支持二级地址翻译的硬件看作有两个TLB,一个保存客户机模式下的虚实地址映射关系,另一个保存主机模式下的虚实地址映射关系。客户机模式下,一个客户机虚拟地址首先通过前一个TLB查出客户机物理地址(它是由主机模式的虚拟内存模拟的,实际上是主机模式的虚拟地址),然后CPU会自动用后一个TLB进行下一级的地址翻译找出真正的主机物理地址。在IO虚拟化方面,通过IOMMU(Input-Output Memory Management Unit10)、支持虚拟化的中断分派等硬件可以有效提升虚拟化效率。适当的硬件支持有助于降低上下文切换需要保存恢复的内容、有助于在客户机模式的程序和真实硬件之间建立直接通道,从而提升虚拟化性能。

4.2.7 六种上下文切换场景的对比

4.5对以上六种上下文切换的场景进行了对比总结。函数调用和系统调用是用户主动发起的,因此可以通过ABI约定来避免不必要的保存恢复。其他几种场景通常都要达到对应用程序透明的效果,因此切换后可能被修改的状态都应该被保存和恢复。

4.3 同步机制

多任务是操作系统最为关键的特性之一,现代操作系统中可能同时存在多个进程,每个进程又可能包含多个同时执行的线程。在Linux操作系统中,某个线程正在操作的数据很可能也在被另一个线程访问。并发访问的线程可能有以下来源:

1)另一个CPU核上的线程。这是真正的多处理器系统。

2)处于中断上下文的线程。中断处理程序打断当前线程的执行。

3)因调度而抢占的另一线程。中断处理后调度而来的其他内核线程。

当线程之间出现资源访问的冲突时,需要有同步和通信的机制来保证并发数据访问的正确性。如在第3.2节中所提到的中断原子性,线程之间的共享数据访问都应该实现原子性:要么完全完成对数据的改动,要么什么改变都没有发生。Linux中包含部分原子操作,如atomic_inc()函数等,这些操作在某些指令系统中可以有特定的实现方法(如X86的lock类指令)。同步机制通常包括基于互斥(Mutual Exclusive)和非阻塞(Non-Blocking)两类。

4.3.1 基于互斥的同步机制

为了使更复杂的操作具有原子性,Linux使用了锁机制。锁是信号量机制的一种简单实现,是对特定数据进行操作的“门票”,访问同一数据的软件都要互相协作,同一时刻只能有一个线程操作该数据,任何访问被锁住数据的线程将被阻塞。

对数据进行原子操作的程序段叫作临界区,在临界区前后应该包含申请锁和释放锁的过程,申请锁失败的线程被阻塞,占有锁的进程在完成临界区操作后应该及时释放锁。

当确认竞争者在另一个CPU核上,而且临界区程序很短时,让等待锁的线程循环检查锁状态直至锁可用显然是合理的,这也是Linux为SMP(Symmetric Multi-Processing)实现的自旋锁。但当竞争者都在同一个CPU核上时,在不可抢占的内核下进行自旋可能导致死锁,此时自旋锁将退化为空操作。

当自旋锁不可用时,需要使用互斥锁的机制。当一个线程获取锁失败时,会将自己阻塞并调用操作系统的调度器。在释放锁的时候还需要同时让其他等待锁的线程离开阻塞状态。挂起和唤醒线程的操作与指令系统无关,但测试锁状态和设置锁的代码依赖于原子的“测试并设置”指令,而LoongArch指令系统的实现方式是LL/SC指令(对32位操作加.W后缀,64位加.D后缀)。LL指令设置LL bit,并检测访问的物理地址是否被修改或可能被修改,在检测到时将LL bit清除。在SMP中,检测LL bit通常使用Cache一致性协议的监听逻辑来实现。在单处理器系统中,异常处理会破坏LL bit。SC指令实现带条件的存储。当LL bit为0时,SC不会完成存储操作,而是把保存值的源操作数寄存器清零以指示失败。

Linux中的atomic_inc()原子操作函数可以使用LL/SC来实现,如下图所示。

  1. atomic_inc:
  2. ll.w $t0, $a0, 0
  3. addi.w $t0, $t0, 1
  4. sc.w $t0, $a0, 0
  5. beqz $t0, atomic_inc
  6. add.w $a0, $t0, $zero
  7. jr $ra

当SC失败时,程序会自旋(循环重试)。由于程序很短,上述程序自旋很多次的概率还是很低的。但当LL和SC之间的操作很多时,LL bit就有较大可能被破坏,因此单纯的LL/SC对复杂的操作并不适合。操作复杂时,可以使用LL/SC来构造锁,利用锁来完成线程间的同步和通信需求。LoongArch指令系统中的“测试并设置”和自旋锁指令的实现如下所示。“测试并设置”指令取回锁的旧值并设置新的锁值,自旋锁指令反复自旋得到锁后再进入临界区。

  1. la.local $a0, lock la.local $a0, lock
  2. test_and_set: selfspin:
  3. ll.w $v0, $a0, 0 ll.w $t0, $a0, 0
  4. li $t0, 0x1 bnez $t0, selfspin
  5. sc.w $t0, $a0, 0 li $t1, 0x1
  6. beqz $t0, test_and_set sc.w $t1, $a0, 0
  7. beqz $t1, selfspin
  8. <Critical section>
  9. st.w $zero, lock
  10. ...

4.3.2 非阻塞的同步机制

基于锁的资源保护和线程同步有以下缺点:

1)若持有锁的线程死亡、阻塞或死循环,则其他等待锁的线程可能永远等待下去。

2)即使冲突的情况非常少,锁机制也有获取锁和释放锁的代价。

3)锁导致的错误与时机有关,难以重现。

4)持有锁的线程因时间片中断或页错误而被取消调度时,其他线程需要等待。

一些非阻塞同步机制可以避免上述不足之处,其中一种较为有名的就是事务内存(Tran-sactional Memory)。事务内存的核心思想是通过尝试性地执行事务代码,在程序运行过程中动态检测事务间的冲突,并根据冲突检测结果提交或取消事务。

可以发现事务内存的核心思想与LL/SC是一致的,事实上LL/SC可以被视为事务内存的一种最基础的实现,只不过LL/SC的局限在于其操作的数据与寄存器宽度相同,只能用于很小的事务。

软件事务内存通过运行时库或专门的编程语言来提供支持,但仍需要最小的硬件支持,如“测试并设置”指令。虽然非常易于多线程编程,但软件事务内存有相当可观的内存空间和执行速度的代价。同时,软件事务内存不能用于无法取消的事务,如多数对IO的访问。

近年来,许多处理器增加了对事务内存的硬件支持。Sun公司在其Rock处理器中实现了硬件事务内存,但在2009年被Oracle公司收购前取消了该处理器,也没有实物发布。2011年,IBM公司在其Blue Gene/Q中首先提供了对事务内存的支持,并在后续的Power8中持续支持。Intel公司最早在Haswell处理器核中支持硬件事务内存,其扩展叫作TSX(Transactional Synchronization Extension)。

4.4 本章小结

本章首先介绍了应用程序二进制接口(ABI)的相关概念,并用LoongArch等指令系统的具体例子说明寄存器约定、函数调用约定、参数传递、虚拟地址空间和栈帧布局等内容;然后介绍了六种上下文切换场景的软硬件协同实现,讨论了切换的具体内容以及指令系统的硬件支持对切换效率的影响;最后简单介绍了同步机制,包括基于互斥的同步机制和非阻塞的同步机制。

4.5 习题

  1. 列出以下C程序中,按照Linux/LoongArch64 ABI的函数调用约定,调用nested函数时每个参数是如何传递的。
  1. struct small {
  2. char c;
  3. int d;
  4. } sm;
  5. struct big {
  6. long a1;
  7. long a2;
  8. long a3;
  9. long a4;
  10. } bg;
  11. extern long nested(char a, short b, int c, long d, float e, double f, struct small g,struct
  12. big h, long i);
  13. long test (void){
  14. return nested((char)0x61, (short)0xffff, 1, 2, 3.0, 4.0, sm, bg, 9);
  15. }
  1. 1). 用LoongArch汇编程序片段来举例并分析在未同步的线程之间(假设有多个线程可以并发运行该片段)进行共享数据访问出错的情况。

    2). 用LL/SC指令改写你的程序片段,使它们的共享数据访问正确。

  2. 1). 写一段包含冒泡排序算法实现函数的C程序,在你的机器上安装LoongArch交叉编译器,通过编译-反汇编的方式提取该算法的汇编代码。

    2). 改变编译的优化选项,记录算法汇编代码的变化,并分析不同优化选项的效果。

  3. ABI中会包含对结构体中各元素的对齐和摆放方式的定义。

    1). 在你的机器上用C语言编写一段包含不同类型(含char、short、int、long、float、double和long double)元素的结构体的程序,并获得结构体总空间占用情况。

    2). 调整结构体元素顺序,观察结构体总空间占用情况的变化,推测并分析结构体对齐的方式。

  4. 用汇编或者带嵌入汇编的C语言编写一个程序,通过直接调用系统调用,让它从键盘输入一个字符并在屏幕打印出来。用调试器单步跟踪指令执行,观察系统调用指令执行前后的寄存器变化情况,对照相应平台的ABI给出解释。


  1. https://eli.thegreenplace.net/2018/measuring-context-switching-and-memory-overheads-for-linux-threads/↩︎

  2. 普通MMU为CPU提供物理内存的虚拟化,IOMMU则为外设提供物理内存的虚拟化,让外设访问内存时可通过虚实地址转换↩︎