和学校里学的x86架构不同,RISC-V指令格式的设计十分简洁、高效。为了在下一节课能够更好地理解如何搭建CPU,首先需要对RISC-V指令集有基本的了解。该文章大部分图片来自彭东老师的计算机基础实战。
什么是指令集?
先来看一个问题,什么是指令集?或者说,什么是指令?
我们都知道,CPU是基于晶体管、电阻、电容等基本元器件所实现的集成电路,那么实际上它是如何工作的呢?
抽象成数字电路来看,当我们给CPU的一些指定端口传入**“有意义”的高低电平时,CPU内部的逻辑电路就会按照“事先设计的规则”进行运转,并在一些端口输出最终“计算的结果”**。用一幅图来描述这个过程:
上图“有意义的高低电平”就是所谓的指令,而右侧的端口输出就是执行这一指令所得到的结果。为便于理解,我们想象一个极其简易的CPU(说CPU都抬举它了···),它有三个输入端口,一个输出端口,其内部只有一个双路选择器,功能也很简单,根据指定的输入高低电平,选择某一路输入端口的数据作为输出,如下:
我们约定,其中一个输入端口In0用来控制双路选择器:如果In0端口是低电平0,则双路选择器接通In1,输出端口Out0的值就是In1,反之则反。
例如,我们输入100(这是二进制表示,最后的0表示In0),则Out0输出0;如果输入101,则Out0输出1.
前面所说的100和101就是一个极其简易的指令。
相信到这,你应该知道指令到底是个什么东西了吧,它其实有两个鲜明的特征:事先约定、CPU实现。
也就是说这些指令是在搭建CPU之前就已经约定好的,而后实现的CPU必须按照指令来实现相应的功能。
**那什么是指令集呢?**当CPU内部逻辑电路比上面所说的双路选择器复杂成千上万倍时,它可以完成各种各样的功能,自然也需要多种多样的指令,这些指令共同构建了一个指令集。
现在较为常见的指令集主要有Intel的x86指令集(微机原理所学的8086微处理器正是基于此)、AMD64指令集、ARM、RISC-V等等。
另外,按照指令集的复杂与否,可以将指令集分为两类:CISC(Complex Instruction Set Computers )和RISC(Reduced Instruction Set Computer)。两者的区别主要在于,寻址方式是否复杂、指令编码是否统一等等,更详细的资料可以参考CISC与RISC。
有意思的是,现在的RISC与CISC其实两者都是一种优势互补的姿态,比如CISC的代表x86体系其实早就开始“偷学”了:它表面的指令集并没有变,但是在CPU内部进行译码时,会将指令解析成多条内部微码,而这写内部微码与RISC有许多相似的地方。
RISC-V指令集
上面简单讲了指令集是什么与RISC、CISC的区别,下面我们专注于其中一个指令集RISC-V。
RISC最初起源于加州伯克利分校的一个4人团队,RISC-V是研究团队在2010年推出的第五代RISC体系,读作“risk-five”,由于它的免费、开源和高效,RISC-V很快便引来越来越多的科技巨头的加入。
基础指令与扩展指令
RISC-V的指令集由两部分构成:基础指令与扩展指令。如下图所示:
其中,根据寄存器位宽和地址空间不同,分为32、64、128位三种不同整数指令集(用I表示)。整数指令集包括算术、逻辑、分支、访存(访问内存)指令等,已经可以实现一个完整的软件栈。
如果一些CPU有更多的功能要求,可以在基础指令的基础上组装扩展指令,扩展指令主要有以下这些:
- M:乘除法、取模求余指令
- F:单精度浮点指令
- D:双精度浮点指令
- Q:四倍浮点指令
- A:原子操作指令,例如常见的cas(compare and swap)指令
- C:压缩指令,主要用于改善程序大小
- G:= I+M+A+D+F,表示通用处理器所包含的指令集
- 其他可参考:RISC-V官方手册
通常使用RISC-V指令集的CPU通常采用这样的命名方式:RV【位宽】【支持的指令】,例如RV32I表示基于32位整数指令构建的;而RV64IMAC表示在64位整数指令集上增加了乘除法、原子、压缩指令集。
寄存器
按理来说接下来应该继续讲解RISC-V指令集的具体指令格式,但是指令格式和寄存器联系紧密,必须先对寄存器构成有基本的了解。
RISC-V定义了32个通用寄存器和一个PC寄存器(看到这可以先想想8086的寄存器类型,区别很明显),寄存器的位宽和指令集位宽匹配。下图列出了32个寄存器的ABI名称和功能说明:
表中的 ABI 全称为 Application Binary Interface,即应用程序二进制接口,可以理解为寄存器别名,在高级语言在生成汇编语言的时候会用到它们。
OK,继续讲指令格式!
指令格式
我们先挑选最基础的RV32I来看看它的指令命令方式,值得注意的是,RV32I包含的指令是固定、永远不会改变的,相应指令的取名方式如下图:
可以看到,RV32I指令集中的指令命名方式有很明显的特点:由英文首字母拼成。如branch equal缩写位be,表示当条件相等时进行分支跳转。之后的章节中遇到不动的指令都可以跳转到这进行查看。
实际上,RISC-V指令根据格式特点可以分为六种类型(Type):
- R Type:用于寄存器——寄存器之间的操作 (Register)
- I Type:短立即数及内存访问操作(Immediate)
- S Type:用于内存store操作 (Store)
- B Type:用于条件跳转操作 (Branch)
- U Type:用于长立即数操作
- J Type:用于无条件跳转操作 (Jump)
它们的指令格式如下图所示:
上图opcode表示指令操作码,通过这7位就知道这是一个什么指令;rs1、rs2、rd分别表示源寄存器1、2以及目的寄存器;imm代表立即数;funct3、funct7代表指令对应的功能,这在之后会讲。
仔细观察上图的指令格式可以发现:三个寄存器都固定在指令同样的位置,这为指令译码提供了便利。
接下来我们就来看看RV32I中立即数的算术逻辑指令(I-Type)长什么样子,如下图:
首先根据指令命名方式判断左侧的含义,从上到下依次为,立即数与源寄存器相加addi、寄存器小于立即数slti、寄存器小于立即数无符号版本sltiu、立即数与源寄存器异或xori、立即数与源寄存器按位或ori、立即数与源寄存器按位与andi、寄存器根据立即数逻辑右移slli、寄存器根据立即数逻辑右移srli、寄存器根据立即数算术右移srai,不清楚的朋友可以回到上文去查看英文全称。
顺带一提,左移(不管是什么左移)会在末尾补0,算术右移在最高位补充符号位,而逻辑右移在最高位补充0。为什么要区分逻辑右移和算术右移呢?这是从计算的角度思考的,无论正数负数,右边加0都等同于乘以2;而负数进行逻辑右移的结果不等于除以2,需要用算术右移;但如果只有算术右移,无符号数的运算受到影响,因此需要逻辑右移。
最右侧的“0010011”就是之前提到的指令操作码。
寄存器与寄存器的操作的指令格式如下,区别仅在于立即数部分被源寄存器所替换:
有关内存如何访写(load and store)、分支跳转(有条件/条件)等强烈建议观看原文:计算机基础实战5。
汇编指令一览
指令格式的讲解比较抽象,因为已经涉及计算机底层表达了,为了方便大家的理解,找到了一篇RISC-V指令汇编指令的总结,相信通过这部分内容会对前文的内容有更深刻的理解,同时,为接下来的手写CPU提供助力!
原文链接:RISCV常见指令
算术运算
- add rd,rs1,rs2
:将寄存器rs1与rs2的值相加并写入寄存器rd。 - sub rd,rs1,rs2
:将寄存器rs1与rs2的值相减并写入寄存器rd。 - addi rd,rs1,imm
:将寄存器rs1的值与立即数imm相加并存入寄存器rd。 - mul rd,rs1,rs2
:将寄存器rs1与rs2的值相乘并写入寄存器rd。 - div rd,rs1,rs2
:将寄存器rs1除以寄存器rs2的值,向零舍入并写入寄存器rd。 - rem rd,rs1,rs2
:将寄存器rs1模寄存器rs2的值并写入寄存器rd。
逻辑运算
- and rd,rs1,rs2
:将寄存器rs1与rs2的值按位与并写入寄存器rd。 - andi rd,rs1,imm
:将寄存器rs1的值与立即数imm的值按位与并写入寄存器rd。 - or rd,rs1,rs2
:将寄存器rs1与rs2的值按位或并写入寄存器rd。 - ori rd,rs1,imm
:将寄存器rs1的值与立即数imm的值按位或并写入寄存器rd。 - xor rd,rs1,rs2
:将寄存器rs1与rs2的值按位异或并写入寄存器rd。 - xori rd,rs1,imm
:将寄存器rs1的值与立即数imm的值按位异或并写入寄存器rd。
移位运算
- sll rd,rs1,rs2
:将寄存器rs1的值左移寄存器rs2的值这么多位,并写入寄存器rd。 - slli rd,rs1,imm
:将寄存器rs1的值左移立即数imm的值这么多位,并写入寄存器rd。 - srl rd,rs1,rs2
:将寄存器rs1的值逻辑右移寄存器rs2的值这么多位,并写入寄存器rd。 - srli rd,rs1,imm
:将寄存器rs1的值逻辑右移立即数imm的值这么多位,并写入寄存器r - sra rd,rs1,rs2
:将寄存器rs1的值算数右移寄存器rs2的值这么多位,并写入寄存器rd。 - srai rd,rs1,imm
:将寄存器rs1的值算数右移立即数imm的值这么多位,并写入寄存器rd。
内存访问与写入
- lb rd,offset(rs1)
:从地址为寄存器rs1的值加offset的主存中读一个字节,符号扩展后存入rd - lh rd,offset(rs1)
:从地址为寄存器rs1的值加offset的主存中读半个字,符号扩展后存入rd - lw rd,offset(rs1)
:从地址为寄存器rs1的值加offset的主存中读一个字,符号扩展后存入rd - lbu rd,offset(rs1)
:从地址为寄存器rs1的值加offset的主存中读一个无符号的字节,零扩展后存入rd - lhu rd,offset(rs1)
:从地址为寄存器rs1的值加offset的主存中读半个无符号的字,零扩展后存入rd - lwu rd,offset(rs1)
:从地址为寄存器rs1的值加offset的主存中读一个无符号的字,零扩展后存入rd - sb rs1,offset(rs2)
:把寄存器rs1的值存入地址为寄存器rs2的值加offset的主存中,保留最右端的8位 - sh rs1,offset(rs2)
:把寄存器rs1的值存入地址为寄存器rs2的值加offset的主存中,保留最右端的16位
- sw rs1,offset(rs2)
:把寄存器rs1的值存入地址为寄存器rs2的值加offset的主存中,保留最右端的32位
举个例子,有如下C语言片段:
long long A[100]; A[10] = A[3] + a;
假设数组A首地址在寄存器x3内,a在x2内,则这段代码的汇编表达为:
ld x10,24(x3) # long long占64bits=8bytes,A[3]的地址为A[0]+3*8 add x10,x2,x10 sd x10,80(x3)
比较指令
有符号数:
- slt rd,rs1,rs2
:若rs1的值小于rs1的值,rd置为1,否则置为0 - slti rd,rs1,imm
:若rs1的值小于立即数imm,rd置为1,否则置为0
无符号数:
- sltu rd,rs1,rs2
:若rs1的值小于rs1的值,rd置为1,否则置为0 - sltiu rd,rs1,imm
:若rs1的值小于立即数imm,rd置为1,否则置为0
条件跳转
- beq rs1,rs2,lable
:若rs1的值等于rs2的值,程序跳转到lable处继续执行 - bne rs1,rs2,lable
:若rs1的值不等于rs2的值,程序跳转到lable处继续执行 - blt rs1,rs2,lable
:若rs1的值小于rs2的值,程序跳转到lable处继续执行 - bge rs1,rs2,lable
:若rs1的值大于等于rs2的值,程序跳转到lable处继续执行
注意,在汇编中没有括号{}来控制代码作用区域,只能通过label标签来表示要跳转的指令行,类似于C语言中的goto。
无条件跳转
- j label
:程序直接跳转到lable处继续执行 - jal rd,label
:用于调用函数,把下一条指令的地址保存在rd中(通常用x1),然后跳转到label处继续执行 - jalr rd,offset(rs)
:可用于函数返回,把下一条指令的地址存到rd中,**然后跳转到rs+offset地址处的指令继续执行。**若rd=x0就是单纯的跳转(x0不能被修改)
其他
思考题
- 想想看,为什么要通过调整立即数的某些位,从 U-TYPE 指令得到 J-TYPE 指令格式呢?这样调整以后有什么好处?
试图回答:直接在寄存器内部调整指令,减少了指令读取事件,大大加快了指令执行的整体效率。
推荐阅读
,减少了指令读取事件,大大加快了指令执行的整体效率。