ARTICLE DETAIL

资讯详情

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

从零手写CPU:五级流水线设计实战与ori指令调试

从零手写CPU:五级流水线设计实战与ori指令调试 我最早有“自己动手写CPU”这个念头是被一本讲计算机体系结构的书勾起来的。书里说流水线就是把“取指、译码、执行、访存、写回”五件事拆开并行做我觉得自己懂了。结果打开编辑器写Verilog的时候才发现懂概念和能写出一个能跑通指令的CPU之间隔着一整个太平洋。后来我换了个策略不贪多先把一条最简单的指令、一套最简单的五级流水线跑通再谈其他。这条指令我选的是ori。整个调试过程比我想象的更有成就感也踩了不少坑。这篇文章就把我从零搭起五级流水线、并成功跑通第一条ori指令的完整过程记录下来包括骨架代码、测试方法、调试工具和一系列我亲测有效的避坑经验。不管你是学计算机体系结构的学生还是对CPU设计感兴趣的工程师这篇东西应该都能帮你少走点弯路。1. 为什么是五级流水线为什么第一条指令是ori先聊一个特别容易被忽略的问题市面上讲CPU的书那么多为什么大家都喜欢拿五级流水线当教学范例这不是偶然。五级流水线是性能和复杂度之间的一个绝佳平衡点。少于五级比如三级取指和访存会挤在同一级里时序紧张而且很难处理存储器延迟多于五级比如现代处理器动辄十几级甚至二十级流水线引入的转发逻辑、冒险预测、异常恢复会让你根本顾不上理解基本原理。五级流水线的划分非常符合人的直觉流水级英文缩写核心任务取指IF根据PC从指令存储器取出指令译码ID拆解指令字段读取寄存器堆执行EXALU完成算术逻辑运算访存MEM读写数据存储器写回WB将结果写回寄存器堆每一级只干一件事级与级之间用流水寄存器流水线寄存器把数据锁存住。这个“锁存”动作是理解流水线的钥匙前一拍组合逻辑算出结果下一拍时钟沿一到结果被整齐地推进下一级。整个CPU就像一条装配线不同指令在不同工位上同时被加工理想情况下每周期都能完成一条指令。至于第一条指令为什么选ori我当时是这么分析的。ori是“立即数或”指令在MIPS里的语义是rt rs | zero_extend(imm)。它的执行路径是“读寄存器 → ALU做或运算 → 写回寄存器”全程不碰数据存储器、不碰分支跳转、不需要符号扩展判断。换言之它把流水线里最容易出幺蛾子的几个点全部绕开了。你只需要关心指令怎么取出来、字段怎么拆、ALU怎么算、结果怎么写回这正好是流水线最核心的骨架。很多初学者一上来就指定要实现全套指令集这是我很不推荐的路径。指令越多数据冒险、控制冒险、load-use冲突交织在一起出了问题你根本分不清到底是哪一级的逻辑错了。先用一条ori把骨架跑通你就有了一个可以随时回滚的“健康基线”之后每加一条指令都只有增量式的小改动。2. 手写CPU前的环境准备和“骨架级”代码结构2.1 准备工具不一定要Vivado写CPU不一定要用大型EDA工具。我自己平时学习用的是一台老MacBook Pro配合VSCode和开源仿真工具链体验相当清爽。我推荐的组合是环境项推荐选择说明编辑器VS Code Verilog插件语法高亮、自动补全够用仿真器Icarus Verilog (iverilog)免费开源支持IEEE 1364-2005五级流水线完全够用波形查看GTKWave免费开源查看VCD/FST波形抓bug利器版本管理Git GitHub每个流水级作为一个提交节点方便回滚对比目标平台先仿真后FPGA先保证逻辑正确再考虑上板如果你有Vivado或Quartus的授权直接用它们当然更好它们的综合报告和调试界面更工程化。但学习阶段最大的障碍往往是“环境太重改一行代码要等半天”Icarus Verilog的轻量特性会让你愿意频繁迭代。下面是我常用的自动化仿真脚本非常粗糙但很实用。它把编译、仿真、开波形串在一起省掉重复敲命令的麻烦#!/bin/bash # file: run_sim.sh # 用法: ./run_sim.sh # 编译并运行流水线CPU仿真测试 IVERILOGiverilog VVPvvp TOP_TBtb_pipeline_cpu_top VCD_FILEwave.vcd cd $(dirname $0) # 编译所有设计文件和测试文件 $IVERILOG -g2012 -s $TOP_TB -o sim.out \ pipeline_cpu_top.v \ if_id.v \ id_ex.v \ ex_mem.v \ mem_wb.v \ regfile.v \ alu.v \ inst_rom.v \ tb_pipeline_cpu_top.v if [ $? -ne 0 ]; then echo 编译失败请检查语法错误 exit 1 fi # 运行仿真生成波形 $VVP sim.out if [ -f $VCD_FILE ]; then echo 仿真完成打开波形... gtkwave $VCD_FILE fi不想用脚本也可以一行命令搞定iverilog -g2012 -o sim.out pipeline_cpu_top.v tb_pipeline_cpu_top.v vvp sim.out2.2 “骨架级”顶层模块一条ori如何走完五级我先贴一段我最初写的顶层模块骨架。它没有数据转发没有冒险处理甚至连完整的控制信号都没写全但它能非常直观地展示五级流水线的模块边界也给了我最基础的调试观测点。// file: pipeline_cpu_top.v // 五级流水线CPU顶层仅用于验证ori单条指令的完整通路 module pipeline_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_wb_data ); // 取指级 (Fetch) wire [31:0] pc, pc_next, inst; // PC寄存器 reg [31:0] pc_reg; always (posedge clk or negedge rst_n) begin if (!rst_n) pc_reg 32h0000_0000; else pc_reg pc_next; end assign pc pc_reg; // 指令存储器只读 inst_rom u_inst_rom( .addr (pc[7:2]), // 按字寻址取高地址位 .inst (inst) ); // 流水线寄存器P1: IF/ID reg [31:0] if_id_pc; reg [31:0] if_id_inst; always (posedge clk or negedge rst_n) begin if (!rst_n) begin if_id_pc 32h0; if_id_inst 32h0; end else begin if_id_pc pc; if_id_inst inst; end end // 译码级 (Decode) wire [31:0] id_pc if_id_pc; wire [31:0] id_inst if_id_inst; // 拆解ori指令字段 wire [5:0] opcode id_inst[31:26]; wire [4:0] rs id_inst[25:21]; wire [4:0] rt id_inst[20:16]; wire [15:0] imm id_inst[15:0]; // 寄存器堆 wire [31:0] reg1_data, reg2_data; regfile u_regfile( .clk (clk), .we (mem_wb_reg_we), .waddr (mem_wb_waddr), .wdata (mem_wb_wdata), .raddr1 (rs), .rdata1 (reg1_data), .raddr2 (rt), .rdata2 (reg2_data) ); // ID/EX流水线寄存器 reg [31:0] id_ex_pc; reg [31:0] id_ex_reg1_data; reg [31:0] id_ex_imm; reg [4:0] id_ex_rt; reg id_ex_reg_we; always (posedge clk or negedge rst_n) begin if (!rst_n) begin id_ex_pc 32h0; id_ex_reg1_data 32h0; id_ex_imm 32h0; id_ex_rt 4h0; id_ex_reg_we 1b0; end else begin id_ex_pc id_pc; id_ex_reg1_data reg1_data; id_ex_imm {16h0000, imm}; // 无符号扩展ori立即数是零扩展 id_ex_rt rt; id_ex_reg_we 1b1; // ori必然写寄存器 end end // 执行级 (Execute) wire [31:0] ex_pc id_ex_pc; wire [31:0] ex_reg1_data id_ex_reg1_data; wire [31:0] ex_imm id_ex_imm; wire [4:0] ex_rt id_ex_rt; // ALU执行“或”运算 wire [31:0] alu_result ex_reg1_data | ex_imm; // EX/MEM流水线寄存器 reg [31:0] ex_mem_result; reg [4:0] ex_mem_rt; reg ex_mem_reg_we; always (posedge clk or negedge rst_n) begin if (!rst_n) begin ex_mem_result 32h0; ex_mem_rt 4h0; ex_mem_reg_we 1b0; end else begin ex_mem_result alu_result; ex_mem_rt ex_rt; ex_mem_reg_we id_ex_reg_we; end end // 访存级 (Memory) // ori不访问内存所以MEM级只是透传 reg [31:0] mem_wb_result; reg [4:0] mem_wb_rt; reg mem_wb_reg_we; always (posedge clk or negedge rst_n) begin if (!rst_n) begin mem_wb_result 32h0; mem_wb_rt 4h0; mem_wb_reg_we 1b0; end else begin mem_wb_result ex_mem_result; mem_wb_rt ex_mem_rt; mem_wb_reg_we ex_mem_reg_we; end end // 写回级 (Write Back) wire [31:0] mem_wb_wdata mem_wb_result; wire [4:0] mem_wb_waddr mem_wb_rt; assign debug_wb_data mem_wb_wdata; assign debug_pc pc_reg; assign debug_inst inst; // PC更新 assign pc_next pc 32d4; endmodule这段代码有几处看起来“很不专业”的地方我先说明一下id_ex_reg_we直接赋成了1没有经过译码控制信号产生逻辑。因为ori必然写寄存器所以这么做对当前场景是成立的。但这是为了先把通路跑通而做的简化后面扩展指令时必须改成真正的控制信号。MEM级没有数据存储器因为ori不访存所以MEM级只是把EX的结果透传给WB。没有数据转发的任何逻辑。也就是说这条代码只支持“指令之间完全没有数据依赖”的理想场景。但恰恰是这种“幼稚”代码让我真正理解了流水线的节奏。我强烈建议你亲手敲一遍不要复制粘贴。敲的过程中你会自然地问自己为什么PC要跟着进IF/ID寄存器为什么rt要跟着一路传到WB为什么写使能信号也要跟着传这些问题想通了流水线的精髓你就抓住了一半。2.3 寄存器堆同步写、异步读寄存器堆是CPU里负责暂存数据的模块一般用寄存器数组实现。它的关键是写操作同步于时钟读操作是组合逻辑异步输出。// file: regfile.v module regfile( input wire clk, input wire we, input wire [4:0] waddr, input wire [31:0] wdata, input wire [4:0] raddr1, output wire [31:0] rdata1, input wire [4:0] raddr2, output wire [31:0] rdata2 ); reg [31:0] regs [0:31]; // 第0号寄存器永远为0 always (posedge clk) begin if (we (waddr ! 5d0)) regs[waddr] wdata; end // 异步读 assign rdata1 regs[raddr1]; assign rdata2 regs[raddr2]; // 初始化所有寄存器为0 integer i; initial begin for (i 0; i 32; i i 1) regs[i] 32h0; end endmodule这个模块有三个值得注意的细节第一0号寄存器要特殊处理。MIPS约定$0永远读出0任何写入它的操作都应该被忽略。如果不加waddr ! 5d0这个判断万一某条指令错误地写入了$0后面所有依赖$0的指令都会得到脏数据。第二读写时序是分离的。写操作需要等到时钟沿才生效但读操作是组合逻辑只要地址变化输出立刻变化。这意味着在同一个周期里你既能读到旧值如果该寄存器正在被写但还没到时钟沿也能在时钟沿之后读到新值。理解这一点对后续处理数据冒险非常重要。第三为什么需要两个读端口因为像or这类双操作数指令需要同时读两个源寄存器。虽然ori只用到rs但你迟早要扩展所以寄存器堆直接写成双读口更省事。2.4 指令ROM用$readmemh加载机器码学习阶段我不会用FPGA厂商的RAM IP核而是直接用Verilog内置的$readmemh从一个文本文件加载指令到数组中。这样改程序只需要改文本文件无需重新综合迭代速度非常快。// file: inst_rom.v module inst_rom( input wire [5:0] addr, // 这里简化用PC的高6位当地址 output wire [31:0] inst ); reg [31:0] mem [0:63]; initial begin $readmemh(inst.hex, mem); end assign inst mem[addr]; endmodule对应的inst.hex文件内容是34210005 // ori $1, $0, 5 $1 5 3422000A // ori $2, $0, 10 $2 10 00221825 // or $3, $1, $2 $3 15 00000000 // nop这里我直接写机器码因为学习阶段我想强迫自己熟悉指令编码。等你熟悉了可以用汇编器生成HEX文件效率更高。2.5 ALU从“只做一个或运算”开始ALU的设计我建议留好扩展接口但当前只需要实现一个或运算。// file: alu.v module alu( input wire [31:0] a, input wire [31:0] b, input wire [3:0] alu_op, output reg [31:0] result ); always (*) begin case (alu_op) 4b0001: result a | b; // OR // 后续可以扩展加法、减法、AND、SLT... default: result 32h0; endcase end endmodule2.6 流水寄存器写之前先问自己三个问题流水寄存器是五级流水线的灵魂也是最容易写错的地方。我总结出一套自检方法每次写流水寄存器之前都问自己三个问题这一级需要把哪些控制信号带到下一级这些控制信号需要延迟几拍才生效每个信号的位宽是多少这三个问题想清楚流水寄存器基本不会写错。我犯过的一个典型错误是把WB级才需要的写使能信号提前在EX级就设置好导致在写回阶段出现错误的寄存器写入。这类错误极其隐蔽因为单看某一级都能工作但整个流水线跑起来就出乱子。正确做法是控制信号和数据的生命周期必须完全对齐——写使能信号要和它对应的写地址、写数据一起在流水线里同步移动。3. 测试平台怎么写让第一条ori真正“走通”3.1 testbench的基本结构代码写完只是开始怎么验证它工作正常才是关键。我的测试平台思路很简单往指令存储器里放几条ori指令让CPU跑若干周期最后检查写回数据是否符合预期。// file: tb_pipeline_cpu_top.v timescale 1ns/1ps module tb_pipeline_cpu_top; reg clk; reg rst_n; wire [31:0] dbg_pc; wire [31:0] dbg_inst; wire [31:0] dbg_wb; pipeline_cpu_top dut( .clk(clk), .rst_n(rst_n), .debug_pc(dbg_pc), .debug_inst(dbg_inst), .debug_wb_data(dbg_wb) ); // 生成50MHz时钟 initial begin clk 1b0; forever #10 clk ~clk; // 20ns周期 end // 激励与检查 initial begin // 复位 rst_n 1b0; #25; rst_n 1b1; #10; // 让CPU跑20个周期观察流水线输出 repeat (20) (posedge clk); // 打印观察信息 $display(PC%h, INST%h, WB%h, dbg_pc, dbg_inst, dbg_wb); // 检查最终写回结果 if (dbg_wb 32h0000_1234) $display(PASS: 写回数据正确); else $display(FAIL: 写回数据 %h, 期望 00001234, dbg_wb); $finish; end endmodule这个testbench有几个细节值得注意复位时间要足够长确保所有流水寄存器都被清空。我一般复位25ns到30ns然后才开始跑业务周期。用(posedge clk)来对齐时钟沿避免在信号变化的瞬间采样防止看到亚稳态值。观察点要留足时间。流水线需要好几拍才能把第一条指令送到WB级所以千万不要复位结束后立刻检查结果否则你看到的肯定是全0。3.2 用自检断言替代肉眼检查只是“仿真跑通了”还不够我推荐在testbench里加自检断言。这样每次改完代码一跑仿真就能自动发现问题不用反复肉眼看波形。对于ori这种单条指令测试最简单的断言就是检查最终写回值。但如果你想更早发现“哪一级出了问题”可以在每一级流水寄存器的输出位置加断言检查中间值。比如在第一个周期后检查IF/ID寄存器里的指令字在第二个周期后检查ID/EX里的立即数扩展结果是否等于0x00001234。这样出错时你能立刻定位错误发生在哪一级。3.3 边界条件测试全0、全1、最高位为1ori用的是16位立即数所以边界条件至少包括ori $t0, $0, 0x0000 # 立即数全零 ori $t1, $0, 0xFFFF # 立即数全一 ori $t2, $0, 0x8000 # 最高位为1验证零扩展而非符号扩展 ori $t3, $t2, 0x0001 # 依赖上一条结果验证相邻数据依赖把这几条指令放进指令ROM跑完再检查$t0到$t3的值能同时验证三件事ALU的或逻辑是否正确立即数是否做了零扩展而不是符号扩展相邻指令的数据依赖是否被正确处理如果你已经加了转发其中0x8000这个边界特别值得测。因为如果立即数扩展逻辑做成了有符号扩展ori $t2, $0, 0x8000会把0xFFFF8000送入ALU而不是期望的0x00008000结果会相差十万八千里。这类bug在仿真波形上非常隐蔽很容易被忽略。4. CPU调试的五把刀从波形到断点的定位流程我在调试这个五级流水线CPU的过程中摸索出一套自己的排查方法论。这里分享五个核心工具按使用顺序排列。4.1 第一把刀PC是一切的基础无论遇到什么诡异现象先看PC。PC是整个CPU的指令指针它要是乱了后面全白搭。我的排查顺序永远是先确认复位后PC0再确认每个周期PC PC 4最后确认取出的指令字是否和期望的指令编码一致只要PC这条链是正常的问题大概率出在译码或执行环节。反过来如果PC已经乱跳那指令存储器、PC更新逻辑、复位逻辑就是第一嫌疑对象。4.2 第二把刀把每一级流水寄存器的内容打印出来流水寄存器是数据流动的关卡。如果某级数据错了一定能在某个关卡处发现。我在调试时会在关键观察点用$display打印信号值// 伪代码示意各级观测点 always (posedge clk) begin $display(TIME%0t | PC%h | IF/ID.inst%h | EX.alu_result%h | WB.wdata%h, $time, pc, if_id_inst, ex_alu_result, mem_wb_wdata); end看到底是第几级开始出错再定位到那一级的组合逻辑。这个方法比直接盯波形高效得多尤其是在仿真时间很长的情况下。4.3 第三把刀对照指令编码表逐位核查有时候不是逻辑错而是你手写的指令编码写错了。比如ori的opcode应该是001101如果你记成001100那取出来就是一条完全不同的指令。所以遇到“仿真结果完全不对”的情况先拿指令编码表逐位核对你的指令ROM内容不要上来就怀疑自己的数据通路。这里我特别推荐一个方法把指令编码按位拆开在testbench里打印出每个字段。比如$display(opcode%b, rs%d, rt%d, imm%h, inst[31:26], inst[25:21], inst[20:16], inst[15:0]);这样一眼就能看出指令拆解逻辑有没有问题。4.4 第四把刀最小复现用例如果整个程序跑下来出错不要急着在全程序里猜。先把程序缩减到最小的出错序列。比如先只跑两条ori看是否出错再跑三条、四条逐步逼近问题。这个过程就像调试普通软件代码一样核心是不断缩小问题范围。我曾经遇到过一个问题跑一整段程序时第7条指令写回错误但怎么查都查不出原因。后来我把程序砍到只剩前7条立刻发现是第6条和第7条指令之间的数据依赖没处理。如果一开始就在十几条指令的大程序里排查这个bug可能要耗掉我一整天。4.5 第五把刀强制信号二分定位当你把问题缩小到某个模块后如果还定位不了就采用“强制信号”法。把某个模块的输出强制成已知值比如把ALU的结果直接赋成一个常数看后面的逻辑是否正常。这样能快速区分“是上游数据错了”还是“下游消费错了”。5. 从基础版到转发版再到停顿版三种流水线层次的演进路径我注意到一个现象很多人以为流水线就一种写法其实不是。你完全可以从最简单的“无冒险处理版”开始逐步演进到“带转发版”再到“带停顿版”。每个版本的复杂度和目标都不同。5.1 基础版验证数据通路完整基础版就是我前面贴的代码没有数据冒险处理没有控制冒险处理每条指令假设完全独立。它的价值只有一个——让你把流水线的骨架搭起来。如果你连这个都没跑通千万别急着加转发和停顿否则bug会叠加到无法排查。基础版还有一个额外的好处它是验证数据通路是否完整的最佳方式。因为如果数据通路本身有bug加了冒险处理之后你会分不清到底是通路bug还是冒险处理bug。5.2 转发版解决大部分数据冒险当程序里出现连续两条指令依赖同一寄存器时比如ori $t0, $0, 5 # 第1条$t0 0 | 5 5 ori $t1, $t0, 1 # 第2条$t1 $t0 | 1 6第2条指令在译码阶段读$t0但第1条指令要到写回阶段才把结果写入寄存器堆。如果第2条指令在译码阶段读的是旧值就会出错。解决办法是数据转发把第1条指令在EX段算出的结果直接旁路到第2条指令的ALU输入。因为你不再等待“写回寄存器堆再读出来”而是把结果在内部走捷径送过去。这就是流水线里“旁路”或“转发”的含义。对于ori这种简单指令转发网络也很简单主要是在EX级判断当前ALU的源操作数寄存器号是否等于前面某条还没写回指令的目标寄存器号。如果是就用前面那条指令的结果替代寄存器堆的输出。5.3 停顿版处理load-use冲突和控制冒险当lw指令后面紧跟一条使用该load结果的指令时即使转发也来不及因为load结果在MEM段才出现而下一条指令在EX段就要使用。唯一的办法是插入一个气泡停顿周期让流水线“等一下”。同时遇到分支指令你还要决定“预测跳转还是不跳转”。最粗暴的做法是遇到分支就停顿两个周期等分支结果出来再取指。这也是为什么很多教科书的5级处理器分支效率不高的原因。5.4 三种版本的对比总结版本核心目标适合阶段代码复杂度基础版无冒险处理验证数据通路完整刚学流水线结构低转发版解决算数指令数据冒险已经掌握通路、想优化性能中停顿版解决load-use和分支冒险想完整实现指令集高我的建议始终是别一上来就写“高性能CPU”先把基础版跑通再往上加转发最后加停顿。每一步都是可运行、可验证的状态这样的学习曲线最平滑。6. 从ori扩展到其他指令循序渐进的扩展路线等你能让ori在五级流水线上完美运行下一步就是往这个框架里不断加指令。我的建议扩展顺序是ori → addi/add → lw/sw → beq/bne → jal/jr每加一类指令都会引入新的知识点新增指令引入的新问题addi/addALU需要支持加法和减法需要区分有符号/无符号立即数扩展lw/sw需要在MEM级真正访问数据存储器可能引入load-use停顿beq/bne需要处理分支比较和PC跳转可能引入控制冒险jal/jr需要保存返回地址处理链接跳转的特殊写法6.1 加addi的时候立即数扩展要区分有符号和无符号addi和ori看起来只有opcode不同但有一个致命区别addi的立即数需要符号扩展而ori的立即数需要零扩展。因为addi处理的是有符号整数如果立即数是负数最高位为1必须扩展成32位的负数才能正确参与加法。我见过很多人在这里翻车复制ori的代码只改了ALU的操作类型忘了改立即数扩展方式。结果addi $t0, $0, -1算出来成了0x0000FFFF相加完全错误。所以初学者一定要记住立即数扩展方式不是由“指令是否使用立即数”决定的而是由“指令的语义是有符号还是无符号”决定的。6.2 加lw/sw的时候load-use冲突第一次出现添加lw和sw之后你第一次需要在MEM级真正访问数据存储器。这里的第一个坑是数据存储器的写时序和寄存器堆类似需要同步写、异步读。第二个坑则是load-use冲突lw $t0, 0($sp)的下一条指令如果立刻使用$t0就必须停顿一个周期。处理load-use冲突的经典做法是在ID级检测到“当前指令的源寄存器号等于上一条load指令的目标寄存器号”时暂停流水线一拍。注意这里的检测时机必须非常精确否则会漏掉冲突或者误停。6.3 加beq/bne的时候控制冒险登场分支指令是流水线设计里第二个分水岭。最简单的实现是在EX级比较两个源操作数是否相等同时算出分支目标地址如果分支成立就冲刷IF/ID和ID/EX两级流水寄存器并让PC跳转到目标地址。这种做法的代价是每次分支都要损失两个周期。更精确的分支判断可以前移到ID级用额外的比较器在译码阶段就完成比较但这样做会加大译码级的组合逻辑延迟需要小心控制时序。6.4 加jal/jr的时候返回地址和跳转目标jal需要把PC8写入$31寄存器同时跳转到目标地址。注意是PC8不是PC4因为MIPS管线里jal在EX级才算出跳转目标而这时候PC已经自增过两次了。这个细节非常容易弄错我当初就因为这个8卡了半天。jr则相对简单只需要从寄存器堆读出跳转目标地址但要注意清理流水线。7. 我踩过的几个坑和应对方式这一节整理我在整个过程中最常遇到的坑很多都是教科书不会写、但实际代码一定会碰到的。7.1 立即数扩展错误ori要零扩展addi要符号扩展这是初学者最容易犯的错误。ori是逻辑或操作除了零扩展没有别的选择addi是有符号加法必须做符号扩展。搞混的直接后果就是边界立即数算出的结果完全不符合预期而且这种错误在普通数值测试中很可能测不出来只有到达边界时才暴露。7.2 寄存器堆写端口时序错配寄存器堆如果是同步写那么写使能和写数据必须在时钟沿同时有效。有的初学者图省事把写操作写成了异步写导致写结果立即反映到读口破坏了流水线节拍。这个问题极其隐蔽因为单条指令时看起来正常两条依赖指令时就会出错。7.3 流水寄存器复位不完整有的流水寄存器只复位了部分信号比如忘了复位控制信号或某一路数据。这样复位后CPU会跑飞到奇怪的状态。我建议把每个流水寄存器的所有信号都纳入复位范围不要因为“这个信号反正后面也会被覆盖”就省略。你永远无法预知哪个未复位信号会在某个时刻被误用。7.4 测试程序第一个周期从哪里开始很多人在testbench里复位结束后马上检查结果但流水线需要好几拍才能把第一条指令送到WB级。我建议至少等待10到20个周期等第一条指令真正写回后再检查。如果你用的是自动断言请在testbench里显式说明“检查时刻”避免时序含糊。7.5 断言失败不一定是执行错也可能是期望值算错了我在调试时经常发现程序逻辑其实是对的但testbench里的期望值算错了。所以写断言时期望值一定要自己手算一遍不要想当然。比如ori $1, $0, 0x1234结果一定是0x00001234不要写成0x12340000或者0xFFFF1234。8. 仿真波形怎么看GTKWave实操要点GTKWave是个非常朴素的工具但足够强大。我把自己的看波形习惯分享一下。8.1 添加关键信号进入GTKWave后从模块树里找到pipeline_cpu_top把以下信号添加到波形窗口clk和rst_n确认复位和时钟关系pc确认PC递增if_id_inst确认取指和译码内容id_ex_reg1_data和id_ex_imm确认译码输出alu_result确认EX级计算mem_wb_wdata确认WB级写回数据把这些信号放在同一时间轴然后缩放查看每个时钟沿的数据变化。8.2 对齐观察每个时钟沿流水线的核心是每个时钟沿数据向前推进一级。所以看波形时要养成“以时钟沿为锚点”的习惯。在每个上升沿之前检查各级输入是否已经稳定在上升沿之后检查各级输出是否正确变化。我自己的观察套路是先看PC是否每周期4再看流水寄存器里的指令字是否在往前传最后看目标寄存器的写回值。只要这条链上的信号都对流水线基本就通了。8.3 用光标定位错误发生时刻当你在波形里发现某个信号异常时马上用光标marker定位到那个时间点然后向回看几个周期。因为流水线有延迟所以错误通常不是当场发生的而是几个周期之前某级组合逻辑算错了。往前追3到5个周期通常就能找到源头。9. 写在最后动手吧流水线没你想的那么神秘当我第一次在GTKWave里看到PC从0x0、0x4、0x8一步步递增看到WB级输出正确的0x00001234时那种感觉就像亲手点亮了一盏灯。我想说的是CPU流水线并不是什么高深莫测的东西它本质上就是把“取指-译码-执行-访存-写回”这几件事拆开用流水寄存器让它们并行处理不同的指令从而实现每周期完成一条指令的效果。你不需要一开始就做出一台能跑Linux的复杂处理器你只需要让第一条ori在你的五级流水线上发光发热。这条路上最大的敌人不是难度而是“我还没准备好”的心态。所以现在请你打开编辑器建一个pipeline_cpu_top.v把PC、IF/ID、ID/EX、EX/MEM、MEM/WB这四级流水寄存器写出来然后塞一条ori $1, $0, 0x1234进去把仿真跑通。相信我当你看到那条指令真正走完五级、写回寄存器堆的那一刻你会觉得前面所有的尝试都值了。
返回列表