4. Verilog基础语法(下):组合逻辑与时序逻辑、状态机设计、仿真与Testbench编写
好,咱们接着往下聊。上一章我们把Verilog的基本语法骨架搭起来了,这一章要聊的,才是真正让FPGA“动起来”的核心——组合逻辑与时序逻辑、状态机设计,以及仿真与Testbench编写。
说实话,很多初学者写Verilog,代码看着没问题,一上板就翻车。为什么?因为脑子里没有“硬件思维”。你写的每一行代码,最终都会变成实实在在的门电路和触发器。所以这一章,我会带着你把这些概念掰开揉碎,再结合我踩过的坑,帮你把基础打扎实。
核心要点:组合逻辑是“纯电路”,输出只取决于当前输入;时序逻辑是“带记忆的电路”,输出还取决于过去的状态。状态机则是把这两者结合起来,实现复杂的控制逻辑。
4.1 组合逻辑 vs 时序逻辑:本质区别
先问个问题:你写了一个 assign c = a & b;,这个c会“记住”a和b之前的值吗?不会。这就是组合逻辑——输入一变,输出立马跟着变,没有延迟(理想情况下)。
那时序逻辑呢?比如 always @(posedge clk) q <= d;,这个q只有在时钟上升沿才会更新,而且会“记住”之前的值。说白了,时序逻辑就是给电路加了个“记忆单元”——触发器。
我在项目中遇到过最典型的翻车案例:有人用组合逻辑做计数器。你想想看,组合逻辑没有时钟控制,计数器的值会瞬间跳变,根本没法用。所以记住:凡是需要“记住”状态的,必须用时序逻辑。
4.1.1 组合逻辑的写法
组合逻辑有两种常用写法:
- assign语句:适合简单的逻辑运算,比如
assign sum = a + b; - always @(*):适合复杂的组合逻辑,比如多路选择器、译码器
// 组合逻辑示例:4选1多路选择器
module mux4to1(
input [1:0] sel,
input [3:0] data_in,
output reg data_out
);
always @(*) begin
case(sel)
2'b00: data_out = data_in[0];
2'b01: data_out = data_in[1];
2'b10: data_out = data_in[2];
2'b11: data_out = data_in[3];
default: data_out = 1'b0;
endcase
end
endmodule
小技巧:写组合逻辑时,always块里一定要把所有输入信号都列在敏感列表里。用 always @(*) 是最省心的,系统会自动推导敏感列表。我以前手写过 always @(a or b or c),后来加了个信号忘了更新列表,仿真半天查不出错……
4.1.2 时序逻辑的写法
时序逻辑的核心是时钟和复位。我习惯用 always @(posedge clk or negedge rst_n) 这种写法,异步复位,同步释放。
// 时序逻辑示例:带同步使能的8位计数器
module counter8(
input clk,
input rst_n,
input en,
output reg [7:0] count
);
always @(posedge clk or negedge rst_n) begin
if(!rst_n)
count <= 8'd0;
else if(en)
count <= count + 1'b1;
end
endmodule
注意这里用的是 非阻塞赋值 <=。为什么?因为时序逻辑里,多个赋值语句是并行执行的。如果用阻塞赋值 =,就会出现“先赋值再使用”的问题,仿真结果和实际电路不一致。我曾经在这个坑里摔过两次,后来养成了习惯:时序逻辑一律用 <=,组合逻辑一律用 =。
4.2 状态机设计:从FSM到实战
状态机,说白了就是“有限个状态 + 状态跳转条件 + 输出逻辑”。FPGA里90%的控制逻辑都可以用状态机实现。我个人习惯把状态机分成三段式来写,清晰又好维护。
4.2.1 三段式状态机
哪三段?
- 第一段:时序逻辑,描述状态跳转(当前状态 → 次态)
- 第二段:组合逻辑,描述状态跳转条件(次态如何产生)
- 第三段:组合逻辑或时序逻辑,描述输出
举个例子:一个简单的交通灯控制器,红灯30秒,绿灯25秒,黄灯5秒。
// 三段式状态机示例:交通灯控制器
module traffic_light(
input clk,
input rst_n,
output reg [2:0] light // bit0:红, bit1:黄, bit2:绿
);
// 状态编码
localparam IDLE = 2'b00;
localparam RED = 2'b01;
localparam GREEN = 2'b10;
localparam YELLOW = 2'b11;
reg [1:0] state, next_state;
reg [4:0] timer; // 计时器,最大30秒
// 第一段:状态跳转
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 = RED;
RED: next_state = (timer == 30) ? GREEN : RED;
GREEN: next_state = (timer == 25) ? YELLOW : GREEN;
YELLOW: next_state = (timer == 5) ? RED : YELLOW;
default: next_state = IDLE;
endcase
end
// 第三段:输出逻辑
always @(posedge clk or negedge rst_n) begin
if(!rst_n) begin
light <= 3'b001; // 默认红灯
timer <= 5'd0;
end else begin
case(state)
RED: begin
light <= 3'b001;
timer <= (timer == 30) ? 5'd0 : timer + 1'b1;
end
GREEN: begin
light <= 3'b100;
timer <= (timer == 25) ? 5'd0 : timer + 1'b1;
end
YELLOW: begin
light <= 3'b010;
timer <= (timer == 5) ? 5'd0 : timer + 1'b1;
end
default: begin
light <= 3'b001;
timer <= 5'd0;
end
endcase
end
end
endmodule
注意:状态编码不要用二进制连续编码(00,01,10,11),容易产生毛刺。我一般用独热码(one-hot)或者格雷码。独热码虽然浪费触发器,但译码逻辑简单,时序更好。格雷码适合跨时钟域传输。
4.3 仿真与Testbench编写
写完了代码,不上仿真直接上板?我劝你别这么干。仿真能帮你发现90%的逻辑错误,而且调试效率比上板高十倍。我见过太多人“仿真一时爽,上板火葬场”,其实就是Testbench没写好。
4.3.1 Testbench的基本结构
一个标准的Testbench包含:
- 模块实例化:把待测模块(DUT)例化进来
- 时钟和复位生成:模拟真实时钟
- 激励输入:给DUT提供测试数据
- 结果检查:自动比对输出是否正确
// Testbench示例:测试上面的计数器
`timescale 1ns / 1ps
module tb_counter8;
reg clk, rst_n, en;
wire [7:0] count;
// 实例化DUT
counter8 uut(
.clk(clk),
.rst_n(rst_n),
.en(en),
.count(count)
);
// 生成时钟:周期10ns
initial begin
clk = 0;
forever #5 clk = ~clk;
end
// 生成激励
initial begin
// 初始化
rst_n = 0;
en = 0;
#20;
// 释放复位
rst_n = 1;
#10;
// 使能计数
en = 1;
#100;
// 暂停计数
en = 0;
#50;
// 再计数
en = 1;
#200;
// 结束仿真
$finish;
end
// 结果监控
initial begin
$monitor("Time=%0t, rst_n=%b, en=%b, count=%d", $time, rst_n, en, count);
end
endmodule
我的习惯:写Testbench时,我会先写一个“冒烟测试”——只测最基本的功能,确保DUT能跑起来。然后再加边界条件测试,比如计数器满值回零、使能信号毛刺等。这样一步步来,定位问题快很多。
4.3.2 仿真波形怎么看?
仿真跑完,打开波形图。我一般先看时钟和复位,确认它们正常。然后看状态机跳转是否符合预期。如果发现某个信号是红色的“X”或者蓝色的“Z”,那大概率是没初始化或者多驱动了。
我曾经遇到一个诡异的bug:仿真波形里数据总线上有“X”,查了两天发现是某个模块的输出端口忘了赋值,综合工具给了个高阻态。所以记住:仿真时看到“X”或“Z”,一定要追根溯源。
4.4 本章知识体系
下面这张图,是我梳理的本章核心逻辑。你可以把它当作一个“思维导图”,方便回顾。
4.5 避坑指南
最后,分享几个我实战中踩过的坑,希望能帮你省点时间:
- 组合逻辑环路:千万别把组合逻辑的输出直接反馈到自己的输入,会形成死循环。比如
assign a = a + 1;,仿真直接卡死。 - 敏感列表不全:写
always @(a or b)时漏了c,仿真结果和综合结果不一致。用always @(*)最保险。 - 复位信号没处理好:异步复位一定要同步释放,否则容易产生亚稳态。我一般用两级触发器做同步。
- Testbench里忘了加
$finish:仿真会一直跑下去,你以为卡死了,其实只是没告诉它停。
嗯,这一章的内容就到这里。组合逻辑和时序逻辑是FPGA设计的基石,状态机是控制逻辑的灵魂,仿真则是验证的保障。把这三点吃透了,后面的图像加速实战才能走得稳。
一句话总结:写Verilog时,时刻问自己——这行代码综合出来是什么电路?是组合逻辑还是触发器?状态机有没有漏掉状态?仿真波形有没有异常?想清楚了再上板。
可可精品资料库