3、Verilog基础回顾:模块化设计、组合逻辑与时序逻辑、状态机编写规范

好,咱们进入第三讲。说实话,每次讲这个章节我都挺感慨的。为什么?因为很多新手工程师,甚至一些工作一两年的同事,最后出问题都出在这些最基础的地方。今天我就把我在项目中踩过的坑、积累的经验,一次性倒给你们。

3.1 模块化设计——别让你的代码变成一锅粥

模块化设计,说白了就是「分而治之」。一个复杂的基站加速器,你不可能在一个文件里写完所有逻辑。我见过有人一个模块写了三千行,那调试起来简直是噩梦。

我个人习惯:每个模块只干一件事。比如一个 FIR 滤波器模块,它就只负责滤波;一个 CRC 校验模块,它就只算校验值。这样后期维护、复用都方便。

模块划分的核心原则:

  • 高内聚:模块内部的功能要紧密相关
  • 低耦合:模块之间的接口要尽量简单
  • 接口清晰:输入输出信号命名规范,一看就懂

举个例子,一个简单的计数器模块:

module counter #(
    parameter WIDTH = 8
)(
    input  wire             clk,
    input  wire             rst_n,
    input  wire             en,
    output reg  [WIDTH-1:0] count
);

    always @(posedge clk or negedge rst_n) begin
        if (!rst_n)
            count <= 0;
        else if (en)
            count <= count + 1;
    end

endmodule

你看,这个模块的接口就三个:时钟、复位、使能。谁都能看懂,谁都能用。我在项目中遇到过有人把使能信号和复位信号混在一起处理,结果时序分析的时候一堆问题。嗯,这里要注意:接口信号要正交,不要搞出依赖关系。

3.2 组合逻辑与时序逻辑——搞清楚谁是谁

这个问题,我每次面试必问。你想想看,如果连这个都分不清,后面的时序收敛根本无从谈起。

3.2.1 组合逻辑

组合逻辑的输出只取决于当前输入。说白了,输入一变,输出立马跟着变,没有记忆功能。

常见的组合逻辑写法:

  • assign 连续赋值语句
  • always @(*) 中的阻塞赋值 =

举个例子:

// 组合逻辑:多路选择器
assign out = sel ? a : b;

// 或者用 always 块
always @(*) begin
    if (sel)
        out = a;
    else
        out = b;
end

我曾经踩过的坑:组合逻辑中产生锁存器(latch)。比如在 always 块中,if 条件没有写全 else,或者 case 没有写 default。综合工具会给你生成一个 latch,这玩意儿在 FPGA 里很坑,容易导致时序问题。

3.2.2 时序逻辑

时序逻辑就不一样了,它依赖时钟沿来更新状态。输出不仅和当前输入有关,还和之前的状态有关。

时序逻辑的标准写法:

  • always @(posedge clk) 中的非阻塞赋值 <=
// 时序逻辑:带使能的寄存器
always @(posedge clk or negedge rst_n) begin
    if (!rst_n)
        q <= 0;
    else if (en)
        q <= d;
end

这里有个关键点:非阻塞赋值 <= 和阻塞赋值 = 千万别混用。我刚开始做设计时,在一个 always 块里又用 = 又用 <=,结果仿真结果怎么都不对。后来查了半天,才发现是赋值方式搞混了。

我的个人经验:写时序逻辑就用 <=,写组合逻辑就用 =。永远不要在一个 always 块里混用。这样代码可读性强,也不容易出错。

3.3 状态机编写规范——别让状态机变成「状态机」

状态机是 FPGA 设计的核心。基站加速器里的协议解析、数据调度,说白了都是状态机。我见过太多人把状态机写得乱七八糟,最后 debug 到崩溃。

3.3.1 状态机的三种写法

写法 特点 适用场景
一段式 所有逻辑写在一个 always 块里 简单状态机(不推荐用于复杂设计)
二段式 时序逻辑和组合逻辑分开 中等复杂度,我比较常用
三段式 时序、组合、输出逻辑全部分开 复杂状态机,可读性最好

我个人强烈推荐三段式写法。为什么?因为调试方便。你想想看,如果状态跳转错了,你只需要看第一个 always 块;如果输出错了,你只需要看第三个 always 块。问题定位快得很。

3.3.2 三段式状态机模板

// 状态编码
localparam IDLE   = 2'b00;
localparam START  = 2'b01;
localparam DATA   = 2'b10;
localparam DONE   = 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 (start_sig) next_state = START;
        START:  next_state = DATA;
        DATA:   if (data_done) next_state = DONE;
        DONE:   next_state = IDLE;
        default: next_state = IDLE;
    endcase
end

// 第三段:输出逻辑
always @(*) begin
    // 默认输出
    data_valid = 0;
    done_sig   = 0;
    case (state)
        DATA:   data_valid = 1;
        DONE:   done_sig   = 1;
    endcase
end

状态机编写规范总结:

  • 使用 localparam 定义状态,不要用魔法数字
  • 状态编码推荐使用独热码(one-hot)或格雷码(gray),具体看资源与时序要求
  • 组合逻辑部分一定要有 default,防止产生 latch
  • 输出逻辑中,先给所有输出赋默认值,再根据状态修改

嗯,这里要特别强调一下:状态机一定要有默认状态。我曾经在项目中遇到一个情况,状态机跑到一个未定义的状态,结果整个系统卡死了。后来加了 default 和看门狗复位才解决。你想想看,如果是在基站设备里,这种问题会导致掉话,后果很严重。

3.4 避坑指南——我踩过的那些坑

最后,分享几个我实际项目中遇到的坑,你们以后遇到了心里有数:

  • 敏感列表不全:写组合逻辑时,always 块的敏感列表没写全,导致仿真和综合结果不一致。解决办法:用 always @(*) 代替手动列敏感信号。
  • 复位信号处理不当:异步复位虽然简单,但容易导致 metastability。我建议能同步复位就同步复位,实在要用异步复位,一定要做复位同步器。
  • 状态机输出未寄存:组合逻辑输出直接驱动外部模块,容易产生毛刺。我的习惯是:所有输出都打一拍寄存器,虽然多一个时钟周期延迟,但信号质量好很多。

一个小技巧:写代码之前,先在纸上画出状态转移图。把每个状态、每个跳转条件、每个输出都画清楚。代码写起来就快多了。我每次做复杂状态机,都是先画图再写代码,很少出问题。

好了,这一讲的内容就到这里。模块化设计、组合逻辑与时序逻辑、状态机编写规范,这三个东西是 FPGA 设计的基石。你们回去之后,把今天讲的三段式状态机模板背下来,以后写代码就用这个模板。相信我,你会感谢我的。