ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

FPGA入门实战:从零实现十进制计数器,掌握时序逻辑核心设计

FPGA入门实战:从零实现十进制计数器,掌握时序逻辑核心设计

1. 为什么从计数器入手学习FPGA

1.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‘b0000,9对应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_out:4位计数值输出
  • 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 <= 4'd0; carry_out <= 1'b0; end else if (en) begin if (cnt_out == 4'd9) begin cnt_out <= 4'd0; carry_out <= 1'b1; end else begin cnt_out <= cnt_out + 1'b1; carry_out <= 1'b0; end end else begin carry_out <= 1'b0; end end

这段代码有几个细节值得说。第一,进位输出是在计数到9的那个时钟沿拉高的,也就是说进位信号和计数值9是同时出现的,持续一个时钟周期。第二,当en为低时,进位输出被强制清零,这是为了防止使能关闭时进位信号还挂在那里。第三,复位用的是同步复位,复位信号在时钟沿被采样。

3.3 进位输出的时序考量

上面这段代码里,进位输出是时序逻辑产生的,它和计数值9同时出现。这意味着如果你把进位输出直接接到下一级计数器的使能端,下一级会在同一个时钟沿看到使能有效——但这时候上一级的计数值已经变成0了。这会不会有问题?

不会。因为下一级计数器在同一个时钟沿采样使能信号,它看到的是上一级进位输出在当前时钟沿之前的值。换句话说,上一级在时钟沿N输出进位,下一级在时钟沿N+1才真正开始计数。这正好是级联计数器需要的行为:个位从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 == 4'd9这个条件
进位输出一直为高进位输出赋值条件写错检查进位输出是否只在等于9时拉高
进位输出有毛刺用了组合逻辑输出改成时序逻辑输出
综合报错端口类型声明错误检查output是否声明为reg

4.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,结果分频比变成了N+1,差一个周期。这个坑我在早期项目里踩过好几次,后来养成了习惯:写比较值的时候先想清楚是从0开始还是从1开始。

5.2 级联多个计数器实现多位十进制计数

单个十进制计数器只能表示0到9。如果要表示0到99,就需要两个计数器级联。级联的方式很简单:个位计数器的进位输出接到十位计数器的使能端,两个计数器共用同一个时钟和复位信号。

这里有一个需要注意的地方:十位计数器的使能信号来自个位的进位输出,而个位的进位输出是在个位从9回到0的那个时钟沿拉高的。这意味着十位计数器会在个位回到0的下一个时钟沿才加一。从波形上看,个位从9变0和十位加一之间有一个时钟周期的延迟。这个延迟在大多数场景下是可以接受的,但如果你需要个位和十位严格同步变化,那就需要把进位输出提前一个周期产生。

5.3 计数器在状态机中的应用

计数器经常和状态机配合使用。比如一个简单的交通灯控制器,可以用状态机控制红绿黄灯的切换,用计数器控制每个状态的持续时间。状态机负责“做什么”,计数器负责“做多久”。这种分工在FPGA设计里非常常见。

我个人的经验是:状态机里不要塞太多计数逻辑。把计数功能独立成一个模块,状态机只负责发使能和读进位,这样代码更清晰,也更容易调试。如果你把计数逻辑和状态跳转混在一起写,一旦出问题,排查起来非常痛苦。

6. 综合与实现阶段的注意事项

6.1 综合工具会怎么处理你的代码

综合工具看到cnt_out <= cnt_out + 1'b1这样的代码,会推断出一个加法器加一组触发器。加法器的位宽是4位,触发器也是4位。如果你写的是cnt_out <= cnt_out + 1,综合结果是一样的,因为1会被自动扩展到4位。

但如果你写的是cnt_out <= cnt_out + 32'd1,综合工具可能会推断出一个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 == 4'd9,然后让电路跑起来。

抓到的波形应该和仿真波形一致。如果不一样,那说明仿真和实际电路有差异,可能是综合选项的问题,也可能是约束文件的问题。这一步能帮你提前发现很多潜在问题,避免上板之后抓瞎。

7.2 LED显示的驱动方法

如果你想把计数值显示在LED上,需要做一个译码器,把4位二进制码转换成7段数码管的段选信号。这个译码器就是一个组合逻辑,输入是0到9,输出是7位段选信号。真值表如下:

计数值段选信号(abcdefg)
01111110
10110000
21101101
31111001
40110011
51011011
61011111
71110000
81111111
91111011

这个真值表可以直接写成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输出、级联扩展——你会发现里面涉及的知识点比你想象的多得多。把这些都搞明白了,后面做更复杂的项目就有了扎实的基础。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进