☰
PC加ROM取指模块设计:Quartus II与ModelSim仿真验证全流程
2026/10/3 1:27:48 网站建设 项目流程

在数字逻辑和计算机组成原理的课程设计里,程序计数器PC加ROM取指这个题目出镜率极高。我今年带学生做实验时发现,真正把这两个模块用Quartus II搭起来、跑通仿真的人,并没有想象中那么多。很多人不是不懂原理,而是不知道在Quartus II里从哪里下手,模块之间怎么连,ModelSim仿真的波形一片红又该怎么排查。这篇文章就基于我实际调试过程中的完整经验,把PC模块设计、PC产生ROM地址,以及RTL仿真验证这条链路从头到尾讲清楚。目标读者是正在做课程设计的同学,以及刚接触FPGA、想把"CPU怎么取指令"这件事弄明白的自学者。

1. 项目概述与设计思路

1.1 程序计数器PC的核心作用

程序计数器(Program Counter,简称PC)是CPU里最基础的寄存器之一,它的任务非常纯粹:保存当前正在执行的指令的地址。CPU每取完一条指令,PC会自动指向下一条指令的地址,这样程序才能一条接一条地顺序执行下去。你可以把它理解成读书时夹在书页间的书签——每读完一页,书签自动往后移一页。只不过PC的"自动后移"是由时钟信号驱动的:每个时钟上升沿到来时,PC的值会加上一个固定步长,送到下一条指令的地址上。

如果程序里出现了跳转指令,PC的行为就会改变。比如无条件跳转要把跳转目标地址直接装载进PC,条件跳转要根据条件是否满足来决定是继续顺延还是跳到别的地方。在真实的处理器中,PC还要考虑流水线冲突、异常处理等情况,但在课程设计这个阶段,我们只需要抓住三个核心操作:复位清零、顺序递增、可选跳转。先把这三个行为搞明白,对整个CPU的理解就稳了一大半。

1.2 PC与ROM地址的映射关系

ROM在这里扮演的角色是指令存储器,里面预先存放了一段程序(也可以理解成一组数据),CPU要执行程序,就必须按地址把指令一条条读出来。而地址从哪里来?就是PC。PC的输出端直接连到ROM的地址输入端,所以PC的值和ROM地址是一一对应的关系:PC=0时读出ROM地址0的指令,PC=1时读出地址1的指令,以此类推。

有一点需要特别提醒:这里的ROM是电子设计中的只读存储器,不是手机上刷机包那个"ROM",别搞混。在做这个实验前,你最好在纸上画一下连接框图:一个方块代表PC,输出标记为pc_out;另一个方块代表ROM,左侧输入是地址addr,右侧输出是数据data。然后把pc_out用一条线连到addr上。画完这张图,整个项目的结构就清楚了:PC负责"走地址",ROM负责"出指令",两个模块合起来就是CPU取指阶段的缩影。

1.3 工具链选型:为什么是Quartus II加ModelSim

Quartus II是Intel(原Altera)的FPGA开发环境,承担设计输入、综合、布局布线和下载配置等工作。ModelSim则是业界使用非常广泛的仿真工具,用来在电脑上验证你的设计逻辑是否正确,不需要把代码烧到真实板子上就能看到信号波形。这个组合几乎是国内高校EDA和计算机组成原理课程的标配,原因很直接:Quartus II自带对ModelSim的调用接口,工程里指定好仿真工具后,点一个按钮就能把Testbench跑起来,省去大量手动配置的麻烦。

版本选择方面,我个人最推荐Quartus II 13.0sp1或13.1,它们功能稳定,对Cyclone IV、Cyclone II这些教学板常见器件支持很好,Web Edition版本免费就能用。太高版本(比如Quartus Prime 18.1以后)对老型号FPGA支持反而收紧,很多学校的老开发板直接不认。安装时顺序通常是先装ModelSim再装Quartus,也可以装带Altera定制版ModelSim的一体包,破解和License设置按下载包里的说明操作即可,本质就是把License文件里的网卡MAC地址换成你自己电脑的,再加上环境变量。

2. 模块设计与代码实现

2.1 PC模块实现:计数器加跳转逻辑

PC本质上就是一个带使能、带复位、带可选跳转的计数器。下面这个版本我加上了参数化位宽和跳转功能,兼容性比较强,也方便以后扩展成真正的CPU取指模块:

module pc #( parameter PC_WIDTH = 8 ) ( input wire clk, input wire rst_n, input wire pc_en, input wire jump_en, input wire [PC_WIDTH-1:0] jump_addr, output reg [PC_WIDTH-1:0] pc_out ); always @(posedge clk or negedge rst_n) begin if (!rst_n) pc_out <= {PC_WIDTH{1'b0}}; else if (jump_en) pc_out <= jump_addr; else if (pc_en) pc_out <= pc_out + 1'b1; else pc_out <= pc_out; end endmodule

逐行说几个关键点。rst_n是低电平有效的异步复位,信号为0时PC立即清零,不用等时钟沿,这符合大多数FPGA开发板的按键复位习惯。pc_en是使能信号,只有它为1时PC才会加1,相当于给CPU加了"暂停"能力——这在调试和单步执行时非常有用。jump_en和jump_addr是跳转逻辑,当jump_en拉高时,PC的值变成jump_addr,而不是继续加1。如果你做得更精简,可以把跳转部分删掉,只保留复位和加1逻辑,课程设计的基础要求足够满足。

这里还有一个常见设计选择:复位用异步好还是同步好?我给的代码是异步复位,也就是复位信号一拉低,输出立刻清零。缺点是如果复位信号上有毛刺,容易误触发;优点是不依赖时钟,板子上电后容易被可靠复位。FPGA内部一般推荐异步复位、异步释放的处理方式,但作为课程设计,直接用这种最简单的异步复位写法完全没问题。

2.2 ROM模块实现:MIF文件与MegaWizard配置

ROM的实现方式有两条路:一是直接用Verilog写一个组合逻辑的case语句来模拟ROM内容,适合小容量教学演示;二是用Quartus II里的MegaWizard生成真正的ROM IP核,配一份.mif初始化文件,更接近真实工程。两种我都建议试一遍。

先说最简单的方式,用case语句模拟ROM内容:

module rom #( parameter DATA_WIDTH = 8, parameter ADDR_WIDTH = 8 ) ( input wire [ADDR_WIDTH-1:0] addr, output reg [DATA_WIDTH-1:0] data ); always @(*) begin case (addr) 8'h00: data = 8'h20; 8'h01: data = 8'h30; 8'h02: data = 8'h21; 8'h03: data = 8'h10; 8'h04: data = 8'h40; default: data = {DATA_WIDTH{1'b0}}; endcase end endmodule

case里每一项就相当于ROM里一个地址单元的内容。这里我随手写了一些数据,实际你可以填成一段简单的指令编码,比如20表示"把立即数装入R1",30表示"把立即数装入R2",21表示"R1加R2",这样仿真时就能看到一个很抽象的"程序执行"过程。

如果你用的是MegaWizard方式,流程是这样的:在Quartus里打开MegaWizard Plug-In Manager,选择LPM_ROM或者altsyncram,设置数据位宽和地址位宽,然后在"Memory Initialization"这一步指定.mif文件。.mif文件是Quartus的存储器初始化格式,纯文本就能编辑,内容大概是这样:

WIDTH = 8; DEPTH = 16; ADDRESS_RADIX = HEX; DATA_RADIX = HEX; CONTENT BEGIN 0 : 20; 1 : 30; 2 : 21; 3 : 10; 4 : 40; [5..15] : 00; END;

其中WIDTH是数据位宽,DEPTH是地址深度,地址和数据格式我习惯用HEX,看起来直观。[5..15] : 00;这种写法表示地址5到15全部写0,省得一行行敲。这个文件的路径千万不要用中文,也不要随便移动位置,否则Quartus找不到了,综合时编译能过,但仿真时ROM输出会全是X,这坑我踩过好几次。

2.3 顶层整合与信号规划

PC和ROM两个模块写好后,还需要一个顶层文件把它们连接起来。顶层文件的名字必须和工程名一致,这是Quartus的硬性要求。我把顶层命名为top,例化两个子模块,并把PC的输出信号直接接到ROM的地址输入上:

module top ( input wire clk, input wire rst_n, input wire pc_en, output wire [7:0] addr, output wire [7:0] data ); pc #(.PC_WIDTH(8)) u_pc ( .clk (clk), .rst_n (rst_n), .pc_en (pc_en), .jump_en (1'b0), .jump_addr (8'h00), .pc_out (addr) ); rom #(.DATA_WIDTH(8), .ADDR_WIDTH(8)) u_rom ( .addr (addr), .data (data) ); endmodule

在这个结构里,addr既是顶层输出,也是PC的输出、ROM的地址输入。仿真时观察addr和data这两个信号,就能看到PC的递增和ROM中的数据输出。jump_en和jump_addr这里暂时没有用到,直接给常量值,如果你在实验里做了跳转功能,再把这两个端口引到顶层来,并接上对应的按键或拨码开关。

