ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Verilog阻塞与非阻塞赋值:从电路语义到工程实践详解

Verilog阻塞与非阻塞赋值:从电路语义到工程实践详解 每个学Verilog的人几乎都会被“阻塞赋值和非阻塞赋值”这个概念折磨过。我当年刚接触FPGA的时候写的第一个计数器就是用赋值的仿真波形看着挺正常一上板子就各种诡异后来才明白是阻塞赋值在时序逻辑里埋了雷。这玩意儿看似只是两个符号的区别——和背后却对应着完全不同的硬件行为理解不到位写出来的代码仿真能过、综合出问题、上板跑飞哪一步都能让你欲哭无泪。这篇文章我想把阻塞赋值和非阻塞赋值这件事彻底讲透。不仅讲语法规则还会从电路结构、仿真原理、综合结果的角度拆开来看配合具体的代码示例和波形分析最后再聊聊笔试面试里围绕这个知识点常见的坑。不管是刚入门Verilog的学生还是准备IC秋招的应届生亦或是写了一段时间RTL但偶尔还会犹豫“这里到底该用哪个”的工程师这篇应该都能帮到你。1. 赋值符号背后的电路语义和到底代表什么1.1 两种赋值的语法形态与执行规则先说最基础的语法。Verilog里有两种过程赋值语句分别用和表示。阻塞赋值用的是等号写法长这样always (*) begin y a b; end非阻塞赋值用的是小于等于号写法长这样always (posedge clk) begin q d; end语法上唯一的区别就是一个用一个用但两者执行逻辑完全不同。阻塞赋值的核心特征是赋值语句立刻生效。也就是说执行完a b;之后a的值马上变成b的值如果紧接着再执行c a;那c拿到的是a更新之后的“新值”而不是a原来的旧值。整个always块从上到下按顺序执行就像C语言里的普通赋值一样。非阻塞赋值的核心特征是所有右值先统一采样等到当前时间步结束时再统一更新左值。也就是说在一个always块里不管写了多少条赋值这些语句右边的表达式都是在同一个时刻被采样采样用的都是更新前的旧值采样完成之后才一起把结果赋给左边的变量。赋值操作不会阻塞后续语句的执行所以叫“非阻塞”。这个区别用一句话概括是“先算先赋值一条一条来”是“全部先采样最后统一更新”。就这一句话很多人背得滚瓜烂熟但真要落到代码上、落到硬件上就开始犯迷糊了。1.2 硬件视角为什么两种赋值对应不同的电路结构阻塞赋值和非阻塞赋值不是Verilog语言设计者拍脑袋搞出来的两套写法它们对应的硬件语义从根上就是两套逻辑。阻塞赋值天然对应组合逻辑。因为赋值是立即生效的信号直接透传就像一条导线一样输入变了输出马上跟着变。我们写一个多路选择器always (*) begin if (sel) y b; else y a; end综合出来的电路就是一个纯组合的MUXsel一变y立刻跟着切换中间没有任何存储元件。非阻塞赋值天然对应时序逻辑。因为左值是在时钟沿到来之后统一更新的而且更新用的采样值是时钟沿之前的旧值这个行为跟D触发器的特性完全一致——D触发器也是时钟沿采样输入端的旧值然后在时钟沿之后把采样到的值锁存到输出端。所以always (posedge clk) begin q d; end综合出来就是一个标准的D触发器。这就是为什么行业里有句话叫“时序逻辑用非阻塞组合逻辑用阻塞”本质上不是你非要遵守什么教条而是这种“先采样后更新”的语义天生就是为触发器量身定做的。用生活化的方式去理解阻塞赋值就像流水线上的工人每个人拿到上一个工人手里的零件马上加工加工完立刻递给下一个人整条线是串行的。非阻塞赋值更像公司群发通知所有员工同时收到邮件收到之后各自在同一个截止时间之前完成自己的任务每个人照着邮件里的旧信息行动不会出现“你先做完你再通知我”的先后依赖。2. 用一个最简单的实验把两者的差别彻底看清2.1 两行代码做数据交换阻塞赋值翻车现场理论说再多不如直接跑个仿真。最经典的对比实验就是数据交换。先看阻塞赋值版本。timescale 1ns / 1ps module swap_assign_test; reg a, b; initial begin a 1; b 0; $display(before: a%0d b%0d, a, b); #10; a b; b a; #10; $display(after : a%0d b%0d, a, b); end endmodule这段代码没有时钟就是纯粹在initial块里做顺序赋值。执行过程是a b;执行后a立即变成0接着b a;执行时a已经是0了所以b也变成0。最终结果是a0, b0说好的交换呢两个都变成原来的b了。再看非阻塞版本。module swap_assign_test; reg a, b; initial begin a 1; b 0; $display(before: a%0d b%0d, a, b); #10; a b; b a; #10; $display(after : a%0d b%0d, a, b); end endmodule执行过程这两条非阻塞语句放在同一个时刻右值先被采样——b采样到0a采样到1。然后到这个时间步结束时统一更新左值a变成0b变成1。最终结果是a0, b1交换成功。同样的两行代码只是换了个赋值符号结果天差地别。这个例子在面试里被问过无数次就是考察你知不知道非阻塞赋值“右值先采样、左值后更新”这个核心语义。理解了这一点后面很多问题都能迎刃而解。2.2 时钟边沿下的竞争理解仿真中的“不确定行为”上面只是initial块里的演示真正的麻烦出在时钟驱动的always块里。考虑这样一个场景两个always块同时被同一个时钟沿触发module race_test; reg clk 0; reg a, b; always #5 clk ~clk; // 块1用阻塞赋值驱动 a always (posedge clk) begin a b; end // 块2用阻塞赋值驱动 b always (posedge clk) begin b a; end endmodule这段代码的阴险之处在于两个always块在同一个时钟沿触发而它们都在给对方赋值。如果块1先执行a先变成b的旧值然后块2执行时b再从a那里拿值——但这时候a已经被更新了拿到的是新值反之如果块2先执行结果又不一样。到底哪个块先执行取决于仿真器的调度算法不同仿真器、甚至同一仿真器的不同版本结果都可能不一样。这就是典型的仿真竞争。换成非阻塞赋值就完全不一样了always (posedge clk) begin a b; end always (posedge clk) begin b a; end两个块在同一时刻分别采样b和a的旧值然后统一更新。无论哪个块“先”被执行采样到的都是上一个时钟周期留下的旧值结果完全确定。这就是为什么处理这种跨块信号交互时非阻塞赋值能从根本上消除仿真层面的竞争问题。2.3 RTL仿真和综合结果为什么会出现“两副面孔”很多人会遇到一种情况仿真波形完全正常但综合之后功能就不对了。这种问题很多时候就是阻塞赋值在时序逻辑里埋的雷。原因在于仿真器是严格按照Verilog语义在“模拟”代码行为而综合器是把代码翻译成实际的硬件结构。对于时序逻辑代码如果混用了阻塞赋值仿真器看到的是一组顺序执行的逻辑但综合器在推断硬件时会尝试把always块里的逻辑映射成触发器和组合逻辑的组合。考虑这段代码always (posedge clk) begin a b; c a; end仿真器眼里时钟沿到来a先变成b的值随后c再变成a的新值。一切顺理成章。但综合器眼里a和c都是在一个时钟沿下被更新的寄存器吗c的新值依赖的是a更新之后的值而a又是在同一个时刻被更新的——这就产生了一个矛盾。综合器很可能推断出a不是一个真正的寄存器而是仅仅作为中间节点存在于是c直接连到b的逻辑链上硬件行为变成“c直接采样b”跟仿真行为完全脱节。所以RTL仿真“过了”不代表硬件就是对的。仿真通过只是第一步编码风格是否正确、是否能被综合成你期望的电路结构是另一回事。这也是为什么各大IC公司都有严格的代码规范要求“时序逻辑必须用非阻塞赋值”并不仅仅是风格偏好而是直接关系到仿真与综合的一致性。3. 工程上的黄金法则什么时候用阻塞什么时候用非阻塞3.1 时序逻辑一律使用非阻塞赋值工程上最核心的一条规则就一句话在always (posedge clk)或always (negedge clk)这种时序逻辑块里一律用非阻塞赋值一条阻塞都不要混进去。这条规则背后有两个理由。第一个理由是硬件正确性。时序逻辑的本质就是D触发器触发器的行为就是“时钟沿采样输入旧值然后同步更新输出”。非阻塞赋值的“先采样后更新”语义和D触发器天然一致所以用才能让RTL代码的语义精确映射到硬件上。如果用了阻塞赋值上面已经分析过很可能出现仿真与综合不一致的问题。第二个理由是代码可维护性。时序逻辑块里通常不止一条赋值语句比如一个计数器块里既有计数逻辑又有清零逻辑还有可能加上使能条件。用非阻塞赋值你不需要关心语句的书写顺序每条语句在同一个时钟沿下都是并行采样、并行更新的逻辑上简洁清晰。用阻塞赋值的话语句顺序会对仿真结果造成影响代码一复杂靠“调整代码顺序”来修bug最后往往改出一堆隐性问题。举个典型的计数器例子这是最基础但也最容易写错的地方// 正确写法时序逻辑使用非阻塞赋值 always (posedge clk or negedge rst_n) begin if (!rst_n) cnt 8d0; else if (cnt_en) cnt cnt 1b1; else cnt cnt; end注意cnt cnt 1b1;这一行右边采样的cnt是当前时钟沿之前的旧值加一之后锁存到新值这正好是一个加法器加寄存器组成的计数器的硬件行为。如果这里写成cnt cnt 1b1在部分综合器和仿真环境下结果可能碰巧一样但一旦这个块里还有其他赋值语句顺序问题就会立刻冒出来而且代码审查阶段大概率也会被要求改回来。3.2 组合逻辑使用阻塞赋值并注意默认赋值组合逻辑块也就是always (*)或者always (信号列表)这种写法应该使用阻塞赋值。原因是组合逻辑要求输出对输入即时响应没有存储环节信号的传递是“穿透性”的。阻塞赋值的“立即生效”特性正好符合这种需求。而且组合逻辑块里的赋值顺序是有意义的——后面语句可以读取前面语句刚赋的新值相当于搭建了一条组合逻辑链。一个典型例子是用case语句写译码器always (*) begin case (state) 2b00: y 4b0001; 2b01: y 4b0010; 2b10: y 4b0100; 2b11: y 4b1000; default: y 4b0000; endcase end如果这里用了非阻塞赋值仿真上也能凑合跑但语义上就不对了——非阻塞赋值的“延迟更新”特性意味着输出不会立刻反映输入的变化这在组合逻辑里是说不通的。虽然大多数综合器还是会综合出同样的组合电路但仿真行为可能和预期有偏差而且这种代码风格明显不符合行业规范code review的时候肯定被喷。这里还有一个容易被忽略的点组合逻辑块里应该养成赋默认值的习惯。看这段代码always (*) begin y 1b0; // 默认值 if (sel) y a; else y b; end如果缺少y 1b0;这一行某些分支条件下y可能会保持旧值综合时就会推断出锁存器Latch这在大多数设计中是致命的。关于锁存器的问题后面还会聊到。3.3 特殊场景同一个always块里能不能混用两种赋值这个问题的答案是可以但不推荐只在少数特定场景才会这么做。最常见的混用场景是在一个时序逻辑块里声明一个integer或reg类型的中间变量先通过阻塞赋值把这个中间变量算出来然后用非阻塞赋值把结果锁存到寄存器输出。比如always (posedge clk or negedge rst_n) begin integer temp; if (!rst_n) begin data_out 8d0; end else begin temp data_in 8d1; // 阻塞赋值计算中间结果 data_out temp; // 非阻塞赋值寄存器输出 end end这种写法在一些算法模块里能看到利用阻塞赋值算组合逻辑结果再通过非阻塞赋值完成寄存器采样。逻辑上没有问题前提是你清楚自己在做什么。但从代码风格和可维护性角度我强烈建议把这种逻辑拆开组合逻辑的temp计算放到一个独立的always (*)块里时序寄存输出放到另一个always (posedge clk)块里。工程上有一句忠告——“一个always块只干一件事”这不是老古董的教条而是无数仿真和综合不一致的惨痛教训换来的经验。代码拆开之后每个块的职责单一、风格统一问题定位也容易得多。4. 核心实操五个高频场景的正确写法与常见错误4.1 跨时钟域打拍同步非阻塞赋值的经典应用所有做FPGA的人都会遇到跨时钟域的问题。一个异步信号进到FPGA里如果不做处理直接采样很容易出现亚稳态导致系统功能随机性出错。最常规的处理方式就是两级寄存器打拍同步。reg sync_r, sync_rr; always (posedge clk or negedge rst_n) begin if (!rst_n) begin sync_r 1b0; sync_rr 1b0; end else begin sync_r din; // 第一级采样 sync_rr sync_r; // 第二级采样 end end assign dout sync_rr;这里必须用非阻塞赋值。原因很简单两级寄存器在同一边沿更新如果用阻塞赋值sync_rr会在同一个块里立刻获取sync_r更新后的新值相当于把两级寄存器打拍“偷工减料”成了一级寄存器失去了降低亚稳态的作用。我之前调试一个UART接收模块波特率较高时偶尔会出现误码后来查了很久发现不是波特率配置问题而是接收端的异步信号没有做打拍处理导致亚稳态传到状态机里。加了两级同步寄存器之后问题彻底消失。这个例子充分说明非阻塞赋值和打拍逻辑是绑定在一起的少一个都不行。4.2 移位寄存器和流水线寄存器换种写法就出bug移位寄存器也是非阻塞赋值的经典场景。把数据逐级向后传递本质上和打拍是一个道理。reg [7:0] shift_reg; always (posedge clk or negedge rst_n) begin if (!rst_n) shift_reg 8d0; else begin shift_reg[7] data_in; shift_reg[6] shift_reg[7]; shift_reg[5] shift_reg[6]; shift_reg[4] shift_reg[5]; shift_reg[3] shift_reg[4]; shift_reg[2] shift_reg[3]; shift_reg[1] shift_reg[2]; shift_reg[0] shift_reg[1]; end end在的语义下这些移位语句右侧采样的都是时钟沿之前的旧值所以能正确地把每个bit同时向后移一位。而如果用阻塞赋值第一行shift_reg[7] data_in和最后一行shift_reg[0] shift_reg[1]就会产生顺序耦合——最后一行读取的可能是第一行刚更新的新值移位行为就完全错了。流水线寄存器也是同样的逻辑。比如一个两级流水线中间要插入寄存器来切割组合逻辑路径写法就是每组寄存器用非阻塞赋值。这块写习惯了其实没什么难度就怕那种“图省事把多个流水级写在一个always块里还混用赋值符号”的操作代码一复杂真的会让人查到怀疑人生。4.3 三段式状态机三段各自该用什么赋值状态机是另一个阻塞/非阻塞赋值高频踩坑的地方。老老实实按三段式写每个块的赋值风格是固定的第一段时序逻辑状态跳转用第二段组合逻辑次态判断和输出译码用第三段时序逻辑输出寄存用一个典型的三段式状态机框架// 第一段状态跳转 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 next_state state; // 默认保持 case (state) IDLE: if (start) next_state RUN; RUN: if (done) next_state IDLE; endcase end // 第三段输出寄存器这里以Moore型为例 reg out_reg; always (posedge clk or negedge rst_n) begin if (!rst_n) out_reg 1b0; else out_reg (state RUN); end第二段为什么必须用因为next_state是一个组合逻辑信号它需要在同一次组合逻辑响应中根据当前状态和输入条件立刻算出结果并且要能在不同条件下多次赋值这就是默认赋值的作用。如果用次态更新会延迟一拍状态机行为完全乱套。很多人在笔试里会遇到“给你一个状态机代码片段找出哪里写错了”的题十有八九就是“时序块里用了阻塞赋值”或者“组合块里用了非阻塞赋值”这种经典错误。4.4 数据交换与打拍笔试和面试的最爱前面已经演示过数据交换的initial块例子。在时序逻辑里数据交换其实是流水线中很常见的操作比如乒乓缓冲、双寄存器交换等场景。always (posedge clk or negedge rst_n) begin if (!rst_n) begin reg_a 32d0; reg_b 32d0; end else begin reg_a reg_b; reg_b reg_a; end end两边的数据在一个时钟沿下互相交换正是因为没有中间变量就能交换数据非阻塞赋值才被称为“硬件描述语言的灵魂特性”。面试官问“为什么非阻塞赋值可以不用临时变量就能交换两个寄存器的值”本质上就是在考察你是否掌握了“先采样后更新”这个核心语义。如果这里用阻塞赋值就必须要引入一个临时变量否则交换失败。这恰好也是一个很好的记忆锚点看到交换逻辑第一条反应就应该是非阻塞赋值。4.5 生成块和循环中的赋值陷阱再补充一个进阶一点的场景generate和for循环里的赋值。很多人在for循环里顺手就写了阻塞赋值这在组合逻辑里一般没问题但放到时序块里就要格外小心。组合逻辑中用for循环加阻塞赋值做循环展开是常见操作always (*) begin parity 1b0; for (i 0; i 8; i i 1) begin parity parity ^ data_in[i]; end end这个循环需要依赖上一步的结果所以必须用阻塞赋值把循环展开成一条异或链。如果用非阻塞赋值每次迭代采样到的都是循环前的旧parity算出来的结果永远是data_in[0]完全不对。而时序逻辑里如果要用for循环例如批量寄存器初始化就必须确保循环体内部用的是非阻塞赋值并且循环体的迭代逻辑不依赖刚刚赋的值always (posedge clk or negedge rst_n) begin if (!rst_n) begin for (i 0; i 8; i i 1) mem[i] 8d0; end end这里每条mem[i] 8d0都是独立的、互不依赖的所以完全没有问题。遇到这种批量处理的代码先想清楚循环体内部有没有“前一条赋值被后一条读取”的依赖关系再决定用哪个符号基本就不会错。5. 仿真、综合和笔试里的高频坑替你踩过的都整理在这里5.1 仿真波形正常但上板失败先检查赋值符号在FPGA调试中有一种很典型的现象ModelSim或者Vivado Simulator里波形跑得好好的但下载到板子上功能就是不对。很多人第一反应是约束问题、时钟问题实际上有一类原因就是RTL代码里用了不恰当的赋值符号导致仿真行为与综合电路行为脱节。比如在时序逻辑里这么写always (posedge clk) begin a b; c a; end仿真时c会先看到a的新值再赋值看起来一切正常但综合后a很可能被优化成一个内部节点c直接被推断为采样b的触发器整个数据链路的硬件行为和仿真行为完全不一致。代码里信号一多这种问题靠看波形很难定位最终还是要靠代码审查才能发现。所以在调试“仿真过了但上板失败”的问题时第一轮就该检查时序逻辑块里的赋值符号。一旦发现时序always块里出现任何先改掉再说往往能省下大量排查时间。5.2 笔试面试高频题从概念到代码的考察方式这套知识点在IC秋招笔试里的出现频率高得离谱。最基础的问法是“阻塞赋值和非阻塞赋值的区别”稍微进阶一点就是“为什么时序逻辑要用非阻塞赋值”再到代码题就是“指出下面代码的错误”或者“写出某个状态的转移逻辑”。我整理了一个高频速查表遇到这类题直接对照考察点关键回答方向两者语法区别立即生效先采样后更新综合出的电路阻塞→组合逻辑非阻塞→触发器/时序逻辑为何时序用非阻塞匹配D触发器行为避免仿真与综合不一致为何组合用阻塞信号即刻透传支持顺序逻辑展开数据交换写法非阻塞赋值可直接交换阻塞需要临时变量混用风险同一块中混用容易导致推断出锁存器或综合异常还有一个容易被问到的细节阻塞赋值会不会导致锁存器要分情况看。组合逻辑中如果某些分支缺少赋值默认值不管用哪种赋值都可能推断出锁存器。非阻塞赋值导致锁存器的说法不准确——锁存器的产生是因为“有些条件下变量没有被赋值需要保持旧值”跟赋值符号本身关系不大。5.3 典型错误代码速查与修复对照我把实际工程里最常见的几类错误写法整理成一个对照表方便自查错误写法问题现象正确写法时序块中用给多个寄存器赋值仿真行为依赖书写顺序综合结果可能与仿真不一致时序块全部改用组合块中用做逻辑运算输出延迟一拍仿真行为和预期不符组合块全部改用组合逻辑缺少默认赋值推断出锁存器电路时序紊乱在块开头给所有输出赋初值跨时钟域信号直接采样亚稳态导致系统随机性故障加两级寄存器打拍必须用两个always块用互相驱动仿真结果不确定产生竞争统一改用同步更新这些错误说起来都简单但实际项目里因为代码量大、信号交错排查起来非常折磨人。我见过最夸张的一个case工程师花了两天时间调一个UART发送模块的上板异常最后发现只是接收端一个标志位在时序块里用了阻塞赋值导致整个状态机链路错乱。改一个符号问题消停了。5.4 关于代码风格的最后建议从编码风格角度我个人的习惯是每个always块只用一种赋值符号要么全要么全除非有特别清晰的临时变量计算需求否则坚决不混用。这个习惯让我少踩了无数坑。还有一个值得养成的习惯写完代码之后花两分钟通读一遍所有always块确认每个块对应的逻辑类型。看到always (posedge clk)就检查里面是不是全部用的看到always (*)就检查里面是不是全部用的。这个简单的自查习惯能过滤掉大部分低级错误而且在面试中带着这种严谨性去答题印象分会好很多。写在最后说实话阻塞赋值和非阻塞赋值的知识点并不难难的是真的把它变成肌肉记忆。我在写I2C控制器的时候一开始也犯过“组合逻辑里顺手写了个”这种低级错误仿真时闹了半天才发现问题。后来养成“先定逻辑类型、再选赋值符号”的习惯这类问题基本就绝迹了。最后再分享一个小技巧当你拿不准一个信号该用还是的时候先问自己一个问题——“这个变量是希望在时钟沿的瞬间被锁存还是希望输入一变它就跟着变”前者用非阻塞后者用阻塞。把这个问题想清楚比背再多规则都管用。Verilog的语法不多但每一个符号背后都对应着真实的硬件行为把符号和硬件绑在一起理解才是学这门语言最快的路径。
返回列表