learn-tech/专栏/计算机基础实战课/20RISC-V指令精讲(五):原子指令实现与调试.md
2024-10-16 10:18:29 +08:00

22 KiB
Raw Blame History

                        因收到Google相关通知网站将会择期关闭。相关通知内容
                        
                        
                        20 RISC-V指令精讲原子指令实现与调试
                        你好我是LMOS。

通过前面的课程我们学过了RISC-V的各种跳转指令以及这些指令的各种变形并且了解了它们的机器编码。

今天我们开始学习RISC-V下的原子指令原子指令是RISC-V的指令扩展命名为 A。这个扩展指令中包含两部分分别是LR/SC指令和AMO指令。

我们先搞明白为什么需要原子指令什么情况用得上它们。再分别学习和对比LR/SC指令与AMO指令另外我还会让你知道这些指令各自的使用场景是什么。

课程代码你可以从这里下载。话不多说,让我们直接开始吧。

为什么需要原子指令

你对学生时代上的物理课还有什么印象么?那时候我们就接触过“原子”这个概念了。“原子”是物质的最小组成,即原子是不可分割的。虽然到现在科学家已经发现在原子内部有更小的成分,但是在广义上原子仍然保持“不可分割”的语义。

那么在芯片中的原子指令是什么呢?它延续了“不可分割”这个含义,表示该指令的执行是不可分割的,完成的操作不会被其它外部事件打断。

我们结合一段代码,来了解原子指令的具体作用和使用场景。

//全局变量A int A = 0; //线程A执行的函数 void thread_a() { A++; printf("ThreadA A is:%d\n"A); return; } //线程B执行的函数 void thread_b() { A++; printf("ThreadB A is:%d\n"A); return; }

以上两个函数分别由不同的线程运行都是对全局变量A加1后打印出来。让我们暂停一下想想看你认为程序的打印结果是什么

也许你的判断是两种情况即输出A值1、 2A值2、2。但你把代码跑一下试试就会发现结果出乎意料。除了前面两种情况还多了一个可能A值1、1。这就很奇怪了为什么出现这种情况呢

原因便是A++不是原子指令实现的不可分割操作它可以转化为后面这样的CPU指令形式。

load regA #加载A变量到寄存器 Add reg1 #对寄存器+1 store Areg #储存寄存器到A变量

我们已经看到了A++被转换成了三条指令有可能线程A执行了上面第一行指令线程B也执行了上面第一行指令这时就会出现线程A、B都输出1的情况。其本质原因是这三条指令是独立、可分割的。

解决这个问题的方案不止一种。我们可以使用操作系统的线程同步机制让线程A和线程B串行执行即thread_a函数执行完成了再执行thread_b函数。另一种方案是使用原子指令利用原子指令来保证对变量A执行的操作也就是加载、计算、储存这三步是不可分割的即一条指令能原子地完成这三大步骤。

现实中,小到多个线程共享全局变量,大到多个程序访问同一个文件,都需要保证数据的一致性。对于变量可以使用原子指令,而文件可以利用原子指令实现文件锁,来同步各个进程对文件的读写。这就是原子指令存在的价值。

为了实现这些原子操作一款CPU在设计实现时就要考虑提供完成这些功能的指令RISC-V也不例外原子指令是现代CPU中不可或缺的一种指令除非你的CPU是单个核心没有cache且不运行操作系统。显然RISC-V架构的CPU不是那种类型的CPU。

搞清楚了为什么需要原子指令我们接下来就去看看RISC-V究竟提供了哪些原子指令

LR/SC指令

首先RISC-V提供了LR/SC指令。这虽然是两条指令但却是一对好“搭档”它们需要配合才能实现原子操作缺一不可。看到后面你就会知道这是为什么了我们先从这两条指令用在哪里说起。

在原子的比较并交换操作中常常会用到LR/SC指令这个操作在各种加锁算法中应用广泛。我们先来看看这两条指令各自执行了什么操作。

LR指令是个缩写全名是Load Reserved即保留加载而SC指令的缩写展开是Store Conditional即条件存储。

我们先来看看它们在汇编代码中的书写形式,如下所示:

lr.{w/d}.{aqrl} rd(rs1) #lr是保留加载指令 #{可选内容}W32位、D64位 #aqrl为内存顺序一般使用默认的 #rd为目标寄存器 #rs1为源寄存器1

