Contents

单周期MIPS CPU

写在前面

终于,在p3开始的四周后,苯人以p3p4各挂的一次的战绩,有惊有险地结束了单周期CPU的部分。于是,怀着终于推进进度的兴奋和即将面对流水线的惶恐,主包决定在这里写一篇总结,记录一下自己和身边人的经验和踩过的一些坑。当然,受课程尚未结束的限制(更主要的原因是主包实在太懒),我并不打算在这里分享我的设计架构,如果这篇博客真的有幸被后人看到,那么请注意,不要试图在这里找到有关单周期CPU的设计方法(笑)(当然,如果文中涉及到和架构有关的问题,我可能会浅浅的说一下有关系的那一部分架构),但是,希望你可以在这里复习一下菜菜的我们真实犯过的知识点错误,避一些菜菜的我们真实踩过的坑,或者,就当是每周一上机前(如果上机依然是周一的话)缓解焦虑的方式也好吧。

P3:单周期CPU的Logisim实现

注意电路外观

因为这个问题极其隐蔽,发现时又极其令人懊悔,所以主包决定把这个问题放在第一个!具体而言,当我们在一个模块中加入一个新的输入输出端口时,受我们加入端口的位置影响,Logisim有概率对外观中端口的位置进行重新排列(特别是你之前重新排列过端口的位置),导致上层电路中连线全部错误,而你几乎不可能发现这一点。

尽可能减少模块的修改

当我们在面对课上奇奇怪怪的新增指令时,我们应当尽可能减少对已经封装好的模块的修改。比如,对于nPC模块,其功能即计算下一个PC值,而我们PC的跳转方式其实只有固定的4种(正常而言课上也不会新增一个跳转的方式),所以我们的这一模块是完全不需要进行任何更改的,面对新增的跳转指令,我们只需要判断他的跳转方式属于已实现的4种当中的哪一种即可。事实上,除了ControllerALU外,大多数情况下我们不需要对其他模块进行任何的修改。

增强模块的扩展性

在任何时候,良好的可扩展性都是面对新增需求时降低修改量的必要条件。这里举一个例子。如果你封装了IFU模块,虽然我们最终只需要得到下一条指令,但是我们最好把当前指令的PCPC+4的值一并输出。原因是在跳转指令jaljalr中,我们需要向寄存器写入PC+4的地址,以便函数调用结束后程序还能清楚地知道该返回哪里;而PC则是因为我们课程的评测机需要我们输出当前的PC值用于评测。

注意顶层电路的输出

为评测我们CPU的正确性,题目会要求我们输出一些关键信号,包括但不限于PC,写入寄存器/数据存储器的值,写入寄存器/数据存储器的地址,寄存器/数据存储器的写使能信号等信息。请注意,如果我们新增的指令中的这些信号与我们原有的信号不同(也就意味着我们需要增加一个多路选择器对不同情况进行选择),我们一定要注意最终输出的信号是选择后的信号,而不是选择前的某一路信号。

输出信号的特判

我们经常能见到这种题:在某些情况下,我们需要向寄存器写入运算结果,而在另一些情况下,我们不写入。如果你像我一样,写使能信号完全由Controller控制,而Controller只接受OpCodeFunct两个信号的话,你就会发现我们无法对是否开启写使能信号做出精确的判断。我们当然可以让写使能信号时刻保持开启,然后在ALU或其他计算写入数值的模块中根据题目条件,决定输出的数值是运算结果还是寄存器内原本的值——是的,这当然是一种很好的曲线救国的方法,但问题在于,我们的评测机需要写使能信号,而评测机的答案当然有且只有一个。所以,如果你和我的架构一样,请加入WriteEnable的特判模块。
同理,如果我们DM模块的输入直接与ALUGRF相连,而没有进行任何写使能的特判,那么我们所输出的写入的数据MemData和地址MemAddr会时刻随ALU运算结果变化(当然受使能信号的限制我们实际并未写入任何内容),解决方案是输出MemData & sign_extend(WriteEnable)MemAddr & sign_extend(WriteEnable)

无伤大雅的小建议

  1. 我们在调整子电路外观时,其实可以调整外框的大小和形状,也可以在适当的地方加入文字,并可以调整文字的大小粗细等
  2. 为了美观,我们在使用tunnel布线时,可以更改tunnel中文字的大小粗细等
  3. 把Controller模块放在一个大一点的地方,因为你不能保证后面可能会扩展什么信号

P4:单周期CPU的Verilog实现

由于P4完全是对Logisim电路图的翻译,故这一部分重点在于verilog当中的一些易混淆的语法。

区分always在时序逻辑和组合逻辑中的区别

