1. 程式人生 > >基於棧與基於暫存器的指令集架構

基於棧與基於暫存器的指令集架構

用C的語法來寫這麼一個語句:
C程式碼  收藏程式碼
  1. a = b + c;  

如果把它變成這種形式:
add a, b, c
那看起來就更像機器指令了,對吧?這種就是所謂“三地址指令”(3-address instruction),一般形式為:
op dest, src1, src2
許多操作都是二元運算+賦值。三地址指令正好可以指定兩個源和一個目標,能非常靈活的支援二元操作與賦值的組合。ARM處理器的主要指令集就是三地址形式的。

C裡要是這樣寫的話:
C程式碼  收藏程式碼
  1. a += b;  

變成:
add a, b
這就是所謂“二地址指令”,一般形式為:
op dest, src
它要支援二元操作,就只能把其中一個源同時也作為目標。上面的add a, b在執行過後,就會破壞a原有的值,而b的值保持不變。x86系列的處理器就是二地址形式的。

上面提到的三地址與二地址形式的指令集,一般就是通過“基於暫存器的架構”來實現的。例如典型的RISC架構會要求除load和store以外,其它用於運算的指令的源與目標都要是暫存器。

顯然,指令集可以是任意“n地址”的,n屬於自然數。那麼一地址形式的指令集是怎樣的呢?
想像一下這樣一組指令序列:
add 5
sub 3
這隻指定了操作的源,那目標是什麼?一般來說,這種運算的目標是被稱為“累加器”(accumulator)的專用暫存器,所有運算都靠更新累加器的狀態來完成。那麼上面兩條指令用C來寫就類似:
C程式碼  收藏程式碼
  1. acc += 5;  
  2. acc -= 3;  

只不過acc是“隱藏”的目標。基於累加器的架構近來比較少見了,在很老的機器上繁榮過一段時間。

那“n地址”的n如果是0的話呢?
看這樣一段Java位元組碼:
Java bytecode程式碼  收藏程式碼
  1. iconst_1  
  2. iconst_2  
  3. iadd  
  4. istore_0  

注意那個iadd(表示整型加法)指令並沒有任何引數。連源都無法指定了,零地址指令有什麼用??
零地址意味著源與目標都是隱含引數,其實現依賴於一種常見的資料結構——沒錯,就是棧。上面的iconst_1、iconst_2兩條指令,分別向一個叫做“求值棧”(evaluation stack,也叫做operand stack“運算元棧”或者expression stack“表示式棧”)的地方壓入整型常量1、2。iadd指令則從求值棧頂彈出2個值,將值相加,然後把結果壓回到棧頂。istore_0指令從求值棧頂彈出一個值,並將值儲存到區域性變數區的第一個位置(slot 0)。
零地址形式的指令集一般就是通過“基於棧的架構”來實現的。請一定要注意,這個棧是指“求值棧”,而不是與系統呼叫棧(system call stack,或者就叫system stack)。千萬別弄混了。有些虛擬機器把求值棧實現在系統呼叫棧上,但兩者概念上不是一個東西。

由於指令的源與目標都是隱含的,零地址指令的“密度”可以非常高——可以用更少空間放下更多條指令。因此在空間緊缺的環境中,零地址指令是種可取的設計。但零地址指令要完成一件事情,一般會比二地址或者三地址指令許多更多條指令。上面Java位元組碼做的加法,如果用x86指令兩條就能完成了:
X86 asm程式碼  收藏程式碼
  1. mov  eax, 1
  2. add  eax, 2

(好吧我犯規了,istore_0對應的儲存我沒寫。但假如區域性變數比較少的話也不必把EAX的值儲存(“溢位”,register spilling)到呼叫棧上,就這樣吧 =_=
其實就算把結果儲存到棧上也就是多一條指令而已……)

一些比較老的直譯器,例如CRuby在1.9引入YARV作為新的VM之前的直譯器,還有SquirrleFish之前的老JavaScriptCore,它們內部是樹遍歷式直譯器;直譯器遞迴遍歷樹,樹的每個節點的操作依賴於解釋其各個子節點返回的值。這種直譯器裡沒有所謂的求值棧,也沒有所謂的虛擬暫存器,所以不適合以“基於棧”或“基於暫存器”去描述。

