☰
三级流水线CPU实战:Verilog数据通路、冒险处理与仿真验证
2026/10/20 6:22:07 网站建设 项目流程

把单周期CPU改成三级流水线之后,性能提升并没有想象中那么“魔法”。你只是把原本一口气干完的活拆成了三个工序,但如果三个工序之间的交接没设计好,流水线反而会越跑越乱,甚至跑出错误结果。这篇文章不打算从教科书的概念讲起,我直接拿一个可综合、可仿真的三级流水线CPU设计来拆解——包括数据通路的划分、流水线寄存器的定义、冒险处理的具体手段,以及一组能验证这些机制的测试程序。这个项目我之前完整写过,Verilog实现,十六位字长,八个通用寄存器,支持ALU运算、访存、分支和跳转,跑通仿真之后我才真正理解了教科书里“流水线冒险”到底在说什么。

1. 动手前的关键决策:为什么做三级流水线

1.1 三级到底把处理器切成了几块

单周期CPU的问题很直接:一条指令从取指、译码、执行到写回,全部在一个时钟周期内完成,这意味着时钟周期必须按最慢的指令来定。如果大部分指令只需要一半的时间,剩下那一半就是在空等。流水线的本质就是把这个长周期拆成多个短周期,让不同指令在不同的流水段并行工作。

三级流水线的切法非常经典:IF段负责取指令和更新PC,ID段负责译码、读取寄存器堆和生成控制信号,EX段负责运算、访存和分支跳转判断。整条流水线只在两个关键位置打拍:IF/ID之间和ID/EX之间。这两个寄存器组就是流水线的“交接区”,数据能不能正确传递,全看它们。

为什么不是五级?因为五级流水线把访存和写回拆成了独立的MEM和WB段。对于教学或验证流水线机制的实验而言,五级多出来的那些冒险处理逻辑很容易淹没主线。三级流水线结构紧凑,每段的事务边界清晰,做出来的CPU既保留了流水线的核心问题,又不会让代码体量失控。

另外,三级流水线还有一个非常实在的优点:分支指令在EX段就能出结果,跳转造成的损失只有一拍。五级流水线里分支判断往往要到第三或第四拍才出结果,惩罚周期明显更大,处理起来也更麻烦。所以如果目标是“用最少的代码理解流水线的核心机制”,三级是最合适的选择。

1.2 指令集缩水但五脏俱全

设计CPU时容易被“完整指令集”绑架,试图把所有指令都塞进去。实际上验证流水线只需要六类指令就足够:算术逻辑指令、立即数加法、读内存、写内存、条件分支、无条件跳转。

我用的是十六位指令字长,寄存器数量只做八个。指令格式分三种:

  • R型:opcode占4位,rs占3位,rt占3位,rd占3位,func占3位。
  • I型:opcode占4位,rs占3位,rt占3位,立即数占6位。
  • J型:opcode占4位,立即数占12位。

有人可能会问,为什么不用32位MIPS指令集?原因很简单:验证三级流水线机制时,字长和寄存器数量完全不影响核心逻辑。十六位编码让每条指令刚好占两个字节,PC每次加2,处理起来非常直观。如果你以后想把代码扩展成32位,真正要改的只是位宽和立即数扩展逻辑,流水线框架本身不需要动。

这套指令集虽然小,但包含了完整的数据通路类型:ALU指令需要寄存器读写和运算,访存指令需要计算地址和访问存储单元,分支指令需要比较和跳转地址计算,跳转指令需要直接改写PC。所有流水线相关的冒险场景——数据冒险、控制冒险、写后读冲突——都能在这个指令集里构造出来。

1.3 双沿写回:教学级流水线的惯用技巧

这是整个设计里最值得讲清楚的一个决策。寄存器堆的写回操作,我放在了时钟下降沿,而所有流水线寄存器的更新放在上升沿。这个看似取巧的做法,实际上直接消掉了流水线中最讨厌的RAW数据冒险。

为什么能消掉?考虑两条相邻指令:第一条是ADD r1,r2,r3,第二条是SUB r4,r1,r5。在普通的单沿设计里,ADD的结果要等下一个时钟上升沿才能写回寄存器堆,而SUB在同一个上升沿之后进入ID段读寄存器堆,读到的还是r1的旧值,于是数据就错了。

