1
1
mirror of https://github.com/foxsen/archbase.git synced 2026-02-03 02:14:40 +08:00
Files
archbase/14-software-hardware-coop.Rmd
Zhang Fuxin 7b4bd106f1 删除多余的“浮点”两字
感谢@bjrjk指正

Resolved #11
2021-11-04 08:19:44 +08:00

448 lines
54 KiB
Plaintext
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 软硬件协同
作为软硬件的界面指令系统结构不仅包含指令和相关硬件资源的定义还包含有关资源的使用方式约定。与二进制程序相关的约定被称为ABIApplication Binary Interface,应用程序二进制接口。ABI定义了应用程序二进制代码中相关数据结构和函数模块的格式及其访问方式它使得不同的二进制模块之间的交互成为可能。本章首先讲述ABI的基本概念和具体组成并举例说明了其中一些比较常见的内容。
在软硬件之间合理划分界面是指令系统设计的一项关键内容。计算机完成一项任务所需要的某个工作常常既可以选择用软件实现也可以选择用硬件实现设计者需要进行合理的权衡。第3章中LoongArch指令系统的TLB管理就是一个很好的软硬件协同实现案例。本章继续讲述对理解计算机系统的工作过程比较重要的一些软硬件协同案例包括函数调用、异常与中断、系统调用、进程、线程和虚拟机等六种不同的上下文切换场景以及同步机制的实现。
不另加说明的情况下本章的案例采用LoongArch指令系统。
## 应用程序二进制接口
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中一些比较常见的内容。
### 寄存器约定
本节列举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位。
表\@ref(tab:mips-reg)给出了MIPS O32和N32/N64对整数或称为定点通用寄存器的命名和使用约定。
```{r mips-reg, echo = FALSE, message=FALSE, tab.cap='MIPS整数通用寄存器约定'}
autonum <- run_autonum(seq_id = "tab", bkm = "mips-reg", bkm_all = TRUE)
readr::read_csv('./materials/chapter4/mips_reg.csv') %>%
flextable() %>%
set_caption(caption="MIPS整数通用寄存器约定", autonum = autonum) %>%
merge_h() %>%
theme_box() %>%
autofit()
```
这三个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。但它们的寄存器约定都是一致的。对比表\@ref(tab:mips-reg)和表\@ref(tab:la-reg)我们可以看到LoongArch的约定比MIPS要更规整和简洁些主要有如下差别
- 取消了汇编暂存器(\$at。MIPS的一些汇编宏指令用多条硬件指令合成汇编暂存器用于数据周转。LoongArch指令系统的宏指令可以不用周转寄存器或者显式指定周转寄存器因而不再需要汇编暂存器。这可以增加编译器可用寄存器的数量。
- 取消了预留给内核的专用寄存器(\$k0/\$k1。MIPS预留两个寄存器的目的是支持高效异常处理在希望异常处理过程尽量快的时候可以用这两个寄存器省去保存上下文到内存中的开销。LoongArch指令系统提供了便签寄存器来高效暂存数据可以在不预留通用寄存器的情况下保持高效实现给编译器留下了更多的可用寄存器。
- 取消了\$gp寄存器。MIPS中用\$gp寄存器指向GOTGlobal Offset Table表以协助动态链接器计算可重定位的代码模块的相关符号位置。LoongArch指令集支持基于PC的运算指令能够用其他高效的方式实现动态链接不再需要额外花费一个通用寄存器。
- 复用参数寄存器和返回值寄存器,参数寄存器\$a0/\$a1也被用作返回值寄存器。这也是现代指令系统比较常见的做法它进一步增加了通用暂存器的数量。
- 增加了线程指针寄存器\$tp用于高效支持多线程实现。\$tp总是指向当前线程的TLSThread Local Storage区域。
```{r la-reg, echo = FALSE, message=FALSE, tab.cap='LoongArch整数通用寄存器约定'}
autonum <- run_autonum(seq_id = "tab", bkm = "la-reg", bkm_all = TRUE)
readr::read_csv('./materials/chapter4/la_reg.csv') %>%
flextable() %>%
set_caption(caption="LoongArch整数通用寄存器约定", autonum = autonum) %>%
theme_box() %>%
autofit()
```
以上几点都有助于提升编译器生成的代码的性能。曾有实验表明在完全相同的微结构和外部配置环境下LoongArch指令系统的SPEC CPU 2006基准程序平均性能比MIPS高15%左右其中部分性能来自指令集的优化部分性能来自更高效的ABI。
### 函数调用约定
LoongArch的函数调用规范如下略去了少量过于复杂且不常用的细节
**整型调用规范**
1.基本整型调用规范提供了8个参数寄存器\$a0-\$a7用于参数传递前两个参数寄存器\$a0和\$a1也用于返回值。
2.若一个标量宽度至多XLEN位对于LP32 ABIXLEN=32对于LPX32/LP64XLEN=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的值在返回时和入口处一致。
**硬件浮点调用规范**
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个。
我们来看几个例子。图\@ref(fig:fun-c)的程序用gcc -O2 fun.c -S得到汇编文件见图\@ref(fig:func-la-S)略有简化下同。可以看到对于第9个浮点参数已经没有浮点参数寄存器可用此时根据浮点调用规范第4条剩下的参数按整型调用规范传递。因此a9、a10、a11和a12分别用\$a0-\$a3这四个定点寄存器来传递虽然这段代码引用的a9和a11实际上是浮点数。
```{r fun-c, echo=FALSE, fig.align='center', fig.cap="fun.c源代码", out.width='100%'}
knitr::include_graphics("materials/chapter4/fun.c.png")
```
```{r func-la-S, echo=FALSE, fig.align='center', fig.cap="fun.c对应的LoongArch汇编代码", out.width='100%'}
knitr::include_graphics("materials/chapter4/fun_la.S.png")
```
这个程序在MIPS N64 ABI下的参数传递方式则有所不同。按MIPS ABI规则前八个参数仍然会使用浮点参数寄存器传递但是后四个参数将通过栈上的内存空间传递因此a9和a11会从栈中获取如图\@ref(fig:func-mips-S)所示。
```{r func-mips-S, echo=FALSE, fig.align='center', fig.cap="fun.c对应的MIPS汇编代码", out.width='100%'}
knitr::include_graphics("materials/chapter4/fun_mips.S.png")
```
对于可变数量参数的情况,图\@ref(fig:varg-c)给出了一个测试案例,表\@ref(tab:varg-passing)是对应的参数传递表。可以看到,第一个固定参数是浮点参数,用\$fa0后续的可变参数根据浮点调用规范第2条全部按整型调用规范传递因此不管是浮点还是定点参数都使用定点寄存器。
```{r varg-c, echo=FALSE, fig.align='center', fig.cap="varg.c源代码", out.width='100%'}
knitr::include_graphics("materials/chapter4/varg.c.png")
```
```{r varg-passing, echo = FALSE, message=FALSE, tab.cap='varg.c对应的LoongArch参数传递'}
autonum <- run_autonum(seq_id = "tab", bkm = "varg-passing", bkm_all = TRUE)
readr::read_csv('./materials/chapter4/varg_passing.csv') %>%
flextable() %>%
set_caption(caption="varg.c对应的LoongArch参数传递", autonum = autonum) %>%
theme_box() %>%
autofit()
```
### 进程虚拟地址空间
虚拟存储管理为每个进程提供了一个独立的虚拟地址空间指令系统、操作系统、工具链和应用程序会互相配合对其进行管理。首先指令系统和OS会决定哪些地址空间用户可以访问哪些只能操作系统访问哪些是连操作系统也不能访问的保留空间。然后工具链和应用程序根据不同的需要将用户可访问的地址空间分成几种不同的区域来管理。图\@ref(fig:address-space)展示了一个典型C程序运行时的用户态虚拟内存布局。
```{r address-space, echo=FALSE, fig.align='center', fig.cap="C程序的典型虚拟内存布局", out.width='80%'}
knitr::include_graphics("images/chapter4/address_space.png")
```
可以看到C程序的典型虚拟内存布局包括如下几部分
* 应用程序的代码、初始化数据和未初始化数据
* 堆
* 函数库的代码、初始化数据和未初始化数据
* 栈
应用程序的代码来自应用程序的二进制文件。工具链在编译链接应用程序时会将代码段地址默认设置为一个相对较低的地址但这个地址一般不会为0地址0在多数操作系统中都会被设为不可访问的地址以便捕获空指针访问。运行程序时操作系统中的装载器根据程序文件记录的内存段信息把代码和数据装入相应的虚拟内存地址。有初始值的全局变量和静态变量存放在文件的数据段中。未初始化的变量只需要在文件中记录其大小装载器会直接给它分配所需的内存空间然后清零。未初始化数据段之上是堆空间。堆用于管理程序运行过程中动态分配的内存C程序中用malloc分配的内存由堆来管理。接近用户最高可访问地址的一段空间被用作进程的栈。栈向下增长用先进后出的方式分配和释放。栈用作函数的临时工作空间存储C程序的局部变量、子函数参数和返回地址等函数执行完就可以抛弃的数据栈的详细管理情况参见下节。堆需要支持任意时刻分配和释放不同大小的内存块需要比较复杂的算法支持因此相应的分配和释放开销也比较大。而栈的分配和释放实质上只是调整一个通用寄存器\$sp开销很小但它只能按先进后出的分配次序操作。应用程序用到的动态函数库则由动态链接程序在空闲空间中寻找合适的地址装入通常是介于栈和堆之间。
图\@ref(fig:as-example)是64位Linux系统中一个简单C程序程序名为hello运行时的虚拟内存布局的具体案例。它基本符合上述典型情况。栈之上的三段额外空间是现代Linux系统的一些新特性引入的有兴趣的读者可以自行探究。
```{r as-example, echo=FALSE, fig.align='center', fig.cap="一个简单C程序的虚拟内存布局", out.width='100%'}
knitr::include_graphics("images/chapter4/as_example.png")
```
需要说明的是一般来说ABI并不包括进程地址空间的具体使用约定。事实上进程虚拟内存布局一般也不影响应用程序的功能。我们可以通过一些链接器参数来改变程序代码段的默认装载地址让它出现在更高的地址上也可以在任意空闲用户地址空间内映射动态链接库或者分配内容。这里介绍一些典型的情况是为了让读者更好地理解软硬件如何协同实现程序的数据管理及其装载和运行。
### 栈帧布局
像C/C++这样的高级语言通常会用栈来管理函数运行过程使用的一些信息,包括返回地址、参数和局部变量等。栈是一个大小可以动态调整的空间,在多数指令系统中是从高地址向下增长。如图\@ref(fig:stack-frame)所示,栈被组织成一个个栈帧(一段连续的内存地址空间每个函数都可以有一个自己的栈帧。调用一个子函数时栈增大产生一个新的栈帧函数返回时栈减小释放掉一个栈帧。栈帧的分配和释放在有些ABI中由调用函数负责在有些ABI中由被调用者负责。
我们以LoongArch LP64为例看看具体的案例。图\@ref(fig:stack-frame)是最完整的情况,它同时利用了\$sp和\$fp两个寄存器来维护栈帧。\$sp寄存器指向栈顶\$fp寄存器指向当前函数的栈帧开始处。编译器为函数在入口处生成一个函数头Prologue在返回处生成一个函数尾Epilogue它们负责调整\$sp和\$fp寄存器以生成新的栈帧或者释放一个栈帧并生成必要的寄存器保存和恢复代码。
```{r stack-frame, echo=FALSE, fig.align='center', fig.cap="使用帧指针寄存器的栈帧布局", out.width='50%'}
knitr::include_graphics("images/chapter4/stack_frame.png")
```
图\@ref(fig:simple-c)的简单函数用gcc -O2 -fno-omit-frame-pointer -S来编译会产生图\@ref(fig:simple-as)这样的汇编代码(为清晰起见,将形如\$rxx的寄存器名替换为约定的助记符下同)。
```{r simple-c, echo=FALSE, fig.align='center', fig.cap="一个简单的simple函数", out.width='100%'}
knitr::include_graphics("materials/chapter4/simple.c.png")
```
```{r simple-as, echo=FALSE, fig.align='center', fig.cap="simple函数的汇编代码", out.width='100%'}
knitr::include_graphics("materials/chapter4/simple.S.png")
```
前3条指令属于函数头第一条指令设立了一个16字节的栈帧(LP64要求栈帧以16字节对齐)第二条指令在偏移8的位置保存了\$fp寄存器第三条指令则把\$fp指向刚进入函数时的\$sp。第4条和第7条指令属于函数尾分别负责恢复\$fp和释放栈帧。当然很容易看到对这么简单的情况维护栈帧完全是多余的因此如果不加-fno-omit-frame-pointer强制使用\$fp的话gcc -O2 -S生成的代码将会如图\@ref(fig:simple-nofp-as)所示,整个函数不再产生和释放栈帧。
```{r simple-nofp-as, echo=FALSE, fig.align='center', fig.cap="simple函数不保留栈帧指针的编译结果", out.width='100%'}
knitr::include_graphics("materials/chapter4/simple_nofp.S.png")
```
大部分函数可以只用\$sp来管理栈帧。如果在编译时能够确定函数的栈帧大小编译器可以在函数头分配所需的栈空间通过调整\$sp,这样在函数栈帧里的内容都有一个编译时确定的相对于$sp的偏移也就不需要帧指针\$fp了。例如图\@ref(fig:normal-c)中的normal函数用gcc -O2 -S编译的结果如图\@ref(fig:normal-as)所示。normal函数调用了一个有9个整数参数的外部函数这样它必须有栈帧来为调用的子函数准备参数。可以看到编译器生成了一个32字节的栈帧把最后一个参数9保存到偏移0把返回地址\$ra保存到偏移24。
```{r normal-c, echo=FALSE, fig.align='center', fig.cap="normal函数代码", out.width='100%'}
knitr::include_graphics("materials/chapter4/normal.c.png")
```
```{r normal-as, echo=FALSE, fig.align='center', fig.cap="normal函数的gcc -O2编译结果", out.width='100%'}
knitr::include_graphics("materials/chapter4/normal.S.png")
```
但有时候可能无法在编译时确定一个函数的栈帧大小。在某些语言中可以在运行时动态分配栈空间如C程序的alloca调用这会改变\$sp的值。这时函数头会使用\$fp寄存器将其设置为函数入口时的\$sp值函数的局部变量等栈帧上的值则用相对于\$fp的常量偏移来表示。图\@ref(fig:dynamic-c)中的函数用alloca动态分配栈空间导致编译器生成带栈帧指针的代码。如图\@ref(fig:dynamic-as)所示,\$fp指向函数入口时\$sp的值\$sp则先减32字节留出调用子函数的参数空间以及保存\$fp和\$ra的空间然后再为alloca(64)减去64以动态分配栈空间。
```{r dynamic-c, echo=FALSE, fig.align='center', fig.cap="dynamic函数源代码", out.width='100%'}
knitr::include_graphics("materials/chapter4/dynamic.c.png")
```
```{r dynamic-as, echo=FALSE, fig.align='center', fig.cap="dynamic函数的汇编代码", out.width='100%'}
knitr::include_graphics("materials/chapter4/dynamic.S.png")
```
## 六种常见的上下文切换场景
CPU运行指令的过程中根据应用或者操作系统的需要经常会改变指令的执行流同时根据需要在不同的上下文之间切换。本节讲述指令系统如何实现函数调用、中断与异常、系统调用、进程、线程以及虚拟机等上下文切换场景。
### 函数调用
函数调用是用户主动发起的指令流和上下文改变。普通的转移指令只改变指令流不改变上下文函数调用则通过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指令恢复原窗口。这个技术看起来非常巧妙然而它会给寄存器重命名等现代流水线技术带来很大的实现困难现在常常被人们当作指令系统过度优化的反面案例。
### 异常和中断
上一章已经介绍了异常和中断的概念及其常规处理流程。通常异常和中断的处理对用户程序来说是透明的相关软硬件需要保证处理前后原来执行中的代码看到的CPU状态保持一致。这意味着开始异常和中断处理程序之前需要保存所有可能被破坏的、原上下文可见的CPU状态并在处理完返回原执行流之前恢复。需要保存的上下文包括异常处理代码的执行可能改变的寄存器如Linux内核自身不用浮点部件因此只需要处理通用整数寄存器而无须处理浮点寄存器、发生异常的地址、处理器状态寄存器、中断屏蔽位等现场信息以及特定异常的相关信息如触发存储访问异常的地址。异常和中断的处理代码通常在内核态执行如果它们触发前处理器处于用户态硬件会自动切换到内核态。这种情况下通常栈指针也会被重新设置为指向内核态代码所使用的栈以便隔离不同特权等级代码的运行信息。
对于非特别高频的异常或者中断操作系统往往会统一简化处理直接保存所有可能被内核修改的上下文状态然后调用相应的处理函数最后再恢复所有状态。因为大部分情况下处理函数的逻辑比较复杂所以算起开销比例来这么做的代价也可以接受。例如3A5000处理器的Linux内核中所有中断都采用统一的入口处理代码它的主要工作就是保存所有的通用整数寄存器和异常现场信息除此之外只有少量指令用于切换中断栈、调用实际中断处理函数等代码。入口处理的指令总共只有几十条而一个有实际用处的中断处理过程一般至少有数百条指令其中还包括一些延迟比较长的IO访问。例如看上去很简单的键盘中断处理在把输入作为一个事件报告到Linux内核的输入子系统之前就已经走过了如图\@ref(fig:keyboard-interrupt)所示那么多的函数。
```{r keyboard-interrupt, echo=FALSE, fig.align='center', fig.cap="键盘输入的中断处理部分路径", out.width='100%'}
knitr::include_graphics("materials/chapter4/keyboard_interrupt.txt.png")
```
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重填异常处理只用这两个寄存器因此没有额外的保存恢复代价但所有的应用程序都牺牲了两个宝贵的通用寄存器
### 系统调用
系统调用是操作系统内核为用户态程序实现的子程序。系统调用的上下文切换场景和函数调用比较类似和普通调用相比主要多了特权等级的切换。Linux操作系统中的部分系统调用如表\@ref(tab:syscall)所示。一些系统调用如gettimeofday系统调用只返回一些内核知道但用户程序不知道的信息。系统调用要满足安全性和兼容性两方面的要求。安全性方面在面对错误甚至恶意的应用时内核应该是健壮的应能保证自身的安全兼容性方面操作系统内核应该能够运行已有的应用程序这也要求系统调用应该是兼容的轻易移除一个系统调用是无法接受的。
```{r syscall, echo = FALSE, message=FALSE, tab.cap='Linux/LoongArch操作系统的部分系统调用'}
autonum <- run_autonum(seq_id = "tab", bkm = "syscall", bkm_all = TRUE)
readr::read_csv('./materials/chapter4/syscall.csv') %>%
flextable() %>%
set_caption(caption="Linux/LoongArch操作系统部分系统调用", autonum = autonum) %>%
merge_v() %>%
theme_box() %>%
autofit()
```
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()来完成与用户空间相关的复制操作。它们为相应的访存操作提供了专门的异常处理代码,避免内核因为用户传入的非法值而发生崩溃。
图\@ref(fig:syscall-write)展示了一个汇编语言编写的write系统调用的例子。用gcc编译运行它会在屏幕上输出“Hello World!”字符串。当然通常情况下应用程序不用这样使用系统调用系统函数库会提供包装好的系统调用函数以及更高层的功能接口。比如glibc库函数write包装了write系统调用C程序直接用write(1,“Hello World!\\n”,14)或者用更高层的功能函数printf(“Hello World\\n”)就可以实现同样的功能。
```{r syscall-write, echo=FALSE, fig.align='center', fig.cap='调用write系统调用输出字符串', out.width='80%'}
knitr::include_graphics("materials/chapter4/syscall_write.S.png")
```
### 进程
为了支持多道程序并发执行操作系统引入了进程的概念。进程是程序在特定数据集合上的执行实例一般由程序、数据集合和进程控制块三部分组成。进程控制块包括很多信息它记录每个进程运行过程中虚拟内存地址、打开文件、锁和信号等资源的情况。操作系统通过分时复用、虚拟内存等技术让每个进程都觉得自己拥有一个独立的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指令系统提供了专门的TSTask State段和硬件自动保存进程上下文的机制适当设置之后进程切换可以由硬件完成。但由于硬件机制不够灵活而且性能收益不明显包括Linux和Windows在内的多数操作系统都没有使用这个机制。
### 线程
线程是程序代码的一个执行路径。一个进程可以包含多个线程这些线程之间共享内存空间和打开文件等资源但逻辑上拥有独立的寄存器状态和栈。现代系统的线程一般也支持线程私有存储区Thread Local Storage,简称TLS。例如GCC编译器支持用__thread int number;这样的语句来定义一个线程私有的全局变量不同线程看到的number地址是不一样的。
线程可以由操作系统内核管理也可以由用户态的线程库管理或者两者混合。线程的实现方式对切换开销有很大的影响。例如Linux系统中最常用的线程库NPTL POSIX Thread Library采用内核和用户1:1的线程模型每个用户级线程对应一个内核线程。除了不切换地址空间线程的切换和进程的大部分流程一致都需要进入和退出核心态,经历至少两次用户态和核心态上下文的切换。因此对一些简单测试来说Linux中进程和线程切换的速度差异可能不太明显。而Go语言提供的goroutines可以被看作一种用户级实现的轻量级线程它的切换不需要通过内核一些测试表明其切换开销可比NPTL小一半以上^[https://eli.thegreenplace.net/2018/measuring-context-switching-and-memory-overheads-for-linux-threads/]。当然进程和线程切换不仅仅有执行切换代码的直接开销还有因为TLB、Cache等资源竞争导致的间接开销在数据集比较大的时候进程和线程的实际切换代价差异也可能较大。
同样适当的硬件支持也有助于提升线程切换效率。例如LoongArch的ABI将一个通用寄存器用作专门的\$tp寄存器用来高效访问TLS空间。切换线程时只需要将\$tp指向新线程的TLS访问TLS的变量时用\$tp和相应的偏移就能实现访问每个线程一份的变量。相比之下Linux/MIPS系统则依赖系统调用set_thread_area来设置当前线程的TLS指针将它保存到内核的线程数据结构中用户程序用rdhwr指令来读取当前的线程指针这个指令会产生一个异常来陷入内核读取TLS指针。相比之下这样的实现效率会低很多。
### 虚拟机
线程把一份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虚拟化方面,通过IOMMUInput-Output Memory Management Unit^[普通MMU为CPU提供物理内存的虚拟化IOMMU则为外设提供物理内存的虚拟化让外设访问内存时可通过虚实地址转换])、支持虚拟化的中断分派等硬件可以有效提升虚拟化效率。适当的硬件支持有助于降低上下文切换需要保存恢复的内容、有助于在客户机模式的程序和真实硬件之间建立直接通道,从而提升虚拟化性能。
### 六种上下文切换场景的对比
表\@ref(tab:context-switch)对以上六种上下文切换的场景进行了对比总结。函数调用和系统调用是用户主动发起的因此可以通过ABI约定来避免不必要的保存恢复。其他几种场景通常都要达到对应用程序透明的效果因此切换后可能被修改的状态都应该被保存和恢复。
```{r context-switch, echo = FALSE, message=FALSE, tab.cap='六种上下文切换场景'}
autonum <- run_autonum(seq_id = "tab", bkm = "context-switch", bkm_all = TRUE)
readr::read_csv('./materials/chapter4/context_switch.csv') %>%
flextable() %>%
set_caption(caption="六种上下文切换场景", autonum = autonum) %>%
theme_box() %>%
autofit()
```
## 同步机制
多任务是操作系统最为关键的特性之一现代操作系统中可能同时存在多个进程每个进程又可能包含多个同时执行的线程。在Linux操作系统中某个线程正在操作的数据很可能也在被另一个线程访问。并发访问的线程可能有以下来源
1)另一个CPU核上的线程。这是真正的多处理器系统。
2)处于中断上下文的线程。中断处理程序打断当前线程的执行。
3)因调度而抢占的另一线程。中断处理后调度而来的其他内核线程。
当线程之间出现资源访问的冲突时,需要有同步和通信的机制来保证并发数据访问的正确性。如在第\@ref(sec-exception)节中所提到的中断原子性线程之间的共享数据访问都应该实现原子性要么完全完成对数据的改动要么什么改变都没有发生。Linux中包含部分原子操作如atomic_inc()函数等这些操作在某些指令系统中可以有特定的实现方法如X86的lock类指令。同步机制通常包括基于互斥Mutual Exclusive和非阻塞Non-Blocking两类。
### 基于互斥的同步机制
为了使更复杂的操作具有原子性Linux使用了锁机制。锁是信号量机制的一种简单实现是对特定数据进行操作的“门票”访问同一数据的软件都要互相协作同一时刻只能有一个线程操作该数据任何访问被锁住数据的线程将被阻塞。
对数据进行原子操作的程序段叫作临界区,在临界区前后应该包含申请锁和释放锁的过程,申请锁失败的线程被阻塞,占有锁的进程在完成临界区操作后应该及时释放锁。
当确认竞争者在另一个CPU核上而且临界区程序很短时让等待锁的线程循环检查锁状态直至锁可用显然是合理的这也是Linux为SMPSymmetric 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来实现如下图所示。
```
atomic_inc:
ll.w $t0, $a0, 0
addi.w $t0, $t0, 1
sc.w $t0, $a0, 0
beqz $t0, atomic_inc
add.w $a0, $t0, $zero
jr $ra
```
当SC失败时程序会自旋循环重试。由于程序很短上述程序自旋很多次的概率还是很低的。但当LL和SC之间的操作很多时LL bit就有较大可能被破坏因此单纯的LL/SC对复杂的操作并不适合。操作复杂时可以使用LL/SC来构造锁利用锁来完成线程间的同步和通信需求。LoongArch指令系统中的“测试并设置”和自旋锁指令的实现如下所示。“测试并设置”指令取回锁的旧值并设置新的锁值自旋锁指令反复自旋得到锁后再进入临界区。
```
la.local $a0, lock la.local $a0, lock
test_and_set: selfspin:
ll.w $v0, $a0, 0 ll.w $t0, $a0, 0
li $t0, 0x1 bnez $t0, selfspin
sc.w $t0, $a0, 0 li $t1, 0x1
beqz $t0, test_and_set sc.w $t1, $a0, 0
beqz $t1, selfspin
<Critical section>
st.w $zero, lock
...
```
### 非阻塞的同步机制
基于锁的资源保护和线程同步有以下缺点:
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处理器核中支持硬件事务内存其扩展叫作TSXTransactional Synchronization Extension
## 本章小结
本章首先介绍了应用程序二进制接口ABI的相关概念并用LoongArch等指令系统的具体例子说明寄存器约定、函数调用约定、参数传递、虚拟地址空间和栈帧布局等内容然后介绍了六种上下文切换场景的软硬件协同实现讨论了切换的具体内容以及指令系统的硬件支持对切换效率的影响最后简单介绍了同步机制包括基于互斥的同步机制和非阻塞的同步机制。
## 习题
1. 列出以下C程序中按照Linux/LoongArch64 ABI的函数调用约定调用nested函数时每个参数是如何传递的。
```
struct small {
char c;
int d;
} sm;
struct big {
long a1;
long a2;
long a3;
long a4;
} bg;
extern long nested(char a, short b, int c, long d, float e, double f, struct small g,struct
big h, long i);
long test (void){
return nested((char)0x61, (short)0xffff, 1, 2, 3.0, 4.0, sm, bg, 9);
}
```
2.
1). 用LoongArch汇编程序片段来举例并分析在未同步的线程之间(假设有多个线程可以并发运行该片段)进行共享数据访问出错的情况。
2). 用LL/SC指令改写你的程序片段使它们的共享数据访问正确。
3.
1). 写一段包含冒泡排序算法实现函数的C程序在你的机器上安装LoongArch交叉编译器通过编译-反汇编的方式提取该算法的汇编代码。
2). 改变编译的优化选项,记录算法汇编代码的变化,并分析不同优化选项的效果。
4. ABI中会包含对结构体中各元素的对齐和摆放方式的定义。
1). 在你的机器上用C语言编写一段包含不同类型含char、short、int、long、float、double和long double元素的结构体的程序并获得结构体总空间占用情况。
2). 调整结构体元素顺序,观察结构体总空间占用情况的变化,推测并分析结构体对齐的方式。
5. 用汇编或者带嵌入汇编的C语言编写一个程序通过直接调用系统调用让它从键盘输入一个字符并在屏幕打印出来。用调试器单步跟踪指令执行观察系统调用指令执行前后的寄存器变化情况对照相应平台的ABI给出解释。
\newpage