单周期CPU搭建的细节记录
固定开场
你好!本文章为该博客的第1篇文章!
本文章将会记录我自己在尝试自行搭建第一个基于MIPS-C指令集架构(不包含特权和异常处理指令)产生的心得!
由于搭建的时候几乎完全是在闭门造车,所以里面的内容可能不正确/不严谨/难扩展/…,暂且请读者自行甄别内容好坏。
理理思路——怎样细化工作?
首先,问一个问题:我们要干什么?
当然,我们的回答可以是搭建一个单周期的CPU。
但是这个回答不够具体,我们不能通过这个回答搞清楚我们如何搭一个CPU。
所以我们根据理论课上的内容,添加一个简单的描述:我们希望把一些模块组合连接起来,搭成一个单周期的CPU。
那么,我们通过这个描述,对这个问题拆开一下,变成这样三个问题:
- 我们需要用到哪些模块构建这个CPU?
- 模块内是长什么样子的?
- 这些模块之间是如何连接的?
对于第一个问题,我们可以很快的用课上知识回答出绝大部分需要的元件:我们需要一个ALU用来计算,需要一块内存(或者两块,一块存指令,另一块存内存)保存数据,需要一些寄存器保存数据和数据交换,需要一个主要的控制器来控制数据的通断。
但是,对于剩下两个问题,如果我们不去关注自己要实现哪些指令的话,回答起来是有些困难的。所以,我们需要先对要实现的指令分个类,总结一些特征,才能回答剩下两个问题。
以下,文章就会按照“分类和描述指令-定义模块端口-实现,测试和连接模块”的顺序依次展开。
分类和描述指令
首先我们要面对一个问题:如何给指令分类?
实际上,课程组给的MIPS-C指令集手册第7页有一个指令图,看懂这张图会对指令的分类和之后控制器的实现十分清晰。
我们考虑执行一条指令的基本流程:取指令-译码-执行指令-读取-写回结果。其中,为了方便译码,MIPS指令集只有三种指令格式:R型指令,I型指令和J型指令。而在之后的三个过程中,按照指令作用的不同,其数据通路会有较大的差别,所以按照通路还可以分为加载,保存,R-R运算(除乘除指令),乘除指令,R-I运算,分支,J型跳转,R型跳转,传输这九种指令。
按照指令格式考虑,其具体格式用Verilog表示如下:
对于R型指令而言:
1 | wire [31:0] inst; // 指令 |
对于I型指令而言:
1 | wire [31:0] inst; // 指令 |
对于J型指令而言:
1 | wire [31:0] inst; // 指令 |
按照指令功能分类,可以列表如下:
功能 | 指令类型 | 特征(opcode[,funct]) | 包含指令 | 执行指令的具体流程 |
---|---|---|---|---|
加载 | I | 100xxx | LB,LBU,LH,LHU,LW | 从通用寄存器读取数据,从指令中读取立即数并扩展,经过ALU相加得到地址,在内存中读取数据后写入到通用寄存器中 |
保存 | I | 101xxx | SB,SH,SW | 从通用寄存器读取数据,从指令中读取立即数并扩展,经过ALU相加得到地址,根据该地址向内存中写入数据(考虑到我们设计的内存位宽是32bit的,在存储的时候我们可能需要从内存读原数据,然后只修改其中一部分存回去) |
R-R运算 | R | 0,000xxx/100xxx/101xxx | ADD,ADDU,SUB,SUBU,SLT,SLTU,SLL,SRL,SRA,SLLV,SRLV,SRAV,AND,OR,XOR,NOR | 从通用寄存器中读取数据(对于SLL,SRL,SRA,还需要从指令中读取一个s作为立即数),经过ALU运算得到结果,结果写入通用寄存器中 |
乘除 | R | 0,011xxx | MULT,MULTU,DIV,DIVU | 从通用寄存器中读取数据,经过ALU运算得到结果,结果写入HI和LO寄存器中 |
R-I运算 | I | 001xxx | ADDI,ADDIU,ANDI,ORI,XORI,LUI,SLTI,SLTIU | 从通用寄存器读取数据,从指令中读取立即数并扩展,经过ALU运算得到结果,结果写入通用寄存器中 |
分支 | I | 000001/000004~000007 | BEQ,BNE,BLEZ,BGTZ,BLTZ,BGEZ | 从通用寄存器读取数据,经过ALU运算得到结果后,如果结果满足某种条件,则修改将要写入PC寄存器的值,否则不对其进行修改 |
J型跳转 | J | 000002/000003 | J,JAL | 从指令读取数据并扩展,将PC寄存器的值修改为该数据,对于某些指令而言还需要将当前PC寄存器的值写入到通用寄存器中 |
R型跳转 | R | 0,001000/001001 | JR,JALR | 从通用寄存器读取数据,将PC寄存器的值修改为该数据,对于某些指令而言还需要将当前PC寄存器的值写入到通用寄存器中 |
传输 | R | 0,010xxx | MFHI,MFLO,MTHI,MTLO | 从HI寄存器或LO寄存器读数据写入通用寄存器中,或从通用寄存器读数据写入HI寄存器或LO寄存器中 |
据此,我们可以大致分析出来一定会用到的模块:PC寄存器,通用寄存器组,HI寄存器,LO寄存器,指令存储器,数据存储器,ALU,主控制器。
从这个表格里,我们能看到每种指令大概都做了些什么,但是其中也不乏出现诸如”对于某些指令而言额外做些什么“的表述,并且对于单个模块来说,其何时被读取,何时被写入也在多种指令里被涉及。所以对于单个模块来说,输入来源可能是不确定的,也就需要各种各样的选择器来约束输入。同时,对于一些指令来说,我们必须设置一些独立于主控制器的控制器来控制输入输出。
对于一些需要对操作数进行扩展的操作,我们需要根据指令判断需要如何进行扩展,所以还有一些扩展器。
同时,考虑到Verilog上并没有什么现成的除法器,我们还需要单独实现一个单周期除法器(分有符号和无符号两种,但是有符号除法器可以用无符号除法器改装出来)。
(尽管单周期内的乘法器和除法器速度低的吓人,但是当时做的时候只考虑了”做出来“,所以还是设计了这样两个东西)
大体来说就是这些,接下来我们挑一些模块展开说明。
定义模块接口
存储模块的读写
对于随意一个存储模块,我们都希望它支持读操作和写操作。具体来说:
- 对于读操作,我们希望有一个input指示此时是否读取,有一个input输入读取的地址,有一个output输出此时被读取的数据。
- 对于写操作,我们希望有一个input指示此时是否写入,有一个input输入写入的地址,有一个input输入此时写入的数据。
所以此时我们可以列出这样一个表格:
问题: | 1.Read Enable? | 2.Read Address? | 3.Write Enable? | 4.Write Address? | 5.Write Data? |
---|---|---|---|---|---|
指令存储器 | 任意时刻 | PC | 不可写入 | / | / |
数据存储器 | 加载,保存 | 从ALU获取 | 保存 | 从ALU获取 | 从通用寄存器组获取(如果该内存位宽为32位的话,可能需要考虑写入的时候依赖该位置原数据的值) |
PC寄存器 | 任意时刻 | 仅单元 | 任意时刻 | 仅单元 | 默认为PC+4,分支时根据ALU结果为PC+4或PC+4+BRANCH_OFFSET,J型跳转时从指令和PC高位获取,R型跳转时从通用寄存器组获取 |
HI寄存器 | 任意时刻(或仅mfhi) | 仅单元 | 乘除/mthi | 仅单元 | 乘除时从ALU获取,mthi时从通用寄存器组获取 |
LO寄存器 | 任意时刻(或仅mflo) | 仅单元 | 乘除/mtlo | 仅单元 | 乘除时从ALU获取,mtlo时从通用寄存器组获取 |
通用寄存器组 | 任意时刻 | 从指令获取 | 加载/R-R运算/R-I运算/jal,jalr,mfhi,mflo | 从指令获取 | 加载时从数据存储器获取,R-R运算/R-I运算时从ALU获取,jal/jalr时为PC+4,mfhi时从HI寄存器获取,mflo时从LO寄存器获取 |
根据这个表格,我们就可以设计出对应的存储单元,以及相对应的写入数据的选择器。
组合逻辑模块
这里说到的主要是ALU,控制器,以及一个单独针对分支指令的选择器(分支指令是否能够跳转成功依赖于ALU的计算结果是否符合条件,需要一个额外的选择器来干活)。
ALU
对于ALU,我们实际上只需要知道参与运算的两个数是多少,需要执行的指令是什么,输出也只有一个结果(但是对于乘除来说,需要单独输出两个结果到HI寄存器和LO寄存器里)。所以我们只需要定义三个input:执行的指令,操作数1,操作数2,以及三个output:运算结果,写入HI的结果,写入LO的结果。
以及,由于ALU要进行的运算种类实际上可以远少于指令个数(如所有分支指令都只需要进行减法操作,SLL和SLLV都是逻辑左移的结果),所以我们可以搞一个针对ALU的解码器,对opcode和funct再解码出需要的运算操作。
然而,ALU的操作数既可能来自寄存器里的值,也可能来自指令,因此我们还需要列一个表格:
指令 | 操作数1 | 操作数2 | 目标寄存器 |
---|---|---|---|
JAL | 无(来自指令) | 无(0) | $ra($31) |
SLL/SRL/SRA | 指令里的s(5位,需要做零扩展) | 寄存器rt的值 | rd |
其他用到ALU的R型指令 | 寄存器rs的值 | 寄存器rt的值 | rd |
非BEQ和BNE的Branch指令 | 寄存器rs的值 | 0 | 无(PC) |
LUI | 寄存器rs的值(0) | 指令里的立即数(imm放在高16位,0放在低16位) | rt |
ANDI,ORI,XORI,LBU,LHU | 寄存器rs的值 | 指令里的立即数(零扩展) | rt |
其他用到ALU的I型指令 | 寄存器rs的值 | 指令里的立即数(符号扩展) | rt |
根据这个表格,我们可以写出一个针对ALU操作数的选择器,以及一个针对立即数的扩展器。
控制器和分支选择器
对于控制器,我们实际上需要的只有“根据opcode和funct,得出各处数据的通断、是否写入,写入的数据来自何处”,所以针对存储模块的各个非任意时刻可写的Write Enable和Write Data的来源,我们都可以引出一个output来控制。
分支选择器实际上也没啥,其实就是根据opcode,funct和ALU的运算结果,选择输出PC+4或者PC+4+offset*4而已。
杂项
实际上,因为Verilog没有什么现成的单周期除法器可用,所以我们还需要单独写一个除法器,不过这个东西的端口定义很显然(input:被除数,除数,output:余数,商)。
实现,测试和连接模块
实际上,如果上述部分在实现之前已经思考的足够清楚了(很不幸,我在第一次搭建的时候完全没想清楚,几乎都是写一段想一段),那么实现和连接模块实际上没啥好说的,就是把某一个模块的输出接到对应需要的输入上,所以我们主要是讲一下测试的关键点。
首先,对于单元测试而言,对于存储模块只需要检测各位是否能正常存取即可(对于通用寄存器组,需要注意的是0寄存器是不能被写入任何数据的,它在任何时候都只能是0),各个控制器和各个小的选择器也只需要检查是否能根据输入指示正确的输出而已。
对于ALU,我们显然不可能枚举所有可能的操作数,所以需要做的就是针对每个指令挑几个典型。比如说对于有符号数的操作,我们需要枚举两个寄存器的正负取值,以此检查是否有诸如把有符号数处理成无符号数的情况,或者错误的使用了扩展方式(比如mult,div,比如sll,addi,ori,lui)
然后就是测试整个CPU是否能够跑指令了。这部分你可能需要借助MARS,把你写的测试程序变成机器码(十六进制或者二进制之类的皆可,只要和你在存储器使用的read相匹配即可)。在这一部分的时候,你的程序需要保证严格遵守指令格式(一个反例就是你使用的立即数超出了原本设计的范围),并且在使用伪指令的时候需要清晰的知道其转化结果(不仅仅是转化成了哪几条指令,还要注意at寄存器时常会因此被狠狠修改)
然后?就没啥工作了,开始使用你的CPU,或者考虑开始学习流水线吧,恭喜!
一些碎碎念
首先,闭门造车一定是不好的,从开始设计到现在,其实我一直不能确定这样的结构是否合理。所以我的评价是,自己摸索固然很重要,但是也要记一个思而不学则殆。
然后,做计组很多时候都是想比写重要,在纸上画一个大致的架构图比写出来整个架构其实更困难,对理解计组的收获也会更大一点。不过就算不是动手画,而是只是参考一个架构图,也会有很大的收获。
另一个让我有“想比写重要”的感受的就是对于指令图的解读,这个工作我在一开始并没有做好,因此走了一大堆弯路,写了很多没必要的东西,直到某天突然看懂之后我尝试列了上面的三个表格,再根据这个对着当时写了一半的控制器一顿大改,才终于让整个结构变得比较清楚。
最后就是,这玩意学起来确实麻烦,但是麻烦的主要就是“ALU咋算”和“控制器要控制啥”,所以抓住这个重点即可,剩下的小部件实际上都是水到渠成。
Extra:如何迈进流水线?
这一部分我还没有开始做,仅用于记录自己写这篇文章的时候思考到了哪里。
大致来说,迈进流水线之前,我们还是需要回顾一下一条指令的执行流程:取指令-译码-执行指令-读取-写回结果。对于流水线来说,其最大的作用就是可以让这五个部分在大部分时候各自单独跑一个指令,而不需要等待前一个步骤的结束,从而提高效率。然而,提到了“大部分”就一定会有“小部分”。具体来说,这个“小部分”应该包含这样几种特殊情况:
- 某条指令的源操作数用到了上一条指令的目标操作数。
- 分支和跳转之后,原有预测将要执行的指令将会被清空。
此外,对于流水线本身来说,我们还需要解决几个问题:
- 对于之前提到的每一个元件,它们应该大致属于哪一步;
- 如何把一个元件改造成一个流水线化的元件;
- 各个操作之间应该如何协调控制。
大致就只思考到了这里。之后等我做出来了再写个新的心得吧。