整台機器,攤在一頁上
到現在,你已經一個一個認識過每個零件了。資料路徑是道路網——指向下一條指令的程式計數器、它從中讀取的指令記憶體、保存工作值的暫存器檔、做數學的 ALU、給載入與儲存用的資料記憶體、把小小立即值拉伸到全寬的符號擴充器,以及挑選某個值走哪條路的多工器。控制單元是交通指揮:它讀取運算碼,設定每一個把資料導過這些道路的訊號。這最後一篇導覽只做一件事——藉著追蹤兩條真實的指令、一次走慢慢一步,親眼看著整台機器運轉。
我們會用一段極小的 RISC-V 風格程式來做追蹤。挑載入與分支的用意在於:它們操練了機器相反的兩個角落。載入是任何指令會走的最長路徑(它在寫回之前要碰到暫存器檔、ALU、還有資料記憶體),而分支則最棘手,因為它改變的是「下一條指令是哪一條」,而不是寫進某個暫存器。這兩條加起來,幾乎碰到了每一個零件,所以跟著它們走,就最接近一趟單週期處理器的完整巡禮。
# x10 holds a base address; x11 is a loop counter
lw x5, 8(x10) # LOAD: x5 = Memory[x10 + 8]
beq x5, x0, done # BRANCH: if x5 == 0, jump to 'done'
...
done:
...
We trace lw first, then beq. Watch the control signals
change between the two even though the datapath is the same.一個訊號一個訊號地追蹤載入
回想一下,lw x5, 8(x10) 的意思是:取出暫存器 x10 裡的位址、加上立即值 8、讀取那個記憶體位址上的字,再把它存進 x5。這就是載入執行,也是決定整台單週期機器時脈週期的那條指令,因為它的路徑最長。看看資料如何由左往右流動,以及每一個控制訊號如何在單一週期內、恰好在對的時刻打開對的閘門。
- 取指。程式計數器把位址送進指令記憶體,後者回傳 lw 的 32 個位元。在同一週期裡,一個加法器算出 PC + 4——預設的下一條指令——並把它備妥待命。
- 解碼並讀取暫存器。控制單元讀取運算碼,認出這是一條載入:它設定 RegWrite = 1(我們要寫一個暫存器)、MemRead = 1、ALUSrc = 1(ALU 的第二個輸入來自立即值,而非暫存器)、以及 MemtoReg = 1(要寫回的值來自記憶體,而非 ALU)。同時,暫存器檔把 x10 讀到它的第一個讀取埠上。
- 符號擴充並計算位址。符號擴充器把立即值 8 拉伸到全寬。ALUSrc 多工器選了那個立即值,於是 ALU 算出 x10 + 8,形成記憶體位址。做算術指令用的同一套加法器硬體,在這裡被重複利用,只為了算出一個位址。
- 存取資料記憶體。算出的位址送進資料記憶體;在 MemRead = 1 之下,它回傳存在那裡的字。這次記憶體讀取就是慢的那一段——也就是為什麼載入決定了週期時間。
- 寫回。MemtoReg 多工器選出來自記憶體的值(而非 ALU 的輸出),又因為 RegWrite = 1,那個值會在時脈上升緣被閂進 x5。就在同一個邊緣,PC 更新為 PC + 4。這條指令完成了。
追蹤分支,以及它所提的問題
現在來看 beq x5, x0, done——相等就分支。它的意思是:把 x5 和 x0(永遠是零)比較,若相等,就讓下一條指令變成標籤 'done' 那一條,而不是下一行。這就是分支執行,它的工作和載入根本不同:載入產生一個要存的值,而分支產生的是一個「接下來去哪」的決定。同一條資料路徑服務兩者,但控制單元撥動的是另一組開關。
- 用同樣方式取指:PC 從指令記憶體讀出 beq,並同時平行算出 PC + 4,作為「順著往下走」(未跳)的目標。
- 控制單元把它解碼成分支:RegWrite = 0(分支不寫任何暫存器)、MemRead = MemWrite = 0(它從不碰資料記憶體)、ALUSrc = 0(ALU 的兩個輸入都是暫存器)、以及 Branch = 1(這條指令被允許去改寫 PC)。
- 暫存器檔讀出 x5 與 x0。ALU 被指示去做減法:x5 - x0。我們不要那個差值;我們只要它的 Zero(零)旗標。若 x5 等於 x0,結果為零,ALU 便升起 Zero = 1。
- 另一個獨立的加法器算出分支目標:PC +(擴充到 'done' 的帶符號位移量)。接著一小塊邏輯算出「要分支嗎?」= Branch AND Zero。一個多工器用這單獨一個位元,去選出下一個 PC:跳的話用分支目標,不跳的話用 PC + 4。
仔細看步驟 3:分支重複利用了同一個 ALU,做一次減法,只為了搞清楚兩個數是否相等。我們甚至不保留結果——把差值丟掉,只留下一個旗標位元。正是這種節儉的重複利用,讓資料路徑顯得優雅:同一個加法/減法器同時服務算術、位址計算與相等測試,只要改變 ALU 控制線就好。而機器的右半邊——資料記憶體、寫回多工器——這個週期完全閒置,因為分支沒有東西要存、也沒有東西要寫回。
單週期從哪裡開始痛起來
把這兩段追蹤並排,一個問題就跳了出來。分支根本沒碰資料記憶體就結束了——它真正的工作在週期過半之前就完成。但單週期機器的時脈週期,必須長到足以容納最慢的指令,也就是載入,連著它整條「讀暫存器、ALU、讀記憶體、寫回」的鏈。於是每一條快指令,都被迫熬完載入所要求的那個長週期。這就是單週期的限制:時脈被最壞情況定了拍,所以一條快快的分支或一個快快的加法,把它大半個週期都浪費在原地站著。
同一種浪費,還有第二個更尖銳的版本。因為每條指令都是一個週期,CPI 是完美的 1——聽起來美妙——但那個週期長到,效能鐵律(執行時間 = 指令數 x CPI x 週期時間)整體上仍然輸。用糟糕的週期時間換來的漂亮 CPI,並不划算。更糟的是,你不能在一個週期裡把同一個硬體單元用兩次,所以單週期設計需要分開的指令記憶體與資料記憶體、分開的加法器,以矽片為代價,換取「一口氣做完所有事」的特權。
解藥是多週期資料路徑。與其用一個被拉長的週期,不如把每條指令切成好幾個短週期——取指、解碼、執行、記憶體、寫回——讓一個短時脈每一步走一拍。現在,一條只需三步的分支,在三個短週期裡就完成,而載入要五個;再也沒有人為載入而等。又因為這些步驟發生在不同時間,單一個 ALU 和單一個記憶體可以在同一條指令的各步之間被重複利用,省下硬體。CPI 升到 1 以上,但週期時間大幅下降,鐵律最後算下來是贏的。
微程式對硬接線——通往管線的接縫
一旦一條指令要花好幾個週期,控制單元的工作就更難了:它現在必須在每一步產生不同的一組訊號,而且順序要對。要打造這個定序器,有兩種誠實的做法。硬接線控制把邏輯直接烤進閘門與一個小小的有限狀態機裡——快又精簡,但要為一條新指令重新接線,就意味著重新設計電路。微程式則把控制訊號當成微小的「微指令」,存在一塊晶片上的小記憶體裡,控制器只要每一步把它們讀出一條,像每個週期一張食譜卡。它較慢,卻美妙地靈活:要新增或修正一條指令,改的是食譜,而不是接線。
而這,正好是通往下一級的接縫。多週期機器已經把每條指令拆成了「取指/解碼/執行/記憶體/寫回」這些階段。接下來幾乎令人無法抗拒的想法是:當一條指令還在它的記憶體階段時,何不讓下一條指令已經在它的執行階段、再下一條在解碼——就像一條洗衣流水線同時在洗、烘、摺不同批的衣物?這就是管線化,它把我們剛剛追蹤過的那些階段,變成一條重疊的水流。你剛走過的那段單週期追蹤是地基;管線則是這台機器終於學會一次做許多事的地方。握住你在這裡追蹤過的那兩條乾淨指令——下一級裡每一個冒險與前遞的把戲,都不過就是同樣那些階段,在賽跑罷了。