我要提问
ARTICLE DETAIL

资讯详情

前沿编程新知与开发实战干货的深度解读。

单周期CPU设计实战:Verilog数据通路与控制单元全解析

单周期CPU设计实战:Verilog数据通路与控制单元全解析 简介这是一份来自电子科技大学的CPU设计资料聚焦精简指令集RISC架构下的32位单周期CPU实现适合计算机组成原理、数字逻辑与FPGA开发学习者参考。文档以电路设计为主线详细解析了运算器、控制器、寄存器堆、取指电路以及多路选择器、D触发器、移位器和加/减法器等基本部件的设计思路并覆盖18条MIPS指令的格式与数据路径选择逻辑可帮助读者从整体到细节理解单周期处理器的运行机制。包体为单个PDF文件大小约730KB内容结构完整包含从部件级Verilog代码到指令编码的详细说明以及FPGA测试相关的调试方法。目前已有1657人学习该资料其价值在于提供了课程设计或项目验证时可复用的设计思路与调试参考尤其适合需要完成CPU设计实验或进行RISC架构入门学习的读者。1. 别被“单周期”三个字骗了这个课程设计真正卡人的地方单周期CPU指的是每条指令在一个时钟周期内完成取指、译码、执行、访存、写回全部五步听起来比流水线简单。但真正打开仿真波形时你会发现大多数翻车都发生在看不见的组合逻辑连线上——PC是否按时更新、寄存器堆的写使能是否恰好持续一个周期、beq的零标志有没有接对位置。某高校的计算机组成原理课程设计里RISC 32位单周期CPU是经典题目要求自己选指令集、画数据通路、写Verilog并仿真通过。下面按我自己做这类设计的完整路径来讲先把指令集和字段位分配定死再画数据通路骨架然后写控制单元最后落到可仿真的模块代码、测试台和常见坑。新手能照着一行行敲熟手也能对照检查自己的控制信号真值表有没有埋雷。2. 先把指令集定死指令编码与数据通路骨架的选型理由2.1 从RISC原则反推指令集10条指令覆盖四种格式RISC的核心理念是“指令长度固定、格式规整、只有load/store指令访问内存、寄存器操作数尽量对称”。这些约束在单周期CPU里影响直接指令定长意味着译码器可以用同一套字段位置去取rs、rt、rd、imm不需要逐条判断指令边界load/store架构意味着ALU不需要处理复杂寻址数据通路可以做得又短又规整。我一般把指令集定在10条左右覆盖R型、I型、分支、跳转四种格式。这个数量是反复权衡过的太少像lw/sw、beq这些关键路径没被激活太多控制单元真值表指数级变复杂仿排查工作量陡增。下面这张表就是最常用的一套组合既覆盖了算术、逻辑、访存、分支、跳转全部类型又不会让控制信号多到看不过来。指令类型功能编码要点addRrd rs rtop6b000000, funct6b100000subRrd rs - rtfunct6b100010andRrd rs rtfunct6b100100orRrd rs | rtfunct6b100101sltRrd (rs rt)funct6b101010addiIrt rs immop6b001000lwIrt mem[rsimm]op6b100011swImem[rsimm] rtop6b101011beqIrs rt 则跳转op6b000100jJ直接跳转op6b000010为什么选这10条而不是更多因为单周期CPU没有流水线寄存器每条指令要在同一个时钟周期内把取指、译码、读寄存器、ALU运算、访存、写回全部走完指令越多控制单元的组合逻辑越深时序越紧。10条指令已经能把数据通路上每一个部件和每一条关键控制信号都激活一遍验证足够充分。我自己的习惯是先在这个集合上把仿真跑通再根据题目要求往下删多出来的addi和or哪怕最终不用也能帮你确认立即数符号扩展和位运算通路是通的。2.2 指令字段位分配R/I/J三种格式怎么切32位RISC指令之所以好做就是因为格式里每一位的含义是固定的。32位指令分成三类每一类的字段切分如下R型op[31:26] | rs[25:21] | rt[20:16] | rd[15:11] | shamt[10:6] | funct[5:0]I型op[31:26] | rs[25:21] | rt[20:16] | imm[15:0]J型op[31:26] | addr[25:0]定字段时最需要反复核对的是rs和rt的位置在R型和I型里完全一致这样寄存器堆的读端口地址可以直接接指令的25:21和20:16省掉一组多路选择器。rd在R型里是15:11但在I型指令addi/lw里写回目标寄存器是rt20:16这就引出了RegDst选择信号——R型写rdI型写rt不然后果就是addi的结果写错寄存器仿真数值对不上。立即数位宽是16位需要符号扩展成32位。Verilog里的标准写法是{{16{inst[15]}}, inst[15:0]}左半部分是16个符号位复制。很多新手在这里直接拼16个0结果负数立即数全变成了正数地址计算全部错乱。跳转指令的26位地址要和PC高4位拼接同时末尾补两个0因为指令地址按字对齐{pc_plus4[31:28], inst[25:0], 2b00}。2.3 数据通路骨架从PC到写回的信号流向数据通路按“一条指令从取指到写回”的自然顺序搭PC寄存器输出地址指令存储器取出指令寄存器堆读rs/rt两个端口ALU根据操作类型计算数据存储器在lw/sw时被访问最后写回数据通过MemtoReg选择器送回寄存器堆写端口。整条链在同一个时钟周期内全部走完所以除PC、寄存器堆写端口、数据存储器写端口是时序逻辑外其余路径基本都是组合逻辑。分支和跳转要单独拉两条旁路。beq的分支目标地址是pc_plus4 (符号扩展立即数 2)是否跳转取决于ALU的zero标志j的跳转目标是{pc_plus4[31:28], addr, 2b00}不经过任何运算。这两个地址和pc_plus4一起送进pc_next选择器由控制单元的Branch和Jump信号决定选哪一路。新手最容易绕晕的点是为什么寄存器堆读是组合逻辑、写是时序逻辑因为同一周期内要先读旧值参与ALU计算再在周期结束时把新值写入。如果读也做成时序的就要多等一个周期单周期模型直接崩掉。这个设计不是一个可选项而是单周期CPU能够成立的前提。3. 控制信号是单周期CPU的胜负手真值表与case实现3.1 控制信号真值表每个信号在每条指令上的取值主控制单元的作用是把指令的opcode翻译成一组控制信号。单周期CPU里这组信号必须在组合逻辑中立刻稳定不能依赖时钟。我用到的控制信号一共8个每个的含义如下RegDst写寄存器地址来源1选rdR型0选rtI型RegWrite寄存器堆写使能为1时时钟上升沿写入MemWrite数据存储器写使能MemtoReg写回数据来源1选存储器读数据lw0选ALU结果ALUSrcALU第二个操作数来源1选立即数0选寄存器堆rt口Branchbeq分支使能Jumpj跳转使能ALUOp传给二级译码的ALU操作类型下面是完整的真值表。表格里标X的表示dont care在代码里用默认值0处理不影响功能。指令RegDstRegWriteMemWriteMemtoRegALUSrcBranchJumpALUOpR型110000010addi010010000lw010110000swX01X10000beqX00X01001jX00XX01XXALUOp只用2位00表示加法addi/lw/sw的地址计算都依赖加法01表示减法beq通过减法判断相等10表示R型指令具体做什么由funct字段二次译码。这样设计的核心思路是把主控制单元的决策面缩小把细粒度运算选择下放给ALU控制模块。写代码时我习惯先在always块的第一个语句把所有信号赋默认值0再在case里按需拉高。这样dont care的信号自动是0不会意外产生锁存器也不会因为某个case分支漏写信号而出现时序幻觉。3.2 case语句实现主控制单元主控制单元的实现非常直接一个case语句搞定。注意opcode在指令的31:26位。module control( input [5:0] op, output reg reg_dst, output reg reg_write, output reg mem_write, output reg mem_to_reg, output reg alu_src, output reg branch, output reg jump, output reg [1:0] alu_op ); always (*) begin // 先给所有信号赋默认值0避免锁存器 reg_dst 0; reg_write 0; mem_write 0; mem_to_reg 0; alu_src 0; branch 0; jump 0; alu_op 2b00; case (op) 6b000000: begin // R型 reg_dst 1; reg_write 1; alu_op 2b10; end 6b001000: begin // addi reg_write 1; alu_src 1; alu_op 2b00; end 6b100011: begin // lw reg_write 1; alu_src 1; mem_to_reg 1; alu_op 2b00; end 6b101011: begin // sw mem_write 1; alu_src 1; alu_op 2b00; end 6b000100: begin // beq branch 1; alu_op 2b01; end 6b000010: begin // j jump 1; end default: ; // 未定义指令保持全0 endcase end endmodule每个信号的赋值都在同一时刻完成这就是组合逻辑。default: ;必须保留不然仿真器会推断出锁存器所有未定义指令的输出会变成前一个值查错时极其隐蔽。代码里alu_op的类型是reg [1:0]和真值表里2位的ALUOp一一对应。3.3 ALUOp到ALUControl的二级译码ALU本身不认识R型指令的funct字段需要一级专门的译码把ALUOp和funct翻译成4位ALU控制码。这是经典的二级译码设计主控制单元只根据opcode做出粗粒度判断ALU控制单元再根据funct细化成真正的算术逻辑操作。module alu_control( input [1:0] alu_op, input [5:0] funct, output reg [3:0] alu_ctrl ); always (*) begin case (alu_op) 2b00: alu_ctrl 4b0010; // 加法 2b01: alu_ctrl 4b0110; // 减法 2b10: begin case (funct) 6b100000: alu_ctrl 4b0010; // add 6b100010: alu_ctrl 4b0110; // sub 6b100100: alu_ctrl 4b0000; // and 6b100101: alu_ctrl 4b0001; // or 6b101010: alu_ctrl 4b0111; // slt default: alu_ctrl 4b0010; endcase end default: alu_ctrl 4b0010; endcase end endmoduleALU控制码的编码约定如下0000按位与0001按位或0010加法0110减法0111有符号比较小于则置1。这个编码没有必须遵守的标准只要和alu.v里的case一致就行但我建议沿用这套经典编码后续对照资料排查时能省很多脑力。这里有一个值得注意的点slt的减法实现。result (a b)本质上是判断a减b的符号位结合溢出标志才能严谨处理边界情况。我在alu.v里直接用$signed(a) $signed(b)让工具链处理符号比较代码可读性比手动算溢出高得多也避免了一个经典翻车点。4. 把设计落成代码Verilog模块划分与最小仿真4.1 模块划分与端口定义整个CPU按职责拆成8个文件每个文件只干一件事。这个拆分方式最大的好处是排查问题时可以单独验证某个模块不需要每次都跑到顶层去盯整条数据通路。我自己通常按下面的目录组织工程cpu-single-cycle/ ├── rtl/ │ ├── top.v # 顶层连线 │ ├── pc_reg.v # 程序计数器 │ ├── inst_mem.v # 指令存储器 │ ├── regfile.v # 寄存器堆 │ ├── alu.v # 算术逻辑单元 │ ├── alu_control.v # ALU二级译码 │ ├── control.v # 主控制单元 │ └── data_mem.v # 数据存储器 └── sim/ ├── top_tb.v # 测试台 └── inst.hex # 指令文件先看最简单的pc_reg。它只有一个职责在时钟上升沿把pc_next锁存到pc异步复位清零。这里必须用异步复位否则复位信号释放后要等一个时钟沿pc才会变化仿真一开始就会出现PC悬空。module pc_reg( input clk, input rst_n, input [31:0] pc_next, output reg [31:0] pc ); always (posedge clk or negedge rst_n) begin if (!rst_n) pc 32h00000000; // 复位后从地址0取指 else pc pc_next; // 每个上升沿锁存下一条地址 end endmodule寄存器堆是对称双读端口、单写端口。读用组合逻辑直接输出写用时钟上升沿锁存。特别要注意x0寄存器永远为0这个特判要同时出现在写路径和读路径上写路径上禁止写入x0读路径上强制输出0。只堵一端的话仿真里就会出现x0变成非0的诡异现象。module regfile( input clk, input we, input [4:0] raddr1, input [4:0] raddr2, input [4:0] waddr, input [31:0] wdata, output [31:0] rdata1, output [31:0] rdata2 ); reg [31:0] mem [0:31]; always (posedge clk) begin if (we (waddr ! 5b00000)) // x0永不写入 mem[waddr] wdata; end assign rdata1 (raddr1 5b00000) ? 32h0 : mem[raddr1]; assign rdata2 (raddr2 5b00000) ? 32h0 : mem[raddr2]; endmoduleALU的内部只做一件事根据4位控制码从两个32位操作数算出结果和zero标志。zero标志是组合逻辑只要result为全0就拉高这个信号直接决定beq是否跳转。注意result是reg类型因为要在always块里赋值。module alu( input [31:0] a, input [31:0] b, input [3:0] alu_ctrl, output reg [31:0] result, output zero ); always (*) begin case (alu_ctrl) 4b0000: result a b; 4b0001: result a | b; 4b0010: result a b; 4b0110: result a - b; 4b0111: result ($signed(a) $signed(b)) ? 32h1 : 32h0; default: result 32h0; endcase end assign zero (result 32h0); endmodule4.2 顶层例化连线就是数据通路顶层模块把上面所有部件用wire连起来连线的过程就是搭数据通路的过程。我习惯把地址计算、操作数选择这些“胶水逻辑”直接写在顶层而不是塞进某个子模块这样看整个设计的时候一眼就能扫完所有关键路径。module top( input clk, input rst_n, output [31:0] pc_out ); // 内部连线声明 wire [31:0] pc, pc_next, pc_plus4, pc_branch, pc_jump; wire [31:0] inst, rdata1, rdata2, imm_ext, alu_result; wire [31:0] mem_rdata, write_back_data, alu_b; wire zero; wire reg_dst, reg_write, mem_write, mem_to_reg; wire alu_src, branch, jump; wire [1:0] alu_op; wire [3:0] alu_ctrl; wire [4:0] waddr; // 地址计算逻辑 assign pc_plus4 pc 32h4; assign imm_ext {{16{inst[15]}}, inst[15:0]}; // 符号扩展 assign pc_branch pc_plus4 ({imm_ext[29:0], 2b00}); // 分支目标 assign pc_jump {pc_plus4[31:28], inst[25:0], 2b00}; // 跳转目标 assign pc_next jump ? pc_jump : (branch zero) ? pc_branch : pc_plus4; // 操作数与写回选择 assign alu_b alu_src ? imm_ext : rdata2; assign write_back_data mem_to_reg ? mem_rdata : alu_result; assign waddr reg_dst ? inst[15:11] : inst[20:16]; // 子模块例化 pc_reg u_pc (.clk(clk), .rst_n(rst_n), .pc_next(pc_next), .pc(pc)); inst_mem u_im (.addr(pc), .inst(inst)); regfile u_rf (.clk(clk), .we(reg_write), .raddr1(inst[25:21]), .raddr2(inst[20:16]), .waddr(waddr), .wdata(write_back_data), .rdata1(rdata1), .rdata2(rdata2)); alu u_alu (.a(rdata1), .b(alu_b), .alu_ctrl(alu_ctrl), .result(alu_result), .zero(zero)); control u_ctrl(.op(inst[31:26]), .reg_dst(reg_dst), .reg_write(reg_write), .mem_write(mem_write), .mem_to_reg(mem_to_reg), .alu_src(alu_src), .branch(branch), .jump(jump), .alu_op(alu_op)); alu_control u_ac (.alu_op(alu_op), .funct(inst[5:0]), .alu_ctrl(alu_ctrl)); data_mem u_dm (.clk(clk), .we(mem_write), .addr(alu_result), .wdata(rdata2), .rdata(mem_rdata)); assign pc_out pc; endmodulepc_next的三目运算符嵌套是这里最容易看花眼的地方。完整的语义是jump信号为1时无条件跳转否则如果branch为1且zero为1走分支路径否则顺序执行pc_plus4。我在代码里用缩进和括号把优先级写清楚而不是依赖运算符默认的结合顺序不然综合结果和仿真行为可能不一致。inst_mem和data_mem两个存储模块还没有给出它们的读都是组合逻辑写都是时钟边沿触发。指令存储器的初始内容通过$readmemh装载数据存储器在仿真开始时全为0。4.3 最小测试程序与testbench让第一条指令跑起来为了让仿真有意义先写一个计算(5 3) - 2的测试程序结果放到寄存器19。这段程序虽然只有5条指令但覆盖了addi、add、sub三种类型足够验证PC更新、寄存器堆读写、ALU运算、写回选择这几条核心通路。// inst_mem.v module inst_mem( input [31:0] addr, output [31:0] inst ); reg [31:0] mem [0:127]; initial $readmemh(inst.hex, mem); // 从文件装载指令 assign inst mem[addr[31:2]]; // 字寻址地址右移两位 endmodule// inst.hex计算 (5 3) - 2结果写入 $s3寄存器19 00000000 20100005 // addi $s0, $zero, 5 20110003 // addi $s1, $zero, 3 20120002 // addi $s2, $zero, 2 02114020 // add $t0, $s0, $s1 01129822 // sub $s3, $t0, $s2testbench里除了生成时钟和复位还要在恰当的时刻检查结果。检查方式不能靠人眼盯波形可以用wait语句等PC走到预期位置后直接比对寄存器19的值。module top_tb; reg clk, rst_n; wire [31:0] pc; top uut(.clk(clk), .rst_n(rst_n), .pc_out(pc)); always #5 clk ~clk; // 10ns周期50MHz initial begin clk 0; rst_n 0; #10 rst_n 1; // 第10ns释放复位 #150 $finish; // 约15个周期足够执行完5条指令 end // 自动检查最终结果 initial begin wait (pc 32h00000014); // 5条指令执行完后PC回到0x14 #1; // 避开时钟边沿等稳定 if (uut.u_rf.mem[19] 32d6) $display(PASS: s3 %d, uut.u_rf.mem[19]); else $display(FAIL: s3 %d, uut.u_rf.mem[19]); end endmodule这里有个使用细节wait (pc 32h00000014)里的地址是5条指令之后的下一条地址也就是0x14。仿真器按指令周期推进当PC到达这个值时最后一条sub已经执行完毕寄存器19应该正好是6。#1延时是为了让组合逻辑在时钟边沿之后稳定再读寄存器堆否则可能读到边沿前的旧值。data_mem模块我通常会顺手写一个$dumpfile配合波形查看但注意在最终提交版本里把dumpargument去掉不然仿真文件会膨胀到几百MB。5. 单周期CPU常见问题排查5个高频翻车现场5.1 现象PC永远停在0指令一直重复执行第一条仿真波形里pc从复位后一直是0x00000000后面的指令一条都没跑。原因基本出在两个方面一是pc_next这个组合逻辑没有算出正确值二是pc_reg的复位逻辑写成了同步复位且复位释放时没有时钟沿。我更常遇到的其实是前者——pc_next的assign里三目运算符嵌套顺序错误导致无论条件如何都选到pc_plus4而pc_plus4又因为pc没有更新而恒等于4可波形里pc却停在0矛盾点在时钟没接上。解决先看clk和rst_n的波形确认复位释放后是否有时钟上升沿。再看pc_next的值在每个周期是否按预期变化。推荐在testbench里加一条$monitor(t%0t pc%h, $time, pc);一眼就能看出PC是死在复位还是死在组合逻辑。5.2 现象寄存器堆读出来全是0rdata1和rdata2的波形从头到尾都是0但寄存器堆内部明明有数据。这是把寄存器堆的读端口写成了时序逻辑典型错误是always (posedge clk)里给rdata1/rdata2赋值。读如果依赖时钟ALU在同一周期内根本拿不到操作数所有计算结果都是错的。另一个常见原因是把x0的特判只加在了写路径上读路径直接assign rdata1 mem[raddr1]导致读地址为0时返回了mem[0]里的垃圾值。解决读端口必须用纯组合逻辑assign语句直接输出x0特判同时放在读写两个方向。改完之后寄存器堆的rdata应该随着raddr变化立即变化不依赖任何时钟沿。5.3 现象beq指令“玄学”失效两个寄存器的值明明相等beq却不跳或者不相等反而跳了。这类问题的隐蔽性在于beq的flag信号来自ALU的sub操作但sub的前提是ALUSrc必须为0让ALU的b端口走寄存器堆rt口而不是立即数扩展。如果控制单元里beq分支忘了把ALUSrc拉低ALU就会拿立即数和rs比较结果永远不相等。还有一处在分支目标地址的立即数拼接忘了把符号扩展后的立即数左移2位导致跳转地址错乱。解决用波形检查beq执行周期里ALU的两个输入操作数是否正是rs和rt检查alu_ctrl是否为sub4b0110检查zero标志是否按要求翻转。分支地址计算按pc_plus4 ({imm_ext[29:0], 2b00})来写30位有效立即数左移2位填充低两位。5.4 现象仿真波形一大片红色X态很多信号的波形都是红色的X看起来像是整个设计都死了。这种问题十有八九是未复位或未初始化。PC没有复位时是X态所有依赖它的信号全部变成X寄存器堆的mem数组在仿真开始也没有初始化读出来就是X。另一个常见成因是case语句的default分支缺失仿真器推断出锁存器在某些未定义操作码上输出X。解决pc_reg必须用异步复位复位后从0开始。寄存器堆如果不想依赖复位可以在声明时给个initial赋值或者干脆在testbench前几周期把寄存器堆的写使能拉高清零。case语句的default分支不能省哪怕内容为空也要写上default: ;。5.5 现象addi/lw指令写错了寄存器addi的结果出现在一个莫名其妙的寄存器里或者lw的数据没有回到rt位置。根因是RegDst信号没接对。R型指令写rdinst[15:11]I型指令写rtinst[20:16]这是一对需要MUX选择的地址源。很多设计在顶层直接硬连inst[15:11]导致所有I型指令的结果都写到了rd字段指向的寄存器。解决在控制单元里输出reg_dst信号R型拉高、I型拉低顶层用assign waddr reg_dst ? inst[15:11] : inst[20:16];。这个MUX在课上画数据通路时每个人都画了但写代码时经常漏掉。我之前就在这个点上卡了一个下午最后是逐条指令对寄存器写地址才发现的。6. 验证别再靠人眼盯波形断言检查与扩展思路6.1 用断言替代人眼盯波形看波形图排查问题是最初级的验证手段效率太低10条指令以内还能应付程序一长就完全失控。我现在的习惯是在testbench里写断言用$display自动汇报每条指令的预期结果与实际结果是否一致。把前面检查寄存器19的逻辑泛化一下可以做成一个自动比对框架每执行一条指令后检查PC和目标寄存器值是否与汇编注释里的预期一致。不需要复杂的UVM环境几个wait加if判断就够了。// 断言片段检查 addi $s0, $zero, 5 的结果 initial begin (posedge uut.u_rf.clk); // 等第一个写周期完成 #1; if (uut.u_rf.mem[16] 32d5) $display(PASS: $s0 %d, uut.u_rf.mem[16]); else $display(FAIL: $s0 %d, uut.u_rf.mem[16]); end断言的价值在于仿真跑完一遍终端直接给出PASS/FAIL列表而不是让你在几十MB的波形文件里拖滚动条。某个信号错了立刻能在终端看到是哪条指令、哪个寄存器出了问题。这个习惯帮我省掉的排错时间至少占整个课程设计的一半。6.2 给CPU留好扩展接口单周期CPU做完之后很多题目会要求扩展成多周期甚至往流水线方向走。我在写代码时就会刻意留几个扩展口第一个是数据存储器地址总线的位宽。设计里直接用32位alu_result做地址如果想让CPU支持字节寻址可以把地址总线的低2位单独引出来。第二个是寄存器堆的双读端口已经天然支持后续流水线的前递逻辑不用改。第三个是控制单元的信号命名和真值表结构改成多周期状态机时只需要把组合逻辑的输出变成状态寄存器整体架构不用推翻重来。我自己的习惯是仿真通过后立刻写一个稍微长一点的测试程序比如循环累加或者查找数组最大值把每一条指令的执行情况通过断言全部打出来。跑通这一步这个CPU才算真正能在你手里运行程序而不是只在示例上好看。最后收个尾单周期CPU这块硬骨头吃透它后面多周期和流水线都是顺水推舟的事。控制信号那张真值表一定要自己亲手填一遍再亲手把case写完——最容易出错的地方通常不是复杂的计算方法而是那些不起眼的“想当然”。希望帮到你。本文还有配套的精品资源点击获取
返回列表