FPGA入门实战:从零实现十进制计数器,掌握时序逻辑核心设计 1. 为什么从计数器入手学习FPGA1.1 计数器是时序逻辑的“Hello World”很多刚接触FPGA的朋友学完组合逻辑之后第一个真正意义上的时序电路实验就是计数器。这不是没有道理的。计数器麻雀虽小五脏俱全——它包含了时钟边沿触发、复位逻辑、寄存器更新、进位判断、输出译码这几个时序电路最核心的要素。你把这几个东西搞明白了后面做状态机、做数据通路、做流水线本质上都是在这套框架上做扩展。我见过不少初学者一上来就想做图像处理或者高速接口结果连最基本的时序收敛概念都没有代码写出来综合能过上板就是跑不起来。所以我的建议很明确先把计数器吃透再往复杂了走。十进制计数器又是一个特别好的切入点因为它比二进制计数器多了一层“逢十进一”的逻辑这个逻辑背后涉及的是数字系统设计里非常关键的一个思想——用有限的硬件资源去表达人类习惯的计数方式。1.2 十进制计数器和二进制计数器的本质区别这里要先厘清一个概念。FPGA内部所有的寄存器、所有的数据存储本质上都是二进制的。你不可能在硅片里直接造出一个“十进制”的存储单元。所谓十进制计数器指的是计数行为对外表现为十进制——从0数到9然后回到0并产生一个进位脉冲。内部实现上它仍然是用4位二进制寄存器来存储当前值因为4位二进制可以表示0到15足够覆盖0到9只是在到达9的时候下一个时钟沿强制回到0而不是继续数到10、11。这个“强制回到0”的动作就是十进制计数器的核心。它需要一个比较逻辑来判断当前值是否等于9然后通过组合逻辑或者同步逻辑来控制下一个状态。听起来简单但这里面有几个坑比较逻辑是组合逻辑还是时序逻辑进位输出是跟计数同步还是延迟一拍复位信号需不需要同步这些问题在实际工程里都有讲究。1.3 这个项目适合谁、能学到什么如果你已经会写基本的Verilog模块知道always (posedge clk)是什么意思但还没独立做过一个完整的时序电路那这个项目非常适合你。做完之后你应该能掌握以下几个能力理解同步复位和异步复位的区别知道什么时候该用哪种掌握用计数器实现分频的基本方法学会用ModelSim或者类似工具做功能仿真理解综合工具如何把RTL代码映射成实际的触发器资源知道怎么用SignalTap或者类似工具做在线调试这些能力不是纸上谈兵是后面做任何FPGA项目都要反复用到的基本功。2. 十进制计数器的核心设计思路拆解2.1 状态编码方式的选择设计一个十进制计数器第一个要做的决策是用什么编码方式来表示0到9这十个状态最直观的是用4位二进制自然码0对应4‘b00009对应4’b1001。这种编码方式的好处是简单、直观综合出来的电路面积小。但也有一些场景会用独热码或者格雷码那些主要是为了降低功耗或者避免毛刺在计数器这种简单场景里一般用不上。我个人的习惯是计数器一律用二进制自然码。原因很简单计数器本身就是一个算术运算单元用二进制码可以直接做加一操作不需要额外的译码逻辑。如果你用独热码每来一个时钟沿要做一个移位操作虽然速度可能快一点但面积开销大得多而且代码写起来也啰嗦。2.2 同步复位还是异步复位这是初学者最容易纠结的问题之一。异步复位的写法是always (posedge clk or negedge rst_n)复位的生效不依赖时钟同步复位的写法是always (posedge clk)复位信号在时钟沿被采样。异步复位的优点是复位响应快不需要等时钟沿缺点是复位释放的时刻如果离时钟沿太近可能产生亚稳态。同步复位的优点是复位释放是同步的不会有亚稳态问题缺点是需要时钟在跑才能复位而且复位信号需要满足建立保持时间。在实际工程里我倾向于推荐初学者用同步复位。原因有两个第一同步复位的时序分析更简单不容易出问题第二很多现代FPGA的触发器资源本身就更适合同步复位。当然如果你做的是低功耗设计需要时钟停止时也能复位那就得用异步复位。但对于一个学习用的计数器同步复位足够了。2.3 进位输出的产生方式十进制计数器通常需要一个进位输出信号用来级联多个计数器。比如你要做一个00到99的计数器就需要两个十进制计数器级联个位计数器的进位输出接到十位计数器的使能端。进位输出有两种产生方式。第一种是组合逻辑输出当计数值等于9时进位输出直接拉高。这种方式的优点是进位信号和计数值同相没有延迟缺点是进位信号上可能有毛刺因为它是组合逻辑产生的。第二种是时序逻辑输出进位信号在计数到9的那个时钟沿被寄存一拍输出是干净的但比计数值晚一个周期。怎么选看你的下游电路。如果下游是同步电路对进位信号的毛刺不敏感那组合逻辑输出就行如果下游对毛刺敏感或者你需要进位信号和某个时钟严格对齐那就用时序逻辑输出。我一般会用时序逻辑输出因为多打一拍对计数器这种低速场景没什么影响但能换来更干净的信号。3. 从零开始搭建十进制计数器3.1 模块接口定义先确定模块的输入输出。一个基本的十进制计数器需要这些端口clk时钟输入rst_n复位输入低电平有效en计数使能高电平有效cnt_out4位计数值输出carry_out进位输出这里我加了en使能信号而不是让计数器一直跑。为什么因为在实际系统里计数器往往需要被使能控制——比如分频出来的低频脉冲作为使能或者上游模块告诉它什么时候开始计数。加上使能信号之后这个模块的复用性会好很多。端口定义用Verilog写出来大概是这样module decimal_counter ( input wire clk, input wire rst_n, input wire en, output reg [3:0] cnt_out, output reg carry_out );注意cnt_out和carry_out都声明为reg类型因为它们是在always块里赋值的。3.2 核心计数逻辑的实现接下来写核心的计数逻辑。思路很直接每个时钟上升沿如果复位有效就清零否则如果使能有效就判断当前值是不是9——是9就回到0并拉高进位不是9就加一。always (posedge clk) begin if (!rst_n) begin cnt_out 4d0; carry_out 1b0; end else if (en) begin if (cnt_out 4d9) begin cnt_out 4d0; carry_out 1b1; end else begin cnt_out cnt_out 1b1; carry_out 1b0; end end else begin carry_out 1b0; end end这段代码有几个细节值得说。第一进位输出是在计数到9的那个时钟沿拉高的也就是说进位信号和计数值9是同时出现的持续一个时钟周期。第二当en为低时进位输出被强制清零这是为了防止使能关闭时进位信号还挂在那里。第三复位用的是同步复位复位信号在时钟沿被采样。3.3 进位输出的时序考量上面这段代码里进位输出是时序逻辑产生的它和计数值9同时出现。这意味着如果你把进位输出直接接到下一级计数器的使能端下一级会在同一个时钟沿看到使能有效——但这时候上一级的计数值已经变成0了。这会不会有问题不会。因为下一级计数器在同一个时钟沿采样使能信号它看到的是上一级进位输出在当前时钟沿之前的值。换句话说上一级在时钟沿N输出进位下一级在时钟沿N1才真正开始计数。这正好是级联计数器需要的行为个位从9回到0的那一刻十位加一。如果你希望进位输出提前一个周期产生可以在计数值等于8的时候就拉高进位。但那样的话进位信号和计数值9就不对齐了级联的时候需要额外注意。我建议初学者就用上面这种“进位和9同时出现”的方式逻辑最清晰。3.4 测试平台的编写写完RTL之后一定要做仿真。测试平台不需要太复杂但要把几种典型情况都覆盖到正常计数、复位、使能关闭、进位输出。timescale 1ns/1ps module tb_decimal_counter; reg clk; reg rst_n; reg en; wire [3:0] cnt_out; wire carry_out; decimal_counter uut ( .clk (clk), .rst_n (rst_n), .en (en), .cnt_out (cnt_out), .carry_out (carry_out) ); initial begin clk 0; forever #5 clk ~clk; end initial begin rst_n 0; en 0; #20; rst_n 1; #10; en 1; #200; en 0; #40; en 1; #100; $stop; end endmodule这个测试平台跑了大概360纳秒覆盖了复位、正常计数、使能关闭、重新使能这几个场景。跑完仿真之后你应该能看到计数值从0数到9然后回到0进位输出在9的时候拉高一个周期。4. 仿真验证与常见问题排查4.1 仿真波形的关键观察点跑完仿真之后重点看几个地方。第一复位期间计数值是不是0进位输出是不是0。第二使能拉高之后计数值是不是每个时钟沿加一。第三计数值到9的时候进位输出是不是拉高了而且只拉高一个周期。第四计数值从9回到0的时候进位输出是不是跟着拉低了。如果这四点都对了那基本功能就没问题。如果哪里不对就要顺着信号往回查。比如进位输出一直不拉高那可能是比较逻辑写错了或者进位输出的赋值条件有问题。4.2 常见问题速查表现象可能原因排查方法计数值一直为0复位信号一直有效检查rst_n是否在仿真中被正确拉高计数值不变化使能信号一直为低检查en信号是否被正确驱动计数值超过9比较逻辑写错检查cnt_out 4d9这个条件进位输出一直为高进位输出赋值条件写错检查进位输出是否只在等于9时拉高进位输出有毛刺用了组合逻辑输出改成时序逻辑输出综合报错端口类型声明错误检查output是否声明为reg4.3 实操心得仿真通过不代表上板能跑这是我踩过的一个坑。仿真的时候一切正常上板之后计数值乱跳。后来查了半天发现是时钟信号的问题——板子上的晶振频率太高直接拿来做计数器计数值变化太快肉眼根本看不出来而且LED的刷新率跟不上。解决办法很简单加一个分频器把时钟降到1Hz左右这样LED才能显示出计数效果。这个分频器本身也是一个计数器只不过计数值比较大比如50MHz的时钟分频到1Hz需要计数到25_000_000。这个数需要多少位2的25次方是33_554_432所以至少需要25位。实际写的时候我会用一个26位的寄存器来存分频计数值。提示分频器的比较值不要写死用parameter定义这样换板子的时候只需要改一个参数。5. 从计数器延伸到更实用的场景5.1 用计数器做精准的分频器十进制计数器本身就是一个分频器——每输入10个时钟脉冲输出一个进位脉冲分频比是10。如果你需要其他分频比只需要改比较值就行。比如要做一个27分频就把比较值改成26从0数到26一共27个状态。这里有一个细节分频比是N的时候比较值应该是N-1。因为计数器从0开始数数到N-1的时候一共经历了N个状态。很多人会写成N结果分频比变成了N1差一个周期。这个坑我在早期项目里踩过好几次后来养成了习惯写比较值的时候先想清楚是从0开始还是从1开始。5.2 级联多个计数器实现多位十进制计数单个十进制计数器只能表示0到9。如果要表示0到99就需要两个计数器级联。级联的方式很简单个位计数器的进位输出接到十位计数器的使能端两个计数器共用同一个时钟和复位信号。这里有一个需要注意的地方十位计数器的使能信号来自个位的进位输出而个位的进位输出是在个位从9回到0的那个时钟沿拉高的。这意味着十位计数器会在个位回到0的下一个时钟沿才加一。从波形上看个位从9变0和十位加一之间有一个时钟周期的延迟。这个延迟在大多数场景下是可以接受的但如果你需要个位和十位严格同步变化那就需要把进位输出提前一个周期产生。5.3 计数器在状态机中的应用计数器经常和状态机配合使用。比如一个简单的交通灯控制器可以用状态机控制红绿黄灯的切换用计数器控制每个状态的持续时间。状态机负责“做什么”计数器负责“做多久”。这种分工在FPGA设计里非常常见。我个人的经验是状态机里不要塞太多计数逻辑。把计数功能独立成一个模块状态机只负责发使能和读进位这样代码更清晰也更容易调试。如果你把计数逻辑和状态跳转混在一起写一旦出问题排查起来非常痛苦。6. 综合与实现阶段的注意事项6.1 综合工具会怎么处理你的代码综合工具看到cnt_out cnt_out 1b1这样的代码会推断出一个加法器加一组触发器。加法器的位宽是4位触发器也是4位。如果你写的是cnt_out cnt_out 1综合结果是一样的因为1会被自动扩展到4位。但如果你写的是cnt_out cnt_out 32d1综合工具可能会推断出一个32位的加法器然后只取低4位。这会浪费资源。所以加法的常数位宽要和寄存器位宽匹配不要随手写一个大位宽的常数。6.2 时序约束的基本写法计数器这种低速电路时序约束一般不是问题。但如果你用的是高速时钟还是需要加一个基本的时钟约束。在SDC文件里写一行create_clock -name clk -period 20 [get_ports clk]这行约束告诉工具时钟周期是20纳秒对应50MHz。工具会根据这个约束来检查建立时间和保持时间。如果你的设计跑不到50MHz工具会报时序违例这时候你就需要优化逻辑或者降低时钟频率。6.3 资源占用的评估一个4位十进制计数器大概会占用4个触发器加一个4位加法器。在大多数FPGA里这点资源微不足道。但如果你级联了很多个计数器资源占用就会线性增长。比如做一个8位十进制计数器0到99_999_999需要8个4位计数器总共32个触发器加8个加法器。这个规模在中小规模FPGA里还是可以接受的。如果你需要更大范围的计数可以考虑用二进制计数器加输出译码的方式这样触发器数量会少一些但译码逻辑会复杂一点。具体怎么选看你的资源预算和速度要求。7. 在线调试与上板验证7.1 用SignalTap抓取内部信号仿真通过之后上板之前最好用SignalTap或者类似工具抓一下内部信号。把cnt_out和carry_out都加到观察列表里设置一个合适的触发条件——比如cnt_out 4d9然后让电路跑起来。抓到的波形应该和仿真波形一致。如果不一样那说明仿真和实际电路有差异可能是综合选项的问题也可能是约束文件的问题。这一步能帮你提前发现很多潜在问题避免上板之后抓瞎。7.2 LED显示的驱动方法如果你想把计数值显示在LED上需要做一个译码器把4位二进制码转换成7段数码管的段选信号。这个译码器就是一个组合逻辑输入是0到9输出是7位段选信号。真值表如下计数值段选信号abcdefg01111110101100002110110131111001401100115101101161011111711100008111111191111011这个真值表可以直接写成case语句。注意段选信号的有效电平——有些数码管是共阳极低电平点亮有些是共阴极高电平点亮。写代码之前先确认你的板子用的是哪种。7.3 上板验证的步骤上板验证的步骤大概是先确认时钟和复位信号正确连接然后下载比特流观察LED的显示。如果LED不亮或者显示乱码先检查引脚约束对不对再检查译码逻辑有没有写错。我一般会先用一个最简单的测试——让计数器每个时钟周期加一观察LED的最低位是不是在快速闪烁。如果最低位在闪说明时钟和计数器都在工作。然后再加分频器让计数速度降下来观察完整的0到9循环。8. 从十进制计数器到更复杂的设计8.1 可逆计数器的实现思路可逆计数器就是既能加计数也能减计数。实现方法很简单加一个方向控制信号dir当dir为1时加一为0时减一。减计数的时候判断条件从“等于9”变成“等于0”到0的时候回到9并产生借位输出。这里有一个细节减计数器的借位输出和加计数器的进位输出逻辑是对称的。加计数器在9的时候进位减计数器在0的时候借位。如果你要做级联的可逆计数器需要把进位和借位信号都引出来然后根据方向选择用哪个。8.2 带预置数的计数器带预置数的计数器可以在计数之前加载一个初始值。实现方法是加一个load信号和一个load_data输入当load有效时计数值被加载为load_data而不是从0开始。这种计数器在分频器里很有用。比如你需要一个分频比可调的分频器就可以用带预置数的计数器来实现——预置数决定了分频比。预置数的值等于最大计数值减去所需的分频比。8.3 计数器在PWM生成中的应用PWM脉冲宽度调制是计数器的一个经典应用。基本原理是用一个计数器不断计数同时用一个比较器把计数值和占空比设定值比较当计数值小于设定值时输出高电平否则输出低电平。这样输出信号的占空比就等于设定值除以计数器的最大值。PWM在电机控制、LED调光、电源管理里都有广泛应用。如果你把十进制计数器扩展成PWM生成器那就从一个学习项目变成了一个实用模块。我建议你在做完基本计数器之后试着把它改造成一个PWM模块这样能学到更多东西。9. 我在实际操作中积累的几个经验第一个经验仿真的时候一定要看波形不要只看打印信息。打印信息只能告诉你某个时刻的值波形能告诉你信号之间的时序关系。很多问题——比如进位输出晚了一拍、复位释放的时机不对——只有看波形才能发现。第二个经验复位信号的处理要统一。如果你的系统里有的模块用同步复位有的用异步复位那复位释放的时候很容易出问题。我一般会在顶层做一个复位同步器把外部来的异步复位同步化之后再分发给各个模块。这样既保留了异步复位的快速响应又避免了复位释放的亚稳态问题。第三个经验计数器的位宽要留够。如果你需要计数到1000那至少需要10位2的10次方是1024。但如果你用的是十进制计数器级联那需要3个十进制计数器0到999。这两种方式的资源占用和代码复杂度不一样选哪种看你的具体需求。第四个经验上板之前先做静态时序分析。综合工具报的时序违例一定要看不要觉得“仿真过了就行”。仿真是在理想条件下跑的实际电路有时钟偏斜、有布线延迟仿真过了不代表上板能跑。静态时序分析能帮你提前发现这些问题。第五个经验代码风格要统一。比如复位信号统一用低电平有效命名统一用_n后缀时钟信号统一用clk开头使能信号统一用en。这样代码的可读性会好很多后面维护的时候也省事。10. 后续可以继续深入的方向十进制计数器做完之后你可以沿着几个方向继续深入。一个是往高速方向走研究一下流水线计数器怎么设计怎么在高速时钟下保证时序收敛。另一个是往低功耗方向走研究一下门控时钟怎么用在计数器上怎么在不计数的时候把时钟关掉省电。还有一个方向是往复杂协议方向走比如用计数器做UART的波特率生成器或者做SPI的时钟分频器。这些应用场景里的计数器往往需要更精确的分频比和更严格的时序控制能让你对计数器的理解更上一层楼。如果你对验证感兴趣可以试着给计数器写一个覆盖率驱动的测试平台把各种边界情况都覆盖到——比如复位和使能同时有效、计数值在9的时候复位、进位输出和使能信号的交互等等。这些测试用例能帮你发现一些平时想不到的边界问题。我个人觉得FPGA学习最有效的方法就是把一个简单的模块反复打磨每次加一点新功能每次解决一个新问题。十进制计数器看起来简单但如果你把它做透——加上可逆计数、预置数、PWM输出、级联扩展——你会发现里面涉及的知识点比你想象的多得多。把这些都搞明白了后面做更复杂的项目就有了扎实的基础。