简介:这份资料包面向高校计算机组成原理课程学习者与课程设计备赛者,围绕北航计算机学院计组课设的P0至P5等阶段,覆盖Logisim与Verilog-HDL两条实现路线,涉及部件及状态机设计、CRC校验、ALU、正则表达式匹配、格雷码计数器、合法表达式识别、汇编语言矩阵乘法与回文判断,以及支持7条指令的单周期CPU和10条指令的流水线CPU。包内共1541个文件,以Verilog源码、汇编程序、C源码、Logisim电路、工程文件及日志报告文档为主,压缩包约33.25MB,便于按项目阶段检索。已有288人学习,适合需要对照课下测试与课上测试要求、理解从数字部件到流水线CPU完整设计链路的学习者,可参考工程组织、源码实现与实验记录,补齐状态机设计、指令扩展和流水线工程化方法等关键环节。
1. 从「仿真波形对上了」到「板子上数码管亮了」:这套课设资源该按什么顺序拆
把一份计算机组成原理课程设计的源码包解压出来,最先看到的往往是一堆.v文件和几个.asm,还有若干约束文件、脚本和一份说不清版本的实验指导。多数人第一反应是从顶层文件顺着往下读,读到一半就迷路;另一部分人把 testbench 跑通、波形看着像那么回事,就认定万事大吉,等真上板发现数码管乱跳、按键没反应、串口吐乱码,才开始怀疑自己写的到底是 CPU 还是一堆与非门。计算机组成原理课程设计这条链路其实有固定顺序:指令集定义、单周期数据通路、流水线改造、汇编激励与参考模型比对、综合约束与上板验证。这套资源就是按这个顺序组织的,每个环节对应独立目录,改一处不会牵动全身。它适合正在做课设、需要交一份能被人照着复现的实验报告的人,也适合想把课本上那张数据通路图变成能仿真、能上板、能跑矩阵乘程序的实物的人。下面按我拆包的顺序讲。
2. 指令集先砍到 24 条:单周期数据通路的骨架与控制器拆解
动手写第一行 Verilog 之前,先把指令集定死。这一步偷懒,后面控制单元会变成一团解不开的线。
2.1 指令集裁剪:为什么 24 条就够用
课设周期通常按周算,不是按学期算。指令集每多一条,主译码要多一个分支、ALU 要多一个控制码、测试程序要多一组激励、报告要多一段说明,工作量是乘法关系而不是加法关系。我一般会压到 24 条左右,覆盖五类:R 型算术逻辑、I 型立即数运算、访存、分支、跳转。乘除法、浮点、非对齐访存、异常与中断一律砍掉,需要乘法就用移位加法软件模拟,需要异常就作为加分项单开一个模块,别塞进主数据通路里。
具体清单如下,编码按 MIPS 子集来,这样现成的汇编器和模拟器都能直接用,省掉自己写工具链的时间。
| 类型 | 指令 | opcode / funct | 说明 |
|---|---|---|---|
| R 型 | add / sub | 0x00 / 0x20、0x22 | 寄存器间加减 |
| R 型 | and / or / slt | 0x00 / 0x24、0x25、0x2a | 逻辑与、或、小于置 1 |
| R 型 | sll / srl / sra | 0x00 / 0x00、0x02、0x03 | 移位量取 rs 低 5 位 |
| R 型 | jr | 0x00 / 0x08 | 寄存器跳转,函数返回用 |
| I 型 | addi / andi / ori | 0x08、0x0c、0x0d | 立即数参与运算 |
| I 型 | lw / sw | 0x23、0x2b | 基址加偏移访存 |
| I 型 | beq / bne | 0x04、0x05 | 相等/不等分支 |
| I 型 | lui | 0x0f | 立即数装入高 16 位 |
| J 型 | j / jal | 0x02、0x03 | 无条件跳转、带链接跳转 |
这张表的价值在于它是后面所有模块的唯一真理源:主译码的 case 分支、ALU 的控制码、汇编测试程序的写法,全部从这里派生。改表之前先改这份清单,别直接改代码。
2.2 数据通路:五个部件的连接顺序不能乱
单周期数据通路的连接顺序是固定的:PC 输出地址给指令存储器,取出的 32 位指令同时送进译码逻辑和寄存器堆的读地址端口;寄存器堆读出的两个操作数与符号扩展后的立即数一起进 ALU 的输入端,由 ALUSrc 选择走哪一路;ALU 结果一方面作为访存地址进数据存储器,另一方面绕过存储器直接进写回多路器;最终由 MemtoReg 决定写回寄存器堆的是 ALU 结果还是存储器读出数据。
这条链上有三个容易写错的地方,值得单独拎出来:
第一个是寄存器堆。读端口必须是组合逻辑(assign 直接映射),写端口必须挂在时钟上升沿,且$zero寄存器要硬连成常数 0,任何写操作都不能改它。有人把$zero也做成普通寄存器,跑冒泡排序时看着正常,一跑带jr $ra的函数调用就飞了。
第二个是分支目标地址的计算位置。单周期里在哪算都行,但如果你打算后面改流水线,最好一开始就把分支判断放在 EX 级、把目标地址用 ALU 单独算出来,避免后面大改。这个决定影响的是第 3 章控制冒险的处理方式。
第三个是位宽。PC 是 32 位,但指令存储器深度往往只有几百到几千字,地址截断要显式写清楚,别指望综合工具帮你猜。分支偏移是 16 位立即数左移 2 位再符号扩展,这个左移不能忘。
ALU 是整个通路里唯一需要仔细设计组合逻辑的部件,代码如下:
module alu( input wire [31:0] a, input wire [31:0] b, input wire [3:0] alu_ctrl, // 自定义控制码,与主译码的 alu_op 二级译码对应 output reg [31:0] y, output wire zero // 分支指令用它判断相等 ); assign zero = (y == 32'd0); always @(*) begin case (alu_ctrl) 4'b0010: y = a + b; // add / addi / lw / sw 地址计算 4'b0110: y = a - b; // sub / beq / bne 比较 4'b0000: y = a & b; // and / andi 4'b0001: y = a | b; // or / ori 4'b0111: y = ($signed(a) < $signed(b)) ? 32'd1 : 32'd0; // slt 必须有符号比较 4'b0011: y = b << a[4:0]; // sll,移位量只取低 5 位 4'b0100: y = b >> a[4:0]; // srl,逻辑右移高位补 0 4'b0101: y = $signed(b) >>> a[4:0]; // sra,算术右移保留符号位 default: y = 32'd0; // 未定义控制码输出 0,便于波形排查 endcase end endmodule参数说明:alu_ctrl编码是自己定的,只要和译码逻辑对得上就行,不必和任何现成标准一致;zero是纯组合输出,分支指令直接用它,不要额外打拍。slt那条必须用$signed强转,否则负数比较结果全错,这个错误在测试正数时完全看不出来,属于典型的埋伏型 bug。sll用b << a[4:0]而不是b << a,是因为移位量超过 31 时 Verilog 行为不确定,取低 5 位是最省事也最稳的写法。
2.3 控制器:主译码与 ALU 译码分两级写
控制器拆成两级是让代码可读的关键。第一级看 opcode,产出 RegDst、ALUSrc、MemtoReg、RegWrite、MemRead、MemWrite、Branch、Jump 八个一位控制信号,外加一个两位的 ALUOp;第二级只看 ALUOp 和 funct 字段,产出四位 alu_ctrl 送进 ALU。
// 第一级主译码:只列关键分支,其余指令同理 always @(*) begin // 先给全部信号赋默认值,否则综合一定给你推断出锁存器 reg_write = 1'b0; mem_read = 1'b0; mem_write = 1'b0; branch = 1'b0; jump = 1'b0; alu_src = 1'b0; reg_dst = 1'b0; mem_to_reg = 1'b0; alu_op = 2'b00; case (opcode) 6'b000000: begin // R 型:结果写 rd,ALU 做寄存器运算 reg_write = 1'b1; reg_dst = 1'b1; alu_op = 2'b10; end 6'b100011: begin // lw:立即数进 ALU 算地址,读内存写回 reg_write = 1'b1; mem_read = 1'b1; alu_src = 1'b1; mem_to_reg = 1'b1; end 6'b101011: begin // sw:只写内存,不写寄存器 mem_write = 1'b1; alu_src = 1'b1; end 6'b000100: begin // beq:ALU 做减法,zero 有效则跳 branch = 1'b1; alu_op = 2'b01; end 6'b000010: begin // j:直接改 PC 低位 jump = 1'b1; end default: ; // 未识别指令按全零控制处理,等于空操作 endcase end这段代码有两个参数含义要强调。reg_dst选的是写回地址来源:R 型写 rd 字段,lw 这类 I 型写 rt 字段,选错会导致寄存器被莫名其妙覆盖,波形上表现为某个中间变量突然变成内存地址。alu_op是给第二级用的粗粒度分类,2'b10表示「看 funct」,2'b01表示「做减法」,2'b00表示「做加法」,第二级译码据此展开成具体的四位控制码。
default分支里那句空语句不是摆设,它是防止综合工具把未覆盖的 opcode 当成 don't care 进而优化掉整段逻辑。写到这里,单周期的骨架就齐了,接下来跑通一个手写的冒泡排序汇编,确认寄存器堆写回和分支都能正常工作,再进流水线。
3. 五级流水线的三个冒险:旁路、停顿与冲刷怎么写才不翻车
单周期改流水线,核心不是把组合逻辑切开,而是设计好流水线寄存器里该存什么,以及三个冒险单元怎么配合。
3.1 流水线寄存器:字段设计错了后面全是补丁
五级流水线有四个流水线寄存器:IF/ID、ID/EX、EX/MEM、MEM/WB。字段设计的经验是「下游需要的,上游全带上」,宁可多存几个控制位,也别指望在下游重新译码一次指令。重新译码会让控制信号和指令流错位,是新手最常翻的车。
| 寄存器 | 必须携带的字段 | 数量级 |
|---|---|---|
| IF/ID | pc+4、32 位指令 | 约 64 位 |
| ID/EX | 全部控制信号、rs/rt 值、立即数、rs/rt/rd 编号 | 约 150 位 |
| EX/MEM | RegWrite、MemRead、MemWrite、MemtoReg、ALU 结果、rt 值、目标寄存器编号 | 约 75 位 |
| MEM/WB | RegWrite、MemtoReg、内存读出数据、ALU 结果、目标寄存器编号 | 约 71 位 |
注意 EX/MEM 和 MEM/WB 里都存了「目标寄存器编号」,这是旁路单元的比较依据,漏了就做不了转发。
// IF/ID 流水线寄存器:复位时清空,避免上电跑出脏指令 always @(posedge clk or posedge rst) begin if (rst) begin if_id_pc4 <= 32'd0; if_id_instr <= 32'd0; end else if (if_id_flush) begin // 分支跳转时冲刷,插入气泡 if_id_pc4 <= 32'd0; if_id_instr <= 32'd0; // 全零指令译码成空操作 end else if (!if_id_stall) begin // load-use 冒险时保持不动 if_id_pc4 <= pc + 32'd4; if_id_instr <= imem_out; end end三个控制信号的含义:if_id_flush优先级最高,分支在 EX 级判定成立时拉高,清掉刚取进来的错误指令;if_id_stall次之,load-use 冒险时保持当前值,让 EX 级多等一拍;两者都不拉高才正常推进。这里把 flush 放在 stall 前面判断是刻意的,分支冲刷必须立刻生效,不能被停顿拖住。
3.2 数据冒险:旁路单元的真值表与优先级
数据冒险分两类。第一类是相邻指令的写后读,比如add $t0, $t1, $t2紧跟sub $t3, $t0, $t4,这类靠旁路(forwarding)解决,不用停。第二类是 load 后立刻用,比如lw $t0, 0($sp)紧跟add $t1, $t0, $t2,数据要到 MEM 级末尾才出来,旁路救不了,必须停一拍。
旁路单元的判据是两个比较:EX/MEM 级的目标寄存器是否等于当前 ID/EX 级的源寄存器,MEM/WB 级的是否相等。两个都命中时,EX/MEM 优先,因为它更近、数据更新。
// 旁路单元:输出 2'b10 取 EX/MEM,2'b01 取 MEM/WB,2'b00 用寄存器堆原值 always @(*) begin // 源操作数 A(rs) if (ex_mem_reg_write && (ex_mem_rd != 5'd0) && (ex_mem_rd == id_ex_rs)) forward_a = 2'b10; // 最近的一条指令优先 else if (mem_wb_reg_write && (mem_wb_rd != 5'd0) && (mem_wb_rd == id_ex_rs)) forward_a = 2'b01; else forward_a = 2'b00; // 源操作数 B(rt),逻辑完全相同,只换成 rt 比较 if (ex_mem_reg_write && (ex_mem_rd != 5'd0) && (ex_mem_rd == id_ex_rt)) forward_b = 2'b10; else if (mem_wb_reg_write && (mem_wb_rd != 5'd0) && (mem_wb_rd == id_ex_rt)) forward_b = 2'b01; else forward_b = 2'b00; // load-use 停顿:EX 级正在读内存,且目标正好是下一条要用的源 load_use_stall = id_ex_mem_read && ((id_ex_rt == if_id_rs) || (id_ex_rt == if_id_rt)); end这四段判断里有三个参数细节值得说清楚。ex_mem_rd != 5'd0这个判断不能省,$zero永远为 0,如果不排除它,任何以$zero为源的操作都会被错误转发。mem_wb_reg_write这个门控也不能省,否则一条不写回寄存器的指令(比如 sw)会被误判成数据来源。优先级上 EX/MEM 必须压过 MEM/WB,因为两条指令连着写同一个寄存器时,最近的那条才是对的,顺序写反了会出现「用旧值算新值」的诡异结果,而这种错在单条指令测试里根本暴露不出来,只有连续写同一寄存器的序列才会触发。
3.3 控制冒险:分支在 EX 级解决,冲刷两级
分支指令在 EX 级才算得出结果,此时 IF 级已经取进了两条错误指令。处理办法是拉高 flush,把 IF/ID 和 ID/EX 两个寄存器清空,让错误指令变成空操作。代价是两个周期的惩罚,对课设来说完全可以接受,不必上分支预测。
// 分支冲刷:beq 成立或 bne 不成立,且当前 EX 级确实是分支指令 wire branch_taken = id_ex_branch && (id_ex_alu_zero ^ id_ex_branch_ne); assign flush_if_id = branch_taken | jump_taken; assign flush_id_ex = branch_taken | jump_taken; // 两级一起冲,别只冲一级id_ex_branch_ne这个信号是为 bne 准备的,beq 和 bne 共用 ALU 的减法结果,靠这个位决定是「zero 有效跳」还是「zero 无效跳」。只冲刷 IF/ID 不冲刷 ID/EX 是常见的半吊子写法,结果是分支指令后面那条已经被译码的指令照样执行,寄存器被悄悄改掉,波形上看半天找不出原因。
写完这三个单元,建议先用一段不含 load-use 的纯算术程序跑流水线,确认旁路正确,再叠加 load 指令验证停顿,最后加分支验证冲刷。三个冒险分开验证,出问题时才能一眼定位到是哪一级。
4. 验证别只靠肉眼看波形:汇编激励、参考模型与矩阵乘压力用例
仿真波形是对的这件事,说服力约等于零。人眼能同时盯的信号不超过十条,而一个五级流水线跑起来有上百条信号在动。真正靠谱的办法是造一个参考模型,让 RTL 和模型跑同一段程序,逐周期比对寄存器堆和内存。
4.1 汇编激励:从冒泡排序到 4×4 矩阵乘
测试程序要分层。第一层是指令级冒烟测试,每条指令单独写一段,验证基本功能;第二层是算法级测试,用冒泡排序验证分支和访存;第三层是压力测试,用矩阵乘把指令流水线的相关性问题全部逼出来。
矩阵乘之所以合适,是因为 4×4 整数矩阵乘的内层循环天然会产生连续的数据相关:累加器被反复读写、乘加结果立即被下一次使用,正好命中旁路和 load-use 两类冒险。下面这段是内层循环的核心片段,累加和写回都在里面。
# 4x4 矩阵乘内层循环(简化版,A、B 为按行存放的 32 位整数矩阵) # $s0 = A 基址, $s1 = B 基址, $s2 = C 基址 # $t0 = i, $t1 = j, $t2 = k, $t3 = 累加器 outer_i: li $t1, 0 # j = 0 inner_j: li $t3, 0 # 累加器清零 li $t2, 0 # k = 0 inner_k: sll $t4, $t0, 2 # i*4,行偏移 addu $t5, $t4, $t2 # (i*4 + k) 是 A 的元素下标 sll $t5, $t5, 2 # 再乘 4 得到字节偏移 addu $t5, $s0, $t5 lw $t6, 0($t5) # 取 A[i][k] sll $t7, $t2, 2 # k*4 addu $t7, $t7, $t1 # (k*4 + j) 是 B 的元素下标 sll $t7, $t7, 2 addu $t7, $s1, $t7 lw $t8, 0($t7) # 取 B[k][j] mul $t9, $t6, $t8 # 若指令集未实现 mul,改为移位加法子程序 addu $t3, $t3, $t9 # 累加,连续写同一寄存器,专治旁路 bug addi $t2, $t2, 1 blt $t2, 4, inner_k # k < 4 继续 # 此处省略写回 C[i][j] 与 j、i 的循环控制这段代码的寄存器分配是有讲究的:$t3作为累加器在循环里被反复读写,任何旁路优先级错误都会让累加结果偏小;$t5、$t7作为地址计算中间量,紧跟 lw 使用,正好触发 load-use 停顿。如果指令集里没有 mul,用移位加法子程序替代,反而多了一层函数调用和jr $ra的测试覆盖,不亏。
4.2 参考模型:用 Python 写一个几十行的指令级模拟器
参考模型不需要快,只需要对。用 Python 写一个逐条解释执行的模拟器,内存用一个 dict 表示,寄存器堆用一个长度 32 的列表,PC 一个变量,主循环取指、译码、执行、写回。这个模型和 RTL 完全独立实现,两边同时出错且错得一样的概率极低,所以比对结果可信。
# 极简指令级参考模型:只实现关键指令,够比对即可 regs = [0] * 32 mem = {} # 稀疏内存,地址 -> 值 pc = 0 def sign_ext(v, bits=16): return v - (1 << bits) if v & (1 << (bits - 1)) else v for step in range(MAX_STEPS): instr = mem.get(pc, 0) op = (instr >> 26) & 0x3F rs = (instr >> 21) & 0x1F rt = (instr >> 16) & 0x1F rd = (instr >> 11) & 0x1F imm = sign_ext(instr & 0xFFFF) if op == 0x00: # R 型 funct = instr & 0x3F if funct == 0x20: regs[rd] = (regs[rs] + regs[rt]) & 0xFFFFFFFF elif funct == 0x22: regs[rd] = (regs[rs] - regs[rt]) & 0xFFFFFFFF elif funct == 0x2a: regs[rd] = 1 if regs[rs] < regs[rt] else 0 # 其余 funct 按同样套路补全 elif op == 0x23: # lw addr = (regs[rs] + imm) & 0xFFFFFFFF regs[rt] = mem.get(addr, 0) elif op == 0x2b: # sw addr = (regs[rs] + imm) & 0xFFFFFFFF mem[addr] = regs[rt] elif op == 0x04: # beq if regs[rs] == regs[rt]: pc = pc + 4 + (imm << 2) continue pc += 4 regs[0] = 0 # $zero 永远是 0,每步强制复位最保险参数说明:MAX_STEPS设一个上限防止死循环把仿真拖死,一般取测试程序指令数的三到五倍;mem用 dict 而不是列表,是因为程序里可能访问到很高的地址段,稀疏表示省内存;regs[0] = 0每步强制赋值,比在每个写回分支里判断更不容易漏。
4.3 自动比对:把「我觉得对」变成「脚本说对」
有了 RTL 和模型,剩下的事就是让两者输出同一种格式再 diff。RTL 侧在 testbench 里加一段 dump 逻辑,每写一次寄存器堆就打印一行;模型侧跑完打印同样格式。脚本一把梭,退出码非零就是失败。
#!/usr/bin/env bash # run_regression.sh:编译、仿真、比对一条龙 set -e iverilog -g2005 -o sim.out tb_cpu.v alu.v regfile.v cpu_top.v vvp sim.out +PROG=matmul4x4.hex +DUMP=rtl_dump.txt python3 refmodel.py matmul4x4.hex > model_dump.txt if diff -q rtl_dump.txt model_dump.txt > /dev/null; then echo "PASS: matmul4x4" else echo "FAIL: matmul4x4,首个差异行如下" diff rtl_dump.txt model_dump.txt | head -5 exit 1 fi脚本里+PROG和+DUMP是传给 testbench 的 plusarg,前者指定十六进制机器码文件,后者指定 dump 输出路径。set -e保证编译失败时立刻停下,不会拿着上一版的仿真结果去比对。
光比对通过还不够,得知道测了哪些情况。我一般会统计指令覆盖率,把所有 24 条指令和三类冒险各列一行,跑完测试集打勾。
| 覆盖项 | 触发用例 | 是否覆盖 |
|---|---|---|
| R 型算术逻辑 8 条 | instr_smoke.asm | 是 |
| lw / sw 及地址回绕 | sort_bubble.asm | 是 |
| beq / bne 前后向分支 | sort_bubble.asm | 是 |
| j / jal / jr 函数调用 | func_call.asm | 是 |
| 旁路 EX/MEM 优先级 | matmul4x4.asm | 是 |
| load-use 停顿 | matmul4x4.asm | 是 |
| 分支冲刷两级 | sort_bubble.asm | 是 |
这张表放进实验报告,比贴十张波形图有说服力得多。写报告的人应该都能体会到,评审最常问的就是「你怎么证明它是对的」,一张覆盖率表比任何描述都直接。
5. 上板前的静态检查与五条踩坑记录
仿真全部通过不代表能上板。从 RTL 到真正能在板子上跑的比特流,中间隔着综合、实现、时序收敛三道关,很多在仿真里被忽略的问题会在这三步集中爆发。
5.1 综合前必须过一遍的静态检查清单
这份清单我每次都会从头走一遍,大概花十分钟,能挡掉八成低级错误。
| 检查项 | 检查方法 | 期望结果 |
|---|---|---|
| 组合逻辑是否有默认赋值 | 看每个always @(*)开头 | 所有被赋值信号先赋默认值 |
| 是否推断出锁存器 | 综合日志搜 latch | 零条 latch 警告 |
| 是否有组合环 | 综合日志搜 combinational loop | 零条 |
| 复位极性是否统一 | 全局搜posedge rst/negedge rst | 全工程只用一种 |
| 位宽截断 | 综合日志搜 truncat | 逐条确认是有意为之 |
| 跨时钟域信号 | 检查是否有第二时钟 | 课设一般单时钟,出现即排查 |
| 存储器初值 | 看是否依赖上电初值 | 显式复位或初始化文件加载 |
「制造」这个词在这里要落地理解:综合通过只是说明逻辑能映射到器件资源,真正生成可烧录的比特流还要过布局布线和时序分析。时序不收敛时,工具会报负的 slack,这时候功能再对,板子上跑出来也是随机行为,看起来很玄学。
5.2 五条血泪记录:现象、原因、解决
第一条,数码管上电全亮然后乱跳。现象是复位按下后显示正常,松手几秒就开始随机变化。原因是复位信号只接到了 PC 和流水线寄存器,寄存器堆和存储器没有复位,上电初值不确定,第一条指令取出来就是随机的。解决办法是把复位扇出到所有时序元件,或者在存储器初始化文件里显式给每条指令槽填 0。
第二条,仿真波形完全正确,上板跑排序结果错一位。现象是只有最后一个元素位置不对。原因是数据存储器的写使能在时钟上升沿采样,而地址在同一时刻变化,出现了写穿透。解决是把写使能改成下降沿有效,或者让地址在写使能之前一个周期稳定下来。这类问题在行为级仿真里因为零延迟模型完全看不出来,只有加了时序延迟或者上板才暴露。
第三条,综合报一堆 latch 警告但功能仿真正常。现象是某个控制信号在某些指令下保持上一个值。原因就是第 2 章提到的,always @(*)里没有先赋默认值,综合工具认为你要保持状态,于是推断出锁存器。解决是在每个组合 always 块开头把所有输出信号赋一遍默认值,一个都不能漏。
第四条,跑矩阵乘时结果偶尔错,重跑又对了。现象是同一份比特流,复位后跑两次结果不同。原因是初始化文件加载和复位释放之间存在竞争,存储器还没加载完 CPU 就开始取指。解决是加一个上电延时计数器,复位释放后等几百个周期再放开 PC,或者用工具提供的初始化完成信号做门控。
第五条,时序分析报负 slack,但降频后能跑。现象是 50MHz 下不稳定,25MHz 下正常。原因是关键路径在 ALU 的组合逻辑上,尤其是带$signed比较的 slt 那一支,串了好几级。解决办法是把 slt 单独拆出来用减法高位判断实现,或者干脆降频并在报告里说明这是课设约束下的取舍。知道边界在哪,比硬撑着一个跑不稳的频率有价值。
6. 把 CPI 和周期数算准:让报告里的数据经得起追问
做到这一步,功能和上板都通了,最后一段功夫花在数据上。实验报告里最有分量的不是「实现了五级流水线」这句话,而是「同一段矩阵乘程序,单周期跑了多少周期,五级流水跑了多少周期,CPI 分别是多少,差在哪」。
统计周期数最简单的办法是在 testbench 里挂一个计数器,从复位释放开始数到程序最后一条指令写回为止。单周期 CPU 的 CPI 恒等于 1,总周期数就等于指令条数;五级流水线因为流水填充和冒险停顿,CPI 会略高于 1,具体高多少取决于测试程序的相关性密度。
| 实现版本 | 矩阵规模 | 指令条数 | 总周期数 | CPI |
|---|---|---|---|---|
| 单周期 | 4×4 | 约 1200 | 约 1200 | 1.00 |
| 五级流水(无旁路) | 4×4 | 约 1200 | 约 3100 | 约 2.58 |
| 五级流水(有旁路) | 4×4 | 约 1200 | 约 1580 | 约 1.32 |
| 五级流水(有旁路,8×8) | 8×8 | 约 4600 | 约 6100 | 约 1.33 |
这张表的用法是横向比:无旁路版本 CPI 高得离谱,说明大量周期耗在等数据上,加了旁路之后掉到 1.3 附近,剩下的 0.3 主要来自 load-use 停顿和分支冲刷。把这两个来源分别统计出来,就能算出「如果换成延迟槽或者分支预测,还能再省多少」,这是报告里最容易出彩的一段分析。
有个技巧值得单独说:给测试程序里的每类指令加计数器,跑完输出各类型指令占比。如果 load 指令占比高,CPI 偏高就完全解释得通;如果分支占比高而 CPI 也高,那就说明冲刷代价大。这个分布数据用脚本从汇编源里统计就行,几行正则的事,但能让报告里的每一个数字都有出处。
我自己的习惯是,每次改完流水线控制逻辑,不管改动多小,都强制把单周期版本、无旁路版本、有旁路版本三个仿真全跑一遍,三份 dump 文件一起存进带日期的目录。有次改旁路优先级,只跑了有旁路版本,结果单周期版本的参考输出被覆盖了,回头想对比发现没基线,只能重跑一遍全部用例,那次之后这个习惯就再没断过。希望帮到你。
本文还有配套的精品资源,点击获取