深入理解計算機系統_第四章_處理器體系結構

現代微處理器可以稱得上是人類創造出的最複雜的系統之一。一塊手指甲大小的硅片上,可以容納一個完整的高性能處理器、大的高速緩存,以及用來連接到外部設備的邏輯電路。從性能上來說,今天在一塊芯片上實現的處理器已經使 20 年前價值 1000 萬美元、房間那麼大的超級計算機相形見絀了。即使是在像手機、導航系統和可編程恆溫器這樣的日常設備中的嵌入式處理器,也比早期計算機開發者所能想到的強大很多。

到目前爲止,我們看到的計算機系統只限於機器語言程序級。我們知道處理器必須執行一系列指令,每條指令執行某個簡單操作,例如兩個數相加。指令被編碼爲由一個或多個字節序列組成的二進制格式。一個處理器支持的指令和指令的字節級編碼稱爲它的指令集體系結構(Instruction-Set Architecture,ISA)。不同的處理器“家族”,例如 Intel IA32 和 x86-64、IBM/Freescale Power 和 ARM 處理器家族,都有不同的 ISA。一個程序編譯成在一種機器上運行,就不能在另一種機器上運行。另外,同一個家族裏也有很多不同型號的處理器。雖然每個廠商製造的處理器性能和複雜性不斷體改,但是不同的型號在 ISA 級別上都保持着兼容。一些常見的處理器家族(例如 x86-64)中的處理器分別由多個廠商提供。因此,ISA 在編譯器編寫者和處理器設計人員之間提供了一個概念抽象層,編譯器編寫者只需要知道允許哪些指令,以及它們是如何編碼的;而處理器設計者必須建造出執行這些指令的處理器。

本章將簡要介紹處理器硬件的設計。我們將研究一個硬件系統執行某種 ISA 指令的方式。這會使你能更好地理解計算機是如何工作的,以及計算機制造商們面臨的技術挑戰。一個很重要的概念是,現代處理器的實際工作方式可能跟 ISA 隱含的計算機模型大相徑庭。ISA 模型看上去應該是順序指令執行,也就是先取出一條指令,等到它執行完畢,再開始下一條。然而,與一個時刻只執行一條指令相比,通過同時處理多條指令的不同部分,處理器可以獲得更好的性能【還記得第三章中,在一些情況下,把if條件的兩種情況的結果都計算出來執行速度會更快嗎】爲了保證處理器能得到同順序執行相同的結果,人們採用了一些特殊的機制。在計算機科學中,用巧妙的辦法在提高性能的同時又保持了一個更簡單、更抽象模型的功能,這種思想是衆所周知的。在 Web 瀏覽器或平等二叉樹和哈希表這樣的信息檢索數據結構中使用緩存,就是這樣的例子。

你很可能永遠都不會自己設計處理器,這是專家們的任務,他們工作在全球不到 100 家的公司裏。那麼爲什麼你還應該瞭解處理器設計呢?【這正是我想問的。。。】

  • 從智力方面來說,處理器設計是非常有趣而且很重要的。學習事物是怎樣工作的由其內在價值。瞭解作爲計算機科學家和工程師日常生活一部分的一個系統的內部工作原理(特別是對很多人來說這還是個迷),是一件格外有趣的事情。處理器設計包括許多好的工程實踐原理。它需要完成複雜的任務,而結構又要儘可能簡單和規則。
  • 理解處理器如何工作能幫助理解整個計算機系統如何工作。在之後的第六章,我們講述存儲器系統,以及用來創建很大的內存映射同時又有快速訪問時間的技術。看看處理器端的處理器——內存接口,會使那些講述更加完整。
  • 雖然很少有人設計處理器,但是許多人設計包含處理器的硬件系統。將處理器嵌入到現實世界的系統中,如汽車和家用電器,已經變得非常普遍了。嵌入式系統的設計者必須瞭解處理器是如何工作的,因爲這些系統通常在比桌面和基於服務器的系統更低抽象級別上進行設計和編程。
  • 你的工作可能是處理器設計。雖然生產處理器的公司很少,但是研究處理器的設計人員隊伍已經非常巨大了,而且還在壯大。一個主要的處理器設計的各個方面大約涉及 1000 多人。

本章首先定義一個簡單的指令集,作爲我們處理器實現的運行示例。因爲受 x86-64 指令集的啓發,它被俗稱爲“x86”,所以我們稱我們的指令集爲 “y86-64”指令集。於 x86-64 相比,y86-64 指令集的數據類型、指令和尋址方式都要少一些。它的字節級編碼也比較簡單,機器代碼沒有相應的 x86-64 代碼緊湊,不過設計它的 CPU 譯碼邏輯也要簡單一些。雖然 y86-64 指令集很簡單,它仍然足夠完整,能讓我們寫一些處理整數的程序。設計一個實現 y86-64 的處理器要求我們解決許多處理器設計者同樣會面對的問題。

接下來會提供一些數字硬件設計的背景。我們會描述處理器中使用的基本構件塊,以及它們如何連接起來和操作。這些介紹是建立在第 2 章對布爾代數和位級操作的討論的基礎上的。我們還將介紹一種描述硬件系統控制部分的簡單語言,HCL(Hardware Control Language,硬件控制語言)。然後,用它來描述我們的處理器設計。即使你已經有了一些邏輯設計的背景知識,也應該讀讀這個部分以瞭解我們的特殊符號表示方法。

作爲設計處理器的第一步,我們給出一個基於順序操作、功能正確但有點不實用的 y86-64 處理器。這個處理器每個時鐘週期執行一條完整的 y86-64 指令。所以它的時鐘必須足夠慢,以允許一個週期內完成所有的動作。這樣一個處理器是可以實現的,但是它的性能遠遠低於同樣的硬件應該能達到的性能。

以這個順序設計爲基礎,我們進行一系列的改造,創建一個流水線化的處理器(pipelined processor)。這個處理器將每條指令的執行分解成五步,每個步驟由一個獨立的硬件部分或階段(stage)來處理。指令步經流水線的各個階段,且每個時鐘週期有一條新指令進入流水線。所以,處理器可以同時執行五條指令的不同階段。爲了使這個處理器保留 y86-64 ISA 的順序行爲,就要求處理很多冒險衝突(hazard)情況,冒險就是一條指令的位置或操作數依賴於其他仍在流水線中的指令。

我們設計了一些工具來研究和測試處理器設計。其中包括 y86-64 的彙編器、在你的機器上運行 y86-64 程序的模擬器,還有針對兩個順序處理器設計和一個流水線化處理器設計的模擬器。這些設計的控制邏輯用 HCL 符號表示的文件描述。通過編輯這些文件和重新編譯模擬器,你可以改變和擴展模擬器的行爲。我們還提供許多練習,包括實現新的指令和修改機器處理指令的方式。還提供測試代碼以幫助你評價修改的正確性。這些練習將極大地幫助你理解所有這些內容,也能使你更理解處理器設計者面臨的許多不同的設計選擇。

旁註 ARCH:VLOG 給出了用 Verilog 硬件描述語言描述的流水線化的 y86-64 處理器。其中包括爲基本的硬件構建塊和整個的處理器結構創建模塊。我們自動地將控制邏輯的 HCL 描述翻譯成 Verilog。首先用我們的模擬器調試 HCL 描述,能消除很多在硬件設計中會出現的棘手問題。給定一個 Verilog 描述,有商業和開源工具來支持模擬和邏輯合成(logic synthesis),產生實際的微處理器電路設計。因此,雖然我們在此花費大部分精力創建系統的圖形和文字描述,寫軟件的時候也會花費同樣的精力,但是這些設計能夠自動地合成,這表明我們確實在創建一個能夠用硬件實現的系統。

1 Y86-64 指令集體系結構

定義一個指令集體系結構包括定義各種狀態單元、指令集和它們的編碼、一組編程規範和異常事件處理。

1.程序員可見的狀態

在這裏插入圖片描述

y86-64 程序中的每條指令都會讀取或修改處理器狀態的某些部分。這稱爲程序員可見狀態,這裏的“程序員”既可以是用匯編代碼寫程序的人,也可以是產生機器級代碼的編譯器。在處理器實現中,只要我們保證機器級程序能夠訪問程序員可見狀態,就不需要完全按照 ISA 暗示的方式來表示和組織這個處理器狀態。y86-64 的狀態類似於 x86-64 。有 15 個程序寄存器。每個程序寄存器存儲一個 64 位的字。寄存器 %rsp 被入棧、出棧、調用和返回指令作爲棧指針。除此以外,寄存器沒有固定的含義或固定值。有 3 個一位的條件碼,它們保存着最近的算術或邏輯指令所造成影響的有關信息。程序計數器(PC)存放當前正在執行指令的地址。

內存從概念上來說就是一個很大的字節數組,保存着程序和數據。y86-64 程序用虛擬地址來引用內存位置。硬件和操作系統軟件聯合起來將地址翻譯成實際或物理地址,指明數據實際存在內存中哪個地方。第 9 章將更詳細地研究虛擬內存。現在,我們只認爲虛擬內存系統向 y86-64 程序提供了一個單一的字節數組映像。

程序狀態的最後一個部分是狀態碼 Stat,它表明程序執行的總體狀態。它會指示是正常運行,還是出現了某種異常,例如當一條指令試圖去讀非法的內存地址時。

2. Y86-64 指令

在這裏插入圖片描述

上圖給出了 y86-64 ISA 中各個指令的簡單描述。這個指令集就是我們處理器實現的目標。y86-64 指令集基本上是 x86-64 指令集的一個子集。它只包括 8 字節整數操作,尋址方式比較少,操作也少。因爲我們只有 8 字節數據,所以稱之爲“字(Word)”不會有任何歧義。在這個圖中,左邊是指令的彙編碼錶示,右邊是字節編碼。

下面是 y86-64 指令的一些細節:

  • x86-64 的 movq 指令分成了 4 個不同的指令:irmovq、rrmovq、mrmov、rmmovq,分別顯式的指明源和目的的格式。源可以是立即數(i)、寄存器(r)或內存(m)。指令名字的第一個字母就表明了源的類型。目的可以是寄存器(r)或內存(m)。指令名字的第二個字母指明瞭目的的類型。在決定如何實現數據傳送時,顯式的指明數據傳送的這 4 中類型是很有幫助的。兩個內存傳送指令中的內存引用方式是簡單的基址的偏移量形式。在地址計算中,我們不支持第二變址寄存器(second index register)和任何寄存器值的伸縮(scaling)。同 x86-64 一樣,我們不允許從一個內存地址直接傳送到另一個內存地址。另外,也不允許將立即數傳送到內存。
  • 有 4 個整數操作指令,比如上圖的 OPq。它們是 addq、subq、andq 和 xorq 。它們只對寄存器數據進行操作,而 x86-64 還允許對內存數據進行這些操作。這些指令會設置 3 個條件碼 ZF、SF 和 OF(零、符號和溢出)。
  • 7 個跳轉指令是 jmp、jle、jl、je、jne、jge 和 jg。根據分支指令的類型和條件代碼的設置來選擇分支。分支條件和 x86-64 的一樣。
  • 有 6 個條件傳送指令:cmovle、cmovl、cmove、cmovne、cmovge 和 cmovg。這些指令的格式與寄存器-寄存器傳送指令 rrmovq 一樣,但是隻有當條件碼滿足所需的約束時,纔會更新目的寄存器的值。
  • call 指令將返回地址入棧,然後跳到目的地址。ret 指令從這樣的調用中返回。
  • pushq 和 popq 指令實現了入棧和出棧,就像在 x86-64 中一樣。
  • halt 指令停止指令的執行。x86-64 中有一個與之相當的指令 hlt。x86-64 的應用程序不允許使用這條指令,因爲它會導致整個系統暫停運行。對於 y86-64 來說,執行 halt 指令會導致處理器停止,並將狀態碼設置爲 HLT。
3.指令編碼

上圖還給出了指令的字節級編碼。每條指令需要 1 ~ 10 個字節不等,這取決於需要哪些字段。每條指令的第一個字節表明指令的類型。這個字節分爲兩個部分,每部分 4 位:高 4 位是代碼(code)部分,低 4 位是 功能(function)部分。如上圖所示,代碼值位 0 ~ 0xB。功能值只有在一組相關指令共用一個代碼時纔有用。下圖給出了整數操作、分支和條件傳送指令的具體編碼。可以觀察到,rrmovq 與條件傳送有同樣的指令代碼。可以把它看作是一個“無條件傳送”,就好像 jmp 指令是無條件跳轉一樣,它們的功能代碼都是 0。
在這裏插入圖片描述

如下圖,15 個程序寄存器中每個都有一個相對應的範圍在 0 到 0xE之間的寄存器標識符(register ID)。y86-64 中的寄存器編號跟 x86-64 中的相同。程序寄存器存在 CPU 中的一個 寄存器文件中,這個寄存器文件就是一個小的、以寄存器 ID 作爲地址的隨機訪問存儲器。在指令編碼中以及在我們的硬件設計中,當需要指明不應訪問任何寄存器時,就用 ID 值 0xF 來表示。
在這裏插入圖片描述

有的指令只有一個字節長,而有的需要操作數的指令編碼就更長一些。首先,可能有附加的寄存器指示符字節(register specifier byte),指定一個或兩個寄存器。在上上圖中,這些寄存器字段成爲 rA 和 rB。從指令的彙編代碼表示中可以看到,根據指令類型,指令可以指定用於數據源和目的的寄存器,或是用於地址計算的基址寄存器。沒有寄存器操作數的指令,例如分支指令和 call 指令,就沒有寄存器指示符字節。那些只需要一個寄存器操作數的指令(irmovq、pushq 和 popq)將另一個寄存器指示符設爲 0xF。這種約定在我們的處理器實現中非常有用。

