Featured image of post 可编程时钟分频器

可编程时钟分频器

Verilog 可编程时钟分频器:分频比由端口直接给定,支持运行中无毛刺切换。

可编程时钟分频器

固定分频器只能产生一个预先写死的频率;可编程(动态)时钟分频器把分频比做成输入,允许电路在运行过程中随时改变输出时钟频率,并且要求切换过程不产生毛刺、不出现畸形脉冲。

本文给出一个基于"半周期翻转法"的 Verilog 实现:输入系统时钟与半周期计数值,输出 50% 占空比方波,支持运行中动态切换分频比。

分频原理

利用计数器进行分频:用被分频时钟驱动计数器计数,数到设定值就改变输出电平,从而得到按比例降低的频率。下面先讲清分频的通用本质,再说明本文采用的半周期翻转法。

分频的本质:用计数器"数"出更慢的时钟

时钟分频要解决的问题只有一件:从一个固定的高频时钟 f_clk 得到一个更低的频率 f_out。实现它的基本手段也只有一个——计数:用一个计数器数输入时钟的周期,每数满 N 个输入周期就产生一个输出周期。这个 N 称为分频比(分频系数):

1
N = f_clk / f_out          f_out = f_clk / 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 后归零并翻转输出:

1
2
3
4
5
6
上升沿触发
cnt      0   1   2   0   1   2   0   1   2   0   1   2
sys_clk |‾|_|‾|_|‾|_|‾|_|‾|_|‾|_|‾|_|‾|_|‾|_|‾|_|‾|_|‾|_
                    ↑           ↑           ↑
sys_div ____________‾‾‾‾‾‾‾‾‾‾‾‾____________‾‾‾‾‾‾‾‾‾‾‾‾
        |← half=3 →|← half=3 →|

从波形可以看出两件事:

  1. 占空比恒为 50%:高、低电平各占 half 个完整的系统时钟周期,长度完全相等,与 half 取多少无关;
  2. 输出时钟周期 = 2 × half 个系统时钟,即 f_out = f_clk / (2 × half),分频比 N = 2 × half。

为什么端口直接给"半周期计数值"

模块对外暴露的是半周期计数值 half_cnt,而不是完整分频比 N,两者的关系是 N = 2 × half_cnt。这样设计有三个原因:

  1. 省掉除法器。若端口给完整分频比 N,模块内部还要算 half = N / 2(或 f_clk / f_out),变量除法会综合出面积和时序开销都不小的除法逻辑。把除法搬到软件端(或配置寄存器写入前)预计算好,模块内部只剩比较和加法,资源占用大幅下降。
  2. 保证 50% 占空比。翻转法要求高、低电平各占相同的时钟数。如果端口传入的是完整分频比 N,当 N 为奇数时无法把 N 均分成两个整数半周期,占空比必然偏离 50%;而直接给半周期值天然避免了这个问题。
  3. 语义直观。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,则相对误差为

1
|f_out' − f_out| / f_out = |H − 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% 占空比

输出频率与端口值的关系为:

1
f_out = f_clk / (2 × half_cnt)

half_cnt 的取值被夹在 [1, HALF_CNT_MAX] 之间,输出频率的上下限由位宽 HALF_CNT_WIDTH 与上限常量共同决定,推导见 §分频原理 的"频率范围"一节。

Verilog 实现

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
module clk_div#(
    parameter HALF_CNT_WIDTH = 27      // 半周期计数值位宽,决定可支持的最低输出频率
)(
    input sys_clk,
    input sys_rst_n,
    input [HALF_CNT_WIDTH-1:0] half_cnt,  // 半周期计数值:输出分频时钟周期 = 2*half_cnt 个系统时钟
    output reg sys_div
    );

