ARTICLE DETAIL

资讯详情

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

五级流水线CPU设计实战:从数据通路到FPGA实现

五级流水线CPU设计实战:从数据通路到FPGA实现 简介这套基于FPGA的五级流水线CPU课程设计资料面向计算机、电子信息类专业学生适合课程设计、综合实践或项目前期验证。源码已在Windows、macOS与Linux平台运行通过包含Verilog设计文件、仿真激励、工程配置以及实验报告、答辩PPT和使用说明可帮助读者从寄存器传输级实现走向仿真与上板验证。压缩包共25.42MB、94个文件覆盖源代码、仿真用例、工程过程数据和多种格式的说明文档分类清晰便于检索。目前已有138人学习源码中各级流水线模块划分明确并对流水线控制、数据冒险等关键环节给出可参考实现既可在现有代码上扩展功能也能直接作为课设或项目演示交付。整体看这份资料既适合理解CPU设计与FPGA流程的入门者也为高年级课设提供了经过验证的完整参考。1. 五级流水线CPU课程设计先想清楚交付什么计算机组成原理课上五级流水线是教材里的标准章节落到 FPGA 上却不是把图画出来就完事。很多人在 fpga 入门阶段选这个课设一上来就写 ALU、写寄存器堆等到 Vivado 综合出来时序一团糟才意识到问题出在指令集没定、级间寄存器没切、冒险处理没规划。这个题目的本质不是“做一个能跑几条指令的 CPU”而是“用可综合的 RTL 把数据通路、控制信号、流水线寄存器、存储器访问完整对齐”。反直觉的地方在于测试程序机器码必须和 CPU 一起设计指令格式决定了译码逻辑译码逻辑决定了转发和暂停的复杂度。它面向的读者是正在做数字逻辑或组成原理课设、以及想把流水线概念变成真实可测模块的人交付物除了源码还必须包括仿真脚本、十六进制机器码、上板约束和设计说明书——这些在评阅时往往比 RTL 本身更决定分数。2. 五级流水线CPU的架构拆解从指令集到数据通路2.1 五级流水线每一级做什么IF/ID/EX/MEM/WB的边界五级流水线把一条指令分成取指IF、译码ID、执行EX、访存MEM、写回WB五个阶段每个阶段之间用流水线寄存器保存中间结果。IF 级的任务是根据 PC 从指令存储器读指令并计算下一条 PCID 级从指令字段里拆出操作码、寄存器号、立即数读取寄存器堆生成 ALU 控制信号EX 级完成加减、逻辑运算以及分支条件的判断MEM 级只对lw/sw这类访存指令操作数据存储器其他指令直接透传WB 级把 ALU 结果或读出的存储器数据写回寄存器堆。边界划分的关键在于“每一级只接触本级需要的信号”。比如sw指令在 EX 级结束时就要把“要写入存储器的那份数据”送到 EX/MEM 寄存器里而不是等到访存时再去寄存器堆读否则端口就会冲突。同样分支比较可以放在 EX 级也可以放在 ID 级放 ID 级能提前三拍决定跳转但需要额外增加比较器资源。课程设计为了控制信号清晰通常把分支判断放在 EX 级代价是分支跳转固定带来 3 个周期的气泡。如下表是各级职责和关键信号的最小集合流水级完成动作输入信号输出信号IF取指、更新 PCclk, rst_n, stall, branch_taken, branch_targetinst, pc_outID译码、读寄存器堆、生成控制信号inst, pc_plus4, wb_rd, wb_reg_write, wb_datareg_data1, reg_data2, imm, alu_op, mem_write, mem_to_reg, reg_writeEXALU 运算、分支判断reg_data1, reg_data2, imm, alu_op, fwd_a, fwd_balu_result, branch_taken, mem_write_dataMEM数据存储器读写alu_result, mem_write_data, mem_writemem_read_data, alu_result 透传WB写回寄存器堆alu_result, mem_read_data, mem_to_regwrite_data, rd_addr这里的mem_to_reg和reg_write必须从 ID 级开始逐级透传不能只在某一级产生后就丢弃。常见错误是把reg_write只在 EX 级用组合逻辑算出来导致写回端口在时序上不整齐。正确做法是把这些控制信号捆进对应的流水线寄存器里随指令一起向后移动。2.2 数据通路与流水线寄存器划分流水线寄存器是五级流水线里最容易写错的部分因为位宽不但包括数据还包括所有要透传的控制信号。以 IF/ID 寄存器为例它至少需要保存pc_out和inst两部分ID/EX 寄存器需要保存 PC4用于分支目标、寄存器堆读出的两个数据、立即数、以及译码产生的全部控制信号EX/MEM 保存 ALU 结果、写寄存器号、mem_write和reg_writeMEM/WB 保存访存读出的数据和写寄存器号。每个流水线寄存器在时钟上升沿锁存复位信号必须异步复位到 0否则上板后第一次取指会采到随机态。// IF/ID 流水线寄存器注意控制信号随指令一起锁定 module if_id_pipe( input wire clk, input wire rst_n, input wire stall, input wire [31:0] inst_in, input wire [31:0] pc_in, output reg [31:0] inst_out, output reg [31:0] pc_out ); always (posedge clk) begin if (!rst_n) begin inst_out 32h0; pc_out 32h0; end else if (!stall) begin // stall1 时整个 IF/ID 冻结 inst_out inst_in; pc_out pc_in; end end endmodule这段 RTL 里stall信号同时控制取指使能。当 load-use 冲突发生时ID/EX 后方的指令不能停停在 IF/ID 这一级让指令在 ID 级被重复译码。stall为高时 IF/ID 寄存器保持原值同时 PC 也保持不变而 ID/EX 寄存器被插入空指令所有控制位为 0这样下一拍 EX 级执行的就是一条nopload 的数据刚好在 MEM/WB 级可被转发。如果只冻结 IF/ID 而不插入气泡同一条指令会被执行两次。2.3 三类冒险的处理顺序流水线冒险分三类数据冒险、控制冒险、结构冒险。结构冒险指的是同一硬件资源被多级同时使用课程设计里最典型的是“指令存储器和数据存储器共用一块 BRAM”。解决办法很简单程序放在 ROM 或者单独用一块 BRAM 存只读指令数据用另一块 BRAM。板上资源在几万 LUT 级别的 FPGA 上完全够用不必做哈佛总线合并。数据冒险通过转发forwarding解决掉大部分再对 load-use 加一个气泡控制冒险在这套设计里表现为分支指令采用“分支在 EX 级 flush 3 条指令”的方案。flush 的本质是把流水线寄存器里的指令作废通常是给 IF/ID 和 ID/EX 的复位端加一个异步清零脉冲而不是让 PC 跳一下就算完。3. 用 Verilog 把五级流水线CPU写到FPGA上模块划分与参数设置3.1 指令集选择与指令存储器配置课程设计最稳的指令集是 MIPS 风格的整数子集理由是指令格式规整R 型、I 型、J 型三类字段位置固定译码逻辑可以用 case 直接覆盖。建议最小指令集包含add、sub、and、or、sll、lw、sw、beq这 8 条再多加一条j就能跑通冒泡排序这类演示程序。RISC-V 的 RV32I 也很适合改写格式不同但流水线框架完全一样如果题目允许自选RISC-V 在评阅时更容易讲出新意因为课程设计里绝大多数人会选 MIPS。指令存储器用 Vivado 里的 Block Memory Generator 生成单端口 ROM 是最省事的做法读延迟配置直接影响时序。ROM 读出的数据在地址输入后的同一个时钟沿有效Vivado 的 Block ROM 默认无输出寄存器也就是组合读延迟为 0IF 级在一个周期内就能拿到指令。若在 IP 核里打开了输出寄存器Primitives Output Register读出会晚一拍所有指令的 PC 对不齐这是无数人踩过的坑。生成 ROM 时把位宽设 32深度按程序大小选 1024寄存器堆用 LUT 搭的普通always块写不占 BRAM。3.2 译码器与控制信号真值表译码模块的核心是一张控制信号真值表写 RTL 前先画这张表比直接写代码有效率得多。每个控制信号都要考虑两条规则一是未使用的指令位必须给确定值防止 latch二是所有指令默认值从复位态开始推。下面的真值表覆盖了最小指令集指令opcode[5:0]funct[5:0]RegDstALUSrcMemtoRegRegWriteMemReadMemWriteALUOpadd00000010000010010010sub00000010001010010010and00000010010010010010or00000010010110010010sll00000000000011010011lw100011无01111000sw101011无无1无00100beq000100无无0无00001ALUOp 是两位编码在 EX 级根据 funct 或 opcode 再展开成 ALU 的实际操作。把 ALUOp 设成两位而不是直接给四位 ALU 控制信号是为了让控制信号表更短也便于后续在 EX 级扩展指令。译码模块用always (*)加case (opcode)实现default 分支必须把所有输出赋为 0并且RegWrite这类信号不许出现不定态。3.3 转发单元与暂停逻辑load-use冲突的代码实现转发单元连接 EX/MEM、MEM/WB 两级流水线寄存器与 ID/EX 的源操作数比较条件只有三个等式EX/MEM 的目标寄存器是否等于当前 ID/EX 的 rs1/rs2以及 MEM/WB 的目标寄存器是否等于 ID/EX 的 rs1/rs2。比较时还要排除写寄存器号为 0 的情况因为$0恒为 0 不允许被覆盖。优先级上 EX/MEM 比 MEM/WB 更近因为前者数据更新鲜。// EX 级源操作数转发选择 wire [1:0] forward_a; wire [1:0] forward_b; assign forward_a (ex_mem_regwrite (ex_mem_rd ! 5b0) (ex_mem_rd id_ex_rs)) ? 2b10 : (mem_wb_regwrite (mem_wb_rd ! 5b0) (mem_wb_rd id_ex_rs)) ? 2b01 : 2b00; assign forward_b (ex_mem_regwrite (ex_mem_rd ! 5b0) (ex_mem_rd id_ex_rt)) ? 2b10 : (mem_wb_regwrite (mem_wb_rd ! 5b0) (mem_wb_rd id_ex_rt)) ? 2b01 : 2b00; assign alu_src_a (forward_a 2b10) ? ex_mem_alu_result : (forward_a 2b01) ? mem_wb_write_data : id_ex_reg_data1;参数说明forward_a的 2b10 表示选 EX/MEM 级的 ALU 结果2b01 表示选 MEM/WB 级的写回数据2b00 表示直接用 ID 级读出来的寄存器值。注意ex_mem_rd是指令的目标寄存器号它在整个 EX/MEM 流水线寄存器里与 ALU 结果同步移动而不是从当前寄存器堆端口反推。判断ex_mem_rd ! 5b0这行不能省否则beq $0, ...这类指令会把寄存器堆 0 号口的写回路径错误转发。load-use 暂停逻辑需要在 ID 级检测当前 ID/EX 寄存器里的指令是lw且它的目标寄存器是 IF/ID 里指令的源寄存器之一。检测条件只需要两个等式但注意要在转发之前判断因为lw的数据在 MEM 级末段才可用转发来不及。// 在 ID 级产生暂停信号 wire id_ex_memread id_ex_ctrl_memread; wire stall_load_use id_ex_memread ((id_ex_rt if_id_rs) || (id_ex_rt if_id_rt)); assign stall stall_load_use;当stall为高时IF/ID 寄存器保持ID/EX 寄存器清空成 nopPC 寄存器保持。清空 ID/EX 的方式是给它单独一个 flush 输入在always里判断stall || flush时把所有输出置 0。load-use 只会让流水线停一拍后续数据传播由转发解决代价远小于把所有冒险都转成暂停。3.4 存储器访问与写回BRAM的读延迟是关键参数数据存储器用单端口 BRAM写使能由 EX 级mem_write在 MEM 级生效。关键点是 Vivado 的 Block RAM 写时序在时钟上升沿如果we1din写入addr指定的地址同一时钟沿dout输出的是该地址的旧数据。这意味着lw在 MEM 级读出的数据不是在 MEM 级周期开始时可用而是在 MEM 级结束时才锁进 MEM/WB 寄存器WB 级写回正好赶上。如果使用分布式 RAMLUT RAM替代输出寄存器的行为读数据会提前半个周期仿真容易产生毛刺。写回模块非常短但有一个高频错误寄存器堆的写端口必须只在 WB 级时钟沿写入不能用组合逻辑把write_data直接塞给reg_file。下面是常见的寄存器堆实现注意we来自 MEM/WB 流水线寄存器的regwrite信号而不是译码级的regwrite。module regfile #(parameter AW 5, DW 32) ( input wire clk, input wire we, input wire [AW-1:0] raddr1, input wire [AW-1:0] raddr2, input wire [AW-1:0] waddr, input wire [DW-1:0] wdata, output wire [DW-1:0] rdata1, output wire [DW-1:0] rdata2 ); reg [DW-1:0] regs [0:DW-1]; assign rdata1 (raddr1 5b0) ? 32b0 : regs[raddr1]; assign rdata2 (raddr2 5b0) ? 32b0 : regs[raddr2]; always (posedge clk) begin if (we (waddr ! 5b0)) regs[waddr] wdata; end endmodule参数说明raddr1/raddr2来自 IF/ID 寄存器的 rs、rt 字段waddr/wdata来自 MEM/WB 寄存器。读端口是组合逻辑不消耗时钟周期但如果遇到 forwarding 需要把转发数据直接接在写数据通路上要保证转发优先级高于寄存器堆本身的延迟否则会读到旧值。写回数据选择用mem_to_reg控制为 1 时选mem_read_datalw结果为 0 时选alu_result。4. 从仿真到上板Vivado/ModelSim下五级流水线CPU的调试流程4.1 生成测试程序与hex文件写汇编程序后手工转换成机器码容易错最可靠的做法是用 Python 脚本生成 hex。先把 8 条指令的编码规则写进字典再写一个小汇编器输出 Vivado 的 ROM 初始化文件.coe和 ModelSim 的$readmemh文件。下面脚本生成一段计算12...10的循环程序覆盖addi/lw/sw/beq和跳转。#!/usr/bin/env python3 # 最小汇编器输出 30 行以内的 .coe 与 .mem 文件 insts [] def r_type(op, rs, rt, rd, shamt0, funct0): return (op 26) | (rs 21) | (rt 16) | (rd 11) | (shamt 6) | funct def i_type(op, rs, rt, imm): return (op 26) | (rs 21) | (rt 16) | (imm 0xFFFF) # addi $t0, $0, 0 ; sum 0 insts.append(i_type(0b001000, 0, 8, 0)) # addi $t1, $0, 10 ; loop counter 10 insts.append(i_type(0b001000, 0, 9, 10)) # loop: add $t0, $t0, $t1 insts.append(r_type(0, 8, 9, 8, 0, 0b100000)) # addi $t1, $t1, -1 insts.append(i_type(0b001000, 9, 9, -1)) # bne $t1, $0, loop ; 跳转偏移 -4 (3 条指令之外无气泡) insts.append(i_type(0b000101, 9, 0, -4)) # sw $t0, 0($0) insts.append(i_type(0b101011, 0, 8, 0)) with open(prog.mem, w) as f: for i in insts: f.write(f{i:08x}\n) with open(prog.coe, w) as f: f.write(memory_initialization_radix16;\n) f.write(memory_initialization_vector\n) f.write(,\n.join(f{i:08x} for i in insts) ;\n)逻辑说明r_type和i_type两个函数把汇编助记符翻译成 32 位指令字opcode、rs、rt、rd、imm 的位置都在函数参数里写死。bne的跳转偏移用 PC 相对寻址第 3 行指令地址是 8bne自身地址是 16跳转到 8 的偏移是(8-16)/4 -2这里写成-4是因为bne在 EX 级跳转时 PC 已经指向PC4即地址 20因此差值要按 20 计算。动手做的时候先跑一遍不带分支的简单add程序验证 RTL再上分支循环不要把两种问题混在一起调。4.2 波形仿真里看流水线行为ModelSim 或 Vivado Simulator 里建 testbench实例化 CPU 后先置复位 100ns再释放复位。仿真脚本用add wave -radix hex把 PC、各级流水寄存器的inst、控制信号全部拖出来特别要看stall和forward_a/forward_b有没有按预期拉高。load-use 场景做一条lw $t0, 0($0)后紧跟add $t1, $t0, $t2在波形里应该看到stall在当前周期拉高一个时钟同时add在 ID/EX 级被替换为全零指令再下一拍forward_a变成 2b10。# ModelSim 批处理仿真命令 vlib work vlog -f filelist.f vsim -c work.tb_cpu5 -do add wave -radix hex /tb_cpu5/u_cpu5/*; run 2000ns; q参数说明filelist.f里按顺序列出所有 RTL 文件路径u_cpu5是 testbench 顶层下的 CPU 实例路径。run 2000ns跑完一个短程序的完整过程如果程序有死循环用run -all会停不下来改 run 固定时间更稳妥。波形里最容易确认的冒烟点是 PC 是否按0,4,8,c,...增长以及分支跳转发生后 PC 是否落在目标地址。若 PC 出现重复值或跳变到0x??????先把 ROM 内容和复位时序检查一遍再看分支比较逻辑。4.3 上板验证与常见故障排查综合和实现阶段把时钟约束写上XDC 文件里至少包含主时钟周期和引脚约束。课程设计在板子上验证时用拨码开关复位、LED 显示最终结果即可不需要接串口。如果引脚没约束就布线会导致clk走普通 IO在 FPGA 内部出现时钟毛刺现象是“仿真正常、上板不定时跑飞”。下面是一个最小 XDC 片段。create_clock -period 10.000 -name sys_clk [get_ports clk] set_property IOSTANDARD LVCMOS33 [get_ports clk] set_property PACKAGE_PIN W5 [get_ports clk]PACKAGE_PIN换成手头板卡的晶振引脚不同板子差异很大EGo1、DIGILENT 的 Artix 系列都有官方引脚文档。上板前把顶层模块的输入输出端口数量控制在 10 个以内时钟、复位、4 个 LED 输出、可选按键输入。常见故障的第一步不是看 RTL而是看clk和rst_n的极性是否匹配很多板卡按键按下为低电平复位信号要设计成低有效且加 RC 消抖。第二常见的是 BRAM 初始化失败$readmemh文件路径在综合时要放在 IP 核的初始化参数里否则下载后 ROM 全零CPU 执行空指令。下表是调试中概率最高的几类问题与检查顺序现象最可能的根因检查方法波形里 PC 正确但结果全 0写回regwrite没有透传到 WB看 MEM/WB 寄存器的regwrite波形一上板就跑飞仿真正常未加时钟约束或复位极性反了先查 XDC再用 ILA 抓 PClw后结果随机load-use 没插气泡看stall是否在正确周期拉高分支总是不跳bne比较用的寄存器号错位检查 ID/EX 里 rs/rt 是否来自 IF/ID5. 让课设拿高分的进阶技巧验证方法与可扩展点5.1 用实验程序统计IPC评阅老师不会只看仿真波形更关心你的 CPU 到底“快不快”。在 RTL 里加两个计数器一个统计总周期数total_cycles一个统计执行完毕的指令数committed_inststestbench 跑完程序后打印二者的比值。流水线理论 CPI 接近 1但分支和 load-use 会把它拉高算出来的实际值在 1.3 到 1.8 之间都算正常。计数器要放在 WB 级因为只有写回到寄存器堆的指令才算真正提交nop和气泡不算。代码实现是把regwrite与valid信号在 MEM/WB 寄存器里捆绑传递。5.2 微架构扩展分支预测与中断入口五级流水线里最容易加的扩展点是单比特分支预测在 IF 级维护一个 1 位饱和计数器默认不跳当分支在 EX 级被解析且预测错误时把预测位反转同时 flush 已经进入流水线的 3 条指令。这个扩展点能在答辩时解释清楚“预测错误要 flush 几条指令”的问题。另一个低成本扩展是给 CPU 加一个中断入口把0x18地址固定为中断服务程序入口外部按键触发后保存 PC 并跳转这需要把 PC 更新逻辑改成三路选择。若想把框架改成 RISC-V可以用 RV32I 的 lui/addi 实现简单程序加载改动集中在译码和立即数扩展部分。5.3 交付资料的组织方式源码树按rtl/、sim/、mem/、doc/四目录组织大型工程不要把所有 .v 文件堆在根目录。rtl/下每个模块一个文件名字与模块名一致sim/放 testbench 和 do 脚本mem/放汇编源码、Python 生成脚本、prog.mem、prog.coedoc/里放设计说明、指令集表格、仿真截图。说明文档把 2.1 节的流水线级间信号表放进去每一张表对应一个模块这是“全部资料齐全”这个题目里得分最稳的部分。评阅时最常见的问题是“源码能跑但没有说明每张波形没人看得懂”所以仿真截图要标注时间轴和关键信号。本文还有配套的精品资源点击获取
返回列表