简介:这份CPU设计与体系结构实验指导手册(教师版)面向高校计算机专业本科生、研究生及实验指导教师,服务于数字逻辑、计算机组成原理与体系结构等课程的教学实验,帮助学习者在FPGA平台上从基础运算部件逐步过渡到完整CPU设计。压缩包共1个PDF文件,约6.09MB,内容以实验指导为主,配有平台介绍、实验目的、设备与任务说明、设计框图和附录资料。手册覆盖定点加法、定点乘法、寄存器堆、ALU模块、存储器、单周期与多周期CPU等七个基础实验,并延伸至静态5级流水线CPU课程设计及CPU系统优化拓展;附录还整理了所实现的MIPS指令集与CP0寄存器,便于教师组织教学和学生对照实验任务开展设计、调试与结果验证。目前已有681人学习,适合需要系统梳理CPU组成原理实验流程、备课或完成课程设计的师生参考。
1. 一张地图先给:从全加器到五级流水的十次实验串成一条线
顺着实验编号往下排,会看到一条很直的路:先跑通平台与流程(定点加法)→ 再做四个功能部件(乘法器、寄存器堆、ALU、存储器)→ 然后开始搭 CPU(单周期、多周期)→ 最后进入流水线与优化。前面每一项都是后面一项的零件:加法器是 ALU 的零件,寄存器堆和存储器是数据通路的零件,单周期 CPU 又是五级流水的零件。真正拖慢进度的地方,往往不在某一个模块写不出来,而在于不知道当下这个模块在整条链里承担什么角色。这份CPU 设计与体系结构实验指导手册的价值不在某一个 Verilog 案例写得多漂亮,而在于它把「零件 → 部件 → 整机 → 优化」的次序排死了。正在上计算机组成原理、体系结构课的人,以及备考时想把整门课重新串一遍的人,最适合把它当成一张知识地图来读,而不是当成操作手册来抄。
2. 平台与流程:一个 32 位加法器怎么从头走到板上
2.1 实验箱里到底有什么,哪些是给你的
实验箱的构成其实很朴素:一块 FPGA 实验板、一根下载线、一根串口线(含 USB 转串口)、电源与适配器。真正值得先弄明白的是那块 LCD 触摸屏——平台在硬件层面把触摸屏驱动做好了,不需要处理器核就能完成显示和输入,并且对外留了调用接口。这句话的分量在于:它决定了后面所有实验的上板验证方式。
对比一下就很清楚。如果只用拨码开关和 LED 观察结果,一个 32 位加法就要 64 个拨码开关加 32 个 LED,实验板上根本凑不出这么多 IO。改成用触摸屏输入两个加数、显示加法结果,IO 数量的问题直接消失,而且验证过程可读、可重复。代价是学生要多写一个外围展示模块,但平台把这部分做成了黑盒:lcd_module被封装成一个网表文件(.dcp),只给接口不给源码。这是很刻意的设计取舍——让使用者不必陷进 LCD 时序细节里,把注意力留给 CPU 本身。
提示:部分版本的集成开发环境需要先把 dcp 文件解压再添加,添加前留意工程管理区里
lcd_module前面是不是还挂着一个问号,问号消失才算真正进工程。
2.2 从新建工程到上板:一条几乎不变的七步链路
十次实验的工程操作高度重复,把这条链路背下来,后面每次就只是换源文件:
| 步骤 | 要做的事 | 容易忘的点 |
|---|---|---|
| 1 | 新建工程,选 RTL Project | 勾选「暂不指定源文件」 |
| 2 | 器件筛选:Artix-7 系列、fbg676 封装、xc7a200tfbg676-2 | 型号选错后面约束全废 |
| 3 | 添加设计源文件(.v) | 一个模块一个文件,便于看层次 |
| 4 | 添加黑盒网表lcd_module.dcp | 未添加时顶层模块前有问号 |
| 5 | 添加 testbench 并右键 Set as Top | 不设顶层,仿真跑的不是你想的那个 |
| 6 | 行为仿真,看波形对数据与进位 | 加信号后要 Relaunch,进制要改 |
| 7 | 加.xdc约束 → 综合 → 实现 → 生成比特流 → 下载 | 只有引脚没有电平标准会报错 |
第 2 步的筛选器里,family选 Artix-7、package选 fbg676,然后在列表里挑xc7a200tfbg676-2。这一步没有技术含量但不能错,因为约束文件里的引脚编号是和具体封装绑死的,器件换了封装,PACKAGE_PIN AC19这种写法就失去意义。第 5 步的 Set as Top 也是老生常谈的翻车点:工程里同时存在设计文件和仿真文件时,工具不一定默认选中 testbench,波形窗口一片空白或者信号全是 X,八成就是这个原因。
关于仿真界面还有两个细节值得记住。左侧 Scopes 窗口选源文件对象,Objects 窗口里选信号右键 Add To Wave Window,添加完成后必须点 Relaunch 才能看到新信号的数据。波形默认以二进制显示,32 位的数看不过来,在 Name 列选中信号右键 Radix 改成十六进制,验算才有效率。举个实际的验算例子:b897be71 + 42f24186 + 1 = fb89fff7,十六进制下心算低位进位就能判断对错,二进制下根本没法看。
2.3 用定点加法把链路跑通:32 位加法器的两种写法
参考实现里,加法器本体只有一个文件,短到可以整段背下来:
`timescale 1ns / 1ps // 32 位加法器:两个操作数 + 低位进位,输出结果与向高位的进位 module adder( input [31:0] operand1, input [31:0] operand2, input cin, output [31:0] result, output cout ); // 拼接 {cout,result} 共 33 位,进位自然溢出到最高位 assign {cout, result} = operand1 + operand2 + cin; endmodule这段代码的关键在左边那对花括号。{cout, result}组成一个 33 位的向量,右边三个 32 位/1 位操作数相加,综合工具会自动做位宽扩展,最高位溢出正好落进cout。如果写成assign result = operand1 + operand2 + cin;再把cout单独算,逻辑会变复杂,还容易漏掉组合进位。参数上,两个操作数都是 32 位无符号向量,cin是 1 位,这套接口在后面的 ALU 实验里会原样复用。
写法上有两条路可选:一是用全加器自己搭行波进位或超前进位模块,二是直接写+。直接写+会调用工具库里的加法 IP,面积和时序通常更优,指导教师一般会二选一要求学生实现。想理解进位链原理就自己搭,想快速推进就用+,两条路的差别只在这一处,后面拼 CPU 时不再纠结。
testbench 同样短,但几个写法值得抄:
`timescale 1ns / 1ps // 单位时间 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; always #10 cin = {$random} % 2; // 拼接符取非负,再对 2 取余得 0/1 endmodule$random产生的是有符号随机数,直接对 2 取余可能得到负数,所以套一层{$random}拼接符转成非负再取余,这是个小坑但很典型。#10表示每 10 个时间单位赋一次值,三个 always 块各自独立驱动,激励组合足够丰富。这类「只产生激励、不做自动比对」的 testbench 在课程实验里够用,代价是要人工看波形。
上板验证需要约束文件把顶层端口绑到物理引脚上,形式如下:
# 时钟、复位、按键、拨码等控制引脚 set_property PACKAGE_PIN AC19 [get_ports clk] set_property PACKAGE_PIN Y3 [get_ports resetn] set_property PACKAGE_PIN Y6 [get_ports sw_cin] set_property IOSTANDARD LVCMOS33 [get_ports clk] # LCD 数据总线是 16 位向量,必须逐个位绑定,端口名要和顶层完全一致 set_property PACKAGE_PIN H9 [get_ports {lcd_data_io[0]}] set_property PACKAGE_PIN L19 [get_ports ct_int]两件事必须成对出现:PACKAGE_PIN定位置,IOSTANDARD定电平标准(这里是 LVCMOS33)。少写 IOSTANDARD 通常会直接报错;端口名写错大小写、向量不写成{lcd_data_io[0]}这种带下标的形式,会提示找不到端口。这类报错信息很直白,照着改就行,怕的是靠猜。
3. 功能部件四件套:乘法器、寄存器堆、ALU 与存储器
3.1 定点乘法:部分积、移位与 64 位结果
加法之后马上跳到乘法,不是随手排的顺序。加法器是组合逻辑练手,乘法器第一次把「时序 + 控制」引进来:移位、累加、计数、判断结束,这套结构在后面的多周期 CPU 里会反复出现。
核心思路是部分积相加。两个 32 位数相乘,结果最多 64 位,所以乘法模块的输出位宽必须按 64 位规划,绝不能只留 32 位——这是最容易埋雷的地方,低位看着对,高位悄悄丢了。无符号乘法可以按「乘数最低位为 1 就加上被乘数,然后整体右移」循环 32 次;有符号乘法要么先把操作数取绝对值、符号单独处理,要么用 Booth 编码减少部分积个数。
寄存器资源上,乘法结果通常拆成 HI/LO 两个 32 位寄存器存放,MIPS 里配套有mfhi、mflo这类搬移指令。复习时把这条对应关系记牢:乘法模块的输出口 → HI/LO → 搬移指令 → 通用寄存器,这是一条完整的数据链路,缺哪一环都跑不通。至于用组合逻辑一次性算出 64 位结果,还是用时序逻辑迭代 32 个周期,取决于课程对面积和时序的要求,两种都能过验收,但要能说清楚差别在哪。
3.2 寄存器堆:双读单写与「写后读」的时序约定
寄存器堆的结构非常固定:32 个 32 位寄存器,两个读端口、一个写端口。两个读端口意味着同一个周期能同时取出 rs 和 rt,这是 R 型指令能在一个周期内完成的前提;一个写端口对应 rd 回写。读操作是组合的——给地址就出数据,不依赖时钟;写操作是时序的——只在时钟沿、写使能有效时才生效。
接口大致是这几个信号:raddr1/raddr2(读地址)、rdata1/rdata2(读数据)、waddr(写地址)、wdata(写数据)、we(写使能)、clk。第 0 号寄存器恒为 0,这个约定要在读端口上硬接,写入时直接忽略,后面很多指令(比如move用add rd, rs, $zero实现)都靠它。
真正需要想清楚的是同一周期内写地址和读地址相同时的行为。写是时钟沿生效,读是组合输出,所以「先写后读」还是「先读后写」取决于读逻辑是否旁路了写入数据。如果流水线里靠旁路解决数据冒险,寄存器堆内部可以简单地做成「读旧值」,把冲突交给旁路单元;如果没做旁路,就得在这里加内部前推。两种做法都能自洽,混着用就会出玄学错误——某一两条指令结果不对,查半天查不出来。
3.3 ALU:操作码译码与四个标志位
ALU 是整块 CPU 里最像「纯组合逻辑」的部件,也是把前面加法器复用起来的地方。输入两个 32 位操作数和一个操作码,输出运算结果和若干标志位。
| 操作类别 | 典型操作 | 说明 |
|---|---|---|
| 算术 | add / sub / slt | sub 可用加补码实现;slt 按有符号比较 |
| 逻辑 | and / or / xor / nor | 按位运算,无进位概念 |
| 移位 | sll / srl / sra | 移位量来自指令中的 sa 字段 |
| 标志 | zero / carry / overflow / sign | zero 供分支指令判断,overflow 供异常判断 |
zero标志是分支指令的判定依据,beq本质上就是「两数相减,看 zero 是否为 1」。overflow在有符号加减时需要判断:同号相加结果异号即溢出,这个判断逻辑要单独写,不能靠进位标志代替,两者含义不同。slt用有符号比较,sltu用无符号比较,同一条数据通路要能切换比较方式,实现时容易只写一种。
用 case 语句按操作码分支是最直白的写法,写完之后一定要穷举测试:每个操作码至少一组正例、一组边界(最大值、最小值、0)。ALU 出错的代价极高,它错了,单周期、多周期、流水线全都跟着错,而现象往往只是「某条指令结果差 1」。
3.4 存储器:字节使能、对齐与指令数据的取舍
存储器实验要解决三个问题:位宽、对齐、读写分离。
位宽上,数据总线 32 位,但访存指令支持字节(lb/sb)、半字(lh/sh)、字(lw/sw),所以写端口必须有字节使能,按地址低两位决定哪几个字节真正写入。少了字节使能,sb会把整个字覆盖掉,这是调试时很难发现的错误——单独测sw/lw全对,一上字节指令就崩。
对齐上,MIPS 要求字访问地址 4 字节对齐,非对齐地址属于异常。课程实验里可以选择简化处理(忽略对齐检查),但要清楚这是简化,不是规范。
结构上,指令存储器和数据存储器是分开还是合并,直接对应哈佛结构与冯·诺依曼结构。分开实现简单,取指和访存不会打架;合并实现省资源,但单周期里同一周期既要取指又要访存就冲突了——这也是后面流水线要处理结构冒险的根源。先把这个问题想明白,第 4 章的冒险分析会轻松很多。
4. 三种 CPU 组织:单周期、多周期与五级流水的分水岭
4.1 单周期:一条指令一个周期,时钟周期由最长路径决定
单周期 CPU 的定义很干脆:每条指令都在一个时钟周期内完成,CPI 恒等于 1。听起来很美,代价藏在时钟周期里——周期长度必须能容纳最长的那条指令,通常是lw:取指 → 译码读寄存器 → ALU 算地址 → 访存 → 写回,五段串下来。其他指令(比如add)用不到访存,也要陪着等满这一整段。
所以单周期实验的重点不是性能,而是把数据通路完整搭起来。要练的是这几件事:PC 如何更新(PC + 4与分支目标的选择)、指令如何按 opcode/funct 译出控制信号、立即数如何符号扩展、写回地址如何在 rt 和 rd 之间选择。控制器的输入是指令的 opcode 与 funct 字段,输出是一整组控制信号(RegWrite、MemWrite、MemRead、ALUSrc、Branch、MemtoReg、RegDst 等),写成一张真值表再翻译成 case 语句,比边写边想靠谱得多。
一个常见的结构错误是把 PC 更新和数据通路塞进同一个 always 块里,导致非阻塞赋值与阻塞赋值混用、仿真和综合结果不一致。规矩很简单:时序逻辑(PC、寄存器堆写入)用非阻塞赋值<=,组合逻辑用assign或always @(*)加阻塞赋值=,两边不混。
4.2 多周期:拆成五步,用状态机和中间寄存器换资源
多周期把一条指令拆成若干步,每步一个时钟周期,典型划分是:取指、译码读寄存器、执行/算地址、访存、写回。换来两个好处:一是时钟周期只取决于最长的那一步,而不是整条指令;二是部件可以复用——指令存储器和数据存储器合并成一个,ALU 一个周期算地址、另一个周期做运算。
代价是需要一个状态机来推进,还需要在步骤之间加中间寄存器(IR、A、B、ALUOut、MDR 等)把上一拍的数据锁住。这些中间寄存器是多周期实验的核心,也是最容易漏的地方:少锁一个值,下一步就用到了错误数据,波形上看是「隔一拍才出问题」,排查起来很折磨。
控制信号也从单周期的「组合译码」变成「状态机 + 译码」的混合形式。写法上可以把控制信号按状态展开成一张表,每个状态输出一组信号,思路清晰。CPI 从 1 变成大于 1,但时钟周期大幅缩短,整体性能反而常常更好,这个权衡是体系结构课最爱考的点之一,复习时不要只记住「多周期更省资源」,要说得出「为什么省、省在哪、代价是什么」。
4.3 五级流水:三类冒险与旁路、停顿、清空
静态五级流水线把指令执行切成 IF、ID、EX、MEM、WB 五段,理想情况下每个周期都能启动一条新指令,吞吐率接近 5 倍。理想很丰满,现实里全是冒险。
| 冒险类型 | 产生原因 | 解决手段 |
|---|---|---|
| 结构冒险 | 取指与访存抢同一个存储器 | 指令/数据存储器分离,或分时复用 |
| 数据冒险 | 后一条指令要用前一条还没写回的结果 | 旁路前推;load-use 必须停顿一拍 |
| 控制冒险 | 分支指令结果未定,后续取指不确定 | 分支提前判断、延迟槽、预测失败清空 |
数据冒险里有个必须区分的细节:普通 ALU 结果可以通过旁路在 EX 段前推,但lw后面紧跟使用该结果的指令,数据要到 MEM 段结束才可用,光靠旁路不够,必须插入一个气泡(停顿一拍)。只做旁路不做 load-use 停顿,是流水线实验里最经典的半成品——大部分指令对,个别组合错,测试用例简单时根本测不出来。
控制冒险的处理要配合清空逻辑:分支在 EX 段判定,一旦跳转,IF 和 ID 段已经取进来的指令要作废,把控制信号清零(相当于插入气泡)。清空不彻底会出现「不该执行的指令偷偷写了寄存器」,现象是寄存器值莫名其妙被改。
还有一条容易被忽略:控制信号必须跟着流水线寄存器一级一级传下去。EX 段算出的结果,到 MEM、WB 段还要知道「要不要写寄存器、写哪个寄存器、写什么值」,这些信息不随数据一起流动,后面就无从判断。把每个流水线寄存器该带哪些字段列成表,比凭记忆补要稳。
4.4 指令集与 CP0:附录里的两张表怎么用
附录里按阶段拆了三张指令表:单周期实现哪些、多周期新增哪些、五级流水再新增哪些。这三张表不是装饰,它们决定了每个阶段的数据通路要开多少条路。单周期通常只做最基础的一批(算术逻辑、访存、分支),多周期加上乘除和搬移,流水线再补上跳转与异常相关。
CP0 寄存器单独出现在附录里,说明异常与中断是拓展内容。常见的几个:状态寄存器、原因寄存器、异常返回地址寄存器(EPC)。复习时至少要能说清一条异常的处理流程:发生异常 → 保存现场(EPC 记录返回地址、原因寄存器记录异常类型)→ 跳到异常入口 → 处理完用返回指令回到 EPC。这块内容在笔试里出现频率不低,但实验里容易因为赶进度被跳过。
5. 易错点排查:五个最容易翻车的地方
5.1 黑盒网表没进工程,顶层挂着一个问号
易漏点:lcd_module的 dcp 文件添加失败或根本没加,工程管理区里该模块前面显示问号,综合阶段才报错。
为什么漏:加法器本体代码写得很顺,仿真也过了,注意力全在功能上,忘了顶层还挂着一个依赖。有的工具版本还要求先把 dcp 解压再添加,直接选文件会失败,失败提示又不明显。
怎么补:每次新建工程后先看层次结构树,确认顶层下面所有子模块都没有问号再往下走。把「加源文件 → 加网表 → 看层次」当成固定动作,别等到综合报错才回头找。
5.2 仿真波形读不出变化:顶层、Relaunch 与进制
易漏点:testbench 没被设为顶层,仿真跑的是设计文件;或者往波形窗口加了新信号但没点 Relaunch,误以为信号一直不动。
为什么漏:仿真能启动、有波形出来,看起来一切正常,只是数值全是 X 或者不变化。加上波形默认二进制,32 位数看不出规律,很容易误判成逻辑错误去改代码。
怎么补:仿真前先确认工程管理区里 testbench 带顶层标志;加完信号立刻 Relaunch;Name 列右键把 Radix 改成十六进制。养成先看波形再看代码的习惯,能省掉大量无效修改。
5.3 约束文件写了引脚却没写电平标准
易漏点:只写了PACKAGE_PIN,漏掉IOSTANDARD;或者向量端口没有按{lcd_data_io[0]}的写法逐位绑定;端口名大小写和顶层不一致。
为什么漏:约束文件是复制粘贴来的,改了几个引脚就以为完事。位宽较大的总线最容易出问题,16 位数据线要写 16 行,漏一行就是一位的错误。
怎么补:约束文件和顶层模块的端口列表逐行对一遍,位置和电平标准成对出现。综合实现报「找不到端口」时,先查大小写和向量写法,这两类占报错的绝大多数。
5.4 符号扩展与有符号比较
易漏点:立即数扩展统一用零扩展,addi传负数时出错;slt用无符号比较,负数比较结果反了。
为什么漏:测试用例大多是正数,正数下零扩展和符号扩展结果一样、有符号和无符号比较结果也一样,测不出问题。
怎么补:每个模块都准备一组负数用例,至少包括 -1、最小值。写指令时明确标注哪些指令用符号扩展(addi、lw/sw 的偏移、分支偏移)、哪些用零扩展(andi、ori、逻辑类立即数),列成一张小表贴在旁边。
5.5 流水线只做了旁路,没做 load-use 停顿
易漏点:数据冒险统一靠旁路解决,lw紧跟使用其结果的指令时,取到的是错误值。
为什么漏:普通 ALU 指令的旁路能覆盖绝大多数相邻指令组合,手写的测试序列往往恰好避开 load-use 场景。功能仿真看起来全对,一跑综合测试就出问题。
怎么补:测试序列里强制加入lw $t0, 0($sp)紧跟add $t1, $t0, $t2这种组合,并检查是否有气泡插入。判断逻辑上,只要 ID 段指令的源寄存器和 EX 段lw的目的寄存器相同,就必须停顿。
6. 复习清单:按周推进的自查表与验收标准
把这门课拆成四周来推,比按实验编号硬排更符合记忆规律,因为每周结束时都有一个可以自测的交付物。
| 阶段 | 核心内容 | 验收标准 | 常见返工原因 |
|---|---|---|---|
| 第一周 | 平台链路 + 加法器 | 能独立新建工程、仿真、绑引脚、上板 | 顶层设错、约束漏电平标准 |
| 第二周 | 乘法器、寄存器堆、ALU | 每个部件有独立 testbench,边界用例通过 | 乘法输出位宽不足、ALU 比较方式单一 |
| 第三周 | 存储器 + 单周期 CPU | 能跑通一段包含算术、访存、分支的小程序 | 字节使能缺失、控制信号译码错 |
| 第四周 | 多周期 + 五级流水 | 流水线在含 load-use 的序列上结果正确 | 只做旁路没做停顿、控制信号没逐级传递 |
自查时我习惯用两个动作:一是把所有模块的 testbench 集中跑一遍,看有没有哪个在改过别的模块之后悄悄挂了;二是从附录的指令表里随机抽五条,手算一遍结果再和仿真比对,抽到slt、slti、带负偏移的lw就重点看。
还有一份「说得出」清单,比「跑得通」更能反映掌握程度:单周期的时钟周期为什么由lw决定;多周期省下的是哪部分资源;旁路能解决哪类数据冒险、为什么解决不了 load-use;分支清空如果漏做会出现什么现象。这四个问题答不上来,说明只是把代码跑通了,没把结构想透。
我自己踩得最深的一次,是流水线做完之后所有单指令测试全过,一上多指令序列就错,查了两个晚上才发现是控制信号只在 EX 段有效、没随流水线寄存器往下传。从那以后我每次改流水线,都强制把每个流水线寄存器该携带的字段列成一张表贴在屏幕边上,逐字段打勾确认,再开始仿真。这个笨办法救过我很多次,也让我明白这类实验真正考的不是写代码的速度,而是对「数据在哪一拍、在哪一级、被谁用到」的掌控。希望帮到你。
本文还有配套的精品资源,点击获取