ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Verilog手把手实现多周期处理器:状态机与数据通路设计详解

Verilog手把手实现多周期处理器:状态机与数据通路设计详解

1. 为什么劝你别再手搓单周期了

最近有学生问我多周期处理器怎么做,说实话我很能理解这种需求。本科阶段做过单周期之后,很多人都会发现一个尴尬的问题:单周期CPU虽然逻辑简单、一条指令一拍搞定,但代价是时钟频率被所有部件里最慢的那条路径绑架,而且每个部件在一个周期里只被用一次。ALU算完加法之后,剩下的时间全在空转。这套设计交作业没问题,但如果你想往真实的处理器设计方向走,就必须体会一下多周期到底在解决什么问题。

所谓多周期处理器,就是在一条指令的执行过程中,把取指、译码、执行、访存、写回这几个动作拆到多个时钟周期里完成,每个周期只做一件纯粹的事。这样设计有几个肉眼可见的好处:第一,时钟周期可以缩得很短,因为每条子路径都变短了,系统主频能拉上去;第二,硬件资源可以被多次复用,比如ALU既负责计算地址、又负责算运算结果,指令存储器和数据存储器可以分开也可以合一,寄存器写口和写数据选择逻辑也更集中;第三,控制器的设计方式会从一堆分散的组合逻辑变成一个有限状态机,这是从“搭积木”到“写状态机”的巨大思维转变。

这篇文章我会直接用Verilog代码说话,把多周期处理器的数据通路、控制器状态机、关键模块怎么写、仿真怎么调、坑怎么踩,一口气讲透。适合已经写完单周期CPU、想进阶到多周期的同学,也适合正在备战考研复试或者准备数字IC面试的工程师。我尽量做到让你读完就能照着敲出一台能跑通汇编程序的小处理器。

2. 多周期处理器整体设计拆解

2.1 多周期到底“多”在哪里

单周期处理器的控制信号是纯组合逻辑,一条指令的所有控制信号在同一拍内全部有效,然后等着所有部件算完。多周期处理器不一样,控制信号变成了“按时序给出”的,每个周期由状态机决定下一步做什么。CPU不再是“一个周期执行一条指令”,而是“一个指令要等待若干个周期才能结束”。

以经典的五类指令为例:R型运算指令(add、sub、and、or、slt这类)、lw、sw、beq、jump。在各人的分配方案里,R型需要4个周期,lw需要5个周期,sw需要4个周期,beq需要3个周期,jump需要3个周期。为什么会有这种差异?本质原因是lw要单独去数据存储器里取数再写回寄存器,sw只需要把寄存器里的数写进存储器,beq只需要做比较和跳转用不到写回,jump连比较都不用做。

多周期里面还有个关键概念叫“部件复用”。同一个ALU,在一个指令的不同周期里,我先拿它算PC+4,再拿它算分支目标地址,再拿它算真正的运算结果。你把数据通路里所有的多路选择器和控制信号画出来之后会发现,这个设计的美感在于:硬件不增加,只是把时间切细了,让部件轮流干活。

2.2 与单周期对比:资源、频率、控制复杂度

很多教材里有个经典的对比表,我根据自己的设计经验重新梳理了一版:

对比维度单周期处理器多周期处理器
时钟周期由最长路径决定(一般很大)由最短子路径决定(可以很小)
指令执行周期数13到5不等
硬件复用程度低,每个部件每指令只用一次高,ALU等部件多周期复用
控制器实现大量组合逻辑,易混乱有限状态机,清晰可控
CPI恒为1大于1,但主频更高
适用场景教学入门进阶学习、小型嵌入式处理器原型

在工程实践中,多周期这种“用时间换空间”的思路非常常见,不只是CPU,很多通信协议、总线仲裁、状态机控制逻辑都是同样的套路。你在面试的时候如果能说清楚“流水线之所以能提高吞吐率,前提是先有稳定的多周期控制逻辑”,面试官基本就会认定你是真的懂处理器。

2.3 核心思路:用状态机做总指挥

多周期处理器的核心思想可以浓缩成一句话:控制器是一个有限状态机,每一个状态对应一个时钟周期,每个状态下给出这一拍需要拉高或拉低的控制信号。状态机在状态之间跳转,直到一条指令完成,再回到取指状态开始下一条。

这个设计的难点不在数据通路本身,而在控制器的状态安排。你得给每条指令画时序图,然后从时序图推导出状态转移图,再从状态转移图写出状态机的Verilog。很多新手一上来就看别人代码里有一堆localparam,比如S_FETCH、S_DECODE、S_EXECUTE之类的,然后就照着抄,抄完发现仿真错乱,原因是根本没理解每个状态里控制信号为什么要这样给。我会在后面的章节里把每个状态的控制信号逐一讲清楚。

