5、时序逻辑设计:触发器原理、寄存器实现、计数器设计、分频器设计
好,咱们今天聊聊时序逻辑。说白了,组合逻辑只管“现在”,而时序逻辑管的是“过去”和“未来”。没有时序逻辑,FPGA 就是个一堆门电路拼起来的玩具,啥状态都记不住。
我个人习惯,学时序逻辑先别急着写代码,先把触发器搞明白。触发器是时序逻辑的细胞,寄存器是它的肌肉,计数器和分频器就是它干活的样子。
5.1 触发器原理:D触发器的“锁”与“存”
触发器,英文叫 Flip-Flop,简称 FF。FPGA 里最常用的是 D 触发器。它有三个关键端口:数据输入 D、时钟输入 CLK、数据输出 Q。
它的工作逻辑很简单:在每个时钟的上升沿(或下降沿),把 D 端的数据“拍”到 Q 端去。其他时间,Q 保持不变。
你想想看,这就像一扇门。时钟沿来了,门打开,数据进来;门关上了,数据就锁在里面,外面再变也影响不了它。
核心要点:D 触发器是边沿敏感的存储单元。它解决了组合逻辑中的“竞争冒险”问题,让数据在确定的时间点被采样。
我在项目中遇到过一个问题:一个同事用组合逻辑的反馈做了个“伪锁存器”,结果仿真好好的,上板子就乱跳。后来查了半天,就是没用真正的 D 触发器,导致时序混乱。嗯,这里要注意:FPGA 内部尽量不要用锁存器(Latch),要用 D 触发器。
5.2 寄存器实现:从触发器到数据容器
一个 D 触发器只能存 1 位数据。那要存 8 位、16 位、32 位呢?很简单,把多个 D 触发器并排摆在一起,共享同一个时钟,就成了寄存器。
寄存器在 Verilog 里用 reg 类型声明,但要注意:reg 不一定综合成寄存器,也可能是连线。只有写在 always @(posedge clk) 里的 reg,才会被综合成真正的 D 触发器。
看个最简单的 8 位寄存器代码:
module reg_8bit (
input clk,
input [7:0] d,
output reg [7:0] q
);
always @(posedge clk) begin
q <= d;
end
endmodule
这里我用的是非阻塞赋值 <=。为什么?因为多个触发器同时赋值时,非阻塞赋值能保证“同时采样、同时更新”,不会出现先后顺序的混乱。我刚开始学的时候老用 =,结果仿真波形总是不对,后来才明白这个坑。
我的习惯:写时序逻辑的 always 块,一律用非阻塞赋值 <=。组合逻辑才用阻塞赋值 =。这个规矩别破,破了容易出 bug。
5.3 计数器设计:加法、减法、可逆
计数器,说白了就是寄存器加上加法器。每个时钟沿来,寄存器的值加 1(或减 1)。
最简单的二进制加法计数器:
module counter_up (
input clk,
input rst_n,
output reg [3:0] cnt
);
always @(posedge clk or negedge rst_n) begin
if (!rst_n)
cnt <= 4'd0;
else
cnt <= cnt + 1'b1;
end
endmodule
这个计数器从 0 数到 15,然后自动回 0。为什么?因为 4 位二进制最大是 1111,再加 1 就溢出成 0000 了。
我曾经在一个项目中需要计数到 1000 再清零,结果直接写 cnt <= cnt + 1'b1,然后判断 cnt == 1000 时清零。仿真没问题,但上板后发现偶尔会漏掉 1000 这个值。后来查资料才知道,比较器有组合逻辑延迟,计数器跑得快的时候会错过比较点。解决方案是用“先比较再计数”或者用“同步清零”的方式。
可逆计数器(加减可控)也很常用:
module counter_updown (
input clk,
input rst_n,
input up_down, // 1:加, 0:减
output reg [3:0] cnt
);
always @(posedge clk or negedge rst_n) begin
if (!rst_n)
cnt <= 4'd0;
else if (up_down)
cnt <= cnt + 1'b1;
else
cnt <= cnt - 1'b1;
end
endmodule
注意:减法计数器要小心下溢出。比如 4 位计数器,0 减 1 会变成 15(1111)。如果你需要停在 0,必须加判断条件。
5.4 分频器设计:从计数器到时钟分频
分频器,本质上就是计数器的一个应用。FPGA 开发板上通常只有一个晶振,比如 50MHz。但你的项目可能需要 10MHz、1MHz、甚至 1Hz 的时钟。这时候就要用分频器。
最简单的偶数分频:比如把 50MHz 分成 25MHz(2 分频)。
module div_2 (
input clk_in,
input rst_n,
output reg clk_out
);
always @(posedge clk_in or negedge rst_n) begin
if (!rst_n)
clk_out <= 1'b0;
else
clk_out <= ~clk_out;
end
endmodule
这个代码每两个输入时钟翻转一次输出,就得到了 2 分频。那 4 分频呢?再加一级触发器,或者用计数器。
通用偶数分频器(N 分频,N 为偶数):
module div_even #(
parameter N = 10 // 分频系数,必须为偶数
)(
input clk_in,
input rst_n,
output reg clk_out
);
reg [7:0] cnt;
always @(posedge clk_in or negedge rst_n) begin
if (!rst_n) begin
cnt <= 0;
clk_out <= 0;
end else begin
if (cnt == N/2 - 1) begin
cnt <= 0;
clk_out <= ~clk_out;
end else begin
cnt <= cnt + 1'b1;
end
end
end
endmodule
奇数分频稍微麻烦点。比如 3 分频,需要占空比 50% 的话,得用上升沿和下降沿分别计数,然后组合。我直接给个常用模板:
module div_odd #(
parameter N = 3 // 分频系数,必须为奇数
)(
input clk_in,
input rst_n,
output clk_out
);
reg [7:0] cnt1, cnt2;
reg clk1, clk2;
// 上升沿计数
always @(posedge clk_in or negedge rst_n) begin
if (!rst_n) begin
cnt1 <= 0;
clk1 <= 0;
end else begin
if (cnt1 == N-1)
cnt1 <= 0;
else
cnt1 <= cnt1 + 1'b1;
if (cnt1 < N/2)
clk1 <= 1;
else
clk1 <= 0;
end
end
// 下降沿计数
always @(negedge clk_in or negedge rst_n) begin
if (!rst_n) begin
cnt2 <= 0;
clk2 <= 0;
end else begin
if (cnt2 == N-1)
cnt2 <= 0;
else
cnt2 <= cnt2 + 1'b1;
if (cnt2 < N/2)
clk2 <= 1;
else
clk2 <= 0;
end
end
// 组合输出
assign clk_out = clk1 | clk2;
endmodule
这个思路是:用上升沿产生一个占空比非 50% 的波形,再用下降沿产生另一个,最后“或”起来,就得到了 50% 占空比的奇数分频。
避坑指南:我曾经在项目中直接用 PLL 做奇数分频,结果 PLL 输出抖动太大,导致下游电路误采样。后来改用这种“计数器+边沿组合”的方式,反而更稳定。所以,不是所有分频都要用 PLL,简单的分频用计数器更可靠。
5.5 总结与经验
时序逻辑设计,说白了就是三件事:存数据、数数、变时钟。触发器是基础,寄存器是工具,计数器和分频器是应用。
我个人的经验是:
- 写时序逻辑时,先画波形图。把时钟、数据、输出的时序关系画清楚,再写代码,事半功倍。
- 计数器要注意溢出和清零的时机。同步清零和异步清零差别很大,别搞混。
- 分频器尽量用 PLL 做整数分频,小数分频用 DDS 或专用的 MMCM。计数器分频只适合低频、对抖动不敏感的场景。
嗯,这一章就到这。下一章咱们聊聊状态机设计,那才是时序逻辑的进阶玩法。