sc.{w/d}.{aqrl} rdrs2(rs1) #sc是条件储存指令 #{可选内容}W32位、D64位 #aqrl为内存顺序一般使用默认的 #rd为目标寄存器 #rs1为源寄存器1 #rs2为源寄存器2

上述代码中rd、rs1、rs2可以是任何通用寄存器。“{}“中的内容不是必须填写的,汇编器能根据当前的运行环境自动设置。

LR指令和SC指令完成的操作用伪代码可以这样描述

//lr指令 rd = [rs1] reservation_set(cur_hart) //sc指令 if (is_reserved(rs1)) { *rs1 = rs2 rd = 0 } else rd = 1 clean_reservation_set(cur_hart)

观察上述伪代码我们先看看LR指令做了什么rs1寄存器的数据就是内存地址指定了LR指令从哪里读取数据。LR会从该地址上加载一个32位或者64位的数据存放到rd寄存器中。这个地址需要32位或者64位对齐加载之后会设置当前CPU hartRISC-V中的核心读取该地址的保留位。

而SC指令则是先判断rs1中对应地址里的保留位reservation set有没有被设置。如果被设置了则把rs2的数据写入rs1为地址上的内存中并在rd中写入0否则将向rd中写入一个非零值这个值并不一定是1最后清除当前对应CPU hartRISC-V中的核心在该地址上设置的保留位。

从上面的描述我们发现SC指令不一定执行成功只有满足后面这四个条件它才能执行成功

LR和SC指令成对地访问相同的地址。- LR和SC指令之间没有任何其它的写操作来自任何一个hart访问同样的地址。- LR和SC指令之间没有任何中断与异常发生。- LR和SC指令之间没有执行MRET指令。

而这些条件正是LR/SC指令保持原子性的关键所在。

下面我们一起写代码验证一下。为了方便调试我们的代码组织结构还是从写一个main.c文件开始然后在其中写上main函数因为这是链接器所需要的。接着我们写一个lrsc.S文件并在里面用汇编写上lrsc_ins函数这些操作在前面课程中我们已经反复做过了。

代码如下所示:

.globl lrsc_ins #a0内存地址 #a1预期值 #a2所需值 #a0返回值如果成功则为0否则为1 lrsc_ins: cas: lr.w t0(a0) #加载以前的值 bne t0a1fail #不相等则跳转到fail sc.w a0a2(a0) #尝试更新 jr ra #返回 fail: li a01 #a0 = 1 jr ra #返回

这样lrsc_ins函数就写好了。

我结合上面的代码再带你理解一下这个函数首先通过LR指令把a0中的数据也就是地址信息加载到t0中如果t0和a1不相等则跳转到fail处将a0置1并返回否则继续顺序执行通过SC指令将a2的数据写入到a0为地址的内存中写入成功则将a0置0不成功则置为非零。SC指令执行成功与否要看是否满足上面那4个条件最后返回。

我们在main.c文件中声明一下这两个函数并调用它再用VSCode打开工程目录按下“F5”键调试一下如下所示

上图是执行“lr.w t0(a0)”指令后的状态。下一步我们将执行bne比较指令继续做两步单步调试目的是执行SC指令如下所示

上图是执行“sc.w a0a2(a0)”指令后的状态。由于SC指令执行时满足上述四大条件所以SC会把a2的内容写入a0为地址的内存中并将a0置0最后返回到main函数中如下所示

上图描述的过程是main函数调用lrsc_ins函数后然后调用printf输出返回的结果在终端中的输出为result:0val:1。这个结果在我们的预料之中也验证了LR/SC指令正如我们前面所描述的那样。

通过这种LR/SC指令的组合确实可以实现原子的比较并交换的操作在计算机行业中也称为CAS指令。这种CAS指令是实现系统中各种同步锁的基础设施这也是为什么我在写代码时同时使用lrsc_ins和cas两个标号的用意。

我们再看一个例子加深印象,代码如下所示:

int cas(int* lock, int cmp, int lockval); // 声明cas函数 int lock = 0; //初始化锁 void LockInit(int* lock) { lock = 0; return; } //加锁 int Lock(int lock) { int status; status = cas(lock, 0, 1); if(status == 0) { return 1;//加锁成功 } return 0; //加锁失败 } //解锁 int UnLock(int* lock) { int status; status = cas(lock, 1, 0); if(status == 0) { return 1;//解锁成功 } return 0; //解锁失败 }

上述代码是一个加解锁的例子返回1表示加、解锁操作成功返回0表示加、解锁操作失败lock为0表示解锁状态为1则表示上锁状态。加、解锁操作最关键的点在于这个操作是原子的不能被打断而这正是LR/SC指令的作用所在。