使用always块建模不同的逻辑中的细节,确实是让人感到很头疼的问题,也是直接挂了我P1和P4的元凶。我尽量在这里做一个梳理,如果后续发现仍有错误或不全面之处,我将继续补充。
首先应该说明的是,时序逻辑,always后的条件往往写成posedge clk;而组合逻辑,always后的条件则是*
然后,我们应该明确一个概念:<=非阻塞赋值=阻塞赋值。所谓非阻塞赋值,即所有语句同时进行赋值;而阻塞赋值,则是在上一条赋值语句执行完成后,再执行下一条赋值语句。
由此出发,我们可以得到以下规则

  1. 如果我们想描述时序逻辑,请使用<=非阻塞赋值。使用Logisim我们可以清楚的看出,当两个同一时钟驱动的寄存器连在一起时,在时钟上升沿,二者的值同时改变。
  2. 如果我们想描述组合逻辑,请使用=阻塞赋值。组合逻辑实际并不涉及寄存器与时钟的相关信号,而是和我们使用高级语言进行编程的逻辑一致,故使用阻塞赋值。
  3. 在同一时刻,对同一变量进行不同的非阻塞赋值,只有最后一次赋值会生效,故我们应当避免这种写法。
  4. for循环中,如果每次循环是对同一个变量进行赋值,请使用=阻塞赋值;如果是对不同变量进行赋值(请注意,对于类似reg [31:0] RAM [0:4095]的数组,对于不同的i,RAM[i]是不同的变量),则根据逻辑类型选用。这是因为Verilog是一个描述硬件的语言,而硬件并没有真正意义上的循环,所以verilog会把循环进行合理的展开。如果我们使用了非阻塞赋值,展开后便违反了规则3。
  5. case语句中,由于我们每一次只能进入一个分支,顾不受上述for循环规则限制。

for循环的其他注意事项

  1. 对于循环变量integer i,我们需要在always块外进行声明。(这实际上是Verilog-1995的标准)
  2. 变量自增,没有i++这种写法,请写i = i + 1
  3. for循环没有break语句,如果想实现类似效果,请单独定义一个变量用于判断。

多个always块

与高级编程语言不同,verilog中,我们经常出现同步执行的情况,always块便是其中之一。对于always块,我们无所谓他在代码中的先后顺序,只要满足敏感条件列表,即开始执行。因此,我们不能在多个always块中驱动同一变量,因为这可能造成同一时刻该变量被赋不同的值,而导致电路出现震荡,仿真失败。

if与case语句

  1. case语句同样没有break,分支结束后会自动跳出
  2. 如希望根据数据某些位的特点(如某几位是0或某几位是1)选择分支,可以考虑使用casexcasez
  3. 最好为每一个if加上else/每一个case加上default,防止锁存器的产生(尽管我们现阶段无需担心这一点,但最好不要这样做)
  4. 这两种语句只能写在alway块内,在always外请使用三目运算符(此时,我们必须写条件均不满足的结果)

索引部分选择

虽然verilog规定了截取某一变量的多位时规定索引必须是常数(如var[5:3]),但是我们往往需要根据某一变量的值去决定我们所截取的位置(这种写法在for循环中尤其常见,如我们需要var[(i+15):i]),那此时我们该怎么办呢?verilog提供了另外一种写法,如果我们所截取的位宽是常数,如上例,我们可以写var[i+:16]。值得注意的是,索引方向和我们声明时的方向有关,具体而言:

reg [31:0] data;  // 31是MSB,0是LSB
assign slice = data[8 +: 16];  // 取data[23:8]

reg [0:31] data_reverse;  // 0是MSB,31是LSB  
assign slice = data_reverse[8 +: 16];  // 取data_reverse[8:23]

当然,我们也可以使用减号,行为与加号相反。 另外,请注意,冒号右侧常数是截取位宽,而不是索引差(差了1),以及,我们必须人为注意位宽,不要越界。

wire类型与reg类型

  1. wire类型完全对应导线,对wire类型的赋值完全等价于导线的连接。
  2. reg类型未必被综合成寄存器,尤其当我们使用always建模组合逻辑时。
  3. wire类型请在always块外使用assign进行赋值。
  4. reg类型请在always块内使用上述方法进行赋值。
  5. 模块实例化时,.port(wire)即表示将wire与端口相连

同步复位与异步复位

为了便于大家开发,课程组规定,在Logisim中,我们使用异步复位,而在Verilog中,我们使用同步复位(至少今年是这样)。具体而言,Verilog中,两种复位写法如下:

// 异步复位
always @(posedge clk or posedge rst) begin
    if (rst) //复位逻辑
    else //其他逻辑
end

// 同步复位
always @(posedge clk) begin
    if (rst) //复位逻辑
    else //其他逻辑
end

有符号数的运算

在verilog中,绝大多数情况下,只要表达式有无符号数,则整个表达式就是无符号的。所以,如果我们想进行有符号数运算(包括比较运算),请对每一个数均适用$signed().一些例外:算数右移>>>时,操作数需标注signed(否则永远高位补0,即退化为逻辑右移),但移动位数不需要标注;三目运算符冒号前后的值,不会影响条件中的符号性质,即($signed(5'b11111) > $signed(5'b00001)) ? 3'b111 : 3'b000即可实现根据有符号数的比较判断结果。

位拼接

此处需要强调,如果我们想要在一个{}前面加重复位数的话,那么需要在外面再加一层{},即正确的写法是{{16{var[15]}}, var}而非{16{var[15]}, var}(此处为对16位数进行32位有符号拓展)。

写使能特判

与Logisim输出信号的特判部分同理,如果你的这部分架构和我相同,那么便需要进行特判。请注意,一定要理清特判时的逻辑,确保每一种情况都对应了正确的写使能信号。

模块实例化

模块实例化时,请使用按名称关联,避免新增端口时连线错误。

最后

希望每一个读到这里的人和没读到这里的人,都能顺顺利利的AK CO!