3. 数据通路架构与各模块详述

3.1 多周期数据通路全景

这里我基于经典的五状态方案,给出一个可综合的多周期数据通路设计。多周期数据通路里重要的不是各个模块本身,而是模块之间那一大片多路选择器。举个很典型的例子:ALU的第二个输入在R型指令里来自寄存器堆的读数据2,在lw/sw里来自指令的低16位符号扩展后的立即数,在计算分支目标时又来自立即数。你不可能为了这些场景造三个ALU,只能在ALU的输入前加一个2选1多路选择器,再由控制器决定这一拍选择哪条路。

类似的多路选择器还有:

  • PC的输入有四个来源:PC+4、分支目标地址、跳转目标地址、因为lw写回不改变PC所以不需要第四个。实际上常见方案是三个来源,PCWrite信号和PCSource信号配合使用。
  • 寄存器堆写数据来源有两路:ALU运算结果、数据存储器读出的数据。
  • ALU的第一个输入有两路:PC值、寄存器堆读数据1。
  • ALU的第二个输入有两路:寄存器堆读数据2、符号扩展后的立即数。

这些多路选择器的控制信号组合起来,就是状态机的输出。你在设计阶段要做的第一件事不是写代码,而是拿一张大纸,把PC、指令存储器、寄存器堆、ALU、数据存储器、符号扩展模块、多路选择器全部画出来,然后把每条控制信号连起来。没有这张图,后面写代码必乱。

3.2 部件详解:PC、指令存储器与寄存器堆

PC模块。多周期里PC并不是每拍都更新。取指需要两拍:第一拍把PC值送到指令存储器地址口,第二拍才能稳定读回指令。所以PC的更新必须被门控,只在合适的周期才锁存新的PC。这就是PCWrite信号存在的意义。只有PCWrite有效时,PC才从PCNext多路选择器拿新值。

指令存储器。在多周期里我习惯把指令存储器做成组合读、同步写的ROM。因为指令内容不允许修改,读逻辑很简单:assign instr = inst_mem[pc[9:2]];这里PC的低两位因为指令按字对齐被砍掉。你可能会问为什么不能直接用字节地址,这涉及到MIPS指令集按字对齐的基本规则,如果你的设计目标是简化版,可以只取PC的高位部分做索引,但必须保证指令存储器容量是2的幂次。

寄存器堆。寄存器堆的读是组合逻辑,写是同步逻辑。多周期处理器里寄存器堆有两个读口和一个写口。读口的地址在译码周期就已经准备好,写口的地址和写数据在不同指令的不同周期分别来自不同模块。

3.3 部件详解:ALU与数据存储器

ALU。ALU本身并不难,难的是它的控制信号。多周期ALU支持的操作比单周期要少得多,因为控制器已经有足够的时间解码指令,不再需要一堆ALUOp信号来“猜”操作。这里我建议ALU的opcode直接由funct字段和opcode联合确定,在R型指令的EX周期直接用funct字段产生ALU控制信号,在lw/sw/beq里用固定的add/sub操作。

数据存储器。多周期处理器中数据存储器可以复用指令存储器的地址总线,因为指令存储器只在取指周期占用总线,数据存储器只在访存周期占用总线,两者时间上不冲突,可以共用一套存储器。这个特点也是多周期设计中“资源复用”的体现。

3.4 符号扩展与移位模块的细节

