ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

FPGA数字频率计设计:从Verilog实现到Modelsim仿真全流程解析

FPGA数字频率计设计:从Verilog实现到Modelsim仿真全流程解析 1. 项目概述从零构建一个FPGA数字频率计最近在整理以前的项目笔记翻到了一个用Verilog在Quartus里实现的数字频率计感觉挺有代表性的。这个项目麻雀虽小五脏俱全它几乎涵盖了FPGA数字逻辑设计的几个核心环节计数器、状态机、时序约束以及最后的仿真验证。很多朋友刚接触FPGA时可能觉得写个计数器、分频器很简单但一旦要把这些模块组合起来完成一个像频率计这样有明确输入输出、需要精确计时的系统就会遇到一堆时序和功能上的问题。今天我就把这个项目的完整实现思路、代码细节以及如何在Modelsim里进行仿真的过程从头到尾捋一遍希望能帮你打通从模块设计到系统验证的任督二脉。简单说我们要做的是一个能测量外部输入信号频率的数字频率计。它的核心原理就是经典的“测频法”在一个已知的、精确的固定时间门比如1秒内统计输入信号上升沿或下降沿的个数这个计数值就是信号的频率单位Hz。比如在1秒内数到了1000个上升沿那频率就是1000Hz。整个系统会在FPGA上实现用Verilog编写在Quartus Prime里进行综合、布局布线最后用Modelsim进行功能仿真和时序仿真来验证正确性。无论你是想复现这个项目练手还是想深入理解FPGA测频系统的设计要点下面的内容都应该能给你带来不少干货。2. 核心设计思路与方案选型在动笔写代码之前先把设计思路理清楚至关重要。一个清晰的顶层架构能避免后期很多混乱的修改。2.1 测频法原理与误差分析我们选择最直观的“直接测频法”。假设我们定义一个标准的闸门时间T_gate为1秒。在这1秒内让一个计数器对被测信号clk_test的上升沿进行计数。1秒结束时计数器的值N就是clk_test的频率。但这里有个关键问题测量误差。由于被测信号与闸门信号的相位是随机的可能会导致±1个计数误差。如下图所示此处用文字描述假设闸门开启和关闭的时刻恰好不是在被测信号的边沿上那么计数器可能少数一个脉冲也可能多数一个脉冲。因此测量结果的绝对误差最大为±1Hz。对于低频信号这个相对误差±1Hz/频率值会很大。所以直接测频法更适用于测量高频信号。如果要测低频通常会采用“测周法”测量一个信号周期的时间但那个设计起来更复杂我们这次先聚焦于测频法。基于这个原理我们的系统需要以下几个核心部分基准时钟与闸门生成需要一个非常稳定的时钟源比如FPGA板载的50MHz晶振并通过分频器产生精确的1秒闸门信号。主计数器在闸门信号有效期间对被测信号进行计数。控制逻辑状态机协调闸门开启、计数、锁存显示、清零这一系列动作确保每个步骤有序进行。显示模块将计数器的二进制结果转换成十进制并驱动数码管或发送到上位机显示。2.2 系统顶层模块划分根据上述思路我习惯将系统划分为几个层次清晰的模块这样便于编写、调试和复用。整个项目的顶层模块Top Module结构如下freq_meter_top (顶层) ├── gate_generator (闸门信号生成器) ├── control_fsm (控制状态机) ├── counter (主计数器) ├── latch (数据锁存器) └── display_driver (显示驱动器可选)gate_generator输入系统基准时钟如50MHz通过分频产生一个周期为2秒高电平1秒低电平1秒的方波作为闸门信号。这1秒高电平就是我们的测量窗口。control_fsm这是系统的大脑。它检测闸门信号的边沿产生相应的控制信号在闸门上升沿启动计数器在闸门下降沿锁存计数器的值到锁存器并在锁存后对计数器进行清零为下一次测量做准备。counter这是一个带使能端和清零端的二进制计数器。当控制状态机给出的count_en信号为高时它对被测信号clk_test的上升沿进行计数。latch一个简单的寄存器组。当控制状态机给出latch_en脉冲时它将当前计数器的值保存起来。显示模块从此锁存器中读取数据这样在测量过程中显示值不会随计数器跳动只在每次测量完成后更新一次视觉上更稳定。display_driver如果要在板子的数码管上显示则需要这个模块将二进制数转换为BCD码再进行动态扫描。本次我们主要关注核心的测量和仿真显示部分可以简化为将锁存的数据输出到顶层端口在仿真波形中观察。为什么选择这样的结构因为它实现了“测量”与“显示”的解耦。控制状态机确保了测量流程的严格时序锁存器消除了显示抖动。这是一种在FPGA设计中非常可靠和常见的模式。3. 关键模块的Verilog代码实现与解析接下来我们深入到每个模块的代码层面。我会先给出代码然后逐段解释关键点和注意事项。3.1 闸门信号生成器 (gate_generator.v)这个模块负责产生精确的1秒闸门。假设系统时钟clk_50m是50MHz周期20ns。module gate_generator ( input wire clk_50m, // 50MHz系统时钟 input wire rst_n, // 低电平复位 output reg gate_out // 闸门信号高电平有效期为1秒 ); // 参数定义50MHz时钟1秒需要计数50_000_000次 // 闸门周期为2秒高1秒低1秒所以计数器最大值是100_000_000 - 1 parameter CLK_FREQ 50_000_000; parameter GATE_TIME 1; // 闸门高电平时间单位秒 localparam COUNT_MAX CLK_FREQ * GATE_TIME * 2 - 1; // 计算计数终值 localparam COUNT_HIGH CLK_FREQ * GATE_TIME - 1; // 高电平结束点 reg [26:0] counter; // 需要27位才能计数到100,000,000 (2^27134,217,728) always (posedge clk_50m or negedge rst_n) begin if (!rst_n) begin counter 0; gate_out 1b0; end else begin if (counter COUNT_MAX) begin counter 0; end else begin counter counter 1; end // 在计数值小于COUNT_HIGH时闸门输出高电平 if (counter COUNT_HIGH) begin gate_out 1b1; end else begin gate_out 1b0; end end end endmodule代码解析与注意事项参数化设计使用parameter和localparam来定义关键常数。这样做的好处是如果时钟频率或闸门时间需要改变只需修改参数值而不需要重新计算并硬编码到代码里提高了代码的可移植性和可读性。计数器位宽50MHz时钟下计数1秒需要5千万次。为了产生周期2秒的方波我们需要计数到1亿。2^26 67,108,864不够2^27 134,217,728足够因此定义了一个27位的寄存器counter。位宽估算不足是新手常犯的错误会导致计数器溢出后行为异常。闸门生成逻辑通过判断counter的值来产生gate_out。当counter从0计数到COUNT_HIGH即49,999,999时gate_out为高1秒之后为低另1秒。这是一个非常清晰且易于理解的实现方式。3.2 控制状态机 (control_fsm.v)这是整个系统的核心控制器我通常用一个三段式状态机来实现清晰且易于维护。module control_fsm ( input wire clk_50m, input wire rst_n, input wire gate_signal, // 来自gate_generator的闸门信号 output reg count_en, // 计数器使能 output reg latch_en, // 锁存器使能脉冲 output reg clear_en // 计数器清零使能脉冲 ); // 定义状态 localparam S_IDLE 2b00; // 空闲 localparam S_COUNT 2b01; // 计数 localparam S_LATCH 2b10; // 锁存 localparam S_CLEAR 2b11; // 清零 reg [1:0] current_state, next_state; // 状态寄存器第一段 always (posedge clk_50m or negedge rst_n) begin if (!rst_n) current_state S_IDLE; else current_state next_state; end // 状态转移逻辑第二段 always (*) begin next_state current_state; // 默认保持当前状态 case (current_state) S_IDLE: begin if (gate_signal) // 检测到闸门上升沿此处简化实际最好用边沿检测 next_state S_COUNT; end S_COUNT: begin if (!gate_signal) // 检测到闸门下降沿 next_state S_LATCH; end S_LATCH: begin // 锁存状态只维持一个时钟周期立刻跳转到清零 next_state S_CLEAR; end S_CLEAR: begin // 清零状态也只维持一个周期然后回到空闲等待下次闸门 next_state S_IDLE; end default: next_state S_IDLE; endcase end // 输出逻辑第三段 always (posedge clk_50m or negedge rst_n) begin if (!rst_n) begin count_en 1b0; latch_en 1b0; clear_en 1b0; end else begin // 默认输出 count_en 1b0; latch_en 1b0; clear_en 1b0; case (current_state) S_COUNT: begin count_en 1b1; // 在计数状态使能计数器 end S_LATCH: begin latch_en 1b1; // 产生一个时钟周期的锁存脉冲 end S_CLEAR: begin clear_en 1b1; // 产生一个时钟周期的清零脉冲 end // S_IDLE状态无操作 endcase end end endmodule代码解析与避坑指南三段式状态机这是推荐的写法。第一段管理状态寄存器第二段用组合逻辑决定下一个状态第三段在时钟驱动下产生寄存器输出。它避免了组合逻辑输出可能产生的毛刺并且代码结构清晰。边沿检测的简化在状态转移条件中我直接用了if (gate_signal)和if (!gate_signal)。这在实际中可能会因为gate_signal的毛刺或异步性导致误触发。更稳健的做法是添加一个边沿检测电路将gate_signal用系统时钟打两拍然后检测其上升沿和下降沿。这是一个非常重要的实战技巧能极大提高系统抗干扰能力。单周期脉冲注意latch_en和clear_en只在对应的状态持续一个时钟周期。这是控制类状态机的典型做法确保后续动作只发生一次。3.3 主计数器与数据锁存器 (counter_latch.v)我把这两个简单模块写在一起方便理解数据流。module counter_latch ( input wire clk_sys, // 系统时钟用于锁存和清零 input wire clk_test, // 被测时钟 input wire rst_n, input wire count_en, // 计数使能 input wire clear_en, // 清零使能脉冲 input wire latch_en, // 锁存使能脉冲 output reg [31:0] freq_data // 锁存后的频率数据32位足够大 ); // 主计数器 reg [31:0] counter_reg; always (posedge clk_test or negedge rst_n) begin if (!rst_n) begin counter_reg 0; end else if (clear_en) begin // 注意清零信号是异步的且来自clk_sys域这里直接使用有风险 // 更好的做法是同步到clk_test域后再使用。 counter_reg 0; end else if (count_en) begin counter_reg counter_reg 1; end end // 数据锁存器 reg [31:0] data_latch; always (posedge clk_sys or negedge rst_n) begin if (!rst_n) begin data_latch 0; end else if (latch_en) begin data_latch counter_reg; // 在latch_en有效时锁存计数器当前值 end end assign freq_data data_latch; endmodule代码解析与重大陷阱跨时钟域问题 (CDC)这是这段代码最致命的问题counter_reg由clk_test被测信号频率未知驱动而clear_en和latch_en是由clk_sys系统50MHz时钟驱动的控制状态机产生的。直接将一个时钟域的信号用到另一个时钟域是严重的设计错误会导致亚稳态使计数器清零或锁存动作不可预测。正确的CDC处理对于清零信号 (clear_en)需要先将clear_en从clk_sys域同步到clk_test域。通常使用两级触发器进行同步同步后的信号clear_en_sync再用于counter_reg的复位逻辑。对于锁存信号 (latch_en)它控制的是clk_sys域下的data_latch寄存器而锁存的数据源counter_reg在clk_test域。这也是跨时钟域数据传输。更安全的做法是使用一个异步FIFO或者握手协议。但对于频率计由于latch_en脉冲很宽相对于clk_test可能很慢一个简化的、不那么严谨但常被教学项目采用的方法是在clk_sys域直接锁存并接受可能锁存到counter_reg变化过程中的中间值所带来的微小误差通常远小于±1的计数误差。在要求严格的场合必须使用同步器。计数器位宽这里用了32位最大能计数到约42.9亿对于1秒闸门能测到4.29GHz的频率远超一般FPGA和测试信号范围足够用了。3.4 顶层模块集成 (freq_meter_top.v)最后我们把所有模块像搭积木一样连接起来。module freq_meter_top ( input wire clk_50m, // 板载50MHz时钟 input wire rst_n, // 复位按键低有效 input wire clk_test, // 待测频率信号输入 output wire [31:0] freq_out // 测量得到的频率值 ); // 内部连线声明 wire gate_sig; wire count_en; wire latch_en; wire clear_en; // 实例化闸门生成器 gate_generator u_gate_gen ( .clk_50m (clk_50m), .rst_n (rst_n), .gate_out (gate_sig) ); // 实例化控制状态机 control_fsm u_control ( .clk_50m (clk_50m), .rst_n (rst_n), .gate_signal (gate_sig), .count_en (count_en), .latch_en (latch_en), .clear_en (clear_en) ); // 实例化计数器与锁存器注意此版本存在CDC问题见上文分析 counter_latch u_counter_latch ( .clk_sys (clk_50m), .clk_test (clk_test), .rst_n (rst_n), .count_en (count_en), .clear_en (clear_en), // 危险跨时钟域信号 .latch_en (latch_en), // 危险控制信号跨时钟域 .freq_data (freq_out) ); endmodule集成要点这个顶层模块非常简洁只做连线工作。但正是因为它简单所以所有底层模块的隐患都会在这里暴露。上面代码中counter_latch实例的注释已经标明了危险。在实际项目中我们需要一个处理了CDC的counter_latch_cdc模块来替换它。4. 基于Modelsim的功能仿真与测试代码写完了绝不能直接上板子。仿真Simulation是FPGA开发中保证设计正确性最经济、最有效的手段。我们将使用Modelsim进行功能仿真。4.1 编写Testbench测试平台TestbenchTB就是给我们的设计DUT, Device Under Test提供激励输入信号并观察其输出行为的Verilog模块。它只用于仿真不会综合成电路。timescale 1ns/1ns // 定义时间单位/精度 module tb_freq_meter(); // 定义信号 reg clk_50m; reg rst_n; reg clk_test; wire [31:0] freq_out; // 实例化被测设计 freq_meter_top u_dut ( .clk_50m (clk_50m), .rst_n (rst_n), .clk_test (clk_test), .freq_out (freq_out) ); // 生成50MHz系统时钟 initial begin clk_50m 0; forever #10 clk_50m ~clk_50m; // 周期20ns - 50MHz end // 生成复位信号 initial begin rst_n 0; // 初始复位 #100; // 复位保持100ns rst_n 1; // 释放复位 // 可以在这里添加更复杂的复位序列如果有需要 end // 生成被测信号 clk_test // 我们测试几个典型频率1kHz, 2.5MHz, 10MHz initial begin clk_test 0; #200; // 等待系统稳定 // 测试用例11kHz (周期1ms) forever begin #500000 clk_test ~clk_test; // 半周期500us end end // 为了测试多个频率我们可以用多个initial块或者用force/release // 这里用一个简单方法运行一段时间后改变频率 initial begin #2000000000; // 运行2秒模拟一个完整的1秒闸门测量 $display(First measurement done at time %t, $time); // 改变clk_test为2.5MHz (周期400ns) #100; forever begin #200 clk_test ~clk_test; // 半周期200ns end end // 监控和打印结果 always (posedge u_dut.u_control.latch_en) begin $display([%t] Latch triggered! Measured frequency %d Hz, $time, freq_out); end // 仿真时长控制 initial begin #5000000000; // 总共仿真5秒 $display(Simulation finished.); $stop; end endmoduleTestbench编写技巧**timescale**必须定义它决定了#延时语句的单位。1ns/1ns 表示以1ns为单位精度也是1ns。时钟生成使用initial块配合forever循环是生成周期时钟的标准方法。多频率测试一个完整的测试需要覆盖多种情况。我在这里演示了两种方法第一种是用一个forever循环定义初始频率第二种是在仿真一段时间后用另一个initial块覆盖clk_test的生成逻辑切换到新频率。更高级的做法是使用task或fork...join来组织测试序列。关键事件监控使用always (posedge some_signal)来捕获特定事件如锁存使能latch_en并打印信息。这是调试时非常有效的手段能让你知道内部状态机何时发生了关键动作。仿真时长用$stop结束仿真。闸门是1秒所以仿真时间至少要设置几秒才能看到完整的测量周期。4.2 在Modelsim中运行仿真与分析波形编译在Quartus或Modelsim中将设计文件所有.v文件和Testbench文件一起编译。仿真加载Testbench模块tb_freq_meter开始仿真。添加波形将顶层u_dut下的所有关键信号以及Testbench中的clk_test等信号添加到波形窗口。运行运行足够长的时间如5us或更长。如何分析波形查看闸门信号 (gate_sig)它应该是一个周期2秒高1秒低1秒的规整方波。查看控制信号观察count_en,latch_en,clear_en。它们应该严格遵循状态机的设计gate_sig变高后count_en变高gate_sig变低后count_en变低同时出现一个latch_en脉冲紧接着出现一个clear_en脉冲。查看计数器 (counter_reg)在count_en为高期间它应该随着clk_test的上升沿递增。在clear_en脉冲后它应该被清零。查看输出 (freq_out)在latch_en脉冲之后freq_out的值应该更新为counter_reg在闸门下降沿前一瞬间的值。这个值应该等于你设置的clk_test的频率例如1kHz信号应显示1000左右考虑±1误差。仿真调试心得如果波形不对首先检查各模块的复位是否生效时钟是否连接正确。然后重点检查状态机的状态转移是否按预期进行。Modelsim的波形窗口可以分组、重命名信号、使用不同进制显示善用这些功能能极大提高调试效率。比如把状态机的current_state用二进制或自定义的枚举名显示一目了然。5. 时序约束、上板验证与深度优化通过了功能仿真只能说逻辑正确。要真正在FPGA芯片上跑起来还需要考虑时序。5.1 在Quartus中设置时序约束时序约束告诉Quartus工具你的设计需要跑多快工具会努力优化布局布线来满足这个要求。创建.sdc文件在Quartus工程中新建一个Synopsys Design Constraints文件。定义时钟这是最基本的约束。对于我们的50MHz输入时钟create_clock -name {clk_50m} -period 20.000 [get_ports {clk_50m}]这告诉工具clk_50m端口的时钟周期是20ns即50MHz。设置输入/输出延迟对于异步输入信号clk_test和rst_n以及输出信号freq_out需要设置它们相对于时钟的延迟要求。这比较复杂但对于初学者可以先不设工具会使用默认值。但严谨的设计必须设置。执行时序分析全编译后在“Compilation Report” - “Timing Analyzer”中查看“Setup Slack”和“Hold Slack”。如果为正值表示时序满足如果为负则需要优化设计或降低时钟频率。5.2 上板验证与调试技巧生成编程文件.sof并下载到FPGA板后信号观察如果没有逻辑分析仪最直接的方法是利用板上的LED和数码管。LED指示可以将gate_sig、count_en等关键控制信号连接到LED上通过LED的闪烁来定性判断系统是否在工作。数码管显示实现之前提到的display_driver模块将freq_out的数值显示出来。这是最直观的验证。虚拟IOVirtual IO如果板子支持如通过JTAG可以使用Quartus的In-System Sources and Probes工具在PC上实时读写FPGA内部的寄存器非常强大。Signaltap II Logic Analyzer这是Quartus内置的片上逻辑分析仪。你可以将内部信号如状态机状态、计数器值、控制信号添加到Signaltap中设定触发条件如latch_en上升沿然后捕获波形。这是FPGA调试的终极利器相当于把示波器的探头伸到了芯片内部。使用Signaltap会占用额外的逻辑和内存资源但调试效率极高。5.3 常见问题与排查实录在实际操作中你几乎一定会遇到下面这些问题问题1仿真完全正确但上板后数码管显示乱跳或全零。排查思路复位问题检查板卡的复位信号是低有效还是高有效你的代码是否匹配用Signaltap抓一下复位信号和系统时钟看上电后复位是否正常释放。时钟问题系统时钟clk_50m是否真的接到了正确的引脚在Quartus Pin Planner中确认引脚分配。用Signaltap抓取clk_50m看是否有时钟波形。输入信号问题clk_test信号是否输入其电压电平是否与FPGA的IO bank电压匹配可以用一个已知频率的方波如从另一个FPGA引脚产生作为clk_test来测试。跨时钟域问题这是最隐蔽的bug。如果没做CDC处理在板子上由于亚稳态可能导致计数器或锁存器功能完全紊乱。务必按照前面所述添加同步器处理clear_en信号。问题2测量低频信号时误差极大。原因这就是直接测频法的固有缺陷。测1Hz信号±1误差就是±100%的相对误差。解决方案延长闸门时间将闸门从1秒增加到10秒那么±1误差就变成了±0.1Hz相对误差降到±10%。但这会降低测量速度。改用等精度测频法或测周法等精度测频法同时用被测信号和基准时钟计数能在整个频率范围内获得高精度。这是更高级的实现其核心是使用两个计数器一个对被测信号计数另一个对基准时钟计数通过比例运算得到频率。问题3Signaltap抓不到信号或信号显示为红色不定态。排查思路编译设置确保在Quartus设置中打开了“Preserve fan-out free register node”和“Preserve all registers”等选项防止综合工具优化掉你想观察的信号。采样时钟Signaltap的采样时钟必须是你设计中的同步时钟通常是clk_50m且频率不能高于它。触发条件检查触发条件是否设置得当。如果永远触发不了就抓不到数据。可以先设一个简单的触发比如某个信号从0变1。信号未复位如果寄存器没有明确的复位值上电后可能是X不定态。确保所有关键寄存器都在复位信号下被初始化。问题4测量结果偶尔出现巨大跳变如从1000跳到几十万。原因很可能是计数器溢出或者锁存时刻抓到了错误的计数器值由于CDC亚稳态。排查检查计数器位宽是否足够。32位对于1秒闸门测量4GHz以下信号都够但如果你闸门时间很长或信号频率估算错误也可能溢出。重点检查CDC用Signaltap同时抓取clk_test域的counter_reg和clk_sys域的latch_en、data_latch。观察latch_en脉冲到来时counter_reg是否处于一个稳定的状态还是正在变化。如果正在变化锁存到的就是一个错误的中间值。这就是为什么需要同步机制或握手协议的根本原因。这个数字频率计项目从原理到代码从仿真到上板几乎踩遍了FPGA数字系统设计的典型环节。它就像一把钥匙帮你打开了理解同步设计、状态机、时序分析、跨时钟域处理和硬件调试的大门。把这里面的每个细节都搞透再去看更复杂的FPGA项目你会发现很多思路都是相通的。
返回列表