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。

三段式状态机

  1. 第一段:时序逻辑,描述状态跳转(当前状态 -> 次态)
  2. 第二段:组合逻辑,描述次态逻辑(根据当前状态和输入,计算下一个状态)
  3. 第三段:组合逻辑或时序逻辑,描述输出逻辑

来看一个经典的例子——一个简单的交通灯控制器(简化版):

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 不是目的,目的是验证设计正确。我个人的工作流是这样的:

  1. 写 RTL 代码:按三段式状态机、时序逻辑的规范写。
  2. 写 Testbench:先写一个简单的,只测基本功能。
  3. 跑仿真:用 ModelSim 或 VCS 跑,看波形。
  4. 加断言:在 Testbench 里加自动检查,比如“如果 cnt 超过 15 还不归零,就报错”。
  5. 边界测试:比如复位释放瞬间、时钟抖动、输入毛刺等。

你想想看,如果连仿真都过不了,上板调试只会更痛苦。我在一个基站项目里,就因为状态机少写了一个 default 分支,导致在某种异常输入下状态机“飞”了,仿真时没发现,上板后系统随机死机。后来花了三天才定位到问题。

所以,仿真不是浪费时间,是节省时间

4.5 小结

这一章我们聊了:

  • 时序逻辑的核心:always@(posedge clk) + 非阻塞赋值
  • 三段式状态机的写法与套路
  • Testbench 的基本结构与仿真技巧

嗯,内容不少。但这些都是基本功,就像练武要扎马步一样。下一章我们会把这些知识串起来,做一个真正的小项目——一个简单的 UART 接收器。到时候你会发现,状态机 + 计数器 + 仿真,这三板斧能解决大部分问题。

好,今天就到这里。有问题随时交流。