ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

MIPS32单周期CPU设计全流程:从数据通路到Verilog仿真

MIPS32单周期CPU设计全流程:从数据通路到Verilog仿真 干了这么多年数字逻辑带过不少学生做课程设计也帮人排查过各种奇奇怪怪的仿真报错单周期CPU设计这个题目几乎每隔一段时间就会遇到一次。它确实是计算机组成原理课里最有分量的一个实验很多人刚开始以为只是写几个模块拼一拼真正动手才发现架构选型、信号连接、时序配合、仿真调试每个环节都能把人折腾到怀疑人生。这篇文章就把我实际做MIPS32单周期CPU的完整过程写出来从指令集选型、数据通路设计到Verilog模块划分、仿真验证和常见坑点排查全部走一遍。无论你是正在做课程设计的学生还是想自己动手写一颗简易CPU的硬件爱好者这篇文章都能给你一套可以直接复现和改装的方案。1. 动手之前先弄清楚单周期CPU到底是怎么回事很多人一上来就拿着代码抄抄完也不知道每根线为什么这么连。这样做的结果就是仿真一报错整个人直接懵掉根本无从下手。所以开头这部分我先把最基础的设计原则讲透。1.1 为什么选MIPS32指令集而不是RISC-V或者x86选指令集是设计CPU的第一步。课程设计里最常见的选择是MIPS32原因很实在它结构规整、指令编码简单、没有x86那种变长指令带来的麻烦而且经典教材几乎都以MIPS为例子参考资料最好找。相比之下RISC-V虽然现在很火指令格式也不复杂但如果你是为了完成课程设计MIPS相关的实验框架、仿真例程、调试工具都已经很成熟碰到问题更容易搜到答案。x86就别想了光是译码那部分就够你写上一个学期。我个人建议的指令子集是下面5条它们足够覆盖单周期CPU的所有关键机制add寄存器间的算术运算走通读寄存器-运算-写回主路径sub算术运算验证ALU减法功能lw从内存加载数据到寄存器走通访存-写回路径sw把寄存器数据写入内存走通访存-写回路径同时验证写内存时序beq条件分支走通PC跳转路径让CPU具备决策能力这5条指令覆盖了单周期CPU最核心的三类操作算术逻辑运算、内存读写、控制流跳转。只要这5条能跑通后面加addi、and、or、j都只是往控制器真值表里加几行的活儿。1.2 单周期的单到底是什么意思先明确一个概念单周期CPU不是指整个程序在一个时钟周期内跑完而是指每条指令的执行只占用一个时钟周期。也就是说一个时钟周期上升沿到来时CPU完成取指、译码、执行、访存、写回这五个阶段然后在下一个时钟周期开始执行下一条指令。这样设计带来的好处是控制逻辑极其简单不需要像流水线那样考虑冒险、停顿、转发之类的复杂问题。每个周期开始的时候所有信号从头开始走一遍互不干扰控制器的状态表也一目了然。缺点也很明显指令执行的快慢由最慢的那条指令决定。比如lw既要读寄存器、算地址、又要访问内存、还要写回寄存器整条链路很长CPU的主频就被它卡住了。add明明只要几十纳秒就能跑完也得等满一个周期。这就是单周期架构最大的浪费也是后面多周期和流水线要解决的问题。1.3 指令的大致执行过程长什么样以一条lw指令为例走一遍全流程程序计数器PC把当前指令地址送到指令存储器取出32位指令码指令被拆解成各个字段opcode操作码、rs源寄存器1、rt源寄存器2、rd目标寄存器、funct功能码、imm1616位立即数控制器根据opcode和funct生成所有控制信号比如ALUSrc、RegWrite、MemRead、MemWrite、MemToReg、Branch等寄存器堆根据rs和rt读出两个操作数同时立即数被符号扩展成32位ALU根据控制信号选择第二个操作数执行地址加法运算根据MemRead信号从数据存储器中读取数据根据MemToReg信号把读出的内存数据写回寄存器堆的rt字段指向的寄存器每个步骤之间没有停顿全在一个周期内完成理解了这个流程后面写代码就有方向了。2. 数据通路搭建画不出图也写不出代码我这个人的习惯是写Verilog之前一定先把数据通路图画出来。你能把每根线的走向画清楚代码就是水到渠成的事情。反过来如果你连图都画不利索就急着写代码大概率会写成一团乱麻。2.1 模块划分和信号连接总览单周期CPU拆开来就是六大模块每个模块职责单一这样设计和调试都方便模块功能关键输入关键输出PC程序计数器给出指令地址clk、rst、Branch信号、跳转地址当前指令地址指令存储器存指令根据PC读出指令指令地址32位指令码寄存器堆存通用寄存器支持双读单写rs、rt、rd、写数据、写使能读数据1、读数据2ALU算术逻辑运算操作数A、操作数B、ALU控制信号运算结果、零标志数据存储器存数据支持读写地址、写数据、读写控制读数据控制器生成所有控制信号opcode、funct全部控制信号数据通路的骨架是PC驱动指令存储器指令驱动控制器和寄存器堆寄存器堆和立即数驱动ALUALU驱动数据存储器数据存储器或ALU的结果驱动寄存器堆写回控制器生成指挥信号控制每一个数据选择器。2.2 控制信号的设计思路控制器是单周期CPU的灵魂它本质上就是一张真值表。你要做的就是把每条指令需要什么控制信号弄清楚然后按指令编码列出来。以我上面选的5条指令为例控制信号真值表如下指令RegDstALUSrcMemToRegRegWriteMemReadMemWriteBranchALUOpadd100100010sub100100010lw011110000swX1X001000beqX0X000101每个信号的含义和设计理由RegDst选择写入寄存器堆的目标寄存器编号。写回的是rd还是rt取决于指令格式R型指令add/sub写rd所以为1lw写rt所以为0ALUSrc选择ALU第二个操作数来自寄存器堆还是立即数。R型指令用寄存器操作数所以为0lw/sw用立即数做地址偏移所以为1MemToReg选择写回寄存器堆的数据来自ALU结果还是内存读数据。lw为1其他为0RegWrite寄存器堆写使能。lw、add、sub为1sw和beq为0MemRead和MemWrite数据存储器读写控制lw和sw对应使能Branch是否为分支指令只有beq为1ALUOpALU操作编码。这里02位编码10表示看funct字段01表示做减法比较00表示做加法2.3 ALU控制信号的编码逻辑ALU操作可能多此一举。其实ALUOp和funct配合是为了不让控制器直接面对32种funct编码。控制器只要告诉ALU这是R型指令去查funct字段吧具体的加、减、与、或就由ALU自己根据funct来定了。我的ALU控制信号编码如下ALUOpfunct或指令类型ALU控制信号操作00lw/sw0010加法01beq0110减法10addfunct1000000010加法10subfunct1000100110减法这样设计的好处是将来扩展and、or、slt这些R型指令时控制器的ALUOp一直保持10不变只需要在ALU内部往funct译码逻辑里加几行就行控制器的真值表不用大改。3. Verilog核心代码实现从模块到整机前面铺垫了这么多终于到写代码的环节了。我按模块从底层往上层写每个模块都给完整代码和设计说明。仿真环境用的是Icarus Verilog后面会专门讲怎么用它做验证。3.1 PC模块让指令一条条跑起来PC是整个CPU的发动机每个时钟上升沿把当前地址加4。加4是因为MIPS按字节寻址每条指令占4字节所以下一条指令的地址是当前地址加4。module pc( input wire clk, input wire rst, input wire branch_taken, input wire [31:0] branch_addr, output reg [31:0] pc ); always (posedge clk or posedge rst) begin if (rst) begin pc 32h0000_0000; end else if (branch_taken) begin pc branch_addr; end else begin pc pc 32d4; end end endmodule有两个设计细节需要说明。第一个是异步复位posedge rst时PC直接清零不管时钟是什么状态这样复位不受时钟影响仿真和上板都方便。第二个是分支跳转地址我在这里直接把branch_taken信号和branch_addr传进来了而没有在PC内部计算跳转地址。跳转地址的计算(pc4) sign_extend(imm16) 2放在顶层模块里做PC只负责选择继续执行还是跳转职责更单纯。3.2 指令存储器最简单的就是最好的指令存储器就是一块只读ROM把预先写好的机器码放进去根据地址取指令。我用reg数组加initial块实现方便在仿真时用$readmemh加载汇编后的机器码文件。module instr_mem( input wire [31:0] addr, output reg [31:0] instr ); reg [31:0] mem [0:63]; initial begin $readmemh(instr.hex, mem); end always (*) begin instr mem[addr[31:2]]; end endmodule地址使用addr[31:2]取高30位作为数组索引是因为MIPS按字节寻址而每个存储单元存放一条32位指令相当于4字节。addr[31:2]相当于把字节地址除以4得到指令编号。用$readmemh的好处是我把汇编器生成的机器码放到instr.hex文件里改程序的时候只要重新生成这个文件Verilog代码一行都不用动。3.3 寄存器堆双端口读、单端口写寄存器堆是CPU里数据中转站读操作是组合逻辑写操作是时序逻辑。这里有一个关键设计点写使能有效且时钟上升沿到来时数据才会写入而读操作只要给定寄存器编号数据立即输出不需要等时钟。module regfile( input wire clk, input wire reg_write, input wire [4:0] read_addr1, input wire [4:0] read_addr2, input wire [4:0] write_addr, input wire [31:0] write_data, output reg [31:0] read_data1, output reg [31:0] read_data2 ); reg [31:0] regs [0:31]; integer i; initial begin for (i 0; i 32; i i 1) begin regs[i] 32d0; end end always (*) begin read_data1 (read_addr1 5d0) ? 32d0 : regs[read_addr1]; read_data2 (read_addr2 5d0) ? 32d0 : regs[read_addr2]; end always (posedge clk) begin if (reg_write write_addr ! 5d0) begin regs[write_addr] write_data; end end endmodule寄存器0永远为0是MIPS的硬件约定所以我在读端口做了判断写入端口也加了写地址不为0的保护。这个细节容易被忽略但缺了它程序一旦尝试写寄存器0整个CPU的行为都会出错。initial块把32个寄存器清零是为了让仿真从已知状态开始否则寄存器值是X态波形一片红什么都看不清。3.4 ALU运算核心支持扩展ALU的设计思路是接口固定功能可增。我现在的ALU支持加、减、与、或将来要扩展其他运算只需要在funct译码逻辑里加分支就行。module alu( input wire [31:0] a, input wire [31:0] b, input wire [3:0] alu_ctrl, output reg [31:0] result, output reg zero ); always (*) begin case (alu_ctrl) 4b0010: result a b; 4b0110: result a - b; 4b0000: result a b; 4b0001: result a | b; default: result 32d0; endcase zero (result 32d0); end endmodulealu_ctrl是4位信号ALUOp从控制器出来是2位中间通过一个小的译码逻辑扩展成4位。对这个译码逻辑我放在顶层模块里用case实现。注意零标志zero是组合逻辑产生的因为beq指令需要在同一个周期内根据比较结果决定是否跳转不能等时钟。3.5 数据存储器读写分离操作数据存储器和指令存储器的最大区别在于支持写操作。写操作在时钟上升沿触发读操作是组合逻辑。module data_mem( input wire clk, input wire mem_read, input wire mem_write, input wire [31:0] addr, input wire [31:0] write_data, output wire [31:0] read_data ); reg [31:0] mem [0:63]; integer i; initial begin for (i 0; i 64; i i 1) begin mem[i] 32d0; end // 可以在initial里预置一些测试数据 // mem[0] 32d5; end assign read_data (mem_read addr 32d256) ? mem[addr[31:2]] : 32d0; always (posedge clk) begin if (mem_write addr 32d256) begin mem[addr[31:2]] write_data; end end endmodule读操作前面接了mem_read使能防止非访存指令周期里地址乱码导致读到垃圾数据。地址范围addr 32d256是个简单的保护机制避免地址超界。如果你在做实验时有特定的地址映射需求这部分可以改成更适合你平台的存储布局。3.6 控制器一张真值表走天下控制器就是前面那张真值表的Verilog实现。它是一个纯组合逻辑模块输入opcode和funct输出所有控制信号。module controller( input wire [5:0] opcode, input wire [5:0] funct, output reg reg_dst, output reg alu_src, output reg mem_to_reg, output reg reg_write, output reg mem_read, output reg mem_write, output reg branch, output reg [1:0] alu_op ); always (*) begin // 默认值全部拉低避免产生锁存器 reg_dst 0; alu_src 0; mem_to_reg 0; reg_write 0; mem_read 0; mem_write 0; branch 0; alu_op 2b00; case (opcode) 6b000000: begin // R型指令 reg_dst 1; reg_write 1; alu_op 2b10; // funct只是留给ALU译码器用控制器本身不需要细分 end 6b100011: begin // lw alu_src 1; mem_to_reg 1; reg_write 1; mem_read 1; alu_op 2b00; end 6b101011: begin // sw alu_src 1; mem_write 1; alu_op 2b00; end 6b000100: begin // beq branch 1; alu_op 2b01; end default: begin // 未知指令所有控制信号保持默认 end endcase end endmodule重点说一下默认值先全部拉低这个写法。组合逻辑里如果存在没有覆盖的分支信号就会保持之前的值这在组合逻辑中是禁止的会导致综合出锁存器而不是纯组合逻辑。先赋默认值再覆盖是写组合逻辑的标准姿势。3.7 顶层模块把积木拼起来顶层负责把PC、指令存储器、寄存器堆、ALU、数据存储器、控制器全部连起来同时承担立即数符号扩展、ALUOp译码、分支地址计算等辅助逻辑。module single_cycle_cpu( input wire clk, input wire rst ); // 内部信号声明 wire [31:0] pc_addr; wire [31:0] instr; wire [31:0] read_data1; wire [31:0] read_data2; wire [31:0] alu_result; wire [31:0] mem_read_data; wire [31:0] write_data; wire [31:0] sign_ext_imm; wire [31:0] branch_addr; wire zero; wire reg_write; wire reg_dst; wire alu_src; wire mem_to_reg; wire mem_read; wire mem_write; wire branch; wire [1:0] alu_op; wire [3:0] alu_ctrl; wire [4:0] write_reg_addr; // 立即数符号扩展 assign sign_ext_imm {{16{instr[15]}}, instr[15:0]}; // 分支目标地址 (PC4) (立即数 2) assign branch_addr pc_addr 32d4 (sign_ext_imm 2); // 目标寄存器选择 assign write_reg_addr reg_dst ? instr[15:11] : instr[20:16]; // 写回数据选择 assign write_data mem_to_reg ? mem_read_data : alu_result; // ALU控制信号译码 always (*) begin case (alu_op) 2b00: alu_ctrl 4b0010; // 加法 2b01: alu_ctrl 4b0110; // 减法 2b10: begin case (instr[5:0]) 6b100000: alu_ctrl 4b0010; // add 6b100010: alu_ctrl 4b0110; // sub default: alu_ctrl 4b0000; endcase end default: alu_ctrl 4b0000; endcase end // 分支判定 wire branch_taken; assign branch_taken branch zero; // 模块实例化 pc u_pc( .clk(clk), .rst(rst), .branch_taken(branch_taken), .branch_addr(branch_addr), .pc(pc_addr) ); instr_mem u_instr_mem( .addr(pc_addr), .instr(instr) ); regfile u_regfile( .clk(clk), .reg_write(reg_write), .read_addr1(instr[25:21]), .read_addr2(instr[20:16]), .write_addr(write_reg_addr), .write_data(write_data), .read_data1(read_data1), .read_data2(read_data2) ); alu u_alu( .a(read_data1), .b(alu_src ? sign_ext_imm : read_data2), .alu_ctrl(alu_ctrl), .result(alu_result), .zero(zero) ); data_mem u_data_mem( .clk(clk), .mem_read(mem_read), .mem_write(mem_write), .addr(alu_result), .write_data(read_data2), .read_data(mem_read_data) ); controller u_controller( .opcode(instr[31:26]), .funct(instr[5:0]), .reg_dst(reg_dst), .alu_src(alu_src), .mem_to_reg(mem_to_reg), .reg_write(reg_write), .mem_read(mem_read), .mem_write(mem_write), .branch(branch), .alu_op(alu_op) ); endmodule有四个细节值得拿出来单独说。第一个是write_reg_addr的选择逻辑。R型指令要写入的寄存器在rd字段instr[15:11]lw指令要写入的寄存器在rt字段instr[20:16]用reg_dst信号来选。第二个是ALU的第二操作数选择。这段alu_src ? sign_ext_imm : read_data2写在端口上没有在ALU内部做选择是为了让ALU保持纯粹它永远只管算不管操作数从哪来。第三个是分支地址计算。注意是pc_addr 4再加上偏移而不是用当前PC直接加。因为取指令时PC已经指向下一条指令了分支偏移量是相对于下一条指令的地址来算的这个细节错了beq跳转的位置就全错了。第四个是branch_taken的计算。branch是控制器给出的这条指令是不是beqzero是ALU比较结果两者相与才产生真正的跳转信号。合起来的意思是这条是beq而且两个操作数相等所以跳转。3.8 测试程序的生成有了CPU本体还需要一段能验证功能的程序。我这里写了一个简单测试完成的功能如下把寄存器$t0设为10寄存器$t1设为20用add把两者相加结果存入$t2用sub计算$t2 - $t1结果存入$t3用sw把$t2存到内存某个地址用lw把数据读回$t4用beq比较$t3和$t1如果相等则跳转退出循环MIPS汇编大概是这样的addi $t0, $zero, 10 # $t0 10 addi $t1, $zero, 20 # $t1 20 add $t2, $t0, $t1 # $t2 $t0 $t1 30 sub $t3, $t2, $t1 # $t3 $t2 - $t1 10 sw $t2, 0($s0) # mem[0] 30 lw $t4, 0($s0) # $t4 mem[0] 30 beq $t3, $t1, loop # $t3 $t1? 不相等, 继续注意到一个问题我没有实现addi指令但测试程序里用了它。这其实是个常用的取巧方法汇编指令addi $t0, $zero, 10其实等价于addi指令的机器码但如果你不想扩展指令集也可以用几条指令组合完成赋值。实际做实验时我更推荐先把addi也加进去因为它在测试程序里太常用了只差多写一行真值表的工作量。关于机器码生成最省事的办法是装一个离线汇编器把汇编转成instr.hex格式。如果没有现成工具也可以手写或者用Excel按MIPS指令编码规则生成。5条指令的编码格式其实很简单对照格式手册填字段就行。4. 仿真与调试Icarus Verilog环境的完整实践代码写完了接下来是最耗时间的仿真调试环节。我习惯用Icarus Verilog做仿真搭配GTKWave看波形。这套组合免费、跨平台、命令行友好特别适合课程设计这种规模的项目。4.1 Testbench的编写思路Testbench要做的事情有三件产生时钟信号、产生复位信号、实例化CPU并监控内部信号。timescale 1ns/1ps module tb_cpu; reg clk; reg rst; integer cycle_count; single_cycle_cpu u_cpu( .clk(clk), .rst(rst) ); initial begin cycle_count 0; clk 0; rst 1; #20 rst 0; end always #5 clk ~clk; always (posedge clk) begin cycle_count cycle_count 1; if (cycle_count 30) begin $display(Simulation complete at cycle %0d, cycle_count); $finish; end end initial begin $dumpfile(cpu_wave.vcd); $dumpvars(0, tb_cpu); end endmodule时钟周期设成10ns半周期5ns这样在波形图上一个周期一目了然。复位信号拉高20ns后再拉低保证CPU从已知状态开始。$dumpvars(0, tb_cpu)会把这个testbench层级下所有信号都导出到VCD文件里方便后面在GTKWave里观察。4.2 波形验证的五个关键观察点跑完仿真后要检查的波形位置和判断标准如下PC的值从0开始每个周期加4遇到beq跳转时跳到目标地址这是PC流水线推进的正确性寄存器堆的写使能和写地址。以add指令为例当reg_write为1、write_addr为01010$t2时write_data应该是30ALU的两个操作数和计算结果。检查alu_src为1时第二个操作数确实是符号扩展后的立即数数据存储器读写的时机。mem_write为1的时钟上升沿写数据读数据是指令执行周期中组合逻辑的输出branch和zero同时为1时branch_taken拉高下一个时钟的PC跳转到目标地址在GTKWave里建议按时钟周期对齐看用二进制格式显示控制信号用十六进制格式显示数据总线这样信号的高低和数值变化都很清楚。4.3 常见问题和排查技巧整理做单周期CPU实验我几乎把所有能踩的坑都踩了一遍这里整理成表格方便大家对照排查。问题现象可能原因排查方法波形全是X态未正确复位或时钟没接检查rst信号是否拉低clk是否正常翻转PC不动一直为0PC复位逻辑有问题或者时钟没进PC观察PC的输入时钟检查复位优先级beq跳转位置不对分支偏移量没做2或者是相对于PC4计算检查branch_addr计算逻辑寄存器写入不生效写地址选择错误或RegWrite信号时序错误检查write_addr是rd还是rt寄存器堆写信号是否在时钟沿前稳定满足建立时间lw读回来的数据不对MemToReg信号选错数据源或读取地址算错检查ALU结果低2位对齐以及write_data选择逻辑仿真报simulation license错误Icarus Verilog环境配置问题确认安装了iverilog并且PATH路径正确指令完全跑不对指令存储器加载的机器码地址错位核对hex文件格式确认$readmemh索引是否正确关于仿真环境白板问题补充一点Icarus Verilog如果在Windows环境下报license错误基本可以断定是装了别的商业仿真器后环境变量残留了。把系统里的LM_LICENSE_FILE这类变量清理掉再确保命令行里调用的是iverilog而不是其他仿真器问题就解决了。4.4 调试思想自底向上模块越多调试越需要方法。我的习惯是自底向上先验证模块再拼整机。刚写完代码时我会为每个模块单独写一个小testbench。比如单独测ALU给几组操作数检查加、减、与、或的结果。单独测寄存器堆写入一个值再读出来确认写使能无效的时候数据不会变。模块都通过了再连起来测整机。这种做法的好处是一旦整机仿真出错问题肯定出在模块连接或者控制信号上不用去怀疑模块本身。而如果跳过模块测试直接拼整机出错时你根本不知道是该查ALU还是查寄存器堆还是查控制器。5. 从单周期到更远的边界单周期CPU做通之后往下的路怎么走往往是更有意思的话题。这里分享几个我实际思考过、也帮学生验证过的扩展方向。5.1 单周期CPU的主频瓶颈单周期CPU的时钟周期等于指令执行的最长路径。以我前面写的版本为例最长的路径是lw指令PC输出地址到指令存储器取指经过控制器产生信号寄存器堆读操作数立即数符号扩展ALU算地址数据存储器读数据最后写回寄存器堆。每一步都有门延迟全部算下来一条lw指令的总延迟决定了主频上限。实测下来如果直接用Icarus Verilog仿真不涉及真实时序这个问题并不明显。但如果上板跑真实时钟lw链路大概占掉整个周期时序预算的60%以上。所以想提高主频要么把存储器访问拆到下一个周期多周期要么让指令像流水线一样重叠执行。5.2 多周期和流水线到底改了啥多周期CPU把每条指令拆成取指、译码、执行、访存、写回五个阶段每个阶段占用一个周期指令与指令之间可以共享硬件单元数据存储器和指令存储器可以合二为一。代价是控制逻辑比单周期复杂需要增加状态机来管理现在是哪个阶段。流水线CPU更进一步让五条指令在五个阶段重叠执行理论上吞吐量能提升到接近单周期的5倍。但由此引入了结构冒险、数据冒险、控制冒险三大问题需要加转发逻辑、停顿检测、分支预测。我第一次做流水线时光是把冒险处理对就花了一周时间调试。我的建议是先把单周期吃透把它当成一个标准的参考模型。做流水线时拿单周期版本做对照看每条指令最终的结果是否一致对照着找bug会轻松很多。5.3 指令集扩展的打开方式我前面实现的5条指令只是最小集合。实际做实验时最常扩展的指令有这几类addi立即数加法测试ALU的立即数路径需要在控制器真值表里加一行and、or逻辑运算测试ALU的funct译码j无条件跳转需要在PC模块里增加无条件跳转选择比beq还简单slt小于置位测试ALU的比较输出jal跳转并链接涉及返回地址写入寄存器需要处理PC4的回写路径每扩展一条指令无非是改三处控制器真值表加一行ALU译码加一个分支顶层模块针对新指令的数据通路做相应调整。把这条规则记牢扩展指令集就是体力活不需要改架构。5.4 上板验证的必要性如果条件允许我强烈建议把仿真通过的单周期CPU下到FPGA板上真实跑一遍。仿真环境里信号都是理想的上板后你会遇到时钟约束、异步信号处理、按键消抖、数码管显示等一系列真实硬件问题。我第一次上板时CPU在仿真里跑得好好的一上板就死机最后发现是时钟没有做同步处理导致的。上板调试的核心思路是降速观察。板载时钟通常50MHz起步你的程序可能几千个周期就跑完了肉眼根本看不到现象。办法是用一个计数器把时钟分频到1Hz左右让CPU一个指令一个指令地慢慢跑然后通过LED或者数码管把PC值、寄存器值、ALU结果显示出来观察每条指令的实际执行情况。这种直观的验证方式比你盯着仿真波形琢磨半天要省力得多。写在最后的一些实在话调CPU这件事很磨人但也特别能锻炼人。我记得自己第一次单周期CPU仿真出现第一个正确结果时波形图上PC值一个周期一个周期地往后走寄存器堆里的数值按预期变化那种感觉确实挺特别的。后来帮别人调CPU见得最多的错误反而不是复杂的逻辑问题而是信号没连接、位宽不匹配、always块里漏了敏感变量这些低级错误。所以在写代码的时候就老老实实一个模块一个模块来写完一个测一个别急着拼整机。如果这篇文章能让你少走几个弯路那这些踩坑记录就没白写。最后再分享一个小技巧仿真过了之后别急着删掉testbench把它留着后面做多周期或者流水线CPU时它就是现成的回归测试用例改一个版本跑一遍保证不把老功能改坏。这个小习惯能帮你在后面的实验中省下大量排错的时间。
返回列表