而像V8那樣直接編譯JavaScript生成機器碼,而不通過中間的位元組碼的中間表示的JavaScript引擎,它內部有虛擬暫存器的概念,但那只是普通native編譯器的正常組成部分。我覺得也不應該用“基於棧”或“基於暫存器”去描述它。
V8在內部也用了“求值棧”(在V8裡具體叫“表示式棧”)的概念來簡化生成程式碼的過程,使用所謂“虛擬棧幀”來記錄區域性變數與求值棧的狀態;但在真正生成程式碼的時候會做窺孔優化,消除冗餘的push/pop,將許多對求值棧的操作轉變為對暫存器的操作,以此提高程式碼質量。於是最終生成出來的程式碼看起來就不像是基於棧的程式碼了。

關於JavaScript引擎的實現方式,下文會再提到。


基於棧與基於暫存器架構的VM,用哪個好?


如果是要模擬現有的處理器,那沒什麼可選的,原本處理器採用了什麼架構就只能以它為源。但HLL VM的架構通常可以自由構造,有很大的選擇餘地。為什麼許多主流HLL VM,諸如JVM、CLI、CPython、CRuby 1.9等,都採用了基於棧的架構呢?我覺得這有三個主要原因:

·實現簡單
由於指令中不必顯式指定源與目標,VM可以設計得很簡單,不必考慮為臨時變數分配空間的問題,求值過程中的臨時資料儲存都讓求值棧包辦就行。
更新:回帖中cscript指出了這句不太準確,應該是針對基於棧架構的指令集生成程式碼的編譯器更容易實現,而不是VM更容易實現。

·該VM是為某類資源非常匱乏的硬體而設計的
這類硬體的儲存器可能很小,每一位元組的資源都要節省。零地址指令比其它形式的指令更緊湊,所以是個自然的選擇。

·考慮到可移植性
處理器的特性各個不同:典型的CISC處理器的通用暫存器數量很少,例如32位的x86就只有8個32位通用暫存器(如果不算EBP和ESP那就是6個,現在一般都算上);典型的RISC處理器的各種暫存器數量多一些,例如ARM有16個32位通用暫存器,Sun的SPARC在一個暫存器窗口裡則有24個通用暫存器(8 in,8 local,8 out)。
假如一個VM採用基於暫存器的架構(它接受的指令集大概就是二地址或者三地址形式的),為了高效執行,一般會希望能把源架構中的暫存器對映到實際機器上暫存器上。但是VM裡有些很重要的輔助資料會經常被訪問,例如一些VM會儲存源指令序列的程式計數器(program counter,PC),為了效率,這些資料也得放在實際機器的暫存器裡。如果源架構中暫存器的數量跟實際機器的一樣,或者前者比後者更多,那源架構的暫存器就沒辦法都對映到實際機器的暫存器上;這樣VM實現起來比較麻煩,與能夠全部對映相比效率也會大打折扣。
如果一個VM採用基於棧的架構,則無論在怎樣的實際機器上,都很好實現——它的源架構裡沒有任何通用暫存器,所以實現VM時可以比較自由的分配實際機器的暫存器。於是這樣的VM可移植性就比較高。作為優化,基於棧的VM可以用編譯方式實現,“求值棧”實際上也可以由編譯器對映到暫存器上,減輕資料移動的開銷。

回到主題,基於棧與基於暫存器的架構,誰更快?看看現在的實際處理器,大多都是基於暫存器的架構,從側面反映出它比基於棧的架構更優秀。
而對於VM來說,源架構的求值棧或者暫存器都可能是用實際機器的記憶體來模擬的,所以效能特性與實際硬體又有點不同。一般認為基於暫存器的架構對VM來說也是更快的,原因是:雖然零地址指令更緊湊,但完成操作需要更多的load/store指令,也意味著更多的指令分派(instruction dispatch)次數與記憶體訪問次數;訪問記憶體是執行速度的一個重要瓶頸,二地址或三地址指令雖然每條指令佔的空間較多,但總體來說可以用更少的指令完成操作,指令分派與記憶體訪問次數都較少。

