4. Verilog基础(二):时序逻辑、状态机与仿真
好,咱们接着聊。上一章我们把组合逻辑捋了一遍,说白了就是“输入一变,输出立马变”。但数字电路里光有组合逻辑可不行,你得有“记忆”。
时序逻辑就是干这个的。它能在时钟的节拍下,记住过去的状态。我刚开始学的时候,总觉得时序逻辑比组合逻辑难理解,因为它是“动态”的。但一旦你抓住了always@(posedge clk)这个核心,后面就顺了。
4.1 时序逻辑的核心:always@(posedge clk)
在Verilog里,时序逻辑最标准的写法就是:
always @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
// 复位操作
q <= 1'b0;
end else begin
// 时钟上升沿触发的操作
q <= d;
end
end
这里有几个关键点,我一个个说。
敏感列表:posedge clk 表示“时钟上升沿触发”。negedge rst_n 是异步复位,低电平有效。我个人习惯,只要不是特别简单的设计,都会加上异步复位。为什么?因为上电瞬间寄存器的状态是不确定的,没有复位,仿真时会出现大量的 X(未知态),调试起来很头疼。
非阻塞赋值 <=:这是时序逻辑的标配。记住一个原则:时序逻辑用 <=,组合逻辑用 =。我曾经在项目里因为混用这两种赋值,导致仿真和实际行为不一致,查了两天才找到原因。非阻塞赋值的意思是:所有赋值操作在时钟沿到来时“同时”计算右值,然后在时钟沿之后“同时”更新左值。这模拟了真实触发器的行为。
核心原则:
- always@(posedge clk) 里,用 <= 赋值
- always@(*) 组合逻辑里,用 = 赋值
- 一个 always 块里,不要混用两种赋值
举个例子,一个简单的计数器:
module counter (
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
这个代码,每个时钟上升沿,cnt 就加 1。复位时清零。很简单,但很实用。
4.2 状态机基础:FSM 的设计套路
状态机,说白了就是“根据当前状态和输入,决定下一个状态和输出”。在基站加速器里,状态机无处不在——协议解析、数据调度、接口控制,全是状态机。
我一般把状态机分成三段式来写。为什么?因为清晰、好维护、不容易出 bug。
三段式状态机:
- 第一段:时序逻辑,描述状态跳转(当前状态 -> 次态)
- 第二段:组合逻辑,描述次态逻辑(根据当前状态和输入,计算下一个状态)
- 第三段:组合逻辑或时序逻辑,描述输出逻辑
来看一个经典的例子——一个简单的交通灯控制器(简化版):
module traffic_light (
input clk,
input rst_n,
input car_sensor, // 车辆传感器
output reg red,
output reg green
);
// 状态编码
localparam IDLE = 2'b00;
localparam RED_ON = 2'b01;
localparam GREEN_ON = 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
// 第二段:次态逻辑
always @(*) begin
case (state)
IDLE: begin
if (car_sensor)
next_state = RED_ON;
else
next_state = IDLE;
end
RED_ON: begin
// 红灯亮一段时间后自动变绿
next_state = GREEN_ON;
end
GREEN_ON: begin
// 绿灯亮一段时间后回到空闲
next_state = IDLE;
end
default: next_state = IDLE;
endcase
end
// 第三段:输出逻辑
always @(*) begin
red = 1'b0;
green = 1'b0;
case (state)
IDLE: {red, green} = 2'b00;
RED_ON: {red, green} = 2'b10;
GREEN_ON:{red, green} = 2'b01;
default: {red, green} = 2'b00;
endcase
end
endmodule
我的经验:
- 状态编码用 localparam,别用 `define。localparam 只在当前模块有效,不会污染全局命名空间。
- case 语句一定要写 default,否则综合时会生成不必要的锁存器。
- 第三段输出逻辑,我习惯用组合逻辑,这样输出对状态变化响应更快。但如果需要输出寄存(比如去抖),可以用时序逻辑。
4.3 仿真与 Testbench 编写
写完了代码,怎么知道对不对?仿真。说白了,就是给设计“喂”激励,看它输出对不对。
Testbench 是仿真环境,它本身不需要综合,所以写起来可以更“放肆”一些——可以用 for 循环、可以用 initial、可以用 $display。但注意,这些都不能综合到硬件里。
一个标准的 Testbench 结构:
`timescale 1ns / 1ps
module tb_counter;
// 信号声明
reg clk;
reg rst_n;
wire [3:0] cnt;
// 实例化待测模块
counter u_counter (
.clk (clk),
.rst_n (rst_n),
.cnt (cnt)
);
// 时钟生成
initial begin
clk = 0;
forever #5 clk = ~clk; // 10ns 周期,100MHz
end
// 激励生成
initial begin
// 初始化
rst_n = 0;
#20;
rst_n = 1;
// 等待一段时间
#200;
// 观察 cnt 是否正常计数
// 可以用 $monitor 或波形查看
#500;
$finish;
end
// 可选:自动检查
initial begin
#100;
if (cnt !== 4'd10) begin
$display("ERROR: cnt should be 10 at time 100ns");
$finish;
end
end
endmodule
这里有几个要点:
- `timescale:定义时间单位和精度。1ns/1ps 表示时间单位是 1ns,精度是 1ps。
- initial 块:只执行一次,用于生成激励和初始化。
- forever:无限循环,常用于生成时钟。
- $display:打印信息,用于调试。
- $finish:结束仿真。
避坑指南:
- 我曾经在仿真时忘记加复位,结果所有寄存器都是 X,波形一片红。后来我养成了习惯:每个 Testbench 里先拉低复位,再释放。
- 仿真时间不要设得太短。比如计数器要计到 255,你只跑了 100 个时钟周期,那肯定看不到溢出。我一般会跑足够多的周期,确保所有状态都覆盖到。
- 用 $monitor 可以实时监控信号变化,但别在大型仿真里用太多,否则打印信息会刷屏。
4.4 实战建议:从仿真到调试
写 Testbench 不是目的,目的是验证设计正确。我个人的工作流是这样的:
- 写 RTL 代码:按三段式状态机、时序逻辑的规范写。
- 写 Testbench:先写一个简单的,只测基本功能。
- 跑仿真:用 ModelSim 或 VCS 跑,看波形。
- 加断言:在 Testbench 里加自动检查,比如“如果 cnt 超过 15 还不归零,就报错”。
- 边界测试:比如复位释放瞬间、时钟抖动、输入毛刺等。
你想想看,如果连仿真都过不了,上板调试只会更痛苦。我在一个基站项目里,就因为状态机少写了一个 default 分支,导致在某种异常输入下状态机“飞”了,仿真时没发现,上板后系统随机死机。后来花了三天才定位到问题。
所以,仿真不是浪费时间,是节省时间。
4.5 小结
这一章我们聊了:
- 时序逻辑的核心:always@(posedge clk) + 非阻塞赋值
- 三段式状态机的写法与套路
- Testbench 的基本结构与仿真技巧
嗯,内容不少。但这些都是基本功,就像练武要扎马步一样。下一章我们会把这些知识串起来,做一个真正的小项目——一个简单的 UART 接收器。到时候你会发现,状态机 + 计数器 + 仿真,这三板斧能解决大部分问题。
好,今天就到这里。有问题随时交流。