32位RISC单周期CPU设计:数据通路与Verilog实现

32位RISC单周期CPU设计:数据通路与Verilog实现 简介这是一份面向计算机、微电子等专业学生及硬件设计初学者的课程设计资料围绕电子科技大学精简指令集RISC32位单周期CPU设计展开重点解决从数据路径搭建到控制器译码、再到FPGA上板验证的完整实现问题具备一定数字逻辑基础的学习者可据此完成同类课设或复试项目复现。资源包内含1个PDF文档压缩包约730KB篇幅完整地梳理了CPU整体电路结构、R型I型J型三类32位MIPS指令格式与18条基本指令并逐一给出32位2选1与4选1多路选择器、5位2选1选择器、带异步清零的D触发器、移位器、32位加/减法器等基本部件的设计与Verilog HDL代码同时说明控制器如何由op、func和zero信号生成pcsource、aluimm、shift等控制信号以及如何借助数码管观察各部件运行数据完成FPGA测试。目前已有1657人学习下载适合用作单周期CPU设计的流程参照与代码对照手册。1. 从取指到写回只用一拍32位RISC单周期CPU的设计边界在哪里在电子科技大学的计算机组成原理课程设计里RISC 32位单周期CPU几乎是最常见也最容易翻车的一道题。很多人第一反应是单周期比流水线简单写完才发现恰恰相反它把所有组合逻辑压进同一个时钟周期一条指令从取指、译码、执行、访存到写回全部在一拍内完成于是时钟周期必须迁就最长的那条路径。理解这一点整个设计的目标就清楚了——不是追求高主频而是把数据通路搭得干净、控制信号给得准确让每条指令在一个周期内自洽地闭合。适合正在做课程设计、想用Verilog把MIPS单周期CPU从框图落到仿真波形的同学也适合借单周期设计回头复习RISC指令格式与硬布线控制的人。2. RISC单周期CPU数据通路拆解取指、译码、执行、访存、写回的Verilog落地2.1 单周期时序与五级数据通路的取舍单周期设计的核心矛盾是CPI等于1但时钟周期等于整条数据通路的传播延迟之和。取指要过指令存储器译码要过寄存器堆和控制单元执行要过ALU访存要过数据存储器写回又要回到寄存器堆。于是最长的R型指令和load指令决定了频率上限。常见的做法是把PC的更新放在时钟上升沿其余全部用组合逻辑连接模块之间靠wire直接串起来不做任何中间寄存器。这样做的好处是控制信号一目了然调试时看波形就能把每条指令的流向对上一遍代价是关键路径长上板时主频往往只能跑到几十兆。做课程设计时不必纠结频率先把功能跑通再考虑把访存拆到第二个周期。提示单周期CPU的寄存器堆写回发生在时钟沿读操作是组合的写优先write-first还是读优先会直接影响连续写读同一寄存器的结果建议先明确并写进注释。2.2 顶层结构与取指单元PC、指令存储器与PC4顶层模块负责把各个子模块用wire连起来取指单元只做两件事用PC索引指令存储器以及计算PC加四。下面是取指部分的最小实现假设指令存储器是独立的ROM模块。// 取指单元PC寄存器 PC4 加法器 module fetch_unit( input wire clk, input wire rst_n, input wire pc_src, // 0: PC4, 1: 分支/跳转目标 input wire [31:0] branch_addr, // 跳转或分支目标地址 output wire [31:0] pc, output wire [31:0] pc_plus4 ); reg [31:0] pc_reg; assign pc pc_reg; assign pc_plus4 pc_reg 32d4; // pc_src 选择下一拍PC来源 wire [31:0] next_pc pc_src ? branch_addr : pc_plus4; always (posedge clk or negedge rst_n) begin if (!rst_n) pc_reg 32h0000_0000; // 复位从0地址取指 else pc_reg next_pc; end endmodule这段逻辑说明两件事PC自增固定为加四因为32位RISC指令字长四字节下一条PC的来源由pc_src选择分支和跳转共用这个多路器。参数上branch_addr由后续的跳转地址生成逻辑给出J型指令用{pc_plus4[31:28], target[25:0], 2b00}拼接分支指令用pc_plus4 (sign_ext(imm) 2)。复位值选0地址方便仿真时直接对应测试程序的第一条指令。取指输出的pc_plus4不只用于PC更新还承担两个职责R型写回寄存器号、分支偏移基准、以及J型地址高四位。把这根线在顶层展开成多个端口比后期再补加法器要省事。2.3 寄存器堆与写回通路读口写口的阻塞式写法寄存器堆是数据通路里少数带状态的模块。32位RISC一般提供两个组合读口和一个时钟沿写口$0寄存器恒为零。写回数据来自ALU结果或数据存储器读出值由mem_to_reg信号选择。module regfile( input wire clk, input wire rst_n, input wire we, // 写使能 input wire [4:0] waddr, // 写寄存器号 input wire [31:0] wdata, // 写数据 input wire [4:0] raddr1, input wire [4:0] raddr2, output wire [31:0] rdata1, output wire [31:0] rdata2 ); reg [31:0] regs [0:31]; integer i; // 读口组合读出$0恒为0 assign rdata1 (raddr1 5d0) ? 32d0 : regs[raddr1]; assign rdata2 (raddr2 5d0) ? 32d0 : regs[raddr2]; // 写口上升沿写入写寄存器号为0时忽略 always (posedge clk or negedge rst_n) begin if (!rst_n) begin for (i 0; i 32; i i 1) regs[i] 32d0; end else if (we waddr ! 5d0) begin regs[waddr] wdata; end end endmodule逻辑上两个读口完全组合$0的判断放在assign里避免仿真时读到初始化残留。写口做两重保护we有效且目标不是零号寄存器。这里没有实现写后读前推因为在单周期里同一拍读出的值一定来自上一拍写入的结果天然没有冒险这一点和流水线设计正好相反。参数说明地址宽度5位对应32个通用寄存器复位用循环清零综合时会被展开成异步复位逻辑如果只做仿真可以简化成上电初始化。写回选择器在顶层完成mem_to_reg为0时把ALU结果接进wdata为1时接数据存储器输出。2.4 数据存储器与访存对齐32位字访问的地址处理数据存储器只被load和store两类指令访问位宽32位按字寻址。关键细节是地址对齐字节地址除以四得到字地址最低两位必须为零否则视为未对齐访问。信号位宽作用取值说明mem_read1读使能load指令有效mem_write1写使能store指令有效addr32字节地址取[31:2]做字索引wdata32写入数据来自寄存器堆第二读口rdata32读出数据送入写回多路器// 数据存储器按字寻址最低两位忽略 module data_mem( input wire clk, input wire mem_read, input wire mem_write, input wire [31:0] addr, input wire [31:0] wdata, output wire [31:0] rdata ); reg [31:0] mem [0:255]; // 1KB 数据区 wire [7:0] word_idx addr[9:2]; // 字索引 assign rdata mem_read ? mem[word_idx] : 32d0; always (posedge clk) begin if (mem_write) mem[word_idx] wdata; // store在时钟沿写入 end endmodule地址只取[9:2]是因为课程设计的存储空间通常只需几百字高位不参与译码如果外扩更大容量把位宽改成[31:2]并相应扩大深度。读出是组合的与寄存器堆读口一致写入在时钟沿完成。store指令把第二个读口的数据直接送到wdataload指令把rdata通过写回多路器送回寄存器堆这两条路径互不干扰。3. 32位ALU与控制单元指令译码、立即数扩展与关键参数怎么定3.1 指令格式与opcode/funct字段划分32位RISC指令通常沿用MIPS风格的三类格式。R型用opcode区分大类、funct区分具体运算I型用opcode加16位立即数J型用opcode加26位跳转目标。译码器要先把指令字段拆开才能送进后续模块。格式字段划分高位到低位典型指令R型op[31:26] rs[25:21] rt[20:16] rd[15:11] shamt[10:6] funct[5:0]add、sub、and、or、sltI型op[31:26] rs[25:21] rt[20:16] imm[15:0]addi、lw、sw、beqJ型op[31:26] target[25:0]j、jalop字段先决定指令属于哪一类再由funct在R型内部二次译码。做单周期时把这两级译码都放在主控制单元里用case语句一次性输出全部控制信号比分层译码更直观。需要留意的是shamt字段移位指令用它指定移位位数其余R型指令该字段为零。3.2 立即数扩展I型、J型与符号位处理I型立即数是16位参与运算前必须扩展到32位。算术和访存指令用符号扩展逻辑指令用零扩展这个差别不处理就会在负数立即数上翻车。// 立即数扩展sign1符号扩展sign0零扩展 module imm_extend( input wire [15:0] imm16, input wire sign, output wire [31:0] imm32 ); assign imm32 sign ? {{16{imm16[15]}}, imm16} // 复制符号位 : {16d0, imm16}; // 高16位补零 endmodule符号扩展把第15位复制到高16位零扩展直接补零。参数sign由主控制单元根据opcode给出addi、lw、sw、beq取1andi、ori取0。J型没有立即数扩展问题但跳转地址要左移两位再与pc_plus4的高四位拼接这一步常被漏掉导致跳转落到错误地址。3.3 ALU控制码与32位有符号整数运算ALU是执行级的核心输入两个32位操作数输出结果和零标志。控制码用4位编码覆盖加减、逻辑、比较和移位。ALU控制码运算说明4b0000A B加法add/addi/lw/sw4b0001A - B减法sub/beq4b0010A B按位与4b0011A | B按位或4b0100A ^ B按位异或4b0101A B有符号slt按32位有符号整数比较4b0110B shamt逻辑左移4b0111B shamt逻辑右移module alu( input wire [31:0] a, input wire [31:0] b, input wire [3:0] alu_ctrl, output reg [31:0] result, output wire zero ); assign zero (result 32d0); // 供分支指令使用 always (*) begin case (alu_ctrl) 4b0000: result a b; 4b0001: result a - b; 4b0010: result a b; 4b0011: result a | b; 4b0100: result a ^ b; // 有符号比较符号位不同则负数小 4b0101: result ($signed(a) $signed(b)) ? 32d1 : 32d0; 4b0110: result b a[4:0]; 4b0111: result b a[4:0]; default: result 32d0; endcase end endmodule比较运算必须用$signed否则负数会被当成很大的无符号数slt结果全错。移位位数取a[4:0]因为32位最多移31位。zero标志给beq使用判断两操作数相减是否为零。把zero做成对result的assign而非时序信号能避免在单周期里引入额外延迟。3.4 主控制单元真值表与Verilog实现主控制单元决定所有多路器选择和写使能。它的输入是opcode和funct输出包括reg_write、mem_read、mem_write、mem_to_reg、alu_src、alu_ctrl、pc_src、ext_sign。可以把真值表直接写成case语句。// 主控制单元根据opcode/funct输出全部控制信号 module control( input wire [5:0] op, input wire [5:0] funct, output reg reg_write, output reg mem_read, output reg mem_write, output reg mem_to_reg, output reg alu_src, output reg pc_src, output reg ext_sign, output reg [3:0] alu_ctrl ); always (*) begin // 默认值避免综合出锁存器 reg_write0; mem_read0; mem_write0; mem_to_reg0; alu_src0; pc_src0; ext_sign1; alu_ctrl4b0000; case (op) 6b000000: begin // R型由funct细分 reg_write 1; case (funct) 6b100000: alu_ctrl 4b0000; // add 6b100010: alu_ctrl 4b0001; // sub 6b100100: alu_ctrl 4b0010; // and 6b100101: alu_ctrl 4b0011; // or 6b101010: alu_ctrl 4b0101; // slt endcase end 6b001000: begin // addi reg_write1; alu_src1; alu_ctrl4b0000; end 6b100011: begin // lw reg_write1; mem_read1; mem_to_reg1; alu_src1; alu_ctrl4b0000; end 6b101011: begin // sw mem_write1; alu_src1; alu_ctrl4b0000; end 6b000100: begin // beq pc_src1; alu_ctrl4b0001; end endcase end endmodule代码里每个分支都先把默认值写全是为了避免always (*)里漏赋值导致综合出锁存器这是硬布线控制最常见的坑。alu_src为1时ALU第二操作数取扩展后的立即数为0时取寄存器堆第二读口。pc_src只在beq里拉高配合ALU的zero标志决定是否真跳。参数说明ext_sign默认给1逻辑类立即数指令要单独改成0mem_to_reg只对load有效。4. 单周期MIPS硬布线CPU的仿真验证测试用例、波形排查与典型错误4.1 测试平台搭建与指令级激励功能验证不靠肉眼盯波形而是写testbench喂指令序列并断言结果。指令存储器用$readmemh从十六进制文件加载测试程序覆盖R型、I型、访存和分支。timescale 1ns/1ps module tb_top; reg clk, rst_n; wire [31:0] pc; initial begin clk 0; rst_n 0; #20 rst_n 1; // 复位20ns后放开 #500 $finish; // 预留500ns观察窗口 end always #5 clk ~clk; // 100MHz仿真时钟 cpu_top u_cpu( .clk(clk), .rst_n(rst_n), .pc_out(pc) ); initial begin $dumpfile(cpu.vcd); // 生成波形文件 $dumpvars(0, tb_top); end endmodule测试程序建议写成先做几条addi建立操作数再add/sub验证ALU接着sw/lw验证访存最后beq验证分支。每个周期结束后可以打印PC和写回寄存器号。参数上仿真时钟给100MHz只是为了让波形好读真实验证时缩短周期就能直观暴露组合路径问题。4.2 波形上要盯住的四个信号波形里信号几十根真正要重点看的是PC、写使能、写回地址、写回数据这四组。PC能看出取指是否按预期推进写使能能看出控制信号是否在错误指令上误拉高写回地址和数据能直接对照测试程序的预期值。verilog里把这几根线在顶层引出比在层层子模块里翻要快得多。如果PC卡住不动先查pc_src是否被误置1导致跳转到自身或者复位信号没有正常释放。如果写回数据不对但PC正常问题多半在ALU控制码或者立即数扩展的符号位。如果访存指令后数据变了而寄存器没变检查mem_to_reg是否生效。4.3 常见错误写后读、PC偏移与分支条件单周期没有流水线冒险但仍有几类高频错误。一是写后读连续两条指令写读同一寄存器由于写回在时钟沿、读出是组合读到的其实是旧值。这不是bug而是单周期的固有语义测试程序要按这个语义设计或者干脆在两条指令间插入无关指令。二是PC偏移错位分支目标算成pc_plus4 (imm 2)如果写成pc (imm 2)就会差一条指令。三是分支条件取反zero标志在beq里意思是相等则跳若在控制单元里写成pc_src ~zero方向就反了。四是立即数符号扩展漏做导致负数访存地址落到数据区之外。4.4 用指令序列做端到端回归把测试程序分段跑每段结束后在testbench里用if对比寄存器堆内部值比只看波形可靠得多。测试段指令序列预期结果ALUaddi, add, sub, slt寄存器值等于手工计算值访存addi, sw, lw读回值等于写入值分支beq 相等/不相等PC分别落到目标和PC4跳转jPC低28位等于目标左移两位回归时把每段断言写在独立initial块里任何一段失败立即报错并打印PC这样定位错误比通读波形快一个数量级。5. 单周期CPU的关键路径收敛与最小外设扩展5.1 关键路径定位与时钟约束上板跑不起来八成是关键路径太长。单周期最长路径通常是PC到指令存储器、译码、寄存器堆读出、ALU、数据存储器、再回写回多路器的这条链。用综合工具的报告找出最长延迟然后按优先级处理把数据存储器读出改成组合输出避免再串一级寄存器把控制单元的case精简减少级联ALU里把移位和比较拆到并行的子模块别用嵌套条件。约束文件里先给一个保守周期比如50MHz跑通再往上加。注意不要为了提频把PC更新改成半步沿那会破坏单周期一拍一条指令的语义得不偿失。5.2 挂载数码管与UART的最小改动课程设计常要求把PC或某寄存器值显示到数码管。最小改动是加一个只读的观察端口从寄存器堆引出regs[某号]用分频后的时钟做动态扫描不要动主数据通路。UART同理把要输出的字接到发送模块的tx_data用tx_start在特定PC值上触发一次发送就能实现跑到某条指令时打印寄存器的调试手段。外设接入信号是否影响主通路数码管寄存器堆观察端口否UART发送PC比较触发否LEDALU零标志或写使能否5.3 上板前的检查清单综合前把这几项过一遍所有always (*)块的首行是否赋了默认值$0寄存器是否在所有读路径被强制为零立即数符号扩展的sign信号是否按指令区分分支与跳转的PC来源是否共用一个多路器且优先级正确数据存储器地址是否做了字对齐。把这几条写进一份自检表比每次靠波形猜要省时间。真正把单周期CPU跑通的人回头再看流水线和分支预测会发现所有冒险处理的出发点都藏在这条最长组合路径里。本文还有配套的精品资源点击获取