穿過機器的一趟旅程
在上一篇指南裡,你以接線圖的形式認識了資料路徑——程式計數器、指令記憶體、暫存器檔、ALU、資料記憶體、符號擴展器,以及在不同路線間做選擇的多工器。那篇指南告訴你每樣東西坐落在哪裡,這一篇則讓它動起來。我們會跑一台單週期處理器——一整條指令在一個時脈週期內完成——並看著位元真正流動。
每一條指令,不管它要做什麼,開頭都一樣。這個共同的開場就是指令週期的擷取那一半:讀出 PC 所指向的指令,並算出下一條指令的位址。唯有等指令的位元到手之後,機器才會分岔,因為唯有此刻它才知道自己拿的是加法、載入、儲存還是分支。把它想成廚房從掛架上讀下一張點單:抽出點單這個動作每次都一模一樣;接下來做什麼,取決於點單上寫什麼。
一個算術運算:最短的路徑
從最簡單的情況開始:一個暫存器對暫存器的算術指令,比如 add x5, x6, x7(把 x6 與 x7 的和放進 x5)。在 RISC-V 裡這是一條 R 型指令,把它走過資料路徑,正是對 R 型執行最乾淨的示範。擷取之後,解碼器直接從指令的固定欄位讀出兩個來源暫存器編號(6 與 7)與目的編號(5)。
暫存器檔同時讀出 x6 與 x7——它有兩個讀取埠正是為此而設——並把它們的值送到 ALU 的兩個輸入。ALU 被告知要做加法(下一篇指南會看到那個「加法」命令從哪來),於是產生和。這個和沿線回到暫存器檔的寫入埠,被寫進 x5。同時 PC + 4 成為下一個 PC。注意哪些東西完全沒被碰到:資料記憶體閒著、符號擴展器的輸出沒去處、分支加法器的結果被忽略。那些路徑的硬體依舊存在——這個週期裡它只是擱著沒用。
add x5, x6, x7 (R-type)
PC --> instr.mem --> instruction bits
| reg numbers 6,7,5
v
reg[6] --+
+--> ALU(add) --> result --> write into reg[5]
reg[7] --+
data memory: not used
sign-extender: not used
next PC: PC + 4一個載入與一個儲存:伸手探進記憶體
現在來看載入:lw x5, 8(x6) 的意思是「取出位址(x6 裡的值)+ 8 處的那個字,放進 x5」。這是 載入執行,它用到兩個被加法忽略的零件。首先,位移 8 以一個小小的立即值形式住在指令裡;符號擴展器把它加寬成完整的 32(或 64)位,好讓它能與那麼寬的暫存器相加——之所以用符號擴展,是因為位移可以是負的。接著 ALU 把基底暫存器 x6 與那個擴展後的位移相加,算出有效位址。
這裡與加法的關鍵差別在於:ALU 的輸出不是最終答案——它是一個位址。它餵進資料記憶體的位址埠,記憶體讀出那個位置並回傳那個字。此時暫存器檔寫入埠處的一個多工器,選擇把記憶體的輸出(而非 ALU 的)當作寫進 x5 的值。那個多工器正是載入與加法能共用一組線路的全部理由:同一條 ALU 結果線在兩者裡都存在,但載入在最後一刻用從記憶體拉來的值把它蓋過去。
一個儲存,sw x5, 8(x6),算位址的方式一模一樣——把位移做符號擴展、用 ALU 加到 x6 上——但資料流的方向相反。它不讀記憶體,而是把 x5 的值寫進那個位址,並且不把任何東西寫回任何暫存器。所以儲存與載入幾乎是鏡像:完全相同的位址算術、相反的記憶體方向,而儲存不動暫存器檔。翻轉這些方向的控制訊號,正是第 3 篇指南要談的。
一個分支:改變接下來的去向
前三條指令都讓 PC + 4 勝出——它們直直往前流。一個像 beq x5, x6, label 的條件分支則不同:它可能讓下一條指令出現在完全不同的地方。這是 分支執行,它把 ALU 的工作和位址的工作分開。這裡的 ALU 不是用來產生一個值,而是用來比較:它把 x5 減去 x6,檢查結果是否為零,這就回答了「它們相等嗎?」。
同時,第二個加法器算出分支目標:它把分支的立即位移做符號擴展,加到 PC 上,得到 label 的位址。此時一個多工器在兩個候選之間選下一個 PC——PC + 4(落空、繼續往下)與分支目標(採用分支)——而 ALU 的相等結果就是那個多工器的選擇線。若相等,目標勝出;若不相等,PC + 4 勝出。那一個多工器正是程式控制流住的地方:它是決定一個迴圈要不要再轉一圈、或一個函式要不要返回的硬體。
為什麼一個週期開始讓人吃不消
在單週期機器裡,每條指令都在時脈的一拍內完成——優雅地簡單,也是學資料路徑的好方法。但時脈只能跳得跟最慢那條指令所允許的一樣快,因為週期必須長到足夠讓那條指令整串工作穩定下來。那一串就是關鍵路徑:對載入來說,它是擷取、然後讀暫存器、然後 ALU 為位址做加法、然後一次完整的資料記憶體讀取、然後寫回——這是任何指令所走的最長旅程。
浪費就在這裡:一個加法也許半個時間就做完了,卻被逼著等完同樣長的週期,因為時脈週期被釘死在最壞情況上。依效能鐵律——執行時間 = 指令數 x CPI x 週期時間——單週期設計把 CPI 釘在漂亮的 1,卻拿巨大的週期時間去換,所以乘積很差。這就是單週期的限制,而誠實地說,沒有哪台真正的高效能 CPU 是這樣造的。
有兩條出路。一條是多週期資料路徑:把每條指令切成幾個短步驟,給每一步自己一個快速的時脈拍,於是加法三拍就能做完、載入則要五拍——再也沒有人為最壞情況等待。另一條,也是下一個梯級要建造的方向,是管線化:每個階段仍維持一個週期,但讓不同的指令在不同階段裡重疊進行,就像一條洗衣流水線一邊洗一批、一邊烘另一批。兩者都立基於同一個體悟:一條指令的各個部分可以在時間上、而不只是在空間上被分開。
誰來告訴線路該做什麼
整篇指南裡有一件事一直待在暗處:是誰設定了多工器、告訴 ALU 要加還是要減、決定記憶體是被讀、被寫、還是擱著不動?那就是控制單元。它只看運算碼(外加幾個位元),並發出一束控制訊號——每個開關一條線——為這條指令把資料路徑配置好。資料路徑搬動資料;控制單元為它掌舵。第 3 篇指南會打開那個盒子。
建造那個控制器有兩種經典做法,它們預告了一個你會再遇到的主題。硬連線控制把運算碼到訊號的對應燒進固定的邏輯閘裡——又快又精簡,是乾淨的 RISC 指令集的天然搭配。微程式設計則把這些訊號當成小小的字存進一塊小記憶體裡、用查表的方式取出,拿一點速度去換表達極複雜指令的彈性——歷史上,繁複的 CISC 機器就是這樣應付過來的。這個資料路徑與控制的分工,連同如何實作控制的抉擇,正是你現在握著的這幅簡單圖像通往一台處理器真實工程的那道接縫。