4、Verilog实现基础:always块与组合逻辑、时序逻辑的写法、三段式状态机模板

好,咱们今天聊点实在的。Verilog 里最核心的东西是什么?我个人觉得,就是 always 块。你想想看,整个硬件描述语言,说白了就是靠 always 来告诉综合工具:这里要生成一堆门,那里要生成一堆触发器。

很多新手一上来就被各种阻塞赋值、非阻塞赋值搞晕了。嗯,别急,咱们一步步拆开看。

4.1 always块:硬件描述的灵魂

always 块就像是一个永不停止的循环。只要敏感列表里的信号发生变化,它就执行一次。我刚开始学的时候,总把它当成软件里的 while(1),后来发现完全不是一回事——硬件里它是并行执行的,不是顺序执行的。

这里有个关键点:always 块的敏感列表决定了它是组合逻辑还是时序逻辑。

  • 组合逻辑:敏感列表里只放电平信号,比如 always @(*) 或 always @(a or b)。
  • 时序逻辑:敏感列表里放时钟边沿,比如 always @(posedge clk) 或 always @(negedge rst_n)。

重要提醒:千万别把时钟和电平混在一个敏感列表里,除非你真的知道自己在干什么。我曾经见过一个同事,把 always @(posedge clk or a) 写出来,结果综合出来的电路完全不是他想要的样子——那叫一个惨。

4.2 组合逻辑的写法

组合逻辑,说白了就是没有记忆功能的电路。输入一变,输出立马跟着变。在 Verilog 里,我们通常用两种方式写组合逻辑:

方式一:assign 语句

适合简单的赋值,比如:

assign y = a & b | c;

方式二:always @(*) 块

适合复杂的逻辑,比如多分支判断:

always @(*) begin
    case (sel)
        2'b00: y = a;
        2'b01: y = b;
        2'b10: y = c;
        default: y = 0;
    endcase
end

注意,组合逻辑里必须用阻塞赋值(=)。为什么?因为阻塞赋值是立即生效的,符合组合逻辑的“即时响应”特性。如果你在组合逻辑里用了非阻塞赋值(<=),仿真时可能会对,但综合出来的电路大概率会多出一些奇怪的锁存器。

我的小技巧:写组合逻辑时,我习惯在 always @(*) 块里把所有输出都赋一遍默认值,比如 y = 0;。这样可以避免综合出锁存器。你想想看,如果某个分支没覆盖到,综合工具就会认为“保持原值”,那就给你生成一个 latch——这往往不是我们想要的。

4.3 时序逻辑的写法

时序逻辑就带记忆功能了。它靠时钟边沿来触发,把输入的值“拍”到输出上。最经典的例子就是 D 触发器:

always @(posedge clk or negedge rst_n) begin
    if (!rst_n)
        q <= 0;
    else
        q <= d;
end

这里有几个要点:

  • 必须用非阻塞赋值(<=)。这是硬件描述语言的铁律——时序逻辑里用非阻塞赋值,可以避免仿真时的竞争冒险。
  • 异步复位一般用低电平有效(negedge rst_n),这是业界惯例。
  • 敏感列表里,时钟和复位要分开写,不要写成 posedge clk or posedge rst,除非你用的是高电平复位。

我记得有一次做项目,一个同事在时序逻辑里用了阻塞赋值,仿真时一切正常,结果上板调试时发现数据总是慢一拍。查了两天才发现是赋值方式的问题——从那以后,我对这个规则就特别敏感。

4.4 三段式状态机模板

状态机是数字电路里的常客。我个人最推荐的是三段式状态机。为什么叫三段式?因为它把状态机的三个功能分到了三个 always 块里:

  1. 第一段:时序逻辑,描述状态寄存器的跳转。
  2. 第二段:组合逻辑,描述下一状态的逻辑。
  3. 第三段:组合逻辑或时序逻辑,描述输出。

下面是一个经典的三段式状态机模板:

// 状态定义
parameter IDLE = 2'b00;
parameter S1   = 2'b01;
parameter S2   = 2'b10;
parameter S3   = 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
    case (state)
        IDLE: begin
            if (start)
                next_state = S1;
            else
                next_state = IDLE;
        end
        S1: begin
            if (done)
                next_state = S2;
            else
                next_state = S1;
        end
        S2: begin
            if (error)
                next_state = IDLE;
            else
                next_state = S3;
        end
        S3: begin
            next_state = IDLE;
        end
        default: next_state = IDLE;
    endcase
end

// 第三段:输出逻辑(组合逻辑)
always @(*) begin
    // 默认输出
    out1 = 0;
    out2 = 0;
    case (state)
        S1: out1 = 1;
        S2: out2 = 1;
        S3: begin
            out1 = 1;
            out2 = 1;
        end
    endcase
end

避坑指南:我曾经在第三段输出逻辑里用了时序逻辑(always @(posedge clk)),结果输出总是晚一个时钟周期。后来才意识到,状态机的输出应该是组合逻辑,这样才能在当前状态立即生效。除非你特意需要打一拍,否则别在输出段加时钟。

4.5 知识体系总览

下面这张图是我自己画的,把本章的核心逻辑串起来了。你看一眼,应该就能明白 always 块、组合逻辑、时序逻辑、三段式状态机之间的关系。

always 块 组合逻辑 时序逻辑 assign 语句 always @(*) D触发器 计数器 三段式状态机 第一段:状态寄存器(时序) 第二段:下一状态(组合) 第三段:输出(组合) 阻塞赋值 = 非阻塞赋值 <=

从这张图可以看得很清楚:always 块是根,往下分出组合逻辑和时序逻辑两大分支。而三段式状态机,其实就是把这两种逻辑巧妙地组合在一起——第一段用时序逻辑存状态,第二段用组合逻辑算下一状态,第三段用组合逻辑出结果。

嗯,这套模板我用了快十年了,几乎所有的控制逻辑都能套用。你只要把状态定义和跳转条件换一换,就能适配各种场景。说白了,这就是硬件工程师的“万能钥匙”。

最后说一句:刚开始写三段式的时候,你可能会觉得代码有点啰嗦。但相信我,等你要调试一个复杂的状态机时,这种清晰的分离会让你少掉很多头发。我当年就是靠这个模板,三天调通了一个 16 状态的通信协议——要是用两段式,估计得一周。

好了,这一章的内容就到这儿。记住:always 块是硬件描述的灵魂,组合逻辑用阻塞赋值,时序逻辑用非阻塞赋值,三段式状态机是工程实践中的最佳选择。把这些基础打牢,后面的路就好走了。