這方面有篇被引用得很多的論文講得比較清楚,Virtual Machine Showdown: Stack Versus Registers,是在VEE 2005發表的。VEE是Virtual Execution Environment的縮寫,是ACM下SIGPLAN組織的一個會議,專門研討虛擬機器的設計與實現的。可以去找找這個會議往年的論文,很多都值得讀

基於棧與基於暫存器架構的VM的一組圖解

要是拿兩個分別實現了基於棧與基於暫存器架構、但沒有直接聯絡的VM來對比,效果或許不會太好。現在恰巧有兩者有緊密聯絡的例子——JVM與Dalvik VM。JVM的位元組碼主要是零地址形式的,概念上說JVM是基於棧的架構。Google Android平臺上的應用程式的主要開發語言是Java,通過其中的Dalvik VM來執行Java程式。為了能正確實現語義,Dalvik VM的許多設計都考慮到與JVM的相容性;但它卻採用了基於暫存器的架構,其位元組碼主要是二地址/三地址混合形式的,乍一看可能讓人納悶。考慮到Android有明確的目標:面向移動裝置,特別是最初要對ARM提供良好的支援。ARM9有16個32位通用暫存器,Dalvik VM的架構也常用16個虛擬暫存器(一樣多……沒辦法把虛擬暫存器全部直接對映到硬體暫存器上了);這樣Dalvik VM就不用太顧慮可移植性的問題,優先考慮在ARM9上以高效的方式實現,發揮基於暫存器架構的優勢。
Dalvik VM的主要設計者Dan Bornstein在Google I/O 2008上做過一個關於Dalvik內部實現的演講;同一演講也在Google Developer Day 2008 China和Japan等會議上重複過。這個演講中Dan特別提到了Dalvik VM與JVM在位元組碼設計上的區別,指出Dalvik VM的位元組碼可以用更少指令條數、更少記憶體訪問次數來完成操作。(看不到YouTube的請自行想辦法)

眼見為實。要自己動手感受一下該例子,請先確保已經正確安裝JDK 6,並從官網獲取Android SDK 1.6R1。連不上官網的也請自己想辦法。

建立Demo.java檔案,內容為:

Java程式碼  收藏程式碼
  1. publicclass Demo {  
  2.     publicstaticvoid foo() {  
  3.         int a = 1;  
  4.         int b = 2;  
  5.         int c = (a + b) * 5;  
  6.     }  
  7. }  

通過javac編譯,得到Demo.class。通過javap可以看到foo()方法的位元組碼是:
Java bytecode程式碼  收藏程式碼
  1. 0:  iconst_1  
  2. 1:  istore_0  
  3. 2:  iconst_2  
  4. 3:  istore_1  
  5. 4:  iload_0  
  6. 5:  iload_1  
  7. 6:  iadd  
  8. 7:  iconst_5  
  9. 8:  imul  
  10. 9:  istore_2  
  11. 10: return  


接著用Android SDK裡platforms\android-1.6\tools目錄中的dx工具將Demo.class轉換為dex格式。轉換時可以直接以文字形式dump出dex檔案的內容。使用下面的命令:
Command prompt程式碼  收藏程式碼
  1. dx --dex --verbose --dump-to=Demo.dex.txt --dump-method=Demo.foo --verbose-dump Demo.class  

可以看到foo()方法的位元組碼是:
Dalvik bytecode程式碼  收藏程式碼
  1. 0000: const/4       v0, #int 1 // #1
  2. 0001: const/4       v1, #int 2 // #2
  3. 0002: add-int/2addr v0, v1  
  4. 0003: mul-int/lit8  v0, v0, #int 5 // #05
  5. 0005: return-void  

(原本的輸出裡還有些code-address、local-snapshot等,那些不是位元組碼的部分,可以忽略。)

讓我們看看兩個版本在概念上是如何工作的。
JVM:

