来源:liangkangnan的博客
更新于 2021-01-31
tinyriscv 一个从零开始写的极简易懂的RISC-V处理器核
从零开始写RISC-V处理器之一 前言
从零开始写RISC-V处理器之二 绪论
从零开始写RISC-V处理器之三 硬件篇
从零开始写RISC-V处理器之四 软件篇
从零开始写RISC-V处理器之五 实践篇
从零开始写RISC-V处理器之六 写在最后
硬件篇
硬件篇主要介绍tinyriscv的verilog代码设计。
tinyriscv整体框架如图2_1所示。
图2_1 tinyriscv整体框架
可见目前tinyriscv已经不仅仅是一个内核了,而是一个小型的SOC,包含一些简单的外设,如timer
、uart_tx
等。
tinyriscv SOC输入输出信号有两部分,一部分是系统时钟clk和复位信号rst,另一部分是JTAG调试信号,TCK
、TMS
、TDI
和TDO
。
上图中的小方框表示一个个模块,方框里面的文字表示模块的名字,箭头则表示模块与模块之间的的输入输出关系。
下面简单介绍每个模块的主要作用。
jtag_top
:调试模块的顶层模块,主要有三大类型的信号,第一种是读写内存的信号,第二种是读写寄存器的信号,第三种是控制信号,比如复位MCU,暂停MCU等。
pc_reg
:PC寄存器模块,用于产生PC寄存器的值,该值会被用作指令存储器的地址信号。
if_id
:取指到译码之间的模块,用于将指令存储器输出的指令打一拍后送到译码模块。
id
:译码模块,纯组合逻辑电路,根据if_id模块送进来的指令进行译码。当译码出具体的指令(比如add指令)后,产生是否写寄存器信号,读寄存器信号等。由于寄存器采用的是异步读方式,因此只要送出读寄存器信号后,会马上得到对应的寄存器数据,这个数据会和写寄存器信号一起送到id_ex模块。
id_ex
:译码到执行之间的模块,用于将是否写寄存器的信号和寄存器数据打一拍后送到执行模块。
ex
:执行模块,纯组合逻辑电路,根据具体的指令进行相应的操作,比如add指令就执行加法操作等。此外,如果是lw等访存指令的话,则会进行读内存操作,读内存也是采用异步读方式。最后将是否需要写寄存器、写寄存器地址,写寄存器数据信号送给regs模块,将是否需要写内存、写内存地址、写内存数据信号送给rib总线,由总线来分配访问的模块。
div
:除法模块,采用试商法实现,因此至少需要32个时钟才能完成一次除法操作。
ctrl
:控制模块,产生暂停流水线、跳转等控制信号。
clint
:核心本地中断模块,对输入的中断请求信号进行总裁,产生最终的中断信号。
rom
:程序存储器模块,用于存储程序(bin)文件。
ram
:数据存储器模块,用于存储程序中的数据。
timer
:定时器模块,用于计时和产生定时中断信号。目前支持RTOS时需要用到该定时器。
uart_tx
:串口发送模块,主要用于调试打印。
gpio
:简单的IO口模块,主要用于点灯调试。
spi
:目前只有master角色,用于访问spi从机,比如spi norflash。
PC寄存器
PC寄存器模块所在的源文件:rtl/core/pc_reg.v
PC寄存器模块的输入输出信号如下表所示:
序号 |
信号名 |
输入/输出 |
位宽(bits) |
说明 |
1 |
clk |
输入 |
1 |
时钟输入信号 |
2 |
rst |
输入 |
1 |
复位输入信号 |
3 |
jump_flag_i |
输入 |
1 |
跳转标志 |
4 |
jump_addr_i |
输入 |
32 |
跳转地址,即跳转到该地址 |
5 |
hold_flag_i |
输入 |
3 |
暂停标志,即PC寄存器的值保持不变 |
6 |
jtag_reset_flag_i |
输入 |
1 |
复位标志,即设置为复位后的值 |
7 |
pc_o |
输出 |
32 |
PC寄存器值,即从该值处取指 |
PC寄存器模块代码比较简单,直接贴出来:
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
第3行,PC寄存器的值恢复到原始值(复位后的值)有两种方式,第一种不用说了,就是复位信号有效。第二种是收到jtag模块发过来的复位信号。PC寄存器复位后的值为CpuResetAddr
,即32’h0
,可以通过改变CpuResetAddr
的值来改变PC寄存器的复位值。
第6行,判断跳转标志是否有效,如果有效则直接将PC寄存器的值设置为jump_addr_i
的值。因此可以知道,所谓的跳转就是改变PC寄存器的值,从而使CPU从该跳转地址开始取指。
第9行,判断暂停标志是否大于等于Hold_Pc
,该值为3’b001
。如果是,则保持PC寄存器的值不变。这里可能会有疑问,为什么Hold_Pc
的值不是一个1bit的信号。因为这个暂停标志还会被if_id
和id_ex
模块使用,如果仅仅需要暂停PC寄存器的话,那么if_id
模块和id_ex
模块是不需要暂停的。当需要暂停if_id
模块时,PC寄存器也会同时被暂停。当需要暂停id_ex
模块时,那么整条流水线都会被暂停。
第13行,将PC寄存器的值加4。在这里可以知道,tinyriscv的取指地址是4字节对齐的,每条指令都是32位的。
通用寄存器
通用寄存器模块所在的源文件:rtl/core/regs.v
一共有32个通用寄存器x0~x31,其中寄存器x0是只读寄存器并且其值固定为0。
通用寄存器的输入输出信号如下表所示:
序号 |
信号名 |
输入/输出 |
位宽(bits) |
说明 |
1 |
clk |
输入 |
1 |
时钟输入 |
2 |
rst |
输入 |
1 |
复位输入 |
3 |
we_i |
输入 |
1 |
来自执行模块的写使能 |
4 |
waddr_i |
输入 |
5 |
来自执行模块的写地址 |
5 |
wdata_i |
输入 |
32 |
来自执行模块的写数据 |
6 |
jtag_we_i |
输入 |
1 |
来自jtag模块的写使能 |
7 |
jtag_addr_i |
输入 |
5 |
来自jtag模块的写地址 |
8 |
jtag_data_i |
输入 |
32 |
来自jtag模块的写数据 |
9 |
raddr1_i |
输入 |
5 |
来自译码模块的寄存器1读地址 |
10 |
rdata1_o |
输出 |
32 |
寄存器1读数据 |
11 |
raddr2_i |
输入 |
5 |
来自译码模块的寄存器2读地址 |
12 |
rdata2_o |
输出 |
32 |
寄存器2读数据 |
13 |
jtag_data_o |
输出 |
32 |
jtag读数据 |
注意,这里的寄存器1不是指x1寄存器,寄存器2也不是指x2寄存器。而是指一条指令里涉及到的两个寄存器(源寄存器1和源寄存器2)。一条指令可能会同时读取两个寄存器的值,所以有两个读端口。又因为jtag模块也会进行寄存器的读操作,所以一共有三个读端口。
读寄存器操作来自译码模块,并且读出来的寄存器数据也会返回给译码模块。写寄存器操作来自执行模块。
先看读操作的代码,如下:
// 读寄存器1
always @ (*) begin
if (rst == `RstEnable) begin
rdata1_o = `ZeroWord;
end else if (raddr1_i == `RegNumLog2'h0) begin
rdata1_o = `ZeroWord;
// 如果读地址等于写地址,并且正在写操作,则直接返回写数据
end else if (raddr1_i == waddr_i && we_i == `WriteEnable) begin
rdata1_o = wdata_i;
end else begin
rdata1_o = regs[raddr1_i];
end
end
// 读寄存器2
always @ (*) begin
if (rst == `RstEnable) begin
rdata2_o = `ZeroWord;
end else if (raddr2_i == `RegNumLog2'h0) begin
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
可以看到两个寄存器的读操作几乎是一样的。因此在这里只解析读寄存器1那部分代码。
第5行,如果是读寄存器0(x0),那么直接返回0就可以了。
第8行,这涉及到数据相关问题。由于流水线的原因,当前指令处于执行阶段的时候,下一条指令则处于译码阶段。由于执行阶段不会写寄存器,而是在下一个时钟到来时才会进行寄存器写操作,如果译码阶段的指令需要上一条指令的结果,那么此时读到的寄存器的值是错误的。比如下面这两条指令:
add x1, x2, x3
add x4, x1, x5
第二条指令依赖于第一条指令的结果。为了解决这个数据相关的问题就有了第8~9行的操作,即如果读寄存器等于写寄存器,则直接将要写的值返回给读操作。
第11行,如果没有数据相关,则返回要读的寄存器的值。
下面看写寄存器操作,代码如下:
// 写寄存器
always @ (posedge clk) begin
if (rst == `RstDisable) begin
// 优先ex模块写操作
if ((we_i == `WriteEnable) && (waddr_i != `RegNumLog2'h0)) begin
regs[waddr_i] <= wdata_i;
end else if ((jtag_we_i == `WriteEnable) && (jtag_addr_i != `RegNumLog2'h0)) begin
regs[jtag_addr_i] <= jtag_data_i;
end
end
end
第5~6行,如果执行模块写使能并且要写的寄存器不是x0寄存器,则将要写的值写到对应的寄存器。
第7~8行,jtag模块的写操作。
CSR寄存器模块(csr_reg.v
)和通用寄存器模块的读、写操作是类似的,这里就不重复了。
取指
目前tinyriscv所有外设(包括rom和ram)、寄存器的读取都是与时钟无关的,或者说所有外设、寄存器的读取采用的是组合逻辑的方式 。这一点非常重要!
tinyriscv并没有具体的取指模块和代码。PC寄存器模块的输出pc_o会连接到外设rom模块的地址输入,又由于rom的读取是组合逻辑,因此每一个时钟上升沿到来之前(时序是满足要求的),从rom输出的指令已经稳定在if_id模块的输入,当时钟上升沿到来时指令就会输出到id模块。
取到的指令和指令地址会输入到if_id模块(if_id.v
),if_id模块是一个时序电路,作用是将输入的信号打一拍后再输出到译码(id.v
)模块。
译码
译码模块所在的源文件:rtl/core/id.v
译码(id)模块是一个纯组合逻辑电路,主要作用有以下几点:
1.根据指令内容,解析出当前具体是哪一条指令(比如add指令)。
2.根据具体的指令,确定当前指令涉及的寄存器。比如读寄存器是一个还是两个,是否需要写寄存器以及写哪一个寄存器。
3.访问通用寄存器,得到要读的寄存器的值。
译码模块的输入输出信号如下表所示:
序号 |
信号名 |
输入/输出 |
位宽(bits) |
说明 |
1 |
rst |
输入 |
1 |
复位信号 |
2 |
inst_i |
输入 |
32 |
指令内容 |
3 |
inst_addr_i |
输入 |
32 |
指令地址 |
4 |
reg1_rdata_i |
输入 |
32 |
寄存器1输入数据 |
5 |
reg2_rdata_i |
输入 |
32 |
寄存器2输入数据 |
6 |
csr_rdata_i |
输入 |
32 |
CSR寄存器输入数据 |
7 |
ex_jump_flag_i |
输入 |
1 |
跳转信号 |
8 |
reg1_raddr_o |
输出 |
5 |
读寄存器1地址,即读哪一个通用寄存器 |
9 |
reg2_raddr_o |
输出 |
5 |
读寄存器2地址,即读哪一个通用寄存器 |
10 |
csr_raddr_o |
输出 |
32 |
读csr寄存器地址,即读哪一个CSR寄存器 |
11 |
mem_req_o |
输出 |
1 |
向总线请求访问内存信号 |
12 |
inst_o |
输出 |
32 |
指令内容 |
13 |
inst_addr_o |
输出 |
32 |
指令地址 |
14 |
reg1_rdata_o |
输出 |
32 |
通用寄存器1数据 |
15 |
reg2_rdata_o |
输出 |
32 |
通用寄存器2数据 |
16 |
reg_we_o |
输出 |
1 |
通用寄存器写使能 |
17 |
reg_waddr_o |
输出 |
5 |
通用寄存器写地址,即写哪一个通用寄存器 |
18 |
csr_we_o |
输出 |
1 |
CSR寄存器写使能 |
19 |
csr_rdata_o |
输出 |
32 |
CSR寄存器读数据 |
20 |
csr_waddr_o |
输出 |
32 |
CSR寄存器写地址,即写哪一个CSR寄存器 |
以add指令为例来说明如何译码。下图是add指令的编码格式:
可知,add指令被编码成6部分内容。通过第1、4、6这三部分可以唯一确定当前指令是否是add指令。知道是add指令之后,就可以知道add指令需要读两个通用寄存器(rs1和rs2)和写一个通用寄存器(rd)。下面看具体的代码:
case (opcode)
...
`INST_TYPE_R_M: begin
if ((funct7 == 7'b0000000) || (funct7 == 7'b0100000)) begin
case (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;
end
...
第1行,opcode就是指令编码中的第6部分内容。
第3行,`INST_TYPE_R_M的值为7’b0110011。
第4行,funct7是指指令编码中的第1部分内容。
第5行,funct3是指指令编码中的第4部分内容。
第6行,到了这里,第1、4、6这三部分已经译码完毕,已经可以确定当前指令是add指令了。
第7行,设置写寄存器标志为1,表示执行模块结束后的下一个时钟需要写寄存器。
第8行,设置写寄存器地址为rd,rd的值为指令编码里的第5部分内容。
第9行,设置读寄存器1的地址为rs1,rs1的值为指令编码里的第3部分内容。
第10行,设置读寄存器2的地址为rs2,rs2的值为指令编码里的第2部分内容。
其他指令的译码过程是类似的,这里就不重复了。译码模块看起来代码很多,但是大部分代码都是类似的。
译码模块还有个作用是当指令为加载内存指令(比如lw等)时,向总线发出请求访问内存的信号。这部分内容将在总线一节再分析。
译码模块的输出会送到id_ex模块(id_ex.v)的输入,id_ex模块是一个时序电路,作用是将输入的信号打一拍后再输出到执行模块(ex.v)。
执行
执行模块所在的源文件:rtl/core/ex.v
执行(ex)模块是一个纯组合逻辑电路,主要作用有以下几点:
1.根据当前是什么指令执行对应的操作,比如add指令,则将寄存器1的值和寄存器2的值相加。
2.如果是内存加载指令,则读取对应地址的内存数据。
3.如果是跳转指令,则发出跳转信号。
执行模块的输入输出信号如下表所示:
序号 |
信号名 |
输入/输出 |
位宽(bits) |
说明 |
1 |
rst |
输入 |
1 |
复位信号 |
2 |
inst_i |
输入 |
32 |
指令内容 |
3 |
inst_addr_i |
输入 |
32 |
指令地址 |
4 |
reg_we_i |
输入 |
1 |
寄存器写使能 |
5 |
reg_waddr_i |
输入 |
5 |
通用寄存器写地址,即写哪一个通用寄存器 |
6 |
reg1_rdata_i |
输入 |
32 |
通用寄存器1读数据 |
7 |
reg2_rdata_i |
输入 |
32 |
通用寄存器2读数据 |
8 |
csr_we_i |
输入 |
1 |
CSR寄存器写使能 |
9 |
csr_waddr_i |
输入 |
32 |
CSR寄存器写地址,即写哪一个CSR寄存器 |
10 |
csr_rdata_i |
输入 |
32 |
CSR寄存器读数据 |
11 |
int_assert_i |
输入 |
1 |
中断信号 |
12 |
int_addr_i |
输入 |
32 |
中断跳转地址,即中断发生后跳转到哪个地址 |
13 |
mem_rdata_i |
输入 |
32 |
内存读数据 |
14 |
div_ready_i |
输入 |
1 |
除法模块是否准备好信号,即是否可以进行除法运算 |
15 |
div_result_i |
输入 |
64 |
除法结果 |
16 |
div_busy_i |
输入 |
1 |
除法模块忙信号,即正在进行除法运算 |
17 |
div_op_i |
输入 |
3 |
具体的除法运算,即DIV、DIVU、REM和REMU中的哪一种 |
18 |
div_reg_waddr_i |
输入 |
5 |
除法运算完成后要写的通用寄存器地址 |
19 |
mem_wdata_o |
输出 |
32 |
内存写数据 |
20 |
mem_raddr_o |
输出 |
32 |
内存读地址 |
21 |
mem_waddr_o |
输出 |
32 |
内存写地址 |
22 |
mem_we_o |
输出 |
1 |
内存写使能 |
23 |
mem_req_o |
输出 |
1 |
请求访问内存信号 |
24 |
reg_wdata_o |
输出 |
32 |
通用寄存器写数据 |
25 |
reg_we_o |
输出 |
1 |
通用寄存器写使能 |
26 |
reg_waddr_o |
输出 |
5 |
通用寄存器写地址 |
27 |
csr_wdata_o |
输出 |
32 |
CSR寄存器写数据 |
28 |
csr_we_o |
输出 |
1 |
CSR寄存器写使能 |
29 |
csr_waddr_o |
输出 |
32 |
CSR寄存器写地址,即写哪一个CSR寄存器 |
30 |
div_start_o |
输出 |
1 |
开始除法运算 |
31 |
div_dividend_o |
输出 |
32 |
除法运算中的被除数 |
32 |
div_divisor_o |
输出 |
32 |
除法运算中的除数 |
33 |
div_op_o |
输出 |
3 |
具体的除法运算,即DIV、DIVU、REM和REMU中的哪一种 |
34 |
div_reg_waddr_o |
输出 |
5 |
除法运算完成后要写的通用寄存器地址 |
35 |
hold_flag_o |
输出 |
1 |
暂停流水线信号 |
36 |
jump_flag_o |
输出 |
1 |
跳转信号 |
37 |
jump_addr_o |
输出 |
32 |
跳转地址 |
下面以add指令为例说明,add指令的作用就是将寄存器1的值和寄存器2的值相加,最后将结果写入目的寄存器。代码如下:
...
`INST_TYPE_R_M: begin
if ((funct7 == 7'b0000000) || (funct7 == 7'b0100000)) begin
case (funct3)
`INST_ADD_SUB: begin
jump_flag = `JumpDisable;
hold_flag = `HoldDisable;
jump_addr = `ZeroWord;
mem_wdata_o = `ZeroWord;
mem_raddr_o = `ZeroWord;
mem_waddr_o = `ZeroWord;
mem_we = `WriteDisable;
if (inst_i[30] == 1'b0) begin
reg_wdata = reg1_rdata_i + reg2_rdata_i;
end else begin
reg_wdata = reg1_rdata_i - reg2_rdata_i;
end
...
end
...
第2~4行,译码操作。
第5行,对add或sub指令进行处理。
第6~12行,当前指令不涉及到的操作(比如跳转、写内存等)需要将其置回默认值。
第13行,指令编码中的第30位区分是add指令还是sub指令。0表示add指令,1表示sub指令。
第14行,执行加法操作。
第16行,执行减法操作。
其他指令的执行是类似的,需要注意的是没有涉及的信号要将其置为默认值,if和case情况要写全,避免产生锁存器。
下面以beq指令说明跳转指令的执行。beq指令的编码如下:
beq指令的作用就是当寄存器1的值和寄存器2的值相等时发生跳转,跳转的目的地址为当前指令的地址加上符号扩展的imm的值。具体代码如下:
...
`INST_TYPE_B: begin
case (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};
end else begin
jump_flag = `JumpDisable;
jump_addr = `ZeroWord;
end
...
end
...
第2~4行,译码出beq指令。
第5~10行,没有涉及的信号置为默认值。
第11行,判断寄存器1的值是否等于寄存器2的值。
第12行,跳转使能,即发生跳转。
第13行,计算出跳转的目的地址。
第15、16行,不发生跳转。
其他跳转指令的执行是类似的,这里就不再重复了。
访存
由于tinyriscv只有三级流水线,因此没有访存这个阶段,访存的操作放在了执行模块中。具体是这样的,在译码阶段如果识别出是内存访问指令(lb、lh、lw、lbu、lhu、sb、sh、sw),则向总线发出内存访问请求,具体代码(位于id.v)如下:
...
`INST_TYPE_L: begin
case (funct3)
`INST_LB, `INST_LH, `INST_LW, `INST_LBU, `INST_LHU: begin
reg1_raddr_o = rs1;
reg2_raddr_o = `ZeroReg;
reg_we_o = `WriteEnable;
reg_waddr_o = rd;
mem_req = `RIB_REQ;
end
default: begin
reg1_raddr_o = `ZeroReg;
reg2_raddr_o = `ZeroReg;
reg_we_o = `WriteDisable;
reg_waddr_o = `ZeroReg;
end
endcase
end
`INST_TYPE_S: begin
case (funct3)
`INST_SB, `INST_SW, `INST_SH: begin
reg1_raddr_o = rs1;
reg2_raddr_o = rs2;
reg_we_o = `WriteDisable;
reg_waddr_o = `ZeroReg;
mem_req = `RIB_REQ;
end
...
第2~4行,译码出内存加载指令,lb、lh、lw、lbu、lhu。
第5行,需要读寄存器1。
第6行,不需要读寄存器2。
第7行,写目的寄存器使能。
第8行,写目的寄存器的地址,即写哪一个通用寄存器。
第9行,发出访问内存请求。
第19~21行,译码出内存存储指令,sb、sw、sh。
第22行,需要读寄存器1。
第23行,需要读寄存器2。
第24行,不需要写目的寄存器。
第26行,发出访问内存请求。
问题来了,为什么在取指阶段发出内存访问请求?这跟总线的设计是相关的,这里先不具体介绍总线的设计,只需要知道如果需要访问内存,则需要提前一个时钟向总线发出请求。
在译码阶段向总线发出内存访问请求后,在执行阶段就会得到对应的内存数据。
下面看执行阶段的内存加载操作,以lb指令为例,lb指令的作用是访问内存中的某一个字节,代码(位于ex.v)如下:
...
`INST_TYPE_L: begin
case (funct3)
`INST_LB: begin
jump_flag = `JumpDisable;
hold_flag = `HoldDisable;
jump_addr = `ZeroWord;
mem_wdata_o = `ZeroWord;
mem_waddr_o = `ZeroWord;
mem_we = `WriteDisable;
mem_raddr_o = reg1_rdata_i + {{20{inst_i[31]}}, inst_i[31:20]};
case (mem_raddr_index)
2'b00: begin
reg_wdata = {{24{mem_rdata_i[7]}}, mem_rdata_i[7:0]};
end
2'b01: begin
reg_wdata = {{24{mem_rdata_i[15]}}, mem_rdata_i[15:8]};
end
2'b10: begin
reg_wdata = {{24{mem_rdata_i[23]}}, mem_rdata_i[23:16]};
end
default: begin
reg_wdata = {{24{mem_rdata_i[31]}}, mem_rdata_i[31:24]};
end
endcase
end
...
第2~4行,译码出lb指令。
第5~10行,将没有涉及的信号置为默认值。
第11行,得到访存的地址。
第12行,由于访问内存的地址必须是4字节对齐的,因此这里的mem_raddr_index
的含义就是32位内存数据(4个字节)中的哪一个字节,2’b00表示第0个字节,即最低字节,2’b01表示第1个字节,2’b10表示第2个字节,2’b11表示第3个字节,即最高字节。
第14、17、20、23行,写寄存器数据。
回写
由于tinyriscv只有三级流水线,因此也没有回写(write back,或者说写回)这个阶段,在执行阶段结束后的下一个时钟上升沿就会把数据写回寄存器或者内存。
需要注意的是,在执行阶段,判断如果是内存存储指令(sb、sh、sw),则向总线发出访问内存请求。而对于内存加载(lb、lh、lw、lbu、lhu)指令是不需要的。因为内存存储指令既需要加载内存数据又需要往内存存储数据。
以sb指令为例,代码(位于ex.v)如下:
...
`INST_TYPE_S: begin
case (funct3)
`INST_SB: begin
jump_flag = `JumpDisable;
hold_flag = `HoldDisable;
jump_addr = `ZeroWord;
reg_wdata = `ZeroWord;
mem_we = `WriteEnable;
mem_req = `RIB_REQ;
mem_waddr_o = reg1_rdata_i + {{20{inst_i[31]}}, inst_i[31:25], inst_i[11:7]};
mem_raddr_o = reg1_rdata_i + {{20{inst_i[31]}}, inst_i[31:25], inst_i[11:7]};
case (mem_waddr_index)
2'b00: begin
mem_wdata_o = {mem_rdata_i[31:8], reg2_rdata_i[7:0]};
end
2'b01: begin
mem_wdata_o = {mem_rdata_i[31:16], reg2_rdata_i[7:0], mem_rdata_i[7:0]};
end
2'b10: begin
mem_wdata_o = {mem_rdata_i[31:24], reg2_rdata_i[7:0], mem_rdata_i[15:0]};
end
default: begin
mem_wdata_o = {reg2_rdata_i[7:0], mem_rdata_i[23:0]};
end
endcase
end
...
第2~4行,译码出sb指令。
第5~8行,将没有涉及的信号置为默认值。
第9行,写内存使能。
第10行,发出访问内存请求。
第11行,内存写地址。
第12行,内存读地址,读地址和写地址是一样的。
第13行,mem_waddr_index
的含义就是写32位内存数据中的哪一个字节。
第15、18、21、24行,写内存数据。
sb指令只改变读出来的32位内存数据中对应的字节,其他3个字节的数据保持不变,然后写回到内存中。
跳转和流水线暂停
跳转就是改变PC寄存器的值。又因为跳转与否需要在执行阶段才知道,所以当需要跳转时,则需要暂停流水线(正确来说是冲刷流水线。流水线是不可以暂停的,除非时钟不跑了)。那怎么暂停流水线呢?或者说怎么实现流水线冲刷呢?tinyriscv的流水线结构如下图所示。
其中长方形表示的是时序逻辑电路,云状型表示的是组合逻辑电路。在执行阶段,当判断需要发生跳转时,发出跳转信号和跳转地址给ctrl(ctrl.v)模块。ctrl模块判断跳转信号有效后会给pc_reg、if_id和id_ex模块发出流水线暂停信号,并且还会给pc_reg模块发出跳转地址。在时钟上升沿到来时,if_id和id_ex模块如果检测到流水线暂停信号有效则送出NOP指令,从而使得整条流水线(译码阶段、执行阶段)流淌的都是NOP指令,已经取出的指令就会无效,这就是流水线冲刷机制。
下面看ctrl.v模块是怎么设计的。ctrl.v的输入输出信号如下表所示:
序号 |
信号名 |
输入/输出 |
位宽(bits) |
说明 |
1 |
rst |
输入 |
1 |
复位信号 |
2 |
jump_flag_i |
输入 |
1 |
跳转标志 |
3 |
jump_addr_i |
输入 |
32 |
跳转地址 |
4 |
hold_flag_ex_i |
输入 |
1 |
来自执行模块的暂停标志 |
5 |
hold_flag_rib_i |
输入 |
1 |
来自总线模块的暂停标志 |
6 |
jtag_halt_flag_i |
输入 |
1 |
来自jtag模块的暂停标志 |
7 |
hold_flag_clint_i |
输入 |
1 |
来自中断模块的暂停标志 |
8 |
hold_flag_o |
输出 |
3 |
暂停标志 |
9 |
jump_flag_o |
输出 |
1 |
跳转标志 |
10 |
jump_addr_o |
输出 |
32 |
跳转地址 |
可知,暂停信号来自多个模块。对于跳转(跳转包含暂停流水线操作),是要冲刷整条流水线的,因为跳转后流水线上其他阶段的其他操作是无效的。对于其他模块的暂停信号,一种最简单的设计就是也冲刷整条流水线,但是这样的话MCU的效率就会低一些。另一种设计就是根据不同的暂停信号,暂停不同的流水线阶段。比如对于总线请求的暂停只需要暂停PC寄存器这一阶段就可以了,让流水线上的其他阶段继续工作。看ctrl.v的代码:
...
always @ (*) begin
if (rst == `RstEnable) begin
hold_flag_o = `Hold_None;
jump_flag_o = `JumpDisable;
jump_addr_o = `ZeroWord;
end else begin
jump_addr_o = jump_addr_i;
jump_flag_o = jump_flag_i;
// 默认不暂停
hold_flag_o = `Hold_None;
// 按优先级处理不同模块的请求
if (jump_flag_i == `JumpEnable || hold_flag_ex_i == `HoldEnable || hold_flag_clint_i == `HoldEnable) begin
// 暂停整条流水线
hold_flag_o = `Hold_Id;
end else if (hold_flag_rib_i == `HoldEnable) begin
// 暂停PC,即取指地址不变
hold_flag_o = `Hold_Pc;
end else if (jtag_halt_flag_i == `HoldEnable) begin
// 暂停整条流水线
hold_flag_o = `Hold_Id;
end else begin
hold_flag_o = `Hold_None;
end
end
end
...
第3~6行,复位时赋默认值。
第8行,输出跳转地址直接等于输入跳转地址。
第9行,输出跳转标志直接等于输入跳转标志。
第11行,默认不暂停流水线。
第13、14行,对于跳转操作、来自执行阶段的暂停、来自中断模块的暂停则暂停整条流水线。
第16~18行,对于总线暂停,只需要暂停PC寄存器,让译码和执行阶段继续运行。
第19~21行,对于jtag模块暂停,则暂停整条流水线。
跳转时只需要暂停流水线一个时钟周期,但是如果是多周期指令(比如除法指令),则需要暂停流水线多个时钟周期。
总线
设想一下一个没有总线的SOC,处理器核与外设之间的连接是怎样的。可能会如下图所示:
可见,处理器核core直接与每个外设进行交互。假设一个外设有一条地址总线和一条数据总线,总共有N个外设,那么处理器核就有N条地址总线和N条数据总线,而且每增加一个外设就要修改(改动还不小)core的代码。有了总线之后(见本章开头的图2_1),处理器核只需要一条地址总线和一条数据总线,大大简化了处理器核与外设之间的连接。
目前已经有不少成熟、标准的总线,比如AMBA、wishbone、AXI等。设计CPU时大可以直接使用其中某一种,以节省开发时间。但是为了追求简单,tinyriscv并没有使用这些总线,而是自主设计了一种名为RIB(RISC-V Internal Bus)的总线。RIB总线支持多主多从连接,但是同一时刻只支持一主一从通信。RIB总线上的各个主设备之间采用固定优先级仲裁机制。
RIB总线模块所在的源文件:rtl/core/rib.v
RIB总线模块的输入输出信号如下表所示(由于各个主、从之间的信号是类似的,所以这里只列出其中一个主和一个从的信号):
序号 |
信号名 |
输入/输出 |
位宽(bits) |
说明 |
1 |
m0_addr_i |
输入 |
32 |
主设备0读写外设地址 |
2 |
m0_data_i |
输入 |
32 |
主设备0写外设数据 |
3 |
m0_data_o |
输出 |
32 |
主设备0读取到的数据 |
4 |
m0_ack_o |
输出 |
1 |
主设备0访问完成标志 |
5 |
m0_req_i |
输入 |
1 |
主设备0访问请求标志 |
6 |
m0_we_i |
输入 |
1 |
主设备0写标志 |
7 |
s0_addr_o |
输出 |
32 |
从设备0读、写地址 |
8 |
s0_data_o |
输出 |
32 |
从设备0写数据 |
9 |
s0_data_i |
输入 |
32 |
从设备0读取到的数据 |
10 |
s0_ack_i |
输入 |
1 |
从设备0访问完成标志 |
11 |
s0_req_o |
输出 |
1 |
从设备0访问请求标志 |
12 |
s0_we_o |
输出 |
1 |
从设备0写标志 |
RIB总线本质上是一个多路选择器,从多个主设备中选择其中一个来访问对应的从设备。
RIB总线地址的最高4位决定要访问的是哪一个从设备,因此最多支持16个从设备。
仲裁方式采用的类似状态机的方式来实现,代码如下所示:
...
// 主设备请求信号
assign req = {m2_req_i, m1_req_i, m0_req_i};
// 授权主设备切换
always @ (posedge clk) begin
if (rst == `RstEnable) begin
grant <= grant1;
end else begin
grant <= next_grant;
end
end
// 仲裁逻辑
// 固定优先级仲裁机制
// 优先级由高到低:主设备0,主设备2,主设备1
always @ (*) begin
if (rst == `RstEnable) begin
next_grant = grant1;
hold_flag_o = `HoldDisable;
end else begin
case (grant)
grant0: begin
if (req[0]) begin
next_grant = grant0;
hold_flag_o = `HoldEnable;
end else if (req[2]) begin
next_grant = grant2;
hold_flag_o = `HoldEnable;
end else begin
next_grant = grant1;
hold_flag_o = `HoldDisable;
end
end
grant1: begin
if (req[0]) begin
next_grant = grant0;
hold_flag_o = `HoldEnable;
end else if (req[2]) begin
next_grant = grant2;
hold_flag_o = `HoldEnable;
end else begin
next_grant = grant1;
hold_flag_o = `HoldDisable;
end
end
grant2: begin
if (req[0]) begin
next_grant = grant0;
hold_flag_o = `HoldEnable;
end else if (req[2]) begin
next_grant = grant2;
hold_flag_o = `HoldEnable;
end else begin
next_grant = grant1;
hold_flag_o = `HoldDisable;
end
end
default: begin
next_grant = grant1;
hold_flag_o = `HoldDisable;
end
endcase
end
end
...
第3行,主设备请求信号的组合。
第7~13行,切换主设备操作,默认是授权给主设备1的,即取指模块。从这里可以知道,从发出总线访问请求后,需要一个时钟周期才能完成切换。
第18~66行,通过组合逻辑电路来实现优先级仲裁。
第20行,默认授权给主设备1。
第24~35行,这是已经授权给主设备0的情况。第25、28、31行,分别对应主设备0、主设备2和主设备1的请求,通过if、else语句来实现优先级。第27、30行,主设备0和主设备2的请求需要暂停流水线,这里只需要暂停PC阶段,让译码和执行阶段继续执行。
第3647行,这是已经授权给主设备1的情况,和第2435行的操作是类似的。
第4859行,这是已经授权给主设备2的情况,和第2435行的操作是类似的。
注意:RIB总线上不同的主设备切换是需要一个时钟周期的,因此如果想要在执行阶段读取到外设的数据,则需要在译码阶段就发出总线访问请求。
中断
中断(中断返回)本质上也是一种跳转,只不过还需要附加一些读写CSR寄存器的操作。
RISC-V中断分为两种类型,一种是同步中断,即ECALL、EBREAK等指令所产生的中断,另一种是异步中断,即GPIO、UART等外设产生的中断。
对于中断模块设计,一种简单的方法就是当检测到中断(中断返回)信号时,先暂停整条流水线,设置跳转地址为中断入口地址,然后读、写必要的CSR寄存器(mstatus、mepc、mcause等),等读写完这些CSR寄存器后取消流水线暂停,这样处理器就可以从中断入口地址开始取指,进入中断服务程序。
下面看tinyriscv的中断是如何设计的。中断模块所在文件:rtl/core/clint.v
输入输出信号列表如下:
序号 |
信号名 |
输入/输出 |
位宽(bits) |
说明 |
1 |
clk |
输入 |
1 |
时钟信号 |
2 |
rst |
输入 |
1 |
复位信号 |
3 |
int_flag_i |
输入 |
8 |
外设中断信号 |
4 |
inst_i |
输入 |
32 |
指令内容 |
5 |
inst_addr_i |
输入 |
32 |
指令地址 |
6 |
hold_flag_i |
输入 |
1 |
未使用 |
7 |
data_i |
输入 |
32 |
未使用 |
8 |
csr_mtvec |
输入 |
32 |
mtvec寄存器内容 |
9 |
csr_mepc |
输入 |
32 |
mepc寄存器内容 |
10 |
csr_mstatus |
输入 |
32 |
mstatus寄存器内容 |
11 |
global_int_en_i |
输入 |
1 |
全局外设中断使能 |
12 |
hold_flag_o |
输出 |
1 |
流水线暂停标志 |
13 |
we_o |
输出 |
1 |
写使能 |
14 |
waddr_o |
输出 |
32 |
写地址 |
15 |
raddr_o |
输出 |
32 |
读地址 |
16 |
data_o |
输出 |
32 |
写数据 |
17 |
int_addr_o |
输出 |
32 |
中断入口地址 |
18 |
int_assert_o |
输出 |
1 |
中断有效标志 |
先看中断模块是怎样判断有中断信号产生的,如下代码:
...
always @ (*) begin
if (rst == `RstEnable) begin
int_state = S_INT_IDLE;
end else begin
if (inst_i == `INST_ECALL || inst_i == `INST_EBREAK) begin
int_state = S_INT_SYNC_ASSERT;
end else if (int_flag_i != `INT_NONE && global_int_en_i == `True) begin
int_state = S_INT_ASYNC_ASSERT;
end else if (inst_i == `INST_MRET) begin
int_state = S_INT_MRET;
end else begin
int_state = S_INT_IDLE;
end
end
end
...
第3~4行,复位后的状态,默认没有中断要处理。
第6~7行,判断当前指令是否是ECALL或者EBREAK指令,如果是则设置中断状态为S_INT_SYNC_ASSERT
,表示有同步中断要处理。
第8~9行,判断是否有外设中断信号产生,如果是则设置中断状态为S_INT_ASYNC_ASSERT
,表示有异步中断要处理。
第10~11行,判断当前指令是否是MRET指令,MRET指令是中断返回指令。如果是,则设置中断状态为S_INT_MRET
。
下面就根据当前的中断状态做不同处理(读写不同的CSR寄存器),代码如下:
...
always @ (posedge clk) begin
if (rst == `RstEnable) begin
csr_state <= S_CSR_IDLE;
cause <= `ZeroWord;
inst_addr <= `ZeroWord;
end else begin
case (csr_state)
S_CSR_IDLE: begin
if (int_state == S_INT_SYNC_ASSERT) begin
csr_state <= S_CSR_MEPC;
inst_addr <= inst_addr_i;
case (inst_i)
`INST_ECALL: begin
cause <= 32'd11;
end
`INST_EBREAK: begin
cause <= 32'd3;
end
default: begin
cause <= 32'd10;
end
endcase
end else if (int_state == S_INT_ASYNC_ASSERT) begin
// 定时器中断
cause <= 32'h80000004;
csr_state <= S_CSR_MEPC;
inst_addr <= inst_addr_i;
// 中断返回
end else if (int_state == S_INT_MRET) begin
csr_state <= S_CSR_MSTATUS_MRET;
end
end
S_CSR_MEPC: begin
csr_state <= S_CSR_MCAUSE;
end
S_CSR_MCAUSE: begin
csr_state <= S_CSR_MSTATUS;
end
S_CSR_MSTATUS: begin
csr_state <= S_CSR_IDLE;
end
S_CSR_MSTATUS_MRET: begin
csr_state <= S_CSR_IDLE;
end
default: begin
csr_state <= S_CSR_IDLE;
end
endcase
end
end
...
```第3~6行,CSR状态默认处于`S_CSR_IDLE`。
第1023行,当CSR处于S_CSR_IDLE时,如果中断状态为S_INT_SYNC_ASSERT,则在第11行将CSR状态设置为S_CSR_MEPC,在第12行将当前指令地址保存下来。在第1323行,根据不同的指令类型,设置不同的中断码(Exception Code),这样在中断服务程序里就可以知道当前中断发生的原因了。
第24~28行,目前tinyriscv只支持定时器这个外设中断。
第30~31行,如果是中断返回指令,则设置CSR状态为S_CSR_MSTATUS_MRET
。
第34~48行,一个时钟切换一下CSR状态。
接下来就是写CSR寄存器操作,需要根据上面的CSR状态来写。
...
// 发出中断信号前,先写几个CSR寄存器
always @ (posedge clk) begin
if (rst == `RstEnable) begin
we_o <= `WriteDisable;
waddr_o <= `ZeroWord;
data_o <= `ZeroWord;
end else begin
case (csr_state)
// 将mepc寄存器的值设为当前指令地址
S_CSR_MEPC: begin
we_o <= `WriteEnable;
waddr_o <= {20'h0, `CSR_MEPC};
data_o <= inst_addr;
end
// 写中断产生的原因
S_CSR_MCAUSE: begin
we_o <= `WriteEnable;
waddr_o <= {20'h0, `CSR_MCAUSE};
data_o <= cause;
end
// 关闭全局中断
S_CSR_MSTATUS: begin
we_o <= `WriteEnable;
waddr_o <= {20'h0, `CSR_MSTATUS};
data_o <= {csr_mstatus[31:4], 1'b0, csr_mstatus[2:0]};
end
// 中断返回
S_CSR_MSTATUS_MRET: begin
we_o <= `WriteEnable;
waddr_o <= {20'h0, `CSR_MSTATUS};
data_o <= {csr_mstatus[31:4], csr_mstatus[7], csr_mstatus[2:0]};
end
default: begin
we_o <= `WriteDisable;
waddr_o <= `ZeroWord;
data_o <= `ZeroWord;
end
endcase
end
end
...
第11~15行,写mepc寄存器。
第17~21行,写mcause寄存器。
第23~27行,关闭全局异步中断。
第29~33行,写mstatus寄存器。
最后就是发出中断信号,中断信号会进入到执行阶段。
...
// 发出中断信号给ex模块
always @ (posedge clk) begin
if (rst == `RstEnable) begin
int_assert_o <= `INT_DEASSERT;
int_addr_o <= `ZeroWord;
end else begin
// 发出中断进入信号.写完mstatus寄存器才能发
if (csr_state == S_CSR_MSTATUS) begin
int_assert_o <= `INT_ASSERT;
int_addr_o <= csr_mtvec;
// 发出中断返回信号
end else if (csr_state == S_CSR_MSTATUS_MRET) begin
int_assert_o <= `INT_ASSERT;
int_addr_o <= csr_mepc;
end else begin
int_assert_o <= `INT_DEASSERT;
int_addr_o <= `ZeroWord;
end
end
end
...
有两种情况需要发出中断信号,一种是进入中断,另一种是退出中断。
第9~12行,写完mstatus寄存器后发出中断进入信号,中断入口地址就是mtvec寄存器的值。
第13~15行,发出中断退出信号,中断退出地址就是mepc寄存器的值。
JTAG
JTAG作为一种调试接口,在处理器设计里算是比较大而且复杂、却不起眼的一个模块,绝大部分开源处理器核都没有JTAG(调试)模块。但是为了完整性,tinyriscv还是加入了JTAG模块,还单独为JTAG写了一篇文章《深入浅出RISC-V调试》,感兴趣的同学可以去看一下,这里不再单独介绍了。要明白JTAG模块的设计原理,必须先看懂RISC-V的debug spec。
RTL仿真验证
写完处理器代码后,怎么证明所写的处理器是能正确执行指令的呢?这时就需要写testbench来测试了。其实在写代码的时候就应该在头脑里进行仿真。这里并没有使用ModelSim这些软件进行仿真,而是使用了一个轻量级的iverilog和vvp工具。
在写testbench文件时,有两点需要注意的,第一点就是在testbench文件里加上读指令文件的操作:
initial begin
$readmemh ("inst.data", tinyriscv_soc_top_0.u_rom._rom);
end
第2行代码的作用就是将inst.data文件读入到rom模块里,inst.data里面的内容就是一条条指令,这样处理器开始执行时就可以从rom里取到指令。
第二点就是,在仿真期间将仿真波形dump出到某一个文件里:
initial begin
$dumpfile("tinyriscv_soc_tb.vcd");
$dumpvars(0, tinyriscv_soc_tb);
end
这样仿真波形就会被dump出到tinyriscv_soc_tb.vcd文件,使用gtkwave工具就可以查看波形了。
到这里,硬件篇的内容就结束了。
说实话,对于数字设计而言,我只是一名初学者,甚至连门都还没入,有写得不好或者不清楚的地方还请多多包涵。