1. 引言
要控制计算机硬件,就必须用它的语言。计算机语言中的单词称为指令,其词汇表称为指令系统 (instruction set)。
1.1 从软件到硬件
众所周知,计算机中的硬件只能执行极为简单的低级指令,从复杂的应用程序到原始的指令涉及若干软件层次来将高层次操作解释或翻译成简单的计算机指令。下图给出了这些软件的层次结构,外层是应用软件,中心是硬件,系统软件 (systems software) 位于两者之间。

系统软件就像应用软件与底层硬件之间的过渡连接层一样,系统软件有很多种,其中有两种对于现代计算机系统来说是必需的:操作系统和编译器。
- 操作系统 (operating system) 是用户程序和硬件之间的接口,为用户提供各种服务和监控功能。
- 编译器 (compiler) 把高级语言 (如 C、C++、Java 或 Visual Basic 等) 编写的程序翻译成硬件能执行的指令。
1.2 从高级语言到硬件语言
对于计算机来说,最简单的信号是通和断。通常认为计算机语言就是二进制数,每个二进制数由0和1组成。计算机服从于我们的命令,即计算机术语中的指令 (instruction)。指令是能被计算机识别并执行的位串,可以将其视为数字,例如:
1001010100101110
第一代程序员是直接使用二进制数与计算机通信的,这是一项非常乏味的工作。所以他们很快发明了助记符,以符合人类的思维方式。最初助记符是手工翻译成二进制的,其过程显然过于烦琐。随后设计人员开发了一种称为汇编器(assembler) 的软件,可以将助记符形式的指令自动翻译成对应的二进制。例如, 程序员写下:
add A,B
其中,add就是助记符。汇编器会将该符号翻译成:
1001010100101110
该指令告诉计算机将 A 和 B 两个数相加。这种符号语言的名称今天还在用,即汇编语言 (assembly language)。而机器可以理解的二进制语言是机器语言 (machine language)。
虽然这是一个巨大的进步,但是汇编语言需要程序员写出计算机执行的每条指令,要求程序员像计算机一样思考,这样仍不符合人的思维习惯。所以需要编写一个程序来将更强大的高级语言翻译成计算机指令,编译器(complier) 由此诞生,可将高级编程语言编译为汇编语言语句。此后,程序员可以直接用高级语言来写程序,高级编程语言及其编译器大大地提高了软件的生产率。下图显示了高级语言、汇编语言、机器语言、编译器、汇编器之间的关系:

而本文要讲的就是RISCV处理器中的汇编语言程序。
2. RISCV指令集
RISCV指令集分为基础指令集和扩展指令集;又根据处理器位数不同,分为32位、64位以及128位。,其中32位指令集(RV32I)为了进一步支持嵌入式又制定嵌入式指令集(RV32E),将32个32位的通用寄存器砍掉一半,即只有16个32位的通用寄存器,其他保持不变;
- “I” 基本整数集,其中包含整数的基本计算、Load/Store和控制流,所有的硬件实现都必须包含这一部分。
- “C”压缩指令扩展,将某些指令进行压缩,提高代码密度;
- “M” 整数乘除法扩展,增加了整数寄存器中的乘除法指令。
- “A” 原子操作扩展,增加对储存器的原子读、写、修改和处理器间的同步。
- “F” 单精度浮点扩展,增加了浮点寄存器、计算指令、L/S指令。
- “D” 双精度扩展,扩展双精度浮点寄存器,双精度计算指令、L/S指令。
I+C+M+F+A+D 被缩写为 “G” ,共同组成通用的标量指令。
本项目实现的是RISCV基础指令集RV32I和扩展指令集M,下面将介绍指令集。
3. RV寄存器
RV32I型指令集具有32个32-bit的通用寄存器,具体定义和功能如下:

