1. 程式人生 > >虛擬機器:直譯器,樹遍歷直譯器,基於棧與基於暫存器,大雜燴

虛擬機器:直譯器,樹遍歷直譯器,基於棧與基於暫存器,大雜燴


解析器是parser,而直譯器是interpreter。兩者不是同一樣東西,不應該混用。 

前者是編譯器/直譯器的重要組成部分,也可以用在IDE之類的地方;其主要作用是進行語法分析,提取出句子的結構。廣義來說輸入一般是程式的原始碼,輸出一般是語法樹(syntax tree,也叫parse tree等)或抽象語法樹(abstract syntax tree,AST)。進一步剝開來,廣義的解析器裡一般會有掃描器(scanner,也叫tokenizer或者lexical analyzer,詞法分析器),以及狹義的解析器(parser,也叫syntax analyzer,語法分析器)。掃描器的輸入一般是文字,經過詞法分析,輸出是將文字切割為單詞的流。狹義的解析器輸入是單詞的流,經過語法分析,輸出是語法樹或者精簡過的AST。 
(在一些編譯器/直譯器中,解析也可能與後續的語義分析、程式碼生成或解釋執行等步驟融合在一起,不一定真的會構造出完整的語法樹。但概念上說解析器就是用來抽取句子結構用的,而語法樹就是表示句子結構的方式。關於邊解析邊解釋執行的例子,可以看看
這帖
的計算器。) 
舉例:將i = a + b * c作為原始碼輸入到解析器裡,則廣義上的解析器的工作流程如下圖: 
 
其中詞法分析由掃描器完成,語法分析由狹義的解析器完成。 
(嗯,說來其實“解析器”這詞還是按狹義用法比較準確。把掃描器和解析器合起來叫解析器總覺得怪怪的,但不少人這麼用,這裡就將就下吧 =_= 
不過近來“scannerless parsing”也挺流行的:不區分詞法分析與語法分析,沒有單獨的掃描器,直接用解析器從原始碼生成語法樹。這倒整個就是解析器了,沒狹不狹義的問題) 

後者則是實現程式執行的一種實現方式,與編譯器相對。它直接實現程式原始碼的語義,輸入是程式原始碼,輸出則是執行原始碼得到的計算結果;編譯器的輸入與直譯器相同,而輸出是用別的語言實現了輸入原始碼的語義的程式。通常編譯器的輸入語言比輸出語言高階,但不一定;也有輸入輸出是同種語言的情況,此時編譯器很可能主要用於優化程式碼。 
舉例:把同樣的原始碼分別輸入到編譯器與直譯器中,得到的輸出不同: 
 
值得留意的是,編譯器生成出來的程式碼執行後的結果應該跟直譯器輸出的結果一樣——它們都應該實現原始碼所指定的語義。 

在很多地方都看到解析器與直譯器兩個不同的東西被混為一談,感到十分無奈。 
最近某本引起很多關注的書便在開篇給讀者們當頭一棒,介紹了“JavaScript解析機制”。“編譯”和“預處理”也順帶混為一談了,還有“預編譯” 0_0 
我一直以為“預編譯”應該是ahead-of-time compilation的翻譯,是與“即時編譯”(just-in-time compilation,JIT)相對的概念。另外就是PCH(precompile header)這種用法,把以前的編譯結果快取下來稱為“預編譯”。把AOT、PCH跟“預處理”(
preprocess
)混為一談真是詭異。算了,我還是不要淌這渾水的好……打住。 


2、“直譯器”到底是什麼?“解釋型語言”呢? 

很多資料會說,Python、Ruby、JavaScript都是“解釋型語言”,是通過直譯器來實現的。這麼說其實很容易引起誤解:語言一般只會定義其抽象語義,而不會強制性要求採用某種實現方式。 
例如說C一般被認為是“編譯型語言”,但C的直譯器也是存在的,例如Ch。同樣,C++也有直譯器版本的實現,例如Cint。 
一般被稱為“解釋型語言”的是主流實現為直譯器的語言,但並不是說它就無法編譯。例如說經常被認為是“解釋型語言”的Scheme就有好幾種編譯器實現,其中率先支援R6RS規範的大部分內容的是Ikarus,支援在x86上編譯Scheme;它最終不是生成某種虛擬機器的位元組碼,而是直接生成x86機器碼。 