经过刚刚的调试LR/SC指令的功能细节我们已经心中有数了。现在我们继续一起看看它的二进制数据。

打开终端切换到工程目录下输入命令riscv64-unknown-elf-objdump -d ./main.elf > ./main.ins就会得到main.elf的反汇编数据文件main.ins。我们打开这个文件就会看到它们的二进制数据如下所示

我们一起看看上图中的反汇编代码这里编译器为了节约内存使用了一些压缩指令也就是RISC-V的C类扩展指令。

比如ret的机器码是0x8082li a01的机器码为0x4505它们只占用16位编码即二字节。

上图机器码与汇编语句的对应关系如下表所示:

让我们继续一起来拆分一下LR、SC指令的各位段的数据看看它是如何编码的。对照后面的示意图你更容易理解

LR/SC指令的操作码和功能码都是相同的它们俩是靠27位~31位来区分的。其它的寄存器位段在前面的课程中已经介绍得相当详细了而aq-rl位段是用来设置计算储存顺序的使用默认的就行这里我们就不深入研究了。

AMO指令

前面我们通过例子演示了LR/SC指令如何实现锁的功能。基于此我们给操作对象加锁就能执行更多逻辑上的“原子”操作。但这方式也存在问题实现起来很复杂对于单体变量使用这种方式代价很大。

因此AMO类的指令应运而生。这也是一类原子指令它们相比LR/SC指令用起来更方便。因为也属于原子指令所以每个指令完成的操作同样是不可分割不能被外部事件打断的。

AMO 是 Atomic Memory Operation 的缩写即原子内存操作。AMO 指令又分为几类,分别是原子交换指令、原子加法指令、原子逻辑指令和原子取大小值指令。

大部分调试指令的操作,我们都在前几节课里学过了,这里我们不再深入调试,只是用这些指令来写一些可执行的代码,方便我们了解其原理就行了。调试过程和前面的一样。你自己有兴趣可以自己动手调试。

首先我们来看看原子交换指令,它能执行寄存器和内存中的数据交换,并保证该操作的原子性,其汇编代码形式如下所示:

amoswap.{w/d}.{aqrl} rd,rs2,(rs1) #amoswap是原子交换指令 #{可选内容}W32位、D64位 #aqrl为内存顺序一般使用默认的 #rd为目标寄存器 #rs1为源寄存器1 #rs2为源寄存器2

上述代码中rd、rs1、rs2可以是任何通用寄存器。“{}“中的可以不必填写,汇编器能根据当前的运行环境自动设置。

我们用伪代码来描述一下amoswap指令完成的操作你会看得更清楚。

//amoswap rd = *rs1 *rs1 = rs2

观察上述伪代码amoswap指令是把rs1中的数据当成内存地址加载了该地址上一个32位或者64位的数据到rd寄存器中。然后把rs2中的数据写入到rs1指向的内存单元中实现rs2与内存单元的数据交换该地址需要32位或者64位对齐。这两步操作是原子的、不可分割的。

下面我们在工程目录中建立一个amo.S文件并在其中用汇编写上amoswap_ins函数代码如下所示

.globl amoswap_ins #a0内存地址 #a1将要交换的值 #a0返回值 amoswap_ins: amoswap.w a0, a1, (a0) #原子交换a0=[a0]=a1 jr ra #返回

我们直接看代码里的amoswap_ins函数其中amoswap指令的作用是把a0地址处的内存值读取到a0中然后把a1的值写入a0中的地址处的内存中完成了原子交换操作。你可以自己进入工程调试一下。

接着我们来看看原子加法指令,这类指令能把寄存器和内存中的数据相加,并把相加结果写到内存里,然后返回内存原有的值。原子加法指令的汇编代码形式如下所示。

amoadd.{w/d}.{aqrl} rd,rs2,(rs1) #amoadd是原子加法指令 #{可选内容}W32位、D64位 #aqrl为内存顺序一般使用默认的 #rd为目标寄存器 #rs1为源寄存器1 #rs2为源寄存器2

上述代码中除了指令符和原子交换指令不同其它都是一样的amoadd指令完成的操作用伪代码描述如下

//amoadd rd = *rs1 *rs1 = *rs1 + rs2