符号扩展模块很简单,输入是16位立即数,输出是32位。但细节上有两个容易忽略的地方:一是lw/sw需要符号扩展到32位然后传给ALU计算地址;二是beq里需要把立即数左移两位再和PC+4相加,因为分支偏移量是以指令为单位的。左移两位这个动作在Verilog里用{imm_ext[29:0], 2'b00}实现,千万不要在符号扩展模块内部直接左移,因为lw/sw也需要原始符号扩展值。

4. 控制器状态机设计:多周期CPU的灵魂

4.1 状态分配原则

敢说一句比较绝对的话:多周期处理器成也状态机,败也状态机。状态机设计得好,后续写代码一气呵成;状态机设计得混乱,后面仿真基本就是一场灾难。

我推荐的方案是使用六个状态,命名如下:

  • S_IF1:取指第一阶段,输出PC值到指令存储器地址口。
  • S_IF2:取指第二阶段,指令已经稳定,锁存IR,同时计算PC+4。
  • S_ID:译码阶段,读寄存器堆,同时把立即数符号扩展。
  • S_EX:执行阶段,ALU干活。R型做运算,lw/sw算地址,beq做比较和分支地址计算。
  • S_MEM:访存阶段,只有lw/sw用到,读写数据存储器。
  • S_WB:写回阶段,把结果写进寄存器堆。

有些教材会把取指合并成一个状态,然后执行阶段拆两个,但我觉得刚入门还是拆细一点好,状态多了看仿真波形的时候每一拍干什么清清楚楚,排查问题也方便。六个状态已经是非常优秀的状态分配方案。

状态转移的原则也很容易总结:所有指令都从S_IF1开始,按顺序走到S_IF2、S_ID,然后根据操作码分叉。R型走S_EX -> S_WB;lw走S_EX -> S_MEM -> S_WB;sw走S_EX -> S_MEM;beq走S_EX然后直接回S_IF1,因为分支不写寄存器;jump直接回S_IF1。

4.2 R型指令的完整状态流转

拿一条add rd, rs, rt举例。状态流转是这样的:

  • S_IF1:PC值送到指令存储器地址口,控制信号把PCWrite拉低,PC保持不变。
  • S_IF2:IR锁存从存储器读回的指令,IRWrite拉高。同时ALU计算PC+4,源选择信号ALUSrcA=0, ALUSrcB=01。注意这里ALU的输出不是给寄存器的,是传给PC更新逻辑的临时值。
  • S_ID:把IR里的rs和rt字段送到寄存器堆地址口,读数据1和读数据2组合输出。符号扩展立即数。此时不写寄存器。
  • S_EX:ALU选择ALUSrcA=1(用寄存器读数据1)、ALUSrcB=00(用寄存器读数据2),ALUOp根据funct解码出具体运算。
  • S_WB:寄存器堆写使能拉高,写地址用rd字段,写数据选择ALU结果(MemToReg=0),把结果写回。
  • 下一拍回到S_IF1,继续取下一条指令。

你看,整个过程就是状态机每一拍决定哪一个控制信号有效。单周期里一堆组合逻辑做的事,多周期里变成了状态机在不同时刻依次给信号。这就是最核心的思维差异。

4.3 lw、sw、beq、jump的差异化流转

lw指令:S_IF1 -> S_IF2 -> S_ID -> S_EX -> S_MEM -> S_WB。其中S_EX里ALU用符号扩展后的立即数和寄存器读数据1相加得到访存地址。S_MEM里数据存储器读使能拉高,数据从存储器输出端口出来。S_WB里写寄存器,写数据来源选择数据存储器输出的值(MemToReg=1)。

sw指令:S_IF1 -> S_IF2 -> S_ID -> S_EX -> S_MEM,没有写回阶段。S_EX里ALU同样算地址,S_MEM里数据存储器写使能拉高,写入数据来自寄存器堆读数据2,地址是ALU计算结果。

beq指令:S_IF1 -> S_IF2 -> S_ID -> S_EX,S_EX里ALU做减法(或异或),Zero信号输出判断结果是否为0。如果分支成立,PC来源选择S_EX里计算好的分支目标地址;分支不成立,PC来源选择PC+4。这里要特别注意:分支目标地址在S_ID就已经可以算了,但真正让PC更新的要放到S_EX,因为判断条件(rs与rt是否相等)要等寄存器读出来之后才能确定。

jump指令:S_IF1 -> S_IF2 -> S_ID,然后在S_ID周期直接拉高PCWrite和设置PCSource为Jump地址来源,下一拍进入S_IF1。因为jump不需要任何运算,指令的低26位左移两位拼上PC高四位就能得到跳转地址。有的设计会把jump的跳转地址计算放到S_ID,我觉得没问题,反正jump不依赖任何寄存器的值。

4.4 控制信号全表:每个状态该给什么信号

这部分我直接给一个完整的状态控制信号表,你可以把它当成写代码时的对照清单。我按照自己调试成功的版本整理,值得收藏。

状态PCWriteIRWriteRegWriteMemWriteALUSrcAALUSrcBALUOpMemToRegPCSource
S_IF10000001加XX
S_IF21100001加X00(PC+4)
S_ID0000XXXXX
S_EX(R型)0000100funct解码XX
S_EX(lw/sw)0000110加XX
S_EX(beq)分支时1000100减X01(分支)
S_MEM(lw)0000XXXXX
S_MEM(sw)0001XXXXX
S_WB(R型)0010XXX0X
S_WB(lw)0010XXX1X

注意这张表里有一个容易被忽略的细节:S_IF2里PCWrite=1是因为在这一拍计算出PC+4后,PC要立刻更新为下一条指令地址,为下一个取指周期做准备。但如果你用的是边沿触发的PC寄存器,更新发生在S_IF2的时钟上升沿,那下一条指令的取指要等到再下一个状态S_IF1才真正把新PC送到存储器,这正好符合多周期设计。

5. 核心模块Verilog实现

5.1 多路选择器与PC更新逻辑

先把PC更新逻辑写出来,这是最基础的部分。PC寄存器的值只有在PCWrite为高时才更新,更新来源由PCSource选择。

module pc_update ( input wire clk, input wire rst, input wire pc_write, input wire [1:0] pc_source, input wire [31:0] pc_plus4, input wire [31:0] branch_target, input wire [31:0] jump_target, output reg [31:0] pc ); always @(posedge clk) begin if (rst) pc <= 32'h0; else if (pc_write) begin case (pc_source) 2'b00: pc <= pc_plus4; 2'b01: pc <= branch_target; 2'b10: pc <= jump_target; default: pc <= pc_plus4; endcase end // pc_write为0时保持原值 end endmodule

这里有个很容易踩的坑:很多人喜欢把PCWrite和PCSource信号直接接到多路选择器上,然后对PC做组合逻辑赋值,这样会生成电平敏感的锁存器,在FPGA里综合会报一堆warning。正确做法是像我这样,PC用always @(posedge clk)时序逻辑,PC是否更新用使能信号门控。注意pc_source的编码含义要和控制器对齐,比如2'b10表示跳转地址。我自己调试时遇到过PC在分支跳转时多跳了两拍的情况,最后发现是PCSource在S_EX周期提前一个周期变成了分支地址值,导致PC在S_IF2用错误来源更新,这种问题只能靠波形排查。

5.2 寄存器堆的实现

寄存器堆读是组合逻辑、写是同步逻辑,这个写法基本是行业标准。

module regfile ( input wire clk, input wire reg_write, input wire [4:0] read_addr1, input wire [4:0] read_addr2, input wire [4:0] write_addr, input wire [31:0] write_data, output wire [31:0] read_data1, output wire [31:0] read_data2 ); reg [31:0] regs [31:0]; integer i; initial begin for (i = 0; i < 32; i = i + 1) regs[i] = 32'h0; end assign read_data1 = (read_addr1 == 5'd0) ? 32'h0 : regs[read_addr1]; assign read_data2 = (read_addr2 == 5'd0) ? 32'h0 : regs[read_addr2]; always @(posedge clk) begin if (reg_write && (write_addr != 5'd0)) regs[write_addr] <= write_data; end endmodule

