FPGA数码管动态显示原理与Verilog实现详解

FPGA数码管动态显示原理与Verilog实现详解 1. 项目概述从静态到动态的显示跃迁刚接触FPGA开发板时数码管显示往往是第一个“点亮”的成就感来源。但很多新手在实现单个数字静态显示后面对“动态显示”四个字就犯了难明明板子上有4个、6个甚至8个数码管为什么代码里只控制一个它们却能轮流显示不同的数字这背后其实是FPGA设计中一个非常经典且实用的技巧——利用人眼的视觉暂留效应和FPGA的高速扫描特性实现硬件资源的高效复用。这次实验我们就来彻底拆解“数码管动态显示”的原理与实现让你不仅能让数码管“动起来”更能理解其背后的设计哲学为后续更复杂的时序逻辑设计打下坚实基础。所谓动态显示也叫扫描显示其核心思想是“分时复用”。在任一时刻实际上只有一个数码管被点亮但由于FPGA以极高的频率通常几百赫兹到几千赫兹依次循环点亮每一个数码管人眼由于视觉暂留Persistence of Vision无法分辨这种快速切换就会感觉所有数码管是同时亮起的。这与电影院播放电影的原理异曲同工。相比静态显示每个数码管独立连接一套控制电路动态显示能极大节省FPGA的I/O引脚资源是驱动多位数码管显示器的标准方法。本次实验将基于常见的共阳极数码管和FPGA开发板如Altera Cyclone IV系列或Xilinx Basys3使用Verilog HDL语言一步步构建一个稳定的动态显示驱动模块。2. 核心原理与硬件基础拆解2.1 数码管类型与驱动逻辑在动手写代码前必须搞清楚你手头数码管的类型这是所有工作的基础。数码管主要分为共阳极和共阴极两大类。共阳极数码管所有LED的阳极正极连接在一起接高电平如3.3V。当我们需要点亮某一段LED时对应的阴极引脚需要被设置为低电平。可以理解为“公共端给电控制端接地来点亮”。共阴极数码管所有LED的阴极负极连接在一起接低电平GND。当我们需要点亮某一段LED时对应的阳极引脚需要被设置为高电平。可以理解为“公共端接地控制端给电来点亮”。市面上大多数FPGA教学开发板为了与FPGA的I/O口输出特性常为推挽输出驱动高电平能力较强方便匹配较多采用共阳极数码管。因此在下面的讲解中我们将以共阳极为例。如果你的板子是共阴极只需将段选信号的逻辑取反即可。一个标准的7段数码管不包括小数点DP有a, b, c, d, e, f, g 7个段。为了显示数字“0”我们需要点亮a, b, c, d, e, f段熄灭g段。如果我们将这7个段对应的FPGA引脚输出值组合成一个7位的二进制数那么数字“0”对应的这个值假设a为最低位就是7b1000000共阳极0表示点亮1表示熄灭。这个预先定义好的、数字与段码之间的映射关系我们称之为段码表或译码表。2.2 动态扫描的工作原理与时序设计动态显示之所以能工作依赖于两个关键技术位选和段选的时分复用以及精确的扫描时序。位选Digit Select这是选择当前要点亮哪一个数码管的信号。对于共阳极数码管位选信号为低电平有效。例如一个有4位数码管的模块其位选信号可能是一个4位的向量sel[3:0]。当sel为4b1110时表示选中最低位最右边的数码管当sel为4b1101时表示选中次低位的数码管以此类推。段选Segment Select这是决定当前被选中的数码管显示什么字符的信号。即我们上面提到的7位段码seg[6:0]。动态扫描的过程如下在第一个时间片例如2ms位选信号sel设置为4b1110选中第0位数码管。同时段选信号seg输出想要在第0位显示的数字所对应的段码。此时只有第0位数码管被通电显示指定数字其他数码管因位选为高电平而完全断电不显示。2ms后进入第二个时间片。位选信号sel变为4b1101选中第1位数码管。同时段选信号seg更新为想要在第1位显示的数字的段码。如此循环依次点亮第2位、第3位数码管完成一轮扫描后再回到第0位周而复始。扫描频率是关键。如果扫描太慢比如低于60Hz人眼会明显感觉到闪烁。如果扫描太快每个数码管点亮的时间太短会导致亮度不足。通常扫描频率设置在60Hz到1kHz之间都是可接受的。例如对于4位数码管若希望整体刷新率为100Hz则扫描周期为10ms每位点亮时间即为10ms / 4 2.5ms。在实际FPGA设计中我们通过一个计数器来产生这个扫描时钟实现稳定的时序控制。注意位选信号的切换必须与段选信号的更新严格同步。理想情况下应在位选信号改变的前一个时钟周期就准备好下一位的段码数据并在位选信号切换的同一时钟沿输出以避免在切换瞬间产生错误的“鬼影”显示。3. 系统设计与模块划分一个健壮的动态显示驱动模块不应该是一个“巨无霸”的单一模块而应该进行清晰的层次化设计。这不仅便于调试和维护也体现了良好的FPGA设计习惯。通常我们可以将系统划分为以下几个子模块3.1 顶层模块top顶层模块是设计的“外壳”负责实例化所有子模块并定义它们之间的连接关系同时将内部信号映射到FPGA的实际物理引脚Pin Assignment。在这个数码管动态显示实验中顶层模块主要完成以下任务实例化时钟分频/扫描时钟生成模块。实例化显示数据缓冲模块可选用于存储要显示的多位数字。实例化动态扫描控制模块核心。将扫描控制模块输出的位选(sel)和段选(seg)信号分配到对应的FPGA引脚上。3.2 扫描时钟生成模块clk_genFPGA的主时钟频率通常很高如50MHz我们需要从中分频得到一个适合动态扫描的时钟例如1kHz。这个模块通常就是一个简单的计数器。module clk_gen ( input wire clk_50m, // 50MHz主时钟 input wire rst_n, // 低电平复位 output reg clk_scan // 扫描时钟约1kHz ); parameter CLK_DIV 50000; // 50MHz / 1kHz 50000 reg [15:0] cnt; always (posedge clk_50m or negedge rst_n) begin if (!rst_n) begin cnt 16d0; clk_scan 1b0; end else begin if (cnt (CLK_DIV/2 - 1)) begin cnt 16d0; clk_scan ~clk_scan; // 时钟翻转 end else begin cnt cnt 1b1; end end end endmodule这个模块产生一个占空比为50%的1kHz时钟其上升沿将作为动态扫描状态机切换的触发信号。3.3 显示数据缓冲与译码模块display_data这个模块负责管理要显示的内容。例如我们可能有一个32位的二进制数需要以十进制形式显示在8位数码管上。这个模块需要完成数据缓冲接收来自其他模块如计数器、传感器接口的原始数据。数制转换将二进制数转换为十进制数的每一位BCD码。例如将16进制数0x123转换为4组BCD码1,2,3。这个过程可以通过除法取余硬件实现或者更常用的“移位加3”算法Double Dabble Algorithm。段码译码将每一位十进制数字0-9翻译成对应的7段段码。这是一个简单的查找表LUT过程。module seg_decoder ( input wire [3:0] data_in, // BCD码输入0-9 output reg [6:0] seg_out // 7段段码输出a~g ); // 共阳极数码管段码表 (0点亮1熄灭) always (*) begin case(data_in) 4d0: seg_out 7b1000000; // 0 4d1: seg_out 7b1111001; // 1 4d2: seg_out 7b0100100; // 2 4d3: seg_out 7b0110000; // 3 4d4: seg_out 7b0011001; // 4 4d5: seg_out 7b0010010; // 5 4d6: seg_out 7b0000010; // 6 4d7: seg_out 7b1111000; // 7 4d8: seg_out 7b0000000; // 8 4d9: seg_out 7b0010000; // 9 default: seg_out 7b1111111; // 全灭 endcase end endmodule3.4 动态扫描控制核心模块scan_control这是整个动态显示系统的“大脑”。它以一个状态机为核心在扫描时钟的驱动下工作状态定义每个状态对应一位数码管被选中。例如对于4位数码管有4个状态SEL_DIG0,SEL_DIG1,SEL_DIG2,SEL_DIG3。状态转移每个扫描时钟周期状态机切换到下一个状态实现数码管的轮询。输出逻辑在当前状态下根据状态索引从显示数据缓冲模块中取出对应位的段码输出到seg。同时根据状态索引生成对应的位选信号输出到sel。例如在SEL_DIG0状态sel输出4b1110。消隐处理在状态切换的瞬间可以短暂地将所有位选置为无效全高或将段选置为全灭然后再输出新的值这能有效消除位间串扰带来的“鬼影”。4. Verilog代码实现与逐行解析下面我们以一个驱动4位共阳极数码管显示固定数字“1234”的完整例子来串联上述所有模块。为了突出重点我们将部分模块简化合并。4.1 顶层模块实现module top_dynamic_display ( input wire clk, // 50MHz时钟 input wire rst_n, // 复位键低有效 output wire [6:0] seg, // 7段段选信号a~g output wire [3:0] sel // 4位位选信号低有效 ); // 内部信号声明 wire clk_1k; // 1kHz扫描时钟 reg [3:0] display_data [3:0]; // 4位显示数据寄存器组 wire [6:0] seg_data; // 当前要输出的段码 reg [1:0] state; // 扫描状态机2位可表示4个状态 reg [3:0] sel_reg; // 位选寄存器 // 实例化扫描时钟生成模块 clk_gen u_clk_gen ( .clk_50m(clk), .rst_n(rst_n), .clk_scan(clk_1k) ); // 显示数据初始化固定显示1234 initial begin display_data[0] 4d4; display_data[1] 4d3; display_data[2] 4d2; display_data[3] 4d1; end // 动态扫描状态机在扫描时钟域下工作 always (posedge clk_1k or negedge rst_n) begin if (!rst_n) begin state 2d0; sel_reg 4b1111; // 复位时所有数码管关闭 end else begin case(state) 2d0: begin sel_reg 4b1110; // 选中第0位 state 2d1; end 2d1: begin sel_reg 4b1101; // 选中第1位 state 2d2; end 2d2: begin sel_reg 4b1011; // 选中第2位 state 2d3; end 2d3: begin sel_reg 4b0111; // 选中第3位 state 2d0; end default: begin sel_reg 4b1111; state 2d0; end endcase end end // 段码译码器组合逻辑 seg_decoder u_seg_decoder ( .data_in(display_data[state]), // 关键根据当前状态索引数据 .seg_out(seg_data) ); // 输出赋值 assign seg seg_data; assign sel sel_reg; endmodule代码解析display_data是一个寄存器数组存储了4位要显示的BCD码。这里为了简单用initial块赋了固定值。在实际应用中这里应该连接到一个能实时更新数据的模块。关键联动seg_decoder模块的输入data_in是display_data[state]。这意味着段码输出直接由状态机的当前状态state决定。当state0时输出display_data[0]即4的段码当state1时输出display_data[1]即3的段码完美实现了数据与位置的同步。状态机在clk_1k的上升沿触发每2.5ms1kHz/4切换一次状态和位选信号。4.2 功能扩展显示自增计数器让固定数字动起来我们来实现一个从0累加到9999再清零的计数器。这需要修改数据生成部分。module top_counter_display ( input wire clk, input wire rst_n, output wire [6:0] seg, output wire [3:0] sel ); wire clk_1k; wire clk_1hz; // 1Hz时钟用于计数器累加 reg [13:0] counter_val; // 14位二进制计数器最大16383 reg [3:0] bcd [3:0]; // 4位BCD码缓存 // ... (扫描时钟生成、扫描状态机模块与之前相同) // 1Hz时钟生成模块用于驱动计数器 // ... (类似clk_gen分频系数为50_000_000) // 二进制计数器每秒加1 always (posedge clk_1hz or negedge rst_n) begin if (!rst_n) counter_val 14d0; else if (counter_val 14d9999) counter_val 14d0; else counter_val counter_val 1b1; end // 二进制转BCD模块关键 // 这里介绍一种易于理解的循环算法在50MHz时钟下运行 reg [3:0] i; reg [13:0] bin_temp; always (posedge clk or negedge rst_n) begin if (!rst_n) begin for(i0; i4; ii1) bcd[i] 4d0; bin_temp 14d0; end else begin bin_temp counter_val; // 锁存当前计数值 // 初始化BCD码为0 bcd[3] 4d0; bcd[2] 4d0; bcd[1] 4d0; bcd[0] 4d0; // 循环14次因为counter_val是14位 for(i0; i14; ii1) begin // 如果个位5加3 if(bcd[0] 4) bcd[0] bcd[0] 2b11; // 如果十位5加3 if(bcd[1] 4) bcd[1] bcd[1] 2b11; // 如果百位5加3 if(bcd[2] 4) bcd[2] bcd[2] 2b11; // 如果千位5加3 if(bcd[3] 4) bcd[3] bcd[3] 2b11; // 整体左移一位移入二进制数的最高位 {bcd[3], bcd[2], bcd[1], bcd[0]} {bcd[3], bcd[2], bcd[1], bcd[0]} 1; bcd[0][0] bin_temp[13-i]; end end end // 段码译码器输入改为BCD码数组 seg_decoder u_seg_decoder ( .data_in(bcd[state]), // 根据状态选择对应的BCD位 .seg_out(seg_data) ); // ... (其余部分与顶层模块相同) endmodule代码解析我们新增了一个clk_1hz模块产生1Hz时钟来驱动计数器counter_val每秒加1。二进制转BCD是难点。上面的代码展示了一种“移位加3”算法的硬件描述实现。它在一个时钟周期内通过一个循环完成转换属于组合逻辑时序逻辑的混合。在实际工程中为了时序更优可能会采用多周期流水线的方式来实现这个算法或者直接调用FPGA供应商提供的IP核。转换得到的4位BCD码存储在bcd[3:0]数组中然后像之前一样被扫描状态机state索引送入段码译码器。5. 关键参数计算、调试与深度优化5.1 扫描频率与亮度平衡的计算扫描频率的选择不是随意的它需要在无闪烁和足够亮度之间取得平衡。无闪烁条件根据视觉暂留原理刷新率应高于临界闪烁频率CFF通常认为50-60Hz以上人眼就感觉不到闪烁。因此整体扫描频率所有位扫描一遍的频率应 60Hz。整体扫描频率 主扫描时钟频率 / 数码管位数。例如主扫描时钟clk_scan1kHz4位数码管整体扫描频率 1000Hz / 4 250Hz远高于60Hz无闪烁。亮度考量亮度与每个数码管在一个扫描周期内点亮的时间占空比成正比。占空比 (点亮时间 / 扫描周期) 1 / 位数。对于4位数码管占空比为25%。对于8位数码管占空比仅为12.5%。这就是为什么驱动更多位数码管时如果不增加驱动电流亮度会明显下降。亮度补偿可以通过提高段选信号的驱动电流检查FPGA IO的驱动能力设置或外接三极管/驱动芯片来解决。切勿通过大幅降低扫描频率来增加点亮时间这会导致闪烁。计算公式总结确定期望的整体刷新率F_overall(如100Hz)。确定数码管位数N(如4位)。计算所需扫描时钟频率F_scan F_overall * N(100Hz * 4 400Hz)。根据FPGA主频F_main(如50MHz)计算分频系数DIV F_main / (2 * F_scan)(用于生成50%占空比方波)。例如DIV 50_000_000 / (2*400) ≈ 62500。5.2 硬件连接检查与常见故障排查即使代码仿真完美硬件上也可能点不亮。请按以下清单排查现象可能原因排查方法所有数码管全亮但显示乱码8字形位选信号全部有效共阳极为低电平导致所有管同时被选中。检查位选信号sel的硬件连接是否正确测量其在扫描时是否真的在循环变化。可能是引脚分配错误或短路。所有数码管全暗位选信号全部无效共阳极为高电平或段选信号全部为熄灭值。1. 检查复位逻辑确保系统已脱离复位状态。2. 检查扫描时钟clk_scan是否正常产生可用一个LED闪烁来指示。3. 检查段码表逻辑是否正确特别是共阳/共阴极搞反。只有一位数码管常亮其他不亮位选信号卡在某一个状态不变。检查动态扫描状态机是否正常运转。添加调试信号将state用LED指示出来观察其是否循环变化。显示有重影鬼影位选信号切换时段选信号变化不同步导致在切换瞬间两个数码管被同时短暂点亮。1.消隐处理在状态机切换的瞬间插入一个短暂的“全灭”状态或先将位选全部置为无效更新段码后再开启新位选。2. 检查代码中位选sel和段选seg的赋值是否在同一个时钟沿下完成避免组合逻辑竞争。显示数字闪烁、抖动扫描频率处于临界闪烁频率附近或时序约束不满足产生亚稳态。1. 提高扫描时钟频率如从100Hz提升到200Hz。2. 在跨时钟域处如将计数器值传给扫描模块添加同步器两级寄存器。5.3 高级优化技巧与工程化考量消隐电路的实现这是消除鬼影的标准做法。在状态机中在每个状态切换的间隙插入一个短暂的“消隐状态”。// 以4位数码管为例增加一个消隐状态 localparam S_BLANK 2d4; always (posedge clk_1k or negedge rst_n) begin if(!rst_n) begin state S_BLANK; sel_reg 4b1111; end else begin case(state) S_BLANK: begin sel_reg 4b1111; state 2d0; end // 全灭 2d0: begin sel_reg 4b1110; state S_BLANK; end // 显示第0位后进入消隐 2d1: begin sel_reg 4b1101; state S_BLANK; end // ... 其他状态类似 endcase end end // 注意在消隐状态段码seg也应输出全灭值。这样在切换位选前先关闭所有显示更新好段码后再打开新的位选从根本上切断了串扰路径。亮度不均匀的软件补偿由于不同数字点亮的LED段数不同如“1”点亮2段“8”点亮7段在相同驱动电流下功耗大的数字可能会使电源电压略有下降导致整体亮度微变。更常见的问题是在多位数码管中两端的管子因布线原因亮度稍低。可以在段码表中对不同的数字或不同的位进行微调例如略微增加点亮时间但需保持总周期不变这需要精细的测量和调试。资源优化与可复用性将动态扫描控制器设计成参数化模块。module dynamic_scan #( parameter DIGIT_NUM 4, // 数码管位数 parameter CLK_FREQ 50_000_000, // 输入时钟频率 parameter SCAN_FREQ 1000 // 扫描时钟频率 )( input wire clk, input wire rst_n, input wire [DIGIT_NUM*4-1:0] bcd_data, // 打包输入的BCD数据 output reg [6:0] seg, output reg [DIGIT_NUM-1:0] sel ); // 内部根据参数计算分频系数等 localparam SCAN_CLK_DIV CLK_FREQ / (SCAN_FREQ * DIGIT_NUM * 2); // ... 其余通用化代码 endmodule这样同一个模块只需改变参数就能轻松适配不同位数、不同时钟的开发板。6. 从实验到项目综合应用设想掌握了动态显示的核心技能后它可以成为你众多FPGA项目的“展示窗口”。这里提供几个进阶方向电子时钟/计时器结合内部的PLL或计数器实现精确的时、分、秒计时与显示。可以增加按键调整时间、闹钟等功能。环境监测显示器连接数字温湿度传感器如DHT11需单总线协议、或通过ADC读取模拟光敏电阻值将环境参数实时显示在数码管上。频率计/计数器利用FPGA的高速逻辑对外部脉冲信号进行计数并将频率值通过动态扫描显示出来。这是练习测量时序的绝佳项目。简单计算器结合矩阵键盘输入实现一个能进行加减乘除运算的计算器并将输入和结果动态显示出来。这需要整合按键消抖、状态机、算术逻辑单元等多个模块。通信数据监视器在实现UART、SPI或I2C通信时可以将接收到的数据以十六进制形式实时显示在数码管上是调试硬件协议的“利器”。在实现这些项目时你会遇到一个核心问题多时钟域数据处理。例如传感器读取可能是一个慢速过程几十毫秒一次而动态扫描需要稳定的高速时钟几百赫兹。你不能在扫描时钟进程中直接等待传感器数据。正确的做法是使用一个共享的寄存器或FIFO先入先出存储器作为数据缓冲区。传感器接口模块在数据准备好后将其写入缓冲区动态显示模块在每次扫描循环中从缓冲区读取当前值。同时要注意对跨时钟域的信号进行同步处理避免亚稳态。动态显示虽然是一个基础实验但它蕴含了FPGA设计的精髓并行思维、时序控制、资源复用和模块化设计。把这个实验做透、做稳后面遇到更复杂的视频扫描VGA/HDMI、高速数据流处理时你会发现其底层逻辑是相通的——都是在精确的时间节拍下将数据有序地送到指定的位置。