简介:面向计算机组成原理、体系结构课程的教师版实验指导手册,依托龙芯 LS-CPU-EXB 教学实验箱与 Vivado、Verilog 开发流程,为课堂讲授与实验指导提供从数字逻辑到完整 CPU 的渐进式路径。内容按实验一至实验七展开:定点加法与定点乘法两例夯实数据运算及加法器、乘法器设计,随后依次实现寄存器堆、ALU 模块与存储器,再进入单周期与多周期 CPU 的完整搭建;课程设计部分给出静态 5 级流水线 CPU 实现,拓展部分讨论指令调度、资源分配等优化方向,附录整理所实现的 MIPS 指令集与 CP0 寄存器,便于对照查表与结果验证。资源为 1 个 PDF 文件,约 6.09MB,按实验章节与附录编排,可直接用于备课、实验任务下达与验收参考。目前已有 681 人学习,适合需要成体系实验案例与硬件平台操作指引的高校教师及自学者。
1. 从一块会发光的板子说起:CPU 设计与体系结构实验到底在做什么
第一次听到「CPU 设计与体系结构实验」,不少新手的反应是:我连电脑怎么开机都说不清,就要去设计 CPU?其实这门课的门槛不在智商,而在有没有人先把话说明白。你面前那个带触摸屏、带按键、带一堆排线的实验箱,本质是一块能反复擦写的 CPU 草稿纸:芯片内部怎么连线,由你用几行代码描述,写错了改一行重新下载就行,板子不会烧、电容不会炸。
这份 LS-CPU-EXB-002 配套的指导手册,做的事情就是把这件听起来很唬人的事拆成八个小台阶:从 32 位加法器起步,经过乘法器、寄存器堆、ALU、存储器,拼出单周期 CPU、多周期 CPU,最后是五级流水线。它适合刚学完数字逻辑、想动手又不敢下手的人。读完这一篇,你至少知道自己第一步该打开什么软件、第一段代码该写什么、第一个波形该长什么样。
2. 先弄明白要学什么:一条从加法器到流水线 CPU 的路线图
新手的第二个困惑通常是「这么多实验,我到底在学哪一门」。答案是:这不是八门课,是一栋楼的八层,缺一层就上不去。
2.1 八个实验不是八门课,是一栋楼的八层
把整本手册想象成盖一栋楼。地基是实验一的定点加法——最基础的 32 位加法器,它决定后面所有运算部件长什么样。砖块是实验二的定点乘法和实验三的寄存器堆:乘法器靠「移位 + 加法」堆出来,寄存器堆则是 CPU 的草稿本,运算前先把数搁进去、算完再写回来。承重墙是实验四的 ALU 和实验五的存储器,一个负责算,一个负责存指令和数据。
真正拼成整栋楼是实验六的单周期 CPU:把前面所有零件用一条数据通路串起来,一条指令一个时钟周期走完。实验七的多周期 CPU 相当于给楼加了一部电梯——把一条指令拆成几个周期走,每个周期只做一小步,硬件复用度更高。最后课程设计的静态五级流水线,是让楼里的人能同时上下楼梯:取指、译码、执行、访存、写回五个阶段并排跑,吞吐量一下就上来了。
这个顺序有一个硬约束:乘法器里要用到加法器,ALU 里要用到加法器和乘法器,单周期 CPU 里要调 ALU、寄存器堆和存储器。你跳过实验三直接看实验六,会发现自己连顶层模块里那一堆端口名都对不上号。
2.2 为什么入门第一课是加法器,而不是直接写 CPU
很多人会想:既然目标是 CPU,为什么不直接抄一份 CPU 代码跑起来,非要先折腾一个加法器?因为加法器只有 5 行代码,却把整条开发链走完了一遍:新建工程、加源文件、写 testbench、跑仿真、看波形、写约束文件、综合布局布线、下载上板、在屏幕上看结果。这条链路你走通一次,后面七次实验只是把 5 行代码换成 500 行,流程一模一样。
反过来,如果你第一次就上手 CPU,报错信息会同时来自十几个方向——端口位宽对不上、时钟没接、约束文件写错、仿真顶层没设对。新手根本分不清是哪一个环节出的问题,这就是最容易劝退的地方。加法器的作用就是给你一个「只有一种错法」的环境,先把工具用熟。
另外,加法器本身也不是玩具。CPU 里所有地址计算、循环计数、指针偏移,底层全是加法。ALU 的第一块电路就是它。你把它吃透,后面学 ALU 会轻松很多。
2.3 上手前只需要两样底子:数字逻辑 + 一点点 Verilog
不用先把操作系统、编译原理啃完再来。真正的前置知识只有两样。
第一样是数字逻辑里的基本概念:组合逻辑和时序逻辑的区别、时钟上升沿、触发器和寄存器、真值表和卡诺图。你只要知道「组合逻辑的输出只跟当前输入有关,时序逻辑的输出还跟上一拍的状态有关」,就够开始了。手册里的实验三寄存器堆,本质就是一堆带时钟的触发器加上读写端口。
第二样是 Verilog 的最小语法集合。新手最容易在这里翻车,因为 Verilog 长得像 C,但它是描述电路,不是写程序。举个小例子对比:
// 描述电路:两条 assign 是同时生效的,没有先后顺序 module demo( input wire a, input wire b, output wire y, output wire z ); assign y = a & b; // 与门 assign z = a | b; // 或门 endmodule这段代码里,y和z是两根真实的导线,同时连着两个门,不存在「先算 y 再算 z」。如果你用写 C 的思维去理解,会一直想不通「执行顺序」这件事。入门阶段你只需要掌握四个语法点:module ... endmodule定义模块、input/output声明端口、assign写组合逻辑、always @(posedge clk)写时序逻辑。剩下的查手册就行。
2.4 每次实验都走同一条四步流水线:画图 → 写码 → 仿真 → 上板
手册里每个实验都按「实验目的 / 实验设备 / 实验任务 / 实验要求 / 参考实现」这个结构排。看起来是模板,实际上它在提醒你按固定节奏走。
第一步是画框图。在纸上把模块的输入输出端口一个个列出来,标清位宽。这一步花十分钟,能省掉后面两小时的调试。手册在实验一里专门要求「画出结构框图,详细标出输入输出端口」,就是这个意思。
第二步是写代码。先写功能模块(比如加法器本体),再写外围展示模块(比如把结果显示到屏幕上),层次要清楚。
第三步是功能仿真。写一个 testbench 灌输入激励,看波形对不对。仿真不过就别急着上板,上板之后你什么都看不见,只能靠猜。
第四步是综合、布局布线、下载。这一步要配约束文件,把顶层模块的端口绑到 FPGA 板子上真实的引脚。绑定错了,板子上的现象就是「什么都没发生」。
四步走顺了,后面每次实验你都可以照着这个模板推进,不用每次重新想流程。
3. 第一次上手:把 5 行 Verilog 加法器跑通
现在进入动手部分。下面以实验一「数据运算:定点加法」为主线,把每一步拆开。
3.1 建工程:器件型号照着抄,别自己猜
打开 FPGA 厂商配套的集成开发环境,菜单栏点File→New Project,进入新建工程向导。工程名和路径自己起一个,路径里不要有中文和空格,这是血泪经验——有些工具对中文路径的处理很玄学,编译到一半报个莫名其妙的错。
关键的一步在器件选择页:筛选器里依次选系列(7 系列)、封装(fbg676),然后在列表里点中手册指定的那一颗型号。这一步不能凭感觉选,选错了后面引脚约束会全部对不上,报错信息还不会告诉你是型号问题。
接着勾选RTL Project,并且勾上「Do not specify sources at this time」,先建空工程,源文件稍后再加。这一步新手经常漏掉,结果向导里被要求立刻提供源文件,卡在那里不知道怎么办。
工程建好后,在Project Manager里点Add Sources,选Add or create design sources,把写好的.v文件加进来。
3.2 加法器本体:一行 assign 省掉一堆门电路
加法器的完整代码如下:
`timescale 1ns / 1ps // 模块名 adder,两个 32 位加数、一个进位输入 module adder( input [31:0] operand1, // 加数 1 input [31:0] operand2, // 加数 2 input cin, // 低位进位 output [31:0] result, // 32 位和 output cout // 向高位的进位 ); // 拼接运算:把 cout 和 result 拼成 33 位一起赋值 assign {cout, result} = operand1 + operand2 + cin; endmodule逐行解释。timescale 1ns / 1ps定义仿真时间单位是 1 纳秒、精度是 1 皮秒,只影响仿真,不影响实际电路。input [31:0]表示这是 32 根线并排的一组总线,从第 31 位到第 0 位。assign是连续赋值,右边的表达式一变,左边立刻跟着变,等价于一根导线连着一个加法电路。
最关键的是{cout, result}这个拼接写法。如果只写result = operand1 + operand2 + cin,两个 32 位数相加溢出时,第 33 位会被直接丢掉,你就永远看不到进位。把cout拼到高位凑成 33 位,溢出位就保住了。
手册在这里给了一个值得记住的建议:可以直接写+号,让综合工具去调用内部的加法器 IP,面积和时序往往比你自己用全加器一层层搭出来的更好。入门阶段就用+,等你把原理吃透了再考虑自己搭门级电路。
3.3 testbench:给电路喂数据,波形就是你的后悔药
功能模块写完了,但它现在只是一段没人调用的代码。要验证它对不对,得写一个 testbench。
`timescale 1ns / 1ps module testbench; // 输入用 reg,因为要在 initial/always 里给它赋值 reg [31:0] operand1; reg [31:0] operand2; reg cin; // 输出用 wire,只能观察不能赋值 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; // 等 100ns,让全局复位稳定 end // 每过 10ns 换一组随机激励 always #10 operand1 = $random; always #10 operand2 = $random; always #10 cin = {$random} % 2; endmodule几个要点。reg和wire的区别是新手第一道坎:在 testbench 里,你要主动驱动的信号用reg,你要观察的被测模块输出用wire。#10表示等待 10 个时间单位,也就是 10 纳秒,所以三行always每 10 纳秒就换一组随机数。$random是系统任务,产生一个随机整数;{$random} % 2里的花括号是拼接符,作用是先把有符号数变成非负,再对 2 取余,得到 0 或 1 的随机进位。
加完 testbench 之后,如果文件名前面没有顶层的标记,右键选Set as Top,否则仿真跑的是空的东西。然后点Run Simulation→Run Behavioral Simulation,出波形窗口。
波形默认是二进制显示,32 位的数看成一长串 0 和 1,眼睛会瞎。在信号名上右键选Radix→Hexadecimal,换成十六进制。手册里给了一组可以手算验证的数据:b897be71 + 42f24186 + 1 = fb89fff7,你在波形里对着看,对上了就说明功能正确。
3.4 上板:32 位加法为什么非得靠触摸屏
仿真过了不等于板子上能看到。要上板,得解决「怎么把两个 32 位数输进去、怎么把结果看出来」这个问题。
最直觉的方案是用拨码开关输入、用 LED 灯输出。但 32 位加法需要 64 个拨码开关和 32 个 LED,实验板上根本没有这么多 IO。手册的方案是调用 LCD 触摸屏模块:屏幕上做输入框和显示框,触摸输入加数,屏幕上显示结果。
触摸屏的驱动被封装成一个黑盒网表文件(.dcp),你只需要在工程里把它加进来、在顶层模块里实例化、连好端口,不需要关心内部怎么实现。这个设计很聪明——如果让新手先去啃 LCD 时序,一周都进不到 CPU 那一步。
顶层模块的端口要绑到真实引脚,靠约束文件(.xdc):
# 时钟引脚 set_property PACKAGE_PIN AC19 [get_ports clk] set_property IOSTANDARD LVCMOS33 [get_ports clk] # 进位指示 LED set_property PACKAGE_PIN A3 [get_ports led_cout] set_property IOSTANDARD LVCMOS33 [get_ports led_cout] # 复位按键 set_property PACKAGE_PIN Y3 [get_ports resetn] set_property IOSTANDARD LVCMOS33 [get_ports resetn]每两行是一组:PACKAGE_PIN指定这个端口接芯片的哪个物理引脚,IOSTANDARD指定电平标准,这里用 3.3V 的 LVCMOS33。[get_ports xxx]是按端口名查找。引脚号不能自己编,必须照手册抄——编错一个,轻则那个功能没反应,重则把 IO 口顶坏。
约束文件加好后,点综合、实现、生成比特流、下载。板上屏幕出现输入框和显示框,你手点两个数进去,结果对得上,实验一就算完成了。
4. 新手最容易翻车的五个地方:现象、原因、解法
下面这五条,是我带新人时见得最多的。每条按「你看到什么 → 为什么 → 怎么办」写,遇到问题直接对号入座。
4.1 加文件时冒出问号:黑盒模块没找到
现象:工程管理区里lcd_module前面挂着一个问号,综合时报错说找不到这个模块的定义。
原因:.dcp网表文件没加进工程,或者加进去了但没被识别为设计源文件。手册里提到过,某些版本的开发工具需要先把.dcp解压再添加。
解决:回到Add Sources→Add or create design sources,把.dcp文件加进去,加完看一眼层次结构里问号是否消失。如果还在,检查文件是不是被加到了仿真源那一栏里。
4.2 仿真波形一片空白或者全是红线
现象:点了运行仿真,波形窗口里信号是直的,或者干脆什么都没有。
原因:通常是 testbench 没设成顶层,工具仿真的是一个没有激励的空模块;也可能是timescale写错,导致仿真时间跑得极慢,看起来像没动。
解决:看工程管理区里 testbench 文件名前有没有顶层标记,没有就右键Set as Top。另外确认always #10里的数字不是写成了#10ns这种非法格式。改完记得点Relaunch,光点运行是不会重新加载的。
4.3 综合能过,下载到板子上什么反应都没有
现象:流程全部走完,下载也提示成功,但屏幕不亮、灯不闪。
原因:大概率是约束文件里的引脚号或端口名跟顶层模块对不上。比如顶层模块里叫resetn,约束文件里写成reset_n,工具找不到这个端口,那条约束就被静默忽略。
解决:把顶层模块的端口列表和约束文件里的[get_ports ...]逐个对一遍,名字必须完全一致,大小写也不能差。再看一眼引脚号有没有抄错位,手册里的引脚表是一行一个,抄的时候容易串行。
4.4 代码改了,板子上的现象没变
现象:明明改了逻辑,重新下载后行为跟之前一模一样。
原因:改完代码没有重新跑一遍综合和实现,直接点了下载,下进去的还是上一次生成的比特流。
解决:养成习惯,改代码之后按顺序点「综合 → 实现 → 生成比特流 → 下载」,四步一个不落。很多工具在检测到源文件变动后会提示重新跑,别嫌烦,让它跑。
4.5 后面实验才有的坑:时钟没接对,整个 CPU 像在抽风
现象:单周期 CPU 做完了,仿真全对,上板后屏幕偶尔刷新、偶尔卡死,或者不同次下载表现不一样。
原因:时钟信号没有从板子的晶振引脚接进来,或者用了组合逻辑产生的「假时钟」。时序逻辑必须吃真实的、干净的时钟沿,你自己用逻辑门拼出来的时钟会带来毛刺。
解决:时钟一律从约束文件里指定的晶振引脚引入,接到所有时序模块的clk端口上。复位也一样,用板子上的按键,不要用逻辑产生。这条在后面流水线实验里尤其致命,因为流水线各级靠同一套时钟推进,时钟一抖,整条流水线就乱了。
5. 从单周期到五级流水:后面六次实验怎么接着跟
实验一跑通之后,你手里握着的是一套完整的开发流程。后面六次实验换的是内容,不是流程。
5.1 寄存器堆和 ALU:先把零件攒齐
实验三的寄存器堆是 CPU 的草稿本。它要支持两个读端口、一个写端口,还要有时钟控制:写操作在时钟上升沿生效,读操作是组合逻辑,地址一变数据就出来。写完一定要仿真,重点看「同一时刻写和读同一个寄存器」会发生什么——这是后面流水线数据冒险的源头。
实验四的 ALU 是把加法器、乘法器和一堆逻辑运算打包成一个模块,用一个控制信号选择当前做哪种运算。ALU 的输入端口除了两个操作数,还有运算选择码;输出除了结果,通常还要有零标志位。这个零标志位在后面做条件跳转时会用到。
这两个实验的代码量都不大,但端口定义一定要反复确认。ALU 的端口定义错了,实验六的单周期 CPU 里所有调用它的地方都得改。
5.2 存储器与单周期 CPU:第一次拼出一台整机
实验五的存储器分两块:指令存储器只读,数据存储器可读可写。入门阶段不需要考虑缓存,把地址和数据端口搞明白就行。
实验六是关键一跃。它要做的就是把前面四个零件用数据通路连起来:程序计数器给出地址 → 指令存储器取出指令 → 译码分出操作码和寄存器号 → 寄存器堆读出操作数 → ALU 运算 → 数据存储器访存 → 结果写回寄存器堆。一条指令在一个时钟周期内走完全程,所以叫单周期。
单周期 CPU 最容易出问题的地方是控制单元。它要根据指令的操作码,产生一堆控制信号:寄存器堆写不写、ALU 做哪种运算、存储器读还是写、下一条指令是顺序执行还是跳转。新手常犯的错是控制信号真值表列了一半就动手写代码,结果跑到某条指令就卡住。建议在纸上把支持的每条指令对应的一行控制信号全填完,再落代码。
手册附录里列了单周期 CPU 需要实现的 MIPS 指令集,入门时不要贪多,先把加法、减法、取数、存数、分支跳转这几条跑通,其余的后面慢慢加。
5.3 多周期与五级流水:性能是被「挤」出来的
实验七的多周期 CPU 把一条指令拆成取指、译码、执行、访存、写回几个周期,每个周期只做一件事。好处是硬件可以复用——比如指令存储器和数据存储器可以合并成一个,因为取指和访存不在同一个周期。代价是控制逻辑变复杂,需要一个状态机来指挥每个周期做什么。
课程设计的静态五级流水线,是把多周期的几个步骤「叠」起来同时做:
| 阶段 | 干什么 | 主要部件 |
|---|---|---|
| IF | 取指令、PC 加 4 | PC、指令存储器 |
| ID | 译码、读寄存器、算跳转地址 | 寄存器堆、控制单元 |
| EX | ALU 运算 | ALU、移位器 |
| MEM | 读或写数据存储器 | 数据存储器 |
| WB | 结果写回寄存器堆 | 寄存器堆写端口 |
五个阶段并排跑,理想情况下每个时钟周期都能完成一条指令,吞吐量是单周期的好几倍。但流水线一上,麻烦也跟着来:后一条指令要用前一条指令刚算出来的结果,这叫数据冒险;遇到分支跳转,后面已经取进来的指令可能取错了,这叫控制冒险。
入门阶段处理数据冒险最常用的办法是「转发」:ALU 的结果还没写回寄存器,就直接从流水线寄存器里取出来喂给下一条指令。处理控制冒险最简单的办法是「暂停」:分支指令一出现,让流水线停一两拍等结果。
关键结构是流水线寄存器,它把上一级的输出在时钟沿锁存,交给下一级:
// IF/ID 流水线寄存器:在时钟上升沿把取到的指令锁存下来 always @(posedge clk) begin if (!resetn) begin id_inst <= 32'b0; // 复位时清零,避免出现未知态 id_pc <= 32'b0; end else if (!stall) begin id_inst <= if_inst; // 指令从取指级传过来 id_pc <= if_pc; end // stall 为 1 时保持原值,相当于让流水线暂停一拍 end<=是非阻塞赋值,在时序逻辑里用,表示所有赋值在时钟沿同时生效,不会互相干扰。stall信号就是前面说的暂停机制:它为 1 时寄存器保持原值,前一级的新数据进不来,流水线就停住了。
流水线实验的调试难度比前面高一个档次,建议每加一级就仿真一次,别五级全写完再跑。哪一级的波形不对,问题就在那一级附近。
6. 下一步学什么:给自己排一张能坚持下来的练习表
手册读到这里,最怕的是「看完了,然后呢」。入门阶段真正决定你能不能走下去的,不是理解了多少原理,而是有没有连续四周、每周都动手。
如果你手上有实验箱,就按下面的顺序推:
| 周次 | 目标 | 完成标准 |
|---|---|---|
| 第 1 周 | 加法器 + 乘法器 | 波形正确、屏幕上能看到结果 |
| 第 2 周 | 寄存器堆 + ALU | 每个模块单独仿真通过 |
| 第 3 周 | 存储器 + 单周期 CPU | 能跑通加法、取数、存数三条指令 |
| 第 4 周 | 多周期或五级流水起头 | 至少把取指和译码两级跑起来 |
如果暂时没有实验箱,也别停。前五个实验的代码在纯仿真环境里都能验证,波形对了就等于功能对了,上板只是加一层显示。你可以先按这个节奏把模块一个个写出来、仿真出来,等有设备了再补上板那一步。
有一件事我要提醒:不要一开始就想着「把附录里的指令集全实现」。我第一次带新人时,有人上来就要做完整指令集 + 流水线转发,写到第三周卡在控制信号表里出不来,最后连单周期都没跑通。后来我学乖了,每次只加一条指令,加完仿真、跑通、再上下一条。慢,但不会返工。
从那以后我每次开新实验,都会强制自己先干一件事:在纸上把这次要实现的模块端口列全,位宽标清,输入输出方向标清,然后再打开软件。这十分钟的「预习」我已经坚持了很多年,因为它替我挡掉的返工时间,远远不止十分钟。
希望帮到你。
本文还有配套的精品资源,点击获取