一、前言
突发奇想打算开贴写一篇导引,或许是因为刚刚写完五级流水线有感而发吧。
由于某些众所周知的原因,你济的硬件课程设计约等于在DDL前3天从github上down一个不知对错的CPU修改个变量名再卷壹佰页报告交上去,然后用一个打表的下板bit水过验收。我无意讨论这样做正确与否, 在此开贴只是想给那些希望自己亲手去实现一个CPU却面对着数据通路无从下手的同学一些点拨和信心,毕竟用上自己的CPU这很cool不是吗。
二、从代码到数据通路
初见CPU想必是在MIPS31实验上了,你的老师大概率会扔给你一张这样的图:
我知道你面对这张图困惑不解,我也对此困惑不解,因为这根本不是用代码实现CPU该参考的东西。从github上查阅往届的代码会发现,大多数的代码都是根据这张图一个模块一个模块的实现过去(e.g. https://github.com/L-I-M-I-T/CO_MIPS31),而这在我看来完全没有必要。
现在忘掉这张图,回顾一下什么是时序逻辑电路 —— 一种次态的输出只与当前输入和当前状态有关的电路。对于我们实现的简单CPU而言,没有外部输入,只有此时刻的状态和下一时刻的状态。具体而言,就是根据当前寄存器和内存的状态决定下一时刻寄存器和内存的状态。
考虑一个只有PC和寄存器a、内存b的简单CPU,他支持三条指令1 和 2,3,指令1的作用是让寄存器a变为0,指令2的作用是让寄存器a加上1,指令3的作用是让内存b等于内存a,我们可以很轻松的写出其代码:
module cpu(input clk);
reg [31:0] pc;
reg [31:0] inst = 取指令(pc);
reg [31:0] a, b;
always@(posedge clk)
pc <= pc+ 1;
always @(posedge clk)
if(inst == 1)
a <= 0;
else if(inst == 2)
a <= a + 1;
else if(inst == 3)
b <= a;
endmodule
这就是你所要写的CPU的一个雏形了。而为了操作寄存器,我们需要寄存器堆;为了操作内存,我们需要指令存储器和数据存储器;为了实现多种运算,我们需要ALU,这就是你需要的所有模块。现在让我们来看看我们的CPU变成了什么样:
module cpu(input clk);
reg [31:0] pc;
always@(posedge clk)
pc <= pc + 1;
wire [31:0] inst;
指令存储器 imem(
输入pc,
输出取出的指令inst
);
寄存器堆 reg(
输入 reg_raddr 作为读取地址
输入 reg_rena 作为是否需要读取的信号
输出 reg_rdata 作为读取的数据
输入 reg_waddr 作为写入地址
输入 reg_wdata 作为写入的数据
输入 reg_wena 作为是否需要写入的信号
);
数据存储器 dmem(
输入 dmem_raddr 作为读取地址
输入 dmem_rena 作为是否需要读取的信号
输出 dmem_rdata 作为读取的数据
输入 dmem_waddr 作为写入地址
输入 dmem_wdata 作为写入的数据
输入 dmem_wena 作为是否需要写入的信号
);
运算单元 alu(
输入 alua 作为操作数a
输入 alub 作为操作数b
输入 aluc 作为操作指令
输出 aluo 作为结果
输出 flags 作为标志
);
endmodule
这些代码是固定的,现在想实现我们的cpu就变成了如何控制这些变量让其变成我们想要的形态了,这里先简单地写指令1(实现的方法有很多,我建议新手使用@lllbbbyyy的方法,采用三态门设计方式,这样能让你更快上手,同时理解为什么设计很多教程设计CPU上手就用excel):
wire inst1 = inst == 1; 取出指令1
//指令1要做a <= 0:
assign reg_wena=inst1 ? 1 : 1'bz;//如果不是,必须要拉高阻
assign reg_waddr=inst1 ? 寄存器a : 5'bz;//打开向寄存器a的写入通道
assign reg_wdata=inst1 ? 0 : 32'bz;//写入一个0
同样的方法,我们就可以一条接一条将我们的指令实现,例如指令2:
wire inst2 = inst == 2; 取出指令2
//指令2要做a <= a + 1:
//先把a读取出来,结果在reg_raddr中:
assign reg_rena=inst2 ? 1 : 1'bz;
assign reg_raddr=inst2 ? 寄存器a : 5'bz;
//再进行加法 a + 1,结果在aluo中:
assign alua=inst2 ? reg_rdata : 32'bz;
assign alub=inst2 ? 1 : 32'bz;
assign aluc=加法;
//最后写回到a中:
assign reg_wena=inst2 ? 1 : 1'bz;
assign reg_waddr=inst2 ? 寄存器a : 5'bz;
assign reg_wdata=inst2 ? aluo : 32'bz;
指令3:
wire inst3 = inst == 3; 取出指令3
//指令3要做b <= a:
assign reg_rena=inst3 ? 1 : 1'bz;//如果不是,必须要拉高阻
assign reg_raddr=inst3 ? 寄存器a : 5'bz; //打开寄存器a的读取通道
assign dmem_wena=inst3 ? 1 : 1'bz;
assign dmem_waddr=inst3 ? 寄存器b : 5'bz; //打开内存b的写入通道
assign dmem_wdata=inst3 ? reg_rdata : 32'bz; //向其中写入读取的寄存器a的数据
这样子重复31次,你就完成了你的MIPS31条CPU(当然,你还需要考虑到分支跳转指令的对PC寄存器的修改),当然不可忽视的是这种方法使用了大量的三态门造成了额外的开销,为此你需要考虑如何才能避免使用三态门,也就是枚举每一个命令可能出现的情况,这里以reg_rena为例。
//reg_rena 只被指令2和指令3用到:
assign reg_rena = inst2 | inst3 ? 1 : 0;
更复杂一点的呢?不妨假设对每一条指令1-4,我们需要将信号c设为1-4:
assign signal_c = inst1 ? 1 :
inst2 ? 2 :
inst3 ? 3 :
inst4 ? 4 :
0; //default
这也就是最开始那张图中,那么多多路选择器的来源了。当你写到这到儿你就会发现那张复杂的数据通路图是哪里来的——是先有的CPU设计,后有的数据通路图!进一步而言,我们谈CPU设计其实也就是在谈该给这些信号赋哪些值,因此进阶的我们不需要实际编写代码,只需要在excel中画出一张类似这样的图,就等价于写出了一个CPU:(这里摸了一张图,实际不需要考虑ext)
以ALUB为例,1-16号命令使用Rt作为操作数,17-26号命令使用Ext16作为操作数,按我们的法子来设计会变为:
assign alub = add | ... | sra ? Rt :
addi | ... | Sw ? Ext16 : 0;
绝大部分人的设计里会把这两者提取出来,重新设计为一个二位的MUX信号,起一个MUX5或者MUX8的名称,再放进control模块中,反而让人不知所云了,我的观点是即使将其提取出来,也应在命名上保留这个多路选择器的真实信号含义。
当你熟练的时候,你会发现其实你只需要给出一个这样的数据流向,你就知道有哪些信号应当被设计了:
ADD: reg[rs] + reg[rt] -> reg[rd]
因此你会发现大佬的教程中(e.g. GitHub - 4x10msv/MIPS54SP-Lifesaver) 上来就是一个excel,然后开始编排各段的数据流向,其实是省略了上述这些步骤了。
谈到这里,想必你已经对CPU有一个基本的设计概念了,当然MIPS的CPU远不止于此,你所需要考虑的还有很多,包括如何将C语言变成汇编,如何将汇编变成CPU可用的输入,如何使用MARS,如何进行调试和指令验证等等。这里推荐@skyleaworlder的文章:从 100 开始的 Mars Vivado CPU31 小实验 | 遐想的空中宫殿 作为你的下一站,有关C语言编译的部分可以使用@lingbai-kong的C转mips编译器 https://github.com/lingbai-kong/C-like-compiler ,本篇文章就谈到这里了呀。
另外提醒一点,在你学习数字逻辑时一般都会采用上升沿触发的方式,但是在CPU设计中往年流传下来的设计惯例是状态存储器(PC,寄存器,内存等)采用下降沿触发,其他只服务于当回合的临时逻辑变量(比如采用时序逻辑的方式生成alua)采用上升沿触发。