ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

CPU设计实验全链路:从32位加法器到MIPS五级流水线

CPU设计实验全链路:从32位加法器到MIPS五级流水线

简介:《CPU设计与体系结构实验指导手册(教师版)》面向高校计算机组成原理、体系结构课程的教师与学习者,提供从数字逻辑部件到完整CPU实现的实验教学路径。内容以龙芯LS-CPU-EXB系列教学实验平台为硬件环境,依次覆盖定点加法与乘法、寄存器堆、ALU模块、存储器、单周期与多周期CPU,并延伸到静态5级流水线CPU课程设计及CPU系统优化拓展,附录还整理MIPS指令集与CP0寄存器,便于对照实验任务与指令实现。压缩包仅含1个PDF文件,约6.09MB,篇幅完整、目录清晰,适合作为课堂实验指导、课程设计参考和教师备课资料。目前已有681人学习下载,读者可据此梳理实验目的、设备要求与任务安排,理解各模块在CPU数据通路中的作用,并为流水线设计与性能优化建立整体框架。

1. 一份能把 CPU 从加法器堆到五级流水的实验指导手册

“CPU设计与体系结构实验指导手册”这类资料最容易翻车的地方在于:目录看着很全,真动手时每一步都差半个前提。这份 LS-CPU-EXB-002 的手册覆盖链路算是少见的完整——从 32 位定点加法器起步,经乘法器、寄存器堆、ALU、存储器,推到单周期 CPU、多周期 CPU,最后落到静态五级流水线和系统优化,实验一到实验七对着组成原理课,课程设计和拓展对着体系结构课,附录还把三个阶段的 MIPS 指令集和 CP0 寄存器单独列了清单。它适合两类人:手上有这台实验箱、被课程进度推着走的同学;以及没有硬件、只想找一条从单模块到流水线的完整搭建路线的自学者。我按自己拆这份手册的顺序讲,先把最小链路跑通,再谈流水线里那些真正会咬人的细节。

2. 平台落地:从 32 位加法器打通第一条上板链路

实验一表面上是“写个加法器”,实际上是把新建工程、加源文件、挂黑盒网表、跑仿真、绑引脚、下板这一整条链子走一遍。这条链子跑通了,后面六个实验和两个课程设计都是同一套流程换内容;跑不通,后面每一步都会怀疑是 CPU 写错了,其实是约束文件里的封装选错了。我见过太多人卡在“仿真波形全对、板子毫无反应”,最后发现是器件型号和 xdc 里的引脚对不上。

2.1 工程骨架:三个筛选条件决定后面所有麻烦

用 Vivado 新建工程,选 RTL Project,勾上“Do not specify sources at this time”,把源文件留到后面按需添加。到了器件选择页,筛选条件只有三个要留意:产品系列选 7 系列、封装选 fbg676、速度等级选 -2。

这三个参数不是随便填的。约束文件里写的是set_property PACKAGE_PIN AC19 [get_ports clk]这种物理引脚编号,编号跟封装一一对应。封装填错,综合能过、实现能过、比特流能生成,下载进去就是黑屏或者行为诡异——这是最没有性价比的一种翻车,因为你会在代码里找一整天。我一般建完工程第一件事不是写代码,是先去确认板卡型号和封装,再去核对 xdc 里的引脚列表数量对不对。

工程建好后,一个完整的加法器实验目录下会有五类文件,缺任何一类流程都会断:

文件角色缺失后果
adder.v加法器本体,被调用的子模块顶层无模块可例化
adder_display.v顶层,例化 adder 与触摸屏模块无顶层,无法综合
lcd_module.dcp触摸屏黑盒网表综合报找不到模块
testbench.v仿真激励,不参与综合只能上板试错
adder.xdc引脚与电平约束引脚随机分配,行为不可预期

这张表值得存下来,后面每加一个实验模块,无非是在这张表上多一行 .v。

2.2 adder.v:为什么“+”比手搭全加器更划算

