RISC-V汇编语言

C程序翻译成为可以在计算机上执行的机器语言程序的四个经典步骤。
在这里插入图片描述

函数调用规范(Calling convention)

函数调用过程通常分为6个阶段:

  1. 将参数存储到函数能够访问的位置。
  2. 跳转到函数开始位置(使用RV32I的jal指令jump and link)。
  3. 获取函数需要的局部存储资源,按需保存寄存器。
  4. 执行函数中的指令。
  5. 将返回值存储到调用者能够访问到的位置,恢复寄存器,释放局部资源。
  6. 返回调用函数的位置(使用ret指令)。

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

函数调用中的寄存器:

  • 当做保存寄存器来使用,在函数调用前后值不变。
  • 当做临时寄存器来使用,函数调用中不保留。
  • 函数会更改用来保存返回值的寄存器,因此它们和临时寄存器类似。
  • 用来给函数传递参数的寄存器也不需要保留,它们也类似于临时寄存器。
  • 用于存储返回地址的寄存器和存储栈指针的寄存器,要保证函数调用前后保持不变。
  • 图3.2列出了寄存器的RISC-V应用程序二进制接口(ABI)名称和它们在函数调用中是否保留的规定。
    在这里插入图片描述
    根据ABI规范,标准的RV32I函数入口:
entry_label:
	addi sp,sp,-framesize # 调整栈指针(sp寄存器)分配栈帧
	sw ra,framesize-4(sp) # 保存返回地址
	# 按需保持其它寄存器
	# 函数体

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

# 按需恢复其它寄存器
lw ra,framesize-4(sp) # 恢复返回地址
addi sp,sp,framesize #释放栈帧空间
ret # 返回调用点

汇编器

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

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

  • .text:进入代码段。
  • .align 2:后续代码按22字节对齐。
  • .global main:声明全局符号“main”。
  • .section .rodata:进入只读数据段
  • .balign 4:数据按4字节对齐。
  • .string “Hello,%s!\n”:创建空字符结尾的字符串。
  • .string “world”:创建空字符结尾的字符串。

汇编器产生如图3.7的目标文件,格式为标准的可执行可链接文件(ELF格式)。
在这里插入图片描述

链接器

链接器允许各个文件独立地进行编译和汇编,这样在改动部分文件时,不需要重新编译全部源代码。
链接器把新的目标代码和已经存在的机器语言模块(如库函数)等拼接起来。

  • 链接器这个名字原于它的功能之一,即编辑所有对象文件的跳转并链接。
    在这里插入图片描述
  • 图3.10展示了一个典型的RISC-V程序分配给代码和数据的内存区域,链接器需要调整对象文件的指令中程序和数据的地址,使之与图中地址相符合。
  • 如果输入文件中的是与位置无关的代码(PIC),链接器的工作量会有所降低。PIC中所有的指令转移和文件内的数据访问都不受代码位置的影响。
  • RV32I的相对转移(PC-relative branch)特性使得程序更易于实现PIC。
  • 除了指令,每个目标文件还包含一个符号表,存储了程序中标签,由链接过程确定地址。
  • 其中包含了数据标签和代码标签,图3.6中有两个数据标签(string1和string2)和两个代码标签(main和printf)需要确定。
  • 由于在单个32位指令中很难指定一个32的地址,RV32I的链接器通常腰围每个标签调整两条指令。
  • 如图3.6所示:数据标签需要调整lui(load upper immediate)和addi(add immediate),代码标签需要调整auipc(add upper immediate to pc)和jalr(jump and link register).
  • RISC-V编译器支持多个ABI,具体取决于F和D扩展是否存在。
  • RV32的ABI分别名为ilp32、ilp32f和ilp32d。ilp32表示C语言的整型(int),长整型(long)和指针(point)都是32位,可选后缀表示如何传递浮点参数。ilp32中,浮点参数在整数寄存器中传递;ilp32f中,单精度浮点参数在浮点寄存器中传递;ilp32d中,双精度浮点参数也在浮点寄存器中传递。
  • 如果想在浮点寄存器中传递浮点参数,需要相应的浮点ISA添加F或D扩展。
  • 因此要编译RV32I的代码(GCC选项-march=rv32i),必须使用ilp32 ABI(GCC选项-mabi=lib32)。