// 模块内部常量:位宽与 half_cnt 一致,用位拼接表示,兼顾显式位宽与工具兼容性
localparam [HALF_CNT_WIDTH-1:0] HALF_CNT_MAX  = {HALF_CNT_WIDTH{1'b1}};              // 半周期计数值上限,限制最低输出频率
localparam [HALF_CNT_WIDTH-1:0] HALF_CNT_ZERO = {HALF_CNT_WIDTH{1'b0}};              // 半周期计数值的常量0
localparam [HALF_CNT_WIDTH-1:0] HALF_CNT_ONE  = {{(HALF_CNT_WIDTH-1){1'b0}}, 1'b1};  // 半周期计数值的常量1

reg [HALF_CNT_WIDTH-1:0] cnt;             // 半周期计数器
reg [HALF_CNT_WIDTH-1:0] cnt_term;        // 当前生效的计数终值(半周期计数值-1)
reg [HALF_CNT_WIDTH-1:0] half_cnt_r;      // 当前生效的半周期计数值
reg [HALF_CNT_WIDTH-1:0] half_cnt_meta;   // 输入同步器第一级
reg [HALF_CNT_WIDTH-1:0] half_cnt_sync;   // 输入同步器第二级(同步后的目标值)
reg [HALF_CNT_WIDTH-1:0] half_cnt_eff;    // 经上下限保护后的目标半周期计数值
reg [HALF_CNT_WIDTH-1:0] half_cnt_latch;  // req置位时寄存的目标值,装载边界使用
wire change;                              // 半周期计数值发生改变
reg change_r;                             // change延迟一拍,连续两拍都有变化请求才锁存
reg         req;                          // 请求锁存

// 两级同步器:隔离异步输入,抑制亚稳态向后级传播
always@(posedge sys_clk or negedge sys_rst_n) begin
    if(!sys_rst_n) begin
        half_cnt_meta <= HALF_CNT_ZERO;
        half_cnt_sync <= HALF_CNT_ZERO;
    end
    else begin
        half_cnt_meta <= half_cnt;        // 第一级:先打一拍
        half_cnt_sync <= half_cnt_meta;   // 第二级:输出同步后的稳定值
    end
end

// 上下限保护:下限为1,对应最高输出频率 sys_clk的频率/2;上限为HALF_CNT_MAX,限制最低输出频率
always @(*) begin
    half_cnt_eff = half_cnt_sync;                // 默认赋值:先给默认值,避免产生锁存器
    if(half_cnt_sync == HALF_CNT_ZERO) begin
        half_cnt_eff = HALF_CNT_ONE;             // 下限保护:半周期计数值不能小于1
    end
    else if(half_cnt_sync > HALF_CNT_MAX) begin
        half_cnt_eff = HALF_CNT_MAX;             // 上限保护:超过最大值时钳位
    end
end

assign change = half_cnt_r != half_cnt_eff;    // 目标值有无变化:有变化才请求更新
always@(posedge sys_clk or negedge sys_rst_n) begin
    if(!sys_rst_n) begin
        cnt <= HALF_CNT_ZERO;
        sys_div <= 1'b0;
        half_cnt_r <= HALF_CNT_ONE;
        cnt_term <= HALF_CNT_ZERO;
        half_cnt_latch <= HALF_CNT_ONE;
        change_r <= 1'b0;
        req <= 1'b0;
    end
    else begin
        change_r <= change;

        // 连续两拍都检测到变化请求:锁存req(脉冲转电平),并寄存当拍已钳位的目标值
        if(!req && change && change_r) begin
            half_cnt_latch <= half_cnt_eff;   // 固定目标值:装载边界不再采样组合逻辑
            req <= 1'b1;
        end

        if(cnt < cnt_term) begin // 当前半周期未计满
            cnt <= cnt + HALF_CNT_ONE;
        end
        else begin // 半周期计满:翻转输出,并在半周期边界更新分频比
            cnt <= HALF_CNT_ZERO;
            sys_div <= ~sys_div;
            if(req) begin // 用 req 置位时寄存的目标值装载,不采样组合逻辑
                half_cnt_r <= half_cnt_latch;
                cnt_term <= half_cnt_latch - HALF_CNT_ONE;
                req <= 1'b0;
            end
        end
    end
end

endmodule

关键实现点

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. 分频参数的上下限保护与默认赋值

组合逻辑采用"先默认、后覆盖"的写法:

1
2
3
4
5
6
7
half_cnt_eff = half_cnt_sync;                // 默认赋值
if(half_cnt_sync == HALF_CNT_ZERO) begin
    half_cnt_eff = HALF_CNT_ONE;             // 下限:不能小于1
end
else if(half_cnt_sync > HALF_CNT_MAX) begin
    half_cnt_eff = HALF_CNT_MAX;             // 上限:钳位到最大值
end

好处是任何情况下 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 端"的组合路径,减法器一直挂在计数路径上。

改进做法是用一个寄存器保存计数终值:

1
2
3
cnt_term  <= half_cnt_latch - HALF_CNT_ONE;  // 只在切换(半周期边界)时计算一次
...
if(cnt < cnt_term) begin           // 每周期只做一次比较

减法只在分频比更新时执行一次,计数路径上只剩比较器,逻辑更短、时序更宽松,意图也更清晰:cnt_term 就是"这个半周期最后计到几"。

4. 边界装载:无毛刺切换的核心

req 是电平型请求标志(脉冲转电平),置位后一直保持到新分频比真正装载,避免切换请求在等待期间丢失。置位的同时,把经过上下限保护的目标值寄存到 half_cnt_latch:

1
2
3
4
5
// 确认通过:寄存稳定值 + 锁存请求
if(!req && change && change_r) begin
    half_cnt_latch <= half_cnt_eff;   // 只在这一拍采样组合逻辑
    req <= 1'b1;
end

计数器只在当前半周期计满(cnt == cnt_term,即输出即将翻转的时刻)才用这份寄存值更新 half_cnt_r 与 cnt_term,并清除 req:

1
2
3
4
5
6
// 半周期边界:用寄存值装载
if(req) begin
    half_cnt_r <= half_cnt_latch;
    cnt_term  <= half_cnt_latch - HALF_CNT_ONE;
    req       <= 1'b0;
end

为什么不直接在装载那一拍采样 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% 占空比。

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
`timescale 1ns / 1ps

module tb_clk_div;

    localparam     HALF_CNT_WIDTH = 27;
    localparam real T_CLK = 10.0;     // 100MHz 系统时钟周期(ns)
    localparam real EPS   = 0.01;     // 时间比较容差(ns)

    reg                       sys_clk   = 1'b0;
    reg                       sys_rst_n = 1'b0;
    reg  [HALF_CNT_WIDTH-1:0] half_cnt  = 5;
    wire                      sys_div;

    // TB 侧跟踪的"当前已生效半周期计数值",与 DUT 的 half_cnt_r 同步(复位后为下限 1)
    integer cur_val = 1;

    // 半周期宽度白名单(ns):切换期间给出旧值与新值两种,生效后收紧为单一值
    real exp_half_a = T_CLK;
    real exp_half_b = T_CLK;

    integer errors  = 0;              // 非法半周期计数
    integer checked = 0;              // 已检查的半周期数
    reg     armed   = 1'b0;           // 复位释放之后才开始测量
    reg     primed  = 1'b0;           // 复位后的第一个沿只建立时间基准

    real t_ref;                       // 上一次翻转沿时刻
    real width;                       // 实测半周期宽度

    clk_div #(
        .HALF_CNT_WIDTH(HALF_CNT_WIDTH)
    ) u_dut (
        .sys_clk  (sys_clk),
        .sys_rst_n(sys_rst_n),
        .half_cnt (half_cnt),
        .sys_div  (sys_div)
    );

    // 100MHz 系统时钟
    always #(T_CLK / 2.0) sys_clk = ~sys_clk;

    // 设置白名单:允许的半周期宽度(参数是 half_cnt 值,内部换算为 ns)
    task allow;
        input integer a;
        input integer b;
        begin
            exp_half_a = a * T_CLK;
            exp_half_b = b * T_CLK;
        end
    endtask

    // 已生效值变为 eff_val:更新当前值,并把白名单收紧为单一允许值
    task settled;
        input integer eff_val;
        begin
            cur_val    = eff_val;
            exp_half_a = eff_val * T_CLK;
            exp_half_b = eff_val * T_CLK;
        end
    endtask

    // 发起一次切换:在输出高电平中段写入新值,等待其生效后收紧白名单
    task switch_to;
        input integer new_val;
        begin
            @(posedge sys_div) #(T_CLK * 0.3);        // 刻意避开翻转沿:请求落在高电平中段
            allow(cur_val, new_val);                  // 切换期间旧值与新值都合法
            half_cnt = new_val;
            $display("[%0t ns] 切换 half_cnt %0d -> %0d,期望输出 %f MHz",
                     $realtime, cur_val, new_val, 1.0e3 / (2.0 * new_val * T_CLK));
            #(T_CLK * (8 + cur_val + 8 * new_val));   // 两拍同步 + 最坏一个旧半周期 + 八个新半周期
            settled(new_val);
        end
    endtask

    // 在当前(已收紧的)白名单下继续观察 n 个半周期
    task hold;
        input integer n;
        begin
            #(T_CLK * n * cur_val);
        end
    endtask

    // 每个翻转沿检查一次:实测半周期必须落在白名单内
    always @(posedge sys_div or negedge sys_div) begin
        if (armed) begin
            if (!primed) begin
                primed = 1'b1;                        // 复位后的第一个沿只建立基准
                t_ref  = $realtime;
            end
            else begin
                width   = $realtime - t_ref;
                t_ref   = $realtime;
                checked = checked + 1;
                if (!((width > exp_half_a - EPS && width < exp_half_a + EPS) ||
                      (width > exp_half_b - EPS && width < exp_half_b + EPS))) begin
                    errors = errors + 1;
                    $display("[ERROR] time = %0t ns: 半周期 = %f ns,期望 %f ns 或 %f ns",
                             $realtime, width, exp_half_a, exp_half_b);
                end
            end
        end
    end

    initial begin
        $display("=== clk_div 自检 TB:sys_clk = 100MHz ===");

        // 1) 复位释放后自动装载目标值 5(10MHz)
        half_cnt = 5;
        allow(1, 5);                                  // 复位后已生效值为 1,目标值为 5
        #(T_CLK * 20);
        @(negedge sys_clk) sys_rst_n = 1'b1;          // 与时钟相位对齐地释放复位
        armed = 1'b1;                                 // 释放复位之后才开始测量
        $display("[%0t ns] 释放复位,half_cnt = 5,期望输出 %f MHz", $realtime, 1.0e3 / (2.0 * 5 * T_CLK));
        #(T_CLK * (8 + 1 + 8 * 5));
        settled(5);
        hold(20);

        // 2) 切到 25(2MHz):大跨度、多位同时变化
        switch_to(25);
        hold(20);

        // 3) 写入 0:下限保护应把半周期钳位为 1(50MHz)
        @(posedge sys_div) #(T_CLK * 0.3);
        allow(cur_val, 1);                            // 白名单按钳位后的 1 给出
        half_cnt = 0;
        $display("[%0t ns] 写入 half_cnt = 0,期望被钳位为 1(50MHz)", $realtime);
        #(T_CLK * (8 + cur_val + 8 * 1));
        settled(1);
        hold(200);

        // 4) 切到 2(25MHz):相邻值切换
        switch_to(2);
        hold(100);

        if (errors == 0)
            $display("\n[PASS] 共检查 %0d 个半周期,未出现非法宽度(无截断、无拉长)", checked);
        else
            $display("\n[FAIL] 共发现 %0d 处非法半周期宽度", errors);

        $finish;
    end

endmodule

几点说明:

  • 检查点在翻转沿上,看的是"相邻两次翻转的间隔",所以白名单里出现第三种宽度,就意味着某个半周期被截断或拉长——这正是切换毛刺的波形特征。
  • 复位释放后才置 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 改小,再把白名单期望值改成钳位后的值。

综合与使用注意事项

  1. 多 bit 跨时钟域传输:两级同步器解决的是亚稳态,不是位偏斜。代码用"一拍确认 + 稳定值寄存"缓解了这个问题,前提是目标值在变化后会保持稳定;若 half_cnt 可能被连续高频改写,应改用握手(req/ack)或双口 RAM/FIFO 等标准 CDC 方案。
  2. 只支持偶数分频比:输出频率只能是 f_clk / (2 × half_cnt),无法实现 f_clk / 3 这类奇数分频。需要奇分频时要在本模块之外另加双沿/相位组合逻辑。
  3. 位宽与上限的配合:HALF_CNT_WIDTH 按"最低输出频率"反推,满足 2^W − 1 ≥ f_clk / (2 × f_out_min);若只想抬高最低频率而不想加位宽,把源码里的局部常量 HALF_CNT_MAX 调小即可(它是 localparam,实例化时无法覆盖,需要按实例配置就把它改回 parameter)。注意 HALF_CNT_MAX 至少要 ≥ 1,否则钳位结果退化为 0,反而会把半周期拉得极长。
  4. 复位初值的选择:half_cnt_r 复位为最小值 1,意味着复位释放后、真正装载目标值之前,输出会以 f_clk / 2 的高频翻转几拍。若下游对这几拍敏感,可以把复位值改成一个更温和的默认分频比,或在装载完成前把 sys_div 屏蔽(输出保持 0)。
  5. 分频时钟的使用:sys_div 是真实时钟,用它驱动大量逻辑会额外占用时钟资源并引入新的时钟域。在 FPGA 内更常见的做法是保留系统时钟,把分频结果当作**时钟使能(Clock Enable)**使用:低频逻辑每 N 个周期使能一次,从而避免门控时钟带来的时序与资源问题。分频器更适合用于驱动外设时钟引脚或低速接口的时钟输入。
  6. 端口语义:对外暴露的是半周期计数值,软件端(或配置逻辑)负责先算出 half_cnt = f_clk / (2 × f_out) 再写入,模块内部因此不含任何除法运算,资源与时序都更友好。

小结

  • 采用"半周期翻转法",输出天然为 50% 占空比;
  • 分频比由端口 half_cnt 直接给定,模块内无除法器,运行中可动态切换;
  • 输入经两级同步器 + 一拍确认处理,兼顾亚稳态与多 bit 位偏斜;
  • 用 cnt_term 保存计数终值,把减法移出每周期的计数路径,只剩一次比较;
  • 组合逻辑采用"先默认赋值、后覆盖"写法,结构清晰且不会综合出锁存器;
  • 上下限保护:下限固定为 1(最高 f_clk / 2),上限由局部常量 HALF_CNT_MAX 给定(限制最低输出频率);
  • 确认通过后先把稳定值寄存为 half_cnt_latch,再由"请求锁存 + 半周期末装载"完成切换,保证无毛刺,代价是最坏要等满一个半周期才生效;
  • 只能实现偶数分频比,且频率精度受整数分频限制,高精度或奇数分频需另寻方案。
使用 Hugo 构建
主题 Stack 由 Jimmy 设计