Verilog计数器中的阻塞赋值与非阻塞赋值:从仿真到综合的完整解析

发布时间:2026/9/7 11:22:21
Verilog计数器中的阻塞赋值与非阻塞赋值:从仿真到综合的完整解析 Verilog 计数器看起来是最普通的入门例程但它恰恰是把阻塞赋值和非阻塞赋值讲清楚的最好样本。很多初学者第一次写计数器都会遇到同一种现象代码逻辑看着没问题但仿真要么只加一次就停住要么多个位的跳变顺序和预期的二进制计数完全不一致。真正的问题往往不在计数器本身而在 always 块里到底用了还是。这篇文章直接用计数器做主线把阻塞赋值与非阻塞赋值的底层执行逻辑拆开再结合 testbench、Modelsim 仿真、按键消抖、UART、SPI 等常见场景给出可以直接落地的判断方法和排查顺序。如果你正在学 Verilog或者准备数字 IC 与 FPGA 相关岗位的基础面试这篇值得读完。1. 计数器为什么是理解阻塞/非阻塞赋值的最小样本1.1 计数器模型里藏着寄存器更新的基本动作计数器在硬件上并不复杂一个寄存器、一个加法器、一组组合逻辑。每个时钟上升沿到来时寄存器把组合逻辑算好的下一拍值锁存进来下一个时钟沿再重复这个动作。整个过程可以压缩成一句话读当前状态计算下一状态时钟沿写入。这句话看起来简单但里面藏着两个发生时刻完全不同的动作。“当前状态”是时钟沿到来之前寄存器里保存的旧值“下一状态”是在同一个时钟沿到来时组合逻辑根据当前状态算出来的新值而“写入”则发生在时钟沿的触发之后。阻塞赋值和非阻塞赋值正好对应这个过程中的不同位置。用错了读到的状态和写入的状态就会错位仿真结果自然不正常。很多入门教程会直接告诉你时序逻辑用非阻塞赋值组合逻辑用阻塞赋值。这个结论是对的但只背结论不够。只有把计数器当成观察窗口才能看清两种赋值分别在什么时间点生效。1.2 一个最小可综合计数器示例先看一个最基础的计数器写法。module counter #( parameter WIDTH 8 )( input wire clk, input wire rst_n, input wire en, output reg [WIDTH-1:0] cnt ); always (posedge clk or negedge rst_n) begin if (!rst_n) begin cnt {WIDTH{1b0}}; end else if (en) begin cnt cnt 1b1; end end endmodule这个模块的重点不是功能而是写法的信号含义。clk是时钟rst_n是低有效复位en是计数使能cnt是计数值。当rst_n为低时计数器被清零当rst_n为高且en为高时每个时钟上升沿计数器加一当en为低时计数器保持当前值不变。这里所有寄存器更新都用的是非阻塞赋值。这不是习惯问题而是要让仿真器按照真实寄存器的行为去模拟“并行更新”。多位寄存器在同一个时钟沿同时被改写时彼此读到的都应该是旧值而不是同一时刻已经被改写的新值。1.3 适用读者和预期收获这篇内容比较适合三类人第一类是刚接触 Verilog 的初学者。你们最需要的不只是记住“什么时候用什么时候用”而是理解为什么有这个约定。第二类是已经能跑通简单模块但对仿真波形里那些“奇怪的跳变”没有把握的人。你们可以从计数器的错误写法里找到对照。第三类是准备数字 IC 或 FPGA 岗位基础面试的人。阻塞赋值与非阻塞赋值是高频考点计数器又是最常见的现场分析场景。读完这篇你应该能回答三个问题为什么时序逻辑里默认用非阻塞赋值阻塞赋值在时序逻辑里到底会造成什么危害一个计数器不递增、跳变异常、综合后不工作应该按什么顺序排查。2. 从仿真事件队列看两种赋值语法的执行差异2.1 阻塞赋值立即写入顺序执行阻塞赋值的含义是这一行语句执行完成后当前变量立刻变成新值后续语句能看到这个新值。它像一段普通程序里的赋值语句从上到下顺序执行。前一句改了变量后一句再读这个变量时读到的就是新值。在组合逻辑里这种“立即传递”符合真实硬件中组合逻辑门级信号传播的特点所以组合逻辑的 always 块经常使用阻塞赋值。但在计数器这类时序逻辑里问题就来了。计数器需要模拟的是寄存器行为寄存器在时钟沿到来前保持旧值时钟沿到来后才统一更新。如果在一个由posedge clk触发的 always 块里使用阻塞赋值赋值动作会在仿真时间步的早期完成同一时间步内后面的语句可能看到本次更新后的值这与真实硬件中寄存器采样的语义并不一致。2.2 非阻塞赋值先算好再统一提交非阻塞赋值的逻辑完全不同。当仿真器执行到cnt cnt 1b1时它并不会立刻把cnt改成新值而是把“在某时刻把cnt更新为某个新值”这件事登记到仿真事件队列里。在当前时间步的所有活跃事件执行完成后仿真器再统一提交这些更新。这个机制模拟的正是真实硬件中寄存器的并行更新行为。同一个时钟上升沿会触发多个 always 块这些 always 块在执行时读到的都是旧值。它们各自计算好下一拍需要写入的新值然后在事件队列里统一提交。于是多个寄存器看起来是在同一个瞬间完成更新的彼此之间不会出现“先改 AB 看到新 A”的串行依赖。这也是非阻塞赋值叫“非阻塞”的原因赋值动作并不阻塞当前语句的继续执行它只是被安排到了稍后的更新阶段。2.3 两种赋值的现场对比下面这张表可以直接用来判断赋值语法的使用场景。对比维度阻塞赋值非阻塞赋值赋值时机执行到该行时立即写入当前时间步结束前统一提交后续语句读到什么新值旧值典型使用场合组合逻辑、testbench 中的过程赋值时序逻辑寄存器更新综合后的电路含义组合逻辑或临时变量触发器、寄存器在时序逻辑中使用风险容易产生竞争、额外中间状态更符合寄存器并行更新模型用一个经典例子能快速体会差异。always (posedge clk) begin a b; b a; end这段非阻塞赋值的语义是时钟沿到来后a变成旧bb变成旧a两个寄存器完成交换。如果把两行改成阻塞赋值always (posedge clk) begin a b; b a; end第一行执行后a已经变成旧b。第二行执行时a是新值于是b也会变成旧b。最后a和b都等于旧b交换失败。这个例子很多人背过但只有理解了事件队列才能真正知道为什么失败。2.4 综合器是怎么看待这两段代码的综合器不会逐行翻译 Verilog而是把 always 块映射成电路结构。时序逻辑 always 中使用非阻塞赋值通常会映射成触发器。组合逻辑 always 中使用阻塞赋值通常会映射成组合逻辑门。如果在由posedge clk触发的 always 块里写阻塞赋值综合器仍然可能生成触发器但代码里隐藏的顺序依赖会变成额外的组合判断路径。更麻烦的是仿真与综合可能表现在不同的结果上。仿真器严格按照事件队列执行而综合器更关注信号之间的逻辑依赖。一旦阻塞赋值带来了“同一时间步内先更新后判断”的写法综合结果和 RTL 仿真行为可能不一致这也是很多人遇到的“仿真能过上板就挂”的原因之一。3. 计数器里的错误写法、正确写法和容易误判的边界3.1 常见错误一在时序逻辑里用阻塞赋值进位我看到比较多的新手写法是手动拆进位模仿十进制加法的逐位进位过程。always (posedge clk) begin cnt[0] cnt[0] 1b1; if (cnt[0] 1b0) begin cnt[1] cnt[1] 1b1; end if (cnt[1] 1b0) begin cnt[2] cnt[2] 1b1; end end这段代码在仿真里可能碰巧能跑出计数效果因为阻塞赋值会在同一时间步内“即时更新”低位后面的高位判断马上就能看到低位回绕。但问题也出在这里。综合器可能无法把你的书写顺序完整翻译成同一条组合进位链而且这种写法在位数增加后会生成很长的组合逻辑路径。计数位越宽进位链越长时序收敛压力越大。更好的做法是让综合工具去处理加法进位而不是在 always 里自己拆位。实际上这类代码在多人协作的工程里也很难维护。你写的是计数器但阅读代码的人要花很长时间去确认每一条阻塞赋值更新的顺序是否合理。与其写这种看似高级的手动进位不如老老实实写成cnt cnt 1b1。3.2 常见错误二多个 always 块驱动同一个寄存器另一种高频错误是这样的always (posedge clk) begin if (!rst_n) begin cnt 8d0; end end always (posedge clk) begin if (en) begin cnt cnt 1b1; end end两个 always 块都试图给同一个cnt赋值。仿真器可能不报错但综合工具一般会报 multiple driver 或类似警告因为硬件上无法确定这个寄存器到底由哪个逻辑驱动。判断标准很简单一个 reg 只能在一个 always 块中被赋值。如果复位逻辑和计数逻辑确实要分开写可以拆成两个信号或者把复位、使能、更新都放到同一个 always 块中处理而不是在多个 always 里驱动同一个变量。3.3 推荐写法一个 always 块 非阻塞赋值带计数上限的计数器建议写成这样module counter #( parameter WIDTH 4, parameter MAX 4d9 )( input wire clk, input wire rst_n, input wire en, output reg [WIDTH-1:0] cnt, output wire done ); assign done (cnt MAX); always (posedge clk or negedge rst_n) begin if (!rst_n) begin cnt {WIDTH{1b0}}; end else if (en) begin if (cnt MAX) begin cnt {WIDTH{1b0}}; end else begin cnt cnt 1b1; end end end endmodule这里done是组合输出直接比较cnt是否等于MAX。如果希望done是一个持续一个时钟周期的脉冲并且与后续状态机配合更稳可以在时序逻辑里再打一拍输出。reg done_r; always (posedge clk or negedge rst_n) begin if (!rst_n) done_r 1b0; else done_r done; end用done_r作为扩展一个周期的标志位可以避免组合输出在某些条件下出现毛刺。这里要注意比较cnt MAX时MAX的位宽和数据宽度要一致。如果cnt是 4 位而MAX写成4d10没问题。如果写成10综合工具会按整数位宽处理可能产生额外的比较逻辑或警告。3.4 什么时候用阻塞赋值是合理的默认规则是时序逻辑用非阻塞组合逻辑用阻塞。阻塞赋值确实有合法使用场景而且很重要。第一种是组合逻辑 always 块中计算中间变量。比如一个复杂的多级组合运算先算一个临时结果再基于临时结果继续计算。使用阻塞赋值可以让代码表达顺序化的组合计算流程。第二种是 testbench 中生成组合激励信号。比如用 initial 块给寄存器变量赋值或者用 always 块生成时钟这些场景不牵扯寄存器并行更新语义使用阻塞赋值更直观。第三种是行为级模型。如果你在写一个纯算法仿真模型不关心综合结果可以用阻塞赋值让代码更接近普通编程风格。但这类代码不能直接当可综合工程模块使用。多个场景区分清楚之后就不会看到阻塞赋值就紧张也不会在计数器这种需要严格时序语义的地方随意使用。4. 仿真验证testbench、Modelsim 和波形判断方法4.1 一个最小 testbench 怎么写写 testbench 时需要例化计数器并生成时钟、复位和使能信号。timescale 1ns / 1ps module tb_counter; reg clk 1b0; reg rst_n 1b0; reg en 1b0; wire [3:0] cnt; wire done; counter #( .WIDTH(4), .MAX(4d10) ) dut ( .clk(clk), .rst_n(rst_n), .en(en), .cnt(cnt), .done(done) ); always #10 clk ~clk; initial begin rst_n 1b0; en 1b0; #100; rst_n 1b1; #20; en 1b1; #200; en 1b0; #50; $finish; end endmodule这个 testbench 做了四件事。always #10 clk ~clk;生成周期为 20ns 的时钟。前 100ns 保持复位有效让计数器处于清零状态。复位释放后先等 20ns再把使能拉高避免使能信号和时钟上升沿刚好撞在一起。200ns 后把使能拉低观察计数器保持功能最后结束仿真。更推荐的做法是在关键阶段使用$monitor或把信号加入波形窗口而不是只靠$finish看结果。4.2 判断计数器仿真结果是否正确的三个点仿真结果正确不代表代码没有隐患至少要看三个点。第一复位是不是清零成功。复位期间cnt应该一直为 0复位释放后计数器的第一个状态不应出现高阻或未知值。第二使能控制是否有效。使能为低时计数器保持当前值不递增使能为高时每个有效时钟上升沿递增一。如果使能无效时计数器仍在跳变说明使能逻辑有问题或者把组合逻辑混进了时序逻辑。第三计数到上限后是否回绕。MAX设为 10计数到 10 后下一个有效沿应该回到 0。如果MAX判断缺失计数器会一直加到位宽最大值后自动回绕回绕周期可能是 16而不是 10。更关键的是看更新时刻cnt应该是在时钟上升沿之后更新而不是在上升沿内反复跳变。如果用阻塞赋值写出了组合反馈行为波形上就可能出现一个时钟周期内cnt多次变化的现象这是非常重要的异常信号。4.3 仿真通过但综合后失败先查这里出现“仿真正常综合后不正常”的情况时不要立刻怀疑工具也不要马上改参数。按下面的顺序排查。先看编译和综合警告。综合工具通常会报告 latch、多驱动、未完整敏感列表等警告。这些警告不是摆设往往就是问题源头。再看时钟和复位。时钟是否经过 BUFG复位是否需要同步。如果计数器被分频时钟驱动而该时钟本身又有毛刺仿真时可能不明显上板后就会随机出错。再看代码风格。同一个 always 块里是否混用了阻塞和非阻塞赋值多个 always 块是否驱动同一个 reg时序逻辑中是否用了阻塞赋值来手动进位这些问题在 RTL 仿真中可能被掩盖但在综合后的时序仿真中会被放大。最后降低时钟频率验证功能。如果降频后功能恢复正常大概率不是逻辑问题而是组合路径太长或时序约束不足。这时候应该去看关键路径报告而不是继续调代码逻辑。4.4 常见仿真环境问题很多人搜索“Modelsim 如何仿真 Verilog 文件”其实流程很固定新建工程、添加设计文件和 testbench、编译全部文件、加载仿真、run -all。容易踩坑的点有几个。一是遗漏timescale。testbench 里如果没有timescale默认延迟精度可能不符合预期导致波形看起来乱跳。二是编译顺序。设计文件要在 testbench 之前编译否则例化模块时找不到 module。三是波形窗口里看不到信号。这通常不是仿真器问题而是没有在 run 之前把信号添加进波形窗口或者模块实例名称写错。四是复位释放和使能拉高时机。很多人都曾经在时钟上升沿同一时刻释放复位或拉高使能导致第一个时钟周期状态不确定。解决办法很简单让这些信号的动作避开时钟边沿比如在时钟沿之后一小段时间再变化。5. 从计数器走向真实模块按键消抖、分频、UART、SPI 与 FIR5.1 按键消抖一个计数器最容易写出隐性竞争的场景按键消抖是计数器最常见的工程应用之一。核心思路是检测按键电平稳定持续时间超过一定时间后认为按键有效。always (posedge clk or negedge rst_n) begin if (!rst_n) begin cnt 20d0; key_reg 1b1; key_out 1b1; end else if (key_in ! key_reg) begin key_reg key_in; cnt 20d0; end else if (cnt 20d1_000_000) begin key_out key_in; end else begin cnt cnt 1b1; end end这段代码把按键电平寄存、消抖计数、输出更新放在同一个 always 块里全部使用非阻塞赋值。这里最容易写错的是key_reg。如果key_reg key_in改成key_reg key_in那么同一个 always 块内后面的key_in ! key_reg判断会立即读到新值消抖计数器还没加满就不断被清零。表面上看代码没有语法错误但按键永远消不了抖。用非阻塞赋值key_reg在当前时间步保持旧值计数器可以稳定累计到预设阈值之后才把按键电平输出。这个场景比普通计数器更能说明“非阻塞赋值让多个状态基于同一拍旧值推进”的意义。5.2 分频器和波特率发生器里的非阻塞赋值分频器也是计数器的经典应用。偶分频的通用思路是计数器从 0 计数到DIV_PARAM-1然后回绕输出电平根据计数值中间点翻转。always (posedge clk or negedge rst_n) begin if (!rst_n) cnt 0; else if (cnt DIV_PARAM - 1) cnt 0; else cnt cnt 1; end assign clk_div (cnt (DIV_PARAM 1)) ? 1b0 : 1b1;clk_div是组合输出会随cnt的变化产生电平变化。如果计数器内部使用阻塞赋值分频输出可能在同一仿真时间步内出现多次跳变波形看起来就是毛刺。UART 的波特率发生器本质上也一样。按公式计算需要多少个系统时钟周期产生一个波特率采样点然后用计数器生成使能。这个计数器的稳定程度直接影响串口误码率。5.3 UART、SPI、I2C 等总线场景中的计数器UART 发送一个字节时不仅需要波特率分频计数还要记录当前发送的是第几个位同时要控制移位寄存器。SPI master 也是类似场景。SCLK 分频计数器、当前位编号计数器、移位数据寄存器必须在同一个时钟域内同步推进。如果使用阻塞赋值三条语句会按书写顺序影响变量位计数器可能提前看到移位寄存器更新后的数据从而导致发送时序错乱。I2C 更明显。SCL 电平产生、位计数、数据线方向切换、ACK 采样多个状态变量在同一时钟沿并行更新。用非阻塞赋值才能保证所有状态都基于上一拍的值同步变化。这类总线模块的代码量会比计数器大很多但赋值选择原则没有变凡是被时钟沿触发更新的寄存器全部使用非阻塞赋值。5.4 FIR 和滑动窗口滤波中的赋值边界涉及 FIR 或滑动窗口滤波时经常有人把组合累加和寄存器移位塞进同一个 always 块然后混用和。建议拆开写。移位寄存器属于典型时序逻辑。数据在每个时钟沿移入窗口应该使用非阻塞赋值。always (posedge clk or negedge rst_n) begin if (!rst_n) data_shift d0; else data_shift {data_shift[WIDTH-2:0], data_in}; end累加和乘加部分属于组合逻辑可以用assign或组合 always 的阻塞赋值去实现。如果坚持放在同一个 always 里至少要做到逻辑清晰时序更新部分用非阻塞组合中间结果用阻塞。但这样做很容易在后续维护时引入误判所以更稳妥的方案是物理上拆开不同 always 块一个管时序状态一个管组合计算。6. 排查清单与编码习惯先看现象再看输入最后改参数6.1 计数器常见现象与排查顺序现象常见原因优先排查计数器不递增en始终为 0、复位未释放、时钟没跑看时钟、复位、使能三根线计数跳变异常时序逻辑里用了阻塞赋值、多个 always 驱动搜索代码中的阻塞赋值、检查多驱动计数到某个值后回绕位宽溢出或MAX判断缺失确认位宽和计数上限综合出现 latch 警告组合 always 缺少默认分支或敏感列表不完整补else或设置默认值综合后时序仿真失败时钟约束不足、组合路径过长降低频率验证功能查看关键路径我一般按照“现象、输入、环境、参数、工具”的顺序排查。先看现象。到底是完全不计数、计数到某值停止还是计数序列杂乱。再看输入。时钟、复位、使能三根线在仿真波形里是否正常。再看环境。依赖版本、编译顺序、timescale 是否缺失。然后才考虑参数。位宽够不够MAX是否合理是否用了魔法数字。最后才怀疑工具或综合器。现在 AI 工具生成 Verilog 代码越来越常见但生成结果经常出现赋值风格混乱的问题。计数器恰恰是最容易暴露这个问题的模块因为它足够短代码里的每一次赋值都可以人工检查。6.2 位宽、计数上限和回绕行为无符号计数器加到最大值后继续加会自动回绕到 0。回绕周期是 2 的 N 次方其中 N 是计数位宽。很多设计并不是 2 的 N 次方周期计满回绕而是要求模 100、模 1000 这类任意模值。比如模 100 计数器需要计数到 99 后回 0而不是等 8 位计数器从 255 自然回绕。自然回绕周期是 256不是 100两者差距很大。所以写计数器时要明确三个量位宽、计数上限、回绕条件。parameter COUNT_MAX 100; always (posedge clk or negedge rst_n) begin if (!rst_n) cnt d0; else if (cnt COUNT_MAX - 1) cnt d0; else cnt cnt 1b1; end这种写法把模值显式表达出来代码可读性和可维护性都更好也不会出现位宽不够导致永远比较不到上限值的问题。6.3 代码评审时可以执行的几条硬规则计数器虽然小但代码评审时最值得盯几条规则。第一条一个 reg 只能在一个 always 块中赋值。多驱动问题必须在编码阶段就避免。第二条时序逻辑用非阻塞组合逻辑用阻塞。同一 always 内不要混用。第三条不要用阻塞赋值手动拆进位。加法器进位交给综合器处理。第四条敏感列表要用完整写法。时序逻辑明确列出时钟和复位组合逻辑使用(*)。第五条复位、使能、时钟三个信号不要组合出未知逻辑。不要对时钟信号直接赋值也不要在多个 always 里生成同一根时钟。第六条所有参数用parameter或localparam不要直接写魔法数字。计数器上限变更时只动参数不改逻辑。这六条规则能覆盖大部分计数器常见问题。6.4 新手练习路径如果你想把这个主题真正学扎实可以按下面这条路径练。第一步写一个 4 位计数器使用非阻塞赋值仿真观察cnt更新与时钟沿的关系。第二步把非阻塞改成阻塞赋值重新仿真。对比波形差异特别注意cnt是否在一个时间步内出现多次变化。第三步给计数器增加使能、清零、计数上限和回绕条件。第四步写一个组合逻辑模块例如比较器或选择器使用阻塞赋值。体会两种赋值在仿真里的不同节奏。第五步把计数器放入按键消抖或 UART 波特率发生器验证你在简单模块中形成的赋值习惯能否在复杂模块中保持稳定。第六步综合一次看警告。重点看是否有 latch、多驱动、未完整敏感列表。这条路走完你对阻塞赋值和非阻塞赋值的理解就不再是背结论而是真正能落到代码上。阻塞赋值与非阻塞赋值的争议本质不是语法偏好而是仿真事件模型与电路结构映射之间的关系。计数器足够小却能完整展示这种关系。先花时间把最小计数器、错误写法和 testbench 波形看清楚再去碰按键消抖、UART、SPI你会少踩很多坑。