尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Verilog计数器设计陷阱:同步复位与模12计数器的RTL实现

Verilog计数器设计陷阱:同步复位与模12计数器的RTL实现 做到HDLbits的Counter系列时很多人以为计数器不过就是if-else里做加一减一直到在Counter 1-12这道题上第一次翻车。这道题在题库里的完整编号是Exam/ece241 2014 q7a是ECE241课程2014年考试的真题改造版表面上是入门的模12计数器练习实际考了三个很容易被忽略的细节同步复位值不是0而是1、计数范围是1到12、以及输出c的译码方式。这篇文章就从题目拆解、实现选型、时序细节、仿真验证到工程扩展完整复盘一道题的思考过程适合正在刷HDLbits、准备数字逻辑课设或者刚接触Verilog时序逻辑的读者。1. 题目拆解Counter 1-12到底在考什么为什么容易翻车1.1 行为规范逐条解读打开HDLbits这道题题面其实很短。模块只有一个顶层top_module接口是module top_module ( input clk, input reset, input enable, output [3:0] Q, output c );行为规范大致翻译过来是三句话reset有效时计数器复位到1enable有效时每个时钟上升沿Q递增当计数值到达12时Q回到1同时c输出高电平。看起来像一个普通的带同步复位和使能的计数器但把这三句话和传统的模12计数器对比差异立刻出来了。传统教材讲模12计数器默认是0到11复位值是0上限是11。这道题偏偏把范围换成了1到12复位值是1。别小看这个改动它直接影响你对“模”的理解不是所有计数器都以0为起点。很多同学在写代码时习惯性地把reset分支写成Q 4d0结果仿真波形里Q在复位释放后闪出一个0整个1到12序列的相位就全错了。c信号的语义也需要仔细揣摩。题面说“c should be high when the count is 12”意思是当前计数值等于12时c为高。这是一个对当前状态的译码输出不是“从12跳回1的那一拍”产生脉冲也不是“即将到达12”提前一拍预告。用Verilog表达最直接的写法就是组合逻辑assign读起来跟题面一一对应。1.2 和常规模12计数器的三个关键差异我把这道题和传统的0-11模12计数器做了个对比差异主要在三处列成表格会看得更清楚对比项传统模120~11Counter 1-12本题复位值01计数范围0,1,...,111,2,...,12回绕触发值1112输出位宽4位最高用到114位最高用到12实际场景教材通用钟表小时、分频序列第一处差异是复位值。复位到1意味着电路中产生有效状态的时刻是从1开始的。如果后续模块把Q当作某个分频相位或者状态编号1和0代表完全不同的物理含义。第二处差异是回绕条件。0-11计数器的判断条件是Q 4d11而这道题必须写成Q 4d12。有些人看到“模12”就直接写上限11没有意识到这里的有效值整体平移了一个单位。第三处是位宽与最高值。4位二进制能表示0到15容纳12没有任何问题但如果不小心把Q扩展成5位或缩小成3位行为就会异常3位只能表示到7压根到不了12。1.3 出题意图与考点还原按我做HDLbits的经验这类Exam题的价值不在于难而在于贴近工程习惯。ECE241是一门口袋级FPGA课程q7a对应的正是“同步时序逻辑设计”那一章。题目故意把复位值设成1就是逼你养成“复位值跟随需求而定而不是永远写0”的思维习惯。在实际工程里计数器常用来做协议状态编号、分频相位、显示循环值起始值完全由上游模块决定可能是0、1、3甚至是任意配置值。另外这道题也考察设计者对“输出译码”和“计数更新”两类逻辑的区分能力题目把c单独拉出来作为输出就是在提醒你计数器内部寄存器的更新和对外输出的译码是两条独立的数据路径。2. 三种可落地的RTL实现思路选型对比2.1 最直观的if-else写法不绕弯子先给最推荐的方案。完整代码如下module top_module ( input clk, input reset, input enable, output reg [3:0] Q, output c ); always (posedge clk) begin if (reset) begin Q 4d1; end else if (enable) begin if (Q 4d12) begin Q 4d1; end else begin Q Q 1b1; end end end assign c (Q 4d12); endmodule这里要特别强调两个地方。第一所有寄存器赋值都用了非阻塞赋值这是时序逻辑的基本规范。如果在同一个always块里用阻塞赋值仿真阶段可能看不出问题但一旦模块变复杂竞争冒险会立刻暴露出来。第二内部变量Q声明成了output reg因为它在always块里被赋值而c只是Q的译码所以声明成wire类型用assign直接连出去。逻辑流程是这样的每个时钟上升沿到来时先从最高优先级判断reset。reset为1不管enable和当前Q是多少Q都赋成1。reset为0时再看enableenable为0就什么都不做Q天然保持原值。enable为1时判断当前Q是否等于12等于12就让Q回到1否则Q自增1。这个嵌套if-else的结构实际上描述了一个带优先级的多路选择逻辑复位最高其次是使能最后才是回绕判断。2.2 三目运算符压缩写法如果觉得嵌套if-else太长可以压缩成三目运算符形式module top_module ( input clk, input reset, input enable, output reg [3:0] Q, output c ); always (posedge clk) begin if (reset) Q 4d1; else if (enable) Q (Q 4d12) ? 4d1 : Q 1b1; end assign c (Q 4d12); endmodule这种写法把“回绕判断”和“递增”合并到一个表达式里代码行数明显减少。三目运算符在综合工具眼里就是一个2选1多路器比较器输出为真选4d1为假选Q1。功能上和第一种完全没有区别仿真波形也完全一致。我个人的建议是如果你对Verilog的运算符优先级足够熟悉这种写法适合在正式工程里用它更紧凑review时一眼就能看清回绕条件。但如果你是初学者刚开始练习第一种嵌套写法更适合理解执行流程。2.3 计数逻辑与比较逻辑分离的模块化写法还有一种写法把“下一个状态”的计算单独拉出来module top_module ( input clk, input reset, input enable, output reg [3:0] Q, output c ); wire [3:0] next_Q; assign next_Q (Q 4d12) ? 4d1 : Q 1b1; always (posedge clk) begin if (reset) Q 4d1; else if (enable) Q next_Q; end assign c (Q 4d12); endmodule这种风格把组合逻辑和时序逻辑分离开next_Q是一个纯组合的“次态”信号。它的好处在于当计数器需要扩展功能时比如增加加载值、暂停、方向控制只需要修改next_Q的表达式时序部分的always块几乎不用动。很多规范严格的团队在设计状态机或计数器时强制要求这种“组合次态时序更新”的分离结构因为便于代码审查和形式验证。但说实话对于这道题来说这种写法和前两种在综合结果上基本等价。工具最终都会生成类似的多路选择器和加法器。选择哪种主要看你的团队规范和你自己的维护习惯。2.4 三种方案的选型对比与推荐把三种写法放在一起对比方案代码量可读性综合结果适用场景if-else嵌套稍长最清晰无差别新手学习、团队review三目运算符短中等无差别熟练开发者日常使用次态分离中等清晰无差别复杂计数器、状态机扩展我一般做题时首选第一种因为HDLbits的题目核心是验证逻辑正确性可读性优先。如果是写工程代码我会看周围同事的既有风格如果项目中大量使用次态分离我就会跟着用第三种保证风格统一。至于三目运算符非常好用但不要在if-else里套多层三目那样可读性会迅速恶化综合结果也不会更好。3. 同步复位、计数使能与c脉冲的时序细节理论和实测的关键点3.1 同步复位到1而不是异步复位影响在哪里题面明确要求reset是同步复位也就是说reset信号必须在时钟上升沿到来时被采样到才会触发复位动作。用代码表达就是always (posedge clk)块内部的if (reset)。如果你写过异步复位典型的写法是always (posedge clk or posedge reset)里面用if (reset)判断。这两种风格在仿真波形上的差异很明显异步复位下reset拉高的瞬间Q立刻变1不需要等时钟沿同步复位下reset拉高后要等到下一个时钟上升沿Q才变1。HDLbits的测试台默认就按同步信号来处理激励它会在固定的时钟沿附近改变reset所以你去硬件电路板上跑如果误用了异步复位写法题目也不一定会报错。但如果你在写真正的FPGA工程同步复位和异步复位的选择会影响时序收敛和复位释放的可靠性属于非常重要的问题。本题不需要纠结跟着题面走用同步复位即可。3.2 enable无效时Q和c都要保持再强调一遍enable的作用enable为0时每个时钟上升沿到来时计数器不应该做任何更新。在我们的代码里else if (enable)分支不成立所以always块里没有任何赋值语句作用到QD触发器会把原来的值原封不动地锁存下来。有人担心没有else分支会不会综合出latch这里要注意always (posedge clk)是时序逻辑块综合工具默认生成D触发器没有分支时就是保持不会产生latch。latch的问题只出现在组合逻辑always块里比如always (*)里if-else不完整。这两者不要混淆很多同学正是在这里栽了跟头。c的行为也和enable直接相关。因为c (Q 4d12)是组合逻辑只要Q不变c就不变。极端情况是如果enable恰好被拉低时Q正好是12那么c会一直保持高电平直到下一次enable有效、计数器回绕到1c才拉低。这个行为是符合题面描述的因为题面只说c在count为12时为高没有说c必须是一个严格的一周期脉冲。3.3 c的译码方式组合输出还是时序输出这是这道题最容易踩的坑之一。如果按照“输出寄存器化”的思维新手很容易把c写成这样always (posedge clk) begin if (reset) begin Q 4d1; c 1b0; end else if (enable) begin if (Q 4d12) begin Q 4d1; c 1b1; end else begin Q Q 1b1; c 1b0; end end end这种写法会让c晚一个时钟周期才变高。假设T0时刻Q已经等于12时序写法里c要到T1时刻才会变1而那时Q已经从12回到了1。从外部观察者的角度看c高电平出现的周期和Q12的周期错开了如果下游模块用c做使能就会抖动或者漏掉一拍。在HDLbits的自动测试里这种延迟几乎肯定会导致输出比对失败。所以正确做法是用assign c (Q 4d12);让它跟着Q组合变化。这也符合数字电路设计中的一条常识对于那些指示当前状态的控制信号用组合译码对于那些需要保持一拍或者作为状态参与时序计算的信号才放进寄存器。3.4 实测波形中c的精确时序拿iverilog自己做仿真把时钟周期设为10ns复位拉高两拍后释放enable一直为1。可以观察到复位释放后的第一个上升沿Q从1开始Q依次经过2、3、4到第11个计数周期时Q变成12同时c从0跳变到1第12个计数周期上升沿到来时Q从12回到1c同步回到0。c的波形宽度正好等于一个时钟周期而且是和Q12对齐的。这个对齐特性正是组合译码带来的。如果把c改成时序输出波形就会整体右移一个周期。肉眼看起来好像也算“到了12之后拉高”但严格比对就会发现它是在Q从12回到1的那个周期才拉高的。对某些下游电路这一拍的偏差就是致命的。4. 搭testbench验证时我实际踩过的几个坑4.1 手写testbench的基本骨架HDLbits平台自带测试环境但我们自己做练习时往往想本地复现。用iverilog或Vivado都可以测试平台的骨架大概是这样的timescale 1ns / 1ps module tb_top_module(); reg clk; reg reset; reg enable; wire [3:0] Q; wire c; top_module dut( .clk(clk), .reset(reset), .enable(enable), .Q(Q), .c(c) ); initial begin clk 0; forever #5 clk ~clk; end initial begin reset 1; enable 0; #20; reset 0; enable 1; repeat (14) (posedge clk); if (Q ! 4d1) $display(FAIL: after wrap, Q %0d, Q); else $display(PASS: wrap to 1); enable 0; #20; if (c ! 1b0 || Q ! 4d1) $display(FAIL: enable low behavior); reset 1; #20; if (Q ! 4d1) $display(FAIL: reset to 1); $finish; end endmodule注意我用了$display打印检查结果并且把使能保持的时间控制在整数个时钟周期避免在时钟沿附近改变信号导致采样不确定。时钟周期是10ns所以#20正好跨过两个上升沿。这个testbench虽然简单但已经能覆盖复位、正常计数、回绕、使能暂停这几个核心场景。4.2 我实际遇到的几个问题定位过程先说最常见的错误复位值写成0。现象是复位释放后Q从0开始序列变成0、1、2、...、12、?到12之后跳过1直接回绕整个序列长度变成了13个状态相位全乱。定位方法很简单在波形里拉一条marker对齐复位释放后的第一个上升沿看Q的值到底是1还是0立刻就暴露了。第二个常见错误是回绕条件写错。有人把if (Q 4d12)写成了if (Q 4d11)结果Q走到11就提前回绕12这个状态永远不会出现c也永远不拉高。这种错误用波形也容易看出来看Q的峰值是11还是12就行。第三个坑是c用了时序逻辑现象我前面说过c晚了一拍。波形上c和Q12不对齐只要把c和Q叠在同一屏里看即可。第四个坑是enable处理遗漏比如把enable拼到了加法器上但没有关闭更新导致enable为0时Q还在变。这类错误在波形上表现为Q波形“停不下来”。4.3 归纳一份快速定位错误的检查清单结合多次做题的体会归纳出一份检查清单按顺序排查复位释放后第一个上升沿Q是否为1正常使能时Q序列是否为1、2、3、...、12、1、2...Q到达12时c是否在同一周期内拉高Q从12回绕到1时c是否在同一周期拉低enable拉低后Q和c是否都保持不变任意时刻reset拉高Q是否在下一个上升沿变回1如果六项全过基本可以确定这道题通过了。我用这个清单检查过其它计数器题目同样适用只是把序列和回绕值换成对应题目要求而已。5. 从1到12到任意模把这道题改造成通用计数器5.1 参数化写法做题只是第一步真正有用的是把它变成参数化的模块以后任何需要模N计数器的场合直接例化。下面是一个通用版本的计数器module counter_wrap #( parameter WIDTH 4, parameter START_VALUE 4d1, parameter LIMIT_VALUE 4d12 )( input clk, input reset, input enable, output reg [WIDTH-1:0] Q, output tick ); always (posedge clk) begin if (reset) Q START_VALUE; else if (enable) begin if (Q LIMIT_VALUE) Q START_VALUE; else Q Q 1b1; end end assign tick (Q LIMIT_VALUE); endmodule这里我用了parameter把起始值、上限和位宽全部参数化用的时候只要改参数就能得到任意范围的计数器。位宽和常数要匹配如果WIDTH设成8但LIMIT_VALUE还是4d12比较器就会发生位宽不匹配综合工具可能要报warning。建议写成LIMIT_VALUE 8d12这样与位宽一致或者用clog2自动计算位宽。有一个小细节Q 1b1这种写法在参数化WIDTH之后依然成立因为1b1会自动扩展到WIDTH位。如果写成Q 1在有些仿真器里可能因为integer的符号问题产生隐式扩展虽然最终结果一般没错但严谨起见还是用1b1干净。5.2 什么时候用脉冲输出什么时候用时钟使能tick信号是“计数值到达上限”的标志。工程上这个信号有两种典型用法。一是作为下游逻辑的时钟使能比如秒表里“秒”计数器到了59拉一个tick去使能“分”计数器加一这时tick应该接到下游的enable上而绝不能接到下游的clk上。二是作为中断或事件标志比如“任务计时到12个周期”tick直接拉高通知控制逻辑。把计数器输出当作时钟是新手很容易犯的毛病。晶振进来的时钟只有一个所有时序逻辑都应该接在同一个全局时钟上这样才能保证时序收敛和STA结果正常。如果拿tick去驱动触发器时钟就会产生门控时钟问题毛刺、建立时间违例都会跟着来。这也是这道题用enable信号作为“门控”而非直接改时钟的原因之一。5.3 在HDLbits同类题里的迁移思路掌握这道题的写法之后HDLbits里好几个类似的题目就有了统一的解题框架。比如Counter 1-12的变体、Countbcd多位的BCD计数器、以及一些带load、带direction的计数器题说到底都是“复位值 计数条件 回绕条件 输出译码”四个点的组合。做题时先问自己四个问题复位值是什么enable是同步使能还是异步使能回绕的判断值是多少输出是组合译码还是时序标志把这四个问题回答了代码基本就有了。这类题目的难点不在语法而在读题。HDLbits的Exam题里经常混着“count from 1 to 12”这种带迷惑性的描述读题的时候把关键数值圈出来动手之前先列行为清单比直接写代码要高效得多。最后再说一个我个人的习惯。每次完成这类计数器题我会在RTL文件顶部写一段注释把复位值、计数范围、回绕条件、输出语义全部记录下来。看起来有点小题大做但在后续维护或者多人协作时这段注释能省下大量沟通成本。比如这个Counter 1-12如果注释里没写明“复位到1”这个反直觉的设计两个月后的自己大概率也会当成普通模12计数器改回去。计数器是数字系统里最容易写对、也最容易改错的模块养成严谨记录的习惯比多刷几道题更能提升工程能力。
返回列表