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 三段式状态机

哪三段?

  1. 第一段:时序逻辑,描述状态跳转(当前状态 → 次态)
  2. 第二段:组合逻辑,描述状态跳转条件(次态如何产生)
  3. 第三段:组合逻辑或时序逻辑,描述输出

举个例子:一个简单的交通灯控制器,红灯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 本章知识体系

下面这张图,是我梳理的本章核心逻辑。你可以把它当作一个“思维导图”,方便回顾。

Verilog基础语法(下)知识体系 组合逻辑 时序逻辑 状态机设计 assign / always @(*) 阻塞赋值 = always @(posedge clk) 非阻塞赋值 <= 计数器 / 移位寄存器 三段式写法 状态编码(独热/格雷) 输出逻辑(Mealy/Moore) 仿真与Testbench编写 时钟生成 / 激励输入 / 结果检查

4.5 避坑指南

最后,分享几个我实战中踩过的坑,希望能帮你省点时间:

  • 组合逻辑环路:千万别把组合逻辑的输出直接反馈到自己的输入,会形成死循环。比如 assign a = a + 1;,仿真直接卡死。
  • 敏感列表不全:写 always @(a or b) 时漏了c,仿真结果和综合结果不一致。用 always @(*) 最保险。
  • 复位信号没处理好:异步复位一定要同步释放,否则容易产生亚稳态。我一般用两级触发器做同步。
  • Testbench里忘了加 $finish:仿真会一直跑下去,你以为卡死了,其实只是没告诉它停。

嗯,这一章的内容就到这里。组合逻辑和时序逻辑是FPGA设计的基石,状态机是控制逻辑的灵魂,仿真则是验证的保障。把这三点吃透了,后面的图像加速实战才能走得稳。

一句话总结:写Verilog时,时刻问自己——这行代码综合出来是什么电路?是组合逻辑还是触发器?状态机有没有漏掉状态?仿真波形有没有异常?想清楚了再上板。

可可精品资料库