参考设计里的加法器只有六行有效代码:

`timescale 1ns / 1ps // 32 位定点加法器,带进位输入与进位输出 module adder( input [31:0] operand1, input [31:0] operand2, input cin, output [31:0] result, output cout ); // 拼接成 33 位再赋值,高位自然落到 cout assign {cout, result} = operand1 + operand2 + cin; endmodule

三个参数点必须说清楚。第一,{cout, result}是位拼接,左侧 33 位、右侧表达式也是 33 位,这样第 32 位的进位才有地方落;如果写成assign result = operand1 + operand2 + cin;,进位会被静默截断,仿真时看着结果对,是因为你压根没检查 cout。第二,cin单独留出来是为了后面拼多位加法器和 ALU 复用。第三,timescale 1ns/1ps只对仿真有效,综合时被忽略,但漏写会让#10这类延时变成未定义单位,波形时间轴直接错乱。

手册里明确写了两条实现路线:手搭全加器级联,或者直接用“+”。前者是给数字逻辑课交作业用的,能看清进位传播链;后者综合工具会映射到器件自带的进位链资源上,面积和时序都更优。我在实际项目里一律用“+”,只有在需要讲清楚超前进位、行波进位差异时才手搭一遍做对比。如果你的课程要求必须手搭,记得把两种实现都综合一次,比较 LUT 数量和最长路径延迟,这组数据写进报告比波形截图有说服力得多。

2.3 adder_display.v:把黑盒当外设用,别当谜题拆

顶层模块干三件事:例化 adder、例化 lcd_module、在两者之间做数据搬运。32 位加数没法用拨码开关输入(需要 64 个开关加 32 个 LED),所以实验箱把 LCD 触摸屏做成了不依赖处理器核就能工作的外设,封装成 dcp 网表交给学生调用。

典型写法是这样:

// 顶层:触摸屏输入两个加数,屏上显示结果 module adder_display( input clk, input resetn, input input_sel, // 切换当前编辑哪个加数 input sw_cin, // 低位进位,拨码开关给 output led_cout, // 进位用单灯指示 // 以下为 LCD 物理接口,直接透传给黑盒 output lcd_rst, lcd_cs, lcd_rs, lcd_wr, lcd_rd, lcd_bl_ctr, inout [15:0] lcd_data_io, output ct_int, ct_sda, ct_scl, ct_rstn ); wire [31:0] op1, op2, sum; wire carry; adder u_adder( .operand1(op1), .operand2(op2), .cin(sw_cin), .result(sum), .cout(carry) ); lcd_module u_lcd( .clk(clk), .resetn(resetn), .data_in(...), // 端口名以配套 dcp 的声明为准 .lcd_data_io(lcd_data_io), /* 其余屏幕与触摸信号原样透传 */ ); assign led_cout = carry; endmodule

逻辑说明:加法器是纯组合逻辑,触摸屏模块负责采样触摸坐标、刷新显示,两者通过顶层连线交换数据。参数说明:input_sel决定当前输入落到 op1 还是 op2,这个选择信号在顶层做多路选择,不进 adder 内部;sw_cin走拨码开关是为了让你能手动制造进位,验证 cout 通路。

有个细节容易被忽略:lcd_module 是网表,端口名固定,你只能在顶层按它的声明去连,不能改。加进工程后如果模块前面带问号,说明 dcp 没被正确识别;用老版本工具时还得先解压 dcp 再添加,这是手册里专门点出来的一句,很多人当成可选项跳过了。

2.4 testbench:只给激励不给判据,那就要靠人眼盯

实验一的 testbench 是“最简版”——只产生激励,不做自动比对:

`timescale 1ns / 1ps module testbench; reg [31:0] operand1, operand2; reg cin; wire [31:0] result; wire cout; adder uut( .operand1(operand1), .operand2(operand2), .cin(cin), .result(result), .cout(cout) ); initial begin operand1 = 0; operand2 = 0; cin = 0; #100; // 等全局复位稳定 end // 每 10ns 换一组随机激励 always #10 operand1 = $random; always #10 operand2 = $random; // $random 是有符号数,拼上 {} 变无符号,再对 2 取余得 0/1 always #10 cin = {$random} % 2; endmodule

