關於跨平臺的一些認識

eleven_yw發表於2017-09-05

  前段時間看了 周志明的那本 《深入理解java虛擬機器》。對於平臺無關性問題,有了一些新的認識。所以特寫一篇部落格來進行總結。

  這是我的第一篇不針對具體技術,而只針對計算機系統和原理的部落格文章,而這種話題,總是比較寬泛,而我本人的水平有限,所以我也只能泛泛的寫寫,思考的不對的地方,還望讀者不吝批評。

 C為什麼不能跨平臺

  我們們先來討論一下,C語言的執行過程,從而搞清楚為什麼C語言不能跨平臺。

// www.yaoxiaowen.com
#include <stdio.h>
int main()
{
    printf("Hello, World!");
    return 0;
}

  以上就是廣大人民群眾 都知道的hello world程式。最終執行的結果 就是在console上輸出一行字串, hello world!。

大學時,譚浩強的C語言教材,main方法的返回值是void,但這是錯誤的。實質上應該返回int來告訴作業系統執行結果。(當然,後來學習的深入了,才知道譚的教材有不少錯誤的地方,但是我對這本教材依舊印象很好,因為那是我程式設計的啟蒙教材)。

  我們知道,計算機只認識0和1(就是二進位制),換句話說,不管我們在計算機上幹了什麼事情,執行了多麼複雜的程式,從ps繪圖,到qq聊天,再到聽音樂,最終到了CPU的執行層面,其實就是 一串串的0和1組成的指令罷了。 當然,到了硬體層面,那就是與或非門的領域了。但是,上面的那個 hello world程式是怎麼轉換為0和1的呢。

  一般情況下,對於我們使用的是 IDE,比如 Visual Studio, CodeBlocks之類的,就是點選個執行按鈕那麼簡單,或者你就是使用了gcc命令列來進行編譯,也可以一行命令 gcc -o hello hello.c,就 輸出了最後的編譯結果。但是實際上,hello world的編譯過程是這樣的:

c語言編譯過程

  我們分階段來討論:

  • 預處理階段。前處理器(cpp)來把 程式碼中#開頭的行進行展開, 比如標頭檔案,巨集等內容,修改最初的C檔案。
  • 編譯階段。編譯器(ccl)將修改後的C檔案,翻譯成了 另一文字檔案,hello.s,這就是我們所說的彙編程式了。 開啟這個文字檔案內容 類似下面的格式:
  // www.yaoxiaowen.com
     section .data
 msg     db      'Hello, world!',0xA
 len     equ     $-msg
 
     section .text
 global  _start
 _start:
         mov     edx,len
         mov     ecx,msg
         mov     ebx,1
         mov     eax,4
         int     0x80
 
         mov     ebx,0
         mov     eax,1
         int     0x80

當然,不同CPU和平臺環境,編譯輸出的彙編程式碼也不同,我們這裡僅作為示例。

  • 彙編階段。彙編器(as)將hello.s翻譯成機器語言指令。 把這些命令打包成一種叫做可重定位目標程式(relocatable object program)的格式,此時的輸出格式就是hello.o了。這其實就是二進位制檔案了。
  • 連結階段。編譯過程最後還有一個連結階段(程式呼叫了 printf函式),最後的輸出結果還是和上一步類似,都是直接二進位制檔案。

  瞭解了 hello world程式的編譯過程,我們就來討論一下,什麼是彙編程式。我們先來看一下維基百科上的定義。(那個維基百科的連結,沒fan牆的同學應該是打不開的)。