有些指令需要一個附加 4 字節常數字(constant word)。這個字能作爲 irmovq 的立即數數據,rmmovq 和 mrmovq 的地址指示符的偏移量,以及分支指令和調用指令的目的地址。注意,分支指令和調用指令的目的是一個絕對地址,而不像 IA32 中那樣使用 PC 相對尋址方式。處理器使用 PC 相對尋址方式,分支指令的編碼會更簡潔,同時這樣也能允許代碼從內存的一部分複製到另一部分而不需要更新所有的分支目標地址。因爲我們更關心描述的簡單性,所以就使用了絕對尋址方式。同 IA32 一樣,所有整數採用小端法編碼。當指令按照反彙編格式書寫時,這些字節就以相反的順序出現。

用十六進制來表示指令 rmmovq %rsp,0x12345678abcd(%rdx)的字節編碼。rmmovq 的第一個字節位 40。源寄存器 %rsp 應該編碼放在 rA 字段中,而基址寄存器 %rdx 應該編碼放在 rB 字段中。所以得到寄存器指示符字節 42。最後,偏移量編碼放在 8 字節的常數字中。首先在 0x123456789abcd 的前面填充上 0 變成 8 個字節,變成字節序列 00 01 23 45 67 89 ab cd 。寫成按字節反序就是 cd ab 89 867 45 23 01 00。將它們都連接起來就得到指令的編碼 4042cdab896745230100。

指令集的一個重要性質就是字節編碼必須有唯一的解釋。任意一個字節序列要麼是一個唯一的指令序列的編碼,要麼就不是一個合法的字節序咧。y86-64 就具有這個性質,因爲每條指令的第一個字節有唯一的代碼和功能組合,給定這個字節,我們就可以決定所有其他附加字節的長度和含義。這個性質保證了處理器可以無二義性地執行目標代碼程序。即使代碼嵌入在程序的其他字節中,只要從序列的第一個字節開始處理,我們讓然可以很容易確定指令序列。反過來說,如果不知道一段代碼序列的起始位置,我們就不能準確地確定怎樣將序列劃分成單獨的指令。對於試圖直接從目標代碼字節序列中抽取出機器級程序的反彙編程序和其他一些工具來說,這就帶來了問題。
在這裏插入圖片描述
在這裏插入圖片描述

在這裏插入圖片描述
在這裏插入圖片描述
在這裏插入圖片描述

4.Y86-64 異常

對 y86-64 來說,程序員可見的狀態包括狀態碼 Stat,它描述程序執行的總體裝填。這個代碼可能的值如下圖。
在這裏插入圖片描述
代碼值 1,命名爲 AOK,表示程序執行正常,而其他一些代碼則表示發生了某種類型的一場。代碼2,命名爲 HLT,表示處理器執行了一條 halt 指令。代碼3,命名爲 ADR,表示處理器試圖從一個非法內存地址讀或者向一個非法內存地址寫,可能是當取指令的時候,也可能是當讀或者寫數據的時候。我們會限制最大的地址,任何訪問超出這個限定值的地址都會引發 ADR 異常。代碼4,命名爲 INS,表示遇到了非法的指令代碼。

對於 y86-64 ,當遇到這些異常的時候,我們就簡單地讓處理器停止執行指令。在更完整的設計中,處理器通常會調用一個異常處理程序(exception handler),這個過程被指定用來處理遇到的某種類型的異常。就像在第 8 章中講述的,異常處理程序可以被配置成不同的結果,例如,中止程序或者調用一個用戶自定義的信號處理程序(signal handler)

5.y86-64 程序

在這裏插入圖片描述
在這裏插入圖片描述

x86-64 代碼是由 GCC編譯器產生的。y86-64代碼與之淚洗,但有以下不同點:

  • y86-64 將常數加載到寄存器(第 2~3行),因爲它在算術指令中不能使用立即數。
  • 要實現從內存讀取一個數值並將其與一個寄存器相加,y86-64 代碼需要兩條指令(第 8~9 行),而 x86-63 只需要一條 addq 指令(第 5 行)
  • 手工編寫的 y86-64 實現有一個優勢,即 subq 指令(第 11 行)同時還設置了條件碼,因此 GCC 生成代碼中的 testq 指令(第 9 行)就不是必需的。不過爲此,y86-64 代碼必須用 andq 指令(第 5 行)在進入循環之前設置條件碼。

下圖給出了用 y86-64 彙編代碼編寫的一個完整的程序文件的例子。這個程序既包括數據,也包括指令。僞指令(directive)指明應該將代碼或數據放在什麼位置,以及如何對齊。這個程序詳細說明了棧的放置、數據初始化、程序初始化和程序結束等問題。
在這裏插入圖片描述
在這裏插入圖片描述

在這個程序中,以“·”開頭的詞是彙編器僞指令(assembler directives),它們告訴彙編器調整地址,以便在那兒產生代碼或插入一些數據。僞指令.pos 0(第 2 行)告訴彙編器應該從地址 0 處開始產生代碼。這個地址是所有 y86-64 程序的起點。接下來的一條指令(第 3 行)初始化棧指針。我們可以看到程序結尾處(第 40 行)聲明瞭標號 stack,並且用一個 .pos 僞指令(第 39 行)指明地址 0x200。因此棧會從這個地址開始,向低地址增長。我們必須保證棧不會增長得太大以至於覆蓋了代碼或者其他程序數據。

程序的第 8 ~ 13 行聲明瞭一個 4 個字的數組,值分別爲
在這裏插入圖片描述

標號 array 表明了這個數組的起始,並且在 8 字節邊界處對齊(用.align僞指令指定)。第 16 ~ 19行給出了 “main”過程,在過程中對那個四字數組調用了 sum 函數,然後停止。

正如例子所示,由於我們創建 y86-64 代碼的唯一工具是彙編器,程序員必須執行本來通常交給編譯器、鏈接器和運行時系統來完成的任務。型號我們只用 y86-64 來寫一些小的程序,對此一些簡單的機制就足夠了。
在這裏插入圖片描述
在這裏插入圖片描述

上圖是 YAS 的彙編器對代碼進行彙編的結果。爲了便於理解,彙編器的輸出結果是 ASCII碼格式。

我們實現了一個指令集模擬器,稱爲 YIS,它的目的是模擬 y86-64 機器代碼程序的執行,而不用試圖去模擬任何具體處理器實現的行爲。這種形式的模擬有助於在有實際硬件可用之前調試程序,也有助於檢查模擬硬件或者在硬件上運行程序的結果。用 YIS 運行例子的目標代碼,產生輸出如下:
在這裏插入圖片描述

模擬輸出的第一行總結了執行以及 PC 和程序狀態的結果值。模擬器只打印出在模擬過程中被改變了的寄存器或內存中的字。左邊是原始值(這裏都是 0),右邊是最終的值。從輸出我們可以看到,寄存器 %rax 的值爲 0x0000abcdabcdabcd,即傳給子函數 sum 的四元素數組的和。另外,我們還能看到棧從地質 0x200 開始,向下增長,棧的使用導致內存地址 0x1f0 ~ 0x1f8 發生了變化。可執行代碼的最大地址爲 0x090,所以數值的入棧和出棧不會破壞可執行代碼。

在這裏插入圖片描述
在這裏插入圖片描述
在這裏插入圖片描述

在這裏插入圖片描述
在這裏插入圖片描述
在這裏插入圖片描述
在這裏插入圖片描述
在這裏插入圖片描述

6.一些 y86-64 指令的詳情

大多數 y86-64 指令是以一種直接明瞭的方式修改程序狀態的,所以定義每條指令想要達到的結果並不困難。不過,兩個特別的指令的組合需要特別注意一下。

pushq 指令會把棧指針減 8 ,並且將一個寄存器值寫入內存中。因此,當執行 pushq %rsp 指令時,處理器的行爲是不確定的,因爲要入棧的寄存器會被同一條指令修改。通常有兩種不同的約定:1.壓入 %rsp 的原始值,2.壓入減去 8 的 %rsp 的值。

對於y86-64 處理器來說,我們採用和 x86-64 一樣的做法,就像下面這個練習題確定出的那樣。
在這裏插入圖片描述
在這裏插入圖片描述

對 popq %rsp 指令也有類似的歧義。可以將 %rsp 置爲從內存中讀出的值,也可以置爲增加了增量後的棧指針。
在這裏插入圖片描述
在這裏插入圖片描述
在這裏插入圖片描述

2 邏輯設計和硬件控制語言 HCL

在硬件設計中,用電子電路來計算對位進行運算的函數,以及在各種存儲器單元中存儲位。大多數現代電路技術都是用信號線上的高電壓或低電壓來表示不同的位置。在當前的技術中,邏輯 1 是用 1.0 V 左右的高電壓表示,而邏輯 0 是用 0.0 V 左右的低電壓表示。要實現一個數字系統需要三個主要的組成部分:計算對位進行操作的函數的組合邏輯、存儲位的存儲器單元,以及控制存儲器單元更新的時鐘信號。

本節簡要描述這些不同的組成部分。我們還將介紹 HCL(Hardware Control Language,硬件控制語言),用這種語言來描述不同處理器設計的控制邏輯。

曾經,硬件 設計者通過描繪是示意性的邏輯電路圖來進行電路設計(最早是用紙和筆,後來是用計算機圖形終端)。現在,大多數設計都是用硬件描述語言(Hardware Description Language,HDL)來表達的。HDL 是一種文本表示,看上去和編程語言類似,但是它是用來描述硬件結構而不是程序行爲的。最常用的語言是 Verilog,它的語法類似於 C;另一種是 VHDL,它的語法類似於編程語言 Ada。這些語言本來就是用來表示數字電路的模擬模型的。20 世紀 80 年代中期,研究者開發出了邏輯合成(logic synthesis)程序,它可以根據 HDL 的描述生成有效的電路設計。現在有許多商用的合成程序,已經成爲產生數字電路的主要技術。從手工設計電路到合成生成的轉變就好像從寫彙編程序到寫高級語言程序,再用編譯器來產生機器代碼的轉變一樣。

我們的 HCL 語言只表達硬件設計的控制部分,只有有限的操作集合,也沒有模塊化。不過,正如我們會看到的那樣,控制邏輯是設計微處理器中最難的部分。

1.邏輯門

邏輯門是數字電路的基本計算單元。它們產生的輸出,等於它們輸入位值的某個布爾函數。下圖是布爾函數 AND、OR 和 NOT 的標準符號,C 語言中運算符的邏輯門下面是對應的 HCL 表達式: AND 用 && 表示,OR 用 || 表示,而 NOT 用 ! 表示。我們用這些符號而不用 C 語言中的位運算符 &、| 和 ~ ,這是因爲邏輯門只對單個位的數進行操作,而不是整個字。雖然圖中只說明瞭 AND 和 OR 的兩個輸入的版本,但是常見的是它們作爲 n 路操作,n >2。不過,在 HCL 中我們還是把它們寫作二元運算符,所以,三個輸入的 AND 門,輸入爲 a、b 和 c,用 HCL 表示就是 a && b && c。

邏輯門總是活動的(active)。一旦一個門的輸入變化了,在很短的時間內,輸出就會相應地變化。在這裏插入圖片描述

2.組合電路和 HCL 布爾表達式

將很多的邏輯門組合成一個網,就能構建計算塊(computational block),稱爲組合電路(combinational circuits)。如何構建這些網有幾個限制:

  • 每個邏輯門的輸入必須連接到下述選項之一:一個系統輸入,某個存儲器單元的輸出,某個邏輯門的輸出
  • 兩個或多個邏輯門的輸出不能連接在一起。否則可能會使線上的信號矛盾,可能會導致一個不合法的電壓或電路故障
  • 這個網必須是無環的。也就是在網中不能有路徑經過一系列的門而形成一個迴路,這樣的迴路會導致該網絡計算的函數有歧義
    在這裏插入圖片描述
    上圖是一個我們覺得非常有用的簡單這電路的例子。它有兩個輸入 a 和 b,有唯一的輸出 eq,當 a 和b 都是 1 或都是 0 時,輸出爲 1。用 HCL 來寫這個網的函數就是:
    bool eq = (a && b)||(!a && !b);

這段代碼簡單地定義了位級信號 eq,它的輸入 a 和 b 的函數。從這個例子可以看出 HCL 使用了 C 語言風格的語法,‘ = ’ 將一個信號名與一個表達式聯繫起來。不過同 C 不一樣,我們不把它看成執行了一次計算並把結果放入內存中某個位置,它只是給表達式一個名字。

在這裏插入圖片描述

上圖給出了另一個簡單但很有用的組合電路,稱爲多路複用器(multiplxor,通常稱爲“MUX”)。多路複用器根據輸入控制信號的值,從一組不同的數據信號中選出一個。在這個單個位的多路複用器中,兩個數據信號是輸入位 a 和 b,控制信號是輸入位 s。當 s 爲 1 時,輸出等於 a;而當 s 爲 0 時,輸出等於 b。在這個電路中,我們可以看出兩個 AND 門決定了是否將它們相對應的數據輸入傳送到 OR 門。當 s 爲 1 時,上面的 AND 門將傳送信號 b,當 s 爲 1 時,下面的 AND 門將傳送信號 a。接下里,我們來寫輸出信號的 HCL 表達式,使用的就是邏輯中相同的操作:

bool out = (s && a)||(!s && b);

HCL 表達式很清楚地表明瞭組合邏輯電路和 C語言中邏輯表達式的對應之處。它們都是用布爾操作來對輸入進行計算的函數。值得注意的是,這良種化表達計算的方法之間有以下區別:

  • 因爲組合電路是由一系列的邏輯門組成,它的屬性是輸出會持續地響應輸入的變化。如果電路的輸入變化了,在一定的延遲之後,輸出也會相應地變化。相比之下,C 表達式只會在程序執行過程中被遇到時才進行求值
  • C 的邏輯表達式允許參數是任意整數,0 表示 FALSE,其他任何值都表示 TRUE。而邏輯門只對位置 0 和 1 進行操作
  • C 的邏輯表達式有個屬性就是它們可能只被部分求值。如果一個 AND 或 OR 操作的結果只用對第一個參數求值就能確定,那麼就不會對第二個參數求值了。例如下面的 C 表達式:(a && !a) && func(b,c),這裏函數 func 是不會被調用的,因爲表達式(a && !a)求值是 0 。而邏輯組合沒有部分求值這條規則,邏輯門只是簡單地響應輸入的變化。
