簡介 傳統的
編譯器 ,即靜態編譯器,把一個程式的原始碼編譯為機器可識別的目標代碼(即執行檔)。儘管靜態編譯器十分普遍,由於它往往只能在一種體系結構上運行,所以限制很大。從理論上來看,我們可以把針對某個體系結構的二進制代碼通過反編譯轉換成原始碼,再通過另一個編譯器轉換成我們想要的那個體系結構上的執行檔。然而,由於編譯器之間的差異很大,加上
反編譯 的正確性和性能並不讓人滿意,所以最後導致了動態編譯器的誕生。
最初的動態編譯器來自於
解釋器 ,通過解釋器對原始碼的解釋,來執行和解釋器同一平台上的程式。可是,這樣的執行效率低下,用戶難以忍受。為了解決這一問題,人們發明了編譯-解釋器。編譯-解釋器先把原始碼編譯成位元組碼(bytecode),也就是編譯前端的輸出檔案,然後等到執行的時候,通過解釋位元組碼執行程式。這種方式存在了很多年,但是近年來隨著人們對程式性能要求的不斷提高,編譯-解釋器的性能也不能滿足目前的需要了
20 世紀80 年代初,Smalltalk 的實現首次探索了真正的動態編譯,隨後Java
虛擬機 的出現推進了JIT(Just-In-Time)的技術進步。起初,動態編譯器的性能並不理想,後來,隨著位元組碼長度的縮短和編譯方式的簡化,動態編譯器的性能越來越高,針對體系結構的最佳化也逐步引入,這大大提高了動態編譯器的競爭力。
1997 年前後,Sun 公司推出的HotSpot 編譯器是第一款高性能的Java 動態編譯器。隨著Java 技術的普及和計算機體系結構的改良,動態編譯器的套用領域也越來越廣。目前動態編譯技術已經套用於所有常用的計算機體系結構和作業系統,其中包括微軟公司.NET 環境下的基於JIT 的系統以及硬體設計上的Crusoe 處理器。
優缺點 優點 動態編譯從科學和商業角度來看有如下的優點:
(1)性能提高:由於編譯過程包含在運行過程中,因此可以根據運行時具體程式的特點對每個程式分別進行最佳化,這比靜態的編譯器針對所有程式作統一最佳化更有效。我們可以在程式設計師未知的情況下進行動態最佳化和簡檔引導的(profile-guided)最佳化。
(2)軟體形態:目標機在沒有動態編譯器的條件下是不能運行非目標機軟體的,動態編譯器在這裡充當了軟體層的作用,也就是在作業系統之上建立一層軟體平台,通過該平台對源程式的翻譯,從而得到能夠在目標機上運行的代碼。由於動態編譯通過軟體實現,因此減少了硬體設計的難度,增加了軟體的靈活性。
(3)硬體複雜度降低:普通的超標量機往往把計算結果一遍遍地重複放到記憶體中,不但浪費了計算資源,還占用了相當多的計算時間。用動態編譯器可以輕鬆地解決上述問題。動態編譯器只對相同代碼翻譯和最佳化一遍,然後將目標代碼存儲在記憶體中,每次執行時不用像硬體那樣重複進行代碼最佳化工作。
(4)遺留代碼的新生:許多年前的遺留代碼由於“年久失修”,其性能已經大大下降。從源程式角度進行維護已經毫無意義。動態編譯技術的出現使得原來的遺留代碼可以重新進行編譯,並在新的體系結構上運行。對它們來說,不啻為一種“新生”。
(5)加快軟體發布的速度:在新的體系結構上開發軟體總是一件麻煩的事情,往往需要結合其新的優點重新設計開發。動態編譯技術的出現徹底解決了這個問題,它不僅消除了軟體重新開發的問題,還加快了軟體發布的速度,提高了軟體在新的體系結構上的性能。
(6)擴展了軟體最佳化的空間:傳統的軟體最佳化技術總是著眼於局部的、靜態的最佳化。動態編譯技術的出現帶動了軟體最佳化技術的發展,針對間接跳轉、函式返回、共享庫、系統調用等等都逐步形成了新的最佳化技術。
(7)改善了存儲系統的使用:一般的程式都有相當多的跳轉,經常造成顛簸現象,導致存儲系統性能下降,從而影響了軟體的整體性能。動態編譯技術能夠消除不必要的跳轉,改善目標代碼的分布情況,更好地利用目標機存儲系統的特點,節省訪存時間,提高指令高速快取的性能。
(8)改正硬體的錯誤:某些處理器問世以來,會發現許多新的錯誤。對用戶而言,不斷更換新的硬體是一筆昂貴的開銷。有些硬體錯誤可以通過動態編譯器來解決。例如,某些硬體不支持某條指令,或是錯誤地實現了某條指令。通過動態編譯器的重新實現可以完全解決上述問題。
(9)推進處理器的發展:動態編譯器從某種程度上可以代替硬體。因此晶片上可以騰出不少空間實現複雜的電路、增加處理器核、擴大高速快取的容量。這些手段都推進了處理器的發展。
缺點 動態編譯不可避免地有如下缺點,在設計動態編譯器時必須儘可能地減少這些因素對動態編譯器正確性和性能的影響。
(1)占用額外的運行時間:動態編譯,顧名思義就是在運行時進行編譯,很顯然動態編譯會占用額外的運行時間。
(2)占用額外的記憶體資源:動態編譯的結果必須存儲在記憶體中才能被執行(除非完全採用解釋執行),這就占用了機器額外的記憶體資源。
(3)初次執行的代碼效率很低:動態編譯器在第一次遇到代碼時,必須先翻譯後執行,因此會造成性能的低下。通常一個程式剛被啟動是動態編譯器效率最低的時候。
(4)動態編譯程式的調試很困難:由於動態編譯器編譯後代碼的地址同源程式中的地址是不同的。因此調試起來必須注意譯前譯後地址的轉換,稍有不慎就容易引起調試失敗。
動態編譯器的設計 動態編譯器的設計思想和靜態編譯器有較大的不同。它沒有靜態編譯器那樣複雜的編譯過程,但是它在編譯方式、模擬方式、編譯範圍、體系結構的選擇方面比靜態編譯器靈活、複雜。
出於性能的考慮,動態編譯器的設計架構不僅要考慮編譯代碼的正確性,還要同時關注代碼的執行性能。因此在設計中必須為將來可能的性能最佳化留出接口,數據結構的組織和通用算法的安排都需要把性能因素考慮在內。
設計難點 儘管動態編譯器有許多優點,設計上也有相當大的彈性。然而成功設計一個有實際價值的動態編譯器還面臨著相當大的難題。縱觀現有的動態編譯器,主要難點如下:
(1)自修改代碼:
Henry Massalin 是自修改代碼之父。他最早發現並指出了自修改代碼對Java 虛擬器的影響。同樣,自修改代碼對動態編譯器也是一個棘手的問題。所謂自修改代碼就是在程式運行過程中出於某種目的能夠修改自身代碼的代碼。這種目的可能是運行時生成代碼,也可能是子函式調用的回補,甚至可能是最佳化某個與狀態無關的循環。以Windows 系統為例,有一個段暫存器同時包含了數據和代碼,它往往就是自修改代碼產生的根源。由於數據段、代碼段和堆疊段的屬性各不相同,數據段可讀可寫,代碼段可讀可執行,堆疊段可讀可寫可執行,因此自修改代碼利用不同段的特性修改自身代碼從而達到需要的效果。
動態編譯器必須能夠對作業系統載入和卸載的不同程式進行翻譯。當源程式中含有自修改代碼時,它不可避免地在滿足某些條件時(往往是執行了某條指令後)修改自身的代碼。對動態編譯器而言,這是十分危險的動作。動態編譯器不僅應該知道代碼已經被修改,還必須知道是哪些代碼被修改了。無論是哪種情況,都必須對翻譯後的相應目標代碼作無效處理。由於動態編譯器沒有作業系統的功能,因此自修改代碼的執行並不會通知動態編譯器,所以任何一個能處理自修改代碼的動態編譯器都得主動檢查段屬性,從而發現自修改代碼,進行相應的處理。
(2)精確異常:
在一個以最佳化性能為主要目的的流水線中(或者是用於指令並行執行的設計中),系統的順序執行只是一種抽象。如果硬體不是設計得特別聰明,中斷使我們看到程式不是順序執行的。當一個異常發生,系統的順序執行被中斷時,將會有幾條指令處於流水線的不同階段。因為我們不想中斷處理破壞程式的正常執行,對於沒有執行完的指令,我們必需記住它們執行到哪一個階段,以便在中斷處理之後能恢復程式執行。
如果處理器是精確異常的,那么異常的軟體處理就會很簡單。對於一個精確異常的處理器,在發生異常時,我們都會有一個引起異常的指令。該指令前面的所有指令都以執行完,該指令以及該指令以後的指令都不會有任何軟體值得考慮的負作用。所以軟體作異常處理時,可以完全忽略指令的亂序執行。
異常發生的順序與指令的順序相同。在非流水線的處理器上,這是顯而易見的。然而在流水線的處理器上,異常可能會發生在指令執行的不同階段,從而產生潛在的問題。比如,如果一個讀記憶體指令產生一個地址異常,這個異常一直要到讀寫記憶體階段才產生。如果它的後一條指令在取指階段就產生錯誤,則後一條指令會想產生異常,從而破壞異常發生的順序跟指令的順序相同這個約定。為了避免這個問題,被發現的異常情況一直要到確認有異常情況的指令的前面的所有指令都不產生異常時才產生異常。在發現異常情況時,該情況只是被記下來沿著流水線傳遞下去直到某一級。如果在這個過程中以前的指令產生的異常被發現,該異常情況僅僅被簡單的忽略掉,從而解決了整個問題。
當然對動態編譯器來說,頁錯誤、異步異常之類的計時器中斷會提供一個當前機器執行的一致狀態,得到這個一直狀態之後,動態編譯器可以模擬源體系結構上異常處理器的行為,從而在目標體系結構上精確重現這個異常。
(3)地址轉換:
把一個虛地址翻譯成物理地址時,處理器必須先得出虛頁號和偏移量。處理器使用虛頁號作為檢索進程頁表記錄的索引。如果對應那偏移量的頁表記錄是有效的,處理器就從中拿出物理頁號。如果記錄是無效的,表明進程想存取一個不在物理記憶體中的地址。在這種情況下,處理器不能翻譯這個虛地址,而必須把控制權傳給作業系統,讓它處理。噹噹前進程試圖存取一個處理器無法翻譯的虛地址時,處理器引發一個“頁錯誤”,並將產生頁錯誤的虛地址和原因告訴給作業系統。假設找到的是一有效的頁表記錄,處理器就取出物理頁號並且乘以頁的大小,得到記憶體中頁的基地址。最後,處理器加上偏移量得到它需要指令或數據的地址。
可見,動態編譯器必須能夠處理這種情況:當指令計算出的虛擬地址所映射的物理地址並不是真正含有某個正確值的地址,或者當前進程試圖存取一個處理器無法
翻譯的虛地址導致處理器引發一個“頁錯誤”。當然,還可能有更複雜的情況,比如,
在記憶體映射的輸入輸出中, 某些地址是不可快取的。無論哪種情況,動態編譯器必
須首先將其識別,然後採用某種機制加以解決。這是動態編譯器不得不面對的問題。
(4)自引用代碼
自引用代碼把指令流作為數據進行處理,其典型套用就是計算自身的校驗和,從而保證程式沒有因為某些原因被損壞。動態編譯器必須能夠讓自引用代碼正確運行。一般我們通過在源地址空間中保留一段翻譯後的自引用代碼來解決這個問題。現在幾乎所有的動態編譯器都能很好地處理這個問題。
(5)翻譯高速快取的管理
把經常使用的翻譯結果保存在記憶體里可以獲得更加高的效率。這塊特別的記憶體區域被命名為翻譯高速快取,它允許代碼融合軟體的重新使用,並消除冗餘的代碼。在遇到了以前曾經翻譯的源指令順序,代碼冗餘軟體會忽略翻譯的進程並直接執行在翻譯快取的快取翻譯結果。
由於翻譯高速快取的大小是有限的,不可能把所有的翻譯結果都放在翻譯高速快取中。當翻譯高速快取全部用完之後,新翻譯的結果就必須逐步替換以前翻譯的結果。同樣,如果以前翻譯的結果由於某些原因,如自修改代碼,必須被無效的時候,翻譯高速快取也必須體現出這個變化。
翻譯高速快取快取和重新使用翻譯在日常的工作中會高度重複出現。代碼冗餘軟體使用在翻譯快取和最佳化的單個翻譯來匹配重複執行的工作,這樣在最少的開銷下獲得了全速。早期翻譯的花費被後面重複的執行分次還清了。
翻譯高速快取的替換策略也是多種多樣。總的思想還是只有兩種。一是翻譯高速快取用完以後對其中存儲的內容全部替換,二是用垃圾回收(garbage collection)算法維護高速快取的正確性,當然垃圾回收算法有各種變形,不同的動態編譯器根據實際需要選擇合適的垃圾回收算法。
設計架構 動態編譯器是連線兩種體系結構的編譯器,讓其中一個體系結構上的二進制代碼經過翻譯以後運行在另一個體系結構上,所以動態編譯器中必然包含兩種不同的體系結構。其中被翻譯的那個體系結構成為源體系結構(source architecture),在這個體系結構上編譯的程式是動態編譯器的輸入;而在底層實際運行,使得二進制代碼能夠正確運行的那個體系結構,我們稱之為目標體系結構(target architecture)。圖表述了動態編譯器的運行環境。
動態編譯器的運行環境 根據程式的局部性原理,即程式在執行時所呈現的局部性規律,即在一段較短時間內,程式的執行僅限於某個部分。相應地,它所訪問的存儲器空間也局限在某個空間。當硬體發展的速度大大加快的時候,動態編譯器的最佳化機會也相應增加,所以我們可以利用反饋指導的最佳化思想識別出執行最頻繁的代碼,並對這些頻繁代碼進行特殊的最佳化。
這樣一來,動態編譯器的基本結構就很明朗了。我們把動態編譯器的編譯過程分為兩個階段。第一個階段是普通代碼,只需要逐條翻譯,在翻譯過程中記錄下代碼的執行頻率,也就是將來可能成為頻繁代碼的代碼段;第二個階段是頻繁代碼,也就是我們準備充分最佳化的代碼。在這兩個階段以外的工作,我們可以放到編譯動態編譯器的過程中完成,這就完全避免了運行時可能造成的代價了。