常见RISC-V汇编指示符
在这里插入图片描述

静态链接和动态链接

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

加载器

  • 加载器的作用就是把这个程序加载到内存中,并跳转到它开始的地址。如今的“加载器”就是操作系统。

  • 动态链接程序的加载稍微有些复杂,操作系统不直接运行程序,而是运行一个动态链接器,由动态链接器开始运行程序,并负责处理所有外部函数的第一次调用,把它们加载到内存中,并且修改程序,填入正确的调用地址。

  • 汇编器向RISC-VISA中增加了60条伪指令,使得RISC-V代码更容易读写,且不增加硬件开销。将一个寄存器编码为0使得其中很多伪指令更容易实现。使用加载高位立即数(lui)和程序计数器与高位立即数相加(auipc),简化了编译器和链接器寻找外部数据/函数的地址的过程。使用相对地址转移的代码与位置无关,减少了链接器的工作。大量的寄存器减少了寄存器保存和恢复的次数,加速函数调用和返回。

乘法和除法指令

RV32M向RV32I中添加了整数乘法和除法指令。
图4.1是RV32M扩展指令集的图形表示。
RV32M具有有符号和无符号整数的除法指令:divide(div)和divide unsigned(divu),它们将商放入目标寄存器。少数情况下,程序员需要余数而不是商,因此32RVM提供remainder(rem)和remainder unsigned(remu),它们在目标寄存器写入余数,而不是商。
在这里插入图片描述
图4.2列出了它们的操作码。
在这里插入图片描述
乘法的算式很简单:
积 = 被乘数 x 乘数

  • 但乘法比除法更为复杂,因为积的最大长度是乘数和被乘数的长度的和。将两个32位数相乘得到64位的乘积。
  • 为了正确得到一个有符号或无符号的64位积,RISC-V中带有4个乘法指令。
  • mul指令:得到整数32位乘积(64位中的低32位)。
  • mulh指令:如果操作数都是有符号整数,得到高32位。
  • mulhu指令:如果操作数都是无符号整数,得到高32位。
  • mulhsu指令:操作数一个为有符号一个为无符号,得到高32位。
  • 在一条指令中完成把64位积写入两个32位寄存器的操作会使硬件设计变得复杂,所以RV32M需要两条乘法指令才能得到一个完整的64位积。
  • 对许多微处理器来说,整数除法是相对较慢的操作。除数为2的幂次的无符号除法可以用右移来代替。事实证明,通过乘以近似倒数再修正积的高32位的方法,可以优化除数为其他数的除法。

RV32F和RV32D:单精度和双精度浮点数

在这里插入图片描述
图5.1是RV32F和RV32D扩展指令集的图形表示。
在这里插入图片描述
图5.2列出了RV32F的操作码
在这里插入图片描述
图5.3列出了RV32D的操作码

浮点寄存器

  • RV32F和RV32D使用32个独立的f寄存器而不是x寄存器。将寄存器容量和带宽乘以二。
  • 使用两组寄存器的主要原因是:可以提高处理器性能。
  • 使用两组寄存器对RISC-V指令集的主要影响是,必须要添加新的指令来对f寄存器加载和存储数据,还需要添加新指令用于x和f寄存器之间传递数据
    在这里插入图片描述
  • 图5.4列出了RV32D和RV32F寄存器以及对应的由RISC-V ABI确定的寄存器名称。
  • 如果处理器同时支持RV32F和RV32D扩展,则单精度数据仅使用f寄存器中的低32位。与RV32I中的x0不同,寄存器f0不是硬连线到常量0,而是与其它31个f寄存器一样,是一个可变寄存器。
  • 浮点运算舍入有几种方法,最准确且最常见的舍入模式是舍入到最近的偶数。舍入模式可以通过浮点控制状态寄存器fcsr进行设置。
  • 图5.5显示了fcsr并列出了舍入选项,它还包含标准所需的累积异常标志。
    在这里插入图片描述

