一、导言
从 C 程序翻译成为可以在计算机上执行的机器语言程序的四个经典步骤。这一章的内容包括了后三个步骤,不过我们要从汇编语言在 RISC-V 函数调用规范中的作用开始说起。
![流程图.png](//file1.elecfans.com/web2/M00/03/5F/wKgZombDEduAUXshAAFhTdPy9VA326.png)
上图从C源代码翻译为可运行程序的步骤。这是从逻辑上进行的划分,实际中一些步骤会被结合起来,加速翻译过程。我们在这里使用了 Unix 的文件后缀命名习惯,分别对应MS-DOS中的.C, .ASM, .OBJ, .LIB 和.EXE。
二、函数调用规范
函数调用过程通常分为 6 个阶段。
- 将参数存储到函数能够访问到的位置;
- 跳转到函数开始位置(使用 RV32I 的 jal 指令);
- 获取函数需要的局部存储资源,按需保存寄存器;
- 执行函数中的指令;
- 将返回值存储到调用者能够访问到的位置,恢复寄存器,释放局部存储资源;
- 返回调用函数的位置(使用 ret 指令)。
为了获得良好的性能,变量应该尽量存放在寄存器而不是内存中,但同时也要注意避免频繁地保存和恢复寄存器,因为它们同样会访问内存。RISC-V 有足够多的寄存器来达到两全其美的结果:既能将操作数存放在寄存器中,同时也能减少保存和恢复寄存器的次数。其中的关键在于,在函数调用的过程中不保留部分寄存器存储的值,称它们为临时寄存器;另一些寄存器则对应地称为保存寄存器。不再调用其它函数的函数称为叶函数。当一个叶函数只有少量的参数和局部变量时,它们可以都被存储在寄存器中,而不会“溢出(spilling)”到内存中。但如果函数参数和局部变量很多,程序还是需要把寄存器的值保存在内存中,不过这种情况并不多见。函数调用中其它的寄存器,要么被当做保存寄存器来使用,在函数调用前后值不变;要么被当做临时寄存器使用,在函数调用中不保留。函数会更改用来保存返回值的寄存器,因此它们和临时寄存器类似;用来给函数传递参数的寄存器也不需要保留,因此它们也类似于临时寄存器。对于其它一些寄存器,调用者需要保证它们在函数调用前后保持不变:比如用于存储返回地址的寄存器和存储栈指针的寄存器。下图列出了寄存器的 RISC-V 应用程序二进制接口(ABI)名称和它们在函数调用中是否保留的规定。
![2.png](//file1.elecfans.com/web2/M00/04/4C/wKgaombDEvOABxb1AANmmNdAFOs317.png)
RISC-V 整数和浮点寄存器的汇编助记符。RISC-V 有足够的寄存器,如果过程或方法不产生其它调用,就可以自由使用由 ABI 分配的寄存器,不需要保存和恢复。调用前后不变的寄存器也称为“由调用者保存的寄存器”,反之则称为“由被调用者保存的寄存器”。
补充说明:保存寄存器和临时寄存器为什么不是连续编号的?为了支持 RV32E,一个只有 16 个寄存器的嵌入式版本的 RISC-V,只使用寄存器 x0 到 x15,—部分保存寄存器和一部分临时寄存器都在这个范围内。其它的保存寄存器和临时寄存器在剩余 16 个寄存器内。RV32E 较小,但由于和 RV32I 不匹配,目前还没有编译器支持。
三、汇编器
在 Unix 系统中,这一步的输入是以.s 为后缀的文件,比如 foo.s;在 MS-DOS 中则是.ASM。
汇编器的作用不仅仅是从处理器能够理解的指令产生目标代码,还能翻译一些扩展指令,这些指令对汇编程序员或者编译器的编写者来说通常很有用。这类指令在巧妙配置常规指令的基础上实现,称为伪指令。上面两张图中列出了 RISC-V伪指令,前者中要求x0 寄存器始终为 0,后者中则没有这种要求。例如,之前提到的 ret 实际上是一个伪指令,汇编器会用 jalr x0, x1, 0来替换它。大多数的 RISC-V 伪指令依赖于 x0。因此,把一个寄存器硬编码为 0 便于将许多常用指令——如跳转(jump)、返回(return)、等于 0时转移(branch on equal to zero)——作为伪指令,进而简化 RISC-V 指令集。
汇编程序的开头是一些汇编指示符(assemble directives)。它们是汇编器的命令,具有告诉汇编器代码和数据的位置、指定程序中使用的特定代码和数据常量等作用。代表性指示符有如下:
• .text:进入代码段。
• .align 2:后续代码按 22 字节对齐。
• .globl main:声明全局符号“main”。
• .section .rodata:进入只读数据段
• .balign 4:数据段按 4 字节对齐。
• .string “Hello, %s!\n”:创建空字符结尾的字符串。
• .string “world”:创建空字符结尾的字符串。
![指令列表.png](//file1.elecfans.com/web2/M00/03/60/wKgZombDFMmAXUP1AAZw83REDco475.png)
上图依赖于 x0 的 RISC-V 伪指令。在 RV32I 中,那些读取 64 位计数器的指令默认读取低 32 位,增加“h”时读取高 32 位。
![伪指令.png](//file1.elecfans.com/web2/M00/04/4C/wKgaombDFTiAB16DAAcYOBgJWok780.png)
上图不依赖于 x0 寄存器的 RISC-V 伪指令。在 la 指令一栏,GOT 代表全局偏移表(Global Offset
Table),记录动态链接库中的符号的运行时地址。
![image.png](//file1.elecfans.com/web2/M00/03/60/wKgZombDFWeAF1aFAABOGAGbRjg907.png)
C 语言的 Hello World 程序(hello.c)
![汇编语言.png](//file1.elecfans.com/web2/M00/03/60/wKgZombDFcKACKIIAAQPoJv6dHs337.png)
RISC-V 汇编语言的 Hello World 程序(hello.s)
![机器语言.png](//file1.elecfans.com/web2/M00/03/60/wKgZombDFguAE4EcAAEQGqxSnNE928.png)
RISC-V 机器语言的 Hello World 程序(hello.o)。位置 8 到 1c 这六条指令的地址字段为 0,将在后面由链接器填充。目标文件的符号表记录了链接器所需的标签和地址。
四、链接器
链接器允许各个文件独立地进行编译和汇编,这样在改动部分文件时,不需要重新编译全部源代码。链接器把新的目标代码和已经存在的机器语言模块(如函数库)等“拼接”起来。链接器这个名字源于它的功能之一,即编辑所有对象文件的跳转并链接指令(jump and link)中的链接部分。它其实是链接编辑器(link editor)的简称。在 Unix 系统中,链接器的输入文件有.o 后缀,输出 a.out 文件;在 MS-DOS 中输入文件后缀为.OBJ 或.LIB,输出.EXE 文件。上图中展示了一个典型的 RISC-V 程序分配给代码和数据的内存区域,链接器需要调整对象文件的指令中程序和数据的地址,使之与图中地址相符。如果输入文件中的是与位置无关的代码(PIC),链接器的工作量会有所降低。PIC 中所有的指令转移和文件内的数据访问都不受代码位置的影响。
除了指令,每个目标文件还包含一个符号表,存储了程序中标签,由链接过程确定地址。其中包括了数据标签和代码标签。上图中有两个数据标签(string1 和 string2)和两个代码标签(main 和 printf)需要确定。由于在单个 32 位指令中很难指定一个 32 位的地址,RV32I 的链接器通常需要为每个标签调整两条指令。
![image.png](//file1.elecfans.com/web2/M00/04/4C/wKgaombDFxaAC1tqAADYEhXQOPU828.png)
链接后的 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 扩展的组合,但机器上可能只安装了特定的几种库。因此,一种常见的错误是在缺少合适的库的情况下链接程序。在这种情况下,链接器不会直接产生有用的诊断信息,它会尝试进行链接, 然后提示不兼容。这种错误常常在从一台计算机上编译另一台计算机上运行的程序(交叉编译)时发生。
![常见的汇编指示符.png](//file1.elecfans.com/web2/M00/04/4D/wKgaombDF8SASfR8AAaRFE9MEiQ412.png)
常见 RISC-V 汇编指示符
![image.png](//file1.elecfans.com/web2/M00/03/60/wKgZombDGBCAOOuzAAF_Tm8oZCk053.png)
RV32I 为程序和数据分配内存。图中的顶部是高地址,底部是低地址。在 RISC-V 软件规范中, 栈指针(sp)从 0xbffffff0 开始向下增长;程序代码段从 0x00010000 开始,包括静态链接库;程序代码段结束后是静态数据区,在这个例子中假设从 0x10000000 开始;然后是动态数据区,由 C 语言中的malloc()函数分配,向上增长,其中包含动态链接库。
五、静态链接和动态链接
在程序运行前所有的库都进行了链接和加载。如果这样的库很大,链接一个库到多个程序中会十分占用内存。另外,链接时库是绑定的,即使它们后来的更新修复了 bug,强制的静态链接的代码仍然会使用旧的、有 bug 的版本。为了解决这两个问题,现在的许多系统使用动态链接(dynamic linking),外部的函数在第一次被调用时才会加载和链接。后续所有调用都使用快速链接(fast linking),因此只会产生一次动态开销。每次程序开始运行,它都会按照需要链接最新版本的库函数。另外,如果多个程序使用了同一个动态链接库,库代码在内存中只会加载一次。编译器产生的代码和静态链接的代码很相似。其不同之处在于,跳转的目标不是实际的函数,而是一个只有三条指令的存根函数(stub function)。存根函数会从内存中的一个表中加载实际的函数的地址并跳转。不过,在第一次调用时,表中还没有实际的函数的地址,只有一个动态链接的过程的地址。当这个动态链接过程被调用时,动态链接器通过符号表找到实际要调用的函数,复制到内存中,更新记录实际的函数地址的表。后续的每次调用的开销就是存根函数的三条指令的开销。
六、加载器
程序以一个可执行文件的形式存储在计算机的存储设备上。运行时,加载器的作用是把这个程序加载到内存中,并跳转到它开始的地址。如今的“加载器”就是操作系统。换句话说,加载 a.out 是操作系统众多的任务之一。动态链接程序的加载稍微有些复杂。操作系统不直接运行程序,而是运行一个动态链接器,再由动态链接器开始运行程序,并负责处理所有外部函数的第一次调用,把它们加载到内存中,并且修改程序,填入正确的调用地址。
七、结束语
汇编器向 RISC-V ISA 中增加了 60 条伪指令,使得 RISC-V 代码更易于读写,并且不增加硬件开销。将一个寄存器硬编码为 0 使得其中许多伪指令更容易实现。使用加载高位立即数(lui)和程序计数器与高位立即数相加(auipc)两条指令,简化了编译器和链接器寻找外部数据/函数的地址的过程。使用相对地址转移的代码与位置无关,减少了链接器的工作。大量的寄存器减少了寄存器保存和恢复的次数,加速函数调用和返回。RISC-V 提供了一系列简单又有影响力的机制,降低成本,提高性能,并且使得编写程序更加容易。
|