注意我在读口和写口都做了0号寄存器特判。0号寄存器永远为0,读写都不生效。如果你不做这个特判,在跑汇编程序时遇到jr $0之类的指令就会出错。另外这个寄存器堆是单时钟沿写入,如果你用的FPGA开发板支持双口RAM,可以直接映射到RAM原语,性能更好,但教学用的话普通寄存器数组足够。

5.3 ALU以及控制信号解码

ALU的设计应该尽量简单,多周期里它的操作数来源由控制器决定,ALU本身只负责算。

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 == 32'h0); always @(*) begin case (alu_ctrl) 4'b0010: result = a + b; // add 4'b0110: result = a - b; // sub 4'b0000: result = a & b; // and 4'b0001: result = a | b; // or 4'b0111: result = (a < b) ? 32'h1 : 32'h0; // slt 4'b1100: result = ~(a | b); // nor default: result = a + b; endcase end endmodule

有些教程会把ALU控制信号设计成两位的ALUOp,然后在ALU内部再解码。我的习惯是控制器直接产生四位alu_ctrl信号,这样控制器代码会稍微长一点,但ALU的实现简单,调试时逻辑清晰。具体在控制器的S_EX周期,R型指令根据funct字段直接映射成alu_ctrl;lw/sw/beq里用加法或减法。

5.4 有限状态机控制器完整实现

终于到控制器了。这是整篇代码的重头戏,我给出一个可运行的版本。状态编码用独热码或者普通二进制都可以,独热码在FPGA里更省逻辑,二进制码在仿真时更直观。我这里用二进制编码,方便初学同学看波形。

