本文,我們要做一件大膽的事情,從零開始實作一個全新的指令集架構,以此深入了解處理器的工作原理。
指令集發展曆史概況
開始我們的創造之旅前,先了解一下曆史上的指令集架構都有哪些。
一個處理器支援的指令和指令的位元組級編碼稱為它的指令集架構(Instruction Set Architecture, ISA)。
最為我們熟知的就是x86架構,因為我們日常所用的個人電腦就采用了x86架構的處理器。目前世界上最大的兩個處理器制造商Intel和AMD都有基于x86架構的一系列産品。從Intel i386處理器開始,x86架構進入32位時代,稱為IA32架構(Intel Architecture 32bit)。後來,32位也不能滿足我們的需求了,Intel開始進軍64位處理器領域,提出IA64架構。但是,這個架構并不是我們現在在用的64位處理器,而是一個與x86完全無關的新的處理器架構,不保持向後相容。雖然可以實作很高的性能,但是由于相容性不好,市場反應冷淡。于此同時,AMD公司抓住機會,率先提出了x86-64處理器架構,支援64位的同時保持向後相容,一舉在與Intel的市場競争中占據了主動權。當然,Intel也不會執迷不悟,他們果斷放棄了IA64,開始轉向x86-64架構,并逐漸收回喪失的市場佔有率。後來,雖然AMD将自己的架構命名為AMD64,Intel将自己的架構命名為Intel64,但人們仍然習慣性地将它們統稱為x86-64。
Y86指令集
為了緻敬偉大的x86指令集架構,我們将自己的指令集架構命名為Y86。其實呢,Y86的設計理念完全借鑒x86,相當于一個簡化的x86架構。
要想從頭設計一個指令集架構,需要先規定指令集和指令集編碼,然後将每個指令劃分為幾個階段分步執行,每個階段隻需要做簡單的一兩項工作,之後,将硬體裝置結合适當的邏輯電路實作指令每個階段的工作。下面我們詳細講解具體的實作過程。
指令集及其編碼
對于一個簡易的指令集來說,不需要太多的指令,能實作基本的資料轉移和流程控制就夠了。下圖列出了Y86指令集中包含的所有指令,以及每個指令的編碼。

