一、前言
从6月底刚开始接触 risc-v 架构,到现在完成了一个 risc-v cpu 的设计,并且成功移植了 rt-thread nano 到本 cpu 上运行,中间经过了 4个多月的时间,遇到了数不清的问题,也想过放弃,但好在最后还是坚持下来了,并且最终项目也得到了 gitee 的推荐,可以说是功夫不负有心人把。
本文主要为了对项目的 verilog 代码设计进行介绍,下面是本项目的地址(verilog 代码在 FPGA/rtl 目录下):
risc-v-cpu: 一个基于 RISC-V 指令集的 CPU 实现(成功移植到野火征途 PRO 开发板),以及从零开始写一个基于 RISC-V 的 RT-Thread~
二、RISC-V 是什么
RISC(Reduced Instruction Set Computer)即精简指令集处理器,与之相对应的 CISC(Complex Instruction Set Computer)则为复杂指令集处理器。RISC-V 指的是第 5 代 RISC。
和 ARM、MIPS、X86 一样,RISC-V 也是一种指令集架构,和其他指令集架构相比,RISV-V 最大的优势就是其完全开源。
三、浅谈 Verilog
Verilog HDL(Hardware Description Language),也被称为硬件描述语言,它描述的是数字电路里的硬件,比如与、非门、触发器、锁存器等等。
Verilog 的语法和 C语言有点类似,但核心思想和 C 完全不同,比如 C 程序是顺序执行的,而在 Verilog 中,大多数都是并行执行的,把我们在 C 语言中的串行思维转换成并行思维后,学习 Verilog 就容易多了。
四、流水线设计
采用流水线设计方式,不但可以提高处理器的工作频率,还可以提高处理器的效率。但是流水线并不是越长越好,流水线越长要使用的资源就越多、面积就越大。
在设计一款处理器之前,首先要确定好所设计的处理器要达到什么样的性能(或者说主频最高是多少),所使用的资源的上限是多少,功耗范围是多少。如果一味地追求性能而不考虑资源和功耗的话,那么所设计出来的处理器估计就只能用来玩玩,或者做做学术研究。
本项目和 tinyriscv 一样,采用的是三级流水线,即取指、译码和执行,对标ARM的Cortex-M3系列处理器。
五、Verilog 代码设计
接下来开始介绍本项目的 Verilog 代码设计。
本项目的整体框图如下:
本项目不仅包含 RISCV 内核,还包含一些简单的外设,如 uart、gpio 等,它们都统一地挂载在 rib 总线上。
然后简单介绍一下本项目 rtl 目录下各个部分的内容,里面有五个目录,分别是:
- core:用于存放本 risc-v cpu 核心 verilog 代码,如 pc、id、cu 等模块的代码都存放于此;
- debug:里面的 uart_debug.v 用于实现串口下载程序;
- perips:用于存放本 soc 外设的 verilog 代码,如 rom、ram、uart 等;
- top:用于存放本 soc 的顶层 verilog 代码,如 RISV_SOC_TOP、RISCV、IF_UNIT、ID_UNIT 等;
- utils:用于存放一些工具;
下面简单介绍一下 core 目录下的核心模块以及 perips 目录下的外设模块的主要作用:
- pc.v:该模块每隔一个时钟周期产生一个 pc 值(指令地址),用该 pc 值去 rom 中读取相应的指令。
- if_id.v:该模块将指令存储器取出的指令打一拍(延迟一个时钟周期,毕竟这样才能实现流水线)后传送到译码模块。
- id.v:译码模块,根据 if_id 模块送进来的指令进行译码。当译码出具体的指令 (比如 add 指令) 后,产生是否写寄存器信号,读寄存器信号等。由于寄存器采用的是异步读方式,因此只要送出读寄存器信号后,会马上得到对应的寄存器数据,这个数据会和写寄存器信号一起送到 id_ex 模块。
- id_ex.v:将 id 模块的译码结果打一拍后送到执行模块。
- cu.v:相当于执行模块,根据译码的结果判断需要执行哪些操作,比如控制 alu 进行加法操作,控制 div 进行除法操作等。
- alu.v:运算模块,实现基本的加、减、移位、与、或等算术逻辑运算。
- mul.v:乘法模块,实现基本的相乘。
- div.v:除法模块,实现除法操作,需要 33 个时钟周期计算。
- gpr.v:通用寄存器模块。
- csr.v:控制状态寄存器模块。
- clint.v:本地核心中断控制模块。
- rom.v:程序存储器模块,用于存储程序(bin)文件。
- ram.v:数据存储器模块,用于存储程序中的数据。
- timer.v:定时器模块,用于计时和产生定时中断信号。移植 RT-Thread 时需要用到该定时器。
- uart.v:串口模块,主要用于调试打印。
- gpio.v:简单的IO口模块,主要用于点灯调试。
5.1 PC 寄存器(pc.v)
PC 寄存器负责产生取指地址,rom 根据取指地址来找到对应的指令。
所在位置:FPGA/rtl/core/pc.v
输入输出信号:
序号 | 信号名 | 输入/输出 | 位宽(bits) | 说明 |
---|---|---|---|---|
1 | clk | 输入 | 1 | 时钟输入信号 |
2 | rst_n | 输入 | 1 | 复位输入信号(低电平有效) |
3 | jump_flag_i | 输入 | 1 | 跳转标志 |
4 | jump_addr_i | 输入 | 32 | 跳转地址,即跳转到该地址 |
5 | hold_flag_i | 输入 | 3 | 暂停标志,即PC寄存器的值保持不变 |
6 | pc_o | 输出 | 32 | PC寄存器值,即从该值处取指 |
verilog 代码:
always @ (posedge clk or negedge rst_n) begin// 复位if(!rst_n) beginpc_o <= `RESET_ADDR;end// 跳转else if(jump_flag_i == 1'b1) beginpc_o <= jump_addr_i;end// 暂停else if(hold_flag_i >= `HOLD_PC) beginpc_o <= pc_o;end// 地址加4else beginpc_o <= pc_o + 4'd4;end
end
第 3 行,通过 rst_n 复位信号对 pc_o 的值进行复位,可以通过改变 RESET_ADDR 的值来改变 PC 寄存器的复位值。
第 7 行,判断跳转标志位 jump_flag_i 是否有效,如果有效则将 pc_o 设置成 jump_addr_i 的值,所谓的跳转其实就是改变 PC 寄存器的值,从而使 CPU 从该跳转地址开始取指。
第 11 行,判断暂停标志位是否有效,hold_flag_i 是一个 3bit 的信号,分别控制 pc、if_id、id_ex 三个模块是否暂停。当 if_id 模块暂停时,pc 模块也需要暂停;当 id_ex 模块暂停时,pc 和 if_id 模块都需要暂停,所以使用 >= 来判断 pc 模块是否需要暂停。
第 15 行,将 pc_o 的值加 4,因为每条指令都是 32 位,即 4 字节。
5.2 通用寄存器(gpr.v)
一共有 32 个通用寄存器 x0~x31,其中寄存器 x0 是只读寄存器并且其值固定为0。
所在位置:FPGA/rtl/core/gpr.v
输入输出信号:
序号 | 信号名 | 输入/输出 | 位宽(bits) | 说明 |
---|---|---|---|---|
1 | clk | 输入 | 1 | 时钟输入 |
2 | rst_n | 输入 | 1 | 复位输入 |
3 | wr_en_i | 输入 | 1 | 来自执行模块的写使能 |
4 | wr_addr_i | 输入 | 5 | 来自执行模块的写地址 |
5 | wr_data_i | 输入 | 32 | 来自执行模块的写数据 |
6 | reg1_rd_addr_i | 输入 | 5 | 来自译码模块的寄存器1读地址 |
7 | reg1_rd_data_o | 输出 | 32 | 寄存器1读数据 |
8 | reg2_rd_addr_i | 输入 | 5 | 来自译码模块的寄存器2读地址 |
9 | reg2_rd_data_o | 输出 | 32 | 寄存器2读数据 |
因为 risc-v 指令可能会同时读取两个寄存器的值(源寄存器1和源寄存器2),所以有两个读端口。
读寄存器操作来自译码模块,读出来的寄存器数据也会返回给 id_ex 模块,id_ex 模块将其打一拍后送入执行模块。写寄存器操作来自执行模块。
verilog 代码:
// 32个通用寄存器定义
reg[`INST_REG_DATA] regs[0 : `REG_NUM - 1];// R1读
always @ (*) beginif(reg1_rd_addr_i == `ZERO_REG_ADDR) beginreg1_rd_data_o = `ZERO_WORD;end// 写地址和读地址相同,直接返回要写的数据else if(reg1_rd_addr_i == wr_addr_i && wr_en_i == 1'b1) beginreg1_rd_data_o = wr_data_i;endelse beginreg1_rd_data_o = regs[reg1_rd_addr_i];end
end// R2读
always @ (*) beginif(reg2_rd_addr_i == `ZERO_REG_ADDR) beginreg2_rd_data_o = `ZERO_WORD;end// 写地址和读地址相同,直接返回要写的数据else if(reg2_rd_addr_i == wr_addr_i && wr_en_i == 1'b1) beginreg2_rd_data_o = wr_data_i;endelse beginreg2_rd_data_o = regs[reg2_rd_addr_i];end
end// 写数据
always @ (posedge clk) begin// 写使能有效并且写地址不为0if(wr_en_i == 1'b1 && wr_addr_i != `REG_ADDR_WIDTH'd0) beginregs[wr_addr_i] <= wr_data_i;end
end
第 6 和 20 行,如果读寄存器 x0,直接返回 0 即可。
第 10 和 24 行,涉及到数据相关问题,因为我们的 CPU 是三级流水线,即取指、译码、执行,当前指令处于执行阶段的时候,下一条指令则处于译码阶段。由于执行阶段不会写寄存器,而是在下一个时钟到来时才会进行寄存器写操作,如果译码阶段的指令需要上一条指令的结果,那么此时读到的寄存器的值是错误的。比如下面这两条指令:
add x1, x2, x3 // 将 x2 + x3 的结果存入 x1 中
add x4, x1, x5 // 将 x1 + x5 的结果存入 x4 中
为了避免这种问题,第 10 行会先进行一个判断,如果写寄存器的地址和读寄存器的地址相同,则代表译码阶段的指令需要上一条指令的结果,这时候直接把要写的结果返回即可。
第 14 和 28 行,如果没有数据相关,则返回要读的寄存器的值。
第 35 行,如果写使能有效并且要写的寄存器不是 x0 寄存器,则将要写的值写到对应的寄存器。
5.3 取指
和 tinyriscv 的 master 分支不同,本项目为了将 rom 和 ram 所使用的资源综合到 bram 上,总线上挂载的外设的寄存器读取结果都会延迟一个时钟周期!
PC 寄存器模块的输出 pc_o 直接连到外设 rom 模块的地址输入,在一个时钟周期后,rom 会输出取指结果,由于取到的指令已经是延后一个时钟周期了,所以无需 if_id 模块延迟一个时钟周期。
5.4 译码
译码(id)模块是一个纯组合逻辑电路,主要作用有以下几点:
- 根据指令内容,解析出当前具体是哪一条指令(比如 add 指令)。
- 根据具体的指令,确定当前指令涉及的寄存器。比如读寄存器是一个还是两个,是否需要写寄存器以及写哪一个寄存器。
- 访问通用寄存器,得到要读的寄存器的值。
所在位置:FPGA/rtl/core/id.v
输入输出信号:
序号 | 信号名 | 输入/输出 | 位宽(bits) | 说明 |
---|---|---|---|---|
1 | rst_n | 输入 | 1 | 复位信号 |
2 | ins_i | 输入 | 32 | 指令内容 |
3 | ins_addr_i | 输入 | 32 | 指令地址 |
4 | ins_o | 输出 | 32 | 指令内容 |
5 | reg1_rd_addr_o | 输出 | 5 | 读寄存器1地址,即读哪一个通用寄存器 |
6 | reg2_rd_addr_o | 输出 | 5 | 读寄存器2地址,即读哪一个通用寄存器 |
7 | reg_wr_addr_o | 输出 | 5 | 写寄存器地址,即将结果写入哪个通用寄存器 |
8 | imm_o | 输出 | 32 | 立即数 |
9 | mem_rd_flag_o | 输出 | 1 | 内存读取标志位 |
10 | csr_rw_addr_o | 输出 | 32 | 读csr寄存器地址,即读哪一个csr寄存器 |
11 | csr_zimm_o | 输出 | 32 | csr指令立即数 |
以 ADD 指令为例来说明如何译码。下图是add指令的编码格式:
在译码阶段通过 1、4、6 这三部分确定当前指令为 add 指令,然后读通用寄存器得到 rs1、rs2,以及将目的寄存器的地址 rd,一并送到 ex 模块。下面看具体代码:
always @ (*) begin
...`INS_TYPE_R_M: beginreg1_rd_addr_o = rs1;reg2_rd_addr_o = rs2;reg_wr_addr_o = rd;imm_o = `ZERO_WORD;end
...
因为 ADD 指令属于 R 类指令,所有 R 类指令在译码阶段要做的事情都是一样的,即取出 rs1、rs2,获得目的寄存器地址 rd,所以使用上面这段简单的代码即可完成所有 R 类指令的译码操作。
其他指令的译码过程大部分都是类似的,唯一需要注意的就是访存指令(save、load)需要将 mem_rd_flag_o 置 1,在 id_ex 模块中会根据 mem_rd_flag 是否置 1来提前访存(因为访存结果会延迟一个时钟周期,只有提前访存,才能在执行阶段拿到访存结果)。
5.5 执行
本项目把执行阶段拆分为了多个模块,cu、alu、mul 和 div。
cu(控制单元)
根据不同的译码结果,执行不同的操作,可以控制 alu、div、mul 模块的工作。比如 add 指令,cu 会控制 alu 模块将寄存器1的值和寄存器2的值相加,然后写回。如果是跳转指令,cu 则发出跳转信号。
所在位置:FPGA/rtl/core/cu.v
输入输出信号:
还是以 ADD 指令为例,cu 通过译码结果判断指令为 ADD 指令时,通过设置 alu_op_code_o 来控制 alu 执行加法操作,操作的对象为 rs1 和 rs2,然后将计算结果写回目的寄存器 rd。
...
`INS_TYPE_R_M: begincase({funct7_i,funct3_i}) `INS_ADD: beginalu_data1_o = reg1_rd_data_i;alu_data2_o = reg2_rd_data_i;alu_op_code_o = `ALU_ADD;reg_wr_en_o = 1'b1;reg_wr_addr_o = reg_wr_addr_i;reg_wr_data_o = alu_res_i;end
...
alu 模块能实现算术逻辑运算。mul 模块能实现乘法运算。mul 和 alu 模块的逻辑比较简单,并且使用方法也类似。
但 div 除法模块就不一样了,div 模块从开始计算到计算结束需要 33 个时钟周期,由 div_req_o 信号请求 div 模块开始工作,在 div 模块计算中需要暂停流水线,计算完毕后 div 模块会使能 div_res_ready_i 信号,cu 通过 div_res_ready_i 信号来判断 div 运算是否结束,若运算结束则将运算结果写回:
...
reg_wr_en_o = div_res_ready_i ? 1'b1 : 1'b0;
reg_wr_addr_o = div_res_ready_i ? div_reg_wr_addr_i : reg_wr_addr_i;
reg_wr_data_o = div_res_ready_i ? div_res_i : alu_res_i;
...
...
// 因为div指令需要暂停流水线,所以执行完后需要跳回div的下一条指令继续执行
`INS_DIV: beginjump_flag_o = 1'b1;jump_addr_o = ins_addr_i + 4'd4;hold_flag_o = 1'b1;div_op_code_o = `DIV;div_req_o = 1'b1;
end
...
5.6 访存
因为本项目为三级流水线,所以访存没有单独的阶段。具体工作流程如下,在译码阶段如果识别出是内存访问指令(lb、lh、lw、lbu、lhu、sb、sh、sw),则向总线发出内存访问请求,请求读出相应位置的数据,因为读数据会延后一个时钟周期,所以刚好到执行阶段就能拿到读出的数据,具体代码(位于 id_ex.v)如下:
...
always @ (*) beginif(mem_rd_flag_i == 1'b1) beginmem_rd_rib_req_o = 1'b1;mem_rd_addr_o = $signed(reg1_rd_data_i) + $signed(imm_i);endelse beginmem_rd_rib_req_o = 1'b0;mem_rd_addr_o = `ZERO_WORD;end
end
...
第三行,mem_rd_flag_i 来自译码模块(id.v),译码模块判断指令为内存访问指令时则将 mem_rd_flag_i 置 1。
执行阶段拿到读出的数据后,将特定的部分存入目的寄存器中。以 lb 指令为例,lb 指令的作用是访问内存中的某一个字节,代码 (位于cu.v) 如下:
...
`INS_TYPE_LOAD: beginreg_wr_en_o = 1'b1;case(funct3_i)`INS_LB: begincase(mem_rd_addr_i[1:0])2'b00: beginreg_wr_data_o = {{24{mem_rd_data_i[7]}}, mem_rd_data_i[7:0]};end2'b01: beginreg_wr_data_o = {{24{mem_rd_data_i[15]}}, mem_rd_data_i[15:8]};end2'b10: beginreg_wr_data_o = {{24{mem_rd_data_i[23]}}, mem_rd_data_i[23:16]};end2'b11: beginreg_wr_data_o = {{24{mem_rd_data_i[31]}}, mem_rd_data_i[31:24]};endendcaseend
...
第 6 行,由于访问内存的地址必须是4字节对齐的,因此这里的 mem_rd_addr_i[1:0] 的含义就是 32 位内存数据 (4 个字节) 中的哪一个字节,2’b00 表示第 0 个字节,即最低字节,2’b01 表示第 1 个字节,2’b10 表示第 2 个字节,2’b11 表示第 3 个字节,即最高字节。
第 8、11、14、17 行,读到目的寄存器的数据都需要经过符号位拓展。而 lbu 指令则无需符号位拓展。
5.7 回写
同样,本项目也没有单独的回写阶段,在执行阶段结束后的下一个时钟上升沿就会把数据写回寄存器或者内存。
需要注意的是,在执行阶段,判断如果是内存存储指令(sb、sh、sw),则向总线发出访问内存请求。而对于内存加载(lb、lh、lw、lbu、lhu)指令是不需要的。因为内存存储指令既需要加载内存数据又需要往内存存储数据。
以 sb 指令为例,代码 (位于cu.v) 如下:
...
`INS_TYPE_SAVE: beginmem_wr_rib_req_o = 1'b1;mem_wr_en_o = 1'b1;case(funct3_i)`INS_SB: begincase(mem_wr_addr_o[1:0])2'b00: beginmem_wr_data_o = {mem_rd_data_i[31:8],reg2_rd_data_i[7:0]};end2'b01: beginmem_wr_data_o = {mem_rd_data_i[31:16],reg2_rd_data_i[7:0],mem_rd_data_i[7:0]};end2'b10: beginmem_wr_data_o = {mem_rd_data_i[31:24],reg2_rd_data_i[7:0],mem_rd_data_i[15:0]};end2'b11: beginmem_wr_data_o = {reg2_rd_data_i[7:0],mem_rd_data_i[23:0]};endendcaseend
...
第 3 行,发出总线请求信号。
第 4 行,写内存使能。
第 9、12、15、18 行,写内存数据。
5.8 跳转与流水线暂停
跳转就是改变 PC 寄存器的值。又因为跳转与否需要在执行阶段才知道,所以当需要跳转时,则需要暂停流水线或者说冲刷流水线。
下面直接引用 tinyriscv 文档里面的示意图,区别就是 ex.v 变成了 cu.v,ctrl.v 合并到了 EX_UNIT.v 里面。
在执行阶段,当 cu 模块判断需要发生跳转时,会将 jump_flag_o 置 1,EX_UNIT 发现 jump_flag_o 置 1 则给 pc_reg、if_id 和 id_ex 模块发出全局的流水线暂停信号 hold_flag_o,并且还会给 pc 模块发出跳转地址。在时钟上升沿到来时,if_id 和 id_ex 模块如果检测到流水线暂停信号有效则送出 NOP 指令,从而使得整条流水线 (译码阶段、执行阶段) 流淌的都是 NOP 指令,已经取出的指令就会无效,这就是流水线冲刷机制。
下面是 EX_UNIT.v 中关于流水线暂停部分的代码:
...
// 暂停流水线控制信号 hold_flag_o
always @ (*) begin// 暂停整个流水线if(jump_flag_o == 1'b1 || hold_flag == 1'b1 || div_busy == 1'b1 || clint_busy_i == 1'b1) beginhold_flag_o = `HOLD_ID_EX;end// 暂停PCelse if(rib_hold_flag_i == 1'b1) beginhold_flag_o = `HOLD_PC;endelse beginhold_flag_o = `HOLD_NONE;end
end
...
第 5 行,对于跳转操作、cu 判断需要暂停的情况、除法模块正在工作的情况、 中断模块正在工作的情况则暂停整条流水线。
第 9 行,对于总线请求操作,只暂停 pc 模块的取指,其他模块正常运行。
一般来说,跳转时只需要暂停流水线一个时钟周期,但是如果是多周期指令(比如除法指令),则需要暂停流水线多个时钟周期。
5.9 总线
本项目的总线实现的十分简单,另外 pc 的访存操作不经过总线,直接与 rom 相连。
本总线本质上是一个多路选择器,从多个主设备中选择其中一个来访问对应的从设备。并且总线地址的最高4位决定要访问的是哪一个从设备,因此最多支持16个从设备。各从设备的地址分布如下:
// 访问地址的最高四位决定要访问的是哪一个设备
parameter[3:0] slave_0 = 4'b0000; // 0x0000_0000 ~ 0x0fff_ffff [rom]
parameter[3:0] slave_1 = 4'b0001; // 0x1000_0000 ~ 0x1fff_ffff [ram]
parameter[3:0] slave_2 = 4'b0010; // 0x2000_0000 ~ 0x2fff_ffff [uart]
parameter[3:0] slave_3 = 4'b0011; // 0x3000_0000 ~ 0x3fff_ffff [gpio]
parameter[3:0] slave_4 = 4'b0100; // 0x4000_0000 ~ 0x4fff_ffff [timer]
唯一需要注意的地方就是主设备从总线向从设备读数据的时候,数据会延后一个时钟周期经过总线到达主设备。
5.10 中断
中断 (中断返回) 本质上也是一种跳转,只不过还需要附加一些读写 CSR 寄存器的操作。
RISC-V 中断分为两种类型,一种是同步中断,即 ECALL、EBREAK 等指令所产生的中断,另一种是异步中断,即 GPIO、UART 等外设产生的中断。
对于中断模块设计,一种简单的方法就是当检测到中断(中断返回)信号时,先暂停整条流水线,设置跳转地址为中断入口地址,然后读、写必要的 CSR 寄存器 (mstatus、mepc、mcause 等),等读写完这些CSR寄存器后取消流水线暂停,这样处理器就可以从中断入口地址开始取指,进入中断服务程序。
中断模块所在文件:rtl/core/clint.v
输入输出信号:
中断模块通过如下代码来判断是否有中断信号产生:
// 中断仲裁逻辑
always @ (*) beginif (!rst_n) beginint_state = INT_IDLE;end// 同步中断else beginif (ins_i == `INS_ECALL || ins_i == `INS_EBREAK) begin// 如果执行阶段的指令为除法指令或者跳转指令,则先不处理同步中断if (div_req_i != 1'b1 && jump_flag_i != 1'b1) beginint_state = INT_SYNC_ASSERT;end else beginint_state = INT_IDLE;endend // 异步中断else if (int_flag_i != `INT_NONE && csr_mstatus[3] == 1'b1) beginint_state = INT_ASYNC_ASSERT;end else if (ins_i == `INS_MRET) beginint_state = INT_MRET;end else beginint_state = INT_IDLE;endend
end
第 8 行,如果当前指令为 ECALL 或 EBREAK 指令,则表示有同步中断需要处理,将中断状态 int_state 设置为 INT_SYNC_ASSERT。
第 10 行,如果执行阶段的指令为除法指令或者跳转指令,则先不处理同步中断。
第 18 行,如果外部中断信号不为空,并且 csr 的 mstatus 寄存器的全局中断使能位为 1,则表示有异步中断需要处理,将中断状态 int_state 设置为 INT_ASYNC_ASSERT。
第 21 行,判断当前指令是否是MRET指令,MRET指令是中断返回指令。如果是,则设置中断状态为 INT_MRET。
下面就根据当前的中断状态做不同处理(读写不同的 CSR 寄存器),代码如下:
// 写CSR寄存器状态切换
always @ (posedge clk or negedge rst_n) beginif (!rst_n) begincsr_state <= CSR_IDLE;cause <= `ZERO_WORD;ins_addr <= `ZERO_WORD;end else begincase (csr_state)CSR_IDLE: begin// 同步中断if (int_state == INT_SYNC_ASSERT) begincsr_state <= CSR_MEPC;// 在中断处理函数里会将中断返回地址加4ins_addr <= ins_addr_i;if (ins_i == `INS_ECALL) begincause <= 32'd11;endelse if (ins_i == `INS_EBREAK) begincause <= 32'd3;endelse begincause <= 32'd10;endend // 异步中断else if (int_state == INT_ASYNC_ASSERT) begin// 定时器中断 if (int_flag_i & `INT_TIMER) begincause <= 32'h80000007;end// uart中断,目前这个只用于测试else if (int_flag_i & `INT_UART_REV) begincause <= 32'h8000000b;endelse begincause <= 32'h8000000a;endcsr_state <= CSR_MEPC;if (jump_flag_i == 1'b1) beginins_addr <= jump_addr_i;end// 异步中断可以中断除法指令的执行,中断处理完再重新执行除法指令else if (div_req_i == 1'b1 || div_busy_i == 1'b1) beginins_addr <= div_ins_addr;end else beginins_addr <= ins_addr_i;endend // 中断返回else if (int_state == INT_MRET) begincsr_state <= CSR_MSTATUS_MRET;endendCSR_MEPC: begincsr_state <= CSR_MSTATUS;endCSR_MSTATUS: begincsr_state <= CSR_MCAUSE;endCSR_MCAUSE: begincsr_state <= CSR_IDLE;endCSR_MSTATUS_MRET: begincsr_state <= CSR_IDLE;enddefault: begincsr_state <= CSR_IDLE;endendcaseend
end
第 12 行,判断当前中断状态是否为 INT_SYNC_ASSERT,即是否发生同步中断。
第 13 至 25 行,根据不同的指令(ecall 或 ebreak),来设置中断原因寄存器 mcause 不同的中断码(Exception Code)。下面是不同的中断原因对应的中断码:
第 29 行,目前异步中断只支持定时器中断,uart 中断还在测试阶段。
第 41 至 50 行,如果发生异步中断时,执行阶段需要执行跳转指令,则将返回地址 ins_addr 设置为跳转地址 jump_addr_i;若执行阶段为除法指令,则将返回地址 ins_addr 设置为除法指令地址 div_ins_addr,中断处理完再重新执行除法指令;默认返回地址为当前指令地址 ins_addr_i。
第 53 行,如果是中断返回指令,则设置 CSR 状态为 CSR_MSTATUS_MRET。
第 57 ~ 68 行,一个时钟切换一下CSR状态。
接下来就是写 CSR 寄存器操作,需要根据上面的 CSR 状态来写:
// 发出中断信号前,先写几个CSR寄存器
always @ (posedge clk or negedge rst_n) beginif (!rst_n) beginwr_en_o <= 1'b0;wr_addr_o <= `ZERO_WORD;wr_data_o <= `ZERO_WORD;end else begincase (csr_state)// 将mepc寄存器的值设为当前指令地址CSR_MEPC: beginwr_en_o <= 1'b1;wr_addr_o <= {20'h0, `CSR_MEPC};wr_data_o <= ins_addr;end// 写中断产生的原因CSR_MCAUSE: beginwr_en_o <= 1'b1;wr_addr_o <= {20'h0, `CSR_MCAUSE};wr_data_o <= cause;end// 关闭全局中断,修改特权级别为machine,并将当前特权级别存入MPP中CSR_MSTATUS: beginwr_en_o <= 1'b1;wr_addr_o <= {20'h0, `CSR_MSTATUS};wr_data_o <= {csr_mstatus[31:4], 1'b0, csr_mstatus[2:0]};end// 中断返回,修改特权级别为MPPCSR_MSTATUS_MRET: beginwr_en_o <= 1'b1;wr_addr_o <= {20'h0, `CSR_MSTATUS};wr_data_o <= {csr_mstatus[31:4], csr_mstatus[7], csr_mstatus[2:0]};enddefault: beginwr_en_o <= 1'b0;wr_addr_o <= `ZERO_WORD;wr_data_o <= `ZERO_WORD;endendcaseend
end
第 11 行,写 mepc 寄存器,当遇到 mret 指令时,会退出中断,返回 mepc 寄存器内的地址处继续执行。
第 17 行,写 mcause 寄存器。
第 23 行,通过置 mstatus 的 MIE 位为 0 来关闭全局中断。
第 29 行,退出中断,恢复 mstatus 的 MIE 为 MPIE 的值。
最后就是发出中断信号,中断信号会进入到执行阶段:
// 发出中断信号给cu模块
always @ (posedge clk or negedge rst_n) beginif (!rst_n) beginint_assert_o <= 1'b0;int_addr_o <= `ZERO_WORD;end else begincase (csr_state)// 发出中断进入信号.写完mcause寄存器才能发CSR_MCAUSE: beginint_assert_o <= 1'b1;int_addr_o <= csr_mtvec;end// 发出中断返回信号CSR_MSTATUS_MRET: beginint_assert_o <= 1'b1;int_addr_o <= csr_mepc;enddefault: beginint_assert_o <= 1'b0;int_addr_o <= `ZERO_WORD;endendcaseend
end
有两种情况需要发出中断信号,一种是进入中断,另一种是退出中断。
第 10 行,写完mstatus寄存器后发出中断进入信号,中断入口地址就是mtvec寄存器的值。
第 15 行,发出中断退出信号,中断退出地址就是mepc寄存器的值。
至此,本项目 rtl 部分的主要代码已经介绍完毕,如果还有不清楚的可以在评论区下方留言,本人都会一一解答,后续会继续更新本项目 rt-thread 部分的讲解~