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等几个频率,然后用跳线把输出接到待测信号输入引脚上,就可以自测了。这样不用额外设备就能验证整个系统是否工作正常。