简介:北航计算机学院计算机组成原理课程设计完整资源包,面向正在修读该课程或准备相关实验考核的本科生,覆盖P0到P8九个阶段的课下与课上任务。内容贯穿Logisim部件与状态机设计、CRC校验电路、ALU、正则表达式匹配、Verilog HDL实现、汇编程序设计(矩阵乘法、排序、回文串判断),以及单周期CPU、流水线CPU设计与工程化方法,适合从零搭建到调试验收全流程参考。包体共1541个文件、约33.25MB,以.v Verilog源文件、.circ Logisim电路、.asm汇编源码、.c C程序为主,辅以工程配置、仿真报告、PDF/Word文档和操作记录,可按项目目录逐步对照学习。已有287人学习/下载。通过该资源可获取完整的项目源码与工程配置、各阶段说明笔记,以及从组合逻辑到复杂处理器流水线的递进式实现思路,既能用于课前预习,也能在课上测试或验收前快速排查问题、补齐设计短板。
1. 计算机组成原理课程设计:先定指令集,再谈写代码
很多同学拿到计算机组成原理课程设计的第一反应是找一份现成的工程代码,改改引脚约束、换个测试程序就交差。但真正动手后会发现,这门课设计根本不像软件课设那样“调通接口就完事”,它要求你用硬件描述语言设计出一颗能取指、译码、执行、访存、写回的最小处理器,并在真实芯片上跑出结果。它的难点不在代码量,而在“每一个控制信号都要有依据”,在仿真里对的东西上了板可能完全不动。
如果你正在为这门课设计选型犹豫,或者代码写了一半开始怀疑数据通路画错了,这篇文章会按我自己的完整落地路径讲清楚:从指令集怎么切、模块怎么分,到控制器真值表怎么写、上板前怎么验证。适合已经学完课本、准备动手实现单周期CPU的本科生,也适合想快速了解一颗最小CPU怎么从文字变成可综合工程的软件方向工程师。
2. 课程设计的方案选型:指令集、实现方式和工具链怎么定
课程设计最耗时间的往往不是写代码,而是在选型上反复摇摆。我见过太多人先花了三周用一套方案,做到一半发现资料难找、工具链不顺手,推翻重来。方案选型这件事不是玄学,就是几个成本对比,早定下来能省出后面调试的一整个星期。
2.1 指令集选 MIPS32 子集还是 RISC-V:课程场景下我为什么选 MIPS32
如果你去问做体系结构研究的人,他大概率建议你上手 RISC-V,毕竟模块化、开源、生态在扩展。但课程设计是周期受限的工程任务,我一般会选 MIPS32 的一个精简子集,原因很实际:参考资料的闭环程度完全不同。
| 对比项 | MIPS32 子集 | RISC-V(RV32I) |
|---|---|---|
| 教材配套 | 大多数组成原理教材的数据通路、控制器图就是 MIPS 风格 | 需要自己从官方手册翻译成电路图 |
| 模拟器 | MARS 可以直接写汇编、单步调试、看寄存器,能当参考模型 | 需要额外搭建模拟器或用 Spike,上手门槛高 |
| 汇编器 | 课设常用汇编指令少,MARS 一键导出机器码 | 需要交叉编译工具链,配置流程长 |
| 参考工程 | 网上同类课设案例多,踩坑经验可查 | 资料相对分散,且风格差异大 |
| 与课本衔接 | 控制器真值表可以直接对照课本绘制 | 需要对 RV32I 的立即数编码额外做转换 |
RISC-V 不是不好,而是课程设计的时间大部分应该花在数据通路和验证上,不该花在“把新的指令编码形式翻译成课本熟悉的那套信号”上。如果你后续要参加比赛或做研究,再换到 RISC-V 不迟。课设阶段把 MIPS 风格的控制器真正弄懂,换指令集只是重新填一张真值表。
2.2 指令子集怎么切:一条不改也能跑通冒泡排序的 12 条指令
定指令集是第一个真正影响电路规模的决策。有人喜欢把所有指令都加上,结果控制器的真值表膨胀到难以检查,综合时序也容易出问题。我的习惯是先按“能不能写完一个排序程序”为标准切出一个最小集合,跑通之后有余力再加。
基础集合我固定用这 12 条:
| 类别 | 指令 | 用途 |
|---|---|---|
| R 型 | add, sub, and, or, slt | ALU 算术逻辑运算与比较 |
| I 型 | addi | 立即数加法,常用于地址计算和循环计数 |
| I 型 | lw, sw | 访存,排序算法里读数组、写数组 |
| I 型 | beq | 条件分支,循环和 if 都靠它 |
| I 型 | lui | 加载高位立即数,构造 32 位常数 |
| J 型 | j | 无条件跳转 |
| 伪指令 | nop | 用 sll $0, $0, 0 实现,调试时占位 |
这 12 条能覆盖控制器里的全部关键信号:RegDst、ALUSrc、MemtoReg、MemRead、MemWrite、Branch、Jump、ALUOp。其中 lui 容易被忽略,但很多测试程序需要把大于 16 位的常数放进寄存器,没有它只能靠多条 addi 拼,纯属浪费时间。nop 则是在你写完指令存储器但程序还没载入时,用来填充空白位置,避免 CPU 取到全 X 的指令导致控制器乱跳。
在汇编阶段,MARS 会把 beq、j 的目标地址自动计算好并生成机器码,课设阶段可以先不自己写汇编器,用模拟器导出的十六进制文件直接喂给指令存储器。但你要明白,beq 的目标地址字段是相对当前指令地址加 4 的偏移量,不是绝对地址,这个信息在第 5 章排查跳转错误时会用到。
2.3 实现方式:Verilog 还是 Logisim,取决于课程设计有没有“上板”这一项
如果你的课程设计要求最终下载到 FPGA 开发板上跑出效果,老老实实选 Verilog。Logisim 适合课程作业阶段画电路图验证思路,但它的导出文件没法直接被综合工具使用,也无法做时序约束。反过来,如果课程设计只要求提交电路图和仿真波形,Logisim 的可视化反而比 Verilog 更容易向评阅老师讲清数据通路。
我的选择标准就一条:看验收时要不要“演示实物”。要演示,就 Verilog + 综合工具;只交报告,就 Logisim。还有第三种情况,课程设计允许二选一,我会优先 Verilog,因为它的成果可以延续到后续的体系结构实验和比赛,Logisim 的画图能力在工作场景里几乎用不上。
2.4 总体架构:哈佛结构六段式数据通路怎么划分模块
课程设计里的 CPU 一般做哈佛结构,指令存储器和数据存储器分开。这样做的直接好处是取指和访存可以同时进行,单周期内不会出现同一个存储器的读端口冲突。如果你非要用统一存储器,就得设计双端口 RAM 或者把时钟周期拆成两个阶段,复杂度明显上升。
我习惯把数据通路切成六个阶段:取指(PC -> 指令存储器)、译码(寄存器堆读、控制器生成信号)、执行(ALU 计算、分支判定)、访存(数据存储器读写)、写回(寄存器堆写入)、PC 更新(决定下一条指令地址)。模块划分如下:
| 模块 | 职责 | 关键端口 |
|---|---|---|
| pc | 保存当前指令地址,支持顺序、分支、跳转 | 时钟、复位、PCWrite |
| imem | 指令存储器,按字读取 | 地址、指令输出 |
| regfile | 寄存器堆,两读一写 | 读地址、写地址、写数据、写使能 |
| alu | 算术逻辑运算 | 两输入、控制信号、零标志 |
| dmem | 数据存储器,按字读写 | 地址、写入数据、读写使能 |
| controller | 主控制器,生成全局控制信号 | 操作码、各控制信号输出 |
| alu_controller | ALU 控制,根据 ALUOp 和 funct 生成具体运算 | ALUOp、funct、ALU 控制信号 |
| signext | 立即数扩展 | 16 位进、32 位出 |
这里一个常见误解是“把控制器写在顶层里”。控制器应该是独立模块,方便你对着真值表一条一条检查。我在第一次做的时候把控制器逻辑直接写在顶层模块里,结果某个控制信号被后面代码重新赋值,仿真查了很久才发现是多路驱动问题。模块边界清晰,定位错误时的搜索范围就小。
3. 用 Verilog 实现单周期 CPU:数据通路与控制器的可复现写法
方案定了之后,接下来的工作就是把每一段数据通路翻译成 Verilog。这里给出的代码不是完整工程,而是每个模块的核心骨架,你可以照着拼接成可综合的顶层。写的时候记住一个原则:组合逻辑用阻塞赋值,时序逻辑用非阻塞赋值,这个原则会在第 5 章展开讲为什么。
3.1 顶层模块与端口:命名、位宽、时钟复位怎么约定
顶层模块的任务是把各个子模块连接起来,本身不写功能逻辑。端口设计上,除了时钟和复位,我习惯把 PC、当前指令、ALU 输出作为调试端口引出,这样 testbench 里可以直接监控运行状态,不用逐层跨模块引用。
module cpu_top ( input wire clk, input wire rst_n, output wire [31:0] debug_pc, output wire [31:0] debug_inst, output wire [31:0] debug_alu_out, output wire [31:0] debug_rd2 ); // 内部信号:数据通路连线 wire [31:0] pc, inst, rd1, rd2, imm_ext; wire [31:0] alu_in2, alu_out, mem_data, wb_data; wire [4:0] waddr; wire zero; wire RegWrite, ALUSrc, MemtoReg, MemRead, MemWrite; wire Branch, Jump; wire [1:0] ALUOp; wire [3:0] alu_ctrl; // 各子模块实例化:pc、imem、regfile、alu、dmem、controller 等 // 连接思路:pc 输出给 imem,imem 输出拆成 opcode、rs、rt、rd、funct // regfile 读 rd1、rd2,ALU 计算 alu_out,dmem 读写,最后 wb_data 写回 regfile assign debug_pc = pc; assign debug_inst = inst; assign debug_alu_out = alu_out; assign debug_rd2 = rd2; endmodule端口命名我统一用 rd1/rd2 表示寄存器堆的两个读数据,wb_data 表示写回数据,不用 read_data_A 这种带后缀的全称。位宽上,数据和地址总线固定 32 位,寄存器编号 5 位,操作码和 funct 各 6 位。这样命名的好处是看图连线时,模块间只靠几个短名字就能对应上。
参数化设计可以提前做,但不建议课程设计阶段过度使用。用parameter WIDTH = 32全局定义位宽,当你改到 64 位时只需要改一个地方。但如果你只做 32 位,直接写 32 也不会带来问题。我更建议在 regfile 和 dmem 里用 parameter 定义容量,因为这两个模块的地址位宽与容量直接相关。
3.2 控制器的真值表:每条指令对应哪些控制信号
主控制器是单周期 CPU 最容易写错的地方。我的做法是先画一张真值表,把每条指令对应的九个控制信号全部列出来,然后再把真值表翻译成 Verilog 的 case 语句。不要直接上手写代码,否则漏一个默认分支就是一颗隐形炸弹。
| 信号 | add | lw | sw | beq | j |
|---|---|---|---|---|---|
| RegDst | 1 | 0 | x | x | x |
| ALUSrc | 0 | 1 | 1 | 0 | x |
| MemtoReg | 0 | 1 | x | x | x |
| RegWrite | 1 | 1 | 0 | 0 | 0 |
| MemRead | 0 | 1 | 0 | 0 | 0 |
| MemWrite | 0 | 0 | 1 | 0 | 0 |
| Branch | 0 | 0 | 0 | 1 | 0 |
| Jump | 0 | 0 | 0 | 0 | 1 |
| ALUOp | 10 | 00 | 00 | 01 | xx |
表格里 x 表示该信号不影响这条指令的执行。写 Verilog 时要把 x 都填成确定的默认值,否则综合工具可能会推断出锁存器。控制器代码用组合逻辑 always 块实现:
module controller ( input wire [5:0] opcode, output reg RegDst, ALUSrc, MemtoReg, output reg RegWrite, MemRead, MemWrite, output reg Branch, Jump, output reg [1:0] ALUOp ); always @(*) begin // 先给所有信号赋默认值,这是避免 latch 的关键 RegDst = 1'b0; ALUSrc = 1'b0; MemtoReg = 1'b0; RegWrite = 1'b0; MemRead = 1'b0; MemWrite = 1'b0; Branch = 1'b0; Jump = 1'b0; ALUOp = 2'b00; case (opcode) 6'b000000: begin // R-type: add/sub/and/or/slt RegDst = 1'b1; RegWrite = 1'b1; ALUOp = 2'b10; // 由 funct 决定具体运算 end 6'b100011: begin // lw ALUSrc = 1'b1; MemtoReg = 1'b1; RegWrite = 1'b1; MemRead = 1'b1; ALUOp = 2'b00; // 地址计算 = 加法 end 6'b101011: begin // sw ALUSrc = 1'b1; MemWrite = 1'b1; ALUOp = 2'b00; end 6'b000100: begin // beq Branch = 1'b1; ALUOp = 2'b01; // 减法比较 end 6'b000010: begin // j Jump = 1'b1; end 6'b001000: begin // addi ALUSrc = 1'b1; RegWrite = 1'b1; ALUOp = 2'b00; end default: begin // 未定义指令走空操作 RegWrite = 1'b0; end endcase end endmoduleALUOp 的编码需要和 ALU 控制模块配合。这里我用 00 表示加法、01 表示减法、10 表示由 R 型指令的 funct 字段决定。这样 lw/sw 的地址计算自动走加法,beq 走减法,而 R 型指令则进入 funct 解码。把这套编码固定下来,两个模块之间就不容易对不上。
3.3 寄存器堆与 ALU:0 号寄存器屏蔽和非阻塞赋值
寄存器堆是时序逻辑和组合逻辑混用的典型模块。读操作是组合逻辑,地址一变输出立刻变化;写操作是时序逻辑,只在时钟上升沿把数据写入。写地址为 0 时必须屏蔽,否则程序里对 $0 的写入会把“恒为 0”的硬件约定破坏掉。
module regfile ( input wire clk, input wire RegWrite, input wire [4:0] raddr1, raddr2, waddr, input wire [31:0] wdata, output wire [31:0] rdata1, rdata2 ); reg [31:0] regs [0:31]; integer i; // 上电时清零,避免仿真出现 X 态 initial begin for (i = 0; i < 32; i = i + 1) regs[i] = 32'b0; end // 读端口:0 号寄存器恒为 0,组合逻辑输出 assign rdata1 = (raddr1 == 5'b0) ? 32'b0 : regs[raddr1]; assign rdata2 = (raddr2 == 5'b0) ? 32'b0 : regs[raddr2]; // 写端口:同步写,非阻塞赋值,waddr=0 时禁止写入 always @(posedge clk) begin if (RegWrite && (waddr != 5'b0)) regs[waddr] <= wdata; end endmodule这里两个细节容易踩坑:一是 initial 块里初始化寄存器的写法只对仿真有效,上板后寄存器堆的内容是不确定的,所以课程设计里的测试程序不能依赖寄存器堆的初值;二是写数据用的是非阻塞赋值<=,如果写成=,在波形里可能看不出问题,但综合出的电路会多出不必要的中间寄存器,时序报告会变差。
ALU 模块相对简单,但要特别注意 slt 的有符号比较问题:
module alu ( input wire [31:0] a, b, input wire [3:0] alu_ctrl, output reg [31:0] result, output wire zero ); assign zero = (result == 32'b0); always @(*) begin case (alu_ctrl) 4'b0000: result = a + b; 4'b0001: result = a - b; 4'b0010: result = a & b; 4'b0011: result = a | b; 4'b0100: result = ($signed(a) < $signed(b)) ? 32'b1 : 32'b0; default: result = 32'b0; endcase end endmodule如果把$signed(a) < $signed(b)写成a < b,那么 slt 在比较负数时会得到完全错误的结果,因为此时按无符号数比较,负数会变成很大的正数。这是课程设计中非常隐蔽的一个逻辑错误。
3.4 立即数扩展与指令存储器:符号扩展和字节地址的约定
立即数扩展决定了 lw/sw 的负偏移量是否有效。MIPS 的 I 型指令携带 16 位立即数,ALU 计算地址时需要把它扩展成 32 位。对于 lw/sw/addi,这个扩展必须是符号扩展,否则访问数组时往前偏移就变成了访问超大地址。
module signext ( input wire [15:0] imm16, output wire [31:0] imm32 ); assign imm32 = {{16{imm16[15]}}, imm16}; endmodule如果是 lui 指令,则需要零扩展后左移 16 位,这个操作通常不放在 signext 模块里,而是在数据通路连线上用拼接实现。lui 的立即数代表的是高 16 位,低 16 位补零,写成{imm16, 16'b0}即可。
指令存储器的初始化要依赖$readmemh,文件里每行一个 32 位十六进制数,顺序就是内存地址顺序。地址线连接要特别注意,CPU 的 PC 是字节地址,而寄存器数组的索引是字地址,所以必须用addr[31:2]作为数组下标,否则每条指令间隔四个地址,取指会乱掉。
module imem ( input wire clk, input wire [31:0] addr, output wire [31:0] inst ); reg [31:0] mem [0:255]; initial begin $readmemh("program.hex", mem); end assign inst = mem[addr[31:2]]; endmodule$readmemh的文件路径是相对仿真运行时的工作目录的,不是相对工程文件位置。很多同学在 ModelSim 里能跑,换了 Vivado 就报文件找不到,就是因为工作目录变了。我一般会在每条路径前加仿真宏定义,或者把 hex 文件复制到仿真目录下,省得跟路径较劲。
4. 从仿真到上板:一整套可复现的验证闭环
代码写完只能算完成三分之一,剩下的大部分时间在验证。我见过太多人写完代码直接生成比特流下板,结果板子毫无反应,然后开始瞎改。正确的顺序是:先用 testbench 在仿真里跑通所有指令,再导入综合工具查告警,最后才上板。每往前走一步,都要保证前一步是干净的。
4.1 最小 testbench:时钟、复位和运行时长怎么控制
testbench 是验证闭环的地基。它的任务有三个:产生时钟、释放复位、给足运行时间。时钟周期我习惯设 10ns,这样一秒钟跑 1 亿条指令,仿真 200 个周期也就几微秒,范围合理。
module tb_cpu; reg clk, rst_n; wire [31:0] debug_pc, debug_inst, debug_alu_out, debug_rd2; cpu_top uut ( .clk(clk), .rst_n(rst_n), .debug_pc(debug_pc), .debug_inst(debug_inst), .debug_alu_out(debug_alu_out), .debug_rd2(debug_rd2) ); // 10ns 时钟周期:5ns 高电平,5ns 低电平 initial clk = 1'b0; always #5 clk = ~clk; // 复位保持 30ns,覆盖三个时钟上升沿,确保 PC 和寄存器堆稳定复位 initial begin rst_n = 1'b0; #30 rst_n = 1'b1; repeat (200) @(posedge clk); $finish; end // 每个周期打印状态,方便对照 MARS 的寄存器变化 initial begin $monitor("%0t: PC=%h inst=%h alu=%h rd2=%h", $time, debug_pc, debug_inst, debug_alu_out, debug_rd2); end endmodule复位释放时机不是拍脑袋定的。如果复位时间只有一个时钟沿,PC 可能还没完成清零就被释放,起点就不对。我给 30ns 是为了覆盖复位信号释放前的三个上升沿,保证所有时序模块都见过复位。repeat 的 200 个周期是估算值,你可以根据测试程序的指令条数适当放大,跑完后$finish会自动结束仿真。
运行命令在开源工具链下很简单:
iverilog -o tb_cpu.vvp tb_cpu.v cpu_top.v regfile.v alu.v imem.v signext.v controller.v vvp tb_cpu.vvp如果你用 Vivado 或 ModelSim,流程类似,但要注意把 hex 文件放到仿真目录下。自带的波形查看工具可以看到每个信号的跳变,配合$monitor打印的 PC 和 ALU 值,多数问题在这一步就能定位。
4.2 自动断言:让仿真帮你查“哪条指令错了”,而不是人盯波形
看着波形找错误,在小程序里还能坚持,程序一长就彻底不靠谱。我后来改用断言的方式:在 testbench 里写 task 或 if 语句,把每个关键点的期望值与实际值比较,仿真跑完自动打印 PASS/FAIL,不用人盯着波形看半天。
reg [31:0] expected; initial begin @(posedge rst_n); repeat (3) @(posedge clk); // 假设前三条指令是:addi $1, $0, 5 / addi $2, $0, 3 / add $3, $1, $2 // 第三条指令执行完,$3 应为 8 if (uut.debug_rd2 !== 32'h8) $display("FAIL: rd2=%h, expected=8 at time %0t", uut.debug_rd2, $time); else $display("PASS: add result correct"); $finish; end断言的值从哪里来?最简单的办法是先在 MARS 里把同样的汇编程序跑一遍,记下每一条指令执行后的寄存器值,然后填进 testbench。这个过程很机械,但非常有效。当你把冒泡排序跑完,每一步的寄存器快照都能对上 MARS 时,CPU 的数据通路基本可以认定是对的。
4.3 板上验证:引脚约束、时钟源和复位按键的处理
仿真通过只是第一关,上板是另一关。板级验证要考虑三件事:时钟引脚是否正确连接、复位按键是否有效、调试结果怎么观察。时钟必须连接到 FPGA 的专用时钟输入引脚,通过全局时钟网络进入设计,不能接在普通 IO 上,否则综合工具会产生时钟布线告警。
| 项目 | 常见配置方式 | 检查要点 |
|---|---|---|
| 系统时钟 | 板载晶振引脚 | 约束文件里有没有绑定到时钟专用端口 |
| 复位按键 | 按键按下为低电平 | 需做同步处理,避免亚稳态 |
| 运行结果 | LED 显示 PC 低 8 位 | 能直接看到程序是否在跑 |
| 调试接口 | UART 打印寄存器值 | 比看波形直观,但需要额外模块 |
复位按键的处理是很多翻车现场的来源。机械按键按下和释放的瞬间会有抖动,如果不做处理,CPU 可能在一次复位中收到多个上升沿。课程设计里最简单可靠的做法是:在顶层模块里把按键信号经过两级触发器打拍后再接进时序逻辑,这样能把大部分抖动过滤掉。
上板前还有一件必做的事:查看综合报告里的 latch 告警和未连接引脚告警。如果某个 always 块的所有条件分支没有覆盖全,综合工具会在那里默默插入一个锁存器,而这个锁存器通常是完全没必要的,它不会让你仿真失败,但会让电路的时序变差,甚至在上板后出现随机错误。
5. 常见问题与避坑:单周期 CPU 里最容易被放倒的 9 个细节
这一章是血泪经验的合集。课程设计里很多问题看起来像是硬件玄学,实际上都是可复现的电路设计错误。我按现象、原因、解决的顺序把高频坑列出来,你在调试时可以直接对着排查。
5.1 现象:仿真波形完全正确,上板后 LED 不闪、数码管不亮
原因:时钟约束没有生效。仿真时时钟是 testbench 里生成的理想信号,不会涉及物理引脚;上板后时钟必须来自板载晶振,如果约束文件里没有把顶层 clk 端口绑定到晶振引脚,芯片内部根本没有时钟信号,所有时序逻辑都静止。
解决:把顶层 clk 引脚约束到开发板的晶振引脚,并在时序约束文件里声明时钟周期。如果你能看到综合报告里的时钟网络,确认 clk 信号经过 BUFG 进入全局时钟网络,这一步就算过了。
5.2 现象:仿真偶尔正确,综合报告里出现多个 latch
原因:控制器和 ALU 控制模块的 always 块里,条件分支没有覆盖完整。最常见的是 case 语句缺少 default,或者 if 语句缺少 else,综合工具遇到“条件不满足时输出保持原值”的情况,就会推断出锁存器。
解决:组合逻辑 always 块的第一行先给所有输出赋默认值,再用 case/if 覆盖特殊情况。控制器代码里的RegDst = 1'b0; ALUSrc = 1'b0;这几行就是干这个用的,不要删。
5.3 现象:beq 跳转后 PC 落在目标指令的下一条,总是差一条
原因:beq 的目标地址算错了。MIPS 的分支地址不是“当前 PC + 立即数左移 2 位”,而是“(PC + 4) + 符号扩展立即数左移 2 位”。如果你直接用当前 PC 加偏移,所有 backward 分支都会跳到目标往后 4 字节的位置。
解决:先把 PC 加 4,再把立即数左移两位后相加。顶层连线的代码应该是:
wire [31:0] pc_plus4 = pc + 32'd4; wire [31:0] branch_target = pc_plus4 + {{14{imm_ext[15]}}, imm_ext[15:0], 2'b00};5.4 现象:负数偏移量的 lw/sw 总是访问到超大地址
原因:立即数扩展模块做成了零扩展,而不是符号扩展。当偏移量是负数时,16 位立即数的最高位是 1,零扩展会让它变成很大的正数,地址自然偏到高位空间。
解决:检查 signext 模块是不是用了{{16{imm[15]}}, imm}这种拼接写法。如果你把 lw 和 lui 的扩展方式混在一个模块里,也会出类似问题,建议分开处理:lw/sw/addi/beq 用符号扩展,lui 用零扩展加左移。
5.5 现象:程序在仿真里跑着跑着 PC 变成 X,然后全部归零
原因:指令存储器里取到了未定义指令,控制器进入 default 分支,但你的 PC 更新逻辑没有对未定义指令做处理,导致 PC 计算出不确定结果。另一个常见来源是程序最后没有跳转到结束地址,直接执行了全 X 的空白指令。
解决:在汇编程序末尾加一条无条件跳转或死循环指令,让 PC 不进入空白区域。比如跳转到自身地址loop: j loop,或者在内存里把未用区域全部填成 nop。
5.6 现象:仿真时寄存器堆初始值全是 X,程序一启动就算不出结果
原因:寄存器堆的 initial 块没写,或者写了但仿真的工作目录和 hex 文件不匹配导致$readmemh静默失败。Verilog 里$readmemh读不到文件时不会报错,而是保留数组初值,这个坑特别隐蔽。
解决:在 regfile 里加 initial 循环清零,同时检查 hex 文件路径。用$fopen打开文件并判断句柄是否为 0,能立刻发现文件路径问题。
5.7 现象:上板后程序跑一段就停住,波形显示中间某条指令执行了两次
原因:很可能是复位信号出现了毛刺,或者某个异步信号没同步就进入了时序逻辑。上板环境存在真实的信号延迟和抖动,仿真里看不到的竞争条件在芯片上会暴露。
解决:按键复位必须经过同步器,建议在一个 always 块里用两级触发器打拍后再使用。如果你用的是同步复位,确保复位信号在时钟上升沿之前稳定,必要时在顶层把复位信号和时钟信号做一次 AND,但这只是临时的补救方法。
5.8 现象:lw 从内存读回的数据总是错字,低字节和高字节顺序对不上
原因:数据存储器实现了字节使能,但你只用了 32 位读写端口。小端系统里 lw 从地址 A 读到的是 A、A+1、A+2、A+3 组合成的字,如果你按字地址直接读,相当于地址左移了两位,读到的内容自然错位。
解决:dmem 的读写地址也要用addr[31:2]做字对齐索引。如果你用字节使能信号,保持默认全为 1,让 lw/sw 一次读写完整 4 字节。
5.9 现象:仿真一开始就报错“非阻塞赋值不能用在组合逻辑进程里”
原因:在always @(*)块里写了<=,或者在时序逻辑里用了阻塞赋值=。这个错误综合工具不一定报,但仿真器会直接拒绝执行,或者产生不一致的仿真结果。
解决:记住一个口诀:时序逻辑(带posedge clk)用<=,组合逻辑(always @(*))用=。testbench 里的信号赋值不受这个限制,但在设计代码里必须严格区分。
6. 验收前必做的验证技巧:自动对拍替代肉眼看波形
课程设计最耗时的环节是验证,而验证最容易出错的方式是“人盯波形”。当你把程序从 20 条指令扩到 200 条,肉眼看波形基本等于大海捞针。我后来养成一个习惯:写一个极简的参考模型,让 CPU 的仿真结果和参考模型自动对比,一次性跑完所有测试指令,错误定位到具体周期。
6.1 用 Python 脚本对拍:CPU 仿真轨迹和参考模型逐条比较
思路很简单:CPU 仿真的 testbench 在每个时钟周期把 PC、指令、关键寄存器的值打印成 CSV 文件;参考模型用 Python 写一个极简的 MIPS 解释器,执行同样的 hex 文件,输出同样的 CSV。两个文件逐行对比,差异一目了然。
#!/usr/bin/env python3 import csv, sys def load_trace(path): trace = {} with open(path, newline='') as f: for row in csv.DictReader(f): trace[row['cycle']] = row return trace cpu = load_trace(sys.argv[1]) ref = load_trace(sys.argv[2]) for cycle in sorted(ref.keys()): if cpu[cycle]['regfile'] != ref[cycle]['regfile']: print(f"cycle {cycle} mismatch") sys.exit(1) print("all instructions match") sys.exit(0)testbench 端每周期输出到 CSV 时,要把寄存器堆的 32 个寄存器值拼成一个字符串。对比时如果只在某一条指令后开始不一致,就把$monitor打开,看那附近几条指令的状态,很快能定位到是 ALU 控制信号错还是写回数据选择错。这套对拍方法能把“验证 CPU 正确性”的时间从几小时压缩到几分钟。
6.2 随机指令序列生成:边界条件靠程序找,不靠人想
手写测试用例只能覆盖你想到的场景,随机测试能覆盖你想不到的边界。生成随机指令序列时要注意三个约束:lw/sw 的地址必须是 4 字节对齐,beq 的目标地址必须落在指令空间里,不能生成未实现的指令编码。
import random def random_r_type(): op = random.choice(["add", "sub", "and", "or", "slt"]) rd = random.randint(1, 31) rs = random.randint(0, 31) rt = random.randint(0, 31) return f"{op} ${rd}, ${rs}, ${rt}" def random_imm(): rt = random.randint(1, 31) rs = random.randint(0, 31) imm = random.randint(-32768, 32767) return f"addi ${rt}, ${rs}, {imm}"生成完指令序列后,先交给 MARS 跑一遍得到预期寄存器快照,再喂给 CPU 仿真对拍。随机测试的价值在于它的不可预测性,经常能暴露出控制器真值表里某个信号在特定指令组合下的错误值。
6.3 现场演示怎么组织:先跑排序,再用单步模式讲数据通路
验收演示的关键不是“能跑”,而是“能讲清楚”。我的习惯是先全速跑一遍冒泡排序,让评委看到效果,然后切到单步模式,每执行一条指令就用波形图指着对应的数据通路路径讲一遍:PC 从哪来、指令怎么译码、ALU 做了什么、数据写回了哪个寄存器。
演示之前还有一件小事值得做:清理调试代码。如果 testbench 里的$display没有删干净,不会影响综合结果,但如果你把仿真专用的逻辑误写进设计模块,综合器会把它当成真实电路的一部分,导致资源占用异常。我在一次课程设计里就吃过这个亏,综合报告里多出几百个 LUT,查了半天才发现是调试用的计数器忘了注释掉。
从那以后我养成了一个习惯:上板前必看三条信息——综合报告里的 latch 计数、时序余量、未连接引脚告警。这三条没问题才敢生成比特流。希望帮到你。
本文还有配套的精品资源,点击获取