VHDL实战:分频器+计数器+数码管实现数字频率计

发布时间:2026/10/12 20:35:32
VHDL实战:分频器+计数器+数码管实现数字频率计 1. 从零拆解一个数字频率计的核心架构分频器、计数器、数码管显示这三个词放在一起稍微摸过FPGA或者CPLD的兄弟应该马上就能反应过来——这不就是一个数字频率计的核心链路吗我当初第一次接触这个组合的时候脑子里想的是“不就是把信号分个频、数个数、再显示出来嘛”结果真正动手写VHDL的时候才发现每一个环节都有坑而且坑坑不一样。这个项目的本质是构建一个完整的数字频率测量系统。输入是一个待测的方波或数字脉冲信号输出是数码管上稳定显示的频率值。中间要经过分频产生基准时基、计数器在闸门时间内统计脉冲个数、数码管驱动把二进制数值转换成七段码并动态扫描显示。整条链路涉及VHDL最核心的几个设计思想时钟分频、边沿检测、计数溢出处理、动态扫描时序、以及跨时钟域的信号同步。适合谁来参考如果你正在学VHDL已经能写简单的组合逻辑和时序逻辑但还没做过一个完整的、能跑在开发板上的小系统那这个项目就是为你准备的。如果你已经工作但一直用Verilog想看看VHDL怎么处理同样的需求也能从这里找到对应写法。甚至你是个嵌入式软件工程师想了解一下FPGA这边怎么处理频率测量这篇文章也能给你一个完整的实现思路。我下面会按照实际开发的顺序从整体设计思路开始然后逐个模块拆解分频器、计数器、数码管驱动再讲顶层怎么连起来最后把我踩过的坑和调试技巧全部倒出来。代码全部基于VHDL-93标准任何主流FPGA开发环境都能综合。2. 整体方案设计与模块划分思路2.1 为什么选“分频计数显示”这条路线测量频率有很多种方法最常见的是两种一种是直接测频法在固定的闸门时间内数待测信号的脉冲个数另一种是测周法测量待测信号一个周期内基准时钟的个数然后取倒数。这两种方法各有适用场景直接测频法适合高频信号测周法适合低频信号。这个项目用的是直接测频法原因很简单实现起来最直观而且和标题里的“分频器计数器数码管”完全对应。分频器负责把系统时钟分频产生一个精确的闸门信号比如1秒的闸门计数器在这个闸门时间内对待测信号进行计数数码管把计数值显示出来。整个链路清晰每个模块职责单一非常适合作为学习项目。如果你要测一个1Hz到10MHz范围的信号直接测频法在低频段误差会比较大因为闸门时间1秒时1Hz的信号只能数到1个脉冲±1误差就是100%。这时候可以切换到测周法或者用等精度测量的方法。但那是进阶内容先把基础链路跑通再说。2.2 模块划分与信号流整个系统我划分成四个模块加一个顶层分频器模块把板载晶振时钟假设50MHz分频产生1Hz的闸门信号同时产生一个用于数码管动态扫描的扫描时钟比如1kHz。计数器模块在闸门信号有效期间对待测信号进行计数闸门结束后把计数值锁存输出。数码管驱动模块把锁存的计数值转换成七段码并通过动态扫描的方式在多个数码管上显示出来。顶层模块把上面三个模块例化连接分配引脚。信号流是这样的晶振时钟进入分频器分频器输出闸门信号和扫描时钟待测信号和闸门信号进入计数器计数器输出计数值计数值和扫描时钟进入数码管驱动驱动输出段选和位选信号到数码管。这里有一个关键点闸门信号和待测信号是异步的。闸门信号的上升沿和下降沿相对于待测信号来说是随机的这会导致±1个计数的误差。这是直接测频法的固有误差无法消除但可以通过延长闸门时间来减小相对误差。比如闸门时间从1秒延长到10秒±1误差就从1Hz降到0.1Hz。不过显示刷新会变慢需要权衡。2.3 时钟域的处理策略系统里至少有两个时钟域一个是板载晶振产生的系统时钟另一个是待测信号本身作为计数时钟。分频器和数码管驱动都在系统时钟域下工作计数器模块的计数逻辑在待测信号域下工作但闸门控制和数据锁存在系统时钟域下。跨时钟域的信号有两类闸门信号从系统时钟域到待测信号域计数值从待测信号域到系统时钟域。对于闸门信号我采用的方法是先在系统时钟域下生成一个使能信号然后把这个使能信号用两级触发器同步到待测信号域。对于计数值因为是多位并行数据不能简单用两级触发器同步我采用的方法是在闸门结束时先用一个锁存信号把计数值锁存到一个寄存器组这个锁存信号本身是系统时钟域下的锁存后的数据再在系统时钟域下读取。注意跨时钟域处理是FPGA设计里最容易出问题的地方。如果你忽略了这个综合可能通过仿真可能也对但上板后数据会随机出错而且极难排查。这个项目虽然简单但跨时钟域的思想必须从一开始就建立起来。3. 分频器模块的三种写法与选型对比3.1 偶数分频最简单也最常用偶数分频就是分频系数是偶数比如50MHz分频到1Hz分频系数是50,000,000是个偶数。实现方法很简单用一个计数器从0数到N/2-1然后输出时钟翻转一次。-- 偶数分频示例50MHz - 1Hz signal cnt : integer range 0 to 24999999 : 0; signal clk_out : std_logic : 0; process(clk_in, rst_n) begin if rst_n 0 then cnt 0; clk_out 0; elsif rising_edge(clk_in) then if cnt 24999999 then cnt 0; clk_out not clk_out; else cnt cnt 1; end if; end if; end process;这段代码的逻辑是每来25,000,000个时钟周期输出翻转一次所以输出周期是输入周期的50,000,000倍频率就是1Hz。注意计数器范围是0到24,999,999一共25,000,000个状态。这种写法的优点是简单直观缺点是输出时钟是由寄存器翻转产生的会占用一个额外的触发器而且时钟树上的负载会增加。在高速设计里更推荐用使能信号代替分频时钟。3.2 奇数分频需要双边沿处理奇数分频稍微麻烦一点因为不能简单地在中点翻转。常用的方法是分别用上升沿和下降沿计数然后组合输出。比如3分频-- 奇数分频示例3分频 signal cnt_pos : integer range 0 to 2 : 0; signal cnt_neg : integer range 0 to 2 : 0; signal clk_pos : std_logic : 0; signal clk_neg : std_logic : 0; process(clk_in, rst_n) begin if rst_n 0 then cnt_pos 0; clk_pos 0; elsif rising_edge(clk_in) then if cnt_pos 2 then cnt_pos 0; else cnt_pos cnt_pos 1; end if; if cnt_pos 0 then clk_pos 1; elsif cnt_pos 1 then clk_pos 0; end if; end if; end process; process(clk_in, rst_n) begin if rst_n 0 then cnt_neg 0; clk_neg 0; elsif falling_edge(clk_in) then if cnt_neg 2 then cnt_neg 0; else cnt_neg cnt_neg 1; end if; if cnt_neg 0 then clk_neg 1; elsif cnt_neg 1 then clk_neg 0; end if; end if; end process; clk_out clk_pos or clk_neg;这个项目里分频系数是50,000,000是偶数所以用第一种方法就够了。但如果你要产生一个非整数分频比比如从50MHz产生一个1kHz的扫描时钟分频系数是50,000也是偶数。所以这个项目里所有分频都是偶数分频用第一种方法即可。3.3 使能信号替代分频时钟更推荐的做法在实际工程中我越来越倾向于用使能信号代替分频时钟。什么意思呢就是不分频出新的时钟而是生成一个单周期宽的使能脉冲其他逻辑还是用系统时钟只在使能有效时才动作。-- 使能信号生成每50,000,000个时钟周期产生一个使能脉冲 signal cnt : integer range 0 to 49999999 : 0; signal en_1hz : std_logic : 0; process(clk_in, rst_n) begin if rst_n 0 then cnt 0; en_1hz 0; elsif rising_edge(clk_in) then if cnt 49999999 then cnt 0; en_1hz 1; else cnt cnt 1; en_1hz 0; end if; end if; end process;这样整个设计只有一个时钟在跑时序分析简单不需要处理多个时钟域之间的约束。对于这个项目闸门信号可以用使能信号的方式生成计数器在使能有效时锁存数据。我最终采用的是这种方案因为它更干净综合后资源占用也更少。实操心得很多教程喜欢用分频时钟因为直观。但如果你以后要做更复杂的系统多个分频时钟会让时序约束变得非常麻烦。养成用使能信号的习惯受益无穷。4. 计数器模块的边沿检测与闸门控制4.1 待测信号的边沿检测计数器要数的是待测信号的脉冲个数但待测信号可能不是标准的方波可能有毛刺也可能上升沿很慢。所以第一步是对待测信号做边沿检测提取出干净的上升沿脉冲。-- 上升沿检测 signal sig_d1, sig_d2 : std_logic : 0; signal rising_edge_pulse : std_logic : 0; process(clk_sys, rst_n) begin if rst_n 0 then sig_d1 0; sig_d2 0; rising_edge_pulse 0; elsif rising_edge(clk_sys) then sig_d1 sig_in; sig_d2 sig_d1; rising_edge_pulse sig_d1 and (not sig_d2); end if; end process;这里我用系统时钟对输入信号打两拍然后检测sig_d1为1且sig_d2为0的时刻就是上升沿。打两拍的作用是同步化防止亚稳态传播。虽然这个项目里待测信号频率不高但养成这个习惯没坏处。4.2 闸门信号的生成与同步闸门信号决定了计数的时间窗口。我用一个1Hz的使能信号每秒钟有效一个系统时钟周期。但计数是在待测信号域下进行的所以需要把这个使能信号同步到待测信号域。-- 闸门使能同步到待测信号域 signal gate_en_d1, gate_en_d2 : std_logic : 0; signal gate_active : std_logic : 0; process(sig_in, rst_n) begin if rst_n 0 then gate_en_d1 0; gate_en_d2 0; gate_active 0; elsif rising_edge(sig_in) then gate_en_d1 en_1hz; gate_en_d2 gate_en_d1; if gate_en_d1 1 and gate_en_d2 0 then gate_active not gate_active; end if; end if; end process;这段代码的逻辑是检测到en_1hz的上升沿后翻转gate_active信号。gate_active为高时计数为低时停止并锁存。这样闸门时间就是1秒但闸门的起止时刻是同步到待测信号上升沿的避免了在计数过程中切断脉冲导致的错误。4.3 计数与锁存逻辑计数逻辑本身很简单在gate_active有效期间每个待测信号上升沿计数器加一。闸门结束时把计数值锁存到输出寄存器。-- 计数与锁存 signal count : integer range 0 to 99999999 : 0; signal count_latched : integer range 0 to 99999999 : 0; process(sig_in, rst_n) begin if rst_n 0 then count 0; count_latched 0; elsif rising_edge(sig_in) then if gate_active 1 then count count 1; else count_latched count; count 0; end if; end if; end process;这里有个细节count_latched是在gate_active为低时更新的但gate_active为低会持续将近1秒所以count_latched会被反复赋值。这没问题因为count在gate_active为低时被清零了所以count_latched的值是稳定的。但这里有一个跨时钟域的问题count_latched是在待测信号域下更新的而数码管驱动在系统时钟域下读取它。如果直接读可能读到正在变化的数据。解决方法是在系统时钟域下再锁存一次-- 在系统时钟域下锁存计数值 signal count_display : integer range 0 to 99999999 : 0; process(clk_sys, rst_n) begin if rst_n 0 then count_display 0; elsif rising_edge(clk_sys) then count_display count_latched; end if; end process;这样count_display就是稳定的可以在系统时钟域下安全使用。注意这里我用了integer类型在VHDL里integer默认是32位有符号数范围很大。但综合器可能会报错因为integer的范围太宽。更安全的做法是用std_logic_vector或者unsigned类型指定位宽。比如最大计数值是99,999,999需要27位可以用unsigned(26 downto 0)。5. 数码管动态扫描驱动的实现细节5.1 七段码译码表数码管有共阳和共阴两种译码表不一样。我这里以共阴数码管为例段选信号高电平点亮。-- 七段码译码函数 function seg7_decode(digit : integer range 0 to 9) return std_logic_vector is variable seg : std_logic_vector(6 downto 0); begin case digit is when 0 seg : 0111111; -- abcdefg when 1 seg : 0000110; when 2 seg : 1011011; when 3 seg : 1001111; when 4 seg : 1100110; when 5 seg : 1101101; when 6 seg : 1111101; when 7 seg : 0000111; when 8 seg : 1111111; when 9 seg : 1101111; when others seg : 0000000; end case; return seg; end function;这个译码表对应的是a到g七个段顺序是seg(6)到seg(0)。如果你的开发板段选顺序不一样需要调整。5.2 动态扫描的时序设计动态扫描的原理是多个数码管共用段选线位选线独立。每次只点亮一个数码管快速轮流点亮利用人眼视觉暂留形成同时显示的效果。扫描频率一般选1kHz左右即每个数码管点亮1ms如果有6个数码管刷新率就是166Hz人眼看不到闪烁。-- 扫描时钟生成50MHz - 1kHz signal scan_cnt : integer range 0 to 49999 : 0; signal scan_clk : std_logic : 0; process(clk_sys, rst_n) begin if rst_n 0 then scan_cnt 0; scan_clk 0; elsif rising_edge(clk_sys) then if scan_cnt 49999 then scan_cnt 0; scan_clk 1; else scan_cnt scan_cnt 1; scan_clk 0; end if; end if; end process;这里scan_clk是一个单周期使能脉冲不是时钟。扫描逻辑在系统时钟下检测到scan_clk有效时切换位选。5.3 位选切换与数据多路复用假设用6个数码管显示需要把计数值拆成6个十进制数字。比如计数值是123456需要拆成1、2、3、4、5、6。-- 计数值拆分为十进制数字 signal digit0, digit1, digit2, digit3, digit4, digit5 : integer range 0 to 9 : 0; signal temp : integer range 0 to 999999 : 0; process(clk_sys, rst_n) begin if rst_n 0 then digit0 0; digit1 0; digit2 0; digit3 0; digit4 0; digit5 0; elsif rising_edge(clk_sys) then temp count_display mod 1000000; digit0 temp mod 10; digit1 (temp / 10) mod 10; digit2 (temp / 100) mod 10; digit3 (temp / 1000) mod 10; digit4 (temp / 10000) mod 10; digit5 (temp / 100000) mod 10; end if; end process;这里用了除法和取模综合器会生成除法器资源占用较大。更高效的方法是用BCD转换算法比如Double Dabble算法。但对于这个项目计数值最大999999除法器资源可以接受。位选切换逻辑-- 位选切换 signal scan_idx : integer range 0 to 5 : 0; signal seg_data : std_logic_vector(6 downto 0) : (others 0); signal dig_sel : std_logic_vector(5 downto 0) : (others 0); process(clk_sys, rst_n) begin if rst_n 0 then scan_idx 0; seg_data (others 0); dig_sel (others 0); elsif rising_edge(clk_sys) then if scan_clk 1 then if scan_idx 5 then scan_idx 0; else scan_idx scan_idx 1; end if; end if; case scan_idx is when 0 seg_data seg7_decode(digit0); dig_sel 000001; when 1 seg_data seg7_decode(digit1); dig_sel 000010; when 2 seg_data seg7_decode(digit2); dig_sel 000100; when 3 seg_data seg7_decode(digit3); dig_sel 001000; when 4 seg_data seg7_decode(digit4); dig_sel 010000; when 5 seg_data seg7_decode(digit5); dig_sel 100000; when others seg_data (others 0); dig_sel (others 0); end case; end if; end process;这段代码在scan_clk有效时切换scan_idx然后根据scan_idx选择对应的数字和位选信号。注意seg_data和dig_sel是在系统时钟下更新的但scan_clk是单周期脉冲所以实际上每个扫描周期只更新一次。实操心得动态扫描的亮度取决于两个因素扫描频率和限流电阻。扫描频率太低会闪烁太高会变暗。1kHz是个经验值实际可以根据数码管数量和亮度要求调整。限流电阻一般选220Ω到1kΩ太小会烧数码管太大会太暗。6. 顶层连接与引脚分配实战6.1 顶层端口定义顶层模块需要定义所有输入输出端口entity freq_meter_top is port ( clk_50m : in std_logic; -- 板载50MHz时钟 rst_n : in std_logic; -- 复位按键低有效 sig_in : in std_logic; -- 待测信号输入 seg : out std_logic_vector(6 downto 0); -- 七段码段选 dig : out std_logic_vector(5 downto 0) -- 数码管位选 ); end entity;6.2 模块例化与信号连接顶层架构里把分频器、计数器、数码管驱动例化连接architecture rtl of freq_meter_top is -- 分频器输出 signal en_1hz : std_logic; signal scan_clk : std_logic; -- 计数器输出 signal count_display : integer range 0 to 999999; -- 数码管驱动输出 signal seg_data : std_logic_vector(6 downto 0); signal dig_sel : std_logic_vector(5 downto 0); begin -- 分频器例化 u_clk_div : entity work.clk_divider port map ( clk_in clk_50m, rst_n rst_n, en_1hz en_1hz, scan_clk scan_clk ); -- 计数器例化 u_counter : entity work.freq_counter port map ( clk_sys clk_50m, rst_n rst_n, sig_in sig_in, en_1hz en_1hz, count_out count_display ); -- 数码管驱动例化 u_display : entity work.seg_driver port map ( clk_sys clk_50m, rst_n rst_n, scan_clk scan_clk, count_in count_display, seg_out seg_data, dig_out dig_sel ); -- 输出赋值 seg seg_data; dig dig_sel; end architecture;6.3 引脚分配与约束引脚分配取决于具体的开发板。以某款常见的FPGA开发板为例50MHz时钟通常接在特定的全局时钟引脚上复位按键接在某个GPIO上待测信号可以接在扩展IO上数码管段选和位选接在对应的排针上。在约束文件里需要指定引脚位置和电平标准。比如# 时钟引脚 set_location_assignment PIN_E1 -to clk_50m set_instance_assignment -name IO_STANDARD 3.3-V LVTTL -to clk_50m # 复位引脚 set_location_assignment PIN_M1 -to rst_n set_instance_assignment -name IO_STANDARD 3.3-V LVTTL -to rst_n # 待测信号引脚 set_location_assignment PIN_A1 -to sig_in set_instance_assignment -name IO_STANDARD 3.3-V LVTTL -to sig_in注意不同开发板的引脚定义完全不同一定要查你手头板子的原理图。把信号分配到错误的引脚上轻则不工作重则烧芯片。我第一次做的时候就是把数码管段选接到了电源引脚上上电后数码管全亮吓得我赶紧断电。7. 常见问题排查与调试技巧实录7.1 数码管显示乱码或闪烁这是最常见的问题。排查顺序如下现象可能原因排查方法全亮或全灭段选极性反了检查共阳/共阴设置改译码表显示数字错位位选顺序反了交换dig_sel的位顺序闪烁严重扫描频率太低减小扫描分频系数提高扫描频率亮度不均限流电阻不一致检查每个段的限流电阻某一位不亮位选引脚虚焊用万用表测位选引脚电压我遇到过一次显示乱码查了半天发现是译码表里seg(6)到seg(0)的顺序和实际段选引脚顺序不一致。后来用了一个笨办法写一个测试模式让每个段依次点亮记录下实际对应的段然后重新排列译码表。7.2 计数值不稳定或跳变计数值跳变通常是因为闸门信号和待测信号没有同步好。如果你发现计数值在某个值附近上下跳动比如1000、1001、999来回变那就是±1误差属于正常现象。但如果跳动幅度很大比如从1000跳到2000那就是跨时钟域处理有问题。检查以下几点闸门信号是否用两级触发器同步到了待测信号域计数值是否在系统时钟域下重新锁存复位信号是否同步释放实操心得复位信号最好用同步复位或者异步复位同步释放。异步复位如果释放时刻靠近时钟边沿会导致亚稳态。我一般用异步复位同步释放的电路-- 异步复位同步释放 signal rst_sync : std_logic : 0; signal rst_d1 : std_logic : 0; process(clk_sys, rst_n) begin if rst_n 0 then rst_d1 0; rst_sync 0; elsif rising_edge(clk_sys) then rst_d1 1; rst_sync rst_d1; end if; end process;7.3 高频信号计数不准如果你测一个10MHz的信号发现计数值只有实际的一半或者更少那可能是待测信号的频率超过了计数器逻辑能处理的最大频率。计数器逻辑在待测信号域下工作每个待测信号上升沿计数器加一。如果待测信号频率太高计数器的进位链延迟会导致计数错误。解决方法有两个一是用更高速度的计数器结构比如超前进位二是先用一个高速预分频器把待测信号分频然后测量分频后的信号最后把结果乘以分频比。比如待测信号是100MHz先用一个4分频器分到25MHz测量25MHz得到计数值再乘以4就是实际频率。但预分频器本身也要能处理100MHz的信号所以预分频器的逻辑要尽可能简单最好只用一级触发器。7.4 综合报错与资源优化VHDL综合时常见的报错integer范围太宽把integer改成unsigned或std_logic_vector指定位宽。除法器资源不够用Double Dabble算法替代除法和取模。时钟信号用了非全局引脚把时钟分配到全局时钟引脚或者在约束文件里加时钟约束。组合逻辑环路检查是否有信号自己赋值给自己。资源优化方面如果FPGA资源紧张可以把数码管驱动里的除法器改成BCD转换可以省下大量LUT。另外如果不需要显示6位可以只显示4位减少扫描逻辑。8. 从能跑到好用几个值得尝试的改进方向这个项目做到能显示频率基本就完成了。但如果你想让它更好用有几个方向可以继续折腾。第一个是自动量程切换。现在的设计是固定闸门时间1秒低频信号误差大。可以加一个量程判断逻辑如果计数值小于1000就把闸门时间延长到10秒如果大于100000就把闸门时间缩短到0.1秒。这样在低频段和高频段都能有较好的精度。第二个是增加溢出指示。如果计数值超过6位数码管能显示的最大值999999可以点亮一个溢出指示灯或者让数码管显示“------”表示溢出。第三个是用等精度测量法。等精度测量法通过同时计数待测信号和基准信号消除了±1误差在整个频率范围内精度一致。实现起来比直接测频法复杂但精度提升明显。如果你已经把这个基础项目跑通了强烈建议试试等精度测量。第四个是把计数值通过串口输出。这样可以在电脑上记录频率变化曲线比看数码管直观多了。串口发送模块在VHDL里也很成熟网上有很多参考代码。我在实际使用中发现这个频率计用来测按键抖动特别有意思。按键按下时会有几十毫秒的抖动频率计会显示一个跳变的频率值松开后归零。你可以用这个来观察不同按键的抖动特性挺有意思的。最后再分享一个小技巧如果你手头没有信号发生器可以用FPGA自己产生一个待测信号。写一个简单的分频器从50MHz分频出1kHz、10kHz、100kHz等几个频率然后用跳线把输出接到待测信号输入引脚上就可以自测了。这样不用额外设备就能验证整个系统是否工作正常。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询