FPGA入门:从原理图设计模60计数器理解数字电路底层原理
1. 项目缘起:为什么从原理图输入开始学FPGA?
如果你刚接触FPGA,打开Quartus II或者Vivado,看到满屏的代码编辑器,可能会有点懵。Verilog HDL虽然强大,但对于理解数字电路最底层的“门”与“线”是如何工作的,它更像是一种高级抽象。很多初学者直接从Verilog写计数器、状态机开始,虽然功能实现了,但心里总感觉不踏实:我写的这几行代码,到底在芯片里变成了什么样子?
这就是我推荐新手,尤其是电子、通信、自动化相关专业的学生,从原理图输入开始第一个FPGA项目的原因。模60计数器,作为数字电路中最经典、最基础的设计之一,是理解时序逻辑的绝佳切入点。通过原理图,你可以直观地看到:
- 时钟信号如何驱动触发器(D Flip-Flop)。
- 数据通路如何通过逻辑门(与、或、非)和选择器(Mux)构建。
- 反馈回路如何形成计数序列。
- 复位信号如何将电路置于已知的初始状态。
这个过程,相当于在亲手搭建一个乐高积木版的数字电路。当你看着自己用一个个逻辑符号“画”出来的电路,经过编译、综合、布局布线,最终在开发板上跑起来,LED按照0-59循环显示时,那种对硬件“掌控感”的理解,是直接写always @(posedge clk)代码难以比拟的。今天,我就带你完整走一遍用Quartus II原理图输入方式,设计一个模60计数器的全过程,并深入聊聊背后的设计考量、常见坑点以及如何向Verilog平滑过渡。
2. 模60计数器的核心需求与设计拆解
模60计数器,顾名思义,就是循环计数从0到59(或从1到60,看设计需求),然后归零重新开始。它本质上是一个有限状态机,有60个离散的状态。在数字电路中,我们通常用二进制数来表示这些状态。
2.1 确定计数器规格与输出编码
首先,我们需要明确设计规格:
- 计数范围:0 ~ 59。这是一个通用约定,符合时钟、计时器的习惯。
- 计数使能:需要一个使能信号(
en),当en为高电平时,计数器才在每个时钟上升沿进行计数操作。 - 同步复位:需要一个全局复位信号(
rst_n,低电平有效),在时钟上升沿到来时,将计数器清零。使用同步复位可以避免复位信号与时钟竞争带来的亚稳态风险,在FPGA设计中更为推荐。 - 计数输出:需要两组输出。一组是二进制码输出,用于内部逻辑或后续处理;另一组是BCD码输出,用于直接驱动数码管显示。因为59的二进制是
111011(6位),但为了驱动两个数码管(十位和个位),我们需要将其转换为两个4位的BCD码(十位:0101即5,个位:1001即9)。
所以,我们的模块接口可以初步定义为:
- 输入:时钟
clk, 同步复位rst_n, 使能en。 - 输出:6位二进制数
cnt_bin[5:0], 十位BCD码bcd_ten[3:0], 个位BCD码bcd_unit[3:0]。
2.2 顶层架构:分解为模6与模10计数器
直接用60个状态设计一个状态机过于复杂。一个经典且高效的方法是级联法:将模60计数器分解为一个模10计数器(负责个位计数)和一个模6计数器(负责十位计数)。
工作原理:
- 个位的模10计数器从0计到9。
- 当它计到9(且下一个时钟使能有效时),它会产生一个进位信号(
carry_unit)。 - 这个进位信号作为十位的模6计数器的使能信号。十位计数器在收到进位信号时加1。
- 当十位计数器计到5,且个位计数器计到9时,下一个时钟周期,两个计数器同时归零,完成一个完整的60计数循环。
这种分解大大简化了设计。我们只需要分别设计模6和模10计数器,然后将它们连接起来。在原理图设计中,我们可以先分别绘制这两个子模块的原理图,然后将它们作为“符号”(Symbol)在顶层原理图中调用。
2.3 二进制与BCD码转换的思考
这里有一个关键点:我们级联的模6和模10计数器,其内部状态(即cnt_bin)本身就是一种混合进制表示(十位是六进制,个位是十进制),但它不等于标准的BCD码。
例如,当计数到59时:
- 级联计数器的二进制输出可能是:十位
101(5), 个位1001(9), 拼接起来是1011001。 - 而59的纯二进制是
111011。 - 我们需要的BCD码输出是:十位
0101(5), 个位1001(9)。
因此,我们需要一个二进制到BCD码的转换逻辑。对于0-59这个有限范围,这个转换逻辑可以通过一个简单的组合逻辑查找表(LUT)来实现,或者更直观地,在原理图中用逻辑门搭建。但更简单的方法是:我们直接设计一个输出就是BCD码的模10计数器和一个输出就是0-5二进制码的模6计数器。这样,bcd_unit和bcd_ten可以直接从子计数器的输出引脚得到,而cnt_bin则需要一个额外的转换电路(bcd_ten * 10 + bcd_unit),这个转换也可以在原理图中用加法器和乘法器(实际上是移位相加)实现,但考虑到这是入门项目,我们可以先聚焦于计数器本身,cnt_bin的输出可以暂时用Verilog写一个简单的转换模块,或者后期再补充。为了简化初始设计,我们优先保证BCD码输出的正确性。
3. 在Quartus II中绘制模10计数器原理图
现在,我们进入实战环节。首先设计基础的模10计数器。我们将使用D触发器(DFF)和基本的逻辑门来构建。
3.1 使用D触发器构建同步计数器
同步计数器的核心思想是:所有触发器的时钟端连接在一起,由同一个时钟信号clk驱动。每个触发器下一时刻的状态(D端输入),由当前所有触发器的状态(Q端输出)通过一个组合逻辑电路(称为“次态逻辑”)决定。
对于一个模10计数器(计数0-9),我们需要4个D触发器(因为2^3=8 < 10, 2^4=16 > 10)。我们将其输出命名为Q3, Q2, Q1, Q0(Q3是最高位)。
步骤:
- 新建原理图文件:在Quartus II中,选择
File -> New -> Block Diagram/Schematic File。 - 放置元件:
- 在空白处双击,输入
dff,选择D Flip-Flop。放置4个,分别命名为U0,U1,U2,U3。将它们的clk引脚连接在一起,接到输入端口clk。将它们的clrn(异步清零,低有效)或sclr(同步清零)引脚连接在一起,接到输入端口rst_n。这里为了教学清晰,我们使用sclr(同步清零)引脚,并将其连接到经过一个非门(not)的rst_n信号(因为sclr通常是高有效)。 - 放置输入端口:
input。放置三个,分别命名为clk,rst_n,en。 - 放置输出端口:
output。放置四个,分别命名为Q0,Q1,Q2,Q3。这将是我们的BCD个位输出bcd_unit[3:0]。 - 放置逻辑门:
and2(2输入与门)、and3、and4、or2、or3、not、xor(异或门)等。我们将用它们来搭建次态逻辑。 - 放置一个
nand2(2输入与非门),用于产生进位信号carry_unit。当计数器状态为9(1001)时,carry_unit变为低电平(有效)。
- 在空白处双击,输入
- 推导次态逻辑(核心):这是最考验数字电路功底的一步。我们需要列出计数器的状态转换表。
| 当前状态 (Q3 Q2 Q1 Q0) | 代表十进制 | 下一状态 (D3 D2 D1 D0) | 进位carry_unit(状态9时有效) |
|---|---|---|---|
| 0000 | 0 | 0001 | 1 |
| 0001 | 1 | 0010 | 1 |
| ... | ... | ... | ... |
| 1000 | 8 | 1001 | 1 |
| 1001 | 9 | 0000 | 0 |
我们需要写出D0, D1, D2, D3的卡诺图,并化简得到最简逻辑表达式。这个过程可以手动完成,也可以借助软件,但为了理解,我们写出化简后的结果(假设使用JK触发器特性化简,对于D触发器,D=JQ‘ + K’Q,但最终我们直接得到D的表达式):
D0 = Q0’(每来一个时钟就翻转)D1 = Q1 ^ Q0(当Q0为1时,Q1翻转)D2 = Q2 ^ (Q1 & Q0)(当Q1和Q0都为1时,Q2翻转)D3 = Q3 ^ (Q2 & Q1 & Q0)(当Q2, Q1, Q0都为1时,Q3翻转)
但这只是二进制计数器的规律。我们是模10计数器,需要在计到9(1001)后归零,所以需要修改D3和D2的逻辑,在状态9时强制让它们变为0。同时,还需要考虑使能en:只有当en=1时,才进行上述计数,否则保持。因此,完整的次态逻辑为:
D0 = (en) ^ Q0? 更准确地说,D0 = en & (Q0’) + !en & Q0? 这样写太复杂。更好的方法是将en作为所有次态逻辑与门的一个输入。即,D0 = en & (Q0’)是不对的,因为当en=0时,D0应该等于Q0(保持),而不是0。所以我们需要一个选择逻辑:D0 = (en & (Q0’)) | (!en & Q0)。在原理图中,这可以用一个2选1数据选择器(MUX)来实现。
考虑到原理图绘制的复杂性,对于初学者,一个更可行且清晰的方法是:
- 先搭建一个带使能的4位二进制加法计数器。这可以通过调用Quartus II内置的宏功能模块
lpm_counter来实现,但这就失去了手绘的意义。 - 我们退一步,为了专注于“原理图输入”的体验,我们可以先设计一个不带使能
en的模10计数器。使能功能可以在顶层通过控制时钟或与门来实现。这样,次态逻辑可以简化为只处理0-9的循环。
让我们调整目标:先绘制一个同步清零的模10计数器。当检测到状态9(Q3 & !Q2 & !Q1 & Q0)时,在下一个时钟周期清零。这可以通过将清零逻辑接入触发器的sclr(同步清零)端来实现,但这样会占用控制端。更通用的方法仍然是修改次态逻辑。
实际上,绘制完整的4位模10计数器次态逻辑原理图非常庞大。作为教学示例,我建议使用原理图绘制“行为级”描述:即调用一个可预置数的同步计数器宏模块(如lpm_counter),将其设置为模10,然后将其输入输出端口引出。这样既能快速完成功能,又能理解原理图连接。但为了极致地“手搓”,我们将绘制一个简化的2位模3计数器来演示思路,因为其逻辑简单。模10和模6的绘制方法完全类似,只是门电路更多。
3.2 绘制简化示例:模3计数器原理图
假设我们要画一个计数0->1->2->0…的模3计数器。需要2个D触发器(因为2^2=4>3)。 状态转换:00 -> 01 -> 10 -> 00。 列出状态表求次态:
- 状态00 (Q1=0, Q0=0): 次态01 (D1=0, D0=1)
- 状态01 (Q1=0, Q0=1): 次态10 (D1=1, D0=0)
- 状态10 (Q1=1, Q0=0): 次态00 (D1=0, D0=0)
通过卡诺图化简得到:
D0 = !Q1 & !Q0(在状态00时,D0为1)D1 = Q0(在状态01时,D1为1)
在Quartus II中绘制:
- 放置两个
dff,U0和U1。连接clk和sclr。 - 对于
U0(输出Q0):其D端输入D0的逻辑是!Q1 & !Q0。所以需要放置一个not门接Q1,另一个not门接Q0,然后用一个and2门连接这两个非门的输出,再将and2的输出连接到U0的D引脚。 - 对于
U1(输出Q1):其D端输入D1的逻辑就是Q0。直接将U0的Q引脚连接到U1的D引脚。 - 添加输入
clk,rst_n。rst_n连接非门后接到两个DFF的sclr。 - 添加输出
Q0,Q1。
这样,一个模3计数器的核心原理图就画好了。编译后可以通过仿真验证其功能。模10和模6计数器就是这种模式的扩展,需要更多的触发器和更复杂的组合逻辑网络。
3.3 生成符号(Symbol)
绘制好模10计数器(假设我们将其简化设计或使用宏功能完成了)后,将其保存为.bdf文件,例如mod10_counter.bdf。然后,在Quartus II菜单栏选择File -> Create/Update -> Create Symbol Files for Current File。这将生成一个mod10_counter.bsf文件。这个符号就可以像其他门电路一样,被拖放到顶层原理图中使用了。
按照完全相同的步骤,我们可以绘制一个模6计数器(计数0-5,需要3个D触发器,因为2^2=4<6, 2^3=8>6),并生成mod6_counter.bsf符号。
4. 顶层原理图集成与关键信号处理
有了两个子计数器符号后,我们开始搭建顶层的模60计数器。
4.1 连接级联与使能逻辑
- 新建顶层原理图文件:
top_mod60_counter.bdf。 - 放置子模块符号:在空白处双击,输入
mod10和mod6,找到刚才生成的符号,放置到图中。分别命名为U_mod10_unit和U_mod6_ten。 - 放置输入/输出端口:
- 输入:
clk,rst_n,en。 - 输出:
bcd_unit[3..0],bcd_ten[2..0],carry_out。注意总线表示法,[3..0]代表4根线。
- 输入:
- 连接电路:
- 将全局
clk连接到两个子计数器的clk输入端。 - 将全局
rst_n连接到两个子计数器的rst_n输入端。 - 关键连接:使能与进位。个位模10计数器的使能端(
en)直接连接全局使能en。个位模10计数器需要有一个进位输出端口(我们在画图时就应预留),当计数到9时,carry_unit输出一个时钟周期的高电平脉冲。将这个carry_unit信号与全局使能en相与(使用一个and2门),结果作为十位模6计数器的使能信号。即:en_ten = en & carry_unit。这样,只有当全局使能有效且个位计满时,十位才加1。 - 连接输出:将
U_mod10_unit的4位输出总线连接到bcd_unit[3..0]输出端口。将U_mod6_ten的3位输出总线连接到bcd_ten[2..0]输出端口。bcd_ten是0-5的二进制,直接对应十位的0-5。 - 生成整体进位
carry_out:当十位计到5(bcd_ten == 3‘b101)且个位计到9(bcd_unit == 4‘b1001)且使能有效时,下一个时钟周期计数器应归零,此时可以产生一个进位信号。这需要一个4输入与门(判断个位)和一个3输入与门(判断十位),再将这两个与门的输出相与,最后再与全局en相与。这个carry_out信号可以用来驱动更高位的计数器(例如在时钟设计中驱动小时计数器)。
- 将全局
4.2 二进制输出转换逻辑的实现
如前所述,cnt_bin[5:0]需要从BCD码转换。cnt_bin = bcd_ten * 10 + bcd_unit。在原理图中实现乘法器比较繁琐。我们可以用一个技巧:bcd_ten * 10 = (bcd_ten << 3) + (bcd_ten << 1)。即左移3位相当于乘8,左移1位相当于乘2,加起来就是乘10。
- 位扩展与移位:
bcd_ten是3位,bcd_unit是4位。我们需要一个6位的加法器。- 将
bcd_ten左移3位:这意味着在低位补3个0,形成一个6位数{bcd_ten, 3‘b000}。 - 将
bcd_ten左移1位:形成一个6位数{2‘b00, bcd_ten, 1‘b0}。
- 将
- 放置加法器:在Quartus II元件库中查找
lpm_add_sub(参数化加减法器)。实例化一个,设置数据位宽为6位,功能为加法。 - 连接:将上述两个移位后的6位数据连接到加法器的两个输入口。
- 最终加法:将加法器的输出与
bcd_unit(需要零扩展为6位:{2‘b00, bcd_unit})通过另一个6位加法器相加,结果就是cnt_bin[5:0]。
这个过程在原理图中会显得连线很多。对于初学者项目,如果cnt_bin不是必须的,可以暂不实现,优先保证BCD显示的正确性。或者,我们可以用嵌入式逻辑块:写一个很小的Verilog文件来实现这个转换,然后将这个Verilog模块也生成一个符号,插入到原理图中。Quartus II支持原理图和HDL的混合设计,这非常有用。
4.3 设计实例:在原理图中调用Verilog模块
- 新建一个Verilog HDL文件,命名为
bcd_to_bin.v。module bcd_to_bin ( input [2:0] bcd_ten, // 十位 (0-5) input [3:0] bcd_unit, // 个位 (0-9) output reg [5:0] bin_out // 二进制输出 (0-59) ); always @(*) begin bin_out = bcd_ten * 6‘d10 + bcd_unit; // 直接使用乘法运算符,综合器会将其优化为电路 end endmodule - 对这个Verilog文件进行分析与综合(Analysis & Synthesis)。
- 在顶层原理图文件中,双击空白处,输入
bcd_to_bin,就可以找到并放置这个模块的符号了。 - 将
bcd_ten和bcd_unit总线连接到该符号的输入,将其输出连接到cnt_bin输出端口。
这种方法混合了原理图的直观性和HDL的灵活性,是工程中常见的做法。
5. 编译、仿真与硬件测试
设计绘制完成后,必须经过验证才能保证正确性。
5.1 引脚分配与编译
- 创建测试向量或设置时钟:我们需要为设计指定一个时钟引脚。在
Assignment -> Pin Planner中,将clk分配到开发板上连接晶振的专用时钟引脚(如PIN_23),将rst_n和en分配到按键或拨码开关对应的引脚,将bcd_unit和bcd_ten分配到数码管段选或LED对应的引脚。 - 编译:点击
Start Compilation。Quartus II会进行一系列操作:分析与综合、布局布线、时序分析、生成编程文件。在这个过程中,软件会将你的原理图翻译成门级网表,并映射到FPGA内部的逻辑单元(LE)和触发器上。 - 查看RTL视图:编译成功后,点击
Tools -> Netlist Viewers -> RTL Viewer。这是极其重要的一步!在这里,你可以看到Quartus II综合后生成的电路结构。对比一下这个自动生成的电路和你手绘的原理图,思考它们是否在功能上等价。这能极大地加深你对“综合”的理解。
5.2 功能仿真(ModelSim或Quartus自带仿真器)
仿真是在将设计下载到芯片前,用软件模拟其行为,是排查逻辑错误的最有效手段。
- 创建波形文件:
File -> New -> University Program VWF(或使用ModelSim创建.do脚本和.vwf文件)。 - 添加信号:将
clk,rst_n,en,bcd_unit,bcd_ten,cnt_bin等信号添加到波形窗口。 - 设置激励:
- 设置
clk为周期性的方波(如周期20ns)。 - 初始时刻,设置
rst_n=0保持一段时间(如100ns),然后拉高。 - 设置
en=1,使能计数。
- 设置
- 运行仿真:运行足够长的时间(如2us),观察波形。
- 检查复位:在
rst_n为低期间,所有输出是否都为0? - 检查计数序列:
bcd_unit是否从0递增到9,然后归零?同时bcd_ten是否在bcd_unit从9归0时加1? - 检查模60循环:当
bcd_ten为5且bcd_unit为9时,下一个时钟沿,两者是否同时归零? - 检查使能:将
en在某段时间拉低,观察计数器是否暂停。
- 检查复位:在
- 分析结果:如果波形与预期不符,需要返回原理图检查逻辑连接,特别是进位和清零逻辑。
5.3 常见问题与调试心得
问题1:计数器不计数,一直为0。
- 排查:首先检查时钟信号是否正常。在仿真波形里看
clk有没有跳变。其次检查复位信号rst_n是否一直为有效电平(低电平)。最后检查使能信号en是否被拉高。 - 心得:同步复位信号
sclr需要在时钟上升沿采样时才起作用。确保你的复位信号在仿真开始后有足够的稳定时间(在第一个时钟上升沿之前就变为无效状态)。
- 排查:首先检查时钟信号是否正常。在仿真波形里看
问题2:个位能正常计0-9,但十位不计数。
- 排查:十位的使能信号
en_ten是否正确产生?检查个位计数到9时,carry_unit信号是否出现了一个脉冲?检查连接carry_unit和全局en的与门是否正常工作。 - 心得:在原理图中,经常因为连线错误或总线连接不当导致信号不通。使用
Netlist Viewer查看综合后的网表,确认信号连接关系。对于总线,确保位宽匹配,例如[3..0]的总线不能直接连到[2..0]的端口上。
- 排查:十位的使能信号
问题3:计数到59后,下一个状态不是00,而是其他值(如10, 20等)。
- 排查:这是清零逻辑错误。模10和模6计数器在达到最大值时,必须能正确归零。检查你的模6计数器是否真的只有6个状态(0-5)。检查在状态5时,其次态逻辑是否被正确设置为0。同时检查级联的清零信号是否在59状态时正确传递。
- 心得:对于模M计数器,确保你用最少的触发器(N个,满足2^(N-1) < M <= 2^N)实现了所有状态,并且没有无效状态(即那些不会进入的状态)。如果存在无效状态,需要考虑电路上电后能否自动回到有效循环,否则可能“跑飞”。一个稳健的做法是,在组合逻辑中明确指定所有无效状态的次态为0(同步复位状态)。
问题4:时序警告(Timing Violation)。
- 排查:编译后查看
TimeQuest Timing Analyzer报告。关注“最差建立时间余量(Worst-case setup slack)”和“最差保持时间余量(Worst-case hold slack)”是否为负。 - 心得:对于这个低速计数器项目,在几十MHz的时钟下通常不会有时序问题。如果出现,可能是由于组合逻辑路径过长(例如BCD转二进制的那一堆加法器)。可以尝试流水线或寄存器打拍:将复杂的组合逻辑拆分成多个时钟周期完成。例如,不在同一个时钟周期内完成
*10和+的操作,而是先计算bcd_ten*8和bcd_ten*2并寄存,下一个周期再相加。对于原理图设计,这意味需要插入更多的D触发器来分割路径。
- 排查:编译后查看
6. 从原理图到Verilog:思维模式的转变
通过这个项目,你应该对计数器的硬件构成有了血肉般的认识。接下来,我们就可以平滑地过渡到Verilog了。你会发现,之前画的每一个部分,都对应着Verilog中的一段描述。
- D触发器->
always @(posedge clk)过程块。 - 组合逻辑网络->
assign连续赋值语句,或always @(*)组合过程块。 - 模块/符号->
module定义和实例化。 - 连线-> wire 类型变量。
用Verilog重写上面的模60计数器,代码会简洁得多:
module mod60_counter ( input wire clk, input wire rst_n, input wire en, output reg [3:0] bcd_unit, output reg [2:0] bcd_ten, output wire [5:0] cnt_bin ); // 二进制转换组合逻辑 assign cnt_bin = bcd_ten * 6‘d10 + bcd_unit; // 个位模10计数器 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin bcd_unit <= 4‘d0; end else if (en) begin if (bcd_unit == 4‘d9) begin bcd_unit <= 4‘d0; end else begin bcd_unit <= bcd_unit + 4‘d1; end end end // 十位模6计数器,受个位进位控制 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin bcd_ten <= 3‘d0; end else if (en && (bcd_unit == 4‘d9)) begin // 个位进位条件 if (bcd_ten == 3‘d5) begin bcd_ten <= 3‘d0; end else begin bcd_ten <= bcd_ten + 3‘d1; end end end endmodule看,是不是清晰多了?原理图训练让你理解了这短短十几行代码背后的硬件结构。你知道bcd_unit <= bcd_unit + 1背后是一个加法器和一组D触发器;你知道if (bcd_unit == 4‘d9)背后是一个4输入的比较器电路。
最后,我个人体会是,原理图输入作为FPGA学习的起点,价值不在于用它去做大型项目(那会非常低效),而在于它像一副“X光片”,让你能透视HDL代码所描述的硬件世界。当你以后再写Verilog时,脑海中能自动浮现出对应的门、触发器和连线图,你会对如何写出可综合、高性能、面积优的代码有更直觉的把握。从这个模60计数器开始,试着去画更复杂的电路,比如状态机、FIFO控制器,然后再用Verilog实现,对比两者,你的硬件设计功力会扎实地迈上一个台阶。