組合語言(英語:assembly language)是一種用於電子計算機、微處理器、微控制器,或其他可程式設計器件的低階語言。在不同的裝置中,組合語言對應著不同的機器語言指令集。一種組合語言專用於某種計算機系統結構,而不像許多高階語言,可以在不同系統平臺之間移植。

  什麼是彙編,相關專業的同學應該都明白,因為計算機只認識0和1(就是二進位制),所以在計算機剛開始發明時,那些科學家們就是直接 向計算機輸入0和1,來執行計算任務的。(當然,他們是通過穿孔紙帶的方式來向計算機輸入, 比如有孔代表1,沒孔代表0)。通過這樣的方式,計算機終於能執行了,但是這樣的效率實在太慢了。

  而在他們輸入的0和1中,有些代表的是指令,這些是有固定含義和編碼的。也是晶片能識別的。而另一些是資料。這些不同的程式的資料自然是不同的。我們前面就說,不管多麼複雜的計算機操作,到了cpu級別都是0和1,資料雖然多變,但是 指令的數量是有限的。因為 指令是要被晶片固定識別的。晶片中要用 電晶體(最初是電子管)組成的與或非門組合來識別這些指令和資料。因為直接輸入0和1,實在太繁瑣了,所以他們就發明了組合語言。來簡化 程式的編寫。

  比如 計算 1+1,兩個 資料1都 使用 0x0001 來表示,而 加操作,放在cpu中,可以是 0xa90df(這個是胡亂寫的),這個二進位制代表的加操作能被計算機識別。而因為這個加操作對於cpu來說,編碼的0xa90df格式是固定的。所以可以直接一個助記符add來表示,這樣科學家們寫程式就方便多了,而這就是彙編程式的由來。因為彙編程式完成之後,可以再有一個專門的程式(就是要上文中所說的彙編器)來把編寫的彙編程式編譯成0和1.這樣計算機也可以識別了,而組合語言本身也方便了程式的編寫和閱讀。

  編寫彙編比直接編寫二進位制方便高效了太多。但是 隨著計算任務的複雜,程式的規模越來越龐大,使用匯程式設計序也很累啊,那麼是否有更簡單的方式呢?所以科學家們發明了高階語言(比如 C,lisp等),在編寫程式的時候,使用C語言等編寫,然後再使用 編譯器將C語言程式翻譯成彙編程式,彙編程式再使用匯編器編譯成0和1,這樣,cpu能識別的東西沒有變化,但是對於編寫程式的人,確實方便了很多。

  通過以上的描述,我們就知道了高階語言的大概由來。也明白了我們所編寫的各種高階語言,到了最後,其實都是轉化為二進位制執行。

  而直接二進位制格式的程式,我們稱之為本地機器碼(native code)。而類似那些 add之類的 助記符,以及彙編的編寫格式或標準,我們稱之為 指令集。

  但是問題的關鍵來了。不同公司所生產的 cpu晶片。他們所使用的指令集不同啊, 這種晶片設計的事情,又不像TCP/IP協議那樣,有國際統一的標準,甚至像intel所代表的複雜指令集,和arm為代表的精簡指令集,它們指令集的設計思路就是不一樣的。

  所以 我們C語言最後編譯出來的的二進位制檔案,假設是這段93034030930900090222ab2d11cd22dfad(隨便寫的),不同的cpu上識別的意義是不同的。

  所以為什麼說C語言不能實現跨平臺執行,就是因為它編譯出來的 輸出檔案的格式,只適用於某種cpu,其他cpu不認識啊。

  • 我們所說的跨平臺執行,並不是指hell.c這個文字檔案的執行。因為文字檔案本身也沒辦法執行。執行的只是它的編譯結果hello,而這個由0和1組成的編譯結果,不同的cpu和平臺,他們的格式不同。所以C語言編譯出來的結果,沒辦法跨平臺執行。
  • 甚至在不同的平臺下,hello.c最後所編譯出來的檔案的格式都不同。比如linux下編譯出的hello,window下編譯結果是hello.exe,而mac下編譯結果是hello.out,(至於微控制器上編譯結果的字尾是啥子,這個忘記了)。
  • 也有些人會講,為了讓linux下編寫的一段hello程式執行在window上,我不拿最後的編譯結果hello來直接執行,我在window環境下重新用IDE建立專案,同樣的原始碼在window下重新執行一遍,輸出hello.exe,再在window上執行,行不行啊?這個答案是No。因為不同環境下,c語言的標準有差別。例如 int型別,在有的平臺上 可能16位表示,而有的平臺上則是32位表示。所以不同環境下的同一個程式,會存在資料溢位之類的錯誤。
  • 其實還有一點,大家平時寫個程式,IDE上點選個run/build之類的,稍等一會就輸出結果了,但是實際上,很多大型程式的編譯過程是比較長的,比如我第一家公司做手機系統的,編譯一個Android5.0的系統rom,在i7 cpu,16G記憶體的電腦上,需要編譯執行一個多小時,才能編譯成功輸出最後的結果。

  知道了 C語言不能跨平臺執行,那有沒有一種辦法,能夠 讓高階語言實現跨平臺的執行呢?

  思考實際程式設計中的一個場景,我們前端需要處理的某個資料是A格式,但是後臺只能提供B格式的資料,那我們怎麼辦?很簡單啊。寫個介面,把B格式轉化為A格式不就行了嘛。這就是設計模式當中的介面卡設計模式。

  關於跨平臺也是一樣的道理。cpu的指令集不同, 不同平臺編譯出來的結果格式都不同,那麼我們可以在各個平臺上執行虛擬機器,然後我們制定某種編譯結果的輸出格式,我們的輸出了某種格式的結果,直接在虛擬機器上執行。這樣不就ok了嘛。。

  這其實就是 java採取的方式。

 Class檔案格式,虛擬機器以及 ByteCode

  這是java版本的helloJava;