浮点加载,存储和算术指令

  • 对于RV32F和RV32D,RISC-V有两条加载指令(flw,fld)和两条存储指令(fsw,fsd)。他们和lw和sw拥有相同的寻址模式和指令格式。
  • 添加到标准算术运算中的指令有fadd.s,fadd.d,fsub.s,fsub.d,fmul.s,fmul.d,fdiv.s,fdiv.d。
  • RV32F和RV32D还包括平方根指令fsqrt.s,fsqrt.d
  • 也有最小值和最大值指令fmin.s,fmin.d,fmax.s和fmax.d
  • 这些指令在不使用分支指令进行比较的情况下,将一对源操作数中的较小值或较大值写入目的寄存器。
  • 许多浮点算法(例如矩阵乘法)在执行完乘法运算后会立即执行一条加法或减法指令。因此RISC-V提供了指令用于先将两个操作数相乘然后将乘积加上(fmadd.s,fmadd.d)或减去(fmsub.s,fmsub.d)第三个操作数,最后再将结果写入目的寄存器。
  • 它还有在加上或减去第三个操作数之前对乘积取反的版本:fnmadd.s,fnmadd.d,fnmsub.s,fnmsub.d。
  • 这些融合的乘法-加法指令比单独的使用乘法及加法指令更准确也更快,因为它们只舍入过一次,而单独的乘法及加法指令舍入了两次。
  • 这些指令需要一条新指令格式指定第4个寄存器,称为R4。图5.2和5.3显示了R4格式,它是R格式的一个变种。
    在这里插入图片描述
    在这里插入图片描述
  • RV32F和RV32D没有提供浮点分支指令,而是提供了浮点比较指令,根据两个浮点的比较结果将一个整数寄存器设置为1或0:feq.s,feq.d,flt.s,flt.d,fle.s,fle.d。
  • 这些指令允许整数分支指令根据浮点数比较指令设置的条件进行分支跳转。
    例如,这段代码在f1<f2时,分支跳转到Exit:
flt x5,f1,f2
bne x5,x0,Exit

浮点转换和搬运

RV32F和RV32D支持在32位有符号整数,32位无符号整数,32位浮点数和64位浮点数之间进行所有组合的转换。
在这里插入图片描述
图5.6按源数据类型以及转换后的目的数据类型,罗列了这十条。
RV32F还提供了将数据从f寄存器移动到x寄存器的指令(fmv.x.w),以及反方向移动数据的指令(fmv.w.x)

其它浮点指令

  • RV32F和RV32D提供了不寻常的指令,有助于编写数学库以及提供有用的伪指令。
  • 第一个是符号注入指令,它从第一个源操作数复制了除符号位之外的所有内容。符号位的取值取决于具体是什么指令。
  • 浮点符号注入fsgjn.s,fsgnj.d:结果的符号位是rs2的符号位。
  • 浮点符号取反注入fsgnjn.s,fsgnjn.d:结果的符号位与rs2的符号位相反。
  • 浮点符号异或注入(fsgnjx.s,fsgnjx.d):结果符号位是rs1和rs2的符号位异或的结果。
  • 除了符号操作,基于符号注入指令还提供了三种流行的浮点伪指令。
  • 复制浮点寄存器:fmv.s rd,rs事实上是fsgnj.s rd,rs,rs
  • 否定:fneg.s rd,rs 映射到fsgnjn.s rd,rs,rs
  • 绝对值 fabs.s rd,rs 映射到fsgnjx.s rd,rs,rs

第二个不常见的浮点指令是classify分类指令(fclass.s,fclass.d)。

  • 测试一个源操作数满足下列10个浮点数属性中的哪些属性,然后将测试结果的掩码写入目的寄存器的低10位。十位中仅有一位被设置为1,其余被设置为0.
    在这里插入图片描述
Logo

开放原子开发者工作坊旨在鼓励更多人参与开源活动,与志同道合的开发者们相互交流开发经验、分享开发心得、获取前沿技术趋势。工作坊有多种形式的开发者活动,如meetup、训练营等,主打技术交流,干货满满,真诚地邀请各位开发者共同参与!

更多推荐