☰
无毛刺时钟切换设计:原理、Verilog实现与验证要点
2026/9/28 5:37:34 网站建设 项目流程

1. 为什么切换时钟会冒毛刺:先把问题定义清楚

无毛刺时钟切换(Glitch-free Clock Switching)在数字IC里是个高频面试题,同时也是实际项目中很常见的需求。通信芯片里有多档工作频率,CPU子系统有低频待机模式,SoC里有多个PLL动态调频,这些场景都需要在系统运行过程中把某个时钟域的主时钟从一路切到另一路。关键是,切换动作发生时电路不能停,更不能因为切换本身产生一个不完整的时钟脉冲。

很多人第一次听到这个问题,第一反应是“用MUX不就行了吗”?CLK_SEL为低选A钟,为高选B钟,一拍搞定。但稍微画一下时序就会发现,如果特权切换发生在A钟的高电平中间,输出时钟会变成一个窄脉冲;如果发生在低电平中间,输出时钟会出现一个比正常周期短很多的半拍。更糟的情况是两路时钟完全异步,相位关系完全随机,输出时钟的最后一个沿和第一个沿间隔完全不可控,后端时序约束根本没法做。

我当年第一次被问到这个问题时,面试官让我先画“错误切换”的波形,再画“理想切换”的波形。画完才发现,问题的核心根本不在MUX本身,而在“切换的那一拍”。理想切换行为是:旧时钟完整走完最后一个周期,然后新时钟从第一个完整周期开始输出,中间不能出现任何窄脉冲、直通毛刺和亚稳态传递。

这里先说一个基本概念,毛刺的本质是什么?数字电路里,时钟毛刺就是一个比规范最小脉宽还窄的脉冲,它可能触发生成触发器采样到错误数据,也可能让计数器状态错乱,还可能让异步复位生效的时序完全失控。哪怕毛刺只窄了0.1ns,后仿中触发器的hold时序也可能挂掉,这不是概率问题,是确定性违例。所以无毛刺切换设计的最终检查标准就一句话:切换前后,输出时钟的每一个高电平宽度、每一个低电平宽度都必须满足目标时钟所要求的脉冲宽度约束。

那为什么老设计里偶尔会看到直接用MUX切时钟的设计?因为这类设计往往限制了切换发生的时刻——比如只在旧时钟为低电平时切换,且两路时钟同源、频率成整数倍关系。一旦限制条件不满足,就会出问题。无毛刺时钟切换电路的价值就在于去掉这些限制,让切换动作在任意时刻发起都能安全完成。这也是为什么它在数字IC校招面试和社招手撕代码环节里出现率这么高。

2. 经典无毛刺切换架构拆解:每一步都为了解决一个具体问题

先看教科书里最常见的结构。它由三部分组成:输入选择信号的前级同步、两套独立的“沿检测+下降沿打拍”链路,以及最后的时钟门控与输出组合。整个设计的目标是把“任意时刻发起的异步切换请求”转换成“只发生在旧钟下降沿和新钟下降沿的安全切换时刻”。

2.1 核心思路:用时钟下降沿统一所有动作

为什么一定要用下降沿?想象一下输出时钟的高电平宽度是怎么形成的。输出钟的高电平由两段拼接:前半段来自旧钟,后半段来自新钟。如果切换动作发生在某一时刻,旧钟的最后一个高电平和新钟的第一个高电平连在一起,拼接点两侧的电平都是高,高电平宽度就是“旧钟在此之前的剩余高电平时间”加上“新钟从零开始的高电平时间”。这个值跟两路时钟的相位差直接相关,可能远小于正常脉宽。但下降沿不一样——切换发生在旧钟下降沿之后、新钟下降沿之后,拼接点落在低电平区间,新旧两钟的完整高电平都被保留,拼接出来的高电平宽度恰好等于其中某一路的完整半周期。因为低电平时钟输出为0,所以拼接点不会产生毛刺。

这就是这个电路的核心手筋:寄存器在下降沿打拍,产生一个带有“下降沿之后才改变”性质的控制信号,用它来门控时钟,使时钟切换只可能发生在下降沿附近。所有路径都对齐到下降沿,从根本上避开高电平脉冲拼接问题。

