RISC-V 汇编语言分析

发布时间 2023-11-15 19:08:33作者: 吴建明wujianming

RISC-V 汇编语言分析

 

 

 3.1 导言

图 3.1 表明了从 C 程序翻译成为可以在计算机上执行的机器语言程序的四个经典步骤。 这一章的内容包括了后三个步骤,不过要从汇编语言在 RISC-V 函数调用规范中的作用 开始说起。

图 3.1 从 C 源代码翻译为可运行程序的步骤。这是从逻辑上进行的划分,实际中一些步骤会被结合起 来,加速翻译过程。在这里使用了 Unix 的文件后缀命名习惯,分别对应 MS-DOS 中 的.C, .ASM, .OBJ, .LIB 和.EXE。

3.2 函数调用规范(Calling convention) 函数调用过程通常分为 6 个阶段。

1. 将参数存储到函数能够访问到的位置;

2. 跳转到函数开始位置(使用 RV32I 的 jal 指令);

3. 获取函数需要的局部存储资源,按需保存寄存器;

4. 执行函数中的指令;

5. 将返回值存储到调用者能够访问到的位置,恢复寄存器,释放局部存储资源;

6. 返回调用函数的位置(使用 ret 指令)。 为了获得良好的性能,变量应该尽量存放在寄存器而不是内存中,但同时也要注意避免 频繁地保存和恢复寄存器,因为它们同样会访问内存。 RISC-V 有足够多的寄存器来达到两全其美的结果:既能将操作数存放在寄存器中,同 时也能减少保存和恢复寄存器的次数。其中的关键在于,在函数调用的过程中不保留部分寄 存器存储的值,称它们为临时寄存器;另一些寄存器则对应地称为保存寄存器。不再调用其 它函数的函数称为叶函数。当一个叶函数只有少量的参数和局部变量时,它们可以都被存储 在寄存器中,而不会“溢出(spilling)”到内存中。但如果函数参数和局部变量很多,程序 还是需要把寄存器的值保存在内存中,不过这种情况并不多见。

函数调用中其它的寄存器,要么被当做保存寄存器来使用,在函数调用前后值不变;要 么被当做临时寄存器使用,在函数调用中不保留。函数会更改用来保存返回值的寄存器,因 此它们和临时寄存器类似;用来给函数传递参数的寄存器也不需要保留,因此它们也类似于 临时寄存器。对于其它一些寄存器,调用者需要保证它们在函数调用前后保持不变:比如用 于存储返回地址的寄存器和存储栈指针的寄存器。图 3.2 列出了寄存器的 RISC-V 应用程序 二进制接口(ABI)名称和它们在函数调用中是否保留的规定。

 

图 3.2 RISC-V 整数和浮点寄存器的汇编助记符。RISC-V 有足够的寄存器,如果过程或方法不产生其它 调用,就可以自由使用由 ABI 分配的寄存器,不需要保存和恢复。调用前后不变的寄存器也称为“由调 用者保存的寄存器”,反之则称为“由被调用者保存的寄存器”。

根据 ABI 规范,来看看标准的 RV32I 函数入口和出口。下面是函数的开头:

 

如果参数和局部变量太多,在寄存器中存不下,函数的开头会在栈中为函数帧分配空间, 来存放。当一个函数的功能完成后,它的结尾部分释放栈帧并返回调用点:

 

很快将会看到使用这套 ABI 的一个例子,但首先需要对汇编的其它部分进行 一些解释。

3.3 汇编器

在 Unix 系统中,这一步的输入是以.s 为后缀的文件,比如 foo.s;在 MS-DOS 中则 是.ASM。 图 3.1 中的汇编器的作用不仅仅是从处理器能够理解的指令产生目标代码,还能翻译一 些扩展指令,这些指令对汇编程序员或者编译器的编写者来说通常很有用。这类指令在巧妙 配置常规指令的基础上实现,称为伪指令。图 3.3 和 3.4 列出了 RISC-V 伪指令,前者中要 求 x0 寄存器始终为 0,后者中则没有这种要求。例如,之前提到的 ret 实际上是一个伪指令, 汇编器会用 jalr x0, x1, 0 来替换它(见图 3.3)。大多数的 RISC-V 伪指令依赖于 x0。因此, 把一个寄存器硬编码为 0 便于将许多常用指令——如跳转(jump)、返回(return)、等于 0 时转移(branch on equal to zero)——作为伪指令,进而简化 RISC-V 指令集。

图 3.5 为经典的 C 程序 Hello World,编译器产生的汇编指令如图 3.6,其中使用了图 3.2 的调用规范和图 3.3、3.4 的伪指令。 汇编程序的开头是一些汇编指示符(assemble directives)。它们是汇编器的命令,具有 告诉汇编器代码和数据的位置、指定程序中使用的特定代码和数据常量等作用。

