建立數據通路
指令周期(Instruction Cycle)
前面講計算機機器碼的時候,向你介紹過 PC 寄存器、指令寄存器,還介紹過 MIPS 體系結構的計算機所用到的 R、I、J 類指令。如果我們仔細看一看,可以發現,計算機每執行一條指令的過程,可以分解成這樣幾個步驟。
-
Fetch(取得指令),也就是從 PC 寄存器里找到對應的指令地址,根據指令地址從內存里把具體的指令,加載到指令寄存器中,然后把 PC 寄存器自增,好在未來執行下一條指令。
-
Decode(指令譯碼),也就是根據指令寄存器里面的指令,解析成要進行什么樣的操作,是 R、I、J 中的哪一種指令,具體要操作哪些寄存器、數據或者內存地址。
-
Execute(執行指令),也就是實際運行對應的 R、I、J 這些特定的指令,進行算術邏輯操作、數據傳輸或者直接的地址跳轉。
-
重復進行 1~3 的步驟。
這樣的步驟,其實就是一個永不停歇的“Fetch - Decode - Execute”的循環,我們把這個循環稱之為指令周期(Instruction Cycle)。
在這個循環過程中,不同部分其實是由計算機中的不同組件完成的。
在取指令的階段,我們的指令是放在存儲器里的,實際上,通過 PC 寄存器和指令寄存器取出指令的過程,是由控制器(Control Unit)操作的。
指令的解碼過程,也是由控制器進行的。一旦到了執行指令階段,無論是進行算術操作、邏輯操作的 R 型指令,還是進行數據傳輸、條件分支的 I 型指令,都是由算術邏輯單元(ALU)操作的,也就是由運算器處理的。
不過,如果是一個簡單的無條件地址跳轉,那么我們可以直接在控制器里面完成,不需要用到運算器。
除了 Instruction Cycle 這個指令周期,在 CPU 里面我們還會提到另外兩個常見的 Cycle。
一個叫Machine Cycle,機器周期或者CPU 周期。CPU 內部的操作速度很快,但是訪問內存的速度卻要慢很多。每一條指令都需要從內存里面加載而來,所以我們一般把從內存里面讀取一條指令的最短時間,稱為 CPU 周期。
還有一個是我們之前提過的Clock Cycle,也就是時鍾周期以及我們機器的主頻。一個 CPU 周期,通常會由幾個時鍾周期累積起來。一個 CPU 周期的時間,就是這幾個 Clock Cycle 的總和。
對於一個指令周期來說,我們取出一條指令,然后執行它,至少需要兩個 CPU 周期。取出指令至少需要一個 CPU 周期,執行至少也需要一個 CPU 周期,復雜的指令則需要更多的 CPU 周期。
所以,我們說一個指令周期,包含多個 CPU 周期,而一個 CPU 周期包含多個時鍾周期。
建立數據通路
ALU 就是運算器嗎?在討論計算機五大組件的運算器的時候,我們提到過好幾個不同的相關名詞,比如 ALU、運算器、處理器單元、數據通路,它們之間到底是什么關系呢?
名字是什么其實並不重要,一般來說,我們可以認為,數據通路就是我們的處理器單元。它通常由兩類原件組成。
第一類叫操作元件,也叫組合邏輯元件(Combinational Element),其實就是我們的 ALU。在前面講 ALU 的過程中可以看到,它們的功能就是在特定的輸入下,根據下面的組合電路的邏輯,生成特定的輸出。
第二類叫存儲元件,也有叫狀態元件(State Element)的。比如我們在計算過程中需要用到的寄存器,無論是通用寄存器還是狀態寄存器,其實都是存儲元件。
我們通過數據總線的方式,把它們連接起來,就可以完成數據的存儲、處理和傳輸了,這就是所謂的建立數據通路了。
下面我們來說控制器。它的邏輯就沒那么復雜了。我們可以把它看成只是機械地重復“Fetch - Decode - Execute“循環中的前兩個步驟,然后把最后一個步驟,通過控制器產生的控制信號,交給 ALU 去處理。
聽起來是不是很簡單?實際上,控制器的電路特別復雜。下面給你詳細解析一下。
一方面,所有 CPU 支持的指令,都會在控制器里面,被解析成不同的輸出信號。我們之前說過,現在的 Intel CPU 支持 2000 個以上的指令。這意味着,控制器輸出的控制信號,至少有 2000 種不同的組合。
運算器里的 ALU 和各種組合邏輯電路,可以認為是一個固定功能的電路。控制器“翻譯”出來的,就是不同的控制信號。這些控制信號,告訴 ALU 去做不同的計算。可以說正是控制器的存在,讓我們可以“編程”來實現功能,能讓我們的“存儲程序型計算機”名副其實。
CPU 所需要的硬件電路
那么,要想搭建出來整個 CPU,我們需要在數字電路層面,實現這樣一些功能。
首先,自然是我們之前已經講解過的 ALU 了,它實際就是一個沒有狀態的,根據輸入計算輸出結果的第一個電路。
第二,我們需要有一個能夠進行狀態讀寫的電路元件,也就是我們的寄存器。我們需要有一個電路,能夠存儲到上一次的計算結果。這個計算結果並不一定要立刻拿到電路的下游去使用,但是可以在需要的時候拿出來用。常見的能夠進行狀態讀寫的電路,就有鎖存器(Latch),以及我們后面要講的 D 觸發器(Data/Delay Flip-flop)的電路。
第三,我們需要有一個“自動”的電路,按照固定的周期,不停地實現 PC 寄存器自增,自動地去執行“Fetch - Decode - Execute“的步驟。我們的程序執行,並不是靠人去撥動開關來執行指令的。我們希望有一個“自動”的電路,不停地去一條條執行指令。
我們看似寫了各種復雜的高級程序進行各種函數調用、條件跳轉。其實只是修改 PC 寄存器里面的地址。PC 寄存器里面的地址一修改,計算機就可以加載一條指令新指令,往下運行。實際上,PC 寄存器還有一個名字,就叫作程序計數器。顧名思義,就是隨着時間變化,不斷去數數。數的數字變大了,就去執行一條新指令。所以,我們需要的就是一個自動數數的電路。
第四,我們需要有一個“譯碼”的電路。無論是對於指令進行 decode,還是對於拿到的內存地址去獲取對應的數據或者指令,我們都需要通過一個電路找到對應的數據。這個對應的自然就是“譯碼器”的電路了。
好了,現在我們把這四類電路,通過各種方式組合在一起,就能最終組成功能強大的 CPU 了。但是,要實現這四種電路中的中間兩種,我們還需要時鍾電路的配合。
時序邏輯電路
實現一個完整的 CPU 功能,除了加法器這樣的電路之外,我們還需要實現其他功能的電路。其中有一些電路,和我們實現過的加法器一樣,只需要給定輸入,就能得到固定的輸出。這樣的電路,我們稱之為組合邏輯電路(Combinational Logic Circuit)。
電路輸入是確定的,對應的輸出自然也就確定了。那么,我們要進行不同的計算,就要去手動撥動各種開關,來改變電路的開閉狀態。這樣的計算機,不像我們現在每天用的功能強大的電子計算機,反倒更像古老的計算尺或者機械計算機,干不了太復雜的工作,只能協助我們完成一些計算工作。
這樣,我們就需要引入第二類的電路,也就是時序邏輯電路(Sequential Logic Circuit)。
時序邏輯電路可以幫我們解決這樣幾個問題。
第一個就是自動運行的問題。時序電路接通之后可以不停地開啟和關閉開關,進入一個自動運行的狀態。這個使得我們上一講說的,控制器不停地讓 PC 寄存器自增讀取下一條指令成為可能。
第二個是存儲的問題。通過時序電路實現的觸發器,能把計算結果存儲在特定的電路里面,而不是像組合邏輯電路那樣,一旦輸入有任何改變,對應的輸出也會改變。
第三個本質上解決了各個功能按照時序協調的問題。無論是程序實現的軟件指令,還是到硬件層面,各種指令的操作都有先后的順序要求。時序電路使得不同的事件按照時間順序發生。
時鍾信號的硬件實現
想要實現時序邏輯電路,第一步我們需要的就是一個時鍾。CPU 的主頻是由一個晶體振盪器來實現的,而這個晶體振盪器生成的電路信號,就是我們的時鍾信號。
實現這樣一個電路,和我們之前講的,通過電的磁效應產生開關信號的方法是一樣的。只不過,這里的磁性開關,打開的不再是后續的線路,而是當前的線路。
在下面這張圖里你可以看到,我們在原先一般只放一個開關的信號輸入端,放上了兩個開關。一個開關 A,一開始是斷開的,由我們手工控制;另外一個開關 B,一開始是合上的,磁性線圈對准一開始就合上的開關 B。
於是,一旦我們合上開關 A,磁性線圈就會通電,產生磁性,開關 B 就會從合上變成斷開。一旦這個開關斷開了,電路就中斷了,磁性線圈就失去了磁性。於是,開關 B 又會彈回到合上的狀態。這樣一來,電路接通,線圈又有了磁性。我們的電路就會來回不斷地在開啟、關閉這兩個狀態中切換。
這個不斷切換的過程,對於下游電路來說,就是不斷地產生新的 0 和 1 這樣的信號。如果你在下游的電路上接上一個燈泡,就會發現這個燈泡在亮和暗之間不停切換。這個按照固定的周期不斷在 0 和 1 之間切換的信號,就是我們的時鍾信號(Clock Signal)。
一般這樣產生的時鍾信號,就像你在各種教科書圖例中看到的一樣,是一個振盪產生的 0、1 信號。
這種電路,其實就相當於把電路的輸出信號作為輸入信號,再回到當前電路。這樣的電路構造方式呢,我們叫作反饋電路(Feedback Circuit)。
接下來,我們還會看到更多的反饋電路。上面這個反饋電路一般可以用下面這個示意圖來表示,其實就是一個輸出結果接回輸入的反相器(Inverter),也就是我們之前講過的非門。
通過 D 觸發器實現存儲功能
有了時鍾信號,我們的系統里就有了一個像“自動門”一樣的開關。利用這個開關和相同的反饋電路,我們就可以構造出一個有“記憶”功能的電路。這個有記憶功能的電路,可以實現在 CPU 中用來存儲計算結果的寄存器,也可以用來實現計算機五大組成部分之一的存儲器。
我們先來看下面這個 RS 觸發器電路。這個電路由兩個或非門電路組成。我在圖里面,把它標成了 A 和 B。
- 在這個電路一開始,輸入開關都是關閉的,所以或非門(NOR)A 的輸入是 0 和 0。對應到列的這個真值表,輸出就是 1。而或非門 B 的輸入是 0 和 A 的輸出 1,對應輸出就是 0。B 的輸出 0 反饋到 A,和之前的輸入沒有變化,A 的輸出仍然是 1。而整個電路的輸出 Q,也就是 0。
- 當我們把 A 前面的開關 R 合上的時候,A 的輸入變成了 1 和 0,輸出就變成了 0,對應 B 的輸入變成 0 和 0,輸出就變成了 1。B 的輸出 1 反饋給到了 A,A 的輸入變成了 1 和 1,輸出仍然是 0。所以把 A 的開關合上之后,電路仍然是穩定的,不會像晶振那樣振盪,但是整個電路的輸出 Q變成了 1。
- 這個時候,如果我們再把 A 前面的開關 R 打開,A 的輸入變成和 1 和 0,輸出還是 0,對應的 B 的輸入沒有變化,輸出也還是 1。B 的輸出 1 反饋給到了 A,A 的輸入變成了 1 和 0,輸出仍然是 0。這個時候,電路仍然穩定。開關 R 和 S 的狀態和上面的第一步是一樣的,但是最終的輸出 Q 仍然是 1,和第 1 步里 Q 狀態是相反的。我們的輸入和剛才第二步的開關狀態不一樣,但是輸出結果仍然保留在了第 2 步時的輸出沒有發生變化。
- 這個時候,只有我們再去關閉下面的開關 S,才可以看到,這個時候,B 有一個輸入必然是 1,所以 B 的輸出必然是 0,也就是電路的最終輸出 Q必然是 0。
這樣一個電路,我們稱之為觸發器(Flip-Flop)。接通開關 R,輸出變為 1,即使斷開開關,輸出還是 1 不變。接通開關 S,輸出變為 0,即使斷開開關,輸出也還是 0。也就是,當兩個開關都斷開的時候,最終的輸出結果,取決於之前動作的輸出結果,這個也就是我們說的記憶功能。
這里的這個電路是最簡單的 RS 觸發器,也就是所謂的復位置位觸發器(Reset-Set Flip Flop) 。對應的輸出結果的真值表,你可以看下面這個表格。可以看到,當兩個開關都是 0 的時候,對應的輸出不是 1 或者 0,而是和 Q 的上一個狀態一致。
再往這個電路里加兩個與門和一個小小的時鍾信號,我們就可以實現一個利用時鍾信號來操作一個電路了。這個電路可以幫我們實現什么時候可以往 Q 里寫入數據。
我們看看下面這個電路,這個在我們的上面的 R-S 觸發器基礎之上,在 R 和 S 開關之后,加入了兩個與門,同時給這兩個與門加入了一個時鍾信號 CLK作為電路輸入。
這樣,當時鍾信號 CLK 在低電平的時候,與門的輸入里有一個 0,兩個實際的 R 和 S 后的與門的輸出必然是 0。也就是說,無論我們怎么按 R 和 S 的開關,根據 R-S 觸發器的真值表,對應的 Q 的輸出都不會發生變化。
只有當時鍾信號 CLK 在高電平的時候,與門的一個輸入是 1,輸出結果完全取決於 R 和 S 的開關。我們可以在這個時候,通過開關 R 和 S,來決定對應 Q 的輸出。
如果這個時候,我們讓 R 和 S 的開關,也用一個反相器連起來,也就是通過同一個開關控制 R 和 S。只要 CLK 信號是 1,R 和 S 就可以設置輸出 Q。而當 CLK 信號是 0 的時候,無論 R 和 S 怎么設置,輸出信號 Q 是不變的。這樣,這個電路就成了我們最常用的 D 型觸發器。用來控制 R 和 S 這兩個開關的信號呢,我們視作一個輸入的數據信號 D,也就是 Data,這就是 D 型觸發器的由來。
一個 D 型觸發器,只能控制 1 個比特的讀寫,但是如果我們同時拿出多個 D 型觸發器並列在一起,並且把用同一個 CLK 信號控制作為所有 D 型觸發器的開關,這就變成了一個 N 位的 D 型觸發器,也就可以同時控制 N 位的讀寫。
CPU 里面的寄存器可以直接通過 D 型觸發器來構造。我們可以在 D 型觸發器的基礎上,加上更多的開關,來實現清 0 或者全部置為 1 這樣的快捷操作。
PC 寄存器所需要的計數器
我們常說的 PC 寄存器,還有個名字叫程序計數器。下面我們就來看看,它為什么叫作程序計數器。
有了時鍾信號,我們可以提供定時的輸入;
有了 D 型觸發器,我們可以在時鍾信號控制的時間點寫入數據。
我們把這兩個功能組合起來,就可以實現一個自動的計數器了。
加法器的兩個輸入,一個始終設置成 1,另外一個來自於一個 D 型觸發器 A。我們把加法器的輸出結果,寫到這個 D 型觸發器 A 里面。於是,D 型觸發器里面的數據就會在固定的時鍾信號為 1 的時候更新一次。
這樣,我們就有了一個每過一個時鍾周期,就能固定自增 1 的自動計數器了。這個自動計數器,可以拿來當我們的 PC 寄存器。事實上,PC 寄存器的這個 PC,英文就是 Program Counter,也就是程序計數器的意思。
每次自增之后,我們可以去對應的 D 型觸發器里面取值,這也是我們下一條需要運行指令的地址。同一個程序的指令應該要順序地存放在內存里面。這里就和前面對應上了,順序地存放指令,就是為了讓我們通過程序計數器就能定時地不斷執行新指令。
加法計數、內存取值,乃至后面的命令執行,最終其實都是由我們一開始講的時鍾信號,來控制執行時間點和先后順序的,這也是我們需要時序電路最核心的原因。
在最簡單的情況下,我們需要讓每一條指令,從程序計數,到獲取指令、執行指令,都在一個時鍾周期內完成。如果 PC 寄存器自增地太快,程序就會出錯。因為前一次的運算結果還沒有寫回到對應的寄存器里面的時候,后面一條指令已經開始讀取里面的數據來做下一次計算了。這個時候,如果我們的指令使用同樣的寄存器,前一條指令的計算就會沒有效果,計算結果就錯了。
在這種設計下,我們需要在一個時鍾周期里,確保執行完一條最復雜的 CPU 指令,也就是耗時最長的一條 CPU 指令。這樣的 CPU 設計,我們稱之為單指令周期處理器(Single Cycle Processor)。
很顯然,這樣的設計有點兒浪費。因為即便只調用一條非常簡單的指令,我們也需要等待整個時鍾周期的時間走完,才能執行下一條指令。在后面章節里我們會講到,通過流水線技術進行性能優化,可以減少需要等待的時間。
讀寫數據所需要的譯碼器
現在,我們的數據能夠存儲在 D 型觸發器里了。如果我們把很多個 D 型觸發器放在一起,就可以形成一塊很大的存儲空間,甚至可以當成一塊內存來用。像我現在手頭這台電腦,有 16G 內存。那我們怎么才能知道,寫入和讀取的數據,是在這么大的內存的哪幾個比特呢?
於是,我們就需要有一個電路,來完成“尋址”的工作。這個“尋址”電路,就是我們接下來要講的譯碼器。
在現在實際使用的計算機里面,內存所使用的 DRAM,並不是通過上面的 D 型觸發器來實現的,而是使用了一種 CMOS 芯片來實現的。不過,這並不影響我們從基礎原理方面來理解譯碼器。在這里,我們還是可以把內存芯片,當成是很多個連在一起的 D 型觸發器來實現的。
如果把“尋址”這件事情退化到最簡單的情況,就是在兩個地址中,去選擇一個地址。這樣的電路,我們叫作2-1 選擇器。我把它的電路實現畫在了這里。
我們通過一個反相器、兩個與門和一個或門,就可以實現一個 2-1 選擇器。通過控制反相器的輸入是 0 還是 1,能夠決定對應的輸出信號,是和地址 A,還是地址 B 的輸入信號一致。
一個反向器只能有 0 和 1 這樣兩個狀態,所以我們只能從兩個地址中選擇一個。如果輸入的信號有三個不同的開關,我們就能從 2^3,也就是 8 個地址中選擇一個了。這樣的電路,我們就叫3-8 譯碼器。現代的計算機,如果 CPU 是 64 位的,就意味着我們的尋址空間也是 2^64,那么我們就需要一個有 64 個開關的譯碼器。
所以說,其實譯碼器的本質,就是從輸入的多個位的信號中,根據一定的開關和電路組合,選擇出自己想要的信號。除了能夠進行“尋址”之外,我們還可以把對應的需要運行的指令碼,同樣通過譯碼器,找出我們期望執行的指令,也就是在之前我們講到過的 opcode,以及后面對應的操作數或者寄存器地址。只是,這樣的“譯碼器”,比起 2-1 選擇器和 3-8 譯碼器,要復雜的多。
建立數據通路,構造一個最簡單的 CPU
D 觸發器、自動計數以及譯碼器,再加上一個我們之前說過的 ALU,我們就湊齊了一個拼裝一個 CPU 必須要的零件了。下面,我們就來看一看,怎么把這些零件組合起來,才能實現指令執行和算術邏輯計算的 CPU。
- 首先,我們有一個自動計數器。這個自動計數器會隨着時鍾主頻不斷地自增,來作為我們的 PC 寄存器。
- 在這個自動計數器的后面,我們連上一個譯碼器。譯碼器還要同時連着我們通過大量的 D 觸發器組成的內存。
- 自動計數器會隨着時鍾主頻不斷自增,從譯碼器當中,找到對應的計數器所表示的內存地址,然后讀取出里面的 CPU 指令。
- 讀取出來的 CPU 指令會通過我們的 CPU 時鍾的控制,寫入到一個由 D 觸發器組成的寄存器,也就是指令寄存器當中。
- 在指令寄存器后面,我們可以再跟一個譯碼器。這個譯碼器不再是用來尋址的了,而是把我們拿到的指令,解析成 opcode 和對應的操作數。
- 當我們拿到對應的 opcode 和操作數,對應的輸出線路就要連接 ALU,開始進行各種算術和邏輯運算。對應的計算結果,則會再寫回到 D 觸發器組成的寄存器或者內存當中。
這樣的一個完整的通路,也就完成了我們的 CPU 的一條指令的執行過程。在這個過程中,你會發現這樣幾個有意思的問題。
- 第一個,是我們之前在講過的程序跳轉所使用的條件碼寄存器。那時,講計算機的指令執行的時候,我們說高級語言中的 if…else,其實是變成了一條 cmp 指令和一條 jmp 指令。
cmp 指令是在進行對應的比較,比較的結果會更新到條件碼寄存器當中。
jmp 指令則是根據條件碼寄存器當中的標志位,來決定是否進行跳轉以及跳轉到什么地址。
為什么我們的 if…else 會變成這樣兩條指令,而不是設計成一個復雜的電路,變成一條指令?
到這里,我們就可以解釋了。這樣分成兩個指令實現,完全匹配好了我們在電路層面,“譯碼 - 執行 - 更新寄存器“這樣的步驟。
cmp 指令的執行結果放到了條件碼寄存器里面,我們的條件跳轉指令也是在 ALU 層面執行的,而不是在控制器里面執行的。這樣的實現方式在電路層面非常直觀,我們不需要一個非常復雜的電路,就能實現 if…else 的功能。
- 第二個,是關於我們之前講到的指令周期、CPU 周期和時鍾周期的差異。在上面的抽象的邏輯模型中,你很容易發現,我們執行一條指令,其實可以不放在一個時鍾周期里面,可以直接拆分到多個時鍾周期。
我們可以在一個時鍾周期里面,去自增 PC 寄存器的值,也就是指令對應的內存地址。然后,我們要根據這個地址從 D 觸發器里面讀取指令,這個還是可以在剛才那個時鍾周期內。但是對應的指令寫入到指令寄存器,我們可以放在一個新的時鍾周期里面。指令譯碼給到 ALU 之后的計算結果,要寫回到寄存器,又可以放到另一個新的時鍾周期。所以,執行一條計算機指令,其實可以拆分到很多個時鍾周期,而不是必須使用單指令周期處理器的設計。
因為從內存里面讀取指令時間很長,所以如果使用單指令周期處理器,就意味着我們的指令都要去等待一些慢速的操作。這些不同指令執行速度的差異,也正是計算機指令有指令周期、CPU 周期和時鍾周期之分的原因。因此,現代我們優化 CPU 的性能時,用的 CPU 都不是單指令周期處理器,而是通過流水線、分支預測等技術,來實現在一個周期里同時執行多個指令。
總結
把 CPU 運轉需要的數據通路和控制器介紹完了,也找出了需要完成這些功能,需要的 4 種基本電路。它們分別是,ALU 這樣的組合邏輯電路、用來存儲數據的鎖存器和 D 觸發器電路、用來實現 PC 寄存器的計數器電路,以及用來解碼和尋址的譯碼器電路。
雖然 CPU 已經是由幾十億個晶體管組成的及其復雜的電路,但是它仍然是由這樣一個個基本功能的電路組成的。只要搞清楚這些電路的運作原理,你自然也就弄明白了 CPU 的工作原理。
通過引入了時序電路,我們終於可以把數據“存儲”下來了。我們通過反饋電路,創建了時鍾信號,然后再利用這個時鍾信號和門電路組合,實現了“狀態記憶”的功能。
電路的輸出信號不單單取決於當前的輸入信號,還要取決於輸出信號之前的狀態。最常見的這個電路就是我們的 D 觸發器,它也是我們實際在 CPU 內實現存儲功能的寄存器的實現方式。
這也是現代計算機體系結構中的“馮·諾伊曼”機的一個關鍵,就是程序需要可以“存儲”,而不是靠固定的線路連接或者手工撥動開關,來實現計算機的可存儲和可編程的功能。
通過自動計數器的電路,來實現一個 PC 寄存器,不斷生成下一條要執行的計算機指令的內存地址。然后通過譯碼器,從內存里面讀出對應的指令,寫入到 D 觸發器實現的指令寄存器中。再通過另外一個譯碼器,把它解析成我們需要執行的指令和操作數的地址。這些電路,組成了我們計算機五大組成部分里面的控制器。
我們把 opcode 和對應的操作數,發送給 ALU 進行計算,得到計算結果,再寫回到寄存器以及內存里面來,這個就是我們計算機五大組成部分里面的運算器。
面向流水線的指令設計
單指令周期處理器
一條 CPU 指令的執行,是由“取得指令(Fetch)- 指令譯碼(Decode)- 執行指令(Execute) ”這樣三個步驟組成的。這個執行過程,至少需要花費一個時鍾周期。因為在取指令的時候,我們需要通過時鍾周期的信號,來決定計數器的自增。
那么,很自然地,我們希望能確保讓這樣一整條指令的執行,在一個時鍾周期內完成。這樣,我們一個時鍾周期可以執行一條指令,CPI 也就是 1,看起來就比執行一條指令需要多個時鍾周期性能要好。采用這種設計思路的處理器,就叫作單指令周期處理器(Single Cycle Processor),也就是在一個時鍾周期內,處理器正好能處理一條指令。
不過,我們的時鍾周期是固定的,但是指令的電路復雜程度是不同的,所以實際一條指令執行的時間是不同的。在講加法器和乘法器電路的時候,我給你看過,隨着門電路層數的增加,由於門延遲的存在,位數多、計算復雜的指令需要的執行時間會更長。
不同指令的執行時間不同,但是我們需要讓所有指令都在一個時鍾周期內完成,那就只好把時鍾周期和執行時間最長的那個指令設成一樣。這就好比學校體育課 1000 米考試,我們要給這場考試預留的時間,肯定得和跑得最慢的那個同學一樣。因為就算其他同學先跑完,也要等最慢的同學跑完間,我們才能進行下一項活動。
所以,在單指令周期處理器里面,無論是執行一條用不到 ALU 的無條件跳轉指令,還是一條計算起來電路特別復雜的浮點數乘法運算,我們都等要等滿一個時鍾周期。在這個情況下,雖然 CPI 能夠保持在 1,但是我們的時鍾頻率卻沒法太高。因為太高的話,有些復雜指令沒有辦法在一個時鍾周期內運行完成。那么在下一個時鍾周期到來,開始執行下一條指令的時候,前一條指令的執行結果可能還沒有寫入到寄存器里面。那下一條指令讀取的數據就是不准確的,就會出現錯誤。
到這里你會發現,這和我們之前講時鍾頻率時候的說法不太一樣。當時我們說,一個 CPU 時鍾周期,可以認為是完成一條簡單指令的時間。為什么到了這里,單指令周期處理器,反而變成了執行一條最復雜的指令的時間呢?
這是因為,無論是 PC 上使用的 Intel CPU,還是手機上使用的 ARM CPU,都不是單指令周期處理器,而是采用了一種叫作指令流水線(Instruction Pipeline)的技術。
現代處理器的流水線設計
其實,CPU 執行一條指令的過程和我們開發軟件功能的過程很像。
如果我們想開發一個手機 App 上的功能,並不是找來一個工程師,告訴他“你把這個功能開發出來”,然后他就吭哧吭哧把功能開發出來。真實的情況是,無論只有一個工程師,還是有一個開發團隊,我們都需要先對開發功能的過程進行切分,把這個過程變成“撰寫需求文檔、開發后台 API、開發客戶端 App、測試、發布上線”這樣多個獨立的過程。每一個后面的步驟,都要依賴前面的步驟。
我們的指令執行過程也是一樣的,它會拆分成“取指令、譯碼、執行”這樣三大步驟。更細分一點的話,執行的過程,其實還包含從寄存器或者內存中讀取數據,通過 ALU 進行運算,把結果寫回到寄存器或者內存中。
如果我們有一個開發團隊,我們不會讓后端工程師開發完 API 之后,就歇着等待前台 App 的開發、測試乃至發布,而是會在客戶端 App 開發的同時,着手下一個需求的后端 API 開發。那么,同樣的思路我們可以一樣應用在 CPU 執行指令的過程中。
CPU 的指令執行過程,其實也是由各個電路模塊組成的。我們在取指令的時候,需要一個譯碼器把數據從內存里面取出來,寫入到寄存器中;在指令譯碼的時候,我們需要另外一個譯碼器,把指令解析成對應的控制信號、內存地址和數據;到了指令執行的時候,我們需要的則是一個完成計算工作的 ALU。這些都是一個一個獨立的組合邏輯電路,我們可以把它們看作一個團隊里面的產品經理、后端工程師和客戶端工程師,共同協作來完成任務。
這樣一來,我們就不用把時鍾周期設置成整條指令執行的時間,而是拆分成完成這樣的一個一個小步驟需要的時間。同時,每一個階段的電路在完成對應的任務之后,也不需要等待整個指令執行完成,而是可以直接執行下一條指令的對應階段。
這就好像我們的后端程序員不需要等待功能上線,就會從產品經理手中拿到下一個需求,開始開發 API。這樣的協作模式,就是我們所說的指令流水線。這里面每一個獨立的步驟,我們就稱之為流水線階段或者流水線級(Pipeline Stage)。
如果我們把一個指令拆分成“取指令 - 指令譯碼 - 執行指令”這樣三個部分,那這就是一個三級的流水線。如果我們進一步把“執行指令”拆分成“ALU 計算(指令執行)- 內存訪問 - 數據寫回”,那么它就會變成一個五級的流水線。
五級的流水線,就表示我們在同一個時鍾周期里面,同時運行五條指令的不同階段。這個時候,雖然執行一條指令的時鍾周期變成了 5,但是我們可以把 CPU 的主頻提得更高了。我們不需要確保最復雜的那條指令在時鍾周期里面執行完成,而只要保障一個最復雜的流水線級的操作,在一個時鍾周期內完成就好了。
如果某一個操作步驟的時間太長,我們就可以考慮把這個步驟,拆分成更多的步驟,讓所有步驟需要執行的時間盡量都差不多長。這樣,也就可以解決我們在單指令周期處理器中遇到的,性能瓶頸來自於最復雜的指令的問題。像我們現代的 ARM 或者 Intel 的 CPU,流水線級數都已經到了 14 級。
雖然我們不能通過流水線,來減少單條指令執行的“延時”這個性能指標,但是,通過同時在執行多條指令的不同階段,我們提升了 CPU 的“吞吐率”。在外部看來,我們的 CPU 好像是“一心多用”,在同一時間,同時執行 5 條不同指令的不同階段。在 CPU 內部,其實它就像生產線一樣,不同分工的組件不斷處理上游傳遞下來的內容,而不需要等待單件商品生產完成之后,再啟動下一件商品的生產過程。
超長流水線的性能瓶頸
既然流水線可以增加我們的吞吐率,你可能要問了,為什么我們不把流水線級數做得更深呢?為什么不做成 20 級,乃至 40 級呢?這個其實有很多原因,我在之后會詳細講解。這里,我先講一個最基本的原因,就是增加流水線深度,其實是有性能成本的。
我們用來同步時鍾周期的,不再是指令級別的,而是流水線階段級別的。每一級流水線對應的輸出,都要放到流水線寄存器(Pipeline Register)里面,然后在下一個時鍾周期,交給下一個流水線級去處理。所以,每增加一級的流水線,就要多一級寫入到流水線寄存器的操作。雖然流水線寄存器非常快,比如只有 20 皮秒(ps,10^−12 秒)。
但是,如果我們不斷加深流水線,這些操作占整個指令的執行時間的比例就會不斷增加。最后,我們的性能瓶頸就會出現在這些 overhead 上。如果我們指令的執行有 3 納秒,也就是 3000 皮秒。我們需要 20 級的流水線,那流水線寄存器的寫入就需要花費 400 皮秒,占了超過 10%。如果我們需要 50 級流水線,就要多花費 1 納秒在流水線寄存器上,占到 25%。這也就意味着,單純地增加流水線級數,不僅不能提升性能,反而會有更多的 overhead 的開銷。所以,設計合理的流水線級數也是現代 CPU 中非常重要的一點。
奔騰4
流水線技術是一個提升性能的靈丹妙葯。它通過把一條指令的操作切分成更細的多個步驟,可以避免 CPU“浪費”。每一個細分的流水線步驟都很簡單,所以我們的單個時鍾周期的時間就可以設得更短。這也變相地讓 CPU 的主頻提升得很快。
這一系列的優點,也引出了現代桌面 CPU 的最后一場大戰,也就是 Intel 的 Pentium 4 和 AMD 的 Athlon 之間的競爭。在技術上,這場大戰 Intel 可以說輸得非常徹底,Pentium 4 系列以及后續 Pentium D 系列所使用的 NetBurst 架構被完全拋棄,退出了歷史舞台。但是在商業層面,Intel 卻通過遠超過 AMD 的財力、原本就更大的市場份額、無所不用的競爭手段,以及最終壯士斷腕般放棄整個 NetBurst 架構,最終依靠新的酷睿品牌戰勝了 AMD。
在此之后,整個 CPU 領域競爭的焦點,不再是 Intel 和 AMD 之間的桌面 CPU 之戰。在 ARM 架構通過智能手機的快速普及,后來居上,超越 Intel 之后,移動時代的 CPU 之戰,變成了高通、華為麒麟和三星之間的“三國演義”。
“主頻戰爭”帶來的超長流水線
我們其實並不能簡單地通過 CPU 的主頻,就來衡量 CPU 乃至計算機整機的性能。因為不同的 CPU 實際的體系架構和實現都不一樣。同樣的 CPU 主頻,實際的性能可能差別很大。所以,在工業界,更好的衡量方式通常是,用 SPEC 這樣的跑分程序,從多個不同的實際應用場景,來衡量計算機的性能。
但是,跑分對於消費者來說還是太復雜了。在 Pentium 4 的 CPU 面世之前,絕大部分消費者並不是根據跑分結果來判斷 CPU 的性能的。大家判斷一個 CPU 的性能,通常只看 CPU 的主頻。而 CPU 的廠商們也通過不停地提升主頻,把主頻當成技術競賽的核心指標。
Intel 一向在“主頻戰爭”中保持領先,但是到了世紀之交的 1999 年到 2000 年,情況發生了變化。
1999 年,AMD 發布了基於 K7 架構的 Athlon 處理器,其綜合性能超越了當年的 Pentium III。2000 年,在大部分 CPU 還在 500~850MHz 的頻率下運行的時候,AMD 推出了第一代 Athlon 1000 處理器,成為第一款 1GHz 主頻的消費級 CPU。在 2000 年前后,AMD 的 CPU 不但性能和主頻比 Intel 的要強,價格還往往只有 Intel 的 2/3。
在巨大的外部壓力之下,Intel 在 2001 年推出了新一代的 NetBurst 架構 CPU,也就是 Pentium 4 和 Pentium D。Pentium 4 的 CPU 有個最大的特點,就是高主頻。2000 年的 Athlon 1000 的主頻在當時是最高的,1GHz,然而 Pentium 4 設計的目標最高主頻是 10GHz。
為了達到這個 10GHz,Intel 的工程師做出了一個重大的錯誤決策,就是在 NetBurst 架構上,使用超長的流水線。這個超長流水線有多長呢?我們拿在 Pentium 4 之前和之后的 CPU 的數字做個比較,你就知道了。
Pentium 4 之前的 Pentium III CPU,流水線的深度是 11 級,也就是一條指令最多會拆分成 11 個更小的步驟來操作,而 CPU 同時也最多會執行 11 條指令的不同 Stage。隨着技術發展到今天,你日常用的手機 ARM 的 CPU 或者 Intel i7 服務器的 CPU,流水線的深度是 14 級。
可以看到,差不多 20 年過去了,通過技術進步,現代 CPU 還是增加了一些流水線深度的。那 2000 年發布的 Pentium 4 的流水線深度是多少呢?答案是 20 級,比 Pentium III 差不多多了一倍,而到了代號為 Prescott 的 90 納米工藝處理器 Pentium 4,Intel 更是把流水線深度增加到了 31 級。
要知道,增加流水線深度,在同主頻下,其實是降低了 CPU 的性能。因為一個 Pipeline Stage,就需要一個時鍾周期。那么我們把任務拆分成 31 個階段,就需要 31 個時鍾周期才能完成一個任務;而把任務拆分成 11 個階段,就只需要 11 個時鍾周期就能完成任務。在這種情況下,31 個 Stage 的 3GHz 主頻的 CPU,其實和 11 個 Stage 的 1GHz 主頻的 CPU,性能是差不多的。事實上,因為每個 Stage 都需要有對應的 Pipeline 寄存器的開銷,這個時候,更深的流水線性能可能還會更差一些。
流水線技術並不能縮短單條指令的響應時間這個性能指標,但是可以增加在運行很多條指令時候的吞吐率。因為不同的指令,實際執行需要的時間是不同的。我們可以看這樣一個例子。我們順序執行這樣三條指令。
- 一條整數的加法,需要 200ps。
- 一條整數的乘法,需要 300ps。
- 一條浮點數的乘法,需要 600ps。
如果我們是在單指令周期的 CPU 上運行,最復雜的指令是一條浮點數乘法,那就需要 600ps。那這三條指令,都需要 600ps。三條指令的執行時間,就需要 1800ps。
如果我們采用的是 6 級流水線 CPU,每一個 Pipeline 的 Stage 都只需要 100ps。那么,在這三個指令的執行過程中,在指令 1 的第一個 100ps 的 Stage 結束之后,第二條指令就開始執行了。在第二條指令的第一個 100ps 的 Stage 結束之后,第三條指令就開始執行了。這種情況下,這三條指令順序執行所需要的總時間,就是 800ps。那么在 1800ps 內,使用流水線的 CPU 比單指令周期的 CPU 就可以多執行一倍以上的指令數。
雖然每一條指令從開始到結束拿到結果的時間並沒有變化,也就是響應時間沒有變化。但是同樣時間內,完成的指令數增多了,也就是吞吐率上升了。
冒險和分支預測
那到這里可能你就要問了,這樣看起來不是很好么?Intel 的 CPU 支持的指令集很大,我們之前說過有 2000 多條指令。有些指令很簡單,執行也很快,比如無條件跳轉指令,不需要通過 ALU 進行任何計算,只要更新一下 PC 寄存器里面的內容就好了。而有些指令很復雜,比如浮點數的運算,需要進行指數位比較、對齊,然后對有效位進行移位,然后再進行計算。兩者的執行時間相差二三十倍也很正常。
既然這樣,Pentium 4 的超長流水線看起來很合理呀,為什么 Pentium 4 最終成為 Intel 在技術架構層面的大失敗呢?
第一個,自然是我們講過的功耗問題。提升流水線深度,必須要和提升 CPU 主頻同時進行。因為在單個 Pipeline Stage 能夠執行的功能變簡單了,也就意味着單個時鍾周期內能夠完成的事情變少了。所以,只有提升時鍾周期,CPU 在指令的響應時間這個指標上才能保持和原來相同的性能。
同時,由於流水線深度的增加,我們需要的電路數量變多了,也就是我們所使用的晶體管也就變多了。
主頻的提升和晶體管數量的增加都使得我們 CPU 的功耗變大了。這個問題導致了 Pentium 4 在整個生命周期里,都成為了耗電和散熱的大戶。而 Pentium 4 是在 2000~2004 年作為 Intel 的主打 CPU 出現在市場上的。這個時間段,正是筆記本電腦市場快速發展的時間。在筆記本電腦上,功耗和散熱比起台式機是一個更嚴重的問題了。即使性能更好,別人的筆記本可以用上 2 小時,你的只能用 30 分鍾,那誰也不愛買啊!
更何況,Pentium 4 的性能還更差一些。這個就要我們說到第二點了,就是上面說的流水線技術帶來的性能提升,是一個理想情況。在實際的程序執行中,並不一定能夠做得到。
還回到我們剛才舉的三條指令的例子。如果這三條指令,是下面這樣的三條代碼,會發生什么情況呢?
int a = 10 + 5; // 指令 1
int b = a * 2; // 指令 2
float c = b * 1.0f; // 指令 3
我們會發現,指令 2,不能在指令 1 的第一個 Stage 執行完成之后進行。因為指令 2,依賴指令 1 的計算結果。同樣的,指令 3 也要依賴指令 2 的計算結果。這樣,即使我們采用了流水線技術,這三條指令執行完成的時間,也是 200 + 300 + 600 = 1100 ps,而不是之前說的 800ps。而如果指令 1 和 2 都是浮點數運算,需要 600ps。那這個依賴關系會導致我們需要的時間變成 1800ps,和單指令周期 CPU 所要花費的時間是一樣的。
這個依賴問題,就是我們在計算機組成里面所說的冒險(Hazard)問題。這里我們只列舉了在數據層面的依賴,也就是數據冒險。在實際應用中,還會有結構冒險、控制冒險等其他的依賴問題。
對應這些冒險問題,我們也有在亂序執行、分支預測等相應的解決方案。我們在后面會詳細講解對應的知識。
但是,我們的流水線越長,這個冒險的問題就越難一解決。這是因為,同一時間同時在運行的指令太多了。如果我們只有 3 級流水線,我們可以把后面沒有依賴關系的指令放到前面來執行。這個就是我們所說的亂序執行的技術。比方說,我們可以擴展一下上面的 3 行代碼,再加上幾行代碼。
int a = 10 + 5; // 指令 1
int b = a * 2; // 指令 2
float c = b * 1.0f; // 指令 3
int x = 10 + 5; // 指令 4
int y = a * 2; // 指令 5
float z = b * 1.0f; // 指令 6
int o = 10 + 5; // 指令 7
int p = a * 2; // 指令 8
float q = b * 1.0f; // 指令 9
我們可以不先執行 1、2、3 這三條指令,而是在流水線里,先執行 1、4、7 三條指令。這三條指令之間是沒有依賴關系的。然后再執行 2、5、8 以及 3、6、9。這樣,我們又能夠充分利用 CPU 的計算能力了。
但是,如果我們有 20 級流水線,意味着我們要確保這 20 條指令之間沒有依賴關系。這個挑戰一下子就變大了很多。畢竟我們平時撰寫程序,通常前后的代碼都是有一定的依賴關系的,幾十條沒有依賴關系的指令可不好找。這也是為什么,超長流水線的執行效率發而降低了的一個重要原因。
總結
可以看到,為了能夠不浪費 CPU 的性能,我們通過把指令的執行過程,切分成一個一個流水線級,來提升 CPU 的吞吐率。而我們本身的 CPU 的設計,又是由一個個獨立的組合邏輯電路串接起來形成的,天然能夠適合這樣采用流水線“專業分工”的工作方式。
因為每一級的 overhead,一味地增加流水線深度,並不能無限地提高性能。同樣地,因為指令的執行不再是順序地一條條執行,而是在上一條執行到一半的時候,下一條就已經啟動了,所以也給我們的程序帶來了很多挑戰。
水線技術和其他技術一樣,都講究一個“折衷”(Trade-Off)。一個合理的流水線深度,會提升我們 CPU 執行計算機指令的吞吐率。我們一般用 IPC(Instruction Per Cycle)來衡量 CPU 執行指令的效率。
IPC 呢,其實就是我們之前講的 CPI(Cycle Per Instruction)的倒數。也就是說, IPC = 3 對應着 CPI = 0.33。Pentium 4 和 Pentium D 的 IPC 都遠低於自己上一代的 Pentium III 以及競爭對手 AMD 的 Athlon CPU。