简介:单周期CPU设计项目以Verilog为描述语言,在Vivado中完成从RTL编写、仿真到综合实现的全套流程,适合计算机组成原理课程设计、FPGA入门及体系结构学习者。工程内包含指令寄存器、指令解码器、ALU、数据寄存器、控制单元、内存接口与程序计数器等关键模块,并通过取指、译码、执行、写回四个阶段直观展示处理器工作原理。压缩包共107个文件,约1.31MB,含12个.v源码文件,以及仿真日志、JTAG会话记录、编译批处理脚本、工程约束与综合网表(.log/.jou/.bat/.xpr/.dcp)等,可据此还原完整开发与调试过程。资源已有3622人学习下载,对想要亲手实践单周期CPU、理解Vivado工程组织方式及常见时序约束问题的读者来说,是一份贴近实战的参考项目。从这里入手,可以快速掌握数据通路构建、控制信号生成和FPGA布局布线的基本思路。 说实话,在Vivado里把板卡上的灯点亮、跑个计数器,跟你真正理解“一台计算机是怎么跑起来的”,中间隔着一道坎:能不能用硬件描述语言把CPU的数据通路搭出来。单周期CPU设计正是跨越这一步最好的项目——控制器、运算器、存储器全都落地,一条指令一个周期执行完,概念最纯粹、调试最直观。这篇把我用Vivado从零搭单周期CPU的完整过程、模块划分、仿真方法、以及综合上板阶段反复踩过的坑都整理出来,适合正在学计算机组成原理、或者刚掌握Verilog想进阶的读者参考。
1. 为什么第一个CPU要选单周期,而不是流水线
1.1 单周期CPU的本质与学习价值
很多人卡在计组教材的数据通路图上:PC、寄存器堆、ALU、数据存储器、控制单元,箭头画得密密麻麻,好像看懂了,一关上书就写不出几行代码。我当初也是这个状态。后来鼓起勇气在Vivado里动手写,才发现真正阻碍我的不是Verilog语法,而是对“控制信号从哪来、到哪去”没有形成闭环。
单周期CPU的意思是CPI等于1,每条指令都在一个时钟周期内完成,下一条指令在下一个时钟沿到来后才开始。代价是时钟周期必须按照最长的指令路径来定,性能不高,但控制逻辑极其清晰:所有的控制信号都由当前指令的操作码当场产生,是纯组合逻辑,不需要状态机、不需要时序调度。这对于第一次做CPU设计来说太重要了——你再多一个状态量,调试难度直接翻倍。
Vivado在这个项目里扮演的不只是“编译Verilog的工具”。它自带的行为仿真器能让你看到每个时钟沿上PC、指令、寄存器堆的变化;综合后生成的原理图能让你对照书本验证数据通路;上板之后还有ILA逻辑分析仪可以实时抓内部信号。等于给计组课配了一台电子显微镜,所有抽象概念都有了对应物。
1.2 指令集范围怎么定
做单周期CPU之前,先划指令集范围。我建议不要贪多,8条左右的类MIPS指令足够,或者用RISC-V RV32I的一个子集也可以。怕选型的读者可以直接抄这个组合:
- 算术运算:add、sub、and、or、slt
- 访存指令:lw、sw
- 跳转指令:beq、j
为什么不选mul、div这类?因为单周期里一个周期要完成乘法,组合逻辑路径会非常长,时钟频率被拖得很低,综合实现也容易报时序问题。先把9条指令跑通,后续再扩展完全来得及。用类MIPS的另一个好处是Patterson教材里就是这套指令,控制信号真值表可以直接对照书看,省去自己推导的功夫。
2. 数据通路与控制真值表:写RTL前必须过的坎
2.1 一条指令在单周期数据通路里怎么流动
动手写Verilog之前,我劝你先在纸上把数据通路画一遍,哪怕画得丑。以lw指令为例,它在单周期里要走的路径是:
- PC把地址送到指令存储器,取出32位指令;
- 指令里的rs、rt、rd、立即数等字段被拆分,送到寄存器堆和控制单元;
- 寄存器堆读出两个源操作数;
- ALU把其中一个寄存器值和符号扩展后的立即数相加,算出访存地址;
- 地址送到数据存储器,读出数据;
- 读出的数据写回寄存器堆的rt寄存器。
这六步必须在一个时钟周期内完成,所以控制信号在同一个周期内稳定输出,最后在时钟上升沿统一写回PC和寄存器堆。PC的更新逻辑也简单:默认PC + 4;如果是beq且ALU的Zero标志为1,则跳转到PC + 4 + 符号扩展立即数左移两位的地址;如果是j指令,直接跳转到指令低26位左移两位拼上PC高四位。
2.2 控制信号的来源与真值表
控制单元本质是一个以opcode为输入的译码器。我刚开始老犯的错是想用“状态机”实现控制单元,这是典型的把多周期思路混进来了。单周期里控制信号必须在一个周期内稳定,它只能是组合逻辑。
控制信号主要包括:RegWrite、ALUSrc、MemWrite、MemtoReg、Branch、Jump、ALUOp。以下是核心指令的控制信号对照表,也是我每次调试时翻得最多的表:
| 指令 | RegWrite | ALUSrc | MemWrite | MemtoReg | Branch | Jump | ALUOp |
|---|---|---|---|---|---|---|---|
| add/sub/and/or/slt | 1 | 0 | 0 | 0 | 0 | 0 | 10 |
| lw | 1 | 1 | 0 | 1 | 0 | 0 | 00 |
| sw | 0 | 1 | 1 | x | 0 | 0 | 00 |
| beq | 0 | 0 | 0 | x | 1 | 0 | 01 |
| j | 0 | x | 0 | x | x | 1 | xx |
ALUOp还需要和指令的低6位funct字段组合,才能生成最终的ALU控制信号。8条指令用4位ALU控制信号就够:and用0000,or用0001,加法用0010,减法用0110,slt用0111。规律是:lw/sw不管funct是什么,ALU都做加法;beq必须做减法来比较相等;R型指令才根据funct译码。这些逻辑写成Case语句也就十几行,但如果你不先整理真值表,写出来的代码必然是一团乱麻,后面仿真错了也说不清是数据通路错还是控制信号错。
3. 模块划分与Verilog实现要点
3.1 模块清单与连接关系
数据通路理清之后,代码就是水到渠成的事。我把设计拆成六个模块,每个模块只干一件事:
- pc:程序计数器,带同步复位
- imem:指令存储器,只读,用$readmemh初始化
- regfile:寄存器堆,两读一写
- alu:算术逻辑单元
- ctrl:控制单元,输出所有控制信号
- dmem:数据存储器,可读可写
- top:顶层模块,负责例化和连线
从风格上我建议统一用非阻塞赋值,尤其是PC和寄存器堆的写操作,否则综合后容易出现仿真和硬件行为不一致的问题。组合逻辑模块(ALU、控制单元)用assign或always@(*)搭配阻塞赋值都行。
3.2 核心模块代码与关键细节
PC模块的写法很固定,关键是PCSrc的信号组合要搞清楚:
always @(posedge clk) begin if (rst) pc <= 32'h00000000; else if (jump) pc <= {pc[31:28], instr[25:0], 2'b00}; else if (branch && zero) pc <= pc + 4 + ({{14{instr[15]}}, instr[15:0]} << 2); else pc <= pc + 4; end寄存器堆的注意点有两个。第一,读口是组合逻辑,写口是时钟沿触发,这是MIPS风格寄存器堆的通用写法;第二,写数据要区分来自ALU结果还是数据存储器,这个选择由MemtoReg控制:
always @(posedge clk) begin if (we) regfile[wa] <= wd; end assign rd1 = regfile[rs1]; assign rd2 = regfile[rs2];写寄存器堆时还有一个坑:如果wa等于0,即要写入$0寄存器,MIPS的规定是$0永远是0,必须强制屏蔽写入,否则程序跑出来的结果会莫名其妙错位。很多新手踩过这个坑。
ALU部分就没太多悬念了,以一个4位的alu_ctrl作为输入,Case分支实现各种运算。控制单元的代码量和指令数量直接相关,用Case opcode生成RegWrite、ALUSrc、MemWrite、Branch、Jump、ALUOp,再用一组assign或always把ALUOp和funct组合成alu_ctrl。这里我强烈建议把控制信号先定义成wire或者reg然后逐位赋值,不要一次性写一个超长的if-else链,否则后续加指令时排查极其痛苦。
4. Vivado里的完整流程:仿真先行,综合后上板
4.1 工程创建与文件组织
在Vivado里新建工程,器件型号一定要和你手上的板卡对上。我用的是Basys3,型号是xc7a35tcpg236-1;如果你用的是Nexys4,则是xc7a100tcsg324-1。选错型号会导致后面的管脚约束无法通过,甚至生成的比特流根本下载不进去。版本方面,Vivado 2018.3到2024.1都行,选自己熟悉的稳定版本就好,这个小设计对工具版本不敏感。
文件组织建议把RTL、仿真文件、约束文件分开放。我的习惯是rtl/、sim/、xdc/三个目录,这样综合时能清楚哪些是设计源文件哪些是仿真源文件。添加源文件时注意,testbench和$readmemh加载的汇编hex文件不要被误加进综合源文件列表,否则会影响只读存储器初始化的行为。
4.2 仿真与程序加载
单周期CPU的调试绕不开仿真。Vivado自带的XSim对于这个规模的设计完全够用,没必要特意去配ModelSim联合仿真,少一个工具就少一处坑。testbench的核心任务有三个:产生时钟、产生复位、加载程序。
时钟产生用最常用的写法,周期设10ns,对应100MHz,但单周期CPU最长路径不一定能在100MHz下收敛,仿真时无所谓,上板前再根据时序报告调整约束:
initial clk = 0; always #5 clk = ~clk;复位我习惯在testbench里保持20ns高电平,让PC和寄存器堆先归零,然后拉低开始跑程序。程序文件的格式是每行一条32位十六进制指令,用$readmemh("program.hex", imem.mem)加载。你可以在顶层直接给imem存储器数组命名为mem,这样testbench里就能用层次化引用来初始化。
跑完一个程序后不能只看波形“跳动了一下”,要在testbench里加断言或者用$display打印关键寄存器的值。我习惯在每个时钟上升沿打印PC、当前指令、写回寄存器的编号和值,几百个周期跑下来,程序执行的每一步都在终端里留下记录,定位错误比盯波形高效得多。仿真通过后再跑综合、实现、生成比特流,顺序别乱。
5. 综合实现和上板阶段的报错排查实录
5.1 约束报错:set_clock_groups找不到对象
这个报错几乎每个Vivado新手都会遇到,完整提示类似“set_clock_groups: no valid object(s) found for '-group [get_clocks clk]'”。问题根源通常是XDC文件里引用了不存在的时钟对象。要么是时钟端口名写错了,比如设计里叫sys_clk,XDC里写成clk;要么是还没有用create_clock定义这个时钟,直接就想用get_clocks去抓。
排查思路是先在Tcl控制台运行get_ports和get_clocks,看设计里实际存在的名称。然后按标准写法先建时钟再分时钟组:
create_clock -period 10.0 [get_ports clk] set_clock_groups -asynchronous -group [get_clocks clk]有一个小经验:create_clock用的对象是端口get_ports,而set_clock_groups里用的是时钟对象get_clocks,两者不要混写。报错报在set_clock_groups时,九成是前面create_clock那一步没写或写错。
5.2 比特流失败与仿真闪退的坑
生成比特流失败最常见的原因是时序约束没满足。很多人的第一反应是“我的逻辑没错啊”,其实逻辑正确只代表功能仿真通过,综合实现后信号经过LUT和布线会有物理延迟,如果关键路径超过时钟周期,就会报时序违例。单周期CPU的关键路径一般在“PC → 指令存储器 → 寄存器堆读 → ALU → 数据存储器 → 寄存器堆写”这一串。解决办法不是改逻辑,而是先看时序报告里最差的路径出现在哪个模块,再决定是放宽时钟周期(比如把约束从10ns改成20ns)还是优化组合逻辑。
仿真闪退这个坑在Windows版Vivado上比较常见,尤其是仿真时间设得很长、记录波形过大时。我遇到过两次,一次是磁盘空间不够,一次是波形文件里存了太多无意义信号。对策是仿真时间别贪长,先跑几百个周期验证核心功能;波形窗口里只勾选关心的信号;再不行就换用batch模式跑仿真。如果遇到Vivado安装过程中winpcap安装失败、打开elaborated design闪退这一类问题,它们通常不影响仿真、综合和下载比特流的正常流程,先跳过继续用就行,不用卡在安装环节。
6. 上板验证方案:让CPU的运行状态看得见
6.1 LED、ILA还是串口
仿真通过后上板,你面临的下一个问题是:怎么知道CPU真的在板上跑对了?如果只是让某个寄存器的值通过LED输出,信息量太小;上串口又要额外写UART模块,对初学者来说又多一个变量。我的建议是直接用Vivado自带的ILA(Integrated Logic Analyzer)逻辑分析仪,它是这个场景下性价比最高的验证方式。
ILA相当于在FPGA内部“插”一组探针,把PC、指令、寄存器堆写信号、数据存储器写信号等实时抓出来,通过JTAG回传到Vivado的Hardware Manager窗口里显示。你不用在外接任何设备,就能看到CPU内部的运行波形。我曾经靠ILA抓到一个beq跳转地址算错的bug——跳转目标比正确位置提前了一条指令,波形上看得清清楚楚,这要在板子上纯靠LED盲猜,估计要折腾一晚上。
6.2 管脚约束与调试流程
上板前先写XDC约束文件,最基础的三个约束是时钟、复位、指示灯:
set_property PACKAGE_PIN W5 [get_ports clk] set_property IOSTANDARD LVCMOS33 [get_ports clk] set_property PACKAGE_PIN V17 [get_ports rst] set_property IOSTANDARD LVCMOS33 [get_ports rst]注意不同板卡的管脚号不一样,务必参考自家板卡的手册。约束文件的坑集中在IOSTANDARD漏写,或者把时钟引脚接到普通IO口上,导致时钟质量很差、系统跑飞。
ILA的使用流程分两步。第一步在设计中例化ila_0核,把要观察的信号连上去;第二步综合实现后在Hardware Manager里连接板卡,下载比特流,触发条件可以设为复位信号下降沿,这样上电复位结束后开始采集,正好抓满整个程序的执行过程。资源占用方面,9条指令的单周期CPU在Artix-7上大概只消耗几百个LUT和一两块BRAM,加上ILA也远没达到资源瓶颈,所以不用为资源发愁,大胆加探针。
我自己做完这个项目后最深的体会是:计算机组成原理课上那些“控制信号”“数据通路”的名词,和你在Vivado里亲眼看到它们协同工作,是两种完全不同的理解深度。如果把时钟频率降到10MHz,把程序改成循环累加,再在ILA里观察PC的递增,那种“我真的造了一个会跑程序的机器”的实感,远不是仿真能给的。后续想进阶的话,可以试着在现有单周期基础上加更多指令、改成多周期,或者进一步挑战五级流水线;但前提是把单周期这一步彻底吃透,别跳到流水线后再回头补控制逻辑的课。
本文还有配套的精品资源,点击获取