4、异步FIFO设计:双时钟域下的FIFO架构、格雷码转换、同步器链
异步FIFO,说白了就是用来在两个不同时钟域之间安全传数据的。我刚开始做FPGA那会儿,总觉得这东西不就是个存储嘛,有啥难的?直到第一次遇到亚稳态问题,数据传着传着就乱了,查了三天才找到原因。嗯,从那以后我再也不敢小看异步FIFO的设计了。
4.1 双时钟域下的FIFO架构
异步FIFO的核心架构其实不复杂。它有两个时钟域:写时钟域和读时钟域。每个时钟域都有自己的指针——写指针和读指针。
我习惯把架构分成三块来看:
- 存储体(Dual-Port RAM):真正的数据存放地,一边写一边读
- 指针生成逻辑:写时钟域产生写地址,读时钟域产生读地址
- 空满判断逻辑:这是最关键的,也是最容易出问题的部分
你想想看,写指针在写时钟域里跑,读指针在读时钟域里跑。要判断FIFO是空还是满,就得把两个指针放到同一个时钟域里比较。这就引出了同步的问题。
关键点:异步FIFO的设计难点不在于存储,而在于如何安全地传递指针信号,避免亚稳态。
4.2 格雷码转换——为什么非它不可?
直接传二进制指针行不行?我试过,结果很惨。二进制指针在递增时,可能多位同时变化。比如从3(011)变到4(100),三位全变了。如果同步器刚好在这个节骨眼上采样,读到的可能是个乱七八糟的值。
格雷码就不一样了。它每次只变化一位。从3(0010)变到4(0110),只有一位变化。这样即使同步器采样到中间状态,最多也就是旧值或者新值,不会出现完全错误的值。
二进制转格雷码的公式很简单:
// 二进制转格雷码
gray = (binary >> 1) ^ binary;
// 格雷码转二进制(用于读指针比较)
binary[MSB] = gray[MSB];
for(i = MSB-1; i >= 0; i = i-1)
binary[i] = binary[i+1] ^ gray[i];
我在项目中遇到过一个问题:格雷码虽然解决了多位变化的问题,但它不能直接用来寻址RAM。所以实际设计中,指针在各自时钟域内还是用二进制,只在跨时钟域同步时才转成格雷码。
小技巧:格雷码的深度必须是2的幂次。如果你的FIFO深度不是2的幂,比如10,那就得用16的深度,然后做地址回绕处理。我一般直接选2的幂次深度,省心。
4.3 同步器链——两级还是三级?
同步器链,说白了就是一连串的寄存器,用来降低亚稳态的概率。最常见的做法是两级同步器。
为什么会这样?因为亚稳态虽然不可避免,但经过一级寄存器后,信号稳定下来的概率已经很高了。再加一级,基本就稳了。我个人的习惯是:
- 普通应用:两级同步器就够了
- 高速或高可靠性场景:用三级同步器
- 极端环境(比如航天):可能会用到四级甚至五级
同步器链的代码长这样:
// 两级同步器,将写指针同步到读时钟域
reg [ADDR_WIDTH:0] wr_ptr_gray_sync1;
reg [ADDR_WIDTH:0] wr_ptr_gray_sync2;
always @(posedge rd_clk or negedge rst_n) begin
if (!rst_n) begin
wr_ptr_gray_sync1 <= 0;
wr_ptr_gray_sync2 <= 0;
end else begin
wr_ptr_gray_sync1 <= wr_ptr_gray; // 第一级
wr_ptr_gray_sync2 <= wr_ptr_gray_sync1; // 第二级
end
end
注意:同步器链的每一级寄存器必须用同一个时钟域。我曾经见过有人把两级寄存器分别放在两个时钟域里,结果同步了个寂寞。另外,同步器链会增加延迟,这个延迟会影响空满判断的实时性。
4.4 空满判断——最容易被坑的地方
空满判断的逻辑其实不复杂,但细节很多。我直接说结论:
| 判断条件 | 说明 |
|---|---|
| 空标志 | 同步后的写指针 == 读指针(格雷码比较) |
| 满标志 | 写指针的格雷码 == 同步后的读指针格雷码取反(最高两位取反) |
为什么要取反?因为满的时候,写指针比读指针多绕了一圈。格雷码下,绕一圈的标志就是最高位和次高位都取反。举个例子:深度为8的FIFO,写指针绕了一圈回到0,读指针还在0,这时候是满的。但格雷码比较时,0和0相等,会误判为空。所以满标志的判断要特殊处理。
我曾经犯过一个错误:空满判断用的指针位宽不够。深度为16的FIFO,地址需要4位,但指针需要5位(多一位用来标记回绕)。结果FIFO深度用到一半就报满了,查了半天才发现是位宽问题。
避坑指南:指针位宽 = log2(深度) + 1。多出来的那一位用来区分空和满。这个+1很容易忘,我吃过亏。
4.5 完整设计流程
我总结一下异步FIFO的设计步骤:
- 确定深度和位宽:深度选2的幂次,位宽根据数据决定
- 设计双口RAM:写时钟域写,读时钟域读
- 生成二进制指针:各自时钟域内递增
- 转格雷码:二进制转格雷码,准备跨时钟域
- 同步器链同步:写指针同步到读时钟域,读指针同步到写时钟域
- 格雷码转二进制:同步后的格雷码转回二进制(用于比较)
- 空满判断:根据比较结果生成空/满标志
嗯,这套流程我用了十几年,基本没出过问题。你刚开始做的时候,建议先仿真验证一下空满标志的时序,别急着上板。异步FIFO的仿真比同步FIFO难搞,因为跨时钟域的时序仿真工具不一定能准确模拟亚稳态。我一般会在仿真里故意加一些时钟抖动,看看空满标志会不会误判。
最后说一句:如果你用的是Xilinx或Altera的FPGA,它们自带的FIFO IP核其实已经处理好了这些问题。但作为工程师,理解底层原理还是很有必要的。毕竟,出了问题你得知道怎么查,对吧?