
最近帮朋友调试一台设备输出的一路方波信号频率很低大概在几十赫兹到几十千赫兹之间来回变占空比还会跟着工况变化。手头没有频率计示波器虽然能看波形但总不能一直让人盯着屏幕读数据。我干脆用手里一块闲置的FPGA开发板写了一个低频方波测量模块把频率和占空比直接测出来显示在数码管上顺便通过串口发给上位机。做完之后发现这个需求其实非常典型很多做传感器、电机驱动、电源控制的人都会遇到所以把这个项目的完整思路、代码结构、仿真方法和踩过的坑整理出来。这个方案的核心思路是用FPGA内部的高频基准时钟去“数”输入方波一个周期内跑了多少拍以及高电平期间跑了多少拍然后反算出频率和占空比。听上去不复杂但真正做起来有几个容易翻车的地方输入信号怎么整形、计数器位宽怎么定、除法怎么做、数码管怎么刷新不闪烁、低频信号仿真花的时间太长怎么办。这篇文章会一个一个说清楚并且给出可以直接拿来改的Verilog代码框架。适合刚入门FPGA、想做一个完整小项目的朋友也适合已经在做信号测量、想了解测周期法和测频法区别的工程师。1. 需求拆解与方案选型1.1 低频方波测量到底难在哪如果只测频率很多人第一反应是“数脉冲不就行了”开一个固定时间窗口比如1秒统计这个窗口内来了多少个上升沿频率就是计数结果。这个方法叫测频法原理很简单但对低频信号非常不友好。假设被测信号是10Hz1秒窗口内只能数到10个脉冲计数结果稍微波动一下显示就变成9Hz或11Hz误差直接到10%。就算把窗口时间拉长到10秒分辨率也只能到0.1Hz而且刷新速度慢得让人抓狂。低频方波的周期长这是它的物理特性决定的想要用固定窗口测频法拿到高分辨率代价非常大。占空比测量就更麻烦一点。占空比等于高电平时间除以整个周期时间要得到这两个量必须精确知道信号什么时候变高、什么时候变低。如果信号边沿不干净带着毛刺或者上升沿很缓计数结果就会忽大忽小显示值跳得没法看。所以这个项目真正要解决的不是“怎么数数”这种简单问题而是三个层面的问题信号进来之后怎么处理才能让计数器稳定工作计数器和计算逻辑怎么设计才能保证精度结果显示怎么做才能既直观又不闪烁。1.2 为什么选测周期法而不是测频法既然固定窗口测频法不适合低频那就反过来想不数“一个窗口内有多少个被测信号周期”而是数“一个被测信号周期内有多少个基准时钟周期”。这就是测周期法。具体做法是FPGA内部跑一个高频基准时钟比如100MHz。被测信号一个完整的周期内基准时钟会走过很多个时钟沿用计数器把这些时钟沿数出来记作N。那么被测信号的周期就是N乘以基准时钟周期频率就是基准时钟频率除以N频率 100MHz / N举个例子被测信号是1kHz时周期是1ms100MHz时钟在一个周期内能数到100000拍。用这个计数值反推频率分辨率远高于测频法。而且被测信号频率越低一个周期内数到的拍数越多测量精度越高。这和固定窗口测频法正好相反天生就是为低频信号准备的。我这次把目标测量范围定在1Hz到100kHz占空比范围1%到99%。在这个范围内测周期法的精度完全够用。如果被测信号频率很高比如几兆赫兹以上才需要考虑等精度测量法或者纯测频法。对于低频方波测量这个题目测周期法是最合适的方案没有之一。1.3 设计指标与整体架构动手之前先定了几个指标后续所有设计都围绕这几个指标展开指标项设计目标频率测量范围1Hz ~ 100kHz频率分辨率最低1Hz低频段远优于0.1Hz占空比测量范围1% ~ 99%占空比显示精度0.1%输入信号电平3.3V / 5V兼容基准时钟100MHz刷新率每个被测信号周期更新一次整体架构分成五块信号预处理电路、边沿检测与同步模块、双计数器模块、计算模块、显示输出模块。信号进来先经过施密特触发器整形变成干净的方波然后进FPGA做两级同步消除亚稳态再进入计数器。计数器在每个被测信号周期内同时统计周期计数值和高电平计数值锁存后送给计算模块做除法最后把频率和占空比送到数码管和串口。2. 硬件预处理与核心参数计算2.1 输入信号整形与电平适配FPGA的IO不能直接接乱七八糟的信号。很多传感器输出的所谓“方波”实际边沿很缓甚至带有明显的振铃和毛刺。如果直接把这种信号送进FPGA引脚计数器会在边沿附近反复触发测出来的数据完全是乱的。我习惯在输入端加一级74HC14施密特触发器。施密特触发器的特点是有迟滞特性输入电压超过上门限才输出翻转低于下门限才再次翻转中间有一段“死区”。这个死区能天然滤掉一部分毛刺同时能把缓慢变化的边沿整形成陡峭的标准方波。实测下来输入信号哪怕是从几百毫伏缓慢爬升的三角波经过74HC14之后也能变成质量很好的方波。电平适配方面要特别注意。FPGA的Bank电压如果是3.3VIO引脚一般不能直接接受5V信号。如果被测设备输出5V方波必须做电平转换。最简单的办法是电阻分压把5V分到3.3V以下。我在BNC接口后面放了一个10kΩ和6.8kΩ的分压电阻配合稳压管钳位实测很稳。如果被测信号是12V甚至更高的电压就不要用分压了直接上光耦隔离更安全顺便把地环路也断开了。2.2 时钟选择与计数器位宽推导基准时钟直接决定测量分辨率。我用了开发板上的100MHz有源晶振时钟周期10ns。为什么不用更常见的50MHz道理很简单50MHz时钟周期20ns同样是1kHz信号一个周期只能数到50000拍而100MHz能数到100000拍分辨率提高了一倍。功耗和资源占用在这个量级几乎没差别但测量精度实打实提升了一倍这个性价比很划算。计数器位宽需要根据最低测量频率来算。最低频率1Hz周期1秒100MHz时钟在一个周期内会走100000000个时钟沿。100000000转换成二进制需要27位。为了留余量、防止意外情况导致计数溢出我直接全部用32位计数器。FPGA上32位计数器也就占用十几个逻辑单元完全不用省。这里有个容易忽略的点计算频率时要把计数值加1再拿去用。因为计数器在上升沿清零下一次上升沿到来时锁存实际数到的值会比真实周期少一个时钟周期。这是测量原理带来的系统性偏差虽然只有10ns但如果不修正频率高的时候误差会明显偏大。计算模块里我统一做了加1修正。2.3 显示与通信接口选择显示部分我做了两路输出。频率用一组四位数码管显示单位Hz直接显示整数部分就够用。占空比用另一组两位数码管加一个小数点显示显示成百分比形式比如“50.0%”。数码管动态扫描容易出问题后面专门讲。我的建议是如果条件允许优先把UART串口调通用串口把频率和占空比打出来调试。串口数据能直接看到完整数值还能看到原始计数值排查问题时比数码管直观得多。数码管更适合最终产品使用而串口更适合开发调试阶段。这次我两个都做了但在开发的前半段几乎所有问题都是靠串口打印发现的。3. FPGA核心模块实现3.1 顶层模块划分整个工程在Vivado里面建了一个顶层文件下面挂了六个子模块clk_gen时钟管理把有源晶振的100MHz时钟处理一下必要时加PLLsync_edge输入信号两级同步同时产生上升沿和下降沿脉冲counter双计数器统计周期计数值和高电平计数值calc计算模块做除法和格式转换display数码管动态扫描驱动uart_tx串口发送把测量结果格式化输出模块化设计的好处是每个模块可以单独写testbench验证出了问题定位很快。很多初学者习惯把代码全部堆在一个always块里看起来省事但一旦结果不对很难判断是计数器问题还是计算问题还是显示问题。顶层例化的时候要注意counter模块输出的valid信号要连到calc模块表示“本次计数值有效”。calc模块内部有一个除法器除法器不是组合逻辑需要几个时钟周期才能出结果所以valid信号也要经过相应延迟保证数据同步。3.2 跨时钟域同步与边沿检测输入信号来自外部设备和FPGA的100MHz时钟没有确定的相位关系属于异步信号。异步信号直接进触发器有可能采到介于高低电平之间的中间状态也就是亚稳态。亚稳态会导致寄存器输出不确定严重的时候整个逻辑都会卡死。解决亚稳态的标准做法是两级寄存器同步。输入信号打两拍第二拍输出的信号基本可以认为是和100MHz时钟同步的了。边沿检测在这两级同步之后做用同步后的信号和它延迟一拍的信号做比较reg [1:0] sync_reg; reg sig_dly; wire rising; wire falling; always (posedge clk or negedge rst_n) begin if (!rst_n) begin sync_reg 2b00; sig_dly 1b0; end else begin sync_reg {sync_reg[0], sig_in}; sig_dly sync_reg[1]; end end assign rising sync_reg[1] ~sig_dly; assign falling ~sync_reg[1] sig_dly;这里有一个细节rising信号比真正的输入边沿晚了一个到两个时钟周期。这个延迟是固定的对周期测量没有影响因为锁存和清零用的都是同一个延迟后的边沿信号误差只是整体平移不会影响周期值。真正影响测量值的是上一节说的“少一拍”问题通过加1修正解决。3.3 周期计数与高电平计数时序核心计数器逻辑是整个项目的灵魂我贴出关键代码reg [31:0] cnt_total; reg [31:0] cnt_high; reg [31:0] period_cnt; reg [31:0] high_cnt; reg valid; always (posedge clk or negedge rst_n) begin if (!rst_n) begin cnt_total 32d0; cnt_high 32d0; period_cnt 32d0; high_cnt 32d0; valid 1b0; end else begin valid 1b0; cnt_total cnt_total 1b1; if (sync_reg[1] 1b1) cnt_high cnt_high 1b1; if (rising) begin period_cnt cnt_total 1b1; // 加1修正 high_cnt cnt_high 1b1; // 占空比测量同样修正 cnt_total 32d0; cnt_high 32d0; valid 1b1; end end end这个逻辑的思路是cnt_total 一直在走不管输入信号是高还是低每个clk都加1cnt_high 只在输入信号为高的时候加1。每检测到一个上升沿说明上一个周期结束了把当前计数值锁存到period_cnt和high_cnt然后把两个计数器清零开始新一周期的计数。上升沿锁存的是“上一周期”的数据这一点要特别注意。第一次上电后需要等待一个完整周期才会输出第一个有效数据这是正常的。valid信号会在锁存数据的那一个时钟周期拉高告诉后面的计算模块“数据来了”。占空比的计算公式就是占空比百分比 high_cnt / period_cnt × 100%注意high_cnt在信号刚好等于50%占空比时理论上应该等于period_cnt的一半。由于计数器清零和边沿检测存在最多1拍的相位差实际会差几个计数值但这在低频测量中完全可以忽略。3.4 频率占空比计算从除法到定点数频率计算公式是100000000除以period_cnt占空比公式是high_cnt乘100再除以period_cnt。这里涉及FPGA最头疼的运算之一除法。FPGA里实现除法有两种常见方式。一种是直接用厂商提供的除法器IP核Vivado里面有Divider GeneratorQuartus里面有LPM_DIVIDE。配置好位宽和流水线级数把被除数和除数十进制输入几十个时钟周期后能拿到商和余数。另一种是自己写移位相减除法器结构简单但占资源速度也慢。我建议这个项目直接用IP核原因很简单数据更新频率很低高频除法器根本没有必要IP核虽然延迟几十个时钟周期但结果稳定不会出错。自己写除法器如果位宽没控制好很容易出现商和余数错位的问题排查起来非常头疼。计算模块的状态机大致是这样检测到valid信号后把period_cnt和100000000送入除法器等到除法器输出有效信号时把商和余数锁存转换成BCD码送入显示模块。占空比计算类似先把high_cnt乘100直接移位加几个加法器就能实现乘以100再送除法器。显示部分用到BIN转BCD。四位十进制数可以用纯组合逻辑的查表法也可以用移位加3算法。我这次用的是移位加3写了一个小函数逐位处理代码很短效果稳定。3.5 结果输出数码管动态显示与UART数码管动态显示的原理并不复杂就是利用人眼的视觉暂留效应快速轮流点亮每一位数码管。四位数码管每一位点亮1ms整个循环4ms刷新率250Hz看起来就是同时亮的。这里有个非常容易踩的坑动态扫描一定要保证每一位的显示数据在切换时不出现毛刺。我一开始直接在时钟上升沿同时切换位选和段选结果数码管亮度不均匀还出现鬼影。后来把操作拆成两步先在时钟低电平准备好段码数据再在时钟上升沿切换位选问题就消失了。其实就是给段码一个建立时间道理和普通时序约束一样。UART部分我写了一个简单的发送模块波特率96008位数据1位停止位。上位机用串口助手接收每秒刷新一次。发送的内容格式化之后大概长这样F: 1000Hz D: 50.0%串口调试最重要的作用是能看到原始计数值。我把period_cnt和high_cnt也一起打出来格式是P:100000 H:50000 F:1000Hz D:50.0%这样一旦显示值不对立刻能看出来是计数阶段出了问题还是计算阶段出了问题。开发调试阶段这个输出格式帮我省了大量时间。4. 仿真验证与实测结果4.1 Testbench编写要点仿真验证阶段我写了两个testbench。第一个很简单产生一个固定1kHz方波占空比40%100MHz时钟下理论上period_cnt应该大约是100000high_cnt应该是40000左右。产生带占空比的信号Verilog里最直接的方式是用两组延时控制initial begin clk 0; forever #5 clk ~clk; // 100MHz时钟 end initial begin sig_in 0; #10000; // 先给10us低电平 forever begin sig_in 1; #400; // 高电平保持400us占空比40% sig_in 0; #600; // 低电平保持600us end end这里有个细节被测信号1kHz一个周期1ms仿真至少要跑到几个完整周期才能看到有效输出。在ModelSim里1ms仿真时间对应的时钟沿数量是100000个跑起来还算流畅但如果测的是10Hz信号一个周期100ms仿真跑完就是1000万个时钟沿这时候ModelSim会明显变慢。所以仿真阶段用1kHz以上的信号验证逻辑正确性实测阶段再去验证低频场景这样可以节省大量仿真时间。第二个testbench模拟了上电后立刻有信号的场景用来验证第一个测量周期是否会产生异常值。实测发现上电后第一个有效输出之前计算模块可能拿到一个无效的中间值需要通过一个计数器屏蔽前两次输出保证稳定后再刷新显示。4.2 精度分析误差从哪里来测周期法的理论误差来源主要是量化误差也就是计数器只能数整数个时钟周期结果和真实值之间最多差1个时钟周期。100MHz时钟下这个量化误差就是10ns。不同频率下的理论误差可以列表对比被测频率周期周期计数值频率理论误差1Hz1s100000000约0.000001%100Hz10ms1000000约0.0001%1kHz1ms100000约0.001%10kHz100us10000约0.01%100kHz10us1000约0.1%从表格能清楚看到频率越低精度越高。在100kHz时误差到了0.1%这个量级对于通用测量还是可以接受的但如果你要做精密测量就不要用纯测周期法了得换等精度测量方案。占空比的误差除了量化误差还受高电平计数的精度影响。高电平时间同样是10ns分辨率所以占空比误差大约是占空比误差 ≈ 2 × 10ns / 周期低频时这个误差几乎可以忽略高频时会逐渐变大。实测下来1kHz信号占空比测量的误差在0.1%以内完全满足设计指标。4.3 实测数据对比与波形观察用信号发生器输出了几组标准信号和FPGA模块的显示值做了对比信号发生器设定FPGA实测频率FPGA实测占空比100Hz / 50.0%100.0Hz50.0%1kHz / 40.0%1000Hz40.1%10kHz / 60.0%9999Hz59.9%50kHz / 30.0%50014Hz30.2%频率越高偏差越明显这和理论误差分析的趋势完全一致。整体来看低频段精度表现非常好100kHz以内误差都在0.3%以内符合预期。有一个有意思的细节信号发生器输出50%占空比示波器上看起来也是完美的方波但FPGA读出来却是49.8%左右。一开始以为是代码问题查了很久才发现是74HC14的阈值电压不是正好在输入电压中点导致输出高电平时间比理论值略短。这不是FPGA测错了是整形电路固有的偏移。做高精度占空比测量的话这个偏移可以在软件里做校准。5. 常见问题与排查技巧5.1 计数溢出或长时间无更新如果被测信号频率高于设计上限period_cnt会变得很小此时除法器的结果跳动会很厉害。比如输入1MHz信号一个周期只数到100拍频率值可能从990kHz跳到1010kHz看起来就像电路坏了。这个问题可以通过增加档位判断解决当period_cnt小于某个阈值比如1000时自动切换到测频法模式两种方法结合就能覆盖更宽的频率范围。长时间无更新先检查边沿检测信号有没有输出。可以用调试逻辑分析仪或者直接把rising信号引到LED上看有没有闪烁。如果LED不闪说明输入信号没有正确进入FPGA检查外部整形电路和引脚分配如果LED闪但显示不刷新问题在计数器清零逻辑或valid信号链路上。5.2 显示乱跳、数值不稳定显示乱跳最常见的原因是输入信号带毛刺。毛刺会触发额外的上升沿导致计数器在错误位置清零周期值和高电平值完全是乱的。解决办法是外部加施密特触发器并且在FPGA内部对同步后的信号再做一次毛刺滤波比如连续采样到多个高电平才认为信号真正变高。另一个原因是数码管动态扫描刷新率太低。如果每位点亮时间太长人眼能看到明显的闪烁如果太短数码管亮度不够而且可能显示不均匀。我最终把每位点亮时间设为1ms四位数码管一个循环4ms刷新率250Hz既看不到闪烁亮度也足够。5.3 输入引脚损坏与接口保护这个坑我替大家踩过了。最开始图省事直接从信号发生器输出5V方波接到FPGA板子的3.3V Bank引脚上结果用了不到半天那个引脚就失灵了。FPGA的IO引脚不是5V兼容的超压输入会直接损坏引脚内部的ESD保护结构。正确做法是做好防护再进FPGA。我在输入端加了串联电阻和二极管钳位串联电阻选100Ω左右既能限制电流又和引脚寄生电容组成低通滤波能稍微滤掉一些高频噪声。如果输入信号来自工业现场强烈建议用光耦隔离虽然光耦会引入额外的上升沿延迟但对于低频方波测量来说这点延迟完全不影响精度。光耦还能彻底解决地电位不一致的问题避免地环路干扰。5.4 ModelSim仿真常见坑仿真低频信号最大的问题是时间尺度太大。我第一次用10Hz信号仿真跑了20分钟波形窗口里还看不到一个完整周期后来想通了仿真只是验证逻辑正确性不是验证低频性能完全可以用1kHz或10kHz信号代替逻辑只要对了低频下只是计数值更大不会有本质区别。另一个常见坑是仿真时寄存器初始值不定。如果复位信号设计不好一开始所有寄存器都是X态计数器加来加去还是X波形一片红。我的做法是设计一个异步复位、同步释放的复位信号并且在testbench里一开始就拉低复位释放之后等几个时钟周期再送被测信号。同时用ModelSim的“wave”窗口观察内部信号时重点关注reset、sync_reg、cnt_total这几个关键信号从源头排查比瞎猜快得多。这个模块做完之后我又把它改成了脉冲宽度测量的小工具原理完全一样只是换了一下计数触发条件。如果你也想做一个类似的东西我的建议是不要一上来就搞数码管先UART把数据打出来确认计数和计算都对再做显示部分这样问题不会混在一起。低频方波测量这个题目看似简单但把信号处理、跨时钟域、计数器设计、除法运算、显示刷新这些点全部串起来对理解FPGA开发流程很有帮助。