三个写法各有理由。#10是半周期采样,保证每个时钟沿前后数据都稳定;$random直接赋给 32 位 reg 会得到有符号随机数,赋给无符号变量时按位截断,正好覆盖全值域;{$random} % 2里那对大括号是关键,不加的话负数取余会得到 -1,cin 变成 x 态,波形上看着像“随机出现了未知值”,其实是语法坑。

看波形有个提高效率的动作:把 Name 列的信号选中、右键 Radix 改成十六进制。三十二位数用二进制看是自虐行为。改完之后手算几组核对,比如b897be71 + 42f24186 + 1 = fb89fff7,进位为 0;再故意把 cin 置 1 制造一次溢出,确认 cout 拉高。这几组数据对上了,加法器这个模块就可以封箱了。

2.5 约束文件与上板:电平标准写错比引脚写错更隐蔽

xdc 文件本质是一串 Tcl 命令,分两类:引脚绑定和电平标准。手册给的模板长这样:

# 时钟、复位、拨码与指示 set_property PACKAGE_PIN AC19 [get_ports clk] set_property PACKAGE_PIN Y3 [get_ports resetn] set_property PACKAGE_PIN AC21 [get_ports input_sel] set_property PACKAGE_PIN Y6 [get_ports sw_cin] set_property PACKAGE_PIN A3 [get_ports led_cout] set_property IOSTANDARD LVCMOS33 [get_ports clk] set_property IOSTANDARD LVCMOS33 [get_ports resetn] set_property IOSTANDARD LVCMOS33 [get_ports led_cout] # LCD 数据总线,按位逐个绑定 set_property PACKAGE_PIN H9 [get_ports {lcd_data_io[0]}] set_property PACKAGE_PIN K17 [get_ports {lcd_data_io[1]}] # ... [2] 到 [15] 依此类推 set_property IOSTANDARD LVCMOS33 [get_ports {lcd_data_io[0]}]

说明两点。第一,LVCMOS33是 3.3V 电平标准,写错成 1.8V 或漏写,工具会按默认值分配,结果可能是引脚烧掉或者信号幅度不够,表现是“偶尔能跑、偶尔不跑”,属于玄学类故障。第二,总线型端口必须写成{lcd_data_io[0]}这种带下标的花括号形式,直接写lcd_data_io会报找不到端口。写完一段就Ctrl+S保存,让工具实时校验,别等综合完再回头找。

流程走完:综合、实现、生成比特流、硬件管理器里下载。下载后如果屏幕不亮,先查复位极性(resetn 是低有效),再查时钟引脚是否接到了板载晶振,最后才怀疑黑盒网表版本。这套排查顺序能把大部分“板子坏了”的误判挡在前面。

3. 时序分水岭:定点乘法、寄存器堆与存储器的实现取舍

实验二是整份手册里第一个真正跨过组合逻辑边界的地方。加法器的输出随输入即时变化,乘法器不行——32 位乘 32 位要出 64 位结果,纯组合实现的路径延迟长到能拖垮整个时钟域,所以必须拆成多周期时序逻辑。实验三的寄存器堆、实验五的存储器同理,一旦引入时钟和状态,问题的性质就从“算得对不对”变成“什么时候算完、谁来读”。

3.1 定点乘法:移位累加的状态机怎么写才不丢拍

教科书上的移位累加思路很直白:乘数最低位为 1 就把被乘数加到积上,然后被乘数左移、乘数右移,循环 32 次。落到 Verilog 里,关键是时钟沿上做什么、组合逻辑里做什么,分不清就会出现“结果差一位”的经典现象。

