從契約到電路
到目前為止,這道階梯交給了我們一顆 CPU 的兩半。指令集架構告訴我們機器答應做什麼——加、載入、儲存、分支——那是一份寫給程式設計師的契約。各個元件導覽則給了我們能兌現這些承諾的硬體:一個會加減的 ALU、一個存放工作變數的暫存器檔,以及存放指令與資料的記憶體。資料路徑(datapath)正是這兩個世界終於相遇的地方:它是一張由線、多工器與單元織成的網,真正的位元在一條指令執行時,就在其中穿行。
想像一個小工廠的廠房。原料運進來,沿著輸送帶被送到工作台,在那裡被重新塑形,完成的零件再被放回架上。資料路徑就是那片廠房:架子是暫存器檔與記憶體,工作台是 ALU,輸送帶則是把它們連起來的一束束電線。關鍵在於,資料路徑只負責搬運與變換數值——它對於今天該開哪一條輸送帶、工作台該做什麼,毫無意見。那些決定屬於我們稍後才會見到的另一位老闆,控制單元;現在我們只是先把路鋪好。
一次認識一個架子上的元件
每一條指令都從程式計數器開始,那是一個小小的暫存器,存著我們正要執行的那條指令的位址。PC 餵進指令記憶體(instruction memory),它回傳坐在那個位址上的 32 位元指令字。同時,一個小小的加法器悄悄算出 PC + 4——排在下一個的指令的位址——因為大多數時候,我們只是一個字一個字地往前走過程式。(一條 RISC-V 指令寬四個位元組,所以是 + 4。)
指令字一旦被取出,就依照位置被切成一個個欄位。有些位元指名來源與目的暫存器;暫存器檔接收最多兩個暫存器編號,一次把它們的值讀到兩個輸出埠上,並能透過第三個埠把一個值寫回去。另一些位元組成一個立即數——一個烤進指令本身的小常數。因為立即數存得窄,而 ALU 是對全寬度的數字運算,所以一個符號擴展器(sign-extender)把它加寬:它把符號位元往左複製,好讓在二補數裡,一個負的小數字在全寬度下仍是同一個負數。這正是我們在資料表示那幾級遇過的符號擴展,現在被接進了一顆真正的 CPU。
廠房的正中央坐著 ALU——那張會做加、減、AND、OR 與比較的工作台。靠在一旁的是資料記憶體(data memory),那是載入與儲存指令伸手取用的大架子;指令記憶體在一次執行中是唯讀的,資料記憶體則既可讀也可寫。而貫穿整張圖、到處穿插的,是一個個多工器(multiplexer):小小的雙輸入開關,靠一條控制線決定兩個值裡哪一個往下流。多工器是資料路徑裡無名的英雄,因為正是它,讓同一組電線能服務許多不同的指令。
把它接成一台單週期機器
現在用輸送帶把架子連起來。PC 驅動指令記憶體;取出的指令裡的暫存器欄位驅動暫存器檔的讀取埠;兩個暫存器輸出(也可能是那個由多工器挑出來的符號擴展立即數)驅動 ALU 的兩個輸入;ALU 的結果要嘛當作位址送進資料記憶體,要嘛直接送回去寫進暫存器檔。最末端還有一個多工器,挑選要寫回暫存器的是什麼:ALU 的結果,還是剛從資料記憶體載入的值。這整張連通的網——取指、解碼、讀取、運算、存取記憶體、寫回——就是一顆完整的單週期處理器(single-cycle processor):每一條指令都恰好在一個時脈週期裡完成。
+--------+ +--------------+
PC --------->| Instr |--word->| split into |
| +4 | Memory | | reg# / reg# /|
| (next) +--------+ | immediate |
| +------+-------+
| |
| +-----------+ rs1 val v
| | Register |---------->[mux]----+
| | File |---rs2 val--->[mux] |
| +-----------+ immediate-> v
| ^ +-----+
| | write-back | ALU |--result--+
| | +-----+ |
| [mux]<--- loaded value --+ | |
| ^ | | address |
| +-- ALU result ------|------+-----+ |
| | v |
| +----------+ +--------+ |
+-- branch target (PC+offset)--| (branch) | | Data |<--+ (store data)
+----------+ | Memory |--> loaded value
+--------+
Flow for one instruction, left to right: fetch -> decode/read
-> ALU compute -> (maybe) memory -> write back. The [mux]es are
the only places the control unit gets to steer the traffic.為什麼同一張網能服務四種大不相同的指令?因為多工器能在不重接任何一條線的情況下,重新設定路線。一個加法把兩個暫存器值送進 ALU,再把結果寫回。一個載入把一個暫存器加上一個立即數送進 ALU 算出位址,讀取資料記憶體,再把載入的值寫回。一個儲存用同樣的方式算出位址,但把一個暫存器值寫進記憶體,並且不寫任何東西回暫存器檔。一個分支用 ALU 去比較兩個暫存器,如果相等,就把下一個 PC 的多工器導向一個目標位址,而不是 PC + 4。同樣的道路,不同的開關設定。
追蹤時脈的一聲滴答
讓我們追蹤一個加法——以 RISC-V 風格寫成 `add x5, x6, x7`,意思是「把 x6 + x7 放進 x5」。在一台單週期機器裡,這整趟旅程都發生在一個時脈週期之內,組合邏輯由左往右穩定下來,結果在關閉的時脈邊緣被閂進 x5。看看每一個元件如何依序做好它的那一件事,而多工器(由控制單元)早已設成「算術」這條路線。
- 取指:PC 握著這條指令的位址,把它交給指令記憶體,後者回傳 `add x5, x6, x7` 的 32 位元字。同時,+4 加法器算出下一條指令的位址。
- 解碼與讀取:指令字被切成欄位。兩個來源暫存器編號(6 與 7)送到暫存器檔的讀取埠,後者把 x6 與 x7 的值放上它的兩條輸出匯流排。
- 運算:一個多工器替 ALU 的第二個輸入選了暫存器值(而非立即數)。ALU 把 x6 + x7 相加,在它的結果輸出上產生那個和。
- 略過記憶體:對一個算術運算而言,沒有載入也沒有儲存,所以資料記憶體完全不動,結果只是從它旁邊流過。
- 寫回:最末端的多工器選了 ALU 的結果(而非載入的值),在時脈邊緣它被寫進暫存器 x5。PC 也同時接受 PC + 4,於是下一個週期取出接下來的那條指令。
注意是什麼讓這趟追蹤毫不費力:資料路徑沒有決定任何事。它忠實地把 x6 與 x7 搬到 ALU、把和搬到 x5,只因為多工器剛好被設成算術那一邊。把同樣那些開關設成不同的樣子,這一模一樣的硬體就會改把一個位址導進資料記憶體。資料流動,控制導向。把這兩個觀念分開來握住,是理解任何處理器最重要的一個習慣。
把一切擠進一個週期的代價
單週期設計推理起來美麗地容易,卻藏著一個昂貴的毛病。因為每一條指令都得在一個時脈週期內完成,那個週期就得長到足以容下最慢的指令。一個載入必須取指、讀暫存器、在 ALU 裡加出位址,還要讀資料記憶體,全部串接著來——一條很長的鏈。一個簡單的加法早早就完成了,卻被迫枯等同樣那段長長的週期。我們是以最壞情況的速度在打整台機器的時脈。這就是單週期的限制,而它正是效能鐵律的一個直接教訓:一個恰好等於 1 的低 CPI 並不划算,如果它逼出一個長得要命的週期時間的話。
第一個補救辦法是多週期資料路徑(multi-cycle datapath)。與其把取指、解碼、執行、記憶體與寫回全擠進一個長週期,不如把工作拆成幾個短步驟,給每一步它自己的短時脈週期。一個簡單的加法也許要三個週期;一個載入也許要五個。現在時脈可以很快,因為每個週期只需涵蓋一個小步驟,而便宜的指令也真的在較少的週期裡完成。代價是一個更講究的控制單元,它必須記住自己走到了哪一步——但這種一步一步的思路,正正是管線(pipelining)的種子,管線把不同指令的步驟交疊起來,而不是一次只做一條指令的那些步驟。
在我們離開之前,還有最後一道接縫值得點出名字。撥動所有那些開關的控制,本身也能用兩種方式來建。硬接線控制(hardwired control)把規則燒進固定的邏輯閘——快,但僵硬、難以更動。微程式控制(microprogrammed control)把控制訊號當成一個小記憶體裡一行行的小資料來存,像一張機器一步步走過的查找表,這讓更豐富的指令集設計與修補都容易許多。真正的 CPU 兩者兼用,而那張表正是現代晶片悄悄把一條複雜指令拆成我們稍後會見到的較簡單微指令的方式。資料與它的指揮者之間的這道分割,就是從組織通往前方那條管線的門戶。