前言
这个学习笔记是基于Gitee上的tinyriscv项目记录的,如果不懂直接看源码会更好,里面也有注释,另外就是这篇文章写的一坨,没错,1万多字拼凑出一坨,有错误恳请各位大佬指正,再次谢过各位大佬和未来的大佬。
1.指令集
RISC-V架构采用模块化分层设计,指令集分为基础指令集和可选扩展指令集,按需即可。
基础指令集:提供最基本的运算和操作。比如 :
整数运算:ADD,SUB ,AND,OR,XOR,等(加,减,与,或 ...)
内存操作:LW ,SW等 (内存读取,写入内存 ...)
分支跳转:BEQ,JAL等(条件跳转,链接跳转...)
系统调用:ECALL ,EBREAK等(环境调用,调试模式...)
扩展指令集:
M扩展:乘除法,例如MUL,DIV,REM等
A扩展:原子多核操作(核同步) 例如LR.W , SC.W等
C/D扩展:单精度浮点运算 例如FADD.S , FMUL.D等
C扩展:压缩指令(16bit) 例如C.ADD , C.LW 等
B扩展; 位操作 ;例如CLZ , ROL 等
现在你已经知晓了最基本的指令集,其他对照指令集表学习即可
那么在学习RISC-V架构之前,我们先要了解汇编指令。
下面是最简单的汇编指令
又比如LW指令等基本汇编,相信你代码看多了后自然理解。
指令架构:
以R型指令举例:
Func7 | rs2 | rs1 | funct3 | rd | opcode
7bit 12bit 10bit 3bit 5bit 7bit
其中func7 和funct3为功能码,细分指令的功能
操作码(opcode):定义了指令的类型,用于识别指令。
I型指令: 存在立即数(imm)的指令,以立即数的英文首字母命名。
结构: Imm{11:0] | rs1 | funct3 | rd | opcode
那么,什么又是立即数?
立即数(imm)是汇编语言中的概念,指的是被编译在指令中的常数,能够被立即使用,无需从内存中加载,这样减少了耗时。
所以,立即数就是指令中的常数,仅此而已。
指令中的地址又是如何计算的呢?
在LW或SW指令中,存储地址=寄存器基地址+立即数符号位扩展
在上面的LW指令中就是 rd = rs1+imm{11:0]加载到32位
在跳转指令如中:地址计算:跳转地址=指令地址+符号位扩展立即数
故而这里引申出问题:为什么要扩展,怎么扩展?
原因:由于系统中各个组件的可能具有不同的位宽,运算时要把数据扩展到与位宽要求相同才能运算。比如寄存器是32位的,即使基地址为32位,但是立即数就11位,自然就要扩展到32位 后再和基地址相加。
怎么扩展:
1.符号扩展:针对有符号数,如果立即数为正数(最高位为0),则在最高位后一直填充0,直到满足位宽;如果是负数(最高位为1),则最高位后面一直填充1。
2.零扩展:立即数最高位全部填充为0.
比如:一个8位的立即数5(二进制位00000101),扩展到16位后为0000000000000101.
好了,上面就是基础指令架构,还有其他可以参照指令表查看。
架构基础:
RISC-V架构中一共有32个通用寄存器,每个寄存器都是32位的,用于存放存放指令或计算等。
要注意的的是,通用寄存器中的第一个(x0)寄存器恒为0,用于返回基地址或进行数据移动(MOV),但RISC_V架构中没有mov指令,不过用ADD指令就能完成。
此外:RISC-V架构只有LW,SW指令可以访存,其他指令都是访问寄存器。
因此不要把通用寄存器理解成内存(DRAM或FLASH),这两个本身不是一个东西。
通过寄存器的读写速度极快 ,是直接集成在内核内部的,直接参与数据运算。
内存是集成在内核外的(也不固定),主要存放bin文件或其他程序,比如指令等,取指时就是去内存或者缓存里拿,不是去寄存器拿。
流水线:把一件事拆成几个周期去做
将一件事分为几个周期去操作就成为流水线,每个周期都干专门的事。一般来说:把一条命令拆成几个周期共同完成就是CPU流水线,故而我们有时会看完成一条指令需要几个周期就成为几级处理器。
其他流水线比如AHB协议的哪个思想也差不多。
经典的五级流水线处理器:(也就是做五件事)
周期1 |
周期2 |
周期3 |
周期4 |
周期5 |
取指 |
译码 |
执行 |
访存 |
写回 |
流水线的设计能大大加快运算效率,相比于单周期读写的CPU来说,流水线的设计就大大增加了指令的运算效率。
当然不是流水线越多越好,要考虑到功耗面积设计复杂度资源消耗等。
单发射与多发射:
单发射CPU:每个时钟周期内处理器只从指令队列中取一条指令给ALU(逻辑运算单元)执行,也就是说,它每个时时钟周期只送入一条指令执行,可以理解为单车道公路。
因此,单发射CPU的设计简单并且并行度低,指令是按顺序执行的,吞吐量受限。
多发射CPU:每个周期内可以同时发送多条指令给多个单元执行,以提升并行性。
多发射CPU也是分为静态多发射和动态多发射的,静态发射处理指令需要指令高度可预测,灵活性差,动态发射处理灵活,可自动分析指令依赖,故而需要乱序执行(超标量架构)。
下面看看两者的流水线,你会对CPU流水线理解更深。(只有前三个周期)
单发射CPU:
周期1:取指令A
周期2:指令A译码 同时取指令B
周期3:指令A执行,同时指令B译码,并且同时取指令C
多发射CPU:
周期1:取指令A 和 B
周期2: 指令A和B译码,同时取指令C和D
周期3:指令A和B执行 ,同时指令C和D译码,并且同时取指令E 和F
可以和明显看出单发射每周期就是只执行一条指令,多发射就是两条甚至更多并行执行。
现在我们已对CPU内核做了一个初步的了解,知道了内核要做的五件事,下面就来仔细看下每件事是怎么操作的。
下面是我对Tinyriscv项目学习记录:
Tinyriscv:
该项目是一个3级流水线的CPU(五件事分3个周期完成),下面看看组件吧。
程序计数器(PC):产生计数器的数值,该值指向那里CPU就执行那条命令。
时序十分简单:四个状态机:复位,跳转,暂停,递增。
递增加4的原因:一条指令是32bit ,也就是4个字节,寄存器是以字节存储的,所以一次跳转4字节就能到下一个地址。
如图代码显示:
always @ (posedge clk) begin
// 复位
if (rst == `RstEnable || jtag_reset_flag_i == 1'b1) begin
pc_o <= `CpuResetAddr;
// 跳转
end else if (jump_flag_i == `JumpEnable) begin
pc_o <= jump_addr_i;
// 暂停
end else if (hold_flag_i >= `Hold_Pc) begin
pc_o <= pc_o;
// 地址加4
end else begin
pc_o <= pc_o + 4'h4;
end
end
注意:程序计数器(PC)指向的地址是内存地址,不是指向寄存器地址。
特殊情况:在执行压缩指令(C扩展)时,因为指令的长度可能为16位,故而此时PC是加2,不是加4,不过也可使用硬件自动检测指令长度来控制PC递增数值。
取指模块:相当于个打拍模块
从指令存储器中拿取指令,打拍后给译码模块。(打拍意味着延时,打一拍就是延时一个时钟周期)
这里引申出问题:为什么要打拍后才给译码模块?
这里涉及到跨时钟域传输问题,当处在不同的时钟域下,我们通常都会对数据进行打两拍操作来消除采样不准和亚稳态传输问题:
打一拍:阻隔亚稳态传输。
再打一拍: 保证采样能够稳定
当然还有打一拍或打三拍的情况,这里不做介绍。
译码模块:解析指令给执行模块。
纯组合逻辑电路(其实我觉得使用时序逻辑更可靠点,毕竟可以与时钟对齐),使用状态机条装。
状态机跳转条件:依据指令类型来判断解析那条指令。
问题:译码要怎么识别出指令?
识别指令首先要依靠操作码(opcode)来判断指令类型,然后根据funct7(功能码)来判断到底是那条指令。
更加细分时还要看funct3(功能码)来看到底是那一条指令。
现在看下代码:
......
wire[6:0] opcode = inst_i[6:0];
wire[2:0] funct3 = inst_i[14:12];
wire[6:0] funct7 = inst_i[31:25];
wire[4:0] rd = inst_i[11:7]; //很明显这就是把指令拆分为各个信号,相当于做了初始化功能吧
wire[4:0] rs1 = inst_i[19:15];
wire[4:0] rs2 = inst_i[24:20];
......
case (opcode)
`INST_TYPE_R_M: begin //根据操作数来判断这是啥类型的指令
if ((funct7 == 7'b0000000) || (funct7 == 7'b0100000)) begin //根据funct7来判断指令
case (funct3) //根据funct3更加具体的判断,因为有的指令就是funct3不同,比如加减法
`INST_ADD_SUB, `INST_SLL, `INST_SLT, `INST_SLTU, `INST_XOR, `INST_SR, `INST_OR, `INST_AND: begin
reg_we_o = `WriteEnable;
reg_waddr_o = rd;
reg1_raddr_o = rs1;
reg2_raddr_o = rs2;
op1_o = reg1_rdata_i; //操作数赋值
op2_o = reg2_rdata_i; //操作数赋值
end
default: begin //case都要写全,否则会生成锁存器
reg_we_o = `WriteDisable;
reg_waddr_o = `ZeroReg;
reg1_raddr_o = `ZeroReg;
reg2_raddr_o = `ZeroReg;
end
endcase
执行模块:
解析出指令并做出动作,同时做出响应。
这里引申出问题:为什么在译码器中已经识别出指令了,还要在执行模块中识别一次呢?
我在看了代码后得出的可能的解释为:
译码模块将输出结构化地址输出和操作数信号等给执行模块,但执行模块只能知道要操作的地址以及操作数,不知道操作数和其他控制信号的就位情况,所以具体那条命令还得再译一次。
也有可能是保证命令解析正确,无论是那种,对于小白来说都极为友好了。
执行模块的核心组件:
ALU(算术逻辑单元) :执行算术运算和逻辑运算,输入的操作数为寄存器里的或者是立即数,输出的结果直接写回寄存器或后续操作。
REG(寄存器操作) :执行写操作和读操作,起临时存储的作用。
Control Unit(控制单元):解析指令码和功能码生成控制信号,(这里我理解的就是再译码一遍,然后做出动作)
其他单元可以扩展,比如: 乘除法单元,浮点单元,原子操作单元等。
执行过程:
1.从寄存器或立即数里取到操作数,给ALU
2.ALU开始运算数据,比如计算地址,逻辑比较等操作,如遇分支判断,ALU会计算分支地址,看条件是否要跳转
3.访存:访问内存,向总线拉起请求
4.写回:写回寄存器临时存储。
这个过程不断循环进行。
比如跳转指令:
BEQ X1 X2 label
ALU会比较X1 和 X2的值是否相等,相等后将会计算目标地址,然后更新程序计数器。
加法指令:
ADD X1 X2 X3
ALU会读取X2和X3两个操作数,然后执行加法运算,然后写回地址X1。
诸如此类的往返循环下去,这就是执行模块工作的流程。
既然是按流程工作肯定会有出错或者延时的情况,而我们知道执行模块处在流水线中的某一级,这势必会阻碍流水线后续运行,减弱处理器性能。
那么我们该如何对流水线中的执行进行优化呢?
这里又要分类讨论:
旁路(Forwarding):
增加旁路(就是增加新的小路):
当遇到数据冒险时,比如后续的指令要依赖前一条指令的结果才能运算,这时后续的指令总不能迟迟等着不干吧。
因此,我们会直接从执行阶段的输出数据直接给到ALU输入,这样就避免了等待写回的操作,直接给下一条命令操作数。
流水线停顿(Stall):
如果遇到没办法通过旁路解决数据冒险的话,我们通常会插入流水线气泡(就是NOP命令 啥也不做)来暂停后续指令一个周期,这样可以减少对执行模块运算时又输入指令的情况。
分支预测(Branch Prediction):
这个操作就是减少指令导致的流水线清空(就是废除当前再流水线上的指令等一系列操作,重新去指,我们成为Flash流水线)
因为这个操作很复杂,可以去其他博客看看。
上面只是执行模块的基本功能,那么其他功能呢,比如其他的功能总得有吧,必须要支持中断吧。
执行模块执行中断的过程大概为这样:
首先得有触发中断的条件,比如非法指令,内存访问错误,外设中断以及内部中断等都能触发中断。
执行模块接收到中断信号后
1 . 保存当前的PC值到MEPC(异常程序计数器),其实就是个寄存器保存地址的,方便中断执行完后返回原来的地址继续执行。
2 . 设置MCAUSE(异常原因)
3 . 跳转到MTVEC(异常程序处理地址入口)执行异常程序
4. 完成后通过MRET指令恢复
以上就是中断执行过程,这个过程还需要配合中断仲裁器才行,下文写到clint的时候会有介绍。
执行模块代码思想如下:
比如BEQ指令为例:
`INST_TYPE_B: begin
case (funct3) //根据funct3判断出这是个跳转指令
`INST_BEQ: begin
hold_flag = `HoldDisable;
mem_wdata_o = `ZeroWord;
mem_raddr_o = `ZeroWord;
mem_waddr_o = `ZeroWord;
mem_we = `WriteDisable;
reg_wdata = `ZeroWord;
if (reg1_rdata_i == reg2_rdata_i) begin //如果两个操作数相等就跳转
jump_flag = `JumpEnable; //跳转标志位
jump_addr = inst_addr_i + {{20{inst_i[31]}}, inst_i[7],inst_i[30:25],inst_i[11:8], 1'b0}; //跳转地址计算,地址总位宽为32位
end else begin
jump_flag = `JumpDisable; //如果不相等就不跳转
jump_addr = `ZeroWord;
end
...
end
...
可以看出这个执行模块是十分的简单,其他的执行也都是按照这个思想创建的。
下面来看看访存和写回:
这个项目内访存以及写回在同一个周期内执行了,因为它只有三级流水线,故而只能把访存和写回合并操作。
访存:
当执行模块中知晓了LW 或SW 指令后需要进行数据搬运,这个时候就需要向总线提出申请来运输数据。
这个时候我在想:有没有DMA一样的模块可以快速的搬运数据,实际上是可以的,使用DMA配合总线等行为已经存在。
这正是RISC-V的灵活性体现。
访存代码如下:
always @ (*) begin
if (rst == `RstEnable) begin
rdata2_o = `ZeroWord;
end else if (raddr2_i == `RegNumLog2'h0) begin //如果读取的寄存器0x,那么就返回0
rdata2_o = `ZeroWord;
end else if (raddr2_i == waddr_i && we_i == `WriteEnable) begin // 如果下一条指令读地址等于这条指令的写地址,并且这条指令正在写操作,则直接返回写数据给下一条指令使用
rdata2_o = wdata_i;
end else begin
rdata2_o = regs[raddr2_i]; //读取寄存器操作
end
end
好了,我们已经初步了解了RISC-V的内核基本操作。
我们还需要把它们集成起来封装成内核,将它们的各个模块连接起来,至此形成内核。但很可惜,这个内核基本上不能用,我们还需要其他扩展模块。
现在来看看除法器模块,这个项目中没有乘法器模块,显然,乘法指令在执行阶段时ALU就能完成了,但是由于运算的复杂性,我们可能要单独的乘法器实现。
除法器模块(div):
该模块采用了试商法的思想进行除法运算,这个算法最大的延时很高,完成一次除法运算需要至少32个周期,这会阻碍流水线执行。(所以我们会一直想办法优化流水线)
试商法基本思想:
被除数÷除数=商 + 余数
它的核心就是逐步试探商的每一位数值,看余数的大小来调整商,以此完成除法(其实这个算法我还是模棱两可的,应该就是通过改变余数后,看余数与减数相差多少,然后...)
建议还是有个了解吧,有兴趣再深入看下其他除法器。
算法解释:
第一步初始化:
将余数寄存器里的余数初始化为被除数: 余数 = 被除数。
商设置为0。
除数已知。
第二步:逐步迭代:
先把余数左移一位,就是乘以2;
再把商左移一位,这个移位只是为了空出地位,当然理解成0*2 也没问题
第三步:试探商:
用当前余数减去除数,相当于被除数*2 后再减去除数,看其大小
>0:表示当前余数(被除数*2)> 除数
此时要把商的当前位设为1,也就是 : 0000——>0001
余数更新为当前的差值(就是当前余数减除数的差)。
<0:表示当前余数(被除数*2)< 除数
当前商位设为0
保留余数不更新。
重复n次后得出结果(我也没懂这个算法,是直接拿来用的),参考deepseek的解释吧,我看了deepseek解释还是没懂,看其他书试试。)
除法器组件:
余数寄存器:存取当前余数
商寄存器:存储逐步形成的商
除数寄存器:存除数用的
ALU:执行减法和移位操作。
状态机实现:
1.首先要将被除数和除数都转换成补码形式(为了方便识别符号位区分正负)
2.根据最高位确定被除数和除数是否同号(为了调整商的符号)
3.状态机实现有符号取补码和无符号运算
4.根据符号位对结果调整
以上就是我对除法器极为粗略的了解,我觉得我也没讲清楚是为什么,看不懂的直接略过吧,去网上搜索肯定比我详细一万倍。
其他算法:
SRT算法:允许每步多位试商,适用于高性能
非恢复余数法:不恢复余数后续补偿操作,减少延时。
现在你对除法器有了初步的了解,知晓了除法器延时相当高,这对流水线的执行十分不利,所以你又得回去看下如何优化除法器以及流水线的操作。
部分代码注释如下:
......
wire[31:0] dividend_invert = (-dividend_r); //取反操作吗? 查阅资料后得知:加个负号是告诉编译器自动转换成补码形式
wire[31:0] divisor_invert = (-divisor_r); //所以不是取反操作,但是比取反操作更高级吧。
wire minuend_ge_divisor = minuend >= divisor_r; //比较大小吗? 这里是看作比较当前余数和减数的大小吧
wire[31:0] minuend_sub_res = minuend - divisor_r; //当前余数减去当前商做差
wire[31:0] div_result_tmp = minuend_ge_divisor? ({div_result[30:0], 1'b1}): ({div_result[30:0], 1'b0}); //这个是看要不要把当前商位设为1吧
wire[31:0] minuend_tmp = minuend_ge_divisor? minuend_sub_res[30:0]: minuend[30:0];
STATE_START: begin
if (start_i == `DivStart) begin
// 除数为0
if (divisor_r == `ZeroWord) begin
if (op_div | op_divu) begin
result_o <= 32'hffffffff;
end else begin
result_o <= dividend_r;
end
ready_o <= `DivResultReady;
state <= STATE_IDLE; //除数为0返回空闲状态
busy_o <= `False;
// 除数不为0
end else begin
busy_o <= `True;
count <= 32'h40000000; //弄一个计数器是为什么?性能监控还是什么?能解释一下吗?
state <= STATE_CALC;
div_result <= `ZeroWord;
div_remain <= `ZeroWord;
// DIV和REM这两条指令是有符号数运算指令
if (op_div | op_rem) begin
// 被除数求补码
if (dividend_r[31] == 1'b1) begin //依据最高位来判断是否为负数
dividend_r <= dividend_invert; //取被除数补码
minuend <= dividend_invert[31]; //负数补码的最高位为1,所以被减数赋值为1
end else begin
minuend <= dividend_r[31];
end
// 除数求补码
if (divisor_r[31] == 1'b1) begin //判断除数是否为负数
divisor_r <= divisor_invert;
end
end else begin
minuend <= dividend_r[31];
end
// 运算结束后是否要对结果取补码
if ((op_div && (dividend_r[31] ^ divisor_r[31] == 1'b1)) //判断除数是否为正数
|| (op_rem && (dividend_r[31] == 1'b1))) begin
invert_result <= 1'b1; //这个应该是用来判断要不要把结果取反的标志位吧
end else begin
invert_result <= 1'b0;
end
end
控制模块(ctrl) :
这个模块很简单,主要就是发出跳转,暂停流水线等信号,看代码就能知晓,这里不做过多介绍了。
CSR寄存器(控制状态寄存器):
负责处理异常的中断并记录当前系统的状态,这个寄存器十分重要。
CSR寄存器的基本作用:
控制处理器行为:配置中断,支持调试,内存管理。
记录系统状态:保存异常原因,当前特权模式,中断状态
切换状态:在机器模式,监管模式和用户模式之间切换(这和ARM架构还是有点类似的)
CSR寄存器可以理解成是一种寄存器块,里面的寄存器都是控制系统状态用的。
常见的寄存器为:
Mstatus(机器状态寄存器):全局状态配置,包括了中断使能,级别转换等。
字段: MIE:机器模式使能中断
MPP:异常前的特权级别(该项目中没有)
其他:不举例了,项目中也没有,所以不必担心
Mtvec(异常入口地址寄存器):处理异常程序的地址入口
模式: 直接模式:所有异常都跳转到基地址
向量模式:不同异常类型跳转到基地址+4*异常码(类似与ARM架构中的异常向量表),该项目中没有。
Mepc(异常程序计数器):虽然名字为计数器,但是它的功能却是保存异常发生时的指令地址,在执行往后返回该地址。(很好理解,中断执行完成后就要返回到原来的地址继续执行)
Mcasuse(异常原因寄存器):保存异常原因。
结构:
高位:中断标志(1=中断,0=异常)
低位:异常原因码(比如0=地址错误,3=断点)
Mscratch(临时寄存器):保存临时数据
CSR的地址空间:
地址范围:12位的地址空间,里面的寄存器都有各自的地址。
现在你对CSR寄存器有了大概的认识:那么执行阶段时是怎么处理异常的呢?
- 发生异常时,先将当前的PC地址保存到mepc里
- 设置mcause记录异常原因
- 跳转到异常程序入口地址mtvec那里执行异常程序
- 处理完成后返回mepc的地址继续执行
处理中断也是类似,不过不同的是
启动中断,设置mstatus.mie=1使能中断
处理中断,mcause的高位为1,表示中断
因为这个项目中没有特权级别切换,只有机器模式,故这里不做介绍模式切换。
Clint:
本地中断核心控制器(core local interruptor) 简称Clint,与之对应的还有Plic(platform-level interruptor controller)外部中断控制器。
clint主要用于处理核心的中断和定时器功能
它的功能如下:
定时器中断(Timer interrupt)
组件:
Mtime:一个持续增长的64为计数器,提供全局时钟基准,由系统时钟驱动
Mtimecmp:计较寄存器,当Mtime的数值超过Mtimecmp设定的数值时触发定时器中断(学过stm32的人应该就会理解这是什么意思)
功能:触发周期性事件,或者PWM波输出,以及操作系统任务调度
软件中断(soft interrupt)
组件 :
MSIP(machine software interrupt pending):这是一个核心,软件可以通过寄存器触发核内中断。
用途:核间通信(多核系统)看不懂没关系,这个项目中并没有支持软件中断。
Clint的角色就是控制内部中断源,但是在一个可用的核心中还应该有PLIC,这两个中断控制器配合控制全局。
如果你对中断还是不熟悉,那么我还是建议您去查看其他中断控制器的详细介绍,因为我也没弄清楚它是如何工作的,还是去看看项目中的源码或许更好。
总线(RIB):
连接各个模块,使之能互相通信的“高速公路”,这是必要的,如果你看过AHB协议,那么这个总线是十分简单的。
要是没学过,那么就去看一下AHB协议。
在这个项目中,我们可以注意到它有四个主机,6个从机,因此它是多主多从设备架构。
那么,这里势必要有仲裁机制才行。
仲裁机制实现:
- 定义好主机和从机的请求信号和其他数据
- 仲裁,使用条件判断语句制定优先级
- 根据状态结果给出总线控制权
至此总线协议结束,没错,就这么点,可以说它是十分简单的一个总线,仅仅实现了仲裁机制,并没有AHB协议那么难。
Top顶层不必多说,将各个模块例化后连接起来,至此一个十分简单的核介绍完毕。
好了,写成这样一坨十分抱歉,后期结尾草率,主要是解释不清楚内部逻辑,当个入门看看吧。