直譯器就是個黑箱,輸入是原始碼,輸出就是輸入程式的執行結果,對使用者來說中間沒有獨立的“編譯”步驟。這非常抽象,內部是怎麼實現的都沒關係,只要能實現語義就行。你可以寫一個C語言的直譯器,裡面只是先用普通的C編譯器把原始碼編譯為in-memory image,然後直接呼叫那個image去得到執行結果;使用者拿過去,發現直接輸入原始碼可以得到源程式對應的執行結果就滿足需求了,無需在意直譯器這個“黑箱子”裡到底是什麼。 
實際上很多直譯器內部是以“編譯器+虛擬機器”的方式來實現的,先通過編譯器將原始碼轉換為AST或者位元組碼,然後由虛擬機器去完成實際的執行。所謂“解釋型語言”並不是不用編譯,而只是不需要使用者顯式去使用編譯器得到可執行程式碼而已。 

那麼虛擬機器(virtual machine,VM)又是什麼?在許多不同的場合,VM有著不同的意義。如果上下文是Java、Python這類語言,那麼一般指的是高階語言虛擬機器(high-level language virtual machine,HLL VM),其意義是實現高階語言的語義。VM既然被稱為“機器”,一般認為輸入是滿足某種指令集架構(instruction set architecture,ISA)的指令序列,中間轉換為目標ISA的指令序列並加以執行,輸出為程式的執行結果的,就是VM。源與目標ISA可以是同一種,這是所謂same-ISA VM。 
前面提到直譯器中的編譯器的輸出可能是AST,也可能是位元組碼之類的指令序列;一般會把執行後者的程式稱為VM,而執行前者的還是籠統稱為直譯器或者樹遍歷式直譯器(tree-walking interpreter)。這只是種習慣而已,並沒有多少確鑿的依據。只不過線性(相對於樹形)的指令序列看起來更像一般真正機器會執行的指令序列而已。 
其實我覺得把執行AST的也叫VM也沒啥大問題。如果認同這個觀點,那麼把DLR看作一種VM也就可以接受了——它的“指令集”就是樹形的Expression Tree。 

VM並不是神奇的就能執行程式碼了,它也得采用某種方式去實現輸入程式的語義,並且同樣有幾種選擇:“編譯”,例如微軟的.NET中的CLR;“解釋”,例如CPython、CRuby 1.9,許多老的JavaScript引擎等;也有介於兩者之間的混合式,例如Sun的JVM,HotSpot。如果採用編譯方式,VM會把輸入的指令先轉換為某種能被底下的系統直接執行的形式(一般就是native code),然後再執行之;如果採用解釋方式,則VM會把輸入的指令逐條直接執行。 
換個角度說,我覺得采用編譯和解釋方式實現虛擬機器最大的區別就在於是否存下目的碼:編譯的話會把輸入的源程式以某種單位(例如基本塊/函式/方法/trace等)翻譯生成為目的碼,並存下來(無論是存在記憶體中還是磁碟上,無所謂),後續執行可以複用之;解釋的話則把源程式中的指令是逐條解釋,不生成也不存下目的碼,後續執行沒有多少可複用的資訊。有些稍微先進一點的直譯器可能會優化輸入的源程式,把滿足某些模式的指令序列合併為“超級指令”;這麼做就是朝著編譯的方向推進。後面講到直譯器的演化時再討論超級指令吧。 

如果一種語言的主流實現是直譯器,其內部是編譯器+虛擬機器,而虛擬機器又是採用解釋方式實現的,或者內部實現是編譯器+樹遍歷直譯器,那它就是名副其實的“解釋型語言”。如果內部用的虛擬機器是用編譯方式實現的,其實跟普遍印象中的“直譯器”還是挺不同的…… 