我们观察一下amoadd指令都做了什么。它把rs1中的数据当成了内存地址先把该地址上一个32位或者64位的数据读到rd寄存器中。然后把rs2的数据与rs1指向的内存单元里的数据相加结果写入到该地址的内存单元中该地址仍需要32位或者64位对齐。这两步操作是不可分割的。

下面我们在amo.S文件中用汇编写上amoadd_ins函数代码如下

.globl amoadd_ins #a0内存地址 #a1相加的值 #a0返回值 amoadd_ins: amoadd.w a0, a1, (a0) #原子相加a0=[a0] [a0]=[a0] + a1 jr ra #返回

上述代码中amoadd_ins函数中的amoadd指令把a0中的地址处的内存值读取到a0中然后把a1的值与a0中的地址处的内存中的数据相加结果写入该地址的内存单元中这操作是原子执行的完成了原子加法操作。指令的调试你可以课后自己练一练。

我们继续研究原子逻辑操作指令,一共有三条,分别是原子与、原子或、原子异或。它们和之前的逻辑指令功能相同,只不过它们在保证原子性的同时,还能直接对内存地址中的数据进行操作。

原子逻辑操作指令的汇编代码形式如下所示:

amoand.{w/d}.{aqrl} rd,rs2,(rs1) amoor.{w/d}.{aqrl} rd,rs2,(rs1) amoxor.{w/d}.{aqrl} rd,rs2,(rs1) #amoand是原子按位与指令 #amoor是原子按位或指令 #amoxor是原子按位异或指令 #{可选内容}W32位、D64位 #aqrl为内存顺序一般使用默认的 #rd为目标寄存器 #rs1为源寄存器1 #rs2为源寄存器2

上述代码中三条指令除了指令符不同其它是一样的rd、rs1、rs2可以是任何通用寄存器。“{}“中的可以不必填写,汇编器能根据当前的运行环境自动设置。

amoand、amoor、amoxor三条指令各自完成的操作我们分别用伪代码描述一下如下所示

//amoand rd = *rs1 *rs1 = *rs1 & rs2 //amoor rd = *rs1 *rs1 = *rs1 | rs2 //amoxor rd = *rs1 *rs1 = *rs1 ^ rs2

上面的伪代码中都是把rs1中数据当成地址把该地址内存单元中的数据读取到rd中然后进行相应的按位与、或、异或操作最后把结果写入该地址的内存单元中。这些操作是不可分割的且地址必须对齐到处理器位宽。

下面我们在amo.S文件中用汇编写上三个函数代码如下

.globl amoand_ins #a0内存地址 #a1相与的值 #a0返回值 amoand_ins: amoand.w a0, a1, (a0) #原子相与a0 = [a0] [a0] = [a0] & a1 jr ra #返回

.globl amoor_ins #a0内存地址 #a1相或的值 #a0返回值 amoor_ins: amoor.w a0, a1, (a0) #原子相或a0 = [a0] [a0] = [a0] | a1 jr ra #返回

.globl amoxor_ins #a0内存地址 #a1相异或的值 #a0返回值 amoxor_ins: amoxor.w a0, a1, (a0) #原子相异或a0 = [a0] [a0] = [a0] ^ a1 jr ra #返回

这段代码中amoand_ins、amoor_ins、amoxor_ins三个函数都是把a0中数据作为地址把该地址内存单元中的值读取到a0中。然后再对a1的值与该地址内存单元中的数据进行与、或、异或操作把结果写入该地址的内存单元中这样就完成了原子与、或、异或操作。调试的思路和前面指令一样我就不重复了。

最后,我们来看看原子取大小值的指令,它包括无符号数和有符号数版本,一共是四条指令,分别是:原子有符号取大值指令、原子无符号取大值指令、原子有符号取小值指令、原子无符号取小值指令。

汇编代码形式如下所示:

amomax.{w/d}.{aqrl} rd,rs2,(rs1) amomaxu.{w/d}.{aqrl} rd,rs2,(rs1) amomin.{w/d}.{aqrl} rd,rs2,(rs1) amominu.{w/d}.{aqrl} rd,rs2,(rs1) #amomax是原子有符号取大值指令 #amomaxu是原子无符号取大值指令 #amomin是原子有符号取小值指令 #amominu是原子无符号取小值指令 #{可选内容}W32位、D64位 #aqrl为内存顺序一般使用默认的 #rd为目标寄存器 #rs1为源寄存器1 #rs2为源寄存器2

上述代码中四条指令,除了指令符不同,其它内容是一样的。