module controller ( input wire clk, input wire rst, input wire [5:0] opcode, input wire [5:0] funct, output reg pc_write, output reg ir_write, output reg reg_write, output reg mem_write, output reg mem_to_reg, output reg [1:0] alu_src_a, output reg [1:0] alu_src_b, output reg [3:0] alu_ctrl, output reg [1:0] pc_source, output wire [1:0] state_out ); localparam S_IF1 = 3'd0; localparam S_IF2 = 3'd1; localparam S_ID = 3'd2; localparam S_EX = 3'd3; localparam S_MEM = 3'd4; localparam S_WB = 3'd5; localparam OP_R_TYPE = 6'b000000; localparam OP_LW = 6'b100011; localparam OP_SW = 6'b101011; localparam OP_BEQ = 6'b000100; localparam OP_JMP = 6'b000010; reg [2:0] state, next_state; assign state_out = state; // 状态转移 always @(posedge clk) begin if (rst) state <= S_IF1; else state <= next_state; end always @(*) begin next_state = S_IF1; case (state) S_IF1: next_state = S_IF2; S_IF2: next_state = S_ID; S_ID: begin case (opcode) OP_R_TYPE: next_state = S_EX; OP_LW: next_state = S_EX; OP_SW: next_state = S_EX; OP_BEQ: next_state = S_EX; OP_JMP: next_state = S_IF1; default: next_state = S_IF1; endcase end S_EX: begin case (opcode) OP_R_TYPE: next_state = S_WB; OP_LW: next_state = S_MEM; OP_SW: next_state = S_MEM; OP_BEQ: next_state = S_IF1; default: next_state = S_IF1; endcase end S_MEM: begin if (opcode == OP_LW) next_state = S_WB; else next_state = S_IF1; end S_WB: next_state = S_IF1; default: next_state = S_IF1; endcase end // 控制信号输出 always @(*) begin // 默认全零 pc_write = 1'b0; ir_write = 1'b0; reg_write = 1'b0; mem_write = 1'b0; mem_to_reg = 1'b0; alu_src_a = 2'b00; alu_src_b = 2'b00; alu_ctrl = 4'b0010; pc_source = 2'b00; case (state) S_IF1: begin // 把PC输出到指令存储器地址口,什么都不做 end S_IF2: begin ir_write = 1'b1; pc_write = 1'b1; pc_source = 2'b00; // ALU输出是PC+4 alu_src_a = 2'b00; // 操作数a = PC alu_src_b = 2'b01; // 操作数b = 4 alu_ctrl = 4'b0010; // 执行加法 end S_ID: begin // 寄存器堆读出数据,立即数符号扩展 // 其他控制信号保持默认 end S_EX: begin case (opcode) OP_R_TYPE: begin alu_src_a = 2'b01; // a = reg_data1 alu_src_b = 2'b00; // b = reg_data2 case (funct) 6'b100000: alu_ctrl = 4'b0010; // add 6'b100010: alu_ctrl = 4'b0110; // sub 6'b100100: alu_ctrl = 4'b0000; // and 6'b100101: alu_ctrl = 4'b0001; // or 6'b101010: alu_ctrl = 4'b0111; // slt default: alu_ctrl = 4'b0010; endcase end OP_LW, OP_SW: begin alu_src_a = 2'b01; // a = reg_data1 alu_src_b = 2'b10; // b = 立即数 alu_ctrl = 4'b0010; // 加法算地址 end OP_BEQ: begin alu_src_a = 2'b01; // a = reg_data1 alu_src_b = 2'b00; // b = reg_data2 alu_ctrl = 4'b0110; // 减法比较 pc_write = branch_taken; // 外部根据zero信号反馈 pc_source = 2'b01; // 分支目标地址 end default: ; endcase end S_MEM: begin if (opcode == OP_LW) begin // 读数据存储器,mem_read信号需要单独加 end else if (opcode == OP_SW) begin mem_write = 1'b1; end end S_WB: begin reg_write = 1'b1; if (opcode == OP_LW) mem_to_reg = 1'b1; else mem_to_reg = 1'b0; end default: ; endcase end endmodule

这个控制器的核心是两段式状态机:第一段是时序逻辑的状态转移,第二段是组合逻辑的下一状态计算和输出信号生成。之所以不把输出信号放在时序逻辑里,是因为如果输出信号只在时钟沿变化,会导致控制信号晚半个周期才到达,这在多周期处理器里会造成灾难性的时序混乱。组合逻辑输出虽然可能会有毛刺,但因为我们每个状态持续整整一个时钟周期,毛刺在这个周期内已经稳定,不会影响功能。

注意branch_taken信号我还没有定义,实际使用中需要通过外部把ALU的zero信号引进来,组合成pc_write = zero,我在这里留了接口。你也可以把branch_taken定义为输入端口,然后在顶层连线时用wire连到ALU的zero输出。

5.5 顶层模块与数据通路连线

顶层模块的作用是把所有子模块串起来,并且把关键多路选择器写出来。这里我给出ALU输入多路选择器的核心逻辑。

