ARTICLE DETAIL

资讯详情

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

寄存器堆设计实验全解析:从Verilog代码到FPGA调试

寄存器堆设计实验全解析:从Verilog代码到FPGA调试 如果你正在和杭电的计算机组成原理课程设计实验七搏斗应该已经意识到“寄存器堆”绝不是简单的一堆寄存器叠在一起。这个模块放在CPU里要求在一个时钟周期内同时读出两个操作数、写入一个结果端口之间的读写时序、复位逻辑、零号寄存器的特殊语义任何一个细节没想清楚仿真波形就会立刻给你脸色看。我当年第一次在FPGA上验证寄存器堆时LED显示得乱七八糟查了一个晚上才发现只是复位没做好。这篇内容我会完整拆解寄存器堆设计实验从MIPS指令对寄存器堆的需求出发给出可综合的Verilog代码、Testbench验证用例、下板调试经验以及为后续CPU实验预留的扩展点让你不只会“抄代码”还能把实验报告写出深度。1. 寄存器堆在CPU数据通路里的位置一个“端口多、时序严”的存储模块很多同学容易把寄存器堆当成普通RAM来设计这是第一个误区。普通存储器只需要一个读端口或者一个读加一个写端口而寄存器堆在CPU数据通路里承担的是“指令操作数与运算结果之间高速中转站”的角色因此它的端口数量和时序模型都有特殊要求。1.1 从一条加法指令看寄存器堆被调用过程以MIPS风格的汇编指令为例add $t0, $s1, $s2这条指令经过译码之后控制单元会向寄存器堆发出这样几个请求读地址1$s1的寄存器编号也就是rs字段读地址2$s2的寄存器编号也就是rt字段写地址$t0的寄存器编号也就是rd字段写数据加法器计算出来的结果在回写阶段写入$t0。这就意味着寄存器堆必须同时支持“两读一写”。如果只做一个读端口那这条加法指令就需要两个时钟周期才能把$s1和$s2都读出来CPU的吞吐量会立刻塌掉。所以设计实验里最常见的规格就是两个读地址端口、一个写地址端口、两个读数据端口、一个写数据端口。寄存器堆内部虽然也是存储阵列但它和实验一里可能做过的RAM完全不同。RAM通常只关心“按地址存取”而寄存器堆更关心“多个地址在同一时刻互不干扰地访问”。你在写代码时如果直接声明一个二维数组然后随意索引仿真没问题但综合工具可能会把它推断成某种单端口内存反而导致功能错误。1.2 为什么读必须异步、写必须同步这是寄存器堆设计里最重要、也最容易在实验报告里被忽略的考点读数据通道应该是组合逻辑写数据通道应该是时钟边沿触发。从CPU数据通路的角度想加法指令在“执行阶段”需要把从寄存器堆读出来的两个操作数立刻送进ALUALU在一个周期内完成计算然后结果在“回写阶段”的时钟上升沿写入寄存器堆。如果读操作也等时钟沿那么ALU必须多等一拍才能拿到操作数整个流水线就平白多了一个气泡。所以教科书上的寄存器堆默认采用异步读也就是读地址一变读数据就跟着组合逻辑变化而写操作必须等时钟沿到来才真正落进寄存器组保证所有写回到寄存器堆的数据在时间上同步。异步读和同步写混在一起恰好是很多书里一张看似简单的电路图背后的完整逻辑。你在设计时可以这样记忆读端口用assign或者组合always块写端口用带时钟的always块。只要把这两条规则刻进脑子后边的代码基本不会跑偏。1.3 寄存器堆和片上存储器的本质差异有一类坑是FPGA平台特有的。Xilinx或Intel的FPGA内部有BRAM、分布式RAM等硬核存储资源综合工具看到你写了一个reg [31:0] regs[0:31]有可能会自动把它推断成RAM。BRAM的读端口通常是同步读也就是说读地址变化后要等一拍读数据才有效这和你想要的异步读行为完全冲突。寄存器堆和存储器的本质差异就在这里存储器优化的是“密度”一片BRAM可以塞进很多比特但端口有限寄存器堆优化的是“多端口并行访问”它物理上更接近触发器阵列而不是大块RAM。设计实验里32×32位总共才1024比特这点容量完全可以全部用触发器实现强行省RAM没有任何意义反而会引入时序问题。后面我会给出一个综合属性让工具老老实实按触发器来综合。2. 实验规格拆解接口定义、功能表与零号寄存器不同学校、不同实验台给的寄存器堆规格可能略有差别但核心接口基本一致。下面这套接口是我在实验里用的版本也符合MIPS处理器对寄存器堆的常规要求。如果你的实验任务书里额外加了输出指示信号、读写使能分开等要求只需要在这个基础上加端口即可。2.1 我采用的模块接口和参数用Verilog实现时建议把地址宽度、数据宽度和寄存器数量做成参数而不是写死这样后面做CPU实验时可以直接例化复用。信号名方向位宽功能说明clkinput1系统时钟写操作在上升沿发生rst_ninput1异步复位低电平有效RegWriteinput1写使能高电平有效ReadAddr1inputADDR_WIDTH读端口1地址通常是rsReadAddr2inputADDR_WIDTH读端口2地址通常是rtWriteAddrinputADDR_WIDTH写端口地址通常是rdWriteDatainputDATA_WIDTH待写入数据ReadData1outputDATA_WIDTH读端口1输出数据ReadData2outputDATA_WIDTH读端口2输出数据参数化定义如下parameter ADDR_WIDTH 5, DATA_WIDTH 32, REG_NUM 32地址宽度5位对应32个寄存器数据宽度32位对应MIPS机器字长。如果你后面实验的CPU数据通路是16位只需改DATA_WIDTH模块内部逻辑不用动。2.2 功能表与信号优先级寄存器堆的功能可以用一张真值表写清楚这也是实验报告里很加分的内容输入条件时钟沿行为rst_n 0任意所有寄存器清零读输出为0rst_n 1RegWrite 1WriteAddr 0上升沿不写入零号寄存器保持不变rst_n 1RegWrite 1WriteAddr ! 0上升沿WriteData写入WriteAddr对应寄存器rst_n 1RegWrite 0上升沿不写入任意时刻无ReadData1 regs[ReadAddr1]ReadData2 regs[ReadAddr2]零号恒为0要注意优先级排列复位信号的优先级最高其次是写使能。很多同学在写代码时把复位和写使能放在同一个if的多个分支里逻辑上可能没错但代码可读性会变差综合结果也可能出现冗余结构。按照“先复位、再写使能、最后正常写”的顺序写是最稳妥的。2.3 零号寄存器不是可选功能而是MIPS语义的一环MIPS架构规定0号寄存器恒为0写入无效。这个设计看起来“浪费”了一个物理寄存器实际上对指令集和编译器非常有用。比如sub $t0, $zero, $s1就可以实现取负move $t0, $s1实际上就是add $t0, $s1, $zero。没有恒零寄存器这些指令都要额外增加硬件。所以在寄存器堆设计里写地址等于0时要屏蔽写入读地址等于0时读数据始终输出0。这样处理之后CPU在进行数据前递、异常处理等操作时不需要额外判断寄存器编号是否为0简化了后续控制逻辑。实验报告中把这个动机写清楚能明显体现出你对指令集架构的理解而不只是在“抄一个功能”。3. Verilog实现一份能过仿真也能综合的寄存器堆代码下面这份代码是我在实际实验里调通的版本经过ModelSim仿真和FPGA下板验证。代码结构不复杂但关键点都写进去了可以直接抄到你的工程里再根据实验台的要求改端口名称。3.1 存储阵列和写端口存储阵列最常见的写法是声明一个二维内存(* ram_style registers *) reg [DATA_WIDTH-1:0] regs [0:REG_NUM-1];写端口采用同步时序逻辑同时处理异步复位和零号寄存器屏蔽integer i; always (posedge clk or negedge rst_n) begin if (!rst_n) begin for (i 0; i REG_NUM; i i 1) regs[i] {DATA_WIDTH{1b0}}; end else if (RegWrite (WriteAddr ! 0)) begin regs[WriteAddr] WriteData; end end这段代码里有几个值得注意的细节使用非阻塞赋值这是时序逻辑的标准写法避免仿真时出现竞争复位用for循环把整个数组清掉虽然32个寄存器不算多综合后面积也没压力但如果你后面把REG_NUM调大这种写法依然通用WriteAddr ! 0的条件放在RegWrite分支内部意味着即使RegWrite为高写地址为0也不会写异步复位是negedge rst_n触发这一点必须和模块端口声明一致。我建议你写完后在ModelSim里先跑一次复位仿真看复位信号释放后所有寄存器的波形是否都变成0。如果没有复位逻辑FPGA上电后寄存器内容不确定下板时会非常痛苦。3.2 读端口用组合逻辑输出别用时序逻辑读端口我推荐用两个组合always块实现逻辑清晰也不会意外生成锁存器always (*) begin if (ReadAddr1 0) ReadData1 {DATA_WIDTH{1b0}}; else ReadData1 regs[ReadAddr1]; end always (*) begin if (ReadAddr2 0) ReadData2 {DATA_WIDTH{1b0}}; else ReadData2 regs[ReadAddr2]; end组合块里使用阻塞赋值是合理的因为这里不产生时序逻辑。如果你写成always (posedge clk)来作为读端口那输出就会延迟一拍和前面讲的“异步读”原则冲突。有的参考代码会用assign ReadData1 (ReadAddr1 0) ? 0 : regs[ReadAddr1];效果也是一样的。组合always块的好处是后续想扩展读端口逻辑时内部可以加更复杂的条件判断而不必把一堆三目运算符堆在一起。这里还要注意一个综合细节组合always块的敏感列表是(*)不是(ReadAddr1 or regs)。使用*可以避免漏掉敏感信号尤其当敏感源包含整个regs数组时手工列出所有信号很容易遗漏。3.3 防止综合器把它推断成BRAM我在2.3节提过综合工具可能会把regs数组推断成BRAM。在Xilinx Vivado里一种比较靠谱的做法是在声明前加综合属性(* ram_style registers *) reg [DATA_WIDTH-1:0] regs [0:REG_NUM-1];加上这行之后工具会优先用触发器阵列实现而不是RAM块。对于只有32×32位的寄存器堆触发器资源消耗完全可以接受。如果你的工具是Quartus可以在综合属性里写ramstyle M9K或者直接删掉RAM推断不过具体写法得看平台文档。为什么不建议让工具把它综合成BRAMBRAM的读端口一般是同步读也就是地址变化后数据要在下一个时钟沿才有效。但寄存器堆要求异步读仿真时你的Testbench如果在同一个周期检查读数据综合后的硬件行为就会和仿真不一致。这种“仿真正确、上板错误”的情况非常难查所以最直接的办法就是禁用BRAM推断。4. 用Testbench把Bug逼出来五个必须覆盖的验证场景很多同学写完RTL就急着下板结果一板子上去全是问题。寄存器堆这种模块仿真验证的成本极低一条$display就能告诉你功能是否正常为什么不在仿真阶段多花10分钟4.1 Testbench骨架时钟、复位和写事务一个标准寄存器堆Testbench先要生成时钟和复位然后向待测模块发送写事务。下面是一个最小骨架module regfile_tb; reg clk, rst_n, RegWrite; reg [4:0] ReadAddr1, ReadAddr2, WriteAddr; reg [31:0] WriteData; wire [31:0] ReadData1, ReadData2; regfile uut ( .clk(clk), .rst_n(rst_n), .RegWrite(RegWrite), .ReadAddr1(ReadAddr1), .ReadAddr2(ReadAddr2), .WriteAddr(WriteAddr), .WriteData(WriteData), .ReadData1(ReadData1), .ReadData2(ReadData2) ); initial begin clk 0; forever #10 clk ~clk; end initial begin rst_n 0; RegWrite 0; ReadAddr1 0; ReadAddr2 0; WriteAddr 0; WriteData 0; #25 rst_n 1; // 后续测试事务 ... $finish; end endmodule注意我在改变输入信号时尽量选在时钟下降沿附近或者使用(negedge clk)来同步。这是因为写事务是在上升沿采样的所有写数据必须在上升沿之前保持稳定否则仿真时会出现偶然通过、实际时序违例的情况。Testbench里的这种好习惯比单纯“跑通波形”更有价值。4.2 五个测试场景与自动检查代码我建议至少覆盖下面5个场景写后读向寄存器5写入0x12345678然后读地址5检查输出是否等于写数据双端口同时读分别向寄存器1和2写入不同值同时给两个读端口不同地址检查两个输出是否互不干扰同地址读写同拍一个时钟周期内WriteAddr和ReadAddr1指向同一地址读数据应为旧值而不是新写入值零号寄存器向地址0写入任何值读地址0始终为0复位复位期间写入数据复位释放后所有寄存器清零。比如写后读的事务可以这样写task write_reg; input [4:0] addr; input [31:0] data; begin (negedge clk); RegWrite 1; WriteAddr addr; WriteData data; (negedge clk); RegWrite 0; end endtask然后在initial块里调用task再设置读地址并检查输出write_reg(5, 32h12345678); (negedge clk); ReadAddr1 5; #5; if (ReadData1 ! 32h12345678) $display(ERROR: read back fail, got %h, ReadData1); else $display(OK: write/read pass);这里比较用!而不是!是为了在仿真早期数据还是X态时就能立刻发现问题。会把x和0或1的比较当成未知容易漏报。4.3 仿真中“读旧值”现象怎么看第一次看波形的人经常会误判一个现象写数据已经变化了但读数据还是老样子。这是因为同步写还没到时钟沿而异步读输出的是当前寄存器组里的旧值。如果你在写事务结束后的同一个下降沿立刻去读看到旧值是正常的只有等到下一个时钟沿读出新值才说明设计没有问题。这个“读旧值”的行为在流水线CPU里非常关键。CPU在执行阶段读寄存器堆回写阶段写寄存器堆如果某条指令需要“刚写入的结果立刻被下一条指令读到”硬件上必须通过数据前递来解决而不是指望寄存器堆自己变魔法。实验报告里能结合这一点解释“读旧值”现象会显得你对CPU数据通路的理解远不止一个寄存器堆模块本身。5. 下板调试LED乱闪、按键抖动和分时显示仿真过了不代表板子一定过。FPGA下板调试时最典型的几个问题就是上电状态不确定、显示资源不够、按键输入抖动。这三个问题我在实验里都遇到过一个一个说。5.1 上电不复位LED显示随机值的排查顺序如果你上板后发现LED显示完全随机首先不要怀疑代码逻辑按照下面的顺序排查确认rst_n有没有真正拉低过。很多开发板的复位按键默认是高电平低电平才有效按键没按时如果电路本身没有可靠下拉FPGA上电后rst_n可能是悬浮的确认寄存器堆模块有没有被综合成BRAM。如果Vivado报告里显示使用了RAM请把ram_style属性加上强制用触发器确认读数据端口有没有稳定地连接到LED。32位数据如果直接接到24个LED上高位或低位被截断看起来也像乱码确认有没有给寄存器堆做异步复位。如果省略了复位上电时寄存器内容可能是未知所有输出都会是随机的。实践经验是先把复位信号用手动按键固定死看LED是否稳定输出0再写一个固定值到寄存器看LED是否显示对应内容。从全0到固定值这个过程能快速定位问题是出在复位、写入还是显示。5.2 数据位宽不够显示拨码选地址、LED显数据实验板上通常只有16个或24个LED但寄存器数据是32位一次显不完。常用办法是用拨码开关选寄存器地址LED显示该寄存器的低16位高16位可以通过另一个开关切换。在顶层模块里可以这样做wire [31:0] debug_data; reg [4:0] debug_addr; reg high_low; assign debug_addr debug_addr_code; // 来自拨码开关 assign debug_data (ReadAddr1 debug_addr) ? ReadData1 : ReadData2; // 具体接线看你的端口 assign led high_low ? debug_data[31:16] : debug_data[15:0];这样做的好处是你不需要把32个LED全部引出来只需要占用5个拨码开关和16个LED就能检查所有寄存器。注意如果你直接通过拨码开关给ReadAddr1输入地址由于组合读的特性LED上的内容会随拨码变化而即时变化这本身也验证了异步读的功能。5.3 手动按键当写时钟先做过消抖再说有些同学图省事想直接用按键作为写使能信号甚至用按键作为clk输入。这个做法在实验课上很容易“翻车”机械按键按下的一次瞬间会产生几十毫秒的抖动导致寄存器写入多次。比如你明明想写入1最后寄存器里可能变成2或随机值。简单的消抖思路是用系统时钟持续采样按键信号只有按键电平稳定一段时间后才认为有效reg [19:0] cnt; reg btn_ok; always (posedge clk or negedge rst_n) begin if (!rst_n) begin cnt 20d0; btn_ok 1b0; end else if (btn_in) begin if (cnt 20d999999) cnt cnt 1b1; else btn_ok 1b1; end else begin cnt 20d0; btn_ok 1b0; end end这段代码的思想是按键必须持续保持高电平约1000万个时钟周期50MHz时钟下约20ms才认为按键稳定按下输出btn_ok。但btn_ok会一直保持高电平如果你把它直接当RegWrite按键按住期间会一直使能写。更稳妥的做法是再对它做一次上升沿检测产生一个单周期脉冲然后用这个脉冲去触发写事务。不过我的实际建议是写使能最好还是由顶层状态机来控制按键只负责产生“请求”而不是直接驱动RegWrite。不要为了省事把手动信号当同步控制信号后面做完整CPU时你会感谢自己养成了这个习惯。6. 给后续CPU实验留接口从寄存器堆走向流水线实验七通常不是终点紧接着可能就是单周期CPU或者五级流水线CPU。寄存器堆设计得好不好直接影响后面CPU实验的进度。我觉得有三个方面值得提前思考。6.1 扩大端口数不是复制读逻辑那么简单如果后续CPU需要三个读端口比如某些指令要同时读rs、rt和rd你可能会想“那就再复制一个读端口”只要读地址增加一个、读数据增加一个就行。从寄存器堆模块看确实如此但放到CPU数据通路上三读端口意味着寄存器堆的面积和延迟都会上升多路选择器的规模也会变大综合后的时序可能会变差。所以在做CPU实验时老师通常不会让你无限加读端口而是通过合理的指令流水线设计来避免这种需求。比如MIPS五级流水线每周期最多只需要两个读端口这本身就是指令集架构和硬件实现互相权衡的结果。写实验报告时建议把“为什么是两读一写”这个点展开比单纯罗列代码有价值得多。6.2 写后读冒险与数据前递你在仿真阶段看到的“同地址读写同拍时读旧值”放到流水线CPU里就是典型的写后读冒险Read After Write。假设第一条指令写寄存器1第二条指令在同一周期的执行阶段读寄存器1由于寄存器堆只能在下一个时钟沿写入第二条指令读到的必然是旧值。解决这个问题不能靠改寄存器堆而是要在数据通路里加前递网络把处于回写阶段的数据直接转发给处于执行阶段的ALU输入。前递逻辑是CPU实验里最容易出Bug的部分但它的起点就是你在这个实验里理解的“同步写导致读旧值”。所以寄存器堆实验并不是孤立的小模块它背后牵着一整条流水线的数据冒险问题。6.3 用$readmemh给寄存器堆预置初始值做CPU实验时你可能希望把一段“程序”预先放进寄存器堆或者让某个寄存器拥有非零初值。仿真阶段可以用$readmemh在initial块里加载initial begin $readmemh(regfile_init.hex, regs); end但这条语句只能用于仿真不能综合到FPGA里。如果你希望在FPGA上电后某些寄存器有初值正确做法是修改复位逻辑在rst_n释放后对特定寄存器赋初始值比如$t0初始化为0x1000。另一个思路是把寄存器堆的初值放在一个只读ROM里复位后自动加载。这些内容超出了实验七的范围但对后续综合实验很有帮助。我个人的建议是在做实验七时就把regfile模块和顶层测试分开写把参数留好后面做CPU实验直接例化。模块内部尽量别出现和实验板强相关的引脚约束所有引脚级操作都放在顶层这样你换一块板子也不至于把RTL代码改得面目全非。最后再说一个我自己的小习惯每次改完寄存器堆代码我都会先在Testbench里把“同地址读写”和“复位释放”两个场景跑一遍哪怕只是加了一个端口也至少回归一次。因为这两个场景最容易暴露同步写和异步读之间的隐性冲突。寄存器堆设计的核心从来不是“怎么把32个寄存器写出来”而是“如何让读写端口在同一个周期内互不干扰地工作”。把这个想明白了实验七的代码量虽然不大但后面的CPU实验会顺利很多。
返回列表