Verilog实现二进制转BCD码:从加3移位算法到FPGA工程实践

📅 2026/8/24 10:55:44
Verilog实现二进制转BCD码:从加3移位算法到FPGA工程实践
1. 项目概述从二进制到BCD码的硬件思维转换在数字电路和嵌入式系统里我们经常要和数字打交道。计算机和FPGA这类硬件它们最“母语”的表示法是二进制一串0和1。但我们人类更习惯看十进制数字比如显示屏上的“123”。这就产生了一个核心需求如何在硬件内部高效地将一个二进制数比如8‘b01111011即十进制的123转换成对应的BCD码8421码即8位二进制数表示一位十进制数123会变成三个BCD码0001_0010_0011这个“二进制转BCD码”的模块是连接底层硬件运算和上层人机交互如数码管显示、串口发送数据的关键桥梁。很多新手FPGA工程师的第一个综合项目可能就是做一个数码管动态扫描显示而其中核心的一环就是这个转换模块。它看似基础却蕴含着硬件描述语言Verilog设计思维的精髓面积、速度和资源的权衡。网上能找到很多这段代码有的用除法有的用查找表但如果你只是复制粘贴而不理解其背后的“为什么”一旦遇到位宽变化、时序紧张或者资源受限的情况就会束手无策。今天我们就深入底层手把手带你用Verilog实现一个健壮、可配置的二进制转BCD码模块并拆解其中的每一个设计抉择。我们将重点探讨最经典、最节省资源的“加3移位算法”并在此基础上讨论如何优化、如何测试、以及在实际工程中你会踩到的那些坑。2. 核心原理为什么是“加3移位算法”在开始写代码之前我们必须先搞清楚我们要解决的问题的本质。BCD码用4位二进制表示1位十进制数但这4位的取值范围是0-90000到1001。而4位二进制本身可以表示0-150000到1111。当4位二进制的值大于9即1010到1111时它就不再是一个有效的BCD码。直接移位是无法得到正确BCD码的。举个例子想把二进制数8‘b1111_0011(243) 转换成BCD码。如果我们天真地把它当成二进制数直接向左移位结果会完全错误。“加3移位算法”巧妙地解决了这个问题。它的核心思想是在每一次左移位之前检查每一个BCD码单元每4位的值是否大于等于5即二进制0101。如果是则先给这个单元加上3然后再进行整体左移。为什么是“大于等于5”和“加3”这需要一点数学推导。考虑一个4位的BCD码单元其值为X。左移一位相当于乘以2。如果X 5那么2X 10。左移后这个4位单元就会产生向高4位即上一个十进制位的进位并且自身会变成一个无效的BCD码值在10-15之间。如果我们预先给X加上3变成X3再左移一位得到2(X3) 2X 6。而直接左移后再进行“十进制调整”即如果值10则减去10并向高位进1的结果是2X - 10 16 2X 6因为向高位进1相当于加了16。看两者结果完全一致这个“加3”操作本质上是将后续左移可能需要的“减10调整”提前到了移位之前用加法替代了减法而加法在硬件中实现起来要简单直接得多。整个算法可以概括为以下步骤假设我们要转换一个W位的二进制数为BCD码初始化一个足够大的寄存器来存放转换中的BCD码。其宽度至少为ceil(W / log2(10)) * 4位简单估算可以先用W (W/3)位或者直接取W*4位再优化。我们称之为bcd_reg。将待转换的二进制数放在bcd_reg的低位高位补零。进行W次循环每次循环包含两个操作 a.检查与加3对bcd_reg中每一个4位的BCD码单元从最低位开始每4位一组判断其值是否大于等于5。如果是则将该单元的值加3。 b.左移将整个bcd_reg向左移位1位最低位由下一次待处理的二进制位从原始二进制数高位移入填充。循环结束后bcd_reg中的内容就是转换好的BCD码。这个过程完全可以用硬件并行实现无需真正的“循环”这正是我们用Verilog描述的优势所在。3. 模块设计与接口定义理解了算法我们就可以开始设计Verilog模块了。一个好的模块设计接口清晰、参数可配置是工程化的第一步。module bin2bcd #( parameter BIN_WIDTH 8, // 输入二进制数的位宽 parameter DEC_DIGITS 3 // 输出BCD码的十进制位数 )( input wire clk, // 时钟信号 input wire rst_n, // 低电平异步复位信号 input wire start, // 转换启动信号高电平脉冲有效 input wire [BIN_WIDTH-1:0] bin_data, // 输入的二进制数据 output reg [DEC_DIGITS*4-1:0] bcd_data, // 输出的BCD码每4位代表一个十进制位 output reg done // 转换完成标志高电平有效 );设计抉择与解析参数化设计 (parameter): 这是必须的。你的项目可能今天需要转换8位数明天就需要转换16位数。通过BIN_WIDTH和DEC_DIGITS参数化模块的复用性极大提高。DEC_DIGITS可以通过计算得到例如(BIN_WIDTH*43)/4但作为参数输入更直观也方便根据实际显示需求调整比如固定显示4位不足补零。同步设计 (clk,rst_n): 几乎所有的FPGA设计都推荐采用同步时序设计。使用全局时钟和复位有利于综合工具进行静态时序分析保证设计的稳定性和可移植性。异步复位、同步释放是一种更稳健的复位方式这里为了简化我们使用低电平有效的异步复位rst_n。握手信号 (start,done): 这是模块与外部系统通信的“语言”。start脉冲告诉模块“数据准备好了开始转换吧”。done信号则告诉外部“转换完成了结果有效可以来取了”。这种握手机制避免了模块内部状态与外部控制逻辑的紧密耦合是构建复杂系统的基石。输出寄存器 (output reg): 将输出定义为寄存器类型意味着输出信号是由触发器直接驱动的。这虽然会多用一些寄存器资源但好处是巨大的输出信号是“干净”的没有毛刺时序稳定更容易满足外部电路的建立/保持时间要求。这是工程实践中的一个重要技巧。注意DEC_DIGITS的计算。对于一个BIN_WIDTH位的无符号二进制数其能表示的最大十进制数的位数N满足10^N 2^{BIN_WIDTH}即N BIN_WIDTH * log10(2)。近似为N ceil(BIN_WIDTH * 0.30103)。例如8位二进制最大255需要3位十进制数。16位二进制最大65535需要5位十进制数。在参数中明确指定可以让意图更清晰。4. 核心算法移位加3法的Verilog实现我们将采用状态机来实现这个算法。状态机是数字逻辑设计的核心思想之一它将一个时序过程清晰地划分为几个状态每个状态完成特定的操作。这里我们设计一个简单的状态机IDLE空闲: 等待start信号。SHIFT移位加3: 进行核心的移位和加3操作循环BIN_WIDTH次。DONE完成: 产生done信号标志转换完成。// 状态定义 localparam S_IDLE 2b00; localparam S_SHIFT 2b01; localparam S_DONE 2b10; reg [1:0] state, next_state; reg [BIN_WIDTH-1:0] bin_shift; // 用于移位操作的二进制数寄存器 reg [DEC_DIGITS*4-1:0] bcd_shift; // 用于移位操作的BCD码寄存器 reg [$clog2(BIN_WIDTH)-1:0] shift_cnt; // 移位计数器 // 状态转移逻辑时序部分 always (posedge clk or negedge rst_n) begin if (!rst_n) begin state S_IDLE; end else begin state next_state; end end // 状态转移与输出逻辑组合部分 always (*) begin // 默认值避免产生锁存器 next_state state; done 1b0; bcd_data bcd_shift; // 输出连接到内部移位寄存器 case (state) S_IDLE: begin if (start) begin next_state S_SHIFT; end end S_SHIFT: begin if (shift_cnt BIN_WIDTH - 1) begin next_state S_DONE; end end S_DONE: begin done 1b1; next_state S_IDLE; end default: next_state S_IDLE; endcase end // 数据通路逻辑时序部分在SHIFT状态执行核心算法 always (posedge clk or negedge rst_n) begin integer i; // 用于循环遍历BCD码单元 if (!rst_n) begin bin_shift 0; bcd_shift 0; shift_cnt 0; end else begin case (state) S_IDLE: begin if (start) begin // 初始化二进制数放入移位寄存器BCD寄存器清零 bin_shift bin_data; bcd_shift 0; shift_cnt 0; end end S_SHIFT: begin // --- 关键操作先加3再移位 --- // 1. 对BCD移位寄存器的每一个十进制位4位进行加3判断 for (i 0; i DEC_DIGITS; i i 1) begin // 取出当前4位BCD单元 if (bcd_shift[i*4 : 4] 4d5) begin // “:” 是位片选择语法 bcd_shift[i*4 : 4] bcd_shift[i*4 : 4] 4d3; end end // 注意上面的for循环是组合逻辑描述综合后会是并行的比较器和加法器。 // 在实际写入时我们通常将加3后的结果暂存但为了逻辑清晰这里先描述判断逻辑。 // 更常见的写法是将“检查加3”和“移位”在同一个always块中顺序描述综合工具会理解。 // 2. 整体左移一位并将二进制移位寄存器的最高位移入BCD寄存器的最低位 {bcd_shift, bin_shift} {bcd_shift, bin_shift} 1; // 3. 移位计数器加1 shift_cnt shift_cnt 1; end // S_DONE状态数据保持由组合逻辑输出 default: begin // 保持数据不变 end endcase end end代码细节与避坑指南:位片选择操作符:bcd_shift[i*4 : 4]表示从索引i*4开始选择宽度为4位的数据段。这是Verilog-2001标准引入的比旧的[i*43 : i*4]语法在参数化设计中更安全、更清晰。循环的综合:for循环在Verilog中是可以综合的但必须注意循环次数必须在编译时确定这就是我们使用参数DEC_DIGITS的原因。综合工具会将这个循环“展开”生成并行的硬件电路。这里它生成了DEC_DIGITS个并行的4位比较器和加法器。操作顺序与时序: 在S_SHIFT状态我们描述的是“先对每个BCD单元进行条件加3然后整体左移”。在硬件上这两个操作是在同一个时钟周期内完成的。更准确地说在时钟上升沿到来时计算“加3”的结果并将其与移位操作合并然后更新到bcd_shift和bin_shift寄存器中。上面的描述性代码为了清晰分成了两步实际综合后的电路是组合逻辑计算“加3”然后与移位操作结合最后在时钟沿寄存。避免锁存器: 在组合逻辑always (*)块中必须给所有在条件分支中赋值的变量一个默认值如next_state state;否则在未覆盖的分支下综合工具会生成我们不希望的锁存器这通常是bug的来源。5. 优化与变体追求性能与面积的平衡上面的实现是一个通用、清晰的版本。但在实际项目中我们可能需要根据需求进行优化。5.1 纯组合逻辑实现单周期完成如果你的系统对转换速度要求极高且待转换数据位宽不大比如小于等于12位可以考虑纯组合逻辑实现。它在一个时钟周期内就能给出结果但会消耗更多的逻辑资源并且可能因为组合路径过长导致时序紧张。module bin2bcd_comb #( parameter BIN_WIDTH 8 )( input wire [BIN_WIDTH-1:0] bin, output wire [((BIN_WIDTH*43)/4)*4-1:0] bcd // 自动计算输出位宽 ); // 使用generate块和循环实例化多级“加3移位”硬件 // 此处代码较长其本质是將算法中的W次循环全部展开用多层组合逻辑实现。 // 例如一个8位转换器需要8级这样的逻辑。 // 这种代码通常由脚本生成或使用SystemVerilog的循环生成更简洁。 // 资源消耗大致与 BIN_WIDTH * DEC_DIGITS 成正比。 endmodule使用场景用于高速数据通路且转换频率不是特别高避免动态功耗过大或者位宽很小。5.2 流水线化实现提高吞吐率如果系统需要连续不断地转换大量数据吞吐率是关键。我们可以将上面的时序状态机改造成流水线。思路将BIN_WIDTH次移位操作拆分成P个阶段P为流水线级数。每个阶段处理BIN_WIDTH/P次移位操作。实现设计一个深度为P的移位寄存器链。数据从第一级流入每一级用一个时钟周期完成其负责的“加3移位”操作然后传递给下一级。这样虽然单个数据的转换延迟还是P个周期但你可以每个时钟周期都灌入一个新的数据整体吞吐率提高到每周期一个结果。代价寄存器资源消耗约为原来的P倍。适用场景视频处理、高速通信中需要实时将大量像素值或统计值转换成十进制显示或发送。5.3 资源优化共享加法器在我们基础版本的状态机中for循环为每个BCD码单元生成了一个独立的4位加法器用于加3。如果DEC_DIGITS很大这些加法器会占用不少资源。一个优化思路是时分复用单个加法器。思路在S_SHIFT状态我们不是在一个周期内并行处理所有BCD单元而是再用一个子状态机或计数器用多个周期依次处理每个BCD单元的“加3”操作然后再移位。这样只需要一个4位加法器。权衡这显著减少了面积逻辑资源但增加了转换所需的时钟周期总数从BIN_WIDTH个周期增加到BIN_WIDTH * DEC_DIGITS个周期左右。这是一种典型的“面积换速度”或“速度换面积”的折衷。实操心得在绝大多数中小规模FPGA项目如数码管显示驱动中基础状态机版本已经是最优选择。它的资源消耗很小几十个LUT和寄存器速度也足够转换一个8位数只需要8个时钟周期在50MHz时钟下仅0.16us。不要过早优化先实现正确、清晰的设计。当系统集成后通过综合报告发现这里真的是资源或时序瓶颈时再考虑上述优化策略。6. 仿真测试用ModelSim确保万无一失写好了代码不经过仿真测试就直接上板无异于闭着眼睛开车。我们用ModelSim或任何你喜欢的仿真工具来验证我们的设计。timescale 1ns/1ps module tb_bin2bcd(); reg clk, rst_n, start; reg [7:0] bin_data; wire [11:0] bcd_data; // 3 digits * 4 bits 12 bits wire done; // 实例化被测模块 bin2bcd #(.BIN_WIDTH(8), .DEC_DIGITS(3)) uut ( .clk(clk), .rst_n(rst_n), .start(start), .bin_data(bin_data), .bcd_data(bcd_data), .done(done) ); // 生成时钟周期20ns (50MHz) initial begin clk 0; forever #10 clk ~clk; end // 测试流程 initial begin // 1. 初始化 rst_n 0; start 0; bin_data 0; #100; rst_n 1; #20; // 2. 测试用例1: 二进制 0 - BCD 0 bin_data 8d0; start 1; #20; start 0; wait(done 1); $display(Test 1: Bin%d, BCD%h (Expected: 0x000), bin_data, bcd_data); if (bcd_data ! 12h000) $error(Test 1 Failed!); #20; // 3. 测试用例2: 二进制 123 - BCD 123 (0x123) bin_data 8d123; start 1; #20; start 0; wait(done 1); $display(Test 2: Bin%d, BCD%h (Expected: 0x123), bin_data, bcd_data); if (bcd_data ! 12h123) $error(Test 2 Failed!); #20; // 4. 测试用例3: 二进制 255 (最大值) - BCD 255 (0x255) bin_data 8d255; start 1; #20; start 0; wait(done 1); $display(Test 3: Bin%d, BCD%h (Expected: 0x255), bin_data, bcd_data); if (bcd_data ! 12h255) $error(Test 3 Failed!); #20; // 5. 测试用例4: 随机数测试 repeat(10) begin bin_data $random % 256; start 1; #20; start 0; wait(done 1); // 将BCD结果转换回十进制进行比对 // bcd_data[11:8] 是百位[7:4]是十位[3:0]是个位 reg [11:0] calc_bcd; calc_bcd[11:8] bin_data / 100; calc_bcd[7:4] (bin_data % 100) / 10; calc_bcd[3:0] bin_data % 10; $display(Test Random: Bin%d, BCD%h, Expected BCD%h, bin_data, bcd_data, calc_bcd); if (bcd_data ! calc_bcd) $error(Random Test Failed for Bin%d!, bin_data); #20; end $display(All tests passed!); $finish; end // 可选将波形信号导出到文件便于查看 initial begin $dumpfile(tb_bin2bcd.vcd); $dumpvars(0, tb_bin2bcd); end endmodule测试要点解析边界测试必须测试最小值0和最大值255。这是发现溢出和边界条件错误的最有效方法。典型值测试测试一些有代表性的值如123。随机测试使用$random生成大量随机输入并与软件计算或行为级模型的预期结果进行比对。这是发现角落案例Corner Case错误的重要手段。自动比对在测试平台中直接计算预期结果并与模块输出比较用$error报告错误。这实现了自动化测试效率远高于人工看波形。波形查看使用$dumpfile和$dumpvars生成VCD波形文件在ModelSim等工具中打开可以直观地观察状态机跳转、数据移位过程对调试有极大帮助。7. 上板验证与调试技巧仿真通过后就可以进行综合、实现、生成比特流并下载到FPGA开发板进行实测了。这里连接一个常见的应用驱动七段数码管显示。7.1 顶层模块集成假设我们有一个50MHz的时钟一个8位拨码开关sw[7:0]作为二进制输入一个按键btn作为启动信号以及一个6位7段数码管seg和位选信号dig。module top_display( input wire sys_clk, input wire rst_n, input wire [7:0] sw, input wire btn_start, output wire [7:0] seg, output wire [5:0] dig ); wire clk_1khz; // 用于数码管扫描的1KHz时钟 wire [11:0] bcd_data; wire conv_done; // 时钟分频模块产生1KHz扫描时钟 clk_div #(.DIV_RATIO(50000)) u_clk_div (.clk_in(sys_clk), .clk_out(clk_1khz)); // 二进制转BCD模块 bin2bcd #(.BIN_WIDTH(8), .DEC_DIGITS(3)) u_bin2bcd ( .clk(sys_clk), .rst_n(rst_n), .start(btn_start), .bin_data(sw), .bcd_data(bcd_data), .done(conv_done) // 此例中可能未使用可连接LED观察 ); // 数码管动态扫描显示模块 seg_display u_seg_display ( .clk(clk_1khz), .rst_n(rst_n), .bcd_data({8h00, bcd_data}), // 将3位BCD码扩展为6位高位补零 .seg(seg), .dig(dig) ); endmodule7.2 常见问题与排查数码管显示乱码或全亮/全灭检查段选和位选极性确认你的数码管是共阴极还是共阳极。你的seg信号是段选控制a,b,c,d,e,f,g,dpdig是位选控制哪个数码管亮。这两组信号的极性可能相反。例如共阴极数码管位选高电平有效对应位为1时点亮段选也是高电平有效对应段为1时点亮。而共阳极则相反。查阅开发板原理图是第一步。检查扫描频率动态扫描频率一般在1KHz左右每秒扫描每个数码管1000/6≈166次高于人眼视觉暂留。频率太低会闪烁频率太高可能因数码管响应时间导致亮度不均。用示波器测量dig信号波形。检查BCD码输出用SignalTap II或Vivado的ILA集成逻辑分析仪抓取bcd_data信号确认转换结果是否正确。这是最直接的硬件调试手段。转换结果不正确仿真与综合不一致确保仿真时没有使用不可综合的语法如initial块给寄存器赋值在综合时会被忽略。检查复位逻辑是否可靠。时序违例如果系统时钟频率很高如200MHz而你的组合逻辑路径如状态机输出逻辑、BCD单元加3判断逻辑过长可能导致建立时间违例。查看综合实现后的时序报告看是否有setup time违例。解决方法对关键路径进行流水线打拍或者降低时钟频率。位宽溢出确保bcd_shift寄存器的位宽足够容纳转换过程中的中间结果。对于W位二进制数最坏情况下需要的BCD码位数是ceil(W * log10(2))但移位寄存器在转换过程中需要更多位来存放中间状态。一个安全的做法是初始化为{DEC_DIGITS*4{1‘b0}}并在移位时确保高位不会丢失。启动信号 (start) 识别问题按键消抖如果start信号来自机械按键必须进行消抖处理否则一次按压可能被识别为多次启动。添加一个按键消抖模块检测到稳定的低电平或高电平持续15-20ms后再产生一个时钟周期宽度的脉冲。同步处理如果start信号来自异步时钟域必须进行同步处理打两拍避免亚稳态传播到核心状态机。调试心得FPGA调试ILA/SignalTap是你的最佳伙伴。不要只依赖仿真。将关键信号state,bcd_shift,bin_shift,shift_cnt,done添加到逻辑分析仪中在实际硬件上触发和观察波形能与仿真结果相互印证快速定位那些只有在实际硬件上才会出现的时序、复位或接口问题。养成“编码-仿真-上板-抓波形”的调试习惯效率倍增。8. 进阶思考从模块到系统当你成功实现并验证了这个基础模块后可以思考如何将它应用到更复杂的系统中这能极大提升你的设计能力。封装成AXI-Stream接口如果你想在基于Xilinx Zynq或Intel SoC FPGA的系统中使用这个模块可以为其封装一个AXI-Stream从接口。这样它就可以通过DMA或处理器直接写入数据流转换结果再通过AXI-Stream主接口送出轻松集成到Vivado/IP Integrator或Qsys系统中。与微处理器协同在软核CPU如Nios II, MicroBlaze系统中你可以通过Avalon-MM或AXI-Lite总线将这个模块配置成一个外设。CPU通过写寄存器传入二进制数触发转换然后通过读寄存器获取BCD结果。这需要添加一些总线接口逻辑和状态寄存器。性能分析与优化使用综合工具如Vivado, Quartus查看该模块的资源占用报告LUT, FF, DSP和时序报告。尝试不同的实现方式如纯组合、流水线对比它们在不同位宽下的面积-速度折衷曲线加深对硬件成本的理解。二进制转BCD码这个看似简单的功能贯穿了从算法原理、Verilog编码、仿真验证、时序约束到硬件调试的完整FPGA开发流程。吃透它你收获的不仅仅是一个可用的模块更是一套应对未来更复杂数字逻辑设计问题的思维方法和工程实践能力。