Y86指令集
這些都是非常基本的指令,不過看起來有些奇怪,這是因為我們把x86中的
movl
指令替換成了四個獨立的指令
rrmovl
、
irmovl
、
rmmovl
和
mrmovl
,每個指令指明了操作數的來源,這樣就避免了各種尋址方式的麻煩。
可以看到,各個指令的長度從1位元組到6位元組不等,這樣編碼可以減少程式代碼占用的空間。第1個位元組的高4位作為指令編碼,用來區分不同的指令,低4位要麼是0,要麼是
fn。
fn稱為功能代碼,用來區分不同的操作。如下圖所示,不同的功能碼在不同的指令中有不同的含義。在運算指令中,分别代表加、減、與和異或;在分支跳轉指令中,分别代表不同的跳轉條件;在條件轉移指令中,分别代表不同的轉移條件。
Y86指令集的功能碼
第2個位元組,對于大部分指令來說存放的是寄存器辨別符,請看下圖:
Y86程式寄存器辨別符
每個寄存器與一個數字一一對應,F代表無寄存器操作數。
最後,有些指令還包含四個位元組的立即數。
舉一個例子來幫助我們更好地了解指令編碼。例如對于如下指令
rmmovl %esp, 0x12345(%edx)
對應的編碼為
40 42 45 23 01 00
其中,從左到右,40是指令編碼,42分别是寄存器
%esp
對應的4和寄存器
%edx
對應的2,45230100是偏移量
0x12345
在小端機器上的表示。
硬體控制語言HCL
處理器的各個硬體裝置(比如ALU、程式計數器)之間通常需要特定功能的邏輯電路來連接配接,在設計階段,我們使用一種結構化的語言來描述這些邏輯關系。
HCL(Hardware Control Language)是一種類似C的硬體控制語言,用于描述處理器的控制邏輯。
舉一個簡單的例子,對于如下所示的組合邏輯電路:
可以用HCL語言表示為
e = (a && !(b||c)) || (!d && !(b||c))
這句話描述了輸出和輸入的邏輯關系,無論多麼複雜的組合電路,都可以用最基本的與或非門來實作。HCL在後面将會有大量的應用。
存儲器和時鐘
細心的讀者可能會注意到,上一段話講到“無論多麼複雜的組合電路”。為什麼特别強調組合電路呢,因為還有另一種電路——時序電路。
大家應該都有基本的電路知識,組合電路隻是完成了一個函數的功能,不同的輸入導緻不同的輸出,電路本身并不存儲任何資訊。而時序電路就不一樣了,它可以存儲資訊,而且在時鐘信号的控制下對輸入做出反應。
接下來,重點來了。在處理器中有兩種儲存設備:
- 時鐘寄存器 (簡稱寄存器) 存儲單個位或字。時鐘信号控制寄存器加載輸入值。
- 随機通路存儲器 (簡稱存儲器) 存儲多個字,由位址選擇讀寫哪個字。這裡所說的存儲器可以分為兩種:處理器的虛拟存儲器系統和寄存器檔案。前者是通常意義上的記憶體系統,後者才是我們指令集中8個寄存器辨別符對應的通用寄存器。
下圖為寄存器的工作原理,寄存器輸出一直保持在目前狀态,直到時鐘上升沿,新的輸入将成為目前的寄存器狀态。
寄存器操作
寄存器檔案可以看成這樣一個功能塊:
寄存器檔案
它有兩個讀端口和一個寫端口,支援讀寫同時操作。值得注意的是,寄存器檔案的讀操作是即時的,而寫操作是基于時鐘的。也就是說,讀出的值valA和valB随時根據srcA和srcB的變化而變化,而要寫入的值valW隻在clock的上升沿才能寫入。仔細想想,寄存器檔案的讀寫特性好像和寄存器是完全一樣的,隻不過是多了一個選址操作。
指令的分階段執行
雖然宏觀上來看,指令已經是程式不可分割的基本元素。但在處理器中,一條指令的執行還是要分多個階段,這樣才可以提高硬體的處理效率。在Y86架構中,我們将每個指令的執行分為6個階段。
- 取指 :從PC中取出目前要執行的指令,并按照指令編碼對其分解,得到icode、ifun、rA、rB、valC等值。
- 譯碼 :根據rA、rB取出對應寄存器的值valA、valB。
- 執行 :ALU在不同指令下執行不同的操作,包括簡單運算、位址加減等等,運算結果為valE,運算時會對條件碼産生影響。
- 訪存 :從存儲器讀取資料或向存儲器寫入資料。讀出的值為valM。
- 寫回 :将前面生成的結果寫回寄存器檔案。
- 更新PC :将PC設定成下一條指令的位址。
這些步驟現在看起來雜亂無章,不知有何用處。但仔細分析,可以看到,每個階段隻做與一兩個硬體相關的事情,由輸入決定輸出,完全可以在一個時鐘周期内做完。而各個階段之間的聯系就是各種信号的輸入和輸出,比如,譯碼階段的輸出valA可以作為執行階段的輸入,而執行階段的輸出又可以作為寫回階段的輸入,這樣就可以用簡單的組合電路把這些硬體單元連接配接起來,實作我們需要的功能。
為了大家更清楚地了解各個階段的作用,我們用一個例子來詳細說明。
指令的分階段實作
上圖分别為
OPl rA, rB
、
rrmovl rA, rB
、
irmovl V, rB
這三個指令的分階段執行過程。在取指階段中,M表示存儲器,M1[PC]表示以PC為基址從存儲器中取出1位元組資料。由于各個指令長短不一,是以取指階段做的事情也不盡相同。在該階段最後,會計算出PC的新值valP。譯碼階段是從寄存器檔案中取出寄存器的值,用R[rA]來表示寄存器rA的值。執行階段對于OPl指令來說會設定狀态碼CC,而後兩個指令則不會對狀态碼産生影響。訪存階段在這三個指令中都沒有涉及。最後的更新PC階段将valP的值指派給PC。
當我讀到這裡的時候,我有很大的疑問:不是說每個階段隻做一件簡單的事情嗎,但是不同的指令在同一個階段做的事情似乎各不相同。比如剛才的三個指令,在執行階段隻有OPl指令會設定狀态碼,而另外兩個不會,這是為什麼?包括書中後面舉的其它例子,更新PC階段并不一定是把valP的值指派給PC,有些指令比如call和ret,它們會将valC的值或valM的值指派給PC,這又是怎麼做到的?
大家是否也想到了這些問題呢?很顯然,每個階段對不同的指令有不同的響應是很自然的事情,不然怎麼适應各個指令的不同功能呢。我們前面提到的HCL硬體控制語言,就是要完成這個任務,控制每個指令在每個階段要完成的任務。
好了,在詳細說明如何用HCL控制邏輯之前,先給出完整的硬體結構圖。
SEQ硬體結構
我們要注意圖中不同顔色的方塊和不同粗細的線條,它們代表着不同的意思。綠色塊代表基本的硬體單元,比如ALU、寄存器檔案、PC,基本上我們都已經接觸過。灰色方塊将是我們下一步研究的重點,它們是HCL描述的組合邏輯電路,用于連接配接綠色塊并實作特定的選擇或邏輯運算。白色圓圈并沒有特殊的含義,隻是用來辨別信号線的名稱。圖中還有三種線條,粗實線表示寬度為字長的信号線,細實線表示寬度為1個位元組或更窄的信号線,而虛線表示單個位的信号線。
圖中從下到上分别是剛才介紹的取指、譯碼(寫回)、執行、訪存和更新PC階段。由于譯碼和寫回階段都是對寄存器檔案的操作,是以它們在圖中畫在了同一個位置。用圓圈标出的信号就是前文提到的各個階段産生的中間值,這些值通常在不同指令中擔任着不同的角色,是以會出現一個信号分叉為兩個信号的情況。例如圖中valA産生之後分為兩條線,一條通向ALUB控制邏輯,另一條通向Data控制邏輯。再例如圖中valM産生之後分為兩條線,一條通向New PC控制邏輯,另一條通向寄存器檔案的輸入端。我們需要明白的是,一個信号分為兩個信号,意味着兩個接收端都可以讀取到該信号的值,但讀取到該值并不意味着使用該值,接收端的控制邏輯決定是否使用該值,下文将會詳細叙述。
SEQ的狀态改變周期
上一張圖的标題我沒做解釋,其實是留了個疑問。SEQ的意思是Sequential(順序的),“SEQ硬體結構”就是說“順序的硬體結構”或者“硬體結構的順序實作”。什麼!!難道還有其它方式的實作?答案是當然的,我們留到後面再揭開謎底。SEQ的硬體結構使得指令必須按順序一個接一個地執行,下一條指令的開始必須晚于上一條指令的結束。這就導緻處理器效率極其低下,因為一個指令必須在一個時鐘周期内通過所有階段,而由于電路延遲的固有因素,通過所有階段需要的時間很長,也就限制了時鐘周期無法提高。然而,為什麼一個指令必須在一個時鐘周期内通過所有階段呢?
因為對于時序邏輯電路,比如SEQ中的存儲器、寄存器檔案、CC和程式計數器,它們隻在時鐘信号的上升沿寫入資料。目前個指令結束,下個指令開始的時候,時鐘信号上升沿觸發這幾個硬體單元的更新。如果在下一個時鐘周期上升沿到來之前,需要更新的新值還沒有産生,這個指令就相當于沒執行或執行了一半。是以時鐘周期不能提得太高,否則将造成指令執行紊亂。
下圖展示了兩個指令周期的過程中,由時鐘控制的各個硬體單元的狀态改變。
跟蹤SEQ的兩個執行周期
可以看到,圖中将四個時序邏輯電路之外的其它部分作為一個組合邏輯電路的整體來看待。當周期3開始時,組合邏輯電路開始運作,直到周期3結束前,所有結果都已得出,準備寫入存儲器等裝置。當周期4開始時,存儲器、寄存器檔案、CC和程式計數器的值被更新,同時,這些新值被組合邏輯電路讀取并開始計算結果,如此循環往複。是以,每個時鐘周期SEQ的狀态改變一次。
SEQ的各階段實作
前文給出的SEQ硬體結構圖隻是一個大概的實作,有些細節并沒有給出。現在,我們一個階段一個階段地分析SEQ的具體實作。
取指階段:
SEQ取指階段
指令從記憶體中取出後按位元組分為了兩部分:Split和Align。Split又分為icode和ifun。Align分為rA、rB和valC,這些都很容易了解。重點在于PC增加的邏輯。PC增加多少要根據本條指令的長短來決定,而本條指令的長短又在于指令中是否包含寄存器辨別,以及是否包含常數valC,圖中的兩個組合電路Need valC和Need rigids就是用來做這個判斷。
以Need rigids為例,它的HCL語言描述如下:
bool need_rigids =
icode in { IRRMOVL, IOPL, IPUSHL, IPOPL, IIRMOVL, IRMMOVL, IMRMOVL };
意即,當icode等于括号中7種指令碼之一時,need_rigids為真。也就是說這7種指令中包含寄存器辨別。同理,need_valC也可以用這個枚舉的方法确定,隻需要查前面的指令集編碼表,找到包含valC的指令,放在括号裡面就行了。
當need_rigids和need_valC都确定了之後,PC increment将按如下公式計算新的PC值,其實就是加上了該條指令的長度:
newPC = oldPC + 1 + need_rigids + 4*need_valC
現在我們明白了,灰色方框代表的組合電路可以用HCL語言來描述。而實際電路中這些HCL語句将通過綜合成為真正的組合邏輯電路。在這裡,HCL是一種很好的抽象,将原理與具體的實作相分離,友善我們的設計。
譯碼和寫回階段:
SEQ譯碼和寫回階段
這兩個階段都與寄存器檔案的讀寫相關。從取指階段得到的信号icode、rA和rB在這裡作為輸入信号,經過一些組合電路生成寄存器檔案的輸入。我們的目的是,在譯碼階段,對于那些需要使用特定寄存器的指令,從寄存器檔案中取出這些寄存器的值,位址由srcA和srcB來決定,結果輸出為valA和valB;在寫回階段,将執行階段的結果valE或訪存階段的結果valM寫回特定的寄存器,寄存器的位址由dstE和dstM來決定。以組合電路srcA為例,它的HCL表述為:
int srcA = [
icode in { IRRMOVL, IRMMOVL, IOPL, IPUSHL } : rA;
icode in { IPOPL, IRET } : RESP;
1 : RNONE; #Don't need register
];
方括号類似C語言中的switch語句,當第一個分号前的條件滿足時傳回rA,後面的兩個條件不再考慮;否則再判斷第二個條件是否滿足,滿足則傳回RESP;否則傳回RNONE,表示不需要讀取寄存器檔案。從中可以看出,在譯碼階段,當指令為第一個分号前的四種時,将讀取rA寄存器的值并放入結果valA;當指令為第二個分号前的兩種時,将讀取RESP寄存器的值并放入結果valA;否則,不必讀取任何寄存器。
與srcA類似的還有srcB、dstE和dstM三個組合邏輯電路,它們的HCL表述可以從SEQ的硬體結構和指令集編碼中分析得出,不再一一叙述。
執行階段:
SEQ執行階段
ALU需要兩個操作數和一個alufun信号,alufun信号用于指明ALU對兩個操作符執行怎樣的邏輯運算(加、減、與、異或)。
以第一個操作數aluA為例,它的HCL描述如下:
int aluA = [
icode in { IRRMOVL, IOPL } : valA;
icode in { IIRMOVL, IRMMOVL, IMRMOVL } : valC;
icode in { ICALL, IPUSH } : -4;
icode in { IRET, IPOPL } : 4;
# Other instructions don't need ALU
];
可以看出,操作數aluA有時取valA,有時取valC,有時取-4或4,完全決定于指令類型。
alufun信号的HCL描述如下:
int alufun = [
icode == IOPL : ifun;
1 : ALUADD;
];
僅當指令為IOPL指令(即運算指令)時,alufun由ifun決定,其它情況下ALU全部當做加法器來使用。這也就不難了解為什麼剛才aluA會取-4或4,是以此時aluA作為加法器的一個加數,而另一個加數從圖中可以看到隻能來自于valB,雖然valB在譯碼階段的HCL我們并沒有給出,不過可以告訴大家valB在這四種情況下的輸出都是RESP。是以對于ICALL和IPUSH來說是為了讓棧指針esp-4,對于IRET和IPOPL來說是為了讓棧指針esp+4。
訪存階段:
SEQ訪存階段
Mem read和Mem write決定目前指令對存儲器是讀操作還是寫操作。Mem addr和Mem data決定讀寫操作的位址和資料。以Mem addr為例,HCL描述如下:
int mem_addr = [
icode in { IRMMOVL, IPUSHL, ICALL, IMRMOVL } : valE;
icode in { IPOPL, IRET } : valA;
# Other instructions don't need address
];
更新PC階段 :
SEQ更新PC階段
新的PC值來源可以從valC、valM和valP中選擇,New PC的HCL描述如下:
int new_pc = [
# Call. Use instruction constant
icode == ICALL : valC;
# Taken branch. Use instruction constant
icode == IJXX && Cnd : valC;
# Completion of RET instruction. Use value from stack
icode == IRET : valM;
# Default. Use incremented PC
1 : valP;
];
流水線的一般原則
到此為止,我們的前奏剛剛落幕,終于要步入正題了。(這個前奏的确有點長,哈哈。)
在“SEQ的狀态改變周期”中埋下了一個伏筆,現在我們來揭開謎底。由于SEQ的時鐘頻率太低,我們需要想些辦法來提高時鐘頻率。通常可以想到兩種途徑,一是縮短每條指令的執行時間,二是讓多條指令同時執行。方法一不可行,因為每條指令的執行時間很難壓縮,這是由電路的固有性質決定的。是以隻能采用方法二,即流水線技術。
先來用一個形象的比喻來形容流水線技術。有一種帶傳送帶的自助餐廳,食物擺在傳送帶上經過顧客,顧客可以随意取走自己喜歡的食品。如果我們把一盤食物當做一條指令,而傳送帶兩旁的顧客當做指令執行的各個階段,那麼SEQ的實作就相當于每次隻往傳送帶上放一盤食物,當這盤食物走到傳送帶盡頭後再放下一盤食物,如果餐館真這麼做的話顧客恐怕都要餓死了。實際情況是,食物一盤接一盤地放在傳送帶上,每個顧客送走這一盤食物馬上迎來下一盤食物,效率大大提高。
處理器架構的流水線技術也是這樣,每個階段都有一條指令正在執行,6個階段就會有6條指令同時執行,将吞吐量提高為SEQ時的6倍。這樣是不是感覺非常給力呢,不過,事情遠沒有想象中那麼簡單,最直接的問題是多個指令間會不會互相幹擾?
我們回顧一下SEQ的硬體結構圖,不同階段間經常有跨階段的連線,比如取指階段得到的valC直接連接配接到了更新PC階段的New PC。這在流水線情況下會出問題,因為後面的指令會覆寫前面指令産生的valC,是以,當先前的指令到達更新PC階段再回頭取valC的值時,已經不是當初自己在譯碼階段生成的值了。怎麼辦呢?
解決方案也很容易想到,把每條指令後面有可能用到的值都儲存下來不就行了。相當于每個階段多加一套寄存器,在階段開始時将這些寄存器的值更新為目前指令配套的值。在流水線技術中,這些插入到各個階段間的寄存器稱為流水線寄存器。
現在我們的處理器架構更新為PIPE-(Pipeline-,減号表示非最終版本),如下圖所示。
PIPE-硬體結構
與SEQ相比有兩處變化,一是将更新PC階段和取指階段放在了一起,在取指之前更新PC;二是每兩個階段間插入了流水線寄存器。這些流水線寄存器是基于時鐘更新的,每個時鐘周期的開始将會更新這些寄存器中的資料,相當于把目前指令的狀态傳遞到了下一個階段。
流水線冒險
現在大功告成了嗎?還沒有。當我們仔細分析PIPE-的時候我們會發現仍然存在一些問題。雖然流水線寄存器隔離了各個指令之間的資料共享,但是多個指令之間仍然存在依賴,包括兩個方面:
資料依賴:前一條指令寫入的寄存器或存儲器正好是後一條指令需要讀取的寄存器或存儲器。在PIPE-中,當後一條指令在譯碼階段讀寄存器的時候,前一條指令才剛剛到執行階段,是以新值還沒有寫入寄存器,如果此時後一條指令直接讀寄存器的話,讀到的是舊值,這就違反了代碼順序執行的規則。
控制依賴:當一條指令是jump、call或return時,下一條指令的位址是無法提前确定的,它依賴于目前指令的執行結果。是以流水線很可能需要中斷。
這些依賴可能導緻流水線産生計算錯誤,這種現象稱為流水線冒險。我們先來考慮資料冒險。下圖畫出了一段代碼的分階段執行過程。
prog1代碼段的執行過程
irmovl $3, %eax
和
addl %edx, %eax
之間插入了三個空指令。這樣的話,前者執行完寫回階段,後者才開始執行譯碼階段,保證了讀取寄存器前已經寫入完畢。不發生資料冒險。
再看下圖。
prog2代碼段的執行過程
現在去掉了一個空指令,情況立馬惡化。指令0x006的寫回階段和指令0x00e的譯碼階段同時發生,但由于寫回寄存器的操作直到第7周期的開始才會生效,是以譯碼階段讀出的值仍然是舊值,出現資料冒險現象。
如果把剩下的兩個空指令也去掉,結果可想而知,肯定會發生更嚴重的資料冒險,我們在此不再驗證。接下來考慮如何避免資料冒險。
仍然有兩種解決方案:
暫停:與插入nop空指令類似,處理器自動向可能發生資料冒險的代碼間插入bubble,使目前正在執行的指令暫停一個時鐘周期。
prog2使用暫停時的執行過程
如上圖所示,當addl指令執行到譯碼階段時,檢測到将會發生資料冒險,于是插入一個bubble,addl指令在譯碼階段重複一個時鐘周期。
如果把所有nop都去掉,仍然可以用插入bubble的方法解決資料冒險,隻不過需要插入多個bubble而已,如下圖所示。
prog4使用暫停時的執行過程
轉發:暫停有一點很不好,它會降低程式執行效率,因為加入了很多無用的指令,純粹在浪費時間。而轉發可以更充分地利用每一個周期的時間。
仍然以剛才的代碼段為例講解轉發如何起作用。
prog2使用轉發時的執行過程
如圖,當addl到譯碼階段的時候,irmovl到寫回階段,由于還沒有寫入寄存器,是以讀取資料時發生資料冒險。不過,我們可以用一個巧妙的方法避免這個冒險。既然寫回階段需要等到下個周期開始才能寫入寄存器,那不如直接把要寫入的值轉發給譯碼階段,這樣的話譯碼階段也不需要再從寄存器讀了,直接拿轉發來的值用就行了。
接下來,如果是prog3代碼段呢?
prog3使用轉發時的執行過程
prog3和prog2的差別在于少了一個nop指令,這就導緻當addl到譯碼階段的時候irmovl指令才到訪存階段。不過似乎對轉發并沒有影響,因為irmovl指令并不操作記憶體,在下一個階段将要寫入寄存器的值現在已經産生了,就是M_valE(需要注解一下,M_valE的意思是M階段的流水線寄存器中儲存的valE的值,請檢視前面的PIPE-硬體結構圖),是以直接把M_valE轉發給譯碼階段就行了。
再接下來,如果是prog4代碼段呢?
prog4使用轉發時的執行過程
現在,一個nop指令都沒有了,irmovl後面緊跟着addl,當addl到譯碼階段的時候irmovl才到執行階段。可是令人驚訝的是,仍然可以轉發。首先,我們可以發現最後需要的寄存器的值就是在執行階段經過計算得出的。其次,我們要考慮到執行階段得出結果需要一定時間,這個時間會不會導緻不能按時轉發到譯碼階段呢?答案是否定的。因為譯碼階段即使很早拿到這個值,也會等到下一個周期開始才把它寫入執行階段的流水線寄存器。是以隻要在下個周期開始之前計算出這個值就可以了,而這個條件是永遠都能得到滿足的。
有沒有感覺到很神奇呢?竟然可以用轉發在不降低程式效率的條件下解決資料冒險問題,簡直太棒了。可是任何事情都不是完美的,剛才的例子隻是irmovl後面跟addl且兩者使用同一個寄存器,而實際程式有非常多種可能的組合,是不是轉發可以解決所有的問題?我們看下面這個例子。
load/use資料冒險
prog5代碼段的0x018和0x01e兩行代碼稱為加載/使用資料冒險,mrmovl将資料從存儲器加載到寄存器%eax,然後緊接着addl使用寄存器%eax的值。仍然用轉發,将mrmovl執行階段的值轉發給addl,卻得到了錯誤的結果。其實原因很容易想到,因為mrmovl指令需要到訪存階段才能擷取到正确的值并指派給%eax,是以再從執行階段轉發到譯碼階段已經完全不可行了。如何解決這個問題呢?我們可以把暫停和轉發兩種方式結合起來,先暫停一個周期,然後mrmovl到了訪存階段就可以把值正确地轉發給addl了。
好了,解決了這麼多問題,終于可以給出我們的最終版硬體結構圖了。
PIPE硬體結構
比PIPE-增加的内容就是為了解決資料冒險問題而增加的轉發電路,轉發的接收方基本都在譯碼階段。
更完善的設計
任何事情都講究完美,我們現在得到的PIPE其實還不夠完美,有些關鍵細節沒有考慮到。
異常處理:處理器非常重要的一個方面就是異常處理。很多指令執行過程中都可能發生各種各樣的異常,比如通路存儲器時無效的位址、無效的指令的編碼等等。當程式發生異常時,應該立即中止程式,從外面來看的效果應該是正好停在異常發生的位置:即前面的代碼已經完全執行,而後面的代碼完全沒有執行。看起來很簡單的事情在PIPE中并不那麼容易實作,因為流水線中有多個指令同時執行,如果某個指令在某個階段發生了異常,此時很可能後面的代碼已經執行了一部分,要想得到完全沒執行的效果,就要消除掉已經産生的影響,這需要加強控制邏輯的功能。
控制冒險:上一節流水線冒險中我們提到了控制依賴,它會導緻控制冒險。當執行到條件跳轉指令時,需要做分支預測,一旦預測錯誤,就需要消除已經執行的若幹條指令,重新執行正确分支的指令。當執行到子函數傳回指令時,需要從存儲器中取出傳回位址,是以下一條指令直到訪存階段才能開始執行。這些特殊情況都需要我們特殊考慮,并在控制邏輯中實作。
如果詳細講解這兩部分的具體實作,又會花很多篇幅,有興趣的朋友可以通路這本書的官網進一步了解。
與真實指令集架構的差距
本文講述了Y86指令集架構的設計過程,雖然叙述已經足夠粗略,可還是寫了這麼長的篇幅。然而如果與真實的指令集架構(比如x86)的複雜度相比那又真是小巫見大巫了。我們隻規定了一個非常簡單的指令集,并完成了一個簡易的實作。而真實的指令集會包含非常多的指令,包括一些多周期的指令,比如浮點數運算指令,這些指令無法在一個周期内完成,是以需要一些額外的硬體單元的支援。Y86中的存儲器被我們看做是理想的存儲單元,我們認為資料的存取操作都可以在一個時鐘周期内完成。然而CPU速率與記憶體速率其實相差上千倍,通常需要多級緩存構成一個複雜的存儲器層次結構才能加快存取效率。現代處理器還采用了多發射和亂序執行技術,已經不是Y86中所描述的一個階段一個階段地執行了,而是多條指令同時執行,而且與它們在代碼中的先後順序無關。近些年,處理器向多核方向發展,多個核具有更強的處理能力,也使指令在代碼級别的并行執行成為潮流。今後,處理器會采用哪些新技術我們無從得知,但一定會變得越來越複雜。不過萬變不離其宗,了解了處理器和指令集的基本原理,我們可以看透一切,再複雜的系統也是從基本形式一步步擴充得到的,把握核心才是最關鍵的。