可以舉這樣一個例子:ActionScript 3,一般都被認為是“解釋型語言”對吧?但這種觀點到底是把FlashPlayer整體看成一個直譯器,因而AS3是“解釋型語言”呢?還是認為FlashPlayer中的虛擬機器採用解釋執行方案,因而AS3是“解釋型語言”呢? 
其實Flash或Flex等從AS3生成出來的SWF檔案裡就包含有AS位元組碼(ActionScript Byte Code,ABC)。等到FlashPlayer去執行SWF檔案,或者說等到AVM2(ActionScript Virtual Machine 2)去執行ABC時,又有直譯器和JIT編譯器兩種實現。這種需要讓使用者顯式進行編譯步驟的語言,到底是不是“解釋型語言”呢?呵呵。所以我一直覺得“編譯型語言”跟“解釋型語言”的說法太模糊,不太好。 
有興趣想體驗一下從命令列編譯“裸”的AS3檔案得到ABC檔案,再從命令列呼叫AVM2去執行ABC檔案的同學,可以從這帖下載我之前從原始碼編譯出來的AVM2,自己玩玩看。例如說要編譯一個名為test.as的檔案,用下列命令: 
Command prompt程式碼  收藏程式碼
  1. java -jar asc.jar -import builtin.abc -import toplevel.abc test.as  

就是用ASC將test.as編譯,得到test.abc。接著用: 
Command prompt程式碼  收藏程式碼
  1. avmplus test.abc  

就是用AVM2去執行程式了。很生動的體現出“編譯器+虛擬機器”的實現方式。 
這個“裸”的AVM2沒有帶Flash或Flex的類庫,能用的函式和類都有限。不過AS3語言實現是完整的。可以用print()函式來向標準輸出流寫東西。 
Well……其實寫Java程式不也是這樣麼?現在也確實還有很多人把Java稱為“解釋型語言”,完全無視Java程式碼通常是經過顯式編譯步驟才得到.class檔案,而有些JVM是採用純JIT編譯方式實現的,內部沒直譯器,例如JRockit和Jikes RVM。我愈發感到“解釋型語言”是個應該避開的用語 =_= 

關於虛擬機器,有本很好的書絕對值得一讀,《虛擬機器——系統與程序的通用平臺》(Virtual Machines: Versatile Platforms for Systems and Processes)。國內有影印版也有中文版,我是讀了影印版,不太清楚中文版的翻譯質量如何。據說翻譯得還行,我無法印證。 


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

用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引擎的實現方式,下文會再提到。 


4、基於棧與基於暫存器架構的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組織的一個會議,專門研討虛擬機器的設計與實現的。可以去找找這個會議往年的論文,很多都值得讀。 


5、樹遍歷直譯器圖解 

在演示基於棧與基於暫存器的VM的例子前,先回頭看看更原始的直譯器形式。 
前面提到解析器的時候用了i = a + b * c的例子,現在讓我們來看看由解析器生成的AST要是交給一個樹遍歷直譯器,會如何被解釋執行呢? 

用文字說不夠形象,還是看圖吧: 
 
這是對AST的後序遍歷:假設有一個eval(Node n)函式,用於解釋AST上的每個節點;在解釋一個節點時如果依賴於子樹的操作,則對子節點遞迴呼叫eval(Node n),從這些遞迴呼叫的返回值獲取需要的值(或副作用)——也就是說子節點都eval好了之後,父節點才能進行自己的eval——典型的後序遍歷。 
(話說,上圖中節點左下角有藍色標記的說明那是節點的“內在屬性”。從屬性語法的角度看,如果一個節點的某個屬性的值只依賴於自身或子節點,則該屬性被稱為“綜合屬性”(synthesized attribute);如果一個節點的某個屬性只依賴於自身、父節點和兄弟節點,則該屬性被稱為“繼承屬性”(inherited attribute)。上圖中節點右下角的紅色標記都只依賴子節點來計算,顯然是綜合屬性。) 

SquirrelFish之前的JavaScriptCore、CRuby 1.9之前的CRuby就都是採用這種方式來解釋執行的。 

可能需要說明的: 
·左值與右值 
在原始碼i = a + b * c中,賦值符號左側的i是一個識別符號,表示一個變數,取的是變數的“左值”(也就是與變數i繫結的儲存單元);右側的a、b、c雖然也是變數,但取的是它們的右值(也就是與變數繫結的儲存單元內的值)。在許多程式語言中,左值與右值在語法上沒有區別,它們實質的差異容易被忽視。一般來說左值可以作為右值使用,反之則不一定。例如數字1,它自身有值就是1,可以作為右值使用;但它沒有與可賦值的儲存單元相繫結,所以無法作為左值使用。 
左值不一定只是簡單的變數,還可以是陣列元素或者結構體的域之類,可能由複雜的表示式所描述。因此左值也是需要計算的。 