// 32 位无符号乘法器:移位累加,32 拍出结果 module mult_seq( input clk, input rstn, input start, // 单拍脉冲启动 input [31:0] a, b, output reg [63:0] p, output reg done ); reg [5:0] cnt; // 0~32 计数 reg [63:0] mcand; // 被乘数扩到 64 位,左移不溢出 reg [31:0] mplier; // 乘数右移 reg busy; always @(posedge clk or negedge rstn) begin if (!rstn) begin cnt <= 6'd0; mcand <= 64'd0; mplier <= 32'd0; p <= 64'd0; done <= 1'b0; busy <= 1'b0; end else if (start && !busy) begin mcand <= {32'd0, a}; // 高 32 位补零 mplier <= b; p <= 64'd0; cnt <= 6'd0; busy <= 1'b1; done <= 1'b0; end else if (busy) begin if (cnt == 6'd32) begin busy <= 1'b0; done <= 1'b1; // 结果有效的单拍标志 end else begin // 乘数最低位为 1 则累加当前被乘数 if (mplier[0]) p <= p + mcand; mcand <= mcand << 1; mplier <= mplier >> 1; cnt <= cnt + 6'd1; end end end endmodule

代码里有三个必须解释的参数。mcand用 64 位而不是 32 位,是因为左移 32 次后最高位会跑到第 63 位,用 32 位会静默丢高位,症状是“大数相乘结果偏小”。cnt用 6 位而不是 5 位,因为要能表示 32 这个终止值,5 位最大 31,状态机永远退不出来——这是最典型的死锁。done只拉高一拍,下游模块要用它做握手,不能拿它当电平使。

还有一处容易写错:p <= p + mcand和mcand <= mcand << 1在同一个 always 块里,非阻塞赋值保证两者用的是本拍旧值,加法用的是移位前的 mcand,这是对的;换成阻塞赋值就会变成“先移位再相加”,结果整体错位。设计时建议先写一个 8 位版本跑通波形,确认 8 位下0xFF × 0xFF = 0xFE01,再扩到 32 位,调试成本差好几倍。

有符号乘法是另一个坑。手册附录里列了实现的指令集,如果课程要求支持有符号乘,别在移位累加里打补丁,常见做法是取出符号位、按绝对值算完再补符号,或者直接调用工具的乘法 IP,把符号处理交给工具。手写有符号移位累加的边界情况(负负得正、最小值取负溢出)足够写满一页报告,还很难保证对。

3.2 寄存器堆:双端口读、单端口写与那个绕不开的写优先

寄存器堆是 CPU 里被访问最频繁的部件,MIPS 指令格式决定了它必须支持同一拍读两个源寄存器、写一个目的寄存器。32 个 32 位寄存器,用分布式 RAM 或寄存器阵列实现都行,关键是零号寄存器的特殊处理和写读同址的时序。

module regfile( input clk, input rstn, input [4:0] raddr1, raddr2, output [31:0] rdata1, rdata2, input we, input [4:0] waddr, input [31:0] wdata ); reg [31:0] rf [0:31]; integer i; // 读:组合读出,零号寄存器恒为 0 assign rdata1 = (raddr1 == 5'd0) ? 32'd0 : rf[raddr1]; assign rdata2 = (raddr2 == 5'd0) ? 32'd0 : rf[raddr2]; // 写:上升沿写入,屏蔽零号寄存器 always @(posedge clk or negedge rstn) begin if (!rstn) begin for (i = 0; i < 32; i = i + 1) rf[i] <= 32'd0; end else if (we && waddr != 5'd0) begin rf[waddr] <= wdata; end end endmodule

参数说明:we是写使能,由控制器给;waddr != 5'd0这个判断保证零号寄存器写不进去,同时读端口用三目运算强制输出 0,两道保险都要有——只做读端屏蔽,写端还是会把值写进去,虽然读不出来,但综合工具可能因此推不出常量优化。复位时用 for 循环清空,注意integer i要放在 always 外面,放里面在某些工具版本上会报错。

