计算机体系结构基础(第3版)
上QQ阅读APP看书,第一时间看更新

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位的X86-64 ABI,以及利用了64位指令集的寄存器数量等优势资源但保持32位指针的X32 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对整数(或称为定点)通用寄存器的命名和使用约定。

表4.1 MIPS整数通用寄存器约定

这三个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)区域。

表4.2 LoongArch整数通用寄存器约定

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

4.1.2 函数调用约定

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

1.整型调用规范

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)以小端顺序排列。跨越其整型类型的对齐边界的位域将从下一个对齐边界开始。例如:

·struct{int x:10;int y:12;}是一个32位类型,x为9~0位,y为21~10位,31~22位未定义。

·struct{short x:10;short y:12;}是一个32位类型,x为9~0位,y为27~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的值在返回时和入口处一致。

2.硬件浮点调用规范

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实际上是浮点数。

图4.1 fun.c源代码

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

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

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

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

图4.4 varg.c源代码

表4.3 varg.c对应的参数传递

4.1.3 进程虚拟地址空间

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

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

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

·应用程序的代码、初始化数据和未初始化数据

·堆

·函数库的代码、初始化数据和未初始化数据

·栈

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

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

图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的寄存器名替换为约定的助记符,下同)。

图4.8 一个简单的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所示,整个函数不再产生和释放栈帧。

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

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

图4.11 normal函数代码

图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以动态分配栈空间。

图4.13 dynamic函数源代码

图4.14 dynamic函数的汇编代码