用双沿写回之后,时序变成这样:ADD在EX段计算出结果,时钟下降沿写回寄存器堆,时钟上升沿时SUB的译码结果才被锁存进ID/EX寄存器。也就是说,SUB在ID段读r1的时候,ADD已经完成了写回。只要写回的建立时间满足要求,指令间的相关数据就能被后续指令正确读到。

这个设计并不是我拍脑袋想出来的,很多教材版CPU为了规避数据前递的复杂逻辑,在实验课上都会采用这种“下降沿写回”策略。它的代价是写回路径的时序余量变小,实际流片时时钟频率会被压缩,但在仿真验证和FPGA教学场景下完全够用。如果你想做一个完全工业级的设计,可以把这个细节替换成真正的转发网络,后面我会单独讲。

2. 数据通路拆解与流水线寄存器设计

2.1 IF段:取指与PC更新

IF段的任务很单纯:根据PC从指令存储器取出指令,然后更新PC,准备取下一跳。但“更新PC”在流水线里并不是无脑加2,它要处理三种情况:顺序执行时PC加2,分支跳转时PC等于分支目标地址,跳转指令时PC等于跳转目标地址。

PC的更新逻辑写起来不长,但优先级要理清楚:

reg [15:0] pc; always @(posedge clk or negedge rst_n) begin if (!rst_n) pc <= 16'h0000; else if (branch_taken) pc <= id_ex_branch_target; else if (id_ex_jump) pc <= {pc[15:12], id_ex_jump_target}; else pc <= pc + 16'h0002; end

注意跳转地址的拼接方式:J型指令只给出了低12位,高4位直接沿用当前PC的高4位。这是最简单的跳转寻址方式,省掉了复杂的地址计算。分支目标地址的算法规格是:当前指令地址加2,再加符号扩展后的立即数左移一位,这样可以让分支跳转的范围更大一些。

指令存储器的读取是组合逻辑,用Verilog数组加wire连续赋值就能实现:

reg [15:0] inst_mem [0:255]; wire [15:0] inst = inst_mem[pc[15:1]];

PC是字节地址,每条指令占两个字节,所以取指令数组索引时要把最低位去掉。这里的组合读时序很好理解:PC一旦变化,inst马上跟着变化,然后等待上升沿把指令锁存进IF/ID寄存器。

2.2 ID段:译码、控制信号与寄存器堆

ID段是整个CPU的“大脑中枢”。它在每个周期拿到IF/ID寄存器里锁存的指令,完成三件事:解析指令类型、读取寄存器堆、生成控制信号。

寄存器堆设计成两读一写,两个读端口都是异步读:

reg [15:0] regfile [0:7]; wire [15:0] rs_data = regfile[if_id_inst[11:9]]; wire [15:0] rt_data = regfile[if_id_inst[8:6]];

写端口放在时钟下降沿,写地址和写数据来自ID/EX寄存器,也就是说写回操作是针对“当前正在EX段执行的那条指令”。这个设计让写回天然延后了半个周期,配合上一节说的双沿调度,彻底绕开了数据冒险。

控制信号的生成是一个大case语句,根据opcode输出所有控制信号:

reg reg_write, mem_write, mem_to_reg, alu_src, branch, jump; reg [2:0] alu_op; always @(*) begin {reg_write, mem_write, mem_to_reg, alu_src, branch, jump} = 7'b0; case (if_id_inst[15:12]) 4'b0001: begin // R型 reg_write = 1'b1; alu_op = if_id_inst[2:0]; end 4'b1001: begin // ADDI reg_write = 1'b1; alu_src = 1'b1; alu_op = 3'b000; end 4'b1010: begin // LW reg_write = 1'b1; alu_src = 1'b1; mem_to_reg = 1'b1; alu_op = 3'b000; end 4'b1011: begin // SW alu_src = 1'b1; mem_write = 1'b1; alu_op = 3'b000; end 4'b1100: begin // BEQ branch = 1'b1; alu_op = 3'b001; end 4'b1110: jump = 1'b1; endcase end

