ARTICLE DETAIL

资讯详情

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

FPGA数码管动态扫描与二进制转BCD码的硬件实现详解

FPGA数码管动态扫描与二进制转BCD码的硬件实现详解 1. 项目背景与核心需求从秒表到数码管显示最近在整理FPGA学习笔记翻到了之前做的一个秒表项目。这个项目本身不复杂但其中的数码管显示部分却是一个非常好的切入点能把FPGA开发中关于时序控制、数据转换和模块化设计这几个核心概念串起来。很多新手在学FPGA时一上来就搞复杂的算法或者通信协议结果在数码管、LED这种看似简单的外设上栽了跟头时序乱了显示就花了问题还不好定位。所以我觉得有必要把这个“秒表电路的数码管显示部分”单独拿出来掰开揉碎了讲清楚。简单来说我们这个模块要干的事就是把一个来自秒表计时逻辑的二进制数值比如一个32位的计数值单位是毫秒转换成能在多位七段数码管上清晰、稳定显示出来的十进制数字。你可能会想这不就是个数制转换和查表显示吗用单片机几行代码就搞定了。但在FPGA里我们是用硬件并发的思维来做的所有的“转换”和“显示”都是通过硬件电路在同一个时钟周期内并行完成的并且要精心设计时序避免视觉上的闪烁和重影。这背后涉及到的分频、数据拆分、数码管动态扫描、译码驱动等一系列操作正是理解FPGA硬件描述语言Verilog设计思想的关键。基于网络上的相关讨论我发现大家关注的点主要集中在几个方面如何实现稳定的动态扫描消除鬼影如何将二进制数高效地转换成十进制并分离出每一位即所谓的“提取位值”如何编写可复用、参数化的显示模块以及如何用Modelsim等工具对显示时序进行仿真验证。接下来我就围绕这些核心问题结合我实际在Xilinx Artix-7比如Basys 3或Nexys 4开发板上的实现过程把整个设计思路、代码细节和调试心得分享出来。2. 显示模块的顶层架构与接口定义在动手写代码之前必须先想清楚整个模块的“骨架”。一个好的顶层设计能让后续的编码和调试事半功倍。对于这个秒表显示模块我将其划分为三个核心子模块二进制转十进制模块Bin2BCD、数码管位选与段选驱动模块Seg_Driver以及一个可选的显示数据缓冲模块Display_Buffer。它们之间的关系和数据处理流程我画了下面这个简单的框图来帮助理解------------------- | 顶层显示模块 | | (Top_Display) | ------------------ | | clk, rst_n, binary_data[31:0] v ------------------- | 二进制转十进制 | | (Bin2BCD) | ------------------ | | bcd_data[15:0] (4位BCD码) v ------------------------------ | | -----------v----------- -----------v----------- | 显示数据缓冲 | | 数码管驱动 | | (Display_Buffer) | | (Seg_Driver) | | (可选用于消抖/同步)| | | ----------------------- ---------------------- | | | stable_bcd_data[15:0] | seg_sel[3:0], seg_data[7:0] ------------------------------------- 连接到物理数码管2.1 模块接口信号定义首先我们定义顶层模块的输入输出端口。这相当于给这个“黑盒子”定好对外的引脚。module top_display ( input wire clk, // 系统主时钟例如100MHz input wire rst_n, // 低电平有效的全局复位信号 input wire [31:0] binary_data, // 来自秒表计时器的二进制数值单位可为ms output wire [3:0] seg_sel, // 数码管位选信号控制哪一位数码管亮 output wire [7:0] seg_data // 数码管段选信号控制显示什么字符a~gdp );这里有几个关键点binary_data[31:0]假设我们的秒表最大能计到2^32-1毫秒大约是49.7天对于秒表来说完全够用。实际项目中你可能只需要24位或28位可以根据需要调整。seg_sel[3:0]我们假设控制4位数码管。如果是6位或8位需要相应修改位宽。位选信号通常是低电平有效共阴数码管或高电平有效共阳数码管需要根据你的硬件原理图确定。seg_data[7:0]对应数码管的a, b, c, d, e, f, g, dp小数点段。同样有效电平取决于数码管是共阴还是共阳。2.2 子模块功能划分与设计考量二进制转十进制模块Bin2BCD这是算法的核心。FPGA里没有现成的除法器让你直接做“除以10”的操作我们需要用硬件逻辑实现。最经典的方法是“加3移位算法”Double Dabble Algorithm它通过迭代的移位和条件加3操作将二进制数转换为BCD码。我会在下一节详细解释其原理和Verilog实现。这个模块的输出是16位数据每4位代表一个十进制数字BCD码例如bcd_data[15:12]是千位[11:8]是百位以此类推。数码管驱动模块Seg_Driver这是控制的核心。它负责两件事动态扫描在任一时刻只让一位数码管点亮通过快速轮流点亮每一位通常扫描频率在1kHz以上利用人眼的视觉暂留效应形成“同时点亮”的错觉。这能极大节省FPGA的IO引脚。译码将BCD码0-9转换成对应的7段码a-g。这部分通常用一个查找表LUT实现非常简单。显示数据缓冲模块可选这是一个提升稳定性和用户体验的细节。如果来自秒表计时器的binary_data变化非常快比如每毫秒变一次直接将其送入显示驱动可能会导致数码管数字频繁快速跳变难以看清。我们可以用一个寄存器在固定的时间间隔比如每10ms或100ms锁存一次BCD数据这样显示的数字就会有一个短暂的“保持”时间看起来更舒服。同时它也能隔离前级数据变化对扫描时序的潜在干扰。这样的架构清晰地将“数据准备”Bin2BCD和“设备控制”Seg_Driver解耦符合FPGA模块化设计的思想便于单独仿真、测试和复用。3. 核心算法实现二进制转BCD码的“加3移位法”这是整个显示部分最关键的算法环节。我们如何不用除法器就把一个任意大小的二进制数转换成十进制BCD码呢“加3移位法”就是一个优雅的硬件解决方案。3.1 算法原理与手工演算其核心思想是模拟我们手工计算二进制转十进制的过程从最高位开始依次判断每一位的十进制权重。在硬件中我们通过移位和条件加3来实现。规则很简单在每次左移之前检查每一个BCD位4位一组是否大于等于5即二进制0101。如果是就在这个BCD位上加3然后再进行整体左移操作。我以一个8位二进制数8‘b1111_1111十进制255为例来手工走一遍流程帮助理解初始化一个足够宽的寄存器比如12位因为255的BCD码需要3个4位即12位高位置放二进制数低位BCD部分清零。我们假设寄存器宽度为12位初始值为{8‘b11111111 4‘b0000}但算法通常从二进制数在寄存器一端开始。更常见的描述是从一个BCD寄存器宽度为二进制位数所需BCD位数*4开始二进制数放在一端。我们简化一下思路我们有8位二进制数想要3个BCD数字12位。我们用一个20位的移位寄存器8位二进制 12位BCD初始为0但实际操作中二进制位会逐步移入BCD区域。 为了更直观我们换一种表述我们关注BCD部分。算法步骤是将二进制数左移入BCD寄存器的最低位。每次左移后检查每一个4位的BCD单元百位、十位、个位是否≥5。如果≥5则给该BCD单元加3。重复移位和检查的过程直到所有二进制位都从另一端移出。由于手工演算整个255过程较长我们看一个更小的例子将二进制8‘b1000_0110134转换为BCD。初始BCD寄存器12位为0二进制数放在一旁待移入。步骤简化经过8个时钟周期的移位和条件加3操作后最终BCD寄存器的高4位是0001百位1中间4位是0011十位3低4位是0100个位4即134。3.2 Verilog硬件实现与代码分析在Verilog中我们通常采用状态机或者简单的循环移位寄存器来实现。这里给出一个易于理解的、针对32位输入、输出4位BCD码16位的“迭代式”实现。这种实现在一个时钟周期内完成所有迭代属于组合逻辑时序逻辑的混合面积较大但速度快。对于低速显示应用更常用“时序式”实现即每个时钟周期完成一次移位/判断需要多个周期才能完成一次转换但面积小。这里我先给出一个“时序式”的经典设计它更节省资源也更常见于教学和实际项目module bin2bcd #( parameter BIN_WIDTH 32, parameter BCD_DIGITS 8 // 最大十进制位数32位二进制最大约42.9亿需要10位这里取8位已足够秒表显示 )( input wire clk, input wire rst_n, input wire start, // 转换启动信号高电平脉冲 input wire [BIN_WIDTH-1:0] bin, // 二进制输入 output reg [BCD_DIGITS*4-1:0] bcd, // BCD输出每4位一个数字 output reg done // 转换完成标志 ); localparam SHIFT_CNT_WIDTH $clog2(BIN_WIDTH); reg [BIN_WIDTHBCD_DIGITS*4-1:0] shift_reg; // 移位寄存器包含二进制数和BCD部分 reg [SHIFT_CNT_WIDTH-1:0] shift_cnt; reg state; // 简单状态机0IDLE 1CONVERTING always (posedge clk or negedge rst_n) begin if (!rst_n) begin state 1‘b0; shift_reg 0; shift_cnt 0; bcd 0; done 1‘b0; end else begin case (state) 1‘b0: begin // IDLE 状态 done 1‘b0; if (start) begin // 初始化移位寄存器BCD部分清零二进制数放在低位具体位置取决于设计 // 一种常见设计将二进制数放在移位寄存器的高BIN_WIDTH位低BCD_DIGITS*4位为0 shift_reg {bin, {BCD_DIGITS*4{1‘b0}}}; shift_cnt 0; state 1‘b1; end end 1‘b1: begin // CONVERTING 状态 if (shift_cnt BIN_WIDTH) begin // 1. 对移位寄存器中的每一个BCD数字每4位进行“加3”判断 integer i; for (i 0; i BCD_DIGITS; i i 1) begin if (shift_reg[i*4 : 4] 4‘d5) { // 如果该BCD数字 5 shift_reg[i*4 : 4] shift_reg[i*4 : 4] 4‘d3; // 则加3 } end // 2. 整体左移一位 shift_reg shift_reg 1; shift_cnt shift_cnt 1; end else begin // 转换完成输出结果 // 此时原始二进制位已全部移出移位寄存器的低BCD_DIGITS*4位就是BCD结果 bcd shift_reg[BCD_DIGITS*4-1:0]; done 1‘b1; state 1‘b0; end end endcase end end endmodule注意上面的代码是一个经典框架但其中shift_reg[i*4 : 4]的索引和加3判断的位置需要根据具体的移位寄存器结构进行调整。一种更常见的结构是将二进制数放在移位寄存器的高位然后向左移位这样二进制数会逐渐移入BCD区域。上面的代码是一种简化示意强调了“判断-加3-移位”的核心循环。在实际编写时你需要仔细设计移位寄存器的初始布局和移位方向。3.3 实际项目中的优化与选择在实际的秒表项目中因为显示更新频率要求不高比如每秒更新10次或100次我们完全可以使用这种“时序式”转换器。它的优点是占用逻辑资源少LUT和寄存器。我们可以让转换模块持续工作或者由显示驱动模块在需要更新数据时发出一个start脉冲。另一种更简单粗暴的方法是直接使用FPGA厂商提供的IP核如Xilinx的Binary to BCDIP或者用查找表LUT实现小范围数值的转换。但对于32位这样的大数查找表就不现实了。我的经验是在资源允许的情况下可以设计一个“流水线式”的转换器。即把32位数拆分成高16位和低16位分别转换后再合并。或者针对秒表应用我们通常只显示“分:秒:百分秒”这样的格式那么我们可以直接用计数器分别产生分钟、秒、毫秒的计数值这样就完全避免了大规模的二进制转BCD只需要对0-59或0-99的小范围数字进行转换直接用查找表最快。这启示我们硬件设计一定要结合具体应用场景选择最优方案。4. 数码管动态扫描驱动设计与消影策略拿到BCD码之后下一步就是让数码管亮起来了。动态扫描是驱动多位数码管的标准方法但做不好就会出现“鬼影”该灭的段微微发亮或亮度不均的问题。4.1 动态扫描的基本原理与Verilog实现动态扫描的本质是分时复用。假设我们有4位共阴数码管那么电路连接通常是所有数码管的相同段a, b, c...连在一起接到FPGA的一组IO上seg_data而每一位数码管的阴极公共端则由独立的IOseg_sel控制。驱动流程如下准备第一位要显示的数字的段码比如数字‘1’的段码是8‘b1111_1001假设共阴且段选高电平有效。将段码输出到seg_data。将位选信号seg_sel设置为使能第一位例如4‘b1110最低位为0表示选中第一位具体看原理图。保持这个状态一小段时间通常1-5ms。关闭所有位选seg_sel全部置为无效如4‘b1111清空seg_data可选但有助于消影。重复步骤1-5轮到第二位、第三位、第四位。快速循环扫描频率100Hz人眼就看到稳定的四位数显示了。Verilog代码实现一个简单的扫描驱动module seg_driver #( parameter CLK_FREQ 100_000_000, // 输入时钟频率100MHz parameter SCAN_FREQ 1000 // 扫描频率1kHz )( input wire clk, input wire rst_n, input wire [15:0] bcd_data, // 4位BCD码输入[15:12]千位[11:8]百位[7:4]十位[3:0]个位 output reg [3:0] seg_sel, // 位选低有效 output reg [7:0] seg_data // 段选高有效共阴 ); // 计算扫描周期所需的计数器位宽和终值 localparam SCAN_PERIOD CLK_FREQ / (SCAN_FREQ * 4); // 每位显示时间对应的时钟周期数 reg [31:0] scan_cnt; reg [1:0] sel_index; // 用于选择当前显示哪一位 // BCD到7段码的查找表 (共阴段选高电平有效显示0-9) reg [7:0] seg_lut [0:9]; initial begin seg_lut[0] 8‘h3f; // 0x3F - 0b0011_1111 (gfedcba) seg_lut[1] 8‘h06; // 0x06 - 0b0000_0110 seg_lut[2] 8‘h5b; // 0x5B - 0b0101_1011 seg_lut[3] 8‘h4f; // 0x4F - 0b0100_1111 seg_lut[4] 8‘h66; // 0x66 - 0b0110_0110 seg_lut[5] 8‘h6d; // 0x6D - 0b0110_1101 seg_lut[6] 8‘h7d; // 0x7D - 0b0111_1101 seg_lut[7] 8‘h07; // 0x07 - 0b0000_0111 seg_lut[8] 8‘h7f; // 0x7F - 0b0111_1111 seg_lut[9] 8‘h6f; // 0x6F - 0b0110_1111 end // 扫描计数器 always (posedge clk or negedge rst_n) begin if (!rst_n) begin scan_cnt 0; sel_index 0; seg_sel 4‘b1111; // 默认所有位关闭 seg_data 8‘h00; end else begin if (scan_cnt SCAN_PERIOD - 1) begin scan_cnt 0; // 切换显示位 sel_index sel_index 1; // 根据sel_index更新位选和段选 case (sel_index) 2‘d0: begin seg_sel 4‘b1110; // 选中个位 seg_data seg_lut[bcd_data[3:0]]; end 2‘d1: begin seg_sel 4‘b1101; // 选中十位 seg_data seg_lut[bcd_data[7:4]]; end 2‘d2: begin seg_sel 4‘b1011; // 选中百位 seg_data seg_lut[bcd_data[11:8]]; end 2‘d3: begin seg_sel 4‘b0111; // 选中千位 seg_data seg_lut[bcd_data[15:12]]; end default: begin seg_sel 4‘b1111; seg_data 8‘h00; end endcase end else begin scan_cnt scan_cnt 1; end end end endmodule4.2 “鬼影”的产生与硬件消影方法上面这个基本驱动代码有一个潜在问题鬼影。当sel_index变化seg_sel切换到下一位时seg_data需要从上一个数字的段码变成下一个数字的段码。如果seg_data的变化稍慢于seg_sel或者seg_sel的变化稍慢于seg_data在切换的瞬间旧的段码可能会短暂地出现在新的位选上导致不该亮的段微微发光。解决鬼影的核心思路是在切换位选的瞬间确保段选信号处于一个确定的无显示状态即所有段熄灭。硬件消影推荐在seg_sel变化前先关闭所有段seg_data 8‘h00;保持一个极短的时间比如几个时钟周期然后再更新seg_sel和新的seg_data。这个“全灭”的间隔就是消影时间。修改上面的扫描过程代码加入消影状态// 在always块中修改状态切换逻辑 localparam BLANK_CYCLES 5; // 消影时钟周期数 reg [1:0] state; // 0: 显示状态 1: 消影状态 reg [31:0] state_cnt; always (posedge clk or negedge rst_n) begin if (!rst_n) begin state 0; state_cnt 0; seg_sel 4‘b1111; seg_data 8‘h00; sel_index 0; end else begin case (state) 0: begin // 显示状态 if (state_cnt SCAN_PERIOD - 1) begin state_cnt 0; state 1; // 进入消影状态 seg_data 8‘h00; // 先关闭所有段 end else begin state_cnt state_cnt 1; end end 1: begin // 消影状态 if (state_cnt BLANK_CYCLES - 1) begin state_cnt 0; state 0; // 返回显示状态 // 切换到位 sel_index sel_index 1; // 更新位选和段选此时段选已是全灭状态位选切换不会产生鬼影 case (sel_index) // 注意这里用的是更新前的sel_index切换后指向下一位 ... // 同前更新seg_sel和seg_data endcase end else begin state_cnt state_cnt 1; end end endcase end end通过增加一个“消影状态”我们强制在两位切换之间插入了一个段选全灭的间隙彻底消除了因信号延迟不同步造成的鬼影。这是在实际PCB板上稳定驱动数码管的必备技巧。5. 系统集成、仿真测试与上板调试各个子模块完成后我们需要将它们集成起来并进行仿真和实际上板测试这是验证设计正确性的关键一步。5.1 顶层模块集成与时钟域考虑将bin2bcd和seg_driver模块在顶层例化连接。这里有一个重要的设计决策时钟域。bin2bcd模块的转换可能需要几十个时钟周期而seg_driver模块以固定的扫描频率运行。通常我们会使用同一个系统时钟clk来驱动所有模块但通过使能信号来控制数据流。一种稳健的集成方式是引入一个显示更新定时器。例如每10ms100Hz产生一个脉冲用这个脉冲启动一次BCD转换并将转换完成的结果锁存到一个显示缓冲寄存器中。seg_driver则始终从缓冲寄存器中读取BCD数据进行显示。这样即使BCD转换耗时较长也不会影响扫描的稳定性显示也不会出现中间状态的乱码。顶层连接示意module top_display ( input wire clk_100m, input wire rst_n, input wire [31:0] timer_value, // 来自秒表计时器 output wire [3:0] seg_sel, output wire [7:0] seg_data ); wire [15:0] bcd_out; wire conv_done; reg [15:0] display_buffer; reg update_tick; // 更新脉冲例如10ms一次 // 生成10ms更新脉冲 // ... 分频计数器代码 ... // 例化BCD转换器 bin2bcd u_bin2bcd ( .clk(clk_100m), .rst_n(rst_n), .start(update_tick), // 每10ms启动一次转换 .bin(timer_value), .bcd(bcd_out), .done(conv_done) ); // 转换完成后锁存数据到显示缓冲 always (posedge clk_100m or negedge rst_n) begin if (!rst_n) display_buffer 16‘h0000; else if (conv_done) display_buffer bcd_out; end // 例化数码管驱动 seg_driver u_seg_driver ( .clk(clk_100m), .rst_n(rst_n), .bcd_data(display_buffer), // 驱动模块从缓冲读取稳定数据 .seg_sel(seg_sel), .seg_data(seg_data) ); endmodule5.2 使用ModelSim进行功能仿真仿真对于验证逻辑正确性至关重要。我们需要编写测试平台Testbench模拟输入信号观察输出波形。测试要点BCD转换测试给bin2bcd模块输入几个边界值和典型值如0 1234 9999 65535等检查其输出的BCD码是否正确。需要等待done信号变高后再读取结果。扫描时序测试观察seg_driver模块的seg_sel和seg_data信号。seg_sel应该是一个循环右移或左移的序列如1110-1101-1011-0111-1110...每个状态的保持时间应与设计相符SCAN_PERIOD。seg_data应在seg_sel变化前后根据当前sel_index和bcd_data对应位正确变化并且能看到消影期间seg_data变为全0。系统集成测试在顶层Testbench中模拟timer_value的变化观察display_buffer是否在conv_done后更新以及数码管驱动输出的段码和位选是否随之稳定变化。5.3 上板调试与常见问题排查仿真通过后就可以生成比特流文件下载到FPGA开发板进行实测了。上板调试可能遇到以下问题及排查思路问题一数码管完全不亮或全亮检查IO约束首先确认你的.xdc或.ucf约束文件是否正确地将seg_sel和seg_data信号分配到了对应的FPGA引脚上。这是最常见的问题。检查电平标准确认约束文件中设置的电平标准如LVCMOS33与开发板上的实际电压匹配。检查复位信号确保复位信号rst_n在上电后处于无效状态通常是高电平。可以尝试在代码中暂时注释掉复位逻辑看是否正常。检查共阴/共阳确认代码中的段码和位选有效电平与硬件匹配。如果硬件是共阳数码管段选低电平有效那么查找表和位选逻辑都需要取反。问题二显示数字乱码或某些段不亮检查段码表对照数码管的数据手册确认你编写的段码查找表seg_lut是否正确。a, b, c...段与FPGA引脚的对应关系是否与原理图一致一个快速测试方法是写一个简单的程序让所有数码管固定显示数字“8”所有段都亮看是否正常。检查BCD数据通路使用板载LED或者虚拟IOVIO核将display_buffer的值引出来观察看BCD转换结果是否正确。如果不正确回溯检查bin2bcd模块。问题三显示有闪烁或重影调整扫描频率扫描频率太低如低于60Hz会让人眼感到闪烁太高可能会因为数码管余辉时间导致重叠。通常1kHz左右是个安全值。尝试调整SCAN_FREQ参数。确认消影逻辑用示波器或逻辑分析仪抓取seg_sel和seg_data的波形重点看两者切换的边沿区域。seg_data是否在seg_sel变化前就变成了全灭状态消影时间是否足够通常几百纳秒到几微秒即可如果看不到消影间隙说明消影逻辑未生效。驱动能力在极少数情况下如果FPGA引脚驱动能力不足可能导致信号边沿过慢加剧鬼影。可以尝试在约束文件中增加输出驱动电流强度如DRIVE属性。问题四显示的数字跳变不稳定检查数据缓冲如果timer_value变化非常快而seg_driver直接使用bcd_out可能会在转换过程中显示中间值。确保使用了display_buffer在转换完成done有效时才更新数据。跨时钟域问题如果存在如果秒表计时器模块和显示模块使用不同的时钟需要添加同步器如两级触发器来处理timer_value信号避免亚稳态导致显示乱跳。调试是一个系统工程从仿真到上板从信号逻辑到物理电气特性都需要仔细考量。最有效的调试工具是“信号抓取”和“对比验证”。养成在关键节点引出测试信号的习惯能极大提升排查效率。
返回列表