ARTICLE DETAIL

资讯详情

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

Verilog阻塞赋值与非阻塞赋值的区别及工程实践

Verilog阻塞赋值与非阻塞赋值的区别及工程实践 1. 认识阻塞赋值与非阻塞赋值1.1 从语法写法入手两个符号的直观差异先看一段最简单的代码// 写法一阻塞赋值 always (posedge clk) begin a b; c a; end // 写法二非阻塞赋值 always (posedge clk) begin a b; c a; end两种写法在语法上只差一个“等号”的样式但综合出来的硬件电路完全不同。这不是风格问题而是功能问题。很多刚接触Verilog的同学都觉得“不就是赋值符号嘛能有多大区别”实际项目里因为搞混这两个符号导致功能异常、时序收敛不过的问题我见过太多次了。阻塞赋值的符号是单个等号非阻塞赋值的符号是。单从写法来说非阻塞赋值更接近我们平时说的“小于等于”符号所以有些教材把它念作“小于等于赋值”不过业内习惯直接叫“非阻塞”。在RTL代码里建议养成看到就条件反射想到“时序逻辑、并行、寄存器的D端”的习惯看到就想到“组合逻辑、立即生效”的习惯。1.2 两者最终生成的电路结构完全不同从综合角度说阻塞赋值在always块内部是一条接一条“当场算完”的语句更像软件程序里那种顺序执行。非阻塞赋值则不同它右边的表达式在时钟沿到来时先统一求值求完值之后左边的变量在更新阶段才被真正改写。这个“先采样后更新”的机制本质上就是在告诉你非阻塞赋值天生就是用来描述寄存器的。我们做FPGA或者是IC前端设计天天跟寄存器打交道。D触发器最核心的行为就是“在时钟沿把D端数据锁存到Q端其他时间Q端保持不变”。这个行为用非阻塞赋值来表达几乎是零损耗的等价映射。阻塞赋值则更适合描述纯组合逻辑比如多路选择器、译码器、加法器的中间结果传递。如果硬要用阻塞赋值去写时序逻辑会得到一个什么样的电路呢我拆给你看。// 错误示范用阻塞赋值描述“移位寄存器” always (posedge clk) begin q1 din; q2 q1; q3 q2; end这段代码从仿真上看确实像移位寄存器din先给q1q1再给q2q2再给q3一个时钟沿之后三级寄存器的值都变了。但综合工具最终给你的是什么它会把这条链路优化成一条组合逻辑直通然后用一个寄存器锁存最终结果。也就是说你写了三个寄存器最后电路里可能只有一个寄存器加一堆组合逻辑甚至可能直接优化掉中间级。这在真实设计里会引起极大的困惑尤其是当你需要精确控制每一级延迟的时候。所以我的建议非常直接只要某个变量是写在always (posedge clk)块里的一律用非阻塞赋值只要某个变量是写在always (*)或always (a or b)组合逻辑块里的一律用阻塞赋值。这条规则能覆盖95%以上的日常场景剩下的5%我们会在后面的章节展开。2. 核心区别与底层原理2.1 先把“执行顺序”这件事聊透阻塞赋值和非阻塞赋值最本质的区别在于对always块内语句“执行顺序”的模拟方式。阻塞赋值是“立即生效”的它在执行的时候会立刻改写左边的变量并且这个新的值会对后续语句立刻可见。如果你写always (*) begin temp a b; y temp | c; end那么temp在第一条语句执行完的瞬间就变成了a b第二条语句用的就是新的temp值等价于y (a b) | c。这就是“阻塞”二字的含义——第一条语句没执行完第二条语句就得等着它是一种串行的执行逻辑。非阻塞赋值则不同它在时钟沿到来时先把所有赋值语句的右侧表达式求值并且把求出来的结果暂时存到一个“看不见的缓冲区”里。等到这一轮的所有语句都“读”完了才在第二个阶段统一把缓冲区里的值更新到左侧变量。这个“先读后写”的机制就是“非阻塞”三个字的来源了。a b不会立刻把b的值塞给a后面的语句读到的a还是旧值。如果把这个机制拆成两个阶段来理解就清楚很多了阶段阻塞赋值非阻塞赋值求值时刻每条语句执行到就立刻求值时钟沿触发时所有RHS统一求值更新时刻同一时刻立即写入LHS所有RHS求值完成后统一写入LHS后续语句可见性能立刻看到新值需要等到下一个时钟沿典型应用场景组合逻辑建模、中间变量传递时序逻辑建模、寄存器、状态机提示如果你刚开始接触Verilog建议直接把“非阻塞赋值 寄存器行为”当成一个核心认知记下来。这不是死记硬背而是因为它背后的硬件本质确实如此。2.2 从仿真事件队列看为什么要“先读后写”很多同学会问为什么Verilog标准非要搞这么一套复杂的两个阶段直接像软件语言一样从上到下执行不行吗答案很简单Verilog是硬件描述语言不是程序语言。硬件世界里同一个时钟沿到来时几十万个寄存器是“同时动作”的不存在谁先谁后的问题。仿真器要模拟这种“同时性”就必须引入一个机制。而“先统一采样再统一更新”这个机制恰好能还原出“所有触发器在时钟沿一起翻转”的效果。具体到仿真事件队列里非阻塞赋值的RHS求值发生在“活跃事件区”LHS的更新则被安排在“非阻塞赋值更新区”。仿真器在处理完当前时间步里所有活跃事件之后才会去执行更新区里的赋值操作。这样设计带来的一个直接影响是如果同一个always块里有两条非阻塞赋值语句它们的RHS用的都是这个时钟沿之前的旧值而不是彼此更新后的新值。我举个swap的例子。如果你在C语言里想交换两个变量必须引入一个临时变量temp a; a b; b temp;但在Verilog时序逻辑里交换两个寄存器根本不需要临时变量always (posedge clk) begin a b; b a; end这段代码综合出来就是两个寄存器交叉连接一个时钟沿之后a拿到了b的旧值b拿到了a的旧值。如果你用阻塞赋值写always (posedge clk) begin a b; b a; end仿真结果会变成a和b都等于b的旧值这显然不是我们想要的交换而且综合出来的电路也会非常奇怪。这个例子很直观能验证“先读后写”四个字建议你亲自跑一下仿真看波形。2.3 用生活类比再聊一遍“同时性”硬件设计里“所有事件同时发生”这个概念对写过软件的人来说是最难转过来的弯。我常用的一个类比是课间操换位置假设一个班级有40个学生每个人都要移动到旁边同学的座位上。如果这40个人是同时行动的那所有人才能准确坐到新位置如果按学号一个一个动第一个人动了之后第二个人就没位置坐了。非阻塞赋值就是“同时行动”的建模方式——每个人在移动前先记住自己下一站是谁的座位右侧求值等到哨声吹响大家一起移动到新位置统一更新。这样就不会出现“你动了我就没法动”的连锁反应。阻塞赋值则是“按学号一个一个来”的方式每个人动完下一个人看到的是最新布局。这两种模式对应的是不同的物理现实硬件世界天然是“同时行动”的而软件世界天然是“一个一个来”的。你的代码选哪种赋值符号本质上就是在选择你描述这个世界的方式。3. 实际场景怎么选赋值规范与工程实践3.1 RTL编码铁律组合逻辑用阻塞时序逻辑用非阻塞这是数字逻辑设计里最经典的一条规则没有之一。我把它称为“RTL编码铁律”因为在绝大多数场景里它就是标准答案。// 组合逻辑阻塞赋值 always (*) begin if (sel) y a; else y b; end // 时序逻辑非阻塞赋值 always (posedge clk or negedge rst_n) begin if (!rst_n) q 1b0; else q d; end具体写成什么样有一个非常朴素的自查方法先看always块的敏感列表。如果敏感列表里有posedge或negedge说明这段代码描述的是时序逻辑里面所有对寄存器变量的赋值必须用如果敏感列表是(*)或者全是电平信号说明是组合逻辑里面所有赋值建议用。为什么时序逻辑里不能用阻塞赋值除了前面说的语义问题还有一个关键原因用阻塞赋值会导致仿真行为与综合结果不一致。仿真器执行阻塞赋值是“顺序执行、立刻生效”的但综合工具却可能把它当成组合逻辑优化掉或者综合出与预期不符的寄存器结构。功能仿真通过了上板验证却出错这种事最容易在阻塞赋值的时序代码里发生。3.2 always块内部混用问题同一个块里能不能两者都用这是个高频问题很多面试官也喜欢问。先说结论RTL代码里不应该在同一个always块里混用阻塞赋值和非阻塞赋值。为什么我们回到Verilog标准对变量类型的要求。被非阻塞赋值写入的变量在综合后对应的是寄存器类型被阻塞赋值写入的变量在综合后可以是线网或者寄存器中间变量。如果你在一个块里混用工具无法确定这个变量最终应该映射成什么类型的硬件结构轻则仿真出现竞争重则综合结果完全不可控。有一个例外情况就是写测试激励testbench的时候。在testbench里为了模拟时钟和激励的时序关系可能会在同一个initial块里混用#10 a b;和#10 c d;这两种写法。但这是仿真专用代码不是可综合RTL两者性质完全不同。我在带新人的时候经常强调写testbench的时候你可以随意一些心情好怎么都行写RTL的时候把自己当成一个守规矩的工匠什么时候用哪个符号没有半点商量余地。3.3 三段式状态机的赋值风格状态机是FPGA设计里最常写的结构三段式写法里也处处体现着阻塞与非阻塞的分工。我把三段式状态机的标准模板放在这里// 第一段时序逻辑当前状态寄存器更新 always (posedge clk or negedge rst_n) begin if (!rst_n) cur_state IDLE; else cur_state next_state; end // 第二段组合逻辑次态判断 always (*) begin case (cur_state) IDLE: begin if (start) next_state SEND; else next_state IDLE; end SEND: next_state DONE; default: next_state IDLE; endcase end // 第三段时序逻辑输出控制信号 always (posedge clk or negedge rst_n) begin if (!rst_n) data_out 8b0; else if (cur_state SEND) data_out tx_data; else data_out 8b0; end注意看第二段和第三段里分别用了什么赋值符号第二段是纯组合逻辑用的是第一段和第三段都是时序逻辑用的是。这就是前面说的铁律落到实处。第一段的cur_state next_state体现的是“时钟沿把次态锁进当前状态寄存器”第三段的data_out tx_data体现的是“状态确认后在下一个时钟沿把数据锁存到输出”。整个结构清清楚楚综合出来就是标准的FSM寄存器结构。3.4 到底有没有“时序逻辑里也可以部分使用阻塞赋值”的例外有但仅仅限于“纯组合逻辑中间变量在always块内用阻塞赋值传递”这个场景跟普通变量赋值是两码事。比如你写一个多级组合逻辑always (*) begin t1 a b; t2 t1 | c; y t2 ^ d; end这里的t1、t2作为组合逻辑中间变量用是完全正常的写法。如果你在这里用反而是错的因为会让t1、t2变成寄存器语义下一拍才更新组合链就被打断了。至于网上有些资料说什么“时序逻辑用非阻塞组合逻辑用阻塞但testbench里可以用阻塞来产生时钟”这话本身没大毛病只是容易让初学者误以为规则可以随便破。记住一个本质你写的是硬件每个变量在综合后必须有明确的硬件含义。有些变量是寄存器的输出有些变量只是线网连线的中间节点把它们等价到正确的赋值符号上代码自然就规范了。4. 常见问题与排查技巧实录4.1 仿真结果与预期不符多半是赋值符号用错了我印象最深的一次调试经历是在一个UART收发模块里。发送状态机在连续发送多个字节时数据总是错一位。我一开始怀疑是波特率分频算错了用示波器抓波形也看不出明显问题后来把仿真波形展开才意识到发送寄存器的更新瞬间比我预期的晚了一拍。问题出在发送模块的代码里有人写了这样的片段always (posedge clk) begin if (tx_done) begin tx_data next_byte; tx_state SEND_START; end end这段代码用阻塞赋值写了时序逻辑。仿真时因为阻塞赋值“立刻更新”tx_data在时钟沿瞬间就变成了next_byte而tx_state的更新看得见但tx_data的“瞬间变化”跟真正的硬件行为对不上。更麻烦的是这样的代码在综合后可能被优化成奇怪的组合逻辑导致网表仿真和RTL仿真结果完全对不上。后来改成非阻塞赋值always (posedge clk) begin if (tx_done) begin tx_data next_byte; tx_state SEND_START; end end再仿真时序就完全符合预期了。这个bug我后来复盘了很久问题的根子不在状态机设计而在赋值符号这种“小细节”上。硬件设计里很多“灵异问题”最后查出来都是这种最基础的用法问题。4.2 组合逻辑always块里漏写else会蹦出锁存器还有一个跟阻塞赋值常一起出现的问题就是组合逻辑块里的变量没有在所有分支被覆盖导致综合出latch锁存器。看这段代码always (*) begin if (enable) y a; endenable为0的时候y要保持原值但组合逻辑没有存储能力工具只能给你生成一个锁存器。很多同学在代码里写完这一段还疑惑为什么综合报告里多了几个latch就是因为条件分支不完整。解决方式很简单给y一个默认赋值。最常见的写法是在always块开头先把所有输出变量赋一个默认值always (*) begin y 1b0; if (enable) y a; end这样enable为0的时候y被默认赋成0不会产生锁存器。这个“默认赋值”的习惯建议一开始就养成它是组合逻辑最稳妥的写法。如果担心这样会多出额外的逻辑放心综合工具会优化的没你想的那么浪费资源。4.3 多级流水线里的“既用阻塞又用非阻塞”的迷思我在不少开源项目里见过一种写法在一个始终是时序逻辑的always块里用阻塞赋值去算一个中间变量再用非阻塞赋值去更新寄存器。比如always (posedge clk) begin temp a b; // 阻塞 sum temp; // 非阻塞 end这段代码仿真起来大概率是对的但风格上我不推荐。原因有两点一是混用两种赋值符号在同一个块里即使功能验证过后期维护的人很容易误解你的意图二是综合工具对这种写法的处理依赖版本和策略可能产生不稳定的结果。正确的做法是分开两个always块或者干脆把中间计算部分提到always块外面用assign语句wire [7:0] temp a b; always (posedge clk) sum temp;这样表达更清晰也完全避免了两类赋值混用的问题。说实话多级流水线逻辑并不复杂复杂的是变量太多导致的“赋值语义混乱”。把中间变量用wire和assign拎出来整个代码的层次会清楚很多。4.4 常见错误与解决方案速查表错误现象可能原因解决方式时序逻辑仿真出现竞争冒险always块里用了阻塞赋值改为非阻塞赋值仿真波形“提前一拍”或“滞后一拍”非阻塞赋值常用在组合逻辑里组合逻辑改回阻塞赋值综合报告出现latch组合逻辑分支不完整每个输出变量加默认赋值同一个always块里混用两种赋值语义不清晰拆分成多个always块或使用wire编译报错“variable x is driven by multiple processes”多个always块对同一个变量赋值一个变量只在一个always块里赋值这张表的最后一条值得单独说一句一个reg变量只能在一个always块里被赋值这是Verilog语法层面的硬性要求。如果你在多个always块里写同一个变量编译直接报错。正确做法是把变量的赋值逻辑收敛到一个always块里或者用wire去组合多个信号源的输出。4.5 仿真调试时看波形的一个小技巧最后一个实用技巧是关于用仿真波形区分两类赋值问题的。如果你在ModelSim或者Vivado Simulator里看到某个信号在时钟沿处有“毛刺”或者“瞬间跳变又跳回”的现象第一反应不应该是怀疑时钟问题而是检查这个信号来源的always块用了什么赋值符号。组合逻辑用阻塞赋值输出通常随输入立即变化在时钟沿没有特殊行为。时序逻辑用非阻塞赋值输出只在时钟沿之后的更新区变化波形上是“沿触发”的。如果你发现某个被赋值的信号在非时钟沿时刻也变了那就要警惕代码里是不是混进了组合逻辑对同一个变量的干扰。把这两类信号在波形图上用不同颜色标记出来排查起来会快很多。我个人在实际调试中的体会是阻塞赋值与非阻塞赋值的问题大多数时候是“设计者写代码时脑子里想的是硬件还是软件”的问题。只要你切换到“硬件视角”告诉自己“每个时钟沿所有寄存器同时采样、同时更新”绝大多数赋值相关的坑都能提前避开。写RTL代码的时候慢一点每写一个always块先问一句“我描述的是组合逻辑还是时序逻辑”再动手写赋值符号这个习惯能帮你省下大把的仿真调试时间。
返回列表