第四章:Verilog基础(下)——时序逻辑、状态机与Testbench

好,我们接着往下聊。上一章我们把组合逻辑讲透了,这一章轮到时序逻辑了。说白了,时序逻辑就是「带记忆」的电路。你想想看,如果所有逻辑都是组合的,那系统怎么记住当前的状态?怎么知道下一步该做什么?所以,时钟信号和触发器就成了数字系统的「心跳」和「记忆细胞」。

4.1 时序逻辑基础:always @(posedge clk)

在Verilog里,描述时序逻辑最核心的语法就是 always @(posedge clk)。这个块里的代码,只在时钟上升沿触发执行。我刚开始学的时候,总把它和组合逻辑的 always @(*) 搞混,后来踩过坑才真正理解——时序逻辑里赋值要用非阻塞赋值 <=,这是铁律。

核心原则:

  • 组合逻辑:always @(*) + 阻塞赋值 =
  • 时序逻辑:always @(posedge clk) + 非阻塞赋值 <=

为什么会这样?因为非阻塞赋值能模拟触发器的行为——所有赋值在时钟沿同时采样、同时更新。如果用阻塞赋值,仿真结果可能对,但综合出来的电路会多出很多不必要的组合逻辑链,甚至产生竞争冒险。我曾经在一个项目里因为混用了赋值方式,导致仿真通过但板子跑起来数据全乱,查了整整两天才找到问题。

来看一个最简单的D触发器例子:

// 一个带同步复位的D触发器
module d_flip_flop (
    input  wire       clk,
    input  wire       rst_n,
    input  wire       d,
    output reg        q
);

    always @(posedge clk) begin
        if (!rst_n)
            q <= 1'b0;
        else
            q <= d;
    end

endmodule

嗯,这里要注意:复位信号 rst_n 是低电平有效,这是业界惯例。我个人习惯在模块端口命名时,用 _n 后缀表示低有效信号,这样看代码一目了然。

4.2 状态机设计:FSM的三种写法

状态机是FPGA设计的灵魂。量化交易里的指令解析、订单管理、风险控制,说白了都是一堆状态机在跑。我设计过的最复杂的状态机有32个状态,用于处理交易所的FIX协议。

状态机分两种:Moore型(输出只与当前状态有关)和 Mealy型(输出与当前状态和输入都有关)。在交易系统中,我更喜欢用Moore型,因为输出稳定,不会因为输入毛刺而抖动。

标准的FSM写法分三段:

段落 功能 描述方式
第一段 状态转移 时序逻辑,always @(posedge clk)
第二段 次态逻辑 组合逻辑,always @(*)
第三段 输出逻辑 组合逻辑或时序逻辑

来看一个简单的交易状态机示例——一个限价单的状态管理:

// 三段式状态机:限价单状态管理
module order_fsm (
    input  wire        clk,
    input  wire        rst_n,
    input  wire        new_order,      // 新订单触发
    input  wire        fill_done,      // 成交完成
    input  wire        cancel_req,     // 取消请求
    output reg         order_active,   // 订单活跃中
    output reg         order_filled,   // 已成交
    output reg         order_canceled  // 已取消
);

    // 状态编码
    localparam IDLE     = 2'b00;
    localparam PENDING  = 2'b01;
    localparam FILLED   = 2'b10;
    localparam CANCELED = 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:    if (new_order)    next_state = PENDING;
            PENDING: begin
                if (fill_done)         next_state = FILLED;
                else if (cancel_req)   next_state = CANCELED;
            end
            FILLED:                    next_state = FILLED;   // 终态
            CANCELED:                  next_state = CANCELED; // 终态
            default:                   next_state = IDLE;
        endcase
    end

    // 第三段:输出逻辑(组合逻辑)
    always @(*) begin
        order_active   = (state == PENDING);
        order_filled   = (state == FILLED);
        order_canceled = (state == CANCELED);
    end

endmodule

我的经验:三段式状态机虽然代码量稍大,但可读性和可维护性最好。在量化交易系统里,状态机经常需要修改——比如增加一个「部分成交」状态。三段式只需要改第二段和第三段,第一段基本不动,不容易引入bug。

4.3 状态编码的选择