2.2 同步器:为什么要在这里插两级触发器

切换请求信号CLK_SEL通常来自系统控制总线,跟A钟、B钟毫无相位关系。如果直接把它送进门控逻辑,CLK_SEL可能在A钟上升沿附近变化,第一级触发器可能采到亚稳态。亚稳态的Q端在解析完成前会振荡、漂移,如果这个Q直接参与时钟门控,输出会直接冒毛刺。

所以标准做法是在A钟域用两级触发器同步沿检测信号,B钟域同样用两级触发器。目的是什么?第一级触发器可能进入亚稳态,但多给一个周期,让它有足够时间恢复到确定电平。两级触发器的输出虽然仍可能在极端情况下出错,但至少是“确定性的错”,不是亚稳态的不确定行为。这个在功能仿真里看不出来,但在真实硅片上,亚稳态窗口会导致输出的时钟沿时间抖动,后端时序收敛极其困难。

要注意的是,这里的同步器不能省略。有些同学面试时会说“CLK_SEL是静态信号,切过去之后不会再变”,言下之意不用同步。这在某些受控场景下也许成立,但在通用设计里,谁也不能保证软件不会在切换过程中改寄存器、不复位模块不会发出重复请求。同步器的代价只是一个周期左右的延时,换来的是设计对输入时序的完全不敏感,这笔账很划算。

2.3 沿检测与下降沿打拍:请求信号如何变成安全的使能

再看每个时钟域内部的具体逻辑。以A钟为例,当外部选择信号要求切到A钟时,经过A钟域同步后得到一个内部信号sel_a_sync。我们希望当sel_a_sync变为高时,不是立刻把A钟放出来,而是要等到A钟的下降沿,才真正让A钟穿过门控。为什么?如果A钟目前没在输出,说明B钟正在输出。这时A钟的上升沿如果直接打开门控,A钟的高电平会插进当前B钟的高电平中间,产生毛刺。等到A钟下降沿才打开门控,是因为当前输出如果是B钟,B钟此刻很可能处于高电平或低电平的任意位置,只有让A钟的上升沿与B钟的某个边界错开,才安全。

设计里用一个下降沿触发的触发器来寄存“A钟请求已经到达”的状态。这个寄存器的输出sel_a_gated只在A钟下降沿变化。然后把它和A钟做与门,输出gated_a。B钟域做同样的事情:sel_b_sync经过B钟下降沿打拍,得到sel_b_gated,与B钟相与得到gated_b。最后gated_a和gated_b相或,就是输出CLK_OUT。

为什么两路要同时置位而不是只置位一路?因为切换前,旧钟的控制信号还在高电平,新钟的控制信号还没拉高,这一拍内两个门控输出都是低电平,输出时钟维持在低。等到新钟下降沿到来,新钟的控制信号拉高,新钟开始输出高电平。输出从低到高的跳变必然是新钟的上升沿,因为此时旧钟已经关断,新钟的低电平区间覆盖了切换点。整个过程中,输出时钟不会出现一个不完整的脉冲。

2.4 几条关键路径的时序博弈

这个架构里有几条路径值得单独拎出来想。

第一条路径是从外部CLK_SEL变化到最终的CLK_OUT恢复稳定。这条路径包含同步器的两级触发器、逻辑门和门控单元。一共两个周期左右的延时。对实时性要求高的系统需要注意,切换不是瞬时的,它需要一个安全窗口。

第二条路径是“请求保持”路径。见很多初学者会问:如果CLK_SEL在切换完成前又变回去了怎么办?答案是电路里专门设计了“状态保持”——sel_a_gated一旦拉高,不会因为CLK_SEL变低而立刻撤销,它会保持到对方时钟域完成切换逻辑更新之后。这需要所谓的“请求-确认”握手逻辑。教科书里的基本型电路其实已经隐式包含了这个机制:sel_a_gated和sel_b_gated同时为高时,两个门控都打开,输出是两个时钟“竞争”的结果;若CLK_SEL此时又要切回A钟,由于两个使能都已拉高,输出会在一拍内重新选中A钟,但这个过程仍然受下降沿约束,所以不会冒毛刺。

