尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

FPGA数字速度表设计:从Verilog代码到Modelsim仿真的完整实践

FPGA数字速度表设计:从Verilog代码到Modelsim仿真的完整实践 1. 项目缘起从需求到FPGA实现的思考最近在整理一些FPGA的入门项目发现“速度表”是一个非常好的综合练习。它不像流水灯那么基础也不像图像处理那么复杂正好卡在一个能用到状态机、计数器、显示驱动等多个核心知识点的位置。很多人学Verilog照着书上的例子写个计数器、分频器跑个仿真看到波形就以为会了但一到需要把这些模块组合起来解决一个实际的小问题时就有点无从下手。这个速度表的设计恰好能填补这个空白。简单来说我们要用Verilog在FPGA上实现一个数字速度表。它的核心功能是模拟接收一个代表速度的脉冲信号比如来自霍尔传感器然后实时计算并在数码管或LED上显示出当前的速度值。这听起来简单但拆开来看里面涉及了信号去抖、频率测量或周期测量、数值转换、显示驱动等一系列环节。而整个开发流程从代码编写、功能仿真Simulation到最后的Quartus综合与下载又是一个完整的FPGA开发闭环体验。这次我们就聚焦在最能验证逻辑正确性的环节——仿真。我会用最“接地气”的方式带你走一遍代码设计和Modelsim仿真的全过程把那些教程里一笔带过、但实际操作时必踩的坑都摆出来。2. 系统架构设计与模块划分在动手写代码之前必须先想清楚系统怎么架构。一个粗糙的设计会导致代码臃肿、调试困难。对于这个速度表我习惯采用“自顶向下”的模块化设计这样每个模块功能单一便于仿真和调试。整个系统可以划分为以下几个核心模块脉冲输入与去抖模块 (key_debounce.v)实际的速度传感器如霍尔传感器产生的脉冲信号在FPGA的GPIO口上可能会存在机械抖动或电气噪声导致在极短时间内出现多个跳变。这个模块的作用就是对输入的脉冲信号进行滤波确保每个有效的速度事件只产生一个干净的脉冲。我通常会用一个20ms左右的防抖窗口这是按键消抖的常用值对于低速旋转的速度测量也基本适用。速度测量核心模块 (speed_meter.v)这是整个系统的大脑。它的任务是精确测量“去抖后脉冲”之间的时间间隔周期或者在一定闸门时间内统计脉冲的个数频率。对于速度表测量周期更直观因为周期直接对应转速。这里我选择周期测量法用系统高频时钟例如50MHz去计数两个有效脉冲之间的时钟周期数。假设时钟是clk_50m周期为T_clk20ns测得两个脉冲间有N个时钟周期那么脉冲周期T_pulse N * 20 ns脉冲频率F_pulse 1 / T_pulse。再根据“每转产生P个脉冲”的传感器参数就能换算成转速。这个模块的输出就是计算得到的速度值比如一个16位的二进制数。数值转换模块 (bin_to_bcd.v)速度测量模块计算出的速度值通常是二进制数。但我们要用十进制显示在数码管上这就需要二进制到BCD码的转换。例如二进制数1010十进制10需要转换成两个BCD码0001和0000分别代表十位和个位。这里可以用经典的“加3移位”算法来实现。数码管显示驱动模块 (seg_driver.v)该模块负责将BCD码转换成数码管各段a, b, c, d, e, f, g, dp的亮灭信号并实现多位数码管的动态扫描以节省FPGA的IO口资源。顶层模块 (speedometer_top.v)这个模块就像项目的总接线图负责实例化并连接所有子模块定义整个系统对外的接口时钟、复位、脉冲输入、数码管段选/位选输出。为什么这么划分因为仿真时可以逐个击破。你可以先单独仿真speed_meter.v给它模拟一个脉冲输入看它计算出的数值对不对。然后再把bin_to_bcd.v加进来一起仿真。这种分层次、分模块的仿真策略是调试复杂FPGA设计的最有效手段远比写一个巨无霸模块然后抓瞎要强。3. 核心模块代码详解与关键逻辑实现接下来我们深入最核心的速度测量模块speed_meter.v的代码细节。我会解释每一段代码的意图而不仅仅是贴代码。3.1 脉冲边沿检测首先我们需要检测输入脉冲的上升沿或下降沿取决于传感器类型。这是测量周期的起点和终点。module speed_meter ( input wire clk, // 系统时钟如50MHz input wire rst_n, // 低电平复位 input wire pulse_in, // 去抖后的速度脉冲输入 output reg [15:0] speed_value // 计算出的速度值二进制 ); reg pulse_in_dly; // 用于打拍延迟一拍的寄存器 wire pulse_pos_edge; // 上升沿标志位 // 时钟沿触发的逻辑 always (posedge clk or negedge rst_n) begin if (!rst_n) begin pulse_in_dly 1b0; end else begin pulse_in_dly pulse_in; // 将输入信号延迟一个时钟周期 end end // 产生上升沿脉冲当前拍为高上一拍为低 assign pulse_pos_edge (~pulse_in_dly) pulse_in;这段代码是数字电路中的经典操作。pulse_in_dly保存了pulse_in上一个时钟周期的状态。通过比较当前状态和上一状态就能精确地捕捉到信号的跳变沿。pulse_pos_edge这个信号会在检测到上升沿时产生一个时钟周期宽的高电平脉冲。这个脉冲就是我们测量周期的“发令枪”和“终点线”。3.2 周期测量与速度计算抓到边沿后就要用高频时钟去测量两个边沿之间的时间了。这里我采用一个计数器在第一个边沿到来时清零并开始计数在第二个边沿到来时锁存计数器的值。reg [31:0] period_counter; // 周期计数器位宽要足够大防止溢出 reg [31:0] period_reg; // 锁存的周期值 reg measure_active; // 测量使能标志 always (posedge clk or negedge rst_n) begin if (!rst_n) begin period_counter 32d0; period_reg 32d0; measure_active 1b0; end else begin if (pulse_pos_edge) begin // 一旦检测到脉冲边沿 if (!measure_active) begin // 第一次边沿启动测量清零计数器 measure_active 1b1; period_counter 32d1; // 从1开始计数代表第一个时钟周期已过去 end else begin // 第二次边沿结束测量锁存计数值 measure_active 1b0; period_reg period_counter; // 锁存两个边沿之间的时钟周期数 period_counter 32d0; // 计数器清零准备下次测量 end end else if (measure_active) begin // 测量进行中计数器递增 period_counter period_counter 32d1; // 安全保护如果计数器溢出长时间没来下一个脉冲强制结束本次测量 if (period_counter) begin // 如果计数器全为1即达到最大值 measure_active 1b0; period_reg 32hFFFF_FFFF; // 锁存一个特殊值表示超时 end end end end这段逻辑包含了一个状态机通过measure_active标志实现。period_reg最终锁存的值N就是两个速度脉冲之间系统时钟的周期数。速度换算假设系统时钟频率F_clk 50 MHz周期T_clk 20 ns。传感器每旋转一圈产生P 1个脉冲。 那么测得的脉冲周期T_pulse N * 20 ns。 转速转/秒Speed_rps 1 / T_pulse F_clk / N。 如果我们要显示“转/分”RPM则Speed_rpm Speed_rps * 60 (F_clk * 60) / N。在Verilog中做除法是昂贵的尤其是对于FPGA。一个常见的优化是如果时钟频率和显示单位固定我们可以预先计算一个常数因子。例如F_clk * 60 50,000,000 * 60 3,000,000,000。那么Speed_rpm 3_000_000_000 / N。但3e9这个数已经超过了32位有符号整数的范围约21亿所以period_counter和计算过程需要更宽的位宽或者我们可以接受一定的精度损失进行缩放。为了简化仿真和初学者理解我们可以在仿真中先不进行这个除法而是直接观察period_reg的倒数关系period_reg越小表示速度越快。// 简化版速度值输出用周期值的倒数近似表示速度仅用于原理演示 // 实际项目需要做定点数除法或使用查找表 always (posedge clk or negedge rst_n) begin if (!rst_n) begin speed_value 16d0; end else if (pulse_pos_edge measure_active) begin // 在第二个边沿锁存时 // 这里做一个非常简化的处理假设最大周期对应速度0最小周期对应最大值 // 实际应用必须进行精确的 (常数 / period_reg) 运算 speed_value ~period_reg[15:0]; // 取反操作仅用于仿真观察趋势非真实计算 end end注意上面的speed_value ~period_reg[15:0];这行代码是完全错误的工程实现仅用于在仿真波形中直观地看到“周期越小输出值越大”的反比趋势方便我们验证测量逻辑是否正确。真正的产品代码必须实现除法运算或其它等效算法。这是一个重要的仿真技巧用一些取巧的方式让波形更容易观察但心里必须清楚这离最终实现还差得远。4. 编写Testbench进行功能仿真代码写好了对不对呢不能上板子再看仿真才是第一道关卡。我们用Modelsim或Quartus自带的ModelSim-Altera来写一个测试平台Testbench。4.1 搭建Testbench框架创建一个tb_speed_meter.v文件。它的主要任务是产生时钟和复位信号。模拟产生一个频率可变的pulse_in信号。实例化待测试的设计DUT, Design Under Test。将关键信号输出到波形窗口观察。timescale 1ns/1ns // 定义时间单位/精度 module tb_speed_meter(); // 1. 定义连接到DUT的信号 reg clk; reg rst_n; reg pulse_in; wire [15:0] speed_value; // 2. 实例化被测试模块 speed_meter u_speed_meter ( .clk(clk), .rst_n(rst_n), .pulse_in(pulse_in), .speed_value(speed_value) ); // 3. 生成时钟信号周期20ns对应50MHz initial begin clk 0; forever #10 clk ~clk; // 每10ns翻转一次周期20ns end // 4. 生成复位信号 initial begin rst_n 0; // 初始复位 #100; // 复位保持100ns rst_n 1; // 释放复位 #2000000; // 仿真运行一段时间 $stop; // 结束仿真 end // 5. 生成模拟的速度脉冲信号 pulse_in // 这是仿真的核心我们要模拟不同速度即不同周期的脉冲 integer pulse_period; // 用整数变量控制脉冲周期单位ns initial begin pulse_in 0; pulse_period 20000; // 初始周期20us对应频率50kHz假设的传感器高速情况 forever begin #(pulse_period/2) pulse_in 1; // 脉冲高电平 #1000 pulse_in 0; // 假设脉冲宽度为1us // 动态改变周期模拟速度变化 if ($time 500000) begin // 仿真到500us时 pulse_period 50000; // 周期变长为50us模拟速度变慢 end if ($time 1000000) begin // 仿真到1ms时 pulse_period 10000; // 周期变短为10us模拟速度变快 end end end // 6. 可选将信号记录到VCD文件用于其它波形查看器 initial begin $dumpfile(wave.vcd); $dumpvars(0, tb_speed_meter); end endmodule4.2 仿真执行与波形分析在Modelsim中编译speed_meter.v和tb_speed_meter.v然后仿真。我们需要重点观察以下几组信号的关系clk和rst_n确保时钟和复位正常。pulse_in观察我们模拟的脉冲是否按预设的周期20us-50us-10us变化。pulse_pos_edge这个信号应该在每个pulse_in的上升沿出现一个时钟周期的尖脉冲。这是测量触发的标志。measure_active它应该在第一个pulse_pos_edge后拉高在第二个pulse_pos_edge后拉低形成一个测量窗口。period_counter在measure_active为高期间这个计数器应该每个时钟周期加1。它的最终值在measure_active下降沿时就是锁存到period_reg的值。period_reg和speed_value这是我们的核心结果。当脉冲周期从20us变为50us速度变慢时period_reg锁存的值应该变大因为N T_pulse / T_clk 50us / 20ns 2500比之前的20us/20ns1000大。相应地我们那个“取反近似”的speed_value应该变小这符合速度变慢的预期。当周期变为10us速度变快时period_reg应该变小speed_value应该变大。通过波形验证这些逻辑是仿真阶段最重要的任务。如果发现period_counter在脉冲高电平期间也在计数那说明边沿检测可能没做好如果measure_active窗口不对那状态机逻辑有问题。这个过程就是“调试”。5. 集成与系统级仿真挑战单独测试了速度测量核心后下一步就是把去抖模块、BCD转换模块、显示驱动模块都集成到顶层进行系统级仿真。这时会遇到一些新的挑战挑战一仿真速度。系统级仿真涉及多个模块和更长的逻辑链尤其是显示驱动模块的动态扫描通常以毫秒计而我们的时钟是纳秒级。如果仿真一个完整的1秒现实时间在软件里可能需要跑很久。解决办法是合理设置仿真时间只仿真几个关键的速度变化周期而不是完整的一分钟。对慢速模块进行“仿真加速”例如在Testbench中可以将显示扫描的时钟分频比改小或者直接注释掉扫描过程只检查BCD码输出是否正确。挑战二模拟传感器抖动。在更真实的测试中pulse_in输入不应该是一个理想的方波。我们应该在Testbench里模拟抖动在真正的边沿附近随机插入几个毛刺脉冲。然后观察去抖模块是否能有效过滤它们确保speed_meter模块接收到的是干净的信号。// 在tb中模拟带抖动的脉冲输入简略示例 reg clean_pulse; // 理想的、无抖动的脉冲 // ... 生成clean_pulse的逻辑同前... // 模拟抖动在理想边沿前后加入随机毛刺 always (posedge clk) begin if ($random % 100 5) begin // 5%的概率产生毛刺 pulse_in ~pulse_in; // 随机翻转 #(10 {$random} % 50); // 毛刺持续10-59ns pulse_in clean_pulse; // 恢复为理想值 end else begin pulse_in clean_pulse; end end挑战三验证数值转换链。系统级仿真的最终目的是验证从“原始脉冲”到“显示数字”的整条链路。你需要设计一个测试用例给定一个特定的脉冲频率手动计算出预期的转速RPM然后观察BCD转换模块的输出是否与预期一致。这往往需要你在Testbench里添加一些自动检查的语句$display或assert。// 在Testbench中计算预期值并与输出比较 always (posedge u_speed_meter.pulse_pos_edge) begin // 当测量完成时measure_active下降沿计算理论周期 if (u_speed_meter.measure_active 1b0 u_speed_meter.period_reg ! 0) begin integer measured_period_ns; integer expected_speed; measured_period_ns u_speed_meter.period_reg * 20; // 周期ns // 简单计算速度 60 / (周期 * 1e-9) 6e10 / period_reg // 这里做整数近似仅用于示例 expected_speed 60_000_000_000 / measured_period_ns; $display(Time%tns, Measured Period%d clks (%d ns), Expected Speed approx.%d RPM, $time, u_speed_meter.period_reg, measured_period_ns, expected_speed); // 可以在这里添加assert语句比较expected_speed和转换后的显示值 end end6. Quartus工程设置与仿真流程要点写完代码和Testbench最终要在Quartus Prime里走一遍流程。这里有几个容易忽略的要点1. 仿真库的编译如果你的设计里用了Altera的IP核比如PLL、RAM在Modelsim仿真前必须先将这些IP核对应的仿真库编译到你的Modelsim环境中。Quartus的安装目录下通常有脚本.../altera/xx.x/modelsim_ase/可以完成这个工作。忽略这一步会导致仿真时找不到这些底层模块而失败。2. Testbench文件的管理在Quartus中通常只添加设计文件.v。Testbench文件tb_*.v不需要添加到Quartus工程里。你需要在Modelsim中单独建立一个仿真项目或者通过Quartus的“Settings - EDA Tool Settings - Simulation”指定Testbench文件名和顶层实体。我更推荐前者因为更灵活。3. 仿真脚本.do文件的使用手动在GUI里点来点去效率太低。编写一个Tcl脚本.do文件可以自动化整个仿真流程创建库、映射库、编译所有文件、启动仿真、添加波形、运行。下次仿真时只需要在Modelsim命令行执行do run_sim.do即可。# 示例 run_sim.do 文件内容 vlib work vmap work work # 编译设计文件 vlog ../src/speed_meter.v vlog ../src/bin_to_bcd.v # ... 编译其他设计文件 ... # 编译Testbench文件 vlog tb_speed_meter.v # 启动仿真指定顶层Testbench模块 vsim -t ns work.tb_speed_meter # 添加波形 add wave -position insertpoint sim:/tb_speed_meter/* # 运行仿真一段时间 run 2ms # 可选将波形保存为.wlf文件 # dataset save wave wave.wlf4. 波形调试技巧Modelsim的波形窗口中可以将period_reg和speed_value等信号的数据格式设置为“十进制无符号数Unsigned Decimal”这样看起来更直观。对于总线信号可以分组Group并赋予有意义的名称。善于使用“比较Compare”功能将两次不同参数下的仿真波形叠加能快速定位差异。7. 从仿真到上板的注意事项仿真通过只意味着你的逻辑在理想环境下是正确的。下载到FPGA开发板上板才是真正的考验。这里有几个仿真阶段不易发现但上板必现的问题1. 时钟约束与时序问题仿真默认是理想的没有延时。但实际FPGA内部信号走线有延迟。如果代码中组合逻辑路径过长例如在一个时钟周期内做了多次复杂的计算或条件判断可能导致建立时间Setup Time或保持时间Hold Time违例电路无法在指定的时钟频率下稳定工作。在Quartus编译后必须查看“TimeQuest Timing Analyzer”的报告确保没有时序违规Timing Violation。对于这个速度表项目如果speed_value的计算逻辑过于复杂比如做了完整的32位除法就很可能出现时序问题。这时就需要考虑流水线Pipeline或使用更快的算法。2. 异步信号处理我们的pulse_in来自外部传感器它与FPGA内部的clk是异步关系。在代码中我们直接用clk去采样pulse_in这可能会遇到亚稳态Metastability问题导致采样值不稳定。虽然我们前面加了去抖模块但去抖模块的输入同样面临亚稳态风险。更严谨的做法是在去抖模块前先对输入的异步信号进行至少两级同步器Two-Flip-Flop Synchronizer处理。// 在脉冲输入引脚后的第一级处理同步器 reg pulse_sync1, pulse_sync2; always (posedge clk or negedge rst_n) begin if (!rst_n) begin pulse_sync1 1b0; pulse_sync2 1b0; end else begin pulse_sync1 pulse_in_raw; // pulse_in_raw是直接来自IO引脚的信号 pulse_sync2 pulse_sync1; end end // 将同步后的pulse_sync2送入去抖模块 key_debounce u_debounce (.clk(clk), .rst_n(rst_n), .key_in(pulse_sync2), ...);3. 资源利用率与优化仿真不关心你的设计占用了多少查找表LUT或寄存器Register。但上板前必须查看编译报告中的“Flow Summary”。如果资源占用率超过80%就需要考虑优化。对于这个项目最耗资源的可能是BCD转换模块中的除法/乘法。如果速度范围固定可以考虑用查找表LUT来实现除法即预先把所有可能的输入值对应的输出值算好存到ROM里。用空间换时间既能保证速度又能简化时序。4. 引脚分配与物理连接在Quartus中需要通过“Pin Planner”将顶层模块的输入输出信号分配到FPGA芯片的具体物理引脚上并设置正确的电气标准如3.3V LVTTL。脉冲输入引脚应分配到一个支持外部中断或快速输入的通用IO上。数码管的段选和位选信号要根据开发板的原理图正确分配并注意驱动电流有时需要外接上拉电阻或三极管驱动。仿真就像在图纸上设计汽车一切都很完美。而上板测试则是把车造出来在真实路况下跑。图纸上的曲线再平滑也预料不到路上的一颗小石子。只有通过严谨的仿真排除逻辑错误再通过上板调试解决物理世界的问题一个FPGA项目才算真正成功。这个速度表项目虽小但完整地走完了这个流程对于理解FPGA开发的全貌价值远大于那些孤立的代码片段。
返回列表