其实这篇总结在上周过了p3的时候就应该写出来的,但本人苦于近一周各类事务过于繁忙,因此把上一次的给鸽了。这周过了p4,感觉事实上这两p的道理都差不多,坑点大致类似,p3踩过了几个小坑后p4也就进行得比较顺利,因此在这里将这两p的总结合在一起写一写,顺便告别单周期CPU的时代,迈步进入流水线阶段。
在本文中,您可以收获:便宜的单周期CPU搭建经验(包括胎教级别的logisim搭建操作、Verilog搭建操作以及加指令操作)、本人两次上机的真题概述、便宜的心得。但是请注意:严禁抄袭代码。
p.s. 总是在总结文里使用刃牙表情包的我……要是被不认识の同学线下开盒了,大学生涯就要结束了罢……
初见单周期CPU时,想必大家都在课件上见过这样一句话:CPU的功能是控制指令执行,过程是取指、取数、执行(运算或从内存中取数存数)。请不要将这句话当作课件套话一笑而过,事实上,这句话就是整个单周期CPU设计实验的真正诀窍。只要搭建时严格按照这一过程建立CPU框架,新加指令时以RTL描述为基准、严格按照这一过程添加或修改信号,就能够轻松地应付p3、p4的实验。
或许同学们对R型指令、I型指令、J型指令这样的字眼也很熟悉,它们也是不可忽视的一点。根据不同指令,我们能够总结出不同的[固定搭建方法],这在添加新指令时是非常有效的。为了方便记忆,我们可以把它们记成 [算术逻辑运算类]、[立即数运算类](事实上取数存数和有条件跳转的过程也就是对立即数的运算过程,只不过把结果当成了内存地址 / PC值而已)和 [无条件跳转类]。
在实验的实际搭建过程中,指令的执行过程可以大致被总结为:取指 -> (查找指令集中对应指令的RTL语言) -> 取数 -> 运算 -> (内存存取操作) -> (向寄存器写值操作) -> 计算Next PC值
下面分别给出三类型指令的固定执行方法。
下面分析CPU控制指令执行的三个过程对应的单元(你需要搭建的部分)。
取指
取数
执行
运算或者存取数据 -> 对应【算术逻辑运算单元ALU部件、数据存储器DM部件、数据扩展Ext部件】
// Ext的实现可有可无,但有了一定更方便,实现对立即数/地址的有符号、无符号扩展以及加载到高位,如果新加指令对立即数有任何的骚操作,都可以放在这里执行
大致框架已经分析结束,然后就可以直接进行单周期CPU的搭建了~(下面的内容将是Logisim和Verilog结合的)
首先请你先决定:我需要实现多少个指令?这将与各个指令控制信号的位数强相关。我的建议是:ALU控制码采用4位,数据存储器和指令存储器采用32bits * 1K的规模,扩展部件Ext采用2位。现在的你可能会对如何搭建手足无措,我认为平地起高楼,应该先从与具体信号低相关甚至无关的部件入手,比如ALU部件。那么我们先实现ALU。
ALU部件事实上是一个运算器黑盒子。输入运算数A、运算数B和ALU控制码ALUOP(选择进行何种运算),并在内部进行运算处理后,我们将希望得到的结果从Result输出。同时,为了满足Branch信号的判断要求*,我们添加一个零判断输出。有闲情雅致的话可以多做一个溢出检测考验一下自己的熟练度,但根据本人的上机经历它并没有什么用。)
*判断要求:举例说,如果我们希望实现beq指令,即从寄存器堆取出的两个数相等时跳转,那么我们只需要选择运算为[减法],如果结果为0,那么零判断输出为1,与Beq控制信号进行与运算以后决定PC是否进行有条件跳转。在课上遇到新加指令类型为[有条件跳转]时,我们都可以好好利用这个零标志位,为自己的跳转进行条件判断。
Verilog
`define AND 4'b0000
`define OR 4'b0001
`define ADD 4'b0010
`define SUB 4'b0011
`define LTU 4'b0100
`define GTU 4'b0101
`define SLL 4'b0110
`define SRL 4'b0111
module ALU(
input [31:0] A,
input [31:0] B,
input [3:0] OpCode,
output [31:0] Result,
output Zero_Sig,
output Overflow_Sig
);
reg [31 : 0] Re;
reg Overflow;
assign Zero_Sig = (Result == 32'd0) ? 1'b1 : 1'b0;
assign Result = Re;
assign Overflow_Sig = Overflow;
always@(*)begin
case(OpCode)
`AND: {Of, Re} = A & B;
`OR : {Of, Re} = A | B;
`ADD: {Of, Re} = A + B;
`SUB: {Of, Re} = A - B;
`LTU: begin
Re = (A < B) ? 32'd1 : 32'd0;
Of = 1'b0;
end
`GTU: begin
Re = (A > B) ? 32'd1 : 32'd0;
Of = 1'b0;
end
`SLL: {Overflow, Re} = (A << B[4 : 0]);
`SRL: {Overflow, Re} = (A >> B[4 : 0]);
default: begin
Re = 0;
Overflow = 0;
end
endcase
end
endmodule
下一个指令低相关的部件是指令存储器。在Logisim中,我们只要用一个只读存储器ROM指令就可以轻松完成了。如果你的指令存储器容量是32,那么取PC[6:2]为地址,如果是1K就取PC[11:2]为地址,以此类推。
在Verilog中,存储器通过这样的语句实现:reg [BitWidth - 1 : 0] Memory [Num - 1 : 0]
,比如,想要实现一个32bits * 1K的存储器,就可以通过这样的语句实现:reg [31 : 0] instrumemory [1023 : 0]
。
module InsMemory(
input [31:0] PC,
output [31:0] RD
);
reg [31 : 0] instrumemory [1023 : 0];
assign RD = instrumemory[PC[11 : 2]];
endmodule
同时,强烈建议将得到的指令在IM中按字段分好(本人在自己的实验中已实现,只是在截图中没有体现)。为免去大家查找指令集的麻烦,具体的字段分割如下图所示:
你可能会遇到的问题:
怎么往ROM里读指令?
Logisim:右键单击ROM部件,点击Load Image
选项导入机器码txt文件,切记文件头需要有v2.0 raw
。
Verilog:使用$readmemh("code.txt", instrumemory);
语句向内存中导入code.txt里存储的机器码,注意要将这个文件通过Add source的方式加入到ISE左侧的Project列表里;同时,这个操作应当通过initial块实现。
为什么地址从PC值的第2位开始呢?(同时是p4的一道思考题)
可以看到,为了解析内存调用行为:
传入的32位Address数据会被翻译成:给定的虚拟地址vAddr,决定调用指令还是数据的信号IorD -> 找到对应的物理地址pAddr,以及用于解析内存调用的高速缓存一致性算法。因此低2位在从内存中取数时会被用作访问索引IorD,决定是否访问指令或数据,并查找[AccessLength]这一字段内容中的数据;
对于Logisim,通过了p1(?我不记得了)的同学应该已经清楚其实现了,不再赘述,疯狂ctrl c + ctrl v即可。
对于Verilog,其实现要简单得多,无非是利用上面提到的语句,建立reg [31:0] Registers [31:0]
即可。但是需要注意:请额外判断0号寄存器的输出,已经观察到有同学在这里出错导致WA了。同时也要注意用Initial块帮所有寄存器初始化为0。
题外话:请各位一定要养成对[存储记忆部件]进行Initial操作的好习惯,p4上机完后竟然看到有人因为这个出错而没过,还是很可惜的。
没什么好说的,定下0扩展(无符号扩展)、有符号扩展、加载到高16位的操作码进行黑盒子操作,跟ALU的思路一模一样。可以留下一位给课上或许会出现的新加指令骚操作。
Verilog中的实现就更简单了。
module Ext(
input [15:0] Imm,
input [1:0] Ext,
output [31:0] Result
);
assign Result = (Ext == 2'b00) ? {{16{1'b0}}, Imm} :
(Ext == 2'b01) ? {{16{Imm[15]}}, Imm} :
(Ext == 2'b10) ? {Imm, {16{1'b0}}} : {{16{1'b0}}, Imm};
endmodule
至此,与指令低相关的部件都已经搭建完了。现在只剩下控制信号生成单元和数据存储器两个大部件,以及Next PC的计算和一些稀稀拉拉的MUX了。我们先来实现指令相关程度更弱的数据存储器。
前面我们可以看到,针对【立即数运算类】指令的固定操作,[送数入ALU运算]后接着的是[写结果入DM],这个”写结果“的操作表现为将ALU运算得到的结果或者GRF中取出的数作为数据存储器输入的地址A或者存入数据WD。通过查阅指令集,我们总结出规律:存入存储器的数据一般是GRF[rt],也就是GRF的RD2输出结果;而输入的地址A一般是ALU运算得到的结果。(当然,课上或许会有出现例外的指令,但这是正常指令的规律)。
对于DM内部的行为,如果只求实现基本指令,那么只用一个RAM就可以解决,思路同前面的ROM。(记得在Logisim中要将Data Interface改成Separate load)。
但如果你想要锻炼自己的搭建操作,就可以试着实现lh、lb、sh、sb等位宽不同的操作。事实上通过在指令集中查阅相应指令的RTL就可以轻松地搭建出这四个指令。在这里给出或许不太容易做出的Logisim的参考,Verilog的实现很简单,不再给出了。
这可以说是单周期CPU中搭建最为关键的一步,但在我看来,又是搭建过程中最简单的一步,只需要无脑查表、填表、搭电路即可。首先确定我们需要怎样的控制信号:
寄存器写使能RegWrite - 我想往寄存器里写东西时这个值为1
数据存储器写使能MemWrite - 我想往数据存储器里写东西时这个值为1
算术码ALUOP - 选择这个指令需要怎样的运算操作
扩展指令Ext - 选择进行哪种类型的扩展(这里务必通过查指令集的RTL保证正确性,已经见过有人在此出锅)
寄存器写入地址来源选择信号RegDst - 观察不同指令的RTL,我们发现有的结果需要写到rt寄存器,有的需要写到rd寄存器,因此需要用一个MUX来选择
寄存器写入地址来源选择信号Jal - 如果你做了Jal指令,就会发现其中有一个(GRF[31] <- PC + 4)的操作,这个时候需要选择写入地址为31号寄存器还是上面得到的rt / rd,用一个MUX来实现(强烈建议实现Jal,课上两次都出现了GRF[31] <- PC + 4的组合操作!届时只需要让该指令跟Jal共用一个选择信号即可!!)
选择写入寄存器堆的值为运算器运算结果还是存储器中取出的数的选择信号MemToReg - 想把内存中的值写入寄存器(如load指令)时为1
选择ALU的操作数B为立即数还是寄存器堆中取出的数的选择信号ALUSrc - 想跟立即数运算时取1
位宽信号BW - 如果你做了上面的lh等不同位宽指令,那么就需要一个这样的信号选择当前位宽
j、jr、beq、blez……:选择Next PC值为PC + 4还是该指令对应的跳转地址
【?】:根据课上新加指令添加的新选择信号,通常用来选择运算数 / 写入数据 / 写入地址。涉及PC运算的控制信号一般可以与上面的合并。
因此在课上得到新指令时,我们应当先分析指令的RTL,看看需要更改这些控制信号的值为什么,以及需不需要新加控制信号。
Logisim搭建小技巧
Verilog搭建说明
根据跳转指令的RTL具体描述进行选择就可以了。不过在Verilog中要千万注意初始化的问题!(Logisim中的实现很简单,用几个MUX串在一起就可以了)
module NPC(
input Clk,
input Reset,
input Branch,
input J,
input Jr,
input [31:0] imm,
input [25:0] Address,
input [31:0] PC_JR,
output [31:0] NextPC,
output [31:0] PC_4
);
reg [31 : 0] NPC;
reg [31 : 0] NPC_4;
assign NextPC = NPC;
assign PC_4 = (NPC + 4);
initial begin
NPC = 32'h0000_3000;
end
always@(posedge Clk)begin
if (Reset)begin
NPC <= 32'h00003000;
end
else if (Branch == 1'b1)begin
NPC <= ((imm << 2) + (NPC + 4));
end
else if (J == 1'b1)begin
NPC <= {{NPC_4[31 : 28]}, Address, {2{1'b0}}};
end
else if (Jr == 1'b1)begin
NPC <= PC_JR;
end
else begin
NPC <= (NPC + 4);
end
end
endmodule
至此,所有部件搭建结束。
Logisim
Verilog
将机器码文件导入IM部件中,执行测试。具体的对拍等方法讨论区已经珠玉在前,这里不赘述了。
要来力,要来力
回望初见单周期CPU时的迷茫,还是生出了不少感悟,非常希望这篇文章可以切实地帮到不知道从何建起的同学、正在经历p3、p4实验的同学以及在p3 / p4考试中不慎失误的同学!(没人看也没关系hhh)如有错误,请大家不吝赐教;如果有什么别的想知道的内容(助教问答?思考题?)也欢迎在讨论区告知。(土下座)