做 FPGA 做到能独立写模块之后第一个让你怀疑人生的往往不是逻辑写错而是跨时钟域问题。仿真跑一百遍都是对的上板跑几分钟出现一次错数单独验证每个模块都没问题拼起来偶尔就丢一拍数据。这些情况十有八九和 CDCClock Domain Crossing跨时钟域、亚稳态有关。到了 Part.17正好是零基础 FPGA 学习路线里最容易劝退、也最值得停下来彻底搞懂的一章。这篇文章把 CDC 拆成三块讲先搞明白为什么会出现亚稳态再讲单比特、多比特不同场景该怎么选同步手段最后落在手写异步 FIFO 上完整过一遍指针、格雷码、空满判断和代码实现。适合已经会写基础时序逻辑、但还没系统性处理过多个时钟的同学看完能直接拿去对付项目中真正的跨时钟链路。1. 跨时钟域问题的本质CDC 和亚稳态到底在怕什么1.1 时钟域多时钟带来的“鸡同鸭讲”先说清楚什么是时钟域。一个设计里如果只有同一个时钟那所有寄存器都在同一个节拍下工作数据传递就是“同一首歌按同一个拍子唱”这是最简单的情况。但实际 FPGA 项目里几乎不可能只有单一时钟PLL/MMCM 会派生多个频率外部接口会有自己的时钟PCIe、Ethernet、DDR 这些高速接口更是有独立时钟体系。更常见的是某个模块在 50MHz 下工作另一个模块在 125MHz 下工作中间还要互相传数据。这时候就出现了两个“各过各的时间轴”的时钟域。你可以把两个时钟域想象成两个时区的人。A 时钟域的人说“下午三点见面”B 时钟域的人得先换算才知道是几点因为两边的“时间”不是同一个基准。FPGA 里的信号也是这样A 时钟域产生的信号直接送到 B 时钟域的寄存器输入端口B 时钟域的时钟沿来的时候这个信号不一定正好稳定。它可能刚好在变化可能刚变化完还没稳定也可能已经稳定了很久。不同的相遇情况会导致 B 域寄存器采到不一样的结果这就是跨时钟域问题的来源。这里要特别提醒一点两个同频但相位不同的时钟比如同一个 PLL 出来的 0 度和 90 度时钟它们之间也是跨时钟域。很多人以为只有频率不同才算 CDC其实只要两个时钟沿不能保证对齐或者说沿之间有不能静态分析的不确定关系就都要按跨时钟域处理。甚至两个“同源 100MHz”但经过不同 BUFG 的时钟也不能拍胸脯说绝对没问题。1.2 亚稳态触发器在建立时间窗口内的“犹豫”跨时钟域问题最终都会落到一个物理现象上亚稳态。D 触发器的工作原理大家都熟悉时钟上升沿到来前数据必须先稳定在输入端口上一段时间这就是建立时间沿到来之后数据还要继续保持一段时间这就是保持时间。如果数据变化刚好落在这个窗口里触发器内部的锁存结构会进入一个中间状态输出既不是稳定的 0也不是稳定的 1而是在两者之间徘徊。这个徘徊状态就是亚稳态。亚稳态最要命的地方在于它不会永远停留在中间。经过一段不确定的恢复时间之后输出最终会稳定到 0 或 1但“最终稳定到哪个值”是不可预测的而且这个恢复时间本身也是随机的。对数字设计来说这意味着你没有办法在下一拍用这个信号做任何可靠的逻辑判断。亚稳态发生的概率不是零但通常很低。业界用 MTBF平均故障间隔时间来量化亚稳态发生的概率和时钟频率、数据变化频率、触发器的建立保持窗口都有关系。简单理解就是时钟越快、数据翻转越频繁概率越高单个触发器发生亚稳态的概率不高但一个大设计里有几万个跨时钟触发器乘起来之后偶尔冒一次错完全正常。所以做 CDC 处理的目标从来不是“绝对不出亚稳态”而是“让亚稳态只发生在隔离位置不让它传播到下游逻辑”。1.3 两级同步器不能“消除”亚稳态只能“隔离”亚稳态很多初学者把两级同步器当成万能膏药以为看到跨时钟信号就串联两个寄存器问题就消失了。这个理解需要修正。两级同步器做的事情是第一级寄存器直接采样跨时钟信号如果发生亚稳态让这个亚稳态在第一级输出端“自己消化”第二级寄存器再采样第一级的输出此时第一级已经大概率稳定了所以第二级输出的逻辑值是可以安全使用的。换句话说两级同步器并不能让第一级不出亚稳态它只是保证亚稳态不会直接连到功能逻辑上。第一级可能拍出错误值但错误值最多导致这一拍的数据值不对不会让整个逻辑链陷入不确定状态。这个区别很重要同步器的效果是“降低错误传播率”不是“保证采样值正确”。所以在今后的设计中看到一个跨时钟单比特信号第一反应不应该是“加两级寄存器”而应该是“这个信号能不能容忍延时两拍它是电平信号还是脉冲信号如果第一级拍错了后果是什么”。只有把这几个问题想清楚CDC 处理才不会流于形式。2. 单比特、多比特CDC 手段怎么选2.1 单比特信号两级同步器是最基础的单比特“隔离舱”最基本的 CDC 处理就是单比特信号跨时钟。典型场景外部按键输入、某个模块的 busy 状态、中断标志、请求信号。这类信号通常是电平信号持续时间远大于目标时钟周期直接用两级同步器就够了。两级同步器的写法非常简单// 慢时钟域信号 sig_a 同步到快时钟域 clk_b reg sig_a_sync1, sig_a_sync2; always (posedge clk_b) begin sig_a_sync1 sig_a; sig_a_sync2 sig_a_sync1; end注意几个细节。第一两级同步器的两个触发器应该放在同一个 always 块里综合工具会把它们放在相邻位置降低布线延迟也方便给同步器加 ASYNC_REG 属性。第二如果是脉冲信号即源时钟域只拉高一个周期同步到目标时钟域后很可能检测不到因为目标时钟域采样时脉冲早就过去了。这时要先在源时钟域把脉冲转成电平比如用 toggle 信号目标时钟域检测到边沿后再恢复成单拍脉冲。这是新手最容易踩的坑。还有一个被忽略的点如果单比特信号是从快时钟域同步到慢时钟域电平信号本身也有风险。比如快时钟域的信号每几十个周期翻转一次看起来慢但它翻转后的高电平持续时间可能短于慢时钟域的一个周期慢时钟域采样时可能正好错过。所以判断一个信号能不能用两级同步器不能只看“跨时钟”还要看信号的最小脉冲宽度和目标时钟周期的关系。2.2 握手协议多比特数据的低速保险方案单比特信号可以用两级同步器解决多比特数据就不行了。原因很直接一组并行数据比如 8bit8 根线在 PCB 或 FPGA 内部走线的延迟各不相同到目标时钟域寄存器输入端的到达时间会有微小差别。如果目标时钟沿正好在这些信号变化的窗口附近可能出现部分 bit 已经变成新值、部分 bit 还是旧值的情况组合出来就是一个完全不存在的“错数”。两级同步器解决不了多比特问题因为每个 bit 独立同步后仍然可能在同一个时钟沿被采样到时“新旧混杂”。这不是同步器的问题而是并行数据多根线之间没有统一“对齐点”的问题。多比特数据最安全但也最慢的做法是握手协议。基本流程是源时钟域把数据准备好后拉高 req 信号目标时钟域同步 req看到 req 有效后采样数据然后把 ack 拉高源时钟域同步 ack看到 ack 后知道数据已经被安全拿走撤销 req 并准备下一笔数据。这个机制可以保证目标时钟域只在数据稳定之后才采样代价是每传一笔数据至少要经历“ req 跨时钟 ack 跨时钟”两个来回吞吐量很低只能用于低频控制类数据比如寄存器配置、少量指令传输。握手协议里最容易被坑的是req 的撤销时机和 ack 的撤销时机如果处理不好会出现“第二次握手假启动”。严谨的做法是 req 和 ack 都用电平信号源域等 ack 拉高后再撤销 req目标域等 req 撤销后再撤销 ack也就是四段式握手。项目里如果频率都不高用三态门或简化版握手也能跑但我建议还是按完整四段式写省得后面排查边界问题。2.3 异步 FIFO连续数据流场景的正解握手协议虽安全但慢不适合连续高速数据流。比如 ADC 在 100MHz 下连续采样数据流往 75MHz 的 DSP 处理模块送这是一刻不能停的数据流不可能每笔数据都停下来等握手。这时候异步 FIFO 就是标准答案。异步 FIFO 的本质是一个双端口 RAM写端口在写时钟域读端口在读时钟域读写双方各自维护自己的读写指针。写侧只管往 RAM 里写读侧只管从 RAM 里读两侧通过一套特殊的“指针同步机制”知道对方走到了哪里从而判断 FIFO 是空是满。因为数据是流水式进出不需要停顿等待应答吞吐量可以达到每拍一笔所以异步 FIFO 天然适合连续数据流场景。既然异步 FIFO 这么好为什么不在所有跨时钟场景都用它因为资源消耗和延迟都比握手协议高。双端口 RAM 要占 BRAM 或 LUTRAM指针同步要占额外寄存器空满判断本身也有延迟。如果你只是要传几笔配置数据用 FIFO 就有点大炮打蚊子了。CDC 手段的选择原则很简单控制类低频数据用握手连续数据流用异步 FIFO简单状态标志用两级同步器。我把三种手段的适用场景总结成一张表方便以后快速选择信号类型典型场景推荐方案最大吞吐量实现难度单比特电平状态标志、使能信号两级同步器每拍可采样极低单比特脉冲跨时钟单拍请求脉冲转电平同步受慢域限制中多比特控制数据寄存器配置、命令字四段握手极低中多比特连续数据ADC数据、图像像素流异步FIFO每拍一笔高3. 异步 FIFO 的核心设计指针、格雷码与空满判断3.1 异步 FIFO 总览双口 RAM 加两个指针手写异步 FIFO 前先把整体结构画在脑子里。FIFO 内部有一个深度为 2^N 的双端口 RAM写端口和读端口彼此独立。写侧负责产生写地址、接收写数据、维护写指针读侧负责产生读地址、输出读数据、维护读指针。FIFO 的一个关键特征是全满时不能再写全空时不能再读。所以设计核心其实只有三件事指针生成、指针跨时钟、空满判断。为什么指针比较要跨时钟因为判断 FIFO 是否满需要比较写指针和读指针的距离判断是否空也需要比较这两个指针。但写指针在写时钟域读指针在读时钟域直接比较不可能。常规做法是把读指针通过两级同步器同步到写时钟域用于判断是否满把写指针同步到读时钟域用于判断是否空。这里有个非常容易误解的点同步后的指针天然有延迟。也就是说写侧看到的读指针可能比真实读指针落后几拍读侧看到的写指针也可能落后几拍。这个延迟会让“满”和“空”的判断结果偏向保守写侧可能“明明还有空间却暂时认为满了”读侧可能“明明还有数据却暂时认为空了”。没关系这不会导致数据写穿或读错只会让 FIFO 的利用率和延迟性能略微下降。我们的目标就是把这个“保守”控制在一个可接受的范围。3.2 为什么指针跨时钟必须用格雷码前面我说过多比特数据直接做两级同步会出“新旧混杂”的问题。指针同样是多比特所以也不能直接同步二进制指针。假设写指针从 4‘b0111 跳到 4’b1000四位同时翻转读侧同步器采样时可能看到 4‘b0100、4’b1101 之类的乱值这个乱值会直接导致满判断彻底错乱。因为二进制多位同时翻转时每一位的到达时间都不一样目标时钟沿采到的“混合体”是各种中间状态的随机组合而且这种错误可能持续好几拍。格雷码能解决这个问题是因为格雷码相邻两个值之间只有一位变化。写指针从 7 跳到 8在格雷码里是从 4‘b0100 跳到 4’b1100只有最高位在翻转。读侧同步这组格雷码时最坏情况是采样瞬间这个位刚好处于中间状态最终稳定到 0 或 1。但无论稳定到哪个值读侧得到的格雷码要么是 7 的格雷码要么是 8 的格雷码不会有第三种“错码”。最多导致满判断晚一拍或早一拍生效不会让指针指向完全不存在的地址。这个“唯一一位变化”的特性让格雷码同步后的失真范围被限制在两个相邻状态之间。所以异步 FIFO 的指针几乎一定用格雷码表示。哪怕是复杂的异步 FIFO IP内部也是这套逻辑。3.3 空满判断怎么知道“装满了”还是“读空了”空满判断是异步 FIFO 最容易写错的地方。先说结论用一个 ADDR_WIDTH1 位的指针最高位用来表示“圈数”。假设 FIFO 深度为 16地址需要 4bit但指针宽度用 5bit。当写指针和读指针完全相等时可能是空也可能是满。怎么区分看最高位。如果写指针比读指针多跑完整一圈又回到同一个低 4bit 地址说明满如果两个指针完全一致说明空。所以 5bit 指针不仅包含地址还包含“已经绕了几圈”的信息。换成格雷码之后判断条件变得更巧妙。读空判断把写指针格雷码同步到读时钟域和读指针格雷码比较完全相等就是空。因为空状态只出现在“指针完全相同且没有额外圈数”这一种情况同步延迟只会让它更保守不会把非空误判成空。写满判断把读指针格雷码同步到写时钟域后和写指针格雷码比较条件是“关于一圈满距离对齐”。在二进制里满等于写指针领先读指针正好一个 FIFO 深度对应到格雷码可以用一个非常优雅的条件表示把同步过来的读指针格雷码的最高两位取反其他位保持不变然后和写指针格雷码比较相等即为满。这句话背后其实就是“写指针已经领先一圈”的映射关系。写代码时我会写成这样wire wfull_val (wptr_gray {~rptr_gray_sync2[ADDR_WIDTH:ADDR_WIDTH-1], rptr_gray_sync2[ADDR_WIDTH-2:0]});刚开始看这个式子会觉得像魔法但理解它背后的“圈数对齐”逻辑后就不会再忘了。重点是满判断不准可能导致写侧在真正满的时候还继续写数据覆盖掉未读内容空判断不准可能导致读侧在真正空的时候还继续读读到旧数据或垃圾数据。所以空满判断的优先级是“宁可保守不可激进”。4. 手写一个异步 FIFOVerilog 实现与约束要点4.1 模块接口和参数设计说到写代码我直接给一个可运行的异步 FIFO 模块。为了不过度复杂这个版本省略了 FWFTFirst Word Fall Through模式也没有做类似 BRAM 输出寄存器的厂商专用优化但核心的指针逻辑、格雷码转换、空满判断都是完整且可直接综合的。参数方面DATA_WIDTH 设置数据宽度ADDR_WIDTH 用来决定深度FIFO 深度就是 2^ADDR_WIDTH。如果把 ADDR_WIDTH 设为 4那 FIFO 深度就是 16指针宽度为 5bit。接口分两组时钟wclk 和 wrst_n 是写时钟域rclk 和 rrst_n 是读时钟域winc 是写请求rinc 是读请求wfull 和 rempty 分别是写满和读空标志。注意写请求和读请求我用的是“inc”命名强调它们是“使能信号”不是“写使能地址”这种 AXI 接口。这里有一个重要设计决策复位信号。内部同步器寄存器需要在各自时钟域复位所以 wrst_n 影响写时钟域的所有寄存器rrst_n 影响读时钟域的所有寄存器。外部输入复位信号如果是一次性的全局复位最好在各自时钟域先做一次同步释放避免复位信号本身参与跨时钟比较。为了简化代码这个版本假设外部已经提供了同步后的复位。4.2 指针、同步器和 RAM 读写实现我把实现分成三个部分RAM、指针生成、指针同步与空满。先看整体代码。module async_fifo #( parameter DATA_WIDTH 8, parameter ADDR_WIDTH 4 )( input wire wclk, input wire wrst_n, input wire winc, input wire [DATA_WIDTH-1:0] wdata, output reg wfull, input wire rclk, input wire rrst_n, input wire rinc, output reg [DATA_WIDTH-1:0] rdata, output reg rempty ); localparam PTR_WIDTH ADDR_WIDTH 1; localparam DEPTH 1 ADDR_WIDTH; // 双端口 RAM reg [DATA_WIDTH-1:0] mem [0:DEPTH-1]; // 二进制指针和格雷码指针 reg [PTR_WIDTH-1:0] wptr_bin; reg [PTR_WIDTH-1:0] wptr_gray; reg [PTR_WIDTH-1:0] rptr_bin; reg [PTR_WIDTH-1:0] rptr_gray; // 跨时钟同步后的对侧指针格雷码 reg [PTR_WIDTH-1:0] rptr_gray_sync1; reg [PTR_WIDTH-1:0] rptr_gray_sync2; reg [PTR_WIDTH-1:0] wptr_gray_sync1; reg [PTR_WIDTH-1:0] wptr_gray_sync2; wire [PTR_WIDTH-1:0] wptr_next wptr_bin 1b1; wire [PTR_WIDTH-1:0] rptr_next rptr_bin 1b1; // 写指针生成 always (posedge wclk or negedge wrst_n) begin if (!wrst_n) begin wptr_bin 0; wptr_gray 0; end else if (winc !wfull) begin wptr_bin wptr_next; wptr_gray wptr_next ^ (wptr_next 1); end end // 读指针生成 always (posedge rclk or negedge rrst_n) begin if (!rrst_n) begin rptr_bin 0; rptr_gray 0; end else if (rinc !rempty) begin rptr_bin rptr_next; rptr_gray rptr_next ^ (rptr_next 1); end end // 写数据 always (posedge wclk) begin if (winc !wfull) mem[wptr_bin[ADDR_WIDTH-1:0]] wdata; end // 读数据同步读输出 always (posedge rclk or negedge rrst_n) begin if (!rrst_n) rdata 0; else if (rinc !rempty) rdata mem[rptr_bin[ADDR_WIDTH-1:0]]; end // 同步读指针到写时钟域 always (posedge wclk or negedge wrst_n) begin if (!wrst_n) begin rptr_gray_sync1 0; rptr_gray_sync2 0; end else begin rptr_gray_sync1 rptr_gray; rptr_gray_sync2 rptr_gray_sync1; end end // 同步写指针到读时钟域 always (posedge rclk or negedge rrst_n) begin if (!rrst_n) begin wptr_gray_sync1 0; wptr_gray_sync2 0; end else begin wptr_gray_sync1 wptr_gray; wptr_gray_sync2 wptr_gray_sync1; end end // 空满判断 wire rempty_val (rptr_gray wptr_gray_sync2); wire wfull_val (wptr_gray {~rptr_gray_sync2[PTR_WIDTH-1:PTR_WIDTH-2], rptr_gray_sync2[PTR_WIDTH-3:0]}); always (posedge wclk or negedge wrst_n) begin if (!wrst_n) wfull 1b0; else wfull wfull_val; end always (posedge rclk or negedge rrst_n) begin if (!rrst_n) rempty 1b1; else rempty rempty_val; end endmodule几个细节值得说。第一写指针格雷码的生成用的是“下一拍二进制指针”而不是“当前指针”这样可以保证格雷码和二进制指针在同一拍对齐。第二RAM 的写地址用的是 wptr_bin 的低 ADDR_WIDTH 位这里 wptr_bin 是当前拍的值正好是数据写入的真实地址。第三rdata 是同步读输出也就是读使能后的下一拍才出数据这个时间关系在写 testbench 时要记清楚。有人可能会问为什么空满标志不是组合逻辑而是寄存器输出因为空满判断本身要依赖同步后的指针同步后的指针来自两级寄存器天然多一级寄存器延迟再用一级寄存器输出空满是为了防止空满信号上面带毛刺也方便时序收敛。满信号在写时钟域是稳定信号空信号在读时钟域是稳定信号下游逻辑可以放心使用。4.3 仿真、约束和综合注意事项写完代码之后仿真测试比写代码更容易暴露问题。我的测试思路是先做“只写不读”验证满标志再做“只读不写”验证空标志最后做“随机读写”验证数据连续性。随机读写时我给读写接口各接一个伪随机使能信号每笔数据写入一个递增数读出后用计数器比较如果数据不连续就报错。这套 testbench 能覆盖大多数指针边界问题。约束方面异步 FIFO 的同步寄存器要特别标注。在 Vivado 的 XDC 里可以对两级同步器加 ASYNC_REG 属性告诉工具这些寄存器的位置要尽量靠近并且作为异步输入路径处理。同时两个时钟域之间要设置异步时钟组约束否则工具会尝试分析本来无法分析的跨时钟路径导致时序报告里出现大量红色。常见写法是把它们声明为异步时钟组再对同步器的入口路径设置 set_false_path 或 set_max_delay。在某些工程里你也可以直接用厂商 FIFO IP。Xilinx 的 FIFO Generator、Intel 的 FIFO Intel FPGA IP 内部都做了完整的格雷码和空满处理还会根据 BRAM 特性优化输出性能和可靠性都比自己写的版本强。我强烈建议学习阶段手写一次异步 FIFO真正做产品时用 IP 并在 IP 配置里选好“异步时钟”模式。这不是推翻前面的内容而是让你在用好 IP 的同时能看懂 IP 的行为出问题时知道从哪儿排查。5. 调试实录常见坑和排查思路5.1 最容易踩的三个坑先说第一个坑复位不同步。很多同学写异步 FIFO 时直接把外部 RESET 接到 wrst_n 和 rrst_n 上。如果外部复位释放时和两个时钟的相位关系不确定可能导致同步器寄存器复位后一开始就处于不可预测状态或者两个时钟域的复位释放时间差太大导致指针初始值不一致。解决办法是每个时钟域自己做一次“异步复位、同步释放”的复位同步再送给内部寄存器。第二个坑满信号拉高后写侧没有立即停笔。满信号是经过同步延迟才产生的所以它本身就比真实满状态晚几拍。如果写侧逻辑在满信号拉高之后还继续写了一个周期就可能覆盖掉读侧还没读走的数据。因此满信号之后的第一个动作必须是“无条件停止写使能”不能存在任何组合逻辑再延一拍。这也是为什么计算写指针时使用 winc !wfull而不是只用 winc。第三个坑仿真正常上板偶发丢数。这种情况最常见的原因是约束没做。两个异步时钟域没有声明为异步时钟组综合工具强行分析跨时钟路径结果要么布局让同步器距离过远要么把同步路径当成关键路径优化反而破坏了同步器的特性。加一行 ASYNC_REG 约束给两个时钟域声明异步关系再把同步器入口路径设为 false path问题通常就消失了。5.2 实测下来好用的调试手法异步 FIFO 出问题时波形里看空满标志经常是“看起来都正常”因为偶发错误和亚稳态一样复现概率低抓波形很难。我的做法是把调试数据做成“带编号的帧”。写侧每写一笔数据里带一个连续递增的帧号读侧读出后检查帧号是否连续。连续就是没问题跳号说明丢数重复说明没读走或写覆盖。配合 Vivado/Quartus 的 ILA 或 Signaltap 抓读侧输出很快能定位是空满标志错误还是 RAM 读写冲突。另一个好用的手法是加断言。比如写满之后 winc 不能再拉高读空之后 rinc 不能再拉高如果违反就打印 error。断言放在仿真里可以在长时间随机测试时自动抓问题。很多偶发问题不是逻辑拍脑袋能看出来的就是靠这种长时间随机跑逼出来的。还有一个经验如果手写 FIFO 数据宽度和深度都很大建议在 RAM 读写端口加输出寄存器。BRAM 本身有输出寄存器位可以用厂商原语或综合选项打开减少关键路径长度。如果 RAM 用的是 LUTRAM宁可多花资源把读端口做成同步读也不要追求一拍组合输出否则读时序很容易崩。5.3 一个快速判断 CDC 错误的经典波形例子最后分享一个非常典型的查错过程。有一次我把一个 FIFO 的读写指针都用二进制同步每个 bit 接了独立的两级同步器。仿真时 1000 笔数据没出错我以为没问题一上板跑 5 分钟就丢了一笔。后来加帧号检查发现丢数点是写指针从 0111 到 1000 的跨越位置。原因就是二进制指针多位同时跳变同步时被采成混值满判断偶尔错误放行了一个写操作。换成格雷码指针后这个问题再也没有出现。这个例子说明异步 FIFO 里的格雷码不是“锦上添花”而是“必要设计”。你只有在现场被这种偶发问题折磨过才会真正理解那一句“跨时钟域处理的基础是降低出错概率而不是消灭出错”到底是什么意思。我现在的习惯是项目开始前先画一张“CDC 地图”把所有跨时钟信号列出来每个信号标清楚类型是什么、单比特还是多比特、连续流还是离散控制数据、用哪种同步方案、需要加什么约束。有了这张地图后面的代码、约束、验证都有章可循比出了问题再翻波形高效得多。如果你正被跨时钟域的偶发问题折磨可以先回过来检查一下自己手头的设计是不是还在用二进制指针同步多比特数据或者满信号之后还多写了一拍。这两个问题解决掉一半的 CDC 故障就消失了。