與流水線實戰(zhàn):從格式到冒險全解析)
年前幫一個同學準備復試他拿著指令系統(tǒng)的筆記問我五級流水線的公式我背得很熟可一讓我畫執(zhí)行時間圖就亂套到底是哪里出了問題我翻了翻他的草稿紙發(fā)現(xiàn)他幾乎每張時間圖都在同一個地方翻車load-use 該停幾個周期、分支判斷在哪一級、轉(zhuǎn)發(fā)能不能救回來全憑感覺。其實指令系統(tǒng)這門課從來不是背公式而是理解硬件執(zhí)行指令的真實節(jié)奏。這篇文章我想從一條指令的一生講起把指令格式、流水線計算、冒險處理這幾塊串成一條線再結(jié)合我?guī)ы椖俊懩M器時踩過的坑給正在復習備考或者剛接觸體系結(jié)構(gòu)的讀者一些能直接上手的思路。如果你是準備筆試面試或者想搞清楚 CPU 里指令到底怎么跑這篇文章應該能幫你省下不少自我折騰的時間。1. 指令系統(tǒng)不是一張指令表而是硬件與軟件的契約1.1 一條指令的一生從取指到寫回很多人把指令系統(tǒng)理解成一張操作碼表這沒錯但遠遠不夠。一條指令從內(nèi)存里被取出來到最終把結(jié)果寫回中間要經(jīng)過一套固定的流水動作這套動作才是理解整個章節(jié)的鑰匙。以最經(jīng)典的五級流水線為例IF取指根據(jù) PC 從指令存儲器取出指令同時 PC 自增指向下一條。ID譯碼解析指令格式讀出寄存器操作數(shù)順便生成立即數(shù)和控制信號。EX執(zhí)行ALU 干活計算地址、做算術(shù)邏輯運算或者判斷分支條件。MEM訪存訪問數(shù)據(jù)存儲器load/store 在這里完成。WB寫回把結(jié)果寫回寄存器堆。這里有一個非常關(guān)鍵的視角指令格式?jīng)Q定了 ID 階段怎么拆字段流水線冒險決定了 EX 階段要不要等、MEM 階段堵不堵而評論區(qū)經(jīng)常有人問的為什么這條指令要空一拍往往就藏在這五個階段的交接處。1.2 指令集的三個維度格式、尋址、操作一個指令系統(tǒng)設計得好不好通常看三個維度指令格式操作碼、寄存器字段、立即數(shù)字段怎么排列定長還是變長。尋址方式操作數(shù)在哪里是寄存器、立即數(shù)還是通過基址加偏移訪存。操作類型運算、訪存、分支跳轉(zhuǎn)、系統(tǒng)調(diào)用等。這三個維度不是獨立的。舉個例子MIPS 風格的 I 型指令把 16 位立即數(shù)放在低 16 位后續(xù)在 ID 階段做符號擴展這直接影響到了訪存尋址范圍而分支指令的立即數(shù)又被規(guī)定為相對于 PC 的字偏移這讓流水線在 ID 階段就能完成目標地址計算。你拆開任何一個實際指令集都會發(fā)現(xiàn)格式、尋址、操作三者是互相咬合的。1.3 為什么流水線計算要以指令格式為前提我在項目里見過不少人直接把流水線題目當成小學排隊題來算誰先誰后排一下就完事。但一旦你面對真實的指令序列你立刻就會發(fā)現(xiàn)不讀懂指令格式你根本不知道哪條指令寫哪個寄存器、哪條指令讀哪個寄存器、訪存地址來自哪條指令的結(jié)果。舉個最簡單的例子lw $t0, 0($t1) add $t2, $t0, $t3第一條是 I 型訪存指令把內(nèi)存中$t10的值裝入$t0第二條是 R 型運算指令需要讀出$t0和$t3。如果不看指令格式你只會覺得這是兩條普通指令看了格式你才知道第二條指令的rs字段指向了第一條指令的rt字段這是一條 RAW 數(shù)據(jù)冒險命中 load-use 場景。流水線計算的所有起點都來自指令格式這層最基礎的認知。2. 指令格式設計里的門道定長、變長與操作碼擴展2.1 定長格式為什么能省下大量硬件經(jīng)典 RISC 風格的指令比如 MIPS 的 R 型、I 型、J 型都是 32 位定長。定長最大的好處是取指階段不用猜IF 階段拿到 PC 后直接讀一個固定寬度內(nèi)存塊下一條指令地址就是PC4。譯碼階段也能標準化寄存器字段永遠在固定的 bit 區(qū)間控制信號可以從同一組位線里拉出來。有人可能會問32 位全部塞進去一條指令不是太浪費了嗎是的指令密度確實不高但犧牲密度換來的是一整條極其規(guī)整的流水線取指不需要對齊判斷、譯碼不需要邊界掃描、分支目標計算非常簡單。對于教學用的五級流水線來說定長格式是讓你能安心畫時間圖的前提。現(xiàn)代很多開放指令集延續(xù)了這種設計哲學但把格式劃分得更細比如 R 型、I 型、S 型、B 型、U 型、J 型。仔細觀察會發(fā)現(xiàn)rs、rd 這些關(guān)鍵字段在每種格式中的位置是刻意固定下來的這樣 ID 階段可以少接很多交叉選擇邏輯后端布線也輕松不少。這個固定寄存器位置的細節(jié)是你在對比不同指令集時值得留意的。2.2 變長指令的代價與收益CISC 風格的指令則走另一條路指令長度從 1 字節(jié)到 15 字節(jié)不等靠操作碼前綴和可選的地址字段決定最終長度。好處是代碼密度高同樣的功能可能比定長指令少占用不少存儲空間代價就是 IF 階段要先看第一個字節(jié)才能判斷后面還有多長譯碼單元要處理一堆邊界情況流水線前端的壓力直線上升。解決變長指令譯碼問題最常見的招數(shù)是讓硬件猜一個預解碼邊界或者把復雜指令在譯碼階段拆成多條微操作后續(xù)還是走類 RISC 的流水核心。你在真實項目里看到的各種前端指令隊列微操作緩存本質(zhì)上都是在緩沖變長指令給取指和譯碼帶來的不確定性。理解這一點對你理解為什么很多 CPU 內(nèi)部是外 CISC、內(nèi) RISC非常有幫助。2.3 操作碼擴展16位指令如何裝下更多操作指令格式這個章節(jié)里操作碼擴展是我覺得最貼近考試思路的一個點。假設指令長度只有 16 位寄存器地址占 4 位如果全部用二地址指令操作碼占16 - 4 - 4 8位最多可以定義 256 種二地址指令。但如果想同時支持三地址、二地址、單地址甚至無操作數(shù)指令就需要操作碼擴展讓部分操作碼值作為逃逸碼表示后面的位段不再是純地址而是擴展出來的操作碼。一個經(jīng)典的例子0000開頭且后續(xù) 4 位再作為擴展操作碼可以表達更多指令0001、0010等其他操作碼值直接定義二地址指令。操作碼位段地址/立即數(shù)安排可編碼數(shù)量4位操作碼前3種指向二地址指令rs(4位) rt(4位) imm(4位)每種操作碼搭配4位立即數(shù)組合0000 作為擴展前綴再取后續(xù)4位作為擴展操作碼在不占用普通地址的前提下擴展指令空間這種設計的本質(zhì)是用犧牲地址位數(shù)換操作碼位數(shù)而且必須從短操作碼到長操作碼逐級分配否則會出現(xiàn)編碼沖突。做這類題的時候我常提醒自己先從使用頻率最高的指令開始分配短操作碼把擴展路徑留給低頻指令這樣編碼空間利用率最高。2.4 立即數(shù)符號擴展的常見理解偏差指令格式里最容易栽跟頭的是立即數(shù)的符號擴展。很多人以為 16 位立即數(shù)就是正的 16 位數(shù)值但實際在 RISC 風格中I 型立即數(shù)按有符號數(shù)處理最高位是符號位在 ID 階段需要把 16 位擴展成 32 位再交給 ALU 或者地址計算單元。為什么這么設計因為訪存指令的偏移既可能是正數(shù)也可能是負數(shù)。比如訪問棧上的數(shù)據(jù)$sp是基址-8(%sp)這種負偏移非常常見。如果立即數(shù)被當成無符號數(shù)那地址空間只能往高地址方向偏移很多程序結(jié)構(gòu)就沒法表達。還有一個容易忽略的點分支指令的立即數(shù)通常被規(guī)定為相對于下一條指令 PC 的偏移并且在 RISC-V 這類設計中是以半字或字為單位的實際跳轉(zhuǎn)時要乘以固定倍數(shù)。做題時如果忘記這個倍數(shù)算出的分支目標地址往往差出幾條指令時間圖畫出來也是錯的。3. 流水線計算先把時間圖畫對再談優(yōu)化3.1 五級流水線的關(guān)鍵路徑流水線計算的第一個基本功是搞清楚每個時鐘周期里各個階段可以并行工作。五級流水線里第 1 周期 IF 取第一條指令第 2 周期第一條指令進入 ID同時 IF 取第二條指令。理想情況下一個新周期就有一條新指令進入流水線直到最后一條指令完成寫回。如果每條指令花費一級對應的延時時單條指令需要 5 個周期走完但 n 條指令重疊執(zhí)行總周期數(shù)不是5n而是總周期數(shù) n 4為什么是 4因為第一條指令從 IF 走到 WB 需要 5 個周期但后面 n-1 條指令都已經(jīng)跟在流水線上最后一條指令從進入 IF 到 WB 還要額外 4 個周期。凡是在這里算成5 n的基本上都多算了一個周期。這里必須提醒一句總周期數(shù)只跟級數(shù)和指令條數(shù)有關(guān)跟每條指令實際用不用某級無關(guān)。比如分支指令在 ID 階段就能判斷是否跳轉(zhuǎn)它后面其實不需要完整的 MEM、WB 功能但做題時為了簡化模型仍會把它畫成占滿五級少占一級會導致控制邏輯復雜化教材和考試通常不這么做。3.2 一個包含轉(zhuǎn)發(fā)與停頓的完整推演下面這段指令序列是我給項目組新同學講時間圖時最愛用的例子lw $t0, 0($t1) add $t2, $t0, $t3 sub $t4, $t2, $t0采用常見教學模型五級流水線、有轉(zhuǎn)發(fā)、load-use 需要停頓 1 個周期。完整時間圖如下周期12345678lw$t0,0($t1)IFIDEXMEMWBadd$t2,$t0,$t3IFIDstallEXMEMWBsub$t4,$t2,$t0IFIDEXMEMWB關(guān)注第 4 周期add 已經(jīng)進入 EX 階段但它需要的$t0要等 lw 的 MEM 階段結(jié)束才能拿到。即使有轉(zhuǎn)發(fā)load 的數(shù)據(jù)也只有在 MEM/WB 寄存器上才算真正可用所以 EX 必須往后挪一個周期這就是 load-use 停頓。到了第 5 周期add 執(zhí)行 EXsub 也在同一周期進入 ID第 6 周期 add 的 EX 結(jié)果已經(jīng)產(chǎn)生sub 的 EX 通過轉(zhuǎn)發(fā)拿到$t2不需要額外停頓。3 條指令總完成周期是 8理想流水線周期數(shù)是347多出來的那 1 個周期就是 load-use 停頓造成的。逐條執(zhí)行需要3×515個周期流水線加速比為加速比 15 / 8 1.875如果沒有停頓的理想流水線加速比本應是15 / 7 ≈ 2.14。這一條 load-use損失了整整 0.27 的加速比聽起來不多但在真實程序中 load 指令占比往往超過兩成累積效應非??捎^。3.3 加速比、吞吐率與CPI的計算口徑筆試里常見的計算還有三類加速比、吞吐率、CPI。我按自己的理解整理如下加速比 單條串行總時間 / 流水線實際總時間。當各階段延遲相同時等價于周期數(shù)之比。吞吐率 單位時間內(nèi)完成的指令數(shù)常用n / 總周期數(shù)表示。CPI 總周期數(shù) / 指令條數(shù)。理想流水線 CPI 可以接近 1但停頓和清空會把 CPI 拉高。假設時鐘周期是 2ns上面 3 條指令實際總時間是8×216ns吞吐率是3/16ns ≈ 0.1875 條/nsCPI 是8/3 ≈ 2.67。注意蝦如果你在試卷上只寫公式不寫假設很容易被扣分我在輔導時強烈建議先寫出本文采用什么模型再做數(shù)值計算。流水線的極限加速比等于流水線級數(shù)五級流水線理想加速比趨近于 5但現(xiàn)實中還要考慮寄存器鎖存延遲、最慢級延遲、冒險停頓實際能達到 4 左右已經(jīng)相當不錯。做加速比題時先確認每級延遲相等還是不相等如果最慢級是訪存階段時鐘周期會被它拉長加速比計算方式完全不同。4. 三類流水線冒險的實戰(zhàn)拆解4.1 數(shù)據(jù)冒險RAW是主旋律數(shù)據(jù)冒險說白了就是后面的指令要用前面的結(jié)果但前面的結(jié)果還沒寫進寄存器。按寫后讀、讀后寫、寫后寫三種依賴關(guān)系分別叫 RAW、WAR、WAW。在按序執(zhí)行的五級流水線里WAR 和 WAW 幾乎不會出現(xiàn)因為每條指令都是按先后順序發(fā)射、按先后順序?qū)懟睾笠粭l指令不可能超前完成。真正頻繁出現(xiàn)的是 RAW比如add $t0, $t1, $t2后面緊接sub $t3, $t0, $t1sub 要在 EX 階段讀$t0而 add 的結(jié)果要等 EX 階段結(jié)束才能從 EX/MEM 寄存器轉(zhuǎn)發(fā)過來。轉(zhuǎn)發(fā)forwarding的核心思想是不等到 WB 階段寫回寄存器堆直接把上一階段的結(jié)果從旁路送到 ALU 輸入端。這要求 EX/MEM 和 MEM/WB 這兩組流水線寄存器把結(jié)果泄漏出來通過多路選擇器接到 ALU 的輸入。代價是硬件走線變多收益是絕大多數(shù) RAW 不再需要停頓。但有一個例外load-use。如果前一條是 lw后一條馬上要用 lw 的結(jié)果做運算轉(zhuǎn)發(fā)也救不了。因為 lw 的結(jié)果要到 MEM 階段結(jié)束才出現(xiàn)在 MEM/WB 寄存器上而后一條指令的 EX 階段緊跟著就要用數(shù)據(jù)。唯一的辦法是讓后一條指令在 ID 階段多停一拍這就是我們在 3.2 里看到的那個 stall。4.2 結(jié)構(gòu)冒險訪存沖突只有一個出口結(jié)構(gòu)冒險由硬件資源沖突引發(fā)最常見的是指令存儲器和數(shù)據(jù)存儲器共用同一塊存儲。五級流水線中 IF 階段要取指令MEM 階段要訪存數(shù)據(jù)如果這兩者同時發(fā)生而存儲系統(tǒng)只有一個讀端口那就撞車了。解決方案有三種思路把指令存儲器和數(shù)據(jù)存儲器分開也就是哈佛結(jié)構(gòu)。IS 和 DS 各用獨立緩存通過緩存設計避免物理端口沖突。在硬件層面讓訪存操作優(yōu)先取指暫停一個周期。實際項目中幾乎都用分離緩存來消除這個冒險。但考試題有時候偏偏不告訴你是不是分離存儲而是讓你根據(jù)已知信息判斷有沒有結(jié)構(gòu)冒險。拿到類似題目先看題干有沒有指令與數(shù)據(jù)共用存儲器的說明沒有默認不考有的話就要在 IF 和 MEM 重疊的周期里插入停頓。4.3 控制冒險分支的代價由誰承擔控制冒險是由分支指令帶來的。流水線在分支結(jié)果出來之前只能按順序取后續(xù)指令如果分支判定是跳轉(zhuǎn)那之前取進來的后續(xù)指令就白取了必須清空。分支判斷的位置決定了控制冒險的代價。經(jīng)典教材里有兩種常見設定分支在 ID 階段判斷分支指令后面已經(jīng)取了一條指令若跳轉(zhuǎn)清空 1 條代價 1 個周期。分支在 EX 階段判斷分支指令后面已經(jīng)取了兩條指令若跳轉(zhuǎn)清空 2 條代價 2 個周期。我見過不少學生習慣性只記分支停頓 1 拍結(jié)果題目里明確寫了分支在 EX 階段判斷也照樣填 1這屬于審題事故??荚嚽耙欢ㄒ杨}干中的模型假設單獨圈出來分階段數(shù)清周期。分支延遲槽是另一種思路把分支指令后的一條指令放到無條件執(zhí)行的位置無論跳不跳轉(zhuǎn)都執(zhí)行。延遲槽里的指令最好是一條無關(guān)指令這樣分支清空時不會損失有用工作。這個技術(shù)在老牌 RISC 架構(gòu)里很常見而現(xiàn)在更主流的做法是硬件分支預測預測不跳轉(zhuǎn)或者用分支歷史記錄來預測猜錯了再沖刷。4.4 出題人常埋的三個陷阱第一分不清分支判斷級和分支完成寫回級。有的題畫周期圖時把分支指令畫到 MEM 才判斷這會讓后續(xù)清空范圍擴大但大多數(shù)模型在 ID 就完成了比較。第二搞混寄存器堆的讀寫時序。有些模型允許同一周期先寫后讀load 的 WB 和后續(xù)指令的 ID 可以在同一周期發(fā)生數(shù)據(jù)冒險的停頓數(shù)會減少有些模型不允許就要多停。在做任何數(shù)據(jù)冒險分析前先問自己這個題目里寄存器堆是讀寫同時還是讀舊寫新。第三忽略指令間的間接依賴。比如add $t0, $t1, $t2、sub $t3, $t0, $t1、and $t4, $t3, $t1這種鏈式依賴第 2 條通過轉(zhuǎn)發(fā)恢復后第 3 條可能還得等第 2 條的 EX 結(jié)果不能想當然全部清零。5. 寫模擬器時的實操經(jīng)驗與驗證工具5.1 轉(zhuǎn)發(fā)單元的判定條件我在自己寫教學模擬器時最不容易一次寫對的就是轉(zhuǎn)發(fā)邏輯。轉(zhuǎn)發(fā)條件不能只看寄存器編號相等還得看上游指令到底會不會寫寄存器、以及目標寄存器是否為 0 號。以 ALU 兩個輸入端的轉(zhuǎn)發(fā)為例第一個輸入端的選擇邏輯大概是// 來自 EX/MEM 階段的上游結(jié)果 if (EX/MEM.RegWrite (EX/MEM.RegisterRd ! 0) (EX/MEM.RegisterRd ID/EX.RegisterRs1)) begin ForwardA 2b10; end // 來自 MEM/WB 階段的上游結(jié)果 else if (MEM/WB.RegWrite (MEM/WB.RegisterRd ! 0) (MEM/WB.RegisterRd ID/EX.RegisterRs1)) begin ForwardA 2b01; end第一個條件為什么優(yōu)先因為 EX/MEM 的結(jié)果更新晚到的 MEM/WB 結(jié)果可能已經(jīng)被覆蓋如果兩條上游指令寫了同一個寄存器必須以最近的為準。這種優(yōu)先級問題是模擬器里最常見的 bug 來源調(diào)試的時候你會看到寄存器值明明被算出來了可 EX 階段用的還是舊值。5.2 用Python腳本輔助核對周期表畫時間圖很繁瑣我后來寫了個小腳本自動生成理論周期表主要作用是驗證手工推演。思路很簡單先給每條指令標注它的源寄存器、目標寄存器和類型然后模擬五級流水線逐周期推進一旦檢測到 load-use 就插入一個 stall。# 示意腳本按五級模型計算每條指令的周期區(qū)間 def calc_cycles(insts): cycles [] stall_penalty 0 for idx, inst in enumerate(insts): issue idx 1 stall_penalty if idx 0 and is_load_use(insts[idx - 1], inst): stall_penalty 1 issue idx 1 stall_penalty cycles.append((issue, issue 4)) return cycles這個腳本當然沒有處理復雜分支預測但對于純順序執(zhí)行、只考慮數(shù)據(jù)冒險的入門級模擬它能非??焖俚乇┞妒止ぎ媹D中漏停的問題。我在項目里把這個腳本當成測試基準手推一遍腳本跑一遍兩邊對上才敢往下寫 RTL 代碼。5.3 踩過的坑寄存器寫回時序與指令邊界寫模擬器時最容易踩的第三個坑是寄存器寫回發(fā)生在 WB 階段的哪個邊沿。如果你把寫回放在周期結(jié)束時的下降沿而讀寄存器放在 ID 階段上升沿那么 load 指令 WB 和后續(xù)指令 ID 在同一周期相遇時后續(xù)指令讀到的到底是不是新值這個完全由模擬器時序模型決定。我的經(jīng)驗是在做項目之前先用最簡單的一條lw加一條add做回歸測試如果周期數(shù)比預期多一拍先查寄存器堆讀寫時序而不是急著改轉(zhuǎn)發(fā)邏輯。還有指令邊界問題定長指令很容易處理PC4完事變長指令模擬起來就得先解析前綴再決定下一條取指地址。很多模擬器在實現(xiàn)分支目標地址時也會踩坑因為分支偏移量是相對于當前指令的下一條指令計算的不是相對于當前指令地址寫代碼時少減一個 4分支目標就全錯了。5.4 高頻考點與臨場答題思路最后把我總結(jié)的高頻考點列一下方便你自查考點常見題型答題要點指令格式給出二進制位段識別操作碼、寄存器、立即數(shù)先按題給格式表拆字段再符號擴展操作碼擴展16位系統(tǒng)能容納多少條指令從短操作碼到長操作碼逐級分配流水線周期給定指令序列畫時間圖先標依賴再定冒險類型最后排周期加速比/CPI給定停頓數(shù)計算性能指標明確理想周期數(shù)n4再加停頓分支代價不同判定級下的清空數(shù)看題干是 ID 判斷還是 EX 判斷硬件轉(zhuǎn)發(fā)描述轉(zhuǎn)發(fā)單元邏輯注意就近優(yōu)先、RegWrite、非0寄存器臨場答題時我的習慣是拿到一段匯編先做三件事先圈出所有寫$t0這類目的寄存器的地方再圈出所有讀它們的地方最后對照流水線階段算一下間隔。只要這三步做扎實數(shù)據(jù)冒險和周期圖基本不會錯。指令系統(tǒng)這塊內(nèi)容說到底是格式、冒險、周期三個詞的游戲。格式讓你看清指令在干什么冒險告訴你硬件會在哪里等周期計算則是把前兩件事?lián)Q算成可比較的數(shù)字。我在這篇文章里講的轉(zhuǎn)發(fā)條件、分支假設、寄存器堆時序都是在實際寫模擬器和做項目時反復確認過的東西。你在自己學習或備戰(zhàn)面試時與其抱著公式死記不如多畫幾張時間圖拿小段匯編親自動手推一遍推錯幾輪之后你對指令系統(tǒng)的理解會比背十遍筆記都扎實。