我们用伪代码来描述一下amomax、amomaxu、amomin、amominu四条指令各自完成的操作形式如下

max(a,b) { if(a > b) return a; else return b; } min(a,b) { if(a < b) return a; else return b; } exts(a) { return 扩展符号(a) } //amomax rd = *rs1 *rs1 = max(exts(*rs1),exts(rs2)) //amomaxu rd = *rs1 *rs1 = *rs1 = max(*rs1,rs2) //amomin rd = *rs1 *rs1 = min(exts(*rs1),exts(rs2)) //amominu rd = *rs1 *rs1 = *rs1 = min(*rs1,rs2)

观察上面的伪代码我们可以看到max函数可以返回两数之间的大数、min函数可以返回两数之间的小数exts函数负责处理数据的符号。

我们对比学习这几条指令理解起来更容易。上面的amomax、amomaxu指令都是把rs1中数据当成地址把该地址内存单元中的数据读取到rd中然后与rs2进行比较。最后把两者之间大的那个数值写入该地址的内存单元中区别是比较时的数据有无符号。

而amomin、amominu指令则是把rs1中数据当成地址把该地址内存单元中的数据读取到rd中然后与rs2进行比较最后把两者之间小的数值写入该地址的内存单元中。这两个指令的区别同样是比较时的数据有无符号。

下面我们在amo.S文件中用汇编写上四个函数代码如下所示

.globl amomax_ins #a0内存地址 #a1相比的值 #a0返回值 amomax_ins: amomax.w a0, a1, (a0) #原子相与a0 = [a0] [a0] = max([a0] , a1) jr ra #返回

.globl amomaxu_ins #a0内存地址 #a1相比的值 #a0返回值 amomaxu_ins: amomaxu.w a0, a1, (a0) #原子相与a0 = [a0] [a0] = maxu([a0] , a1) jr ra #返回

.globl amomin_ins #a0内存地址 #a1相比的值 #a0返回值 amomin_ins: amomin.w a0, a1, (a0) #原子相与a0 = [a0] [a0] = min([a0] , a1) jr ra #返回

.globl amominu_ins #a0内存地址 #a1相比的值 #a0返回值 amominu_ins: amominu.w a0, a1, (a0) #原子相与a0 = [a0] [a0] = minu([a0] , a1) jr ra #返回

上述代码中amomax_ins、amomaxu_ins、amomin_ins、amominu_ins四个函数都是把a0中数据作为地址把该地址内存单元中的值读取到a0中然后把a1的值与该地址内存单元中的数据进行比较操作结果取大或者取小最后把结果写入该地址的内存单元中这些操作都是原子执行的、不可分割。你可以自己进入工程调试一下。

下面我们一起把这些amo指令进行测试相关代码我已经帮你写好了我们工程项目按下“F5”来调试。下面是指令调用后的打印结果截图你可以对照一下。

截图中的输出与我们预期的结果分毫不差,这说明我们用相关指令编写的汇编函数所完成的功能是正确无误的。

至此关于RISC-V所有的原子指令一共有11条指令我们就全部学完了。这些指令分别完成不同的功能重要的是它们的原子特性特别是AMO类指令在处理一些全局共享的单体变量时相当有用。

重点回顾

现在我们一起来回顾一下今天所学内容。

首先,我们讨论了为什么一款芯片需要有原子指令,从这里入手来了解原子指令的特性,它具有操作不可分割性。所以,原子指令是现代高级通用芯片里不可缺少的,是系统软件或者应用软件现实共享数据保护,维护共享数据一致性的重要基础依赖设施。

RISC-V的原子指令中包含两部分分别是LR/SC指令和AMO指令。

LR/SC指令必须成对使用才能达到原子效果在执行LR指令的同时处理器会设置相应的标志位用于监控其内存地址上有没有其它hart访问有没有产生中断异常有没有执行MRET指令。只要发生上述情况里的一种就会导致SC指令执行失败。通过这样的规则才能确保LR与SC指令之间的操作是原子的。

不过有时候LR/SC指令用起来还是挺复杂的所以AMO类指令即原子内存操作应运而生。RISC-V提供了一系列AMO类指令它们是原子交换指令、原子加法指令、原子逻辑指令、原子取大小指令这些指令相比LR、SC指令使用起来更加方便。

思考题

请你尝试用LR、SC指令实现自旋锁。

期待你在留言区记录自己的收获,或者向我提问。如果觉得这节课还不错,别忘了推荐给身边更多朋友,跟他一起学习进步。