最近些年。RISC-V引起了全球關注。這款革命性的 ISA 憑借其持續的創新,以及無數的學習和工具資源以及來自工程界的貢獻,像潮水般席卷了市場。RISC-V 最大的魅力在于它是一款開源 ISA。
在本文中,我(指代本文作者Mitu Raj,下同)將介紹如何從零開始設計一款RISC-V CPU ,我們將講解定義規格、設計和改進架構、識別和解決挑戰、開發 RTL、實現 CPU 以及在仿真/FPGA 板上測試 CPU 的流程。
從命名開始
為你的想法命名或打造品牌至關重要,這樣才能激勵你不斷前進,直至達成目標!我們打算構建一個非常簡單的處理器,所以我想出了一個花哨的名字“ Pequeno ”,在西班牙語中是“微小”的意思;完整名稱是:Pequeno RISC-V CPU,又名PQR5。
RISC-V 的 ISA 架構有多種風格和擴展。我們先從最簡單的RV32I開始,它又稱為 32 位基本整數 ISA。該 ISA 適用于構建支持整數運算的 32 位 CPU。因此,Pequeno 的第一個規格如下:
Pequeno 是一款 32 位 RISC-V CPU,支持 RV32I ISA。
RV32I 有 37 條 32 位基本指令,我們計劃在 Pequeno 中實現。因此,我們必須深入了解每條指令。我費了一番功夫才完全掌握了 ISA。在此過程中,我學習了完整的規范,并設計了自己的匯編程序pqr5asm,并與一些流行的 RISC-V 匯編程序進行了驗證。
“RISBUJ”
上面六個字母的單詞總結了 RV32I 中的指令類型。這 37 條指令屬于以下類別之一:
R型:所有寄存器上的整數計算指令。
I 型:所有基于寄存器和立即數的整數計算指令。還包括 JALR 和 Load 指令。
S型:全部存儲說明。
B型:所有分支指令。
U型:LUI、AUIPC等特殊指令。
J型:類似JAL的跳轉指令。
RISC-V 架構中有 32 個通用寄存器,x0-x31. 所有寄存器都是 32 位的。在這 32 個寄存器中,零又稱為x0寄存器,是一個很有用的特殊寄存器,它被硬連線為零,無法寫入,并且始終讀取為零。那么它有什么用呢?你可以使用x0作為虛擬目標來轉儲您不想讀取的結果,或用作操作數零,或生成 NOP 指令來閑置 CPU。
整數計算指令是針對寄存器和/或12位立即數執行的ALU指令。加載/存儲指令用于在寄存器和數據存儲器之間存儲/加載數據。跳轉/分支指令用于將程序控制轉移到不同的位置。
每條指令的詳細信息可以在 RISC-V 規范中找到:RISC-V 用戶級 ISA v2.2。
要學習 ISA,RISC-V 規范文檔就足夠了。不過,為了更清晰起見,您可以研究一下 RTL 中不同開放核心的實現。
除了 37 條基本指令外,我還為 pqr5asm 添加了 13 條偽/自定義指令,并將 ISA 擴展至 50 條指令。這些指令源自基本指令,旨在簡化匯編程序員的工作……例如:
NOP指令與ADDI x0, x0, 0這在CPU上當然什么也不做!但它更簡單,更容易在代碼中解釋。
在開始設計處理器架構之前,我們的期望是完全了解每條指令如何以 32 位二進制進行編碼以及它的功能是什么。
我用 Python 開發的 RISC-V RV32I 匯編器 PQR5ASM 可以在我的 GitHub 上找到。您可以參考《匯編器指令手冊》編寫示例匯編代碼。編譯它,并查看它如何轉換為 32 位二進制文件,以便在繼續下一步之前鞏固/驗證您的理解。
規格和架構
在本章中,我們定義了 Pequeno 的完整規格和架構。上次我們只是簡單地將其定義為 32 位 CPU。接下來,我們將對其進行更詳細的介紹,以大致了解即將設計的架構。
我們將設計一個簡單的單核 CPU,它能夠按照獲取指令的順序一次執行一條指令,但仍采用流水線方式。我們不支持 RISC-V 特權規范,因為我們目前不打算讓我們的核心操作系統支持該規范,也不打算讓它支持中斷。
該CPU規格如下:
32位CPU,單發射,單核。
經典的五級 RISC 流水線。嚴格有序流水線。
符合RV32I 用戶級 ISA v2.2。支持全部 37 條基本指令。
用于指令和數據存儲器訪問的獨立總線接口。(為什么?以后再討論……)
適用于裸機應用程序,不支持操作系統和中斷。(更確切地說是限制!)
正如上文所述,我們將支持 RV32I ISA。因此,CPU 僅支持整數運算。
CPU 中的所有寄存器都是 32 位的。地址和數據總線也是 32 位的。CPU 采用經典的小端字節尋址內存空間。每個地址對應于 CPU 地址空間中的一個字節。
0x00 - byte[7:0], 0x01 - byte[15:8] ...
32 位字可以通過 32 位對齊的地址訪問,即 4 的倍數的地址:
0x00—— byte 0,0x04—— byte 1……
Pequeno 是一款單發射 CPU,即每次只從內存中獲取一條指令,并發出指令進行解碼和執行。采用單發射的流水線處理器的最大IPC = 1(或最小/最佳CPI = 1),即最終目標是以每時鐘周期 1 條指令的速率執行。這在理論上是可以實現的最高性能。
經典的五級 RISC 流水線是理解任何其他 RISC 架構的基礎架構。這對于我們的 CPU 來說是最理想且最簡單的選擇。Pequeno 的架構就是圍繞這種五級流水線構建的。讓我們深入探討一下其底層概念。
簡單起見,我們將不支持 CPU 流水線中的計時器、中斷和異常。因此,CSR 和特權級別也無需實現。因此, RISC-V 特權 ISA不包含在 Pequeno 的當前實現中。
設計 CPU 最簡單的方法是非流水線方式。讓我們看看非流水線 RISC CPU 的幾種設計方法,并了解其缺點。
讓我們假設 CPU 執行指令所遵循的經典步驟序列:獲取、解碼、執行、內存訪問和寫回。
第一種設計方法是:將 CPU 設計成一個具有四到五個狀態的有限狀態機 (FSM),并按順序執行所有操作。例如:
但這種架構會嚴重影響指令執行速度。因為執行一條指令需要多個時鐘周期。比如,寫入寄存器需要 3 個時鐘周期。如果是加載/存儲指令,內存延遲也會隨之增加。這是一種糟糕且原始的 CPU 設計方法。我們徹底拋棄它吧!
第二種方法是:指令可以從指令存儲器中取出,解碼,然后由完全組合邏輯執行。然后,ALU 的結果被寫回到寄存器文件。直到寫回的整個過程可以在一個時鐘周期內完成。這樣的 CPU 稱為單周期 CPU。如果指令需要訪問數據存儲器,則應考慮讀/寫延遲。如果讀/寫延遲為一個時鐘周期,則存儲指令仍可能像所有其他指令一樣在一個時鐘周期內完成執行,但加載指令可能額外需要一個時鐘周期,因為必須將加載的數據寫回到寄存器文件。PC 生成邏輯必須處理這種延遲的影響。如果數據存儲器讀取接口是組合的(異步讀取),則 CPU 對于所有指令都將真正變為單周期。
該架構的主要缺點顯然是從取指到寫入存儲器/寄存器文件的組合邏輯關鍵路徑較長,這限制了時序性能。然而,這種設計方法簡單,適用于低端微控制器中那些需要低時鐘速度、低功耗和低面積的CPU。
為了實現更高的時鐘速度和性能,我們可以將 CPU 的指令順序處理功能分離出來。每個子進程被分配給獨立的處理單元。這些處理單元按順序級聯,形成流水線。所有單元并行工作,并對指令執行的不同部分進行操作。通過這種方式,可以并行處理多條指令。這種實現指令級并行性的技術稱為指令流水線。該執行流水線構成了流水線 CPU 的核心。
經典的五級 RISC 流水線有五個處理單元,也稱為流水線階段。這些階段分別是:取指(IF)、解碼(ID)、執行(EX)、內存訪問(MEM)、寫回(WB)。流水線的工作原理可以直觀地表示為:
每個時鐘周期,一條指令的不同部分會被處理,并且每個階段都會處理不同的指令。如果仔細觀察,會發現只有第 5 個周期,指令 1 才完成執行。這段延遲被稱為流水線延遲。Δ此延遲與流水線級數相同。在此延遲之后,第 6 個周期:指令 2 執行完畢,第 7 個周期:指令 3 執行完畢,依此類推……理論上,我們可以計算吞吐量(每周期指令數,IPC),如下所示:
因此,流水線CPU保證每個時鐘周期執行一條指令。這是單發射處理器中可能的最大IPC。
通過劃分多個流水線階段的關鍵路徑,CPU 現在也可以以更高的時鐘速度運行。從數學上講,這使得流水線 CPU 的吞吐量比同等的非流水線 CPU 提高了一個倍數。
這被稱為流水線加速。簡單來說,一個具有s階段流水線 CPU 的時鐘速度是非流水線產品的S倍。
流水線通常會增加面積/功耗,但性能提升是值得的。
數學計算假設流水線永遠不會停滯,也就是說,數據在每個時鐘周期內都會從一個階段持續傳輸到另一個階段。但在實際的 CPU 中,流水線可能會由于多種原因而停滯,主要原因是結構/控制/數據依賴性。
舉個例子:寄存器X不能被Nth指令讀到,因為X并不是由(N-1)th指令修改了X讀回,這是流水線中數據風險的一個例子。
Pequeno 的架構采用了經典的五級 RISC 流水線。我們將實現嚴格的順序流水線。在順序處理器中,指令的獲取、解碼、執行和完成/提交都按照編譯器生成的順序進行。如果一條指令停滯,整個流水線都會停滯。
在亂序處理器中,指令按照編譯器生成的順序獲取和解碼,但執行可以按不同的順序進行。如果一條指令停頓,除非存在依賴關系,否則它不會停頓后續指令。獨立的指令可以向前傳遞。執行仍然可以按順序完成/提交(這就是當今大多數CPU的現狀)。這為實現各種架構技術打開了大門,通過減少停頓所浪費的時鐘周期并最大限度地減少氣泡的插入(什么是“氣泡”?繼續閱讀……) ,顯著提高吞吐量和性能。
亂序處理器由于指令的動態調度而相當復雜,但現在已成為當今高性能 CPU 中事實上的流水線架構。
五個流水線階段被設計為獨立單元:取指單元(FU)、譯碼單元(DU)、執行單元(EXU)、內存訪問單元(MACCU)和寫回單元(WBU)。
取指單元(FU):流水線的第一級,與指令存儲器接口。FU 從指令存儲器中取指并送至譯碼單元。FU 可能包含指令緩沖區、初始分支邏輯等。
解碼單元(DU):流水線的第二階段,負責解碼來自執行單元 (FU) 的指令。DU 還會啟動對寄存器文件的讀取訪問。來自 DU 和寄存器文件的數據包被重新定時同步,并一起發送到執行單元 (Execution Unit)。
執行單元(EXU):流水線的第三階段,用于驗證并執行來自 DU 的所有解碼指令。無效/不支持的指令不允許在流水線中繼續執行,它們會成為“氣泡”。算術單元 (ALU)負責所有整數算術和邏輯指令。分支單元 (Branch Unit)負責處理跳轉/分支指令。加載/存儲單元 (Load-Store Unit)負責處理需要訪問內存的加載/存儲指令。
內存訪問單元(MACCU):流水線的第四級,用于與數據存儲器接口。MACCU 負責根據 EXU 的指令發起所有內存訪問。數據存儲器是尋址空間,可能由數據 RAM、內存映射的 I/O 外設、橋接器、互連等組成。
寫回單元(WBU):流水線的第五級或最后一級。指令在此完成執行。WBU 負責將 EXU/MACCU 中的數據(加載數據)寫回寄存器文件。
在流水線階段之間,實現了有效-就緒握手。乍一看這并不那么明顯。每個階段都會注冊一個數據包并將其發送到下一階段。該數據包可能是下一階段或后續階段要使用的指令/控制/數據信息。該數據包通過有效信號進行驗證。如果數據包無效,則在流水線中稱為氣泡(Bubble)。氣泡只不過是流水線中的“洞”(hole),它只是在流水線中向前移動,實際上不執行任何操作。這類似于 NOP 指令。但不要認為它們沒有用!在后續部分討論流水線風險時,我們將看到它們的一種用途。下表定義了 Pequeno 指令流水線中的氣泡。
每個階段還可以通過發出停頓信號來停頓前一個階段。一旦停頓,該階段將保留其數據包,直到停頓狀態消失。此信號與反轉的就緒信號相同。在順序處理器中,任何階段產生的停頓都類似于全局停頓,因為它最終會停頓整個流水線。
flush信號用于刷新管道。刷新操作將一次性使之前階段注冊的所有數據包失效,因為它們被識別為不再有用。
舉個例子,當流水線在執行跳轉/分支指令后,從錯誤的分支獲取并解碼了指令,而該指令僅在執行階段被識別為錯誤時,流水線應該被刷新,并從正確的分支獲取指令!
雖然流水線顯著提升了性能,但也增加了 CPU 架構的復雜性。CPU 的流水線技術總是伴隨著它的孿生兄弟——流水線風險!現在,我們假設我們對流水線風險一無所知。我們在設計架構時并沒有考慮風險。
處理流水線風險
在本章中,我們將探討流水線風險。我們上次成功設計了 CPU 的流水線架構,但卻沒有考慮到伴隨流水線而來的“邪惡雙胞胎”。流水線風險對架構可能造成哪些影響?需要進行哪些架構修改來緩解這些風險?讓我們繼續,揭開它們的神秘面紗!
CPU 指令流水線中的危險是指一些依賴關系,這些依賴關系會干擾流水線的正常執行。當危險發生時,指令無法在指定的時鐘周期內執行,因為這可能導致錯誤的計算結果或控制流。因此,流水線可能會被迫暫停,直到指令能夠成功執行。
在上面的例子中,CPU 按照編譯器生成的順序按序執行指令。假設指令 i2對i1有一定的依賴性,比如i2需要讀取某個寄存器,但該寄存器也正在被前一條指令i1修改。因此,i2必須等到i1將結果寫回寄存器文件,否則舊數據將被解碼并從寄存器文件讀取,供執行階段使用。為了避免這種數據不一致,i2被強制暫停三個時鐘周期。流水線中插入的氣泡表示暫停或等待狀態。只有當i1完成時,i2才會被解碼。最終,i2在第 10 個時鐘周期而不是第 7 個時鐘周期完成執行。由于數據依賴性導致的暫停,引入了三個時鐘周期的延遲。這種延遲如何影響 CPU 性能?
理想情況下,我們期望 CPU 以滿吞吐量運行,即 CPI = 1。但是,當流水線暫停時,由于 CPI 增加,CPU 的吞吐量/性能會降低。對于非理想 CPU:
管道中發生危險的方式多種多樣。管道危險可分為三類:
結構性危險
控制危害
數據危害
結構性風險是由于硬件資源沖突而發生的。例如,當流水線的兩個階段想要訪問同一資源時。例如:兩條指令需要在同一時鐘周期內訪問內存。
在上面的例子中,CPU 只有一個內存用于存儲指令和數據。取指階段每個時鐘周期都會訪問內存以獲取下一條指令。因此,如果內存訪問階段的上一條指令也需要訪問內存,則取指階段和內存訪問階段的指令可能會發生沖突。這將迫使 CPU 增加停頓周期,取指階段必須等待,直到內存訪問階段的指令釋放資源(內存)。
減輕結構性危險的一些方法包括:
暫停管道,直到資源可用。
復制資源,這樣就不會發生任何沖突。
流水線資源,使得兩條指令將處于流水線資源的不同階段。
讓我們分析一下可能導致 Pequeno 管道出現結構性危險的不同情況,以及如何解決。 我們無意使用停工作為緩解結構性危險的選項!
在 Pequeno 的架構中,我們實施了上述三種解決方案來減輕各種結構性危險。
控制風險是由跳轉/分支指令引起的。跳轉/分支指令是 CPU ISA 中的流程控制指令。當控制權到達跳轉/分支指令時,CPU 必須決定是否執行該分支指令。此時,CPU 應該采取以下操作之一。
在 PC+4 處獲取下一條指令(不執行分支)或獲取分支目標地址處的指令(分支已執行)。
只有在執行階段計算分支指令的結果時,才能判斷決策的正確與否。根據分支是否被執行,確定分支地址(CPU 應該分支到的地址)。如果之前做出的決策是錯誤的,那么在該時鐘周期之前在流水線中獲取和解碼的所有指令都應該被丟棄。因為這些指令根本不應該被執行!這是通過刷新流水線并在下一個時鐘周期獲取分支地址的指令來實現的。刷新使指令無效并將其轉換為 NOP 或冒泡。這會花費大量的時鐘周期作為懲罰。這被稱為分支懲罰。因此,控制冒險對 CPU 性能的影響最嚴重。
在上面的例子中,i10在第 10 個時鐘周期完成了執行,但它應該在第 7 個時鐘周期完成執行。由于執行了錯誤的分支指令 (i5),因此損失了 3 個時鐘周期。當執行階段在第 4 個時鐘周期識別出錯誤分支指令時,必須在流水線中進行刷新。這會如何影響 CPU 性能?
如果在上述 CPU 上運行的程序包含 30% 的分支指令,則 CPI 將變為:
CPU 性能降低50%!
為了減輕控制風險,我們可以在架構中采用一些策略……
如果指令被識別為分支指令,則只需暫停流水線即可。該解碼邏輯可以在提取階段本身實現。一旦執行了分支指令并解析了分支地址,就可以提取下一條指令并恢復流水線。
在 Fetch 階段添加類似分支預測的專用分支邏輯。
分支預測的本質是:我們在取指階段采用某種預測邏輯來猜測分支是否應該被執行。在下一個時鐘周期,我們獲取猜測的指令。這條指令要么從 PC+4 處獲取(預測分支不被執行),要么從分支目標地址處獲取(預測分支被執行)。現在有兩種可能性:
如果在執行階段發現預測正確,則不執行任何操作,管道可以繼續處理。
如果發現預測錯誤,則刷新流水線,從執行階段解析的分支地址中獲取正確的指令。這會產生分支懲罰。
如您所見,分支預測如果預測錯誤,仍然會招致分支懲罰。設計目標應該是降低錯誤預測的概率。CPU 的性能很大程度上取決于預測算法的“好壞”。像動態分支預測這樣的復雜技術會保存指令歷史記錄,以便以 80% 到 90% 的概率進行正確預測。
為了減輕 Pequeno 中的控制風險,我們將實現一個簡單的分支預測邏輯。更多細節將在我們即將發布的關于提取單元設計的博客中揭曉。
當一條指令的執行對流水線中仍在處理的上一條指令的結果存在數據依賴時,就會發生數據風險。讓我們通過示例來了解三種類型的數據風險,以便更好地理解這個概念。
假設一條指令i1將結果寫入寄存器 x。下一條指令i2也將結果寫入同一寄存器。程序順序中的任何后續指令都應讀取 x 處i2的結果。否則,數據完整性將受損。這種數據依賴關系稱為輸出依賴關系,可能導致 WAW((Write-After-Write)) 數據風險。
假設一條指令i1讀取了寄存器 x。下一條指令i2將結果寫入同一寄存器。此時,i1應該讀取 寄存器X的舊值,而不是i2的結果。如果 i2在i1讀取結果之前將結果寫入 x,則會導致數據風險。這種數據依賴稱為反依賴,可能導致 WAR ((Write-After-Read))數據風險。
假設一條指令i1將結果寫入寄存器 x。下一條指令i2讀取同一個寄存器。此時,i2應該讀取 i1寫入寄存器 x 的值,而不是之前的那個值。這種數據依賴關系被稱為真依賴,可能導致 RAW (Read-After-Write)數據風險。
這是流水線 CPU 中最常見、最主要的數據危險類型。
為了減輕有序 CPU 中的數據危險,我們可以采用一些技術:
檢測到數據依賴性時,暫停流水線(參見第一張圖)。解碼階段可以等到上一條指令執行完成后再執行。
編譯重新調度:編譯器通過調度代碼到稍后執行來重新安排代碼,以避免數據風險。這樣做的目的是避免程序停頓,同時又不影響程序控制流的完整性,但這并非總是可行。編譯器也可以在兩個具有數據依賴性的指令之間插入 NOP 指令。但這會導致停頓,從而影響性能。
數據/操作數轉發:這是順序執行 CPU 中緩解 RAW 數據風險的突出架構解決方案。讓我們分析一下 CPU 流水線,以了解這項技術背后的原理。
假設兩個相鄰的指令i1和i2,它們之間存在 RAW 數據依賴性,因為它們都在訪問寄存器X。CPU 應該暫停指令i2,直到i1將結果寫回寄存器x。如果 CPU 沒有停頓機制,則i2會在第三個時鐘周期的解碼階段從 x 讀取較舊的值。在第四個時鐘周期,i2指令會執行錯誤的 x 值。
如果你仔細觀察管道,我們在第三個時鐘周期就已經得到了i1的結果。當然,它不會被寫回寄存器文件,但結果仍然可以在執行階段的輸出端使用。因此,如果我們能夠以某種方式檢測數據依賴性,然后將該數據“forward”到執行階段的輸入,那么下一條指令就可以使用轉發的數據,而不是來自解碼階段的數據。這樣一來,數據風險就得到了緩解!這個想法是這樣的:
這稱為數據/操作數轉發或數據/操作數旁路。我們將數據按時間向前轉發,以便流水線中后續的依賴指令可以訪問這些被旁路的數據,并在執行階段執行。
這個想法可以擴展到不同的階段。在一個按 i1、i2、..in順序執行指令的 5 級流水線中,數據依賴關系可能存在于:
i1和i2- 需要在執行階段和解碼階段的輸出之間旁路。
i1和i3- 需要在內存訪問階段和解碼階段的輸出之間旁路。
i1和i4- 需要在寫回階段和解碼階段的輸出之間旁路。
用于緩解源自流水線任何階段的 RAW 數據風險的架構解決方案如下所示:
請考慮以下情形:
兩條相鄰指令i1和i2之間存在數據依賴關系,其中第一條指令是 Load。這是數據風險的一種特殊情況。這里,在數據加載到 x1 之前,我們無法執行i2。那么,問題在于我們是否仍然可以通過數據轉發來緩解這種數據風險?加載數據僅在 i1的內存訪問階段可用,并且必須將其轉發到i2的解碼階段才能防止這種風險。該要求如下所示:
假設加載數據在第 4 個周期的內存訪問階段可用,您需要將此數據“轉發”到第 3 個周期,發送到i2的解碼階段輸出(為什么是第 3 個周期?因為在第 4 個周期,i 就已經在執行階段完成了執行!)。本質上,您是在嘗試將當前數據轉發到過去,除非您的 CPU 進行時間旅行,否則這是不可能的!這不是數據轉發,而是“數據回溯”。
數據轉發只能沿時間方向向前進行。
這種數據風險稱為流水線互鎖(Pipeline Interlock)。解決這個問題的唯一方法是,在檢測到數據依賴性時插入一個氣泡,使流水線暫停一個時鐘周期。
在 i1和i2之間插入了 NOP 指令(又稱 Bubble)。這會將i2延遲一個周期,因此數據轉發現在可以將加載數據從內存訪問階段轉發到解碼階段的輸出。
到目前為止,我們只討論了如何緩解 RAW 數據風險。那么,WAW 和 WAR 風險又如何呢?RISC-V 架構本身就具備抵抗有序流水線實現的 WAW 和 WAR 風險的能力!
所有寄存器的寫回都按照指令發出的順序進行。寫回的數據總是會被后續寫入同一寄存器的指令覆蓋。因此,WAW 風險永遠不會發生!
寫回是流水線的最后一個階段。當寫回發生時,讀取指令已經成功完成了對較舊數據的執行。因此,WAR 風險永遠不會發生!
為了緩解 Pequeno 中的 RAW 數據風險,我們將使用流水線互鎖保護功能硬件實現數據轉發。更多細節將在后文揭曉,屆時我們將在其中設計數據轉發邏輯。
我們理解并分析了現有 CPU 架構中可能導致指令執行失敗的各種潛在流水線風險。我們還設計了解決方案和機制來緩解這些風險。讓我們整合必要的微架構,并最終設計出 Pequeno RISC-V CPU 的架構,使其完全杜絕所有類型的流水線風險!
在接下來的文章中,我們將深入探討每個流水線階段/功能單元的 RTL 設計。我們將討論設計階段中不同的微架構決策和挑戰。
獲取單元
從這里開始,我們開始深入探討微架構和 RTL 設計了!在本章中,我們將構建和設計Pequeno 的Fetch Unit (FU) 。
取指單元 (FU) 是 CPU 流水線的第一階段,用于與指令存儲器交互。取指單元 (FU) 從指令存儲器中取指,并將取指的指令發送到譯碼單元 (DU) 。正如前文中 Pequeno 的改進架構所討論的那樣,FU 包含分支預測邏輯和刷新支持。
1
接口
讓我們定義 Fetch Unit 的接口:
2
指令訪問接口
CPU 中 FU 的核心功能是指令訪問。指令訪問接口 (Instruction Access:I/F)即用于此目的。指令在執行期間存儲在指令存儲器 (RAM) 中。現代 CPU 從高速緩存 (Cache) 中獲取指令,而不是直接從指令存儲器中獲取。指令緩存(在計算機架構術語中稱為主緩存或L1 緩存)更靠近 CPU,通過緩存/存儲頻繁訪問的指令并在附近預取較大塊的指令,實現更快的指令訪問。因此,無需持續訪問速度較慢的主存儲器 (RAM)。因此,大多數指令都可以直接從緩存中快速訪問。
CPU 不會直接訪問帶有指令緩存/內存的接口。它們之間會有一個緩存/內存控制器來控制它們之間的內存訪問。
定義一個標準接口是一個好主意,這樣任何標準指令存儲器/緩存 (IMEM) 都可以輕松地插入到我們的 CPU 中,并且只需極少的膠合邏輯甚至無需膠合邏輯。讓我們定義兩個用于指令訪問的接口。請求接口 (I/F )處理從指令存儲器 (FU) 到指令存儲器的請求。響應接口 (I/F)處理從指令存儲器到指令存儲器 (FU) 的響應。我們將為指令存儲器 (FU) 定義一個簡單的基于有效就緒的請求和響應接口 (I/F),因為如果需要,這很容易轉換為 APB、AXI 等總線協議。
指令訪問需要知道指令在內存中的地址。通過請求接口 (Request I/F) 請求的地址實際上就是 FU 生成的 PC。在 FU 接口中,我們將使用暫停信號 (stall signal) 來代替就緒信號,其行為與就緒信號相反。緩存控制器通常有一個暫停信號來暫停來自處理器的請求。該信號由cpu_stall表示。來自內存的響應是通過響應接口 (Response I/F) 接收到的已取指令。除了已取指令之外,響應還應包含相應的 PC。PC 用作 ID,用于識別已收到響應的請求。換句話說,它指示已取指令的地址。這是 CPU 流水線下一階段所需的重要信息(如何實現?我們很快就會看到! )。因此,已取指令及其 PC 構成了對 FU 的響應數據包。當內部流水線暫停時,CPU 可能還需要暫停來自指令內存的響應。該信號由mem_stall表示。
此時,讓我們定義CPU 管道中的 instruction packet= {instruction, PC}。
3
PC 生成邏輯
FU 的核心是控制請求接口 (I/F) 的 PC 生成邏輯。由于我們設計的是 32 位 CPU,因此 PC 的生成應該以 4 為增量。該邏輯復位后,每個時鐘周期都會生成 PC。PC 的復位值可以硬編碼。這是 CPU 復位后從中獲取并執行指令的地址,即內存中第一條指令的地址。PC 生成是自由運行的邏輯,僅由 c pu_stall暫停。
自由運行的PC可以通過刷新I/F和內部分支預測邏輯來繞過。PC生成算法實現如下:
4
指令緩沖器
FU 內部有兩個背靠背的指令緩沖區。緩沖區 1緩沖從指令存儲器中獲取的指令。緩沖區 1 可以直接訪問響應接口 (Response I/F)。緩沖區 2緩沖來自緩沖區 1 的指令,然后通過 DU I/F 將其發送到 DU。這兩個緩沖區構成了 FU 內部的指令流水線。
5
分支預測邏輯
正如上文所討論的,我們必須在 FU 中添加分支預測邏輯來緩解控制風險。我們將實現一個簡單且靜態的分支預測算法。該算法的主要內容如下:
總是會進行無條件跳轉。
如果分支指令是向后跳轉,則執行分支。因為可能性如下:
1、這條指令可能是某些do-while 循環的循環退出檢查的一部分。在這種情況下,如果我們執行分支指令,則正確的概率更高。
如果分支指令是向前跳轉,則不要執行它。因為可能性如下:
2、這條指令可能是某些for 循環或while 循環的循環入口檢查的一部分。如果我們不執行分支并繼續執行下一條指令,則正確的概率更高。
3、這條指令可能是某個if-else語句的一部分。在這種情況下,我們總是假設if條件為真,并繼續執行下一條指令。理論上,這筆交易(bargain)有50%是正確的。
緩沖區 1 的指令包由分支預測邏輯監控和分析,并生成分支預測信號:branch_taken。該分支預測信號隨后被注冊,并與發送給 DU 的指令包同步傳輸。分支預測信號通過 DU 接口發送給 DU。
6
DU
這是獲取單元和解碼單元之間用于發送有效載荷的主要接口。有效載荷包含獲取的指令和分支預測信息。
由于這是CPU兩個流水線階段之間的接口,因此實現了有效就緒I/F。以下信號構成了DU I/F:
在之前的博文中,我們討論了 CPU 流水線中停頓和刷新的概念及其重要性。我們還討論了 Pequeno 架構中需要停頓或刷新的各種場景。因此,必須在 CPU 的每個流水線階段中集成適當的停頓和刷新邏輯。確定在哪個階段需要停頓或刷新至關重要,以及該階段中哪些邏輯部分需要停頓和刷新。
在實施停頓和刷新邏輯之前的一些初步想法:
流水線階段可能會因外部或內部產生的條件而停止。
管道階段可以通過外部或內部生成的條件進行刷新。
Pequeno 中沒有集中式的停頓或刷新生成邏輯。每個階段可能都有自己的停頓和刷新生成邏輯。
流水線中一個階段只能被下一個階段所阻塞。任何階段的阻塞最終都會影響流水線的上游,并導致整個流水線阻塞。
下游流水線中的任何一個階段都可以刷新某個階段。這被稱為流水線刷新,因為上游的整個流水線都需要同時刷新。在 Pequeno 中,只有執行單元 (EXU)中的分支未命中才需要進行流水線刷新。
停頓邏輯包含產生本地和外部停頓的邏輯。刷新邏輯包含產生本地和流水線刷新的邏輯。
本地停頓在內部產生,并在本地用于停止當前階段的運行。外部停頓在內部產生,并通過外部發送到上游流水線的下一級。本地和外部停頓均基于內部條件以及下游流水線下一級的外部停頓而產生。
本地刷新 (Local flush)是指在內部生成并用于本地刷新階段的刷新。外部刷新或管道刷新 (Pipeline flush)是指在內部生成并發送到外部上游管道的刷新。這會同時刷新上游的所有階段。本地刷新和外部刷新均基于內部條件生成。
只有 DU 可以從外部停止 FU 的運行。當 DU 置位停頓時,FU 的內部指令流水線(緩沖區 1 –> 緩沖區 2)應立即停止,并且由于 FU 無法再接收來自 IMEM 的數據包,它還應向 IMEM 置位mem_stall 。根據 IMEM 中的流水線/緩沖深度,PC 生成邏輯最終也可能被來自 IMEM 的cpu_stall停止,因為 IMEM 無法再接收任何請求。FU 中不存在導致本地停頓的內部條件。
只有 EXU 可以外部刷新 FU。EXU 會在 CPU 指令流水線中啟動branch_flush 函數,并傳入刷新流水線后要獲取的下一條指令的地址 ( branch_pc )。FU 提供了刷新接口 (Flush I/F),以便接受外部刷新。
FU 中的緩沖區 1、緩沖區 2 和 PC 生成邏輯通過branch_flush刷新。來自分支預測邏輯的信號branch_taken也充當了對緩沖區 1 和 PC 生成邏輯的本地刷新。如果分支被采用:
下一條指令應從分支預測的 PC 中獲取。因此,PC 生成邏輯應被刷新,并且下一條 PC 應 = branch_pc。
緩沖區 1 中的下一條指令應被刷新并使其無效,即插入 NOP/bubble。
奇怪為什么 Buffer-2 沒有被branch_taken刷新?因為來自 Buffer-1 的分支指令(負責刷新生成)應該在下一個時鐘周期緩沖到 Buffer-2,并允許其在流水線中繼續執行。這條指令不應該被刷新!
指令內存流水線也應該進行適當的刷新。IMEM 刷新mem_flush由branch_flush和branch_taken生成。
讓我們整合目前為止設計的所有微架構,以完成 Fetch Unit 的架構。
好了,各位!我們已經成功設計出Pequeno的Fetch Unit了。在接下來的部分中,我們將設計Pequeno 的解碼單元(DU:Decode Unit)。
解碼單元
解碼單元(DU)是 CPU 流水線的第二階段,負責將來自取指單元(FU)的指令譯碼,并送至執行單元(EXU)。此外,它還負責將寄存器地址譯碼,并送至寄存器文件進行寄存器讀操作。
讓我們定義解碼單元的接口。
其中,FU接口是獲取單元和解碼單元之間接收有效載荷的主要接口。有效載荷包含獲取的指令和分支預測信息。此接口已在上一部分討論過。
EXU接口是解碼單元和執行單元之間發送有效載荷的主要接口。有效載荷包括解碼后的指令、分支預測信息和解碼數據。
以下是構成 EXU I/F 的指令和分支預測信號:
解碼數據是 DU 從獲取的指令中解碼并發送到 EXU 的重要信息。讓我們來了解一下 EXU 執行一條指令需要哪些信息。
Opcode、funct3、funct7:標識 EXU 對操作數要執行的操作。
操作數:根據操作碼,操作數可以是寄存器數據(rs0,rs1),用于寫回的寄存器地址(rdt),或 12 位/20 位立即數。
指令類型:標識必須處理哪些操作數/立即值。
解碼過程可能比較棘手。如果您正確理解了 ISA 和指令結構,就可以識別出不同類型的指令模式。識別模式有助于設計 DU 中的解碼邏輯。
以下信息被解碼并通過 EXU I/F 發送到 EXU。
EXU 將使用此信息將數據解復用到適當的執行子單元并執行指令。
對于 R 型指令,必須解碼并讀取源寄存器rs1和rs2 。從寄存器讀取的數據即為操作數。所有通用用戶寄存器都位于 DU 外部的寄存器堆中。DU 使用寄存器堆接口將rs0和rs1 的地址發送到寄存器堆進行寄存器訪問。從寄存器堆讀取的數據也應與有效載荷一起在同一時鐘周期內發送到 EXU。
寄存器文件讀取寄存器需要一個周期。DU 也需要一個周期來寄存要發送到 EXU 的有效載荷。因此,源寄存器地址由組合邏輯直接從 FU 指令包解碼。這確保了 1) 從 DU 到 EXU 的有效載荷和 2) 從寄存器文件到 EXU 的數據的時序同步。
只有 EXU 可以從外部停止 DU 的運行。當 EXU 置位停止時,DU 的內部指令流水線應立即停止,并且由于無法再接收來自 FU 的數據包,它還應向 FU 置位停止。為了實現同步操作,寄存器文件應與 DU 一起停止,因為它們都位于 CPU 五級流水線的同一級。因此,DU 將外部停止從 EXU 反饋到寄存器文件。DU 內部不存在導致本地停止的情況。
只有 EXU 可以外部刷新 FU。EXU 會在 CPU 指令流水線中啟動branch_flush 函數,并傳入刷新流水線后要獲取的下一條指令的地址 ( branch_pc )。DU 提供了刷新接口 (Flush I/F),以便接受外部刷新。
內部流水線由branch_flush刷新。來自 EXU 的branch_flush應該立即使指向 EXU 的 DU 指令無效,且延遲時間為 0 個時鐘周期。這是為了避免在下一個時鐘周期 EXU 中出現潛在的控制風險。
在取指單元 (Fetch Unit) 的設計中,我們沒有在收到branch_flush 指令后,以 0 周期延遲使 FU 指令失效。這是因為 DU 在下一個時鐘周期也會被刷新,因此 DU 中不會發生控制冒險 (control hazard)。所以,沒有必要使 FU 指令失效。同樣的思路也適用于從 IMEM 到 FU 的指令。
上述流程圖展示了來自 FU 的指令包和分支預測數據如何在指令流水線的 DU 中進行緩沖。DU 中僅使用單級緩沖。
讓我們整合迄今為止設計的所有微架構,以完成解碼單元的架構。
目前我們已經完成了:取指單元(FU)、譯碼單元(DU)。在接下來的部分中,我們將設計Pequeno的寄存器文件。
寄存器文件
在 RISC-V CPU 中,寄存器文件是一個關鍵組件,它由一組通用寄存器組成,用于在執行期間存儲數據。Pequeno CPU 有 32 個 32 位通用寄存器 ( x0 – x31 )。
寄存器x0稱為零寄存器 (zero register)。它被硬連接到一個常量值 0,提供一個有用的默認值,可與其他指令一起使用。假設您想將另一個寄存器初始化為 0,只需執行mv x1, x0即可。
x1-x31是通用寄存器,用于保存中間數據、地址和算術或邏輯運算的結果。
在前文設計的 CPU 架構中,寄存器文件需要兩個訪問接口。
當中,讀訪問接口用于讀取 DU 發送地址處的寄存器。某些指令(例如ADD)需要兩個源寄存器操作數rs1和rs2。因此,讀取訪問接口 (I/F) 需要兩個讀取端口,以便同時讀取兩個寄存器。讀取訪問應為單周期訪問,以便讀取數據與 DU 的有效載荷在同一時鐘周期內發送到 EXU。這樣,讀取數據和 DU 的有效載荷在流水線中保持同步。
寫訪問接口用于將執行結果寫回到 WBU 發送地址處的寄存器。執行結束時僅寫入一個目標寄存器rdt 。因此,一個寫入端口就足夠了。寫入訪問應為單周期訪問。
由于 DU 和寄存器文件需要在流水線的同一階段保持同步,因此它們應該始終一起停止(為什么?請查看上一部分的框圖!)。例如,如果 DU 停止,寄存器文件不應將讀取數據輸出到 EXU,因為這會損壞流水線。在這種情況下,寄存器文件也應該停止。這可以通過將 DU 的停止信號反轉生成寄存器文件的read_enable來確保。當停止有效時,read_enable被驅動為低電平,先前的數據將保留在讀取數據輸出端,從而有效地停止寄存器文件操作。
由于寄存器文件不向EXU發送任何指令包,因此它不需要任何刷新邏輯。刷新邏輯只需在DU內部處理。
總而言之,寄存器文件設計有兩個獨立的讀取端口和一個寫入端口。讀寫訪問均為單周期。讀取的數據會被寄存。最終架構如下:
目前我們已經完成了:取指單元(FU)、譯碼單元(DU)、寄存器文件。
后續部分,敬請期待。