4. Verilog基础(下):组合逻辑与时序逻辑设计
好,咱们接着聊。上一章我们把Verilog的基本语法和数据类型过了一遍,这一章要动真格的了——直接上手写电路。我个人觉得,学Verilog最忌讳的就是把它当软件来写。你写的每一行代码,最终都会变成实实在在的硬件。所以,脑子里要时刻有电路图。
这一章我们分三块:组合逻辑、时序逻辑、状态机。这三块是数字设计的基石,你想想看,再复杂的芯片,拆开来看也无非就是这些东西的组合。
4.1 组合逻辑设计
组合逻辑,说白了就是没有记忆的电路。输入一变,输出立马跟着变,中间不带任何延迟(理想情况下)。实际电路中当然有门延迟,但那是物理特性,不是逻辑功能。
4.1.1 加法器
加法器是算术运算的基础。最简单的就是半加器,只加两个bit,不考虑进位。全加器则多了一个进位输入。我刚开始学的时候,总觉得全加器挺绕的,后来画了个真值表,一下就明白了。
全加器真值表
| A | B | Cin | S | Cout |
|---|---|---|---|---|
| 0 | 0 | 0 | 0 | 0 |
| 0 | 0 | 1 | 1 | 0 |
| 0 | 1 | 0 | 1 | 0 |
| 0 | 1 | 1 | 0 | 1 |
| 1 | 0 | 0 | 1 | 0 |
| 1 | 0 | 1 | 0 | 1 |
| 1 | 1 | 0 | 0 | 1 |
| 1 | 1 | 1 | 1 | 1 |
用Verilog实现全加器,最直接的方式就是用连续赋值语句:
// 全加器 - 数据流建模
module full_adder (
input a, b, cin,
output s, cout
);
assign s = a ^ b ^ cin;
assign cout = (a & b) | (a & cin) | (b & cin);
endmodule
当然,你也可以用行为级描述,用always块。但我个人习惯,简单的组合逻辑用assign就够了,代码更简洁,综合出来的结果也差不多。
如果要做一个多位加法器,最简单的办法就是例化多个全加器,或者直接用加号。嗯,这里要注意:直接用加号虽然方便,但综合工具会推断出进位链,位宽越大,延迟越大。我在一个项目中做过128位的加法器,直接用加号,结果时序跑不过。后来改成了进位选择加法器(Carry-Select Adder),才把时序搞定。
4.1.2 多路选择器
多路选择器,简称MUX,就是从多个输入中选一个出来。2选1 MUX是最基本的:
// 2选1多路选择器
module mux2to1 (
input [7:0] a, b,
input sel,
output [7:0] y
);
assign y = sel ? b : a;
endmodule
三目运算符在这里特别好用。如果是4选1,可以用case语句:
// 4选1多路选择器
module mux4to1 (
input [7:0] d0, d1, d2, d3,
input [1:0] sel,
output reg [7:0] y
);
always @(*) begin
case (sel)
2'b00: y = d0;
2'b01: y = d1;
2'b10: y = d2;
2'b11: y = d3;
endcase
end
endmodule
小技巧:写case语句时,一定要把所有的分支都覆盖到。如果有些组合不会出现,用default兜底,赋值成'bx或者'bz。我曾经因为漏了default,综合出来一堆锁存器,查了半天才找到原因。
4.1.3 译码器
译码器就是把一个编码转换成另一个编码。最常用的是3-8译码器:3位输入,8位输出,每次只有一位输出有效。
// 3-8译码器
module decoder3to8 (
input [2:0] in,
output reg [7:0] out
);
always @(*) begin
out = 8'b0;
out[in] = 1'b1;
end
endmodule
你看,这个写法多简洁。直接用in作为索引,把对应位置1。不过要注意,这种写法要求输入不能是'x或'z,否则综合出来会有问题。
4.2 时序逻辑设计
时序逻辑和组合逻辑最大的区别是什么?它有时钟,有记忆。说白了,就是电路的状态会随着时钟边沿发生变化。写时序逻辑,核心就是always块配合posedge clk。
4.2.1 计数器
计数器是时序逻辑里最经典的例子。一个简单的二进制计数器:
// 8位二进制计数器
module counter (
input clk,
input rst_n,
output reg [7:0] cnt
);
always @(posedge clk or negedge rst_n) begin
if (!rst_n)
cnt <= 8'b0;
else
cnt <= cnt + 1'b1;
end
endmodule
这里我用了异步复位,rst_n低电平有效。为什么用低电平?很多FPGA的复位引脚默认就是低电平复位,这是硬件决定的。另外注意,时序逻辑里赋值要用非阻塞赋值(<=),这个和组合逻辑的阻塞赋值(=)要区分开。搞混了的话,仿真结果会非常诡异。
避坑指南:我曾经在一个项目里,把always块里的非阻塞赋值写成了阻塞赋值,结果仿真波形看起来是对的,但上板跑就是不对。后来用Vivado的RTL分析一看,综合出来的电路和我预想的完全不一样。所以,记住:时序逻辑用 <=,组合逻辑用 =。
4.2.2 分频器
分频器本质上就是计数器。比如要把50MHz的时钟分成1Hz,那就数50,000,000个时钟周期,翻转一次输出。但实际项目中,我很少用计数器做分频,因为PLL(锁相环)才是正经的分频方案,精度高、抖动小。
不过,如果是简单的二分频,用寄存器取反就行:
// 二分频器
module div2 (
input clk,
input rst_n,
output reg clk_out
);
always @(posedge clk or negedge rst_n) begin
if (!rst_n)
clk_out <= 1'b0;
else
clk_out <= ~clk_out;
end
endmodule
嗯,这里要提醒一下:分频出来的时钟,最好不要作为其他模块的时钟源。因为它的相位和原始时钟不同,容易引起时序问题。更好的做法是用时钟使能信号。
4.2.3 移位寄存器
移位寄存器在串行通信中特别常用。比如SPI接口,就是通过移位寄存器把并行数据转成串行发出去。
// 8位移位寄存器
module shift_reg (
input clk,
input rst_n,
input din,
output reg [7:0] q
);
always @(posedge clk or negedge rst_n) begin
if (!rst_n)
q <= 8'b0;
else
q <= {q[6:0], din};
end
endmodule
这个写法,每次时钟上升沿,数据向左移一位,新数据从低位进来。如果你想从高位进,那就反过来:q <= {din, q[7:1]}。
4.3 状态机设计
状态机是数字设计的灵魂。你想想看,任何复杂的控制逻辑,都可以用状态机来描述。状态机分两种:Moore型和Mealy型。区别很简单:Moore型的输出只取决于当前状态,Mealy型的输出还取决于输入。
4.3.1 Moore型状态机
Moore型状态机的输出,只和当前状态有关。所以它的输出在时钟边沿之后才会变化,比输入晚一个周期。这种状态机比较稳定,不容易出现毛刺。
// Moore型状态机示例:检测序列"101"
module moore_fsm (
input clk,
input rst_n,
input din,
output reg dout
);
// 状态编码
localparam IDLE = 2'b00,
S1 = 2'b01,
S2 = 2'b10,
S3 = 2'b11;
reg [1:0] state, next_state;
// 状态寄存器
always @(posedge clk or negedge rst_n) begin
if (!rst_n)
state <= IDLE;
else
state <= next_state;
end
// 次态逻辑
always @(*) begin
next_state = state;
case (state)
IDLE: next_state = din ? S1 : IDLE;
S1: next_state = din ? S1 : S2;
S2: next_state = din ? S3 : IDLE;
S3: next_state = din ? S1 : S2;
endcase
end
// 输出逻辑(Moore型:只和当前状态有关)
always @(*) begin
dout = (state == S3) ? 1'b1 : 1'b0;
end
endmodule
你看,我把状态机分成了三部分:状态寄存器、次态逻辑、输出逻辑。这种三段式写法是我最推荐的,代码清晰,综合结果也好。
4.3.2 Mealy型状态机
Mealy型的输出和输入有关,所以它可以在同一个时钟周期内对输入做出反应。说白了,就是响应更快。
// Mealy型状态机示例:检测序列"101"
module mealy_fsm (
input clk,
input rst_n,
input din,
output reg dout
);
localparam IDLE = 2'b00,
S1 = 2'b01,
S2 = 2'b10;
reg [1:0] state, next_state;
// 状态寄存器
always @(posedge clk or negedge rst_n) begin
if (!rst_n)
state <= IDLE;
else
state <= next_state;
end
// 次态逻辑和输出逻辑(Mealy型:输出和输入有关)
always @(*) begin
next_state = state;
dout = 1'b0;
case (state)
IDLE: begin
if (din) next_state = S1;
else next_state = IDLE;
end
S1: begin
if (din) next_state = S1;
else next_state = S2;
end
S2: begin
if (din) begin
next_state = S1;
dout = 1'b1; // 检测到"101"
end else begin
next_state = IDLE;
end
end
endcase
end
endmodule
Moore vs Mealy 对比
| 特性 | Moore型 | Mealy型 |
|---|---|---|
| 输出依赖 | 只依赖当前状态 | 依赖当前状态和输入 |
| 输出时机 | 时钟边沿后变化 | 输入变化时立即变化 |
| 状态数量 | 通常较多 | 通常较少 |
| 响应速度 | 慢一个周期 | 快,组合逻辑直接输出 |
| 毛刺风险 | 低 | 高(输出是组合逻辑) |
我个人在实际项目中,更倾向于用Moore型。为什么?因为它的输出是寄存的,没有毛刺风险。Mealy型虽然响应快,但输出是组合逻辑,容易出毛刺。除非对时序要求特别苛刻,否则我建议你用Moore。
经验之谈:写状态机的时候,状态编码也有讲究。小状态机用二进制编码就行,状态多了(比如几十个),可以考虑用独热码(One-Hot)。独热码虽然浪费寄存器,但译码逻辑简单,跑得快。我在一个通信协议解析的项目里,用了16个状态的独热码状态机,时序轻松跑过200MHz。
好了,这一章的内容就到这里。组合逻辑、时序逻辑、状态机,这三块是FPGA设计的核心。你把这些吃透了,后面学什么接口协议、什么IP核,都会轻松很多。记住,多写代码,多仿真,多上板验证。纸上谈兵是学不会FPGA的。
公众号:蓝海资料掘金营,微信deep3321