可编程时钟分频器
固定分频器只能产生一个预先写死的频率;可编程(动态)时钟分频器把分频比做成输入,允许电路在运行过程中随时改变输出时钟频率,并且要求切换过程不产生毛刺、不出现畸形脉冲。
本文给出一个基于"半周期翻转法"的 Verilog 实现:输入系统时钟与半周期计数值,输出 50% 占空比方波,支持运行中动态切换分频比。
分频原理
利用计数器进行分频:用被分频时钟驱动计数器计数,数到设定值就改变输出电平,从而得到按比例降低的频率。下面先讲清分频的通用本质,再说明本文采用的半周期翻转法。
分频的本质:用计数器"数"出更慢的时钟
时钟分频要解决的问题只有一件:从一个固定的高频时钟 f_clk 得到一个更低的频率 f_out。实现它的基本手段也只有一个——计数:用一个计数器数输入时钟的周期,每数满 N 个输入周期就产生一个输出周期。这个 N 称为分频比(分频系数):
|
|
于是"分频"就归结为两个问题:数到几(决定频率),以及数到之后做什么(决定占空比)。对"数到之后做什么"的不同选择,区分出了两种最基本的整数分频方法:翻转法和计数取段法。
半周期翻转法:天然 50% 占空比
设系统时钟频率为 f_clk,期望输出频率为 f_out。一个完整的输出周期(高电平部分+低电平部分)需要 f_clk / f_out 个系统时钟;因为输出是 50% 占空比的方波,所以半个周期只需要计数 half = f_clk / (2 * f_out) 个系统时钟。让计数器每过 half 个时钟就把输出翻转一次,就能得到 50% 占空比的 f_out 方波——这就是半周期翻转法。
以 half = 3(即 6 分频,f_out = f_clk / 6)为例,各信号的关系如下,每个格子代表一个系统时钟周期,计数器从 0 数到 2 后归零并翻转输出:
|
|
从波形可以看出两件事:
- 占空比恒为 50%:高、低电平各占
half个完整的系统时钟周期,长度完全相等,与half取多少无关; - 输出时钟周期 = 2 × half 个系统时钟,即
f_out = f_clk / (2 × half),分频比N = 2 × half。
为什么端口直接给"半周期计数值"
模块对外暴露的是半周期计数值 half_cnt,而不是完整分频比 N,两者的关系是 N = 2 × half_cnt。这样设计有三个原因:
- 省掉除法器。若端口给完整分频比 N,模块内部还要算
half = N / 2(或 f_clk / f_out),变量除法会综合出面积和时序开销都不小的除法逻辑。把除法搬到软件端(或配置寄存器写入前)预计算好,模块内部只剩比较和加法,资源占用大幅下降。 - 保证 50% 占空比。翻转法要求高、低电平各占相同的时钟数。如果端口传入的是完整分频比 N,当 N 为奇数时无法把 N 均分成两个整数半周期,占空比必然偏离 50%;而直接给半周期值天然避免了这个问题。
- 语义直观。
half_cnt就是"计到多少翻转一次",与模块内部行为一一对应。
只支持偶数分频比
f_out = f_clk / (2 × half_cnt) 说明本设计能实现的分频比只有 2, 4, 6, … 这些偶数。例如 100MHz 输入时,能得到 50MHz(half = 1)、25MHz(half = 2)、16.67MHz(half = 3)……却无法产生 f_clk / 3 ≈ 33.3MHz 这类奇数分频比。
这是"半周期翻转 + 恒 50% 占空比"的固有取舍:一次翻转只能把输出周期二等分,而奇数个时钟无法均分成两个长度相等的整数半周期。若确实需要奇数分频,通常有三条路,代价各不相同:
| 方法 | 50% 占空比 | 说明 |
|---|---|---|
| 计数取段法:高、低电平各数固定的时钟数 | 否,N 为奇数时两边相差 1 个时钟 | 只用上升沿,结构最简单;N 越大占空比越接近 50% |
| 双沿法:上升沿、下降沿各产生一路再组合(相位相或) | 是 | 需要 negedge 采样,组合输出容易带毛刺,且结果依赖输入时钟自身的占空比 |
| 小数分频 / 相位累加器(DDS) | 是 | 电路最复杂,输出存在周期性抖动 |
本设计相当于取了第一条路的对称特例:既然端口直接给半周期值,“高、低各数 half 个时钟"就退化成"数到 half 就翻转”,结构最简单、占空比最准,代价是分频比只能是偶数。
频率范围
输出频率在两头各有一个约束,两个约束都来自计数器本身:
- 上限:翻转法要求一个半周期至少包含 1 个完整的系统时钟,故
half_cnt >= 1,最高输出频率为 f_clk / 2(100MHz 输入时为 50MHz)。这也是单沿计数能达到的极限——再快就只能直接引用sys_clk,或者改用双沿。 - 下限:计数器位宽为
HALF_CNT_WIDTH = W时,能表达的最大计数值是2^W − 1,所以最低输出频率为 f_clk / (2 × HALF_CNT_MAX),而HALF_CNT_MAX默认就等于2^W − 1。
例如 100MHz 输入、HALF_CNT_MAX 取默认满量程(W = 27)时,最低输出约 0.37Hz;把源码里的 HALF_CNT_MAX 改为 50_000_000,最低输出就是 1Hz。反过来看,如果只需要支持到 1Hz,W = 26 就够用,还能省下几位寄存器。
关于精度
half_cnt 是整数,因此输出频率只能取 f_clk / (2 × half_cnt) 这一系列离散值。当期望频率无法整除时(例如 100MHz 下想要 3MHz,理想半周期 half = 100 / 6 ≈ 16.67),只能在软件端取整为 16,实际得到 3.125MHz,存在约 4% 的偏差。
这个量化误差可以估算:设理想半周期为 H、取整后的整数为 h,则相对误差为
|
|
截断取整时 |H − h| < 1,所以相对误差小于 1 / half_cnt:半周期计数值越大,误差越小。本例 h = 16,误差上界约 6.3%,实测约 4.2%。
结论是:输出频率越接近 f_clk / 2,精度越差。half_cnt = 2 附近只有 50MHz(half = 1)和 25MHz(half = 2)两档,中间的 40MHz 根本取不到;而当 half_cnt 到了几百量级,相邻两档的间隔已经小到可以忽略。若应用对频率精度要求高,建议直接调用ip核。
本文设计目标
- 输入
sys_clk(如 100MHz)与低有效异步复位sys_rst_n; - 通过输入端口
half_cnt直接给定分频参数(半周期计数值),模块内部不做除法; - 输出
sys_div为 50% 占空比方波; - 运行中改变
half_cnt时,输出无毛刺,不出现被截断的短脉冲; - 对分频参数做上下限保护:下限避免为 0 导致的非法输出,上限避免意外调节输出频率过低导致卡死。
参数与常量定义
| 参数 | 默认值 | 说明 |
|---|---|---|
HALF_CNT_WIDTH |
27 | 半周期计数值位宽,决定可支持的最低输出频率 |
模块内部还有三个与端口同宽的局部常量(localparam),只能通过修改源码调整:
| 常量 | 值 | 说明 |
|---|---|---|
HALF_CNT_MAX |
{HALF_CNT_WIDTH{1'b1}}(满量程) |
半周期计数值上限,用于限制最低输出频率 |
HALF_CNT_ZERO |
{HALF_CNT_WIDTH{1'b0}} |
位宽与 half_cnt 一致的常量 0 |
HALF_CNT_ONE |
{{(HALF_CNT_WIDTH-1){1'b0}}, 1'b1} |
位宽与 half_cnt 一致的常量 1 |
HALF_CNT_MAX 默认取位宽满量程,也就是用尽端口能表达的全部范围;把它改小即可抬高最低输出频率(例如希望输出不低于 1Hz,令 HALF_CNT_MAX = f_clk / 2)。它被定义为局部常量而不是参数,好处是位宽始终与 HALF_CNT_WIDTH 自洽,也不会被上层实例化时误覆盖;代价是无法按实例定制,需要按项目直接改源码,或者把它改回 parameter 以支持实例化配置。
HALF_CNT_ZERO / HALF_CNT_ONE 用来替代 0、1 这类裸字面量,这样复位值、比较值和加减运算的操作数都带着明确的位宽,不依赖隐式零扩展。这里刻意没有用 HALF_CNT_WIDTH'd0 这种"以参数作 size"的字面量形式——部分工具(如 Vivado)不接受参数出现在字面量的位宽位置,而位拼接是标准且各工具通用的写法。
端口定义
| 信号 | 方向 | 位宽 | 说明 |
|---|---|---|---|
sys_clk |
input | 1 | 系统时钟 |
sys_rst_n |
input | 1 | 异步复位,低有效 |
half_cnt |
input | HALF_CNT_WIDTH |
半周期计数值:一个输出周期 = 2 × half_cnt 个系统时钟 |
sys_div |
output | 1 | 分频输出,50% 占空比 |
输出频率与端口值的关系为:
|
|
half_cnt 的取值被夹在 [1, HALF_CNT_MAX] 之间,输出频率的上下限由位宽 HALF_CNT_WIDTH 与上限常量共同决定,推导见 §分频原理 的"频率范围"一节。
Verilog 实现
|
|
关键实现点
1. 输入同步:两级同步器 + 一拍确认
half_cnt 若来自其它时钟域,直接使用会把亚稳态引入本模块,所以先经过 half_cnt_meta、half_cnt_sync 两级触发器同步。第一级负责"隔离"(即使采到亚稳态,也有一个周期的恢复时间),第二级输出即可认为稳定。
需要额外注意:两级同步器只能保证每个 bit 不产生亚稳态,不能保证多位向量的各个 bit 同时更新。half_cnt 是 HALF_CNT_WIDTH 位(默认 27 位)的向量,在切换瞬间同步器输出的第一拍可能出现"部分 bit 已是新值、部分还是旧值"的混合值。因此代码在同步之后又保留了 change_r 做一拍确认(change && change_r):要求同步后的目标值连续两拍都不同于当前生效值,才认为输入真正稳定并置位 req,从而避开那拍混合值;置位的同时会把这份已确认的值寄存到 half_cnt_latch(见"边界装载"一节)。
2. 分频参数的上下限保护与默认赋值
组合逻辑采用"先默认、后覆盖"的写法:
|
|
好处是任何情况下 half_cnt_eff 都有确定值(不会因遗漏分支而综合出锁存器),而且后续再增加保护条件时只需追加 if,不必改动原有分支。
下限保护取 1,对应最高输出 f_clk / 2(100MHz 输入即 50MHz)。上限用常量 HALF_CNT_MAX 表达,用来限制最低输出频率。
这里有一个容易混淆的点:上限约束的目的不是防溢出。端口 half_cnt 本身就是 HALF_CNT_WIDTH 位,数值天然不会超过 2^W − 1;计数器 cnt 也够宽,最大只计到 cnt_term = half_cnt − 1 ≤ 2^W − 2。因此把 HALF_CNT_MAX 保持默认满量程({HALF_CNT_WIDTH{1'b1}})时,判断式恒为假,综合器会直接把这段逻辑优化掉——写了等于没写。它默认取满量程的意义在于提供一个可修改的"业务上限"接口:需要抬高最低输出频率时改小这个常量即可,例如改为 50_000_000(100MHz 输入)就保证输出不低于 1Hz。
3. 计数终值 cnt_term:把减法移出计数路径
如果直接写 if(cnt < half_cnt_r - HALF_CNT_ONE),那么每个时钟周期都要经过一条"寄存器 half_cnt_r → 减法器 → 比较器 → cnt 的 D 端"的组合路径,减法器一直挂在计数路径上。
改进做法是用一个寄存器保存计数终值:
|
|
减法只在分频比更新时执行一次,计数路径上只剩比较器,逻辑更短、时序更宽松,意图也更清晰:cnt_term 就是"这个半周期最后计到几"。
4. 边界装载:无毛刺切换的核心
req 是电平型请求标志(脉冲转电平),置位后一直保持到新分频比真正装载,避免切换请求在等待期间丢失。置位的同时,把经过上下限保护的目标值寄存到 half_cnt_latch:
|
|
计数器只在当前半周期计满(cnt == cnt_term,即输出即将翻转的时刻)才用这份寄存值更新 half_cnt_r 与 cnt_term,并清除 req:
|
|
为什么不直接在装载那一拍采样 half_cnt_eff? 它是纯组合信号(同步器 → 钳位比较器),而 req 的"两拍确认"只能保证置位那一刻的值是稳定的。若在等待装载期间输入再次变化,half_cnt_eff 会跟着变,装载沿就可能采到正在跳变、甚至各 bit 不一致的值。用 half_cnt_latch 把已确认的目标值固定下来,装载路径读到的始终是寄存器输出:既消除了这个窗口,也让装载路径少经过一级钳位比较器,时序更宽裕。
另外 change 比较的仍然是 half_cnt_r 与 half_cnt_eff:若等待期间输入又变了,装载完成后二者不相等,change 会再次拉高,req 清除后的下一拍就能重新请求,新的目标值不会被漏掉。
正因为新分频比只在半周期边界生效,输出的高、低电平不会被中途截断,也就不会产生毛刺或宽窄异常的脉冲。如果在计数中途直接改分频比,就会出现"半个周期被拉长或截断",在波形上表现为一个明显偏窄或偏宽的脉冲。
需要留意这个方案的代价:新分频比不是立即生效,而是等当前半周期结束。从输入被确认(req 置位)到真正装载,最坏要等满一个半周期;低频时这段时间可能长达数百毫秒甚至秒级。对切换实时性有要求的场景,需要在这份"无毛刺"与响应速度之间权衡——若要立即生效,就只能在计数中途直接改 cnt_term,并接受输出出现一个宽窄异常的周期。
5. 占空比与复位
每次翻转的时间间隔都是固定的 cnt_term + 1 个时钟,高、低电平各占一半,因此输出始终是 50% 占空比(包括最高频的 f_clk / 2 情形)。
异步低有效复位把计数器 cnt、计数终值 cnt_term、输出 sys_div、change_r 与 req 全部清零,已生效值 half_cnt_r 与暂存值 half_cnt_latch 都复位为下限值 1,逻辑直接回到确定状态(cnt_term 复位为 0 恰好对应 half_cnt_r = 1 时的计数终值)。
仿真验证
TB 采用"半周期宽度白名单"自检:把当前允许的半周期宽度记录在案(切换期间允许新旧两种值,稳定后收紧为单一值),在每个 sys_div 翻转沿测量半周期宽度,出现白名单之外的宽度就报错。这样不必人眼看波形,就能直接证明切换过程没有产生被截断或拉长的脉冲;稳定段白名单只剩一个值,高、低半周期都必须等于它,也就同时验证了 50% 占空比。
|
|
几点说明:
- 检查点在翻转沿上,看的是"相邻两次翻转的间隔",所以白名单里出现第三种宽度,就意味着某个半周期被截断或拉长——这正是切换毛刺的波形特征。
- 复位释放后才置
armed开始测量:复位期间sys_div会从不定态变 0,这个x -> 0在仿真中同样算一次negedge,不屏蔽的话它会被当成第一个翻转沿,使首次测量就误报。 - 每次切换都先用
allow(旧值, 新值)放宽白名单,生效后再用settled()收紧为单一值;switch_to()把"等相位 → 写值 → 等生效 → 收紧"封装成一行调用,hold(n)再观察 n 个已生效的半周期,保证检查不会长期处于放宽状态而形同虚设。 - 切换请求都安排在
@(posedge sys_div)之后0.3 × T_CLK处,刻意落在输出高电平的中段,用来验证"当前半周期不会被截断"。 - 上限钳位(
half_cnt大于HALF_CNT_MAX)无法在本 TB 中触发:HALF_CNT_MAX是模块内localparam且默认为满量程,任何端口值都不会超限。要验证这条分支,需要先把源码里的HALF_CNT_MAX改小,再把白名单期望值改成钳位后的值。
综合与使用注意事项
- 多 bit 跨时钟域传输:两级同步器解决的是亚稳态,不是位偏斜。代码用"一拍确认 + 稳定值寄存"缓解了这个问题,前提是目标值在变化后会保持稳定;若
half_cnt可能被连续高频改写,应改用握手(req/ack)或双口 RAM/FIFO 等标准 CDC 方案。 - 只支持偶数分频比:输出频率只能是
f_clk / (2 × half_cnt),无法实现 f_clk / 3 这类奇数分频。需要奇分频时要在本模块之外另加双沿/相位组合逻辑。 - 位宽与上限的配合:
HALF_CNT_WIDTH按"最低输出频率"反推,满足2^W − 1 ≥ f_clk / (2 × f_out_min);若只想抬高最低频率而不想加位宽,把源码里的局部常量HALF_CNT_MAX调小即可(它是localparam,实例化时无法覆盖,需要按实例配置就把它改回parameter)。注意HALF_CNT_MAX至少要 ≥ 1,否则钳位结果退化为 0,反而会把半周期拉得极长。 - 复位初值的选择:
half_cnt_r复位为最小值 1,意味着复位释放后、真正装载目标值之前,输出会以 f_clk / 2 的高频翻转几拍。若下游对这几拍敏感,可以把复位值改成一个更温和的默认分频比,或在装载完成前把sys_div屏蔽(输出保持 0)。 - 分频时钟的使用:
sys_div是真实时钟,用它驱动大量逻辑会额外占用时钟资源并引入新的时钟域。在 FPGA 内更常见的做法是保留系统时钟,把分频结果当作**时钟使能(Clock Enable)**使用:低频逻辑每 N 个周期使能一次,从而避免门控时钟带来的时序与资源问题。分频器更适合用于驱动外设时钟引脚或低速接口的时钟输入。 - 端口语义:对外暴露的是半周期计数值,软件端(或配置逻辑)负责先算出
half_cnt = f_clk / (2 × f_out)再写入,模块内部因此不含任何除法运算,资源与时序都更友好。
小结
- 采用"半周期翻转法",输出天然为 50% 占空比;
- 分频比由端口
half_cnt直接给定,模块内无除法器,运行中可动态切换; - 输入经两级同步器 + 一拍确认处理,兼顾亚稳态与多 bit 位偏斜;
- 用
cnt_term保存计数终值,把减法移出每周期的计数路径,只剩一次比较; - 组合逻辑采用"先默认赋值、后覆盖"写法,结构清晰且不会综合出锁存器;
- 上下限保护:下限固定为 1(最高 f_clk / 2),上限由局部常量
HALF_CNT_MAX给定(限制最低输出频率); - 确认通过后先把稳定值寄存为
half_cnt_latch,再由"请求锁存 + 半周期末装载"完成切换,保证无毛刺,代价是最坏要等满一个半周期才生效; - 只能实现偶数分频比,且频率精度受整数分频限制,高精度或奇数分频需另寻方案。