尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

用Verilog手写五级流水线MIPS CPU:从架构到冒险处理

用Verilog手写五级流水线MIPS CPU:从架构到冒险处理 1. 项目概述为什么用 Verilog 手写一个流水线 CPU如果你正在学计算机组成原理或者准备找数字前端、IC 验证相关的工作有一个坎是绕不过去的亲手写一个能跑指令的 CPU。而从我带过的新人和实习生来看大多数人卡在了两个地方——第一是不知道从哪下手第二是写出来的东西只能过综合一跑仿真就各种冒险问题炸锅。这篇博文我会用 Verilog 完整走一遍 5 级流水线 MIPS CPU 的搭建过程包含取指IF、译码ID、执行EX、访存MEM、写回WB每一级的实现思路、数据冒险与控制冒险的处理策略、完整的仿真测试方法以及我在调试时踩过的一些典型坑。项目默认使用 MIPS32 指令集子集支持lw、sw、add、sub、and、or、slt、beq、bne、j这几条常用指令足够跑通冒泡排序一类的小程序。先明确一点我们写的不是玩具。虽然指令集是子集但流水线结构、转发逻辑、气泡插入、分支处理这些核心机制都是真实芯片里在用的东西。你把这套代码吃透回头再看 RISC-V 的五级流水线基本就是换了个指令编码的事。适合谁来参考两类人。一类是正在学计组的本科生做课程设计或准备考研复试机试另一类是准备数字 IC 笔试面试的工程师需要把流水线冒险和处理机制讲清楚、写明白。下面我不会跳过任何关键步骤尽量做到给出一份能直接跑通的工程。2. 整体架构设计五级流水线的骨架与指令集规划2.1 为什么是五级流水而不是单周期或多周期单周期 CPU 设计简单但时钟周期被最慢指令通常是lw拖死主频上不去。多周期 CPU 每条指令多拍完成控制逻辑复杂不说性能提升也有限。五级流水线是经典折中每一级只做一件事硬件结构规整控制信号清晰适合教学也适合工程扩展。这五级分别是流水级英文缩写核心工作关键寄存器取指IF从指令存储器取指令计算 PC4IF/ID 流水线寄存器译码ID解析指令读取寄存器堆生成控制信号ID/EX 流水线寄存器执行EXALU 运算计算访存地址判断分支条件EX/MEM 流水线寄存器访存MEM读写数据存储器MEM/WB 流水线寄存器写回WB将结果写回寄存器堆无直接回写流水线寄存器是这套架构的灵魂它们把五级隔开让每一级可以独立处理不同指令。打个比方这就像一条装配线每个工位只负责一个动作中间用传送带传递半成品。传送带上的每格就是一个流水线寄存器。2.2 指令集子集的编码规则我选的指令集子集覆盖三类指令R 型、I 型、J 型。这是 MIPS 最核心的分类方式也是理解译码逻辑的基础。R 型指令如add $rd, $rs, $rt字段分布如下[31:26] opcode | [25:21] rs | [20:16] rt | [15:11] rd | [10:6] shamt | [5:0] functI 型指令如lw $rt, offset($rs)字段分布如下[31:26] opcode | [25:21] rs | [20:16] rt | [15:0] immediateJ 型指令只有跳转目标地址字段这里我只实现j指令它把低 26 位左移两位后和 PC4 的高四位拼接成跳转地址。在 Verilog 里我建议用宏定义或localparam把 opcode 和 funct 提前声明好这样译码逻辑写起来不会一堆魔法数字满天飞。下面是实际工程中的定义方式// opcode 定义 localparam OP_R_TYPE 6b000000; // R型 localparam OP_LW 6b100011; // lw localparam OP_SW 6b101011; // sw localparam OP_BEQ 6b000100; // beq localparam OP_BNE 6b000101; // bne localparam OP_J 6b000010; // j // funct 定义 localparam FUNCT_ADD 6b100000; localparam FUNCT_SUB 6b100010; localparam FUNCT_AND 6b100100; localparam FUNCT_OR 6b100101; localparam FUNCT_SLT 6b101010;可以把你需要的指令都这样声明后面写译码器和控制信号生成时代码会干净很多也方便扩展。3. 各模块的 Verilog 实现从数据通路到流水线寄存器3.1 取指级PC 逻辑与指令存储器IF 级要干的事很简单维护 PC从指令存储器读指令同时计算好 PC4。但这里有个很多人容易忽略的点PC 的更新不是简单每个时钟加 4它要响应分支跳转、暂停和气泡插入。我习惯把 PC 的更新逻辑单独写成一个 always 块优先级从高到低是暂停stall 分支跳转 顺序执行。这样控制逻辑清晰不会出现多个信号同时驱动 PC 的竞争问题。always (posedge clk or posedge rst) begin if (rst) begin pc 32h0000_0000; end else if (stall_en) begin pc pc; // 暂停时PC保持不变 end else if (branch_taken) begin pc branch_target; // 分支跳转地址 end else begin pc pc 32d4; end end指令存储器用 Verilog 实现时我建议声明成寄存器数组然后用$readmemh在仿真初始化时把机器码装进去。注意一点教学用的指令存储器应该用同步读还是异步读我这里用的是组合逻辑异步读也就是assign instr mem[pc[9:2]];理由是在仿真里更快而且不会引入额外的时序约束问题。如果你要在 FPGA 上跑RAM 的读端口往往是同步的就需要根据实际硬件资源调整。3.2 流水线寄存器 IF/ID把指令“锁存”住IF/ID 寄存器的功能很朴素在每个时钟上升沿把当前取到的指令和 PC4 锁存下来供 ID 级使用。但它的使能信号很关键当流水线暂停时IF/ID 必须保持不动否则当前正在执行的指令会被覆盖。always (posedge clk or posedge rst) begin if (rst) begin if_id_pc_plus4 32d0; if_id_instr 32d0; end else if (stall_en) begin // 保持原值不更新 end else begin if_id_pc_plus4 pc_plus4; if_id_instr instr; end end这里有个细节当检测到分支跳转时IF/ID 里取到的那条指令是无效的需要把它清掉插入气泡。所以我还会加一个flush_en信号在 flush 时把if_id_instr置为 0即nop指令保证 ID 级不会误译码一条不该执行的指令。3.3 译码级寄存器堆、控制信号与立即数扩展ID 级做的事情比较多把指令字段拆出来、读寄存器堆、生成 ALU 控制信号、计算分支目标地址。寄存器堆是这级最核心的组件我采用双读口单写口的经典设计// 寄存器堆32个32位寄存器x0恒为0 reg [31:0] regfile [0:31]; assign read_data1 (rs_addr ! 5d0) ? regfile[rs_addr] : 32d0; assign read_data2 (rt_addr ! 5d0) ? regfile[rt_addr] : 32d0; always (posedge clk) begin if (reg_write_en (write_addr ! 5d0)) begin regfile[write_addr] write_data; end end必须把$0寄存器硬连线成 0这是 MIPS 的规范也是很多新手最容易漏掉的点。如果不做这个保护一旦有指令尝试往$0写数据整个寄存器堆的内容就会出错而且这种 bug 在仿真里极难排查。立即数扩展也有讲究。lw、sw、addi这类指令需要符号扩展因为 offset 可能是负数而逻辑运算指令如果用到立即数通常做零扩展。我在项目里用ext_imm信号区分wire [31:0] imm_ext; assign imm_ext ext_type EXT_SIGN ? {{16{instr[15]}}, instr[15:0]} : {16d0, instr[15:0]};控制信号的生成我用了一个组合逻辑 always 块根据 opcode 输出reg_write_en、alu_src、mem_write_en、mem_to_reg、branch_type等信号。这里要养成的习惯是把控制信号集中生成用case而不是一堆if-else可读性会好很多。3.4 执行级ALU 设计与分支判断EX 级是算力担当核心是 ALU。我的 ALU 支持加、减、与、或、比较小于控制信号alu_control由 funct 字段和 opcode 共同决定。加法器的实现我采用常规的组合逻辑加法但在比较指令slt上有坑它比较的是有符号数所以要先判断符号位再比较数值位不能直接拿无符号比较器糊弄。// slt 实现有符号比较rs rt 时输出1 assign slt_result ($signed(alu_in1) $signed(alu_in2)) ? 32d1 : 32d0;分支判断我放在 EX 级做因为这时rs和rt的数据已经读出来了。beq是相等跳转bne是不等跳转控制逻辑如下assign branch_taken (branch_type BEQ (alu_in1 alu_in2)) || (branch_type BNE (alu_in1 ! alu_in2));这里刻意把分支地址计算PC4 符号扩展立即数左移两位也放在 EX 级和分支判断同步完成。这样分支损失固定为 2 个时钟周期相比在 ID 级判断可以减少一个气泡但代价是数据冒险检测会更复杂后面章节细说。3.5 访存级与写回级数据存储器与回写路径MEM 级是数据存储器读写。这里有一个教学和工程上的经典区分指令存储器和数据存储器一定要分开因为 MIPS 是哈佛结构至少在教学模型里是。数据存储器同样是寄存器数组支持按字读写reg [31:0] data_mem [0:4095]; always (posedge clk) begin if (mem_write_en) begin data_mem[alu_result[13:2]] write_data; end end assign mem_read_data data_mem[alu_result[13:2]];写回级最核心的是三选一的选择逻辑写回的数据可以来自 ALU 结果、数据存储器读取结果或者 PC8处理跳转链接指令时用本例不涉及但留了口子。assign write_back_data (mem_to_reg 2b01) ? mem_read_data : (mem_to_reg 2b10) ? pc_plus8 : alu_result;写回数据通过 MEM/WB 寄存器锁存后在 WB 级时钟沿写入寄存器堆。到这里一条指令的生命周期就完整了。4. 流水线冒险数据相关、转发与气泡插入4.1 数据冒险的三种典型场景流水线真正的难点从这里开始。数据冒险的本质是某条指令要用的数据还没被前面的指令写回寄存器堆。我们最常遇到的是写后读RAW相关典型场景画出来是这样add $t0, $t1, $t2在 EX 级算出了$t0的新值sub $t3, $t0, $t4在 ID 级正要读$t0可是$t0要在 WB 级才写回sub指令现在读到的还是旧值。如果不处理运算结果就是错的。解决思路有两个转发forwarding和暂停stalling。转发是把还在流水线里、尚未写回的结果直接绕道送给 ALU 的输入端暂停则是让流水线停下来等数据真正写回。前者是主流方案后者是兜底方案。4.2 转发逻辑的实现细节转发逻辑位于 EX 级它要比较 EX 级指令的源寄存器号与 MEM 级、WB 级指令的目的寄存器号。一旦相等且目的寄存器不是$0就把 EX 级 ALU 的输入从寄存器堆读值换成流水线里更靠后的结果。// 转发A路EX级的rs来自哪里 always (*) begin case (forward_a) 2b00: alu_in1 id_ex_read_data1; // 正常路径 2b10: alu_in1 ex_mem_alu_result; // 从EX/MEM转发上一条指令的ALU结果 2b01: alu_in1 mem_wb_write_back_data; // 从MEM/WB转发上上条指令的结果 default: alu_in1 id_ex_read_data1; endcase end判定逻辑需要在 EX 级写一个专门的模块// forward_a 生成条件 if (ex_mem_reg_write_en (ex_mem_write_reg ! 5d0) (ex_mem_write_reg id_ex_rs)) forward_a 2b10; else if (mem_wb_reg_write_en (mem_wb_write_reg ! 5d0) (mem_wb_write_reg id_ex_rs)) forward_a 2b01; else forward_a 2b00;对于rt也就是 B 路逻辑完全对称。我特意把两条路分开写是为了在后面排查问题时能快速定位是哪一侧的数据送错了。4.3 什么时候必须暂停load-use 冒险转发不是万能的。有一种情况转发也救不了lw指令在访存级才拿到数据而紧随其后的指令在译码级就要用这个数据。这类“load 后紧跟使用”的冒险唯一的办法是插入一个气泡让lw的结果再往流水线后面走一级才能转发。判断条件在 ID 级做ID/EX 寄存器里存的指令是lw且它的rt等于当前 ID 级指令的rs或rt。这时就拉高stall_enassign load_use_stall (id_ex_opcode OP_LW) ((id_ex_rt id_rs) || (id_ex_rt id_rt));拉高stall_en后要做三件事PC 保持不变防止取到新指令IF/ID 寄存器保持不变防止当前指令被覆盖向 ID/EX 寄存器里插入一条空指令nop让流水线中间断一档第三点是关键。如果不把 ID/EX 置空刚才那条需要暂停的指令会在下个周期错误地进入 EX 级等于白停一拍。4.4 控制冒险分支与跳转的处理策略控制冒险来自指令流向被改变。对于beq、bne我在 EX 级判断分支所以一旦分支跳转发生IF 和 ID 两级已经取进来两条错误指令必须把它们清掉。我的做法是检测到branch_taken时同时拉高flush_if_id和flush_id_ex把这两级寄存器里面的指令替换成nop。always (posedge clk or posedge rst) begin if (rst || flush_en) begin id_ex_rd_data1 32d0; id_ex_rd_data2 32d0; id_ex_alu_ctrl 4d0; // ... 其他控制信号清零 end // ... end对于j指令因为跳转目标直接由指令字段计算不依赖任何寄存器值理论上可以在 ID 级就完成跳转把分支损失压到 1 拍。我的工程里把j的跳转判断也放在 EX 级用同一个branch_taken信号处理这样控制逻辑统一少了一个特殊 case。代价是多了 1 拍延迟——对于教学项目完全值得。这里给大家一个进阶方向论文和工业级 CPU 会用分支预测来降低控制冒险开销比如 BTFN预测不跳转策略。但在基础实现里先把静态预测做好就够了——也就是“默认不跳转跳了再冲刷”我们的做法正是这个。5. 完整工程代码框架与关键文件组织5.1 工程目录结构与模块规划写一个稍微工程化一点的 Verilog 项目不要把所有代码塞到一个文件里这是我从第一份工作起就养成的习惯。整个 CPU 的目录结构如下mips_pipeline/ ├── rtl/ │ ├── mips_cpu_top.v // 顶层模块例化所有子模块 │ ├── if_stage.v // 取指级 │ ├── id_stage.v // 译码级 │ ├── ex_stage.v // 执行级 │ ├── mem_stage.v // 访存级 │ ├── wb_stage.v // 写回级 │ ├── pipe_reg_if_id.v // IF/ID寄存器 │ ├── pipe_reg_id_ex.v // ID/EX寄存器 │ ├── pipe_reg_ex_mem.v // EX/MEM寄存器 │ ├── pipe_reg_mem_wb.v // MEM/WB寄存器 │ ├── alu.v // ALU模块 │ ├── forwarding_unit.v // 转发单元 │ ├── hazard_unit.v // 冒险检测单元 │ └── regfile.v // 寄存器堆 ├── sim/ │ ├── testbench.v // 测试平台 │ ├── inst_mem.hex // 指令存储器初始化文件 │ └── data_mem.hex // 数据存储器初始化文件 └── tools/ └── asm.py // 简易汇编器汇编助记符转机器码把流水线寄存器单独建模成模块有几个好处第一仿真波形里信号名清楚一眼看出每一级的边界第二以后想改成 7 级流水或加乱序执行只需要替换寄存器的位置和数量不用动功能模块。5.2 顶层模块的连接关系顶层模块mips_cpu_top就是把每个子模块按数据通路连接起来。连接时最容易出错的是各种使能信号的传递reg_write_en从 ID 级产生但它要跟着流水线一路传到 WB 级才能用所以每一级流水线寄存器里都要存一份控制信号。我把这些控制信号打包成一个结构体向量或者按位段拼接避免端口列表长得没法看。// 伪代码示意控制信号随流水线寄存器逐级传递 wire [7:0] id_ex_ctrl; // ID/EX级控制信号 wire [7:0] ex_mem_ctrl; // EX/MEM级控制信号由id_ex_ctrl打拍得到 wire [7:0] mem_wb_ctrl; // MEM/WB级控制信号 // 控制信号字段定义 // bit7: reg_write_en // bit6: mem_to_reg[0] // bit5: mem_to_reg[1] // bit4: mem_write_en // bit3: alu_src // bit2: branch_type[1] // bit1: branch_type[0] // bit0: ext_type这个打包方式是我从阅读 ARM 处理器源码时学到的非常实用。你想想如果每个控制信号都单独打一个变量每级寄存器端口多不说关键是容易漏掉某一个信号没传到后面写回级写不了寄存器查半天都不知道是哪个控制位断了。5.3 顶层模块关键例化代码下面给出顶层模块的部分例化代码便于你理解模块间的连接关系module mips_cpu_top ( input wire clk, input wire rst ); // IF级内部信号 wire [31:0] pc_current; wire [31:0] pc_plus4; wire [31:0] instruction; // IF/ID寄存器输出 wire [31:0] if_id_pc_plus4; wire [31:0] if_id_instr; // ID级内部信号 wire [4:0] id_rs; wire [4:0] id_rt; wire [4:0] id_rd; wire [31:0] id_read_data1; wire [31:0] id_read_data2; wire [31:0] id_imm_ext; wire [7:0] id_ctrl; // 控制信号 wire stall_en; wire flush_en; wire [1:0] forward_a; wire [1:0] forward_b; wire [1:0] mem_to_reg; // 流水线寄存器例化 pipe_reg_if_id u_if_id ( .clk (clk), .rst (rst), .en (~stall_en), .flush (flush_en), .pc_plus4_i (pc_plus4), .instr_i (instruction), .pc_plus4_o (if_id_pc_plus4), .instr_o (if_id_instr) ); // ID级例化 id_stage u_id ( .clk (clk), .rst (rst), .instr (if_id_instr), .pc_plus4 (if_id_pc_plus4), .reg_write_en (mem_wb_ctrl[7]), .write_reg (mem_wb_write_reg), .write_data (write_back_data), .reg_write_en_o (id_ctrl[7]), // 其他端口... ); // 冒险控制单元例化 hazard_unit u_hazard ( .id_ex_opcode (id_ex_opcode), .id_ex_rt (id_ex_rt), .id_rs (id_rs), .id_rt (id_rt), .branch_taken (branch_taken), .stall_en (stall_en), .flush_en (flush_en) ); // 转发单元例化 forwarding_unit u_forwarding ( .id_ex_rs (id_ex_rs), .id_ex_rt (id_ex_rt), .ex_mem_reg_write (ex_mem_ctrl[7]), .ex_mem_write_reg (ex_mem_write_reg), .mem_wb_reg_write (mem_wb_ctrl[7]), .mem_wb_write_reg (mem_wb_write_reg), .forward_a (forward_a), .forward_b (forward_b) ); endmodule这个顶层模块看起来很长但逻辑很清楚每个信号从哪里来到哪里去命名规范统一波形调试时对着信号名就能找到问题。6. 仿真验证从写汇编到看波形6.1 手写一个简易汇编器很多人做 CPU 课程设计时最痛苦的不是写 Verilog而是把汇编指令翻译成机器码。手算太容易错所以我写了一个 Python 脚本把助记符转成 .hex 文件。这个工具相当于一个简化版交叉汇编器。它支持我们定义的那 10 条指令直接读取汇编文本输出初始化文件。# asm.py 的关键逻辑节选 reg_map { zero: 0, at: 1, v0: 2, v1: 3, a0: 4, a1: 5, a2: 6, a3: 7, t0: 8, t1: 9, t2: 10, t3: 11, t4: 12, t5: 13, t6: 14, t7: 15, s0: 16, s1: 17, s2: 18, s3: 19, s4: 20, s5: 21, s6: 22, s7: 23, t8: 24, t9: 25, k0: 26, k1: 27, gp: 28, sp: 29, fp: 30, ra: 31 } def encode_branch(parts, line_idx, labels): # beq $t0, $t1, label opcode 0b000100 rs reg_map[parts[1].rstrip(,)] rt reg_map[parts[2].rstrip(,)] offset (labels[parts[3]] - line_idx - 1) 0xFFFF return (opcode 26) | (rs 21) | (rt 16) | offsetbeq的偏移量计算是新手最容易搞错的地方。MIPS 的分支偏移量是以指令数为单位不是字节数并且是相对下一条指令的偏移。也就是说beq跳到label偏移量是(label地址 - (当前指令地址 4)) / 4。我当时第一次写这个脚本时忘了除以 4结果所有分支都跳到错误位置调了很久才反应过来。6.2 测试程序从简单加法到冒泡排序测试用例要分层。我先写一个最简单的加法程序验证数据通路通不通再写一段连续紧邻的算术运算验证转发逻辑最后写一个包含lw后紧跟add的程序专门触发 load-use 暂停再写上分支程序验证控制冒险处理。下面是一段专门用来测试 load-use 冒险的汇编lw $t0, 0($s0) # 从内存读取数据到$t0 add $t1, $t0, $t1 # 紧跟着用$t0必然触发load-use暂停 sw $t1, 4($s0) # 写入结果在仿真波形里你应该能看到stall_en在第二条指令处拉高一个周期同时 ID/EX 里插入了一个气泡。如果这段代码能跑出正确结果说明暂停逻辑是好的。更完整的测试是给寄存器数组初始化一组乱序数据然后运行冒泡排序最后检查内存里的数据是否从小到大排列。这类程序能验证分支、比较、访存、写回的所有环节而且一旦有错你从最终结果的错位就能反推是哪条指令出了问题。6.3 Testbench 的实时监测与自动判错Testbench 不能光看波形还要有自动判错机制。我习惯在 testbench 里做两件事第一在每个时钟上升沿检测一些非法组合比如出现 PC 跳飞、写寄存器地址为 0 还置写了写使能第二在程序运行结束后自动读取数据存储器里的结果并比对期望值。// Testbench 自动判错示例 reg [31:0] expected_data [0:3]; initial begin // 加载程序和数据 $readmemh(../sim/inst_mem.hex, u_cpu.u_if.inst_mem); $readmemh(../sim/data_mem.hex, u_cpu.u_mem.data_mem); expected_data[0] 32h00000005; expected_data[1] 32h00000008; expected_data[2] 32h0000000A; expected_data[3] 32h0000000F; end // 运行足够周期后检查结果 initial begin repeat (200) (posedge clk); if (u_cpu.u_mem.data_mem[0] ! expected_data[0] || u_cpu.u_mem.data_mem[1] ! expected_data[1] || u_cpu.u_mem.data_mem[2] ! expected_data[2] || u_cpu.u_mem.data_mem[3] ! expected_data[3]) begin $display(TEST FAILED: data mismatch); $finish; end else begin $display(TEST PASSED); $finish; end end这种自动判错机制能让你在修改代码后快速回归不至于每次改完都要眼盯波形看几十万个时钟周期。7. 调试心得与常见问题排查7.1 信号初始态导致的仿真崩溃这是新手最常见的问题仿真一开始输出全是 X或者 CPU 跑着跑着就飞了。绝大多数原因是寄存器堆和存储器没有初始化。在 FPGA 上电后 SRAM 内容是不确定的仿真器也是如此。我建议在 testbench 初始化时用$readmemh加载程序同时给寄存器堆加一个异步复位或者用initial把关键寄存器清 0。另外alu_result这类组合逻辑信号如果输入中有 X输出一定是 X所以要尽早发现 X 的根源而不是等它传播到控制逻辑。7.2 转发信号打架优先级顺序没排对转发单元的优先级必须严格EX/MEM 级转发优先于 MEM/WB 级。原因是 EX/MEM 级的数据更新鲜。如果两条连续指令都要写同一个寄存器比如add $t0, $t1, $t2 # 在EX级结果马上可用 add $t3, $t0, $t4 # 在ID级需要$t0 or $t5, $t0, $t6 # 在EX级或后续级也需要$t0如果优先级写反了后面的指令可能拿到or指令已经更新的$t0值而实际上它需要的是add的结果数据就错了。所以我在 4.2 节给出的生成逻辑里用了if-else if-else本质上就是把优先级写死。7.3 分支延迟槽一个容易忽视的实现选择我这里没有实现分支延迟槽因为 MIPS 架构本身有这个概念但很多教学项目选择忽略它。如果你想做得更接近真实 MIPS可以在分支指令后面放一条总会执行的指令延迟槽指令这样分支冲刷逻辑就要相应修改只有延迟槽之后的那条指令会被清掉延迟槽指令本身要保留执行。这个点做起来不难但容易出现“该冲的没冲、不该冲的冲了”的边界问题。我在这个项目里选择不实现延迟槽一是为了控制代码复杂度二是现在的指令集架构基本都抛弃了延迟槽改成硬件分支预测了。7.4 排查问题的方法论从波形反推流水级最后分享一个实战经验当运行结果不对时不要从头到尾看波形。正确做法是先定位出错指令。我一般这样操作在 testbench 里加入$display打印每条指令的 PC、指令编码、译码后的控制信号运行完成后从最后一个错误结果往前找定位第一个写错数据的写回操作根据这条写回指令的 PC在波形里找到它经过每一级的时刻查看它在 EX 级时 ALU 的两个输入是否来自正确的转发路径这套方法帮我解决过几乎所有流水线数据相关的问题。你可能会觉得这样有点笨但事实上在真实芯片调试中定位问题最有效的方式就是“二分定位 关键信号追踪”和软件调试的断点思路一模一样。还有一个心态上的建议写 CPU 这个项目最忌讳“急着把代码堆完然后一把跑通”。我第一次写五级流水线时把全部代码写完才跑仿真结果一堆 X 和未连接信号查了整整两天才理顺。后来学乖了写完一个模块就先单独仿真一个模块寄存器堆单独测读写ALU 单独测运算流水线寄存器单独测使能和清零。等每个模块都验证过再连起来调问题就少得多。这个“自底向上验证”的策略适用于所有数字逻辑项目。到目前为止这个 CPU 已经能稳定运行我手写的冒泡排序、斐波那契等测试程序。如果你也想动手做建议先跑通我这个最简单版本然后逐步加指令、加中断、加分支预测每加一个特性就做一轮完整回归。这个过程会让你对计算机体系结构的理解上一个台阶。
返回列表