阻塞还是非阻塞?Verilog 三种描述方式与赋值机制详解
💡 仿真波形完美无缺,上板后数据总是错一拍。你反复检查逻辑,找不到任何问题。最后发现原因只有一个字符的差别——把 <= 写成了 =。
这不是段子,而是几乎每个 Verilog 新手都会经历的真实噩梦。阻塞赋值(=)和非阻塞赋值(<=)的混用,是 Verilog 中最隐蔽、最难调试的 Bug 来源。它不会让编译器报错,不会让仿真失败,但会让你的硬件行为与仿真完全不一致。
这篇文章会帮你彻底搞明白两件事:
- Verilog 的三种描述方式(数据流、行为、结构化)分别怎么用
- 阻塞赋值和非阻塞赋值的本质区别,以及那条必须遵守的黄金规则
📌 系列衔接:本文承接上一篇 Verilog 运算符与表达式。
目录
- 1. 三种描述方式总览
- 2. 数据流描述:assign
- 3. 行为描述:always 与 initial
- 4. 结构化描述:模块实例化
- 5. 阻塞赋值与非阻塞赋值
- 6. 并行性:最容易被忽略的本质
- 7. 总结
- 常见问题
- 参考资料
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 clk或negedge 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
- 门实例化:实例化基本门电路原语(
and、or、not等),现代设计中很少直接使用 - 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
这个特性直接来源于硬件的物理本质:电路一旦通电,所有部分同时工作。
四条核心规则
- 所有
initial块、always块、assign语句、实例引用都是并行的 - 它们通过变量名相互连接
- 同一模块中三者的先后顺序没有关系
- 只有
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 块只做一件事——要么是纯组合逻辑,要么是纯时序逻辑。
参考资料
- IEEE Std 1364-2005: IEEE Standard for Verilog Hardware Description Language
- Clifford E. Cummings,Nonblocking Assignments in Verilog Synthesis, Coding Styles That Kill!(SNUG 2000)
- 夏宇闻,《Verilog 数字系统设计教程》(第 3 章:过程赋值语句)
系列导航:本文是「FPGA 入门系列」第 9 篇。
- 上一篇:Verilog 运算符与表达式
- 下一篇:Verilog 行为级语句与编译指令
如果这篇文章对你有帮助,欢迎点赞、收藏。阻塞和非阻塞赋值是 Verilog 面试的高频考点,也是实际开发中最常见的 Bug 来源——收藏这篇文章,下次写代码前翻一翻。