3. Verilog基础语法(下):组合逻辑与时序逻辑、阻塞赋值与非阻塞赋值、状态机基础、Testbench编写
好,咱们接着聊。上一章我们把Verilog的基本语法框架搭起来了,这一章要深入几个核心概念。说实话,这几个概念是新手最容易翻车的地方。我在带项目的时候,见过太多因为赋值方式用错、状态机写崩导致的bug。咱们一个一个来拆解。
3.1 组合逻辑 vs 时序逻辑
先问个问题:你写的代码,最终在FPGA里长什么样?
其实就两种东西:组合逻辑和时序逻辑。
组合逻辑,说白了就是纯门电路。输入一变,输出立刻跟着变,没有记忆能力。比如一个与门、一个加法器,都是组合逻辑。
时序逻辑,则是有存储元件(触发器)的电路。它依赖时钟沿来更新状态。换句话说,输出不仅取决于当前输入,还取决于之前的状态。
我刚开始学的时候,总觉得这两者界限模糊。后来想明白一个道理:组合逻辑是“线”,时序逻辑是“沿”。你写always块时,敏感列表里是电平信号(如 @(*)),那就是组合逻辑;敏感列表里是时钟沿(如 @(posedge clk)),那就是时序逻辑。
核心区别速记:
- 组合逻辑:无时钟、无记忆、输出即时响应
- 时序逻辑:有时钟、有记忆、输出在时钟沿更新
嗯,这里要注意:千万不要把组合逻辑和时序逻辑混写在同一个always块里。我见过有人为了省事,一个always块里既赋值又触发时钟,结果综合出来的电路完全不是想要的样子。分开写,清爽又安全。
3.2 阻塞赋值与非阻塞赋值
这个知识点,可以说是Verilog的“鬼门关”。很多面试题就爱考这个。
阻塞赋值(=):执行完当前赋值语句,再执行下一条。它是“串行”的,适合描述组合逻辑。
非阻塞赋值(<=):所有赋值语句同时计算,在块结束时统一更新。它是“并行”的,适合描述时序逻辑。
我曾经在一个项目里,把时序逻辑的always块里用了阻塞赋值。结果仿真波形看起来没问题,但上板跑起来就是不对。查了两天才发现,是因为阻塞赋值导致多个触发器之间产生了意外的依赖关系。从那以后,我给自己定了个规矩:
铁律:
- 组合逻辑 always 块:用 =(阻塞赋值)
- 时序逻辑 always 块:用 <=(非阻塞赋值)
你想想看,为什么会有这个区别?
因为时序逻辑里,多个触发器是在同一个时钟沿同时采样的。如果用阻塞赋值,就会变成“先更新A,再用A的新值去更新B”,这完全违背了硬件并行工作的本质。而非阻塞赋值,则能保证所有赋值在时钟沿到来时同时生效。
看个例子就明白了:
// 正确的时序逻辑写法
always @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
reg_a <= 1'b0;
reg_b <= 1'b0;
end else begin
reg_a <= data_in;
reg_b <= reg_a; // 这里用的是reg_a的旧值
end
end
如果是阻塞赋值,reg_b会拿到reg_a的新值,相当于两个寄存器变成了一个移位寄存器?不对,实际上会变成更奇怪的行为。总之,记住铁律,别乱来。
3.3 状态机基础
状态机是FPGA设计的灵魂。说白了,它就是一个会“记住自己走到哪一步”的电路。
常见的状态机分两种:
- Moore型:输出只取决于当前状态
- Mealy型:输出取决于当前状态和输入
我个人习惯用三段式状态机。为什么?因为清晰、好维护、不容易出bug。
三段式结构如下:
- 第一段:时序逻辑,描述状态跳转(当前状态 → 次态)
- 第二段:组合逻辑,描述次态如何产生(根据输入和当前状态)
- 第三段:时序逻辑或组合逻辑,描述输出
我曾经接手过一个项目,里面全是用两段式状态机写的。代码读起来像天书,状态和输出混在一起,改一个地方要牵动全身。后来我全部重写成三段式,调试效率直接翻倍。
看个简单的例子:一个检测“101”序列的状态机。
// 三段式状态机示例
module seq_detector (
input clk,
input rst_n,
input din,
output reg dout
);
// 状态编码
localparam IDLE = 2'b00,
S1 = 2'b01,
S10 = 2'b10,
S101 = 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
case (state)
IDLE: next_state = din ? S1 : IDLE;
S1: next_state = din ? S1 : S10;
S10: next_state = din ? S101 : IDLE;
S101: next_state = din ? S1 : S10;
default: next_state = IDLE;
endcase
end
// 第三段:输出逻辑(时序逻辑,避免毛刺)
always @(posedge clk or negedge rst_n) begin
if (!rst_n)
dout <= 1'b0;
else if (state == S101)
dout <= 1'b1;
else
dout <= 1'b0;
end
endmodule
小技巧:状态编码尽量用localparam定义,别用数字硬编码。这样改状态编码时,只需要改一处,不会漏改。
嗯,这里还要提一嘴:状态机一定要有default分支。为什么?因为综合工具会认为你没有覆盖所有情况,从而生成不必要的锁存器。我曾经吃过这个亏,综合出来的电路面积比预期大了30%。
3.4 Testbench编写
写Testbench,说白了就是给你的设计“搭个戏台子”。你设计了一个模块,总得验证它能不能正常工作吧?
Testbench的核心要素:
- 时钟生成:用
always块产生时钟 - 复位逻辑:模拟上电复位
- 激励输入:给被测模块喂数据
- 结果检查:看输出是否符合预期
我写Testbench的习惯是:先写一个简单的“冒烟测试”,确保模块能跑起来。然后再加各种边界条件、异常情况。别一上来就搞复杂的测试序列,否则出了问题你都不知道是模块的bug还是Testbench的bug。
看个简单的Testbench模板:
`timescale 1ns / 1ps
module tb_seq_detector;
reg clk;
reg rst_n;
reg din;
wire dout;
// 实例化被测模块
seq_detector uut (
.clk(clk),
.rst_n(rst_n),
.din(din),
.dout(dout)
);
// 时钟生成:周期10ns
always #5 clk = ~clk;
// 测试序列
initial begin
// 初始化
clk = 0;
rst_n = 0;
din = 0;
// 释放复位
#20 rst_n = 1;
// 输入序列:1 0 1 0 1 1
#10 din = 1;
#10 din = 0;
#10 din = 1; // 这里应该检测到一次
#10 din = 0;
#10 din = 1; // 这里又检测到一次
#10 din = 1;
#10 din = 0;
#50 $finish;
end
// 波形输出
initial begin
$dumpfile("wave.vcd");
$dumpvars(0, tb_seq_detector);
end
endmodule
避坑指南:
- Testbench里不要用
#0延时,容易产生竞争 - 时钟和复位要分开生成,别混在一起
- 用
$display或$monitor打印关键信号,方便调试
我曾经在调试一个高速接口时,Testbench里忘了加复位释放的延时,结果模块一直处于复位状态,仿真波形全是0。查了半天才发现是Testbench的问题,不是设计的问题。所以,写Testbench要像写设计代码一样认真。
3.5 本章知识体系
下面这张图,把本章的核心知识点串起来了。你可以把它当作一个“思维导图”来看。
这张图把四个核心知识点串成了一个整体。你可以看到,组合逻辑和时序逻辑是基础,阻塞/非阻塞赋值是具体实现手段,状态机是高级应用,Testbench是验证工具。它们环环相扣,缺一不可。
好了,这一章的内容就到这里。记住:理论要懂,但更重要的是动手写代码、跑仿真。下一章我们会开始真正的图像滤波实战,到时候这些基础语法都会派上用场。