ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

深入理解FPGA跨时钟域:亚稳态、两级同步器与异步FIFO设计

深入理解FPGA跨时钟域:亚稳态、两级同步器与异步FIFO设计 1. 亚稳态为什么寄存器会抽风——先从根上讲清楚问题做 FPGA 开发到了多时钟域阶段基本都会遇到一种让人抓狂的现象仿真怎么跑都正确状态机波形干净利落可是一上板子就偶发出错。逻辑工程师第一反应是代码写错了第二反应是时序没约束好查了半天才发现是跨时钟域CDCClock Domain Crossing的问题。这个问题不解决板子上的表现往往非常玄学——跑十分钟坏一次或者温度一高就出错复位一下又好了。先说说亚稳态是什么。触发器内部本质上是一个带反馈的放大器结构它对输入数据有一个硬性要求数据必须在时钟有效沿之前到达并稳定一段时间建立时间 Tsu在时钟有效沿之后继续保持一段时间保持时间 Th。只要满足了这两个时间窗口触发器输出就是个干净稳定的 0 或 1。但跨时钟域的信号根本不知道另一个时钟什么时候来信号正好在时钟有效沿附近发生变化时触发器就进入了所谓亚稳态——输出电压停留在中间电平既不是完整的 0 也不是完整的 1而且这个状态能维持相当长一段时间纳秒到微秒级都有可能随后才随机塌缩到 0 或 1。这个塌缩的方向完全不可预测更头疼的是在亚稳态期间如果后续的组合逻辑去采样这个输出不同的组合逻辑路径可能会采到不同的值——有人采到 0有人采到 1整个逻辑就瞬间错乱了。所以亚稳态是不能消除只能降低概率的物理现象任何号称完全消除亚稳态的方案都是耍流氓。衡量它概率的指标叫 MTBFMean Time Between Failures平均故障间隔时间公式长这样MTBF e^(tMET/τ) / (T0 * f_clk * f_data)其中 tMET 是允许触发器从亚稳态恢复出来的时间τ 和 T0 是触发器工艺相关的常数f_clk 是采样时钟频率f_data 是异步信号的变化频率。这个公式告诉我们的核心结论是采样时钟越快、异步信号翻转越频繁、留给恢复的时间越短出问题的概率就越大。如果你有一块 Vivado 或者 Quartus 环境可以直接在器件库的 datasheet 里找到触发器的 tMET 和 T0 参数把实际工程的时钟频率和数据变化率代进去算一下。我算过一个典型的例子250MHz 时钟采样一个 25MHz 的异步信号不经过任何同步处理直接采MTBF 大概只有几分钟到几个小时级别这在上板调试中几乎是必炸的。所以问题根本不是要不要处理而是怎么处理才够用。注意在调试阶段如果遇到数据偶尔错一拍状态机莫名其妙跳到非法状态不要先怀疑代码逻辑先检查这个信号是不是跨时钟域过来的。用 Vivado 的 Report CDC 或者 Quartus 的 CDC Viewer 扫一遍整个工程往往一抓一个准。2. 两级同步器的能力边界它到底解决了什么没解决什么2.1 两级同步器的工作原理和正确的写法处理单比特跨时钟域信号最经典的手段是两级同步器——两个触发器串联第一个触发器采样异步信号输出给第二个触发器再来一拍。第一级触发器亚稳态的概率很高但第二级采样的是第一级经过一个完整时钟周期后的输出。只要第一级在下一个时钟沿到来之前完成了塌缩第二级就能采到一个稳定的值。这一拍的时间就是公式里的 tMETMTBF 一下子就上去了。这个逻辑写成 Verilog 很简短但写法上有细节。我见过不少人直接在 always 块里写两个寄存器赋值这没问题但作为工程代码更推荐下面的写法module sync_2ff #( parameter WIDTH 1 )( input wire clk, input wire rst_n, input wire [WIDTH-1:0] async_in, output wire [WIDTH-1:0] sync_out ); (* shreg_extract no, dont_touch true *) reg [WIDTH-1:0] sync_ff1; (* shreg_extract no, dont_touch true *) reg [WIDTH-1:0] sync_ff2; always (posedge clk or negedge rst_n) begin if (!rst_n) begin sync_ff1 {WIDTH{1b0}}; sync_ff2 {WIDTH{1b0}}; end else begin sync_ff1 async_in; sync_ff2 sync_ff1; end end assign sync_out sync_ff2; endmodule这里我特意加了两条综合属性。shreg_extract no是防止综合器把两个寄存器识别成移位寄存器然后优化掉dont_touch true是防止布局布线时把两个触发器拆得太远或者做复制优化。为什么不加不行因为综合工具看到两个串联寄存器有时候会在优化时把它们合并或者挪位置一旦两个触发器距离太远布线延迟变大同步器的 MTBF 反而会退化。这是从实际项目里学到的教训。2.2 两级同步器解决不了的多比特问题两级同步器解决的是单比特电平信号的同步问题。如果信号是单比特但以脉冲形式出现比如只拉高一个周期那要小心异步时钟域的采样频率如果比脉冲宽度低采样时钟可能根本看不到这个脉冲两级同步器输出直接就是 0。这种情况下要用结绳法或者脉冲同步器结构来扩展脉冲宽度让采样的时钟域至少能采到一拍。多比特信号的情况就更麻烦了。假设有两个相关信号 a 和 b 要从时钟域 A 传到时钟域 B对每个信号分别加两级同步器——这种做法在工程上是错误的。因为每个同步器的塌缩时间不一样A 域发出的 (a, b) (0, 1) 变换到了 B 域可能先看到 a 变了而 b 还没变采样到(1, 0)或者(1, 1)完全取决于亚稳态各自的运气。多比特交叉变化时两级同步器只有几十纳秒的时间窗能保证同时采样超过这个时间窗信号就必须用其他方式传输。这就引出了一个关键判断设计跨时钟域数据通路时先问自己三个问题。第一信号是单比特还是多比特第二是电平信号还是脉冲信号第三数据速率和采样时钟频率差距有多大这三个问题的答案直接决定了该用哪种 CDC 处理方案。单比特电平用两级同步器单比特脉冲用脉冲同步器多比特慢速数据用握手协议多比特高速连续数据用异步 FIFO基本就是这个对应关系。3. 异步 FIFO 设计拆解格雷码、指针同步与空满判断的工程实现3.1 为什么多比特数据要用异步 FIFO 而不是直接打拍当多比特数据需要连续不断地从一个时钟域传到另一个时钟域时比如 ADC 采样的数据交给 DSP 内核处理或者以太网 MAC 的数据交给用户逻辑处理方案就只有一个主流选项——异步 FIFO。原理是让数据在某个时钟域写入存储器另一个时钟域读出两侧各自用自己的时钟驱动读写逻辑读写指针完全独立。异步 FIFO 的本质难题是读指针要送进写时钟域做满判断写指针要送进读时钟域做空判断。指针是多比特的不能直接打拍同步上一节已经解释过原因了。解决办法就是格雷码Gray Code。格雷码的伟大之处在于相邻两个值之间只有 1 个比特发生变化。写指针自增时如果把它编码成格雷码再跨时钟域同步那么指针变化时所有比特里只有一个会在时钟沿附近跳变其他比特在采样窗口内保持稳定采样结果要么是旧值要么是新值绝不会出现四不像的中间态。这就把亚稳态从可能输出任意值降级为可能输出旧值或新值逻辑上完全可控。3.2 格雷码转换的公式与 FIFO 深度约束二进制转格雷码的标准公式是gray (bin 1) ^ bin格雷码转二进制则要逐位异或从最高位开始往下bin[MSB] gray[MSB]然后 bin[i] bin[i1] ^ gray[i]。这里有个工程上容易忽略的点异步 FIFO 的深度必须是 2 的幂次方。为什么因为格雷码只对在满深度内循环的指针保证相邻值只有 1 bit 变化而 2 的幂次方深度配合二进制计数器正好让回绕wrap-around时也满足这个条件。FIFO 深度 16、32、64 这些常见配置都没问题但如果做成 12 深或者 20 深格雷码编码就失效了异步 FIFO 会出严重错误。所以要么把深度扩展成 2 的幂次方要么就得换其他同步方案。指针位宽和 FIFO 深度的关系也容易搞混。深度为 2^N 的 FIFO写指针和读指针至少需要 N1 位。多出来的最高位用来干什么判断回绕了一圈的情况——读写指针的低 N 位相同、最高位不同时表示写指针已经领先读指针一整圈FIFO 处于满状态完全相同则是空状态。3.3 一个可直接落地的异步 FIFO 核心代码下面给一个带标准格雷码指针的异步 FIFO 核心框架。这不是完整可直接综合的代码但 CDC 相关的关键逻辑全部在里面了。module async_fifo #( parameter DATA_WIDTH 8, parameter ADDR_WIDTH 4 )( input wire wr_clk, input wire wr_rst_n, input wire wr_en, input wire [DATA_WIDTH-1:0] wr_data, output wire full, input wire rd_clk, input wire rd_rst_n, input wire rd_en, output wire [DATA_WIDTH-1:0] rd_data, output wire empty ); localparam FIFO_DEPTH 1 ADDR_WIDTH; // memory array reg [DATA_WIDTH-1:0] mem [0:FIFO_DEPTH-1]; // binary pointers reg [ADDR_WIDTH:0] wr_ptr_bin; reg [ADDR_WIDTH:0] rd_ptr_bin; // gray pointers wire [ADDR_WIDTH:0] wr_ptr_gray (wr_ptr_bin 1) ^ wr_ptr_bin; wire [ADDR_WIDTH:0] rd_ptr_gray (rd_ptr_bin 1) ^ rd_ptr_bin; // synchronized gray pointers reg [ADDR_WIDTH:0] wr_ptr_sync_rd1, wr_ptr_sync_rd2; reg [ADDR_WIDTH:0] rd_ptr_sync_wr1, rd_ptr_sync_wr2; // write pointer logic always (posedge wr_clk or negedge wr_rst_n) begin if (!wr_rst_n) wr_ptr_bin 0; else if (wr_en !full) wr_ptr_bin wr_ptr_bin 1b1; end // read pointer logic always (posedge rd_clk or negedge rd_rst_n) begin if (!rd_rst_n) rd_ptr_bin 0; else if (rd_en !empty) rd_ptr_bin rd_ptr_bin 1b1; end // synchronize write pointer into read clock domain always (posedge rd_clk or negedge rd_rst_n) begin if (!rd_rst_n) begin wr_ptr_sync_rd1 0; wr_ptr_sync_rd2 0; end else begin wr_ptr_sync_rd1 wr_ptr_gray; wr_ptr_sync_rd2 wr_ptr_sync_rd1; end end // synchronize read pointer into write clock domain always (posedge wr_clk or negedge wr_rst_n) begin if (!wr_rst_n) begin rd_ptr_sync_wr1 0; rd_ptr_sync_wr2 0; end else begin rd_ptr_sync_wr1 rd_ptr_gray; rd_ptr_sync_wr2 rd_ptr_sync_wr1; end end // memory write always (posedge wr_clk) begin if (wr_en !full) mem[wr_ptr_bin[ADDR_WIDTH-1:0]] wr_data; end // memory read (combinational output for simplicity) assign rd_data mem[rd_ptr_bin[ADDR_WIDTH-1:0]]; // full/empty generation assign full (wr_ptr_gray[ADDR_WIDTH:ADDR_WIDTH-1] ~rd_ptr_sync_wr2[ADDR_WIDTH:ADDR_WIDTH-1]) (wr_ptr_gray[ADDR_WIDTH-2:0] rd_ptr_sync_wr2[ADDR_WIDTH-2:0]); assign empty (rd_ptr_gray wr_ptr_sync_rd2); endmodule这个框架有几个关键点要解释清楚。空判断用的是读指针格雷码和同步过来的写指针格雷码直接相等。为什么可以直接相等因为格雷码每个值都是唯一的当前 FIFO 中一个数据都没写读指针和写指针必然指向同一个地址格雷码完全一致。写指针同步到读时钟域可能存在一两拍的延迟但这只会导致空信号晚一点拉高不会导致读空安全。满判断稍微复杂写指针格雷码的高两位和同步过来的读指针格雷码高两位取反后相等同时低 ADDR_WIDTH-1 位相等。这里用格雷码的高两位判断回绕方向。为什么格雷码取反可以用作满判断而二进制不行这是异步 FIFO 设计里最容易讲不清的细节。格雷码的镜像对称性质最高位为 0 和 1 的两半格雷码序列除了最高位不同其余低位的编码完全呈镜像对称。深度 16 的 FIFO 用 4 位指针时地址 0 和 15 的格雷码分别是 0000 和 1000地址 1 和 14 是 0001 和 1001以此类推。所以当写指针比读指针领先一整圈深度 16 的距离写指针格雷码的最高位和读指针相反而低位的镜像对应关系使得~rd_ptr_gray正好可以匹配。满判断信号延迟一两拍只会晚拉高但配合写使能的!full条件FIFO 不会写溢出。还要注意 full 和 empty 是用各自时钟域同步后的指针产生的所以它们必然有同步延迟。满信号是写时钟域的空信号是读时钟域的这个正确性不能跨时钟域用——读时钟域不能拿写时钟域的 full 信号做逻辑这是很多人容易踩的坑。3.4 用双口 RAM 还是分布式 RAM 的选型问题异步 FIFO 的存储体有两种实现选择。数据宽度窄、深度浅(比如 8bit x 16)用分布式 RAM 就好LUT 实现读延迟低。数据宽度大或者深度超过 32用 Block RAM否则 LUT 消耗太夸张。Xilinx 有 XPM_FIFO 原语Altera 有 scfifo IP这些是官方封装好的异步 FIFO。我个人的建议是第一次学习必须自己写一遍异步 FIFO 理解原理但进工程用官方 IP 或者成熟的开源实现因为自己写的 FIFO 在极端情况下的时序裕量很难验证全面。Xilinx 的 XPM_FIFO 只要正确配置了异步时钟、格雷码指针和握手模式在时序收敛方面比手写版本稳健得多。4. 时钟域数据通路的完整方案对比从握手到 DPRAM不同场景用不同武器4.1 慢速控制类数据的握手传输协议异步 FIFO 不是万能的。有些跨时钟域的数据量小、速率低比如配置寄存器、状态查询指令一次只传几个字节拉一个 FIFO 出来反而浪费资源。这种情况用握手协议更合适。握手的经典结构是发送域把数据放到总线上拉高 req 信号接收域看到 req 同步过来后锁存数据拉高 ack发送域看到 ack 后拉低 req接收域看到 req 拉低后拉低 ack完成一个传输周期。四相握手比两相握手多两个周期但有更宽的安全窗口工程上推荐四相。// 发送域状态机示意 module handshake_tx ( input wire clk_tx, input wire rst_n, input wire start, input wire [7:0] data_tx, output reg req, input wire ack_sync, output reg [7:0] data_bus ); localparam IDLE 2d0; localparam WAIT_A 2d1; localparam REQ_LO 2d2; reg [1:0] state; always (posedge clk_tx or negedge rst_n) begin if (!rst_n) begin state IDLE; req 1b0; data_bus 8d0; end else begin case (state) IDLE: begin if (start) begin data_bus data_tx; req 1b1; state WAIT_A; end end WAIT_A: begin if (ack_sync) begin req 1b0; state REQ_LO; end end REQ_LO: begin if (!ack_sync) state IDLE; end endcase end end endmodule握手协议的核心是数据必须在 req 有效期间保持稳定。接收域在检测到 req 有效后采数据发送域必须保证数据跨时钟域期间不变。所以握手协议的数据总线也要同步吗不需要因为数据由 req 通过两级同步器建立了稳定的采样窗口后接收域才去读数据总线。只要总线静态维持足够长的时间直接跨时钟域读也是安全的。注意总线数据多比特变化时要有先锁存再握手的习惯不要在总线上做叠加变化。握手协议的性能瓶颈是每个周期要经历两次同步延迟加 ack 往返所以吞吐率低适合控制类慢速传输不适合数据流。4.2 中高速数据流的 DPRAM 方案另一种方案是双端口 RAMDPRAM两个时钟域各有一个端口一个只管写一个只管读。DPRAM 本身没有空满概念读写互不干扰但需要额外的逻辑来判断这一帧数据写完没有什么时候可以开始读。常见做法是结合行计数frame同步写端写完一帧数据后在另一个跨时钟域信号里用一个 frame_done 脉冲读端收到后开始读取。这种方案的逻辑比异步 FIFO简单但要注意读端开始读之前写端绝对不能覆盖还没读走的数据否则就会出现新旧数据交织。所以 DPRAM 方案通常配一对读写指针快照同步或者帧号计数来保证安全。异步 FIFO 本质上是 DPRAM 指针逻辑 格雷码同步的整体方案所以能用异步 FIFO 的场景没必要手搓 DPRAM 调度。4.3 三类方案怎么选一张表说清楚方案数据特征吞吐率资源占用实现复杂度典型场景两级同步器单比特电平低极低极低状态信号、控制信号、中断请求脉冲同步器单比特脉冲低低低跨域脉冲触发、帧同步信号握手协议多比特慢速低-中低中寄存器配置、命令传输、状态回读异步 FIFO多比特连续流高中(存储器指针)高数据采集、图像处理、通信接口DPRAM多比特成帧数据中-高中中图像帧缓存、数据块搬运工程上经常看到有人把跨时钟域处理简单理解成加个异步 FIFO然后所有信号都往 FIFO 里塞。这是不对的。控制信号、状态信号硬塞 FIFO浪费存储不说还引入读写维护的复杂性反过来高速数据流不用 FIFO 而用手动握手吞吐率就会成为瓶颈。正确的做法是先把每个跨域信号的类型和速率摸清楚再按上表对号入座。5. 我在工程里踩过的 CDC 坑仿真通过不等于上板正确5.1 仿真环境里根本看不出亚稳态这是新手最容易误解的一点。默认情况下RTL 仿真里触发器是理想器件不模拟建立保持时间也不模拟亚稳态。你写了一个两级同步器去仿真波形永远整洁漂亮因为仿真器里根本没有中间电平塌缩的概念。那仿真到底在验什么仿真验证的是同步器的逻辑正确性和延迟链结构不是验证亚稳态本身。想要仿出亚稳态效果需要在仿真里做两件事。第一给异步信号加随机的相位抖动让它相对采样时钟随机偏移可以用$urandom加#delay的方式模拟。第二给触发器模型加$setup、$hold检查或者在约束文件里设置 false path、ASYNC_REG TRUE让工具知道这是跨时钟域信号。更接近真实情况的做法是用 SDFStandard Delay Format反标做门级仿真。把综合布局布线后的网表和延迟文件反标进去跑仿真亚稳态的行为会通过 X(未知态) 体现出来。这时你会看到两级同步器的中间节点出现 X但输出在 1-2拍后恢复稳定这其实就说明同步器工作正常。如果 X 传播到了业务逻辑里说明同步链长度不够或者扇出有问题。5.2 同步器输出被直接扇出到多路逻辑的埋伏同一个同步器的输出信号接进了状态机的跳转条件、接进了使能信号、还接进了一个计数器清零端。看起来没什么问题但实际布局布线后同步器输出到三个目的地有长有短可能在逻辑上产生 1-2 拍的偏差矛盾——这个信号有的路径上已经更新了有的还是旧值。这是同步器正确性的大忌同步器的输出必须只用作单一路径的控制信号不能多路扇出后参与相互约束的逻辑判断。正确的做法是在同步器后面加一个本地寄存器或者限定 fanout 约束让同步器输出先收拢到本地逻辑再分发到各个目的地或者干脆每个目的地各放一个同步器副本互不干扰。这里推荐使用 Vivado 的 ASYNC_REG 属性标记同步器寄存器同时用 MAX_FANOUT 约束限制扇出数。5.3 异步复位的跨时钟域处理和伪 CDC再坑的一点是复位。很多人写异步复位时直接从外部引脚把复位信号拉进所有寄存器完全没考虑复位释放的时刻和各个时钟沿的相对关系。复位释放时如果正好落在某个寄存器时钟沿附近那就是一个复位信号跨时钟域问题各寄存器可能有的释放了复位、有的还在复位态状态机直接错乱。解决手段是异步复位、同步释放。标准实现是先给原始异步复位信号打两拍把释放动作同步到目标时钟域再用这个同步后的信号做所有寄存器的异步复位端。注意Vivado 和 Quartus 对异步复位都有专门的约束属性和模板别自己拍脑袋写直接用工具生成的同步复位释放逻辑最稳。复位还有一个容易被误报为 CDC 的点如果两个时钟域是名义上异步、实际相位锁定的关系比如 MMCM 或 PLL 生成的两个同源时钟它们之间的信号不算真正异步工具可以在约束里通过 set_clock_groups 或者 set_false_path 明确说明这类信号就不需要做全套 CDC 处理只做简单打拍即可。5.4 拿到 CDC 报告后怎么读Vivado 在综合或实现后会产出一个 CDC 报告里面会列出所有跨时钟域的路径标记哪些已经做了同步处理、哪些是未约束的。新手拿到报告最容易犯的错是看到一条跨时钟域路径没加 sync 就紧张其实有些路径是设计上允许冒险的比如数据总线通过握手信号建立的安全窗口读取工具无法自动识别你需要手动加set_false_path或者set_bus_skew约束告诉工具这条路径我已经靠握手协议保证了安全性。反过来有些路径看着加了同步器但约束文件里忘了加ASYNC_REG TRUE工具会把它当普通时序路径去收敛同步器的两级间距可能被优化得不合理MTBF 大幅下降。我建议在实现完成之后直接在报告里搜索所有异步时钟交互路径逐一对照检查。6. 写在最后的经验清单从零开始接触跨时钟域设计我的体会是先理解亚稳态的物理本质再熟悉同步器和异步 FIFO 的结构最后在真实工程里反复对照 CDC 报告改进设计。这里分享几个我常用的检查习惯。第一新写的模块里只要有跨时钟域信号先给信号命名加上_async或_cdc后缀强迫自己在设计阶段就把问题暴露出来不要混在一起最后统一查。第二两级同步器省一个寄存器看似没什么但真正关键的控制路径上我见过有人只打了一拍就送进状态机这种设计在高温、低压、时序收敛差的条件下迟早出问题。关键信号打三拍都不嫌多多花的那一个寄存器换来的 MTBF 提升非常可观。打三拍的原因中间点如果亚稳态恢复时间异常长第三级还能兜底而且多级寄存器能滤掉一部分上游组合逻辑带进来的毛刺。第三异步 FIFO 调试时加 debug 探针看读写指针的格雷码同步值确认空满信号在边界条件下的行为我在 RTL 仿真里从 1/4 深度开始、用慢写快读和快写慢读两组模式分别扫一遍能在上板前拦住大部分指针位宽、边界条件的问题。第四做完实现后一定打开 CDC 报告认真过一遍特别是异步 FIFO 的指针同步路径是否被工具正确约束为 false path同步器寄存器是否被标记为 ASYNC_REG。跨时钟域处理是 FPGA 开发和软件思维差异最大的地方也是每一次在线调试疑难杂症的根源所在。把 CDC 这个硬骨头啃下来之后多时钟域设计、多端口 DDR 读写、PCIe 接口、高速串行收发器这些复杂工程才有扎实的基础后续再往高性能、复杂 IP 集成的方向走才不至于天天被偶发 bug 牵着鼻子跑。
返回列表