汇编名是通用寄存器的别名,在RISC-V汇编当中,都使用这些名称来代表这些寄存器。
其中,最特殊的是x0寄存器,即零寄存器,其被硬编码为0,写入数据忽略,读取数据为0。
4. RV32I指令集
4.1 引例
为了快速理解指令的相关概念,还是从一个例子入手,就以每台计算机都必须实现的加法运算为例:
在高级语言中,实现加法为:a = b + c。而在RISCV汇编指令中为:
add a , b , c
显而易见,这条RISC-V 汇编指令指示计算机将两个变量 b 和 c 相加并将其总和放入 a 中。这种符号表示是固定的,其中每个 RISC-V 算术指令只执行一个操作,并且必须总是只有三个变量。例如,假设要将四个变量 b、c、d、e的和放入变量a中,需要三条指令来完成者四个变量的相加。
add a , b, c //The sum of b and c is placed in a
add a , a, d //The sum of b, c, and d is now in a
add a , a, e //The sum of b, c, d, and e is now in a
类似于这总加法操作的指令为R型指令(还有其他类型指令,后面将会讲到),R型指令有三个操作数:两个被加到一起的数和一个放置总和的位置,例如add指令中的a,b,c。
与高级语言程序不同,算术指令的操作数会受到限制;它们必须取自寄存器,而寄存器数量有限并内建于硬件的特殊位置。寄存器是硬件设计中的基本元素,当计算机设计完成后,对程序员也可见,因此可以将寄存器视为计算机构建的“砖块”。在 RISC-V 体系结构中,寄存器的大小为32位;成组的32位频繁出现,因此它们在 RISC-V 体系结构中被命名为字(Word)。(另一个常见大小是成组的 64位,在 RISC-V 体系结构中称为双字(DoubleWord)。)
还是上面例子,如果已知变量a、b、c分别分配给寄存器x19、x20、x21,编译后的代码为:
add x19、x20、x21
表示将x20、x21寄存器中的值相加放入x19寄存器中,将x20、x21称为源寄存器,x19称为目的寄存器。
事实上,RISCV处理器执行的是机器代码,将上面汇编指令翻译为机器指令为:
00000001010110100000100110110011
这样不便于理解,不妨多翻译一步,把机器指令分成几段并用十进制表示,分段翻译为十进制机器指令为:

一条指令的每一段称为一个字段。第一、第四和第六个字段(0、0 和 51)组合起来告诉 RISC-V 计算机该指令执行加法操作。第二个字段给出了作为加法运算的第二个源操作数的寄存器编号(21 表示 X21), 第三个字段给出了加法运算的另一个源操作数(20 代表X20)。第五个字段存放要接收总和的寄存器编号(19 代表 x19)。因此,该指令将寄存器 X20和寄存器 x21 相加并将和存放在寄存器 x19 中。
该指令也可表示为二进制的形式:

这种指令的设计被称为指令格式。为了把它和汇编语言区分开来,我们把指令的数字表示称作机器语言,把这样的指令序列称作机器码。
RISCV每个字段都有各自的功能,给它们分别命名使其更易于讨论:

以下是 RISC-V 指令中每个字段名称的含义:
- opcode(操作码):指令的基本操作(加法、减法、移位等),这个缩写是它的惯用名称。
- rd:目的操作数寄存器,用来存放操作结果。
- funct3:一个另外的操作码字段。
- rs1:第一个源操作数寄存器。
- rs2:第二个源操作数寄存器。
- funct7:一个另外的操作码字段。
现在,你已经大概了解了R型指令的指令格式,接着来看RV32I指令集中的其他类型指令,不同类型指令有不同的指令格式。
4.2 RV32I中的指令类型
下图是RV32I 六大类指令类型,从这里可以看出Bit域定义是非常规整的,相同的定义总是在同一位置,这样让代码更简洁、实现更加容易:

4.3 RV32I指令集
4.3.1 指令集总览
下面是RV32I各指令的指令格式:

将各指令按类型划分如下,可以看到同一类型的指令字段划分一样:

4.3.2 汇编指令对应的操作汇总

4.3.3 各指令运算
接着,按类型逐条来看各个指令实现什么样的功能:
add

- add:rd = rs1 + rs2,源寄存器1、2相加(忽略溢出),结果将被写入目标寄存器中;
- 汇编写法:
add rd, rs1, rs2
sub

- sub:rd = rs1 – rs2,源寄存器1 – 源寄存器2(忽略溢出),结果将被写入目标寄存器中;
- 汇编写法:
sub rd, rs1, rs2
sll(Shift Left Logical)