有一点要提醒:data是组合逻辑输出的话,地址一变数据马上变;如果是用MegaWizard生成的同步ROM,读数据会延迟一个时钟周期。这两种行为在仿真波形上表现不一样,分析时一定要搞清楚自己用的是哪一种,不然对着波形会对到怀疑人生。

3. Quartus II工程搭建与仿真环境配置

3.1 新建工程与器件选择

打开Quartus II后,从File菜单选择New Project Wizard。工程名字尽量用英文,不要带空格和中文,路径也保持全英文。比如我习惯建一个F:/fpga_lab/pc_rom目录,工程名叫pc_rom,顶层实体名称设置为top。接着会让你选器件,这一步取决于你手上的开发板:如果是学校常见的Cyclone IV板子,通常会选EP4CE10F17C8或者EP4CE6E22C8;如果是老一点的Cyclone II板子,就选EP2C5T144C8或者EP2C8Q208C8。实在不确定就选个同系列的相近型号,反正做仿真验证时器件型号影响不大。

新建工程后,把刚才写好的代码文件加入工程。方法是在Project Navigator里的Hierarchy窗口点右键,选择Add/Remove Files in Project,把top.v、pc.v、rom.v都加进去。之后在Assignments菜单下的Device设置里,确认顶层实体是top。语言版本方面,Verilog-2001就足够用了,不需要特意切到SystemVerilog。

3.2 ModelSim路径与仿真工具绑定

这一步是很多人卡住的地方。Quartus和ModelSim不是装好就能互相找到的,必须手动告诉Quartus:ModelSim装在哪、用哪个版本仿真。打开Tools菜单下的Options,找到EDA Tool Options,在ModelSim一栏填上ModelSim的安装路径,一般是类似C:\modeltech64_10.5或C:\altera\13.1\modelsim_ase这样的目录。填错路径的话,后面点仿真按钮会直接报错或闪退。

接下来在Assignments菜单下打开Settings,找到EDA Tool Settings里的Simulation,把仿真工具改成ModelSim。同时要把Testbench加进去:点Test Benches按钮,New一个Testbench名字,比如top_tb,在File name里填上你的测试文件路径,Top level module in test bench填top_tb。这里如果填错,运行仿真时Quartus会告诉你找不到仿真顶层,白白浪费时间。

我建议把这套配置写成一个流程笔记放在手边,因为每个新工程都要重新设置一遍,很多学生第一次设置花了半小时,第二次还是会忘记填路径。这也是Quartus这款工具比较古董、不够人性化的地方。

3.3 编译仿真全流程

配置完成后,先对顶层做一次Analysis & Synthesis,检查代码有没有语法错误、端口有没有问题。Quartus底部会弹出Message窗口,有Error的话直接双击跳转到报错位置。常见错误无非是端口名写错、模块名不一致、少写分号这类低级问题。综合通过后再点Tools菜单下的Run Simulation Tool,选择RTL Simulation,Quartus会自动启动ModelSim,并把Testbench编译进去。

ModelSim启动后,默认会进入一个波形窗口。你需要手动把想要观察的信号加进来:在sim面板里展开testbench的层次结构,右键top模块下的信号,选择Add Wave,把clk、rst_n、pc_en、addr、data全部加进来。然后设置仿真运行时间,在命令行或者Transcript窗口输入run 1us,回车后就能看到波形。我建议至少跑500ns,因为PC走一个周期是20ns,500ns能看到25个地址,足以观察连续递增和ROM输出变化。

4. ModelSim仿真与波形分析

4.1 Testbench编写要点

Testbench是仿真验证的核心,它负责产生时钟、复位和输入信号。写Testbench有一个关键原则:不要用always生成信号的同时又在initial块里赋值,很容易造成多驱动冲突。下面是这个实验比较稳妥的Testbench写法:

`timescale 1ns/1ps module top_tb; reg clk; reg rst_n; reg pc_en; wire [7:0] addr; wire [7:0] data; top dut ( .clk (clk), .rst_n (rst_n), .pc_en (pc_en), .addr (addr), .data (data) ); initial begin clk = 1'b0; rst_n = 1'b0; pc_en = 1'b0; #100; rst_n = 1'b1; pc_en = 1'b1; #400; pc_en = 1'b0; #100; $stop; end always #10 clk = ~clk; endmodule

always #10 clk = ~clk;这一句生成了周期为20ns的时钟信号,对应50MHz时钟。复位信号先拉低100ns,再拉高,保证PC在上电后经历一次明确的清零过程。pc_en在复位释放后立即拉高,让PC开始递增。#400之后又拉低pc_en,相当于暂停PC,这能验证使能功能是否生效。最后用$stop暂停仿真,模型停在当前状态,方便你慢慢观察波形。

在实际操作中我通常会加一句$display来打印关键信息,比如:

initial $monitor($time, " addr=%h data=%h", addr, data);

这样在Transcript窗口能看到每个时间点的地址和数据,不用只盯着波形。尤其是在数据变化非常快、波形显示不清时,文本输出反而更直观。

4.2 波形验证:PC到ROM地址到指令数据

跑完仿真后,把波形窗口调成适合观察的格式。右键addr和data信号,选择Radix,把Display按十六进制显示,这样波形上直接显示0、1、2这样的地址值,而不是一串二进制位。设置好后就能看到典型的时序关系:

复位阶段,rst_n为0时,addr保持0,data输出ROM地址0的内容。复位释放后,每个时钟上升沿,addr加1,从0递增到1、2、3。同时,因为ROM输出是组合逻辑,addr变化后一小段组合延迟内,data会跟着变成对应地址的存储内容。如果data是你MIF文件里那个地址对应的指令编码,就说明PC到ROM地址的链路完全打通了。

要特别注意pc_en拉低之后的波形。在第400ns附近,pc_en变成0,addr应该停在某个值上不再变化。如果这里addr还在跳,说明使能逻辑写得有问题,PC把pc_en当成无效信号了。波形验证的基本思路,就是把时序行为和代码逻辑对照,看实际现象符不符合预期。

4.3 常见问题与排查技巧实录

仿真过程中我最常遇到的坑有不少,整理成一张速查表,后面就照这个排查:

现象可能原因排查方式
波形全为X(红线)PC没有复位、时序逻辑未初始化、多个驱动源冲突检查rst_n是否有效拉低过,复位时间是否足够;检查代码里有没有对同一信号赋值的重复always块
波形全为Z(高阻)模块例化端口未连接、输出没有被驱动检查顶层例化时的端口映射,尤其是有没有悬空的输出
clk有波形但addr一直为0复位一直有效,或pc_en没有拉高检查复位信号是否被释放,Testbench里pc_en赋值是否正确
ROM输出一直是XMIF文件路径错误、MIF格式有误、ROM没有被正确初始化确认MIF文件是否存在且与工程同目录或已加入工程;检查WIDTH和DEPTH是否与ROM IP配置一致
ModelSim打不开或闪退Quartus里EDA Tool Options的路径设置错误,或版本不匹配重新检查ModelSim安装路径,全英文路径更稳妥
仿真运行很慢End time太长,或波形窗口信号太多先跑短时间(如1us),确认逻辑正确后再加长时间

有一个被问得很多的问题:为什么ModelSim里看到的波形,复位期间data是X,而不是MIF里的地址0内容?这是因为PC还没有完成复位时,地址总线是未知状态,这个未知地址被送进ROM后,ROM自然输出了未知数据。一旦复位完成,addr变成0,data才会稳定成ROM[0]的内容。看到这个现象不用慌,这不是设计错误,而是仿真初始状态的正常表现。

再提一个细节:在ModelSim里如果同时观察到了多个信号都叫data,很可能是你在不同层次模块里各自定义了同名信号。右键信号名,查看它的完整路径,确认你观察的是顶层top里的那个ROM输出,而不是某个临时变量。这种问题经常让人对着波形半天摸不着头脑,实际上只是看错了信号。

5. 一些实操上面的补充体会

到这里,PC设计、ROM地址产生、ModelSim仿真验证这条链路已经完整地跑了一遍。我在反复做这个实验的过程中有几点体会,可能对你后面继续深入有帮助。

动手写代码之前,一定要先在纸上画出模块连接框图。这个实验虽然简单,但很多人上来就写代码,写到一半搞不清addr到底是属于PC还是ROM的。先画图再写代码,逻辑会清晰很多,出错的概率直接减半。

基基础版本跑通以后,强烈建议你再做一个微小的扩展:给PC加上跳转功能。你可以通过jump_en信号让PC直接跳到一个指定地址,并从那里继续递增。这个小小的改动,能让你进一步理解"程序不是永远顺序执行"这个关键点,也为后面学习控制逻辑和指令译码打基础。

最后,如果条件允许,把PC的输出接到一个LED灯组上,在真实板子上看PC从0到255循环递增的二进制显示效果。实物验证和仿真验证的感觉完全不一样,这也是我为什么一直强调先仿真实部署的原因。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询