
写了好几年 RTL如果让我只选一个 Verilog 里最基础也最容易翻车的话题那一定是阻塞赋值和非阻塞赋值的区别。刚学的时候觉得无非就是把换成直到某一次仿真波形全对、上板怎么都不出正确结果我才真正意识到这两个运算符背后对应的是完全不同的硬件行为。别小看这个知识点它不只是笔试面试爱考更是日常写状态机、移位寄存器、FIFO 指针时绕不开的地基。这篇文章我会从仿真器的执行机制讲起把阻塞和非阻塞两种赋值为什么不同、分别在什么场景用、混用会出什么问题、碰到“仿真对板上错”该怎么排查一次说清楚。不管你是刚开始学 Verilog 基本语法的新手还是已经能写一些模块但被这类隐蔽问题折磨过的开发者这篇内容应该都能帮你少走不少弯路。1. 两种赋值在仿真器里的执行方式一个当场生效一个排队更新很多人一开始不理解阻塞赋值和非阻塞赋值是因为只在语法层面看符号和不都是赋值吗其实它们的执行模型差异非常大。而且这个问题只有在理解了 Verilog 仿真器怎么处理事件调度之后才会真正看清。1.1 阻塞赋值语句级“立即生效”阻塞赋值在仿真器里的行为是当前语句把右侧表达式算完立刻更新到左侧变量更新完成后才继续执行下一条语句。在下一条语句看来左侧变量已经是新值。a b; c a;上面这段代码第一行执行完后a立刻变成b的值第二行再去读a时拿到的是“新增量”。这个行为和我们写 C 语言几乎一模一样所以有软件背景的同学上手 Verilog 时很容易把这种“顺序执行”的直觉带到 RTL 设计里。我用生活里的例子打个比方阻塞赋值像超市收银台前面有人插队他得当场完成“扫描、付款、离开”这一整套动作后面的人才能继续。当前语句不完成后面的语句就得一直等着。1.2 非阻塞赋值右值先算左值统一更新非阻塞赋值就完全不同了。遇到非阻塞赋值语句时仿真器先把右侧表达式的结果“记下来”但不会立刻把这个值写进左侧变量而是把它排到当前仿真时间步的更新阶段等当前时间步内所有活动事件都处理完之后再统一把结果写进左侧变量。a b; c a;这里的关键点来了第二行右侧的a取的是“进入这个 always 块时”a的值而不是第一行a b计算出来的新值。因为非阻塞赋值不会立刻把左侧变量改掉。继续用超市比喻非阻塞赋值是叫号处理。所有顾客进超市时先拿一个号号码上记录着你需要的东西真正到收银台完成交易是后面统一的“叫号阶段”。谁先拿号、谁后拿号不影响最终交易阶段大家被依次叫到。1.3 这两种执行模型对应了两种硬件结构为什么仿真器要区分这两种执行方式因为硬件本身就有两种截然不同的行为组合逻辑输入一变输出很快跟着变信号沿逻辑门路径传播没有“时钟沿”的概念。时序逻辑只有在时钟有效沿到来时触发器才采样输入经过一段延迟后输出才变化而且同一时钟域内所有触发器是并行采样的。阻塞赋值天然适合描述组合逻辑的传播特性值一变后面依赖它的逻辑立刻重新计算。非阻塞赋值则天然适合描述时钟沿触发器的采样特性沿到来瞬间统一采样沿过后统一更新。把这层对应关系记住后面所有使用规则都不需要死记。2. 组合逻辑为什么偏爱阻塞赋值2.1 一个标准的组合逻辑 always 块长什么样写组合逻辑时经典做法是用always (*)内部配阻塞赋值。比如一个简单的四选一多路选择器always (*) begin case (sel) 2b00: y a; 2b01: y b; 2b10: y c; default: y d; endcase end这里y是一个组合输出用表达的语义非常直接当sel、a、b、c、d任何一个发生变化y在当前时间步内立即重新计算。如果把这个块改成y a;这种写法仿真波形里y的更新会被推迟到时间步末尾看起来像是多了一拍而综合工具还可能在y上推断出不必要的存储元件结果和你想要的组合逻辑完全是两回事。再举一个常见的组合逻辑中间变量例子always (*) begin tmp a b; y tmp | c; endtmp在这个块里只是组合逻辑的一个中间节点用阻塞赋值完全没问题。实际综合后它就是一段组合逻辑连线不是寄存器。2.2 组合块里最容易出现的两个反模式锁存器和多驱动组合逻辑块虽然语法简单但有两个高频问题。第一个是锁存器。当一个组合逻辑块里某个变量不是在所有分支下都会被赋值时综合工具就会推断出锁存器always (*) begin if (en) y a; end当en为 0 时y没有被赋值组合逻辑本身没有“记忆”能力为了保持住旧值综合器只能插入一个锁存器。很多新手在写组合逻辑时漏掉else分支或case的default就会得到一堆意想不到的锁存器。这不是阻塞赋值本身的问题但在组合块里很容易犯。第二个问题是多驱动。多个always块对同一个变量y赋值这会直接造成多驱动冲突。因为变量在硬件上只能由一份逻辑驱动多驱动会导致综合报错或者在仿真里出现 X 态。写组合逻辑时一个变量只能在同一个块里赋值。组合逻辑块还有一条建议不要把复杂到需要很多中间变量的组合逻辑硬塞进一个always (*)适当拆分或者用assign配合函数综合结果往往更可控。2.3 用阻塞赋值写时序逻辑仿真赢了硬件输了组合逻辑用阻塞赋值但反过来在时序逻辑里用阻塞赋值就会引出最经典的坑。看这个移位寄存器always (posedge clk) begin q1 q0; q2 q1; end我们来推演一下 RTL 仿真行为。posedge clk到来后第一行q1 q0让q1立刻等于q0在上一拍的值第二行q2 q1由于第一行已经更新了q1所以q2拿到的其实还是同一个旧q0值。也就是说RTL 仿真看到的是每个时钟沿q1和q2同时变成q0的旧值q2相对q0只有一级延迟。但综合工具是按照硬件连接关系来解析的q1的输入接q0的输出q2的输入接q1的输出。综合结果是一个两级触发器链q2相对q0是两拍延迟。RTL 仿真说“一拍”综合后电路做的是“两拍”两者对不上上板之后数据就差了一拍。这就是非常典型的“仿真正确、上板错误”场景。有人可能会说那我把语句反过来写q2 q1; q1 q0;让q2先取旧q1值是不是就对了RTL 仿真确实会得到两级延迟但综合工具看的不是语句顺序它只分析变量之间的依赖关系综合结果仍然是q1和q2两级触发器串联。而且这种依赖代码顺序才能“仿真正确”的写法本身就极不可靠一旦分支条件复杂、语句变多很容易出现别的问题。所以有一条铁律时序逻辑块里的寄存器赋值全部使用非阻塞赋值。这里不是“建议”而是工程上避免仿真与综合语义分叉的强制约束。3. 非阻塞赋值与寄存器的“一拍一拍”行为是对得上的3.1 D 触发器的物理行为和非阻塞赋值一一对应真实的 D 触发器在时钟上升沿到来的瞬间采样 D 端电平再经过一个很小的时钟到输出延迟 tcoQ 端才更新。同一个时钟域里所有触发器在同一个边沿并行采样并行更新彼此之间没有代码上的先后顺序。非阻塞赋值的执行模型恰好就是这种物理行为的建模方式时钟沿到来时所有右侧值被统一“采样”到了更新阶段再统一“输出”。用非阻塞赋值写时序逻辑仿真器的行为和真实硬件才能对齐。这也是为什么业界会形成那句被反复强调的话时序逻辑用非阻塞赋值组合逻辑用阻塞赋值。这句话的背后不只是风格偏好而是仿真模型与硬件结构一致性的保证。3.2 语句顺序不敏感这是非阻塞赋值最实用的特性非阻塞赋值还有一个非常好的特性在同一个 always 块里非阻塞赋值语句的书写顺序不会影响最终结果。// 写法一 always (posedge clk) begin b a; c b; end // 写法二 always (posedge clk) begin c b; b a; end这两种写法最后的波形完全一样b拿到上一拍的ac拿到上一拍的b。原因前面说过RHS 都在进入 always 块时被采样LHS 的更新留到统一更新阶段所以不管语句怎么排序结果都跟硬件实际行为一致。你可以做个小实验把同样的场景换成阻塞赋值调换语句顺序波形就会变。但硬件本身不会因为你在源代码里换顺序就改变行为所以一旦你的仿真模型依赖了语句顺序这个模型就是有问题的。非阻塞赋值把这个问题从根上规避掉了。3.3 移位寄存器、计数器、FIFO 指针的标准写法下面这几个是工程里最高频的时序逻辑场景统一都用非阻塞赋值。移位寄存器always (posedge clk or negedge rst_n) begin if (!rst_n) begin q0 1b0; q1 1b0; q2 1b0; end else begin q0 din; q1 q0; q2 q1; end end波形上q0、q1、q2依次延迟一拍非常清晰。计数器always (posedge clk or negedge rst_n) begin if (!rst_n) cnt 4b0; else if (cnt 4d9) cnt 4b0; else cnt cnt 1b1; endFIFO 读写指针的更新也是同一套路由读写使能和时钟沿共同驱动寄存器变量全部用。这些代码的共同特点是沿触发的并行采样、按拍推进、一拍一更新用非阻塞赋值和它们的硬件本质完美对应。4. 一次“仿真对、上板错”的定位过程赋值方式导致的仿真失真4.1 问题现象波形正确但板子信号乱我自己调试过一个串行数据接收模块RTL 仿真时 testbench 打激励波形完全正确发送端发的数据能在接收端正确恢复。但上板联调时输出数据总是错位一拍偶尔还会出现第一个字节丢失。当时第一反应是查时钟约束查复位释放时序查跨时钟域处理折腾了两天一无所获。后来打开综合报告发现接收模块里几个寄存器的数量和 RTL 仿真里看到的行为对不上才把怀疑目标转移到赋值方式上。4.2 排查链路后仿 → 综合网表 → 归因到赋值语句我整理一下当时的排查步骤如果你也遇到类似问题可以按这个顺序来先做 RTL 仿真确认功能正常。再做综合后仿真。如果综合后仿真波形和 RTL 仿真不一致问题基本可以锁定在“综合改变了电路结构”这个层面。打开综合报告或原理图检查寄存器数量和连接关系重点关注那些被优化掉或延迟级数异常的寄存器。回到 RTL 源码逐个检查所有always (posedge clk)块里的赋值符号。把可疑的阻塞赋值改成非阻塞赋值重新跑 RTL 仿真和综合后仿真确认两者一致。那次排查到第 4 步时果然在一个数据移位的 always 块里看到了阻塞赋值。改成非阻塞后RTL 仿真和后仿波形终于对齐上板问题也消失了。4.3 信号层面的暴露方式如果没有完整的综合后仿真环境也可以通过另一个方式快速定位对比 RTL 仿真里中间信号的实际延迟拍数。比如你预期q2应该比q0晚两拍但 RTL 波形里q2和q1同拍变化那基本可以怀疑是阻塞赋值的顺序语义在作怪因为真实同步电路里两个触发器不可能在同一沿同时拿到同一个无延迟来源的值。另外综合报告中如果出现了大量被优化掉的寄存器也要去查是不是组合逻辑块被写到了时序块里。这里分享一个经验所有由posedge或negedge触发的 always 块逐个检查左值符号是排查“仿真与硬件行为不一致”时命中率最高的检查项之一而且成本极低。5. 笔试面试题的底层逻辑把这段经历变成判断能力5.1 交换两个变量的经典考法笔试和面试里阻塞赋值和非阻塞赋值几乎是必考题。最常见的变体就是两个变量交换。always (posedge clk) begin a b; b a; end推演一下假设初始a0, b1第一个时钟沿到来时右侧取旧值所以a变成 1b变成 0。第二个时钟沿到来时a变成当前b的值 0b变成当前a的值 1。也就是说每拍都在交换并不是“交换一次然后稳定”。如果在 testbench 的initial块里用阻塞赋值做交换initial begin temp a; a b; b temp; end因为initial块只顺序执行一次所以确实能完成一次交换。这里最关键的是区分“可综合的 RTL 代码”和“测试代码”前者要建模硬件必须符合时序逻辑的并行语义后者只是仿真激励可以按软件顺序思维来写。很多人在这个题上丢分就是没有把这个上下文分清楚。5.2 时序块里的“读-改-写”考法另一个常见变形是always (posedge clk) begin sum din sum; end这种写法在某些简单场景下综合器也能推出加法器和寄存器的结构结果和非阻塞写法可能一致。但问题在于一旦代码复杂比如加入中间变量、多条赋值语句、分支判断阻塞赋值的顺序依赖会让 RTL 仿真结果和综合网表行为分叉而且分叉的方向不可预测。面试时最稳妥的回答是时序逻辑使用非阻塞赋值组合逻辑使用阻塞赋值不允许在同一个 always 块内混用。这个答案既符合工程规范也体现了你对底层原因的理解。5.3 从考题回到真实模块三段式状态机、UART、FIFO这些考点并不只存在于试卷里工程上的应用非常直接。三段式状态机就是最好的例子第一段状态寄存器全部用非阻塞赋值第二段组合逻辑计算次态用阻塞赋值第三段输出逻辑如果采用时序输出也用非阻塞赋值如果采用组合输出用阻塞赋值。每个块各司其职赋值方式清清楚楚。UART 接收模块里的移位寄存器在每个采样时刻把串行数据移进寄存器使用的就是非阻塞赋值always (posedge clk or negedge rst_n) begin if (!rst_n) rx_shift_reg 8b0; else if (rx_en) rx_shift_reg {rx_shift_reg[6:0], rx_din}; endFIFO 的读写指针更新也是同样的原则在使能有效且时钟沿到来时统一更新。可以说理解了阻塞和非阻塞的本质区别再去看这些模块都只需要一眼。6. 我给自己定的几条“防翻车”规则6.1 代码阶段的硬性规范写了这些年 RTL我自己总结了三条死规矩时序逻辑块所有寄存器变量只用哪怕只有一条赋值语句。组合逻辑块统一用并且保证每个分支都有赋值避免锁存器。同一个 always 块内不混用和。代码提交前用 lint 工具扫一遍开启 mixed blocking/nonblocking assignment 相关检查。这类问题靠肉眼 review 会漏工具能帮你兜住大部分低级错误。6.2 仿真阶段的查证动作仿真通过只是第一步我一般在仿真通过后还会做几件事看综合报告核对寄存器的数量是否符合预期特别是移位寄存器、FIFO 指针这类模块寄存器数量很容易暴露问题。有条件就跑综合后仿真这是验证 RTL 与网表行为一致性的直接手段。波形不能只看输出要把关键中间信号拉出来逐拍核对尤其是使能信号和赋值右值是否同拍。如果仿真和预期不符先检查复位、使能、右值来源再检查赋值方式。6.3 遇到诡异问题时的第一反应现在遇到“仿真正确但上板不对”这类问题我的第一反应已经不是查约束而是先全局搜索所有时序 always 块里的符号。这个动作成本极低但命中率很高。类似的看到组合逻辑里多出锁存器就先查是不是有分支没有补全看到数据差一拍就先查使能信号和右值是否跨拍。很多所谓“诡异”的硬件问题归根结底都是对最基本的语义理解不够扎实阻塞赋值和非阻塞赋值就是里面最典型的一个。把这层地基打牢后面的路会顺很多。