存讀的完整旅程:深入解析事務層MRd與CPLD機制)
有沒有過這種經(jīng)歷lspci -vv明明列出了一個 PCIe 設備BAR 地址也分配了軟件去讀寄存器卻返回全0xFF或者干脆卡在readl里遲遲不返回最后上報一個總線錯誤。很多人第一反應是驅(qū)動寫錯了、中斷沒配好卻忽略了 PCIe 事務層協(xié)議里最基礎的一環(huán)——內(nèi)存讀請求和完成響應是不是真的對上了。這次就用一次內(nèi)存讀的完整旅程把 PCIe 事務層協(xié)議里最關鍵的部分逐段拆開看完之后再去排這一類問題思路會清晰很多。這篇文章的主場景是 CPU 發(fā)起一次對設備映射地址的 32 位 MMIO 讀。一次讀在 PCIe 世界里從來不只是一個方向的動作先要發(fā)一個內(nèi)存讀請求Memory Read Request, MRd設備收到后還必須回一個完成報文Completion with Data, CPLD。這中間涉及 TLP 構(gòu)造、流控信用、地址路由、標簽匹配、完成拆分、錯誤狀態(tài)等一系列機制。適合正在做 PCIe 驅(qū)動開發(fā)、FPGA PCIe 接口調(diào)試、或者剛開始讀 PCIe 協(xié)議但被規(guī)范文檔繞暈的工程師參考。1. 先畫一條路徑一次內(nèi)存讀在 PCIe 里到底要經(jīng)過哪些環(huán)節(jié)1.1 為什么用“讀”來拆事務層而不是“寫”PCIe 的寫事務是“單程票”。Posted 報文發(fā)出去就完事不需要對方回執(zhí)而讀事務天然是“一問一答”請求發(fā)出后請求方必須等待完成報文回來整個往返過程才會結(jié)束。正是這個“回答”機制把事務層的報文類型、路由方式、標簽管理、流控信用、錯誤狀態(tài)全都帶了出來。打個比方寫事務就像寄出一個包裹單號只是方便你事后查讀事務則是寄出一張查詢單要求對方蓋個回執(zhí)再寄回來?;貓?zhí)如果丟了、慢了、寄錯了人立刻就能體現(xiàn)出異常。所以把一次讀吃透寫、消息、配置讀寫這些事務的規(guī)則基本也能順勢理解一大半。1.2 一次讀的完整旅程可以拆成七個站點不用畫流程圖我用文字把這次讀的經(jīng)過列出來后面所有內(nèi)容都是圍繞這條鏈路展開的CPU/驅(qū)動程序訪問一段已映射的 MMIO 地址這個地址在 Root Complex 里被識別為 PCIe 域。Root Complex 的事務層生成一個內(nèi)存讀請求 TLP也就是 MRd。如果拓撲里有 SwitchTLP 按目標地址被路由到對應的下游端口。端點接收 TLP先由數(shù)據(jù)鏈路層做完整性校驗再交給事務層解析。端點根據(jù)地址命中自己的 BAR 空間把內(nèi)部寄存器或存儲器的數(shù)據(jù)讀出來。端點構(gòu)造完成報文 CPLD攜帶數(shù)據(jù)和狀態(tài)返回。Root Complex 根據(jù)請求者 ID 和 Tag 匹配完成最終把數(shù)據(jù)交給 CPU 或系統(tǒng)內(nèi)存。這七個站點是典型場景實際拓撲可能沒有 Switch直接從 Root Port 到設備那第 3 步和第 6 步的路由判斷就更簡單但核心機制完全一樣。我用一個表格把每個站點涉及的關鍵知識點先擺出來階段主要動作涉及事務層知識點Root Complex 發(fā)起地址翻譯、TLP 構(gòu)造BAR、請求頭部、TagSwitch 下行按地址路由地址路由表、內(nèi)存窗口端點接收校驗并解析請求LCRC、事務層接收邏輯端點數(shù)據(jù)源BAR 命中并讀取內(nèi)部寄存器/RAM 映射端點應答構(gòu)造 CPLD完成頭、狀態(tài)、拆分規(guī)則Switch 回程按 ID 路由Completer IDRoot Complex 匹配完成隊列配對Requester ID Tag1.3 MMIO 讀和 DMA 讀是兩條不同方向的路很多朋友看到“內(nèi)存讀”會條件反射想到 DMA也就是端點設備主動去讀系統(tǒng)內(nèi)存。這確實是 PCIe 里最常見的業(yè)務之一但和本文主講的 MMIO 讀方向正好相反。對比項MMIO 讀DMA 讀發(fā)起方Root ComplexEndpoint讀取對象端點的 BAR 空間主機側(cè)系統(tǒng)內(nèi)存請求頭里的地址含義目標設備內(nèi)部地址主機物理內(nèi)存地址完成方向Endpoint - Root ComplexRoot Complex - Endpoint常見故障點BAR 沒使能、設備不回完成Bus Master Enable 沒開、IOMMU 映射錯誤DMA 讀的核心 TLP 機制和 MMIO 讀其實完全同源只是“問路”的方向反了過來。把 MMIO 讀這條鏈路徹底搞懂DMA 讀的請求和完成關系也可以直接遷移過去。2. 事務層的“快遞單”TLP 頭部到底寫了什么2.1 TLP 在 PCIe 分層里的位置PCIe 事務層的核心產(chǎn)物就是 TLP。事務層負責把 CPU 側(cè)的內(nèi)存訪問翻譯成標準報文在接收側(cè)又把報文恢復成內(nèi)存操作。TLP 往下走的時候數(shù)據(jù)鏈路層會在前面加一個序列號、在末尾加 LCRC物理層再負責編碼和高速串行傳送。在這一堆處理中事務層才是真正有“業(yè)務邏輯”的地方鏈路層只是保證搬運不丟不壞物理層解決傳輸介質(zhì)問題。理解這一點很有用平時用協(xié)議分析儀或者 FPGA 里的 TLP tracer 抓到的數(shù)據(jù)本質(zhì)上都是把物理層符號解碼、把鏈路層序號和 CRC 剝掉以后得到的事務層內(nèi)容。只要你會看 TLP就等于拿到了真正的主線劇透。2.2 內(nèi)存讀請求頭部字段逐項拆解一個 32 位地址的 MMIO 讀請求通常使用 3DW 的 TLP 頭部。我按實際意義把關鍵字段列出來字段說明一次 32 位讀的典型取值Fmt / Type報文格式和類型Memory ReadMRd3DW 頭Length本次要讀的總長度單位 DW1即讀取 4 字節(jié)Requester ID請求發(fā)起方的 BDF如 00:01.0Tag請求編號用于配對由發(fā)起方分配的未用號Last DW BE / First DW BE首尾 DW 內(nèi)哪些字節(jié)有效全有效時是 0xF / 0xFAddress目標地址BAR 基址加偏移TC / Attr / EP / TD服務質(zhì)量與完整性屬性常規(guī)讀通常為 0Length字段要特別注意對于讀請求TLP 內(nèi)部并不攜帶數(shù)據(jù)Length 代表的是“希望對方返回多少 DW”。這和寫請求完全不一樣寫請求是Length個 DW 的數(shù)據(jù)實際跟著請求走。地址超過 32 位時頭部會變成 4DW地址字段占 64 位。日常很多設備的 BAR 被系統(tǒng)分配在低 32 位地址空間所以 3DW 頭部出現(xiàn)得最多但在 64 位系統(tǒng)里設備資源也可能被分配到高地址這時不能用老眼光去看報文。First/Last DW BE這組字段容易被人忽略。軟件用readl讀 4 字節(jié)時這兩個字段都是0xF表示 4 個字節(jié)全要。但有些驅(qū)動會用readw或readb操作同一個地址那時 Byte Enable 就只對應相應字節(jié)有效。設備側(cè)如果不按 Byte Enable 處理就很容易出現(xiàn)“讀回來總是整字但驅(qū)動讀半字時數(shù)據(jù)錯位”的問題。2.3 完成報文 CPLD 的頭部和請求頭有何不同完成報文是讀請求的響應頭部結(jié)構(gòu)有明顯差別。它不再有目標地址取而代之的是完成方自己的信息字段說明本例典型值Fmt / Type帶數(shù)據(jù)的完成CPLDCompleter ID完成方的 BDF目標端點的 BDFRequester ID發(fā)起方 BDF必須和請求頭一致Tag原請求的編號必須原樣返回Status完成狀態(tài)0成功1UR2CAByte Count本次完成包含多少數(shù)據(jù)字節(jié)等于請求的 Length * 4Lower Address完成數(shù)據(jù)在地址空間的起始信息請求地址低 bit 對齊后的值Data讀出的有效載荷寄存器里的 4 字節(jié)值這里最關鍵的一點完成報文靠Requester ID Tag找到對應的請求而不是靠地址。也就是說請求頭攜帶了“到哪里去讀”的地址完成頭則只帶了“回給誰”的身份證。后面講回程路由時這個機制會越來越重要。3. 出站之旅請求怎么從根端口跑到目標設備3.1 CPU 的 MMIO 訪問如何變成 PCIe 報文一次readl從 CPU 視角看是簡單的訪存指令但硬件背后很復雜。系統(tǒng)在枚舉階段已經(jīng)為每個設備 BAR 分配了一段物理地址CPU 訪問這段地址時Host Bridge / Root Complex 會識別出它屬于 PCIe 域隨后開始構(gòu)造 TLP。這個過程通常由 RC 硬件自動完成不需要驅(qū)動介入。驅(qū)動拿到ioremap以后的虛擬地址寫下去或者讀出來RC 會把 CPU 指令里的地址和長度翻譯成 3DW 或 4DW 的 MRd。如果目標地址小于 4GBRC 通常用 3DW 頭如果落在 64 位地址空間則使用 4DW 頭。這里最值得注意的一點CPU 指令位寬不完全決定 TLP 頭部格式?jīng)Q定因素是目標地址范圍和平臺實現(xiàn)。所以在排查“讀寄存器數(shù)據(jù)不對”時第一步永遠先確認 BAR 分配是否正確。PCIe 枚舉過程里 BIOS 或 OS 會把 BAR 寫成某一可用地址如果 BAR 里完全沒有使能內(nèi)存空間位或者地址窗口還沒建立RC 就沒法把 CPU 地址和任何設備對上自然也無法構(gòu)造有效的讀請求。3.2 發(fā)送前必須做流控事務層不是想發(fā)就發(fā)PCIe 的流控機制和很多總線不一樣。它不靠接收方發(fā)“忙”信號而是基于信用量Credit。每個虛擬通道里分了三類信用Posted、Non-Posted、Completion每一類又區(qū)分 Header 和 Data。內(nèi)存讀請求屬于 Non-Posted 類型需要消耗一個 NP Header Credit。由于讀請求本身不攜帶數(shù)據(jù)體所以不需要消耗 NP Data Credit。發(fā)送方的事務層在把 TLP 交給數(shù)據(jù)鏈路層之前會檢查對應的 Credit 是否足夠不夠就先等待。這個機制特別像酒店辦理入住時的預授權你得先把額度占住后面退房再結(jié)算。接收方收到 TLP 并消化之后會向發(fā)送方歸還 Credit雙方才能繼續(xù)“轉(zhuǎn)賬”。工程上有一個很隱蔽的坑當接收方?jīng)]有及時歸還 Credit或者 Credit 初始化時配置成 0發(fā)送方就會一直等表現(xiàn)是“請求發(fā)出去了但鏈路上什么都看不到”。這種問題在普通軟件調(diào)不見非得上 TLP 抓包才能定位。3.3 Switch 怎么知道該把讀請求轉(zhuǎn)到哪個下游端口當拓撲中有 PCIe Switch 時RC 發(fā)出的內(nèi)存讀請求先到達 Switch 的上游端口。Switch 需要根據(jù)地址信息決定往哪個下游端口轉(zhuǎn)。這個決定不靠廣播而是靠枚舉階段建立的內(nèi)存窗口。每個下游端口在配置空間里會記錄一組 Memory Base 和 Memory Limit本質(zhì)就是一張地址路由表。Switch 拿到 MRd 之后取出請求頭里的地址和各個下游端口的窗口比對命中就往對應端口轉(zhuǎn)發(fā)沒有命中就返回一個 Unsupported Request 完成或者直接丟棄。這和以太網(wǎng)的泛洪轉(zhuǎn)發(fā)完全不同PCIe 的內(nèi)存讀不會“全員廣播”每跳都必須有唯一判定。這也是 PCIe 拓撲越復雜對地址窗口配置要求越高的原因。3.4 穿過鏈路層和物理層時發(fā)生了什么事務層把 TLP 構(gòu)造好之后數(shù)據(jù)鏈路層會給它加 2 字節(jié)的序列號并在尾部算一份 LCRC。接收端檢查這些信息如果發(fā)現(xiàn) CRC 不對會通過鏈路層重傳機制要求發(fā)送方重發(fā)。物理層則負責把數(shù)據(jù)變成串行信號送到線上期間還有均衡、擾碼、編碼等操作。對事務層討論來說這一層只是“可靠的搬運工”。但是排障時要知道鏈路層如果頻繁丟包重傳最終也會表現(xiàn)為事務層“請求發(fā)出去了但一直等不到完成”容易被誤判成設備故障。這時候需要用lspci -vvv看鏈路帶寬和鏈路狀態(tài)確認物理鏈路是否穩(wěn)定。4. 端點收到讀請求以后在干什么4.1 接收側(cè)的順序先拆包再判斷是不是自己的菜端點從線上收到數(shù)據(jù)后物理層先還原出符號數(shù)據(jù)鏈路層檢查序列號和 LCRC確認無誤后才把 TLP 提交給事務層。事務層接收模塊要做的第一件事是根據(jù)Fmt/Type判斷報文的類別和方向如果是內(nèi)存讀請求再取出地址和 BAR 空間做匹配。這里有個容易被驅(qū)動程序忽略的點接收側(cè)必須負責歸還 Credit。設備和 RC 一樣會在內(nèi)部實現(xiàn)一個信用接收端口收到 TLP 后按規(guī)則釋放緩沖。如果設備固件或邏輯設計有缺陷收了一個報文后 Credit 沒歸還發(fā)送方就會慢慢卡住直到所有 Credit 耗盡。我調(diào)試 FPGA 板卡時遇到過幾次“開始還能正常讀跑了幾百次以后就徹底沒響應”的情況最后都是 Credit 管理狀態(tài)機寫錯了。4.2 BAR 命中以后數(shù)據(jù)源從哪里來對端點的內(nèi)部實現(xiàn)來說BAR 就像一扇門。門打開了以后內(nèi)部地址偏移決定訪問哪個寄存器或哪塊 RAM。FPGA 實現(xiàn) PCIe 時最常見的做法是把 BAR 空間映射到一組 CSR 寄存器、Block RAM、或者 DMA 描述符區(qū)域。內(nèi)存讀請求攜帶的地址會被內(nèi)部譯碼成寄存器地址或 RAM 地址再經(jīng)過組合邏輯把數(shù)據(jù)取出來。這里有一個特別值得提醒的細節(jié)事務層協(xié)議保證不了數(shù)據(jù)是“新鮮”的保證的只是數(shù)據(jù)能取回。如果設備內(nèi)部讀取邏輯沒有做同步比如寄存器還沒被上游模塊刷新讀請求來了就直接返回舊數(shù)據(jù)軟件是感知不到的只會發(fā)現(xiàn)“為什么我寫進去的值讀出來不對”。這種問題通常要靠補同步邏輯或者加入讀時鐘域處理來解決。4.3 構(gòu)造完成報文時要遵守的拆分規(guī)則端點讀取到數(shù)據(jù)后并不是想怎么回就怎么回。PCIe 協(xié)議對讀完成有兩個硬性約束第一個約束是 Max Payload SizeMPS。如果一次讀請求的 Length 很大而完成者的 MPS 只有 128 字節(jié)它就不能把 256 字節(jié)的數(shù)據(jù)一次性塞進一個 CPLD而是要拆成多個完成報文。第二個約束是 Read Completion BoundaryRCB。RCB 定義了完成報文拆分時必須對齊的地址邊界。RC 的完成者可以通過配置空間把 RCB 選為 64B 或 128B非 RC 的完成者固定是 64B。這也是為什么同一個設備在有些平臺上表現(xiàn)特別好換一個 Root Complex 以后大塊讀性能就掉下來——很可能是 RCB 策略變了。每個拆分出來的 CPLD 都帶有自己的Lower Address和Byte Count請求方拿到這些完成報文后會按地址和 Tag 重組數(shù)據(jù)。編寫驅(qū)動時不要假設第一個返回的 CPLD 一定包含最前面一段數(shù)據(jù)協(xié)議允許完成者按自己的拆包策略返回所以軟件端如果要處理多完成的情況必須按頭部信息重新拼裝。5. 回程邏輯完成報文憑什么能找到“原路返回”的路5.1 完成靠 ID 路由不是靠地址路由這是事務層協(xié)議里最容易被誤解的部分。內(nèi)存讀請求往下走用地址路由大家都好理解但完成報文頭部沒有目標地址它拿什么路由答案是靠Completer ID和Requester ID。完成報文從端點發(fā)出時Completer ID就是端點自己的 BDFRequester ID是當初請求方的 BFD。Switch 收到完成報文后根據(jù) ID 判斷應該往上游端口還是下游端口轉(zhuǎn)最終一路轉(zhuǎn)到根端口。可以這樣理解地址路由是“按收貨地址送貨”ID 路由則是“按客戶編號找寄件人”。快遞單上寫了客戶編號分揀站看一眼編號就知道這個回執(zhí)該回到哪去和寄件時寫的收貨地址沒有直接關系。最關鍵的一點是完成報文只會被 Requester ID 對應的那個請求方接收并處理其他設備即使看到也直接忽略不能劫持更不能當作廣播消息去處理。5.2 Tag 空間不足會讓讀請求“堵車”PCIe 允許一個請求方同時掛多個未完成的讀請求不要求發(fā)一個等一個。每個未完成請求都要占用一個 Tag。Tag 就像餐飲店排隊叫號號發(fā)完了后面的人只能等。標準里 Tag 字段是 8 位因此理論上最多可以有 256 個未完成請求。但實際實現(xiàn)未必用滿很多老設備默認只支持一小部分 Tag需要通過配置空間的 Extended Tag 能力開啟更多 Tag 位。鏈路兩側(cè)必須都支持并正確配置否則請求方很快就會把 Tag 耗盡表現(xiàn)為“新請求發(fā)不出去讀吞吐上不去”。軟件層面還有一個常見誤區(qū)CPU 順序代碼執(zhí)行readl時RC 內(nèi)部很可能會串行處理這些 MMIO 讀不會自動把多個 Tag 用滿。不要指望一個 for 循環(huán)里的幾百次readl就能自動產(chǎn)生幾百個并行 outstanding 請求。優(yōu)化大塊讀性能要依賴 DMA 或者 CPU 端的 non-blocking read 機制不能拿順序讀來硬剛。5.3 狀態(tài)字段里的 UR、CA 和讀超時完成報文的狀態(tài)字段有三種常見編碼需要牢記0表示成功完成1表示 Unsupported RequestUR2表示 Completer AbortCA。當設備收到了一個無法識別的請求比如地址沒命中任何 BAR它可以返回 UR當設備內(nèi)部功能無法完成操作比如模塊被復位或者正在初始化它可以返回 CA。這類錯誤完成最后會通過 RC 轉(zhuǎn)化為軟件可見的總線錯誤或者記錄在 AER 寄存器里。驅(qū)動如果只看到“讀寄存器超時”或“總線錯誤”卻不深入看完成狀態(tài)很容易把問題歸到硬件不穩(wěn)定上去實際上協(xié)議已經(jīng)給出了非常明確的錯誤類型。如果請求發(fā)出后完成報文一直不回來就會進入 Completion Timeout。RC 的 Device Control 2 配置空間里可以設置超時值不同平臺默認值可能很不相同。更隱蔽的情況是設備鏈路過早進入低功耗狀態(tài)比如 L1 或熱插拔之后設備還沒有完成重新初始化讀請求發(fā)出后卡很久才超時。這時需要先看鏈路狀態(tài)而不是盯著驅(qū)動代碼。6. 內(nèi)存讀排障實錄幾個最常見的深坑6.1 讀寄存器總是得到全 (0xFF) 或全 0看到全 (0xFF) 時先冷靜不要直接判斷“設備壞了”。按這個順序排查用setpci查看 Command 寄存器確認 Memory Space Enable 是否置 1。用lspci -vvv查看 BAR 值確定地址分配合法且沒有被其他資源覆蓋。用setpci手動讀 BAR確認硬件返回的不是0xFFFFFFFF。排除設備內(nèi)部存在偏移未實現(xiàn)導致端點返回 FF 的預期行為。全0的現(xiàn)象通常比全FF更麻煩。全FF大概率是地址沒命中或信號懸空全0往往是設備軟核邏輯直接返回了固定值不是協(xié)議層面的問題。這兩種都要結(jié)合設備手冊確認每個偏移位的默認值。6.2 readl 卡死或者直接總線錯誤先查完成路徑readl卡住、進而觸發(fā)系統(tǒng) bus error本質(zhì)是 CPU 等了太久仍然沒有等到 CPLD。按照協(xié)議定位時注意區(qū)分兩種場景MMIO 讀場景下EP 不需要開啟 Bus Master Enable 也能回應 RC 的讀請求因為該請求由 RC 發(fā)起。此時如果無響應優(yōu)先檢查端點是否真的收到了 TLP、內(nèi)部譯碼是否命中、完成邏輯是否正常。DMA 讀場景則反過來EP 主動讀系統(tǒng)內(nèi)存前提是 EP 的 Bus Master Enable 必須打開還要有正確的 IOMMU/SMMU 映射。很多 DMA 讀異常根因都是 DMA 映射沒建立或 BME 沒置位。如果 TLP 已經(jīng)發(fā)出但一直收不到完成建議直接用帶 TLP tracer 的調(diào)試工具抓一次。確認 MRd 有沒有出 RCCPLD 有沒有從 EP 回來。如果只看到 MRd 沒有 CPLD問題基本鎖定在端點側(cè)如果 CPLD 已發(fā)出但 RC 沒匹配上則要考慮 Tag 是否被占用、ID 路由是否正確、完成隊列是否滿了。6.3 大塊讀性能上不去多半是 MPS 和 RCB 的鍋有時測量 PCIe 讀帶寬發(fā)現(xiàn)遠低于鏈路速率但不是鏈路沒訓練好而是事務層參數(shù)不合適。最常見兩個因素第一Max Payload Size 太小。如果 MPS 只有 128B一次 256B 的讀請求會被拆成多個完成報文返回頭部開銷占比和事務數(shù)都會增加吞吐自然上不去。第二RCB 設置不合理。RCB 影響完成報文能否在更寬的邊界上對齊非 RC 設備固定 64BRC 側(cè)可以通過配置選擇 64B 或 128B。用lspci -vvv可以快速看到 MaxPayload 和 RCB 配置。注意鏈路兩側(cè)的 MPS 必須協(xié)商出一個共同值如果一端支持 256B另一端只到 128B最后按最小側(cè)對齊。這個時候不是換根線或者調(diào)驅(qū)動能解決的要看設備固件和 RC 的配置協(xié)商。癥狀重點檢查項常見根因讀返回全 FFBAR、Command、偏移映射內(nèi)存空間未使能或地址沒命中讀卡死/超時TLP 抓包、完成隊列端點未回完成、Credit 未歸還DMA 讀失敗BME、IOMMU 映射總線主控未打開或地址翻譯錯誤讀性能低MPS、RCBPayload 過小或拆分邊界限制最后分享一個我自己的習慣真要把 PCIe 事務層搞透與其背一大堆寄存器名不如實打?qū)嵶ヒ唤M MMIO 讀的 TLP。在 Linux 里對某個 BAR 地址執(zhí)行一次devmem同時在總線上抓包把 MRd 請求頭和 CPLD 完成頭按本文表格逐個字段展開對比一個往返看下來比看十篇文檔都管用。曾經(jīng)有一回我在 FPGA 板卡上故意把一段 BAR 映射內(nèi)部的寄存器做成慢速讀取但沒有在 RTL 里做讀同步結(jié)果 CPU 讀到的總是上一次的舊數(shù)據(jù)最后就是通過對比 TLP 的完成順序發(fā)現(xiàn)的問題。如果你也在做 PCIe EP 的設計這類事務層的細節(jié)早晚會找上門。