简介:面向计算机组成原理课程设计与体系结构入门者,这份资料围绕北航计算机学院课设流程,覆盖Logisim与Verilog-HDL部件及状态机设计、汇编语言编程、单周期CPU和流水线CPU开发等阶段,可用于课下测试复盘、课上实验准备与课程项目参考。压缩包共1541个文件,约33.25MB,包含733个.v源码、14个.circ电路、48个.asm汇编程序,以及PDF/DOCX文档、日志和工程配置文件,便于按P0至P8项目检索对应实现。已有288人学习。读者可从中获得CRC校验、ALU、格雷码计数器、矩阵乘法与排序等练习素材,也能参考7条指令单周期CPU、10条指令流水线CPU的工程组织、测试记录和排错线索,适合作为课程设计对照与查漏补缺的成套资料。
1. 从一条 addi 说起:计算机组成原理课程设计到底在造什么
验收现场最常见的一幕,是波形里 PC 一路往前跑,寄存器堆里却整片都是红线。计算机组成原理课程设计真正卡人的地方,从来不是画不出一张数据通路框图,而是要把那张框图变成一颗能在板子上跑通一段排序程序的 32 位处理器:指令取出来能译码、操作数能读到、ALU 算得对、结果能写回、分支能跳对、流水线不把指令执行两遍。它面向的是学过数字逻辑、写过几行 Verilog、但还没独立做完一个完整 CPU 的人。整套东西做下来,你会被迫把「指令集、数据通路、控制信号、时序、验证」这五件事对齐一遍,任何一处偷懒,后面都会以波形错乱的形式找回来。
2. 指令子集与数据通路:先把 32 位单周期骨架钉死
动手写第一行代码之前,先把两件事定死:支持哪些指令、每个部件多宽。这两件事定错了,后面改起来不比重写省事。我带过几届课设,翻车最惨的从来不是写不出 ALU,而是第三天突然想加一条jal,结果发现寄存器堆的写地址选择器、PC 的来源选择器全要改。
2.1 指令子集怎么砍:先保 12 条能跑通排序的指令
课程设计的指令集不是越多越好。评判看的是能不能跑通一段有分支、有访存、有循环的真实程序,而不是指令条数。我的建议是先锁死 12 条,把单周期跑通,再加 2 到 3 条做扩展。
| 类型 | 指令 | 编码要点 | 取舍理由 |
|---|---|---|---|
| R 型 | add / sub / and / or / slt | rs、rt 读,rd 写,funct 区分 | 算术逻辑和比较,排序程序必需,不能砍 |
| I 型 | addi | rt 写,imm 符号扩展 | 循环计数器和数组下标全靠它 |
| I 型 | lw / sw | rs 基址 + imm 偏移 | 数组读写,冒泡排序的核心 |
| I 型 | beq / bne | 比较 rs、rt,偏移左移两位 | 循环回跳,只有 beq 时写循环很别扭 |
| J 型 | j / jal | 26 位目标域拼接 PC 高 4 位 | jal 是加分项,先留接口后补 |
被砍掉的第一批是lb/lh/sb/sh这类字节半字访问。理由很实在:字节访问要处理地址对齐、读写掩码、大小端,单周期里会把 DMEM 的接口从一根 32 位数据线扩成四根带掩码的线,调试成本翻倍,而排序程序一个字节都用不上。第二批是乘除法,多周期指令塞进单周期数据通路,等于要额外做状态机,属于自己给自己加难度。第三批是异常和中断,课设阶段基本用不到,真要做也是最后有余力再说。
定完指令集,紧接着要做的是把每条指令的 32 位编码格式抄在一张纸上贴在显示器边上。R 型是op[31:26] | rs[25:21] | rt[20:16] | rd[15:11] | shamt[10:6] | funct[5:0],I 型是op | rs | rt | imm[15:0],J 型是op | target[25:0]。这一步看着笨,但省下的是后面每次查波形的十分钟。
2.2 数据通路五段划分与关键部件的位宽对照
单周期 CPU 的本质是「一条指令在一个时钟周期内走完全部五个阶段」。取指从 IMEM 拿指令,译码读出寄存器操作数并扩展立即数,执行交给 ALU,访存读写 DMEM,写回塞回寄存器堆。整条路径上没有状态机,只有组合逻辑和一组时序元件。
| 部件 | 位宽 / 深度 | 关键参数说明 |
|---|---|---|
| PC | 32 位 | 复位值固定为 0,跳转时由三选一选择器决定下一拍 |
| IMEM | 1024 × 32 位(4KB) | 地址线接pc[11:2],字寻址,别接字节地址 |
| 寄存器堆 | 32 × 32 位 | 两个异步读口,一个同步写口,0 号寄存器硬连零 |
| 立即数扩展 | 16 → 32 位 | 符号扩展,{{16{inst[15]}}, inst[15:0]} |
| ALU | 32 位输入 / 32 位输出 | 操作码 4 位,额外输出零标志 |
| DMEM | 1024 × 32 位(4KB) | 地址线接alu_out[11:2],读写互斥 |
| 分支目标加法器 | 32 位 | pc_plus4 + (imm_ext << 2),别忘左移两位 |
位宽表里最容易被忽略的是两个地址截位。PC 是字节地址,每条指令 4 字节,所以送进 IMEM 的地址必须是pc[11:2],直接接pc[9:0]会变成每四个字覆盖一次。DMEM 同理,lw和sw的偏移是字节偏移,但存储器组织是字,送进去的必须是alu_out[11:2]。这两个截位错了,现象都是「程序跑到一半数据全都差 4 倍」,而且波形上看不出来,属于典型的需要靠血泪经验记住的点。
另一个参数是容量。1024 个字足够放下排序程序加数据区,编译出来的机器码一般不超过 200 条指令,数据区 8 到 16 个字。真不够就扩到 2048 字,代价是综合后块存储器资源占用上升,入门级板卡上一般还是够的。
2.3 控制信号编码表:把 ALUop 与分支判断落到真值表
控制单元是单周期里唯一的「大脑」,输入是指令的 op 域和 funct 域,输出是一组选择信号和使能信号。写之前先把表列全,代码就是照抄。
| 指令 | RegDst | ALUSrc | MemtoReg | RegWrite | MemRead | MemWrite | Branch | Jump | ALUOp |
|---|---|---|---|---|---|---|---|---|---|
| R 型 | 1 | 0 | 0 | 1 | 0 | 0 | 0 | 0 | 10 |
| addi | 0 | 1 | 0 | 1 | 0 | 0 | 0 | 0 | 00 |
| lw | 0 | 1 | 1 | 1 | 1 | 0 | 0 | 0 | 00 |
| sw | x | 1 | x | 0 | 0 | 1 | 0 | 0 | 00 |
| beq | x | 0 | x | 0 | 0 | 0 | 1 | 0 | 01 |
| bne | x | 0 | x | 0 | 0 | 0 | 1 | 0 | 01 |
| j | x | x | x | 0 | 0 | 0 | 0 | 1 | xx |
表里的x不是随便填的,是「这一位在这一拍不起作用」。但对综合工具来说,x会变成无关项,运气不好就被优化成锁存器。所以我在实际代码里一律填 0,宁可多写几个字符,也不给综合器自由发挥的空间。
ALUOp 只是两级译码的第一级,第二级要结合 funct 域才能定出真正的 ALU 操作。常见的做法是:ALUOp = 00时强制加法(lw、sw、addi都用它),ALUOp = 01时强制减法(分支比较用),ALUOp = 10时再看funct[3:0]映射到 add/sub/and/or/slt。这一段写成一个case嵌套,二十行以内搞定,但它决定了你后面调试时能不能一眼看出是哪条指令出错。
3. 单周期 CPU 的最小可跑通实现:从寄存器堆到第一条 addi
骨架定完就进入写代码阶段。单周期版本的价值不在于性能,而在于它是一个「所有信号在同一拍内全部可见」的参照物。后面做流水线,出问题时第一件事永远是回到单周期版本对答案。所以这一版要写得干净,每个模块单独一个文件,端口命名统一,别图省事全塞一个文件里。
3.1 寄存器堆:异步读、同步写,0 号寄存器硬连零
寄存器堆是整个 CPU 里唯一被三个阶段同时摸到的部件,接口写错了后面全是玄学问题。
module regfile ( input wire clk, input wire rst_n, input wire we, // 写使能,来自控制单元的 RegWrite input wire [4:0] raddr1, // rs input wire [4:0] raddr2, // rt input wire [4:0] waddr, // rd 或 rt,由 RegDst 选出 input wire [31:0] wdata, // 写回数据,来自 ALU 或 DMEM output wire [31:0] rdata1, output wire [31:0] rdata2 ); reg [31:0] rf [0:31]; integer i; // 异步读:单周期要求同一拍内拿到操作数,不能用时钟沿 assign rdata1 = (raddr1 == 5'd0) ? 32'd0 : rf[raddr1]; assign rdata2 = (raddr2 == 5'd0) ? 32'd0 : rf[raddr2]; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin for (i = 0; i < 32; i = i + 1) rf[i] <= 32'd0; // 上板必须清零,否则仿真里全是 X end else if (we && (waddr != 5'd0)) begin rf[waddr] <= wdata; // 0 号寄存器写入被丢弃,读永远返回 0 end end endmodule这段代码有三个点值得说明。第一,读口是组合逻辑而不是always @(posedge clk),因为单周期里「译码读寄存器」和「执行算 ALU」在同一拍完成,读口一旦寄存化,操作数就要晚一拍,整条通路会对不上。第二,复位用了一个for循环把 32 个寄存器全部清零,这一行在仿真里是必须的,否则上电后寄存器堆是x,x会顺着 ALU 一路传播,最后整个波形全是红线,排查起来非常痛苦。第三,写地址加了!= 0的判断,虽然写 0 号寄存器本身无害,但一旦后面做流水线,写 0 号寄存器会误触发旁路逻辑,从一开始就堵住这个口子更省心。
3.2 ALU 与立即数扩展:符号位、移位量和 slt 的 signed
ALU 看着简单,几个细节错了会非常难查。
module alu ( input wire [31:0] a, input wire [31:0] b, input wire [3:0] alu_op, output reg [31:0] y, output wire zero ); // 零标志只服务于分支比较,必须由减法结果单独产生 assign zero = (y == 32'd0); always @(*) begin case (alu_op) 4'b0000: y = a + b; // add / lw / sw / addi 4'b0001: y = a - b; // sub / beq / bne 4'b0010: y = a & b; // and 4'b0011: y = a | b; // or 4'b0100: y = ($signed(a) < $signed(b)) ? 32'd1 : 32'd0; // slt 必须按有符号比较 4'b0101: y = b << a[4:0]; // sll,移位量只取低 5 位 4'b0110: y = b >> a[4:0]; // srl,逻辑右移 default: y = 32'd0; // 兜底,防止推断锁存器 endcase end endmodulezero这一行是单周期里最容易埋雷的地方。如果把它写成assign zero = (alu_op == 4'b0001) && (y == 32'd0),逻辑上更严谨,因为只有减法结果为零才代表相等;但如果直接写成y == 0,那么slt指令在比较结果不相等时输出 1,zero就是 0,看着没事;可一旦slt的两数相等,输出 0,zero变成 1,紧跟在后面的beq就可能误跳。这个问题在单周期里因为指令不重叠还能躲过去,一上流水线立刻原形毕露。
slt用$signed比较也是必须的。Verilog 默认按无符号比较,-1 < 1会算成32'hFFFFFFFF < 32'h00000001,结果是假,排序程序里负数一大就全乱。这个坑非常隐蔽,因为正数测试永远是对的。
立即数扩展和跳转地址拼接单独拎出来:
// I 型立即数符号扩展:lw/sw 的偏移可以是负数 assign imm_ext = {{16{instr[15]}}, instr[15:0]}; // J 型目标地址:高 4 位保留当前 PC+4 的,低位补两个 0 assign jump_addr = {pc_plus4[31:28], instr[25:0], 2'b00};jump_addr那个2'b00是最容易漏的。指令地址按字对齐,目标域存的是字编号,拼回字节地址必须左移两位。漏掉的下场是跳转目标永远落在错误位置,波形上 PC 跳到一个看起来「差不多的数」,但就是不对。
3.3 顶层例化与第一条 addi 的 testbench
单周期顶层要做的事就是把上面这些部件按图连线,中间插上几个多路选择器。写完先别急着跑复杂程序,用一条addi验证通路。
`timescale 1ns / 1ps module tb_single_cycle; reg clk, rst_n; wire [31:0] pc_out, wb_data; wire [4:0] wb_addr; cpu_top u_cpu ( .clk (clk), .rst_n (rst_n), .pc_out (pc_out), .wb_data (wb_data), // 顶层预留的观测口,方便 testbench 直接看 .wb_addr (wb_addr) ); always #5 clk = ~clk; // 100MHz 仿真时钟 initial begin clk = 0; rst_n = 0; $readmemh("inst.hex", u_cpu.u_imem.mem); // 指令镜像 $readmemh("data.hex", u_cpu.u_dmem.mem); // 数据初值 #20 rst_n = 1; #200; // 单周期跑 20 条指令足够 $display("x1=%h x2=%h mem0=%h", u_cpu.u_regfile.rf[1], u_cpu.u_regfile.rf[2], u_cpu.u_dmem.mem[0]); $finish; end endmodule这段 testbench 有两个技巧。一是$readmemh直接灌 IMEM 和 DMEM,比在initial里一条条赋值快得多,前提是你能把汇编程序转成十六进制机器码,最简单的办法是写个几十行的 Python 脚本做汇编,格式一行一个 32 位十六进制数。二是层次化引用u_cpu.u_regfile.rf[1]直接看寄存器内容,比把每个寄存器都拉到顶层端口上省事太多,仿真器都支持,综合时这些引用会被忽略。
addi x1, x0, 5这条指令的期望现象是:pc_out复位后为 0,一拍后变 4,wb_addr为 1,wb_data为 5。跑通了再换成add x2, x1, x1,看是不是 10。这两条过了,说明取指、译码、执行、写回四段是通的,剩下只需要把lw/sw和分支补上。
3.4 上板:时钟、复位与用数码管看结果
仿真过了不等于板子上能跑。上板阶段的第一原则是:不要用分频出来的信号当时钟。
| 项目 | 推荐做法 | 说明 |
|---|---|---|
| 时钟源 | 板载晶振直接进clk端口 | 50MHz 或 100MHz,综合工具能自动识别为全局时钟网络 |
| 降速 | 用计数器产生clk_en使能信号 | 每个模块写always @(posedge clk) if (clk_en),而不是always @(posedge clk_div) |
| 复位 | 按键 + 两级同步 + 20ms 计数去抖 | 按键抖动会让 CPU 反复复位,现象是数码管乱闪 |
| 观测 | 数码管动态扫描显示某个寄存器 | 扫描频率 1kHz 左右,段选译码单独一个模块 |
| 引脚约束 | 板级约束文件写全,时钟引脚必须指定 | 时钟引脚漏写,综合工具会把它当普通 IO,时序全乱 |
分频时钟上板是新手最常见的翻车点。用逻辑资源分出来的时钟走的是普通布线网络,偏斜大、延迟不可控,仿真里一切正常,上板后 CPU 会随机跑飞,现象毫无规律,非常像玄学。正确做法是全局时钟照常进,用一个 32 位计数器每 N 拍拉高一次clk_en,所有时序逻辑都在clk的上升沿里判断clk_en。这样时序分析是按真实时钟做的,跑多慢都稳。
4. 从单周期跨到五级流水线:冒险检测与旁路的可落地改法
单周期跑通之后,多数课设的下一个要求是改成五级流水。这一步的难点不在切分,而在控制:指令重叠之后,后一条指令读到的可能是前一条还没写回的值,分支跳转时后面已经取进来的指令必须作废。这三类问题有各自的解法,但它们的信号会互相打架,顺序搞错就会出现「加了旁路反而更不对」的怪现象。
4.1 五级流水寄存器的切分与冲刷优先级
流水寄存器的写法有一个固定套路:冲刷 > 停顿 > 正常更新,这个优先级顺序不能颠倒。
// IF/ID 流水寄存器:分支跳转时冲刷,load-use 时保持 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin id_instr <= 32'd0; id_pc_plus4 <= 32'd0; end else if (if_id_flush) begin // 最高优先级:作废 id_instr <= 32'd0; // 全置零,译码成一条无害的 nop id_pc_plus4 <= 32'd0; end else if (!if_id_stall) begin // 次优先级:不使能则保持 id_instr <= if_instr; id_pc_plus4 <= if_pc_plus4; end end这里有个设计选择值得说清楚:冲刷时把指令置成 0,还是置成一条真正的nop。置 0 的好处是简单,坏处是32'h00000000在 RISC 编码里通常会被译成sll x0, x0, 0,也就是一条写 0 号寄存器的指令,因为写 0 号被屏蔽,实际效果等于 nop,能白拿一个便利。但如果你的译码器对 op=0 的处理不是走 R 型通路,就会出问题。稳妥做法是译码器里显式判断instr == 32'd0时把所有写使能拉低,一劳永逸。
冲刷信号要覆盖所有携带控制信息的流水寄存器,不只是 IF/ID。ID/EX、EX/MEM、MEM/WB 三级里,只要有一个RegWrite或MemWrite没被清掉,被作废的那条指令就依然会写寄存器或写内存。这是「分支跳转看起来对了,但结果总是差一点」的头号原因,也是最难查的一类,因为波形上 PC 完全正确。
4.2 数据冒险:旁路单元的两个优先判断
旁路(forwarding)的本质是:与其等前一条指令写回寄存器堆,不如直接把它的结果从流水线中间拽过来用。
// 选择 EX 级两个 ALU 操作数的来源:0=寄存器堆 1=EX/MEM 2=MEM/WB always @(*) begin fwd_a = 2'd0; if (ex_mem_regwrite && (ex_mem_rd != 5'd0) && (ex_mem_rd == id_ex_rs)) fwd_a = 2'd1; // 最近的一条,优先级最高 else if (mem_wb_regwrite && (mem_wb_rd != 5'd0) && (mem_wb_rd == id_ex_rs)) fwd_a = 2'd2; end always @(*) begin fwd_b = 2'd0; // rt 只有在「该指令确实把 rt 当源操作数」时才需要旁路 if (ex_mem_regwrite && (ex_mem_rd != 5'd0) && (ex_mem_rd == id_ex_rt)) fwd_b = 2'd1; else if (mem_wb_regwrite && (mem_wb_rd != 5'd0) && (mem_wb_rd == id_ex_rt)) fwd_b = 2'd2; end这段逻辑有四个必须遵守的约束。第一,EX/MEM的判断必须放在MEM/WB前面,因为它更新,如果两条指令写同一个寄存器,用旧值就错了。第二,rd != 0的判断不能省,否则任何一条写 0 号寄存器的指令都会污染旁路。第三,rt的旁路要额外判断这条指令是否真的把rt当源操作数,addi、lw的rt是目的寄存器,不能参与旁路,否则会读到垃圾值。第四,lw的结果在MEM/WB阶段才可用,从EX/MEM旁路过来的是地址而不是数据,这个特例必须单独处理,也就是下面要说的 load-use。
4.3 装载-使用冒险:只停一拍就够
lw后面紧跟一条用到该寄存器的指令,即使有旁路也救不了,因为数据要到访存结束才出来。这时候只能停一拍。
// load-use 检测:ID 级指令的源寄存器 == EX 级 load 的目的寄存器 assign load_use = id_ex_memread && ((id_ex_rt == if_id_rs) || (id_ex_rt == if_id_rt)); assign pc_stall = load_use; // PC 保持,不取新指令 assign if_id_stall = load_use; // IF/ID 保持,指令不往下走 assign id_ex_flush = load_use; // ID/EX 清空,插入一个气泡三个信号必须同时生效,缺一个都会出问题。只清 ID/EX 不停 PC,等于把同一条指令执行两遍;只停 PC 不清 ID/EX,气泡插不进去,后一条指令还是会提前读到旧值。停顿只需要一拍,因为下一拍lw进入MEM/WB,数据就可以通过fwd = 2'd2旁路出来了。
还有一个容易漏的细节:如果lw后面跟的是sw,sw的rt是要写进内存的数据,同样存在 load-use 冲突,检测时必须把sw的rt也算进去。很多人只考虑了 R 型指令的rs和rt,结果排序程序里lw接sw的组合一跑就错。
4.4 用一段冒泡排序压流水线:周期数怎么算
验证流水线只用几条指令是不够的,必须上一段有循环、有分支、有数组访问的真实程序。冒泡排序是最合适的选择:它同时包含嵌套循环(分支密集)、相邻元素比较交换(load-use 密集)、数组下标计算(addi 密集)。
写测试程序时按这个顺序推进:
| 阶段 | 测试内容 | 观察点 |
|---|---|---|
| 1 | 8 个数据的排序,全部正数 | 结果正确性,MEM 里的 8 个字是否升序 |
| 2 | 掺入负数,验证slt的有符号比较 | 负数是否被排到前面 |
| 3 | 数组首地址设为 0,验证lw的零偏移 | 排除地址计算碰巧正确的可能 |
| 4 | 全部元素相等,验证循环边界 | 不死循环、不越界写内存 |
周期数的估算有个通用公式:总周期 = 动态指令数 + 4(流水填充)+ load-use 停顿数 + 分支冲刷数。8 元素冒泡排序编译出来大约 50 到 60 条动态指令,load-use 出现在每次比较交换处,分支冲刷发生在每次内层循环退出和回跳。先手算一个估计值,再和波形里pc_out翻转的次数对,差得多就说明有地方多停了或者少停了。
5. 排查手册:五个让仿真和上板对不上的坑
调试阶段真正花时间的不是写代码,是看着一个错误现象猜原因。下面五条是我这几年在课设里反复见到的,基本覆盖了 80% 的翻车场景。
5.1 复位释放后寄存器堆全是 X
现象:仿真跑起来,rdata1、rdata2从头到尾是红色波浪线,ALU 输出也是x,但 PC 正常跳动。
原因:寄存器堆的复位分支里没有做全量清零,只写了rf[0] <= 0,或者根本没写复位分支,指望initial块赋初值。综合后initial无效,仿真里x会通过 ALU 传播到所有下游信号。
解决:在always @(posedge clk or negedge rst_n)的!rst_n分支里用for循环把 32 个寄存器全部清零。检查方法是复位期间把rf[1]到rf[31]都加到波形里,逐个确认不是x。
5.2 分支跳转正确但结果错:zero 标志被 slt 污染
现象:beq指令的跳转目标、PC 值全对,但排序结果里偶尔有两个元素顺序颠倒,且只在特定数据下出现。
原因:zero标志写成y == 0,而slt在比较两数相等时输出 0,导致zero被拉高。如果紧跟着一条依赖zero的分支指令(流水线里这很常见),就会误跳转。
解决:zero只在 ALU 执行减法或分支比较时才有意义,实现上要么用(alu_op == SUB) && (a == b)单独产生,要么让分支判断直接比较a和b而不依赖 ALU 输出。单周期里这个 bug 可能被掩盖,流水线里一定会暴露。
5.3 上板跑飞:把分频时钟当时钟用
现象:仿真完全正确,上板后 CPU 随机跑飞,数码管显示乱码,重新按复位有时能好有时不能,换一块同型号板子现象又不一样。
原因:用计数器分频出来的信号接到了其他模块的posedge clk上。这个信号走普通布线,偏斜和延迟不可控,综合工具的时序分析也覆盖不到。
解决:全局时钟只从晶振引脚进,所有时序逻辑统一用posedge clk,降速改成if (clk_en)使能。改完重新跑一遍时序分析,确认没有未约束的时钟路径。
5.4 lw/sw 访问错位:字节地址和字地址混着用
现象:sw写进去的数,lw读出来是另一个元素的值,或者数组整体偏移了一位;程序不崩,但排序结果永远不对。
原因:DMEM 是字组织的,地址线却直接接了alu_out[9:0](字节地址),导致地址每次加 4 实际跳过了 4 个字。同理 IMEM 接了pc[9:0]会出现每四条指令取一条。
解决:IMEM 地址接pc[11:2],DMEM 地址接alu_out[11:2]。验证方法很简单:写一段连续sw到地址 0、4、8、12 的程序,看mem[0]到mem[3]是不是依次被填上,而不是只有mem[0]被填了四次。
5.5 综合报出推断锁存器:case 少写 default
现象:综合日志里一排 "inferred latch" 警告,上板后某些指令偶尔执行错误,复位也不能稳定复现。
原因:always @(*)里用case或if但没有覆盖所有输入组合,综合工具为了「保持原值」推断出锁存器。锁存器对毛刺敏感,在组合逻辑链路上会引入不可预测的行为。
解决:每个组合逻辑always块的第一句先给输出赋默认值,case补default,if补else。综合日志里出现任何 latch 警告都要当错误处理,不能因为「仿真跑通了」就放过。
6. 把验证做成流水线:指令级随机比对与时序收敛
做完流水线之后,靠手工写测试程序已经压不住复杂度了。我后来的做法是写一个几十行的指令级参考模型,用 Python 实现那 12 条指令的语义,输入同一份机器码,输出每条指令执行后的寄存器堆和内存快照,再和 Verilog 仿真导出的快照逐行比对。Verilog 侧只需要在 testbench 里每条指令退休时$display一行pc, wb_addr, wb_data,导成文本,两边用脚本对齐。这套东西写起来不超过一个晚上,但它能在一分钟内跑完上百个随机程序,把「某个特定数据组合下才出错」的问题一次性暴露出来。
6.1 用三十行脚本做指令级比对
# golden_model.py:只实现课设那 12 条指令,够用就行 import sys def run(insts, data, steps=2000): rf, mem, pc, trace = [0] * 32, list(data), 0, [] for _ in range(steps): inst = insts[pc // 4] op, rs, rt = inst >> 26, (inst >> 21) & 31, (inst >> 16) & 31 rd, imm = (inst >> 11) & 31, inst & 0xFFFF if imm & 0x8000: # 立即数符号扩展 imm -= 0x10000 if op == 0: # R 型 funct = inst & 0x3F if funct == 0x20: v = rf[rs] + rf[rt] elif funct == 0x22: v = rf[rs] - rf[rt] elif funct == 0x24: v = rf[rs] & rf[rt] elif funct == 0x25: v = rf[rs] | rf[rt] elif funct == 0x2A: v = 1 if rf[rs] < rf[rt] else 0 rf[rd] = v & 0xFFFFFFFF trace.append((pc, rd, rf[rd])) elif op == 0x08: rf[rt] = (rf[rs] + imm) & 0xFFFFFFFF # addi elif op == 0x23: rf[rt] = mem[(rf[rs] + imm) // 4] # lw elif op == 0x2B: mem[(rf[rs] + imm) // 4] = rf[rt] # sw elif op in (0x04, 0x05): # beq / bne eq = (rf[rs] == rf[rt]) if eq == (op == 0x04): pc = pc + 4 + imm * 4 continue elif op == 0x02: pc = (pc & 0xF0000000) | ((inst & 0x3FFFFFF) << 2); continue pc += 4 return rf, mem, trace if __name__ == "__main__": insts = [int(l, 16) for l in open(sys.argv[1]) if l.strip()] data = [int(l, 16) for l in open(sys.argv[2]) if l.strip()] rf, mem, trace = run(insts, data) for pc, addr, val in trace: print(f"{pc:08x} {addr:02d} {val:08x}")脚本里的三处细节和硬件一一对应:立即数符号扩展、lw/sw用字地址除 4、j型保留 PC 高 4 位。这三处只要有一处和 Verilog 写得不一样,比对就会出现系统性偏移,反而帮你检查出硬件实现里的疏漏。
6.2 覆盖率比测试条数更值得盯
随机测试容易陷入「跑了一万条,但全在测加法」的假象。我一般会盯三个覆盖率指标:12 条指令是否每条都执行过、beq和bne是否都出现过「跳」和「不跳」两种结果、load-use停顿和分支冲刷是否都被触发过。前两个用脚本统计指令轨迹就能算,第三个在 Verilog 侧加两个计数器,仿真结束$display出来。三个指标都到 100%,这套 CPU 基本就敢拿去验收了。
时序收敛这块,单周期版本按 25MHz 约束试一版,流水线版本可以试着往 50MHz 推。如果综合报告里关键路径落在 ALU 或分支目标加法器上,那是正常的;如果落在某个多路选择器上,通常是旁路逻辑写成了多级级联的if-else,改成一级case就能砍掉一截延迟。
最后说个我自己的习惯:每次改完代码,不管改动多小,先跑一遍那 8 元素冒泡排序,把结果和上一次的快照比对,一致才继续往下做。这个动作花不到两分钟,但它把「改了 A 结果 B 坏了」这类问题挡在了十分钟以内,比事后翻波形划算太多。希望帮到你。
本文还有配套的精品资源,点击获取