真正需要说清楚的是写读同址的时序。同一个时钟沿,如果写的是 r5、读的也是 r5,读端口拿到的是写入前的旧值——因为非阻塞赋值在时钟沿结束时才生效。单周期 CPU 里这不是问题,因为写和读不在同一拍;但到了流水线阶段,一条指令写回、紧接着的下一条指令要读同一个寄存器,就会出现数据冒险。手册在流水线课程设计里要求处理这个问题,实现手段无非两种:写回阶段做旁路(forwarding),或者读端口加一级“写优先”逻辑。寄存器堆本身不建议加写优先,它会把组合路径拉长,正确的做法是在流水线的译码级做旁路判断,这一点在第 6 章还会提到。

3.3 存储器:指令存储器与数据存储器必须分开

单周期 CPU 要在同一拍取指令和读写数据,如果指令和数据共用一个存储器端口,硬件上做不到。所以实验五的存储器实现里,指令存储器(IMEM)和数据存储器(DMEM)是两块独立的存储体,这是哈佛结构的基本要求,不是什么可选优化。

指令存储器只读,用组合读或者同步读都行,同步读更符合 FPGA 的 Block RAM 时序:

// 指令存储器:只读,同步输出 module imem( input clk, input [31:0] addr, // 字节地址 output reg [31:0] inst ); reg [31:0] mem [0:1023]; // 1K 条指令 initial begin // 实际工程里用 $readmemh 从文件加载 // $readmemh("inst.txt", mem); end always @(posedge clk) begin inst <= mem[addr[11:2]]; // 字对齐:丢弃低 2 位 end endmodule

数据存储器要支持字节、半字、字三种访问宽度,这是 MIPS 的 lb/lh/lw 指令决定的,实验五的难点基本都集中在这里:

module dmem( input clk, input [31:0] addr, input [31:0] wdata, input we, input [2:0] size, // 000 字节 001 半字 010 字 output reg [31:0] rdata ); reg [31:0] mem [0:1023]; always @(posedge clk) begin if (we) begin case (size) 3'b000: begin // 字节写,只改对应字节 case (addr[1:0]) 2'd0: mem[addr[11:2]][7:0] <= wdata[7:0]; 2'd1: mem[addr[11:2]][15:8] <= wdata[7:0]; 2'd2: mem[addr[11:2]][23:16] <= wdata[7:0]; 2'd3: mem[addr[11:2]][31:24] <= wdata[7:0]; endcase end 3'b010: mem[addr[11:2]] <= wdata; // 字写 default: mem[addr[11:2]] <= wdata; endcase end // 读也走同步,与写同拍时读到旧值,符合 MIPS 语义 rdata <= mem[addr[11:2]]; end endmodule

两个参数点。addr[11:2]是把字节地址转成字索引,低两位丢弃;如果忘了这一步,1K 字的存储体会被当成 1K 字节用,地址空间缩水四倍,症状是“程序跑着跑着跳到不存在的地方”。字节写的 case 分支必须按addr[1:0]选字节位置,漏了就会把整个字覆盖掉,lw 后面的 lb 直接读到脏数据。

还有一条血泪经验:$readmemh的路径问题。仿真时用相对路径好好的,换台机器或者换综合流程就找不到文件,工具报一行警告继续跑,存储器全是 x 态,波形上一片红。稳妥的做法是把初始化文件放在工程目录下、用相对路径,并且在仿真开始后立刻检查 mem[0] 的值是不是你预期的第一条指令。

4. ALU 与单周期 CPU:控制信号怎么从指令里拆出来

实验四的 ALU 和实验六的单周期 CPU 是同一件事的两面。ALU 负责“给什么操作码就做什么运算”,单周期 CPU 负责“从 32 位指令里把操作码、寄存器号、立即数拆出来,生成正确的控制信号喂给 ALU 和数据通路”。前四个实验都是在攒零件,到这里第一次把所有零件接起来,也是第一次会遇到“每个模块单独仿真都对、拼起来就是不对”的情况。

4.1 ALU 操作码编码:别自己发明,跟着指令集走

ALU 的对外接口有三种常见设计:一是只给一个操作码字段,内部译码;二是给 ALUOp 高位加功能字段,两级译码;三是直接把控制信号拆成若干根线。手册走的是教学上最清楚的路子——ALU 单独给操作码,控制器产生这个操作码,两级译码便于在报告里画清楚。

module alu( input [31:0] a, b, input [3:0] op, output reg [31:0] y, output zero // 结果为零标志,给分支指令用 ); assign zero = (y == 32'd0); always @(*) begin case (op) 4'b0000: y = a + b; // add 4'b0001: y = a - b; // sub 4'b0010: y = a & b; // and 4'b0011: y = a | b; // or 4'b0100: y = ~(a | b); // nor 4'b0101: y = a ^ b; // xor 4'b0110: y = (a < b) ? 32'd1 : 32'd0; // slt,有符号比较 4'b0111: y = b << a[4:0]; // sllv,移位量取低 5 位 4'b1000: y = b >> a[4:0]; // srlv,逻辑右移 4'b1001: y = $signed(b) >>> a[4:0]; // srav,算术右移 default: y = 32'd0; endcase end endmodule

参数说明里最要紧的是slt和移位。slt是有符号比较,a < b在 Verilog 里默认按无符号比,两个负数比大小会得到完全相反的结果,正确写法是$signed(a) < $signed(b);同理sra必须用$signed修饰操作数才做算术右移。移位量取a[4:0]而不是整个 a,是因为移位超过 31 位在 MIPS 语义里只取低 5 位,多取会导致综合出巨大的桶形移位器。

default: y = 32'd0这一行不是保险,是必须。缺了它,综合工具会推成锁存器,或者仿真出 x 态,两种结果都很难查。我一般还会在测试平台里跑一遍全部操作码,把每种操作的边界值(0、全 1、最小值、最大值)各测一组,波形过一遍再往下走。

4.2 单周期 CPU:一张控制信号表定生死

单周期 CPU 的所有指令都必须在同一个时钟周期内走完取指、译码、执行、访存、写回,所以时钟周期由最长的那条指令决定——通常是 lw,因为它串了取指、读寄存器堆、ALU 算地址、读数据存储器、写回五段。这也是单周期结构性能差的根本原因,不是因为实现得糙,是结构决定的。

控制器的输出信号,教学上通常整理成一张表,这张表写对了,数据通路接线就是体力活:

指令类型RegWriteMemReadMemWriteALUSrcMemtoRegBranchJumpALUOp
R 型(add/sub/and/or)100000010
lw110110000
sw0011x0000
beq0000x1001
j000xx01xx

表里x表示无关项,硬件上不用专门处理,但仿真时如果某个信号全程是 x,说明该指令路径上有模块没被驱动,反而是一个有用的信号。ALUSrc决定 ALU 的第二个操作数来自寄存器堆还是立即数扩展,MemtoReg决定写回数据来自 ALU 结果还是存储器读出值,这两个多路选择器是单周期 CPU 里最容易接反的地方,接反的症状是“算术指令全对、访存指令全错”,很好定位。

数据通路里还有一个反复被踩的点:PC 的更新。单周期下PC <= PC + 4是默认动作,分支指令要改成PC + 4 + (imm << 2),跳转指令要拼{PC+4[31:28], target, 2'b00}。这三个表达式写在一个三选一的多路选择器里,选择信号来自控制器的 Branch、Jump 和 ALU 的 zero 标志。手册附录 A1 列了单周期实现要覆盖的指令清单,动手前先把清单上的每条指令在这三个分支里过一遍,能省掉大量上板调试时间。

4.3 附录里的指令清单才是验收标准

很多人把附录当成参考资料,翻都不翻。实际上附录 A1、A2、A3 分别列了单周期、多周期、五级流水三个阶段要实现的 MIPS 指令,这是最硬的验收标准——你的 CPU 能不能跑某个测试程序,取决于清单上的指令是不是每一条都实现了。

我的做法是把附录的指令清单抄成一张表,每条指令后面标三个状态:数据通路接好了、控制信号给对了、上板跑通了。三个都打勾才算完成。这么做还有个额外好处:阶段之间的差异一目了然。比如多周期阶段比单周期新增了哪些指令,五级流水又新增了哪些,这些新增项往往就是课程设计答辩的重点。

5. 多周期 CPU 与上板排查:五类典型翻车记录

实验七的多周期 CPU 是把单周期那条超长的组合路径切成五段,每段一拍,用状态机控制。理论上性能提升明显,实际写起来坑比单周期多一倍——因为引入了状态,就有了“这一拍该干什么”的时序推理,而时序推理错了,仿真波形很难一眼看出来。

5.1 状态划分:五个状态怎么切、共享资源怎么省

多周期 CPU 的经典划分是:取指(IF)→ 译码(ID)→ 执行(EX)→ 访存(MEM)→ 写回(WB),每条指令走其中若干段。和单周期最大的差别是硬件资源可以复用:指令存储器和数据存储器合并成一个存储器,ALU 在不同状态做不同的事,PC 只在 IF 状态更新。

状态主要动作关键控制信号次态
IF取指令,PC+4 暂存IRWrite, PCWriteID
ID译码,读寄存器堆,算分支地址ALUSrcA=0, ALUSrcB=3按指令类型分派
EXALU 运算或算地址ALUSrcA=1, ALUSrcB=0MEM 或 WB
MEM读/写数据存储器MemRead / MemWriteWB
WB结果写回寄存器堆RegWrite, MemtoRegIF

这张表的价值在于:每一行对应一拍,这一拍里哪些控制信号有效、下一拍去哪,写清楚了状态机就不会乱。实际编码时用两段式或者三段式状态机都行,但default分支必须回到 IF,否则遇到非法指令编码会永久卡死。

5.2 多周期相对单周期的收益与代价

收益很直接:时钟周期从“最长指令的总延迟”缩短到“单段最长延迟”,理论上有三到五倍的空间。代价是控制复杂度上去了,一条指令要拆成好几拍,每拍的中间结果都要锁存——A、B、ALUOut、MDR、IR 这批寄存器就是干这个的,漏锁一个,下一拍数据就没了。

还有一个隐性代价:多周期下分支指令的惩罚变高。单周期里分支判断和数据通路在同一拍完成,多周期里要等到 EX 状态才知道跳不跳,前面已经取进来的指令得作废。这个现象在五级流水线里会被放大成“分支冒险”,处理手段就是第 6 章要说的内容。

5.3 五类上板翻车记录:现象、原因、解决

下面这五条是这类实验中重复出现率最高的,按我遇到的频率排序。

第一类:仿真波形完整,上板屏幕全黑。

现象:行为仿真跑得好好的,下载进板子后 LCD 无显示,LED 也不亮。

原因:九成是 dcp 黑盒网表没被正确综合进去,或者器件封装和 xdc 引脚不匹配。少数情况是复位极性搞反,resetn 低有效但代码里写成了高有效。

解决:先在综合报告里搜 lcd_module 有没有被例化、有没有报“black box”警告;再核对工程器件封装与 xdc 头部信息;最后用示波器或者直接看 LED 确认时钟有没有进来。这三步按顺序做,别跳。

第二类:单个模块仿真对,拼起来结果错位。

现象:加法器、ALU、寄存器堆单独测都正确,接进 CPU 后算术指令结果比预期差一位或者差一个常数。

原因:位宽隐式截断。最常见的是把 32 位立即数塞进 16 位端口,工具只报 warning 不报 error,高位静默丢失。

解决:把工程的所有 warning 过一遍,重点看“width mismatch”“truncation”这类关键词;在每个模块例化时用显式位宽连接,别用隐式;仿真时把中间信号全部加到波形窗口,逐个比对预期值。

第三类:单周期 CPU 算术指令全对,跳转和分支指令全错。

现象:add、sub、and 这些跑得没问题,一遇到 beq、j 就飞。

原因:PC 更新逻辑写错。要么是分支地址忘了<< 2,要么是跳转地址拼接时用了PC而不是PC+4,要么是分支判断用了 ALU 的 zero 但没等 ALU 结果稳定。

解决:把 PC 的三个来源(顺序、分支、跳转)写成三个独立的 wire,再用一个多路选择器选,别在一个表达式里嵌套三目运算。仿真时单独构造一个“先跳后分支”的小程序,逐拍打印 PC 值,一眼就能看出是哪一路算错。

第四类:多周期 CPU 偶尔死锁,复位才能恢复。

现象:跑简单程序正常,跑长一点的测试程序会卡在某个状态不动。

原因:状态机的 default 分支没写,或者写了但次态指回自己;另一种是计数器位宽不够,比如循环计数到 32 但计数器只有 5 位。

解决:给状态机加一个超时计数器,超过 N 拍没进展就强制回 IF 并拉一个错误标志出来;所有计数器位宽按最大值加一位留余量。这属于后悔药,加上之后调试效率提升明显。

第五类:改动一行代码后行为全变,重新综合又好了。

现象:改个注释或者改个信号名,下载进去行为就不对了。

原因:增量综合的缓存问题,或者时序没收敛就生成了比特流。表现为玄学,本质是工具没做全量重跑。

解决:遇到无法解释的行为差异,先做一次全量重新综合(Reset Runs),再看时序报告确认没有未满足的约束。这一条我吃过不止一次亏,现在改完关键路径的代码一律全量重跑。

6. 静态五级流水线:冒险处理与验证方法

课程设计部分是整份手册里最有价值的一段,因为它把前面七个实验攒的零件按流水线重新组织了一遍。静态五级流水线指每条指令都老老实实走完 IF、ID、EX、MEM、WB 五段,不重排、不乱序,只靠转发和停顿解决冒险。

三类冒险要对症下药:

冒险类型典型场景处理手段代价
结构冒险同一拍既取指又访存指令存储器与数据存储器分离多一块存储资源
数据冒险上一条写 r1,下一条读 r1写回级转发到执行级;load-use 停一拍组合路径变长 / 一个气泡
控制冒险分支指令改变 PC静态预测不跳,跳转时清空流水寄存器分支惩罚两拍

数据冒险里有个细节值得单独说:load-use 冒险光靠转发解决不了,因为 lw 的数据要到 MEM 段结束才拿得到,而下一条指令在 EX 段就要用。必须停一拍,让后面的指令等一等。判断条件是“EX 段指令是 lw 且目的寄存器等于 ID 段指令的源寄存器”,这个条件写在流水线控制模块里,写漏了就会出现“寄存器值看起来是对的、算出来结果就是错的”这种最难查的 bug。

验证方法上,我不建议一上来就跑完整测试程序。先把流水线灌满五条互不相关的 add 指令,确认五拍之后每个周期都有一条指令退休;再插入一组 RAW 依赖的指令,观察转发路径有没有生效;最后加分支指令,看清空逻辑对不对。每一步都在波形上数周期数,比跑通过率更能定位问题。

从那以后我每次做流水线相关的改动,都强制先跑这三组最小用例——五条无关 add、三条 RAW 链、一条 beq 加两条后续指令——三组波形都对,再去跑大程序。这套习惯帮我省掉的调试时间,比任何技巧都多。希望帮到你。

本文还有配套的精品资源,点击获取

返回列表