采用RAM实现计数器及FPGA功能验证的工程解析

发布时间:2026/10/26 6:18:59
采用RAM实现计数器及FPGA功能验证的工程解析

简介:面向FPGA课程设计与Verilog HDL学习者的一份完整设计报告,围绕“采用RAM实现计数器及FPGA功能验证”这一典型课题展开。资源以10×8双口RAM为核心,完成10个8位计数器设计,初值分别为1~10,主时钟1MHz经分频后得到1Hz计数频率,并通过按键选择计数通道、数码管或LED显示当前值。内容包括任务分析、总体设计方案、计数器/分频/RAM/显示四大模块详解、引脚定义、基于Modelsim与Quartus II的仿真综合流程、验证项目及源代码附录。包体为单个doc文档,共1个文件,大小165KB,已吸引1482人学习。对于需要完成同类FPGA课程设计或希望理解RAM读写控制、时钟分频和数字显示联动设计的读者,具有直接的参照价值,可直接按报告步骤复现实验并扩展验证。

1. 用 RAM 实现计数器:为什么把计数值存进存储单元反而更省逻辑

传统计数器由一排 D 触发器组成,FPGA 里的触发器藏在逻辑单元中,10 个 8 位计数器至少耗掉 80 个触发器,还要额外处理每个计数器的使能、清零和初值装载。而这份课程设计给出的方案反着来:把计数值直接放进一块 10×8 双口 RAM 的 10 个地址单元,初值 1~10 预先写进去,计数时对当前地址做“读出 → 加一 → 写回”。只要这套读改写闭环转起来,RAM 本身就变成了计数器,这也是“采用 RAM 实现计数器及 FPGA 功能验证”这个题目最反直觉的地方:计数器不非得把状态放在触发器里,存储阵列同样能当状态用。

搞清楚它为什么能 work,你才能真正理解 FPGA 设计里最核心的一层思维——状态放哪里、逻辑放哪里怎么划分。后面的分频、双口 RAM 读写互斥、按键选通和数码管译码,都是围绕这个核心问题展开的。本文会从分频级联、RAM 读改写闭环、显示拆分一路拆到综合实现,最后落在几个最容易翻车的细节上。

2. 分频级联:50MHz 到 1Hz,两级计数器如何掐出“一秒一跳”

开发板上的晶振通常给 50MHz 主时钟,题目却要求计数器按 1Hz 走秒、RAM 按 1MHz 读写。直接拿 50MHz 去驱动 RAM 或计数器都不现实,唯一的办法是把频率降下来。这个设计里分频模块同时输出两路时钟:clk_1mhz 给 RAM 做读写节拍,clk_1hz 给计数器做“每秒加一”的使能脉冲。两路分频共用同一个复位,复位拉低时两路输出都清零。

2.1 50 分频到 1MHz:计数器比较翻转的边界条件

先看 1MHz 这一路,源码里是这么写的:

always @(posedge clk, negedge reset) begin if(!reset) begin clk_1mhz = 0; cnt1 <= 5'd0; end else if(cnt1 == 5'd25) begin cnt1 <= 5'b0; clk_1mhz <= ~clk_1mhz; end else cnt1 <= cnt1 + 1'b1; end

cnt1 是一个 5 位计数器,它在每个 clk 上升沿自加,加到 25 时归零并翻转 clk_1mhz。50MHz 除以 50 等于 1MHz,所以理想情况下 clk_1mhz 应该每 25 个主时钟翻转一次:高电平 25 拍、低电平 25 拍,合起来 50 拍一个周期。但这里比较的阈值是 25,cnt1 从 0 数到 25 需要经过 0、1、2……25 共 26 个状态,翻转周期变成 52 拍,实际输出约 0.96MHz,不是严格的 1MHz。

这就是分频器最常见的边界问题。我一般会这么写:阈值取 N/2−1,N 是分频系数。50 分频就用 5'd24,cnt1 数到 24 归零并翻转,0~24 正好 25 拍,两个半周期才是干净的 1MHz。这个差别对数码管显示不影响什么,但如果你后面要做精确的秒表或者串口波特率,阈值差一拍就会累积出肉眼可见的误差。类似地,如果复位只清 cnt1 不清 clk_1mhz,上电后的第一个半周期占空比也可能是畸形的。

