Veloris.
返回索引
概念基础 2026-02-23

阻塞还是非阻塞?Verilog 三种描述方式与赋值机制详解

7 分钟
2.2k words

阻塞还是非阻塞?Verilog 三种描述方式与赋值机制详解

💡 仿真波形完美无缺,上板后数据总是错一拍。你反复检查逻辑,找不到任何问题。最后发现原因只有一个字符的差别——把 <= 写成了 =

这不是段子,而是几乎每个 Verilog 新手都会经历的真实噩梦。阻塞赋值(=)和非阻塞赋值(<=)的混用,是 Verilog 中最隐蔽、最难调试的 Bug 来源。它不会让编译器报错,不会让仿真失败,但会让你的硬件行为与仿真完全不一致。

这篇文章会帮你彻底搞明白两件事:

  1. Verilog 的三种描述方式(数据流、行为、结构化)分别怎么用
  2. 阻塞赋值和非阻塞赋值的本质区别,以及那条必须遵守的黄金规则

📌 系列衔接:本文承接上一篇 Verilog 运算符与表达式


目录


1. 三种描述方式总览

Verilog 提供三种方式来描述硬件电路,它们可以混合使用:

描述方式核心语句适用场景赋值对象
数据流描述assign简单组合逻辑wire
行为描述always / initial时序逻辑、复杂组合逻辑reg
结构化描述模块实例化复用已有模块

这三种方式并不是三选一,而是在同一个 module 中协同使用。一个典型的 module 可能同时包含 assign 语句、always 块和模块实例化。


2. 数据流描述:assign

assign连续赋值语句,用来描述组合逻辑中的数据流动。

assign y = a & b;              // 与门
assign sum = a + b;            // 加法器
assign mux_out = sel ? d1 : d0; // 2选1 MUX

核心特性

  • 连续驱动:输入任何变化都会立即反映到输出(有传播延迟)
  • 只能给 wire 类型赋值
  • 适合描述简单的组合逻辑——门电路、MUX、加法器等

什么是”数据流”?

在组合逻辑电路中,数据不会在中间停留——输入变化经过逻辑门延迟后,总会体现在输出上。这就像水流过管道,不会被阀门截断。assign 语句精确地描述了这种行为:只要输入变了,输出就跟着变

// 一个完整的组合逻辑模块:全加器
module full_adder (
    input  wire a, b, cin,
    output wire sum, cout
);
    assign sum  = a ^ b ^ cin;
    assign cout = (a & b) | (a & cin) | (b & cin);
endmodule

3. 行为描述:always 与 initial

always 块是 Verilog 中最强大也最容易用错的语句。它通过描述电路的行为来建模——“当什么条件发生时,做什么事”。

always 块的两种用法

用法一:描述时序逻辑

always @(posedge clk) begin
    if (rst)
        q <= 1'b0;
    else
        q <= d;
end
  • 敏感列表是时钟边沿posedge clknegedge clk
  • 综合为触发器(Flip-Flop)
  • 使用非阻塞赋值 <=

用法二:描述组合逻辑

always @(*) begin
    case (sel)
        2'b00: out = in0;
        2'b01: out = in1;
        2'b10: out = in2;
        2'b11: out = in3;
    endcase
end
  • 敏感列表是 *(自动包含所有引用信号)
  • 综合为纯组合逻辑(没有触发器)
  • 使用阻塞赋值 =

initial 块

initial 块只在仿真开始时执行一次,不可综合——它只用于 Testbench。

// 仅用于仿真!
initial begin
    clk = 0;
    rst = 1;
    #100 rst = 0;
end

