概述

上一讲介绍了RISC-V的指令架构,并介绍了过程调用的机制:

  1. 传参(默认x10~x17按传参顺序为参数寄存器)
  2. 转移控制权(PC改变,并保存原来的PC+4)
  3. 获取过程调用所需的存储资源(sp为x2寄存器)
  4. 执行过程(x5~x7,x28~x31存储临时变量,为调用者保存;x8~x9,x18~x27为被调用者保存寄存器)
  5. 返回结果(默认返回寄存器为x1)
  6. 返回PC

本讲介绍过程调用中的细节,并了解RISC-V中堆栈的空间分配、寻址模式、原子操作等。

(一)过程调用——以递归函数为例

RISC-V ABI 使用 x10–x17(a0–a7)传递参数,使用 x10–x11(a0–a1)返回标量值,并通常使用 x1(ra)保存返回地址。嵌套调用需要按照调用约定保存必要的寄存器,栈与栈指针用于管理这些调用状态。

1.递归函数中栈的变化

int fact (int n)
{
    if (n < 1) return f;
    else return n * fact(n - 1);
}

翻译成的汇编代码如下:

第 3 讲技术示意图

该递归过程需要保存参数 n 与返回地址。每次进入函数时,栈指针向低地址移动 8 字节;返回前再恢复保存的值。

x2(sp)是栈指针;x8(s0)可按 ABI 约定作为可选的帧指针。帧指针在函数执行期间提供稳定的栈帧基准,超出参数寄存器容量的参数则通过栈传递。

2.一些过程可以不使用递归而改用迭代实现。

int sum(int n,int acc){
    if(n>0)  return sum(n-1, acc+n);
    else return acc;
}

考虑上面代码n=3的情况,可以转成如下的迭代版本汇编:

sum: ble x10, x0, sum_exit

add x11, x11, x10

addi x10, x10, -1

jal x0, sum

sum_exit:

addi x12, x11, 0

jalr x0, 0(x1)

这种版本的代码可以减少递归调用的部分开销。

3. 数据存储

并不是所有的变量都保存在栈上:

第 3 讲技术示意图
内存结构总览

text:代码段

static data: 存储静态变量,C语言中的数组、字符串等

dynamic data: 堆,例如C中的malloc,java中的new

(二)寻址模式

RISC-V支持四种寻址方式:

1.立即数寻址:I型指令,操作数是指令本身的常量

2.寄存器寻址:R型指令,操作数在寄存器中

3.基址或偏移寻址:I型指令,操作数在寄存器中

4.PC相对寻址:B型指令

第 3 讲技术示意图
RISC-V寻址方式

(三)原子操作

1.多处理器下的竞争

当硬件存在多处理器时,处理器之间的读写操作容易存在竞争。因此,系统设计了加锁、解锁等同步操作,可以创建只有单处理器可以操作的区域,保证数据一致性。

2.双指令对的解决方案

RISC-V设计了一对指令,其中第二个指令返回一个值,该值表示该指令对是否被原子执行。如果任何处理器执行的其他操作发生在这对指令之前或之后,则该指令对实际上是原子的。

保留加载lr.w x10, (x20) 将x20中的地址存到x10中

条件存储:sc.w x11, x23, (x20) 判断x11锁变量之后将尝试x23存入x20的地址

以原子交换为例,完整的指令序列如下:

again: lr.w x10, (x20)

sc.w x11, x23, (x20)

bne x11, x0, again

addi x23, x10, 0

每当处理器干预并修改lr.w和sc.w之间内存中的值时,sc.w就会将非0值写入x11,导致代码序列重新执行。

3.注意

(1)同步虽然是多处理器语境下的,但是原子交换对于单处理器处理多个线程也有用。如果处理器在两个指令之间对上下文进行切换,条件存储也会失败

(2)原子交换只是“同步原语”中的一种,实现更多“同步原语”需要在lr.w 和 sc.w 之间加入其他指令

(3)保证分支跳转能够执行,否则会出现死锁情况

总结:

汇编层面的原子指令是构建锁等同步原语的基础,实际实现还需结合内存模型与并发语义。