4. 组合逻辑建模(一):assign连续赋值语句,用always @(*) 描述组合逻辑,阻塞赋值与非阻塞赋值的核心区别

好,咱们今天来聊聊组合逻辑建模。这部分内容,说实在的,是FPGA设计的基石。你想想看,任何一个数字电路,归根结底就是组合逻辑加上时序逻辑。组合逻辑搞不明白,后面写状态机、写接口时序,那都是空中楼阁。

我个人习惯把组合逻辑建模分成两种方式:一种是直接用 assign 连续赋值,另一种是用 always @(*) 过程块。这两种方式各有各的脾气,咱们一个一个说。

4.1 assign 连续赋值语句

assign 这个词,字面意思就是“赋值”。它描述的是一个持续的、不间断的赋值行为。只要等号右边的信号一变,左边的信号立刻跟着变。没有时钟,没有边沿,纯粹是线性的逻辑关系。

举个例子,我想实现一个 2 选 1 的多路选择器:

module mux2to1(
    input  wire a,
    input  wire b,
    input  wire sel,
    output wire y
);

    assign y = sel ? b : a;

endmodule

这段代码很简单。当 sel 为高电平时,y 等于 b;否则等于 a。注意,这里用的是 wire 类型。为什么?因为 assign 语句的左边必须是 wire 类型,不能是 reg。这是语法规定,没什么好商量的。

小提示: 我刚开始学的时候,总搞混 wire 和 reg。后来我记住一句话:assign 左边是 wire,always 块里赋值左边是 reg。简单粗暴,但好用。

再来看一个更复杂的例子,比如一个 4 位加法器:

module adder4(
    input  wire [3:0] a,
    input  wire [3:0] b,
    input  wire       cin,
    output wire [3:0] sum,
    output wire       cout
);

    assign {cout, sum} = a + b + cin;

endmodule

这里用到了拼接运算符 {},把进位和结果拼在一起。这种写法很常见,简洁明了。

我在项目中遇到过一种情况:有人用 assign 写了一个很长的组合逻辑链,比如几十个信号做与或非运算。结果综合后时序跑不过。为什么?因为组合逻辑太深了,路径延迟太大。所以,assign 虽好,但别滥用。太复杂的组合逻辑,建议拆成多级,或者用 always 块配合中间变量来写。

4.2 用 always @(*) 描述组合逻辑

接下来是 always @(*)。这个写法,说白了就是“只要敏感列表里的信号变了,就执行一次块内的语句”。而 * 是通配符,表示自动推导敏感列表。你不用手动写 @(a or b or sel),系统会自动识别。

always @(*) 重写刚才的 2 选 1 选择器:

module mux2to1_always(
    input  wire a,
    input  wire b,
    input  wire sel,
    output reg  y
);

    always @(*) begin
        if (sel)
            y = b;
        else
            y = a;
    end

endmodule

注意,这里的 y 声明成了 reg 类型。很多初学者看到 reg 就以为是寄存器,其实不是。在 always @(*) 块里,reg 只是语法上的要求,综合出来依然是组合逻辑。这一点一定要记住。

重要:always @(*) 块中,如果某个分支没有给所有信号赋值,就会产生锁存器(latch)。这是新手最容易踩的坑。

举个例子,下面这段代码就会生成 latch:

always @(*) begin
    if (en)
        q = d;
    // 缺少 else 分支
end

en 为 0 时,q 保持原值。这就会综合出一个锁存器。锁存器在 FPGA 里通常是不推荐的,因为它对时序不友好,而且容易出毛刺。我曾经在一个项目里排查了整整两天,才发现是一个 if 语句漏写了 else,导致生成了 latch,整个模块的行为都乱了。嗯,从那以后,我写 always @(*) 块时,一定会检查每个分支是否都覆盖到了。

4.3 阻塞赋值与非阻塞赋值的核心区别

这部分内容,可以说是 Verilog 里最容易让人迷糊的地方。很多面试题都会问这个。咱们把它彻底讲清楚。

先看两个概念:

  • 阻塞赋值(=):顺序执行。先算完右边的值,立刻赋给左边,再执行下一条语句。
  • 非阻塞赋值(<=):并行执行。先算好所有右边的值,然后统一赋值给左边。

听起来有点抽象?咱们直接看代码。

阻塞赋值的例子

always @(*) begin
    a = b;
    c = a;
end

这段代码执行完后,a 等于 bc 也等于 b。因为 a 先被赋值,然后 c 读取的是更新后的 a。这就是“阻塞”的含义——第二条语句被第一条阻塞了,必须等第一条执行完。

非阻塞赋值的例子

always @(posedge clk) begin
    a <= b;
    c <= a;
end

这段代码执行完后,a 等于 b 的旧值,c 等于 a 的旧值。因为所有右边的值都是在同一个时刻采样(时钟上升沿之前的值),然后统一更新。这就是“非阻塞”的含义——两条语句互不干扰,同时执行。

警告:always @(posedge clk) 时序逻辑块中,必须使用非阻塞赋值(<=)。如果用了阻塞赋值,综合出的电路行为会和你想象的大不一样,甚至产生竞争冒险。

我见过一个真实的案例:有人用阻塞赋值写了一个移位寄存器,结果仿真和实际电路行为完全对不上。查了半天,发现是赋值方式用错了。所以,记住这个黄金法则:

always 块类型 推荐赋值方式 原因
组合逻辑 always @(*) 阻塞赋值(=) 符合组合逻辑的“立即更新”特性
时序逻辑 always @(posedge clk) 非阻塞赋值(<=) 模拟寄存器在时钟边沿采样的行为

你可能会问:为什么组合逻辑里可以用阻塞赋值?因为组合逻辑没有时钟,信号的变化是立即传播的。用阻塞赋值正好模拟这种“瞬间完成”的特性。而时序逻辑有时钟,信号的变化是在时钟边沿发生的,所以要用非阻塞赋值来模拟“采样-更新”的过程。

最后,我再补充一点个人经验。写组合逻辑时,我更喜欢用 assign 来处理简单的逻辑,比如加法、选择器、译码器。对于复杂的组合逻辑,比如状态机的下一状态计算、多条件判断,我会用 always @(*) 配合 caseif-else 来写。这样代码可读性更好,也更容易维护。

好了,这一章的内容就到这里。下一章咱们会继续深入组合逻辑建模,聊聊 case 语句和 if-else 的微妙区别。到时候见。