时序控制

  • 事件语句 @:等待信号变化(@(posedge clk)
  • 延时语句 #:等待指定时间(#10)——仅用于仿真,不可综合
  • always @(*) 中的 * 是 Verilog-2001 引入的语法糖,自动将块内引用的所有信号加入敏感列表

💬 你可能会问:always @(*) 和手动列出敏感列表有什么区别?

功能上没区别,但手动列举容易遗漏信号,导致仿真和综合行为不一致。永远用 @(*),让工具自动处理。


4. 结构化描述:模块实例化

通过实例化已有模块来构建更大的系统——就像用现成的芯片搭电路板。

// 实例化两个全加器组成一个2位加法器
full_adder u_fa0 (
    .a   (a[0]),
    .b   (b[0]),
    .cin (1'b0),
    .sum (sum[0]),
    .cout(carry0)
);

full_adder u_fa1 (
    .a   (a[1]),
    .b   (b[1]),
    .cin (carry0),
    .sum (sum[1]),
    .cout(cout)
);

结构化描述有三种形式:

  • Module 实例化:最常用,复用已有的 module
  • 门实例化:实例化基本门电路原语(andornot 等),现代设计中很少直接使用
  • UDP 实例化:用户自定义原语,更少用

5. 阻塞赋值与非阻塞赋值

这是本文最重要的部分。

阻塞赋值(Blocking):=

“阻塞”的意思是:当前赋值完成之后,才执行下一条语句

always @(posedge clk) begin
    b = a;    // 先执行:b 立即获得 a 的值
    c = b;    // 后执行:c 获得 b 的新值(也就是 a 的值)
end
// 结果:b = a, c = a(b 和 c 同时变成 a)

非阻塞赋值(Non-Blocking):<=

“非阻塞”的意思是:所有赋值同时生效,不会阻塞后面的语句

always @(posedge clk) begin
    b <= a;   // 调度赋值:b 将变成 a 的当前值
    c <= b;   // 调度赋值:c 将变成 b 的当前值(旧值!)
end
// 结果:b = a(旧), c = b(旧)——形成移位寄存器

★ 直觉理解——一张图搞懂阻塞与非阻塞

想象一个时钟上升沿到来的瞬间:

  • 非阻塞赋值:所有寄存器同时拍照,然后同时更新。每个寄存器拿到的是其他寄存器在这个时钟沿之前的值。
  • 阻塞赋值:寄存器排队更新,前一个更新完了后一个才开始,后面的寄存器拿到的是前面寄存器已经更新过的值。

黄金规则

这是 FPGA 开发中必须遵守的规则,没有例外:

逻辑类型赋值方式原因
组合逻辑always @(*)阻塞赋值 =模拟信号的即时传播
时序逻辑always @(posedge clk)非阻塞赋值 <=模拟寄存器的同步更新
连续赋值assign=assign 只支持这种方式

违反这条规则会怎样? 仿真结果可能是对的,也可能是错的——取决于仿真器的调度顺序。但综合后的硬件行为是确定的。这就导致了最难调试的 Bug:仿真通过,硬件失败

💡 工程师手记:我曾经在一个项目中,把时序逻辑的 <= 误写成了 =。仿真完美通过,但上板后数据总是错一拍。我花了两天时间排查,最后发现是这一个字符的差别。从那以后,我养成了一个习惯:写完 always 块后,第一件事就是检查赋值符号。

完整示例对比

// 场景:t0时刻 a=1, b=1, c=1
//        t1时刻 a 变为 0,下一个时钟上升沿到来

// ===== 阻塞赋值 =====
always @(posedge clk) begin
    b = a;     // b = 0(a的新值)
    c = b;     // c = 0(b的新值)
end
// 结果:b=0, c=0

// ===== 非阻塞赋值 =====
always @(posedge clk) begin
    b <= a;    // b 将变成 0(a的新值)
    c <= b;    // c 将变成 1(b的旧值)
end
// 结果:b=0, c=1(形成两级移位寄存器)

6. 并行性:最容易被忽略的本质

Verilog 中以下三种结构都是并行执行的:

  • 所有 always
  • 所有 assign 语句
  • 所有模块实例化

它们之间的书写顺序不影响执行顺序。它们通过信号名相互连接。

module example (input clk, input d, output reg q2);
    reg q1;
    
    // 这两个 always 块同时执行,顺序无关
    always @(posedge clk) q1 <= d;
    always @(posedge clk) q2 <= q1;
    
    // 交换顺序,结果完全一样:
    // always @(posedge clk) q2 <= q1;
    // always @(posedge clk) q1 <= d;
endmodule

这个特性直接来源于硬件的物理本质:电路一旦通电,所有部分同时工作。

四条核心规则

  1. 所有 initial 块、always 块、assign 语句、实例引用都是并行的
  2. 它们通过变量名相互连接
  3. 同一模块中三者的先后顺序没有关系
  4. 只有 assign 和实例引用可以独立于过程块存在于模块的功能定义部分

7. 总结

要点核心内容
三种描述方式数据流(assign)、行为(always)、结构化(实例化)
assign连续赋值,只能驱动 wire,适合简单组合逻辑
always行为描述,可描述时序和组合逻辑
阻塞赋值 =顺序执行,用于组合逻辑
非阻塞赋值 <=同时生效,用于时序逻辑
并行性所有 always/assign/实例化并行执行

给初学者的一句话:如果你只能记住本文的一条规则,记住这条——组合逻辑用 =,时序逻辑用 <=,绝对不要混用


常见问题

Q1:为什么 assign 只能给 wire 赋值,不能给 reg 赋值?

因为 assign 模拟的是”连线”行为——信号像电线一样持续传导,不需要存储。wire 正好对应这种语义。而 reg 的语义是”在某个事件发生时更新值”,需要在 always 块中用过程赋值来描述。

Q2:一个信号能同时被 assign 和 always 驱动吗?

不能。一个信号只能有一个驱动源。如果一个 wire 被 assign 驱动,就不能在 always 中赋值;反之亦然。违反这条规则会导致综合错误或多驱动冲突。

Q3:always @(*) 里不写 default/else 会怎样?

综合工具会推断出锁存器(Latch)——这几乎总是一个 Bug。下一篇文章会详细讲 if-else 和 case 语句的使用规范,以及如何避免意外生成锁存器。

Q4:能不能在同一个 always 块里同时描述组合逻辑和时序逻辑?

技术上可以,但强烈不推荐。混合描述会让代码难以阅读和维护,也容易出错。最佳实践是:一个 always 块只做一件事——要么是纯组合逻辑,要么是纯时序逻辑。


参考资料


系列导航:本文是「FPGA 入门系列」第 9 篇。

如果这篇文章对你有帮助,欢迎点赞、收藏。阻塞和非阻塞赋值是 Verilog 面试的高频考点,也是实际开发中最常见的 Bug 来源——收藏这篇文章,下次写代码前翻一翻。

End of file.