COD 实验汇总

这学期的计算机组成原理要求我们用Verilog实现一个简单的CPU:

  • 1-2,我们需要考虑一个简单的ALU和一个寄存器。
  • 3-6, 简单的,单周期的,简陋的,用verilog语言描述的CPU
  • 7-9, 给简单CPU引入有访存延时的真实内存。并通过IO映射的办法,访问外设。
  • 10-13, 用RISCV32的37条指令来写一个简单的CPU。

1 32x32寄存器

我们只需要一个“ 2读1写” :两个读端口 + 一个写端口。

32× 32-bit寄存器堆的输入输出端口定义如下:

端口定义

有一个小要求

  • 仅当wen=1(有效)且waddr不等于0时,才可以向waddr对应的寄存器写入wdata
`timescale 10 ns / 1 ns
`define DATA_WIDTH 32
`define ADDR_WIDTH 5
`define REG_NUM 32

module reg_file(
	input                       clk,
	input  [`ADDR_WIDTH - 1:0]  waddr,
	input  [`ADDR_WIDTH - 1:0]  raddr1,
	input  [`ADDR_WIDTH - 1:0]  raddr2,
	input                       wen,
	input  [`DATA_WIDTH - 1:0]  wdata,
	output [`DATA_WIDTH - 1:0]  rdata1,
	output [`DATA_WIDTH - 1:0]  rdata2
);
	reg [`DATA_WIDTH-1:0]register[`REG_NUM-1:0];
	always @(posedge clk) begin
		if(wen == 1 && waddr != 0)
			register[waddr] <= wdata;
		//else  ;这里是为了保证模块内部的“纯净”
		//register[0] <= 32'b0;
	end
	
	assign rdata1 = (raddr1 != 0)?register[raddr1]:(32'b0);
	assign rdata2 = (raddr2 != 0)?register[raddr2]:(32'b0);
endmodule

2 简单ALU

其端口定义如下:

ALU端口定义

各种操作须知:

定义

2.1 代码实现解释

首先是两个逻辑运算:

assign Res_and = A&B; // 直接按位与
assign Res_or = A|B; //直接按位或

接着是考虑两个逻辑结果的取舍:

assign Mid_logic = ( (Res_and &{32{~ALUop[0]}}) | (Res_or &{32{ALUop[0]}}) )&{32{~ALUop[1]}};

这里是考虑ALUop[0]的不同,进行一个位扩展+与运算。

接下来是算术运算的核心:在计算机中,只有补码和无符号数两种存储方式

2.1.1 无符号数解释

同样的,和原码做补码代替减法一样,不过对无符号数的运算不太一样。它称为取补。核心运算图如下:

unsigned

-0011的取补运算就是按位取反再加1,得到1101,我们在图上考虑这些事情,观察取补运算的核心,这样就可以很方便地得到进位和借位的判断。

我们考虑任何一个无符号数。默认箭头朝右,当作为减数的时候,就将箭头倒向左。这时候补运算的核心就凸显出来了。

直接取一个向右的无符号数与减数的真值互补即可。这时候,如果减数较小,那么其长度小于被减数,所以其补要大于被减数离10000的差距。所以取补加起来必然会产生进位。

反过来,如果减数较大,那么其补要小于差距,所以取补加起来不会产生进位。

得到无符号数做减法时,进位1为正常,否则就为借位。而做简单的加法则相反。对于算术ALUop来说,差距在ALUop[2]上,这也就完成了对CarryOut的解释。

assign CarryOut = ALUop[2]^prob_cout;

2.1.2 有符号数解释(补码)

接下来考虑有符号数,这里我们直接越过原码的思维。

切记:在理解计算机存储数字的路程上,原码只是一个小小的跳板,补码才是核心,原码时一个”多余”的东西,建议直接抛弃。

对补码的核心理解是最高位的理解,它的权值是$-2^{n-1}$。那么用同样的图可知:

2's complement

0011取补得到1101,从中可以看出补码加法的朴实:

  • 如果正数大,那么后三位进位就把负权值的高位消掉了,如果负数大就消不掉,但是可以使正值部分增加,从而让负数变大。

接下来看两种溢出情况

  • 如果两正数产生了负权值的进位,那么就必然是溢出;
  • 如果两负数的负权值位相加进位被抹去,而正值位的进位没有补上,就会得到正数,那么也属于溢出。

其上的情况都可以在图上表示出。

最后从图上可以看出取补运算移动的核心。

最终得到了:

assign Overflow = ((~A[31])&(~B[31])&Res_cal[31]&(~ALUop[2])) 
| ((A[31])&(B[31])&(~Res_cal[31])&(~ALUop[2])) 
| ((A[31])&(~B[31])&(~Res_cal[31])&(ALUop[2])) 
| ((~A[31])&(B[31])&(Res_cal[31])&(ALUop[2]));
//就是把四种情况单独拿出来

从上我们得到了取补运算的合理性,所以用朴素的选择语句:

assign {prob_cout,Res_cal} = A+( (B^{32{ALUop[2]}})+{{31{1'b0}},{ALUop[2]}} );

这里提一句和补码变化的区别;
可以看出取补运算按位取反+1得到的结果相当于1111-$x_3x_2x_1x_0+1$,相当于$1\ 0000-x_3x_2x_1x_0$
而补码运算则考虑了原码符号位的问题,还需要把符号位消去后,再按位取反再+1,所以是$10\ 0000-x_3x_2x_1x_0$

其余的信号和选择都比较好理解,从而得到以下的所有代码:

3 代码示例

`timescale 10 ns / 1 ns

`define DATA_WIDTH 32

module alu(
	input  [`DATA_WIDTH - 1:0]  A,
	input  [`DATA_WIDTH - 1:0]  B,
	input  [              2:0]  ALUop,
	output                      Overflow,
	output                      CarryOut,
	output                      Zero,
	output [`DATA_WIDTH - 1:0]  Result
);
	// TODO: Please add your logic design here
	// 由于线路过于复杂,所以用几个中间变量进行稍微的简化。
	wire [`DATA_WIDTH-1:0] Res_and,Res_or,Res_cal;
	wire [`DATA_WIDTH-1:0] Mid_logic, Mid_cal;
	wire prob_cout;

	// 之后以test为名的信号都是用来测试的,对运算优先级的考察和中间结果运算正误判断起到了积极作用。
	//wire [`DATA_WIDTH-1:0] test,test_1,test_2; 

	assign Res_and = A&B; // 直接按位与
	assign Res_or = A|B; //直接按位或
	assign {prob_cout,Res_cal} = A+( (B^{32{ALUop[2]}})+{{31{1'b0}},{ALUop[2]}} );//这里是根据ALUop的位数不同来选择是对B进行取补运算。
	
	//这里考虑溢出OF,也就是有符号数的问题。
	// 直接考察四种情况,还可以对这四种情况写标准bool式。这里就不化简了。
	//  0 + 0 = 1; 只对一个式子做解释,也就是正数+正数却得到了负数。
	//  1 + 1 = 0;
	//  1 - 0 = 0;
	//  0 - 1 = 1;
	assign Overflow = ((~A[31])&(~B[31])&Res_cal[31]&(~ALUop[2])) | ((A[31])&(B[31])&(~Res_cal[31])&(~ALUop[2])) | ((A[31])&(~B[31])&(~Res_cal[31])&(ALUop[2])) | ((~A[31])&(B[31])&(Res_cal[31])&(ALUop[2]));
	
	// 考虑进位/借位,直接观察+法中的进位即可;例外的情况是当作减法来看的情况,这种情况的溢出体现在最高位的0上。因为“不够减”。
	assign CarryOut = ALUop[2]^prob_cout;
	
	// 考虑逻辑的最终输出结果。只需要对ALUop第二位的不同做分析即可。
	assign Mid_logic = ( (Res_and &{32{~ALUop[0]}}) | (Res_or &{32{ALUop[0]}}) )&{32{~ALUop[1]}};

	// considering the logic part-> 这里只是纠错的中途步骤
	// assign test = (B^{32{ALUop[2]}}) + {{31{1'b0}},{ALUop[2]}};
	// assign test_1 =  B^{32{ALUop[2]}};
	// assign test_2 = {{31{1'b0}},{ALUop[2]}};

	// 同理,也需要考虑ALUop的第二位来判断是否有计算输出;但不同的是,还需要考虑SLT的情况,也就是一个取或,本质上是根据ALUop第三位的01判断是输出Res_cal或者单纯的SLT结果
	// 也就是(Res_cal[31]^Overflow),比大小的过程中也需要考虑Overflow,如果其为0,则只需要看高位即可,否则还需要考虑它。
	assign Mid_cal = ( (Res_cal & (  { 32{~ALUop[0]} }  )) | {{31{1'b0}},{(ALUop[0]&(Res_cal[31]^Overflow))}} ) & {32{ALUop[1]}};
	
	// 最后对两个结果进行一个选择即可。
	assign Result = (Mid_logic|Mid_cal);
	assign Zero = (~(| Result));
endmodule

3 修改ALU

更新操作如下:

对于无符号数,见ALU

alu代码如下:

......
module alu(
	......
);
	// TODO: Please add your logic design here
	// 由于线路过于复杂,所以用几个中间变量进行稍微的简化。
	wire [`DATA_WIDTH-1:0] Res_and,Res_or,Res_cal,Res_xor,Res_nor;
	wire [`DATA_WIDTH-1:0] Mid_logic, Mid_cal;
	wire Mid_comp;
	wire prob_cout;

	// 之后以test为名的信号都是用来测试的,对运算优先级的考察和中间结果运算正误判断起到了积极作用。
	//wire [`DATA_WIDTH-1:0] test,test_1,test_2; 

	assign Res_and = ( (A&B) &{32{~ALUop[0]}} ); 	// 直接按位与
	assign Res_or = (A|B) & {32{ALUop[0]}}; 		//直接按位或
	assign Res_xor = (A^B) &{32{~ALUop[0]}};	//直接按位异或
	assign Res_nor = (~(A|B)) & {32{ALUop[0]}};	//对或信号取反
	assign {prob_cout,Res_cal} = A+( (B^{32{ALUop[2] | ALUop[0]}})+{{31{1'b0}},{ALUop[2] | ALUop[0]}} );//这里是根据ALUop的位数不同来选择是对B进行取补运算。
	
	//......
	assign Overflow = ((~A[31])&(~B[31])&Res_cal[31]&(~ALUop[2])) | ((A[31])&(B[31])&(~Res_cal[31])&(~ALUop[2])) 
		| ((A[31])&(~B[31])&(~Res_cal[31])&(ALUop[2])) | ((~A[31])&(B[31])&(Res_cal[31])&(ALUop[2]));
	
	// 考虑进位/借位,直接观察+法中的进位即可;例外的情况是当作减法来看的情况,这种情况的溢出体现在最高位的0上。因为“不够减”。
	assign CarryOut = ALUop[2]^prob_cout;
	
	// 考虑逻辑的最终输出结果。只需要对ALUop第二位的不同做分析即可。现在需要对ALUop[0]和2进行分析。
	//而且也要加一个选择了;
	assign Mid_logic = ( (( Res_and|Res_or )  & {32{~ALUop[2]}} )
		| (( Res_xor|Res_nor ) & {32{ALUop[2]}} ))   & {32{~ALUop[1]}} ;

	// 同理,也需要考虑ALUop的第二位来判断是否有计算输出;
	//考虑SLT的情况,也就是一个取或,本质上是根据ALUop第三位的01判断是输出Res_cal或者单纯的comp结果
	//而对于SLTU,无符号数。有进位,就是A>=B;无进位则是A<B;
	//也就是,有进位,prob_cout=1;A>=B,输出0
	//无进位,prob_cout=0,A<B,输出1.
	// 也就是(Res_cal[31]^Overflow),比大小的过程中也需要考虑Overflow,如果其为0,则只需要看高位即可,否则还需要考虑它。
	// 比较复杂,重新弄个一个Mid_comp;
	//assign Mid_cal = ( (  Res_cal & ( { 32{~ALUop[0]} } )  ) | {{31{1'b0}},{(ALUop[0]&(Res_cal[31]^Overflow))}} ) & {32{ALUop[1]}};
	assign Mid_comp = ( (Res_cal[31]^Overflow) & ALUop[2] )
			| ( ~(prob_cout | ALUop[2]) ) ;
	......
endmodule

4 添加Shifter

由于Verilog本身移位需要考虑signed之类的麻烦,故采用通式移位器:

`timescale 10 ns / 1 ns
`define DATA_WIDTH 32
module shifter (
	input  [`DATA_WIDTH - 1:0] A,
	input  [              4:0] B,
	input  [              1:0] Shiftop,
	output [`DATA_WIDTH - 1:0] Result
);
	// TODO: Please add your logic code here
	//设置Res_left作为左移结果,Res_right作为右移结果;
	//设置Complement为移位补全位;
	wire [`DATA_WIDTH - 1:0] Res_left, Res_right;
	wire [`DATA_WIDTH - 1:0] Res_left_1,Res_left_2,Res_left_3,Res_left_4;
	wire [`DATA_WIDTH - 1:0] Res_right_1,Res_right_2,Res_right_3,Res_right_4;
	wire Complement;
	
	//左移Lop就是Shiftop[0],直接补零即可
	//右移则是在Shiftop[0]的基础上,看Shiftop[1]&A[31]后。而且,补1的唯一情况就是算术移位+最高位是1;
	assign Complement = (&Shiftop)&A[31];
	
	//最后只需要考虑左右移了。采用桶式移位器!
	//桶式移位器,可以使用一个变量名选择到底;另外,assign可以直接声明变量,不过位长会跟不同IDE的实现有关。
	assign Res_left_1 = (B[4]==1)?({{A[15:0]},{16{Complement}}}):(A);
	assign Res_left_2 = (B[3]==1)?({{Res_left_1[23:0]},{8{Complement}}}):(Res_left_1);
	assign Res_left_3 = (B[2]==1)?({{Res_left_2[27:0]},{4{Complement}}}):(Res_left_2);
	assign Res_left_4 = (B[1]==1)?({{Res_left_3[29:0]},{2{Complement}}}):(Res_left_3);
	assign Res_left = (B[0]==1)?({{Res_left_4[30:0]},{1{Complement}}}):(Res_left_4);
	
	assign Res_right_1 = (B[4]==1)?({{16{Complement}},{A[31:16]}}):(A);
	assign Res_right_2 = (B[3]==1)?({{8{Complement}},{Res_right_1[31:8]}}):(Res_right_1);
	assign Res_right_3 = (B[2]==1)?({{4{Complement}},{Res_right_2[31:4]}}):(Res_right_2);
	assign Res_right_4 = (B[1]==1)?({{2{Complement}},{Res_right_3[31:2]}}):(Res_right_3);
	assign Res_right = (B[0]==1)?({{1{Complement}},{Res_right_4[31:1]}}):(Res_right_4);
	
	assign Result = (Shiftop[1]==1)?(Res_right):(Res_left);
	
endmodule

5 设计CPU

接下来就涉及到CPU的设计了。

CPU实验的核心在以下几个方面:

  • 译码
    • 不能逐条译码,不够优雅;
  • 操作
    • 尤其要注意内存寄存器堆的读写
  • 数据通路

以下是各端口的要求:

我们首先来尝试译码:

5.1 译码

先翻译R-type指令:

从后面可以看出,大部分寄存器的位置都是固定的。

采用一个R_type的信号作为标志,之后也是类似,由此可完成大多数译码。

//1.把R-type译出来;
    //R-高度一致的合并代码
	wire R_type = (opcode == 6'b000000);//所有R_type都会产生寄存器写
	//reg_raddr1/wire [4:0] R_raddr1 = rs;
	//reg_raddr2/wire [4:0] R_raddr2 = rt;
	//RegDst/wire [4:0] R_waddr  = rd;
   
    //运算指令
    wire R_calc = R_type & func[5];//只有opcode都是0时,且func高位是1,Rtype才是1;写入alu_result
    wire [2:0] R_alu_op = {6{R_calc}} & //只有R为真,才是直接使用的alu
                        (func[2]?{func[1],!func[2],func[0]}://逻辑运算译码
                        (!func[3]?{func[1],!func[2],!func[0]}:{!func[0],!func[2],func[1]})//slt有一点特殊
                        );//
	
	//移位指令, 考虑到只有它在用移位器,于是
	wire R_shift = R_type&(func[5:3] == 3'b000);
	assign Shifter_Shiftop = func[1:0];
	wire variable = R_shift & func[2];//考虑是否使用移位变量;
	assign Shifter_A = reg_rdata2;
	assign Shifter_B = variable?reg_rdata1[4:0]:sa;
	//RF_wdata/wire [31:0] R_shift_result = Shifter_Result;//写入Shifter_Result
	
	//跳转指令
	wire R_jump = R_type&&(func[5:1] == 5'b00100);
	wire [31:0] Rj_JumpAddr = reg_rdata1;//感觉此处并不需要特别地处理func[0],可能是到后面选择的时候用吧。
	wire Rj_ret = R_jump&func[0];//写入pc_8
	//RF_wdata/wire [31:0] Rj_wdata = pc_8;
	wire R_wen = R_jump?func[0]:R_type;
	
	//mov指令
	wire R_mov = R_type&&(func[5:1] == 5'b00101);
	wire rt_Zero = ~(|reg_rdata2);//如果rt读出数据是0,那么Zero就是1
	wire mov = R_mov&(rt_Zero^func[0]);//此时就可以靠异或来得到是否mov,此处也是在后面用到的信号。
	//RF_wdata/wire [31:0] Rm_wdata = reg_rdata1;//写入reg_rdata1

需要注意的是,Jr指令是不讲wen拉高的。而jalr是拉高的。

5.2 逐条实践

这里也简单说明几条难实现的指令。

5.2.1 IL指令

lb,lh,lbu,lhu这四条指令都是读出reg,然后将他们放入内存写数据的低位。然后该怎么扩展怎么扩展。

lwl和lwr则是非同常人能设想出的指令,他在大端和小端系统完全不同。

lwl:To load the most-significant part of a word as a signed value from an unaligned memory address

从内存中取出数据,也就是载入到内存中最为重要的字节上。一般来说,我们把左边视作MSB,所以是lw-left。

考虑数据IJKL,其中I是最重要的,按照大小端:

  • big-endian:I被置于小地址;
  • little-endian:I被置于大地址。

大小端可以考虑小地址处的数据。大端则是存最重要的;小端则是存不重要的。

先考虑小端的情况:

我们看,当地址尾巴为11时,我们向低地址区读入,所以是将mem读入的全部内容载入reg。

而00时,也是向低地址区,所以只会读入最低的L进入reg,当然是当作最高位来处理的。

再考虑大端:

它是向高地址对齐,所以11只读入一位;而00读入全部。

从这个意义上来看,它载入的方向都是向着LSB的方向读入的。

所以LWL的全部阐述为:按地址访存,将当前地址到LSB地址中间的内容全部取出,作为高位放入reg中。

wire [31:0] IL_mask_rl_data = //前面是lwr,
	(ILS_Address[1:0]==2'b11 ?(opcode[2]?{reg_rdata2[31:8],IL_read_data[31:24]}:IL_read_data)
	:(ILS_Address[1:0]==2'b10 ?(opcode[2]?{reg_rdata2[31:16],IL_read_data[31:16]}:{IL_read_data[23:0],reg_rdata2[7:0]})
	:(ILS_Address[1:0]==2'b01 ?(opcode[2]?{reg_rdata2[31:24],IL_read_data[31:8]}:{IL_read_data[15:0],reg_rdata2[15:0]})
	:(opcode[2]?IL_read_data:{IL_read_data[7:0],reg_rdata2[23:0]}))));

这时考虑LWR则比较轻松,只不过方向是向着MSB方向取数。

由于是小端,11时就只读入一位作为最低位了。

那么完全的叙述为:

LWR:按地址访存,将当前地址到MSB地址的内容全部取出,作为低位放入目标reg。

5.2.2 IS指令

sb,sh,sw则是根据计算出的地址进行选择。

sb:00是mask为0001,数据也是放入最低位。需要注意的是,放入的总是内存中的低位。

wire [3:0] IS_mask_b_strb = 
	(ILS_Address[1:0]==2'b00 ?4'b0001
    	:(ILS_Address[1:0]==2'b01 ?4'b0010
    	:(ILS_Address[1:0]==2'b10 ?4'b0100:4'b1000)));
wire [31:0] IS_b_data = 
	(ILS_Address[1:0]==2'b00 ? {{24{1'b0}},reg_rdata2[7:0]}
        :(ILS_Address[1:0]==2'b01 ?{{16{1'b0}},reg_rdata2[7:0],{8{1'b0}}}
        :(ILS_Address[1:0]==2'b10 ?{{8{1'b0}},reg_rdata2[7:0],{16{1'b0}}}
        :{reg_rdata2[7:0],{24{1'b0}}})));

sh:放入的总是内存低位

wire [3:0] IS_mask_h_strb = ILS_Address[1:0]==2'b10 ?4'b1100:4'b0011;
wire [31:0] IS_h_data = (ILS_Address[1:0] == 2'b10)?{reg_rdata2[15:0],{16{1'b0}}}:{{16{1'b0}},reg_rdata2[15:0]};

swl:To store the most-significant part of a word to an unaligned memory address

同样的,我们先考虑小端,他是从reg的高位读入。然后考虑内存是向LSB读。

在这一点是,-wl都是一致的。均是从reg的最高位下手,然后向着LSB方向写入。

swl:按地址访存,将当前地址到LSB地址中间的内容全部取出,替换成reg的高位。

swr:To store the least-significant part of a word to an unaligned memory address

那么SWR的作用也容易揭晓:按地址访存,将当前地址到MSB地址中间的内容全部取出,替换成reg的低位。

wire [3:0] IS_mask = //前面是swr
	(ILS_Address[1:0]==2'b11 ?(opcode[2]?4'b1000:4'b1111)
	:(ILS_Address[1:0]==2'b10 ?(opcode[2]?4'b1100:4'b0111)
	:(ILS_Address[1:0]==2'b01 ?(opcode[2]?4'b1110:4'b0011)
	:(opcode[2]?4'b1111:4'b0001))));
wire [31:0] IS_rl_data = //前面是swr,
	(ILS_Address[1:0]==2'b11 ?(opcode[2]?{reg_rdata2[7:0],{24{1'b0}}}:reg_rdata2)
	:(ILS_Address[1:0]==2'b10 ?(opcode[2]?{reg_rdata2[15:0],{16{1'b0}}}:{{8{1'b0}},reg_rdata2[31:8]})
	:(ILS_Address[1:0]==2'b01 ?(opcode[2]?{reg_rdata2[23:0],{8{1'b0}}}:{{16{1'b0}},reg_rdata2[31:16]})
	:(opcode[2]?reg_rdata2:{{24{1'b0}},reg_rdata2[31:24]}))));

5.3 数据通路

//开始构建数据通路
assign RegDst = R_type & (R_mov?(mov?1'b1:1'b0):1'b1);//看看哪些是写入rt,哪些是写rd。还有一个0和特殊的31号。取1,取rd,考虑mov指令的选择,要在0寄存器。
//考虑实际的Write_register, 并注释掉重复写的地方。
assign RF_waddr = RegDst?rd:(//如果是1,就选rd
	            (IL || IC)?rt:(//如果是IL和IC,就选rt
	            (J_type && J_wen)?5'b11111:5'b00000));//如果是JAL,就31,反之则选择0,反正也写不进去。
	                   
//只有RI或者IB,J是直接跳转。不过还需要更细致一点,毕竟还需要Zero的保证。                   
assign Branch = (RI & RI_branch) | (IB & (IB_eq_branch | IB_gl_branch));
//考虑实际的跳转情况,按照之前的构想,我们只需要考虑branch信号和JumpAddr两个信号即可。
assign NEW_PC = Jump?pc_addr:(Branch?pc_branch:pc_4);
assign pc_branch = pc_4 + branch_addr;//IB的跳转目的是一致的。
assign pc_addr = JumpAddr;
//signle cohere/assign branch_addr = RIB_branch_addr;
assign Jump = J_type | R_jump;
assign JumpAddr = J_type?J_next_pc:Rj_JumpAddr;
	
//Mem_reg_op
assign MemWrite = opcode[5] & opcode[3];
assign MemRead = opcode[5] & (~opcode[3]);
assign RF_wdata = (R_calc || IC_wen)?alu_Result:
	      (R_shift?Shifter_Result:
	      ((Rj_ret || J_wen)?pc_8:
	      (mov?reg_rdata1:IL_wdata)));
assign RF_wen = (R_wen | J_wen | IC_wen | IL);//特判一个JR
assign Address = ILS_Address_aligned;
	//assign Write_data = IS_write_data;
	//assign Write_strb = IS_write_strb;
	
	//ALU的解码
assign ALUSrc = (R_calc | RI | IB);//R_calc, RI, IB 三类指令是R+R;IC, IL, IS三类指令是R+imm;1->2R
assign reg_raddr1 = rs; // rs的读取是注定的,纵使有的地方用的是base,不过也是用来作为标号的。其实还有SLL的不同
assign alu_A = reg_rdata1;
assign reg_raddr2 = RI?5'b00000:rt;//rt确实是注定的。
wire [31:0] Imm = (IC?extended_imm:ILS_extended_imm);
assign alu_B = ALUSrc?reg_rdata2:Imm;
assign ALUop = R_calc?R_alu_op:
	               (RI?RI_alu_op:
	               (IB?IB_alu_op:
	               (IC?IC_alu_op:3'b010)));
assign alu_ALUop = ALUop;

这就完成了单周期的全部解释。

6 多周期CPU

但上述CPU的实用性很弱,这是因为clk的周期被最长的指令操作限制了。

根据加速大多数原则,我们尝试把它分成五个部分,那么最长的部分就由小部分决定。

这便是多周期的原理,多周期+流水线的配置是十分自然的,尽管后者的实现十分复杂。

6.1 状态机

我们按如下跳转状态图来构造。

用verilog实现状态机分为三步:

  1. next跳转
  2. 状态行为
  3. 可能的状态输出
/*
 * ============================================================================
 * 1.声明状态和next跳转
 * ============================================================================
 */
    localparam IF       = 5'b00001;
    localparam ID       = 5'b00010;
    localparam EX       = 5'b00100;
    localparam MEM      = 5'b01000;
    localparam WB       = 5'b10000;
    
    reg [4:0] current_state;
    reg [4:0] next_state;
    
    always @(posedge clk)
    begin
        if(rst)
            current_state <= IF;
        else
            current_state <= next_state;
    end

这里状态采取本地参量和one-hot编码,这样对应状态只需要取一位便可以完美分别,实用性很强。

 /*
 * ===========================================================================
 * //时序部分:包含下一状态选择;PC选择
 * ===========================================================================
 */   
    wire NOP = !(|IR);//用来确认当前译码是否可读,否则直接PC+4进下一跳。
    always @(*)
    begin
        case(current_state)
            IF: begin//无条件进ID
                next_state = ID;
            end
            //译码阶段:若指令有效,则进入执行阶段。
            ID: begin
                if(NOP)  next_state = IF;
                else next_state = EX;
            end
            //执行阶段:进行分流进入三个状态。
            EX: begin
                if(RI | IB | Jp) next_state = IF;//这里没有考虑RJ指令的问题
                else if(R_type | IC | J_wen) next_state = WB;
                else next_state = MEM;
            end
            //内存:从LS中选择
            MEM: begin
                if(IL) next_state = WB;
                else next_state = IF;
            end
            //写回,无条件进IF
            WB: begin
                next_state = IF;
            end
        endcase
    end

由于我们实现的是一个十分简单的CPU,所以本来应该放入时序部分的电路部分就暂时归于组合逻辑了。

6.2 PC怎么跳?

CPU一个很重要的地方就是PC的正确,它确定了下一条指令的位置。若是错了,则整个程序很难正确。

首先,需要了解时序逻辑的非阻塞赋值:

  • 在进入always语句时计算右侧值,然后在下一次进入时,将右侧值统一赋给左侧。

所以这周期的变化,下周期开头才会变化。

    //PC 行为--------------------------------------------IMPORTANT
	always@(posedge clk)begin
	   if(current_state[0]) reg_PC <= PC;//if
	   else reg_PC <= reg_PC;
	end
	
	always@(posedge clk)begin
	   if(rst) PC <= 32'b0;
	   else if(current_state[0] & ~rst) PC <= PC + 4;//if
	   else if(current_state[2] & ~rst) PC <= NEW_PC;//ex
	   else PC <= PC;
	end

在IF阶段,已经读入指令,所以PC直接跳转+4;等到了EX阶段,然后再考虑新PC是怎样的。

reg_PC是用来存储当前指令的PC值,给JALR指令使用。

6.3 储存信息

PC的跳转引来一个问题,也就是IF过后,PC就变化了,而Instruction也会发生变化,这时就需要用一个IR寄存器来存储指令。另外一个需要存储的值就是读进来的内存值。

PC一变化,与之相连的组合逻辑模块便会变化,这就是为什么必须要存储。

	always@(posedge clk)begin//IR
	   if(current_state[0] & ~rst) IR <= Instruction;//IF
	   else IR <= IR;
	end
	
	always@(posedge clk)begin//从内存中读出的数据
	   if(current_state[3] & ~rst) mem_data <= Read_data;
	end

然后保证寄存器和内存写和读的信号只在对应状态才能拉高即可。

7 实验内容

基于之前的设计构建真实内存的访问,以及IO外设访问。

  • 硬件:改进基于理想内存的处理器,访问真实内存通路

根据握手机制等待访存延时。

  • 软件:基于访存接口,实现IO外设访问,支持字符串打印。

核心就是IO空间映射

8 握手

以指令为例。

  1. 当我需要指令时,把Inst_Req_Valid拉高,传递我需要指令的讯息。表明此时我正处于需要指令的状态。
  2. 而内存收到Inst_Req_Valid后,知道我们需要指令,于是把Inst_Req_Ready拉高,表示我已受到,开始准备。
  3. 当我得知内存已经开始准备把内存传给我时,我就把Inst_Ready拉高,表明现在我已经准备好接受正确的指令。
  4. 当内存把指令准备好,会将Inst_Valid一并拉高,表明我已经传输正确的指令。

以上的这些行为就是下图$IF\to IW\to ID$的行为。

现给出指令部分的操作。

    ...
        INIT:begin
	    next_state <= IF;
	end
//取指:等待Inst_Req_Ready
	IF: begin
        if(Inst_Req_Ready)
			next_state <= IW;
		else
			next_state <= IF;
        end
//等待指令:等待Inst_Valid
	IW:begin
		if(Inst_Valid)
			next_state <= ID;
		else
			next_state <= IW;
	end
    ...
//IF:
	assign Inst_Req_Valid = current_state[1];
//IW:
	assign Inst_Ready = (current_state[2] || current_state[0]);

9 通用异步串行收发器 UART

UART,Universal Asynchronous Receiver/Transmitter。

用于计算机与外部设备、计算机与计算机之间进行通信,俗称“串口”。

虽然看起来复杂,但实际上就是访问特定的内存空间,对于C来说要用volatile关键字放置编译器将它优化成寄存器变量。最后导致无法实时改变。

C代码如下:

int puts(const char *s)
{
	//TODO: Add your driver code here 
	int i = 0;
	while(s[i] != '\0')//是否结束
	{//传输队列是否为满
		while((*(uart+REAL_UART_STATUS) & UART_TX_FIFO_FULL));
		*(uart+REAL_UART_TX_FIFO) = s[i];
		i++;
	}
	return i;
}

10 需要译码的37条指令

指令译码是一个很重要的地方,而且架构PC和MIPS也有部分区别。

本文参考了两篇文章,一篇为翻译,一篇为讲解。

11 解释

一个需要解释的地方是,MIPS的PC指令多是对PC+4操作;而RISCV是对PC操作。

11.1 LUI与AUIPC

  1. 将立即数放置到高20位,并且末尾全部补0。这一步也可以用移位器来完成。
  2. LUI指令就存入rd寄存器;而AUIPC则是将当前指令的PC和第一步的结果相加,写入rd寄存器。

11.2 JAL与JALR

  1. 两者都将当前PC的下一条指令传给rd。
  2. 而对PC的操作则不尽相同:
    • JAL将当前PC和调整后的立即数相加。
    • JALR将寄存器里的数和符号扩展后立即数相加,末尾置0.

JAL的末尾0由PC和左移保证;而JALR强制置0。</br> 立即数调整,只需要看[]中对应的位即可。

11.3 分支指令

rs1和rs2寄存器内值比较,末尾0由移位保证。

若达成条件则和调整后的Imm相加。

11.4 Load指令

imm符号位扩展和rs1中数相加后得到地址,随后和MIPS一样的操作。

11.5 Store指令

rs1寄存器取数,载入M[R[rs2]+sign_ex(imm)]

11.6 IC指令

就那样吧,后面都差不多。

12 译码

总的来说,RISCV比MIPS要轻松一点。

因为RISCV的opcode不仅和操作有关,还和操作对象有关。比如双寄存器和单寄存器十分都是有一位为1.

13 性能对比分析

通过性能计数器显示,有部分程序的指令数减少十分显著。

  1. 取指

取指阶段,RISCV无延迟槽。由于没有加入流水线,那么也不太能分析出有什么具体变化。

  1. 译码
  • 所有高位扩展的最高位都是指令的最高位。
  • opcode十分整齐,细分指令功能用一个三位的func做了。这一点比MIPS要优秀。
  • 指令行为上看,RISCV的指令行为也比较统一,读写寄存器的地址和位置都比较固定。
  1. 执行

考虑微指令架构的话,更方便的译码应该能带来更简易的、更迅速的执行。

影响ISA性能的因素还有很多,比如对应处理器架构的优化、编译器的优化、微指令操作码的优化。不同的ISA也可能有不同的强项,由于我们只用了最粗浅的Verilog进行顶层设计实现,所以对其的讨论仅仅停留在纸上。也仅仅只能停留在纸上了。

14 DNN

DNN,也就是深度学习网络模型。

本次实验的目的是在CPU上支持乘法指令,并且用c代码完成最基本的卷积运算和池化运算。

测试工具v0.2-dev