基于Verilog的时钟监视模块设计:检测停振与频率异常
发布时间:2026/10/20 6:42:22 锦皓数字建站

做硬件设计的人应该都有过这种经历系统上电之后一切正常主控和外设握手顺畅可运行到一半某个外设突然“失联”了。排查半天总线和电源都没问题最后把示波器探到时钟引脚上才发现晶振或者PLL输出的时钟早就停了或者频率偏得离谱。更麻烦的是掉时钟这种事往往不会立刻触发复位主控根本收不到任何通知只能在超时之后干瞪眼。所以后来我就习惯在系统里面放一个clk monitor模块——用Verilog写专门盯时钟是否健康。它的职责很简单检测时钟是否存在、频率是否落在允许范围内一旦异常立刻拉高报警信号让主控有时间切备用时钟或者做安全复位。这个模块不算复杂逻辑不到一百行但里面的边沿处理、窗口计数、阈值计算这些细节如果没想清楚做出来的东西不是误报就是漏报。这篇文章我就把整个实现思路、RTL代码、仿真验证和调参踩坑的记录完整放出来适合正在写时钟监视模块的FPGA/IC工程师也适合想给自己系统加一层稳妥保护的硬件开发者。1. clk monitor 到底在解决什么问题先搞清楚需求再动手1.1 时钟出问题时系统里会发生什么时钟是数字系统的“心跳”它掉了系统不会瞬间冒烟但会以各种诡异的方式开始表现寄存器状态机卡在某个状态不动了、FIFO读写指针原地踏步、串行协议因为采样点错位开始疯狂报CRC错误、ADC采出来的数据突然全是同一个值。最难受的是这些问题往往没有立即可感知的物理特征因为电源灯还亮着芯片温度正常看起来一切都没死但系统就是“活”不过来了。时钟异常还不只是“停”一种。PLL失锁之后频率可能往上跳也可能往下飘外部晶振虚焊、谐振电容老化输出频率会慢慢偏离标称值。频率偏高时处理器可能超频跑但不稳定频率偏低时通信波特率、PWM周期、音频采样率全都会跟着错。总之凡是依赖时钟周期的功能都会受到牵连。clk monitor的价值就在这里它不关心总线状态不看复位不读寄存器只做一件事——持续观察某一路时钟是不是“正常”。一旦异常立刻输出报警信号。这个报警可以作为中断源通知CPU也可以硬连线直接触发时钟切换逻辑或者系统复位。在很多对可靠性要求比较高的设备里这种模块属于典型的安全监测单元和看门狗平级。1.2 什么情况下才值得自己写一个商用IP库里通常也有现成的时钟监视单元功能甚至更全但很多时候不太适合直接用。一是授权成本问题一套IP license可能比整个FPGA芯片还贵二是配置太重我要的只是“有无/偏高/偏低”三态判定商用IP给出的却是频率精测、自动校正、中断矩阵这一大堆东西集成成本反而上去了三是参数不便改不同板子上被监测时钟的频率不一样商用IP的配置寄存器方案每次都要重新弄一遍驱动。用Verilog自己写一个就不一样了。逻辑简单、占资源少、阈值参数可调例化几十份也毫无压力。我在之前的模拟项目中就用它同时监视了三路时钟分别是给ADC的、给串口的、给PLL反馈的三路频率不同阈值不同但共享同一个参考时钟。总共占不到一千个逻辑单元效果非常稳定。自己写的另一个理由是可以完全理解它的行为出问题时不至于对着黑盒IP抓瞎。下面就先解决一个核心问题怎么判断时钟“不健康”。2. 检测思路与方案选型参考时钟对比最省心2.1 三种常见思路对比做时钟监视有几种主流思路各有利弊我一开始也是纠结过的。这里直接对比一下检测思路实现方式能检测停振能检测频率偏高/偏低需要额外硬件复杂度超时检测法用待测时钟驱动计数器设置超时值能不能自振荡器或长延迟链最低独立基准振荡器法用RC振荡器/PLL生成基准对比频率能能基准振荡器/PLL中等参考时钟边沿计数法用较高频率参考时钟采样待测时钟统计窗口内边沿数能能无需额外复用已有参考时钟中等偏低超时检测法最朴素让待测时钟自己去驱动一个计数器如果计数器到达某个上限还不见清零那就说明时钟一个脉冲都没来直接报停振。这个方法对“停”很敏锐但对“频率偏移”完全无能为力因为时钟只要在跑哪怕频率只有正常值的一半计数器照样能超时。只适合做最简单的心跳检测。独立基准振荡器法比较直接拿一个已知稳定的振荡器当尺子去量待测时钟频率高低一看便知。但它需要额外硬件如果系统里没有现成基准源得自己折腾板级成本会上去。2.2 为什么选参考时钟边沿计数我最终选择的是“参考时钟边沿计数”路线核心思路是用一个频率足够高的参考时钟去采样待测时钟把待测时钟的上升沿转换成参考时钟域的单周期脉冲然后在固定长度的窗口里统计脉冲个数。这个计数值和待测时钟频率成正比落在阈值区间就算正常否则报警。这个路子有三个很实在的优点。第一不需要任何额外硬件系统里一般都有现成的稳定时钟源直接拿来当参考。第二整个模块除了一个两级同步器之外全部逻辑都跑在参考时钟域综合和约束都特别好处理不存在乱七八糟的跨时钟域控制信号。第三精度可调窗口开得越长对频率的分辨能力就越精细可以灵活适配不同场景。它也有个明显的适用边界参考时钟频率必须明显高于待测时钟。原因很简单参考时钟相当于在给待测时钟的上升沿“拍照”如果待测时钟太快两张照片之间可能漏掉一个上升沿计数就会偏小导致误报警。按我的经验参考频率最好是待测频率的5倍以上最少不能低于2倍。如果你要监视的时钟比参考时钟还快那这套方案就不适合了老老实实找带高频支持的专用时钟监视IP吧。2.3 参数到底该怎么定这个模块的核心参数有三个窗口长度、下限阈值、上限阈值。很多人在这里想当然随手填一个阈值就上板结果就是疯狂误报。先给公式。窗口的有效时间长度是T_win (WIN_LEN - 1) / f_ref在窗口内待测时钟上升沿的理论计数值是N_expected T_win × f_mon阈值一般按允许的容差来定比如允许频率偏差在±15%以内那就是LOW_LIMIT N_expected × (1 - 15%) HIGH_LIMIT N_expected × (1 15%)举一个具体数字。假设参考时钟 100MHz待测时钟 10MHz窗口计数值上限 WIN_LEN 取 65536。那么一个窗口的有效长度是 65535 个参考时钟周期也就是 655.35 微秒。在这段时间里10MHz 时钟理论上会出现大约 6553.5 个上升沿。因为窗口起始点和时钟相位不固定实际计数值可能是 6553 或 6554这都属于正常。如果允许±15%偏差下限取 5570上限取 7536对应频率大约 8.5MHz 到 11.5MHz。窗口长度选多少本质上是分辨率和响应时间的折中。窗口越长每个计数代表频率变化量越小监测越精细但报警反应越慢窗口越短响应快可是频率分辨率差。用上面的参数一个计数对应大约 1.5kHz 的频率变化。如果你的系统只需要判断大概范围窗口可以缩短到 4096 或者 1024报警延迟会小很多。3. 模块实现边沿检测加窗口计数器逻辑不到一百行3.1 接口设计这个模块的接口我设计得比较精简方便集成。先定义信号信号方向位宽功能说明clk_refinput1参考时钟要高于待测时钟rst_ninput1异步复位低有效clk_moninput1待监测时钟eninput1监视使能高电平开始工作clr_alarminput1清除报警锁存信号alarmoutput1报警输出高有效last_cntoutputWIN_WIDTH上一个窗口的实际计数值供调试用alarm之所以设计成锁存输出是因为它可能连接到一个响应速度没那么快的主控。如果报警信号只在检测到故障的瞬间拉高下一拍就被自动清掉主控万一没来得及采样故障就被漏掉了。所以一旦报警alarm保持为高直到外部控制器明确送一个 clr_alarm 脉冲才会清除。这在实际系统里非常重要。3.2 运作时序整个模块的工作流程可以这样理解从复位释放开始只要 en 拉高模块就进入一个周而复始的测量循环。参考时钟域里有一个窗口计数器从 0 到 WIN_LEN-1 循环计数。在这个循环中除了最后一个周期之外其余时间都作为“有效窗口”。有效窗口期间模块统计的是待测时钟上升沿产生的脉冲个数等窗口计数器走到最后一个周期停止计数把本次计数值和阈值比较更新报警输出紧接着下一个窗口自动开始继续新一轮测量。换句话说这个模块并不是只在开机时检测一次而是持续检测每隔一个窗口时间就刷新一次判断结果。就算当前窗口报了警只要外部不清除它保持报警如果想要故障恢复后自动解除报警也很容易改把锁存逻辑去掉就行。我在代码里保留了两种风格的切换点后面会有说明。3.3 Verilog实现核心代码下面是完整的可综合RTL代码我尽量加了注释。参数默认值是参考 100MHz待测 10MHz窗口 65536 个参考周期阈值允许±15%。module clk_mon #( parameter WIN_WIDTH 16, // 窗口计数器位宽 parameter WIN_LEN 16d65536, // 窗口长度按参考时钟周期计 parameter LOW_LIMIT 16d5570, // 下限阈值 parameter HIGH_LIMIT 16d7536 // 上限阈值 )( input wire clk_ref, input wire rst_n, input wire clk_mon, input wire en, input wire clr_alarm, output reg alarm, output wire [WIN_WIDTH-1:0] last_cnt ); // ---------------------------------------------------------- // 1. 将 clk_mon 同步到参考时钟域并检测上升沿 // ---------------------------------------------------------- reg [1:0] mon_sync; always (posedge clk_ref or negedge rst_n) begin if (!rst_n) mon_sync 2b00; else begin mon_sync[0] clk_mon; mon_sync[1] mon_sync[0]; end end // 上升沿脉冲当输入上升沿传播到第一级同步器且尚未传播到第二级时产生 wire mon_rise mon_sync[0] ~mon_sync[1]; // ---------------------------------------------------------- // 2. 窗口计数器 // ---------------------------------------------------------- reg [WIN_WIDTH-1:0] win_cnt; wire win_done (win_cnt WIN_LEN - 1b1); wire win_active en ~win_done; always (posedge clk_ref or negedge rst_n) begin if (!rst_n) win_cnt d0; else if (en) begin if (win_done) win_cnt d0; else win_cnt win_cnt 1b1; end else win_cnt d0; end // ---------------------------------------------------------- // 3. 窗口内待测时钟上升沿计数 // ---------------------------------------------------------- reg [WIN_WIDTH-1:0] mon_cnt; always (posedge clk_ref or negedge rst_n) begin if (!rst_n) mon_cnt d0; else if (win_done) mon_cnt d0; // 窗口末尾清零为下一窗口准备 else if (win_active mon_rise) mon_cnt mon_cnt 1b1; end // ---------------------------------------------------------- // 4. 判决与报警锁存 // ---------------------------------------------------------- reg [WIN_WIDTH-1:0] last_cnt_r; always (posedge clk_ref or negedge rst_n) begin if (!rst_n) begin alarm 1b0; last_cnt_r d0; end else if (clr_alarm) begin alarm 1b0; // 外部手动清报警 end else if (win_done en) begin last_cnt_r mon_cnt; // 记录本次窗口的计数值 if (mon_cnt LOW_LIMIT || mon_cnt HIGH_LIMIT) alarm 1b1; // 超范围报警并锁存 end end assign last_cnt last_cnt_r; endmodule这个代码的功能可以概括成一句话参考时钟域里开一扇固定宽度的“计数窗”窗内数一下待测时钟上升沿来了多少次窗关上的瞬间判断这个数是否正常。3.4 代码里的几个隐藏细节第一点注意 clk_mon 是被当成普通数据信号采样的不是接到时钟网络上。这一点很多人会误解。我们把待测时钟作为输入引脚进来经过两级触发器同步再在参考时钟域里做边沿检测。这样 clk_mon 完全不需要进入全局时钟树不会占用宝贵的时钟资源也不需要额外的硬核时钟缓冲。代价就是它必须慢于参考时钟这个前提我们前面已经分析了。第二点两级同步器只能消除亚稳态传递到后续逻辑的风险并不能保证每个上升沿都恰好被采到。待测时钟的上升沿落在参考时钟采样点附近时可能会在同步器里多“犹豫”一拍导致边沿脉冲晚一个周期出现。单看一个上升沿这算正常但窗口边界处就可能出现多一个或者少一个计数值的误差。解决办法就是阈值留余量别把容差卡得太死。第三点这个模块用的复位是异步复位但所有同步器输出本身也是参考时钟域内的寄存器所以复位不存在跨时钟问题。只要参考时钟本身稳定复位释放之后第一个窗口内就一定能开始测量。如果参考时钟也长时间没有那系统已经整体失效不在这个模块的管辖范围内。4. 仿真验证怎么证明这个模块真的会报警4.1 testbench搭建思路写仿真之前先想清楚要验证什么。我的测试场景很明确正常频率不报警、频率偏低报警、频率偏高报警、停振报警、报警锁存和清除逻辑正确。每种场景至少跑完两个窗口以上确保看到完整的判决结果。如果直接用默认的大窗口参数仿真一次要等 655 微秒虽然看起来不长但在仿真器里跑几万个参考时钟周期还是有点浪费时间。我习惯在仿真例化时把窗口参数压小比如 WIN_LEN 取 1024阈值也按比例算好验证逻辑行为后再用真实参数做一次回归。这种“参数量级缩放”的方法在仿真调试里特别实用。下面给一个可用的testbench框架重点不是完整仿真库而是把思路演示清楚module tb_clk_mon; parameter REF_PERIOD 10; // 参考时钟 100MHz reg clk_ref 0; always #(REF_PERIOD/2) clk_ref ~clk_ref; reg rst_n 0; reg clk_mon 0; reg en 0; reg clr_alarm 0; wire alarm; wire [15:0] last_cnt; // 例化被测模块窗口和阈值都缩小 clk_mon #( .WIN_WIDTH (16), .WIN_LEN (16d1024), .LOW_LIMIT (16d90), .HIGH_LIMIT (16d115) ) u_clk_mon ( .clk_ref (clk_ref), .rst_n (rst_n), .clk_mon (clk_mon), .en (en), .clr_alarm (clr_alarm), .alarm (alarm), .last_cnt (last_cnt) ); // 生成指定频率的待测时钟 task set_mon_freq(input real freq_mhz); begin clk_mon 0; // 根据频率算周期半周期翻转不支持动态实时改频率 // 但在仿真中通过停止再重新启动的方式模拟不同场景 end endtask integer i; initial begin // 复位和启动 rst_n 0; #100; rst_n 1; #50; en 1; // 场景1正常频率 10MHz clk_mon 0; forever #50 clk_mon ~clk_mon; // 10MHz // 实际运行两个窗口检查 alarm 维持 0 // ... // 场景2切换到偏低频率 5MHz // 重新生成 5MHz 时钟再跑两个窗口检查 alarm 变为 1 // ... // 场景3清除报警 // 发送 clr_alarm 脉冲检查 alarm 变回 0 // ... $finish; end // 自动检查 initial begin wait (alarm 1b1); $display(ALARM raised as expected at time %0t, $time); end endmodule注意testbench里不能真的在一个forever语句启动后随便改频率实际要用disable或换用可变量周期生成时钟甚至在场景间停止一个进程再启动另一个。上面只是演示框架不是完整可跑的仿真但思路是准确的。4.2 测试用例与预期结果用真实参数100MHz参考窗口65536来计算几种场景的预期计数场景待测频率理论计数值阈值范围预期alarm正常10MHz约65535570~75360偏低5MHz约32775570~75361偏高20MHz约131075570~75361停振0Hz05570~75361仿真时重点观察两处波形一是 win_done 信号上升沿附近last_cnt 是否更新成预期值二是 alarm 是否在窗口结束后紧接着拉高。如果 last_cnt 和预期值差得比较多就先查同步采样和边沿检测的正确性再查窗口长度算得对不对。5. 实测踩坑与调参心得5.1 误报警的三大来源第一个来源是阈值设置太紧。很多初学者看到10MHz的时钟就取±1%容差结果实测时被时钟jitter、PLL锁定暂态、温度漂移这些因素直接打脸。参考时钟和待测时钟都是真实器件不可能像仿真里那么理想。我的经验是如果只是做健康监测±15%到±20%是比较合理的起点如果对频率精度要求很高说明你需要的不是监视器而是频率计。第二个来源是上电时序。待测时钟如果是PLL生成的PLL锁定之前输出频率是乱的甚至可能没有输出。如果这个模块一复位就开始监测第一个窗口几乎必然报警。这不一定是缺陷但需要系统设计者配合要么等PLL locking信号有效之后再拉高en要么给模块加一个初始等待时间要么把这个报警当成上电过程中的预期事件在控制器里屏蔽掉。我实际做的时候是在上层逻辑里控制en的时机模块保持通用。第三个来源是待测频率和参考频率关系太接近。如果 clk_mon 频率超过了 clk_ref 的一半边沿检测就会开始丢失上升沿。丢一个沿计数值就小一点频率越高丢得越多最终误报警。这个属于选型问题不是调参能解决的。5.2 调试技巧调试这个模块时last_cnt 比 alarm 值钱得多。alarm只告诉你“坏了”last_cnt却告诉你“坏到什么程度”。用FPGA上集成的片上逻辑分析仪抓一下last_cnt和示波器实测的频率对照马上就能看出阈值该往哪个方向调。我还在项目里加了一个很实用的扩展连续故障计数。每次窗口结束如果超范围故障计数器加一如果正常故障计数器清零连续三到五次超范围才正式拉高alarm。这样能滤掉偶发的抖动尖峰对于工业现场环境特别管用。代价只是报警延迟增加几个窗口周期完全可以接受。5.3 综合与约束注意事项这个模块虽然简单但毕竟是跨时钟设计。综合时一定要把那一段异步路径约束正确clk_mon 输入到第一个同步触发器之间是异步路径可以设成 false path第一个同步触发器到第二个同步触发器之间是同步器本身不能设置 false path必须让时序工具去优化。如果两级同步器之间的路径没有约束好合成的电路抗亚稳态能力会缩水。这个细节看起来不起眼但在高可靠系统里就是天壤之别。另外如果你在一个FPGA工程里例化了多份 clk_mon共享同一个 clk_ref那么每一路 clk_mon 的输入到各自同步器的异步约束都要单独设置。很多工具支持按引脚本批量生成别偷懒用全自动约束最好检查一遍有没有遗漏。留给你的一个进阶扩展如果觉得这个模块还不够“稳”可以再做两件小事。一是把“连续N次故障”逻辑加进去这个小改动可以让误报率降一个量级。二是把阈值改成两个一路用于“过低报警”一路用于“过高报警”这样主控不仅知道时钟坏了还能知道是往哪个方向坏的对故障处理非常有帮助。这两处改动的量都不大但实用性提升明显我建议你拿到代码之后第一件事就是加上这两块。我个人的体会是clk monitor这类模块做起来不难真正难的是对边界情况的处理和足够保守的参数设计。它本质上不是一个精密测量仪器而是一张安全网。宁可报警阈值设得宽一点也不要因为频繁误报导致这个模块被系统工程师嫌弃。宽松的阈值加上连续确认换来的是“要么不响、响则真有问题”的可靠行为这才是硬件设计里最需要的安全感。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。