- sll:rd = rs1 << rs2,源寄存器1值左移源寄存器2[4:0]位,空位补0,结果写入目标寄存器。为什么这里移动位数取rs2[4:0]低5bit呢?因为寄存器中值是32bit,最多只需要移位 31 位,移位超过31位没有意义,所以仅需要 5 bit即可表示移位位数。
- 汇编写法:
sll rd, rs1, rs2
slt(Set Less Than)

- slt:rd = (rs1 < rs2)? 1:0,有符号比较源寄存器1、2值,源寄存器1小,目标寄存器写1,否则写0;
- 汇编写法:
slt rd, rs1, rs2
sltu(Set Less Than Unsigned)

- sltu:rd = (rs1 < rs2)? 1:0,无符号比较源寄存器1、2值,源寄存器1小,目标寄存器写1,否则写0;
- 汇编写法:
sltu rd, rs1, rs2
xor(Exclusive OR)

- xor:rd = rs1 ^ rs2,源寄存器1、2按位异或,结果写入目标寄存器;
- 汇编写法:
xor rd, rs1, rs2
srl(Shift Right Logical)

- srl:rd = rs1 >> rs2,源寄存器1值右移源寄存器2[4:0]位,并且用0填充高位,结果写入目标寄存器;
- 汇编写法:
srl rd, rs1, rs2
sra(Shift Right Arithmetic)

- sra:rd = rs1 >> rs2,源寄存器1值循环右移源寄存器2[4:0]位,并且用符号位填充高位,结果写入目标寄存器;SRA与SRL指令主要区别在于如何处理符号位。 SRL(逻辑右移)用0填充高位,适用于无符号数的右移;SRA(算术右移)用符号位填充高位,适用于有符号数的右移,保持了数值的符号。
- 汇编写法:
sra rd, rs1, rs2
or

- or:rd = rs1 | rs2,源寄存器1、2按位或,结果写入目标寄存器;
- 汇编写法:
or rd, rs1, rs2
and

- and:rd = rs1 & rs2,源寄存器1、2按位与,结果写入目标寄存器;
- 汇编写法:
and rd, rs1, rs2
lui(Load Upper Immediate)

- lui:rd = imm << 12,将其携带的20位立即数作为32位的高位,低12位置0,然后将结果载入到目标寄存器中;
- 汇编写法:
lui rd, imm
为什么有 LUI 指令?
RISC-V 的指令格式中,绝大部分指令的立即数字段长度是有限的。例如,在 I-type
和 S-type
指令中,立即数只有 12 位。这样,直接在指令中表示较大的立即数是不够的,尤其是当我们需要将一个 32 位的立即数加载到寄存器时。LUI
指令通过将 20 位立即数左移 12 位,从而支持加载较大的数值到寄存器的高 20 位。通过这种方式,LUI
能够加载一个 32 位的数值的高 20 位,而低 12 位清零。另外,LUI
指令配合 ADDI
或其他指令,可以完成 32 位立即数加载,例如:
LUI r6, imm[31:12]
(加载高 20 位)ADDI r7, r6, imm[11:0]
(加载低 12 位)
如果没有LUI
指令,加载一个 32 位的立即数可能需要更复杂的操作码或多条指令。而通过LUI
指令,仅需将高 20 位通过左移 12 位加载,低 12 位通过其他指令处理,这种方式简化了指令的编码,并且使得硬件实现更加高效。
aupic(Add Upper Immediate to PC)

- auipc:rd = PC+(imm << 12),将其携带的20位立即数作为32位的高位,低12位置0,然后与当前的PC值相加,将结果写入目标寄存器中;
- 汇编写法:
auipc rd, imm
为什么有 LUI 指令?
很多情况下,指令要跳转的地址不是确定的地址,而是相对地址,使用 AUIPC
,可以轻松地在当前 PC 的基础上计算一个相对地址。这种方式使得程序在运行时可以计算出相对于当前位置的地址,而不需要在编译时指定绝对地址,从而支持更加灵活的地址计算。另外,跳转指令需要指定目标地址,且在 32 位架构中,立即数的大小一般是 12 位或者更小,这限制了能够表示的偏移量范围。AUIPC
指令通过将一个 20 位的立即数与当前 PC 结合,从而能够表示一个大范围的地址偏移。通过 AUIPC
和 JAL
指令的组合,RISC-V 可以实现较长距离的跳转,特别是在实现函数调用和返回时,能够非常高效地计算目标地址。
jal(Jump and Link)

