经过半个学期的努力,大部分同学已经来到了险象环生的 P5 和 P6。P5 和 P6 已不再像程设、数据结构等课程作业一样只是功能单一、结构简单的设计,相反,我们设计的 CPU 是一个由多个部件组成的有机整体。因此,我们必须对系统进行良好的规划,让它拥有简洁优美的设计,并能稳定工作。
在这里,我为大家分享一些在设计复杂系统时需要遵循的通用准则,并以计组课程的 CPU 为例说明它们的具体应用。这些准则是从我个人数年的阅读、开发经验中总结出来的,希望能够为大家起到一些指引的作用。
为字面量命名
我们在编写代码时,会遇到很多“字面量”(比如指令的 opcode,寄存器号码所在的位置等)。我们来看以下代码片段:
always @(*) begin case (funct) 6'b100100: begin regRead1 = instruction[25:21]; regRead2 = instruction[20:16]; regWriteSel = 1; destinationRegister = instruction[15:11]; aluCtrl = 1; end 6'b100101: begin regRead1 = instruction[25:21]; regRead2 = instruction[20:16]; regWriteSel = 1; destinationRegister = instruction[15:11]; aluCtrl = 2; end 6'b100110: begin regRead1 = instruction[25:21]; regRead2 = instruction[20:16]; regWriteSel = 1; destinationRegister = instruction[15:11]; aluCtrl = 3; end 6'b100111: begin regRead1 = instruction[25:21]; regRead2 = instruction[20:16]; regWriteSel = 1; destinationRegister = instruction[15:11]; aluCtrl = 4; end endcase end
这是一段用于解码 and
, or
, xor
, nor
4 条指令的 case 语句。在这段代码中,我们可以看到许多直接使用的字面量:指令的 funct,rs、rt 和 rd 寄存器的位置,ALU 的功能控制等。这种代码会给编写和阅读带来不小的障碍(特别是当重复使用相同的字面量时)。这时,我认为应该将所有字面量命名,并使用名字来代替字面量。
对于不同类型的字面量,我们使用不同类型的替代方法。例如,对于 opcode 这种在只在当前文件使用的常量,我一般会使用 localparam
;对于跨文件使用的常量,我一般会使用 `include
;对于像 instruction[25:21]
这样的位操作,我一般会直接将截取结果用 wire
来表示。
改造后的代码如下:
// constants.v `define aluOr 2 `define aluAnd 3 `define aluNor 6 `define aluXor 7 `define regWriteALU 1 `define regWriteMemRead 2 // ...
// controller.v `include "constants.v" localparam _and = 6'b100100; localparam _or = 6'b100101; localparam _xor = 6'b100110; localparam _nor = 6'b100111; wire [4:0] rti = instruction[20:16]; wire [4:0] rsi = instruction[25:21]; wire [4:0] rdi = instruction[15:11]; // ... always @(*) begin case (funct) _and: begin regRead1 = rsi; regRead2 = rti; regWriteSel = `regWriteALU; destinationRegister = rdi; aluCtrl = `aluAnd; end _or: begin regRead1 = rsi; regRead2 = rti; regWriteSel = `regWriteALU; destinationRegister = rdi; aluCtrl = `aluOr; end _xor: begin regRead1 = rsi; regRead2 = rti; regWriteSel = `regWriteALU; destinationRegister = rdi; aluCtrl = `aluXor; end _nor: begin regRead1 = rsi; regRead2 = rti; regWriteSel = `regWriteALU; destinationRegister = rdi; aluCtrl = `aluNor; end endcase end
// alu.v `include "constants.v" always @(*) begin case (ctrl) `aluOr: out = A | B; `aluAnd: out = A & B; `aluXor: out = A ^ B; `aluNor: out = ~(A | B); endcase end
不直接使用字面量值,而是将其赋予含义后再使用,将有助于提高代码的可读性,减少混乱。
不要复制粘贴代码 (Don’t repeat yourself)
这里的我们不是在说复制粘贴别人的代码(如果你这样做了,课程组可能会找你麻烦),而是指不要重复自己的代码。当你在对自己的代码按下 Ctrl+V 的时候,你可能需要想一想,是否应该对代码做合理的抽象。
还是以上面那段代码为例。要编写上面的 case
逻辑,我们能想到的直接方式就是将 and 块复制 4 次,然后修改对应的 aluCtrl
逻辑。这样做的问题有:
- 在粘贴-修改时,容易遗漏或者看错
- 在修改设计时,需要对每个粘贴的地方都进行同样的编辑
一个更好的做法是,在复制-粘贴代码前,不妨考虑一下自己为什么要复制,然后将相同的内容抽象出来(以 `define
或 module
的形式),然后仅修改不同的部分。
例如,对于上一节的控制器,我们可以发现,对于简单逻辑运算(甚至包括加、减等运算),大部分内容(例如要读取、写入的寄存器)都是相同的,只有少数内容(ALU 的控制信号)不同。因此,我们可以将相同的内容抽象进一个 `define
块中。
修改后的代码如下:
// 在 define 中实现多行定义,只需在行尾增加反斜杠 `define simpleALU \ regRead1 = rsi; \ regRead2 = rti; \ regWriteSel = `regWriteALU; \ destinationRegister = rdi; always @(*) begin case (funct) _and: begin `simpleALU aluCtrl = `aluAnd; end _or: begin `simpleALU aluCtrl = `aluOr; end _nor: begin `simpleALU aluCtrl = `aluNor; end _xor: begin `simpleALU aluCtrl = `aluXor; end endcase end
可以看到,合理的抽象能够极大地减少代码长度,使维护变得容易。我的 P6 项目仅有 1263 行代码,甚至比一些同学的 P5 项目更短。
想要了解 DRY 原则的更多内容,可以参考这里。
保持简单设计 (Keep It Simple and Stupid)
在设计 CPU 时,我们有时需要决定,是否要将某个功能抽象为一个单独的模块。在遇到这种问题时,我们应该首先思考这样做有什么实际意义。请看下面的代码:
module top; // ... ctrl ctrl(...); extend extendimm(.instr(ctrl.IRO),.siExt(ctrl.siExt),.shift2(ctrl.shift2)); extend16forpc extend16(.instr(ctrl.IRO),.pc(ctrl.PCO)); extend26forpc extend26(.instr(ctrl.IRO),.pc(ctrl.PCO)); mux32 mux16or26(.src1(extend16.imm),.src2(extend26.imm),.op(ctrl.j)); // ... endmodule module extend( input [31:0] instr, input siExt, input shift2, output [31:0] imm ); // ... endmodule module extend16forpc( input [31:0] instr, input [31:0] pc, output [31:0] imm ); // ... ndmodule module extend26forpc( input [31:0] instr, input [31:0] pc, output [31:0] imm ); // ... endmodule
这位同学使用控制器模块操作扩展器模块,扩展器模块从指令中提取立即数,然后使用 mux 来选择。这样的写法虽然能够工作,但是稍显复杂。extend 模块功能非常简单,而且不被任何其他地方使用,显然是不必要的抽象。
既然立即数只由指令和 PC 决定,那么我们能不能直接在控制器中决定立即数,从而避免扩展器模块呢?
module Controller ( input [31:0] instruction, output reg [31:0] immediate ); wire [25:0] bigImm = instruction[25:0]; wire [15:0] imm = instruction[15:0]; wire [31:0] zeroExtendedImmediate = imm; wire [31:0] shiftedImmediate = {imm, 16'b0}; wire [31:0] signExtendedImmediate = $signed(imm); wire [5:0] opcode = instruction[31:26]; always @(*) begin case (opcode) addi: begin // ... immediate = signExtendedImmediate; end ori: begin // ... immediate = zeroExtendedImmediate; end lui: begin // ... immediate = shiftedImmediate; end endcase end
这样,外部可以直接使用控制器输出的立即数信号,而无需经过多个模块的连接。这会使调试更加简便直接。
避免过度设计,同时又要有合理的抽象,我们在设计时需要认真取舍。
更多内容
由于物理作业太多个人能力有限,今天只能写这么多了。我后续还会更新其他内容(如果大家有想听的内容,欢迎直接找我),请大家随时来看看。
拒绝卖菜
这个就不多说了。
本作品使用基于以下许可授权:Creative Commons Attribution-ShareAlike 4.0 International License.