CS61C – Proj3 – RISCV Pipelined CPU Design

五阶段流水线全实现 cpu 总览图镇个楼₍₍ ◝(‘ω’◝) ⁾⁾ ₍₍ (◟’ω’)◟ ⁾⁾

作为 CS61C 最富盛名,似乎也是被认为难度最大的一个 Proj,Proj3 要求我们在 RISC – V 的指令基础上设计一个二阶段流水线的 CPU ,此处笔者稍微魔改了一下,按照 lec22 完整实现了一个 5 阶段 CPU

不过由于讲义原本指导的是 2 阶段流水线 CPU,所以自 Pipelining Your CPU 本身及之后的实验自身提供的测试会完全失效。因为我们翻阅 test_runner.py 就会发现,实验参考的输出只针对 Reference Out,而 Reference Out 是仅针对 2 阶段流水线 CPU 的。事实上笔者开始没甚至反应过来,傻乎乎测试 n 多遍,直到反编译发现某些参考都输出完了自己实现的 CPU 甚至第一条指令甚至幽默的还没 Write Back 时才反应过来(

由于是项目推完才开始动笔,因此实现思路不一定和讲义一致,比如可能在单指令周期阶段就考虑全指令行为,建议仅供参考。

Arithmetic Logic Unit (ALU)

我们的第一个任务是创建一个支持 ISA 中所有指令所需操作的 ALU。不过此处讲义提示我们我们忽略溢出。

那么看一下 alu.circ 的输入引脚

很明显有两个输入 A,B 进行运算以及一个控制操作类型的 ALUSel 负责结果控制,那么依据讲义表格首先构建计算图:

这块没啥好说的基本上都是常规运算,不过注意一下 slt7 由于我们不考虑溢出,且情况是 $\geq$ 和 $\lt$ 分界,那么做个减法考虑首位数字作 0 拓展就行。

另外为了后面指令的实现需要此处构建了一个讲义中未提及的对无符号数的小于比较运算。

至于输出部分,很明显我们能够根据 ALUSel 构建 MUX 对运算结果进行选择输出。

RegFile

这一部分我们需要实现 RISCV 中全 32 个寄存器,我们的 RegFile 应该能够写入或读取给定 RISC-V 指令中指定的寄存器,而不影响任何其他寄存器。不过需要注意一下 x0 需要始终置0 防止被其他写入数据冲刷。

观察 regfile.circ 的输入不难理解, write_data 为写入数据,依据 RegWEn 信号控制写入与否,通过 rd 控制写入哪个寄存器,输出的寄存器则通过 read_reg1 和 read_reg2 进行选择。那么实现思路就很清晰了,各寄存器输入端接 Write_data 同时以 RegWEn 信号为输入, rd 为控制信号,通过解复用器我们能够控制寄存器的写入信号,从而实现针对性写入。输出端名称注意一下别名即可。读出操作可依赖复用器进行逆向操作。

完整的实现如下:

The addi Instruction

这是 Part_A 的最后一部分(我怎么感觉好像啥也没干),我们需要完整的实现一个 addi 指令。

讲义提示我们 Mem 的部分已经完整实现过了,而且 addi 其实也用不到内存,所以我就没管它(然后吃了大亏) 现阶段我们只关注CPU 内的实现流程。

由 lec 22 的内容我们可知,CPU 实现一条指令的全过程可分为 IF/ID/EX/MA/WB 五个阶段, cpu.circ 中 IF 的部分已经为我们设计的大差不差(针对 addi),我们需要从 ID 阶段开始考虑

ID – Instruction Decode

指令解码(译码)部分其实需要做的很简单,将输入的 Instr 按指令格式进行分割,提取出对应区间的操作数即可

此处感谢 RISC 的高度简洁与复用性, I R S B J U 格式之间高度重合的基本操作数使得我们不必再设计一个或几个操作位或者一个 Instr_Type 对输入指令进行鉴别

ID_Inst 是后期流水线操作的产物,此处视为 Instruction 的等同 Tunnel 即可。

当然这是指令解析部分,对于立即数指令,我们还需要使用到 ImmGen 即立即数生成器。

根据操作类型格式的不同, ImmGen 生成立即数的方式也不相同:

I-Type:
|  31 ~ 20  |  19 ~ 15 | 14 ~ 12 | 11 ~ 7 |  6 ~ 0  |
| imm[11:0] |    rs1   |  funct3 |   rd   |  opcode |
S-Type:
|  31 ~ 25  | 24 ~ 20 | 19 ~ 15 | 14 ~ 12 |  11 ~ 7  |  6 ~ 0  |
| imm[11:5] |   rs2   |   rs1   |  funct3 | imm[4:0] |  opcode |
B-Type:
|   31    |  30 ~ 25  | 24 ~ 20 | 19 ~ 15 |  14 ~ 12  |  11 ~ 8   |  7 ~ 0  |
| imm[12] | imm[10:5] |   rs2   |   rs1   |   funtc3  |  imm[4:1] |  opcode |
U-Type:
|    31   |  30 ~ 21  |   20   |   19 ~ 12  | 11 ~ 7 |  6 ~ 0  |
| imm[20] | imm[10:1] | imm[11]| imm[19:12] |   rd   |  opcode |
J-Type:
|   31   |   30 ~ 21  |   20    |  19 ~ 12   | 11 ~ 7 |  6 ~ 0  |
| imm[20]|  imm[10:1] | imm[11] | imm[19:12] |   rd   |  opcode |

所以具体到实现上,我们需要对传入的 Instr 做不同类型的分割然后通过 ImmSel 对输出进行选择:

imm_R 以及 imm_N 属于冗余设计,防止(理论上不太可能出现的) ImmSel 在相应位但是无输入导致的 U 行为。

虽然 测试只针对 imm_I,也就是 imm 输出只连 imm_I 就能通过测试,不过为了设计完备性此处考虑直接实现信号选择功能。

讲义也提示了信号选择是在 control_logic.circ 下,主电路中表示为:

注意输出 Tunnel 本身不存在是笔者后加的

那么深入到 control_logic.circ ,注意到它同样接受 Inst 作为输入进行类型判断,不过除此之外还有两个 1 bit 信号 BrEq, BrLt 用于 B-Type 的类型比较:

不过 BrEq, BrLt 是用于控制 PC 跳转的,我们暂时还用不到,暂且放过来看 logic 部分的主体实现。

讲义(在 part_B)提示我们

“实现控制逻辑主要有两种方法硬布线和 ROM 映射,核心行为是从指令中提取 opcode / funct3 / funct7 并设置相应的控制信号”

讲义推荐的是硬布线,不过此处为了防止出错(偷懒),我们采用 ROM 映射的方式,我们需要根据一个 17 位的输入(opcode 7 + funct3 3+ funct7 7 = 17)给出一个 19 位的输出,并对输出的 19 位二进制数进行位分割以提取相应操作数

为什么要采用 ROM 映射

ROM 的映射好处在于修改量小且增添方便,我们只需要考虑批量生成 Addr $\rightarrow$ Value 的代码框架然后设置指定位数即可,避免了布线的易错易堆积的尬尴

考虑到 ROM 可以 load image 。这个行为接受一个 binary 输入或者分行的 hex 输入,我们考虑用一个简单的 python 脚本搭一个文件输出框架,它接收一个 txt 作为输入并集中文件中的 17 位输入转译的 19 位输出写出到 hex 文件

# 主程序
if len(sys.argv) != 3:
    print("用法: python gen_rom.py 指令输入文件.txt 输出.hex")
    print("输入文件每行为: opcode funct3 funct7 (十进制或0x十六进制)")
    sys.exit(1)

input_file = sys.argv[1]
output_file = sys.argv[2]

# 读取输入文件
with open(input_file, "r") as f:
    for line in f:
        line = line.strip()
        if not line or line.startswith("#"):
            continue
        parts = line.split()
        if len(parts) >= 3:
            opcode_str = parts[0]
            funct3_str = parts[1]
            funct7_str = parts[2]
            # 转换字符串为整数
            try:
                opcode = int(opcode_str, 0)
                funct3 = int(funct3_str, 0)
                funct7 = int(funct7_str, 0)
            except ValueError:
                print(f"数值转换错误: {line}")
                continue
        else:
            print(f"格式错误: {line}")
            continue

        key = (opcode, funct3, funct7)
        if key not in instr_rules:
            print(f"警告: 找不到规则 {key}, 该指令将输出全零")
            continue

        addr = (funct7 << 10) | (funct3 << 7) | opcode
        rom[addr] = instr_rules[key]

# 写出 hex 文件
with open(output_file, "w") as f:
    for val in rom:
        f.write(f"{val:05x}\n")  # 17 位 => 至多 0x1FFFF,用5位hex
print(f"已生成 {output_file} ,共 {rom_size} 行")

而核心结构 instr_rules 由 encode_ctrl 函数进行控制:

# 控制信号编码函数
def encode_ctrl(BranchType, RegWEn, ImmSel, BrUn, ASel, BSel,
                ALUSel, MemRW, WBSel, CSRSel, CSRWen):
    """
    打包 19 位控制信号
    bit 18: CSRWen
    bit 17: CSRSel
    bit 15-16: WBSel (2位)
    bit 14: MemRW
    bit 10-13: ALUSel (4位)
    bit 9:  BSel
    bit 8:  ASel
    bit 7:  BrUn
    bit 4-6: ImmSel (3位)
    bit 3:  RegWEn
    bit 0-2: BranchType (3位)
    """
    value = (CSRWen << 18) | (CSRSel << 17) | (WBSel << 15) | (MemRW << 14) | \
            (ALUSel << 10) | (BSel << 9) | (ASel << 8) | (BrUn << 7) | \
            (ImmSel << 4) | (RegWEn << 3) | (BranchType << 0)
    return value

当然我们可以预 Define 一些宏进行简化:

# ImmSel 枚举
IMM_R   = 0b000
IMM_I   = 0b001
IMM_S   = 0b010
IMM_B   = 0b011
IMM_U   = 0b100
IMM_J   = 0b101
IMM_CSR = 0b110 

# ALU 操作枚举
ALU_ADD     = 0b0000
ALU_SUB     = 0b0001
ALU_AND     = 0b0010
ALU_OR      = 0b0011
ALU_XOR     = 0b0100
ALU_SLL     = 0b0101
ALU_SRL     = 0b0110
ALU_SRA     = 0b0111
ALU_SLT     = 0b1000
ALU_SLTU    = 0b1001
ALU_COPY_B  = 0b1010
ALU_MUL     = 0b1011
ALU_MULH    = 0b1100
ALU_MULHSU  = 0b1101
ALU_MULHU   = 0b1110


# 生成全零 ROM(无操作)
rom_size = 1 << 17
rom = [0] * rom_size

那么 instr_rules 的 R-Type 可以是:

instr_rules = {
    # R-type ALU
    (0x33, 0b000, 0x00): encode_ctrl(0,1,IMM_R,0,0,0,ALU_ADD,0,0b00,0,0),  # ADD
    (0x33, 0b000, 0x20): encode_ctrl(0,1,IMM_R,0,0,0,ALU_SUB,0,0b00,0,0),  # SUB
    (0x33, 0b001, 0x00): encode_ctrl(0,1,IMM_R,0,0,0,ALU_SLL,0,0b00,0,0),  # SLL
    (0x33, 0b010, 0x00): encode_ctrl(0,1,IMM_R,0,0,0,ALU_SLT,0,0b00,0,0),  # SLT
    (0x33, 0b011, 0x00): encode_ctrl(0,1,IMM_R,1,0,0,ALU_SLTU,0,0b00,0,0), # SLTU
    (0x33, 0b100, 0x00): encode_ctrl(0,1,IMM_R,0,0,0,ALU_XOR,0,0b00,0,0),  # XOR
    (0x33, 0b101, 0x00): encode_ctrl(0,1,IMM_R,0,0,0,ALU_SRL,0,0b00,0,0),  # SRL
    (0x33, 0b101, 0x20): encode_ctrl(0,1,IMM_R,0,0,0,ALU_SRA,0,0b00,0,0),  # SRA
    (0x33, 0b110, 0x00): encode_ctrl(0,1,IMM_R,0,0,0,ALU_OR,0,0b00,0,0),   # OR
    (0x33, 0b111, 0x00): encode_ctrl(0,1,IMM_R,0,0,0,ALU_AND,0,0b00,0,0),  # AND
# 其他类型省略
}

那么将生成的 hex 文件 load 进 ROM 后,剩下的操作就很简单了:

我们再来看左下角的 PCSel 部分,这部分其实就是控制模块中的分支决策单元,通过我们读取的 BranchType 进行一个 MUX 的分支选择来决定当前 PC 的下一个输入来源是 PC + 4 或者 PC + Imm 还是 ALU 的计算跳转结果。

OK那么回到 ImmGen 模块,其实完整实现上述流程后我们已经完成了 Part_B 的大部分工作(乐,而在 Part_B 中我们将考虑进一步完善相关工作。

Pipelining Your CPU

这一部分要求实现的是一个二阶段流水线,但无论是讲座中讨论的五阶段流水线还是当前的两阶段流水线,其核心思想是一致的——通过中间寄存器存放必需元素,实现隔离。

由讲座中的设计理念可知,此处我们需要保存 PC 和 Instr 两个值,所以实现就很清晰了

了解了原理测试也就顺利 Pass 了

Part_B

我们在 ImmGen 部分已经完成了对 Branch 和 Logic Module 的实现,所以这块的具体实现我们从 CSR 部分开始

CSR

这部分其实没啥好说的,因为我们只需实现一个寄存器 tohost ,注意一下写入地址是否匹配后根据 WE 进行写入就行了

Processor

主 CPU 电路是主数据通路的完整实现,我们需要将所有子电路(ALU、分支比较器、控制逻辑、立即数生成器、内存和 RegFile)连接在一起。在 A 部分,我们已经实现了 CPU 中的一个简单的两阶段流水线。由于 B 部分需要支持分支和跳转指令,所以我们需要处理分支时发生的控制冒险。

图源:CS61C 20 Fall Lecture 22 Slides

当然首先考虑流水线设计,因为只有流水线设计才会出现 Hazard

由图可知我们需要为以下数据量设计流水下寄存器:

信号/数据名产生阶段需要寄存到阶段用途
PC_IDIFIF/ID → ID/EX → EX/MEM分支计算、jal/jalr返回、PC+4写回
Inst_IDIFIF/IDID译码使用
rs1IDID/EXEX阶段ALU/分支比较
rs2IDID/EX → EX/MEMEX阶段ALU,MEM阶段写内存
ImmIDID/EXALU立即数运算
Alu_ResEXEX/MEM → MEM/WB访存地址、或直接作为写回数据
MemMEMMEM/WB写回寄存器
rdIDID/EX → EX/MEM → MEM/WB寄存器写回地址
PCSelIDID/EXEX阶段分支/跳转
RegWEnIDID/EX → EX/MEM → MEM/WB写回使能
ImmSelIDID/EX控制立即数生成器
BrUnIDID/EX控制无符号比较
ASelIDID/EXALU第一个操作数选择
BSelIDID/EXALU第二个操作数选择
ALUSelIDID/EXALU运算类型选择
MemRWIDEX/MEM控制数据存储器读写
WBSelIDMEM/WB写回来源
CSRselIDID/EX 或 EX/MEMCSR访问类型选择
CSRWenIDID/EX → EX/MEM → MEM/WBCSR写使能

所以大概就知道为什么开头那张图会有很多寄存器了(

哎哟还是给个特写吧

Hazards

经典的五级RISC-V流水线下,所有经典数据 Hazard 、控制 Hazard 都会出现,具体来说:

  • Data Hazards:指令需要使用还没计算/写回完成的数据,通常发生在不同阶段之间的数据依赖
  • Control Hazard:分支跳转影响指令取值需要纠正
  • Structure Hazard:这一类冒险是指两个 Stage 争夺同一硬件资源,不过这个在我们这不会出现,因为我们是 Harvard 结构。

Data Hazards

数据冒险全阶段均有出现,IF/ID 阶段通常是 rs1, rs2 和 imm 的 Read After Write 状况以及 PC 的 Branch and Jump 状况

我们通过一个 BJK信号对 PC 可能的冒险行为进行校正,具体来说,当捕获到 J-Type 以及明确的 B-Type 和 对应的比较信号时我们发出BJK,而 PC 同时接受 PCSel 和 BJK 的控制进行选择

而在 ID/EX 以及 EX/MA 阶段,最有可能的发生 Hazard 的组件为 ALU,同样是 RAW 类型的Hazard 我们需要考虑数据前递以避免储存指令将旧值写入内存

Forward Detector Unit

这部分的实现逻辑其实很简单

$$ \begin{align}
EX\_Forward\_A&= MA\_RegWEn\ And\ (MA\_rd = 0)\ And\ (EX\_rs1 != MA\_rd)\\
WB\_Forward\_A&= WB\_RegWEn\ And\ (WB\_rd = 0)\ And\ (EX\_rs1 != WB\_rd)\\
ForwardA &= (WB\_Hazard\_A\ And\ (NOT\ EX\_Hazard\_A)\ Or\ (EX\_Hazard\_A << 1)
\end{align}$$

Forward_B 同理,不过最后写入的时候需要根据 EX_BSel 来做个保险

对应到 CPU 主电路中的 ALU 部件就是

此外还有一种特殊的数据冒险,我们称之为 Load-Use Hazard ,其意为load 指令的结果在 MEM阶段 才准备好,但下一条指令在 EX阶段 就需要这个值,这种情况下 Forwarding 是无能为力的,我们必须 Stall 一个周期

相应的解决思路是在ID阶段检查,如果EX阶段是load且 rd=下一条的rs → 插入一个bubble,暂停PC、IF/ID流水寄存器,ID/EX清空。

那么至此我们完成了 5 Stage Pipeline CPU 的全流程设计,一路做下来收获还是很多的(踩得坑也挺多的),有些事不上手不知其难,做完的时候确实感觉自己对机组的相当一部分知识有了更深一点的了解,尤其是各部分的时序周期及串联效果,下一个 Proj 又将是一个新的起点~

以上

评论

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注