- jal:rd = PC+4;PC+=imm,将其携带的20位立即数做符号扩展,并左移一位,产生32bit的有符号数,然后与PC值相加产生指令存储器的目标地址,跳转至PC±1MB的地址范围;同时将紧随其后的指令的地址存入目标寄存器;
- 汇编写法:
jal rd, offset
为什么JAL指令分段这么奇怪?
估计是考虑到整个指令集中字段尽可能对齐,是译码简单。
为什么RV32I指令集中涉及到跳转的指令,指令中的立即数译码后都要左移一位,对齐四字节不是需要左移两位吗?
对齐4字节确实是需要左移两位, 但是汇编语言里面imm给的都是偶数,所以左移一位就能4字节对齐。为什么这样设计,猜测是因为考虑到压缩指令C,压缩指令集C中指令长度是16,按字节存储,PC地址间是+2的。
beq(Branch if Equal)

- beq:if(rs1 $==$ rs2) PC+= imm,源寄存器1、2值相等,跳转至目标地址;而目标地址由立即数符号扩展,并左移1位与PC值相加产生;
- 汇编写法:
beq rs1, rs2, imm
bne(Branch if Not Equal)

- bne:if(rs1 != rs2) PC+= imm,源寄存器1、2值不相等,跳转至目标地址;而目标地址由立即数符号扩展,并左移1位与PC值相加产生;
- 汇编写法:
bne rs1, rs2, imm
blt(Branch if Less Than)

- blt:if(rs1 < rs2) PC+= imm,源寄存器1小于源寄存器2值,跳转至目标地址;而目标地址由立即数符号扩展,并左移1位与PC值相加产生;
- 汇编写法:
blt rs1, rs2, imm
bge(Branch if Greater or Equal)

- bge:if(rs1 >= rs2) PC+= imm,源寄存器1不小于源寄存器2值,跳转至目标地址;而目标地址由立即数符号扩展,并左移1位与PC值相加产生;
- 汇编写法:
bge rs1, rs2, imm
bltu(Branch if Less Than Unsigned)

- bltu:if(rs1 < rs2) PC+= imm,源寄存器1小于源寄存器2值,跳转至目标地址;而目标地址由立即数符号扩展,并左移1位与PC值相加产生;
- 汇编写法:
bltu rs1, rs2, imm
bgeu(Branch if Greater or Equal Unsigned)

- bgeu:if(rs1 >= rs2) PC+= imm,源寄存器1不小于源寄存器2值,跳转至目标地址;而目标地址由立即数符号扩展,并左移1位与PC值相加产生;
- 汇编写法:
bgeu rs1, rs2, imm
sb(Store Byte)

- sb:M$[$rs1+imm$]$ $[0:7]$ = rs2$[0:7]$ ,立即数符号扩展后,与源寄存器1相加作为数据存储器的地址,将源寄存器2的最低字节存入;
- 汇编写法:
sb rs2, offset(rs1)
sh(Store Halfword)

- sh:M$[$rs1+imm$]$ $[0:15]$ = rs2$[0:15]$ ,立即数符号扩展后,与源寄存器1相加作为数据存储器的地址,将源寄存器2的最低2个字节存入;
- 汇编写法:
sh rs2, offset(rs1)
sw(Store Word)

- sw:M$[$rs1+imm$]$ $[0:31]$ = rs2$[0:31]$ ,立即数符号扩展后,与源寄存器1相加作为数据存储器的地址,将源寄存器2的整字存入;
- 汇编写法:
sw rs2, offset(rs1)
lb(Load Byte)

- lb:rd = M$[$rs1+imm$]$ $[0:7]$, 立即数符号扩展后,与源寄存器1相加,作为读取数据的地址,读出该地址的1字节数据,经符号扩展写入到目标寄存器中;
- 汇编写法:
lb rd, offset(rs1)
lh(Load Halfword)

