1. 项目缘起为什么要在FPGA里做二进制转BCD最近在做一个基于FPGA的简易数字电压表项目需要把ADC采样得到的二进制数据最终驱动数码管显示成我们熟悉的十进制数字。这个需求听起来简单不就是把二进制数转成十进制吗但FPGA内部是纯数字逻辑它可没有现成的printf或者itoa函数给你用。你得自己用硬件描述语言比如Verilog设计一个电路把一串代表数值的二进制位转换成对应的“二十进制码”也就是BCD码。BCD码你可能不陌生它用4位二进制数来表示一个十进制位0-9。比如十进制数123用BCD码表示就是0001 0010 0011。这个转换过程在软件里用除法取余就能轻松搞定但在FPGA的硬件世界里除法器是面积和延迟都很大的电路直接用并不划算。尤其是在需要高速、流水线处理或者资源受限的场合我们需要更“聪明”的硬件算法。所以这个“二进制转BCD码”的模块就成了数字系统设计里一个非常经典又基础的小组件。它考验的是你对数字逻辑、算法硬件化以及Verilog编码风格的综合理解。网上能找到很多种实现方法比如查找表法、移位加3算法等。这次我就结合自己的实际项目经验带你从原理到实现彻底搞懂如何在FPGA里用Verilog高效、可靠地完成这个转换并分享几个调试过程中容易踩的坑。2. 核心原理剖析从“除10取余”到“移位加3”在深入写代码之前我们必须先弄明白转换的核心原理。软件思维是“除10取余”那硬件思维是什么答案是“移位加3”算法也称为Double-dabble算法。这个算法巧妙地避免了直接的除法运算仅通过移位和条件加法来实现转换非常适合用硬件实现。2.1 算法思想与手动演算我们先从一个简单的例子开始把8位二进制数1111 1111十进制255转换成BCD码。BCD码需要三个4位组来表示百位、十位、个位。算法的核心步骤是从左到右处理二进制位每次先将所有BCD位左移一位移入当前的二进制位然后独立检查每一个BCD位即每一个十进制位如果该位的值大于或等于5就对这个位加上3。重复这个过程直到所有二进制位都处理完毕。听起来有点抽象我们手动算一下初始化BCD寄存器设为0000 0000 0000百位、十位、个位。处理第一个二进制位‘1’先左移整个BCD寄存器并移入二进制位‘1’0000 0000 0001。检查每个BCD位是否≥5百位0十位0个位1。都小于5不加3。处理第二个二进制位‘1’左移并移入‘1’0000 0000 0011即十进制的3。检查个位35不加。处理第三个二进制位‘1’左移并移入‘1’0000 0000 0111即7。检查个位7≥5所以对个位加30111 0011 1010即十进制的10但BCD码中单个数位不能超过9这里的高位‘1’会在下一步左移时进入十位。现在BCD变为0000 0000 1010。处理第四个二进制位‘1’左移并移入‘1’0000 0001 0101注意上一步个位1010左移1进了十位010留在个位再移入新的‘1’到个位最低位。检查十位15个位55对个位加30101 0011 1000。BCD变为0000 0001 1000。……中间省略若干步处理完所有8位后我们最终会得到0010 0101 0101。拆开看0010是2百位0101是5十位0101是5个位。正好是255。为什么加3这是算法的精妙之处。BCD码是逢十进一而4位二进制是逢十六进一。当一个BCD位达到5即二进制0101时它离进位到10还差5。但左移操作相当于乘以2。如果当前值是5左移后变成10二进制1010这已经是一个无效的BCD码1010代表十进制的10超过了9。如果在左移前发现这个位≥5我们先给它加3比如5变成81000再左移变成161 0000但这次左移导致的高位‘1’会正好进位到上一个BCD位同时本位置0完美地模拟了“逢十进一”的效果。你可以把它理解为一种“预补偿”机制。2.2 硬件实现结构框图理解了算法我们就可以设计硬件结构了。对于一个N位的二进制输入我们需要一个至少能容纳ceil(N*log10(2))个十进制位的BCD寄存器。例如8位二进制最大255需要3个十进制位12位BCD码16位二进制最大65535需要5个十进制位20位BCD码。典型的硬件结构可以采用状态机控制或纯组合逻辑流水线。这里我们以一个易于理解的时序逻辑实现为例----------------------- | 控制状态机 | | (或循环计数器) | ---------------------- | 控制信号 v 二进制输入位流 -- [移位选择器] -- ----------------------- | | BCD寄存器组 | ----------| (百位、十位、个位...) | ---------------------- | 左移 ----------v------------ | “加3”修正逻辑 | | (并行检查每个BCD位) | ---------------------- | 修正后的值 ----------v------------ | 多路选择器 | | (选择原始值或修正值) | ---------------------- | ----------v------------ ---| 回写到BCD寄存器 | | ----------------------- | | ----[位计数器]--- (判断是否完成)工作流程初始化BCD寄存器清零位计数器指向二进制数的最高位。在每个时钟周期 a. 将BCD寄存器整体左移1位。 b. 将二进制数的当前位从最高位开始移入BCD寄存器的最低位。 c. 并行检查BCD寄存器中的每一个十进制位每4位一组若其值≥5则将该4位值加3否则保持不变。 d. 将修正后的值更新回BCD寄存器。 e. 位计数器减1指向下一个二进制位。重复步骤2直到所有二进制位都处理完毕。此时BCD寄存器中的值就是转换结果。这种实现方式每个时钟周期处理1个二进制位转换一个N位数需要N个时钟周期。虽然速度不是最快但结构清晰资源消耗少非常适合初学者理解和中等速度需求的应用。3. Verilog实现详解从模块定义到仿真验证理论讲完了我们动手写代码。我将以一个将12位二进制数转换为4位BCD码最大显示0-4095的模块为例展示可综合的Verilog代码。选择12位是因为它足够演示算法且结果4个BCD位用16位寄存器就能容纳。3.1 模块接口与关键信号定义首先我们定义模块的输入输出。采用同步时序设计时钟clk和复位rst_n是标配。输入是12位的二进制数bin_in和一个启动信号start。输出是16位的BCD码bcd_out每4位代表一个十进制数和一个完成标志done。module bin2bcd #( parameter BIN_WIDTH 12, // 二进制输入位宽 parameter BCD_DIGITS 4 // BCD输出位数十进制位数 )( input wire clk, input wire rst_n, input wire [BIN_WIDTH-1:0] bin_in, input wire start, // 高电平启动转换 output reg [BCD_DIGITS*4-1:0] bcd_out, // 打包的BCD码高位在前 output reg done // 转换完成标志高电平有效 );接下来我们需要一些内部寄存器bin_reg: 用于移位操作的二进制数寄存器。bcd_reg: 算法核心的BCD工作寄存器宽度为BCD_DIGITS * 4。bit_cnt: 位计数器记录还剩多少位二进制位需要处理。state/next_state: 状态机寄存器用于控制转换流程。3.2 状态机设计与转换控制逻辑我们用一个简单的状态机来控制转换流程只有三个状态IDLE: 空闲状态等待start信号。CONVERTING: 转换进行状态每个时钟周期处理一位二进制位。DONE: 转换完成状态输出结果并拉高done信号一个周期。localparam [1:0] IDLE 2b00; localparam [1:0] CONVERTING 2b01; localparam [1:0] DONE 2b10; reg [1:0] state, next_state; reg [BIN_WIDTH-1:0] bin_reg; reg [BCD_DIGITS*4-1:0] bcd_reg; reg [$clog2(BIN_WIDTH)-1:0] bit_cnt; // 位计数器位宽自动计算 // 状态转移逻辑 always (posedge clk or negedge rst_n) begin if (!rst_n) begin state IDLE; end else begin state next_state; end end // 次态逻辑 always (*) begin next_state state; case (state) IDLE: begin if (start) next_state CONVERTING; end CONVERTING: begin if (bit_cnt 0) next_state DONE; // 所有位处理完 end DONE: begin next_state IDLE; // 完成状态只保持一个周期 end default: next_state IDLE; endcase end3.3 “移位加3”算法的硬件描述这是模块的核心。在CONVERTING状态每个时钟周期执行一次“移位加3”操作。我们需要用for循环来并行处理每一个BCD位每4位一组。// 转换操作与数据通路 always (posedge clk or negedge rst_n) begin if (!rst_n) begin bin_reg 0; bcd_reg 0; bit_cnt BIN_WIDTH - 1; // 从最高位开始 bcd_out 0; done 0; end else begin done 0; // 默认done为0 case (state) IDLE: begin if (start) begin bin_reg bin_in; // 锁存输入 bcd_reg 0; // 清空BCD寄存器 bit_cnt BIN_WIDTH - 1; // 初始化位计数器 end end CONVERTING: begin // 1. 整体左移一位并将bin_reg的最高位移入bcd_reg的最低位 bcd_reg {bcd_reg[BCD_DIGITS*4-2:0], bin_reg[BIN_WIDTH-1]}; bin_reg {bin_reg[BIN_WIDTH-2:0], 1b0}; // bin_reg也左移方便取最高位 // 2. 对BCD寄存器的每一个十进制位进行“加3”修正 // 这是一个纯组合逻辑的修正过程我们用for循环生成硬件 // 注意这里描述的是在下一个时钟沿到来前对bcd_reg的中间值进行修正。 // 实际编写时我们需要一个中间变量来存储修正后的值或者写在另一个always块中。 // 下面是一种更清晰的写法将修正逻辑单独描述 begin: bcd_correction integer i; reg [BCD_DIGITS*4-1:0] bcd_temp; bcd_temp bcd_reg; // 使用非阻塞赋值先获取左移后的值实际是上一周期的值这里为说明逻辑 // 更准确的描述应在组合逻辑always块中根据当前的bcd_reg计算修正值。 // 为了流程清晰我们换一种方式。 end // 3. 位计数器减1 bit_cnt bit_cnt - 1; end DONE: begin bcd_out bcd_reg; // 输出最终结果 done 1; // 产生完成脉冲 end endcase end end上面的代码中“加3”修正逻辑需要特别注意时序。我们更推荐将左移和条件修正放在同一个时钟沿处理但修正逻辑是组合的。更好的实现方式是定义一个组合逻辑块专门根据bcd_reg的当前值计算修正值然后在时序逻辑中赋值。让我们重写这部分关键逻辑首先定义一个组合逻辑块来计算“加3”修正// 组合逻辑对BCD寄存器进行“加3”修正 reg [BCD_DIGITS*4-1:0] bcd_corrected; integer i; always (*) begin bcd_corrected bcd_reg; // 先假设不变 for (i 0; i BCD_DIGITS; i i 1) begin // 取出第i个BCD位每4位一组 // 注意Verilog中bcd_reg[(i1)*4-1 -: 4] 表示从位 (i1)*4-1 开始向下取4位 // 即 bits: [ (i1)*4-1 : i*4 ] if (bcd_reg[(i1)*4-1 -: 4] 5) begin bcd_corrected[(i1)*4-1 -: 4] bcd_reg[(i1)*4-1 -: 4] 3; end end end然后修改CONVERTING状态的操作使其先左移然后在下个时钟沿更新为修正后的值。但这里有个关键点算法要求先左移移入新位再对左移后的结果进行条件加3。所以我们的时序是当前时钟周期bcd_reg保存的是上一周期修正后的值。在本周期内我们想计算“左移移入新位”后再修正的值并希望在下一个时钟沿更新。 因此我们需要一个中间寄存器来保存“左移后但未修正”的值。让我们调整设计reg [BCD_DIGITS*4-1:0] bcd_shifted; // 左移后的中间值 // 组合逻辑修正块现在基于 bcd_shifted 计算 always (*) begin bcd_corrected bcd_shifted; for (i 0; i BCD_DIGITS; i i 1) begin if (bcd_shifted[(i1)*4-1 -: 4] 5) begin bcd_corrected[(i1)*4-1 -: 4] bcd_shifted[(i1)*4-1 -: 4] 3; end end end // 在时序逻辑中 always (posedge clk or negedge rst_n) begin // ... 复位部分同上 ... CONVERTING: begin // 1. 计算左移后的值组合逻辑路径的起点 bcd_shifted {bcd_reg[BCD_DIGITS*4-2:0], bin_reg[BIN_WIDTH-1]}; // 2. 在下一个时钟沿将组合逻辑修正后的值锁存到bcd_reg bcd_reg bcd_corrected; // bcd_corrected 由组合逻辑根据bcd_shifted实时计算得出 // 3. 二进制寄存器左移 bin_reg {bin_reg[BIN_WIDTH-2:0], 1b0}; // 4. 位计数器减1 bit_cnt bit_cnt - 1; end // ... 其他状态 ... end注意上面的代码中bcd_shifted被用作组合逻辑的输入而bcd_reg在时钟沿更新为bcd_corrected。这里存在一个潜在问题bcd_shifted是在CONVERTING状态下的赋值但组合逻辑块always (*)是敏感于所有信号的当状态不在CONVERTING时bcd_shifted可能未定义。更严谨的做法是将左移操作也放入组合逻辑或者确保bcd_shifted在所有状态下都有确定值。为了简化并避免综合警告一个更常见且清晰的做法是不单独使用bcd_shifted而是直接在组合逻辑中根据bcd_reg和bin_reg计算“左移并修正”后的下一个值。这涉及到对算法时序的细微调整但概念更清晰。考虑到篇幅和初学友好一个经过简化的、完全时序逻辑的描述如下它可能不是最高效的但易于理解// 简化版在一个时钟周期内完成“左移-修正” always (posedge clk or negedge rst_n) begin if (!rst_n) begin // ... 初始化 ... end else if (state CONVERTING) begin // 步骤A: 左移并移入新位 bcd_reg {bcd_reg[BCD_DIGITS*4-2:0], bin_reg[BIN_WIDTH-1]}; bin_reg {bin_reg[BIN_WIDTH-2:0], 1b0}; bit_cnt bit_cnt - 1; end end // 另一个always块专门对左移后的bcd_reg进行修正。 // 注意这里用到了“阻塞赋值”和“非阻塞赋值”的混合在可综合代码中需要非常小心。 // 更推荐的做法是使用“生成块generate”来构建纯组合的修正逻辑并在顶层连接。 // 下面给出一个更可靠、更通用的实现架构鉴于直接混合描述可能引起综合问题我给出一个推荐且稳健的实现架构顶层模块(bin2bcd)包含状态机、计数器、寄存器。一个独立的组合逻辑修正模块(bcd_correction_unit)输入是当前的bcd_reg和要移入的二进制位bit_in输出是修正后的bcd_next。在顶层模块的CONVERTING状态每个时钟周期将bcd_next赋值给bcd_reg。由于篇幅限制这里不再展开两个模块的详细代码但思路已明确。对于初学者可以先理解单周期完成“左移-修正”的概念模型在实际编写时务必注意仿真与综合的差异并优先采用结构清晰的子模块划分。3.4 Testbench编写与Modelsim仿真设计完成后必须通过仿真验证。我们编写一个简单的testbench用Modelsim或任何Verilog仿真工具进行测试。timescale 1ns/1ps module tb_bin2bcd(); reg clk; reg rst_n; reg [11:0] bin_in; reg start; wire [15:0] bcd_out; wire done; // 实例化被测模块 bin2bcd #( .BIN_WIDTH(12), .BCD_DIGITS(4) ) uut ( .clk(clk), .rst_n(rst_n), .bin_in(bin_in), .start(start), .bcd_out(bcd_out), .done(done) ); // 生成时钟周期10ns initial begin clk 0; forever #5 clk ~clk; end // 测试流程 initial begin // 1. 初始化 rst_n 0; start 0; bin_in 0; #100; rst_n 1; #20; // 2. 测试用例1二进制 255 (8‘hFF) bin_in 12d255; start 1; #10; start 0; wait(done 1); // 等待转换完成 #10; // 预期结果bcd_out 16h0255 $display(Test 1: Bin%d, BCD%h, Expected0255, bin_in, bcd_out); if (bcd_out ! 16h0255) $error(Test 1 Failed!); // 3. 测试用例2二进制 4095 (12hFFF) #100; bin_in 12d4095; start 1; #10; start 0; wait(done 1); #10; // 预期结果bcd_out 16h4095 $display(Test 2: Bin%d, BCD%h, Expected4095, bin_in, bcd_out); if (bcd_out ! 16h4095) $error(Test 2 Failed!); // 4. 测试用例3二进制 0 #100; bin_in 12d0; start 1; #10; start 0; wait(done 1); #10; // 预期结果bcd_out 16h0000 $display(Test 3: Bin%d, BCD%h, Expected0000, bin_in, bcd_out); if (bcd_out ! 16h0000) $error(Test 3 Failed!); // 5. 测试用例4随机数测试 #100; bin_in $random % 4096; start 1; #10; start 0; wait(done 1); #10; // 可以手动计算或通过参考模型验证这里只打印 $display(Test 4 (Random): Bin%d, BCD%h, bin_in, bcd_out); #100; $display(All tests completed.); $finish; end // 可选生成波形文件 initial begin $dumpfile(bin2bcd.vcd); $dumpvars(0, tb_bin2bcd); end endmodule在Modelsim中运行这个testbench观察波形。你应该能看到start信号拉高后state从IDLE进入CONVERTING。在CONVERTING状态bit_cnt从11递减到0每递减一次bcd_reg都在变化。当bit_cnt为0时state进入DONEdone信号拉高一个周期同时bcd_out输出稳定且正确的BCD值。对比bin_in和bcd_out验证转换是否正确。4. 优化、扩展与实战中的坑一个基础的转换器工作后我们可以从工程角度考虑更多。4.1 性能优化流水线与全组合逻辑实现上述状态机实现需要N个时钟周期完成转换。如果系统对吞吐率要求高可以考虑流水线设计。思路是将N级移位操作展开成N级连续的组合逻辑每一级对应处理一个二进制位。这样数据可以连续输入每个时钟周期都能输出一个转换结果但延迟是N个周期面积也会增大。另一种极端是纯组合逻辑实现使用“循环展开”的方式用多层嵌套的条件运算符(?:)或generate语句将整个转换过程在一个时钟周期内完成。这对于位数不多比如小于16位且对延迟极其敏感的场景是可行的但代码会变得冗长且随着位数增加逻辑级数变深可能导致时序不满足。// 一个4位二进制转BCD的组合逻辑示例仅作概念展示非推荐写法 assign bcd_out[3:0] (bin_in 4d9) ? (bin_in 6) : bin_in; // 个位处理 assign bcd_out[7:4] (bin_in 4d9) ? 4d1 : 4d0; // 十位处理 // 更高位数的全组合逻辑实现非常复杂通常用脚本生成。在实际项目中需要根据速度、面积、功耗的权衡来选择方案。最常用的还是本文介绍的时序状态机方案它在速度、资源和代码可维护性之间取得了很好的平衡。4.2 功能扩展支持更多位数与有符号数更多位数只需修改参数BIN_WIDTH和BCD_DIGITS。注意BCD_DIGITS需要根据二进制数的最大值计算例如16位二进制65535需要5个BCD位。内部寄存器位宽也要相应调整。有符号数补码转换首先判断输入二进制数的符号位最高位。如果是负数先取其补码得到原码绝对值然后进行无符号数转换最后在输出的BCD码前加上负号标志。这需要额外的符号位输出和取补码逻辑。4.3 调试技巧与常见问题仿真与综合结果不一致这是最常见的问题。通常是因为在可综合的always块中混用了阻塞赋值()和非阻塞赋值()。记住黄金法则在描述时序逻辑的always块中用在描述组合逻辑的always块中用。我们的“加3”修正逻辑最好放在一个独立的组合逻辑always块中。转换结果错误特别是高位为0时检查你的BCD位索引和for循环边界。Verilog的位选取语法[start -: width]和[start : width]要使用得当。建议在仿真中多观察中间信号bcd_reg在每个时钟周期的变化与手动计算的过程对比。时序违例当二进制位宽很大如32位时组合逻辑修正路径那个检查每个BCD位是否≥5并加3的for循环可能会变得很长导致建立时间不足。解决方法包括流水线化将一级修正拆分为多级中间插入寄存器。使用更优化的结构例如可以用查找表LUT来替代加法器但会消耗更多存储资源。降低时钟频率如果系统允许这是最简单的办法。资源占用过多如果FPGA资源紧张可以考虑使用片上存储器Block RAM作为查找表预先计算好所有输入二进制对应的BCD码直接查表输出。这只适用于输入位数较少的情况比如12位因为表的大小会随输入位宽指数增长。使用CPU软核如果系统中有MicroBlaze或Nios II等软核处理器可以将转换任务交给软件完成虽然速度慢但节省逻辑资源。与上位机通信验证在实际系统中FPGA转换出的BCD码可能需要送给单片机或PC。确保双方对数据格式如BCD码的字节顺序、是否有符号位的理解一致。常用的方法是先用FPGA发送一组已知数据如0 255 4095在上位机接收并解析比对结果。5. 在真实项目中的应用以数字电压表为例让我们回到开头的项目。假设ADC是12位精度参考电压2.5V那么ADC输出二进制值D与实际电压V的关系是V D * 2.5 / 4096。我们的显示需求是显示到小数点后两位单位V。流程如下二进制转BCD将12位ADC原始数据D0-4095转换为BCD码得到D_BCD。标度变换计算电压值V D * 250 / 4096。这里乘以250是为了将电压放大100倍便于显示小数。250/4096 ≈ 0.061035。这个乘法可以在FPGA中用乘法器IP核或移位相加实现。二次转换与显示驱动乘法结果是一个二进制数范围0~250*4095/4096 ≈ 0~250我们再次调用二进制转BCD模块将其转换为BCD码。此时得到的BCD码百位、十位、个位就分别对应电压值的个位、十分位、百分位。例如BCD码123表示电压1.23V。送显将得到的BCD码每一位4位送入数码管显示译码器如七段译码模块驱动数码管显示。在这个过程中二进制转BCD模块被调用了两次一次对原始数据一次对标度变换后的数据是整个显示链路的关键。它的正确性和稳定性直接决定了最终显示的准确性。通过这个完整的项目闭环你就能深刻体会到一个看似简单的基础模块是如何在真实的嵌入式系统中扮演不可或缺的角色的。自己动手实现它不仅能巩固Verilog和数字逻辑知识更能建立起从算法到电路从仿真到上板的完整工程思维。