還沒人追過的那些線
在第 1 篇導覽裡,我們鋪好了資料路徑——程式計數器、指令記憶體、暫存器檔、ALU、資料記憶體、符號擴展器,以及把它們接在一起的那些多工器。在第 2 篇裡,我們推一條指令穿過去,看著資料移動。但回頭看我們畫的每一個多工器:每一個都垂著一條選擇線,問著一個是非題:要拿立即值,還是暫存器的值?這個週期要不要寫進暫存器檔?要不要讀記憶體?在那兩篇裡,我們是悄悄地用手回答了這些問題。控制單元就是晶片裡那個會自動回答它們的部分——在每個時脈上,只靠看著指令就能決定。
這裡有一個最乾淨的方式,把整個設計放進腦袋裡,這個切分會貫穿整個這一級:資料路徑是資料往哪流,控制單元是什麼在引導它。資料路徑是水管——載著位元的管子與閥門。控制單元則是每個閥門上的那隻手。它們刻意被分開,因為同一套水管可以載一個加法、一個載入、一個儲存或一個分支;從一條指令到下一條,改變的只是閥門開與關的圖案。這道分隔叫做資料路徑/控制分離,是整台機器裡被重複使用最多的想法之一。
從運算碼到一把旗標
每一條指令都帶著一個運算碼(opcode)——前面那幾個位元,說明這是哪一種指令。控制單元的全部工作就是一次查表:餵進運算碼,吐出控制訊號——一小把單位元旗標,資料路徑裡每個開關各對應一個。把它想成一台販賣機。你按下一個按鈕(運算碼),一組固定的拉桿就落定位:這個多工器倒向左、那條寫入致能拉高、記憶體讀取線維持低。對加法與對載入,同一組實體線得到的是不同的設定,但決定設定的那台機器從不改變——它就是純粹的解碼。
我們講的是哪些旗標?對我們的單週期 RISC-V 來說,一小撮就能涵蓋一切。RegWrite 說「這個週期結束時,把 ALU 或記憶體的結果寫回暫存器檔」。MemRead 與 MemWrite 說的是要不要讀寫資料記憶體。ALUSrc 選 ALU 的第二個輸入:是暫存器的值,還是符號擴展後的立即值。MemToReg 選要寫回什麼:ALU 算出的答案,還是從記憶體取回的字組。而 Branch 則武裝那段可能會重導程式計數器的邏輯。對著眼前的運算碼把這些設對,資料路徑就會做出正確的事——而它從不需要知道自己正在跑哪一條指令。
控制表:一個運算碼一列
因為控制單元就是純粹的解碼——運算碼進、旗標出——你可以把它的全部行為寫成一張控制表。每一列是一種指令類型;每一行是一個控制訊號;每一格是那個訊號必須取的 0 或 1。這裡沒有藏什麼花招:這張表就是控制單元。讓我們填進四列,對應我們一路跟著的四條指令——一個算術 R 型(add)、一個載入(lw)、一個儲存(sw),與一個條件分支(beq)。
Instruction | ALUSrc | MemToReg | RegWrite | MemRead | MemWrite | Branch | ALUop ------------+--------+----------+----------+---------+----------+--------+------- add (R) | 0 | 0 | 1 | 0 | 0 | 0 | add lw (load) | 1 | 1 | 1 | 1 | 0 | 0 | add sw (store) | 1 | x | 0 | 0 | 1 | 0 | add beq (branch)| 0 | x | 0 | 0 | 0 | 1 | sub ( x = don't-care: that result is never written, so the flag's value is irrelevant )
慢慢讀,整條資料路徑就活了過來。add 讀兩個暫存器(ALUSrc 0),叫 ALU 做加法,把答案直接寫回去(MemToReg 0、RegWrite 1)——記憶體沒被碰。lw 把立即值當位移用(ALUSrc 1),加到一個基底暫存器上湊出位址,讀記憶體(MemRead 1),再把取回的那個字組寫回去(MemToReg 1)。sw 同樣加一個位移,但寫進記憶體(MemWrite 1),且什麼都不寫回暫存器(RegWrite 0)。而 beq 做的是減法——不是為了留住結果,而是為了測試它:若兩個暫存器相等,差就是零,而 Branch 1 讓那個零去引導程式計數器。一張表、四種截然不同的行為、線路零改動。
兩層控制:主單元與 ALU 控制
你也許注意到最後一行寫的是「add」或「sub」,而不是乾淨的 0 或 1。那是因為 ALU 能做的不只兩件事——加、減、AND、OR、小於則設一——而要在它們之間選,得花幾個位元,不是一個。結構設計師靠把控制拆成兩層來保持整潔。主控制單元只看運算碼,吐出高階旗標,外加一個叫 ALUop 的小小 2 位元提示。一個小型的第二解碼器,ALU 控制,把那個提示連同指令的功能欄位一起拿來,產生出 ALU 真正需要的那組精確的運算碼。
為什麼要多一層,而不用一張大表?為了省。每一個載入、儲存,以及那些簡單的位址算術,全都只需要「加」,所以主單元可以說 ALUop = 00(「加」)就了事。分支需要「減」,所以 ALUop = 01。但一個 R 型可能是加、減、AND、OR,或更多種,全都共用同一個運算碼,只在功能位元上不同——所以主單元說 ALUop = 10(「去看功能欄位」),把細部決定交給 ALU 控制。這個兩步解碼讓主控制表保持精簡,同時仍能涵蓋一個豐富的 ALU。這是個乾淨的例子:把細節推到「擁有資訊去決定它」的那個地方。
為什麼一個週期不夠
到目前為止,我們的控制單元住在一個單週期處理器裡:每一條指令都在剛好一個時脈裡開始並結束。這推理起來簡單得令人愉快——一個時脈對應表裡的一列——但它藏著一個殘酷的代價。時脈跳動的速度,不可能快過最慢那條指令穿過資料路徑的路。載入就是那個慢的:它必須讀指令記憶體、讀暫存器檔、做一次 ALU 加法湊出位址、讀資料記憶體,然後才寫回——一條長鏈。然而一個單純的加法早早就結束了。在單週期設計裡,那個快的加法被迫耗著一段被拉長到容得下慢載入的時脈週期。大家都以最慢者的速度在跑。
這就是單週期限制,而它之所以咬人,是因為效能的鐵律:執行時間 = 指令數 x CPI x 時脈週期。單週期把 CPI 維持在好看的 1——每條指令一個週期——但代價是時脈週期被臌脹到最壞情況。更糟的是,每個功能方塊一個週期最多只用一次,所以這個設計必須複製本來可以共用的硬體(分開的指令與資料記憶體、額外的加法器)。解法是:別再硬把每一條指令塞進一個時脈裡。
多週期資料路徑登場。把每條指令拆成幾個短步驟——取指、解碼、執行、記憶體、寫回——每個步驟各給一個自己的短時脈。現在時脈以一個步驟的速度在跑,而不是一整條指令。快的加法只花它需要的那三步;慢的載入走完全部五步,只為它用到的部分付費。硬體也能跨步驟重用:同一個 ALU 在一步算位址,在另一步可以替 PC 加一。控制單元也跟著長大:它不再是一張平的查找表,而是一台走過這些步驟的有限狀態機,在每一步吐出不同的一束控制訊號。我們會在第 4 篇導覽裡,完整拆解單週期對多週期的這場取捨。
造控制器的兩種方式:硬接線對微程式
不管你怎麼組織它,控制器在實體上可以用兩種風格之一來造,而這個區別比單週期與多週期都活得更久。硬接線控制把控制表直接變成邏輯閘:運算碼進來,穿過一張固定的 AND/OR 閘網格,控制訊號就出來。它快而精巧,但這張表被凍進了矽片——要改控制行為,你得重新設計那些閘。RISC 晶片靠著它們小而規律的指令集,正是因為它們的表簡單到能便宜地用閘實現,而倚賴硬接線控制。
微程式設計走相反的路:把控制訊號當成資料來存。每個運算碼的行為變成一支由「微指令」組成的小程式,存在晶片內部一小塊快速記憶體裡;控制單元變成一個小小的直譯器,去取出並執行那段微常式,每條微指令鋪下一個週期份量的控制訊號。它比閘慢,卻靈活得輝煌——要支援一條肥胖的 CISC 指令,甚至要修一個控制錯誤,都變成重寫那段儲存的微程式的事,而不必重做矽片。這就是為什麼複雜的 CISC 機器在歷史上偏好它。
別把這讀成一場有勝負的嚴格對決。真實晶片把兩者混用,而我們在 RISC 對 CISC 遇過的那種模糊,在這裡也出現了:一塊現代 x86 把每條複雜指令拆成類似 RISC 的 微指令,所以它替那些棘手、罕見的指令帶著微碼,同時讓常見的那些走過快速的硬接線式路徑。更深的重點是這道接縫本身。一旦控制只是一張表——不管是燒進閘裡,還是當微程式存著——同時重疊好幾條指令的步驟之門就打開了。那個重疊就是管線,是這一級一路鋪墊著的想法,也是接下來幾級的主題。