//www.yaoxiaowen.com
public static void main(String[] args) {
    System.out.println("helloJava");
}

  這段java程式編譯出來的結果是 helloJava.class,換句話說,它輸出的結果是Class檔案格式(也叫位元組碼儲存格式)。

  class檔案的內容大概就像下面那樣:

class檔案二進位制格式

  是不是看不懂?看不懂就對了。這其實就是java虛擬機器定義的二進位制格式,這種我們稱之為 位元組碼(ByteCode),是java虛擬機器所能執行的格式。類似本地機器碼可以反編譯成彙編,這種二進位制也可以反編譯成更容易閱讀的格式。

  類似下面這樣。

位元組碼示例

  而各個平臺的java虛擬機器 是不同的。但是我們編寫的java程式 統一編譯成特定格式的 Class檔案格式,然後Class檔案可以在各個不同平臺的java虛擬機器上執行,當然執行結果肯定也是一致的,至於各個不同平臺之間的差異,這是那些編寫java虛擬機器的人去考慮的事情,我們這些做java的程式設計師,不用去關心這個問題。

  通過這種方式,我們的java程式就實現了跨平臺。

  所以java也被稱為 中介軟體技術語言。意思就是 中間加一層過度。很好理解。(當然,維基百科上對中介軟體技術的解釋,基本把我看暈了,也和java沒關係,不過大家理解這個意思就好)。

 平臺無關性

  而通過java虛擬機器和Class檔案格式,我們就實現了平臺無關性,換句話說,這些適應各個不同平臺和cpu的工作的還是要有人乾的。那就是設計java虛擬機器的人去做這些工作,但是他們的辛苦換來了我們上層程式設計師的輕鬆。我們就完全不關心各個平臺和cpu的差異了。

  程式碼編譯的結果,從本地機器碼(NativeCode)向位元組碼(ByteCode)的轉變,是儲存格式的一小步,卻是程式語言發展的一大步

雖然說名字是 ByteCode,但是我覺的,其實和 NaticeCode 都差不多,反正都是定義了一套指令集,只是前者能被虛擬機器執行引擎去執行,而 後者能被物理機的CPU去執行罷了。

  知道了大概的原理,我們就思考另一個問題,java虛擬機器去執行Class檔案,那和java的原始檔 有什麼關係呢。答案是 沒關係。換句話說,java的原始檔編譯的輸出結果為Class檔案,而Class檔案能被java虛擬機器認識,並執行,這是兩個獨立的過程,中間也沒啥關係和必然性。

  那麼進而引申出另一個問題,某一種其他程式語言,如果我設計出了一種對應的編譯器,將其編譯輸出結果為Class檔案,那這樣該語言豈不是也實現了跨平臺了?

  想到這一點,那麼恭喜你,你發現了 java虛擬機器的另一種 重要特性。語言無關性

 語言無關性

  java虛擬機器在執行Class檔案時,不知道也完全不關心這個Class檔案是咋來的。(這個Class檔案可以是任何一種語言的原始檔編譯而來,當然,就像直接編寫彙編一樣,你直接編寫 ByteCode也行,只要格式正確)。