·優先順序、結合性與求值順序 
這三個是不同的概念,卻經常被混淆。通過AST來看就很容易理解:(假設原始碼是從左到右輸入的) 
所謂優先順序,就是不同操作相鄰出現時,AST節點與根的距離的關係。優先順序高的操作會更遠離根,優先順序低的操作會更接近根。為什麼?因為整棵AST是以後序遍歷求值的,顯然節點離根越遠就越早被求值。 
所謂結合性,就是當同類操作相鄰出現時,操作的先後順序同AST節點與根的距離的關係。如果是左結合,則先出現的操作對應的AST節點比後出現的操作的節點離根更遠;換句話說,先出現的節點會是後出現節點的子節點。 
所謂求值順序,就是在遍歷子節點時的順序。對二元運算對應的節點來說,先遍歷左子節點再遍歷右子節點就是左結合,反之則是右結合。 
這三個概念與運算的聯絡都很緊密,但實際描述的是不同的關係。前兩者是解析器根據語法生成AST時就已經決定好的,後者則是解釋執行或者生成程式碼而去遍歷AST時決定的。 
在沒有副作用的環境中,給定優先順序與結合性,則無論求值順序是怎樣的都能得到同樣的結果;而在有副作用的環境中,求值順序會影響結果。 

賦值運算雖然是右結合的,但仍然可以用從左到右的求值順序;事實上Java、C#等許多語言都在規範裡寫明表示式的求值順序是從左到右的。上面的例子中就先遍歷的=的左側,求得i的左值;再遍歷=的右側,得到表示式的值23;最後執行=自身,完成對i的賦值。 
所以如果你要問:賦值在類似C的語言裡明明是右結合的運算,為什麼你先遍歷左子樹再遍歷右子樹?上面的說明應該能讓你發現你把結合性與求值順序混為一談了。 

看看Java從左到右求值順序的例子: 
Java程式碼  收藏程式碼
  1. public class EvalOrderDemo {  
  2.     public static void main(String[] args) {  
  3.         int[] arr = new int[1];  
  4.         int a = 1;  
  5.         int b = 2;  
  6.         arr[0] = a + b;  
  7.     }  
  8. }  

由javac編譯,得到arr[0] = a + b對應的位元組碼是: 
Java bytecode程式碼  收藏程式碼
  1. // 左子樹:陣列下標  
  2. // a[0]  
  3. aload_1  
  4. iconst_0  
  5. // 右子樹:加法  
  6. // a  
  7. iload_2  
  8. // b  
  9. iload_3  
  10. // +  
  11. iadd  
  12. // 根節點:賦值  
  13. iastore  



6、從樹遍歷直譯器進化為基於棧的位元組碼直譯器的前端 

如果你看到樹形結構與後序遍歷,並且知道字尾記法(或者逆波蘭記法,reverse Polish notation)的話,那敏銳的你或許已經察覺了:要解釋執行AST,可以先通過後序遍歷AST生成對應的字尾記法的操作序列,然後再解釋執行該操作序列。這樣就把樹形結構壓扁,成為了線性結構。 
樹遍歷直譯器對AST的求值其實隱式依賴於呼叫棧:eval(Node n)的遞迴呼叫關係是靠呼叫棧來維護的。字尾表示式的求值則通常顯式依賴於一個棧,在遇到運算元時將其壓入棧中,遇到運算時將合適數量的值從棧頂彈出進行運算,再將結果壓回到棧上。這種描述看起來眼熟麼?沒錯,字尾記法的求值中的核心資料結構就是前文提到過的“求值棧”(或者叫運算元棧,現在應該更好理解了)。字尾記法也就與基於棧的架構聯絡了起來:後者可以很方便的執行前者。同理,零地址指令也與樹形結構聯絡了起來:可以通過一個棧方便的把零地址指令序列再轉換回到樹的形式。 

Java位元組碼與Java原始碼聯絡緊密,前者可以看成後者的字尾記法。如果想在JVM上開發一種語義能直接對映到Java上的語言,那麼編譯器很好寫:祕訣就是後序遍歷AST。 
那麼讓我們再來看看,同樣是i = a + b * c這段原始碼對應的AST,生成Java位元組碼的例子: 
 
