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{1'b0}}; end else if (en) begin cnt <= cnt + 1'b1; 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 + 1'b1时,它并不会立刻把cnt改成新值,而是把“在某时刻把cnt更新为某个新值”这件事登记到仿真事件队列里。在当前时间步的所有活跃事件执行完成后,仿真器再统一提交这些更新。
这个机制模拟的正是真实硬件中寄存器的并行更新行为。
同一个时钟上升沿会触发多个 always 块,这些 always 块在执行时读到的都是旧值。它们各自计算好下一拍需要写入的新值,然后在事件队列里统一提交。于是,多个寄存器看起来是在同一个瞬间完成更新的,彼此之间不会出现“先改 A,B 看到新 A”的串行依赖。
这也是非阻塞赋值叫“非阻塞”的原因:赋值动作并不阻塞当前语句的继续执行,它只是被安排到了稍后的更新阶段。
2.3 两种赋值的现场对比
下面这张表可以直接用来判断赋值语法的使用场景。
| 对比维度 | 阻塞赋值= | 非阻塞赋值<= |
|---|---|---|
| 赋值时机 | 执行到该行时立即写入 | 当前时间步结束前统一提交 |
| 后续语句读到什么 | 新值 | 旧值 |
| 典型使用场合 | 组合逻辑、testbench 中的过程赋值 | 时序逻辑寄存器更新 |
| 综合后的电路含义 | 组合逻辑或临时变量 | 触发器、寄存器 |
| 在时序逻辑中使用风险 | 容易产生竞争、额外中间状态 | 更符合寄存器并行更新模型 |
用一个经典例子能快速体会差异。
always @(posedge clk) begin a <= b; b <= a; end这段非阻塞赋值的语义是:时钟沿到来后,a变成旧b,b变成旧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] + 1'b1; if (cnt[0] == 1'b0) begin cnt[1] = cnt[1] + 1'b1; end if (cnt[1] == 1'b0) begin cnt[2] = cnt[2] + 1'b1; end end这段代码在仿真里可能碰巧能跑出计数效果,因为阻塞赋值会在同一时间步内“即时更新”低位,后面的高位判断马上就能看到低位回绕。
但问题也出在这里。
综合器可能无法把你的书写顺序完整翻译成同一条组合进位链,而且这种写法在位数增加后会生成很长的组合逻辑路径。计数位越宽,进位链越长,时序收敛压力越大。更好的做法是让综合工具去处理加法进位,而不是在 always 里自己拆位。
实际上,这类代码在多人协作的工程里也很难维护。你写的是计数器,但阅读代码的人要花很长时间去确认每一条阻塞赋值更新的顺序是否合理。与其写这种看似高级的手动进位,不如老老实实写成cnt <= cnt + 1'b1。
3.2 常见错误二:多个 always 块驱动同一个寄存器
另一种高频错误是这样的:
always @(posedge clk) begin if (!rst_n) begin cnt <= 8'd0; end end always @(posedge clk) begin if (en) begin cnt <= cnt + 1'b1; end end两个 always 块都试图给同一个cnt赋值。仿真器可能不报错,但综合工具一般会报 multiple driver 或类似警告,因为硬件上无法确定这个寄存器到底由哪个逻辑驱动。
判断标准很简单:一个 reg 只能在一个 always 块中被赋值。如果复位逻辑和计数逻辑确实要分开写,可以拆成两个信号,或者把复位、使能、更新都放到同一个 always 块中处理,而不是在多个 always 里驱动同一个变量。
3.3 推荐写法:一个 always 块 + 非阻塞赋值
带计数上限的计数器建议写成这样:
module counter #( parameter WIDTH = 4, parameter MAX = 4'd9 )( 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{1'b0}}; end else if (en) begin if (cnt == MAX) begin cnt <= {WIDTH{1'b0}}; end else begin cnt <= cnt + 1'b1; end end end endmodule这里done是组合输出,直接比较cnt是否等于MAX。如果希望done是一个持续一个时钟周期的脉冲,并且与后续状态机配合更稳,可以在时序逻辑里再打一拍输出。
reg done_r; always @(posedge clk or negedge rst_n) begin if (!rst_n) done_r <= 1'b0; else done_r <= done; end用done_r作为扩展一个周期的标志位,可以避免组合输出在某些条件下出现毛刺。
这里要注意:比较cnt == MAX时,MAX的位宽和数据宽度要一致。如果cnt是 4 位,而MAX写成4'd10,没问题。如果写成10,综合工具会按整数位宽处理,可能产生额外的比较逻辑或警告。
3.4 什么时候用阻塞赋值是合理的
默认规则是:时序逻辑用非阻塞,组合逻辑用阻塞。
阻塞赋值确实有合法使用场景,而且很重要。
第一种是组合逻辑 always 块中计算中间变量。比如一个复杂的多级组合运算,先算一个临时结果,再基于临时结果继续计算。使用阻塞赋值可以让代码表达顺序化的组合计算流程。
第二种是 testbench 中生成组合激励信号。比如用 initial 块给寄存器变量赋值,或者用 always 块生成时钟,这些场景不牵扯寄存器并行更新语义,使用阻塞赋值更直观。
第三种是行为级模型。如果你在写一个纯算法仿真模型,不关心综合结果,可以用阻塞赋值让代码更接近普通编程风格。但这类代码不能直接当可综合工程模块使用。
多个场景区分清楚之后,就不会看到阻塞赋值就紧张,也不会在计数器这种需要严格时序语义的地方随意使用=。
4. 仿真验证:testbench、Modelsim 和波形判断方法
4.1 一个最小 testbench 怎么写
写 testbench 时需要例化计数器,并生成时钟、复位和使能信号。
`timescale 1ns / 1ps module tb_counter; reg clk = 1'b0; reg rst_n = 1'b0; reg en = 1'b0; wire [3:0] cnt; wire done; counter #( .WIDTH(4), .MAX(4'd10) ) dut ( .clk(clk), .rst_n(rst_n), .en(en), .cnt(cnt), .done(done) ); always #10 clk = ~clk; initial begin rst_n = 1'b0; en = 1'b0; #100; rst_n = 1'b1; #20; en = 1'b1; #200; en = 1'b0; #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 与 FIR
5.1 按键消抖:一个计数器最容易写出隐性竞争的场景
按键消抖是计数器最常见的工程应用之一。核心思路是检测按键电平稳定持续时间,超过一定时间后认为按键有效。
always @(posedge clk or negedge rst_n) begin if (!rst_n) begin cnt <= 20'd0; key_reg <= 1'b1; key_out <= 1'b1; end else if (key_in != key_reg) begin key_reg <= key_in; cnt <= 20'd0; end else if (cnt >= 20'd1_000_000) begin key_out <= key_in; end else begin cnt <= cnt + 1'b1; 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)) ? 1'b0 : 1'b1;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 + 1'b1; end这种写法把模值显式表达出来,代码可读性和可维护性都更好,也不会出现位宽不够导致永远比较不到上限值的问题。
6.3 代码评审时可以执行的几条硬规则
计数器虽然小,但代码评审时最值得盯几条规则。
第一条,一个 reg 只能在一个 always 块中赋值。多驱动问题必须在编码阶段就避免。
第二条,时序逻辑用非阻塞,组合逻辑用阻塞。同一 always 内不要混用。
第三条,不要用阻塞赋值手动拆进位。加法器进位交给综合器处理。
第四条,敏感列表要用完整写法。时序逻辑明确列出时钟和复位,组合逻辑使用@(*)。
第五条,复位、使能、时钟三个信号不要组合出未知逻辑。不要对时钟信号直接赋值,也不要在多个 always 里生成同一根时钟。
第六条,所有参数用parameter或localparam,不要直接写魔法数字。计数器上限变更时,只动参数,不改逻辑。
这六条规则能覆盖大部分计数器常见问题。
6.4 新手练习路径
如果你想把这个主题真正学扎实,可以按下面这条路径练。
第一步,写一个 4 位计数器,使用非阻塞赋值,仿真观察cnt更新与时钟沿的关系。
第二步,把非阻塞改成阻塞赋值,重新仿真。对比波形差异,特别注意cnt是否在一个时间步内出现多次变化。
第三步,给计数器增加使能、清零、计数上限和回绕条件。
第四步,写一个组合逻辑模块,例如比较器或选择器,使用阻塞赋值。体会两种赋值在仿真里的不同节奏。
第五步,把计数器放入按键消抖或 UART 波特率发生器,验证你在简单模块中形成的赋值习惯,能否在复杂模块中保持稳定。
第六步,综合一次,看警告。重点看是否有 latch、多驱动、未完整敏感列表。
这条路走完,你对阻塞赋值和非阻塞赋值的理解就不再是背结论,而是真正能落到代码上。
阻塞赋值与非阻塞赋值的争议,本质不是语法偏好,而是仿真事件模型与电路结构映射之间的关系。计数器足够小,却能完整展示这种关系。先花时间把最小计数器、错误写法和 testbench 波形看清楚,再去碰按键消抖、UART、SPI,你会少踩很多坑。