module mips_top ( input wire clk, input wire rst ); // 内部信号 wire [31:0] pc, pc_plus4, branch_target, jump_target, next_pc; wire [31:0] instr, read_data1, read_data2, alu_a, alu_b; wire [31:0] alu_result, mem_read_data, sign_ext_imm; wire [31:0] write_data_reg; wire [4:0] write_addr_reg; wire zero; // 控制信号 wire pc_write, ir_write, reg_write, mem_write, mem_to_reg; wire [1:0] alu_src_a, alu_src_b, pc_source; wire [3:0] alu_ctrl; wire [1:0] state; // 指令存储器输出到IR的简化 reg [31:0] ir; always @(posedge clk) begin if (ir_write) ir <= instr; end // PC更新 pc_update u_pc ( .clk(clk), .rst(rst), .pc_write(pc_write), .pc_source(pc_source), .pc_plus4(pc_plus4), .branch_target(branch_target), .jump_target(jump_target), .pc(pc) ); // 指令存储器 wire [31:0] instr_mem [1023:0]; assign instr = instr_mem[pc[11:2]]; // 寄存器堆 regfile u_rf ( .clk(clk), .reg_write(reg_write), .read_addr1(ir[25:21]), .read_addr2(ir[20:16]), .write_addr(write_addr_reg), .write_data(write_data_reg), .read_data1(read_data1), .read_data2(read_data2) ); // 符号扩展 assign sign_ext_imm = {{16{ir[15]}}, ir[15:0]}; // ALU输入多路选择器 assign alu_a = (alu_src_a == 2'b01) ? read_data1 : pc; assign alu_b = (alu_src_b == 2'b01) ? 32'd4 : (alu_src_b == 2'b10) ? sign_ext_imm : read_data2; // ALU alu u_alu ( .a(alu_a), .b(alu_b), .alu_ctrl(alu_ctrl), .result(alu_result), .zero(zero) ); // 数据存储器 reg [31:0] data_mem [1023:0]; always @(posedge clk) begin if (mem_write) data_mem[alu_result[11:2]] <= read_data2; end assign mem_read_data = data_mem[alu_result[11:2]]; // PC+4和分支/跳转地址计算 assign pc_plus4 = alu_result; // 在S_IF2周期ALU输出就是PC+4 assign branch_target = pc_plus4 + (sign_ext_imm << 2); assign jump_target = {pc[31:28], ir[25:0], 2'b00}; // 寄存器堆写回逻辑 assign write_addr_reg = (opcode == OP_LW) ? ir[20:16] : ir[15:11]; assign write_data_reg = mem_to_reg ? mem_read_data : alu_result; // 控制器 controller u_ctrl ( .clk(clk), .rst(rst), .opcode(ir[31:26]), .funct(ir[5:0]), .pc_write(pc_write), .ir_write(ir_write), .reg_write(reg_write), .mem_write(mem_write), .mem_to_reg(mem_to_reg), .alu_src_a(alu_src_a), .alu_src_b(alu_src_b), .alu_ctrl(alu_ctrl), .pc_source(pc_source), .state_out(state) ); endmodule

我在顶层里把pc_plus4直接用alu_result赋值,这是一种简化的描述方式,实际含义是在S_IF2状态时ALU的结果正好是PC+4。初学者可能觉得这样写有点绕,但等你看懂数据通路的走向就会明白,这种写法在多周期里很常见,因为PC+4不是靠独立加法器算的,而是靠ALU复用的。

branch_taken的问题,我改成控制器内部根据zero信号组合逻辑:

wire branch_taken; assign branch_taken = pc_write_fsm; // 这里用单独信号名区分,实际需要把zero传进来

严谨的写法是在控制器输入端加一个zero输入,然后在S_EX且opcode为BEQ时,输出pc_write = zero。这个细节很重要,写漏了分支指令永远不会跳转。

6. 仿真验证与调试实录

6.1 仿真环境搭建

写测试平台的时候,我习惯把指令存储器和数据存储器直接在testbench里初始化,这样每次跑不同程序不用改顶层代码。你可以用$readmemh读入一个十六进制文件,也可以直接在initial块里给存储器赋值。我常用后者,对于小程序的验证来说直观方便。

module tb_mips; reg clk, rst; mips_top uut ( .clk(clk), .rst(rst) ); initial begin clk = 0; forever #10 clk = ~clk; // 20ns周期 end // 需要在顶层把指令存储器暴露出来,或者通过层次引用 initial begin rst = 1; #25 rst = 0; #2000 $finish; end endmodule

注意如果你用了层次引用,比如uut.instr_mem[0] = 32'h...,在Verilog里是允许的,但如果你要移植到别的EDA工具或者做综合,层次引用不能用于综合,只能用于仿真。更好的做法是在顶层模块里加一个$readmemh的任务,或者把指令存储器相关内容做成可配置参数。

6.2 单指令逐周期调试

我调试多周期处理器时有一套固定流程,从最简单的指令开始,一步一步验证状态机。

第一步先跑一条add $3, $1, $2,也就是R型指令。在testbench里提前把寄存器1和寄存器2赋值,然后跑20个周期,看波形。我关心的关键点有几个:

  • state信号是否按预期从0走到5再回到0。
  • S_IF2周期的ALU输出是否等于PC+4。
  • S_EX周期的ALU输入是否为寄存器1和寄存器2的值。
  • S_WB周期结束后寄存器3是否变成了正确的相加结果。