其實CPU在執行二進位制的指令時,它不知道也完全不關心這些指令流是咋來的。這都是同一個道理。

  很多程式設計師都還認為Java虛擬機器執行Java程式是一件理所當然和天經地義的事情。這是錯誤的。

  下面某些內容 援引 周志明的 《深入理解java虛擬機器》:

Sun的開發設計團隊在最初設計的時候就把Java的規範拆分成了Java語言規範《The Java Language Specification》及Java虛擬機器規範《The Java Virtual Machine Specification》。

並且在1997年釋出的第一版Java虛擬機器規範中就曾經承諾過:“In the future,we will consider bounded extensions to the Java virtual machine to provide better support for other languages”(在未來,我們會對Java虛擬機器進行適當的擴充套件,以便更好地支援其他語言執行於JVM之上)。

jvm的語言無關性

實現語言無關性的基礎仍然是虛擬機器和位元組碼儲存格式。Java虛擬機器不和包括Java在內的任何語言繫結,它只與“Class檔案”這種特定的二進位制檔案格式所關聯,Class檔案中包含了Java虛擬機器指令集和符號表以及若干其他輔助資訊。基於安全方面的考慮,Java虛擬機器規範要求在Class檔案中使用許多強制性的語法和結構化約束,但任何一門功能性語言都可以表示為一個能被Java虛擬機器所接受的有效的Class檔案。作為一個通用、機器無關的執行平臺,任何其他語言的實現者都可以將Java虛擬機器作為語言的產品交付媒介。

  換句話說,java虛擬機器這個名字其實只是一個誤導,java虛擬機器和java沒啥關係,其實更應該叫做 Class檔案虛擬機器。

  因為其他語言, 只要有對應的編譯器,輸出結果就可以執行在java虛擬機器上,所以時至今日,湧現Clojure、Groovy、JRuby、Jython、Scala一批執行在java虛擬機器上的語言。

  目前下圖中的語言都已經可以執行在java虛擬機器上。

