ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

手把手实现MIPS32单周期CPU:Verilog数据通路与仿真验证全解析

2026/10/6 11:49:18 拓冰建站 浏览量
手把手实现MIPS32单周期CPU:Verilog数据通路与仿真验证全解析 折腾完Verilog单周期CPU设计之后我最大的感受是CPU这东西看着玄乎把它拆成一条数据通路再逐段实现其实比想象中要清晰得多。这篇笔记是我完整走完一遍MIPS32单周期CPU设计后整理的超详细复盘从指令集选型、数据通路搭建、模块编码到仿真验证把每个关键决策背后的原因也一并写透。如果你想亲手写一个属于自己的CPU或者正在做计算机组成原理的实验这篇内容应该能帮你少走不少弯路。单周期CPU设计最核心的价值是逼你把取指—译码—执行—访存—写回这条链路老老实实捋一遍。它不涉及流水线冒险、乱序执行这些复杂话题却能把CPU最基本的工作原理完整呈现出来。学会了它后面再看多周期、流水线、Cache设计你会发现自己对数据通路和控制信号这两个概念已经建立了非常扎实的直觉。1. 单周期CPU到底是什么一个时钟周期跑完一条指令1.1 单周期的周期指什么CPU执行一条指令内部实际上要经历好几个阶段根据PC去指令存储器取指令、解析指令的操作码和操作数、从寄存器堆读取源操作数、交给ALU做运算、如果是访存指令还要访问数据存储器、最后把结果写回寄存器堆。在单周期CPU中这些阶段全部在一个时钟周期内完成下一个时钟上升沿到来时PC已经更新到下一条指令的地址整条链路又开始处理新的指令。打个比方单周期CPU就像一个全能店员顾客下单一杯咖啡他需要同时完成接单、磨豆、萃取、装杯、递给顾客整个流程在一个营业周期内做完。多周期CPU则像一个分工明确的流水线有人负责接单、有人负责磨豆、有人负责萃取每个人只干自己的环节。分工协作效率高但管理逻辑也更复杂。很多初学者会把这个周期误解为每个阶段一个周期其实不是。单周期的意思是一条指令的全部阶段拼在一起只占一个时钟周期。这个周期必须足够长长到能容纳最慢的那条指令通常是lw指令因为它要经历完整的取指、读寄存器、ALU计算、访问数据存储器、写回五个阶段。1.2 为什么选MIPS指令集作为教学基础工业界常用的指令集有x86、ARM、RISC-V但教学实验中最经典的选择是MIPS32。原因有三第一指令长度固定为32位格式非常规整只有R型、I型、J型三类译码逻辑简单清晰。第二MIPS是典型的RISC架构指令数量少、功能单一适合做硬件实现。第三教材和开源资料极其丰富几乎所有计算机组成原理课程都围绕它展开。本设计实现的指令子集为R型指令add、sub、and、or、sltI型指令lw、sw、beq、addi加上J型指令j。这10条指令看似不多但已经覆盖了CPU设计所需的全部核心机制寄存器读写、ALU运算、存储访问、分支跳转、立即数处理。把这10条指令跑通你就拥有了一个五脏俱全的最小CPU。1.3 指令编码速查表MIPS指令的编码格式分三类R型opcode(31:26) | rs(25:21) | rt(20:16) | rd(15:11) | shamt(10:6) | funct(5:0)I型opcode(31:26) | rs(25:21) | rt(20:16) | imm(15:0)J型opcode(31:26) | target(25:0)本设计用到的所有指令编码如下表所示指令类型opcodefunct功能addR000000100000寄存器相加subR000000100010寄存器相减andR000000100100按位与orR000000100101按位或sltR000000101010小于则置1addiI001000-立即数加lwI100011-从内存加载swI101011-存入内存beqI000100-相等则跳转jJ000010-无条件跳转这张表是后面写控制单元的地图建议先把它看熟再动手。2. 数据通路与指令集设计动手写代码前必须想清楚的两件事2.1 五个核心模块的职责边界在设计CPU时模块划分是第一优先级。单周期CPU的数据通路由五个核心模块构成我习惯用数据通路上有哪些站来理解它们PC程序计数器一个32位的时序逻辑模块保存当前指令的地址。每个时钟上升沿更新为下一条指令的地址。指令存储器Instruction Memory保存指令序列。读操作是组合逻辑把PC的值作为地址输出对应的32位指令。寄存器堆Register File32个32位寄存器提供两个读端口和一个写端口。读是组合逻辑写是时序逻辑。ALU算术逻辑单元完成加减、与或、比较运算输出结果和一个zero标志位。数据存储器Data Memory在lw/sw指令时被访问。读是组合逻辑写是时序逻辑。除这五个模块外还需要三个辅助模块立即数扩展模块把16位立即数符号扩展为32位、控制单元根据opcode产生控制信号、ALU控制模块根据ALUOp和funct产生ALU操作码。这里我给一个强烈建议在写任何Verilog代码之前先拿笔在纸上把数据通路图画出来。你不需要画得像教科书那么精致但必须明确每个模块的输入输出、每条线的来源和去向。我见过太多同学上来就敲代码结果在顶层模块里信号连接错乱花在Debug上的时间比写代码的时间还多。2.2 多路选择器的十字路口作用数据通路之所以需要多路选择器是因为同一个硬件模块要服务于不同指令的不同需求。单周期CPU中至少要有四个关键的多路选择器。写寄存器地址选择由RegDst信号控制。R型指令要写入rd字段指令[15:11]I型指令要写入rt字段指令[20:16]。ALU第二个操作数选择由ALUSrc信号控制。R型指令的第二个操作数是寄存器堆输出的rt值I型指令则是立即数扩展后的值。写回数据选择由MemtoReg信号控制。ALU计算结果和从数据存储器读出的值哪个写回寄存器R型运算指令选ALU结果lw指令选存储器读出的数据。分支/跳转地址选择由Branch和Jump信号控制。正常情况下PC更新为PC4beq且zero1时更新为分支目标地址j指令更新为跳转地址。下面这张控制信号真值表是数据通路的开关配置表写控制单元时直接对着它抄即可指令RegDstALUSrcMemtoRegRegWriteMemReadMemWriteBranchJumpALUOpR型1001000010lw0111100000swx1x0010000beqx0x0001001addi0101000000jxxx00001xxx表示无所谓取0或1都不影响该指令的行为代码中设为默认值即可。2.3 ALU操作码的生成逻辑ALUOp是两位的控制信号由控制单元根据opcode生成。它还不能直接驱动ALU因为R型指令的ALU操作码还需要结合funct字段来确定。这两级译码的设计是MIPS的经典做法好处是控制单元不需要关心funct细节ALU控制模块则专门处理R型指令的funct译码。ALU控制信号的生成规则如下ALUOpfunctALU操作ALU控制码说明00-加法000lw/sw/addi01-减法001beq10100000加法000add10100010减法001sub10100100按位与010and10100101按位或011or10101010小于100slt这里的设计巧思在于lw/sw/addi三条指令都只需要ALU做加法所以ALUOp00时统一输出加法beq需要ALU做减法来判断是否相等所以ALUOp01时输出减法。R型指令才需要真正检查funct字段。3. 核心模块的Verilog实现PC、寄存器堆、ALU、控制单元3.1 PC模块整个CPU的节拍器PC模块是整个CPU的起始点它本质上就是一个带异步复位的32位寄存器。每个时钟上升沿把pc_next的值锁存到pc中。module pc( input wire clk, input wire rst_n, input wire [31:0] pc_next, output reg [31:0] pc ); always (posedge clk or negedge rst_n) begin if (!rst_n) pc 32h0000_0000; else pc pc_next; end endmodule这里的几个设计要点复位用的是异步复位、低电平有效。复位后PC指向0x00000000这是仿真时最简单的选择。有些教材会把入口地址设为0x00400000MIPS Linux的用户程序入口但在实验中没必要增加这个复杂度。pc_next是组合逻辑产生的它可能是PC4、分支目标地址、跳转地址三者之一。这个选择逻辑我会放在顶层模块中。从时序角度看PC更新的那一刻意味着一个指令周期的结束和下一个指令周期的开始。所有组合逻辑取指、译码、ALU运算、访存读都必须在这个上升沿到来之前稳定下来。3.2 指令存储器用组合逻辑实现的ROM指令存储器在行为上是一个只读存储器读操作必须是组合逻辑这样取指才能在一个周期内完成。module instr_mem( input wire [31:0] addr, output reg [31:0] instr ); reg [31:0] mem [0:63]; // 支持64条指令可扩展 initial begin $readmemh(instr_hex.txt, mem); end always (*) begin instr mem[addr[31:2]]; // 字节地址转字索引 end endmodule注意addr[31:2]这个写法。MIPS是字节寻址的每条指令占4个字节所以PC每次加4。如果直接用addr作为数组索引那么在PC4时就会访问mem[4]而不是mem[1]完全错误。用addr[31:2]取出字地址部分相当于自动完成了除以4的操作。$readmemh从文本文件加载指令是仿真和工程实践中最常用的初始化方式。文件里每行一个十六进制数例如20080005 20090003 01095020关于这个文件路径如果你使用Vivado推荐把文件放在工程根目录并使用相对路径如果放在了其他位置记得用完整路径。我第一次做实验时犯过这个错误仿真器直接报文件找不到折腾了半天才发现是路径问题。3.3 寄存器堆读异步、写同步寄存器堆是CPU中使用频率最高的模块之一它的设计有两个关键点一是读端口必须是组合逻辑二是0号寄存器必须恒为0。module regfile( input wire clk, input wire reg_write, input wire [4:0] read_reg1, input wire [4:0] read_reg2, input wire [4:0] write_reg, input wire [31:0] write_data, output wire [31:0] read_data1, output wire [31:0] read_data2 ); reg [31:0] regs [0:31]; always (posedge clk) begin if (reg_write write_reg ! 5b0) regs[write_reg] write_data; end assign read_data1 (read_reg1 5b0) ? 32b0 : regs[read_reg1]; assign read_data2 (read_reg2 5b0) ? 32b0 : regs[read_reg2]; endmodule读端口用组合逻辑实现是因为ALU需要在一个时钟周期内拿到两个源操作数并完成运算。如果读操作也是时序的就需要额外等一个时钟周期单周期模型就被破坏了。写端口用同步时序逻辑在时钟上升沿写入。这里必须加一个判断当write_reg为0时即使reg_write有效也不能写入。0号寄存器是硬连线的零是MIPS架构的约定。如果违反这个约定很多依赖$0做基准的程序行为会变得不可预测。关于读写同一个寄存器的时序问题我曾被问过很多次指令1写$t0指令2立刻读$t0读到的值是不是旧的答案是新的。因为指令1和指令2各占一个周期指令1的写操作发生在第一个周期的上升沿指令2的读操作发生在第二个周期的组合逻辑阶段此时寄存器堆里已经是更新后的值了。只要不是在同一个上升沿同时读和写同一个寄存器时序上就没有问题。3.4 ALU算术逻辑单元的设计ALU本身并不复杂难点在于zero标志位的生成方式。zero必须是组合逻辑因为它要在当前周期内被beq指令用到。module alu( input wire [31:0] a, input wire [31:0] b, input wire [2:0] alu_ctrl, output reg [31:0] result, output wire zero ); always (*) begin case (alu_ctrl) 3b000: result a b; 3b001: result a - b; 3b010: result a b; 3b011: result a | b; 3b100: result (a b) ? 32b1 : 32b0; default: result 32b0; endcase end assign zero (result 32b0); endmoduleslt指令用了a b的比较这里默认的是有符号比较。但在Verilog中直接写a b综合结果取决于a和b的声明方式如果声明为wire [31:0]就是无符号比较。这是个很容易踩的坑。如果要做有符号比较需要把输入声明为signed或者用$signed()转换。不过在本设计中我测试程序没有涉及负数比较为简化设计用了无符号比较。如果将来要扩展有符号比较只需要把端口改成input wire signed [31:0]即可。另外注意slc指令的结果是0或1不是比较结果的位扩展。很多人第一次写时会把result直接赋值为a b这在位宽不匹配时会出问题应该用三元运算符显式赋32位的0或1。3.5 控制单元用case语句生成全部控制信号控制单元的输入是opcode输出是各控制信号。一个重要的编码习惯是在always块的开始处给所有输出赋默认值然后只在case中覆盖需要置位的信号。module control_unit( input wire [5:0] opcode, 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 jump, 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; jump 0; alu_op 2b00; case (opcode) 6b000000: begin // R型 reg_dst 1; reg_write 1; alu_op 2b10; 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 6b001000: begin // addi alu_src 1; reg_write 1; alu_op 2b00; end 6b000010: begin // j jump 1; end default: ; // 保持默认值 endcase end endmodule为什么必须先给默认值因为组合逻辑如果没有覆盖所有分支综合时会生成锁存器Latch。锁存器在CPU设计中几乎总是祸根因为它对时钟不敏感容易产生毛刺和时序问题。所有组合逻辑always块都必须保证每个输出在所有路径上都被赋值默认值技巧是最简单的保证方式。3.6 ALU控制模块funct与ALUOp的二次译码module alu_control( input wire [1:0] alu_op, input wire [5:0] funct, output reg [2:0] alu_ctrl ); always (*) begin case (alu_op) 2b00: alu_ctrl 3b000; // 加法 2b01: alu_ctrl 3b001; // 减法 2b10: begin case (funct) 6b100000: alu_ctrl 3b000; // add 6b100010: alu_ctrl 3b001; // sub 6b100100: alu_ctrl 3b010; // and 6b100101: alu_ctrl 3b011; // or 6b101010: alu_ctrl 3b100; // slt default: alu_ctrl 3b000; endcase end default: alu_ctrl 3b000; endcase end endmodule这个两级译码的设计是有意的控制单元不需要认识functALU控制模块不需要认识opcode。两者职责分离将来要扩展新指令时改动的范围被限制在对应的译码模块中。3.7 数据存储器读写时序的关键差异数据存储器与指令存储器的最大区别是它支持写操作。这里读用组合逻辑、写用时序逻辑的设计不是随意为之而是由单周期模型决定的lw指令要在当前周期内把读出的数据写回寄存器所以读数据必须在周期内准备好。sw指令的写入则可以等到时钟上升沿统一落盘因为下一条指令不需要立即看到内存的变化。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 reg [31:0] read_data ); reg [31:0] mem [0:63]; always (*) begin if (mem_read) read_data mem[addr[31:2]]; else read_data 32b0; end always (posedge clk) begin if (mem_write) mem[addr[31:2]] write_data; end endmodule一个常见的错误是把数据存储器的地址和写数据接反。数据存储器的译码地址是ALU算出来的alu_result比如 $sp - 4而write_data是被存储的寄存器的值比如$t0的内容。这两个信号绝对不能搞混。4. 顶层连接与关键信号梳理最考验整体思维的一步4.1 顶层模块的接线工程顶层模块的作用是把前面写的所有子模块像搭积木一样连接起来。这一步最考验整体思维因为你需要对每个信号的来源和去向有清晰的认知。module cpu_top( input wire clk, input wire rst_n ); wire [31:0] pc_current; wire [31:0] pc_next; wire [31:0] pc_plus4; wire [31:0] instr; wire [31:0] read_data1, read_data2; wire [31:0] alu_result; wire [31:0] alu_input2; wire [31:0] sign_ext_imm; wire [31:0] shifted_imm; wire [31:0] branch_addr; wire [31:0] jump_addr; wire [31:0] mem_read_data; wire [31:0] write_back_data; wire [31:0] alu_result_delayed; wire zero_flag; wire reg_dst, alu_src, mem_to_reg, reg_write; wire mem_read, mem_write, branch, jump; wire [1:0] alu_op; wire [2:0] alu_ctrl; wire branch_taken; wire [4:0] write_reg_addr; assign write_reg_addr reg_dst ? instr[15:11] : instr[20:16]; assign pc_plus4 pc_current 32d4; assign shifted_imm sign_ext_imm 2; assign branch_addr pc_plus4 shifted_imm; assign jump_addr {pc_plus4[31:28], instr[25:0], 2b00}; assign branch_taken branch zero_flag; assign pc_next jump ? jump_addr : (branch_taken ? branch_addr : pc_plus4); assign alu_input2 alu_src ? sign_ext_imm : read_data2; assign write_back_data mem_to_reg ? mem_read_data : alu_result; pc u_pc( .clk(clk), .rst_n(rst_n), .pc_next(pc_next), .pc(pc_current) ); instr_mem u_imem( .addr(pc_current), .instr(instr) ); regfile u_regfile( .clk(clk), .reg_write(reg_write), .read_reg1(instr[25:21]), .read_reg2(instr[20:16]), .write_reg(write_reg_addr), .write_data(write_back_data), .read_data1(read_data1), .read_data2(read_data2) ); alu u_alu( .a(read_data1), .b(alu_input2), .alu_ctrl(alu_ctrl), .result(alu_result), .zero(zero_flag) ); data_mem u_dmem( .clk(clk), .mem_read(mem_read), .mem_write(mem_write), .addr(alu_result), .write_data(read_data2), .read_data(mem_read_data) ); control_unit u_ctrl( .opcode(instr[31:26]), .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), .jump(jump), .alu_op(alu_op) ); alu_control u_alu_ctrl( .alu_op(alu_op), .funct(instr[5:0]), .alu_ctrl(alu_ctrl) ); sign_extend u_sign_ext( .imm16(instr[15:0]), .imm32(sign_ext_imm) ); endmodule顶层模块中最容易出错的地方集中在三处写寄存器地址的选择、跳转地址的计算、写回数据的选择。这三处是数据通路的岔路口信号一旦接错整个CPU的行为就会变得莫名其妙。4.2 跳转和分支的目标地址计算规则这里要特别说一下beq和j指令的地址计算逻辑这是初学者最容易踩的坑。beq指令的分支目标地址计算方式是branch_addr PC 4 SignExtImm 2。为什么不是PC直接加因为MIPS的分支偏移是相对于下一条指令PC4来计算的而且立即数是一个字偏移需要左移2位变成字节偏移。如果忘记左移或者忘记加PC4程序一跳就会跳到错误的位置。j指令的跳转地址计算方式是jump_addr {PC4[31:28], instr[25:0], 2b00}。这条规则的含义是跳转地址的高4位取PC4的高4位中间的26位来自指令中的target字段低2位补0。这是MIPS为jump指令定义的地址拼接规则不理解这个规则就会在实现时把地址算错。另一个常被忽略的是信号位宽问题。跳转地址中的shifted_imm是一个32位信号而instr[25:0]拼接上2b00后变成了28位信号再与4位高位拼接成32位。在Verilog中位宽不匹配时会有隐式扩展或截断这种问题很难排查。我的建议是所有地址相关的信号统一声明为32位wire再进行运算。4.3 组合逻辑环路的风险单周期CPU中有几个信号之间存在看起来像环路的关系PC - 指令存储器 - 控制单元 - 数据通路 - PC。但实际上这个环路是通过时钟沿来切割的组合逻辑在周期内完成PC在时钟沿更新。只要PC的更新是唯一的时序逻辑入口就不会有真正的组合逻辑环路。不过有一个风险点不可忽视数据存储器的读地址是alu_result而alu_result又是当前周期组合逻辑算出来的。如果数据存储器的读端口是纯组合逻辑那么从输入地址到输出数据的路径上就不能再插入任何寄存器和复杂逻辑。很多仿真工具在遇到组合逻辑环路时不会报错但综合工具会拒绝或者时序收敛极差。如果你的设计要上板跑务必检查是否存在这种隐藏的组合环路。5. 仿真验证与常见问题如何确认你的CPU真的活了5.1 手工生成机器码从汇编到十六进制测试程序是整个验证环节的灵魂。我的做法是手写一段小程序覆盖所有类型的指令然后手工把每条指令编码成机器码写入指令存储器的初始化文件。下面这段测试程序的汇编代码和对应的机器码addi $t0, $0, 5 # 0x20080005 addi $t1, $0, 3 # 0x20090003 add $t2, $t0, $t1 # 0x01095020 sub $t3, $t0, $t1 # 0x01095822 and $t4, $t0, $t1 # 0x01096024 or $t5, $t0, $t1 # 0x01096825 sw $t2, 32($0) # 0xAC0A0020 lw $t6, 32($0) # 0x8C0E0020 beq $t0, $t1, 1 # 0x11090001 addi $t7, $0, 1 # 0x200F0001 j 0 # 0x08000000编码方式并不复杂。以add $t2, $t0, $t1为例opcode000000rs$t001000rt$t101001rd$t201010shamt00000funct100000拼接得到32位二进制数转十六进制就是0x01095020。手工编码时最容易错的是寄存器编号$t0对应编号801000$t1对应编号901001$t2对应编号1001010不要搞混。我强烈建议准备一张MIPS寄存器编号对照表放在手边。5.2 Testbench设计思路与信号观测Testbench的核心工作是产生时钟和复位信号然后监控CPU内部信号。module tb_cpu(); reg clk; reg rst_n; initial begin clk 0; rst_n 0; #10 rst_n 1; #500 $finish; end always #5 clk ~clk; cpu_top u_cpu( .clk(clk), .rst_n(rst_n) ); initial begin $monitor(time%0t PC%0d instr%h regfile[8]%0d regfile[9]%0d alu_result%h write_back%h, $time, u_cpu.pc_current, u_cpu.instr, u_cpu.u_regfile.regs[8], u_cpu.u_regfile.regs[9], u_cpu.alu_result, u_cpu.write_back_data); end endmodule使用层次化引用如u_cpu.u_regfile.regs[8]可以直接观测内部寄存器的值这在调试时非常方便。我建议至少监控以下几类信号PC值确认每条指令被取指的顺序正确当前指令的机器码确认指令存储器初始化正确寄存器堆的关键寄存器值确认运算和写回正确ALU结果和写回数据确认数据通路的连接正确如果你使用Vivado或ModelSim建议导出VCD波形文件用波形观测各个信号的时序关系。波形比文本日志直观得多尤其在排查信号时序问题时波形是唯一可靠的调试手段。5.3 常见仿真Bug与排查方法我在调试过程中遇到过不少问题这里列出几个代表性的PC一直为0不推进。原因通常是复位信号没有正常释放或者时钟没有翻转。先检查Testbench中的时钟产生逻辑和复位释放逻辑。取出的指令一直为0或者未知。原因通常是指令存储器的初始化文件路径错误$readmemh没有生效。在Vivado的仿真日志中会看到文件找不到的warning但有时warning不明显最好在initialize后立即用$display打印mem[0]验证。lw/sw指令访存地址不对。这个问题的根源几乎都是立即数符号扩展错误或者没有把ALU结果接到数据存储器的地址端口。先检查符号扩展模块的输出再看顶层模块中data_mem的addr端口连接。beq指令跳转后PC异常。原因往往是分支目标地址计算错。检查是否左移了2位是否加了PC4以及zero标志是否真的在组合逻辑中产生。跳转指令j执行后PC直接跳到0x00000000。这是正常的因为初始化文件里就写了跳转到0地址。要测试别的跳转行为需要修改跳转target字段。如果你遇到仿真结果与预期完全对不上的情况我的排查顺序是先看PC再取指再控制信号再ALU结果最后看写回。沿着数据通路从头到尾逐级检查比你盯着代码发呆高效得多。6. 单周期CPU的边界与后续扩展思路6.1 为什么工业界不用单周期架构单周期CPU设计清晰、易于理解但效率很低。原因在于一个时钟周期必须满足最慢指令的时序要求而CPU的时钟频率是由这个最慢路径决定的。lw指令需要经历完整的取指、读寄存器、ALU计算、访存、写回全流程路径最长所以时钟周期被拉得很长。其他指令虽然实际耗时更短但也必须在这个足够长的周期内完成造成硬件资源的浪费。举个直观的例子如果加法只需要2ns而lw需要8ns那么单周期CPU的时钟周期至少是8ns加法指令也在8ns内完成其中6ns是浪费的。这就是单周期CPU性能低下的根本原因。6.2 从单周期到多周期、流水线的进阶路径理解了单周期CPU的瓶颈就能理解多周期CPU的设计动机把一个指令的执行过程拆成若干个阶段每个阶段用一个时钟周期不同指令可以共用同一个硬件模块但时钟频率可以大幅提升。再进一步就是流水线CPU设计把取指、译码、执行、访存、写回五个阶段用寄存器隔开让多条指令在时间上重叠执行。流水线CPU需要处理数据冒险、控制冒险、结构冒险等问题复杂度显著上升但性能提升也非常可观。如果你想在单周期CPU的基础上继续深入我建议按这样的顺序来第一扩展指令集。加上sll、srl、nor、jal、jr等指令体会控制单元的扩展方式。第二改为多周期CPU。在模块之间插入流水线寄存器调整控制信号的时序。第三实现五级流水线。加入冒险检测和转发逻辑这是理解现代CPU设计的关键一步。6.3 粘贴代码前的最后几个检查项如果你准备照着这篇笔记写代码最后再提醒几点端口方向必须严格对应。Verilog中input和output搞反是初学者最常见的错误仿真时会直接报错或出现高阻态。位宽匹配要谨慎。比如data_mem的地址端口如果接的是8位信号而ALU输出是32位就会出现位宽截断地址完全错误。时钟和复位信号统一。整个CPU只用同一个时钟域统一用上升沿触发。混用上升沿和下降沿会让时序分析变得极其困难。存储器初始化文件必须存在。在仿真工程中确保指令存储器初始化文件被正确加载否则CPU取回的一直是0或x所有指令都不会执行。先跑通再优化。不要在一开始就追求代码优雅先用最容易理解的写法把功能验证通过再去重构和优化逻辑。我个人在实际设计中的体会是单周期CPU看起来代码量不大但每一步都踩在计算机组成原理的关键知识点上。做完这个项目后再看其他CPU相关的资料很多概念会变得非常具象。最后再分享一个小技巧仿真时用$display在每个时钟上升沿打印PC、指令和关键结果打印几行就能发现问题所在比看几百个波形信号快得多。