機設計實戰(zhàn))
簡介面向FPGA入門與嵌入式存儲應用開發(fā)者這份工程以開拓者開發(fā)板上的AT24C64為例完整演示了EEPROM的讀寫時序與FPGA控制邏輯實現。EEPROM作為電可擦除可編程只讀存儲器掉電不丟失廣泛用于物聯網與可穿戴設備的小量數據存儲AT24C64支持百萬次擦寫數據可保存百年工程從協議層到寄存器級展示了如何通過狀態(tài)機驅動I2C接口完成字節(jié)讀寫與頁操作。文件包共234個文件大小6.23MB內容以tdf硬件描述語言源碼、Verilog模塊、qpf/qsf工程配置、sof下載文件為主輔以編譯生成的cdb/hdb數據庫與rpt/qmsg報告文件便于直接查看綜合布局結果和時序約束。已有107人學習下載。結合完整工程源碼與目錄結構讀者可掌握地址尋址、讀寫流程、應答檢測及多字節(jié)傳輸等關鍵點并能快速將代碼移植到其他AT24C系列或自定義存儲項目中。1. FPGA讀寫EEPROM為什么這是繞不開的時序基本功新手做FPGA項目十個有九個把第一塊硬骨頭啃在EEPROM讀寫上。仿真波形明明全綠一上板子讀回全0xFF波形抓出來起始條件都對了就是ACK不對好不容易寫進去斷電再來數據沒了。這些都是FPGA讀寫EEPROM最常見也最打擊人的場景。這個標題背后其實就兩件事把I2C總線協議吃透再把狀態(tài)機寫成符合時序的電路。它不需要DDR、PCIe那種高速難題但會把跨時鐘域、三態(tài)門、時序約束這些基本功一次性串起來。做完這個模塊你以后接傳感器、接RTC、接各類I2C從設備全部是同一套思路。適合剛入門FPGA、做過LED流水燈但又想真正碰一下總線協議的開發(fā)者。2. I2C鏈路怎么搭從AT24C系列芯片到FPGA引腳規(guī)劃2.1 AT24C02到AT24C128容量、地址引腳和選型差異市面上FPGA開發(fā)板最常用的EEPROM是AT24C02和AT24C64一個2Kbit一個64Kbit都是I2C接口。選型時最該關心的是兩個參數容量和頁寫大小。型號容量字節(jié)數頁寫大小器件地址7位AT24C022Kbit256字節(jié)8字節(jié)0x50AT24C044Kbit512字節(jié)16字節(jié)0x50/0x51AT24C1616Kbit2KB16字節(jié)0x50~0x57AT24C6464Kbit8KB32字節(jié)0x50AT24C02在開發(fā)板上最常見因為地址引腳A0/A1/A2全接低電平時I2C從機地址固定為0x50。如果你的板子上A0/A1/A2跳線接了高電平地址就要相應加上偏移這是很多人忽略的第一步。引腳上除了SCL、SDA、VCC、GND之外還有個WP寫保護腳用來禁止寫操作。如果WP被拉高寫時序再標準也沒用數據進不去這個坑后面細說。AT24C04以上容量頁寫大小是16字節(jié)起跨頁地址回卷的坑更容易踩。開發(fā)板上做實驗用AT24C02就夠了項目要存較大參數時再考慮AT24C64或更大容量。有一點要注意容量超過16Kbit的型號如AT24C256器件地址里會包含高位地址字節(jié)地址是16位的寫流程多一個地址字節(jié)。2.2 總線幀結構起始位、停止位、ACK與讀寫方向的排布I2C的精髓就一句話SCL為高時SDA上的跳變是控制信號SCL為高時SDA必須保持穩(wěn)定才是數據。起始條件是SCL高時SDA從高拉低停止條件是SCL高時SDA從低拉高。一次完整寫入幀排布是起始位 → 器件地址7位寫方向位0 → ACK → 字節(jié)地址 → ACK → 數據字節(jié) → ACK → 停止位。整個過程中每發(fā)完一個字節(jié)主設備必須釋放SDA總線讓從設備拉低回應一個ACK。九個SCL脈沖里前八個是數據第九個是ACK應答。重點在方向位的排布器件地址0x50左移一位得到0xA0是寫方向地址0xA1是讀方向地址。很多人在代碼里直接把0x50當成發(fā)送字節(jié)導致地址錯位一位整個通信直接失敗。讀操作更特殊它要先發(fā)一個偽寫幀把寄存器指針指到目標地址再發(fā)一個重復起始條件然后換讀方向繼續(xù)讀。這個流程拆開看就是兩個起始條件夾一個地址字節(jié)中間沒有停止位。2.3 FPGA側的引腳規(guī)劃時鐘分頻、SCL生成與sda三態(tài)口FPGA這邊連I2C總線和單片機不同沒有開漏輸出的硬件結構必須自己用三態(tài)門模擬。sda端口要聲明為inout內部需要兩個信號sda_out_data負責輸出數據sda_en控制方向。sda_en為1時FPGA驅動總線為0時釋放總線給外部上拉。時鐘分頻是另一個關鍵點。I2C標準速率是100kHz和400kHz開發(fā)板主時鐘常見50MHz或100MHz。做法是先用計數器做分頻生成一個SCL半周期脈沖再用這個脈沖驅動狀態(tài)機。比如50MHz要得到100kHz SCL每個SCL周期是500個主時鐘周期半周期是250個。localparam HALF_CLK 250 - 1; // 50MHz - 100kHz SCL reg [8:0] half_cnt; reg scl_r; always (posedge clk or negedge rst_n) begin if (!rst_n) begin half_cnt 9d0; scl_r 1b1; end else if (half_cnt HALF_CLK) begin half_cnt 9d0; scl_r ~scl_r; // 每250個主時鐘翻轉一次SCL end else begin half_cnt half_cnt 1b1; end end這段代碼生成了低電平有效的SCL翻轉脈沖scl_r就是內部SCL寄存器scl_r翻轉一次代表一個半周期完成。這里要留意真正的SCL輸出還應該在I/O約束里設置ODT和驅動強度但核心邏輯就是靠這個翻轉信號推進狀態(tài)機。不要試圖用計數器直接算SCL的高電平寬度那會讓狀態(tài)機變得瑣碎難調。用半周期脈沖作為采樣節(jié)拍狀態(tài)機在scl_r的上升沿和下降沿分別做不同動作這是最清晰的寫法。3. 用狀態(tài)機實現I2C主機一個能跑的基礎Verilog骨架3.1 核心狀態(tài)機與字節(jié)移位SCL低電平換數據、高電平采樣寫I2C主機狀態(tài)機我一般采取四個狀態(tài)IDLE、START、SHIFT、ACK。START狀態(tài)負責拉出起始條件SHIFT狀態(tài)按位發(fā)送或接收數據ACK狀態(tài)判斷從設備是否應答。下面這段代碼是字節(jié)發(fā)送的核心骨架只保留關鍵路徑。localparam IDLE 3d0; localparam START_S 3d1; localparam SHIFT_S 3d2; localparam ACK_S 3d3; reg [2:0] fsm; reg [2:0] bit_cnt; reg sda_out; reg sda_en; reg [7:0] tx_data; always (posedge clk or negedge rst_n) begin if (!rst_n) begin fsm IDLE; bit_cnt 3d0; sda_en 1b1; end else begin case (fsm) IDLE: begin sda_out 1b1; sda_en 1b1; if (start_req) fsm START_S; end START_S: begin sda_out 1b0; // SCL為高時SDA拉低 if (scl_neg_edge) fsm SHIFT_S; end SHIFT_S: begin sda_out tx_data[bit_cnt]; if (scl_neg_edge) begin // SCL低電平期間改寫SDA if (bit_cnt 3d0) begin fsm ACK_S; sda_en 1b0; // 釋放總線等從機ACK end else begin bit_cnt bit_cnt - 1b1; end end end ACK_S: begin if (scl_rise_edge) begin o_ack_err sda_in; fsm IDLE; end end endcase end end這段代碼的核心觀點是SDA必須在SCL低電平時變化在SCL高電平時被從設備采樣。所以我只在scl_neg_edge時更新sda_out在scl_rise_edge時讀取從設備的ACK。bit_cnt一路遞減到0表示8個數據位全部發(fā)送完畢然后釋放總線、轉入ACK狀態(tài)。注意tx_data必須在進入SHIFT狀態(tài)前由上層賦值否則第一拍發(fā)送的數據是舊值。3.2 ACK檢測與停止條件主從握手最容易忽略的一拍ACK狀態(tài)是整個I2C通信最容易翻車的位置。原因很常見發(fā)送完8位數據后主設備沒有釋放SDA總線導致從設備想拉低ACK卻拉不動或者說主設備讀到的SDA永遠為高誤判為NACK。// ACK正在采樣——該剝除sda延時的對齊 reg sda_d1; always (posedge clk) sda_d1 sda_in; // 在SCL高電平中點采樣一個sda wire ack_sampled (fsm ACK_S) (half_cnt HALF_CLK/2); always (posedge clk) begin if (ack_sampled) o_ack_err sda_d1; end這里連著三個問題要一起解決。第一采樣時機在SCL高電平的中點不要用上升沿上升沿太快容易采到數據翻轉瞬間的毛刺。第二sda_in經過一級寄存器打拍消除亞穩(wěn)態(tài)風險這是跨時鐘域的基本動作。第三如果o_ack_err為1說明從設備沒應答就直接進IDLE并向上層報錯不要再繼續(xù)發(fā)后續(xù)數據否則總線狀態(tài)全亂。跳線地址不對、EEPROM芯片焊反、上電時序沒滿足最終表現形式都是ACK一直為高。所以調試時先看ACK位再談數據內容。3.3 模塊接口設計如何把i2c_master掛到上層I2C主機不要直接讓業(yè)務邏輯穿梭sda建議封裝成獨立模塊對外暴露一個最簡單的手握級接口start_req、rw方向、要發(fā)送的字節(jié)、讀回的數據、done脈沖、錯誤標志。上層只管發(fā)請求狀態(tài)機內部管時序。module i2c_master #( parameter CLK_DIV 250 )( input wire clk, input wire rst_n, inout wire sda, output wire scl, input wire req_valid, input wire req_rw, // 1:讀 0:寫 input wire [7:0] req_data, output reg [7:0] resp_data, output reg resp_done, output reg resp_ack_err );數據線和控制線分離是這套設計的關鍵。你寫EEPROM驅動時上層模塊關心的只有地址發(fā)過去沒有、數據寫進去沒有、讀回來的字節(jié)是什么。至于起始位寬多少、SCL頻率多少那是i2c_master內部的事。以后項目里要接其他I2C設備OS操作平臺相同整個主機原封不動復用。這種模塊化設計還有個好處仿真時可以單獨給i2c_master寫testbench驗證完時序再掛EEPROM邏輯。如果一鍋燉在一個大模塊里出問題根本分不清是狀態(tài)機錯了還是地址邏輯錯了。4. EEPROM讀寫模塊從字節(jié)地址到頁寫邊界的完整實現4.1 寫操作控制字節(jié)、寄存器地址與數據字節(jié)的排布有了i2c_master做底層寫EEPROM就成了拼幀的過程。AT24C02只有256個字節(jié)地址一個字節(jié)就夠。EEPROM寫一個字節(jié)需要連續(xù)發(fā)送三個字節(jié)0xA0設備地址寫方向、地址字節(jié)、數據字節(jié)。每次發(fā)送之間都要檢查ACK任何一拍的ACK不對整個過程退回IDLE。module eeprom_write_ctrl #( parameter CLK_DIV 250 )( input wire clk, input wire rst_n, inout wire sda, output wire scl, input wire wr_start, input wire [7:0] wr_addr, input wire [7:0] wr_data, output reg wr_done, output reg wr_err ); localparam S_IDLE 3d0; localparam S_DEV 3d1; // 發(fā)送器件地址 localparam S_ADDR 3d2; // 發(fā)送EEPROM內部字節(jié)地址 localparam S_DATA 3d3; // 發(fā)送數據 localparam S_STOP 3d4; // 停止并等待寫周期 reg [2:0] state; reg [1:0] send_cnt; wire m_done; wire m_ack_err; reg req_valid; reg req_rw; reg [7:0] req_data; i2c_master #(.CLK_DIV(CLK_DIV)) u_master ( .clk(clk), .rst_n(rst_n), .sda(sda), .scl(scl), .req_valid(req_valid), .req_rw(req_rw), .req_data(req_data), .resp_data(), .resp_done(m_done), .resp_ack_err(m_ack_err) ); always (posedge clk or negedge rst_n) begin if (!rst_n) begin state S_IDLE; req_valid 1b0; end else begin case (state) S_IDLE: begin if (wr_start) begin send_cnt 2d0; state S_DEV; req_rw 1b0; req_data 8hA0; // 1010_0000 req_valid 1b1; end end S_DEV: begin if (m_done !m_ack_err send_cnt 2d0) begin send_cnt send_cnt 1b1; req_data wr_addr; state S_ADDR; end else if (m_done m_ack_err) begin state S_IDLE; wr_err 1b1; end end S_ADDR: begin if (m_done !m_ack_err) begin send_cnt send_cnt 1b1; req_data wr_data; state S_DATA; end end S_DATA: begin if (m_done !m_ack_err) begin req_valid 1b0; state S_STOP; end end S_STOP: begin wr_done 1b1; state S_IDLE; end endcase end end endmodule這個狀態(tài)機的關鍵點是每次發(fā)送之前都把req_valid拉高一個周期等m_done回來再切狀態(tài)。req_valid不能一直維持高電平因為i2c_master每次只處理一個字節(jié)如果req_valid常高它會重復發(fā)送。wr_err標志在任意一幀ACK出錯時拉高并且整個控制狀態(tài)回到IDLE等待上層重新發(fā)起。寫周期這部分我沒在狀態(tài)機里處理因為EEPROM在收到停止位后需要5ms內部擦寫時間這期間不能訪問。4.2 讀操作虛寫地址、重復起始與連續(xù)讀的搭法讀比寫繞一點。要讀地址0x10的數據必須先發(fā)0xA0、0x10這兩個字節(jié)讓EEPROM內部地址指針指向0x10再發(fā)0xA1方向位從頭讀。這個流程叫偽寫本質上是欺騙從設備把地址裝好再換方向讀。AT24C02支持連續(xù)讀主設備持續(xù)給時鐘EEPROM會自動遞增地址往外吐數據直到主設備回一個NACK再發(fā)停止位。偽寫和真正的寫有兩個區(qū)別第一偽寫發(fā)完地址后不停止而是發(fā)重復起始條件第二方向位從0翻成1。重復起始位不能讓狀態(tài)機先發(fā)停止位再發(fā)起始位那樣就是兩次獨立的傳輸EEPROM會當成偽寫已經結束數據讀不出來。搞定重復起始的i2c_master需要一個restart功能。實踐中我在i2c_master里增加一個restart_req信號在ACK狀態(tài)結束時檢測到restart_req就直接回START狀態(tài)而不回IDLE。完整讀模塊的思路是寫地址幀 → 拉restart → 發(fā)0xA1 → 連續(xù)讀N個字節(jié) → 最后一個字節(jié)回NACK → 停止。4.3 頁寫與tWR等待EEPROM自己限制的兩個邊界頁寫是I2C EEPROM最容易出現玄學錯誤的特性。AT24C02每頁8字節(jié)寫入時如果地址從0x07繼續(xù)往0x08寫實際上不會寫入0x08而是回卷寫入0x00。這是芯片內部硬件行為不是總線協議能約束的。AT24C64每頁32字節(jié)寫跨頁時同樣回卷。處理辦法只有兩個要么一次只寫單字節(jié)要么在編程邏輯里檢測當前地址是否在頁邊界超出就拆成兩次頁寫。頁寫確實能把效率提升好幾倍因為8字節(jié)或32字節(jié)一次寫周期就完成但對應的風險是數據錯亂。寫周期tWR也一樣沒人情味。EEPROM接受停止位后內部控制邏輯開始擦寫這期間最多5ms不響應任何總線操作。如果你不等它寫完就去發(fā)下一個寫命令地址和數據根本進不去而且ACK會異常。一個常規(guī)做法是在S_STOP狀態(tài)里加一個延時計數器默認等待5ms再回IDLE。也可以做ACK輪詢就是反復發(fā)一個偽寫命令直到收到ACK為止但這一般是單片機才用的省事手段FPGA這邊直接延時最穩(wěn)妥。5. 避坑記錄從仿真全綠到上板翻車的六條紀錄5.1 讀回全0xFF先查上拉和WP腳別懷疑芯片現象上板測試無論寫什么地址讀回來全是0xFF。仿真里明明都正常。原因大概率不是你的狀態(tài)機而是硬件連接。EEPROM的SDA需要外部上拉到VCC如果板上沒接上拉電阻或者FPGA引腳配置成內部下拉SDA讀進來永遠是0。再加上WP寫保護腳懸空或接高寫入動作被芯片拒絕讀回自然就是初始值0xFF。解決先用萬用表量SDA引腳靜態(tài)電平總線空閑時應該是高。再把WP腳確認拉低排除寫保護。5.2 時序仿真完美上板卻卡死sda三態(tài)門方向控制寫錯了現象ModelSim波形看著起始條件、ACK、停止一切都對一上板I2C總線直接掛住SCL正常跳但SDA一直低。原因很多人在仿真里把sda_out和sda_en簡單寫成了賦值沒有真正模擬open-drain行為。FPGA管腳如果是推挽輸出持續(xù)驅動低電平從設備拉到低后根本釋放不了。解決檢查三態(tài)門sda_en為低時必須輸出高阻z而不是強行輸出1。用IOBUF原語是最穩(wěn)的寫法。IOBUF #( .DRIVE(12), .IOSTANDARD(LVCMOS33) ) iobuf_sda ( .O (sda_in), .IO(sda), .I (sda_out), .T (sda_en_n) );5.3 寫第二個字節(jié)穩(wěn)定第三個字節(jié)開始丟tWR沒等夠現象連續(xù)寫三個不同地址前兩個斷電后驗證正常第三個總是丟。原因EEPROM寫周期tWR是5ms你寫完第一個字節(jié)沒等夠緊接著發(fā)第二個字節(jié)的命令結果第二個寫操作直接無效。解決每次寫入結束加5ms延時或者在狀態(tài)機里對寫命令間隔做最小間隔約束。速度快不是目的數據穩(wěn)才是。5.4 頁寫數據串位跨頁地址回卷是硬件行為不是bug現象用頁寫模式連續(xù)寫20個字節(jié)發(fā)現數據從第8個開始錯亂但單字節(jié)寫全部正常。原因AT24C02頁大小8字節(jié)地址位最低3位歸零回卷。解決寫之前做地址判斷當前地址距離頁邊界不足計劃長度時拆開寫。5.5 多設備總線上A設備正常B設備異常跳線地址沖突現象開發(fā)板上同時掛了EEPROM和一個溫度傳感器EEPROM讀寫正常傳感器讀不到。原因兩個設備硬件地址相同或者EEPROM的A0/A1/A2跳線被撥到了和另一個器件沖突的位置。解決查兩個芯片的地址位把EEPROM跳線撥到不沖突的組合總線協議層面無解。6. 從仿真到ILA實測驗證讀寫的三板斧6.1 用testbench快速逼出起始、ACK、停止仿真時不要一上來就寫完整讀寫流程先給i2c_master發(fā)一個單字節(jié)寫請求抓三個節(jié)點scl_r電平、sda_in、fsm狀態(tài)。起始條件看SDA是否在SCL高時拉低ACK看第9個時鐘SDA是否被從設備拉低停止看SDA是否在SCL高時拉高。這三個點都對了基本協議就通了一半。模型里EEPROM用仿真模型注意模型里通常有tWR參數仿真時默認寫周期極短真正上板才暴露問題。6.2 ILA實測抓I2C總線三個檢查點上板調試用ILA抓實際波形比仿真多了兩個檢查點SCL信號邊沿是否干凈、SDA的電平是否在SCL高時穩(wěn)定。抓取時把sda設為輸入引腳時鐘用100MHz深度4096足夠看一次完整讀寫。實測中最常見的問題是SCL上升沿上疊加了毛刺導致從設備誤判時鐘沿。這本質是驅動強度太強加上總線電容導致的過沖在約束文件里把I/O的SLEW降下來能明顯改善。我自己的調試習慣是把ILA觸發(fā)放到ACK錯誤標志上先看是不是每個字節(jié)都有ACK再看數據內容。如果ACK全對但數據錯那問題在地址排列如果ACK都錯那問題在硬件電平。這個思路幫我排除過無數個看似玄學的I2C故障。讀操作驗證時建議用連續(xù)讀模式一次讀16字節(jié)觀察地址自動遞增是否正確。只要連續(xù)讀的數據和寫入的數據完全一致整個讀寫鏈路就基本閉環(huán)了。你以后接任何I2C從設備這套調試方法都能直接復用希望幫到你。本文還有配套的精品資源點擊獲取