简介:面向EDA课程设计与FPGA入门学习者,这份资源以Quartus II和Verilog为基础,完整实现了基于FPGA的数字频率计设计。系统采用自顶向下方法,将测频模块按功能划分为多个子模块,分别用Verilog程序实现,再通过顶层设计文件的元件例化语句完成连接,能帮助读者清晰理解数字频率计的模块划分、逻辑综合与整体设计流程。压缩包共744个文件,约28.3MB,包含Quartus工程配置(qpf/qsf)、Verilog/VHDL/tdf源文件、仿真与综合报告(rpt/sdo/vo)、FPGA配置文件(sof)以及PCB和原理图(pcbdoc/schdoc)等类型,既涵盖代码与仿真,也有硬件板卡设计,目录结构完整,适合按模块检索。资源为作者本科阶段完整提交给老师的期末作业,报告、代码、PCB、原理图一应俱全,可对照学习完整项目从需求到实现的全过程。已有4693人学习下载,对需要完成类似EDA课程设计或想掌握数字频率计内部架构的同学,是高质量的一手参考资料。 每年期末这个时候,我都会收到不少学弟学妹的消息,问的大多是同一件事:EDA课程设计的题目出来了,十个里有八个都抽到或选了“基于FPGA的数字频率计”。这个题目听起来挺唬人,其实它是一个非常经典、非常适合用来考察数字逻辑设计和EDA工具链掌握程度的综合型项目。今天就把我当时做这个项目的完整思路、代码框架、仿真方法、以及踩过的坑一次性说清楚,希望能让你少走几个礼拜的弯路。
这个项目最实在的价值在于:它不是一个“纯软件”题,也不是一个“纯硬件”题,而是要求你把两者打通——用硬件描述语言描述一个可运行的数字系统,然后在一个真实的FPGA芯片上让它跑起来,用数码管实时显示外部信号的频率。它涵盖了分频器、计数器、锁存器、译码显示、顶层例化这几个数字电路最核心的知识点,所以老师爱出这类题;而对你来说,把它吃透了,等于把EDA课程百分之八十的知识点都过了一遍。
1. 先把题目看穿:这个项目到底在考什么
很多同学拿到题目就直接打开Vivado或Quartus开写代码,这是个危险的开始。你首先要做的是把题目要求的指标拆开,看清楚它的边界在哪里。
1.1 常见的技术指标与测试条件
数字频率计的核心功能是测量输入信号的频率并在数码管上显示。课程设计版的指标一般是这样:
- 被测信号:方波或正弦波,频率范围通常在1Hz到10MHz之间;
- 测量精度:要求在某个误差范围内,一般是±1Hz或相对误差小于某个百分比;
- 闸门时间:通常可切换,常见的有1s、0.1s、10ms等;
- 显示:4位到8位数码管动态扫描显示,支持十进制读数;
- 输入条件:一般通过开发板上的按键或IO口输入信号,有些题目还要求测周期、占空比等附加功能。
1.2 不同的实现思路对应不同的评分点
这里有一个关键的认知:数字频率计并不是只有一种做法。它至少有三种主流实现方案:
第一,基于等精度测量法,即采用一个标准闸门信号控制计数器,分别对被测信号和标准时钟进行计数,通过比例关系求频率。这种方法精度高,但代码复杂度也高。
第二,基于纯计数法,即固定一个闸门时间(比如1秒),在闸门时间打开期间对被测信号上升沿进行计数。这是最简单的做法,但是低频测量误差大,因为1Hz的信号在1秒内只有1个上升沿,计数结果为1,误差可能高达100%。
第三,基于测周法,即测量被测信号一个周期内所经过的高频标准时钟数量,再换算频率。这种方法适合低频测量,但不适合高频。
我当时选的是第二种(固定闸门+计数),因为它够满足课程设计的指标、代码量适中、原理也容易在报告和答辩中讲清楚。但如果你有余力,建议在基本功能完成后,把等精度测量也加上,这会成为你答辩时最大的加分项。
2. 频率测量的底层逻辑:闸门时间、计数器位宽与误差分析
这一章是接下来写代码的地基。很多人上来就写计数器,结果发现仿真波形不对、显示跳动、高频信号直接溢出,就是因为没算清楚几个关键参数。
2.1 闸门时间的选择与计数过程
闸门时间GATE是频率计的心脏。它的作用是产生一个宽度精确的“测量窗口”。我用的开发板主时钟是50MHz,所以最自然的做法是通过分频产生精确的1s脉冲。
计数过程的时序是:
- GATE信号拉高,计数使能有效,计数器对被测信号CLK_IN的上升沿进行累加;
- 当GATE拉低,计数停止,此时计数器的值就是被测信号在闸门时间内的脉冲个数;
- 如果GATE是精确的1s,那么计数值就等于信号的频率值(单位Hz);
- 计数结束后,将当前值锁存到显示寄存器,再清零计数器准备下一次测量。
这里有一个细节:计数停止后不能立刻清零,必须先把结果“锁存”住,否则数码管上会显示全零或者乱跳。先锁存、后清零,这个顺序必须写死在状态机里,否则显示一定会闪。
2.2 计数器位宽的估算
这个坑我见太多人踩了。计数器位宽如果拍脑袋定,很容易溢出或浪费资源。正确的做法是先算再写:
- 如果你要测的最高频率是10MHz,闸门1s,那么最大计数值为1s × 10MHz = 10,000,000,也就是10^7;
- 换算成二进制,ceil(log2(10^7)) = 24位。
所以计数器位宽至少要24位。如果你用8位计数器,信号一上1MHz就溢出,数据完全错乱。
如果采用BCD码计数(也就是用十进制数码管直接显示十进制数),每个十进制位需要4位BCD码,6位显示就需要24个寄存器位,但每个4位是十进制进位,所以逻辑和二进制计数有所不同。我当时用了BCD计数方式,因为后续译码显示更直接,省去了二进制转十进制的模块。
2.3 误差分析:这门课里“误差”是你必须会算的账
答辩时老师最常问的一句话就是:“你能告诉我在测量1Hz和测量1MHz时,误差分别是多少吗?”
如果你用的是固定闸门计数法,答案是这样的:
- 计数结果N表示闸门时间内被测信号的脉冲个数;
- 因为闸门打开和被测信号上升沿之间是异步的,最多会有±1个脉冲的误差;
- 相对误差 = 1 / N × 100%;
- 当被测信号为1Hz、闸门1s时,N=1,最大相对误差高达100%;
- 当被测信号为1MHz时,N=1,000,000,相对误差只有0.0001%。
所以这种方案的精度会随频率升高而升高,低频测量误差很大。要想改善低频精度,就要把闸门时间加长,比如测1Hz信号用10s闸门,N=10,误差变成10%。这也是为什么有些题目会要求“闸门时间可切换”。
明白这个逻辑以后,你在报告里就能写出让老师满意的误差分析章节,而不仅仅是贴一段代码。
3. 顶层架构设计:功能模块到底怎么划分
这个项目绝对不建议把全部代码写在一个module里。不仅难调试,而且答辩时老师让你讲模块结构,你也不好讲。我当时是按“五模块”拆分的,这个划分方式至今仍然是我推荐的标准做法。
3.1 完整模块清单与功能定义
| 模块名称 | 功能描述 | 关键输出 |
|---|---|---|
| gate_gen | 分频产生闸门信号 | cnt_en、锁存信号lat |
| counter_bcd | 在cnt_en有效期间,对被测信号上升沿计数 | BCD计数值 |
| latch_reg | 在lat脉冲到达时,锁存当前计数值 | BCD锁存值 |
| seg_driver | 动态扫描驱动数码管,输出段码与位选 | seg[7:0]、an[7:0] |
| freq_meter_top | 顶层模块,例化以上模块并完成信号连接 | 对外接口 |
这个拆法的好处是:每个模块的功能单一、可独立仿真、可单独写测试平台(testbench),调试时能快速定位问题。
3.2 数字频率计的顶层信号定义
顶层模块的对外接口设计也很重要,要和开发板上的资源对应起来:
module freq_meter_top( input wire clk_50m, // 板载50MHz时钟 input wire rst_n, // 异步复位,低有效 input wire clk_in, // 被测信号输入 output wire [7:0] seg, // 数码管段码 output wire [3:0] an // 数码管位选 );如果你的板子是6位数码管,an就改成[5:0],8位就改成[7:0]。注意,很多开发板的数码管是共阴极或共阳极之分,段码的高低有效逻辑完全不同,写译码模块之前一定要看原理图,不要想当然。
3.3 为什么强烈建议用BCD计数而非二进制计数
如果你计划用6位数码管显示频率,最高显示999999Hz。如果你用二进制计数再转十进制,就需要额外写一个二进制转BCD的模块;但如果你直接用BCD方式计数,每位4比特按十进制进位,显示时直接按位译码,连转换都省了。
BCD计数的写法也不复杂,每一位在计到9之后主动归零并向前一位进位:
always @(posedge clk_in or negedge rst_n) begin if (!rst_n) begin ones <= 0; tens <= 0; // ... end else if (cnt_en) begin // 个位计数 if (ones == 4'd9) begin ones <= 0; // 十位进位逻辑 if (tens == 4'd9) begin tens <= 0; // 百位进位,依此类推 end else begin tens <= tens + 1'b1; end end else begin ones <= ones + 1'b1; end end end这样写虽然代码量比二进制计数大一点,但是显示器的接口数据可以直接取每一位,不需要再多写一个转换模块,总体工程量其实是减少的,而且逻辑更直观,答辩时也好讲。这是一个“前期多写几行,后期省很多事”的经典决策。
4. 核心代码实现的五个关键细节
代码写起来并不难,难的是把细节做对。这一部分我逐个讲清楚我当时花了比较多时间才搞明白的细节,每一处都是能在仿真或实测中卡你几天的地方。
4.1 分频模块:闸门信号的精确产生
闸门信号由50MHz时钟分频而来。注意两点:
第一,闸门宽度要足够精确。用计数器从0到49,999,999循环,就能得到周期为1s、高电平宽度为1s的信号吗?并不是。如果你在计数到49,999,999时直接让输出翻转,你得到的是周期2s、高电平1s的信号。对频率计来说,我们需要的是“一次计数窗口为1s”,所以应该在计数到49,999,999时产生一个单时钟脉冲,或者让cnt_en在一个计数周期内为高、在另一个计数周期内为低。
我当时是生成两个脉冲信号:
cnt_en:高电平持续1s,低电平持续一个时钟周期(或也持续一个值),用于控制计数器;latch_pulse:在cnt_en下降沿附近产生一个单时钟周期的高脉冲,用于锁存计数结果。
这样一来,时序关系就非常清晰:计数期间数据稳定,结束后先锁存再清零,显示结果保持不变。
4.2 异步信号同步化:这是最容易忽略的问题
被测信号clk_in和板载50MHz时钟来自完全不同的信号源,它们是异步的。如果你直接在计数器里用clk_in作时钟、用cnt_en作使能,看起来没问题,但cnt_en相对于clk_in是异步信号,有极大概率出现亚稳态。
经典做法是用两级触发器把异步的cnt_en同步到clk_in的时钟域:
reg cnt_en_d1, cnt_en_d2; always @(posedge clk_in or negedge rst_n) begin if (!rst_n) begin cnt_en_d1 <= 1'b0; cnt_en_d2 <= 1'b0; end else begin cnt_en_d1 <= cnt_en; cnt_en_d2 <= cnt_en_d1; end end wire cnt_en_sync = cnt_en_d2;这样用了两级寄存器做同步,大幅降低亚稳态导致计数异常的概率。这个细节你在报告里写出来,老师会知道你确实理解异步时序问题,而不是只会机械写代码。
4.3 计数与锁存的时序配合
计数器清零的时机要特别小心。如果清零脉冲和锁存脉冲同时到达,显示内容会不稳定。正确的顺序是:
cnt_en变为低电平,表示计数窗口结束;- 等待两个时钟周期(或者更好:检测到下降沿后),产生
latch_pulse锁存数据; - 再等一个周期,产生
clear_pulse清零计数器; - 下一次
cnt_en到来时,计数器从0重新开始。
我当时在闸门分频模块里直接用了一个小状态机来控制这三个信号的先后顺序,而不是简单地把cnt_en的反相信号当作锁存信号。这个处理方式让整个顶层模块的时序变得非常干净。实测效果就是不管信号频率怎么切换,数码管上的数字都是稳定不变的,不会出现闪烁或显示0的瞬间。
4.4 数码管动态扫描的刷新率
数码管显示如果做不好,所有测量结果都白搭。动态扫描的原理是轮流点亮每一位数码管,利用人眼视觉暂留效应让所有位看起来“同时”亮。
关键参数是刷新频率。以4位数码管为例,每一位的点亮时间不应该超过2ms到4ms,否则人眼能明显看到闪烁。我的做法是:
- 用一个计数器对50MHz时钟分频,产生约1kHz的位选切换频率;
- 每1ms切换一次位选,四位轮流扫描一遍需4ms;
- 刷新率约250Hz,完全满足视觉暂留需求。
如果你的板子有6位或8位数码管,扫描周期相应变成6ms或8ms,依然在可接受范围内。注意不要用太慢的切换,比如10ms以上,肉眼看到闪烁就很明显,会影响评分印象。
4.5 消抖与电平适配问题
被测信号如果是外接的函数发生器发出的信号,一般信号质量还可以。但如果题目要求用手触摸产生信号,或者用按键作为低频信号源,那么必须考虑信号消抖的问题。按键按下去会产生几十毫秒的机械抖动,这种抖动在计数器看来就是无数个上升沿,测出来的频率会离谱到几十万赫兹。
我的处理方法是在输入到FPGA之前做一次RC硬件消抖,或者在FPGA内部做定时采样消抖。对于课程设计,比较直接的方式是用一个计数器对输入信号进行滤波——只有连续检测到多次相同电平状态才认为信号稳定,这个在FPGA内部很容易实现。不过如果题目没有这个要求,简单处理也可行,但答辩时能主动说出这个考虑,会给老师留下不错的印象。
5. 仿真验证:别拿开发板当唯一调试工具
很多同学写完代码就直接下板,这是最大的时间陷阱。FPGA内部信号在板子上是看不见的,尤其是在没有逻辑分析仪的情况下,一旦现象不对,你只能瞎猜。正确的方式是先做仿真验证,仿真通过后再下板。
5.1 测试平台的搭建思路
仿真测试平台(testbench)要覆盖以下几个场景:
- 时钟信号:模拟板载50MHz时钟;
- 被测信号:用
always语句生成不同频率的模拟信号; - 复位信号:先复位,再释放;
- 监控输出:实时打印计数值和显示输出。
生成不同频率被测信号的例子:
// 生成1MHz的方波信号 initial begin clk_in = 0; forever #250 clk_in = ~clk_in; // 1us一个周期 = 1MHz end如果要分别测低频和高频,可以用不同的forever语句配合disable或通过并行块来切换。
5.2 高效仿真的一个实用技巧:修改闸门参数
一个最容易被忽视的问题是:如果你直接在仿真里测1s的闸门,仿真器跑完1s的仿真时间会非常慢。即便用ModelSim或Vivado Simulator,1秒仿真时间也要等很久。
我的做法是:在仿真时通过宏定义或参数覆盖来缩短闸门时间。
`define SIMULATION // 在测试文件中使用10us闸门代替1s // 这样仿真1ms = 实际测量100个窗口,效率高得多这样就能在很短的时间内完成多轮测量验证,逻辑是否正确一目了然。等仿真全部通过了,再把参数改回1s,直接下板实测。
5.3 仿真时注意检查的关键点
我看过很多人的仿真波形,基本问题都出在几个地方,你对照检查一下:
- 计数器清零后,下一轮计数能否从0开始;
- 锁存信号出现时,显示寄存器的数据是否等于上一轮计数值;
- 低频信号(比如5Hz)下,计数结果是否在0到5之间波动(因为闸门时间与信号上升沿的相位差会导致±1误差);
- 高频信号(比如10MHz)下,计数器是否会溢出。如果溢出了,说明位宽不够,回头改计数器定义。
6. 下板实测时容易踩的坑与排查方法
下板以后遇到问题,不要慌,按信号流的方向一步步排查。下面是我实测过程中踩过的一些典型问题,以及对应的排查思路。
6.1 数码管乱码或显示全零
这个问题八成出在段码或位选的极性上。用万用表或者看开发板原理图确认数码管是共阴还是共阳;如果是共阳,段码要取反;如果位选是高有效,你的位选信号就要反过来写。另外检查一下有没有把位选顺序搞反,比如第一个数码管对应的是最低位还是次低位。
6.2 测量高频信号时数字乱跳
高频信号乱跳的主要原因通常是电路板上的信号走线太长,产生了反射或串扰;另一个常见原因是地线接触不良。解决办法是尽量缩短信号线,用同轴线或屏蔽线,并且把信号源的地和FPGA开发板的地连接好。如果函数发生器输出幅度较大,可以考虑串一个电阻做阻抗匹配。
6.3 低频信号测量结果在0和1之间跳变
这个不是故障,而是固定闸门计数法的固有误差。1Hz信号在1s闸门内,有时能采到1个上升沿,有时采不到,所以显示结果在0和1之间跳。你可以把闸门时间加大到10s或者改用测周法来改善。在答辩时如果能主动解释这个现象,反而是展示你对误差原理理解的好机会。
6.4 用仿真验证过的代码下板却完全不工作
这种情况我遇到过好多次。最常见的原因是:开发板上时钟引脚和复位引脚没约束对,或者约束文件里引脚名字与原理图对不上。此时可以先用厂家提供的工程模板点个LED灯,确认板子能正常跑起来,再一步步移植到自己的工程中。
7. 课程报告与答辩准备的加分思路
课程设计不只是把代码跑通就结束了,报告和答辩占了30%到40%的分值,这一块绝对不能忽略。
报告的叙事线建议按照“需求分析 → 方案选型 → 原理框图 → 模块设计 → 仿真验证 → 下板实测 → 误差分析 → 总结展望”这条流程去写,这正好也是你实际做项目的顺序。可以用一张手绘的或画图工具做出的顶层模块连接图,用表格列出各个模块的输入输出信号,这些内容比纯代码截图更让老师觉得你思路清晰。
答辩时容易被问到的问题,我整理成了一张清单:
- 为什么用BCD计数而不是二进制计数?
- 闸门时间变化对精度和分辨力分别有什么影响?
- 如何消除异步信号的亚稳态?
- 如果要测量100MHz的信号,你的设计需要做什么改动?
- 动态扫描的刷新频率怎么确定?
这些问题我在前面的章节里基本都讲到了,关键是要能脱稿说清楚原理,而不是背代码。
最后再分享一个小经验:做这个项目的过程中,每完成一个模块,就立刻写一个对应的测试平台去验证这个模块,不要等所有模块全部写完再统一调试。模块越早验证,问题越早暴露,后期联调的时间就越短。我当时就是先在仿真里把计数模块和锁存模块单独验证好,最后顶层例化时只花了一个下午就联调通过。这个习惯在以后的真实项目开发中也让我受益很多。
本文还有配套的精品资源,点击获取