状态编码不是随便选的。常用的有三种:

  • 二进制编码:状态数少时用,比如4个状态用2位。省寄存器,但组合逻辑复杂。
  • 独热码:每个状态用1位。比如4个状态用4位。组合逻辑简单,适合FPGA(LUT资源多)。
  • 格雷码:相邻状态只有1位变化。适合跨时钟域的场景。

在交易系统中,如果状态机状态数少于16个,我通常用独热码。为什么?因为FPGA的查找表(LUT)对独热码的译码效率极高,而且时序更容易收敛。我曾经把一个32状态的二进制编码状态机改成独热码,时序余量从-200ps变成了+500ps,效果立竿见影。

避坑指南:千万不要用 casexcasez 来写状态机!我曾经接手过一个同事的代码,他用 casex 忽略了某些位,结果仿真和综合行为不一致,板子跑起来状态机乱跳。老老实实用 case,加上 default 分支,把未定义状态导向安全状态(比如IDLE)。

4.4 Testbench编写:让仿真替你说话

写Testbench,说白了就是给设计模块「喂数据、看结果」。我见过很多工程师花大量时间写RTL,却只花10分钟写Testbench,结果上板子一跑就炸。其实,好的Testbench能帮你节省80%的调试时间

一个标准的Testbench结构:

// 测试上面那个D触发器
`timescale 1ns / 1ps

module tb_d_flip_flop;

    reg  clk;
    reg  rst_n;
    reg  d;
    wire q;

    // 实例化待测模块
    d_flip_flop u_dut (
        .clk   (clk),
        .rst_n (rst_n),
        .d     (d),
        .q     (q)
    );

    // 生成时钟:周期10ns,频率100MHz
    initial begin
        clk = 0;
        forever #5 clk = ~clk;
    end

    // 测试激励
    initial begin
        // 初始化
        rst_n = 0;
        d     = 0;
        #20;
        rst_n = 1;  // 释放复位

        // 测试1:d=1时,q在下一个时钟沿变成1
        @(posedge clk);
        d = 1;
        @(posedge clk);
        if (q !== 1) $display("ERROR: q should be 1");

        // 测试2:d=0时,q在下一个时钟沿变成0
        @(posedge clk);
        d = 0;
        @(posedge clk);
        if (q !== 0) $display("ERROR: q should be 0");

        // 测试3:复位测试
        @(posedge clk);
        rst_n = 0;
        @(posedge clk);
        if (q !== 0) $display("ERROR: reset failed");

        #20;
        $finish;
    end

    // 波形导出(用于GTKWave等工具查看)
    initial begin
        $dumpfile("tb_d_flip_flop.vcd");
        $dumpvars(0, tb_d_flip_flop);
    end

endmodule

写Testbench有几个要点:

  • 时钟生成:用 forever 循环,别用 repeat 写死,方便改频率。
  • 自检机制:用 $display$error 自动比对结果,别靠肉眼看波形。
  • 随机测试:用 $random 生成随机激励,覆盖边界情况。
  • 波形导出:VCD格式通用,GTKWave免费好用。

我的习惯:每个模块我都会写一个「冒烟测试」Testbench,只测最基本的功能。然后再写一个「压力测试」Testbench,用随机激励跑几万拍。在量化交易系统里,我还会加一个「异常测试」——比如同时收到成交和取消信号,看状态机怎么处理。这些测试帮我挡掉了至少5次线上事故。

4.5 实战建议:从仿真到上板

最后,我想分享一个实战流程。我每次开发一个交易算法模块,都会走这几步:

  1. 写RTL:先画状态转移图,再写代码。别上来就敲键盘。
  2. 写Testbench:至少覆盖正常路径、异常路径、边界条件。
  3. 仿真验证:用VCS或ModelSim跑,看波形,确认时序正确。
  4. 综合与时序分析:看报告,确保setup/hold满足,频率达标。
  5. 上板调试:用ILA(集成逻辑分析仪)抓内部信号,和仿真对比。

嗯,这一章内容不少。时序逻辑是FPGA设计的基石,状态机是控制逻辑的灵魂,Testbench是验证的保障。把这三点吃透了,你就能写出稳定可靠的交易加速模块。下一章,我们会深入FPGA内部结构,看看LUT、DSP、BRAM这些资源到底怎么用才高效。