ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

FPGA无毛刺时钟切换原理与实现:从BUFGCTRL原语到状态机方案

FPGA无毛刺时钟切换原理与实现:从BUFGCTRL原语到状态机方案

做FPGA的人,早晚都会遇到时钟切换。这个看似用二选一MUX就能解决的问题,一旦上了真板子,轻则波形难看,重则把整条数据链路搞崩。我入行第一年就因为一次“随手写”的时钟切换被折腾到怀疑人生,示波器上那一下尖刺让整个采集系统误触发,从此对“无毛刺时钟切换”这几个字有了很深的敬畏。

简单来说,无毛刺时钟切换要解决的核心问题就是:在电路运行过程中切换时钟源时,输出时钟不能出现一个比正常时钟周期更短的脉冲、不能出现占空比异常、更不能让下游寄存器采到不确定的边沿。它最常出现在图像处理多分辨率切换、高速接口动态速率调整、多时钟测量系统等场景,几乎所有与“运行时换频”有关的FPGA设计都会踩到这个坑。

1. 时钟切换为什么这么容易翻车

1.1 毛刺到底是怎么冒出来的

很多人第一次做时钟切换,第一反应就是写一个二选一MUX:

assign clk_out = clk_sel ? clk_b : clk_a;

仿真波形看着干干净净,上板就露馅。原因在于,clk_sel这个选择信号对于clk_a和clk_b来说,到达两个门控逻辑的时刻不可能完全一致。clk_a通路上的AND门先关或者后关,clk_b通路上的AND门先开或者后开,中间的OR门就可能输出一个只有几百皮秒宽、电平还不确定的尖峰。

打个比方,你家里有两个水箱,一个热水一个冷水,手动阀门从热水端拧到冷水端。如果热水阀还没关死、冷水阀已经打开,两股水会在管子里撞出乱流;如果阀门中间停太久,又会断流。数字电路里的“乱流”,就是毛刺。组合逻辑MUX本质上就是让两个时钟在OR门处发生过路车抢道,谁快谁慢完全看布线延迟,不产生毛刺反而是运气好。

更麻烦的是,毛刺看不见摸不着,仿真里通常也复现不出来,因为它依赖布局布线后的具体延迟。等它真的在板子上出现时,下游触发器可能被这个多余边沿打了一拍,状态机可能无故跳转,计数器可能多计了一个数,整个系统就乱了。

1.2 “无毛刺”到底意味着什么

要做无毛刺切换,得先把“无毛刺”拆成三个层次。

第一层,输出波形中不能出现比正常时钟高电平或低电平更窄的脉冲。这是最基础的要求,一个组合MUX通常连这层都过不了。

第二层,切换后的输出高电平宽度不能异常展宽或缩短。很多自研切换逻辑能消灭窄脉冲,但两个时钟相位不同步时,可能出现旧时钟的最后一拍高电平和新时钟的第一拍高电平“首尾相连”,OR门输出一个接近两倍宽度的脉冲。这个脉冲虽然不短,但会让下游电路分不清沿的位置,对相位敏感的设计同样是灾害。

第三层,整个切换过程的逻辑状态必须确定,不能有亚稳态传播。选择信号是跨时钟域的,如果直接打一拍就送进切换逻辑,非法时间内采样的结果可能是个不定值,导致“该关的门没关、该开的门没开”。

实际工程中,真正满足全部三个层次的方案,只有专用原语或者经过严格时序设计的有限状态机。

1.3 为什么光加同步器救不了场

跨时钟域设计的基本常识是加两级同步器,这在数据通路里很有效。但时钟切换这个问题,难就难在它不是一个单纯的数据跨域问题,而是两个时钟门控信号之间的“先后顺序”问题。

假设你把切换信号分别同步到clk_a域和clk_b域,每个域各自得到一个稳定的选择电平。问题是,这两个同步器输出之间的相对时序完全随机:可能clk_b域先看到切换请求,先打开了新时钟通道,而clk_a域还没关闭旧时钟通道。这时候OR门输出就是两个时钟的混叠波形,而不是干净的单一时钟。

