做嵌入式和工控方向的朋友,多半都遇到过这种场景:PWM调速系统跑起来,想确认功放输出端的方波到底是预期的频率和占空比,结果示波器读数跳来跳去,尤其在几赫兹到几百赫兹这个频段,自带频率测量功能要等好几秒才稳定。后来我直接在FPGA上写了一个低频方波测量模块,把频率和占空比的测量链路完全握在自己手里。这篇文章就把这个方案从原理、架构、Verilog实现到实测数据完整复盘一遍,适合正在做FPGA入门项目、信号处理、电机控制或电源PWM信号校验的开发者参考。
1. 低频方波测量为什么不能照搬高频计数方案
1.1 先看清楚你要测的信号长什么样
低频方波在工业现场出现的频率远超想象,比如PWM调光和电机调速的控制信号、编码器或转速传感器的脉冲输出、充电桩CP信号(1kHz左右,占空比直接换算充电电流上限)、信号发生器校准时的基准方波等。这些信号频率通常在0.1Hz到10kHz之间,周期长,但边沿往往不干净——现场可能有噪声叠加,驱动芯片压摆率有限,甚至出现振铃。
在这个频段做测量,很多人的第一反应是"数上升沿"。但真正上手之后你会发现,直接数上升沿不是不行,而是响应速度和精度不可兼得。低频信号一秒只有几个沿,你要凑够统计意义上足够多的脉冲,就不得不拉长闸门,而拉长闸门意味着刷新率降低,闭环控制根本没法用。
1.2 固定闸门计数法在低频下的"原罪"
频率的本质是单位时间内的脉冲数。固定闸门计数法用一段人为设定的时间T去数上升沿个数N,然后频率等于:
f = N / T
这个公式本身没错,错在误差分析。计数沿的误差是±1个脉冲,所以相对误差约为1/N。当T=1s时,如果被测信号是1Hz,N=1,误差直接到100%;如果信号是0.1Hz,1s闸门内你可能连一个上升沿都等不到,读数直接为0。这就是为什么很多便宜的频率计在低频档刷新极慢——不是没有算法,是闸门时间被硬生生逼着拉长。
高频信号用测频法,低频信号用测周法,这是做频率测量的基本分水岭。测周法的思路反过来了:不再祈祷闸门内能撞上足够多的边沿,而是用板上一颗已知频率的高频时钟去量化信号一个完整周期内有多少个时钟周期,再由时钟频率反推信号频率。
1.3 三种测频策略的对比与选型
我把常见三种测频策略放在一起对比:
| 测量方法 | 核心公式 | 主要误差来源 | 适合频段 | FPGA资源需求 |
|---|---|---|---|---|
| 固定闸门测频法 | f = N / T | 计数沿的±1误差,相对误差1/N | 中高频 | 一个计数器加一个定时器 |
| 测周法 | f = f_clk / M | 周期计数M的±1误差,相对误差1/M | 中低频 | 周期和高电平两个计数器 |
| 等精度测量法 | f = f_clk * N_s / M_clk | 高频时钟计数的±1误差 | 宽范围通用 | 双计数器加同步闸门逻辑 |
等精度测量法是利用被测信号的边沿同步开启和关闭高频时钟计数,本质上是把测频和测周结合,无论信号频率高低,量化误差都只与高频时钟的计数值有关,适合做通用频率计。但对于"低频方波"这个明确场景,测周法已经能拿到极低的量化误差,而且结构更简单、调试更省事,所以我这篇方案最终选择了测周法加多周期平均。
2. 测周法原理与量化误差:低频信号的天然优势
2.1 测周法的核心公式
假设系统时钟频率为50MHz,信号一个完整周期内计数器得到的时钟个数为M,那么信号频率就是:
f = f_clk / M
也就是说,频率测量的本质变成了周期测量。信号周期越长,M就越大,除出来的结果小数点位数越多,相对误差越小。这一点和直觉恰恰相反——低频反而测量精度更高。
举个例子,1kHz信号在50MHz时钟下M=50000,相对量化误差只有1/50000=0.002%。而固定闸门1s的测频法在1kHz时N=1000,误差0.1%。两者差了一个数量级。在0.1Hz时测周法的M=5亿,量化误差已经小到可以忽略。
2.2 量化误差与边沿抖动
量化误差只是理论上限,真正做工程还要考虑边沿抖动。外部信号经过比较器、整形电路或长线传输后,翻转时刻本身存在时间不确定性。这种随机抖动会直接反映到周期计数值上,表现为连续几次测量的低位读数来回跳。
对于占空比测量,情况类似。高电平计数值和周期计数值是同源时钟计出来的,边沿抖动会同时影响两个计数,所以占空比的相对误差和频率测量处于同一量级。但要注意,如果信号边沿上叠加了一个窄毛刺,被同步器误判成一次翻转,整个周期的计数值会瞬间崩掉,这种异常不能靠平均消除,必须做消抖。
2.3 多周期平均:用时间摊薄随机抖动
单个周期的测量会受抖动干扰,但如果你连续测量K个周期,再把这K个周期的时间加起来求平均,随机抖动的贡献会按sqrt(K)的比例被摊薄,量化误差仍然保持在±1个时钟的极限水平。
工程上最简单的方式是"累计K个周期的period_cnt后除以K"。K一般取16、32、64。我实测下来,对1kHz附近的信号,K=64时,读数波动从千分之一级别压到万分之一级别。代价是刷新率变成信号周期的K倍。如果你的应用是实时闭环控制,K最好别超过16,否则控制器会明显感受到测量延迟。
2.4 占空比测量的本质是两路同源计数
占空比的定义是高电平时间占整个周期的比例。在FPGA里做这个比例测量,不需要额外的硬件,只要和周期计数器并行维护一个"仅在高电平时计数"的计数器:
占空比 = high_cnt / period_cnt × 100%
这里有个容易被忽略的关键点:两个计数器必须用同一个系统时钟。如果占空比计数器用了分频时钟,或周期计数器用了别的参考时钟,两者的时基不一致,占空比结果必然漂移。我见过不少初学者在这个地方踩坑,把周期计数器用信号本身的上升沿做时钟,结果high_cnt和period_cnt完全对不上相位,数据一团糟。
3. FPGA系统架构拆解:从引脚到计数器的每一级
3.1 整体数据流与模块划分
把整个测量逻辑想象成一条从输入管脚到结果寄存器的流水线:
- 输入整形(可选项):外部信号先经过比较器或施密特触发器整理成标准方波;
- 输入同步:进入FPGA后先打两到三拍,解决跨时钟域亚稳态;
- 边沿检测:产生高有效一个系统时钟周期的上升沿和下降沿脉冲;
- 双计数器:周期计数器每个时钟都加1,在上升沿清零并锁存;高电平计数器只在信号为高时加1,同样在上升沿清零并锁存;
- 结果寄存器:输出稳定的period_cnt和high_cnt,配合valid脉冲通知外部数据有效;
- 外部接口:根据项目需要接数码管、UART、ILA调试核或AXI总线。
这个链路的每一级都是可以单独测试的。尤其是同步和边沿检测这两级,任何一个翻车,后面计数器数量再对也白搭。
3.2 输入同步与亚稳态处理
很多人第一次写FPGA时会觉得,把外部信号直接接进always块判断电平高低没啥问题。在低速同步信号下确实能跑,但如果信号来自板外,无法保证和系统时钟对齐,触发器就可能进入亚稳态——数据变化窗口内采样时,输出电平不确定,而且这个不确定性可能向后级传播。
解决办法是打拍。第一级触发器输出可能亚稳态,但经过一个完整时钟周期后,绝大多数情况下已经稳定,再采一次就基本安全。代码里我用三级触发器,可以进一步压低剩余概率。
代价是输入信号被延迟了2个系统时钟周期。这个固定偏移对低频测量没有影响,因为两个边沿都被延迟相同的时间;但对占空比测量会有1到2个时钟的固定偏差。代码里我直接用同步后的信号判断高低电平,实测时可以在上位机做一个小补偿。
3.3 计数器位宽怎么定:先算极限再选位数
位宽不是随便拍脑袋定的。依据是两块:系统时钟频率和你要求的最低可测频率。最大周期计数值为:
M_max = f_clk / f_min
如果f_clk=50MHz,最低要测0.1Hz,那么M_max=5亿,二进制需要29位。再考虑高电平计数器在最极端情况下可能接近整个周期,也按同样宽度处理。所以我直接选了32位,可以覆盖到0.01Hz以下,余量充足。
还有一点:如果把K个周期累加起来再平均,累加器位宽需要额外增加ceil(log2(K))位。我建议累加平均交给上位机做,FPGA只输出逐周期的原始计数值,代码会干净很多,也方便用不同K做离线分析。
3.4 结果输出与外部接口
结果输出我采用"锁存副本加valid脉冲"的方式,而不是把计数值直接引到外部总线上。原因有两个:第一,计数器每时每刻都在变,外部直接读可能读到半个周期的垃圾值;第二,跨时钟域采样会出现数据不一致。
valid脉冲在每个上升沿锁存时拉高一个周期,外部设备只要检测到valid,就同步读取period_cnt和high_cnt。后续做多周期平均、串口发送或者总线采集,都从这里取数据。
4. Verilog核心代码实现与仿真验证
4.1 顶层模块RTL代码
完整的测量模块代码如下,我做了参数化处理,换系统时钟时只要改CLK_FREQ这一个参数。
module freq_duty_measure #( parameter CLK_FREQ = 50_000_000 // 系统时钟频率,单位Hz )( input wire clk, // 系统时钟 input wire rst_n, // 异步复位,低有效 input wire wave_in, // 待测方波输入 output reg [31:0] period_cnt, // 信号一个完整周期内的系统时钟个数 output reg [31:0] high_cnt, // 信号高电平阶段内的系统时钟个数 output reg valid // 结果有效标志,与 period_cnt 同时更新 ); // ==================== 1. 输入同步 ==================== reg [2:0] wave_sync; always @(posedge clk or negedge rst_n) begin if (!rst_n) wave_sync <= 3'd0; else wave_sync <= {wave_sync[1:0], wave_in}; end wire wave_rise = wave_sync[2] & ~wave_sync[1]; wire wave_fall = ~wave_sync[2] & wave_sync[1]; // ==================== 2. 周期与高电平计数 ==================== reg [31:0] period_tmp; reg [31:0] high_tmp; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin period_tmp <= 32'd0; high_tmp <= 32'd0; period_cnt <= 32'd0; high_cnt <= 32'd0; valid <= 1'b0; end else if (wave_rise) begin // 上一个完整周期结束,锁存结果 period_cnt <= period_tmp; high_cnt <= high_tmp; valid <= 1'b1; // 开启新一轮计数,当前时钟周期记为第 1 拍 period_tmp <= 32'd1; high_tmp <= 32'd0; end else begin valid <= 1'b0; period_tmp <= period_tmp + 1'b1; if (wave_sync[2]) high_tmp <= high_tmp + 1'b1; end end endmodule这个模块的核心逻辑很直白:wave_rise到来时,上一个完整周期的数据已经凑齐,立刻锁存;同时把两个计数器清零重开。因为上升沿检测的是同步后的信号,两个边沿的延迟完全相同,所以period_cnt的上升沿间隔和真实信号周期一致。
4.2 代码里几个容易被忽略的细节
第一个细节是wave_rise分支中period_tmp <= 32'd1而不是清零。这样处理是为了把当前检测到上升沿的这个时钟周期算进新周期,否则周期计数会少1拍。实际仿真你会发现,锁存值和真实周期的关系是稳定的,即使有几拍偏差也属于固定系统误差,可以在上位机修正。
第二个细节是high_tmp只在wave_sync[2]为高时加1。信号经过三级同步后有约2个时钟的延迟,所以高电平测量值会比真实高电平时间小1到2个时钟。对低频信号来说,这个误差占比小到可以忽略;但如果未来要测1MHz以上的方波,就需要在代码里做补偿,或者直接改用等精度法。
第三个细节是valid信号的产生。代码里valid在锁存时拉高,在其他时刻拉低。这样输出的周期正好是一个时钟宽度的脉冲,方便外部时序判断。
4.3 仿真激励设计与波形关键点
有了RTL之后,仿真验证必须跟上。下面这个Testbench生成10kHz、25%占空比的方波,用来验证逻辑是否正确。
`timescale 1ns/1ps module tb_freq_duty_measure; reg clk = 1'b0; reg rst_n = 1'b0; reg wave_in = 1'b0; wire [31:0] period_cnt; wire [31:0] high_cnt; wire valid; // 50MHz 系统时钟 always #10 clk = ~clk; freq_duty_measure #( .CLK_FREQ(50_000_000) ) uut ( .clk (clk), .rst_n (rst_n), .wave_in (wave_in), .period_cnt(period_cnt), .high_cnt (high_cnt), .valid (valid) ); // 生成 10kHz、25% 占空比方波 // period = 100_000ns,high = 25_000ns,low = 75_000ns initial begin rst_n = 1'b0; #100; rst_n = 1'b1; repeat(10) begin wave_in = 1'b1; #25_000; wave_in = 1'b0; #75_000; end #20_000; $finish; end // 打印最后一个有效结果 initial begin wait (valid); #30; $display("period_cnt=%0d, high_cnt=%0d, freq_Hz=%0d, duty_permille=%0d", period_cnt, high_cnt, 50_000_000 / period_cnt, high_cnt * 1000 / period_cnt); end endmodule按理论值计算,10kHz信号在50MHz时钟下周期计数值应为5000,高电平计数值应为1250左右。由于同步器固定吃掉1到2拍,实际high_cnt可能显示1248或1249,这是正常现象。仿真波形里重点观察两个点:上升沿之后period_cnt是否回到1重新计数;valid脉冲是否只在锁存周期出现。
4.4 上板调试时的观察清单
仿真通过后上板,我建议按下面这个清单排查:
- 先用ILA抓wave_sync、wave_rise、period_cnt、high_cnt四个信号,确认边沿检测没有毛刺;
- 给一个已知频率,比如信号源输出1kHz、50%占空比,看period_cnt是否在49999到50001之间稳定跳动;
- 如果period_cnt偶发跳成非常小的值或非常大的值,先别怀疑逻辑,用示波器看输入信号边沿是否有噪声;
- 如果设备的系统时钟和信号源时钟不是同一个源,低位读数持续波动是"两个独立时钟源的频差"的正常反映,不是bug。
5. 实测数据对比与误差复盘
5.1 实测环境
这一版我用的是黑金AX7020(Zynq-7020)开发板,系统时钟50MHz,信号源用SDG1032X输出0.1Hz到10kHz方波,幅度3.3V,直接接PL侧普通IO。仿真工具用了ModelSim Intel FPGA Starter Edition,上板调试用Vivado自带的ILA。
频率和占空比的计算在后处理工具里完成:
f = 50_000_000 / period_cnt
占空比 = high_cnt / period_cnt × 100%
5.2 频率测量结果
| 信号源设定频率 | 实测period_cnt | 计算频率 | 相对误差 |
|---|---|---|---|
| 0.1Hz | 499999990 | 0.100000Hz | 0.00002% |
| 1Hz | 49999994 | 1.000000Hz | 0.00012% |
| 10Hz | 4999999 | 10.000002Hz | 0.00002% |
| 100Hz | 499999 | 100.000200Hz | 0.00020% |
| 1kHz | 49999 | 1000.020000Hz | 0.00200% |
| 10kHz | 4999 | 10002.000400Hz | 0.02000% |
低频段的精度非常可观,真正限制极限的不是FPGA逻辑,而是信号源自身频率稳定度和测量时的时长。当频率上升到10kHz后,因为周期计数值变小,±1量化误差开始显现,但仍然在0.02%以内。
5.3 占空比测量结果
| 信号源设定 | period_cnt | high_cnt | 计算占空比 |
|---|---|---|---|
| 1kHz, 50% | 49999 | 24998 | 49.996% |
| 1kHz, 25% | 49999 | 12498 | 24.996% |
| 10kHz, 50% | 4999 | 2498 | 49.950% |
| 10Hz, 20% | 4999998 | 999997 | 19.999% |
可以清楚地看到,high_cnt比理论值偏小约2个计数,这正是三级同步器带来的固定偏差。在低频率段,这个偏差对占空比结果的影响小于0.01%;在10kHz时影响约0.05%。如果项目对占空比精度要求更高,可以在上位机统一加一个补偿系数,或者把同步器从三级改成两级减小固定延迟。
5.4 误差来源逐项拆解
第一项是量化误差,低频段可以忽略,高频段成为主导,但10kHz时也只有0.02%。第二项是同步器固定延迟,表现为高电平计数整体偏小,不影响周期计数,属于系统误差,可补偿。第三项是边沿抖动,现场噪声、信号源内阻、线缆反射都会放大它,需要从输入整形层面解决。第四项是计数器溢出,0.01Hz以下信号会逼近32位上限,此时要么加宽位宽,要么降低系统时钟。
6. 从实验模块到工程模块的改造建议
6.1 输入信号整形与电气保护
外部信号不能直接怼到FPGA引脚。输入幅度必须适配bank电压,超过3.3V要做分压或加比较器;对OC输出或推挽信号,建议串一个100到330欧姆的电阻。最好先用LM393或施密特触发器反相器把慢边沿和噪声整形干净,再进FPGA。实测中我遇到过方波上升沿从几百纳秒拖到几微秒的情况,直接接进FPGA后逻辑工作不正常,加一级整形电路后立刻稳定。
6.2 毛刺与异常跳变的处理
如果发现period_cnt偶发跳变很大,先观察wave_in的边沿。毛刺经常出现在0V附近来回振动的信号上。软件层面可以加消抖窗口:边沿检测后连续两个时钟采样到相同电平才认为翻转有效。后处理层面可以做一个中值滤波,把明显偏离均值的异常值剔除。工程上一般两层都加,消抖窗口过滤硬件级干扰,中值滤波清理偶发的逻辑误判。
6.3 自适应刷新率与自动量程
当输入频率动态范围很大时,固定K=64会导致低频率刷新非常慢。可以做一个自适应逻辑:如果最近几个周期的period_cnt很大,说明信号频率低,就降低K;如果period_cnt很小,说明信号频率高,就提高K,让测量刷新率稳定在100ms到500ms之间。测周法和测频法甚至可以在高频段自动切换,形成一个宽范围频率计。
6.4 除法与显示:FPGA内部实时出结果的路线
在低端FPGA板卡上直接做除法会消耗DSP资源。调试阶段我建议用UART把period_cnt、high_cnt和valid发送到PC,用pyserial读取后软件计算。产品化阶段再例化Xilinx或Intel的除法器IP,在FPGA内部实时计算频率和占空比,接数码管或LCD显示。如果板子资源紧张,也可以考虑用移位相减的整数除法,但代码复杂度和可维护性都会上去。
最后分享一个我实际调试中一直在用的小技巧:测这个模块之前,先拿一个已知频率的信号源,把输出设成1kHz、占空比50%,先跑一遍ModelSim仿真再上板。上板后如果period_cnt和仿真里的数字对不上,基本可以断定是引脚约束或电平转换的问题,而不是逻辑本身的问题。这样能把整个调试过程拆成"逻辑验证"和"硬件验证"两半,问题定位的速度会快很多。等这个流程稳定了,再去处理各种边沿毛刺和现场干扰,心里就会非常踏实。