ARTICLE DETAIL

资讯详情

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

FPGA跨时钟域设计:亚稳态原理与异步FIFO实现

FPGA跨时钟域设计:亚稳态原理与异步FIFO实现 1. 跨时钟域问题的本质与工程背景1.1 为什么单时钟设计思维会在多时钟系统里翻车做 FPGA 开发到一定阶段几乎所有人都会撞上同一堵墙设计里出现了两个或更多时钟数据从一个时钟域传到另一个时钟域仿真看着没问题上板跑起来却偶尔出错而且越是温度变化、电压波动的时候越容易出问题。这个现象背后就是**跨时钟域CDCClock Domain Crossing**问题。在单时钟域里所有触发器都由同一个时钟沿驱动建立时间和保持时间的关系是确定的工具做时序分析时能给出明确的裕量。但一旦信号从时钟 A 域进入时钟 B 域两个时钟之间的相位关系是不确定的——它们可能同频不同相可能频率完全无关也可能一个快一个慢。这时候如果直接把 A 域触发器的输出接到 B 域触发器的输入B 域触发器采样时可能正好采在信号跳变的那一瞬间采到的值既不是稳定的高电平也不是稳定的低电平而是一个介于两者之间的中间态。这就是亚稳态Metastability。亚稳态不是错误值而是不确定值。它会在某个时刻随机地稳定到高或低但稳定所需的时间是不确定的。如果这个不确定时间超过了下一级触发器的建立时间窗口错误就会像多米诺骨牌一样传播下去。很多新手会问我仿真跑了几十万个周期都没事啊问题就在这里——亚稳态是概率事件仿真里两个时钟的相位关系往往是固定或简单可预测的而真实硬件里时钟抖动、温度漂移会让相位关系不断变化某些危险相位迟早会出现。1.2 CDC 问题的三种典型场景实际工程中跨时钟域传输大致分三类处理方式完全不同单比特控制信号跨时钟域比如一个时钟域产生的使能、复位、握手请求传到另一个时钟域。这类信号的关键是只关心电平或边沿不关心持续多少个周期。多比特数据总线跨时钟域比如一个时钟域采集到的 ADC 数据要送到另一个时钟域处理。这类最麻烦因为多根线到达时间不一致可能采到半新半旧的组合值。数据流跨时钟域比如高速采集域要把连续数据流送到低速处理域或者反过来。这类通常用 FIFO 来缓冲而异步 FIFO 就是专门解决这个问题的结构。我见过太多项目在 CDC 上栽跟头图像处理里像素时钟和系统时钟之间没处理好画面偶尔撕裂高速 ADC 采样域到 DDR 写入域之间直接连总线数据偶尔错位串口接收域到系统域之间用两级触发器同步了一个多比特计数器结果计数值偶尔跳变。这些问题在实验室常温下可能几天才复现一次到了现场就是灾难。1.3 本篇要解决的核心问题这一篇的目标很明确把 CDC 的底层原理讲透把亚稳态的量化分析讲清楚然后手把手带你设计一个真正能用的异步 FIFO。异步 FIFO 是 CDC 领域最经典、最实用的结构掌握了它你就能处理绝大多数跨时钟域的数据流问题。我会用 Vivado 作为开发环境但原理部分与工具无关换成其他平台一样适用。需要提前说明的是异步 FIFO 的设计有几个魔鬼细节空满判断怎么做到不误判、格雷码为什么能解决多比特同步问题、指针位宽为什么要多一位、复位怎么处理。这些细节网上很多教程一笔带过但恰恰是上板出问题的根源。我会逐个拆开讲。2. 亚稳态的量化分析与同步器设计2.1 亚稳态到底能持续多久要理解同步器为什么有效得先知道亚稳态的持续时间服从什么规律。触发器进入亚稳态后输出稳定到合法电平所需的时间近似服从指数分布工程上用**平均无故障时间MTBFMean Time Between Failures**来衡量MTBF e^(t_r / τ) / (T_0 × f_clk × f_data)其中 t_r 是留给触发器稳定的时间也就是两级触发器之间可用的一整个时钟周期减去建立时间等开销τ 和 T_0 是工艺相关的常数f_clk 是采样时钟频率f_data 是异步数据的变化频率。这个公式的物理含义很直观留给稳定的时间 t_r 越长MTBF 指数级增长采样时钟越快、数据变化越频繁MTBF 越短。举个量级上的例子。在常见工艺下τ 大约在几十皮秒量级T_0 在几十飞秒量级。如果只用一个触发器采样t_r 很小MTBF 可能只有几秒到几分钟根本不能用。加上第二级触发器后t_r 变成接近一个完整时钟周期MTBF 可以轻松达到几万年甚至更高。这就是为什么两级触发器同步器是 CDC 单比特信号的最低配置。注意MTBF 是统计量不是说几万年才出一次错就绝对安全。它只是把出错概率压到工程可接受的范围。对可靠性要求极高的场合比如医疗、航天可能需要三级触发器。2.2 两级与三级同步器的选择依据两级同步器是绝大多数场景的默认选择。它的结构很简单第一级触发器负责承受亚稳态第二级触发器负责过滤——只要第一级在第二个时钟沿到来前稳定下来第二级采到的就是干净的值。关键在于两级触发器之间不能插入任何组合逻辑否则会破坏 t_r 的可用时间。三级同步器什么时候用两种情况一是采样时钟频率非常高比如几百 MHz 以上单周期 t_r 不够二是对 MTBF 要求极高。多一级触发器就是把 t_r 再延长一个周期MTBF 指数级提升。代价是多一个周期的延迟对控制信号通常无所谓。这里有个常见误区有人觉得同步器级数越多越安全于是无脑上四级五级。其实没必要两级已经能把 MTBF 做到工程可接受范围多加级数只是徒增延迟和资源。真正该关注的是同步器本身不能被综合工具优化掉以及同步器后面不能扇出到组合逻辑再进触发器。2.3 同步器的代码实现与综合属性一个标准的两级同步器写法如下module cdc_sync #( parameter WIDTH 1, parameter STAGES 2 )( input wire clk_dst, input wire rst_n, input wire [WIDTH-1:0] async_in, output wire [WIDTH-1:0] sync_out ); (* ASYNC_REG TRUE *) reg [WIDTH-1:0] sync_reg [STAGES-1:0]; integer i; always (posedge clk_dst or negedge rst_n) begin if (!rst_n) begin for (i 0; i STAGES; i i 1) sync_reg[i] {WIDTH{1b0}}; end else begin sync_reg[0] async_in; for (i 1; i STAGES; i i 1) sync_reg[i] sync_reg[i-1]; end end assign sync_out sync_reg[STAGES-1]; endmodule这段代码里最关键的是(* ASYNC_REG TRUE *)这个综合属性。它的作用是告诉 Vivado这些寄存器是同步器链请把它们放在相邻的 slice 里并且不要做任何可能破坏同步器结构的优化。如果不加这个属性工具可能把两级触发器拆开布局走线延迟一大t_r 就被吃掉了MTBF 直接掉几个数量级。实操心得ASYNC_REG 属性一定要加在第一级和第二级触发器上只加第一级没用。另外同步器的复位最好用目标时钟域的同步复位不要用异步复位直接打否则复位释放时又引入一个 CDC 问题。2.4 边沿检测与脉冲同步单比特电平信号用两级同步器就够了但如果源时钟域产生的是一个单周期脉冲直接同步会出问题——脉冲宽度可能小于目标时钟周期同步器根本采不到。这时候需要脉冲同步器思路是把脉冲转成电平翻转同步过去后再还原成脉冲。具体做法源域用一个触发器每来一个脉冲就翻转一次输出电平T 触发器结构。这个电平信号经过两级同步器进入目标域目标域再用一个触发器打一拍两级做异或就能还原出单周期脉冲。这个结构对脉冲间隔有要求两个脉冲之间至少要间隔目标域两到三个时钟周期否则会漏脉冲。// 源域脉冲转电平翻转 always (posedge clk_src or negedge rst_n) if (!rst_n) toggle 1b0; else if (pulse_in) toggle ~toggle; // 目标域同步后还原脉冲 cdc_sync u_sync(.clk_dst(clk_dst), .rst_n(rst_n), .async_in(toggle), .sync_out(toggle_sync)); always (posedge clk_dst or negedge rst_n) if (!rst_n) {toggle_sync_d1, pulse_out} 2b0; else {toggle_sync_d1, pulse_out} {toggle_sync, toggle_sync_d1}; assign pulse_out toggle_sync ^ toggle_sync_d1;这个结构我在串口接收、按键消抖、外部中断触发等场景反复用过非常稳。唯一要注意的是脉冲间隔如果源域脉冲来得太密目标域来不及还原就会丢脉冲。这种情况要么降低脉冲频率要么改用 FIFO 传递。3. 异步 FIFO 的核心设计思路3.1 为什么多比特不能用同步器直接打多比特数据跨时钟域最忌讳的就是每根线各挂一个两级同步器。原因很简单同步器有延迟而且每根线的延迟可能差一个周期。假设一个 8 位数据从 0x7F 变成 0x80如果高位先到、低位后到目标域可能先采到 0xFF 再采到 0x80中间那个 0xFF 就是完全错误的组合值。这种错误在数据总线上是致命的。解决多比特 CDC 有两条路一是握手协议源域把数据放稳后发请求目标域确认收到后源域才改数据保证数据在传输期间不变二是异步 FIFO用双端口存储加读写指针天然隔离两个时钟域。握手协议适合低频、偶发的数据传输异步 FIFO 适合连续数据流。这一篇重点讲异步 FIFO。3.2 异步 FIFO 的整体架构异步 FIFO 的核心组成双端口 RAM一端接写时钟一端接读时钟数据存储在这里。写指针逻辑在写时钟域维护指向下一个要写的位置。读指针逻辑在读时钟域维护指向下一个要读的位置。空满判断逻辑这是整个设计的灵魂也是最容易出错的地方。难点在于写指针在写域读指针在读域要判断空和满必须让两个域都能看到对方的指针。但指针是多比特的直接跨域又会遇到前面说的多比特同步问题。异步 FIFO 的巧妙之处在于指针用格雷码编码格雷码每次只变一位这样跨域同步时最多只有一位在变不会出现中间错误组合值。3.3 格雷码为什么能解决指针同步问题格雷码的特性是相邻两个码字之间只有一位不同。比如二进制 70111到 81000要变四位而格雷码对应的 0100 到 1100 只变一位。指针每次加一格雷码只翻转一位那么跨域同步时即使采样时刻正好在跳变瞬间采到的要么是旧值要么是新值绝不会是中间的错误值。这里有个关键前提指针必须按格雷码顺序递增也就是每次只加一。如果指针跳跃式变化比如一次加多格雷码就可能变多位特性就失效了。所以异步 FIFO 的指针永远是加一操作这也是为什么 FIFO 深度通常取 2 的幂次——方便指针回绕。还有一个细节指针位宽要比实际地址多一位。比如深度 16 的 FIFO地址需要 4 位但指针要用 5 位。多出来的最高位用来区分绕了一圈和没绕圈。判断满的时候写指针比读指针多绕一圈最高位不同其余位相同就是满判断空的时候读写指针完全相同就是空。这个设计非常精妙下面会详细展开。3.4 空满判断的精确逻辑先定义指针写指针 wptr 和读指针 rptr 都是 N1 位N 是地址位宽。判断规则空wptr 和 rptr 完全相同包括最高位。满wptr 和 rptr 的最高位不同但其余位相同。为什么满的判断是这样假设 FIFO 深度 16地址 4 位指针 5 位。写指针从 0 写到 15 时最高位是 0再写一个回到 0 时最高位变 1表示绕了一圈。此时如果读指针还停在 0最高位 0说明写指针比读指针多绕了一圈FIFO 满了。用公式表示满 (wptr[N] ! rptr[N]) (wptr[N-1:0] rptr[N-1:0])。但这里有个陷阱判断满用的是读指针而读指针在读时钟域写域要用它必须先同步过来。同步过来的读指针有延迟可能比实际值旧。这会导致什么写域以为还没满实际可能已经满了继续写就会覆盖未读数据。所以异步 FIFO 的满判断是保守的——它可能提前报满但绝不会漏报满。同理空判断也是保守的可能提前报空但绝不会漏报空。这种保守性正是异步 FIFO 安全的关键。4. 异步 FIFO 的完整实现与 Vivado 实操4.1 双端口 RAM 的推断与配置异步 FIFO 的存储用双端口 RAM 实现一个端口写、一个端口读各自独立时钟。在 Vivado 里可以用两种方式一是用 IP 核生成Block Memory Generator二是用 Verilog 推断让工具自动识别。我倾向于用推断方式代码可移植性好换平台不用重新配 IP。module dp_ram #( parameter DATA_WIDTH 8, parameter ADDR_WIDTH 4 )( input wire wr_clk, input wire wr_en, input wire [ADDR_WIDTH-1:0] wr_addr, input wire [DATA_WIDTH-1:0] wr_data, input wire rd_clk, input wire [ADDR_WIDTH-1:0] rd_addr, output reg [DATA_WIDTH-1:0] rd_data ); localparam DEPTH 1 ADDR_WIDTH; reg [DATA_WIDTH-1:0] mem [DEPTH-1:0]; always (posedge wr_clk) begin if (wr_en) mem[wr_addr] wr_data; end always (posedge rd_clk) begin rd_data mem[rd_addr]; end endmodule这段代码推断出来的是简单双端口 RAMSimple Dual Port一个写口一个读口。注意读数据是带寄存器输出的读地址打一拍后数据才出来这个延迟在后续逻辑里要考虑进去。如果不想有这一拍延迟可以把读改成组合输出但那样时序会差一些高速设计不推荐。注意事项Vivado 推断 RAM 时如果读数据没有寄存工具可能推断成分布式 RAM 而不是 Block RAM资源消耗和时序都会变差。所以读数据一定要打一拍让它推断成 Block RAM。4.2 指针与格雷码转换逻辑指针逻辑分写域和读域两部分。写域维护二进制写指针和格雷码写指针读域维护二进制读指针和格雷码读指针。二进制指针用于地址计算格雷码指针用于跨域同步。// 二进制转格雷码 function [PTR_WIDTH-1:0] bin2gray; input [PTR_WIDTH-1:0] bin; begin bin2gray bin ^ (bin 1); end endfunction // 写域指针 always (posedge wr_clk or negedge rst_n) begin if (!rst_n) begin wbin 0; wgray 0; end else if (wr_en !wfull) begin wbin wbin 1b1; wgray bin2gray(wbin 1b1); end end // 读域指针 always (posedge rd_clk or negedge rst_n) begin if (!rst_n) begin rbin 0; rgray 0; end else if (rd_en !rempty) begin rbin rbin 1b1; rgray bin2gray(rbin 1b1); end end注意格雷码转换是组合逻辑但它的输入是二进制指针输出直接进触发器所以不会引入毛刺问题。这里有个细节wgray bin2gray(wbin 1b1)用的是下一个值的格雷码这样指针和格雷码始终同步更新不会出现指针变了格雷码没变的中间态。4.3 跨时钟域同步与空满生成写域的读指针同步把读域的格雷码读指针用两级同步器同步到写域然后判断满。读域的写指针同步同理。// 写域同步读指针判断满 wire [PTR_WIDTH-1:0] rgray_sync_w; cdc_sync #(.WIDTH(PTR_WIDTH), .STAGES(2)) u_sync_r2w ( .clk_dst(wr_clk), .rst_n(rst_n), .async_in(rgray), .sync_out(rgray_sync_w) ); assign wfull_val (wgray {~rgray_sync_w[PTR_WIDTH-1], rgray_sync_w[PTR_WIDTH-2:0]}); always (posedge wr_clk or negedge rst_n) if (!rst_n) wfull 1b0; else wfull wfull_val; // 读域同步写指针判断空 wire [PTR_WIDTH-1:0] wgray_sync_r; cdc_sync #(.WIDTH(PTR_WIDTH), .STAGES(2)) u_sync_w2r ( .clk_dst(rd_clk), .rst_n(rst_n), .async_in(wgray), .sync_out(wgray_sync_r) ); assign rempty_val (rgray wgray_sync_r); always (posedge rd_clk or negedge rst_n) if (!rst_n) rempty 1b1; else rempty rempty_val;满判断的表达式{~rgray_sync_w[MSB], rgray_sync_w[MSB-1:0]}就是最高位取反其余位相同。这个表达式直接对格雷码操作等价于二进制下写指针比读指针多绕一圈。用格雷码直接比较的好处是不用先转回二进制省逻辑。空判断更简单格雷码相等就是空。但要注意同步过来的写指针有延迟所以空判断是保守的——可能实际还有数据但同步值显示空。这没问题读域等下一拍再判断即可不会读错数据。4.4 顶层模块整合与端口定义把上面几块拼起来加上读写使能的握手逻辑就是完整的异步 FIFOmodule 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 wr_full, input wire rd_clk, input wire rd_rst_n, input wire rd_en, output wire [DATA_WIDTH-1:0] rd_data, output wire rd_empty ); localparam PTR_WIDTH ADDR_WIDTH 1; // ... 内部信号与上面各模块例化 endmodule顶层端口设计有个要点读写复位分开。因为两个时钟域独立复位也应该各自同步到自己的时钟域。如果共用一个异步复位释放时可能又引入 CDC 问题。实际项目中我通常给每个域一个独立的同步复位。4.5 Vivado 综合与时序约束代码写完后Vivado 综合前要加时序约束。异步 FIFO 的约束关键是声明两个时钟为异步关系否则工具会尝试分析它们之间的时序路径报一堆无法满足的违例。create_clock -name wr_clk -period 10.0 [get_ports wr_clk] create_clock -name rd_clk -period 7.5 [get_ports rd_clk] set_clock_groups -asynchronous \ -group [get_clocks wr_clk] \ -group [get_clocks rd_clk]set_clock_groups -asynchronous告诉工具这两个时钟域之间的路径不用做时序分析因为我们已经用同步器处理了。但要注意这个约束是信任声明——工具不再检查所以同步器必须写对否则工具不会帮你兜底。综合后要检查两点一是同步器寄存器是否被正确识别在综合报告里搜 ASYNC_REG二是 Block RAM 是否被正确推断看资源利用率报告。如果 RAM 没推断出来检查读数据是否寄存、地址位宽是否匹配。5. 常见问题排查与实战避坑5.1 空满标志异常的问题定位异步 FIFO 上板后最常见的问题是空满标志异常明明没写几个数就报满或者明明有数据却报空。排查思路如下现象可能原因排查方法一写就满满判断表达式写错检查最高位取反逻辑一直不空空判断用了二进制指针确认比较的是格雷码空满抖动同步器级数不够增加到三级试试数据错位RAM 读写地址时序不对检查读数据延迟拍数我踩过最坑的一次是满判断表达式写成了wgray rgray_sync_w结果 FIFO 永远不满写指针绕圈覆盖了未读数据。仿真时因为读写速度匹配没暴露上板后高速写入立刻出错。所以满判断的表达式一定要反复核对。5.2 复位释放顺序引发的隐蔽 Bug异步 FIFO 有两个时钟域复位释放顺序不对会出问题。假设写域复位先释放读域复位后释放那么写域可能已经开始写数据而读域的指针还是复位值同步到写域的读指针是 0写域以为 FIFO 空一直写直到写指针绕一圈才报满——这期间数据可能已经被覆盖。正确做法是复位释放要同步到各自时钟域并且保证两个域的复位释放时间足够接近。更稳妥的做法是加一个复位同步模块让两个域的复位在各自时钟下同步释放。如果系统允许也可以让 FIFO 在两个域都复位完成后再开始工作。5.3 仿真验证的关键测试用例异步 FIFO 的 testbench 要覆盖几个关键场景写满测试连续写直到满检查满标志是否在正确位置拉高。读空测试连续读直到空检查空标志。同时读写读写同时进行检查数据顺序是否正确。时钟频率比变化写快读慢、写慢读快都测一遍。复位测试读写过程中复位检查恢复后状态。testbench 里两个时钟要用不同的周期并且相位关系要随机化才能暴露 CDC 问题。我通常用forever #(period/2) clk ~clk;生成时钟两个时钟的 period 取互质数比如 10ns 和 7ns这样相位关系会不断变化覆盖更多危险时刻。initial begin wr_clk 0; forever #5 wr_clk ~wr_clk; // 10ns 周期 end initial begin rd_clk 0; forever #3.5 rd_clk ~rd_clk; // 7ns 周期 end5.4 资源与时序的权衡经验异步 FIFO 的资源消耗主要在 Block RAM 和同步器上。深度越大RAM 消耗越多同步器级数越多触发器消耗越多。实际项目中我的经验是深度按最坏情况下的数据积压量来定一般取 2 的幂次留 20% 余量。同步器默认两级时钟超过 200MHz 考虑三级。数据位宽大时考虑用 Block RAM 的级联模式避免用分布式 RAM。如果 FIFO 深度很小比如 4 或 8可以用寄存器阵列代替 Block RAM省资源。时序方面异步 FIFO 本身的时序压力不大因为两个域独立。真正要关注的是同步器路径和 RAM 的读写路径。同步器路径因为跨域工具不做分析但要保证布局合理ASYNC_REG 属性。RAM 读写路径在各自域内正常约束即可。6. 从异步 FIFO 延伸到更复杂的 CDC 场景6.1 多比特握手机制的适用场景异步 FIFO 适合数据流但有些场景用握手更合适。比如配置寄存器跨时钟域更新数据量小、更新不频繁用握手协议比 FIFO 简单。握手的基本流程源域把数据放稳拉高 req目标域同步 req 后采数据拉高 ack源域同步 ack 后撤销 req。这个流程保证数据在传输期间不变。握手协议的关键是 req 和 ack 都要用两级同步器并且状态机要处理超时和异常。我见过有人握手协议没做超时保护目标域如果因为复位没响应源域就永远卡住。所以握手一定要加超时计数器超时后强制复位。6.2 数据流控与背压设计异步 FIFO 用在实际系统里往往还要配流控。比如写域产生数据太快FIFO 满了要能反压上游读域消费太慢FIFO 空了要能通知下游。这些流控信号本身也是跨时钟域的需要同步处理。一个常见的设计是FIFO 的满信号同步回写域的上游模块作为写使能的门控空信号同步到读域的下游模块作为读请求的暂停条件。注意这些同步都要用两级触发器并且要考虑同步延迟带来的保守性——满信号同步回上游时FIFO 可能已经不满但上游还认为满这会降低吞吐但不会出错。6.3 验证 CDC 设计的工具与方法Vivado 提供了 CDC 检查功能在综合或实现后可以生成 CDC 报告列出所有跨时钟域路径和同步器识别情况。这个报告一定要看它能发现你遗漏的 CDC 路径。报告里如果出现unsynchronized的路径说明那条路径没有同步器要么补同步器要么加约束声明为 false path。除了工具检查仿真时可以用随机化激励和断言SVA来验证。比如给 FIFO 加断言满的时候不能写、空的时候不能读、读写指针不能越界。这些断言在仿真里能提前发现逻辑错误比上板调试高效得多。6.4 工程清理与版本管理建议CDC 设计调试过程中会产生大量综合、实现、仿真文件Vivado 工程目录很容易膨胀到几个 GB。我的习惯是定期清理保留源码、约束、testbench 和最终比特流中间产物全部删掉。Vivado 的reset_project命令可以清理综合和实现结果但不会删源码。版本管理方面建议把源码、约束、脚本纳入版本控制工程文件.xpr和中间产物不纳入。这样换机器或换 Vivado 版本时用脚本重建工程即可。我通常写一个create_project.tcl脚本把所有源文件和约束加进去一键重建比手动操作可靠得多。异步 FIFO 这个结构我从入门到现在用了无数次每次重新写都会有新的体会。最开始照抄网上的代码能跑但不知道为什么后来自己推导空满逻辑才真正理解格雷码和指针位宽的设计意图再后来在高速项目里遇到 MTBF 不够的问题才明白同步器级数和布局的重要性。CDC 是 FPGA 设计里少数几个仿真通过不代表正确的领域必须从原理上理解才能写出真正可靠的设计。
返回列表