(假設a、b、c、i分別被分配到區域性變數區的slot 0到slot 3) 
能看出Java位元組碼與原始碼間的對應關係了麼? 
一個Java編譯器的輸入是Java原始碼,輸出是含有Java位元組碼的.class檔案。它裡面主要包含掃描器與解析器,語義分析器(包括型別檢查器/型別推導器等),程式碼生成器等幾大部分。上圖所展示的就是程式碼生成器的工作。對Java編譯器來說,程式碼生成就到位元組碼的層次就結束了;而對native編譯器來說,這裡剛到生成中間表示的部分,接下去是優化與最終的程式碼生成。 

如果你對PythonCRuby 1.9之類有所瞭解,會發現它們的位元組碼跟Java位元組碼在“基於棧”的這一特徵上非常相似。其實它們都是由“編譯器+VM”構成的,概念上就像是Java編譯器與JVM融為一體一般。 
從這點看,Java與Python和Ruby可以說是一條船上的。雖說內部具體實現的顯著差異使得先進的JVM比簡單的JVM快很多,而JVM又普遍比Python和Ruby快很多。 

當直譯器中用於解釋執行的中間程式碼是樹形時,其中能被稱為“編譯器”的部分基本上就是解析器;中間程式碼是線性形式(如位元組碼)時,其中能被稱為編譯器的部分就包括上述的程式碼生成器部分,更接近於所謂“完整的編譯器”;如果虛擬機器是基於暫存器架構的,那麼編譯器裡至少還得有虛擬暫存器分配器,又更接近“完整的編譯器”了。 