3.字級的組合電路和 HCL 整數表達式

通過將邏輯門組合成大的網,可以構造出能計算更加複雜函數的組合電路。通常,我們設計能對數據字(word)進行操作的電路。有一些位級信號,代表一個整數或一些控制模式。例如,我們的處理器設計將包含很多字,字的大小範圍爲 4 到 64 位,代表整數、地址、指令代碼和寄存器標識符。

執行字級計算的組合電路根據輸入的字的各個位,用邏輯門來計算輸出字的各個位。例如下圖中的一個組合電路,它測試兩個 64 位字 A 和 B 是否相等。也就是,當且僅當 A 的每一位都和 B 的相應位相等時,輸出才爲 1。這個電路時用 64 個單個位相等電路實現的。這些單個位電路的輸出用一個 AND 門連起來,形成了這個電路的輸出。

在這裏插入圖片描述
在 HCL 中,我們將所有字級的信號都聲明爲 int,不指定字的大小。這樣做是爲了簡單。在全功能的硬件描述語言中,每個字都可以聲明爲特有的位數。HCL 允許比較字是否相等,因此上圖的函數可以在字級上表達成 bool Eq = (A == B);

這裏參數 A 和 B 是 int 型的。注意我們使用和 C 語言中一樣的語法習慣,‘ = ’表示賦值,而 ‘ == ’ 是相等運算符。
在這裏插入圖片描述
上圖是字級的多路複用器電路。這個電路根據控制輸入位 s,產生一個 64 位的字 Out,等於兩個輸入字 A 或者 B 中的一個。這個電路由 64 個相同的子電路組成,每個子電路的結構都類似於位級多路複用器。不過這個字級的電路並沒有簡單的複製 64 次位級多路複用器,它只產生一次 !s,然後在每個位的地方都重複使用它,從而減少非門(inverters)的數量。

處理器中會遇到很多種多路複用器,使得我們能根據某些控制條件,從許多源中選出一個字。在 HCL 中,多路複用函數是用情況表達式(case expression)來描述的。情況表達式的通用格式如下:
在這裏插入圖片描述
這個表達式包含一系列的情況,每種情況 i 都有一個布爾表達式 select,和一個整數表達式 expr,前者表明什麼時候該選擇這種情況,後者指明的是得到的值。

同 C 的 switch 語句不同,我們不要求不同的選擇表達式之間互斥。從邏輯上講,這些選擇表達式是順序求值的,且第一個求值爲 1 的情況會被選中。例如,上圖的字級多路複用器用 HCL 來描述就是:
在這裏插入圖片描述
在這段代碼中,第二個選擇表達式就是 1,表明如果前面沒有情況被選中,那就選擇這種情況。這是 HCL 中一種指定默認情況的方法。幾乎所有的情況表達式都是以此結尾的。

允許不互斥的選擇表達式使得 HCL 代碼的可讀性更好。實際的硬件多路服用器的信號必須互斥,它們要控制哪個輸入字應該被傳送到輸出,就像 s 和 !s。要將一個 HCL 情況表達式翻譯成硬件,邏輯合成程序需要分析選擇表達式集合,並解決任何可能的衝突,確保只有第一個滿足的情況纔會被選中。

