)
做FPGA和數(shù)字IC設計的人幾乎沒有繞開過FIFO的。不管是圖像數(shù)據(jù)流的跨時鐘域處理、以太網(wǎng)包緩存、還是CPU與外設之間的數(shù)據(jù)交互你總能碰到這個長得不太起眼、卻無處不在的緩沖結構。我剛接觸硬件設計那會兒總覺得FIFO不過是一個“先進先出的隊列”把它當成一個現(xiàn)成的IP核調一下、接上讀寫端口就完事了。后來真正開始自己動手搭RTL、調試異步FIFO的空滿信號、甚至為了一個假滿問題折騰了兩天之后才意識到這個模塊的“水”比想象中深得多。這篇文章我就想把自己的理解完整梳理一遍FIFO到底是怎么工作的為什么它能解決跨時鐘域的數(shù)據(jù)傳輸問題同步FIFO和異步FIFO在設計思路上有什么本質區(qū)別以及當你需要自己寫一個FIFO的Verilog代碼時哪些核心細節(jié)決定了它能不能在工程中穩(wěn)定跑起來。適合剛入門FPGA、數(shù)字IC驗證、以及想把手寫FIFO用在實際項目里的朋友我會盡量用大白話把原理和代碼拆開講透。1. FIFO到底解決什么問題1.1 從數(shù)據(jù)結構到硬件模塊我們在軟件課里學到的FIFOFirst In First Out描述的是一個數(shù)據(jù)緩沖隊列最先寫入的數(shù)據(jù)最先被讀出就像排隊買東西一樣先來的人先結賬。這個邏輯在硬件世界里同樣成立只是它不再是一個抽象的數(shù)據(jù)結構而是由一組存儲單元、讀寫指針、狀態(tài)邏輯共同構成的真實電路。硬件FIFO的核心組成可以拆成幾塊存儲體通常用雙口RAM或者寄存器陣列實現(xiàn)、寫指針、讀指針以及空/滿狀態(tài)的判斷邏輯。數(shù)據(jù)從寫端口進來寫到寫指針指向的地址寫完寫指針加一數(shù)據(jù)從讀端口出去從讀指針指向的地址讀出讀完讀指針加一。當寫指針追上讀指針說明存儲區(qū)被填滿當讀指針追上寫指針說明存儲區(qū)被清空。但真正讓FIFO在硬件設計里“封神”的并不是它的隊列特性而是它能天然地橫跨兩個不同的時鐘域。比如一個ADC采集模塊工作在50MHz一個總線接口工作在150MHz兩邊沒法直接相連中間塞一個FIFO兩邊各寫各的、各讀各的互不干擾。這就是同步FIFO和異步FIFO在實際項目中最重要的應用場景數(shù)據(jù)緩沖和時鐘域隔離。1.2 為什么不用寄存器堆直接接有人可能會問不就是兩個模塊之間傳數(shù)據(jù)嗎我在中間放一堆寄存器用握手信號挨個傳不行嗎當然行但代價很高。如果數(shù)據(jù)速率是400Mbps每次傳輸都要握手應答那握手信號本身的建立保持時間約束、跨時鐘域打拍延遲會嚴重拉低吞吐量。而且當數(shù)據(jù)的產生和消費速率不完全匹配時中間任何一個環(huán)節(jié)稍微堵塞立刻會丟數(shù)據(jù)或者卡住上游。FIFO的好處在于它把“存儲”和“流控”綁在了一起。只要FIFO不滿寫端就可以持續(xù)往里面灌數(shù)據(jù)只要FIFO不空讀端就可以持續(xù)往外取數(shù)據(jù)。它的滿信號天然就是一個反壓信號告訴上游“你先停一停我消化不過來”它的空信號天然就是一個就緒信號告訴下游“我已經有貨了你可以來取”。這種“自我調理”的能力讓FIFO幾乎成了跨時鐘域傳輸?shù)哪J答案。2. 同步FIFO的核心原理與Verilog實現(xiàn)2.1 同步FIFO的讀寫指針與空滿判斷所謂同步FIFO就是讀寫兩端共用一個時鐘只是數(shù)據(jù)速率或節(jié)奏可能不同。比如某個模塊在時鐘上升沿連續(xù)寫入8個數(shù)據(jù)然后暫停另一個模塊在同一時鐘下慢慢讀出。因為共同時鐘指針變化天然同步空滿判斷比較簡單。用計數(shù)器的方式來理解最直觀寫指針指向下一個要寫入的地址讀指針指向下一個要讀出的地址。當寫操作發(fā)生且FIFO未滿寫指針加一當讀操作發(fā)生且FIFO未空讀指針加一當讀和寫同時發(fā)生時保持一個數(shù)據(jù)條目被抵消。要判斷空還是滿最直接的方法是維護一個計數(shù)器寫入時加一讀出時減一讀寫同時發(fā)生時不變計數(shù)器為0表示空計數(shù)器等于深度表示滿。這個方案在小型FIFO里完全夠用代碼清晰、邏輯簡單、仿真也容易驗證。但當你把FIFO深度做到1024、4096甚至更大用二進制計數(shù)器的組合邏輯去算空滿信號路徑延遲可能會變得很難看。更常見的做法是直接比較讀寫指針讀指針等于寫指針時可能空也可能滿需要額外加一個附加位來區(qū)分。比如把指針位寬擴展一位最高位不同的同時低幾位相同就表示滿完全相等則表示為空。2.2 一個可直接使用的同步FIFO代碼下面我寫一個參數(shù)化、帶計數(shù)器的同步FIFO這個結構在中小規(guī)模數(shù)據(jù)緩存里很實用代碼結構也適合做二次修改module sync_fifo #( parameter DATA_WIDTH 32, parameter DEPTH 16 )( input wire clk, input wire rst_n, input wire wr_en, input wire [DATA_WIDTH-1:0] wr_data, input wire rd_en, output reg [DATA_WIDTH-1:0] rd_data, output wire full, output wire empty ); localparam ADDR_WIDTH $clog2(DEPTH); reg [ADDR_WIDTH:0] cnt; reg [DATA_WIDTH-1:0] mem [0:DEPTH-1]; reg [ADDR_WIDTH-1:0] wr_ptr; reg [ADDR_WIDTH-1:0] rd_ptr; assign full (cnt DEPTH); assign empty (cnt d0); always (posedge clk or negedge rst_n) begin if (!rst_n) begin wr_ptr d0; rd_ptr d0; cnt d0; end else begin case ({wr_en ~full, rd_en ~empty}) 2b10: cnt cnt 1b1; 2b01: cnt cnt - 1b1; default: cnt cnt; endcase if (wr_en !full) begin mem[wr_ptr] wr_data; wr_ptr wr_ptr 1b1; end if (rd_en !empty) begin rd_data mem[rd_ptr]; rd_ptr rd_ptr 1b1; end end end endmodule這段代碼有幾個細節(jié)值得注意。寫入條件的wr_en !full和計數(shù)器的更新條件保持一致避免出現(xiàn)“計數(shù)器覺得沒滿但寫入指針已經溢出”的時序風險。讀出數(shù)據(jù)用了寄存輸出雖然多了一拍延遲但能夠有效避免在同一個周期內讀指針變化導致讀數(shù)據(jù)不穩(wěn)定的問題。如果項目對讀延遲敏感可以把rd_data改成組合邏輯直接接mem[rd_ptr]代價是時序收斂難度略增。2.3 參數(shù)設計里容易被忽略的兩個點第一點是深度必須是2的整數(shù)次冪嗎上面代碼里直接用wr_ptr 1b1做回卷隱含假設了地址可以自然溢出。如果深度不是2的冪次比如你需要12個深度的FIFO那指針就要額外做回卷判斷空滿計數(shù)器的計算也會變得繁瑣。工程上我建議優(yōu)先把深度設成2的冪次如果實在需要非2冪次深度就得在指針自增后加邊界判斷代碼復雜度和出錯概率都會上升。第二點是$clog2函數(shù)在部分老工具里可能不支持或者綜合結果和你想的不一樣。如果你的開發(fā)環(huán)境比較老建議直接用常量定義比如localparam ADDR_WIDTH 4;同時寫一行注釋說明深度是16。用$clog2不是不行但一定要在仿真階段就確認參數(shù)展開是否正確別等到綜合報錯才發(fā)現(xiàn)問題。3. 異步FIFO的設計難點與經典解法3.1 跨時鐘域的本質風險異步FIFO的讀寫端口分別工作在兩個完全獨立的時鐘域里比如寫時鐘100MHz讀時鐘75MHz兩個時鐘之間沒有相位關系甚至頻率都不成整數(shù)倍。這時讀寫指針都各自在自己的時鐘域里變化如果直接把讀指針的二進制值拿到寫時鐘域里去比較就會遇到一個致命的跨時鐘域問題多位信號同時變化時采樣結果可能“四不像”。舉個例子讀指針從0111變成1000二進制下最低四位同時翻轉。如果恰好在這個變化的瞬間寫時鐘域對它采樣采樣結果可能是0000、0111、1000或者任何中間組合。這種沒有意義的中間值一旦被用于空滿判斷FIFO就可能發(fā)出錯誤的滿信號直接導致寫端停寫丟數(shù)據(jù)或者發(fā)出錯誤的空信號讓讀端讀到垃圾數(shù)據(jù)。這是異步FIFO設計里最核心的坎也是面試時必考、工程里必踩的坑。3.2 格雷碼讓跨時鐘域指針安全傳遞解決多位跨時鐘域傳遞的標準手段是把指針從二進制轉換成格雷碼。格雷碼的特點是相鄰兩個數(shù)值之間只有1位發(fā)生變化。這意味著指針從一個狀態(tài)遞增到下一個狀態(tài)時跨時鐘域采樣最多只有一個比特處于變化中。對于單比特信號只要接收端用兩級寄存器打拍同步就能以極高的概率采到正確的穩(wěn)態(tài)值不會出現(xiàn)“中間組合值”的問題。那是不是用格雷碼就萬事大吉了還差一步同步后的格雷碼指針在判斷空滿時不能直接拿來和本地的二進制指針比較。通常的做法是寫時鐘域里把寫指針轉成格雷碼再打兩拍同步到讀時鐘域讀時鐘域里把讀指針轉成格雷碼再打兩拍同步到寫時鐘域。判斷滿時用寫時鐘域里的寫指針格雷碼可以再轉回二進制或直接格雷碼比較和同步過來的讀指針格雷碼比較判斷空時用讀時鐘域里的讀指針格雷碼和同步過來的寫指針格雷碼比較。指針從二進制轉格雷碼的公式是gray (bin 1) ^ bin這個公式到處都是但我還是建議親手推一遍。它的本質是保留二進制最高位然后每一位都和上一位做異或。解碼也就是把格雷碼變回二進制需要從最高位逐位向下異或綜合出來是一串組合邏輯鏈。如果FIFO深度很大這個譯碼鏈的延遲也會成為一個時序關注點不過對于一般深度在32到1024范圍內的FIFO通常都能跑得很穩(wěn)。3.3 異步FIFO空滿判斷的精確語義異步FIFO的空滿判斷本質上是一個“近似判斷”。因為讀指針同步到寫時鐘域需要兩拍寫指針同步到讀時鐘域也需要兩拍所以任何一端看到的對端指針都可能是兩個時鐘周期之前的狀態(tài)。這個延遲會帶來兩個現(xiàn)象假滿和假空。假滿的意思是寫端看到滿信號拉高了但實際上因為讀端正在讀數(shù)據(jù)、FIFO已經有空位了這時候寫端選擇停寫就會損失一些寫帶寬。假空同理讀端看到空信號但因為寫端正在寫數(shù)據(jù)FIFO里其實馬上就要有數(shù)據(jù)了這時候讀端等待就會出現(xiàn)一段讀空檔。異步FIFO設計里我們寧可出現(xiàn)假滿假空也絕不能讓滿信號在FIFO真正滿了之后才拉高或者空信號在FIFO真正空了之后才拉低——前者導致覆蓋寫入、數(shù)據(jù)被沖掉后果極其嚴重。設計時我們用格雷碼比較來判斷空滿。以“滿”為例在寫時鐘域寫指針格雷碼的每一位和同步過來的讀指針格雷碼要滿足特定關系。最簡單的方法是當二進制指針的最高兩位不同、其余位都相同時表示FIFO已滿。轉換成格雷碼之后的判斷是最高位和次高位相反其余位相同。這個關系我能記住是因為有一次調試時發(fā)現(xiàn)自己的判斷條件寫反了導致FIFO從不通告滿信號結果上游數(shù)據(jù)嘩嘩往里灌存儲區(qū)被覆蓋得面目全非。那次查了一晚上最終就是把~和對調了一個位置。3.4 異步FIFO的核心代碼框架這里展示一個經典異步FIFO的關鍵邏輯結構。省略了存儲體部分重點看指針同步和空滿判斷的框架module async_fifo #( parameter DSIZE 8, parameter ASIZE 4 )( input wire wclk, wrst_n, input wire winc, input wire [DSIZE-1:0] wdata, output wire wfull, input wire rclk, rrst_n, input wire rinc, output wire [DSIZE-1:0] rdata, output wire rempty ); wire [ASIZE-1:0] waddr, raddr; wire [ASIZE:0] wptr, rptr; wire [ASIZE:0] wq2_rptr, rq2_wptr; // 讀指針同步到寫時鐘域用于滿判斷 sync_r2w sync_r2w_inst ( .wclk(wclk), .wrst_n(wrst_n), .rptr(rptr), .wq2_rptr(wq2_rptr) ); // 寫指針同步到讀時鐘域用于空判斷 sync_w2r sync_w2r_inst ( .rclk(rclk), .rrst_n(rrst_n), .wptr(wptr), .rq2_wptr(rq2_wptr) ); // 滿判斷寫指針與同步讀指針的格雷碼差一個“來回” assign wfull (wptr {~wq2_rptr[ASIZE:ASIZE-1], wq2_rptr[ASIZE-2:0]}); // 空判斷讀指針與同步寫指針完全相等 assign rempty (rptr rq2_wptr); // 存儲體、讀寫指針自增邏輯略 endmodule嚴格說這個框架里的wptr、rptr應該以格雷碼形式存儲和傳遞實際代碼里會在指針模塊內部維護一份二進制指針用于尋址再額外輸出格雷碼指針用于跨域同步。上面的結構重點展示了滿判斷條件當寫指針格雷碼的高兩位等于同步讀指針格雷碼高兩位的反時認為FIFO滿。這是異步FIFO設計中一個非常經典的判斷方式也提醒了我空和滿的判斷條件是不對稱的千萬不要圖省事復制粘貼。4. 設計中的常見坑與排查方法4.1 實測最容易翻車的三個場景第一復位釋放的跨時鐘域同步問題。異步FIFO的復位信號如果是異步復位、釋放時又和各自的時鐘沒有對齊可能讓指針處于一個非法的中間狀態(tài)。工程上比較穩(wěn)妥的做法是在讀寫時鐘域分別做異步復位同步釋放確保復位信號在本地時鐘域內干凈地釋放。我見過最詭異的一個現(xiàn)象是復位明明拉低了但第一個寫操作寫完FIFO直接報滿——原因是復位釋放時寫指針的格雷碼變了一個非法值同步到讀時鐘域后又引發(fā)了對端判斷混亂。第二讀寫同時發(fā)生時計數(shù)器更新順序。在同步FIFO里讀寫同時發(fā)生的優(yōu)先級到底給誰這看起來是個小問題但處理不好會導致計數(shù)器和指針不一致。我慣用的方式是讓寫優(yōu)先也就是先保證寫不丟因為讀端如果等一個周期再讀通常只是損失一點讀吞吐而寫端一旦因為FIFO誤判為滿而丟數(shù)據(jù)這個數(shù)據(jù)就是永久性丟失了。第三FIFO深度和指針位寬不匹配。深度16需要4位地址指針但空滿判斷有時需要額外擴展一位做區(qū)分。如果直接在原來的$clog2(DEPTH)基礎上加1代碼沒問題但如果有人圖省事把指針直接定義成[DEPTH-1:0]仿真時看不出來綜合后大概率會出問題。寫代碼之前把參數(shù)列一張表深度、地址位寬、指針位寬、格雷碼位寬一一對應寫清楚后面排查會省很多事。4.2 空滿標志的常見陷阱速查現(xiàn)象可能原因排查方向寫端還沒寫幾個數(shù)FIFO就報滿讀指針沒有正確同步到寫時鐘域格雷碼判斷條件寫反檢查同步器輸出波形檢查滿判斷條件高兩位的反邏輯讀端一直讀不到數(shù)據(jù)empty拉高不釋放寫指針同步有問題復位釋放異??磳憰r鐘域里寫指針是否正常遞增檢查格雷碼是否出現(xiàn)多比特跳變FIFO數(shù)據(jù)被覆蓋舊數(shù)據(jù)丟失滿信號來得太晚寫端在未滿時提前寫入了非法地址確認滿信號和寫指針自增的時序關系仿真打標記檢查是否出現(xiàn)寫穿仿真正常上板后偶發(fā)錯數(shù)存儲體讀寫碰撞時鐘偏斜未處理讀延遲差異檢查雙口RAM讀寫時序確認是否有組合邏輯路徑未約束這個表格里的每一種現(xiàn)象我都碰到過。印象最深的是“仿真正常上板偶發(fā)錯數(shù)”當時用的是IP核生成的異步FIFO但沒留意IP核里讀數(shù)據(jù)有額外的輸出寄存器選項導致讀路徑比預期晚了一拍和后續(xù)邏輯的采樣窗口錯開。從那以后我每次例化FIFO IP都會先做一次極簡環(huán)回驗證寫端往FIFO里灌0到255讀端再讀出來比對跑一遍全流程再放到大工程里。5. 選型建議與工程實踐心得5.1 手寫還是調IP核現(xiàn)在Xilinx、Intel的FPGA開發(fā)環(huán)境里都有非常成熟的FIFO IP核支持同步、異步、標準模式、首字直通模式、almost full/empty標志甚至還能生成計數(shù)與錯誤標志。如果你是在FPGA上做項目絕大多數(shù)情況下直接用IP核就好原因是IP核經過廠商充分驗證時序約束、復位同步、格雷碼轉換都已經處理好了比自己在RTL里手搓要省心得多。那什么時候需要手寫FIFO一類是ASIC設計里沒有現(xiàn)成IP可用所有東西都得從標準單元搭另一類是FIFO的邏輯非常特殊比如需要同時讀寫多個端口、需要帶優(yōu)先級的覆蓋寫、深度或位寬非常規(guī)。還有一個場景是你在做IC驗證或面試準備想弄明白FIFO內部到底怎么工作這時候手寫一個同步FIFO、再寫一個異步FIFO收獲遠比直接調IP大得多。我自己就是靠“手寫三個不同版本的FIFO”徹底搞懂了空滿判斷和格雷碼的深水區(qū)。5.2 從AXI Stream FIFO看FIFO的延伸如果你接觸過AXI Stream接口你會發(fā)現(xiàn)AXI Stream FIFO和經典FIFO不完全一樣。它不只是把數(shù)據(jù)存進隊列還要處理valid/ready握手信號當FIFO不滿時寫側ready拉高表示愿意接收數(shù)據(jù)當FIFO不空時讀側valid拉高表示有數(shù)據(jù)可供讀取。它比傳統(tǒng)FIFO多了一層“流控協(xié)議”的封裝。在設計這類FIFO時核心思路是一樣的只不過把“空滿信號”翻譯成了握手語義。full對應寫側ready拉低empty對應讀側valid拉低。如果再加almost full、almost empty這些水位線信號還能在數(shù)據(jù)量接近上限或下限時提前給出預警這在DMA傳輸、DDR讀寫調度里非常有用。理解了基礎FIFO的原理再去看各種衍生FIFO基本就是加一個協(xié)議殼子的事。5.3 我在實際項目里的幾點習慣做FIFO設計這么多年我總結出幾個談不上高大上但非常實用的習慣。第一所有FIFO的讀寫指針在仿真時一定要用$monitor或者斷言實時監(jiān)控不能等到跑完整波形成噸的數(shù)據(jù)后才去翻波形。好的做法是在每個寫操作完成后斷言!full或full之前不允許繼續(xù)寫把這個斷言寫進testbench里只要有微小的空滿判斷漏洞仿真立刻紅一大片。第二異步FIFO的同步器打拍寄存器必須使用不帶復位的觸發(fā)器。很多人會在同步器上順手接復位信號但復位信號本身如果和接收時鐘域不同步反而給同步器引入了新的不確定狀態(tài)。FIFO內部同步器的復位一般由接收時鐘域自己的復位生成并且打拍寄存器只做移位不做任何復位干預。這個細節(jié)在ASIC設計規(guī)范里寫得很清楚但FPGA工程里有時會被忽略。第三如果在調試時看到FIFO指針出現(xiàn)格雷碼中不存在的編碼別急著改邏輯先檢查跨時鐘域采樣路徑上是否有組合邏輯。格雷碼指針在跨域之前必須是寄存器直接輸出任何組合邏輯操作都可能破壞“每次只變一位”這個前提。我一同事曾經為了圖方便把格雷碼指針做了一次加一操作再跨域結果那個數(shù)據(jù)路徑上出現(xiàn)毛刺整整排查了三天才定位到。6. 擴展視角分清不同類型FIFO的適用場合6.1 同步FIFO、異步FIFO與首字直通模式同步FIFO適合兩端同頻但節(jié)奏不匹配的場景比如同一個時鐘域內的數(shù)據(jù)打包、解包、速率適配。異步FIFO適合兩端完全異頻的場景它把跨時鐘域的難題從“數(shù)據(jù)線跨域”轉移成了“指針格雷碼跨域”代價是兩拍同步延遲帶來的假空假滿。首字直通模式First Word Fall Through是另一個常被提及的變體數(shù)據(jù)寫入后不需要讀請求就自動出現(xiàn)在讀數(shù)據(jù)總線上只是empty為低。它減少了一拍讀延遲在一些需要極低延遲的數(shù)據(jù)通路里非常實用。代價是讀數(shù)據(jù)總線的組合邏輯路徑邊長時序收斂難度更高。選哪種模式核心取決于你的后續(xù)模塊對讀數(shù)據(jù)延遲的容忍度。6.2 深度、位寬與寬度轉換FIFO可以同時做位寬轉換比如寫側32位、讀側8位FIFO內部存儲的還是32位數(shù)據(jù)讀側每讀一次取其中8位這需要額外的移位和使能控制。反過來8位寫32位讀也常見。位寬轉換會直接影響有效深度的計算因為存儲單元數(shù)量不變但讀到的最小粒度變了。設計時建議把“存儲單元寬度”和“端口氣位寬”分開思考否則很容易把FIFO容量估錯。還有一種情形是讀寫兩側數(shù)據(jù)速率嚴格相等、但交接時中間有毛刺這時一個深度很小的同步FIFO就夠用比如深度4到8之間。深度太小會頻繁拉滿、拉低反壓深度過大又浪費存儲資源且增加延遲。按我的經驗先估算最壞情況下寫端連續(xù)burst長度再留出兩級以上裕量是一個比較穩(wěn)妥的深度選擇方式。6.3 在大系統(tǒng)中的定位緩存、反壓還是異步橋FIFO在一個完整系統(tǒng)里扮演的角色可以分成三種來看。第一種是純粹的數(shù)據(jù)緩存比如CPU要寫一批數(shù)據(jù)到低速外設先把數(shù)據(jù)暫存在FIFO里外設慢慢取。第二種是反壓接口FIFO的滿信號直接給上游模塊的ready做門控讓上游的突發(fā)傳輸自動停下。第三種是異步橋把兩個時鐘域的數(shù)據(jù)無縫地接在一起FIFO本身相當于一個“蓄水池”讀寫兩端各自對池子進水、放水互不干擾。明白了這三種角色你就能更好地理解為什么有的FIFO需要almost_full信號有的需要programmable_full有的需要錯誤標志。不是因為FIFO復雜而是它要適配的系統(tǒng)場景本來就復雜。把FIFO放進系統(tǒng)的整體視野里去看遠比孤立地研究一個隊列結構更有價值。我在實際做過的Zynq平臺在線升級項目里就用過一套異步FIFO來緩存BOOT引導數(shù)據(jù)和升級鏡像。寫側是PS端通過AXI總線灌數(shù)據(jù)讀側是PL端的SPI Flash控制器按扇區(qū)慢慢寫。FIFO深度選的是204832位寬幾乎在升級全程沒有出現(xiàn)過反壓丟包靠的就是設計之初對兩端最壞速率的準確估算和異步FIFO的空滿信號合理使用。那次之后我對FIFO體感最深的一句話是它像一個知道什么時候該喝水的緩沖池而你真正要設計的是池子的水位信號和進出水閘門。