如果每一步都对,R型指令的验证就通过了。这时候千万不要急着跑矩阵乘法之类的程序,先把最基础的路径验证通。

第二步跑lw $4, 12($5),这是一条涉及访存的指令。重点检查S_EX里ALU输出是否为5号寄存器的值加12,S_MEM里数据存储器的读地址是否正确,S_WB里写回寄存器的数据是否来自数据存储器。

第三步跑分支和跳转。这里我用一条非常短的程序来验证PC跳转行为,比如地址0放beq $0, $0, 4(无条件分支),地址4放一条add,然后看PC是不是跳到正确位置。这里最容易出的问题就是分支延迟槽或者PC多跳了一拍,一定要在波形图上仔细看PC在每个时钟沿的变化。

6.3 跑一段完整汇编程序

等单条指令全部调试完,我会把一串指令烧进去跑。下面这段小程序做的是:把两个数相加,把结果存到内存,再读回来,复制到另一个寄存器。

# 地址0: lw $1, 0($0) # 从内存地址0读数 # 地址1: lw $2, 4($0) # 从内存地址4读数 # 地址2: add $3, $1, $2 # 相加 # 地址3: sw $3, 8($0) # 存回内存 # 地址4: lw $4, 8($0) # 读回来 # 地址5: beq $0, $0, 0 # 死循环

对应的机器码和初始化数据:

initial begin uut.instr_mem[0] = 32'h8c010000; uut.instr_mem[1] = 32'h8c020004; uut.instr_mem[2] = 32'h00221820; uut.instr_mem[3] = 32'hac030008; uut.instr_mem[4] = 32'h8c040008; uut.instr_mem[5] = 32'h10000000; uut.data_mem[0] = 32'h00000005; uut.data_mem[1] = 32'h00000007; end

跑完这串程序后,看寄存器3应该是12,内存地址8应该是12,寄存器4也应该是12。如果你的波形里这些值都对了,说明你的多周期处理器已经可以完整执行数据通路通路型程序了。接着你就可以试试算阶乘、冒泡排序这类更复杂的程序。

6.4 波形排查的四个黄金检查点

调试多周期CPU,我总结出四个必须盯死的波形检查点,新手调不出来百分之八十是因为这四个点没检查仔细。

第一个是PC的更新时机。PC必须在正确的状态且信号正确的状态下更新。如果你发现PC连续两个周期都不变,或者PC变成了错误值,先查PCWrite和PCSource信号在S_IF2和S_EX的状态。

第二个是IR的锁存。IR必须在S_IF2结束时正确锁存指令。如果IR的值是X或者全0,说明IRWrite时序有问题,或者指令存储器的读地址不对。指令地址是PC的高位索引,如果PC值本身有问题,IR自然会出错。

第三个是ALU两个输入源。在S_EX周期,你得确认alu_src_a和alu_src_b是否正确。好多初学者调不出问题时,发现ALU在算R型指令时减法的操作数顺序反了,或者lw算地址时用了寄存器读数据2而不是立即数。

第四个是寄存器堆的写回。写回周期里RegWrite信号必须只在S_WB有效,写地址和数据来源要正确。注意寄存器堆是同步写,意味着S_WB周期给出写地址和写数据,真正写入要等到S_WB周期末的时钟上升沿,下一拍读出来才是新的值。

7. 我踩过的那些坑

7.1 悄悄出现的Latch:诡异值从哪来

刚写多周期处理器的时候,我遇到过一种很诡异的情况:仿真结果一开始正确,运行到后面几十个周期开始出现莫名其妙的X值,然后整个CPU就废了。排查了很久,最后发现是控制器的组合逻辑always块里,有些控制信号在部分case分支里没有赋值。Verilog对未赋值的reg变量会生成锁存器,锁存器会在条件不满足时保持旧值,这在时序逻辑里可能表现为诡异的中间状态。

解决方案就是我在控制器代码里写的那个默认赋零的段落:在每个always块的开始把所有输出信号全部赋默认值,然后在case的各个分支里只修改需要改变的信号。这个习惯可以确保所有分支都覆盖到所有信号赋值,不会再产生隐式锁存器。

另一个常见的Latch来源是数据存储器读口的写法。有些同学喜欢这样写:

always @(*) begin if (mem_write) data_mem[addr] = data; end

这绝对会综合出锁存器。数据存储器必须是时钟沿写入,读可以组合输出,但不能在组合逻辑里赋值。我给出的写法是always @(posedge clk)配合mem_write作为门控条件,这才是正确姿势。

7.2 控制器状态机漏了default