選擇表達式可以是任意的布爾表達式,可以有任意多的情況。這就使得情況表達式能描述帶複雜選擇標準的、多路輸入信號的塊。這個電路根據控制信號 s1 和 s0,從 4 個輸入字 A、B、C 和 D 中選擇一個,將控制信號看作一個兩位的二進制數。我們可以用 HCL 來表示這個電路。用布爾表達式描述控制位模式的不同組合:
在這裏插入圖片描述
在這裏插入圖片描述
右邊的註釋(任何以 # 開頭到行尾結束的文字都是註釋)表明了 s1 和 s0 的什麼組合會導致該種情況會被選中。可以看到選擇表達式有時可以簡化,因爲只有 一個匹配的情況纔會被選中。例如,第二個表達式可以寫成 !s1,而不用寫的更完整 !s1 && s0,因爲另一種可能 s1 等於 0 已經出現在第一個選擇表達式中了。類似的,第三個表達式可以寫作 !s0,而第四個可以簡單得寫成 1。

來看最後一個例子,假設我們想設計一個邏輯電路來找一組字 A、B 和 C 中的最小值,如下:
在這裏插入圖片描述
用 HCL 來表達就是:
在這裏插入圖片描述
在這裏插入圖片描述
在這裏插入圖片描述

組合邏輯電路可以設計成字級數據上執行許多不同類型的操作。算術/邏輯單元(ALU)是一種很重要的組合電路,下圖是它的一個抽象的圖示。這個電路由三個輸入:標號爲 A 和 B 的兩個數據輸入,以及一個控制輸入。根據控制輸入的設置,電路會對數據輸入執行不同的算術和邏輯操作。可以看到,這個 ALU 中畫的四個操作對應於 y86-64 指令集支持的四種不同的整數操作,而控制值和這些操作的功能碼相對應。我們還注意到減法的操作數順序,是輸入 B 減去輸入 A。之所以這樣做,是爲了使這個順序於 subq 指令的參數順序一致。
在這裏插入圖片描述

4.集合關係

在處理器設計中,很多時候都需要將一個信號與許多可能匹配的信號做比較,以此來檢測正在處理的某個指令代碼是否屬於某一類指令代碼。下面來看個例子,假設想從一個兩位信號 code 中選擇高位和低位來產生四路複用器的控制信號 s1 和 s0
在這裏插入圖片描述
在這個電路中,兩位的信號 code 就可以用來控制對 4 個數據字 A、B、C 和 D 做選擇。根據可能的 code 值,可以用相等測試來表示信號 s1 和 s0 的產生:
在這裏插入圖片描述
還有一種更簡潔的方式來表示這樣的屬性:當 code 在集合 {2,3} 中時 s1 爲 1
,而 code 在集合 {1,3}中時 s0 爲 1:
在這裏插入圖片描述

5.存儲器和時鐘

組合電路本質上將,不存儲任何信息。相反,它們只是簡單地響應輸入信號,產生等於輸入的某個函數的輸出。爲了產生時序電路(sequential circuit),也就是有狀態並且在這個狀態上進行計算的系統。我們必須引入按位存儲信息的設備。存儲設備都是由同一個時鐘控制的,時鐘是一個週期性信號,決定什麼時候要把新值加載到設備中。考慮兩類存儲器設備:

  • 時鐘寄存器(簡稱寄存器)存儲單個位或字。時鐘信號控制寄存器加載輸入值。
  • 隨機訪問存儲器(簡稱內存)存儲多個字,用地址來選擇該讀或該寫哪個字。隨機訪問存儲器的例子包括:1.處理器的虛擬內存系統,硬件和操作系統軟件結合起來使處理器可以在一個很大的地址空間內訪問任意的字;2.寄存器文件,在此,寄存器標識符作爲地址。在 IA32 或 y86-64 處理器中,寄存器文件 15 個程序寄存器(%rax ~ %r14)。

正如我們看到的那樣,在說到硬件和機器級編程時,“寄存器”這個詞是兩個有細微差別的事情。在硬件中,寄存器直接將它的輸入和輸出線連接到電路的其他部分。在機器級編程中,寄存器代表的是 CPU 中爲數不多的可尋址的字,這裏的地址是寄存器 ID。這些字通常都存在寄存器文件中,雖然我們會看到硬件有時直接將一個字從一個指令傳送到另一個指令,以避免先寫寄存器文件再讀出來的延遲。需要避免歧義時了,我們會分別稱呼這兩類寄存器爲“硬件寄存器”和“程序寄存器”。
在這裏插入圖片描述
上圖更詳細地說明了一個硬件寄存器以及它是如何工作的。大多數時候,寄存器都保持在穩定狀態(用 x 表示),產生的輸出等於它的當前狀態。信號沿着寄存器前面的組合邏輯傳播,這時,產生了一個新的寄存器輸入(用 y 表示),但是隻要時鐘是低電位的,寄存器的輸出就仍然保持不變。當時鍾變成高電位時候,輸入信號就加載到寄存器中,成爲下一個狀態 y,直到下一個時鐘上升沿,這個狀態就一直是寄存器的新輸出。關鍵是寄存器是作爲電路不同部分中的組合邏輯之間的屏障。每當每個時鐘到達上升沿時,值纔會從寄存器的輸入傳送到輸出。我們的 y86-64 處理器會用時鐘寄存器保存程序計數器 PC、條件代碼 CC 和程序狀態 Stat。

下圖展示了一個典型的寄存器文件:
在這裏插入圖片描述
寄存器文件有兩個讀端口(A 和 B),還有一個寫端口(W)。這樣一個多端口隨機訪問存儲器允許同時進行多個讀和寫操作。圖中所示的寄存器文件中,電路可以讀兩個程序寄存器的值,同時更新第三個寄存器的狀態。每個端口都有一個地址輸入,表明該選擇哪個程序寄存器,另外還有一個數據輸出或對應該程序寄存器的輸入值。兩個讀端口有地址輸入 srcA 和 srcB(“source A”和“source B”的縮寫)和數據輸出 valA 和 valB(“value A” 和 “value B”的縮寫)。寫端口有地址輸入 dstW(“destination W”的縮寫),以及數據輸入 valW(“value W”的縮寫)。

雖然寄存器文件不是組合電路,因爲它有內部存儲。不過,在我們的實現中,從寄存器文件讀數據就好像它是一個以地址爲輸入、數據爲輸出的一個組合邏輯塊。當 srcA 或 srcB 被設成某個寄存器 ID 時,在一段延遲之後,存儲在相應程序寄存器的值就會出現在 valA 或 valB 上。例如,將 src A 設爲 3,就會讀出程序寄存器 %rbx 的值,然後這個值就會出現在輸出 valA 上。

向寄存器文件寫入字是由時鐘信號控制的,控制方式類似於將值加載到時鐘寄存器。每次時鐘上升時,輸入 valW 上的值會被寫入輸入 dstW 上的寄存器 ID 指示的程序寄存器。當 dstW 設爲特殊的 ID 值 0xF 時,不會寫任何程序寄存器。由於寄存器文件既可以讀也可以寫,一個很自然的問題就是“如果我們同時讀和寫同一個寄存器會怎麼樣?”,答案很簡單:如果更新一個寄存器,同時在讀端口上用同一個寄存器 ID,我們會看到一個從舊值到新值的變化。當我們把這個寄存器文件加入到處理器設計中,我們保證會考慮這個屬性的。

處理器有一個隨機訪問存儲器來存儲數據數據,如下圖:
在這裏插入圖片描述
這個內存有一個地址輸入,一個寫的數據輸入,以及一個讀的數據輸出。同寄存器文件一樣,從內存中讀的操作方式類似於組合邏輯:如果我們在輸入 address 上提供一個地址,並將 write 控制信號設置爲 0,那麼在經過一些延遲以後,存儲在那個地址上的值會出現在輸出 data 上。如果地址超出了範圍,error 信號會設置爲 1,否則就設置爲 0。寫內存是由時鐘控制的: 我們將 address 設置爲期望的地址,將 data in 設置爲i期望的值,而 write 設置爲 1。然後當我們控制時鐘時,只要地址是合法的,就會更新內存中指定的位置。對於讀操作來說,如果地址是不合法的,error 信號會被設置爲 1.這個信號是由組合邏輯產生的,因爲所需要的邊界檢查純粹就是地址輸入的函數,不涉及保存任何狀態。

我們的處理器還包括另外一個只讀存儲器,用來讀指令。在大多數實際系統中,這兩個存儲器被合併爲一個具有雙端口的存儲器:一個用來讀指令,另一個用來讀或者寫數據。

3. y86-64 的順序實現

現在已經有了實現 y86-64 處理器所需要的部件。首先,我們描述一個稱爲 SEQ(“sequential” 順序的)的處理器。每個時鐘週期上,SEQ 執行處理一條完整指令所需的所有步驟。不過,這需要一個很長的時鐘週期時間,因此時鐘週期頻率可能會低到不可接受。我們開發 SEQ 的目標就是提供實現最終目的的第一步,我們的最終目的是實現一個高效的、流水線化的處理器。

1.將處理組織成階段

通常,處理一條指令包括很多操作。將它們組織成某個特殊的階段序列,即使指令的動作差異很大,但所有的指令都遵循統一的序列。每一步的具體處理取決於正在執行的指令。創建這樣一個框架,我們就能夠設計一個充分利用硬件的處理器。下面是關於各個階段以及各階段內執行操作的簡略描述:

  • 取指(fetch):取指階段從內存讀取指令字節,地址爲 PC 的值。從指令中抽取出指令指示符字節的兩個四位部分,稱爲 icode(指令代碼)和 ifun(指令功能)。它可能取出一個寄存器指示符字節,指明一個或兩個寄存器操作數指示符 rA 和 rB。它還可能取出一個四字節常數字 valC。它按順序方式計算當前指令的下一條指令的地址 valP。也就是說,valP 等於 PC 的值加上已取出指令的長度。
  • 譯碼(decode):譯碼階段從寄存器文件讀入最多兩個操作數,得到值 valA 和 / 或 valB。通常,它讀入指令 rA 和 rB 字段指明的寄存器,不過有些指令是讀寄存器 %rsp 的。
  • 執行(execute):在執行階段,算術/邏輯單元(ALU)要麼執行指令指明的操作(根據 ifun 的值),計算內存引用的有效地址,要麼增加或減少棧指針。得到的值我們稱爲 valE 。在此,也可能設置條件碼。對一條條件傳送指令來說,這個階段會檢驗條件碼和傳送條件(由 ifun 給出),如果條件成立,則更新木雕寄存器。同樣,對一條跳轉指令來說,這個階段會決定是不是應該選擇分支。
  • 訪存( memory):訪存階段可以將數據寫入內存,或者從內存讀出數據。讀出的值爲 valM。
  • 寫回(write back):寫回階段最多可以寫兩個結果到寄存器文件。
  • 更新 PC(PC update):將 PC 設置成下一條指令的地址。

處理器無限循環,執行這些階段。在我們簡化的實現中,發生任何異常時,處理器就會停止:它執行 halt 指令或非法指令,或它試圖讀寫非法地址。在更完整的設計中,處理器會進入異常處理模式,開始執行由異常的類型決定的特殊代碼。

從前面的講述中可以看出,執行一條指令是需要進行很多處理的。我們不僅必須執行指令所表明的操作,還必須計算地址、更新棧指針,以及確定下一條指令的地址。幸好每條指令的整個流程都比較相似。因爲我們想使得硬件數量儘可能少,並且最終將把它映射到一個二維的集成電路芯片的表明,在設計硬件時,一個非常簡單而一致的結構是非常重要的。降低複雜度的一種方法是讓不同的指令共享盡量多的硬件。例如,我們的每個處理器設計都只含有一個算術/邏輯單元,根據所執行的指令類型的不同,它的使用方式也不同。在硬件上覆制邏輯塊的成本比軟件中有重複代碼的成本大得多。而且在硬件系統中處理許多特殊情況和特性要比用軟件來處理困難得多。
在這裏插入圖片描述
我們面臨的一個挑戰是將每條不同指令所需要的計算放入到上述那個通用框架中。我們會使用上圖所示的代碼來描述不同 y86-64 指令的處理。閱讀時可以把它看成是從上至下的順序求值。當我們將這些計算映射到硬件時,會發現其實並不需要嚴格按照順序來執行這些求值。
在這裏插入圖片描述
上圖給出了對 OPq(整數和邏輯運算)、rrmovq(寄存器-寄存器傳送)和irmovq(立即數-寄存器傳送)類型的指令所需的處理。讓我們先來考慮一下整數操作。我們小心地選擇了指令編碼,這樣四個整數操作(addq、subq、andq 和 xorq)都有相同的 icode 值。我們可以以相同的步驟順序來處理它們,除了 ALU 計算必須根據 ifun 中編碼的具體的指令操作來設定。

整數操作指令的處理遵循上面列出的通用模式。在取指階段,我們不需要常數字,所以 valP 就計算爲 PC + 2。在譯碼階段,我們要讀兩個操作數。在執行階段,它們的功能指示符 ifun 一起再提供給 ALU,這樣一來 valE 就成了指令結果。這個計算是用表達式 valB OP valA 來表達的,這裏 OP 代表 ifun 指定的操作。要注意兩個參數的順序——這個順序與 y86-64 的習慣是一致的。例如,指令 subq %rax,%rdx 計算的是 R[%rdx] - R[%rax] 的值。這些指令在訪存階段什麼也不做,而在回寫階段,valE 被寫入寄存器 rB,然後 PC 設爲 valP,整個指令的執行就結束了。

作爲一個例子,我們來看看一條 subq 指令的處理過。前面兩條指令分別將寄存器 %rdx 和 %rbx 初始化成 9 和 21,指令位於地址 0x014,由兩個字節組成,值分別爲 0x61 和 0x23 。
在這裏插入圖片描述
這個跟蹤表明我們達到了理想的效果,寄存器 %rbx 設成了 12,三個條件碼都設成了 0 ,而 PC 加了 2。

執行 rrmovq 指令和執行算術運算類似。不過,不需要取第二個寄存器操作數。我們將 ALU 的第二個輸入設爲 0,先把它和第一個操作數相加,得到 valE = valA,然後再把這個值寫到寄存器文件。對 irmovq 的處理與此類似,除了 ALU 的第一個輸入位常數值 valC。另外是長指令格式,對於 irmovq ,PC 必須加 10。所有這些指令都不改變條件碼。
在這裏插入圖片描述在這裏插入圖片描述
下圖給出了內存讀寫指令 rmmovq 和 mrmovq 所需要的處理。基本流程也和前面的一樣,不過是用 ALU 來加 valC 和 valB ,得到內存操作的有效地址(偏移量於基址寄存器之和)。在訪存階段,會將寄存器值 valA 寫到內存,或從內存中讀出 valM。
在這裏插入圖片描述
再看個例子,這個例子中寄存器 %rsp 初始化成了 128,而 %rbx 仍然是 subq 指令算出來的結果 12。指令位於地址 0x020,有 10 個字節。前兩個值爲 0x40 和 0x43,後 8 個是數字 0x0000000000000064 按字節反過來得到的數。各個階段的處理如下:
在這裏插入圖片描述
下圖給出了處理 pushq 和 popq 指令所需的步驟。它們可以算是最難實現的指令了,因爲它們既設計訪問內存,又要 增加或減少棧指針。雖然這兩天指令的流程比較相似,但是它們還是有很重要的區別。
在這裏插入圖片描述
pushq 指令開始時很像我們前面講過的指令,但是在譯碼階段,用 %rsp 作爲第二個寄存器操作數的標識符,將棧指針賦值爲 valB。在執行階段,用 ALU 將棧指針減 8。減過 8 的值就是內存寫的地址,在寫回階段還會存回到 %rsp 中。將 valE 作爲寫操作的地址,是遵循 y86-64 的慣例,也就是在寫之前,pushq 應該先讓棧指針減去 8,即使棧指針的更新實際上是在內存操作完成之後才進行的。

下面這個例子中寄存器 %rdx 的值爲 9,而寄存器 %rsp 的值爲 128。我們還可以看到指令是位於地址 0x02a,有兩個字節,值分別是 0xa0 和 0x2f。各個階段的處理如下:
在這裏插入圖片描述
popq 指令的執行與 pushq 的執行類似,除了在譯碼階段要讀兩次棧指針以外。這樣做看上去狠多餘,但是我們會看到讓 valA 和 valB 都存放棧指針的值,會使後面的流程跟其他的指令更相似,增強設計的整體一致性。在執行階段,用 ALU 給棧指針加 8,但是沒加過 8 的原始值作爲內存操作的地址。在寫回階段,要用加過 8 的值作爲內存讀地址,保持了 y86-64 的慣例,popq 應該首先讀內存,再增加棧指針。
在這裏插入圖片描述
在這裏插入圖片描述
在這裏插入圖片描述
在這裏插入圖片描述
在這裏插入圖片描述
在這裏插入圖片描述
在這裏插入圖片描述
下圖表明瞭三類控制轉移指令的處理:各種跳轉、call 和 ret 。可以看到,我們能用同前面指令一樣的整體流程來實現這些指令。
在這裏插入圖片描述
同對整數操作一樣,我們更夠以一種統一的方式處理所有的跳轉指令,因爲它們的不同只在於判斷是否要選擇分支的時候。除了不需要一個寄存器指示符字節以外,跳轉指令在取指和譯碼階段都和前面講的其他指令類似。在執行階段,檢查條件碼和跳轉條件來確定是否要選擇分支,產生出一個一位信號 Cnd。在更新 PC 階段,檢查這個標誌,如果這個標誌位 1,就將 PC 設爲 valC (跳轉目標),如果爲 0,就設爲 valP(下一條指令的地址)。我們的表示法 x? a : b 類似於C語句中的條件表達式——當 x 非零時,它等於 a,當 x 爲零時,等於 b。

下面的例子中,指令位於地址 0x02e,有 9 個字節。第一個字節的值爲 0x73,而剩下的 8 個字節是數字 0x0000000000000040 按字節反過來得到的數,也就是跳轉的目標。各個階段的處理如下:
在這裏插入圖片描述
指令 call 和 ret 指令 pushq 和 popq 類似,除了我們要將 PC 的值入棧和出棧以外。對指令 call ,我們要將 valP,也就是 call 指令後緊跟着的那條指令的地址,壓入棧中。在更新 PC 階段,將 PC 設爲 valC,也就是調用的目的地。對指令 ret,在更新 PC 階段,我們將 valM ,即從棧中取出的值,賦值給 PC。

在這裏插入圖片描述
在這裏插入圖片描述
我們創建了一個統一的框架,能處理所有不同類型的 y86-64 指令。雖然指令的行爲大不相同,但是我們可以將指令的處理組織成 6 個階段。現在我們的任務是創建硬件設計來實現這些階段,並把它們連接起來。

下面這個例子中,指令的地址是 0x041,只有一個字節的編碼,0x90。前面的 call 指令將 %rsp 置爲了 120,並將返回地址 0x040 存放在了內存地址 120 中。各個階段的處理如下:
在這裏插入圖片描述

2. SEQ硬件結構

實現所有 y86-64 所需要的計算可以被組織成 6 個基本階段:取指、譯碼、執行、訪存、寫回 和 更新 PC。下圖給出一個能執行這些計算的硬件結構的抽象表示。程序計數器放在寄存器中,在圖中左下角(PC)。然後,信息沿着線流動(多條線組合在一起就用寬一點的灰線來表示),先向上,在向右。同各個階段相關的硬件單元(hardware units)負責執行這些處理。在右邊,反饋線路向下,包括要寫到寄存器文件的更新值,以及更新的 PC 值。在 SEQ 中,所有硬件單元的處理都在一個時鐘週期內完成。這張圖省略了一些小的組合邏輯塊,還省略了所有用來操作各個硬件單元以及將相應的值路由到這些單元的控制邏輯。
在這裏插入圖片描述
在這裏插入圖片描述
硬件單元與各個處理階段相關聯:
取指:將 PC 寄存器作爲地址,指令內存讀取指令的字節。PC 增加器(PC incrementer)計算 valP,即增加了的 PC。
譯碼:寄存器文件有兩個讀端口 A 和 B,從這兩個端口同時讀寄存器值 valA 和 valB。
執行:執行階段會根據指令的類型,將算術/邏輯單元(ALU)用於不同的目的。對證書操作,它要執行指令所指定的運算。對其他指令,它會作爲一個加法器來計算增加或減少棧指針,或者計算有效地址,或者只是簡單的加 0 ,將一個輸入傳遞到輸出。

條件碼寄存器(CC)有三個條件碼位。ALU 負責計算條件碼的新值。當執行條件傳送指令時,根據條件碼和傳送條件來計算決定是否更新目標寄存器。同樣,當執行一條跳轉指令時,會根據條件碼和跳轉類型來計算分支信號 Cnd。
訪存:在執行訪存操作時,數據內存讀出或寫入一個內存字。指令和數據內存訪問的是相同的內存位置,但是用於不同的目的。
寫回:寄存器文件有兩個寫端口。端口 E 用來寫 ALU 計算出來的值,而端口 M 用來寫從數據內存中讀出的值。
PC 更新:PC 的新值選擇自:valP,下一條指令的地址:valC,調用指令或跳轉指令指定的目標地址:valM,從內存讀取的返回地址。

下面更詳細的給出了實現 SEQ 所需要的硬件:
在這裏插入圖片描述
在這裏插入圖片描述
我們看到一組和前面一樣的硬件單元,但是現在線路更看得清楚了,下面是畫圖慣例
【顏色看不清。。。】:

  • 白色方框表示時鐘寄存器。PC 是 SEQ 中唯一的時鐘寄存器。
  • 淺藍色方框表示硬件。這包括內存、ALU等。在我們所有的處理器實現中,都會使用這一組基本的單元。我們把這些單元當做“黑盒子”,不關心它們的細節設計。
  • 控制邏輯塊用灰色圓角矩形表示。這些塊用來從一組信號源中進行選擇,或者用來計算一些布爾函數。我們會非常詳細地分析這些塊,包括給出 HCL 描述。
  • 寬度爲字長的數據連接用中等粗度的線表示。每條這樣的線實際上都代表一簇 64 根線,並列地連在一起,將一個字從硬件的一個部分傳送到另一部分。
  • 寬度爲字節或更窄的數據連接用細線表示。根據線上要攜帶的值的類型,每條這樣的線實際上都代表一簇 4 或 8 根線。
  • 單個位的連接用虛線來表示。這代表芯片上單元於塊之間傳遞的控制值。

下圖展示出了之前展示的意外,還列出了四個寄存器 ID 信號:srcA、valA 的源;srcB、valB 的源;dstE,寫入 valE 的寄存器;以及 dstM,寫入 valM 的寄存器。
在這裏插入圖片描述
上圖中,右邊兩欄給出的是指令 OPq 和 mrmovq 的計算,來說明要計算的值。要將這些計算映射到硬件上,我們要實現控制邏輯,它能在不同硬件單元之間傳送數據,以及操作這些單元,使得對每個不同的指令執行指定的運算。這就是控制邏輯塊的目標,我們的任務就是依次經過每個階段,創建這些塊的詳細設計。

3.SEQ 的時序

介紹之前的表格時,曾說過要把它們看成是用程序符號寫的,那些賦值是從上到下順序執行的。然而,硬件結構的操作運行根本完全不同,一個時鐘變化會引發一個經過組合邏輯的流,來執行整個指令。接下來看看這些硬件怎麼實現表中列出的這一行爲。

SEQ 的實現包括組合邏輯和兩種存儲設備:時鐘寄存器(PC 和 條件碼寄存器),隨機訪問存儲器(存儲器文件,指令內存 和 數據內存)。組合邏輯不需要任何時序或控制——只要輸入變化了,值就通過邏輯門網絡傳播。正如提到過的那樣,我們也將隨機訪問存儲器看成和組合邏輯一樣的操作,根據地址出入產生輸出字。對於較小的存儲器來說(例如寄存器文件),這是一個合理的假設,而對於較大的電路來說,可以用特殊的時鐘電路來模擬這個效果。由於指令內存只用來讀指令,因此我們可以將這個單元看成是組合邏輯。

現在還剩四個硬件單元需要對它們的時序進行明確的控制——PC、條件碼寄存器、數據內存 和 寄存器文件。這些單元通過一個時鐘信號來控制,它觸發將新值裝載到寄存器以及將值寫到隨機訪問存儲器。每個時鐘週期,PC 都會裝載新的指令地址。只有在執行整數運算指令時,纔會裝載條件碼寄存器。只有在執行 rmmovq 、 pushq 或 call 指令時,纔會寫數據內存。寄存器文件的兩個寫端口允許每個時鐘週期更新兩個程序寄存器,不過我們可以用特殊的寄存器 ID 0xF作爲端口地址,來表明此端口不應該執行寫操作。

要控制處理器中活動的時序,只需要寄存器和內存的時鐘控制。硬件獲得了上面表格中所示的那些賦值順序執行一樣的效果,即使所有的狀態更新實際上同時發生,且只在時鐘上升開始下一個週期時。之所以能保持這樣的等價性,是由於 y86-64 指令集的本質,因爲我們遵循以下原則組織計算:

原則:從不回讀
處理器從來不需要爲了完成一條指令的執行而去讀由該指令更新了的狀態。

這條原則對實現的成功來說至關重要。爲了說明問題,假設我們對 pushq 指令的實現是先將 %rsp 減 8,再將更新後的 %rsp 值作爲寫操作的地址。這種同前面所說的那個原則相違背。爲了執行內存操作,它需要先從寄存器文件中讀更新過的棧指針。然而,我們的實現產生出減後的棧指針值,作爲信號 valE,然後再用這個信號既作爲寄存器寫的數據,也作爲內存寫的地址。因此,在時鐘上升開始下一個週期時,處理器就可以同時執行寄存器寫和內存寫了。

再舉個例子,我們可以看到有些指令(整數運算)會設置條件碼,有些指令(跳轉指令)會讀取條件碼,但沒有指令必須既設置又讀取條件碼。雖然要到時鐘上升開始下一個週期時,纔會設置條件碼,但是在任何指令試圖讀之前,它們都會更新。

以下是彙編代碼,左邊列出的是指令地址。
在這裏插入圖片描述
標號爲 1 ~ 4 的各個圖給出了 4 個狀態單元,還有組合邏輯,以及狀態單元之間的連接。組合邏輯被條件碼寄存器環繞着,因爲有的組合邏輯(例如 ALU)產生輸入到條件碼寄存器,而其他部分(例如分支計算和 PC 選擇器)又將條件碼寄存器作爲輸入。圖中寄存器文件和數據內存有獨立的讀連接和寫連接,因爲讀操作沿着這些單元傳播,就好像它們是組合邏輯,而寫操作是由時鐘控制的。
在這裏插入圖片描述
在這裏插入圖片描述
在這裏插入圖片描述
上圖中的不同顏色的代碼表明電路信號是如何與正在被執行的不同指令相聯繫的。我們假設處理是從設置條件碼開始的,按照 ZF、SF 和 OF 的順序,設爲 100。在時鐘週期 3 開始的時候(點1),狀態單元保持的是第二條 irmovq 指令更新過的狀態,該指令用淺灰色表示。組合邏輯用白色表示,表明它還沒有來得及對變化了的狀態做出反應。時鐘週期開始時,地址 0x014 載入PC中。這樣就會取出和處理 addq 指令。值沿着組合邏輯流動,包括讀隨機訪問存儲器。在這個週期末尾(點2),組合邏輯爲條件碼產生了新的值(000),程序寄存器 %rbx 的更新值,以及 PC 的新值(0x016)。在此時,組合邏輯已經根據 addq 指令被更新了,但是狀態還是保持着第二條 irmovq 指令(淺灰色)設置的值。

當時鐘上升開始週期4時(點3),會更新 PC、寄存器文件和條件碼寄存器,因此我們用藍色來表示,但是組合邏輯還沒有對這些變化做出反應,所以用白色表示。在這個週期內,會取出並執行 je 指令,在圖中用深灰色表示。因爲條件碼 ZF 爲0,所以不會選擇分支。在這個週期末尾(點4),PC 已經產生了新值 0x01f。組合邏輯已經根據 je 指令被更新過了,但是直到下一個週期開始之前,狀態還是保持着 addq 設置的值。

如此例,用時鐘來控制狀態單元的更新,以及值通過組合邏輯來傳播,足夠控制我們 SEQ 實現中每條指令執行的計算了。每次時鐘由低變高時,處理器開始執行一條新指令。

4.SEQ 階段的實現

本節會設計實現 SEQ 所需要的控制邏輯塊的 HCL 描述。

我們使用的常數如下:在這裏插入圖片描述
nop 指令只是簡單的經過各個階段,除了要將 PC 加 1,不進行任何處理。halt 指令使得處理器狀態被設置爲 HLT,導致處理器停止運行。

1.取指階段
如下圖,取指階段包括指令內存硬件單元。以 PC 作爲第一個字節(字級 0)的地址,這個單元一次從內存讀出 10 個字節。第一個字節被解釋成指令字節,(標爲 Split 單元)分成兩個 4 位的數。然後,標號爲 icode 和 ifun 的控制邏輯塊計算指令和功能碼,或者使之等於從內存讀出的值,或者當指令地址不合法時(由信號 imem_error 指明),使這些值對應於 nop 指令。根據 icode 的值,我們可以計算三個一位的信號(用虛線表示):
instr_valid: 這個字節對應於一個合法的指令嗎?這個信號是用來發現不合法的指令。
need_regids:這個指令包括一個寄存器指示符字節嗎
need_valC:這個指令包括一個常數字嗎?
(當指令地址越界時會產生的)信號 instr_valid 和 imem_error 在訪存階段被用來產生狀態碼。
在這裏插入圖片描述

讓我們再來看一個例子,need_regids 的 HCL 描述只是確定了 icode 的值是否爲一條帶有寄存器指示值字節的指令。
在這裏插入圖片描述
在這裏插入圖片描述
在這裏插入圖片描述
如圖所示,從指令內存中讀出的剩下 9 個字節是寄存器指示符字節和常數字的組合編碼。標號爲 Align 的硬件單元會處理這些字節,將它們放入寄存器字段和常數字中。當被計算出的信號 need_regids 爲 1 時,字節 1 被分開裝入寄存器指示符 rA 和 rB 中。否則,這兩個字段會被設爲 0xF(NONE),表明這條指令沒有指明寄存器。回想一下,任何只有一個寄存器操作數的指令,寄存器指示值字節的另一個字段都設爲 0xF。因此,可以將信號 rA 和 rB 看成,要麼放着我們想要訪問的寄存器,要麼表明不需要訪問任何寄存器。這個標號爲 Align 的單元還產生常數字 valC。根據信號 need_regids 的值,要麼根據字節 1 ~ 8 來產生 valC,要麼根據字節 2 ~ 9 來產生。

PC 增加器硬件單元根據當前的 PC 以及兩個信號 need_regids 和 need_valC 的值,產生信號 valP。對於 PC 值p、need_regids值 r 以及 need_valC 值 i,增加器產生值 p + 1 + r + 8i。

2.譯碼和寫回階段
下圖給出了 SEQ 中實現譯碼和寫回階段的邏輯的詳情。把這兩個階段聯繫在一起是因爲它們都要訪問寄存器文件。
在這裏插入圖片描述
寄存器文件有四個端口。它支持同時進行兩個讀(在端口 A 和 B上)和兩個寫(在端口 E 和 M 上)。每個端口都有一個地址連接和一個數據連接,地址連接是一個寄存器 ID,而數據連接是一組 64 根線路,既可以作爲寄存器文件的輸出字(對讀端口來說),也可以作爲它的輸入字(對寫端口來說)。兩個讀端口的地址輸入位 srcA 和 srcB,而兩個寫端口的地址輸入位 dstE 和 dstM。如果某個地址端口上的值爲特殊標識符 0xF,則表明不需要訪問寄存器。

根據指令代碼 icode 以及寄存器指示值 rA 和 rB,可能還會根據執行階段計算出的 Cnd 條件信號,上上圖底部的四個塊產生出四個不同的寄存器文件的寄存器 ID。寄存器 ID srcA 表明應該讀哪個寄存器以產生 valA。srcA 的 HCL 描述如下(回想 RRSP 是 %rsp 的寄存器 ID):
在這裏插入圖片描述
在這裏插入圖片描述
在這裏插入圖片描述
寄存器 ID dstE 表明寫端口 E 的目的寄存器,計算出來的值 valE 將放在那裏。dstE 的 HCL 描述:
在這裏插入圖片描述
我們查看執行階段時,會重新審視這個信號,看看如何實現條件傳送。
在這裏插入圖片描述在這裏插入圖片描述
3.執行階段
執行階段包括算術/邏輯單元(ALU)。這個單元根據 alufun 信號的設置,對輸入 aluA 和 aluB 執行 ADD、SUBTRACT、AND 或 EXCLUSIVE-OR 運算。如下圖,這些數據和控制信號是由三個控制塊產生的。ALU 的輸出就是 valE 信號。
在這裏插入圖片描述
列出操作數 ALUB 在前面,後面是 ALUA,這樣是爲了保證 subq 指令是 valB 減去 valA。可以看到,根據指令的類型, ALUA 的值可以是 valA、valC,或者 -8 或 +8。因此我們可以用下面的方式來表達產生 ALUA 的控制塊的行爲:
在這裏插入圖片描述
在這裏插入圖片描述在這裏插入圖片描述
觀察 ALU 在執行階段的操作,可以看到它通常作爲加法器來使用。不過,對於 OPq 指令,我們希望它使用指令 ifun 字段中編碼的操作。因此,可以將 ALU 控制的 HCL 描述寫成:
在這裏插入圖片描述
執行階段還包括條件碼寄存器。每次運行時,ALU 都會產生三個與條件碼相關的信號——零、符號 和 溢出。不過,我們只希望在執行 OPq 指令時才設置條件碼。因此產生了一個信號 set_cc 來控制是否該更新條件碼寄存器:
在這裏插入圖片描述
標號爲 cond 的硬件單元會根據條件碼和功能碼來確定是否進行條件分支或者條件數據傳送。它產生信號 Cnd,用於設置條件傳送的 dstE,也用在條件分支的下一個 PC 邏輯中。對於其他指令,取決於指令的功能碼和條件碼的設置,Cnd 信號可以被設置爲 1 或者 0。但是控制邏輯會忽略它。我們省略這個單元的詳細設計。
在這裏插入圖片描述
在這裏插入圖片描述
4.訪存階段
訪存階段的任務就是讀或者寫程序數據。如下圖,兩個控制塊產生內存地址和內存輸入數據(爲寫操作)的值。另外兩個塊產生表明應該執行讀操作還是寫操作的控制信號。當執行讀操作時,數據內存產生值 valM。

內存讀寫的地址總是 valE 或 valA。這個塊用 HCL 描述就是:
在這裏插入圖片描述在這裏插入圖片描述
在這裏插入圖片描述
我們希望只爲從內訓讀數據的指令設置控制信號 mem_read ,用 HCL 代碼表示就是:
在這裏插入圖片描述
在這裏插入圖片描述
在這裏插入圖片描述
訪存階段最後的功能是根據取值階段產生的 icode、imem_error、instr_valid 值以及數據內存產生的 dmem_error 信號,從指令執行的結果來計算狀態碼 Stat。
在這裏插入圖片描述
在這裏插入圖片描述
5.更新 PC 階段
SEQ 中最後一個階段會產生 PC 的新值。新的 PC 可能是 valC、valM 或 valP。用 HCL 來描述這個選擇就是:
在這裏插入圖片描述在這裏插入圖片描述
6. SEQ 小結
現在我們已經瀏覽了 y86-64 處理器的一個完整的設計。可以看到,通過將執行每條不同指令所需的步驟組織成一個統一的流程,就可以用很少量的各種硬件單元以及一個時鐘來控制計算的順序,從而實現整個處理器。不過這樣一來,控制邏輯就必須要在這些單元之間路由信號,並根據指令類型和分支條件產生適當的控制信號。

SEQ 唯一的爲題就是它太慢了。時鐘必須非常慢,以使信號能在一個週期內傳播所有的階段。讓我們來看看處理一條 ret 指令的例子。在時鐘週期起始時,從更新過的 PC 開始,要從指令內存中讀出指令,從寄存器文件中讀出棧指針,ALU 將棧指針加 8,爲了得到 PC 的下一個值,還要從內存中讀出返回地址。所以這一切都必須在這個週期結束之前完成。

這種實現方法不能充分利用硬件單元,因爲每個單元只在整個時鐘週期的一部分時間內才被使用。我們會看到引入流水線能獲得更好的性能。

4 流水線的通用原理

在試圖設計一個流水線化的 y86-64 處理器之前,讓我們先來看看流水線化的系統的一些通用屬性和原理。對於曾經在自助餐廳的服務線上工作過或者開車通過自動汽車清洗線的人,都會非常熟悉這種系統。在流水線化的系統中,待執行的任務被劃分成了若干個獨立的階段。在自助餐廳,這些階段包括提供沙拉、主菜、甜點以及飲料。在一個典型的自助餐廳流水線上,顧客按照相同的順序經過各個階段,即使他們並不需要某些菜。在汽車清洗的情況中,當前面一輛汽車從噴水階段進入擦洗階段時,下一輛就可以進入噴水階段了。通常,汽車必須以相同的速度通過這個系統,避免撞車。

流水線化的一個重要特性就是提高了系統的吞吐量(throughput),也就是單位時間內服務的顧客總數,不過它也會輕微地增加延遲(latency),也就是服務一個用戶所需要的時間。例如,自助餐廳裏的一個需要甜點的顧客,能很快通過一個非流水線化的系統,只在甜點階段停留。但是在流水線化的系統中,這個顧客如果試圖直接去甜點階段就有可能招致其他顧客的憤怒了。

1.計算流水線

讓我們把注意力放到計算流水線上來,這裏的“顧客”就是指令,每個階段完成指令執行的一部分。下圖a中的給出了一個很簡單的非流水線化的硬件系統例子。它是由一些執行計算的邏輯以及一個保存計算結果的寄存器組成的。時鐘信號控制在每個特定的時間間隔加載寄存器。CD 播放器中的譯碼器就是這樣的一個系統。輸入信號是從 CD 表面讀出的位,邏輯電路對這些位進行譯碼,產生音頻信號。圖中的計算塊是用組合邏輯來實現的,意味着信號會穿過一系列的邏輯門,在一定時間的延遲之後,輸出就成爲了輸入的某個函數。
在這裏插入圖片描述
在現代邏輯設計中,電路延遲以皮秒(簡稱“ps”),也就是10的-12次方秒爲單位來計算。在這個例子中,我們假設組合邏輯需要 300 ps,而加載寄存器需要 20 ps。上圖還給出了一種時序圖,稱爲流水線圖。在圖中,時間從左向右流動。從上到下寫着一組操作。實心的長方形表示這些指令執行的時間。這個實現中,在開始下一條指令之前必須完成前一個。因此,這些方框在垂直方向上並沒有相互重疊。下面這個公式給出了運行這個系統的最大吞吐量:
在這裏插入圖片描述
我們以每秒千兆條指令(GIPS),也就是每秒十億條指令,爲單位來描述吞吐量。從頭到尾執行一條指令所需要的時間稱爲延遲(latency)。在此係統中,延遲爲 320 ps,也就是吞吐量的倒數。
在這裏插入圖片描述
假設將系統執行的計算分成三個階段(A、B和C),每個階段需要 100 ps,如下圖。然後在各個階段之間放上流水線寄存器(pipeline register),這樣每條指令都會按照三步經過這個系統,從頭到尾需要三個完整的時鐘週期。上圖中的流水線圖所示,只要 I1 從 A 進入到 B,就可以讓 I2 進入階段 A 了,以此類推。在穩定狀態下,三個階段都應該是活動的,每個時鐘週期,一條指令離開系統,一條新的進入。從流水線圖中第三個時鐘週期就能看出這一點,此時, I1 是在階段 C, I2 是在階段 B,而 I3 是在階段 A。在這個系統中,我們將時鐘週期設爲 100 + 20 = 120 ps,得到的吞吐量大約爲 8.33 GIPS。因爲處理一條指令需要 3 個時鐘週期,所以這條流水線的延遲就是 3 * 120 = 360 ps。我們將系統吞吐量提高到原來的 8.33 / 3.12 = 2.67 倍,代價是增加了一些硬件,以及延遲的少量增加。延遲變大是由於增加的流水線寄存器的時間開銷。

2.流水線操作的詳細說明

爲了更好地理解流水線是怎樣工作的,讓我們來詳細看看流水線計算的時序和操作。下圖給出了前面看到過的三階段流水線的流水線圖。就像流水線圖上方指明的那樣,流水線階段之間的指令轉移是由時鐘信號來控制的。每隔 120 ps,信號從 0 上升至 1,開始下一組流水線階段的計算,
在這裏插入圖片描述
下圖跟蹤了時刻 240 ~ 360 之間的電路活動,指令 I1 經過階段 C, I2 經過階段 B,而 I3 經過階段 A。就在時刻 240(點1)時鐘上升之前,階段 A 中計算的指令 I2 的值已經到達第一個流水線寄存器的輸入,但是該寄存器的狀態和輸出還保持爲指令 I1 在階段 A 中計算的值。指令 I1 在階段 B 中計算的值已經到達第二個流水線寄存器的輸入。當時鐘上升時,這些輸入被加載到流水線寄存器中,稱爲寄存器的輸出(點2)。另外,階段 A 的輸入被設置成發起指令 I3 的計算。然後信號傳播通過各個階段的組合邏輯(點3)。就像圖中點 3 處的曲線化的波陣面表明的那樣,信號可能以不同的速率通過各個不同的部分。在時刻 360 之前,結果值到達流水線寄存器的輸入(點4)。當時刻 360 時鐘上升時,各條指令會前進經過一個流水線階段。在這裏插入圖片描述在這裏插入圖片描述在這裏插入圖片描述
從這個對流水線操作詳細的描述中,我們可以看到減緩時鐘不會影響流水線的行爲。信號傳播到流水線寄存器的輸入,但是直到時鐘上升時纔會改變寄存器的狀態。另一方面,如果時鐘運行的太塊,就會有災難性的後果。值可能會來不及通過組合邏輯,因此當時鐘上升時,寄存器的輸入還不是合法的值。

根據對 SEQ 處理器時序的討論,我們看到這種在組合邏輯塊之間採用時鐘寄存器的簡單機制,足夠控制流水線中的指令流。隨着時鐘週而復始地上升和下降,不同的指令就會通過流水線的各個階段,不會相互干擾。

3.流水線的侷限性

上面的例子給出了一個理想的流水線化的系統,在這個系統中,我們可以將計算分成三個相互獨立的階段,每個階段需要的時間是原來邏輯需要時間的三分之一。不幸的是,會出現其他一些因素,降低流水線的效率。

1.不一致的劃分
下圖展示的系統和前面不一樣,我們將計算劃分爲三個階段,但是通過這些階段的延遲從 50 ps 到 150 ps 不等。通過所有階段的延遲和仍然爲 300 ps。不過,運行時中的速率是由最慢的階段的延遲限制的。流水線圖表明,每個時鐘週期,階段 A 都會空閒 100 ps,而階段 C 會空閒 50 ps。只有階段 B 會一直處於活動狀態。我們必須將時鐘週期設爲 150 + 20 = 170 ps,得到吞吐量爲 5.88 GIPS。另外,由於時鐘週期減慢了,延遲也增加到了 510 ps。

對硬件設計這來說,將系統計算設計劃分成一組具有相同延遲的階段時一個嚴峻的挑戰。通常,處理器中的某些硬件單元,如 ALU 和 內存,是不能被劃分成多個延遲較小的單元的。這就使得創建一組平衡的階段非常困難。在設計流水線化的 y86-64 處理器中,我們不會過多關注這一層細節,但是理解時序優化在實際系統設計中的重要性還是非常重要的。
在這裏插入圖片描述在這裏插入圖片描述
2.流水線過深,收益反而下降
下圖說明了流水線技術的另一個侷限性。在這個例子中,我們把計算分成了 6 個階段,每個階段需要 50 ps。在每對階段之間插入流水線寄存器就得到一個六階段流水線,這個系統的最小時鐘週期爲 50 + 20 = 70 ps,吞吐量爲 14.29 GIPS。因此,通過將流水線的階段書加倍,我們將性能提高了 14.29 / 8.33 = 1.71 。雖然我們將每個計算時鐘的時間縮短了兩倍,但是由於通過流水線寄存器的延遲,吞吐量並沒有加倍。這個延遲成了流水線吞吐量的一個制約因素。在我們的新設計中,這個延遲佔到了整個時鐘週期的 28.6 %。
在這裏插入圖片描述
爲了提高時鐘頻率,現代處理器採用了很深的流水線(15或者更多)。處理器架構師將指令的執行劃分成很多非常簡單的步驟,這樣一來每個階段的延遲就很小。電路設計者小心地設計流水線寄存器,使其延遲儘可能的小。芯片設計者也必須小心的設計時鐘傳播網絡,以保證時鐘在整個芯片上同時改變。所有這些都是設計高速微處理器面臨的挑戰。
在這裏插入圖片描述
在這裏插入圖片描述

4.帶反饋的流水線系統

到目前爲止,我們只考慮一種系統,其中穿過流水線的對象,不論是汽車、人活着指令,相互都是完全獨立的。但是,相鄰指令之間很可能是相關的。比如:
在這裏插入圖片描述
在這個包含三條指令的序列中,每對相鄰的指令之間都有數據相關(data dependency),用帶圈的寄存器名字和它們之間的箭頭來表示。irmovq 指令將它的結果存放在 %rax 中,然後 addq 指令要讀這個值;而 addq 指令將它的結果存放在 %rbx 中,mrmovq 指令要讀這個值。

另一種相關是由於指令控制流造成的順序相關。看看下面這個指令序列:
在這裏插入圖片描述
jne 指令產生了一個控制相關(control dependency),因爲條件測試的結果會決定要執行的新指令是 irmovq 指令還是 halt 指令。在我們的 SEQ 設計中,這些相關都是由反饋路徑來解決的,這些反饋將更新了的寄存器值向下傳送到寄存器文件,將新的 PC 值向下傳送到 PC 寄存器。
在這裏插入圖片描述
上圖舉例說明了將流水線引入含有反饋路徑的系統中的危險。在原來的系統中,每條指令的結果都反饋給下一條指令。流水線圖就說明了這個情況, I1 的結果成爲 I2 的輸入,以此類推。如果試圖以最直接的方式將它轉換成一個三階段流水線,我們將改變系統的行爲。如上圖c,I1 的結果成爲 I4 的輸入。爲了通過流水線技術加速系統,我們改變了系統的行爲。

當我們將流水線技術引入處理器時,必須正確處理反饋的影響。很明顯,想上圖中的例子那樣改變系統的行爲是不可接受的。我們必須以某種方式來處理指令間的數據和控制相關,以使得到的行爲於 ISA 定義的模型相符。

5 y86-64 的流水線實現

我們終於準備好開始本章的主要任務——設計一個流水線化的 y86-64 處理器。首先,對順序的 SEQ 處理器做一點小的改動,將 PC 的計算挪到取指階段。然後,在各個階段之間加上流水線寄存器。到這個時候,我們的嘗試還不能正確處理各種數據和控制相關。不過,做一些修改,就能實現我們的目標——一個高效的、流水線化的實現 y86-64 ISA 處理器。

1. SEQ+ : 重新安排計算階段

作爲實現流水線化設計的一個過渡步驟,我們必須稍微調整一下 SEQ 中五個階段的順序,使得更新 PC 階段在一個時鐘週期開始執行,而不是結束時才執行。只需要對整體硬件結構做最小的改動,對於流水線階段中的活動的時序,它能工作的更好。我們稱修改過的設計爲 “ SEQ +”。

我們移動 PC 階段,使得它的邏輯在時鐘週期開始時活動,使它計算當前指令的 PC 值。下圖給出了 SEQ 和 SEQ+ 在 PC 計算上的不同之處。在 SEQ 中,PC 計算髮生在時鐘週期結束的時候,根據當前時鐘週期計算出的信號值來計算 PC 寄存器的新值。在 SEQ+ 中,我們創建狀態寄存器來保存在一條指令執行過程中計算出來的信號。然後,當一個新的時鐘週期開始時,這些信號值通過同樣的邏輯來計算當前指令的 PC。我們將這些寄存器標號爲 pIcode、pCnd 等等,來指明在任意給定的週期,它們保存的是前一個週期中產生的控制信號。
在這裏插入圖片描述

SEQ+ 中的 PC 在哪裏
SEQ + 有一個很奇怪的特色,那就是沒有硬件寄存器來存放 PC。而是根據從前一條指令保存下倆的一些狀態信息動態地計算 PC。這就是一個小小的證明——我們可以以一種與 ISA 隱含着的概念模型不同的方式來實現處理器,只要處理器能夠正確執行任意的機器語言程序。我們不需要將狀態編碼成程序員可見的狀態指定的形式,只要處理器能夠爲任意的程序員可見狀態產生正確的值。在創建流水線化的設計中,我們會更多地使用到這條原則。之後描述的亂序(out-of-order)處理技術,以一種完全不同於機器級程序中出現的順序的次序來執行指令,將這一思想發揮到了極致。

SEQ 到 SEQ+ 中隊狀態單元的改變是一種很通用的改進的例子,這種改進稱爲電路重定時(circuit retiming)。重定時改變了一個系統的狀態表示,但是並不改變它的邏輯行爲。通常用它來平衡一個流水線系統中各個階段之間的延遲。
在這裏插入圖片描述
在這裏插入圖片描述

2.插入流水線寄存器

在創建一個流水線化的 y86-64 處理器的最初嘗試中,我們要在 SEQ+ 的各個階段之間插入流水線寄存器,並對信號重新排列,得到 PIPE-處理器,這裏的“ - ”代表這個處理器和最終的處理器設計相比,性能要差一點。PIPE - 的抽象結構如下圖。流水線寄存器在該圖中用黑色方框表示,每個寄存器包括不同的字段,用白色方框表示。每個流水線寄存器可以存放多個字節和字。同兩個順序處理器的硬件結構中的圓角方框不同,這些白色的方框表示實際的硬件組成。

在這裏插入圖片描述

可以看到,PIPE - 使用了與順序設計 SEQ 幾乎一樣的硬件單元,但是有流水線寄存器分隔開這些階段。兩個系統中信號的不同之處在之後討論。

流水線寄存器按如下方式標號:
F : 保存 PC 的預測值
D : 位於取指和譯碼階段之間。它保存關於最新取出的指令的信息,即將由譯碼階段進行處理
E : 位於譯碼和執行階段之間。它保存關於最新譯碼的指令和寄存器文件讀出的值的信息,即將由執行階段進行處理
M : 位於執行和訪存階段之間。它保存最新執行的指令的結果,即將由訪存階段進行處理。它還保存關於處理條件轉移的分支條件和分支目標的信息
W : 位於訪存階段和反饋路徑之間,反饋路徑將計算出來的值提供給寄存器文件寫,而當完成 ret 指令時,它還要向 PC 選擇邏輯提供返回地址

在這裏插入圖片描述
圖中右邊給出了這個指令序列的流水線圖。這個圖描述了每條指令通過流水線各個階段的行進過程,時間從左往右增大。上面一條數字表明各個階段發生的時鐘週期。例如,在週期 1 取出指令 I1,然後它開始通過流水線各個階段,到週期 5 結束後,其結果寫入寄存器文件。在週期 2 取出指令 I2,到週期 6 結束後,其結果寫回,以此類推。在最下面,我們給出了當週期爲 5 時的流水線的擴展圖。此時,每個流水線階段中各有一條指令。

從上圖還可以判斷我們畫處理器的習慣是合理的,這樣,指令是自底向上的流動的。週期 5 時的擴展圖表明的流水線階段,取指階段在底部,寫回階段在最上面。如果看看流水線各個階段中指令的順序,會發現它們出現的順序與在程序中列出的順序一樣。因爲正常的程序是從上到下列出的,我們保留這種順序,讓流水線從下到上進行。

3.對信號進行重新排列和標號

順序實現 SEQ 和 SEQ+ 在一個時刻只處理一條指令,因此諸如 valC、srcA 和 valE 這樣的信號值有唯一的值。在流水線化的設計中,與各個指令相關聯的這些值由多個版本,會隨着指令一起流過系統。例如,在 PIPE - 的詳細結構中,由 4 個標號爲 Stat 的白色方框,保存着 4 條不同指令的狀態碼。我們需要很小心以確保使用的是正確版本的信號,否則會有很嚴重的錯誤,例如,將一條指令計算出的結果存放到了另一條指令指定的目的寄存器。我們採用的命名機制,通過在信號名前面加上大寫的流水線寄存器名字作爲前綴,存儲在流水線寄存器中的信號可以唯一地被標誌。例如,4 個狀態碼可以被命名爲 D_stat、E_stat、M_stat 和 W_stat。我們還需要引用某些在一個階段內剛剛計算出來的信號。它們的命名是在信號名前面加上小寫的階段名的第一個字母作爲前綴。以狀態碼爲例,可以看到在取指和訪存階段中標號爲 Stat 的控制邏輯塊,因而,這些塊的輸出被命名爲 f_stat 和 m_stat 。我們還可以看到整個處理器的實際狀態 Stat 是根據流水線寄存器 W 中的狀態值,由寫回階段中的塊計算出來的。

  • 信號 M_stat 和 m_stat 的差別
    在命名系統中,大寫的前綴 D、E、M 和 W 指的是流水線寄存器,所以 M_stat 指的是流水線寄存器 M 的狀態碼字段。小寫的前綴 f、d、e、m 和 w 指的是流水線階段,所以 m_stat 指的是在訪存階段中由控制邏輯塊產生出的狀態信號。

SEQ+ 和 PIPE - 的譯碼階段都產生信號 dstE 和 dstM,它們指明值 valE 和 valM 的目的寄存器。在 SEQ+ 中,我們可以將這些信號直接連到寄存器文件寫端口的地址輸入。在 PIPE - 中,會在流水線中一直攜帶這些信號穿過執行和訪存階段,直到寫回階段才送到寄存器文件。我們這樣做是爲了確保寫端口的地址和數據輸入是來自同一條指令。否則,會將處於寫回階段的指令的值寫入,而寄存器 ID 卻來自處於譯碼階段的指令。作爲一條通用原則,我們要保存處於一個流水線階段中的指令的所有信息。

PIPE - 中有一個塊在相同表示形式的 SEQ+ 中時沒用的,那就是譯碼階段中標號爲 Select A 的塊。我們可以看出,這個塊會從來自流水線寄存器 D 的 valP 或從寄存器文件 A 端口中讀出的值中選擇一個,作爲流水線寄存器 E 的值 valA。包括這個塊是爲了減少要攜帶給流水線寄存器 E 和 M 的狀態數量。在所有的指令中,只有 call 在訪存階段需要 valP 的值。只有跳轉指令在執行階段(當不需要進行跳轉)需要 valP的值。而這些指令又都不需要從寄存器文件中讀出的值。因此我們合併這兩個信號,將它們作爲信號 valA 攜帶穿過流水線,從而可以減少流水線寄存器的狀態數量。這樣做就消除了 SEQ 和 SEQ+ 中標號爲 Data 的塊,這個塊完成的是類似的功能。在硬件設計上,像這樣仔細確認信號是如何使用的,然後通過合併信號來減少寄存器狀態和線路的數量,是很常見的。

我們的流水線寄存器包括一個狀態碼 stat 字段,開始時是在取指階段計算出來的,在訪存階段有可能會被修改。在講完正常指令執行的實現之後,我們會在之後討論如何實現異常事件的處理。到目前爲止我們可以說,最系統的方法就是讓每條指令關聯的狀態碼與指令一起通過流水線。

4.預測下一個 PC

在 PIPE - 設計中,我們採取了一些措施來正確處理控制相關。流水線化設計的目的就是每個時鐘週期都發射一條新指令,也就是說每個時鐘週期都有一條新指令進入執行階段並最終完成。要是達到這個目的就意味着吞吐量是每個時鐘週期一條指令。要做到這一點,我們必須在取出當前指令之後,馬上確定下一條指令的位置。不幸的是,如果取出的指令是條件分支指令,要到幾個週期後,也就是指令通過執行階段之後,我們才能知道是否要選擇分支。類似地,如果取出的指令是 ret,要到指令通過訪存階段,才能確定返回地址。

除了條件轉移指令 和 ret 以外,根據取指階段中計算出的信息,我們能夠確定下一條指令的地址。對於 call 和 jmp 來說,下一條指令的地址是指令中常數字 valC,而對於其他指令來說就是 valP。因此,通過預測 PC 的下一個值,在大多數情況下,我們能達到每個時鐘週期發射一條新指令的目的。對於大多數指令類型來說,我們的預測是完全可靠的。對條件轉移來說,我們既可以預測選擇了分支,那麼新 PC 值應爲 valC ,也可以預測沒有選擇分支,那麼新 PC 值應爲 valP。無論哪種情況,我們都必須以某種方式來處理預測錯誤的情況,因爲此時已經取出並部分執行了錯誤的指令。我們在之後來討論這個問題。

猜測分支方向並根據猜測開始取指的技術成爲分支預測。實際上所有的處理器都採用了某種形式的此類技術。對於預測是否選擇分支的有效策略已經進行了廣泛的研究。有的系統花費了大量硬件來解決這個任務。我們的設計只使用了簡單的策略,即總是預測選擇了條件分支,因而預測 PC 的新值爲 valC。

我們還沒有討論預測 ret 指令的新 PC 值。同條件轉移不同,此時可能的返回值幾乎是無限的,因爲返回地址是位於棧頂的字,其內容可以是任意的。在設計中,我們不會試圖對返回地址做任何預測。只是簡單地暫時處理新指令,直到 ret 指令通過寫回階段。

使用棧的返回地址預測
對於大多數程序來說,預測返回值很容易,因爲過程調用和返回時成對出現的。大多數函數調用,會返回到調用後的那條指令。高性能處理器中運用了這個屬性,在取指單元中放入一個硬件棧,保存過程調用指令產生的返回地址。每次執行過程調用指令時,都將其返回地址壓入棧中。當取出一個返回指令時,就從這個棧中彈出頂部的值,作爲預測的返回值。同分支預測一樣,在預測錯誤時必須提供一個恢復機制,因爲還是有調用和返回不匹配的時候。通常,這種預測很可靠。這個硬件棧對程序員來說是不可見的。

PIPE - 的取指階段,負責預測 PC 的下一個值,以及爲取指選擇實際的 PC。我們可以看到,標號爲 Predict PC 的塊會從 PC 增加器計算出的 valP 和取出的指令中得到的 valC 中進行選擇。這個值存放在流水線寄存器 F 中,作爲 PC 的預測值。標號爲 Select PC 的塊類似於 SEQ+ 的PC 選擇階段中標號爲 PC 的塊。它從三個值中選擇一個作爲指令內存的地址:預測的 PC,對於到達流水線寄存器 M 的不選擇分支的指令來說是 valP 的值(存儲在寄存器 M_valA 中),或是當 ret 指令到達流水線寄存器 W(存儲在 W_valM)時的返回地址的值。

5.流水線冒險

PIPE - 結構是創建一個流水線化的 y86-64 處理器的好開端。不過,將流水線基礎引入一個帶反饋的系統,當相鄰指令間存在相關時會導致出現問題。在完成設計之前必須解決這個問題。有些相關有兩種形式:1.數據相關,下一條指令會用到這一條指令計算出的結果 2.控制相關,一條指令要確定下一條指令的位置,例如在執行跳轉、調用或返回指令時。這些相關可能會導致流水線產生計算錯誤,稱爲冒險(hazard)。同相關一樣,冒險也可以分爲兩類:數據冒險(data hazard)he 控制冒險(control hazard)。我們首先關心的是數據冒險,然後再考慮控制冒險。

在這裏插入圖片描述
上圖描述的是 PIPE- 處理器處理 prog1 指令序列的情況。假設在這個例子以及後面的例子中,程序寄存器初始值都爲 0。這段代碼將值 10 和 3 放入程序寄存器 %rdx 和 %rax,執行三條 nop 指令,然後將寄存器 %rdx 加到 %rax 。我們重點關注兩條 irmovq 指令和 addq 指令之間的數據相關造成的可能的數據冒險。圖的右邊是這個指令序列的流水線圖。圖中顯示了週期 6 和 7 的流水線階段。流水線圖的下面是週期 6 中寫回活動和週期 7 中譯碼活動的擴展說明。在週期 7 開始以後,兩條 irmovq 都已經通過寫回階段,所以寄存器文件保存着更新過的 %rdx 和 %rax 的值。因此,當 addq 指令在週期 7 經過譯碼階段時,它可以讀到源操作數的正確值。在此示例中,兩條 irmovq 指令 和 addq 指令之間的數據相關沒有造成數據冒險。

我們看到prog1 通過流水線得到了正確的結果,因爲 3 條 nop 指令在有數據相關的指令之間創造了一些延遲。讓我們來看看如果去掉這些 nop 指令會發生些什麼。下圖描述的是 prog2 程序的流水線程序,在兩條產生寄存器 %rdx 和 %rax 值的 irmovq 指令和以這兩個寄存器作爲操作數的 addq 指令之間有兩條 nop 指令。在這種情況下,關鍵步驟發生在週期 6,此時 addq 指令從寄存器文件中讀取它的操作數。該圖底部是這個週期內流水線活動的擴展描述。第一個 irmovq 指令已經通過了寫回階段,因此程序寄存器 %rdx 已經在寄存器文件中更新過了。在該週期內,第二個 irmovq 指令處於寫回階段,因此對程序寄存器 %rax 的寫要到週期 7 開始,時鐘上升時,纔會發生。結果,會讀出 %rax 的錯誤值(回想一下,我們假設所有的寄存器的初始值爲 0),因爲對寄存器的寫還未發生。很明顯,我們必須改進流水線讓它能正確處理這樣的冒險。
在這裏插入圖片描述
下圖是當 irmovq 指令和 addq 指令之間只有一條 nop 指令,即爲程序 prog3 時,發生的情況。現在我們檢查週期 5 內流水線的行爲,此時 addq 指令通過譯碼階段。不幸的是,對寄存器 %rdx 的寫仍處在寫回階段,而對寄存器 %rax 的寫還處在訪存階段,因此,addq 指令會得到兩個錯誤的操作數。
在這裏插入圖片描述
下圖去掉了所有的 nop 指令。現在檢查週期 4 內流水線的行爲,此時 addq 指令通過譯碼階段,不幸的是,對寄存器 %rdx 的寫仍處在訪存階段,而執行階段正在計算寄存器 %rax 的新值。因此,addq 指令的兩個操作數都是不正確的。
在這裏插入圖片描述
這些例子說明,如果一條指令的操作數被它前面三條指令中的任意一條改變的話,都會出現數據冒險。之所以會出現這種冒險,是因爲我們的流水線化的處理器是在譯碼階段從寄存器文件中讀取指令的操作數,而要到三個週期以後,指令經過寫回階段時,纔會將指令的結果寫到寄存器文件中。

數據冒險的類型
當一條指令更新後面指令會讀到的那些程序狀態時,就有可能出現冒險。對於 y86-64 來說,程序狀態包括程序寄存器、PC、內存、條件碼寄存器和狀態寄存器。

  • 程序寄存器:剛剛的例子已經認識這種冒險了。出現這種冒險是因爲寄存器文件的讀寫實在不同的階段進行的,導致不同指令之間可能出現不希望的相互作用
  • PC:更新和讀取 PC 之間的衝突導致了控制冒險。當我們取指階段邏輯在取下一條指令之前,正確預測了 PC 的新值時,就不會產生冒險。預測錯誤的分支和 ret 指令需要特殊的處理
  • 內存:對數據內存的讀寫都發生在訪存階段。在一條讀內存的指令到達這個階段之前,前面所有要寫內存的指令都已經完成這個階段了。另外,在訪存階段中寫數據的指令和取指階段中讀指令之間也有衝突,因爲指令和數據內存訪問的是同一個地址空間。只有包含自我修改代碼的程序纔會發生這種情況,在這樣的程序中,指令寫內存的一部分,過後會從中取出指令。有些系統有複雜的機制來檢測和避免這種冒險,而有些系統知識簡單地強制要求程序不應該使用自我修改代碼。
  • 條件碼寄存器:在執行階段中,整數操作會寫這些寄存器。條件傳送指令會在執行階段以及條件轉移會在訪存階段讀這些寄存器。在條件傳送或轉移到達執行階段之前,前面所有的整數操作都已經完成這個階段了。所以不會發生冒險。
  • 狀態寄存器:指令流經流水線的時候,會影響程序狀態。我們採用流水線中的每條指令都與一個狀態碼相關聯的機制,使得當異常發生時,處理器能夠有條理地停止。

這些分析表明我們只需要處理寄存器數據冒險、控制冒險,以及確保能夠正確處理異常。當設計一個複雜系統時,這樣的分類分析是很重要的。這樣做可以確認出系統實現中可能的困難,還可以指導生成用於檢查系統正確性的測試程序。

1.用暫停來避免數據冒險
暫停(stalling)是避免冒險的一種常用技術,暫停時,處理器會停止流水線中一條或多條指令,直到冒險條件不再滿足。讓一條指令停頓在譯碼階段,直到產生它的源操作數的指令通過了寫回階段,這樣我們的處理器就能避免數據冒險。這種機制的細節之後討論。
在這裏插入圖片描述
在這裏插入圖片描述
上圖中,當指令 addq 處於譯碼階段時,流水線控制邏輯發現執行、訪存或寫回階段中至少有一條指令會更新寄存器 %rdx 或 %rax。處理器不會讓 addq 指令帶着不正確的結果通過這個階段,而是會暫停指令,將它阻塞在譯碼階段,時間爲一個週期或者三個週期。這樣,addq 指令最終都會在週期 7 中得到兩個源操作數的正確值,然後繼續沿着流水線進行下去。

將 addq 指令阻塞在譯碼階段時,我們還必須將緊跟其後的 halt 指令阻塞在取指階段。通過將 PC 保持不變就能做到這一點,這樣一來,會不斷地對 halt 指令進行取指,直到暫停結束。

暫停技術就是讓一組指令阻塞在它們所處的階段,而允許其他指令繼續通過流水線。那麼在本該正常處理 addq 指令的階段中,我們該做些什麼呢?我們使用的處理方法是:每次要把一條指令阻塞在譯碼階段,就在執行階段插入一個氣泡。氣泡就像一個自動產生的 nop 指令——它不會改變寄存器、內存、條件碼或程序狀態。我們用一個 addq 指令的標號爲 D 的方框到標號爲 E的方框之間的箭頭來表示一個流水線氣泡,這些箭頭表明,在執行階段中插入氣泡是爲了代替 addq 指令,它本來應該經過譯碼階段進入執行階段。

雖然實現這一機制相當容易,但是得到的性能並不好。一條指令更新一個寄存器,緊跟其後的指令就使用被更新的寄存器,像這樣的情況不勝枚舉。這會導致流水線暫停長達三個週期,嚴重降低了整體的吞吐量。

用轉發來避免數據冒險
PIPE- 的設計是在譯碼階段從寄存器文件中讀入源操作數,但是對這些源寄存器的寫有可能要在寫回階段才能進行。與其暫停知道寫完成,哺乳簡單地將要寫的值傳到流水線寄存器 E 作爲源操作數。下圖用 prog2 週期 6 流水線圖的擴展描述來說明這一策略。譯碼階段邏輯發現,寄存器 %rax 是操作數 valB 的源寄存器,而在寫端口 E 上還有一個對 %rax 的未進行的寫。它只要簡單地將提供到端口 E 的數據字作爲操作數 valB 的值,就能避免暫停。這種將結果值直接從一個流水現階段傳到較早階段的技術成爲數據轉發(data forwarding,簡稱轉發,或旁路(bypassing))。它使得 prog2 的指令能通過流水線而不需要任何暫停。數據轉發需要在基本的硬件結構中增加一些額外的數據連接和控制邏輯。
在這裏插入圖片描述
下圖中,當訪存階段中有對寄存器未進行的寫時,也可以使用數據轉發,以避免程序 prog3 中的暫停。在週期 5 中,譯碼階段邏輯發現,在寫回階段中端口 E 上有對寄存器 %rdx 未進行的寫,以及在訪存階段中有會在端口 E 上對寄存器 %rax 未進行的寫。它不會暫停知道這些寫真正發生,而是用寫回階段彙總的值(W_valE)作爲操作數 valA,用訪存階段中的值(M_valE)作爲操作數 valB。
在這裏插入圖片描述

爲了充分利用數據轉發技術,我們還可以將新計算出來的值從執行階段傳到譯碼階段,以避免程序 prog4 所需要的暫停,如下圖。在週期 4 中,譯碼階段邏輯發現在訪存階段中有對寄存器 %rdx 未進行的寫,而且執行階段中 ALU 正在計算的值稍後也會寫入寄存器 %rax。它可以將訪存階段中的值(M_valE)作爲操作數 valA,也可以將 ALU 的輸出(e_valE)作爲操作數 valB。注意,使用 ALU 的輸出不會造成任何時序問題。譯碼階段只要在時鐘週期結束之前產生信號 valA 和 valB,這樣在時鐘上升開始下一個週期時,流水線寄存器 E 就能裝載來自譯碼階段的值了。而在此之前 ALU 的輸出已經是合法的了。
在這裏插入圖片描述
程序 prog2 ~ prog4 中描述的轉發技術的使用都是將 ALU 產生的以及其目標爲寫端口 E 的值進行轉發,其實也可以轉發從內存中讀出的以及其目標爲寫端口 M 的值。從訪存階段,我們可以轉發剛剛從數據內存中讀出的值(m_valM)。從寫回階段,我們可以轉發對端口 M 未進行的寫(w_valM)。這樣一共就有五個不同的轉發源(e_valE、m_valM、M_valE、W_valM 和 W_valE),以及兩個不同的轉發目的(valA 和 valB)。

譯碼階段邏輯能夠確定是使用來自寄存器文件的值,還是要轉發過來的值。與每個要寫回寄存器文件的值相關的是目的寄存器 ID。邏輯會將這些 ID 與源寄存器 ID srcA 和 srcB 相比較,以此來檢測是否需要轉發。可能有多個目的寄存器 ID 與 一個源 ID 相等。要解決這樣的情況,我們必須在各個轉發源中建立起優先級關係。

3.加載/使用數據冒險
有一類數據冒險不能單純用轉發來解決,因爲內存讀在流水線發生的比較晚。下圖說明了加載/使用冒險(load/use hazard),其中一條指令(mrmovq)從內存中讀出寄存器 %rax 的值,而下一條指令(addq)需要該值作爲源操作數。圖的下部是週期 7 和 8 的擴展說明,在此假設所有的程序寄存器都初始化爲 0。addq 指令在週期 7 中需要該寄存器的值,但是 mrmovq 指令直到週期 8 才產生出這個值。爲了從 mrmovq “轉發到” addq,轉發邏輯不得不將值送回到過去的時間!這顯然不可能,我們必須找其他機制來解決這種形式的數據冒險。
在這裏插入圖片描述

如下圖,我們可以將暫停和轉發結合起來,避免加載/使用數據冒險。這個需要修改控制邏輯,但是可以使用現有的旁路路徑。當 mrmovq 指令通過執行階段時,流水線控制邏輯發現譯碼階段中的指令(addq)需要從內存中讀出的結果。它會將譯碼階段中的指令暫停一個週期,導致執行階段中插入一個氣泡。如週期 8 的擴展說明所示,從內存中讀出的值可以從訪存階段轉發到譯碼階段中的 addq 指令。寄存器 %rbx 的值也可以從訪存階段轉發到譯碼階段。就像流水線圖,從週期 7 中標號爲 D 的方框到週期 8 中標號爲 E 的方框的箭頭表明的那樣,插入的氣泡代替了正常情況下本來應該繼續通過流水線的 addq 指令。
在這裏插入圖片描述
這種用暫停來處理加載/使用冒險的方法稱爲加載互鎖(load interlock)。加載互鎖和轉發技術結合起來足以處理所有可能類型的數據冒險。因爲只有加載互鎖會降低流水線的吞吐量,我們幾乎可以實現每個時鐘週期發射一條新指令的吞吐目標。

4.避免控制冒險
當處理器無法根據處於取指階段的當前指令來確定下一條指令的地址時,就會出現控制冒險。控制冒險只會發生在 ret 指令和跳轉指令。而且,後一種情況只有在條件跳轉方向預測錯誤時纔會造成麻煩。

對於 ret 指令,考慮下面的示例程序。這個程序時用匯編代碼表示的,左邊是各個指令的地址
在這裏插入圖片描述
下圖給出了我們希望流水線如何來處理 ret 指令。同前面的流水線圖一樣,這幅圖展示了流水線的活動,時間從左向右增加。與前面不同的是,指令列出的順序與它們在程序中出現的順序並不相同,這是因爲這個程序的控制流中指令並不是按線性順序執行的。看看指令的地址就能看出它們在程序中的位置。
在這裏插入圖片描述
在週期 3 中取出 ret 指令,並沿着流水線前進,在週期 7 進入寫回階段。在它經過譯碼、執行和訪存階段時,流水線不能做任何有用的活動。我們只能在流水線中插入三個氣泡。一旦 ret 指令到達寫回階段, PC 選擇邏輯就會將程序計數器設爲返回地址,然後取指階段就會取出位於返回點(0x013)處的 irmovq 指令。

要處理預測錯誤的分支,考慮下面這個用匯編代碼表示的程序,左邊是各個指令的地址
在這裏插入圖片描述
下圖表明是如何處理這些指令的。同前面一樣,指令按照它們進入流水線的順序列出的,而不是按照它們出現在程序中的順序。因爲預測跳轉指令會選擇分支,所以週期 3 中會取出位於跳轉目標處的指令,而週期 4 中會取出該指令後的那條指令。在週期 4,分支邏輯發現不應該選擇分支之前,已經取出了兩條指令,它們不應該繼續執行下去了。幸運的是,這兩天指令都沒有導致程序員可見的狀態發生改變。只有到指令到達執行階段時纔會發生那種情況,在執行階段中,指令會改變條件碼。我們只要在下一個週期往譯碼和執行階段中插入氣泡,並同時去除跳轉指令後面的指令,這樣就能取消那兩條預測錯誤的指令。這樣一來,兩條預測錯誤的指令就會簡單地從流水線中消失,因此不會對程序員可見的狀態產生影響。唯一的缺點就是兩個時鐘週期的指令處理能力被浪費了。

對於控制冒險的討論表明,通過慎重考慮流水線的控制邏輯,控制冒險是可以被處理的。在出現特殊情況的時候,暫停和往流水線中插入氣泡的技術可以動態調整流水線的流程。對基本時鐘寄存器設計的簡單擴展就可以讓我們暫停流水段,並向作爲流水線控制邏輯部分的流水線寄存器中插入氣泡。

6.異常處理

處理器很多事情都會導致異常控制流,此時,程序執行的正常流程被破壞掉。異常可以由程序執行從內部產生,也可以由某個外部信號從外部產生。我們的指令體系結構包括三種不同的內部產生的異常:1. halt 指令,2.有非法指令和功能碼組合的指令 3.取指或數據讀寫試圖訪問一個非法地址。一個更完整的處理器設計應該也能處理外部異常,例如當處理器的一個網絡接口收到新包的信號,或是一個用戶點擊鼠標按鈕的信號。正確處理異常時任何微處理器設計中很有挑戰性的一方面。異常可能出現在不可預測的時間裏,需要明確地中斷通過處理器流水線的指令流。我們對這三種內部異常的處理只是讓你對正確發現和處理異常的真實複雜性略有了解。

我們把導致異常的指令稱爲異常指令(excepting instruction)。在使用非法指令地址的情況中,沒有實際的異常指令,但是想象在非法地址處有一種“虛擬指令”會有所幫助。在簡化的 ISA 模型中,我們希望當處理器遇到異常時,會停止,設置適當的狀態碼,應該是到異常指令之前的所有指令都已經完成,而其後的指令都不應該對程序員可見的狀態產生任何影響。在一個更完整的設計中,處理器會繼續調用異常處理程序(exception handler),這是操作系統的一部分,但是實現異常處理的這部分超出了本書講述的範圍。

在一個流水線化的系統中,異常處理包括一些細節問題。首先,可能同時有多條指令會引起異常。例如,在一個流水線操作的週期內,取指階段中有 halt 指令,而數據內存會報告訪存階段中的指令數據地址越界。我們必須確定處理器應該向操作系統報告哪個異常,基本原則是:由流水線中最深的指令引起的異常,優先級最高。在上面那個例子中,應該報告訪存階段中指令的地址越界。就機器語言程序來說,訪存階段中的指令本來應該在取指階段中的指令開始之前就結束的,所以,只應該向操作系統報告這個異常。

第二個細節問題是,當首先取出一條指令,開始執行時,導致了一個異常,而後來由於分支預測錯誤,取消了該指令。下面就是一個程序示例的目標代碼:
在這裏插入圖片描述
在這個程序中,流水線會預測選擇分支,因此它會取出並以一個值爲 0xFF 的字節作爲指令(由彙編代碼中.byte僞指令產生的)。譯碼階段會因此發現一個非法指令異常。稍後,流水線會發現不應該選擇分支,因此根本呢就不應該取出位於地址 0x016 的指令。流水線控制邏輯會取消該指令,但是我們想要避免出現異常。

第三個細節問題的產生是因爲流水線化的處理器會在不同的階段更新系統狀態的不同部分。有可能出現這樣的情況,一條指令導致了一個異常,它後面的指令在異常指令完成之前改變了部分狀態。比如說,下面的代碼序列,其中假設不允許用戶程序訪問 64 位範圍的高端地址:
在這裏插入圖片描述
pushq 指令導致一個地址異常,因爲減小棧指針會導致它繞回到 0xfffffffffffffff8。訪存階段中會發現這個異常。在同一週期中,addq 指令處於執行階段,而它會將條件碼設置成新的值。這就會違反異常指令之後的所有指令都不能影響系統狀態的要求。

一般地,通過在流水線結構中加入異常處理邏輯,我們既能夠從各個異常中做出正確的選擇,也能夠避免出現由於分支預測錯誤取出的指令造成的異常。這就是爲什麼我們會在每個流水線寄存器中包括一個狀態碼 stat。如果一條指令在其處理中於某個階段產生了一個異常,這個狀態字段就被設置成指示異常的種類。異常狀態和該指令的其他信息一起沿着流水線傳播,直到它到達寫回階段。在此,流水線控制邏輯發現出現了異常,並停止執行。

爲了避免異常指令之後的指令更新任何程序員可見的狀態,當處於訪存或寫回階段中的指令導致異常時,流水線控制邏輯必須禁止更新條件碼寄存器或是數據內存。在上面的示例程序中,控制邏輯會發現訪存階段中的 pushq 導致了異常,因此應該禁止 addq 指令更新條件碼寄存器。

讓我們來看看這種處理異常的方法是怎樣解決剛纔提到的那些細節問題的。當流水線中有一個或多個階段出現異常時,信息只是簡單地存放在流水線寄存器的狀態字段中。異常事件不會對流水線中的指令流有任何影響,除了會禁止流水線中後面的指令更新程序員可見的狀態(條件碼寄存器和內存),直到異常指令到達最後的流水線階段。因爲指令到達寫回階段的順序與它們在非流水線化的處理器中執行的順序相同,所以我們可以保證第一條遇到異常的指令會第一個到達寫回階段,此時程序執行會停止,流水線寄存器 W 中的狀態碼會被記錄爲程序狀態。如果取出了某條指令,過後又取消了,那麼所有關於這條指令的異常狀態信息也都會被取消。所有導致異常的指令後面的指令都不能改變程序員可見的狀態。攜帶指令的異常狀態以及所有其他信息通過流水線的簡單原則是處理異常的簡單可靠的機制。

7.PIPE 各階段的實現
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章