图 3.9 是 RISC-V 的汇编指示符。其中图 3.6 中用到的指示符有:

⚫ .text:进入代码段。

⚫ .align 2:后续代码按 22 字节对齐。

⚫ .globl main:声明全局符号“main”。

⚫ .section .rodata:进入只读数据段

⚫ .balign 4:数据段按 4 字节对齐。

⚫ .string “Hello, %s!\n”:创建空字符结尾的字符串。

⚫ .string “world”:创建空字符结尾的字符串。 汇编器产生如图 3.7 的目标文件,格式为标准的可执行可链接文件(ELF)格式。 按需恢复其它寄存器 恢复返回地址 释放栈帧空间 返回调用点 Hello World 程序通常 是一个新设计处理器 上运行的第一个程序。 设计者通常把能运行 操作系统并成功打印 出“Hello World”作为 新的芯片能工作的标志。

补充说明:保存寄存器和临时寄存器为什么不是连续编号的? 为了支持 RV32E——一个只有 16 个寄存器的嵌入式版本的 RISC-V(参见第 11 章),只使 用寄存器 x0 到 x15——一部分保存寄存器和一部分临时寄存器都在这个范围内。其它的保 存寄存器和临时寄存器在剩余 16 个寄存器内。RV32E 较小,但由于和 RV32I 不匹配,目前 还没有编译器支持。

 

图 3.3 依赖于 x0 的 RISC-V 伪指令。附录 A 包含了这些 RISC-V 的伪指令和真实指令。在 RV32I 中,那 些读取 64 位计数器的指令默认读取低 32 位,增加“h”时读取高 32 位。

 

图 3.4 不依赖于 x0 寄存器的 RISC-V 伪指令。在 la 指令一栏,GOT 代表全局偏移表(Global Offset Table),记录动态链接库中的符号的运行时地址。附录 A 包含了这些 RISC-V 的伪指令和真实指令。

 

图 3.5 C 语言的 Hello World 程序(hello.c)。

 

 图 3.6 RISC-V 汇编语言的 Hello World 程序(hello.s)。

 

图 3.7 RISC-V 机器语言的 Hello World 程序(hello.o)。位置 8 到 1c 这六条指令的地址字段为 0,将在后 面由链接器填充。目标文件的符号表记录了链接器所需的标签和地址。

3.4 链接器 链接器允许各个文件独立地进行编译和汇编,这样在改动部分文件时,不需要重新编译 全部源代码。链接器把新的目标代码和已经存在的机器语言模块(如函数库)等“拼接”起 来。链接器这个名字源于它的功能之一,即编辑所有对象文件的跳转并链接指令(jump and link)中的链接部分。它其实是链接编辑器(link editor)的简称,图 3.1 中的这一步骤过去 就被称为链接编辑。在 Unix 系统中,链接器的输入文件有.o 后缀,输出 a.out 文件;在 MSDOS 中输入文件后缀为.OBJ 或.LIB,输出.EXE 文件。 图 3.10 展示了一个典型的 RISC-V 程序分配给代码和数据的内存区域,链接器需要调 整对象文件的指令中程序和数据的地址,使之与图中地址相符。如果输入文件中的是与位置 无关的代码(PIC),链接器的工作量会有所降低。PIC 中所有的指令转移和文件内的数据访问都不受代码位置的影响。如第 2 章所言,RV32I 的相对转移(PC-relative branch)特性使 得程序更易于实现 PIC。 除了指令,每个目标文件还包含一个符号表,存储了程序中标签,由链接过程确定地址。 其中包括了数据标签和代码标签。图 3.6 中有两个数据标签(string1 和 string2)和两个代 码标签(main 和 printf)需要确定。由于在单个 32 位指令中很难指定一个 32 位的地址, RV32I 的链接器通常需要为每个标签调整两条指令。如图 3.6 所示:数据标签需要调整 lui 和 addi,代码标签需要调整 auipc 和 jalr。图 3.8 显示了图 3.7 中的目标文件链接后产生的 a.out 文件。

 

图 3.8 链接后的 RISC-V 机器语言 Hello World 程序。在 Unix 系统中,它的文件名是 a.out。 RISC-V 编译器支持多个 ABI,具体取决于 F 和 D 扩展是否存在。RV32 的 ABI 分别名 为 ilp32,ilp32f 和 ilp32d。ilp32 表示 C 语言的整型(int),长整型(long)和指针(pointer)都是 32 位,可选后缀表示如何传递浮点参数。在 lip32 中,浮点参数在整数寄存器中传递;在 ilp32f 中,单精度浮点参数在浮点寄存器中传递;在 ilp32d 中,双精度浮点参数也在浮点 寄存器中传递。