第三条路径是两路时钟都关闭时的“全零窗口”路径。若时钟切换过程中,两个时钟域的下降沿各自来了一次,控制信号都变成了低,此时输出被钳在低电平。这个窗口最长可能跨过两个时钟周期,对下游逻辑而言,相当于时钟“停摆”了几个周期。如果下游有异步FIFO的读指针、有实时采样逻辑,必须能容忍短暂停摆。

3. 可综合Verilog实现与逐段注释:手撕代码的正确姿势

直接上代码。下面这个版本是经典结构的完整实现,带参数化位宽,适合面试时边写边讲。我强烈建议你把这个版本背下来、理解透,面试时在此基础上做扩展。

module glitch_free_clk_mux ( input wire clk_a, // 时钟A input wire clk_b, // 时钟B input wire clk_sel, // 0: 选择clk_a, 1: 选择clk_b input wire rst_n, // 异步复位,低有效 output wire clk_out // 无毛刺切换输出 ); // ------------------------------------------------------------ // 第一级:同步器,消除亚稳态 // ------------------------------------------------------------ reg [1:0] sel_a_sync_ff; reg [1:0] sel_b_sync_ff; always @(posedge clk_a or negedge rst_n) begin if (!rst_n) begin sel_a_sync_ff <= 2'b00; end else begin sel_a_sync_ff <= {sel_a_sync_ff[0], (~clk_sel)}; end end always @(posedge clk_b or negedge rst_n) begin if (!rst_n) begin sel_b_sync_ff <= 2'b00; end else begin sel_b_sync_ff <= {sel_b_sync_ff[0], clk_sel}; end end wire sel_a_sync = sel_a_sync_ff[1]; wire sel_b_sync = sel_b_sync_ff[1]; // ------------------------------------------------------------ // 第二级:下降沿打拍,使切换动作发生在安全边界 // ------------------------------------------------------------ reg out_a_r1; reg out_b_r1; always @(negedge clk_a or negedge rst_n) begin if (!rst_n) begin out_a_r1 <= 1'b0; end else begin out_a_r1 <= sel_a_sync & (~out_b_r1); end end always @(negedge clk_b or negedge rst_n) begin if (!rst_n) begin out_b_r1 <= 1'b0; end else begin out_b_r1 <= sel_b_sync & (~out_a_r1); end end // ------------------------------------------------------------ // 第三级:门控与输出组合 // ------------------------------------------------------------ wire clk_a_gated = clk_a & out_a_r1; wire clk_b_gated = clk_b & out_b_r1; assign clk_out = clk_a_gated | clk_b_gated; endmodule

这一段代码逐行看下来,逻辑非常清晰。注意几个细节。

同步器的输入为什么要取反?因为约定clk_sel为0选择A钟,但A钟域内部的有效请求是“我要输出A钟”,这个请求逻辑上等于“clk_sel为0”,写成~clk_sel。你也可以反过来,让clk_sel为1选A钟,两个时钟域的输入都直接接clk_sel,只是这样默认状态需要仔细处理。面试时一定要把选择极性说清楚,否则很容易被追问。

为什么下降沿打拍的输入是sel_a_sync & (~out_b_r1)而不是直接sel_a_sync?这就是握手逻辑的精髓。只有当对方控制器已经撤销输出(out_b_r1为0),本方才允许把请求置位。这样避免了两个门控同时打开的竞争态。原始教科书电路常写成sel_a_sync & (~out_b_r1),其实还有另一种写法是(sel_a_sync | out_a_r1) & (~out_b_r1),后者的含义是“保持当前A钟请求直到对方释放”,对切换时序更鲁棒。

握手这部分,我在面试时见过不少候选人卡住。他们知道要在下降沿打拍,但不知道为什么打拍后的信号要参与对方路径的逻辑。其实道理很简单:切换请求“我要切A钟”需要被A钟域接收,但接收的前提是“B钟域已经安全退出”。只要out_b_r1为高,A钟域就不能把out_a_r1拉高,因为此刻输出还在B钟,两个门控同时打开会让输出同时出现两个时钟的波形,组合逻辑上会产生竞争。等到B钟下降沿到来,out_b_r1变成0,下一拍A钟下降沿到来时,out_a_r1才会拉高。这一来一回,正好实现了安全交接。

为什么复位只复位降沿打拍的寄存器,不同步器的寄存器不参与输出?同步器输出是内部中间信号,就算复位前有不确定值,两拍之后也会收敛。但out_a_r1和out_b_r1直接控制门控,必须复位成0,让输出时钟保持低电平。如果不复位,上电后两个寄存器是X态,门控输出会冒出大量毛刺。

4. 仿真验证的构建思路与边界条件:无毛刺不是仿真看出来的,是约束出来的

写完代码,下一步就是验证。手撕代码阶段,很多同学喜欢直接跑一个简单testbench,看到波形没有毛刺就认为设计正确。我个人的经验是,功能仿真里看不到毛刺,并不代表设计真的无毛刺。因为仿真器里门控逻辑是理想模型,0延时跳变,真正的毛刺发生在后端布线之后。所以验证的思路应该是:

第一,验证逻辑功能正确:切换请求发出后,输出时钟确实从A钟变成B钟,频率切换正确。

第二,验证切换过程中没有产生“非预期边沿”:在输出波形上打标记,检查所有上升沿、下降沿是否都来自某一输入时钟,且相邻沿之间的间隔满足最小脉冲宽度。

第三,验证极端边界:两路时钟频率相近、相位对齐、频率相差很大、切换请求在A钟高电平中间发出、切换请求在B钟低电平中间发出、连续多次切换,这些情况都要跑一遍。

下面是一个基础testbench的框架,包含可调的时钟频率和相位参数。我这里用Verilog直接写,方便大家在仿真工具里快速跑通。

`timescale 1ns/1ps module tb_glitch_free_clk_mux; reg clk_a = 0; reg clk_b = 0; reg clk_sel = 0; reg rst_n = 0; wire clk_out; // 时钟A:100MHz always #5 clk_a = ~clk_a; // 时钟B:150MHz,带相位偏移 initial #2 clk_b = 1; always #3.333 clk_b = ~clk_b; glitch_free_clk_mux dut( .clk_a (clk_a), .clk_b (clk_b), .clk_sel(clk_sel), .rst_n (rst_n), .clk_out(clk_out) ); // 监控输出时钟的最小高电平宽度/低电平宽度 real min_high, min_low; real last_pos_edge, last_neg_edge; always @(posedge clk_out) begin if (last_neg_edge > 0) min_low = (min_low < ($realtime - last_neg_edge)) ? min_low : ($realtime - last_neg_edge); last_pos_edge = $realtime; end always @(negedge clk_out) begin if (last_pos_edge > 0) min_high = (min_high < ($realtime - last_pos_edge)) ? min_high : ($realtime - last_pos_edge); last_neg_edge = $realtime; end integer i; initial begin $dumpfile("wave.vcd"); $dumpvars(0, tb_glitch_free_clk_mux); rst_n = 0; repeat(10) @(posedge clk_a); rst_n = 1; // 等待A钟稳定输出 repeat(5) @(posedge clk_a); // 在任意时刻发起切换到B钟 clk_sel = 1; repeat(20) @(posedge clk_out); // 再次切换回A钟 clk_sel = 0; repeat(20) @(posedge clk_out); // 连续快速切换 clk_sel = 1; repeat(3) @(posedge clk_out); clk_sel = 0; repeat(3) @(posedge clk_out); clk_sel = 1; repeat(30) @(posedge clk_out); $finish; end endmodule

4.1 断言检查:把“无毛刺”变成可自动判定的属性

肉眼盯着波形看不是长久之计。工程上我建议顺手写几个SVA断言,把验证标准明确化。这里给三条最关键的:

// 输出时钟高电平宽度不得小于B钟周期的45% property p_high_width; real high_start; @(posedge clk_out) (1, high_start = $realtime) |=> @(negedge clk_out) ($realtime - high_start > 3.0); endproperty

跑仿真时如果断言报错,就把时间点报出来回查,远比手动看波形高效。注意这里的阈值不能设成50%,因为实际电路里高电平宽度可能因为门控逻辑的传播延时产生微小偏差,设成45%~48%比较合理,能留出制造和仿真误差空间。

4.2 后仿与时钟偏移:功能仿真正确只是入场券

真正到后仿阶段,问题会多得多。主要原因是时钟树综合(CTS)之后,clk_a和clk_b到达门控单元的时间有偏差,逻辑门本身也有cell delay。这些偏差叠加在一起,可能会让本来安全的切换点变成不安全的。标准做法是在综合网表上把门控单元的输入时钟设为set_clock_gating_check,告诉工具“门控信号必须在时钟沿之前稳定”。这样工具会自动优化门控逻辑的时序关系,保证功能正确性。

另外,对于多路时钟切换,后端通常会要求统一用ICG单元(Integrated Clock Gating)做门控,而不是直接写clk_a & out_a_r1。ICG单元有专门的使能端,工具能检查clk_en与clk之间的时序关系,也能避免逻辑综合把与门优化掉导致的问题。很多面试官会追问“能不能在综合时把clk_a & out_a_r1优化掉”,答案是不会,因为综合工具默认把时钟网络标记为不可合并逻辑,但为了保险起见,项目里还是推荐例化工艺库的ICG单元。

4.3 关键边界:同频同相时钟的切换

一个容易被忽略的边界条件是两路时钟完全同频同相。这种情况下即使不做任何处理,直接切换也不会冒毛刺,因为波形完全重合。但我们的设计仍然要保证功能正确,而且要注意:同频时钟若相位有小偏差,哪怕只有几百ps的偏斜,切换时输出时钟的周期会产生一个微小的抖动。这个抖动在jitter容限要求高的系统里可能触发PLL失锁。所以即使是同频切换,也应该让切换动作发生在新时钟的第一个完整周期,而不是试图“无感切换”。这个设计天然满足这个要求。

5. 从手撕代码到落地实现:关于这个设计的进阶细节与替代方案

讲完最基础的经典架构,再聊几个面试和项目中经常深挖的方向。很多候选人会背标准答案,但一旦被问到“如果不满足这个标准答案要怎么办”,就卡壳了。这里我把常见进阶问题盘点一下。

5.1 自切换请求与“保持”逻辑:feedback模式详解

前面代码里的握手逻辑是通过(~out_b_r1)实现的,这是一种简化的握手形式。真正工业级的设计往往需要在每个时钟域内部把请求信号“锁存”住,直到确认对方已释放。标准的写法是:

// A钟域内部 reg req_a_ff1, req_a_ff2; always @(posedge clk_a or negedge rst_n) begin if (!rst_n) begin req_a_ff1 <= 1'b0; req_a_ff2 <= 1'b0; end else begin req_a_ff1 <= ~clk_sel; req_a_ff2 <= req_a_ff1; end end reg ack_a_ff1, ack_a_ff2; always @(posedge clk_a or negedge rst_n) begin if (!rst_n) begin ack_a_ff1 <= 1'b0; ack_a_ff2 <= 1'b0; end else begin ack_a_ff1 <= out_b_r1; ack_a_ff2 <= ack_a_ff1; end end // 输出使能请求:请求有效,且对方已确认释放 wire req_a = req_a_ff2 & ~ack_a_ff2;

这种结构把“请求/确认”完全显式化,好处是不会出现CLK_SEL抖动导致两个时钟域反复抢输出的情况。代价是电路面积和复杂度增加。基础面试题里一般不要求做到这一步,但能在面试中提到“还有反馈握手版本”,会加分不少。

5.2 多路时钟切换:从2选1扩展到N选1

两路时钟切换电路能不能扩展成多路?当然能,但有两种做法。

一种做法是两步走:先做一个两两拼接的二叉树,N路时钟通过log2(N)级的无毛刺切换器串联,每一级处理两个输入。这种做法实现简单,但有个问题,中间节点的时钟会引入额外延时,对高频时钟不利;另一个问题是不支持“任意两路之间直接切换”,比如从clk_0直接切到clk_3,需要先切到clk_1再切到clk_2,这会增加切换时间,期间还可能引入不必要的中间频率。

另一种做法是状态机控制。先为每一路输入时钟设计一个独立的“请求/使能”位,然后一个中央状态机负责仲裁哪一路的使能输出为高。当切换请求到来时,先把旧路的使能撤掉,等待旧路对应时钟的下降沿确认释放,再把新路的使能拉高,同样等待新路时钟的下降沿确认建立。仲裁逻辑可以防止两个输入同时被选中。

两种做法对比下来,工业界更常见的是第二种,因为多路时钟切换往往伴随动态调频策略,切换路径的确定性很重要。二叉树适合时钟数量少且切换路径不敏感的场景。

5.3 时钟偏移对设计的影响:为什么后端约束如此重要

无毛刺切换电路在后端实现时最大的坑就是时钟偏移。假设clk_a和clk_b两个时钟树到达门控单元时存在1ns的偏移差,那么即使功能逻辑正确,输出端依然可能产生一个非常窄的毛刺。原因在于两个门控输出相或的时候,如果一方已经拉低、另一方还没完全拉高,中间的电平状态是悬空的。

后端工程师的标准处理方式有三种:

第一,对门控单元时钟端口设置set_clock_gating_check,让工具自动在综合网表中保证门控信号与时钟沿之间的setup/hold关系。这是必须做的,不做后面没法签核。

第二,在布局规划时尽量把切换电路放在两个时钟源共用路径的汇聚点附近,减少两个时钟树到达延时差。

第三,如果工艺库支持,尽量使用专门的ICG单元。

初学者往往忽略后端的影响,觉得功能仿真通过就够了。等到芯片回来,量测发现特定电压温度下切换时偶尔冒出一个毛刺,才会意识到这个问题的严重性。所以做这个设计时,从一开始就灌输“时钟是模拟信号”的思维,会少走很多弯路。

5.4 从功能仿真到Formal验证:如何证明“无法产生毛刺”

除了动态仿真,形式化验证(Formal Verification)也是证明无毛刺的重要手段。把“CLK_OUT上不存在短于X ns的高电平/低电平脉冲”写成形式化属性,用工具去穷举所有输入序列和时序组合。因为状态空间不大,这个属性通常能很快证明完毕。动态仿真可能覆盖几十万个时钟周期,但形式化验证可以覆盖所有可能性,两者结合基本能把毛刺问题堵死。

5.5 和其他方案对比:异步FIFO、握手协议和脉冲屏蔽

最后说说替代方案。

有些场景下,与其切时钟,不如把数据先缓存到异步FIFO里,等时钟切换完成后继续运行。异步FIFO本身也需要无毛刺时钟切换器来产生读时钟和写时钟,所以它并不是替代方案,而是与无毛刺切换器共存的关系。

另一些场景会用“脉冲屏蔽”逻辑:在CPU里,把时钟切换的窗口设置成指令间隙,先让CPU停在某个固定的流水线阶段,再等待一个安全窗口执行切换。这种方案对软件要求高,硬件上反而可以简化。但对于TBM(Traffic Block Multiplexing)、SerDes的动态速率切换等场景,不可能让流水线暂停,所以还是要靠纯硬件自动切换。

还有一种是“慢速握手协议”方案,例如使用collection tree结构,先让两个时钟域各自产生一个“准备好切换”的脉冲,再经过一个同步握手结决定切换时刻。这个方案与经典结构在实现细节上不同,但最终目的都是让切换沿对齐到下降沿。

从我个人角度看,经典下降沿打拍结构仍然是性价比最高的方案。它简洁、可综合、时序可控、逻辑清晰。如果你的目标是准备面试,把这一段代码吃透,再能把“为什么用下降沿”“为什么加同步器”“为什么需要握手”讲明白,就足够了。如果目标是落地项目,再多想一步后端约束和形式化验证,踩坑的概率会大大降低。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询