7、基於棧與基於暫存器架構的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. public class Demo {  
  2.     public static void 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;  

替換為 

相關推薦

虛擬機器直譯器直譯器基於基於大雜燴

解析器是parser,而直譯器是interpreter。兩者不是同一樣東西,不應該混用。  前者是編譯器/直譯器的重要組成部分,也可以用在IDE之類的地方;其主要作用是進行語法分析,提取出句子的結構。廣義來說輸入一般是程式的原始碼,輸出一般是語法樹(syntax tree,也叫parse tree等)或抽

題目1078二叉(根據前序和中序結果獲得後序)

題目描述: 二叉樹的前序、中序、後序遍歷的定義: 前序遍歷:對任一子樹,先訪問跟,然後遍歷其左子樹,最後遍歷其右子樹; 中序遍歷:對任一子樹,先遍歷其左子樹,然後訪問根,最後遍歷其右子樹; 後序遍歷:對任一子樹,先遍歷其左子樹,然後遍歷其右子樹,最後訪問根。 給定一棵二叉

1364二叉(flist)

【題目描述】 樹和二叉樹基本上都有先序、中序、後序、按層遍歷等遍歷順序,給定中序和其它一種遍歷的序列就可以確定一棵二叉樹的結構。 假定一棵二叉樹一個結點用一個字元描述,現在給出中序和按層遍歷的字串,

實驗2.2二叉的一些應用

題目:以實驗2.1的二叉連結串列為儲存結構,編寫程式實現求二叉樹結點個數、葉子結點個數、二叉樹的高度以及交換二叉樹所有左右子樹的操作。部分程式碼:求二叉樹結點個數://求二叉樹結點個數 int Size(BinaryTreeNode *t){ if(!t) retur

[二叉] 方法總結--遞迴非遞迴--純C實現

非遞迴方法: 思路一:根據訪問次序來入棧並輸出 思路二:模擬訪問過程 思路三:使用識別符號mark來記錄已經第幾次訪問該結點 /* @Desc:二叉連結串列 無頭結點 @Vesrion:0.0.1 @Time:20180922建立 */ #include

《ServerSuperIO Designer IDE使用教程》-3.Modbus協議讀取多個實現多種資料型別解析。釋出v4.2.2版本

更新內容,v4.2.2版本:1.增加Modbus協議讀取多個暫存器,並且按多種資料型別解析資料。2.Modbus Serial和Modbus TCP兩個驅動合併成一個驅動。3.修改資料庫結構,儲存配置資訊。4.優化ServerSuperIO核心程式碼,應用過程中的潛在問題。 v4.2.2 下載地址:官方

計算機儲存結構分析(記憶體快取硬碟)

前言            一個計算機包含多種儲存器比如:暫存器、快取記憶體、記憶體、硬碟、光碟等,為啥有這麼多種儲存方式,對於不太瞭解的人,總是覺得雲裡霧裡的,搞不明白原因。        

通俗易懂mmap硬體的關係

分析應用程式獲取按鍵操作的流程 分析應用程式控制LED燈的操作流程 按鍵:按鍵操作,觸發中斷,讀取硬體暫存器,獲取按鍵狀態,喚醒休眠的程序,read操作呼叫copy_to_user將核心的緩衝區資料拷貝到使用者緩衝區 總結:第一次拷貝:從硬體暫存器讀數讀到核心緩衝區

C語言訪問MCU有兩種方式可以採用!

微控制器的特殊功能暫存器SFR是SRAM地址已經確定的SRAM單元,在C語言環境下對其訪問歸納起

C51微控制器中斷定時PWM原理配置及使用

大家晚上好,我分享的內容大體是先介紹中斷,各暫存器使用(可能這裡有點囉嗦),然後就簡單寫一下定時器,然後PWM原理,以及它的配置和使用。大概今晚就這樣了,如果有什麼錯的,或有什麼疑問,請大家馬上提出來,一起進步。 中斷:當計算機執行正常程式時,系統中出現某些急需處理的異常

組合語言計算2^12儲存在AX並以十進位制形式輸出

一、計算2^12儲存在AX中(王爽組合語言第二版p100): mov cx,12 ;迴圈12次 mov

一口氣看完45個CPU核心技術大揭祕

序言 前段時間,我連續寫了十來篇CPU底層系列技術故事文章,有不少讀者私信我讓我寫一下CPU的暫存器。 暫存器這個太多太複雜,不適合寫故事,拖了很久,總算是寫完了,這篇文章就來詳細聊聊x86/x64架構的CPU中那些紛繁複雜的暫存器們。 長文預警,時速較快,請繫好安全帶~起飛~    

java二叉前序中序後序深度求葉子節點個數層次

import java.util.ArrayDeque; import java.util.Queue; public class CreateTree { /** * @param args */ public static void main(Stri

二叉前序中序後序層序的遞迴以及非遞迴實現

樹,是一種在實際程式設計中經常遇到的資料結構,它的邏輯很簡單:除根節點之外每個節點都有且只有一個父節點,除葉子節點之外所有節點都有一個或多個子節點。我們說的二叉樹,就是指子節點最多2個的樹。 二叉樹中,最重要的操作就是遍歷。二叉樹的遍歷分為: 1.前序遍歷:先訪問根節點,

研究生畢業前一日三題3morris及其平衡搜尋二叉

tip:但凡要用遍歷處理的二叉樹問題,都可以用morris遍歷來解決。時間複雜度為N,空間複雜度為1                                                                                      

HDU 1710Binary Tree Traversals(已知前序中序求後序的二叉)

pid http pan clu names pty efi images 樹遍歷 題目鏈接:http://acm.hdu.edu.cn/showproblem.php?pid=1710 解題思路:可以由先序和中序的性質得到 : 先序的第一個借點肯定是當前子樹的根結點, 那

】二叉算法(深度優先、廣度優先前序、中序、後序、層次)及Java實現

order new link left 算法 很多 == 都是 off 二叉樹是一種非常重要的數據結構,很多其它數據結構都是基於二叉樹的基礎演變而來的。對於二叉樹,有深度遍歷和廣度遍歷,深度遍歷有前序、中序以及後序三種遍歷方法,廣度遍歷即我們平常所說的層次遍歷。因為樹的定義

c++實現二叉層序、前序創建二叉遞歸非遞歸實現二叉

log ios cst ack ret 出棧 隊列 結點 非遞歸實現 #include <iostream> #include <cstdio> #include <stdio.h> #include <string> #i

二叉規則先順/中序/後序

子節點 itl 根據 得到 mar spa 先序遍歷 bubuko 中序 二叉樹三種遍歷方式 先序遍歷:遍歷順序規則為【根左右】 先訪問根節點,在左葉子,右葉子 中序遍歷:遍歷順序規則為【左根右】 後序遍歷:遍歷順序規則為【左右根】 例題 先序遍歷:ABCDEFGHK

二叉的基本操作建立、銷燬;層序

一、簡單的建立、銷燬 .h # pragma once # include<assert.h> # include<malloc.h> # include<stdio.h> # include<stdlib.h> # include<