状态机不写default是很多初学者的通病。如果状态机的编码是三位二进制,实际有8个状态,但我只定义了6个,剩下两个状态如果因为复位异常或者信号毛刺跑进去,状态机就永远卡死在错误状态。

state和next_state的always块里都必须写default分支,把未定义状态引回S_IF1。我遇到过的情况是,仿真时因为给IR赋值的时序差了一拍,导致opcode解析到非法值,状态机的next_state计算逻辑在没有default时把X值传给了state,CPU直接死机。加了default之后,任何非法opcode都会回到取指重新开始,鲁棒性一下子提升了很多。

7.3 分支跳转的时序陷阱

beq指令的时序坑很多,我在这里重点说一个初学者必踩的:分支地址计算居然要用到PC+4,而PC+4是上一拍ALU算出来的结果,这一拍如果直接把alu_result当作用,就会拿错数据。

我建议的分支地址计算方式是把PC+4单独用寄存器锁存一拍。具体操作是在S_IF2结束时,PC+4的ALU结果已经稳定,用一个临时寄存器把PC+4存下来。到S_ID或者S_EX时,用这个寄存器里的PC+4加上立即数左移两位得到分支目标。

另外一个坑是PCWrite信号在分支指令时不能无条件拉高。只有比较结果为真(zero=1)才更新PC为分支目标;zero=0时PC应该继续走PC+4。我刚写控制器时直接pc_write=1; pc_source=2'b01,结果分支不成立也跳转了,完全是错的。正确逻辑是pc_write = (opcode == OP_BEQ) ? zero : pc_write;,在S_EX周期这个信号由zero值来门控。

7.4 存储器容量与地址位宽

指令存储器我定义成[1023:0],使用PC的[11:2]位做索引,这样最多支持1024条指令。如果你的程序超过这个大小,索引位就要加宽。数据存储器同理。这个坑不算深,但容易在程序变大时莫名其妙出现取指错误。我见过有人直接拿PC全32位去索引一个深度不够的存储器,最后高位被截断,程序跑飞后一脸茫然。

7.5 FPGA板级调试的速度差异

仿真通过之后上板调试,又有一批新坑等着你。最常见的是时钟问题:开发板的时钟一般是50MHz甚至更高,仿真里20ns的周期在板子上完全不可行。正确的做法是利用开发板自带的PLL或者时钟分频模块产生一个低速时钟给CPU用。

还有一个诡异现象:上板后程序跑到某一步突然卡死,仿真完全正常。这种问题大概率是异步复位没有同步化。在板级上,复位信号如果不做同步处理,可能在时钟边沿附近抖动,导致状态机进入非法状态。我的建议是复位信号先用两级寄存器打拍,或者直接用开发板的PLL锁定输出作为复位释放条件的成熟方案。

另外一个经常被忽视的问题是按键消抖。如果你用按键来作为单步时钟或者复位输入,不做消抖的话每按一次可能触发多个脉冲,PC会连续跳好几拍。用计数器消抖或者直接用现成的消抖IP是最省事的方案。

8. 从多周期到流水线的进阶之路

多周期处理器设计熟练之后,想进阶到流水线其实是顺水推舟的事。流水线本质上就是把多周期的每一个阶段做成独立的流水级,让不同指令的不同阶段并行执行。流水线里的数据冒险、控制冒险问题,在多周期里通常不需要处理,因为时间上已经天然隔离了,但到了流水线里就成了核心矛盾。

如果你做完了多周期处理器,我建议你按以下顺序拓展:

  • 加一条jal指令,学习怎么在数据通路里新增一条回写返回地址的逻辑。
  • 加一条jr指令,学会用寄存器里的值作为跳转地址。
  • 把乘法指令用多次移位-加法实现,体会一下“用周期换硬件”的另一种玩法。
  • 然后开始学五级流水线,把IF/ID/EX/MEM/WB每一级之间插上流水线寄存器,处理load-use冒险和分支预测。

我个人在实际操作中的体会是:多周期处理器设计最大的价值不在于“能跑”,而在于让你彻底理解“每个周期机器在干什么”以及“为什么需要这些控制信号”。很多人写完流水线处理器后对冒险处理一头雾水,回头再看多周期的状态机设计,就会豁然开朗——原来早期的RISC处理器设计者就是靠着这种精细的控制,在硬件极其有限的条件下榨出了最高的性能。先把这个基础打牢,后面的路会顺很多。

最后再分享一个实用技巧:调试多周期CPU时,别轻易相信第一版波形。先花十几分钟把每个状态对应的控制信号构成捋清楚,对照控制信号表一个个信号检查,发现问题再改代码重跑。这样才能在FPGA上少烧几次板子,也少掉几根头发。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进