ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

从Verilog加法器到五级流水线:手把手拆解CPU设计全流程

从Verilog加法器到五级流水线:手把手拆解CPU设计全流程

简介:面向高校计算机专业师生的中央处理器设计与体系结构实验指导手册(教师版),配套LS-CPU-EXB-002实验箱及Xilinx Vivado平台,适合《数字逻辑》《计算机组成原理》《计算机体系结构》等课程的实验教学与自主研习。手册围绕一套完整实验链路展开:实验一、二分别完成定点加法与定点乘法,帮助理解算术逻辑单元的基本运算;实验三实现寄存器堆,包括通用寄存器与状态寄存器;实验四完成算术逻辑单元模块,执行算术和逻辑操作;实验五搭建存储器模型;实验六、七则依次实现单周期与多周期中央处理器,并对比不同时钟策略;课程设计延伸到静态5级流水线中央处理器及优化系统。附录列出所实现的指令集和对应的寄存器映射,方便读者对照实验电路验证。资源仅1个PDF文件,大小约6.09MB,包含实验目的、任务、结构框图、Verilog设计流程与调试要点,目录清晰,便于教师备课和学生按阶段实践。已有680人浏览学习,适合需要从底层理解计算机系统运作、系统掌握处理器设计流程的教师和高年级学生参考。

1. 从“+”到流水线:一门实验课如何拆穿 CPU 的黑匣子

在 Verilog 里做加法器,最直接的办法不是画全加器,而是敲一个“+”号——综合工具会自动把它换成专用进位链,面积更小、时序更快。这本实验指导手册最反直觉的地方就在这里:它带你从一个“+”号出发,最后落到一条完整的 5 级流水线 CPU,中间每个实验都对应一个真实的体系结构决策。为什么要从定点加法学起?因为加法是所有运算、寻址、比较的公共底座。为什么要一步步搭寄存器堆、ALU、存储器,再做到单周期、多周期 CPU?因为只有亲手实现过控制器,你才会明白指令是怎么被“翻译”成电路动作的。这篇解读就照着手册的实验顺序往下拆:每一层机制的取舍在哪里,仿真与上板之间又会栽哪些跟头。

2. 定点加法与乘法:为什么一个“+”比自己搭全加器更优

2.1 一个“+”号背后:综合工具在悄悄替换你的电路

手册实验一的参考实现只有短短几行:

