背景介绍
EXU主要负责指令的具体执行,一条指令的生命周期,从IFU取指令开始,历经指令解码,执行,写回,访存五个步骤,这其中,除首尾两个步骤由其他模块负责之外,其余步骤均有EXU负责完成。以一个常见的加法指令ADD t5, ra, sp
为例,EXU在完成该指令的执行需要完成如下任务:
- 指令解码,即完成指令的识别,获取该指令的相关信息,例如:
- 是否是合法指令:
ADD
属于合法指令。 - 属于什么指令:
ADD
属于ALU
类,即算术运算类指令 - 是否有立即数参与执行:此处没有
- 是否需要源操作数寄存器:需要,分别需要寄存器
ra
和sp
,对应索引值为1和2(依据riscv-spec,chpt 26,P137) - 是否需要目的操作寄存器:需要,
t5
,索引值(index)是31
- 是否是合法指令:
- 从寄存器文件(regfile)模块获取对应的源操作数,此处需要用到关于源操作数寄存器的相关信息
- 判断依赖关系,在多级流水线的CPU里,如果当前指令需要某个操作数,且该操作数即将被更新(但是此时还没有完成更新),则表明当前指令与之前的历史指令存在RAW(read after write)依赖关系。依赖关系的判定和解除属于EXU设计的重点之一,由于本项目设计的EXU比较简单,基本不涉及依赖关系的判断。
- 指令执行,即执行具体的指令操作
- 结果写回,依据指令的目的操作数信息,将指令执行的结果写回到具体的寄存器中,或者将结果保存到存储器里。
基于上述分析,一个最简单的EXU就呼之欲出了。
有请EXU登台
要实现一条简单的ADD
指令,需要以下几个EXU子模块的协同配合:
- exu_decode:指令解码模块,负责解析指令。
- exu_regfile:寄存器文件模块,负责保存寄存器相关,源操作数需要从此模块读出,结果需要写回到此模块。
- exu_alu:指令执行模块,包含具体的加法器执行单元。
- exu_wbck:结果写回模块,负责结果写回
基于上述划分标准,以下将详细介绍各个模块的设计细节。
指令译码
要说明译码的过程,我们需要从编译的dump文件中找到上述例子中的指令所对应的指令码,如下图所示:
[此处应该有图,但是上传失败了]
上图中,以冒号和空格为分界,第一部分是该指令所在的程序在内存中的地址,即PC。第二部分是该指令的指令码,第三部分是指令的具体内容,即汇编指令。
另一方面,从riscv-spec(定义了riscv指令集的基本框架)上我们发现了,一条有效的ADD指令,必须符合如下图所示的格式:
[此处应该有图,但是上传失败了]
该格式从左到右,左侧是最高位,bit31,右侧是最低位,bit0。每一个分界线为一个部分,都有各自的含义,具体来讲就是:
指令码的第一部分,即最高的7bit,CODE[31:25],func7部分,必须为0000000
指令码的第二部分,即中间的第一个5bit,CODE[24:20],指定为源操作数寄存器2的索引,可以为任意值
指令码的第三部分,即中间的第二个5bit,CODE[19:15],指定为源操作数寄存器1的索引,可以为任意值
指令码的第四部分,即中间的3bit,CODE[14:12],func3部分,必须为000
指令码的第五部分,即中间的第三个5bit,CODE[11:7],指定为目的寄存器的索引,可以为任意值
指令码的第六部分,即最后的7bit,CODE[6:0],属于opcode部分,必须为0110011
由此可知,只要某个指令码,符合上述六个部分的描述,就被判断为一条ADD指令。指令码和具体的指令是一一对应的关系,不存在两条指令对应同一个指令码的情况,也不存在一个指令有两种指令码的情况。当然,某些伪指令由设计者自定义,因此会存在不同指令码对应某一个伪指令的情况,但是不属于上述讨论情形。指令译码的实现代码如下
////////////////////////////
// 指令译码实现,仅支持ADD指令
module xf100_exu_decode (
// 与上层接口,此处是指与IFU的接口
input [31:0] i_dec_pc,
input [31:0] i_dec_instr,
// 指令的译码信息,输送到下游模块
output o_dec_add,
output [4:0] o_dec_rs1_idx,
output [4:0] o_dec_rs2_idx,
output [4:0] o_dec_rd_idx,
output o_dec_rd_wen
);
// 根据指令码的第1、4、6部分,判定是否属于ADD指令
wire add_op = (i_dec_instr[6:0] == 7'b0110011)
& (i_dec_instr[14:12] == 3'b000)
& (i_dec_instr[31:25] == 7'b0000000)
;
// 根据指令的第2、3、5部分,解析出关于源操作数和目的操作数的信息
wire [4:0] rs1_idx = i_dec_instr[19:15];
wire [4:0] rs2_idx = i_dec_instr[24:20];
wire [4:0] rd_idx = i_dec_instr[11:7];
//将解码结果输出到下游模块
assign o_dec_add = add_op;
assign o_dec_rs1_idx = rs1_idx;
assign o_dec_rs2_idx = rs2_idx;
assign o_dec_rd_idx = rd_idx ;
assign o_dec_rd_wen = add_op;
endmodule
寄存器文件
当某个指令需要读写操作数时,就需要访问寄存器文件。访问的依据就是上游译码模块给出的指令信息。其工作原理可以概括为,从指定的寄存器(由源操作数寄存器编号指定)中读取数据,或者将执行结果写到指定的寄存器(由目的寄存器编号指定)。因此就是实现一个二维数组。寄存器文件的实现代码如下:
////////////////////////////
// 寄存器文件模块实现(局部)
module xf100_exu_regfile (
// 读寄存器接口
input [4:0] i_rf_rs1_idx,
input [4:0] i_rf_rs2_idx,
// 写寄存器接口
input i_rf_wen,
input [4:0] i_rf_rdidx,
input [31:0] i_rf_wdat,
//读出的源操作数
output [31:0] o_rf_rs1,
output [31:0] o_rf_rs2,
input clk ,
input rst_n
);
// 寄存器文件,二维数组形式,每个32bit的数都表示一个寄存器,按照riscv-spec定义,总共有32个寄存器。
wire [31:0] rf_r[31:0];
// 一个具体的寄存器的实现,其他寄存器可按编号规律展开即可。
wire rf_wen_0 = i_rf_wen & (i_rf_rdidx == 0);
xf100_gnrl_dfflr #(32) reg0_dfflr (clk, rst_n, rf_wen_0, i_rf_wdat, rf_r[0]);
// 二维数组的直接输出
assign o_rf_rs1 = rf_r[i_rf_rs1_idx];
assign o_rf_rs2 = rf_r[i_rf_rs2_idx];
endmodule
算术运算
当加法指令获取到了必需的源操作数和目的操作数之后,就可以进行运算了。此处的加法器由工具指定,代码设计较简单,如下所示:
////////////////////////////
// 简单的算术运算单元,包含加法器
module xf100_exu_alu (
// 译码模块输入的指令写回信息
input [4:0] i_alu_rd_idx,
input i_alu_rd_wen,
// 寄存器文件模块输入的源操作数值
input [31:0] i_alu_rs1,
input [31:0] i_alu_rs2,
// 译码模块输入的译码信息
input i_add_op,
// 指令写回相关信息,需要输送到写回控制模块
output [31:0] o_alu_wdat,
output [4:0] o_alu_rd_idx,
output o_alu_rd_wen,
input clk ,
input rst_n
);
// 操作数在进行运算之前,首先用门控电路控制一下,防止不必要的翻转,降低功耗【但同时会有时序负担】
wire [31:0] adder_rs1 = {32{i_add_op}} & i_alu_rs1;
wire [32:0] adder_rs2 = {32{i_add_op}} & i_alu_rs2;
// 直接的加法器,简单,粗暴
assign o_alu_wdat = adder_rs1 + adder_rs2;
// 写回所必需的信息,直接转发
assign o_alu_rd_idx = i_alu_rd_idx;
assign o_alu_rd_wen = i_alu_rd_wen;
endmodule
指令写回
加法的结果是必然要保存到寄存器的,这样才能将结果提供给后续其他指令使用。通过分析寄存器文件的设计代码可知,要将结果写回到寄存器,必需要有以下三个信息:
是否要写回寄存器:对于加法器,当然要写回,这个在译码模块就已经知道了。
要写到哪个寄存器:这个在译码模块也已经知道了
要写什么数据:这个在指令执行时就知道了
因此,在本项目中,加法指令的写回,就是在计算完成的时候,将计算结果写回指定的寄存器中。由于相关信息在指令执行的当拍就已经知晓,因此直接转发到寄存器文件模块即可。实现代码如下:
////////////////////////////
// 写回控制模块
module xf100_exu_wbck (
// 写回所需要的信息,来自上游模块
input [4:0] i_alu_wb_idx,
input i_alu_wb_en,
input [31:0] i_alu_wb_dat,
// 写回控制信号,输出到下游模块
output [31:0] o_wbck_rdidx,
output o_wbck_wen,
output [4:0] o_wbck_wdat,
input clk ,
input rst_n
);
// 直接转发
assign o_wbck_rdidx = i_alu_wb_idx;
assign o_wbck_wen = i_alu_wb_en ;
assign o_wbck_wdat = i_alu_wb_dat;
endmodule
一个又一个的顶层
当EXU相关的子模块设计完成后,需要将其按指令执行的顺序,依次例化起来(也就是连起来)。
////////////////////////////
// 一个简单的exu顶层
module xf100_exu (
input i_exu_valid,
output i_exu_ready,
input [31:0] i_exu_pc ,
input [31:0] i_exu_instr,
input clk ,
input rst_n
);
assign i_exu_ready = 1'b1;
///////////////////////////////
// decode the input instr.
wire dec_add ;
wire [4:0] dec_rs1_idx;
wire [4:0] dec_rs2_idx;
wire [4:0] dec_rd_idx ;
wire dec_rd_wen ;
xf100_exu_decode u_xf100_decode (
.i_dec_pc (i_exu_pc ),
.i_dec_instr (i_exu_instr),
.o_dec_add (dec_add ),
.o_dec_rs1_idx (dec_rs1_idx),
.o_dec_rs2_idx (dec_rs2_idx),
.o_dec_rd_idx (dec_rd_idx ),
.o_dec_rd_wen (dec_rd_wen )
);
///////////////////////////////
// get integer-reg from regfile
wire [31:0] rf_rs1;
wire [31:0] rf_rs2;
wire rf_wr_en ;
wire [4:0] rf_wr_idx;
wire [31:0] rf_wr_dat;
xf100_exu_regfile u_xf100_exu_rf (
.i_rf_rs1_idx(dec_rs1_idx),
.i_rf_rs2_idx(dec_rs2_idx),
.i_rf_wen (rf_wr_en ),
.i_rf_rdidx (rf_wr_idx),
.i_rf_wdat (rf_wr_dat),
.o_rf_rs1 (rf_rs1),
.o_rf_rs2 (rf_rs2),
.clk (clk ),
.rst_n (rst_n)
);
///////////////////////////////
// excute the input instr
wire [31:0] alu_o_wdat;
wire [4:0] alu_o_rd_idx;
wire alu_o_rd_wen;
xf100_exu_alu u_xf100_exu_alu (
.i_alu_rd_idx(dec_rd_idx),
.i_alu_rd_wen(dec_rd_wen),
.i_alu_rs1 (rf_rs1),
.i_alu_rs2 (rf_rs2),
.i_add_op (dec_add),
.o_alu_wdat ( alu_o_wdat),
.o_alu_rd_idx(alu_o_rd_idx),
.o_alu_rd_wen(alu_o_rd_wen),
.clk (clk ),
.rst_n (rst_n)
);
///////////////////////////////
// write back the excuted result.
xf100_exu_wbck u_xf100_exu_wbck (
.i_alu_wb_idx(alu_o_rd_idx),
.i_alu_wb_en (alu_o_rd_wen),
.i_alu_wb_dat(alu_o_wdat),
.o_wbck_rdidx(rf_wr_idx),
.o_wbck_wen (rf_wr_en ),
.o_wbck_wdat (rf_wr_dat),
.clk (clk ),
.rst_n (rst_n)
);
endmodule
以及,core顶层:
module xf100_core
(
input clk ,
input rst_n ,
output inst_ram_cs ,
output inst_ram_wen ,
output [31:0] inst_ram_din ,
output [13:0] inst_ram_addr ,
input [31:0] inst_ram_dout
);
// 连接exu模块和ifu模块的信号
wire ifu_o_exu_valid;
wire ifu_o_exu_ready;
wire [31:0] ifu_o_exu_pc;
wire [31:0] ifu_o_exu_instr;
xf100_ifu u_xf100_ifu (
.o_ifu_exu_valid(ifu_o_exu_valid),
.o_ifu_exu_ready(ifu_o_exu_ready),
.o_ifu_exu_pc (ifu_o_exu_pc ),
.o_ifu_exu_instr(ifu_o_exu_instr),
.ifu2ram_cs (inst_ram_cs ),
.ifu2ram_wen (inst_ram_wen ),
.ifu2ram_din (inst_ram_dout),
.ifu2ram_addr(inst_ram_addr),
.clk (clk),
.rst_n(rst_n)
);
xf100_exu u_xf100_ex (
.i_exu_valid(ifu_o_exu_valid),
.i_exu_ready(ifu_o_exu_ready),
.i_exu_pc (ifu_o_exu_pc ),
.i_exu_instr(ifu_o_exu_instr),
.clk (clk),
.rst_n(rst_n)
);
endmodule
当一切准备就绪,就是仿真开启的时刻。跑起来吧......
仿真结果及分析
仿真环境不需要更新,直接在之前的基础上运行即可。会看到如下波形:
[此处应该有图,但是上传失败了]
上图中,当PC(波形图第一行)指示为80000294
时,对于指令码为00208f33
的指令,指令译码模块将其识别为ADD指令,并且识别出该指令需要读取1号和2号寄存器中的数据作为源操作数,由于当前寄存器文件被初始化后未进行任何写操作,因此这两个源操作数都是0,所以加法器的执行结果也是0。加法指令需要将结果写回,目的寄存器是31号寄存器,在加法指令执行的同一周期,其结果被写回到指定寄存器中,整个设计符合预期。
下一步,我们将继续实现其他指令,比如第一条分支跳转指令背后的故事。
总结一下
EXU模块涉及指令的具体执行,按指令执行的一般流程,分为译码,执行,写回以及访存四个步骤,每个步骤由对应的模块完成特定的功能,这是设计模块划分的原则。
EXU的设计比较繁杂,涉及空泡(buble)的消除,依赖关系的判断与解除,数据Fwding的控制,写回的仲裁以及中断异常等等。由于本项目立意就是一个极简版本的CPU,因此或主动或被动的避免设计这些复杂的方面,旨在快速建立起CPU设计框架,降低设计门槛。
没了。
同系列文章首发于微信公众号:ICLiker,愿逢有缘人。