
如果你正在学习 Verilog十有八九会在一开始就被一个看似简单的问题卡住写一个计数器为什么有时候用assign就能工作有时候必须在always块里写赋值为什么大家都在说“时序逻辑用非阻塞赋值组合逻辑用阻塞赋值”但没人把背后的原理讲透。这篇文章不打算罗列规则而是想带你从底层逻辑重新理解阻塞赋值与非阻塞赋值。我们会以计数器这个最经典的 Verilog 场景为主线把和的仿真调度机制、综合行为、常见误区和工程规范一次讲清楚。读完之后你不光能写出正确可靠的计数器还能理解为什么很多数字 IC 面试题喜欢从这个小器件出发考察你对硬件语义的理解。1. 这篇文章真正要解决的问题先说一个现象很多初学者写计数器第一次仿真就能跑出看起来正确的波形于是觉得 Verilog 很简单直到某个信号在其他模块里没有按期望更新或者综合后功能完全不对才开始怀疑人生。问题往往不在逻辑本身而在赋值方式。计数器虽然代码很短但它同时涉及了“寄存器”“时钟边沿”“复位”“比较清零”这些数字电路中最核心的概念。而阻塞赋值与非阻塞赋值恰恰决定了这些概念在仿真器和综合器眼里到底长什么样。换句话说计数器不是用来学习“数数”的而是用来理解“硬件事件如何调度”的最佳入门案例。这篇文章的核心价值有三个讲清楚阻塞赋值与非阻塞赋值的本质区别而不仅仅是背口诀。通过多个可运行的计数器示例让你在仿真和综合中都能得到正确结果。给出工程上的检查清单让你接手他人代码或使用 AI 生成代码时能快速识别赋值相关的隐患。如果你正准备数字 IC 笔试面试或者刚开始接触 FPGA 开发这篇文章值得收藏。2. 基础概念计数器、触发器与 always 块在进入赋值语法之前先把后面会用到的几个基础概念对齐。2.1 计数器本质上是一组触发器计数器在硬件上并不是一个抽象的黑盒。它实际上由多个 D 触发器组成每个触发器存储一位二进制数。时钟上升沿到来时所有触发器同时把输入端的值锁存到输出端。如果我们把这些触发器的输出反馈回输入端并通过组合逻辑让输出值加 1就构成了一个计数器。从这个角度看计数器就是“触发器 组合逻辑”的组合。而 Verilog 中的阻塞赋值和非阻塞赋值正好分别对应了这两类硬件行为的建模方式。2.2 always 块是行为建模的入口always块是 Verilog 中用来描述重复执行的硬件行为的关键字。always (posedge clk or negedge rst_n) begin // 时序逻辑 end always (*) begin // 组合逻辑 end第一个块在时钟上升沿或复位下降沿触发适合描述时序逻辑。第二个块在敏感列表中的信号变化时触发适合描述组合逻辑。很多人刚开始会犯一个错误把always (*)当成“仿真里随便执行的过程”或者把always (posedge clk)里的代码当成 C 语言的循环体。这两种理解都是错的。always块描述的是一种“当事件发生时反复执行的硬件行为”而不是软件函数。2.3 阻塞赋值与非阻塞赋值的直观对比先把最常用的对比放出来对比项阻塞赋值非阻塞赋值赋值符号生效时机立即生效后续语句马上能看到新值当前块结束时统一生效后续语句看到的是旧值典型场景组合逻辑建模时序逻辑建模综合出的硬件组合逻辑或意外锁存器D 触发器仿真行为类似软件顺序执行类似并行硬件同时更新误用后果时序逻辑中容易产生竞争和错误更新组合逻辑中会产生额外的仿真延迟综合可能不匹配这张表说的还是现象真正的原因要看 Verilog 仿真器的事件调度机制。3. 非阻塞赋值的底层逻辑仿真事件调度很多人不理解的一点是为什么不能在 always 块执行过程中“立即”改变变量的值3.1 仿真器如何执行一个 always 块Verilog 仿真器在运行时维护了一个事件队列。当时钟上升沿触发一个 always 块时仿真器会先计算块内所有语句右边的表达式等到当前仿真时间片的末尾才统一把右边的值赋给左边的寄存器变量。举个例子always (posedge clk) begin a b; b a; end如果按顺序执行的思路去看这段代码会把a变成b再把b变成新的a最终两个变量变成同一个值。但实际仿真结果不是这样。由于使用了非阻塞赋值仿真器会先读取a和b的当前值然后“记住”这两个值等到块结束时才更新。结果是a变成b的旧值b变成a的旧值实现了交换。这才符合硬件电路中两个触发器在同一个时钟边沿同时更新的行为。3.2 阻塞赋值为什么在时序逻辑中危险如果在同一个 always 块里使用阻塞赋值写always (posedge clk) begin a b; b a; end仿真器会按顺序执行。第一行执行完a已经立即变成b的值第二行执行时b拿到的是“已经更新后的 a”也就是原来的b值。最终a和b都变成同一个值行为完全不符合触发器并联的硬件结构。如果在不同 always 块里混用情况更糟。多个 always 块对同一个信号进行阻塞赋值实际上是在同一个时刻里对该信号进行了多次立即写入最终值取决于仿真器的执行顺序。这个顺序虽然确定但很难直观判断综合结果也可能与仿真不一致这就是典型的仿真竞争问题。3.3 一个简单的执行流程伪代码可以用伪代码表示非阻塞赋值在仿真器中的处理流程当时钟边沿事件被触发 1. 调度所有 active 事件即执行 always 块中的代码 2. 执行过程中遇到非阻塞赋值不立即更新变量 3. 把“要更新变量更新值”记录到 NBA 更新列表中 4. 当前活跃事件执行完毕后进入 NBA 更新阶段 5. 统一将记录的新值写入左侧变量这就是为什么能建模“并行硬件行为”的根本原因。它并不是语法花哨而是从语言层面模拟了硬件触发器的并行更新特性。从这一点向后总结我们可以得出一个结论非阻塞赋值的本质是让一个时序模块中的多个信号在同一时钟边沿的“语义时刻”完成更新而不受代码书写顺序的影响。4. 计数器核心语法三种基础计数器实现现在进入代码实战。计数器虽然简单但把它写对意味着你真正理解了 always 块、同步逻辑和赋值方式之间的关系。4.1 最简 4 位自由计数器先看最基础的自由计数// 文件路径counter_free.v module counter_free ( input wire clk, input wire rst_n, output reg [3:0] cnt ); always (posedge clk or negedge rst_n) begin if (!rst_n) cnt 4b0; else cnt cnt 1b1; end endmodule这段代码里cnt被声明为reg类型但这里reg并不一定代表硬件寄存器它只是说这个变量在 always 块中被赋值。真正决定它综合成触发器还是组合逻辑的是赋值方式和触发条件。在时序 always 块中cnt cnt 1b1;会被综合成一组带有反馈的 D 触发器。每个时钟上升沿触发器的输出加 1 后再锁存回自身。4.2 带使能信号的同步加载计数器实际工程中很少使用一直在计数的自由计数器。通常需要使能信号来控制是否计数以及一个预置值用来加载。// 文件路径counter_load.v module counter_load #( parameter WIDTH 8 )( input wire clk, input wire rst_n, input wire load_en, input wire cnt_en, input wire [WIDTH-1:0] load_val, output reg [WIDTH-1:0] cnt ); always (posedge clk or negedge rst_n) begin if (!rst_n) cnt {WIDTH{1b0}}; else if (load_en) cnt load_val; else if (cnt_en) cnt cnt 1b1; // 如果没有使能条件cnt 保持当前值 end endmodule这里有几个值得注意的细节parameter WIDTH 8让模块在例化时可以重定义位宽增加复用性。复位优先级最高其次是加载最后才是计数。这个优先级顺序是约定俗成的避免出现多个条件冲突。如果cnt_en为低也没有加载cnt会保持原值这是因为always块中没有对cnt进行赋值触发器会保持上一拍的值。使用内部使能而不是频繁打开关闭时钟是同步设计的常见做法也有利于时序收敛。4.3 模 100 计数器模 100 计数器是很多开发板实验课里的经典题目它的逻辑是计数器从 0 数到 99数满后归零并输出一个进位脉冲。// 文件路径counter_mod100.v module counter_mod100 ( input wire clk, input wire rst_n, output reg [6:0] cnt, output reg carry ); always (posedge clk or negedge rst_n) begin if (!rst_n) begin cnt 7d0; carry 1b0; end else if (cnt 7d99) begin cnt 7d0; carry 1b1; end else begin cnt cnt 1b1; carry 1b0; end end endmodulecnt的最大值是 99所以位宽需要 7 位因为 6 位二进制最大只能表示 63。这里需要注意carry信号也在时序逻辑中赋值。它会在cnt 99那一刻的时钟边沿变为高并在下一拍变为低。如果你希望carry是一个持续一个周期的脉冲这个写法是正确的。如果你希望它在计数到 99 的组合逻辑时刻立即拉高则应该用组合逻辑生成carry而不是在时序逻辑里寄存。很多设计问题都出在“没有想清楚信号到底要寄存还是要组合”。4.4 一个经典错误用阻塞赋值写计数器为了加深理解看一个错误示例// 错误示例不要这样写 always (posedge clk or negedge rst_n) begin if (!rst_n) cnt 4b0; // 阻塞赋值 else cnt cnt 1b1; // 阻塞赋值 end这段代码在很多场景下综合结果也是触发器因为综合工具足够聪明能推断出该赋值最终映射到寄存器。但是在仿真层面如果多个 always 块同时读写cnt或者同一逻辑块里存在多条阻塞赋值语句就可能出现与硬件不一致的中间状态。更隐蔽的问题是当你的代码逐渐变复杂比如在同一个时钟块里需要同时更新计数器和状态标志阻塞赋值的“即时生效”特性会使得代码执行顺序影响最终结果。这违背了硬件并行执行的现实也让代码的可读性和可维护性大幅度下降。5. 阻塞赋值的正确场景组合逻辑建模了解了非阻塞赋值之后再回过来看阻塞赋值。它并不是一无是处而是有自己明确的适用领域。5.1 组合逻辑中的多路选择假设你要根据控制信号从多个计数器的值里选择一个输出// 文件路径mux_comb.v module mux_comb ( input wire [3:0] cnt0, input wire [3:0] cnt1, input wire sel, output reg [3:0] data_out ); always (*) begin if (sel) data_out cnt1; else data_out cnt0; end endmodule这里使用是合适的。因为组合逻辑本身描述的就是“输入变化输出立即变化”的关系使用阻塞赋值可以保证data_out紧随输入条件更新不会产生额外的仿真事件延迟。5.2 避免综合出锁存器组合逻辑 always 块最容易踩的坑是条件分支不完整。比如上面的多路选择器如果只写了if (sel) data_out cnt1;而没有else综合工具会认为你没有定义sel 0时的输出从而推断出一个锁存器。要避免这个问题可以在组合逻辑块开头先给所有输出赋默认值always (*) begin data_out cnt0; if (sel) data_out cnt1; end这样既能保证sel为 0 时的输出确定又符合组合逻辑的设计习惯。5.3 同一个 always 块中不要混用两种赋值这里有一条硬性规则同一个 always 块中不要同时使用阻塞赋值和非阻塞赋值。虽然某些特殊写法在语法上合法但会导致仿真行为难以预测综合结果也不稳定。对于计数器这类场景时序逻辑就用组合逻辑就用不要混搭。6. 仿真验证编写 Testbench 并检查波形写完了计数器代码下一步就是验证。这里以一个 4 位可加载计数器为例展示 Testbench 的完整写法。6.1 时钟生成与初始化// 文件路径tb_counter_load.v timescale 1ns / 1ps module tb_counter_load; reg clk; reg rst_n; reg load_en; reg cnt_en; reg [3:0] load_val; wire [3:0] cnt; counter_load #( .WIDTH(4) ) uut ( .clk (clk), .rst_n (rst_n), .load_en (load_en), .cnt_en (cnt_en), .load_val(load_val), .cnt (cnt) ); initial clk 0; always #5 clk ~clk; initial begin rst_n 0; load_en 0; cnt_en 0; load_val 4d0; #20; rst_n 1; #10; cnt_en 1; #30; load_en 1; load_val 4d5; #10; load_en 0; #50; $finish; end initial begin $monitor(time%0t rst_n%b load_en%b cnt_en%b cnt%d, $time, rst_n, load_en, cnt_en, cnt); end endmodule这段 Testbench 的核心逻辑always #5 clk ~clk生成周期为 10ns 的时钟。复位 20ns 后释放等待 10ns 后拉高计数使能验证计数递增。拉高load_en把计数器加载为 5同一时刻cnt_en仍然为高但代码中load_en优先级高于cnt_en所以计数器应该加载为 5而不是加 1。$monitor会在任何信号变化时打印当前时间和状态适合快速观察。6.2 使用 Icarus Verilog 仿真如果你用的是 Icarus Verilog运行方式如下iverilog -o sim_tb tb_counter_load.v counter_load.v vvp sim_tb期望看到类似输出time0 rst_n0 load_en0 cnt_en0 cnt0 time20 rst_n1 load_en0 cnt_en0 cnt0 time30 rst_n1 load_en0 cnt_en1 cnt0 time40 rst_n1 load_en0 cnt_en1 cnt1 time50 rst_n1 load_en0 cnt_en1 cnt2 ... time60 rst_n1 load_en1 cnt_en1 cnt5 time70 rst_n1 load_en0 cnt_en1 cnt5 time80 rst_n1 load_en0 cnt_en1 cnt6重点验证两处复位释放后cnt从 0 开始递增。加载信号拉高后cnt直接变成 5然后继续递增。如果看不到这些行为优先检查复位信号是否有时钟同步以及load_en优先级是否写对了。6.3 使用 ModelSim / QuestaSim 仿真在 ModelSim 中可以编译后直接添加波形vlib work vlog counter_load.v tb_counter_load.v vsim -c work.tb_counter_load run -all然后使用add wave -r /tb_counter_load/*把顶层 Testbench 的所有信号加入波形窗口观察cnt在每个时钟边沿的变化是否和预期一致。7. 计数器相关常见问题与排查思路把实际开发中常见的计数器问题整理成表格方便你直接对照排错。问题现象可能原因排查方式解决方案计数器一直为 X 或 Z没有复位或者复位被释放但时钟未工作检查波形中复位信号和时钟是否正常翻转确保异步复位信号正确连接检查复位释放时序计数器只在某个特定值卡住比较逻辑写错了例如用判断的位置不对打印当前计数值和比较条件确认清零或加载条件检查和的使用场景仿真通过但上板后行为不对时序逻辑中使用了阻塞赋值导致多个寄存器更新顺序异常检查 always 块中赋值符号时序逻辑统一改为非阻塞赋值多个 always 块对同一个 reg 赋值综合报错或仿真出现不确定值搜索整个工程中对同一信号的赋值位置一个变量只允许在一个 always 块中赋值计数器位宽溢出计数到最大值后自动回绕但你没有预留进位位查看波形中 cnt 到达最大值后的行为增加位宽或在最大值时生成进位脉冲组合逻辑数据选择器输出毛刺if/else分支不完整综合出锁存器查看综合报告中的 latch 警告信息给组合逻辑输出赋默认值使用 AI 生成的 Verilog 代码仿真结果不稳定赋值风格不统一可能混用阻塞和非阻塞检查生成代码中所有 always 块的赋值符号按本文规则统一修改赋值方式需要强调的是仿真通过并不代表综合一定正确。如果你用了initial块初始化寄存器并且没有复位逻辑那么综合工具会忽略initial里的初始化值。很多初学者在仿真里看到计数器正常从 0 开始但上板后寄存器初值不确定就是这个原因。所以计数器模块必须包含可见的复位条件。8. 工程实践与最佳实践建议最后一部分把前面讲的原理落到工程层面。以下几条建议适用于大多数 FPGA 和数字 IC 设计项目。8.1 赋值规则可以这样记在 always 块中使用哪种赋值可以按一个简单的标准判断这个 always 块是否描述时序逻辑。描述触发器、寄存器、计数器、状态机统一使用非阻塞赋值。描述纯组合逻辑如多路选择、译码器、比较器统一使用阻塞赋值。始终不要在一个 always 块中混用两种赋值方式。计数器是时序逻辑的典型代表所以本篇文章中的所有计数器示例都使用非阻塞赋值。8.2 复位策略复位方式有同步复位和异步复位两种。异步复位的写法是在敏感列表中加入复位信号always (posedge clk or negedge rst_n) begin if (!rst_n) cnt d0; else cnt cnt 1b1; end同步复位的写法是只在时钟边沿检查复位always (posedge clk) begin if (!rst_n) cnt d0; else cnt cnt 1b1; end两种写法各有适用场景。异步复位不依赖时钟存在可以在时钟不稳定时立即复位但在复位释放时如果正好靠近时钟边沿容易产生亚稳态同步复位能避免亚稳态但需要时钟正常。工程上更稳妥的做法是“异步复位同步释放”但这是另一个话题这里不展开。对于计数器代码至少要做到复位条件完整不要在 only initial 块里初始化寄存器。8.3 命名规范推荐区分寄存器和组合逻辑信号reg [3:0] cnt_q; // q 表示寄存器输出 wire [3:0] cnt_d; // d 表示组合逻辑输入然后在时序逻辑中只写寄存器更新在组合逻辑中计算cnt_d。这种方法可以让代码结构更清晰也更容易做综合前后的仿真对比。8.4 参数化与常量定义计数器相关的常量尽量用localparam或parameter定义不要写死魔法数字module counter_mod_n #( parameter N 100 )( input wire clk, input wire rst_n, output reg [6:0] cnt ); always (posedge clk or negedge rst_n) begin if (!rst_n) cnt 0; else if (cnt N - 1) cnt 0; else cnt cnt 1b1; end endmodule位宽根据 N 动态确定是一个复杂的问题但至少你可以在模块内声明足够宽的寄存器并在仿真和综合时验证最大计数值避免位宽不够。8.5 使用 AI 生成代码时的检查点目前在网络上可以看到很多 AI 辅助生成 Verilog 代码的示例甚至有直接生成 SPI、I2C、UART 或滤波器的案例。AI 工具确实能快速生成框架但赋值方式必须人工复核。从一个稳定工程的角度看AI 生成代码后至少要做三件事检查所有always (posedge clk)块内部是否统一使用。检查是否存在多个always块对同一变量赋值。检查组合逻辑always (*)中是否所有输出默认值都已赋值。如果你能看完这篇文章并理解赋值背后的调度原理那么检查这些点就不再是死套规则而是真正知道为什么要这么做。9. 总结与后续学习方向计数器是 Verilog 入门的第一个“完整系统”。别看它代码短它把触发器、时钟、复位、使能、比较清零和端口设计全部串在了一起。而阻塞赋值和非阻塞赋值的选择是这段代码能否正确反映硬件行为的分水岭。本文的核心结论可以浓缩为一句话时序逻辑用非阻塞赋值是因为非阻塞赋值的调度语义与 D 触发器的并行更新行为一致组合逻辑用阻塞赋值是因为组合逻辑的输出应当立即反映输入变化。接下来你可以沿着几个方向继续深入用同样的思路学会状态机写法状态机本质上就是“多个寄存器的并行更新”。研究同步复位与异步复位的时序收敛问题这个知识点在 FPGA 工程中非常常见。学习多时钟域下的信号同步此时你需要在每个时钟域内部遵守相同的赋值规则处理跨时钟域信号时才不会瞬间就怀疑自己的仿真结果。尝试给计数器加上接口握手信号比如valid和ready这会帮助你理解数字系统的控制流与数据流分离的设计方法。如果你在阅读过程中遇到过“计数器代码看起来没问题但波形总是不对”的情况建议先把所有 always 块里的赋值符号统一检查一遍。很多时候问题并不是出在功能逻辑而是出在赋值语义和硬件行为不匹配上。