2.2 50MHz 到 1Hz:为什么长计数器要考虑溢出和位宽

1Hz 这路直接对 50MHz 做 5000 万分频,源码用了一个 30 位计数器 cnt:

always @(posedge clk, negedge reset) begin if(!reset) begin clk_1hz = 0; cnt <= 30'd0; end else if(cnt == 30'd2500_0000) begin cnt <= 30'b0; clk_1hz <= ~clk_1hz; end else cnt <= cnt + 1'b1; end

cnt 计数到 2500 0000 时翻转,但注意:cnt 从 0 到 2500 0000 同样经过了 2500 0001 个状态,也就是说高低电平各 25 000 001 拍,周期为 50 000 002 拍,比真正的 1 秒多出 2 个 50MHz 周期。误差约 4e-8,示波器上看不出来,但原理上必须清楚:要得到严格的 1Hz,阈值应当是 30'd24_999_999。把阈值写成 25_000_000 的人,多半是直接把“半周期 = 25 000 000”当成了比较值,忘了计数器从 0 开始的这一拍。

这也在提醒我们,位宽不能拍脑袋。2500 万需要 25 位二进制(2^25 = 3355 万),源码给了 30 位,余量是足够的;但如果你改成 999 999 之类的阈值,一定要先算 2^n 能不能装下,算少了计数器会提前溢出,分频输出直接变成乱跳。长计数器还有一个隐蔽问题:复位里 clk_1hz 用阻塞赋值= 0,正常情况下外部信号在复位释放时已经是确定的,但这种混用写法在综合时会生成不同的仿真语义,后面避坑章节会展开。

2.3 两级分频的层次拼接:1MHz 和 1Hz 到底谁吃谁的时钟

这个设计的层级关系值得停下来想清楚:50MHz 主时钟 clk 同时驱动两条分频链,一条产出 clk_1mhz,一条产出 clk_1hz。clk_1mhz 是 RAM 模块的时钟,clk_1hz 是计数器模块的时钟。也就是说,RAM 的读写在 1MHz 下完成,而计数器的加一动作只在 1Hz 的上升沿发生,两者不在同一个时钟域。

这意味着什么?1Hz 的上升沿到来时,计数器把当前值加一的结果写到 din 端口,之后 RAM 在下一次 1MHz 上升沿才采样这个 din。如果计数器的加一和 RAM 的写使能同时到达,RAM 采到的可能是加一之前的旧值,也可能是新值,取决于两条路径的布线延迟。这个跨时钟域的握手问题,在真正的芯片设计里要用同步器或异步 FIFO 解决;课程设计里靠“计数结果先锁在 din 上、再通过 wr 写回 RAM”这种宽松时序蒙混过去。理解了这层,你就知道为什么后仿真比前仿真更容易暴露问题——前仿真里 1Hz 和 1MHz 是同源时钟,相位关系固定,后仿真里布线延迟一进来,时序裕量就露馅了。

3. 双口 RAM 的读改写闭环:预置初值、读值、加一、写回是怎么串起来的

分频只是提供了节拍,真正实现计数器的是 RAM 模块和计数模块的配合。题目的本意是:10 个计数器的“当前值”存储在 RAM 的 10 个地址单元里,地址编号就是计数器编号,初值 1~10 在复位时预置进去。随后每次 1Hz 节拍到来,对当前地址做一次读改写,RAM 里的值就完成了自增。这个思路的本质,是把“存储”和“状态”合一。

3.1 地址即编号:mem[10:1] 的映射为什么能省掉偏移逻辑

RAM 模块源码开头定义了存储体:

module ram(wr, rd, reset, dout, clk_1mhz, din, addr_in); input wr, rd; input reset, clk_1mhz; input [3:0] addr_in; input [7:0] din; output [7:0] dout; reg [7:0] dout; reg [7:0] mem[10:1]; always @(posedge clk_1mhz, negedge reset) begin if(!reset) begin mem[1] <= 8'd1; mem[2] <= 8'd2; mem[3] <= 8'd3; mem[4] <= 8'd4; mem[5] <= 8'd5; mem[6] <= 8'd6; mem[7] <= 8'd7; mem[8] <= 8'd8; mem[9] <= 8'd9; mem[10] <= 8'd10; end else if(wr == 1 && rd == 0) mem[addr_in] <= din; else if(rd == 1 && wr == 0) dout <= mem[addr_in]; end endmodule

注意 mem 的下标写的是 [10:1],不是 [9:0],地址直接从 1 开始。这意味着“计数器编号”就是“RAM 地址”:1 号计数器的初值放在 mem[1],2 号放在 mem[2],以此类推。如果从 0 开始,你每次访问地址时都得做 addr−1 的换算,多一层减法逻辑不说,还容易在边界处写错。用 1-based 索引虽然浪费掉 0 号单元(8 位存储而已,微不足道),却让地址和控制按键的语义一一对应:按键切到 1,addr_in 就是 1,读出来的就是 1 号计数器的值。这是典型的用一点空间换清晰性的设计,硬件上几乎零成本。

复位里一次性把 10 个初值写进所有单元,这个写法很聪明,它把“预置初始值”从独立的状态机变成了单纯的复位动作。很多人做这个题会先写一个预置状态机,折腾地址和写使能的握手,其实没必要——存储体本身支持同步写,复位本来就是最可靠的初始化时机。要注意的是复位只初始化了 mem,没有初始化 dout,这会在仿真时留下隐患,下一节会细说。

3.2 双口语义的真相:wr/rd 互斥能覆盖读写冲突吗

这个模块声明是“双口 RAM”,但仔细看只有一根地址线 addr_in,读写共用,真正双口 RAM 应有的 rd_addr 和 wr_addr 两套地址并不存在。它实际是单口 RAM 加读写使能互斥:wr=1 且 rd=0 时写入,rd=1 且 wr=0 时读出,两个条件互斥,不会同时命中。

else if(wr == 1 && rd == 0) mem[addr_in] <= din; else if(rd == 1 && wr == 0) dout <= mem[addr_in];

那题目为什么要求双口 RAM?因为从系统层面看,RAM 的写(计数结果回写)和读(显示输出)确实发生在不同时刻:计数节拍里写,按键选通后读。真正的双口是为了让读写可以同时操作不同地址,而课程设计里读写在不同时刻发生,单口加时分复用就够用了。源码选择单地址实现,本质上是用“控制时序”代替“物理双口”,这在 FPGA 上完全可行,但你要知道它付出的代价:如果计数器加一的同时按键切换了地址,读写就会争用同一根地址线,冲突只能靠模块外的握手逻辑规避。理解这一点,你在看综合报告时就不会被“双口 RAM inferred”这种字眼误导了。

3.3 计数模块 JSQ:阻塞赋值、空分支和回写缺失

计数器模块源码是整个设计里争议最大的部分:

module JSQ(dout, start, stop, din, clk_1hz, count_reset); output [7:0] din; input [7:0] dout; input start, stop, clk_1hz, count_reset; reg [7:0] din; always @(posedge clk_1hz) begin if(!count_reset) din = dout; else if(din == 8'd255) din = 8'd0; else if((start == 1) && (stop == 0)) din = din + 1; else din = din; end endmodule

它做了三件事:count_reset 拉低时把 RAM 读出的 dout 装载进 din,当作初值;din 数到 255 时归零,处理 8 位溢出;start=1 且 stop=0 时 din 自增。这个模块的问题不在逻辑功能,而在写法:一是时序逻辑里用了阻塞赋值=,din 的新值在当前 always 块内立即生效,综合工具可能把它推断成组合反馈回路;二是else din = din这种空分支,等于告诉综合器“没使能时保持原值”,本来可以省略,写了反而容易让工具多想。

更要命的是,整个模块根本没有写回动作。din 的值自增之后,直接通过端口送回 RAM 的 din 输入,但 RAM 是否把它存进 mem 取决于 wr 信号。换句话说,源码把“当前计数值”放在 JSQ 内部的 din 寄存器上,RAM 里的 mem 永远停留在初值,除非外部在某个时刻把 wr 拉高做一次暂停写入。这跟题目要的“暂停时存入 RAM”一致,但跟“RAM 实现计数器”的理想模型有距离。

3.4 真正的 RAM 计数器应该长什么样:读改写三步在同一时钟域内完成

如果你想让 RAM 真正承担“状态存储”,计数器不应该放在外部寄存器,而应该做成读改写循环。以 1MHz 时钟为例,每次 1Hz 节拍使能时,在一个周期内完成“mem[addr] → dout → dout+1 → mem[addr]”,类似这样:

always @(posedge clk_1mhz or negedge reset) begin if(!reset) mem[addr] <= initial_value; else if(count_en) begin dout <= mem[addr]; mem[addr] <= dout + 1'b1; end end

这种写法下,计数器的状态只存在于 RAM 内部,外部逻辑只负责提供 count_en 和 addr。它的语义和源码的区别在于,每个计数器的“当前值”永远在 RAM 里,掉电重来、暂停再启动,值都在存储体中,不会因为外部寄存器的偶然重置而丢状态。代价是读改写必须在同一个时钟周期内完成,1MHz 下裕量很足,但如果你把时钟提到 200MHz,RAM 的读延迟加组合加法加写回路径就可能不够一个周期。这也是为什么真实的 CPU 设计中,寄存器堆代替 RAM 做小容量状态存储,拼的是延迟而不是面积。

4. 七段数码管译码与数字拆分:三位显示里最容易踩中的两个雷

计数结果是 8 位二进制,范围 0~255,要用三位数码管显示。显示模块的输入是 RAM 读出的 dout,输出是三组七段码 hex_b、hex_s、hex_g,分别对应百位、十位、个位。这里的两处问题,在仿真里会以非常诡异的方式出现,值得拆开讲。

4.1 八位段码 0xC0:怎么判断数码管是共阳极还是共阴极

先看译码表的一角:

case(dout_b) 4'd0: hex_b = 8'hC0; 4'd1: hex_b = 8'hF9; 4'd2: hex_b = 8'hA4; ...

0xC0 二进制是 1100_0000。如果你的段码按{dp, g, f, e, d, c, b, a}排列,高位到低位的 1 对应熄灭、0 对应点亮,那么 1100_0000 表示 a~f 全亮、g 和 dp 灭,显示的是数字 0。这种“低电平点亮”的编码是共阳极数码管的特征:公共端接高电平,某段给低才亮,所以段码里亮段对应 0。反过来,共阴极数码管的 0 通常是 0x3F,亮段对应 1。

板上用的到底是哪种,不该靠猜,最可靠的办法是看原理图里数码管的公共端接 VCC 还是 GND,也可以直接下载一个把 hex_b 固定成 8'h00 的测试工程看哪段亮。很多课程设计卡在“写了译码表却全不亮”,原因就是共阴共阳搞反,段码表和硬件是镜像关系。这个判断一旦错了,显示全灭或全亮,前仿真根本看不出来,因为 Modelsim 不知道你的板子长什么样。

4.2 dout/10 不等于十位:4 位寄存器装不下整数除法的结果

源码里对三位显示的拆分是这样写的:

dout_b = dout / 100; dout_s = dout / 10; dout_g = dout % 10;

看着很符合直觉:除以 100 得百位,除以 10 得十位,取余得个位。但 dout_s 的类型是 reg [3:0],只能装 0~15。当 dout 是 23 时,dout/10 等于 2.3,整数除法结果是 2,放进 4 位寄存器没问题。但 dout 到 235 时,235/10 = 23,二进制 10111 需要 5 位,截断进 4 位寄存器变成 0111,也就是 7——显示模块会把十位显示成 7,百位显示成 2,个位显示成 5,一眼看去完全是乱码。

正确的十位应当是(dout/10)%10或者(dout%100)/10。这个 bug 的坑爹之处在于,计数器值小于 20 时一切正常,一旦超过 19,十位开始跳错,你会在仿真波形里看到十位突然从“正常”变成“荒谬”,还以为是 RAM 读错了数据。我一般会这么记:除法只负责把这一位“移到个位”,取模才负责“剥掉高位”,拆位的完整三步是dout/100、(dout/10)%10、dout%10,缺一个取模,迟早翻车。

4.3 顶层连线里的幽灵:显示模块的输入到底从哪来

顶层综合模块里有一行很扎眼:

wire [7:0] dout_1, din_1, dout_2; FPQA(.clk(clk), .clk_1hz(clk_1hz1), .clk_1mhz(clk_1mhz1), .reset(reset)); RamB(.wr(wr), .rd(rd), .reset(reset), .dout(dout_1), .clk_1mhz(clk_1mhz1), .din(din_1), .addr_in(addr_in)); JSQC(.dout(dout_1), .start(start), .stop(stop), .din(din_1), .clk_1hz(clk_1hz1), .count_reset(count_reset)); SMG D(.dout(dout_2), .hex_b(hex_b), .hex_s(hex_s), .hex_g(hex_g));

dout_1 是 RAM 的读数据输出,din_1 是计数器模块的输出,两者连在了一条回路上,逻辑上是通顺的。但显示模块 SMG 的输入端接的是 dout_2,而 dout_2 是一个没有接任何驱动源的 wire。综合工具会把它当作悬空输入,仿真时这个引脚就是高阻 Z,数码管输出全是不定态。

这个 bug 暴露了一个经验:顶层连线最容易出的问题不是逻辑写错,而是信号名多打一个后缀。前仿真里每个子模块单独测都对,一封顶就废,因为 wire 悬空在功能仿真里不一定报错,它只是默默变成 X 或 Z。排查时用 Modelsim 的“列出现有信号列表”对着顶层例化逐一比对,比瞪着眼睛看代码可靠得多。正确的接法是把 dout_1(RAM 读出的当前计数器值)接到 SMG 的 dout,或者如果设计意图是显示计数过程中的 din_1,那就该接 din_1,二选一,但不能悬空。

5. 仿真与综合避坑:前仿真过了,后仿真却翻车,问题通常出在哪

从这门课的调试记录来看,作者最后只做到了后仿真,没敢下板。前仿真过了不代表硬件能跑,这正是数字设计里最真实的血泪经验。下面这五类问题,是 RAM 计数器工程里最常出现的,每条按现象、原因、解决整理。

5.1 现象:RAM 读出的数据在复位释放后显示为 X

仿真波形里 mem 复位后确实有 1~10,但 dout 在第一次读之前一直是 X,数码管端子全红。

原因:RAM 模块的复位块只预置了 mem,没有给 dout 赋初值。dout <= mem[addr_in]要等读使能真正拉高后才执行,复位期间 dout 没有任何驱动,保持在未知态。

解决:复位块里给 dout 补一句dout <= 8'd0,或者把 dout 改成组合逻辑直接assign dout = mem[addr_in]。组合读的好处是地址一变输出立刻跟上,不看时钟,仿真更直观;坏处是破坏同步读的时序模型,综合出的 RAM 类型会变化。课程设计阶段无所谓,真要上板建议保持同步读并补齐初值。

5.2 现象:计数到 20 以后数字乱跳,十位显示 7、个位显示 5

同一个 dout 值,百位个位正确,唯独十位离谱。把 dout 换成 235 仿真,hex_s 输出的是 7 的段码而不是 2。

原因:dout/10的整除结果超过 4 位后被截断,23 变 7。这是位宽截断问题,不是 RAM 的问题。

解决:十位改成(dout/10)%10,并且把 dout_s、dout_b 等变量声明为reg [3:0],确保每个中间结果都能装下 0~9。排查时用 $display 打出 dout_s 的实际数值,立刻能看到截断痕迹。

5.3 现象:综合时疯狂报 Latch 警告,或者提示存在组合回路

Quartus 综合报告里出现 “Latch inferred” 和组合环告警,位置指向 JSQ 模块的 always 块。

原因:计数器模块在时序逻辑里用了阻塞赋值,且写了else din = din空分支。阻塞赋值在 always 中会让工具以为你要的是组合电路加锁存器,空分支强化了这个判断,导致综合器推断出锁存结构,而不是干净的 D 触发器。

解决:时序逻辑统一改成非阻塞din <= din + 1'b1,删掉空分支,使能写成if (start && !stop) din <= din + 1'b1; else din <= din;也要避免,直接让保持语义隐含。综合工具对“非阻塞 + always@(posedge clk)”的识别是最可靠的。

5.4 现象:Modelsim 前仿真一切正常,后仿真输出波形毛刺频出

前仿真用的是理想延迟模型,时钟和信号都是干净的 0/1;后仿真把布线延迟和负载电容算进去,RAM 输出 dout 在时钟沿附近出现毛刺,数码管段码在切换瞬间乱闪。

原因:RAM 的同步读输出在时钟沿更新,地址切换和读使能拉高的组合逻辑延迟会形成短暂的竞争区间。前仿真里这些延迟全被当成 0,自然看不见。

解决:显示模块加一级输出寄存,把 RAM 读出的 dout 在 clk_1mhz 下再打一拍,让段码只在稳定窗口更新。这属于时序收敛的基本功,加一拍寄存器几乎不费资源,却能消掉绝大多数毛刺。

5.5 现象:上板后按暂停键再恢复,计数值回到初值

暂停时计数值写回了 RAM,继续计数时数字却从初值 1 重新开始。

原因:暂停后的恢复流程里,count_reset 被拉低过,JSQ 模块误以为要重新装载初值,把 RAM 里的旧值(正好是暂停写回的值)又载入 din。这里的“装载初值”和“恢复计数”共用了一个信号,没有区分真正的复位和暂停恢复。

解决:给复位和暂停恢复分别定义信号。count_reset 只在系统启动时用一次,暂停恢复只操作 start/stop,不碰 count_reset。如果一定要复用,需要在状态机里保证 count_reset 的拉低脉冲宽度小于一个 1Hz 周期,避免误触发装载。

6. 设计取舍:用 RAM 换逻辑资源,这桩交易到底值不值

回到开头的问题:10 个计数器明明用触发器就能做,为什么绕一圈用 RAM?先算资源账。一个 8 位计数器用寄存器实现,要 8 个触发器加加减法组合逻辑,10 个计数器就是 80 个触发器加一堆控制门;而 RAM 方案里,10 个计数器只占 10 个 8 位存储单元,在 Cyclone II 2C35 上大概只摸到 M4K 块的一个角落,逻辑单元几乎全省下来。逻辑资源省了,时序复杂度却涨了:寄存器计数器随时可读可写,RAM 计数器必须经过地址切换、读写互斥、分频握手,每一步都是延迟,每一步都可能踩坑。

如果只是完成题目,这桩交易其实是亏的——那点逻辑资源对现代 FPGA 根本不算瓶颈,反而 RAM 时序带来一堆麻烦。但站在设计方法的角度,它教给你一个更值钱的思路:状态存储跟着需求走,小量状态用寄存器堆,大量状态用 RAM,这不是“省资源”的取舍,而是“状态密度”的取舍。试想扩展成 100 个计数器,寄存器方案要 800 个触发器,RAM 方案只需要把深度从 10 加到 100,面积几乎不变。架构的扩展性,从来不是靠节省单个元件换来的,而是靠选择正确的存储结构。

回到这个设计本身,我更愿意把它读成一个反面教材兼正面示范:反面在于悬空的 dout_2、截断的 dout/10、阻塞赋值推锁存,这些都是仿真过了就掉以轻心的典型;正面在于分频、读改写、地址即编号、复位预置初值,每块都有清晰的设计意图。从那以后,我每次做数字系统,综合前都强制自己跑一遍 lint,重点查悬空 wire、位宽截断、时序逻辑里的阻塞赋值,宁可多花十分钟,也不愿在后仿真阶段被一个 X 信号追着跑。希望这些踩过的坑,能帮你在自己的工程里少走几步弯路。

本文还有配套的精品资源,点击获取