
在数字逻辑和计算机组成原理的课程设计里程序计数器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地址是一一对应的关系PC0时读出ROM地址0的指令PC1时读出地址1的指令以此类推。有一点需要特别提醒这里的ROM是电子设计中的只读存储器不是手机上刷机包那个ROM别搞混。在做这个实验前你最好在纸上画一下连接框图一个方块代表PC输出标记为pc_out另一个方块代表ROM左侧输入是地址addr右侧输出是数据data。然后把pc_out用一条线连到addr上。画完这张图整个项目的结构就清楚了PC负责走地址ROM负责出指令两个模块合起来就是CPU取指阶段的缩影。1.3 工具链选型为什么是Quartus II加ModelSimQuartus 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{1b0}}; else if (jump_en) pc_out jump_addr; else if (pc_en) pc_out pc_out 1b1; 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) 8h00: data 8h20; 8h01: data 8h30; 8h02: data 8h21; 8h03: data 8h10; 8h04: data 8h40; default: data {DATA_WIDTH{1b0}}; endcase end endmodulecase里每一项就相当于ROM里一个地址单元的内容。这里我随手写了一些数据实际你可以填成一段简单的指令编码比如20表示把立即数装入R130表示把立即数装入R221表示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 (1b0), .jump_addr (8h00), .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不是装好就能互相找到的必须手动告诉QuartusModelSim装在哪、用哪个版本仿真。打开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 SimulationQuartus会自动启动ModelSim并把Testbench编译进去。ModelSim启动后默认会进入一个波形窗口。你需要手动把想要观察的信号加进来在sim面板里展开testbench的层次结构右键top模块下的信号选择Add Wave把clk、rst_n、pc_en、addr、data全部加进来。然后设置仿真运行时间在命令行或者Transcript窗口输入run 1us回车后就能看到波形。我建议至少跑500ns因为PC走一个周期是20ns500ns能看到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 1b0; rst_n 1b0; pc_en 1b0; #100; rst_n 1b1; pc_en 1b1; #400; pc_en 1b0; #100; $stop; end always #10 clk ~clk; endmodulealways #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保持0data输出ROM地址0的内容。复位释放后每个时钟上升沿addr加1从0递增到1、2、3。同时因为ROM输出是组合逻辑addr变化后一小段组合延迟内data会跟着变成对应地址的存储内容。如果data是你MIF文件里那个地址对应的指令编码就说明PC到ROM地址的链路完全打通了。要特别注意pc_en拉低之后的波形。在第400ns附近pc_en变成0addr应该停在某个值上不再变化。如果这里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变成0data才会稳定成ROM[0]的内容。看到这个现象不用慌这不是设计错误而是仿真初始状态的正常表现。再提一个细节在ModelSim里如果同时观察到了多个信号都叫data很可能是你在不同层次模块里各自定义了同名信号。右键信号名查看它的完整路径确认你观察的是顶层top里的那个ROM输出而不是某个临时变量。这种问题经常让人对着波形半天摸不着头脑实际上只是看错了信号。5. 一些实操上面的补充体会到这里PC设计、ROM地址产生、ModelSim仿真验证这条链路已经完整地跑了一遍。我在反复做这个实验的过程中有几点体会可能对你后面继续深入有帮助。动手写代码之前一定要先在纸上画出模块连接框图。这个实验虽然简单但很多人上来就写代码写到一半搞不清addr到底是属于PC还是ROM的。先画图再写代码逻辑会清晰很多出错的概率直接减半。基基础版本跑通以后强烈建议你再做一个微小的扩展给PC加上跳转功能。你可以通过jump_en信号让PC直接跳到一个指定地址并从那里继续递增。这个小小的改动能让你进一步理解程序不是永远顺序执行这个关键点也为后面学习控制逻辑和指令译码打基础。最后如果条件允许把PC的输出接到一个LED灯组上在真实板子上看PC从0到255循环递增的二进制显示效果。实物验证和仿真验证的感觉完全不一样这也是我为什么一直强调先仿真实部署的原因。