自然,如果想在浮点寄存中传递浮点参数,需要相应的浮点 ISA 添加 F 或 D 扩展(。因此要编译 RV32I 的代码(GCC 选项-march=rv32i),必须使用 ilp32 ABI(GCC 选项-mabi=lib32)。反过来,调用约定并不要求浮点指令一定要使用浮点寄存器,因此 RV32IFD 与 ilp32,ilp32f 和 ilp32d 都兼容。链接器检查程序的 ABI 是否和库匹配。尽管编译器本身可能支持多种 ABI 和 ISA 扩展 的组合,但机器上可能只安装了特定的几种库。因此,一种常见的错误是在缺少合适的库的 情况下链接程序。在这种情况下,链接器不会直接产生有用的诊断信息,它会尝试进行链接,然后提示不兼容。这种错误常常在从一台计算机上编译另一台计算机上运行的程序(交叉编译)时发生。

补充说明:链接器松弛(linker relaxation) 跳转并链接指令(jump and link)中有 20 位的相对地址域,因此一条指令就足够跳到很远 的位置。尽管编译器为每个外部函数的跳转都生成了两条指令,很多时候其实一条就已经足 够了。从两条指令到一条的优化同时节省了时间和空间开销,因此链接器会扫描几遍代码,尽可能地把两条指令替换为一条。每次替换会导致函数和调用它的位置之间的距离缩短,所 以链接器会多次扫描替换,直到代码不再改变。这个过程称为链接器松弛,名字来源于求解 方程组的松弛技术。除了过程调用之外,对于 gp 指针±2KiB 范围内的数据访问,RISC-V 链 接器也会使用一个全局指针替换掉 lui 和 auipc 两条指令。对 tp 指针±2KiB 范围内的线程 局部变量访问也有类似的处理。

 

图 3.9 常见 RISC-V 汇编指示符。

 

图 3.10 RV32I 为程序和数据分配内存。图中的顶部是高地址,底部是低地址。在 RISC-V 软件规范中,栈指针(sp)从 0xbffffff0 开始向下增长;程序代码段从 0x00010000 开始,包括静态链接库;程序代码 段结束后是静态数据区,在这个例子中假设从 0x10000000 开始;然后是动态数据区,由 C 语言中的 malloc()函数分配,向上增长,其中包含动态链接库。

3.5 静态链接和动态链接

上一节对静态链接(static linking)进行了说明,在程序运行前所有的库都进行了链接和 加载。如果这样的库很大,链接一个库到多个程序中会十分占用内存。另外,链接时库是绑 定的,即使它们后来的更新修复了 bug,强制的静态链接的代码仍然会使用旧的、有 bug 的 版本。 为了解决这两个问题,现在的许多系统使用动态链接(dynamic linking),外部的函数在 第一次被调用时才会加载和链接。后续所有调用都使用快速链接(fast linking),因此只会产 生一次动态开销。每次程序开始运行,它都会按照需要链接最新版本的库函数。另外,如果 多个程序使用了同一个动态链接库,库代码在内存中只会加载一次。 编译器产生的代码和静态链接的代码很相似。其不同之处在于,跳转的目标不是实际的 函数,而是一个只有三条指令的存根函数(stub function)。存根函数会从内存中的一个表中 加载实际的函数的地址并跳转。不过,在第一次调用时,表中还没有实际的函数的地址,只 有一个动态链接的过程的地址。当这个动态链接过程被调用时,动态链接器通过符号表找到 实际要调用的函数,复制到内存中,更新记录实际的函数地址的表。后续的每次调用的开销 就是存根函数的三条指令的开销。

3.6 加载器

类似图 3.8 的程序以一个可执行文件的形式存储在计算机的存储设备上。运行时,加载 器的作用是把这个程序加载到内存中,并跳转到它开始的地址。如今的“加载器”就是操作 系统。换句话说,加载 a.out 是操作系统众多的任务之一。 动态链接程序的加载稍微有些复杂。操作系统不直接运行程序,而是运行一个动态链接 器,再由动态链接器开始运行程序,并负责处理所有外部函数的第一次调用,把它们加载到 内存中,并且修改程序,填入正确的调用地址。

汇编器向 RISC-V ISA 中增加了 60 条伪指令,使得 RISC-V 代码更易于读写,并且不增 加硬件开销。将一个寄存器硬编码为 0 使得其中许多伪指令更容易实现。使用加载高位立即 数(lui)和程序计数器与高位立即数相加(auipc)两条指令,简化了编译器和链接器寻找 外部数据/函数的地址的过程。使用相对地址转移的代码与位置无关,减少了链接器的工作。 大量的寄存器减少了寄存器保存和恢复的次数,加速函数调用和返回。 RISC-V 提供了一系列简单又有影响力的机制,降低成本,提高性能,并且使得编写程 序更加容易。

体系结构研究者常用 静态链接的基准程序 来测试处理器,尽管大多数实际的程序都 有动态链接。他们说, 关心性能的用户应该 只使用静态链接,但其实这并不合理,因为加速实际的程序显 然比加速基准程序更有意义。