- lh:rd = M$[$rs1+imm$]$ $[0:15]$, 立即数符号扩展后,与源寄存器1相加,作为读取数据的地址,读出该地址的2字节数据,经符号扩展写入到目标寄存器中;
- 汇编写法:
lh rd, offset(rs1)
lw(Load Word)

- lw:rd = M$[$rs1+imm$]$ $[0:31]$, 立即数符号扩展后,与源寄存器1相加,作为读取数据的地址,读出该地址的4字节数据,经符号扩展写入到目标寄存器中;
- 汇编写法:
lw rd, offset(rs1)
lbu(Load Byte Unsigned)

- lbu:rd = M$[$rs1+imm$]$ $[0:7]$, 立即数符号扩展后,与源寄存器1相加,作为读取数据的地址,读出该地址的1字节数据,经0扩展写入到目标寄存器中;
- 汇编写法:
lbu rd, offset(rs1)
lhu(Load Halfword Unsigned)

- lhu:rd = M$[$rs1+imm$]$ $[0:15]$, 立即数符号扩展后,与源寄存器1相加,作为读取数据的地址,读出该地址的2字节数据,经0扩展写入到目标寄存器中;
- 汇编写法:
lhu rd, offset(rs1)
addi(Add Immediate)

- addi:rd = rs1+ imm,立即数做符号扩展,与源寄存器1相加(忽略溢出),将结果存入目标寄存器;
- 汇编写法:
addi rd, rs1, imm
slti(Set Less Than Immediate)

- slti:rd = (rs1 < imm)?1:0,立即数做符号扩展,与源寄存器1做比较:条件成立目标寄存器置1,否则置0;
- 汇编写法:
slti rd, rs1, imm
sltiu(Set Less Than Unsigned Immediate)

- sltiu:rd = (rs1 < imm)?1:0,立即数做符号扩展,与源寄存器1做比较:条件成立目标寄存器置1,否则置0;
- 汇编写法:
sltiu rd, rs1, imm
xori(Exclusive OR Immediate)

- xori:rd = rs1 ^ imm,立即数做符号扩展,与源寄存器1相异或,将结果存入目标寄存器;
- 汇编写法:
xori rd, rs1, imm
ori

- ori:rd = rs1 | imm,立即数做符号扩展,与源寄存器1相或,将结果存入目标寄存器;
- 汇编写法:
ori rd, rs1, imm
andi

- andi:rd = rs1 & imm,立即数做符号扩展,与源寄存器相与,将结果存入目标寄存器;
- 汇编写法:
ani rd, rs1, imm
jalr(Jump and Link Register)

- jalr:rd = PC+4; PC = rs1 + imm,将其携带的12位立即数与源寄存器1值相加,并将结果的末尾清零,作为跳转的地址;与jal指令一样,将其后的指令地址存入目标寄存器中;
- 汇编写法:
jalr rd, offset(rs1)
JAL和JALR指令有什么区别?
JAL指令跳转目标地址通过 PC + imm计算,直接跳转,适用于绝对或相对地址跳转;JALR指令通过 rs1 + imm计算,间接跳转,支持动态目标地址,JALR 更灵活,适用于需要动态确定跳转目标的场景,例如函数返回与跳转表。
slli(Shift Left Logical Immediate)

- slli:rd = rs1 << imm[4:0],将源寄存器1值左移shamt位,空位填0,结果写入目标寄存器;shamt[5]为0有效;
- 汇编写法:
slli rd, rs1, shamt
为什么有SLL指令和SLLI指令?
同样地,SLL指令可进行基于寄存器的动态移位;SLLI指令移位位数是固定的,即由立即数指定。
srli(Shift Right Logical Immediate)

- srli :rd = rs1 >> imm[4:0],将源寄存器1值右移shamt位,空位填0,结果写入目标寄存器;shamt[5]为0有效;
- 汇编写法:
srli rd, rs1, shamt
sra(Shift Right Arithmetic Immediate)

- srai:rd = rs1 >> imm[4:0],将源寄存器1值循环右移shamt位,结果写入目标寄存器;shamt[5]为0有效;
- 汇编写法:
srai rd, rs1, shamt