jvm能執行的語言

  所以廣義上的java技術體系,也包括Clojure、JRuby、Groovy,Scale等執行於Java虛擬機器上的語言及其相關的程式。

  Java,Scale等各種語言中的各種變數、關鍵字和運算子號的語義最終都是由多條位元組碼命令組合而成的,因此位元組碼命令所能提供的語義描述能力肯定會比單一語言本身更加強大。

  當然,在java最初剛出現的時候,Write Once,Run Anywhere, 這種 平臺無關性被吹噓的比較厲害,但是現在 這種虛擬機器的思想,被很多其他語言也學會了,比如python和pvm。go語言,.NET等都是同樣的思想。

 為什麼C/C++沒有被替代。

  關於java虛擬機器和Class檔案格式, 貌似很厲害的樣子,什麼 個人一小步,人類一大步都扯上了,那肯定有人疑問,為什麼 c/c++這些不能跨平臺的語言,還現在還被很多人使用,還沒被java取代呢。

  當然,這個原因有很多,比如java的gc過程所無法避免的stop the world過程,這在 某些實時性要求比較高的 系統中,比如 股票交易系統,軍事系統,是不可接受的。(關於垃圾回收這是另一個話題,不在本文範圍內,未來有時間可以花時間另寫部落格討論這個問題)。

  不過有句話說的很好

  java和c++之間有一堵由動態記憶體分配和垃圾收集技術所圍成的'高牆',牆外的人想進去,牆內的人想出來

  另外,對於直接與硬體互動的事情,也只能靠C語言了。畢竟上層再怎麼發展,硬體與系統之間永遠要存在一個驅動層啊。

  但是除了以上這些,還有一個原因。給大家講講軟體歷史上的一個重大教訓,大家也許就明白了。

  當年為了對抗sun的java平臺,微軟2002年推出了類似中介軟體思想的.NET平臺(C#)。當時window xp一統江湖,讓微軟如日中天,不可一世,微軟在下一代作業系統(就是window visa)的開發中,決定使用 C#, 雖然微軟牛逼哄哄,擁有最牛逼的程式設計師,最頂尖的科學家,但是開發到最後他們發現,使用C#這種執行在虛擬機器上的中介軟體語言,無論如何也達不到 C/C++語言的速度。所以最後悲劇的 window visa,全部推倒重來,重新開發。當時李開復在微軟,他的一本書中對此有詳細介紹。

  當然,當年window visa專案的失敗,還有其他一些原因,比如 使用資料庫系統代替檔案系統,驅動不相容等, 但是 使用.NET來進行開發,起碼也是失敗的主要原因之一。

  所以現在大家明白了,ByteCode執行在虛擬機器上,相比於直接編譯成 NativeCode 執行在物理機上,速度較慢。

  現在隨著虛擬機器執行時優化技術的發展,以及硬體的速度越來越快,所以它們速度之間的差異,也沒之前差距那麼大了。

  實質上,Class檔案在虛擬機器上執行的時候,還會有很多的優化措施。

在部分的商用虛擬機器中,Java程式最初是通過直譯器(Interpreter)進行解釋執行的,當虛擬機器發現某個方法或程式碼塊的執行特別頻繁時,就會把這些程式碼認定為“熱點程式碼”(Hot Spot Code)。為了提高熱點程式碼的執行效率,在執行時,虛擬機器將會把這些程式碼編譯成與本地平臺相關的機器碼,並進行各種層次的優化,完成這個任務的編譯器稱為即時編譯器(Just In Time Compiler,簡稱JIT編譯器)。

許多主流的商用虛擬機器都同時包含直譯器與編譯器。直譯器與編譯器兩者各有優勢:當程式需要迅速啟動和執行的時候,直譯器可以首先發揮作用,省去編譯的時間,立即執行。在程式執行後,隨著時間的推移,編譯器逐漸發揮作用,把越來越多的程式碼編譯成原生程式碼之後,可以獲取更高的執行效率。當程式執行環境中記憶體資源限制較大(如部分嵌入式系統中),可以使用解釋執行節約記憶體,反之可以使用編譯執行來提升效率。

  但是實際上,編譯器可以把java原始檔的輸出結果編譯成Class格式(也就是 ByteCode),那自然也可以有其他型別的編譯器 可以直接將java原始檔編譯為NativeCode啊。所以對於程式語言來說,我們可以有各種方式來編譯它,Java語言的“編譯期”其實是一段“不確定”的操作過程。因為我們可以使用不同型別的編譯器編譯出不同的輸出結果。

  java常見的編譯器有以下型別。

  • 前端編譯器:把.java檔案轉變成.class檔案。比如Sun的Javac、Eclipse JDT中的增量式編譯器(ECJ)。
  • JIT編譯器:位元組碼(ByteCode)轉變成機器碼(NaticeCode)。比如HotSpot VM的C1、C2編譯器。
  • AOT編譯器:直接把*.java檔案編譯成本地機器程式碼。 比如GNU Compiler for the Java(GCJ)、Excelsior JET。

 結束

  所以討論到最後,大家就已經明白,所以平臺無關性,與 編譯器與編譯輸出結果格式 的關係。花了一天時間寫了這麼多內容,也希望給大家帶來一些啟發。

在本篇部落格當中,很多內容也並不是精確的分析,比如某些概念,都說的比較模糊,因為我們這片部落格只是討論思想。很多概念和過程也都沒有去深究, 如有錯誤不準確的地方,歡迎指正。

相關文章