同步器解决的是“这个信号是否稳定”,解决不了“两个信号谁先谁后”。要想让输出不出现毛刺,必须保证切换动作只发生在时钟低电平期间,而且严格做到“先关旧时钟、再开新时钟”。这就要靠专门的切换控制逻辑,而不是简单加两块寄存器。

2. 无毛刺切换的几种主流实现方案

2.1 厂商原语方案:BUFGCTRL和BUFGMUX

Xilinx在7系列以及之后的平台里,最推荐的做法是直接例化BUFGCTRL原语。这个原语内部专门做了无毛刺切换的时序控制,所有逻辑资源、布局布线都已经由芯片厂商替你处理好了。

BUFGCTRL的关键端口是I0、I1两个时钟输入,S0、S1两个选择输入,CE0、CE1两个时钟使能,以及IGNORE和输出O。它内部有一组上升沿检测器,当检测到S0上升沿并且CE0有效,它不会立刻切到I0,而是等当前输出的时钟进入低电平之后,再在I0的下一个上升沿把输出真正切过去。这个“等低电平再开新沿”的动作,就是整个无毛刺切换的核心。

实际用起来很简单。假设clk_a是当前时钟,要切到clk_b,可以这样例化:

BUFGCTRL #( .INIT_OUT(0), .PRESELECT_I0("TRUE"), .PRESELECT_I1("FALSE") ) u_bufgctrl ( .I0 (clk_a), .I1 (clk_b), .S0 (1'b0), .S1 (switch_req), .CE0 (1'b1), .CE1 (1'b1), .IGNORE (1'b0), .O (clk_out) );

切换时,把switch_req拉高即可。注意这里S0和S1不能同时拉高,必须保证只有一个选择信号处于有效状态。PRESELECT_I0设为TRUE表示上电后默认输出I0,这样整个系统启动阶段就有一个确定的时钟,不会在复位释放瞬间出现自由振荡。

还有一点很多人容易忽略:BUFGCTRL的S输入是有最小脉冲宽度要求的。你给S1一个单周期脉冲,它可能根本来不及完成内部的“等待低电平、再开新沿”这套动作,导致切换失败或者干脆不切。所以实际项目中,我通常会把切换请求展宽到至少几十个目标时钟周期,确保原语有充足时间响应。

2.2 自研逻辑方案:下降沿控制状态机

如果不用厂商原语,比如你的目标平台是国产FPGA、或者想在跨平台代码库里保持统一风格,那就得自己写切换逻辑。常见的思路是“下降沿采样、先关后开”。

核心代码可以写成这样:

