ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Verilog三分频设计详解:从三分之一到50%占空比的完整实现

Verilog三分频设计详解:从三分之一到50%占空比的完整实现 三分频这道题我印象里起码在四五轮数字IC面试里碰到过而且版本还不止一个。有的面试官直接甩一张白纸让你写Verilog有的会追问“占空比能不能做到50%”还有的会在你交出代码之后补一句“你这个输出毛刺怎么办”。题目本身不难但变形多、坑多扩展性也强几乎把时序逻辑的基础知识全串起来了计数器、状态机、双边沿采样、非阻塞赋值、组合逻辑竞争冒险。这篇文章就把这根硬骨头拆开啃干净。三分之一占空比、三分之二占空比、50%占空比三种情况各给一套可复现的写法再补上时序推导、Testbench验证和面试追问的应答思路。不管你是准备校招、跳槽还是刚开始学Verilog想找点实战练手照着这篇文章的思路走一遍基本能把三分频相关的考点吃透。1. 先搞清楚这道题到底在考什么1.1 一道题三个占空比三个层级的考察三分频听着简单但面试官能把它玩出花来。基本盘是输入一个时钟输出一个频率等于输入频率三分之一、占空比满足指定要求的时钟。三分之一、三分之二、50%三种占空比对应的设计难度是递增的考察点也完全不同。三分之一占空比考的是最基础的计数器思想三个时钟周期里让输出高电平只占一个周期。这是入门级能写出来说明你懂分频本质。三分之二占空比在此基础上稍微绕一下高电平占两个周期可以取反、可以改状态映射、也可以用两个窄脉冲合成这一层考的是你能不能用不同思路解决同一个问题。50%占空比才是真正的分水岭因为三分之一、三分之二都能靠单一上升沿计数器实现但50%在高电平占据一个半时钟周期这就没法用单个上升沿触发搞定了必须引入下降沿采样。面试官问这道题其实是在用最少的代码量快速判断你的数字电路基本功计数器会不会写、时序逻辑和组合逻辑分不分得清、非阻塞赋值懂不懂、遇到“整数周期内凑不出需求占空比”的情况有没有思路。1.2 偶数分频好做奇数分频难在哪分频电路最基础的套路是计数翻转。一个N分频器本质是输入时钟每跑N个周期输出才跑完一个完整周期。如果是偶数分频比如4分频计数器从0数到1、数到2就翻转一次高低电平各占两个周期天然就是50%占空比逻辑极简单。奇数分频麻烦在“对称翻转”做不到。拿3分频来说一个完整输出周期需要3个输入时钟周期50%占空比需要高电平1.5个周期。但时序逻辑的翻转只能发生在时钟边沿普通的上升沿触发器只能在整数倍时钟位置翻转1.5T这个时刻根本不在上升沿上所以单靠上升沿计数器怎么凑都凑不出50%。很多初学者一上来就写if (cnt 2d1) clk_out ~clk_out;计数器0到2循环cnt等于1时翻转一次。看起来是每隔3个周期翻转实际上输出周期变成了6个时钟周期做出来的是6分频。这个错误我在面试模拟里见过太多次本质就是没想清楚“翻转间隔”和“输出周期”的关系。奇数分频的难点说白了就是要在非整数周期的位置上安排一次翻转这需要两条沿配合。1.3 五秒钟建立解题框架拿到分频题先别急着写代码先在脑子里过一遍需求分频数是多少占空比是多少。分频数决定计数器的模占空比决定高电平需要覆盖几个输入周期。这两个数一旦确定题目的复杂度就暴露了。如果是N分频、占空比为M/NM是整数那直接用上升沿计数器就能解决让高电平覆盖M个计数状态即可。如果占空比是50%而N是奇数那必须走“双沿采样组合输出”的路线。把这一步想明白后面写代码基本就是按图索骥。2. 三分之一占空比最简单的状态机解法2.1 模3计数器与输出映射三分之一占空比输出高电平只占一个输入时钟周期。设计思路非常直接搞一个计数器让它在0、1、2三个状态之间循环然后把某个状态映射成高电平其余两个状态映射成低电平。计数器模3需要2位寄存器这是个小细节但面试偶尔会问。2位寄存器能表示0到3虽然3这个状态用不到但取2位才能覆盖0、1、2三个计数状态。用一个1位寄存器肯定不够所以这里定死是reg [1:0] cnt。计数器在cnt等于2时清零让状态序列变成0→1→2→0→1→2循环。输出映射我个人习惯用cnt等于0时输出高其余时间输出低。这样第一个周期拉高第二、第三个周期拉低高电平持续正好一个时钟周期占空比三分之一。2.2 完整代码与逐行说明module div3_1_3 ( input clk, input rst_n, output reg clk_out ); reg [1:0] cnt; always (posedge clk or negedge rst_n) begin if (!rst_n) cnt 2d0; else if (cnt 2d2) cnt 2d0; else cnt cnt 1b1; end always (posedge clk or negedge rst_n) begin if (!rst_n) clk_out 1b0; else if (cnt 2d0) clk_out 1b1; else clk_out 1b0; end endmodule这里有几个细节值得展开。第一个是复位信号用异步复位negedge rst_n复位时计数器和输出都归零。第二个是计数器清零的时机cnt等于2时下一个时钟沿回到0而不是cnt等于3才清零这样可以省掉一个无效状态让每个输出周期严格等于3个输入周期。第三个也最关键的是输出赋值要放在独立的always块里而且必须用非阻塞赋值。原因在于非阻塞赋值能保证两个always块在同一个时钟沿各自独立更新不会产生竞争。如果把计数器和输出写在一个always块里也不是不行但改成两块逻辑更清晰面试时更好解释也方便后面扩展其他占空比版本。2.3 这类基础代码的两个隐蔽坑第一个坑是输出初始状态。如果复位时把clk_out置成高电平那复位释放之后第一个周期输出是高的但第二个周期如果cnt等于0又拉高相当于高电平多持续了一个周期占空比瞬间变成三分之二。所以复位值必须和映射逻辑保持自洽我习惯复位时全部归零。第二个坑是计数器的边界。有人喜欢写if (cnt 2d1) cnt 2d0; else cnt cnt 1b1;让计数器在1时清零。这样计数序列是0、1循环输出会变成2分频而不是3分频。边界写错一个数分频比就完全变了。改代码前先在心里走一遍状态转移表这是最笨但最有效的方法。3. 三分之二占空比三条路选哪条3.1 三条实现路线对比三分之二占空比高电平覆盖两个状态。实现路线至少有三条面试时如果能主动列出多种方法并分析优缺点绝对加分。实现方式核心思路优点风险状态机直接输出cnt等于2时拉低其余时间拉高输出由寄存器直接驱动时序干净无对1/3输出取反assign clk_out ~clk_1_3代码最少复用已有模块组合逻辑延迟输出不再是寄存器驱动两个1/3信号相或相位错开一个时钟周期的两个窄脉冲相或体现相位设计思维同沿竞争存在毛刺隐患第一种写起来最直白也最推荐作为笔试答案。第二种在你已经写了三分之一占空比模块的情况下一条assign语句就能搞定但面试官可能会追问组合逻辑延迟。第三种适合秀思路但实际工程中要小心。3.2 寄存器直接输出最稳的选择module div3_2_3 ( input clk, input rst_n, output reg clk_out ); reg [1:0] cnt; always (posedge clk or negedge rst_n) begin if (!rst_n) cnt 2d0; else if (cnt 2d2) cnt 2d0; else cnt cnt 1b1; end always (posedge clk or negedge rst_n) begin if (!rst_n) clk_out 1b0; else if (cnt 2d2) clk_out 1b0; else clk_out 1b1; end endmodule这段代码和三分之一占空比版本只有输出映射不同。计数器完全一样区别在于cnt等于2时拉低cnt等于0、1时都拉高高电平覆盖两个状态占空比自然就是三分之二。寄存器直接输出的好处是输出信号由D触发器驱动信号边沿和时钟严格对齐没有组合逻辑传播路径也不会有毛刺。综合之后时序收敛压力小在工程实现里这是最让人放心的写法。笔试时优先写这个版本可以少费很多口舌去解释。3.3 取反和相或能省就省但要说清代价取反方案的代码量非常少如果前面已经有了clk_1_3后面直接加一句assign clk_out_2_3 ~clk_1_3;高电平持续两个周期因为低电平原本只占一个周期取反之后高电平就补成两个周期了。这个思路没问题代价是反相器会引入一个组合逻辑延迟输出的边沿和理想位置会有偏差。在同步系统里如果这个分频信号被当作时钟使用延迟会导致时序裕量变差。面试时提到这一点面试官会觉得你考虑问题比较全面。相或方案则需要构造两个相位不同的1/3占空比信号一个在cnt等于0时拉高另一个在cnt等于1时拉高然后相或reg clk_a, clk_b; always (posedge clk or negedge rst_n) begin if (!rst_n) begin clk_a 1b0; clk_b 1b0; end else begin if (cnt 2d0) clk_a 1b1; else clk_a 1b0; if (cnt 2d1) clk_b 1b1; else clk_b 1b0; end end assign clk_out_2_3 clk_a | clk_b;这个方案的问题很隐蔽clk_a在cnt等于1的时钟沿拉低clk_b恰好在同一个时钟沿拉高。两个信号同时反向变化如果它们到达或门的路径延迟不一致输出端会产生瞬间的低电平毛刺。RTL仿真里因为理想模型看不到但实际硅片上非常容易出现。所以这种方法更适合作为思路展示不适合作为工程首选。4. 50%占空比双沿采样的经典解法4.1 为什么单计数器出不了50%这是整个题目的核心也是面试官最想听到你展开分析的部分。3分频的周期是3T50%占空比意味着高电平持续1.5T。上升沿触发器只能把翻转安排在整数倍的时钟沿上也就是0、T、2T、3T这些位置高低电平的持续时间只能是T的整数倍。既然持续时间必须是整数倍3T的周期就只能在3T和1T之间分配或者2T和1T之间分配无论如何都凑不出1.5T。除非让某个翻转发生在T/2的整数倍处也就是时钟的下降沿位置。这就是双边沿采样思路的由来在上升沿触发一路信号在下降沿触发另一路信号两路信号的跳变时间错开半个周期最后再组合出1.5T的高电平。4.2 双计数器实现一上一下各管一半先看最经典的实现。两个计数器一个在上升沿计数一个在下降沿计数各自循环0到2。上升沿计数器的输出clk_pos在cnt等于2时拉高、下一个上升沿拉低产生一个持续1T的窄脉冲。下降沿计数器的输出clk_neg逻辑相同但相位整体后移半个周期。两路窄脉冲相或得到高电平1.5T、周期3T的输出。module div3_50 ( input clk, input rst_n, output clk_out ); reg [1:0] cnt_pos; reg [1:0] cnt_neg; reg clk_pos; reg clk_neg; always (posedge clk or negedge rst_n) begin if (!rst_n) begin cnt_pos 2d0; clk_pos 1b0; end else if (cnt_pos 2d2) begin cnt_pos 2d0; clk_pos 1b1; end else begin cnt_pos cnt_pos 1b1; clk_pos 1b0; end end always (negedge clk or negedge rst_n) begin if (!rst_n) begin cnt_neg 2d0; clk_neg 1b0; end else if (cnt_neg 2d2) begin cnt_neg 2d0; clk_neg 1b1; end else begin cnt_neg cnt_neg 1b1; clk_neg 1b0; end end assign clk_out clk_pos | clk_neg; endmodule为了更直观地理解可以把时间轴上的关键变化列成一张表。假设输入时钟周期为T上升沿出现在0、T、2T、3T下降沿出现在0.5T、1.5T、2.5T、3.5T时间点cnt_posclk_poscnt_negclk_negclk_out0上升沿00--00.5T下降沿--000T上升沿10--01.5T下降沿--1002T上升沿21--12.5T下降沿--2113T上升沿00--13.5T下降沿--000clk_out在2T拉高、3.5T拉低高电平持续1.5T输出周期3T正好是50%。这张表记在脑子里面试时拿笔三分钟画出来比背任何答案都有说服力。4.3 单计数器双沿采样精简版实现如果不想用两个计数器可以只用一个在上升沿工作的计数器然后在下降沿直接采样这个计数器的值。原因是计数器只在上升沿更新下降沿到来时它保持稳定所以拿这个稳定值再去触发另一路信号是安全的。module div3_50_single ( input clk, input rst_n, output clk_out ); reg [1:0] cnt; reg clk_pos; reg clk_neg; always (posedge clk or negedge rst_n) begin if (!rst_n) cnt 2d0; else if (cnt 2d2) cnt 2d0; else cnt cnt 1b1; end always (posedge clk or negedge rst_n) begin if (!rst_n) clk_pos 1b0; else if (cnt 2d2) clk_pos 1b1; else clk_pos 1b0; end always (negedge clk or negedge rst_n) begin if (!rst_n) clk_neg 1b0; else if (cnt 2d2) clk_neg 1b1; else clk_neg 1b0; end assign clk_out clk_pos | clk_neg; endmodule这段代码里下降沿always块看到的cnt是最近一次上升沿写入的值。2T的上升沿cnt变成2紧接着2.5T的下降沿采样到cnt等于2clk_neg拉高3T的上升沿cnt归零3.5T的下降沿采样到0clk_neg拉低。最终效果和双计数器版本完全一致但少了一个计数器面积更小。这种写法在面试中属于小加分项。它说明你清楚非阻塞赋值的执行顺序也理解不同边沿采样同一个信号的时序关系。不过要先把双计数器版本讲明白再提这个精简版如果一开始就写单计数器版面试官可能觉得你的思路不够基础。4.4 或门输出为什么不会毛刺逐拍推演面试官看到最后一行assign clk_out clk_pos | clk_neg;大概率会追问一句“你这里用了组合逻辑输出时钟会不会有毛刺”这个问题如果能答好整道题的印象分会明显拉高。先看两路输入的变化时间clk_pos只在上升沿变化clk_neg只在下降沿变化。它们的跳变时间天然错开半个周期。在2T这个上升沿只有clk_pos从0变1clk_neg稳定为0或门输出干净地从0变1。在2.5T这个下降沿只有clk_neg从0变1clk_pos稳定为1或门输出保持1。3T只有clk_pos从1变0clk_neg稳定为1输出继续为1。3.5T只有clk_neg从1变0clk_pos稳定为0输出从1变0。每一拍都只有一个输入在翻转另一个输入处于稳定电平。即便触发器Q端到达或门的路径有微小延迟偏差也不会产生竞争冒险因为根本没有两路信号同时翻转的窗口。这就是这个结构安全的原因。但要注意如果改成两个信号在同一时钟沿反向变化比如之前提到的clk_a和clk_b相或的方案情形就完全不同。所以50%分频用双沿采样相或不仅解决了占空比问题还天然规避了组合逻辑毛刺一举两得。5. 仿真验证别只靠眼睛数波形5.1 Testbench搭建要点写完代码必须仿真这是基本功。Testbench的最简结构就是产生输入时钟、控制复位、例化DUT。时钟周期可以用forever #5 clk ~clk;这种写法10ns一个周期方便手算。timescale 1ns/1ps module tb_div3(); reg clk; reg rst_n; wire clk_1_3; wire clk_2_3; wire clk_50; initial begin clk 1b0; forever #5 clk ~clk; end initial begin rst_n 1b0; #20; rst_n 1b1; #2000; $finish; end div3_1_3 u_div3_1_3 ( .clk (clk), .rst_n (rst_n), .clk_out(clk_1_3) ); div3_2_3 u_div3_2_3 ( .clk (clk), .rst_n (rst_n), .clk_out(clk_2_3) ); div3_50 u_div3_50 ( .clk (clk), .rst_n (rst_n), .clk_out(clk_50) ); endmodule复位释放时间要覆盖几个完整时钟周期确保计数器从已知状态启动。仿真时间建议跑至少20个输出周期也就是60个输入周期以上这样占空比才能统计出有效结果。5.2 周期与占空比的自动测量人眼看着波形数周期容易数错尤其是高电平持续时间只有一个半周期时数着数着就晕了。在Testbench里写一段自动测量逻辑省心得多。思路是用一个计数器统计clk_50每次高电平持续了多少个输入时钟周期以及相邻上升沿之间的间隔。reg [31:0] clk_cnt; reg clk_50_d; wire clk_50_rise; wire clk_50_fall; assign clk_50_rise clk_50 ~clk_50_d; assign clk_50_fall ~clk_50 clk_50_d; always (posedge clk or negedge rst_n) begin if (!rst_n) begin clk_cnt 32d0; clk_50_d 1b0; end else begin clk_50_d clk_50; clk_cnt clk_cnt 1b1; if (clk_50_rise) begin clk_cnt 32d0; $display(rise at cnt %0d, clk_cnt); end if (clk_50_fall) $display(high duration %0d clk cycles, expected ~1.5 cycles, clk_cnt); end end这段逻辑在clk_50出现上升沿时清零计数器出现下降沿时打印高电平持续长度。理想情况下打印出来的值应该在1到2个时钟周期之间波动多次平均接近1.5。同时每次上升沿打印的间隔也间接反映了输出周期是否为3个时钟周期。仿真跑完所有结论都有数据支撑面试或笔试复盘时拿出来说比一句“我看了波形没问题”可信得多。5.3 面试追问分频时钟到底能不能直接用分频出来的时钟能不能直接接到下一个模块的时钟端口这是工程观念题。真实ASIC设计里分频时钟是逻辑电路产生的没有经过时钟树综合偏斜和抖动都不受控。直接用这种时钟驱动寄存器时序收敛会非常困难稍不留神就出现setup或hold违例。更推荐的做法是把分频器改造成时钟使能。还是用三分频的例子生成一个每隔3个周期拉高一个周期的使能信号所有需要慢速工作的寄存器仍然使用全局时钟额外把clk_en接到寄存器使能端reg clk_en; always (posedge clk or negedge rst_n) begin if (!rst_n) clk_en 1b0; else if (cnt 2d2) clk_en 1b1; else clk_en 1b0; end这样既实现了慢速逻辑又避免了分频时钟带来的时钟树问题。面试时能主动说出这个观点说明你不是只会写代码的书呆子有一定后端和物理设计意识。6. 高频追问与避坑清单6.1 面试官最喜欢追加的6个问题第一个追问是“能不能用PLL实现”。可以PLL是模拟混合信号方案输出干净、抖动小但通常用于直接产生特定频率时钟不是RTL笔试题的考察点。笔试题问Verilog实现默认考察数字逻辑方案PLL可以作为加分补充提一句不要当主线。第二个追问是“扩展到5分频怎么做”。奇数分频50%的通用套路是一致的上升沿计数器从0循环到4cnt等于4时clk_pos拉高下降沿同样采样两路相或得到的高电平持续2.5T周期5T。N分频无非是把计数器模改成N边界值改成N-1逻辑结构不变。第三个追问是“为什么用非阻塞赋值”。时序逻辑必须用非阻塞赋值这是为了模拟真实触发器的并行更新语义防止两个always块之间产生竞争。三段式状态机写多了这个问题必须刻在脑子里。第四个追问是“输出有没有毛刺”。50%版本的相或没有毛刺因为两个输入从不同时钟沿触发跳变时间错开。这个分析过程要有条理地说出来最好配合时间轴示意。第五个追问是“复位方式为什么选异步复位”。分频器是边沿敏感电路用异步复位保证任何时刻复位都能立刻清零不依赖时钟是否活动。如果改用同步复位时钟停摆时复位根本进不来这在工程上是个隐患。第六个追问是“能不能直接把这几个模块级联成6分频或9分频”。理论上级联可以但级联会叠加组合延迟和时钟偏斜不推荐。实际设计要分频比时更常用计数器或PLL级联分频器更多出现在教学示例里。6.2 一眼就能防踩雷的检查表把几个高频错误整理成清单写代码和面试复盘时对照自查检查项常见错误正确做法计数器边界cnt等于3才清零状态出现4个模3计数应在cnt等于2时清零计数器位宽用1位寄存器做模3必须用2位1位只能做模2复位初值clk_out复位拉高占空比被破坏复位值须与映射逻辑自洽统一归零输出赋值用assign直接组合输出寄存器驱动优先组合输出要自证无毛刺非阻塞赋值时序块里写时序逻辑一律用占空比计算只数高电平周期忽略相位高电平持续时间除以输出周期不能只看周期数仿真验证人眼数波形没自动断言用计数器自动打印高电平持续和相邻上升沿间隔这张表把写分频器最容易翻车的点都列全了。笔试后的自检和面试前的快速复习都可以直接拿它当提纲。最后再分享一个实操习惯。面试现场写代码我习惯先在草稿纸上画出时间轴标出上升沿、下降沿、输出信号的跳变位置然后才开始写Verilog。三分频这种题目代码可能只有二三十行但时序关系理不清楚写完也是心里发虚。时间轴画完占空比、输出周期、信号相位一目了然代码只是把这些关系翻译成Verilog而已。磨刀不误砍柴工这个习惯帮我在面试里少掉了不少链子。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进