Verilog实现任意奇数分频:50%占空比原理、参数化代码与仿真验证
时钟分频这个东西表面上看就是“数一下计数器的值翻转一下输出电平”可真要较真起来尤其是奇数分频还要把占空比卡在50%里面能抠出来的细节远比你想象的多。我最早弄这个是在一个某跨平台系统的接口模块里外部器件只认严格对称的采样时钟系统主频又不是它需要的整数倍最后不得不自己写一个五分频、七分频的RTL模块。当时在论坛上翻了不少资料发现大多数代码能跑但没把原理讲透换一个分频数就可能踩坑。这篇文章就把“Verilog HDL实现任意50%占空比的奇数分频”这件事从头到尾梳理一遍从原理推导到参数化代码再到仿真验证和工程避坑适合刚接触FPGA或数字IC设计的同学也适合拿来当校招笔试、面试题的复盘素材。1. 项目背景不是所有分频都能“一拍脑门就写”1.1 典型场景为什么会有奇数分频需求分频的本质是让输入时钟的每个周期对应输出时钟的某个状态输出的频率等于输入频率除以N。偶数分频比如2、4、6、8用单个计数器在posedge下计数到N/2翻转一次到N再翻转一次占空比可以轻松做到50%逻辑也简单。可实际项目里不是总有偶数分频就能解决的问题。举个例子某图像处理Demo用的传感器要求输入时钟26.6MHz而系统主时钟是133MHz这档频率正好是五倍关系必须做五分频。再比如串口波特率生成系统时钟48MHz要出9.6kHz的波特率时钟算下来也是接近奇数分频的组合甚至还要配合小数分频。更常见的是RGMII/GMII接口里的125MHz与25MHz它们之间是五倍关系但很多设计里25MHz并不依赖于PLL单独生成而是直接从本地时钟先分出来。奇数分频的难点不在于计数本身——任何N都能用计数器分出来——难点在于占空比。奇数N的半个周期也就是N/2个输入时钟周期并不是整数个输入时钟沿。而普通的单计数器只在一个边沿上翻转电平天然只能生成整数个输入周期的宽度所以单计数器直接做出来的奇数分频占空比必然不是50%。要么是高了低了的“近似50%”要么就得想别的办法。1.2 50%占空比到底值多少很多人觉得占空比差不多就行这里必须较个真。在低速逻辑里占空比偏离几个百分点可能没感觉但时钟一旦进入比较器、ADC采样保持、DDR接口、数据恢复电路占空比失真直接演变成有效采样窗口缩短进而影响时序裕量。有的数据手册会明确给出占空比要求常见的是45%到55%之间有些高速接口甚至压到48%到52%。所以“任意奇数分频且50%占空比”这个需求不是炫技而是很多接口设计的硬指标。把这篇文章里的方法吃透后面遇到3分频、5分频、7分频、9分频甚至更大的任意奇数分频都可以通过一套参数化代码去套不需要每次都重新造轮子。2. 奇数分频难在哪单个计数器解不了这个题2.1 偶数分频和奇数分频的天然差异先复习一下偶数分频的标准写法。拿四分频举例计数器0到3循环当计数到1N/2-1和3N-1时翻转输出于是高电平两个周期低电平两个周期占空比50%。这里隐含了一个条件N/2是整数翻转点恰好落在两个整数计数值上高电平宽度和低电平宽度都是整数个输入时钟周期。奇数N就尴尬了比如N5N/22.5。如果你让输出高电平两个周期、低电平三个周期那占空比是40%如果高电平三个周期、低电平两个周期占空比是60%。无论你选择在哪两个计数值之间翻转高低电平宽度始终是整数对整数凑不出2.5对2.5。这就是核心矛盾的来源输出电平只能在一个时钟边沿处翻转而奇数分频所要求的翻转时刻落在了两个相邻时钟边沿的正中间。那个中间时刻在单边沿采样逻辑里根本不存在。2.2 从波形角度看“半个周期”要做出那半个周期思路说破其实很简单既然单个计数器只能在一个边沿上翻转那我在上升沿做一个计数器再在下降沿做另一个计数器两个信号各自都不是50%但它们在时间上互相错开了半个输入时钟周期。把两个信号做简单的组合逻辑让输出在某一侧的上升沿被“补”出来另一侧的下降沿被“削”掉最终高低电平宽度都变成N/2个输入周期。用N5举例。我定义一个阈值TH (N-1)/2 2。第一个计数器在posedge clk_in计数当计数值cnt_p大于等于2时clk_p输出高电平也就是说cnt_p为0、1时clk_p是低cnt_p为2、3、4时clk_p是高。clk_p高电平持续3个输入周期低电平2个周期占空比60%。第二个计数器用同样的阈值但在negedge clk_in计数得到clk_n。clk_n的波形和clk_p几乎一样只不过每个翻转沿都整体往后平移了半个输入时钟周期。注意这个平移是关键clk_p在上升沿附近变化clk_n在下降沿附近变化二者永远不会同时翻转。2.3 相位合并与门/或门怎么用现在把clk_p和clk_n做“与”逻辑clk_out clk_p clk_n。因为clk_p高电平的窗口是3个周期clk_n的高电平窗口也差不多3个周期但二者错开半个周期取交集之后高电平长度正好变成2.5个周期。同理两路信号的最终低电平交集也是2.5个周期。于是一个严格的50%占空比五分频就出来了。反过来也成立如果我把clk_p和clk_n定义成“小于TH就为高”也就是高电平窗口较短的那一侧那么两个信号的“或”逻辑同样可以补出50%占空比。工程上你只需要记住一个原则长高电平侧用与门合并长低电平侧用或门合并。大多数教材里给的都是“与门”版本代码写起来也顺手。这个思路对任意奇数N都成立。N3时TH1clk_p在cnt为0时低、1和2时高clk_n同理错开半拍与门合并后高电平宽1.5个周期N7时TH3clk_p低3个周期、高4个周期错开半拍做与门高电平宽3.5个周期。本质上两个非50%信号通过相位差互补把缺失的半周期补了回来。3. 参数化Verilog实现一套代码通吃任意奇数3.1 接口与参数设计为了让模块能适应任意奇数分频参数化是第一选择。顶层参数就一个DIV_N代表分频数。内部需要根据DIV_N自动计算两个关键值计数器位宽CNT_W以及翻转阈值THRESH。计数器位宽要能装下0到DIV_N-1直接用$clog2(DIV_N)最省事。比如5分频需要3位计数7分频也需要3位9分频就需要4位。这是基本的对数计算综合工具都能很好支持。阈值THRESH (DIV_N - 1) / 2。在Verilog里整数除法的结果自动向下取整所以3分频得到15分频得到27分频得到3刚好就是我们要的“低电平计数值个数”。复位策略我用异步复位、低电平有效这是FPGA设计里最常用的方式能够保证复位信号优先且不受时钟影响在仿真中也容易观察清空动作。3.2 完整源代码下面这段代码是完整的参数化奇数分频模块分频数只要是大于等于3的奇数就行。module clk_div_odd #( parameter integer DIV_N 5 )( input wire clk_in, input wire rst_n, output wire clk_out ); // 计数器位宽能容纳 0 ~ DIV_N-1 localparam integer CNT_W $clog2(DIV_N); // 翻转阈值低电平计数值个数 localparam integer THRESH (DIV_N - 1) / 2; reg [CNT_W-1:0] cnt_p; reg [CNT_W-1:0] cnt_n; wire clk_p (cnt_p THRESH); wire clk_n (cnt_n THRESH); // 上升沿计数器 always (posedge clk_in or negedge rst_n) begin if (!rst_n) cnt_p 0; else if (cnt_p DIV_N - 1) cnt_p 0; else cnt_p cnt_p 1b1; end // 下降沿计数器 always (negedge clk_in or negedge rst_n) begin if (!rst_n) cnt_n 0; else if (cnt_n DIV_N - 1) cnt_n 0; else cnt_n cnt_n 1b1; end // 两个相差半周期的信号做与运算 assign clk_out clk_p clk_n; endmodule验证一下参数传递DIV_N3时CNT_W2THRESH1cnt取0、1、2DIV_N5时CNT_W3THRESH2DIV_N7时CNT_W3THRESH3。全部正确。3.3 关键代码逐段解读第一段是cnt_p的上升沿计数。这里复位优先级最高只要rst_n拉低计数器立刻清0。正常工作时计数到DIV_N-1的下一秒回到0形成模DIV_N的计数器不会出现溢出后需要额外清零逻辑的问题。第二段cnt_n几乎一模一样只是时钟敏感沿从posedge换成了negedge。这是整个设计的灵魂如果没有下降沿这一路就永远无法获得那个相差半拍的高电平窗口。第三段是两个输出信号的电平定义。clk_p (cnt_p THRESH)的含义需要仔细理解当计数值为0到THRESH-1时输出低计数值为THRESH到DIV_N-1时输出高后面的高电平持续时间为DIV_N-THRESH个输入时钟周期。对N5来说THRESH2低电平0、1高电平2、3、4高电平持续时间是3个周期长于低电平的2个周期正是我们前面说的“长高电平侧”。最后一段把两路信号用与门合并。因为clk_p只在上升沿附近变化clk_n只在下降沿附近变化两路信号绝不会在同一时刻翻转所以与门输出理论上没有毛刺。这一点后面调试部分还会展开讲。3.4 与门/或门怎么选如果拿到一份现成的代码发现里面用的是clk_out clk_p | clk_n先别急着改。看看clk_p的定义如果它写成(cnt_p THRESH)也就是计数值小于阈值时输出高电平那它此时的高电平持续时间是THRESH个周期是短高电平侧这时候用或门合并同样能得到50%占空比。我习惯把两种风格统一成一种全用“与门长高电平”这样代码可读性好也方便后续维护。但如果你的下游逻辑对输出电平极性有要求比如某个引脚只能在低电平段释放锁存器那也可以刻意选用“或门短高电平”方案。记住一条检验方法先画出clk_p和clk_n两条波形再看两个窗口的重叠部分是叠加得到长高还是长低逻辑门自然就选出来了。4. Testbench仿真与波形验证4.1 Testbench怎么搭代码写出来不代表正确仿真这一步必须认真做。测试平台里我习惯一次性测三个分频数比如5、7、9这样可以顺便验证参数化是否真的“任意”。时钟用initial块配合forever生成周期设成10ns也就是5ns翻转一次。timescale 1ns/1ps module tb; reg clk; reg rst_n; wire div5_out; wire div7_out; wire div9_out; initial begin clk 0; forever #5 clk ~clk; end initial begin rst_n 0; #25 rst_n 1; #2000 $finish; end clk_div_odd #(.DIV_N(5)) u_div5 ( .clk_in (clk), .rst_n (rst_n), .clk_out(div5_out) ); clk_div_odd #(.DIV_N(7)) u_div7 ( .clk_in (clk), .rst_n (rst_n), .clk_out(div7_out) ); clk_div_odd #(.DIV_N(9)) u_div9 ( .clk_in (clk), .rst_n (rst_n), .clk_out(div9_out) ); initial begin $dumpfile(div_odd.vcd); $dumpvars; end endmodule仿真跑完之后在波形编辑器里能看到三个输出周期分别应该是50ns、70ns、90ns高电平宽度分别应该是25ns、35ns、45ns。如果你只关心功能正确性直接在testbench里加监控任务也可以但波形图永远是最直观的排错手段。4.2 怎么准确测量占空比光靠肉眼在波形上看高电平宽度很容易产生错觉尤其是高频波形缩放到一定比例之后。我习惯在testbench里用边沿捕获加时间戳的方式自动统计。思路是用always (posedge clk_out)记录高电平起始时间用always (negedge clk_out)记录高电平结束时间两者相减得到高电平宽度低电平宽度则反过来统计。由于$time返回的是64位整数而timescale是1ns得到的宽度单位是ns。reg [63:0] t_rise, t_fall; reg [63:0] high_width, low_width; always (posedge div5_out) begin t_rise $time; if (t_fall ! 0) low_width t_rise - t_fall; end always (negedge div5_out) begin t_fall $time; if (t_rise ! 0) high_width t_fall - t_rise; end复位释放后前几个周期是初始相位建立过程输出可能还不是稳定的50%统计时需要先等至少2倍输出周期再开始。实际测试中5分频的high_width和low_width都应该精确等于25ns7分频等于35ns9分频等于45ns。4.3 从波形上解读相位关系打开仿真波形重点看三组信号cnt_p、cnt_n、clk_p和clk_n的关系。会看到clk_p的高电平落在计数2到4之间clk_n的高电平则在计数2到4的下降沿版本上二者高电平窗口的公共部分恰好就是输出的高电平。输出高电平和低电平两个窗口在输入时钟的时域里交错排布视觉上就是一个完美对称的方波。再说一个容易误解的地方输出占空比的50%是相对输出周期而言的不是说高电平和低电平都等于整数个输入周期。5分频的输出高电平是25ns换算成输入周期是2.5个这没有问题因为在低电平一侧也同样是2.5个周期不对称性被完美抵消了。5. 实战中那些坑与排查方法5.1 复位时序不要忽略异步复位的恢复时间异步复位本身好理解真正容易翻车的是复位释放时刻。如果复位撤销时刚好接近时钟上升沿或下降沿触发器可能会进入亚稳态导致计数器多计一拍或少计一拍输出占空比在起始阶段明显偏移。虽然FPGA内部寄存器普遍带有复位恢复时间检查但如果你的复位信号来自外部按键、引脚毛刺或者电源监控电路务必先做同步处理。我做过一个模块当时图省事直接把一个低频外部复位引到分频器的异步复位端结果10次上电里有2次前几个周期的占空比是错的。后面在复位路径上加了两级同步触发器问题才消失。分频器本身写得很干净但复位信号脏再干净的RTL也白搭。5.2 计数器位宽与DIV_N的边界要算准$clog2在大多数综合工具里都能用但有一点要注意如果DIV_N是运行时的变量而不是parameter那就没法直接算位宽了必须用够大的固定位宽再配合比较器做回绕。参数化设计只保证编译期注入的DIV_N正确。另外当DIV_N特别大时要警惕比较器“DIV_N - 1”的常量的位宽。比如DIV_N1023CNT_W应该是10计数范围0到1023。如果手滑把CNT_W写成了8计数器会提前回绕而阈值计算和比较全都会错乱仿真波形看起来就像一堆乱码排查起来特别费劲。最稳妥的方法是像上面代码里那样用localparam integer CNT_W $clog2(DIV_N)别手写位宽。5.3 毛刺风险与时钟网络处理分频器的输出本质上来自组合逻辑与门毛刺风险理论上存在。但按照前面分析的相位关系clk_p和clk_n分别在不同边沿附近变化二者不会同时翻转所以与门输出毛刺的风险很小。实际在FPGA里如果clk_out要作为真正的时钟使用我建议通过全局时钟缓冲器或专用时钟网络送到全局布线资源而不是直接拿普通组合逻辑的输出去驱动大量触发器。在ASIC流程里这种自定义时钟往往还要加时钟树约束谨慎起见可以改用门控时钟单元或者干脆用PLL/MMCM生成RTL分频只作为低速无时钟树的辅助用途。如果你只是想给某个内部状态机产生一个慢速节拍信号不打算接时钟树那直接把clk_out当普通信号使也没问题但要注意下游采样逻辑的建立保持时间。5.4 把分频输出当普通信号用和当时钟用是两回事这句话我反复在项目里跟新人讲。如果一个模块只是用分频后的信号做使能或者复位那它就是一个普通逻辑信号时序约束按普通路径处理。如果你想让它作为另一个时钟域的时钟那它就必须进入时钟树并且和主时钟之间要有明确的时序约束。很多人在仿真里看到50%占空比就觉得大功告成上了板子之后发现有的触发器采样不对回过头来排查才发现是时钟网络问题不是分频逻辑问题。记住RTL只管功能正确物理实现上的时钟规划是另一层工作。6. 几个可以继续深挖的扩展方向6.1 从奇数分频走向小数分频理解了“双计数器错相合并”之后你会觉得这个思路其实可以推广。比如5.5分频、8.25分频这类小数分频本质上是多个整数分频周期交替拼接再用相位补偿消除抖动。很多PLL的反馈分频器内部使用的就是类似的双模/多模技术表面上是整数分频实际上是在不同分频比之间跳变平均下来得到目标频率。奇数分频这个案例是理解小数分频的很好前站。6.2 动态可配置分频比参数化只能覆盖编译期固定的分频比如果系统运行时要切换频率比如3分频切到5分频通常的做法是做一个可配置的分频器寄存器里写分频值计数器实时加载。注意切换过程中时钟输出会产生瞬态毛刺严重时会触发下游模块复位所以配上毛刺消除逻辑或者等待输出停在特定电平再切换都是常见的工程手段。6.3 回到项目这套方案的实际取舍我个人的习惯是在FPGA里如果目标频率不是特别离谱而芯片自带PLL又不好配置RTL分频器确实是个轻量方案。PLL的优点在于输出抖动小、占空比稳定、支持任意分频比但缺点是面积和功耗都更大有些环境里PLL的模拟电路还难接近数字逻辑。奇数分频RTL实现则是零模拟开销资源只花几个寄存器和一两级组合逻辑。最后分享一个调试小技巧如果你觉得用$time统计占空比不够直观可以在波形窗口里用两个光标分别放在相邻两个上升沿和下降沿上查看时间差高电平和低电平的时间差小于1ns就可以认为是稳的。这个方法我在好几个项目里都用过比盯着引脚波形猜来猜去快多了。奇数分频虽然是小知识点但把它吃透了后面看时钟处理相关的设计会通透很多。