简介:开放式实验CPU设计程序包围绕“CPU设计”实验主题,面向计算机体系结构课程的学生或希望深入理解处理器原理的开发者,提供从指令集架构到微架构实现的完整实践案例。资源以CPU调试实验6.13为核心,涵盖控制单元、ALU、寄存器堆、内存接口等模块,并涉及数据通路、流水线、RISC/CISC对比及Verilog/VHDL调试技术等关键知识点。整个压缩包共94个文件,以VHDL硬件描述源码、Quartus工程与配置文件、编译报告和仿真测试文件为主,另有少量文本说明,整体仅903KB,便于快速下载和学习。已有272人学习浏览,适合作为课程设计、自主实验或备赛参考。压缩包内包含测试程序、被调试CPU各单元源码以及完整编译与调试记录,能够帮助读者在动手过程中掌握仿真工具使用、断点设置、单步执行等排错手段,从而真正打通CPU设计从理论到实践的闭环。
开放式实验CPU设计:从指令集选择到上板验证的全过程复盘
每年做计算机组成原理实验的时候,总有一批人被“开放式实验CPU设计”这个标题卡住。说它难吧,其实要求的指令就那么几十条;说它简单吧,我见过不少同学在仿真阶段跑得挺好,一到上板就“黑屏”不知道从哪查起。这个实验真正的难点不在于“写代码”,而在于“做决策”——指令集选什么、单周期还是流水线、控制信号怎么定、验证到什么程度算合格,每个岔路口都没有标准答案,开放式无非是把这些选择权全部交给你。
我这次完整走了一遍从零开始的CPU设计流程,选型RISC-V,工具用Vivado,最终在FPGA开发板上把CPU跑了起来。这篇文章不打算重复教材里那种自上而下的理论推导,而是按我实际操作时的思考顺序,把每个关键节点的决策逻辑、实现细节和踩坑记录写清楚。位宽多少、控制信号怎么编码、testbench怎么组织、上板后怎么看结果,这些东西教材里讲得都偏理想化,真正动手时你会发现细节远比概念多。
不管你的课程实验是要求MIPS还是RISC-V,是单周期还是流水线,是仿真验收还是上板验收,这篇复盘都可以作为一个完整的参考框架。我会把自己当时做的选择、为什么这么选、选了之后遇到什么问题一一讲明白,你完全可以根据自己的实验要求替换指令集或微架构,但整体的推进路线是通用的。
1. 动手之前先想清楚:开放式实验的“开放”到底是什么
很多同学拿到这个题目后第一反应是打开搜索引擎找一份现成代码。这恰恰是开放式实验最大的陷阱——开放意味着没有标准答案,但也意味着你没有模板可抄。与其急着写代码,不如先花半天把下面这几个问题想清楚。
1.1 开放的四个维度:从哪里选、选什么、怎么选
开放式实验一般在四个维度上给你选择权:
- 指令集体系结构:是用教学经典的MIPS32,还是当前工业界主流、正在进入教材的RISC-V RV32I,甚至自己定义一套极简教学指令集(有些实验确实允许)。
- 微架构实现:单周期、多周期还是五级流水线。同样的指令集,三种实现方式的工作量差异很大,性能也有明显差别。
- 开发工具链与验证手段:Verilog还是VHDL,Vivado还是Quartus,仿真验证还是上板验证还是两者都要。
- 验收深度:有的实验只要功能正确,有的要求报告指令格式分析和时序图说明,有的还会要求扩展中断、异常或I/O外设。
这四个维度组合起来,可能的方案数量非常多。我当时给自己定的原则是:在不影响毕业要求的范围内,选择学习价值最高、但风险可控的组合。
RISC-V发展很快,很多学校近两年也把实验从MIPS迁移到RISC-V,加上网上公开的参考资源越来越多,所以指令集我选了RV32I——它是RISC-V的基础整数指令集,指令数量不到40条,非常适合教学,工具链GCC、模拟器都有现成的,出了问题还能借助SPIKE、Verilator等工具做对照调试。微架构方面,考虑到时间周期和实验要求,我选的是单周期实现。单周期的好处是控制逻辑直观、每个模块的职责清晰、调试难度低,对于第一次完整设计CPU的人来说,能先把“指令到底是怎么走通数据通路的”这件事彻底搞明白。流水线可以在单周期基础上慢慢加,一步到位反而容易在冒险处理上卡死。
1.2 一句话说清CPU设计的本质工作
CPU设计的核心其实特别朴素:每个时钟周期取出一个指令,通过译码产生控制信号,指挥数据通路完成一次确定的操作,然后更新状态,继续取下一条指令。
RISC-V之类精简指令集之所以教学友好,是因为它的指令编码极其规整,比如32位指令中操作码字段固定占低7位,译码逻辑可以用简单的case语句实现。而单周期CPU本质是一条固定的物理路径,每个模块之间通过总线连接,控制单元根据当前指令把这条路径上的“开关”拨到正确的位置。所谓“设计CPU”,就是定义这条路径上每一个模块的接口,以及拨动开关的控制信号逻辑表。
这个理解方式帮我少走了很多弯路。后面所有模块的划分都围绕它展开,每个信号都能归类于“数据”或“控制”,连接关系自然就清楚了。
2. 指令集选型:为什么我选了RV32I而不是MIPS或自造指令
2.1 三种指令集的现实对比
对于课程实验,选择指令集本质是在教学难度、资料丰富度和验收风险之间做平衡。我把三类常见选择做了个对比如下:
| 选项 | 指令数量 | 工具链成熟度 | 学习资料量 | 课程兼容性 |
|---|---|---|---|---|
| 自造极简指令集 | 8-16条 | 低(需自写汇编器甚至机器码转换) | 少 | 低,验收时无法借助通用工具 |
| MIPS32子集 | 30条左右 | 高(GCC支持、SPIM模拟器) | 很多 | 高,经典教材大量篇幅以MIPS为例 |
| RISC-V RV32I | 37条 | 高(GCC、SPIKE、Verilator都有现成支持) | 近两年快速增长 | 越来越高,多所高校已切换 |
如果只看眼前,MIPS反而最稳妥,毕竟教材和网络资料数量最多。但RISC-V的优势在于它的指令编码是模块化设计的,比例比较均衡,对理解现代处理器设计更贴近,而且但凡你后续工作、继续读研接触的题目,很少有围绕MIPS展开的。MIPS更适合“照教材抄作业”,RISC-V更适合“自己综合理解”。
2.2 RV32I指令的划分逻辑
RV32I共37条基础整数指令,按功能可以分成五组,这个分组直接对应后续控制单元的设计:
- R型运算指令(ADD、SUB、AND、OR、XOR、SLL、SRL、SRA、SLT、SLTU):双源寄存器加一个目的寄存器,功能由funct3和funct7共同决定。
- I型运算指令(ADDI、ANDI、ORI、XORI、SLLI、SRLI、SRAI、SLTI、SLTIU):寄存器加立即数,除了移位指令,funct3字段选定运算类型。
- Load/Store指令(LB、LH、LW、LBU、LHU、SB、SH、SW):访存指令,执行阶段ALU计算有效地址,再按字节/半字/字访问数据存储器。
- 分支指令(BEQ、BNE、BLT、BGE、BLTU、BGEU):比较两个寄存器,决定是否跳转。比较逻辑可以完全复用ALU的减法电路。
- 跳转与链接指令(JAL、JALR、AUIPC、LUI):这类指令承担地址计算和高位立即数加载,处理时要和普通运算指令分开,因为它们的写回来源不是ALU而是PC链路。
每组指令的译码逻辑都有共性,比如R型和I型运算指令共享ALU的计算通道,Load/Store共享地址计算结构。设计控制真值表时按组归纳,能大幅降低出错概率。
2.3 实验裁剪建议:不是指令越多越好
实验指导书如果没有硬性指令清单,我建议你给自己画一条“最小可用曲线”:先把ADD、SUB、ADDI、LW、SW、BEQ、BNE、JAL、LUI这9条指令完整贯通,让一条最简单的程序跑起来;在核心里增加AND、OR、XOR、SLT、SLL、SRL、JALR等常用指令,用于支持稍微复杂一点的验证程序;课外加分再考虑AUIPC、分支族的完整实现、结合移位器走通整数除法/开方。
这个顺序能保证无论时间多紧张,你都有一个可以在仿真里跑通的“活CPU”。千万不要一上来就想把37条指令全部覆盖,控制信号组合会瞬间爆炸,排错时很容易失去方向感。我当时是先用9条核心指令打通了数据通路,确认仿真波形正确后再逐步补齐其余指令,每条新指令从添加到验证通过控制在半天内。
3. 数据通路的搭建顺序:从PC到写回的六段旅程
代码组织结构因人而异,但无论你采用单文件还是多模块文件,数据通路的几个基本部件都是绕不开的。下面按数据实际流经的顺序拆分,这样写代码时也方便按图索骥。
3.1 取指骨架:PC怎么计算,指令存储器怎么读
程序计数器PC是整个CPU的“心脏起搏器”。单周期设计中PC在每个时钟上升沿更新一次,下一周期PC的来源取决于当前指令是否分支、是否跳转,这一点我建议统一用一个两输入的多路选择器实现:
always @(posedge clk) begin if (rst) pc <= 32'h0000_0000; else pc <= pc_next; end assign pc_next = branch_taken ? pc_branch // 分支或跳转目标 : pc_plus_4; // 顺序执行指令存储器在仿真阶段用Verilog数组初始化即可,常见做法是从hex文件加载机器码,这样验证程序不用写在RTL代码里:
reg [31:0] instr_mem [0:1023]; initial $readmemh("program.hex", instr_mem); assign instruction = instr_mem[pc[12:2]];要注意的是PC索引方式,RISC-V指令按4字节对齐,PC的低2位恒为0,所以索引数组时一般用PC的位段,也就是高30位对应的地址序号,别直接拿32位PC当索引,否则仿真时会出现数组越界的高阻态X。
3.2 译码与控制:一张真值表胜过十行case语句
寄存器堆是译码阶段的核心部件,RV32I定义32个32位寄存器,其中x0被硬件固定为0。寄存器堆接口有三组地址和两组输出端口,分别是两个源寄存器读端口和一个目的寄存器写端口,写操作发生在时钟上升沿,读操作是组合逻辑,这个时序在仿真里只要别写反成同步读都不会出大问题。
always @(posedge clk) begin if (reg_write && rd != 5'b0) regfile[rd] <= wd; end assign rs1_val = (rs1 == 5'b0) ? 32'h0 : regfile[rs1]; assign rs2_val = (rs2 == 5'b0) ? 32'h0 : regfile[rs2];控制单元的输入是instruction的opcode、funct3、funct7,输出是一组控制信号:寄存器写使能、内存写使能、ALUSrc(决定ALU第二操作数来自寄存器还是立即数)、ALUOp、MemtoReg(决定写回数据来自ALU还是内存)、Branch、Jump、MemRead等。单周期CPU的成败,几乎就看这批控制信号的编码是否正确。我当时画了一张真值表——行是指令名字,列是每个控制信号,每个格子填0、1或dc,填完之后再写Verilog里的case语句,排错效率提升非常明显。
3.3 执行与访存:ALU和内存访问共享一套数据通路
ALU的运算类型由控制单元的ALUOp、funct3和funct7共同决定。RV32I中R型指令的funct7位段大都有“0”和“32”两种取值,要判断是否需要把它纳入译码条件。简单起见,可以先把运算类型定义为信号,再在ALU里用case匹配:
always @(*) begin case (alu_ctrl) 4'b0000: alu_result = src_a + src_b; 4'b0001: alu_result = src_a - src_b; 4'b0010: alu_result = src_a & src_b; 4'b0011: alu_result = src_a | src_b; 4'b0100: alu_result = src_a ^ src_b; 4'b0101: alu_result = src_a << src_b[4:0]; 4'b0110: alu_result = src_a >> src_b[4:0]; 4'b0111: alu_result = $signed(src_a) >>> src_b[4:0]; endcase end访存阶段的数据存储器跟指令存储器类似,但要注意S型指令写入时是字节使能,硬件上真正实现时还要处理非对齐访问。课程实验里可以定一个规则:只支持地址按4字节对齐的访问,遇到非对齐直接按错误处理。这样能大幅降低访存逻辑复杂度,报告里说明即可,不能算偷工减料,现代CPU对非对齐访问的处理本来就是很复杂的硬件工程。
3.4 立即数扩展:RV32I最容易被忽略的编码细节
我在这块吃过亏。RISC-V的立即数扩展不像MIPS那样按类别复制符号位那么直观,它把立即数位段拆散分布在指令字的不同位置,Load指令用I型立即数——inst[31:20]、分支指令用B型立即数——来自inst[31]、inst[7]、inst[30:25]、inst[11:8]、JAL用J型立即数——分散在inst[31]、inst[19:12]、inst[20]、inst[30:21]。每类的拼接顺序都不一样,写之前最好先从RISC-V手册把位段图抄一遍,按位段重新拼接,别凭印象写。我当时用了一段专门函数处理,还随手写了几个指令码做单元测试,确保每类立即数在极端值下符号扩展正确。
之所以专门强调这节,是因为立即数错误在仿真里表现得很隐蔽:可能只差在一个数据的前几位不对,波形看起来完全正常,但是写回寄存器后整个程序全错,排查困难。所以一定要针对I/B/S/U/J每类立即数分别写断言验证。
4. Vivado工程搭建与调试:把“写到哪算哪”换成分步验证
4.1 工程结构与模块划分
Vivado新建RTL工程时,我建议按模块文件方式组织,比单文件更利于排查定位,也符合工程开发的习惯。我的模块清单如下:
- pc.v: 程序计数器
- instr_mem.v: 指令存储器
- regfile.v: 寄存器堆
- alu.v: ALU运算单元
- imm_gen.v: 立即数扩展模块
- control.v: 主控制单元
- alu_ctrl.v: ALU控制子单元
- data_mem.v: 数据存储器
- cpu_top.v: 顶层模块,负责例化与连线
顶层模块最重要,所有模块的输入输出接口都要汇总到这里。写顶层时最容易出错的是信号名拼写不一致,Vivado的elaboration检查不一定在所有情况下都能快速给出清晰提示,有时候一个信号在子模块里叫reg_write,在顶层写成regwrite,系统会当成正则表达式的两个不同信号,直接导致连接悬空。建议出仿真结果前,先把“connection”相关的warning全部过一遍。
4.2 testbench编写策略:先造仿真指令,再写波形检查
testbench的核心不只是加时钟和复位,更重要的是要有“观察点”。很多人写好testbench后只等着看波形变化,发现波形乱了才开始改代码,这是低效的。我建议在testbench里加一个自动比对逻辑,每一周期把模拟器(比如用SPIKE或用Python写的简易模拟器)算出的期望值和CPU输出的寄存器值做对比,不一致就打印错误信息,仿真结束统计错误数。
initial begin // 加载程序 // 时钟复位 repeat (50) begin @(posedge clk); if (sim_regfile[reg_addr] !== expect_regfile[reg_addr]) $display("Error at cycle %0d: reg[%0d] = %0h, expect %0h", cycle, reg_addr, sim_regfile[reg_addr], expect_regfile[reg_addr]); end $finish; end这里的期望值来自GCC编出来的RV32I汇编,再用SPIKE或自己手写一个小解析器得到。如果嫌麻烦,退而求其次的方式是用一组手工精心设计的汇编测试程序,每条指令前面安排一条不该修改某寄存器值的“哨兵指令”,执行完后检查哨兵是否被意外改动,能捕捉相当一批写错写回地址的问题。
4.3 常见仿真隐坑:时钟、复位与X态传播
仿真是个大坑聚集地,我挑三个最常见的排错经验说说。第一个是复位信号必须同步释放或至少是在时钟沿前建立好,如果复位信号在时钟边沿附近变化,可能导致部分寄存器复位、部分没复位,波形上会看到一堆X态,整条链路全部变红。第二个是X态传播问题,你的指令存储器初始值没写全或某个RAM单元没有正确初始化,读出来的指令是X,接着整个数据通路从取指开始全部变X。要判断问题根因,直接看第一个周期的PC和instruction信号,如果PC正常但instruction是X,先查hex加载路径。第三个是无意的latch,组合逻辑里的case没写default,或者if没有else,综合时Vivado会提示latch inferred,仿真阶段这类问题往往不报错,但上板时序就完全不对。
5. 单一周期改流水线的岔路口:值得做吗,怎么做
如果你的实验要求是流水线实现,或者你想拿它冲刺高分,那就要明白流水线改造不是简单地在模块上加寄存器这么简单。
5.1 流水线改造的真实工作量
单周期到五级流水线,每个阶段之间需要加流水线寄存器(IF/ID、ID/EX、EX/MEM、MEM/WB),目的是把每个阶段的中间结果锁存到下一拍。改造本身的工作量其实还好,真正的复杂度在冒险处理上。数据冒险需要前递(forwarding)加气泡(stall),控制冒险需要分支预测或者至少是简单预测。如果你直接写一个带前递和不带预测的流水线,波形调试的时间可能数倍于单周期。
5.2 实验语境下的合理范围
如果只是课程实验,一个比较合理的方案是:在单周期基础上增加IF/ID和ID/EX两级流水线寄存器,做一个“准两级流水线”,只处理数据冒险中的RAW冒险,对控制冒险采用flush掉错误分支指令的方式。这样你掌握了流水线寄存器的组织方法和冒险处理的基本思路,却又不会陷入分支预测器的深水区。我在后来的扩展里就是这么干的,代码量只增加了约20%,但报告里讲清为何采用flush不采用预测后,答辩老师普遍认可。
5.3 哪个扩展方向性价比最高
对于想在实验里出彩的同学,我建议重点考虑三件事:第一是增加中断或异常支持,对外部中断请求做响应、保存返回地址到特定CSR,这比加乘法指令更能体现系统级理解;第二是增加总线接口,让CPU通过简单的总线协议访问外部串口或GPIO,上板能打印字符串或点亮数码管,展示效果非常直观;第三是增加Cache或者至少是指令/数据分离存储,不过这个对课程实验来说偏重,酌情考虑。我个人认为,上板展示的优先级最高,看得见摸得着,评委交流起来也容易。
6. 上板验证与实战排错:仿真过了为什么板子是黑的
6.1 仿真与上板的本质差异
仿真环境是理想的:时钟稳定、信号无偏差、无引脚约束时序要求。上板就完全不同了,综合后要满足时序约束,不满足约束就会出现亚稳态、毛刺甚至功能错误。最大的差异在三块:真实时钟信号可能有抖动或相位问题;内部信号延时导致控制信号不同时到达;部分初始值在上电前不确定,没有可靠的复位就不可能进入确定状态。
6.2 我在上板时排过的五个实际问题
问题一:LED灯不亮,整体无反应。排查下来是复位信号的引脚约束没有写在XDC文件里,导致复位端口悬空,上电状态不定。解法是把复位引脚绑定到板载按键或直接使用芯片的POR复位逻辑。
问题二:程序跑飞,数码管乱跳。原因是时钟未经BUFG走全局时钟网络,Vivado会报clock not routed as global clock,表面上看综合能过,但运行时延太大,时序已崩。解法是在时钟原语或端口约束里让工具自动缓冲分配。
问题三:部分指令执行正确、部分错误,集中在指令存储器初始化部分。因为hex文件路径在仿真时用相对路径,上板后不可用,Vivado综合时没有把RAM初始化数据打进去。解法是使用$readmemh的绝对路径,或者直接在RTL里用parameter数组初始化。
问题四:分支跳转偶尔出错,波形很难复现。单周期内有两个沿——PC更新沿和寄存器写沿,如果分⽀判断逻辑在写沿之后才稳定,下一拍PC就取错了。解法是看清时序:PC更新的组合逻辑必须在PC时钟沿前稳定建立。
问题五:拨码开关拨动没反应,实测是XDC文件里引脚约束没有配置内部上拉/下拉,所以拨码悬空时读到的是高阻状态。解法是外加约束IO标准为LVCMOS33,并启用内部上拉或下拉。
6.3 一套好用的演示验证程序
演示程序不需要太复杂,但最好能直观展示CPU的通用计算能力。我当时的演示程序功能是:读取拨码开关数值,累加模7后送数码管显示,同时每隔一定循环次数把累加值通过UART发送到PC串口终端。这个程序既包含读取输入、读写内存、数学运算、条件分支、循环控制,也包含外围总线交互,工作量不大,验收时的感觉比单纯跑个流水灯强非常多。
你如果时间紧,一个流水灯程序也能证明CPU能执行指令、能写内存和输出接口,但它不太能证明你的CPU是从取指到访存到写回的完整通路都对。至少留一个内存操作的演示项,比如把输入采样到内存,再读回来比较,再输出,这样才能覆盖访存路径。
7. 交给下一个做CPU设计实验的人
整个项目做完,我最大的体验是:CPU设计没有想象中那么高不可攀,但每一个“看起来很简单”的模块都在测试你对整体数据通路的把握程度。仿真阶段多花一天写自动测试,胜过上板后花三天乱试;控制信号真值表画得越细,后面排错就越轻松;上板阶段老老实实检查XDC引脚约束,能帮你省掉大半“黑屏”噩梦。
如果你马上要开始做这个实验,我给你的行动顺序是:先拿9条核心指令把数据通路贯通并且仿真通过,再补全指令集,然后做上板最小演示,最后再根据个人余力决定是否做流水线、中断或总线扩展。反过来如果想一步到位做复杂的流水线,大概率会在冒险处理上卡到期末。开放式的题,核心考的就是在限定时间内做出合理取舍,这个能力,比CPU本身的电路设计更值钱。
本文还有配套的精品资源,点击获取