`timescale 1ns / 1ps module adder( input [31:0] operand1, input [31:0] operand2, input cin, output [31:0] result, output cout ); assign {cout, result} = operand1 + operand2 + cin; endmodule

拼接符{cout, result}把 33 位结果一次性赋值:最高位是向高位的进位,低 32 位是与输入等宽的加法结果。这段代码值得注意的不是功能,而是注释里那句“直接使用‘+’,会自动调用库里的加法器”。很多初学者把硬件描述语言当高级语言用,以为+只是语法糖,综合工具随便给你兜个底。实际上,现代综合工具对加法有专门优化:FPGA 的每个查找表(LUT)后面都跟着一条专用快速进位链,综合工具会把+映射到这条硬连线结构上,形成类似超前进位的快速传播路径。

手工用全加器搭 32 位行波进位加法器,最坏情况下进位要从最低位一路串到最高位,延迟随位宽线性增长;用门级逻辑搭超前进位,进位逻辑面积又暴涨。FPGA 里的专用进位链正好卡在中间:既有接近超前进位的速度,又几乎不消耗额外 LUT。所以手册建议“如果教师没有特殊要求,优先用+”,不是一个偷懒的选择,而是基于器件结构的工程判断。这背后是硬件设计里一条反复出现的规律:与其自己造轮子,不如让工具调用经过验证、针对器件优化过的原语。

2.2 定点乘法:移位累加为何是 CPU 性能的隐患

实验二把难度从加法抬到乘法。乘法从原理上可以拆成“移位 + 累加”:乘数每一位为 1 时,把被乘数左移对应位数后加入部分积,32 位乘 32 位最多要累加 32 个部分积。这个朴素思路在教材里很直观,在硬件上却是性能噩梦。

加法只要处理一条进位链,延迟是 O(n);乘法要处理 n 行部分积的压缩,复杂度是 O(n²)。如果 CPU 里每一次乘法都老老实实串行累加 32 拍,乘法指令会把流水线拖垮。所以现代乘法器普遍用部分积压缩树:先把 32 行部分积用全加器/半加器压缩成两行,再用一个超前进位加法器收尾。手册实验二的任务正是让学生从“理解移位累加”过渡到“实现一个可用的乘法模块”,这一步直接关系到后面 CPU 实现时乘法指令能不能在一个有限周期数内完成。

这也是多周期 CPU 设计里一个伏笔:如果乘法器不支持单周期出结果,控制器就得给乘法指令安排额外的执行周期。你在实验二里选择的乘法实现方案,会直接影响实验七多周期 CPU 的状态机设计——这是后来我在做课程设计时才想明白的关联。

2.3 黑盒复用:LCD 触摸屏模块与随机激励验证

定点加法本身太简单,真正的门槛在上板验证。若是用拨码开关输入加数、LED 显示结果,32 位加法需要 64 个拨码开关加 32 个 LED,实验箱根本提供不了这么多 IO。这套实验系统的解法是 LCD 触摸屏:硬件驱动被封装成 dcp 网表文件,以黑盒形式提供给调用者。

这里有个很专业的设计取舍。触摸屏驱动的时序逻辑复杂,如果让学生从头写,大概率会迷失在 LCD 初始化、扫描、触控采样这些细节里,丢了主线。封装成黑盒后,学生只需要在顶层模块里调用lcd_module,像接插座一样接好引脚就行。手册明确说这是“避免学生过分关注而导致迷失在 LCD 触摸屏实现中”。这种“把次要复杂度藏起来”的做法,和 CPU 设计里的指令集抽象如出一辙:让使用者通过接口理解功能,而不是被内部实现淹没。

功能验证环节同样体现工程思维。手册给的 testbench 不是枚举有限测试向量,而是用随机激励:

always #10 operand1 = $random; always #10 operand2 = $random; always #10 cin = {$random} % 2;

$random是 Verilog 系统任务,每次调用产生一个 32 位有符号随机数;#10表示每 10ns 触发一次赋值。注意第三行的{$random}加了拼接符,作用是把随机数先转成非负数,再% 2得到 0 或 1——如果不加拼接符,$random % 2在有符号数下可能得到负数,仿真波形里看就是红色未知态。手册用这句话带过了这个细节,但它正是仿真里最常见的玄学翻车点之一。

随机激励的验证逻辑是统计性的:每 10ns 换一组随机输入,跑足够长时间后,如果几十上百组随机数据都没有出错,就认为功能“趋于稳定”。手册里那句“我们认为功能趋于稳定,可以认为是正确的”,措辞很严谨——这不是形式化证明,而是覆盖率足够高后的工程信心。观察波形时,把数据 Radix 改成 16 进制,手算一组b897be71 + 42f24186 + 1 = fb89fff7能对上,就可以进入综合布线环节了。

3. 寄存器堆与 ALU:数据通路的两个心脏

3.1 三端口寄存器堆:双读单写由指令格式决定

寄存器堆在 CPU 里承担“工作台”角色,所有运算都要先把数据从寄存器读出来,算完再写回去。手册实验三的核心问题是:为什么寄存器堆通常设计成两个读端口、一个写端口?

答案藏在指令格式里。以 MIPS 风格的 R 型指令为例,一条指令同时给出两个源寄存器号和一个目的寄存器号,比如add rd, rs, rt。翻译成硬件需求就是:一个时钟周期里,要能同时读出 rs 和 rt 两份数据,同时可能写入 rd。所以最少需要两个读端口和一个写端口。这不是拍脑袋定的,是 ISA 给出的约束,硬件接口只是顺从它。

典型的寄存器堆骨架如下:

reg [31:0] regfile [0:31]; // 写端口:时钟上升沿写入 always @(posedge clk) begin if (we && (addr_w != 0)) regfile[addr_w] <= wdata; end // 读端口:组合逻辑直接输出 assign rdata1 = (addr1 == 0) ? 32'b0 : regfile[addr1]; assign rdata2 = (addr2 == 0) ? 32'b0 : regfile[addr2];

几个细节要讲清楚:regfile是一个 32 个 32 位寄存器组成的数组;实际硬件实现时,综合工具会把它映射成分布式 RAM 或寄存器阵列。写端口是时序逻辑,必须在时钟沿到达且写使能we有效时才会更新。读端口是纯组合逻辑,地址一变,数据跟着变,不依赖时钟。两条读端口各自都有addr 为 0 时输出 0的保护——这是 MIPS 指令集的约定:0 号寄存器永远读出 0,写 0 号寄存器不会生效。这条约定看起来是软件层面的规则,实现在硬件里就是一行判断。

这里有个初学者容易忽略的点:如果读是组合逻辑,那么从“地址稳定”到“数据稳定”之间存在一个读延迟,这段时间内数据可能无效。CPU 数据通路必须把这个延迟算进周期预算,否则后级 ALU 采样的可能是上一轮的数据。

3.2 ALU 操作码:指令字段到控制信号的译码

实验四的 ALU 是 CPU 的执行核心。但直接设计 ALU 前,得先想清楚一个问题:CPU 指令里的操作码,和 ALU 的端口控制信号,不是一回事。MIPS 指令里add和sub的指令码不同,但 ALU 的输入端口上通常只需要一组短得多的操作码,比如00表示加、01表示减、10表示与、11表示或。指令字段到 ALU 操作码的翻译,由控制单元完成。

典型 ALU 的输入输出如下:

module alu( input [31:0] a, input [31:0] b, input [2:0] alu_op, // ALU 控制信号,由控制单元译码得到 output [31:0] result, output zero );

zero标志用于分支判断,它的产生方式是:把 result 的每一位做或运算后取反——只有 result 恰好为 32'b0 时,zero 才为 1。slt(set less than)这类置位指令的实现思路是把 a 和 b 转换为减法,判断结果的符号位:逻辑上写成slt = (a < b),硬件上通常通过符号位比较实现。对比一下:实现slt需要的减法逻辑、实现add需要的加法逻辑、实现and/or需要的位运算逻辑,三者在同一组输入 a、b 上并行做不同运算,最后由alu_op选择一个作为 result 输出。这就是 ALU 的典型内部结构:功能单元并行计算,结果多路选择。

做 ALU 时最大的坑不是运算逻辑,而是扩展性:多周期 CPU 里,ALU 有时要算 PC+4(取指地址自增),有时要算分支目标地址,有时要算运算结果。实验三寄存器堆和实验四 ALU 单独做时都不难,难的是把这两个模块合进数据通路时,控制信号怎么在每条指令的不同阶段切来切去。

3.3 读后写窗口:寄存器堆的时序设计

寄存器堆有一个容易被仿真掩盖的时序问题。假设一条指令要写寄存器,紧接着下一条指令要读同一个寄存器——在单周期 CPU 里,同一周期内写和读不会同时发生;但在流水线 CPU 里,IF/ID 读寄存器和 WB 写寄存器可能在同一周期重叠执行:前一条指令在写回阶段把结果写进寄存器堆,后一条指令在译码阶段读同一个寄存器。如果寄存器堆是“上升沿写入”,而读端口是组合逻辑,那么在一个时钟周期内,前半段读到的是旧值,上升沿一过后新值才出现。读端口在上升沿前采样的指令,拿到的是旧数据,这就触发了典型的数据冒险。

教学 CPU 里常见两种解法。一种是寄存器堆采用“下降沿写、上升沿读”的约定,让写动作发生在读动作之后,避开同一个沿上的竞争;另一种是写回阶段的结果直接通过转发通路送往需要它的执行阶段,不依赖寄存器堆的更新。手册实验三落脚在寄存器堆本身,实验六的单周期 CPU 因为每周期只执行一条指令,不会暴露 RAW 竞争;但到了课程设计的 5 级流水线,这个窗口会变成你第一个要面对的真实冒险。能提前在寄存器堆的仿真波形里找到这个窗口,后面处理转发和暂停会顺很多。

4. 存储器实现:指令与数据为什么必须分开存取

4.1 指令存储与数据存储:分开还是合并

实验五的存储器实现,要面对的第一个选择题是:指令存储器和数据存储器,是共用一块 RAM 还是分开两块?教材上把它表述为冯·诺依曼结构和哈佛结构之争,落到这次实现里,其实是个工程决策。

单周期 CPU 的一个基本事实是:每个周期都要取指,同时 load/store 指令还要访问数据。如果指令和数据共用一块物理存储器,就必须等到取指完成后才能读数据,读数据时取指总线闲着——数据通路上凭空多出一段串行等待。教学 CPU 通常选择指令存储器和数据存储器分开,各用一块独立的 RAM 块(Block RAM),让取指和访存并行,路径清晰,控制逻辑也简单。代价是面积翻倍,而且指令 ROM 里装着程序,数据 RAM 里装着运行期变量,两者不能动态共享。

这个选择还牵出一个细节:指令存储器本质上是只读的。手册里单周期 CPU 实验实现的是固定 MIPS 指令集,程序提前写入指令存储器的初始化文件里。所以在硬件里指令存储器往往实现为 ROM 或者带有初始化数据的 RAM,而数据存储器必须支持读写。读和写对时序的要求完全不同,这直接导向存储器的端口设计。

4.2 单端口与双端口 RAM:面积与并行的交换

存储器的端口数量是一个和面积强相关的参数。单端口 RAM 只有一组地址线和一组数据线,一个时钟周期要么读要么写;双端口 RAM 两组端口独立,可以一个端口读、另一个端口写。

类型端口能力硬件成本适合场景
单端口每周期要么读要么写低,Block RAM 原生支持多周期 CPU 数据存储器、单周期指令存储器
伪双端口一个读口一个写口,地址独立中,读和写可以同时单周期 CPU 数据存储器(读数据、写数据分离)
真双端口两个独立读写端口高,面积接近翻倍多核共享存储、复杂流水线结构

多周期 CPU 的经典设计里,数据存储器用单端口就够,因为它的访存阶段要么是 load(读)要么是 store(写),不会同时发生。但单周期 CPU 的数据存储器更常用伪双端口:load 指令在数据存储器读,store 指令在数据存储器写,两条指令不会在同一个周期访问,可一旦指令集扩展出同时要求读和写的操作,伪双端口就会成为瓶颈。手册把存储器单列一个实验,目的就是让你先把这个参数吃透,再进入 CPU 集成。

4.3 同步读还是异步读:仿真与上板不一致的根源

存储器实现里最容易翻车的是读时序。用 reg 数组搭的存储器,读可以是纯组合逻辑(地址一变,数据立即跟着变),那叫异步读;但如果用 FPGA 的 Block RAM 原语,读操作往往需要等一个时钟沿,数据在下一个周期才稳定下来,那叫同步读。

// 异步读:地址变化 -> 数据组合输出 assign dout = mem[addr]; // 同步读:时钟沿后数据更新 always @(posedge clk) if (read_en) dout <= mem[addr];

这两种写法在行为仿真里都能看到正确的数据,但时序完全不一样。异步读的 dout 在地址变化后立刻变化;同步读的 dout 在时钟沿之后才更新,相当于多了整整一个周期的读延迟。如果你的 CPU 控制逻辑按异步读设计,上板后却用了综合工具自动推断出的 Block RAM 同步读,波形上数据会比预期晚一拍,整条流水线的节奏全乱。

血泪经验是:写存储器前先想清楚,自己到底需要同步读还是异步读,然后在整个数据通路里保持一致。教学 CPU 里,寄存器堆通常用异步读,因为译码阶段要立刻拿到源操作数;数据存储器的读可以用同步读,但控制单元必须把读延迟算进访存阶段的周期里。这个选择没有绝对的对错,只有一致不一致。

5. 从单周期到多周期:控制逻辑的分解与常见问题排查

5.1 单周期 CPU:最慢指令决定时钟周期

实验六实现单周期 CPU,它的核心规则是:每一条指令都在一个时钟周期内完成全部操作。从取指、译码、读寄存器、运算、访存到写回,所有部件在这一个周期里串行工作。这种设计最致命的点是:时钟周期必须按最坏路径的延迟来定。

最坏路径几乎总在 load 指令上:PC 从指令存储器取指,寄存器堆读出基址和偏移,ALU 计算访存地址,数据存储器读出数据,再写回寄存器堆。这条路径上所有部件的延迟相加,就是时钟周期的下限。算术逻辑指令不访存,但它的周期也得按 load 的周期来跑。结果是:单周期 CPU 的 CPI 恒等于 1,看似很理想,但周期时间被最慢指令拖长,整体性能并不高。这就是单周期方案“逻辑简单、性能吃亏”的本质矛盾。

手册附录 A1 列出单周期 CPU 实现的指令集,可以看到它只包含最基本的加减、逻辑运算、存取和分支指令。指令集刻意收敛,是为了让数据通路和控制逻辑保持简单,这个阶段的目标是建立起“指令 → 控制信号 → 部件动作”的直觉。你写控制单元时,要为每一条指令生成一组确定的控制信号(寄存器写使能、存储器写使能、ALU 操作码、多路选择器选择信号),这些信号在一个周期内都保持稳定,不做任何分时复用。

5.2 多周期 CPU:把一条指令拆成一组状态

实验七的多周期 CPU,解决单周期“所有部件同时工作”的浪费。思路是:把一条指令的执行过程拆成若干步骤,每个步骤一个时钟周期,一个周期里只启用部分部件。

典型拆法是取指(IF)、译码(ID)、执行(EX)、访存(MEM)、写回(WB)五步。关键变化在于部件的分时复用:ALU 在取指阶段算 PC+4,在执行阶段算真正的运算结果;数据存储器在取指阶段读指令,在访存阶段读写数据。控制逻辑从一组并行输出的组合逻辑,变成一台状态机:当前状态是哪一步,下一步该走到哪。状态机的实现方式通常是 case 语句,按指令类型和当前状态组合出控制信号。

单周期和多周期的对比,在手册的两个实验里体现得很清楚:

维度单周期 CPU多周期 CPU
周期定义一条指令一周期一条指令多周期
最慢路径所有部件串联一次每周期只走一个子步骤
硬件利用率低,所有部件每周期都动高,部件按需启用
控制逻辑组合逻辑有限状态机
时钟频率被最长路径卡住可以更高,各步骤独立优化

5.3 排查:五个最容易翻车的点

1. 行为仿真正确、上板后完全乱跑

现象:testbench 里波形正确,下载到实验箱后要么无输出、要么偶发错误。原因:引脚约束(xdc 文件)没生效,顶层端口和 FPGA 引脚没绑上,端口悬空或被分配到错误引脚。解决:综合后先查看 I/O Ports 报告,确认每个顶层 input/output 都已经有 PACKAGE_PIN 约束;检查 get_ports 名字是否和 Verilog 顶层端口完全一致(大小写、总线索引都要对)。手册里给的set_property PACKAGE_PIN AC19 [get_ports clk]这类约束,写错一个字母,综合工具不会报错,但上板就会静默失败。

2. 读存储器的数据总比预期晚一拍

现象:仿真时 load 指令读出来的数据不对,波形上看总在预期时刻之后才出现。原因:存储器的读被设计成同步读(时钟沿后更新数据),而控制逻辑按异步读(地址变化后数据立即出现)设计。解决:回到存储器的实现代码,检查 dout 是assign组合输出还是always @(posedge clk)时序输出;如果是同步读,需要在控制状态里预留一拍。

3. 多周期 CPU 状态机卡死,复位后也动不了

现象:跑仿真时状态一直停在某个值,时钟怎么打都不往前走。原因:case 语句没有覆盖所有状态组合,或者状态转移条件互相矛盾,综合后生成了锁存器(latch)。解决:case 必须写 default 分支,默认为回到取指状态;所有输出信号在进入 case 前先赋默认值,避免综合器推断出 latch。波形排查时重点看状态的次态逻辑:在 state 信号上添加条件断点,观察是哪个输入组合让它困住。

4. testbench 里随机数产生负数,导致仿真相貌异常

现象:always #10 cin = $random % 2;跑出来的 cin 时而 0 时而 1,但 operand 的波形偶尔出现负值或未知态。原因:$random返回有符号数,对有符号数取余会得到负数。解决:按手册写法改成{$random} % 2,拼接符先把随机数包装成非负数,再取余。看起来只是括号问题,实际上是有符号数和无符号数语义的典型陷阱。

5. dcp 网表文件添加失败,工程一直报模块找不到

现象:添加触摸屏模块的网表文件后用不了,模块上有个问号,综合时提示未知模块。原因:网表文件针对的版本和当前综合工具版本不匹配。解决:手册明确提到某些工具版本下,需要先把 dcp 文件解压再添加。实验箱配套的 LCD 触摸屏封装成黑盒 dcp 网表,这块最容易踩的坑就是版本兼容,添加前先确认工具版本和网表文件的匹配关系。

6. 静态 5 级流水线:冒险、转发与设计取舍

课程设计把实验推进到静态 5 级流水线,这是整本手册的制高点。流水线把一条指令的执行分成取指、译码、执行、访存、写回五个阶段,每个阶段由独立的硬件完成,不同指令在不同阶段重叠推进。吞吐量上去了,代价也随之而来:相邻指令在流水线里并行执行,前一条还没写完,后一条就要读数据,数据冒险、控制冒险接踵而至。

静态 5 级流水线的设计取舍,恰好在“复杂度”和“教学价值”之间取了一个平衡点。静态意味着指令严格按顺序流经所有流水段,不做乱序发射、不做动态调度。换来的是异常处理和寄存器写回的顺序性天然清晰,CP0 寄存器可以简单地保存 EPC、Cause 等异常现场,流水线遇到异常时冲刷后续指令即可。如果换成动态调度,转发逻辑、保留站、重排序缓冲的复杂度会成倍上升,反而不适合作为组成原理课程的终点。

处理数据冒险的标准手段是转发:把写回阶段的结果直接送到执行阶段的 ALU 输入,绕过寄存器堆。但 load 指令的转发特别难,因为它要等访存完成才知道结果,紧跟在 load 后的指令就必须暂停一拍。这暴露了流水线的另一个本质矛盾:流水段拆得越细,频率越高,但冒险出现的频率和代价也越高。转发路径变长会拖慢关键路径,暂停逻辑变复杂会牺牲 CPI,这些此消彼长的博弈,就是体系结构设计最迷人的部分。手册后面还安排了“优化 CPU 系统”作为拓展,方向正是指令调度、资源分配这些更深的内容。

从那以后,我每写一个数据通路模块,都会先画一张时序图,把每个读写动作标在时钟沿上,再沿着流水线把相邻指令的依赖窗口标出来,最后才动手写代码。这道工序帮我挡掉了大半的冒险问题,希望帮到你。

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

RELATED READING

延伸阅读

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