
1. 项目概述为什么要在FPGA里做二进制转BCD在FPGA开发中我们经常需要把内部处理的二进制数据显示到数码管、LCD或者通过串口发送给上位机看。比如你用FPGA做了一个计数器计数值是16位的二进制数1010_1100_1101_1110这个数换算成十进制是44254。但人眼和大多数显示设备不认识二进制它们需要的是一个个分开的十进制数字也就是‘4’ ‘4’ ‘2’ ‘5’ ‘4’。这个把二进制数转换成十进制数字表示的过程就是二进制到BCD码的转换。BCD码全称是Binary-Coded Decimal用4位二进制数来表示一个十进制数字0-9。所以十进制数44254的BCD码就是0100 0100 0010 0101 0100。在FPGA里实现这个转换看似简单实则有很多门道。直接调用除法器当然可以但FPGA的除法器资源消耗大、速度慢对于高速或资源敏感的应用并不友好。因此我们通常采用更“硬件友好”的算法比如经典的移位加3算法它只用到移位和加法非常适合用Verilog描述并在FPGA中高效实现。这个项目就是带你用Verilog在FPGA上实现一个高效、可配置的二进制转BCD码转换器。无论你是正在学习数字逻辑还是需要在项目中快速集成一个显示驱动模块这篇文章都能给你从原理到代码、从仿真到上板的完整参考。2. 核心算法解析移位加3Double Dabble的精髓为什么不用除法在硬件描述语言中除法操作符/通常会被综合器推断为复杂的除法器电路其面积和延迟都远大于加法和移位。移位加3算法巧妙地避免了除法其核心思想可以比喻成“在算盘上做乘法”。2.1 算法原理与手动演算算法的过程是从左到右从最高位到最低位处理输入的二进制位。我们维护一个BCD码寄存器组初始为0。每处理一个二进制位我们先将整个BCD寄存器左移一位空出的最低位移入当前的二进制位。然后从低位到高位检查每一个4位的BCD“小单元”即每一个十进制位如果这个单元的值大于或等于5我们就给这个单元加上3。之后再处理下一个二进制位。这个“大于等于5就加3”的操作是为了提前补偿接下来左移相当于乘2可能造成的溢出。因为BCD码每个单元只能表示0-9左移后数值范围变成0-18加3的调整可以保证在后续左移后这个单元的值不会超过15即二进制的1111从而保持每个单元都是有效的BCD码。我们来手动算一个简单的例子把8位二进制数1111_1111(255) 转换成BCD码。初始化BCD寄存器我们假设有3个4位单元对应百位、十位、个位为0000 0000 0000。输入二进制流11111111。处理第1位 (1)左移BCD移入10000 0000 0001 -0000 0000 0001检查各单元个位00011 5 十位00005 百位00005。无需调整。处理第2位 (1)左移BCD移入10000 0000 0011 -0000 0000 0011检查个位00113 5 其他位5。无需调整。处理第3位 (1)左移BCD移入10000 0000 0111 -0000 0000 0111检查个位01117 5个位加3 0111 0011 1010。调整后BCD0000 0000 1010处理第4位 (1)左移BCD移入10000 0001 0101 -0000 0001 0101检查个位01015 5个位加3 0101 0011 1000。十位00011 5。调整后BCD0000 0001 1000处理第5位 (1)左移BCD移入10000 0011 0001 -0000 0011 0001检查个位00011 5。十位00113 5。调整后BCD0000 0011 0001处理第6位 (1)左移BCD移入10000 0110 0011 -0000 0110 0011检查个位00113 5。十位01106 5十位加3 0110 0011 1001。调整后BCD0000 1001 0011处理第7位 (1)左移BCD移入10001 0010 0111 -0001 0010 0111检查个位01117 5个位加3 011100111010。十位00102 5。百位00011 5。调整后BCD0001 0010 1010处理第8位 (1)左移BCD移入10010 0101 0101 -0010 0101 0101检查个位01015 5个位加3 010100111000。十位01015 5十位加3 010100111000。百位00102 5。调整后BCD0010 1000 1000最终BCD码为0010 1000 1000即十进制的2、8、8也就是255。成功2.2 算法硬件实现的优势这个算法的美妙之处在于它的规整性。整个转换过程就是“移位-判断-加3”这个固定步骤的循环循环次数等于输入二进制数的位宽。这非常容易用Verilog中的一个for循环或者一个状态机来描述。在FPGA中加3操作可以通过一个小的组合逻辑查找表LUT实现移位是线性的连接整个电路可以做到面积小、频率高。对于需要实时转换的数据流我们还可以设计成流水线结构每个时钟周期完成一位的处理吞吐率非常高。注意移位加3算法是一种“串行”算法其转换所需的时钟周期数与输入位宽成正比。对于16位数据需要16个周期32位则需要32个周期。如果对转换速度要求极高可以考虑“查找表法”或“基于除法的并行计算”但这会以消耗大量的Block RAM或DSP资源为代价。移位加3法在面积和速度之间取得了很好的平衡是绝大多数情况下的首选。3. Verilog实现细节与模块设计接下来我们设计一个可参数化、易于集成的Verilog模块。我们将实现两种常见的架构纯组合逻辑单周期和时序逻辑多周期。前者延迟低但面积大后者面积小但需要多个时钟周期。3.1 接口定义与参数化首先我们定义模块的接口使其具有灵活性。module bin2bcd #( parameter WIDTH 16, // 输入二进制数的位宽 parameter DEC_DIGITS 5 // 输出BCD码的十进制位数根据WIDTH计算如16位对应5位十进制数 )( input wire clk, // 时钟用于时序逻辑版本 input wire rst_n, // 异步低电平复位 input wire start, // 转换启动信号高电平有效 input wire [WIDTH-1:0] bin, // 输入的二进制数 output reg [DEC_DIGITS*4-1:0] bcd, // 输出的BCD码每4位代表一个十进制数 output reg valid, // 转换完成标志高电平有效 output reg busy // 模块忙标志转换过程中为高 );对于DEC_DIGITS一个N位的二进制数所能表示的最大十进制数的位数是ceil(N * log10(2))。对于16位最大值65535是5位。我们可以提供一个计算函数但为简单起见这里作为参数由用户根据WIDTH手动设置。例如16位对应524位对应832位对应10。3.2 组合逻辑实现单周期组合逻辑实现在一个时钟周期内完成所有位的处理本质上是用多层逻辑展开整个移位加3的循环。这适合位宽不大比如小于等于12位且对延迟极其敏感的场景。// 组合逻辑实现 (示例为12位输入4位BCD输出) module bin2bcd_comb #( parameter WIDTH 12 )( input wire [WIDTH-1:0] bin, output wire [15:0] bcd // 4个十进制位共16位 ); // 声明一个足够大的中间变量用于迭代计算 reg [WIDTH4*4-1:0] shift_reg; // 宽度 输入位宽 BCD位数*4 integer i; always (*) begin // 1. 初始化将二进制数放在移位寄存器的最低位 shift_reg {{(4*4){1b0}}, bin}; // 高位补0低位放二进制数 // 2. 循环执行“移位加3”算法 for (i 0; i WIDTH; i i 1) begin // 先左移一位 shift_reg shift_reg 1; // 然后从低到高检查每个BCD单元4位一组 // 个位 if (shift_reg[15:12] 5) shift_reg[15:12] shift_reg[15:12] 3; // 十位 if (shift_reg[19:16] 5) shift_reg[19:16] shift_reg[19:16] 3; // 百位 if (shift_reg[23:20] 5) shift_reg[23:20] shift_reg[23:20] 3; // 千位 (对于12位输入千位是最高位) if (shift_reg[27:24] 5) shift_reg[27:24] shift_reg[27:24] 3; end end // 3. 输出结果就是移位寄存器中对应的BCD部分 assign bcd shift_reg[27:12]; // 取出千、百、十、个位 endmodule实现要点shift_reg的宽度必须足够要能容纳初始的二进制数和所有BCD位。公式是输入位宽 4*输出十进制位数。循环中的if判断和加法操作是并行执行的综合后会产生多级组合逻辑。位宽越大逻辑级数越多关键路径延迟越长可能限制系统最高工作频率。这个模块没有时钟输入变化输出立即经过组合逻辑延迟后变化。3.3 时序逻辑实现多周期、状态机控制时序逻辑实现是更通用、更节省资源的方式。我们使用一个状态机来控制转换流程每个时钟周期完成一位的处理。module bin2bcd_seq #( parameter WIDTH 16, parameter DEC_DIGITS 5 )( input wire clk, input wire rst_n, input wire start, input wire [WIDTH-1:0] bin, output reg [DEC_DIGITS*4-1:0] bcd, output reg valid, output reg busy ); // 状态定义 localparam S_IDLE 2b00; localparam S_SHIFT 2b01; localparam S_CORRECT 2b10; localparam S_DONE 2b11; reg [1:0] state, next_state; reg [WIDTH-1:0] bin_reg; // 输入二进制数寄存器 reg [DEC_DIGITS*4-1:0] bcd_reg; // BCD结果寄存器 reg [7:0] bit_cnt; // 位计数器记录已处理的位数 // 状态机第一段时序逻辑状态转移 always (posedge clk or negedge rst_n) begin if (!rst_n) begin state S_IDLE; bin_reg 0; bcd_reg 0; bit_cnt 0; bcd 0; valid 1b0; busy 1b0; end else begin state next_state; // 在SHIFT状态捕获输入数据 if (state S_IDLE start) begin bin_reg bin; bcd_reg 0; bit_cnt 0; busy 1b1; valid 1b0; end // 在SHIFT状态进行移位和加3操作 else if (state S_SHIFT) begin // 左移并将二进制数的当前最高位移入BCD最低位 bcd_reg {bcd_reg[DEC_DIGITS*4-2:0], bin_reg[WIDTH-1]}; bin_reg bin_reg 1; // 二进制数左移 bit_cnt bit_cnt 1; end // 在CORRECT状态执行加3调整 else if (state S_CORRECT) begin // 对BCD寄存器的每一个十进制位进行检查和加3 integer j; for (j 0; j DEC_DIGITS; j j 1) begin if (bcd_reg[j*4 : 4] 5) // 向量部分选择语法 bcd_reg[j*4 : 4] bcd_reg[j*4 : 4] 3; end end // 在DONE状态输出结果 else if (state S_DONE) begin bcd bcd_reg; valid 1b1; busy 1b0; end end end // 状态机第二段组合逻辑下一状态判断 always (*) begin next_state state; case (state) S_IDLE: if (start) next_state S_SHIFT; S_SHIFT: next_state S_CORRECT; // 移位后必然进入调整状态 S_CORRECT: begin if (bit_cnt WIDTH) // 所有位处理完毕 next_state S_DONE; else next_state S_SHIFT; // 继续处理下一位 end S_DONE: next_state S_IDLE; // 完成回到空闲 default: next_state S_IDLE; endcase end endmodule实现要点与心得状态拆分我将一个位的处理拆成了两个状态S_SHIFT和S_CORRECT。S_SHIFT只负责移位S_CORRECT只负责并行检查所有BCD位并决定是否加3。这样设计逻辑清晰时序也更好分析。也可以合并成一个状态在一个周期内先检查加3基于上一轮的结果再移位。但分开后加3逻辑的路径更短。向量部分选择bcd_reg[j*4 : 4]是Verilog-2001引入的语法表示从j*4位开始向上选择4位。这比bcd_reg[(j1)*4-1 : j*4]的写法更简洁且在某些综合工具中兼容性更好。忙标志busy这是一个非常重要的信号。当busy为高时表示模块正在转换此时不应给start新的脉冲也不应改变bin输入。外部电路可以通过检测busy和valid信号来安全地读取结果。资源与速度权衡这个设计只需要一组BCD寄存器和简单的控制逻辑面积非常小。转换时间等于(输入位宽 1)个时钟周期加上DONE状态。对于100MHz的时钟转换一个16位数不到0.2微秒对于大多数显示应用绰绰有余。4. 仿真测试与常见问题排查设计完成之后必须通过仿真来验证功能的正确性。我们使用一个简单的测试平台Testbench覆盖典型值、边界值和随机值。4.1 测试平台Testbench编写timescale 1ns / 1ps module tb_bin2bcd_seq(); reg clk; reg rst_n; reg start; reg [15:0] bin; wire [19:0] bcd; // 16位对应5个十进制位5*420 wire valid; wire busy; // 实例化被测试模块 bin2bcd_seq #(.WIDTH(16), .DEC_DIGITS(5)) uut ( .clk(clk), .rst_n(rst_n), .start(start), .bin(bin), .bcd(bcd), .valid(valid), .busy(busy) ); // 生成时钟周期10ns (100MHz) always #5 clk ~clk; // 初始化与测试序列 initial begin // 初始化信号 clk 0; rst_n 0; start 0; bin 0; #100; // 等待一段时间 rst_n 1; // 释放复位 #20; // 测试用例1 0 $display(Test 1: 0); bin 16d0; start 1; #10; start 0; wait(valid 1); $display( Binary: %d, BCD: %h (Expected: 0), bin, bcd); #20; // 测试用例2 最大值 65535 $display(Test 2: 65535); bin 16hFFFF; start 1; #10; start 0; wait(valid 1); $display( Binary: %d, BCD: %h (Expected: 6_5_5_3_5), bin, bcd); // 手动验证BCD码 20h65535 即 0110 0101 0101 0011 0101 #20; // 测试用例3 随机数 $display(Test 3: Random Numbers); repeat (10) begin bin $random % 65536; start 1; #10; start 0; wait(valid 1); // 将BCD码转换为整数用于比对仅用于测试平台非硬件行为 begin integer calc_val, bcd_val; integer i; calc_val bin; bcd_val 0; for (i 0; i 5; i i 1) begin bcd_val bcd_val * 10 ((bcd (i*4)) 4hF); end if (calc_val ! bcd_val) $display( ERROR: Binary %d - BCD %h, Calculated Decimal: %d, bin, bcd, bcd_val); else $display( OK: Binary %d - BCD %h, bin, bcd); end #20; end $display(All tests completed.); $finish; end // 监控信号变化 initial begin $monitor(Time%t, state%b, bit_cnt%d, bcd_reg%h, busy%b, valid%b, $time, uut.state, uut.bit_cnt, uut.bcd_reg, busy, valid); end endmodule4.2 常见问题与调试技巧实录在实际实现和调试中你可能会遇到以下问题问题1转换结果错误特别是高位为0时结果不对。可能原因BCD寄存器初始化或移位操作有误。在时序逻辑实现中要确保在S_SHIFT状态是将bin_reg的最高位移入bcd_reg的最低位。检查代码{bcd_reg[DEC_DIGITS*4-2:0], bin_reg[WIDTH-1]}是否正确。bin_reg[WIDTH-1]是当前最高位。排查方法在仿真中仔细观察bcd_reg在每个时钟周期的变化。特别是在处理第一个和最后一个二进制位时。使用$display或波形查看器对照手动计算的过程一步步核对。问题2valid信号只持续一个周期外部电路抓取不到。可能原因这是设计预期。valid是一个脉冲信号指示结果有效。外部电路应该在检测到valid上升沿时锁存bcd输出。解决方案如果下游电路需要更长的有效时间可以在本模块外添加一个结果保持寄存器。或者修改状态机让S_DONE状态维持多个周期直到收到新的start信号为止。问题3综合后时序报告显示建立时间Setup Time违例。可能原因组合逻辑版本中关键路径过长尤其是位宽较大时。时序逻辑版本中S_CORRECT状态下的加3逻辑可能比较慢特别是DEC_DIGITS较大时for循环展开的多个加3比较器形成了长链条。优化技巧流水线化将S_CORRECT中的加3操作拆分成多个周期完成。例如每个周期只处理2个或3个BCD位。这会增加延迟但能提高系统最大工作频率Fmax。寄存器打拍在S_CORRECT状态后插入一个额外的寄存器阶段将加3后的结果寄存一下再进入下一轮移位。这本质上是将一段组合逻辑拆成了两段缩短了关键路径。使用综合工具属性对于不太严重的违例可以尝试使用综合工具的指令如(* register_balancing yes *)或物理约束来优化。问题4资源使用超出预期。可能原因组合逻辑实现方式在综合时可能被展开成巨大的多路选择器特别是DEC_DIGITS较大时。for循环中的多个if语句可能没有像你想象的那样被共享逻辑。优化技巧优先使用时序逻辑实现它几乎总是比同等位宽的组合逻辑实现更省资源。检查综合报告看资源具体用在哪里。如果是比较器5和加法器3重复太多考虑是否真的需要全并行。对于速度要求不高的场合甚至可以串行处理每个BCD位的加3判断用一个小的状态机循环处理这能极大减少资源占用但转换周期数会变成WIDTH * DEC_DIGITS量级。问题5如何支持有符号二进制数补码的转换解决方案这是一个常见的扩展需求。处理流程是检查输入二进制数的最高位符号位。如果是负数最高位为1先对其取补码按位取反再加1得到其绝对值。对绝对值进行上述的无符号二进制转BCD操作。在输出的BCD码前额外添加一个符号位信息例如用一个单独的reg表示正负或者约定一种带符号的BCD格式。 关键点在于转换算法本身只处理无符号数。符号处理是在转换前或转换后附加的步骤。5. 性能优化与高级应用场景一个基础的转换器工作后我们可以根据不同的应用场景对其进行优化和扩展。5.1 流水线化设计实现吞吐率最大化如果你的应用场景是连续不断的数据流需要转换那么流水线Pipeline设计可以让你每个时钟周期都输出一个转换结果极大提升吞吐率。设计思路将处理一位二进制数的“移位加3”操作作为一个流水级。对于一个WIDTH位的转换器你就设置WIDTH级流水线。第一级接收输入的最高位进行移位和加3然后将中间结果传递给第二级第二级处理次高位以此类推。这样数据像流水一样依次通过每一级每经过一个时钟周期就向前流动一级。经过WIDTH个周期的初始延迟后每个时钟周期都会在输出端产生一个完整的转换结果。Verilog片段示意// 流水线寄存器数组 reg [DEC_DIGITS*4-1:0] pipeline_reg [0:WIDTH]; reg [WIDTH-1:0] bin_pipeline [0:WIDTH]; always (posedge clk) begin // 第一级输入 pipeline_reg[0] 0; bin_pipeline[0] bin_input; // 中间各级处理 for (i 0; i WIDTH; i i1) begin // 移位 reg [DEC_DIGITS*4-1:0] temp_bcd {pipeline_reg[i][DEC_DIGITS*4-2:0], bin_pipeline[i][WIDTH-1-i]}; // 加3调整 for (j 0; j DEC_DIGITS; j j1) begin if (temp_bcd[j*4 : 4] 5) temp_bcd[j*4 : 4] temp_bcd[j*4 : 4] 3; end // 传递到下一级寄存器 pipeline_reg[i1] temp_bcd; bin_pipeline[i1] bin_pipeline[i]; // 二进制数也需要传递以保持对齐 end end // 输出最后一级寄存器的值 assign bcd_output pipeline_reg[WIDTH]; assign valid_output 1‘b1; // 流水线始终有效这种设计消耗的寄存器资源会成倍增加但换来了最高的数据吞吐率常用于高速数据采集和处理系统。5.2 与上位机或显示模块的接口整合转换好的BCD码最终要送去显示或通信。这里有两个常见的后续处理驱动七段数码管需要另一个模块BCD to 7-Segment Decoder将4位BCD码转换成对应的7段或8段包括小数点编码。这个模块通常是一个查找表ROM。// 简单的BCD转七段码共阳极数码管0点亮 module bcd_to_7seg ( input [3:0] bcd_in, output reg [6:0] seg_out // 顺序通常是 a,b,c,d,e,f,g ); always (*) begin case (bcd_in) 4h0: seg_out 7b0000001; // g段灭其他段亮显示0 4h1: seg_out 7b1001111; 4h2: seg_out 7b0010010; // ... 省略 3-9 4hA,4hB,4hC,4hD,4hE,4hF: seg_out 7b1111111; // 全灭或显示特定字符 default: seg_out 7b1111111; endcase end endmodule如果你有多个数码管还需要一个动态扫描模块快速循环点亮每一个管子利用人眼视觉暂留形成稳定显示。通过UART发送给PC你需要将每个4位BCD码转换成对应的ASCII码‘0’到‘9’对应的0x30到0x39然后通过UART发送模块一位一位地发送出去。这通常涉及一个并串转换的状态机。5.3 面积与速度的终极权衡查找表法对于位宽非常小比如8位或更小且对速度有极端要求的场景可以直接使用查找表LUT。将256种可能的输入值对应的BCD码预先计算好存储在FPGA的Block RAM或分布式RAM中。转换时直接将输入二进制数作为地址一个时钟周期就能读出结果。优点速度极快单周期完成。缺点资源消耗随输入位宽指数级增长。8位需要256个条目每个条目假设输出3个BCD位12位那么就需要256*12 3072 bit的存储空间这还可以接受。但如果是16位就需要65536个条目约合786Kbit这会消耗大量的存储资源在很多低成本FPGA上是不现实的。因此移位加3算法因其在面积、速度和实现复杂度上的完美平衡成为了FPGA中实现二进制转BCD码的“标准答案”。理解并掌握它是数字逻辑设计和FPGA开发中一项非常基础且实用的技能。