module glitch_free_mux ( input wire clk_a, input wire clk_b, input wire clk_sel, // 1: 选择clk_b, 0: 选择clk_a input wire rst_n, output wire clk_out ); reg sel_a, sel_b; always @(negedge clk_a or negedge rst_n) begin if (!rst_n) sel_a <= 1'b1; else if (clk_sel) sel_a <= 1'b0; else sel_a <= 1'b1; end always @(negedge clk_b or negedge rst_n) begin if (!rst_n) sel_b <= 1'b0; else if (clk_sel) sel_b <= 1'b1; else sel_b <= 1'b0; end assign clk_out = (clk_a & sel_a) | (clk_b & sel_b); endmodule

这段代码的思路是在clk_a的下降沿关闭旧通道,在clk_b的下降沿打开新通道,门控信号只在时钟低电平期间变化,输出就不会被切出半个脉冲。

但必须说清楚,这段代码只能算“教学级”实现,不是万无一失。如果clk_a和clk_b之间完全没有相位关系,有可能出现clk_b的下降沿先到、sel_b先拉高,而sel_a还没拉低,输出OR门就把两个时钟的高电平混在一起,产生一个展宽脉冲。要让它更可靠,还需要把clk_sel先同步到两个时钟域,并且用状态机保证sel_a和sel_b严格互斥。即便如此,自研逻辑的无毛刺等级也仍然低于专用原语。

2.3 同源时钟的动态变频方案

还有一种场景不是异步时钟切换,而是同一个PLL/MMCM动态改配置。比如图像系统需要从1080P切到4K,最直接的办法是动态重配MMCM的分频系数,让VCO输出新的像素时钟。

动态重配本身不产生MUX竞争毛刺,但它有一个更隐蔽的问题:重配期间PLL的输出会短暂不稳定,甚至直接丢失若干个时钟周期。这个窗口对下游电路来说跟毛刺一样致命。所以动态变频不能单独用,必须配合业务逻辑的复位管理和工作窗口规划。我通常的做法是:在帧消隐期、串口空闲期、或者DMA停止的窗口里发起重配,PLL锁定完成后先复位消费时钟域的逻辑,再解除复位继续工作。

严格来说,动态重配解决的是“同源多频切换”,和BUFGCTRL解决的“异步双时钟无毛刺切换”不是一个问题。很多设计把两者混在一起,结果两边都没处理好。

2.4 方案对比速览

方案无毛刺等级支持异步时钟资源与厂商依赖推荐场景
组合逻辑MUX不满足否零资源不推荐
BUFGCTRL原语完整满足三层支持1个BUFGCTRL,Xilinx专用异步时钟切换首选
下降沿状态机基本满足第一层,有宽脉冲风险需配合同步器FF和门电路,通用教学验证、兜底
MMCM/PLL动态重配重配期间输出不稳定不支持需重配控制器同源多频切换

3. 实操中的关键细节

3.1 时序约束必须第一时间跟上

很多人写完切换逻辑就忘了约束,结果综合器不知道两个时钟是异步的,会很“好心”地把选择信号和门控逻辑按同步路径去做时序收敛。这会导致两个结果:要么工具疯狂报时序违例,要么工具为了满足一条错误约束,把切换控制逻辑的布局布线改得乱七八糟。

正确做法是先把两个时钟域声明为异步关系。Vivado里的典型写法是:

set_clock_groups -asynchronous -group {clk_a_clk} -group {clk_b_clk}

同时,所有跨域同步寄存器和BUFGCTRL的S端口路径需要加异步约束。对于FF同步链,建议在RTL里给同步寄存器加上ASYNC_REG属性,告诉工具这些寄存器是专门做跨时钟域同步的,不需要在它们之间做复杂的时序收敛。S端口到原语的路径,我会额外加一条set_max_delay约束,限制选择信号线路上的布线延迟,防止因线路绕远把S信号拖到时钟沿附近变化。

3.2 切换请求不能直接发单周期脉冲

跨时钟域的通用原则是“电平传递优于脉冲传递”。如果切换请求在源时钟域是一个单周期脉冲,而目标时钟比源时钟慢很多,这个小脉冲大概率会被目标时钟漏采。因此,切换请求应该设计成电平信号:请求拉高,表示“我要切换到B时钟”;完成切换后再拉低,表示“切换请求结束”。

同时,BUFGCTRL的S输入有最小脉宽要求,这也是为什么我前面强调请求要展宽。实际操作里,我不会用软件直接写寄存器控制切换,而是在硬件里做一个“请求展宽器”,把CPU发来的短脉冲扩展成至少16个周期的高电平,再送给原语。这样无论原语内部时序怎么走,都有足够余量。

3.3 RTL编码规范:一切选择信号必须寄存器化

选择信号不能来自组合逻辑。如果sel信号本身是组合逻辑输出,它在原语S端口上的变化时刻无法预料,原语内部就算等半天也很难等到一个干净的上升沿。所有控制切换的信号,都必须在源时钟域先打一拍,再进入同步链。

另外一个容易被忽略的点是复位释放。如果切换控制逻辑使用异步复位,复位释放时刻恰好落在时钟高电平中间,控制信号可能在一个不确定的点被释放,导致门控逻辑输出半个脉冲。我的习惯是给切换控制逻辑单独做一套异步复位、同步释放的复位模块,保证复位释放沿与时钟上升沿对齐。

3.4 仿真不能只跑一组相位

写testbench时,很多人设置好两个固定频率的时钟就开始测,测了一次没问题就收工。这远远不够,因为异步时钟切换的时候,两路时钟边沿的相对位置是随机的。我做仿真时会让两个时钟的相位做随机偏移,或者用变量控制相位差,然后在随机时刻拉高切换请求,连跑几千次,统计输出波形里有没有短于正常周期的脉冲。

仿真通过也还是不能掉以轻心。我碰到过仿真完全干净、上板后偶发异常的情况,最后定位到是切换请求信号在PCB上有串扰,导致S端口收到一个毛刺,把原语内部检测器触发了。所以严谨的做法还要在切换完成后,用ILA实时观察内部时钟的计数结果,确认下游逻辑没有发生额外翻转。

4. 典型应用场景里的“换挡”门道

4.1 图像处理:多分辨率像素时钟切换

1080P到4K的切换,不仅仅是改个分辨率参数。像素时钟从148.5MHz跳到594MHz,整个图像处理链路的时序、延迟、行场同步计数器全都要跟着变。如果只做无毛刺时钟切换,不处理下游逻辑,即便时钟本身没有毛刺,图像也大概率出现花屏或者水平错位。

我的做法是分两步走。第一步,在帧消隐期间发起时钟切换,利用BUFGCTRL保证切换沿干净;第二步,切换完成后立即复位整条图像处理链路,等MMCM锁定稳定、行场计数器重新同步之后再开始输出。不要试图“无缝切换并继续保持原来的计数器状态”,那是在跟自己过不去。

4.2 频率测量与串口:计数器和状态机最怕多余边沿

做频率测量或者TDC测时的时候,计数器对时钟边沿极其敏感。一个额外沿,可能就是几十个皮秒的误差。如果测量系统在运行中切换参考时钟,比如为了在不同频段获得不同测频分辨率而切换闸门时钟,毛刺会直接污染计数结果。

串口也一样。如果系统支持动态切换波特率,最好的方案不是砖头一样改波特率分频器,而是预先用PLL生成两路目标波特率时钟,再用无毛切步骤在串口空闲窗口切换。这样数据线上的每一位时序都是规整的,不会出现起止位宽度异常的问题。

4.3 高速接口:LVDS和MIPI的时钟域切换

LVDS接收、MIPI D-PHY这类高速接口,恢复出来的字节时钟往往与源端像素时钟是异步关系。当你要切换通道速率、或者在不同视频源之间切换时,本质上就是在恢复时钟和系统时钟之间做切换。这时候无毛刺切换只是前提,真正要命的是切换后SerDes的初始化状态可能已经乱了。

我的经验是,高速接口的时钟切换必须由链路层协议配合,先在协议帧的边界发起切换请求,切换完成后再重新初始化接收端的deskew、以及FIFO指针。否则时钟干净了,数据还是一团浆糊。

5. 常见问题速查与避坑经验

表现根本原因解决方案
切换瞬间输出出现窄尖峰组合MUX竞争冒险改用BUFGCTRL原语
切换请求偶发无效S信号脉宽不足展宽请求,保持几十个周期
切换后下游状态机乱跳选择信号未同步加两级同步器和ASYNC_REG
输出出现展宽脉冲两路门控信号重叠原语方案或状态机严格互斥
工具报一堆时序违例缺CDC约束set_clock_groups异步分组
仿真正常上板异常S端口毛刺/布线偏斜加mux_delay约束、ILA实测

最后说一个我自己的团队规范:所有可切换时钟永远收口到一个统一模块里管理,切换请求由CPU通过寄存器下发,硬件把请求展宽后同时送给BUFGCTRL和状态机兜底。每次切换完成后,统计模块会记录输出时钟边沿数,如果发现切换前后边沿数偏差超过阈值,直接拉高中断报警。这套机制下来,这几年基本没有再被时钟切换的毛刺坑过。

所以我的建议很简单:能用原语就别自己造轮子,原语背后是芯片厂商烧了很多钱验证过的行为,比自己用Verilog拼出来的状态机靠谱得多。如果你实在要用自研逻辑,也一定记得把“先关旧钟、等低电平、再开新钟”这句口诀刻在代码注释的第一行。

RELATED READING

延伸阅读

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