FPGA计数器的Verilog陷阱:阻塞赋值与非阻塞赋值深度解析

FPGA计数器的Verilog陷阱:阻塞赋值与非阻塞赋值深度解析 在 Verilog 和 FPGA 开发的入门阶段计数器几乎是每个人都会写的第一类时序逻辑模块。它看起来足够简单一个 always 块、一次加一操作、一个复位分支几行代码就能在仿真波形里看到递增序列。但真正把初学者和熟练开发者区分开的不是能不能写出来而是能不能解释清楚为什么计数器代码里普遍使用而不是为什么把换成后有时仿真结果完全一样有时却会出现提前回绕、信号竞争、流水级错乱阻塞赋值与非阻塞赋值在仿真调度、综合映射和硬件语义上到底意味着什么。这篇文章以计数器作为最小载体逐层拆解阻塞赋值与非阻塞赋值的边界行为。先讲两种赋值在仿真调度里的执行方式再用可运行的计数器代码制造几类典型错误随后分析底层事件调度机制最后给出项目里常用的计数器变体、排查链路和编码自检清单。学完之后不仅能写出规范的计数器模块还能在排错或面试时准确解释“时序 always 块为什么不能用阻塞赋值”。1. 先想清楚计数器到底在描述什么硬件1.1 计数器是典型的边沿触发寄存器组从硬件结构看一个计数器并不是“一个会加一的变量”而是一组 D 触发器加上一组组合逻辑。组合逻辑部分计算下一拍要写入的值复位时写入 0使能时写入当前值加 1计数到上限时写回 0触发器部分在时钟上升沿把组合逻辑计算出的结果锁存到输出端。所有触发器在同一个时钟边沿同时采样所谓“同时”是寄存器传输级RTL的行为抽象。忽略亚稳态和时钟偏斜边沿到来的一瞬间每个触发器的 D 端输入信号已经稳定边沿过后所有 Q 端同时更新。计数器代码就是对这个并行硬件结构的行为描述它看起来像一段顺序执行的语句但语义上绝对不能按 C 语言那样的顺序逻辑理解。1.2 组合逻辑与时序逻辑对赋值的要求不同组合逻辑的输出只依赖当前输入输入变化后输出经过一段传播延迟就会更新没有记忆状态。时序逻辑的输出只在时钟边沿变化而且新状态由边沿到来之前的旧状态决定。这两种逻辑对“赋值”的要求完全不同组合逻辑需要“赋值立即见效”。一个assign或一个组合 always 块内部后面的语句可能需要使用前面语句刚算出的中间结果这符合顺序计算直觉。时序逻辑需要“边沿前后状态隔离”。边沿到来时所有触发器读取的都是边沿前的旧值写入的都是边沿后的新值。如果语言机制允许一条语句立即改掉变量后续语句读取到新值就很容易写出与真实硬件行为不符的代码。Verilog 用两种赋值操作符来区分这两种语义阻塞赋值用于立即生效的场景非阻塞赋值用于延后更新的场景。1.3 先看一个标准计数器骨架module counter #( parameter WIDTH 4 )( input wire clk, input wire rst_n, output reg [WIDTH-1:0] cnt ); always (posedge clk or negedge rst_n) begin if (!rst_n) cnt {WIDTH{1b0}}; else cnt cnt 1b1; end endmodule这段代码的关键点有三个posedge clk表示模块只在时钟上升沿时动作是时序逻辑的标志。negedge rst_n是异步复位的敏感条件复位有效时计数器立刻清零不等待时钟。cnt cnt 1b1右侧cnt采样的是时钟边沿前的旧值左侧cnt在边沿之后才更新。如果只看这个单语句例子把改成仿真结果很可能一模一样因为一个 always 块里只有一条赋值语句不存在顺序依赖问题。这也是很多初学者误以为两种赋值可以随意替换的原因。真正的差异需要多语句块和多个信号同时赋值时才能放大出来。2. 阻塞赋值与非阻塞赋值的执行模型2.1 两种赋值的语法形式阻塞赋值写为变量 表达式;非阻塞赋值写为变量 表达式;。二者看起来只差一个等号但仿真器的处理方式完全不同。对比项阻塞赋值非阻塞赋值常用位置组合 always、initial 块时序 always 块生效时机执行到该语句时立即更新当前时间步的 NBA 区统一更新右侧值采样时刻执行到该语句的瞬间进入 always 块时的“旧状态快照”同一变量多次赋值后一句覆盖前一句最后一次 NBA 更新生效后续语句读取结果读到的是更新后的新值读到的是边沿前的旧值建模对象组合逻辑、中间变量触发器、寄存器、流水线2.2 阻塞赋值执行到哪里更新到哪里在仿真器的 Active 事件区域阻塞赋值按语句顺序执行。执行到a b;时b的值立即被写入a下一条语句再读a拿到的已经是新值。这种特性适合描述组合逻辑因为组合逻辑里的中间信号确实存在“先算 A再用 A 算 B”的传播关系。最经典的示例是两变量交换reg a, b; always (posedge clk) begin a b; b a; end逐句追踪时钟边沿到来第一条语句执行a被赋值为b的旧值。第二条语句执行时a已经不是旧a而是旧b。所以最终b也被赋成旧b。结果是a和b都变成了原来的b交换失败。真实硬件里两个触发器在时钟边沿同时采样边沿后a得到旧bb得到旧a是正确交换。阻塞赋值的顺序执行语义在这里和硬件行为完全背离。2.3 非阻塞赋值一个时钟边沿只拍一张快照非阻塞赋值的执行方式可以概括为“先采样后更新”。时钟边沿触发 always 块时仿真器先把块内所有非阻塞赋值语句的右侧表达式全部用当前旧值求值并暂存等到当前时间步的 NBA 区域再统一写回左侧变量。同一段交换逻辑改用非阻塞赋值always (posedge clk) begin a b; b a; end时钟边沿到来时右侧采样b的旧值记为old_ba的旧值记为old_a。两条写回操作被排进 NBA 更新列表。NBA 区执行写回a old_bb old_a。两条语句谁先写、谁后写都不影响最终结果因为右侧采样全部发生在写回之前。这种“整拍快照”的语义正是真实触发器阵列在时钟边沿并行采样的行为抽象。2.4 综合工具如何处理这两种赋值综合工具不会按仿真器的语句顺序去理解设计。它会把 always 块翻译成对应的硬件结构时钟敏感块里的变量大多映射成触发器组合块里的变量映射成连线或组合单元。因此单语句计数器无论用还是综合出的硬件可能完全一样都会是一个带复位的加法寄存器。问题不在于综合不出来而在于复杂场景下两种写法会让“仿真行为”和“综合结果”产生分歧。使用阻塞赋值写出多语句顺序依赖时仿真结果可能和综合后的真实电路行为不一致这种不一致极难定位。所以业界才形成了一条铁律时序逻辑用组合逻辑用不混用。3. 用计数器代码把两种赋值的差异跑出来3.1 一个带使能和回绕标志的可重参数计数器先写一个稍微完整一点的计数器它包含使能、异步复位、计数上限和单周期回绕脉冲module flexible_counter #( parameter WIDTH 4, parameter MOD 10 )( input wire clk, input wire rst_n, input wire en, output reg [WIDTH-1:0] cnt, output reg wrap ); always (posedge clk or negedge rst_n) begin if (!rst_n) begin cnt {WIDTH{1b0}}; wrap 1b0; end else if (en) begin if (cnt MOD - 1b1) begin cnt {WIDTH{1b0}}; wrap 1b1; end else begin cnt cnt 1b1; wrap 1b0; end end else begin wrap 1b0; end end endmodule配套 testbenchtimescale 1ns/1ps module tb_counter; reg clk; reg rst_n; reg en; wire [3:0] cnt; wire wrap; flexible_counter #( .WIDTH(4), .MOD (10) ) u_counter ( .clk (clk), .rst_n(rst_n), .en (en), .cnt (cnt), .wrap (wrap) ); initial clk 1b0; always #10 clk ~clk; // 50MHz 时钟 initial begin rst_n 1b0; en 1b0; #30; rst_n 1b1; en 1b1; #600; $finish; end initial $monitor($time, cnt%0d wrap%0d, cnt, wrap); endmodule用开源仿真工具就可以运行iverilog -o tb_counter tb_counter.v flexible_counter.v vvp tb_counter正常计数序列是cnt: 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, ...其中cnt 9的那个时钟周期wrap输出为 1其余时间为 0。3.2 把换成计数器开始“提前回绕”现在把 flexible_counter 里的赋值全部改成阻塞赋值同时保持“先加一再判断回绕”的写法// 错误示例时序块中使用阻塞赋值且先加后判 always (posedge clk or negedge rst_n) begin if (!rst_n) begin cnt {WIDTH{1b0}}; wrap 1b0; end else if (en) begin cnt cnt 1b1; // 阻塞cnt 立即变成新值 if (cnt MOD - 1b1) begin cnt {WIDTH{1b0}}; wrap 1b1; end else begin wrap 1b0; end end else begin wrap 1b0; end end逐拍追踪会发现计数序列变成cnt: 0, 1, 2, 3, 4, 5, 6, 7, 8, 0, 1, ...原本期望在旧值为 9 时回绕结果旧值为 8 时就触发了回绕。原因在于阻塞赋值执行cnt cnt 1后cnt已经先变成 9紧接着的if (cnt MOD - 1)判断的是新值 9于是立即清零。实际硬件中判断应基于边沿前的旧值 8回绕应发生在下一个边沿即旧值为 9 时。有人会写另一种风格来规避这个错误always (posedge clk) begin if (cnt 9) cnt 0; else cnt cnt 1; end这种写法把判断放在赋值之前旧值参与判断即使用阻塞赋值也能仿真正确。但不能因此推导出“时序块里用阻塞赋值也没问题”。它依赖的是语句顺序碰巧和硬件语义一致一旦后续有人加一条使能、加一个中间信号、调整语句顺序错误就会重新出现。规范的意义在于消除这种偶然正确。3.3 用流水线寄存器放大顺序依赖问题计数器里如果只有一条赋值阻塞与非阻塞很难看出差别。流水线寄存器是多语句时序块的典型场景// 错误写法阻塞赋值把两级流水变成同一拍更新 always (posedge clk) begin stage1 din; stage2 stage1; end执行过程先给stage1写入din然后stage2读到的stage1已经是最新值也会写入din。综合出的硬件里stage2与stage1之间只隔了一层组合直连完全没有形成一拍延迟流水线退化成一级。正确写法always (posedge clk) begin stage1 din; stage2 stage1; end时钟边沿同时采样din的旧值和stage1的旧值写回后stage1得到dinstage2得到上一拍的stage1这才是标准两级流水线。3.4 用 $display 观察“旧值”与“新值”的时差非阻塞赋值的更新发生在当前时间步的 NBA 区而不是 always 块内部语句执行的位置。因此在时序块里紧跟在后面的打印语句看到的仍然是旧值always (posedge clk) begin cnt cnt 1b1; $display(posedge: cnt old %0d, cnt); // 输出的是旧值 end第一次时钟边沿时cnt显示 0但实际下一拍开始cnt已经变成 1。如果想在 testbench 里观察更新后的值需要让打印发生在 NBA 区之后例如$monitor或沿后再加#1延迟。基于仿真打印判断时序时一定要先想清楚取值时刻是在旧状态还是新状态。注意RTL 代码里不要依赖#1或#0来等待非阻塞赋值生效这些是仿真调度技巧不是可综合的硬件语义。4. 底层调度机制为什么仿真标准和硬件行为最终归一4.1 仿真器的时间步分区IEEE 1364 标准把 Verilog 仿真的一个时间步划分为多个事件区域每个区域处理不同类型的事件区域典型事件效果Active 区阻塞赋值、连续赋值、原语求值变量立即更新Inactive 区#0延迟当前时间步内延后执行NBA 区非阻塞赋值左值写回所有右侧采样完成后统一更新Monitor 区$monitor时间步结束后打印变量变化时钟上升沿到来时所有对posedge clk敏感的 always 块都会在 Active 区被唤醒。阻塞赋值在 Active 区立即写入非阻塞赋值只在这里完成右侧表达式的采样并把写回操作排入 NBA 区。Active 区全部执行完后仿真器才进入 NBA 区执行写回。这个设计保证了关键性质在一个时钟边沿上不管 always 块内部语句顺序如何也不管多个 always 块的调度顺序如何所有非阻塞赋值采样到的都是边沿前的统一旧状态。这正是多触发器并行采样的数字电路模型。4.2 阻塞赋值在多个 always 块之间造成的竞争阻塞赋值的问题不只存在于单个 always 块内部更危险的是多个 always 块对同一变量赋值。仿真标准不规定哪个 always 块先执行// 错误两个时序块同时用阻塞赋值操作同一变量 always (posedge clk) cnt cnt 1b1; always (posedge clk) if (cnt 4d9) cnt 4d0;第一个块先执行时cnt先加一第二个块后执行时判断的cnt已经是加一后的新值。反过来第二个块先执行判断的则是加一前的旧值。执行顺序由仿真器的实现决定不同工具、不同配置都可能得出不同结果这就是典型的仿真竞争条件。使用非阻塞赋值后两个块在 Active 区都只采样旧cnt写回统一放到 NBA 区。虽然多个 always 块对同一变量赋值的风格本身并不推荐但至少不会因为块执行顺序不同而出现仿真结果漂移。4.3 从硬件映射看非阻塞赋值为什么更“真实”真实触发器在时钟边沿的行为是边沿前D 端信号已经稳定。边沿瞬间触发器采样 D 端值。边沿后Q 端输出新值Q 端的新状态不影响本次边沿的其他触发器采样。非阻塞赋值的三段式过程右侧采样、排队写回、NBA 更新恰好对应这条链路。右侧表达式相当于 D 端组合逻辑边沿瞬间采样相当于触发器采样NBA 写回相当于 Q 端更新。阻塞赋值把“写入变量”和“读取变量”揉进了同一条顺序执行链在一个时钟周期内部强加了一种硬件上不存在的先后关系。这种先后关系在简单代码里无害在多语句、多块、共享变量的场景里就会制造差异。4.4 为什么这个问题是数字 IC 岗位的常考题数字 IC 笔试和面试几乎必考阻塞与非阻塞赋值常见问法包括a b; b a;与a b; b a;的仿真结果分别是什么为什么 RTL 编码规范要求时序逻辑使用非阻塞赋值一个 always 块里能不能混用和两条非阻塞赋值对同一个变量赋值时最终结果是谁这些问题看似在考语法实际在考“事件调度模型 硬件并行语义”是否清晰。能画出 Active 区和 NBA 区的执行时间线就能推出任何变体的答案而不是靠背结果。面试官追问“如果把某两条语句交换顺序呢”本质是确认你理解了采样和写回不在同一阶段。5. 项目里常见的计数器变体与 Verilog 实现5.1 自由运行计数器和使能计数器最普通的计数器一直累加叫自由运行计数器通常用于产生时间基准或地址递增。上一节的flexible_counter已经包含使能、回绕脉冲和参数化模值项目里可以直接套用WIDTH决定计数器位宽MOD是回绕上限。MOD必须能被WIDTH位二进制数表示否则比较条件永远不会为真或提前溢出。wrap是单周期脉冲在cnt MOD - 1时拉高一个时钟周期适合作为下一个模块的使能信号。5.2 模 100 计数器单变量回绕与 BCD 拆位“模 100 计数器”在秒表、时钟、显示驱动里很常见。有两种实现思路。第一种用 7 位二进制变量范围 0 到 99到 99 后回绕module counter_mod100 ( input wire clk, input wire rst_n, input wire en, 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 (en) begin if (cnt 7d99) begin cnt 7d0; carry 1b1; end else begin cnt cnt 1b1; carry 1b0; end end else begin carry 1b0; end end endmodule第二种是 BCD 拆位把个位和十位分开每一位都是 0 到 9 的模 10 计数器module bcd_counter_2digit ( input wire clk, input wire rst_n, input wire en, output reg [3:0] unit, output reg [3:0] ten, output reg carry ); always (posedge clk or negedge rst_n) begin if (!rst_n) begin unit 4d0; ten 4d0; carry 1b0; end else if (en) begin if (unit 4d9) begin unit 4d0; if (ten 4d9) begin ten 4d0; carry 1b1; end else begin ten ten 1b1; carry 1b0; end end else begin unit unit 1b1; carry 1b0; end end else begin carry 1b0; end end endmoduleBCD 实现的优势是后续做数码管显示时不用再拆位每个变量直接对应一位十进制数。5.3 格雷码计数器与环形计数器多 bit 二进制计数器在跨时钟域传输时多个 bit 同时翻转容易产生采样不确定性。格雷码每次只翻转一个 bit适合作为异步 FIFO 的写指针或读指针。写成二进制计数加格雷码转换即可module gray_counter #( parameter WIDTH 4 )( input wire clk, input wire rst_n, output reg [WIDTH-1:0] cnt, output wire [WIDTH-1:0] gray ); always (posedge clk or negedge rst_n) begin if (!rst_n) cnt {WIDTH{1b0}}; else cnt cnt 1b1; end assign gray (cnt 1) ^ cnt; endmodule计数序列是0000, 0001, 0011, 0010, 0110, 0111, 0101, 0100, ...相邻两个格雷码之间只有一位变化。环形计数器和约翰逊计数器则用于状态机编码环形计数器是 one-hot 移位always (posedge clk or negedge rst_n) begin if (!rst_n) cnt {{WIDTH-1{1b0}}, 1b1}; else cnt {cnt[WIDTH-2:0], cnt[WIDTH-1]}; end这类计数器主要用在编码方式有特殊要求的场景普通计数需求不必额外引入。5.4 用计数器实现分频、定时和 PWM计数器最常见的衍生功能是分频和定时。以 50MHz 时钟为例要让 LED 以 1Hz 频率翻转每 0.5 秒取反一次即每 2500 万次时钟计数取反一次localparam HALF 25_000_000; // 50MHz 下对应 0.5s reg [24:0] cnt_led; reg led; always (posedge clk or negedge rst_n) begin if (!rst_n) begin cnt_led 25d0; led 1b0; end else if (cnt_led HALF - 1b1) begin cnt_led 25d0; led ~led; end else begin cnt_led cnt_led 1b1; end endPWM 的原理同样基于计数器计数器从 0 累加到周期值比较器判断当前计数值和占空比阈值输出高或低电平。计数器、比较器、定时器本质上都是同一套“边沿采样旧值、下一拍写回新值”的时序逻辑。6. 计数器仿真异常排查链路6.1 波形里计数一直不变先确认信号是否真的在活动看rst_n波形复位是不是一直没有释放或释放后又立刻被拉低。看en使能信号使能一直为低计数器自然不跑。看仿真时间是否足够长“多少个时钟周期”可能只跑了不到一个完整周期。看时钟clk是否真的在翻转testbench 里always #10 clk ~clk;是否被覆盖。确认比较条件是否永假例如MOD大于2^WIDTH - 1cnt永远到不了回绕值。使用$monitor打印关键信号比只看波形更容易发现是哪个输入没有满足条件。6.2 计数到某个值后出现 X 态X 态在仿真里表示未知常见原因有三个。第一位宽不足。WIDTH只有 4 位却要求计数到 20计数器本来就会回绕比较条件可能永远不成立。第二存在多驱动。两个 always 块都驱动cnt一个写高电平一个写低电平时仿真表现为 X。用并不能解决多驱动问题只能靠设计上消除重复赋值。第三某个分支没有覆盖全部输入。异步复位、使能、正常计数三条路径只要有一条没写全变量可能保持初值 X。好的写法是每个分支都给所有输出寄存器赋值。6.3 仿真正确但综合后功能不对仿真通过不代表上板正确。常见原因复位极性反了。RTL 用negedge rst_n约束文件却声明高有效复位或板级按键按下后才拉低导致复位时机完全不对。时钟域问题。计数器时钟来自门控时钟或组合逻辑产生的脉冲会造成毛刺和采错应使用全局时钟或经过处理的同步时钟。输出没有寄存。计数器内部是寄存的但wrap或比较结果如果是组合直接出模块外面采样时可能采到毛刺。综合工具报出 latch warning。比如组合 always 块里漏写了某个分支综合出锁存器功能与仿真不一致。综合报告里出现latch inferred或incomplete assignment警告时必须逐一确认。6.4 阻塞赋值导致的时序偏移如何定位如果仿真波形里回绕脉冲比预期提前一个周期或者流水线输出比预期早一拍优先检查是否在时序块里使用了阻塞赋值。定位方法是搜索 always (posedge clk) 块中的所有赋值操作符确认是否混用了。现象常见原因检查方式处理建议计数一直为 0复位一直有效查看 rst_n 波形确认复位释放时间与极性计数一直为 1使能未拉高查看 en 波形testbench 中置高必要时延时使能计数变 X位宽不足或多驱动检查 WIDTH 和 cnt 赋值点扩大位宽删除重复驱动比预期早一个周期回绕时序块里“先加后判”且用了检查赋值符和语句顺序改用判断基于旧值综合出现 latch分支不完整查看综合 warning补全所有分支分离组合/时序块排错顺序应该从输入信号开始先看时钟和复位再看使能然后查构成计数器的关键信号最后才怀疑综合或约束问题。绝大多数计数器异常在波形里都能直接看出是哪一级输入不满足条件。注意不要只验证程序能跑出波形还要验证回绕点、使能关闭、复位释放三个边界场景下的行为是否符合预期。7. 编码规范与自检清单7.1 赋值方式速查表场景推荐写法原因时序 always 块中的寄存器采样旧值、统一写回模拟触发器并行行为组合 always 块中的中间信号需要立即参与后续计算连续赋值assign本质是连续驱动组合逻辑建模同一个 always 块中混用不推荐极易产生仿真与综合不一致initial 块中的测试激励或均可主要面向仿真可综合代码中通常不用 initial 赋初值时序块里混用和尤其危险。假设一个变量用写另一个用写执行顺序和时间点不同后续维护者在阅读时会非常难判断边沿前后的状态。几乎所有公司的 RTL 编码规范都禁止这种写法。7.2 阻塞赋值仍然可以使用的场景阻塞赋值并没有从 Verilog 中消失它适合以下场景组合 always 块例如always (*)中的译码、选择、中间结果计算。纯仿真模型例如 testbench 中生成激励用的 initial 块。函数内部变量。关键在于把“是否立即生效”和“是否描述寄存器”对应起来。组合逻辑希望立即生效使用寄存器希望在边沿统一更新使用。7.3 计数器设计自检清单写计数器前可以逐项核对[ ]WIDTH是否足够表示最大值比较条件不会因为位宽溢出而永不成立。[ ] 回绕条件是cnt MAX还是cnt MAX - 1是否和设计语义一致。[ ] 异步复位用的是negedge rst_n还是posedge rst复位信号电平和模块内部逻辑是否匹配。[ ] 使能信号是否已同步到本时钟域是否会产生亚稳态。[ ]wrap或进位信号是否只持续一个时钟周期。[ ] 仿真观察点覆盖了 0、MAX-1、MAX、回绕后的第一个点。[ ] 综合报告里没有latch inferred警告。[ ] 所有输出寄存器在复位分支中都有赋值不存在保持 X 态的分支。[ ] 时序 always 块内部全部使用没有混用。[ ] 如果计数器输出需要提供给其他模块确认信号已经由寄存器直接输出而不是组合逻辑现算。这套清单也适用于其他时序逻辑模块不只是计数器。7.4 给新手的练习建议如果还没有完全掌握两种赋值的差异可以用三个小实验强化理解把第 3 节的flexible_counter改成阻塞赋值观察回绕点是否提前。写一个两级流水线分别用和实现比较stage2输出的时序。写一个两位 BCD 计数器在个位进位、十位进位两个节点加$display观察 NBA 更新前后的值差异。每次修改后都画出“边沿前旧值表”和“边沿后新值表”再对照仿真波形验证。能准确预测每一条语句的输出才是真正理解了阻塞赋值与非阻塞赋值的底层逻辑。对数字设计者来说这个能力比记住任何编码规范都更重要因为所有规范都是从这套调度模型推导出来的。