这里有个容易被忽略的细节:R型指令的目的寄存器是rd字段,而I型指令的目的寄存器是rt字段。所以需要额外生成一个写回寄存器地址的选择信号:

wire [2:0] id_rd_addr = (if_id_inst[15:12] == 4'b0001) ? if_id_inst[5:3] : if_id_inst[8:6];

如果不做这个选择,ADDI和LW会把结果写到错误的位置,而且这种错误极其隐蔽,波形上看运算结果全对,但寄存器值就是不对。

2.3 EX段:ALU、访存、分支判定三合一

EX段是三级流水线里最忙的一段。它接收ID/EX寄存器送来的数据,完成算术逻辑运算、数据存储器访问、分支条件判断和跳转目标计算。

ALU的设计相对直白:

wire [15:0] alu_src2 = id_ex_alu_src ? id_ex_imm_ext : id_ex_rt_data; reg [15:0] alu_result; always @(*) begin case (id_ex_alu_op) 3'b000: alu_result = id_ex_rs_data + alu_src2; 3'b001: alu_result = id_ex_rs_data - alu_src2; 3'b010: alu_result = id_ex_rs_data & alu_src2; 3'b011: alu_result = id_ex_rs_data | alu_src2; 3'b100: alu_result = ($signed(id_ex_rs_data) < $signed(alu_src2)) ? 16'h0001 : 16'h0000; endcase end

数据存储器的实现同样采用异步读、时钟下降沿同步写:

reg [15:0] data_mem [0:255]; wire [15:0] mem_rdata = data_mem[alu_result[15:1]]; always @(negedge clk) begin if (id_ex_mem_write) data_mem[alu_result[15:1]] <= id_ex_rt_data; end

分支判定在EX段用一组等值比较完成。两个操作数相等即跳转:

wire branch_taken = id_ex_branch && (id_ex_rs_data == id_ex_rt_data);

分支目标地址需要在指令进入EX段之前就算好,否则来不及在同一个周期更新PC。我在ID段就把PC+2和立即数扩展结果一起锁存到ID/EX寄存器,EX段只管做加法和左移:

assign id_ex_branch_target = id_ex_pc_add2 + {{6{id_ex_imm[5]}}, id_ex_imm, 1'b0};

2.4 流水线寄存器位宽规划表

设计流水线寄存器时,最忌讳边写代码边加字段,很容易漏掉某个关键信号。我建议先把位宽列成一张表再动手。

信号分组来源段用途位宽
instIF交给ID段译码的原始指令16
pc_add2IF保存指令地址加2,供分支计算16
rs_dataID寄存器堆rs端口读出数据16
rt_dataID寄存器堆rt端口读出数据16
imm_extID立即数符号扩展后的值16
rd_addrID写回目的寄存器号3
control_bitsIDreg_write、mem_write、mem_to_reg、alu_src、branch、jump等6
alu_opIDALU功能码3

IF/ID寄存器总计保存32位,ID/EX寄存器总计保存60位左右。每个信号在锁存时采用统一模板:

always @(posedge clk or negedge rst_n) begin if (!rst_n) begin id_ex_rs_data <= 16'b0; id_ex_rt_data <= 16'b0; id_ex_imm_ext <= 16'b0; id_ex_rd_addr <= 3'b0; id_ex_reg_write <= 1'b0; // 其余信号清零 end else if (flush) begin // 清空为气泡 id_ex_reg_write <= 1'b0; id_ex_mem_write <= 1'b0; id_ex_branch <= 1'b0; id_ex_jump <= 1'b0; id_ex_rs_data <= 16'b0; id_ex_rt_data <= 16'b0; end else begin // 正常锁存 end end

flush信号的作用在下一节详细展开。

3. 冒险处理与控制信号设计

3.1 数据冒险:负沿写回怎么把坑填了

教科书上讲到数据冒险,一般会列出RAW、WAR、WAW三种。实际上三级流水线里真正需要担心只有RAW,因为写回只发生在一处,指令间不存在WAW冲突;寄存器堆读端口在ID段,写端口在EX段末尾,也不存在WAR。

RAW冲突的场景是这样的:第一条指令add r1,r2,r3,第二条指令sub r4,r1,r5。在普通设计里,第二条指令进入ID段读取寄存器堆时,第一条指令的结果还没有写回,r1还是旧值,sub算出来的结果就是错的。

我的做法是负沿写回。整个时序过程很有意思,你可以跟着走一遍。假设上升沿到来后,ADD进入EX段,SUB进入ID段。此时SUB在组合逻辑中读到的寄存器r1确实还是旧值,但这个值还没有被锁存进ID/EX寄存器,只是停留在线上。接着时钟下降沿到来,ADD的结果写入寄存器堆r1,组合逻辑的读端口立刻反映出新值。等到下一个上升沿,SUB的译码结果锁存进ID/EX寄存器,此时读到的已经是ADD写入的新值。

这相当于把“读取”和“锁存”两个动作错开到了不同的时刻。代价是ID/EX寄存器的锁存沿必须比寄存器堆的写沿晚半个周期,也就是电路设计里说的建立时间窗口要压缩。仿真上完全没问题,FPGA上跑起来也稳。

但这里有个新问题:如果执行的是LW指令,数据存储器的读出结果什么时候能拿到?LW在EX段完成地址计算和数据读取,mem_rdata是组合输出的,它在下降沿之前就已经稳定,随后在下降沿写入寄存器堆。整个过程并没有引入额外的流水段,所以三级结构依然成立。

3.2 控制冒险:分支和跳转的冲刷逻辑

控制冒险来自分支和跳转指令改变了PC的取值。在三级流水线里,分支在EX段才判定,等到判定结果出来时,后续两条指令已经分别进入了ID段和IF段。

我的处理方式是:检测到分支跳转或跳转指令时,在下一个上升沿同时清空IF/ID寄存器和ID/EX寄存器,并把PC更新为目标地址。被清空的那一拍在流水线里就是“气泡”,它不产生任何写操作,也不影响寄存器状态。

冲刷逻辑的实现非常简洁:

wire flush = branch_taken || id_ex_jump;

清空动作在IF/ID和ID/EX寄存器模块里做。注意IF/ID的冲刷会让当前已取到的下一条指令作废,ID/EX的冲刷会让当前正在ID段译码的指令作废。这两条指令都是分支指令之后不该执行的,必须一起丢弃。

这里有一个初学者容易犯的错:只清空IF/ID寄存器,不清空ID/EX寄存器。结果就是分支后面的第二条指令还是被执行了,仿真波形看起来像“多执行了一条指令”,排查半天才发现是冲刷不完整。

采用EX段判定分支的代价是每条分支和跳转指令都要损失一个周期。对于教学CPU来说完全能接受。如果想把分支损失降为零,可以把比较逻辑挪到ID段提前判定,但那样需要在ID段加独立的比较器,控制逻辑会复杂不少。

3.3 控制信号真值表与流水线气泡

把上一节控制单元的输出整理成一张真值表,写代码时可以逐项对照。

opcode指令reg_writealu_srcmem_writemem_to_regbranchjump
0001R型100000
1001ADDI110000
1010LW110100
1011SW011x00
1100BEQ0x0x10
1110JMP0x0x01

密密麻麻的控制信号里最容易出错的是默认值。我的写法是start每周期先把所有控制信号清零,再根据opcode单独置位。这样未识别的指令会退化成“什么都不做”的气泡,不会触发意外的寄存器写或内存写。

flush之后,本来应该在EX段生效的控制信号全部归零,等效于向流水线插入了一个空操作指令。空操作不会写寄存器堆,不会写内存,AC跳转信号也是无效的,所以流水线能在跳转后干净地继续运行。

4. 核心代码实现与仿真验证

4.1 顶层数据通路的Verilog框架

把所有模块拼装起来后,顶层模块的框架大致是这样的:

module cpu_pipeline( input wire clk, input wire rst_n, output wire [15:0] debug_pc, output wire [15:0] debug_r1, output wire [15:0] debug_r2 ); // IF段 wire [15:0] inst; wire [15:0] pc_add2 = pc + 16'h0002; // IF/ID寄存器 reg [15:0] if_id_inst; reg [15:0] if_id_pc_add2; // ID段 wire [15:0] rs_data = regfile[if_id_inst[11:9]]; wire [15:0] rt_data = regfile[if_id_inst[8:6]]; wire [15:0] imm_ext = {{10{if_id_inst[5]}}, if_id_inst[5:0]}; wire [2:0] rd_addr = (if_id_inst[15:12] == 4'b0001) ? if_id_inst[5:3] : if_id_inst[8:6]; // 控制信号生成 // ... // ID/EX寄存器 reg [15:0] id_ex_rs_data; reg [15:0] id_ex_rt_data; reg [15:0] id_ex_imm_ext; reg [15:0] id_ex_pc_add2; reg [2:0] id_ex_rd_addr; reg [2:0] id_ex_alu_op; reg id_ex_reg_write, id_ex_mem_write, id_ex_mem_to_reg; reg id_ex_alu_src, id_ex_branch, id_ex_jump; // EX段 assign branch_target = id_ex_pc_add2 + {{6{id_ex_imm_ext[5]}}, id_ex_imm_ext[5:0], 1'b0}; wire branch_taken = id_ex_branch && (id_ex_rs_data == id_ex_rt_data); wire flush = branch_taken || id_ex_jump; // 写回 wire [15:0] write_data = id_ex_mem_to_reg ? mem_rdata : alu_result; // 调试输出 assign debug_pc = pc; assign debug_r1 = regfile[1]; assign debug_r2 = regfile[2]; endmodule

关键是各段信号的命名要一眼看出它属于哪一段。我习惯用if_id_前缀和id_ex_前缀区分流水线寄存器内的信号,直接读代码就能追踪数据是哪个阶段产生的。

4.2 Testbench设计与测试程序

测试程序是验证流水线正确性的核心资产。普通单周期CPU随便几条指令就能测完,但流水线必须构造特殊指令序列把冒险逼出来。

我给出一段测试程序,十六进制指令机器码直接初始化指令存储器:

initial begin inst_mem[0] = 16'b1001_000_001_000001; // ADDI r1, r0, 1 inst_mem[1] = 16'b1001_000_010_000010; // ADDI r2, r0, 2 inst_mem[2] = 16'b0001_001_010_011_000; // ADD r3, r1, r2 inst_mem[3] = 16'b0001_011_010_100_001; // SUB r4, r3, r2 inst_mem[4] = 16'b1100_001_100_000100; // BEQ r1, r4, 4 inst_mem[5] = 16'b1001_000_101_110010; // ADDI r5, r0, 100 (应该被冲刷) inst_mem[6] = 16'b1011_000_101_000000; // SW r5, [r0+0] inst_mem[7] = 16'b1010_000_110_000000; // LW r6, [r0+0] inst_mem[8] = 16'b1110_000000000000; // JMP 0 // 分支目标 0x12 对应数组索引 8 end

这段程序的逻辑设计是有讲究的。前两条ADDI写r1和r2,第三条ADD读这两个寄存器,构造了远程依赖。第四条SUB读r3,而r3是上一条ADD的结果,构造了最紧的指令相关。BEQ比较r1和r4,预期两者相等跳转,覆盖分支冒险。跳转目标处执行SW和LW,验证访存指令的读写。最后用JMP回跳形成循环。

Testbench的写法不需要太复杂,跑足够的周期,打印关键寄存器即可:

integer i; initial begin clk = 0; rst_n = 0; #10 rst_n = 1; for (i = 0; i < 200; i = i + 1) begin #5 clk = 1; #5 clk = 0; end $display("r1=%d r2=%d r3=%d r4=%d r5=%d r6=%d", top.regfile[1], top.regfile[2], top.regfile[3], top.regfile[4], top.regfile[5], top.regfile[6]); $finish; end

4.3 仿真结果的预期值与对照

运行仿真后,预期的寄存器状态非常明确:

寄存器预期值说明
r11ADDI写入
r22ADDI写入
r33ADD计算结果
r41SUB计算结果,验证紧邻相关
r5100SW执行成功,说明分支冲刷没有误杀该指令
r6100LW读回存储器的值

我个人调试时习惯在仿真波形里额外观察PC和流水线寄存器的变化。重点看几个位置:

  • 分支指令进入EX段时,下一个周期PC是否跳到了目标地址。
  • flush信号拉高后,ID/EX寄存器里的控制信号是否瞬间清零。
  • SUB指令的rs_data在锁存之前是否已经更新成ADD的结果。

如果这些波形都对,那流水线的核心机制基本就验证完了。剩下的细节问题会在下一节总结。

5. 调试心得与踩坑实录

5.1 最容易翻车的6类问题

第一个坑是复位状态不彻底。如果流水线寄存器和寄存器堆的复位初始化不一致,仿真刚开始的前几条指令会读出不定值,后续所有计算结果都被污染。我踩过最狠的一次是忘记初始化寄存器堆,导致某个寄存器的值一直显示成X,查了两天才发现是初始状态的问题。

第二个坑是立即数符号扩展的位宽写错。十六位CPU的立即数只有6位,符号扩展要用高位的符号位补齐到16位。短路一下没关系,但如果你想移植到32位环境,这里的扩展位很容易写错,而且错一个bit之后行为完全不可预测。

第三个坑是控制信号漏初始化。我用的是“先清零再置位”的模板,但如果你不是这样写,而是直接在case里逐个赋值,很容易在某条指令路径里漏掉某个信号。这个错误最恶心的地方在于仿真结果大多数时候是对的,只在特定指令组合下出错。

第四个坑是访存操作和写回操作竞争。LW执行时读取数据存储器,同一条指令的结果要写回寄存器堆。如果寄存器堆采用上升沿同步写,那么写回和数据读取就会踩在同一个沿上,产生竞争。我的做法是统一用下降沿写回,从根本上避开这个冲突。

第五个坑是分支目标地址算错。分支指令的目标地址是当前指令地址加2再加偏移,偏移是立即数左移一位。很多人会忘记左移,或者忘了加2,导致分支跳到一个奇怪的位置。最好是用测试程序里事先算好的地址来验证。

第六个坑是冲刷信号只在正沿有效。flush信号在EX段的组合逻辑中计算出来,它在上升沿之前必须已经稳定。如果你在testbench里正好在沿前后改变信号,仿真结果可能出现微小的时序差。实际使用时我统一加了非阻塞赋值,确保所有周期行为稳定。

5.2 从三级往五级扩展要注意什么

三级流水线跑通后,再去看五级流水线会容易很多,因为核心的流水线寄存器思想和冒险处理思路完全一致。五级在EX和WB之间插入了MEM段,把访存操作独立出来。数据冒险处理方式要从“双沿写回”换成“转发网络”,也就是把EX段的运算结果直接旁路给后续指令的输入,同时还要在加载指令后面插入气泡。

扩展时最值得借鉴的就是我的流水线信号命名习惯。每个信号带上阶段前缀,会让信号在波形图上的归属一目了然。你加了MEM段之后,信号前缀自动变成id_ex、ex_mem、mem_wb三组,代码可读性依然很高。

转发网络的关键是建立“源跟踪”和“目标跟踪”两张表:每个流水线寄存器要记录它携带的寄存器号,后续指令读寄存器时,要与尚未写回的指令的寄存器号做匹配。匹配到了就用旁路的运算结果,而不是寄存器堆的旧值。

5.3 给后续调试留一条后路

最后分享一个小技巧,是我做CPU实验时反复验证过的做法:在顶层模块里给寄存器堆加一个调试用的读端口,把关键寄存器的值直接引出到模块外部。

assign debug_r1 = regfile[1]; assign debug_r2 = regfile[2]; assign debug_pc = pc;

这段代码不参与流水线逻辑,但对仿真和板级验证的帮助非常大。你可以随时在testbench里打印这些值,快速判断CPU跑到哪里出了问题,不用每次都在波形里一层层翻找信号。后续想扩展调试接口时,这个后路也能省下不少力气。

做流水线CPU最忌贪多求全,先把三级跑通,把冒险机制彻底理解透,再往五级走就会顺很多。这套代码我没有做任何性能优化,但它把流水线的机制跑得明明白白,该有的问题一个不落。你自己动手跑一遍仿真,亲手把那些坑都踩一遍,再回头看教科书上的流水线图解,会有完全不一样的感觉。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询