在 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{1'b0}}; else cnt <= cnt + 1'b1; end endmodule这段代码的关键点有三个:
posedge clk表示模块只在时钟上升沿时动作,是时序逻辑的标志。negedge rst_n是异步复位的敏感条件,复位有效时计数器立刻清零,不等待时钟。cnt <= cnt + 1'b1右侧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得到旧b,b得到旧a,是正确交换。阻塞赋值的顺序执行语义在这里和硬件行为完全背离。
2.3 非阻塞赋值:一个时钟边沿只拍一张快照
非阻塞赋值的执行方式可以概括为“先采样,后更新”。时钟边沿触发 always 块时,仿真器先把块内所有非阻塞赋值语句的右侧表达式全部用当前旧值求值并暂存,等到当前时间步的 NBA 区域再统一写回左侧变量。
同一段交换逻辑改用非阻塞赋值:
always @(posedge clk) begin a <= b; b <= a; end时钟边沿到来时:
- 右侧采样:
b的旧值记为old_b,a的旧值记为old_a。 - 两条写回操作被排进 NBA 更新列表。
- NBA 区执行写回:
a = old_b,b = 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{1'b0}}; wrap <= 1'b0; end else if (en) begin if (cnt == MOD - 1'b1) begin cnt <= {WIDTH{1'b0}}; wrap <= 1'b1; end else begin cnt <= cnt + 1'b1; wrap <= 1'b0; end end else begin wrap <= 1'b0; end end endmodule配套 testbench:
`timescale 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 = 1'b0; always #10 clk = ~clk; // 50MHz 时钟 initial begin rst_n = 1'b0; en = 1'b0; #30; rst_n = 1'b1; en = 1'b1; #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{1'b0}}; wrap = 1'b0; end else if (en) begin cnt = cnt + 1'b1; // 阻塞:cnt 立即变成新值 if (cnt == MOD - 1'b1) begin cnt = {WIDTH{1'b0}}; wrap = 1'b1; end else begin wrap = 1'b0; end end else begin wrap = 1'b0; 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得到din,stage2得到上一拍的stage1,这才是标准两级流水线。
3.4 用 $display 观察“旧值”与“新值”的时差
非阻塞赋值的更新发生在当前时间步的 NBA 区,而不是 always 块内部语句执行的位置。因此在时序块里,紧跟在<=后面的打印语句看到的仍然是旧值:
always @(posedge clk) begin cnt <= cnt + 1'b1; $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 + 1'b1; always @(posedge clk) if (cnt == 4'd9) cnt = 4'd0;第一个块先执行时,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 <= 7'd0; carry <= 1'b0; end else if (en) begin if (cnt == 7'd99) begin cnt <= 7'd0; carry <= 1'b1; end else begin cnt <= cnt + 1'b1; carry <= 1'b0; end end else begin carry <= 1'b0; 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 <= 4'd0; ten <= 4'd0; carry <= 1'b0; end else if (en) begin if (unit == 4'd9) begin unit <= 4'd0; if (ten == 4'd9) begin ten <= 4'd0; carry <= 1'b1; end else begin ten <= ten + 1'b1; carry <= 1'b0; end end else begin unit <= unit + 1'b1; carry <= 1'b0; end end else begin carry <= 1'b0; 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{1'b0}}; else cnt <= cnt + 1'b1; 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{1'b0}}, 1'b1}; 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 <= 25'd0; led <= 1'b0; end else if (cnt_led == HALF - 1'b1) begin cnt_led <= 25'd0; led <= ~led; end else begin cnt_led <= cnt_led + 1'b1; end endPWM 的原理同样基于计数器:计数器从 0 累加到周期值,比较器判断当前计数值和占空比阈值,输出高或低电平。计数器、比较器、定时器,本质上都是同一套“边沿采样旧值、下一拍写回新值”的时序逻辑。
6. 计数器仿真异常排查链路
6.1 波形里计数一直不变
先确认信号是否真的在活动:
- 看
rst_n波形,复位是不是一直没有释放,或释放后又立刻被拉低。 - 看
en使能信号,使能一直为低,计数器自然不跑。 - 看仿真时间是否足够长,“多少个时钟周期”可能只跑了不到一个完整周期。
- 看时钟
clk是否真的在翻转,testbench 里always #10 clk = ~clk;是否被覆盖。 - 确认比较条件是否永假,例如
MOD大于2^WIDTH - 1,cnt永远到不了回绕值。
使用$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 更新前后的值差异。
每次修改后,都画出“边沿前旧值表”和“边沿后新值表”,再对照仿真波形验证。能准确预测每一条语句的输出,才是真正理解了阻塞赋值与非阻塞赋值的底层逻辑。对数字设计者来说,这个能力比记住任何编码规范都更重要,因为所有规范都是从这套调度模型推导出来的。