(圖中數字均以十六進位制表示。其中位元組碼的一列表示的是位元組碼指令的實際數值,後面跟著的助記符則是其對應的文字形式。標記為紅色的值是相對上一條指令的執行狀態有所更新的值。下同)
說明:Java位元組碼以1位元組為單元。上面程式碼中有11條指令,每條都只佔1單元,共11單元==11位元組。
程式計數器是用於記錄程式當前執行的位置用的。對Java程式來說,每個執行緒都有自己的PC。PC以位元組為單位記錄當前執行位置裡方法開頭的偏移量。
每個執行緒都有一個Java棧,用於記錄Java方法呼叫的“活動記錄”(activation record)。Java棧以幀(frame)為單位執行緒的執行狀態,每呼叫一個方法就會分配一個新的棧幀壓入Java棧上,每從一個方法返回則彈出並撤銷相應的棧幀。
每個棧幀包括區域性變數區、求值棧(JVM規範中將其稱為“運算元棧”)和其它一些資訊。區域性變數區用於儲存方法的引數與區域性變數,其中引數按原始碼中從左到右順序儲存在區域性變數區開頭的幾個slot。求值棧用於儲存求值的中間結果和呼叫別的方法的引數等。兩者都以字長(32位的字)為單位,每個slot可以儲存byte、short、char、int、float、reference和returnAddress等長度小於或等於32位的型別的資料;相鄰兩項可用於儲存long和double型別的資料。每個方法所需要的區域性變數區與求值棧大小都能夠在編譯時確定,並且記錄在.class檔案裡。
在上面的例子中,Demo.foo()方法所需要的區域性變數區大小為3個slot,需要的求值棧大小為2個slot。Java原始碼的a、b、c分別被分配到區域性變數區的slot 0、slot 1和slot 2。可以觀察到Java位元組碼是如何指示JVM將資料壓入或彈出棧,以及資料是如何在棧與區域性變數區之前流動的;可以看到資料移動的次數特別多。動畫裡可能不太明顯,iadd和imul指令都是要從求值棧彈出兩個值運算,再把結果壓回到棧上的;光這樣一條指令就有3次概念上的資料移動了。

對了,想提醒一下:Java的區域性變數區並不需要把某個區域性變數固定分配在某個slot裡;不僅如此,在一個方法內某個slot甚至可能儲存不同型別的資料。如何分配slot是編譯器的自由。從型別安全的角度看,只要對某個slot的一次load的型別與最近一次對它的store的型別匹配,JVM的位元組碼校驗器就不會抱怨。以後再找時間寫寫這方面。

Dalvik VM:

說明:Dalvik位元組碼以16位為單元(或許叫“雙位元組碼”更準確 =_=|||)。上面程式碼中有5條指令,其中mul-int/lit8指令佔2單元,其餘每條都只佔1單元,共6單元==12位元組。
與JVM相似,在Dalvik VM中每個執行緒都有自己的PC和呼叫棧,方法呼叫的活動記錄以幀為單位儲存在呼叫棧上。PC記錄的是以16位為單位的偏移量而不是以位元組為單位的。
與JVM不同的是,Dalvik VM的棧幀中沒有區域性變數區與求值棧,取而代之的是一組虛擬暫存器。每個方法被呼叫時都會得到自己的一組虛擬暫存器。常用v0-v15這16個,也有少數指令可以訪問v0-v255範圍內的256個虛擬暫存器。與JVM相同的是,每個方法所需要的虛擬暫存器個數都能夠在編譯時確定,並且記錄在.dex檔案裡;每個暫存器都是字長(32位),相鄰的一對暫存器可用於儲存64位資料。方法的引數按原始碼中從左到右的順序儲存在末尾的幾個虛擬暫存器裡。
與JVM版相比,可以發現Dalvik版程式的指令數明顯減少了,資料移動次數也明顯減少了,用於儲存臨時結果的儲存單元也減少了。

你可能會抱怨:上面兩個版本的程式碼明明不對應:JVM版到return前完好持有a、b、c三個變數的值;而Dalvik版到return-void前只持有b與c的值(分別位於v0與v1),a的值被刷掉了。
但注意到a與b的特徵:它們都只在宣告時接受過一次賦值,賦值的源是常量。這樣就可以對它們應用常量傳播,將
Java程式碼  收藏程式碼
  1. int c = (a + b) * 5;  

替換為
Java程式碼  收藏程式碼
  1. int c = (1 + 2) * 5;  

然後可以再對c的初始化表示式應用常量摺疊,進一步替換為:
Java程式碼  收藏程式碼
  1. int c = 15;  

把變數的每次狀態更新(包括初始賦值在內)稱為變數的一次“定義”(definition),把每次訪問變數(從變數讀取值)稱為變數的一次“使用”(use),則可以把程式碼整理為“使用-定義鏈”(簡稱UD鏈,use-define chain)。顯然,一個變數的某次定義要被使用過才有意義。上面的例子經過常量傳播與摺疊後,我們可以分析得知變數a、b、c都只被定義而沒有被使用。於是它們的定義就成為了無用程式碼(dead code),可以安全的被消除。
上面一段的分析用一句話描述就是:由於foo()裡沒有產生外部可見的副作用,所以foo()的整個方法體都可以被優化為空。經過dx工具處理後,Dalvik版程式相對JVM版確實是稍微優化了一些,不過沒有影響程式的語義,程式的正確性是沒問題的。這是其一。

其二是Dalvik版程式碼只要多分配一個虛擬暫存器就能在return-void前同時持有a、b、c三個變數的值,指令幾乎沒有變化:
Dalvik bytecode程式碼  收藏程式碼
  1. 0000: const/4      v0, #int 1 // #1
  2. 0001: const/4      v1, #int 2 // #2
  3. 0002: add-int      v2, v0, v1  
  4. 0004: mul-int/lit8 v2, v2, #int 5 // #05
  5. 0006: return-void  

這樣比原先的版本多使用了一個虛擬暫存器,指令方面也多用了一個單元(add-int指令佔2單元);但指令的條數沒變,仍然是5條,資料移動的次數也沒變。

題外話1:Dalvik VM是基於暫存器的,x86也是基於暫存器的,但兩者的“暫存器”卻相當不同:前者的暫存器是每個方法被呼叫時都有自己一組私有的,後者的暫存器則是全域性的。也就是說,Dalvik VM位元組碼中不用擔心保護暫存器的問題,某個方法在呼叫了別的方法返回過來後自己的暫存器的值肯定跟呼叫前一樣。而x86程式在呼叫函式時要考慮清楚calling convention,呼叫方在呼叫前要不要保護某些暫存器的當前狀態,還是說被呼叫方會處理好這些問題,麻煩事不少。Dalvik VM這種虛擬暫存器讓人想起一些實際處理器的“暫存器視窗”,例如SPARC的Register Windows也是保證每個函式都覺得自己有“私有的一組暫存器”,減輕了在程式碼裡處理暫存器保護的麻煩——扔給硬體和作業系統解決了。IA-64也有暫存器視窗的概念。

題外話2:Dalvik的.dex檔案在未壓縮狀態下的體積通常比同等內容的.jar檔案在deflate壓縮後還要小。但光從位元組碼看,Java位元組碼幾乎總是比Dalvik的小,那.dex檔案的體積是從哪裡來減出來的呢?這主要得益與.dex檔案對常量池的壓縮,一個.dex檔案中所有類都共享常量池,使得相同的字串、相同的數字常量等都只出現一次,自然能大大減小體積。相比之下,.jar檔案中每個類都持有自己的常量池,諸如"Ljava/lang/Object;"這種常見的字串會被重複多次。Sun自己也有進一步壓縮JAR的工具,Pack200,對應的標準是JSR 200。它的主要應用場景是作為JAR的網路傳輸格式,以更高的壓縮比來減少檔案傳輸時間。在官方文件提到了Pack200所用到的壓縮技巧,
JDK 5.0 Documentation 寫道 Pack200 works most efficiently on Java class files. It uses several techniques to efficiently reduce the size of JAR files:
  • It merges and sorts the constant-pool data in the class files and co-locates them in the archive.
  • It removes redundant class attributes.
  • It stores internal data structures.
  • It use delta and variable length encoding.
  • It chooses optimum coding types for secondary compression.
可見.dex檔案與Pack200採用了一些相似的減小體積的方法。很可惜目前還沒有正式釋出的JVM支援直接載入Pack200格式的歸檔,畢竟網路傳輸才是Pack200最初構想的應用場景。

再次提醒注意,上面的描述是針對概念上的JVM與Dalvik VM,而不是針對它們的具體實現。實現VM時可以採用許多優化技巧去減少效能損失,使得實際的執行方式與概念中的不完全相符,只要最終的執行結果滿足原本概念上的VM所實現的語義就行