自拍偷在线精品自拍偷,亚洲欧美中文日韩v在线观看不卡

大前端開(kāi)發(fā)者需要了解的基礎(chǔ)編譯原理和語(yǔ)言知識(shí)

開(kāi)發(fā) 前端
這篇文章并非專門解答一些問(wèn)題,而是希望通過(guò)介紹一些通用的概念,幫助讀者掌握分析問(wèn)題的能力,如果這個(gè)概念在實(shí)際編程中用得到,我也會(huì)舉一些具體的例子。

在我剛剛進(jìn)入大學(xué),從零開(kāi)始學(xué)習(xí) C 語(yǔ)言的時(shí)候,我就不斷的從學(xué)長(zhǎng)的口中聽(tīng)到一個(gè)又一個(gè)語(yǔ)言,比如 C++、Java、Python、JavaScript 這些大眾的,也有 Lisp、Perl、Ruby 這些相對(duì)小眾的。一般來(lái)說(shuō),當(dāng)程序員討論一門語(yǔ)言的時(shí)候,默認(rèn)的上下文經(jīng)常是:“用 xxx 語(yǔ)言來(lái)完成 xxx 任務(wù)”。所以一直困擾著的我的一個(gè)問(wèn)題就是,為什么完成某個(gè)任務(wù),一定要選擇特定的語(yǔ)言,比如安卓開(kāi)發(fā)是 Java,前端要用 JavaScript,iOS 開(kāi)發(fā)使用 Objective-C 或者 Swift。這些問(wèn)題的答案非常復(fù)雜,有的是技術(shù)原因,有的是歷史原因,有的會(huì)考慮成本,很難得出統(tǒng)一的結(jié)論,只能 case-by-case 的分析。這篇文章并非專門解答上述問(wèn)題,而是希望通過(guò)介紹一些通用的概念,幫助讀者掌握分析問(wèn)題的能力,如果這個(gè)概念在實(shí)際編程中用得到,我也會(huì)舉一些具體的例子。

在閱讀本文前,不妨思考一下這幾個(gè)問(wèn)題,如果沒(méi)有頭緒,建議看完文章以后再思考一遍。如果覺(jué)得答案顯而易見(jiàn),恭喜你,這篇文章并非為你準(zhǔn)備的:

  1. 什么是編譯器,它以什么為分界線,分為前端和后端?
  2. Java 是編譯型語(yǔ)言還是解釋型語(yǔ)言,Python 呢?
  3. C 語(yǔ)言的編譯器也是 C 語(yǔ)言,那它怎么被編譯的?
  4. 目標(biāo)文件的格式是什么樣的,段表、符號(hào)表、重定位表有什么作用?
  5. Swift 是靜態(tài)語(yǔ)言,為什么還有運(yùn)行時(shí)庫(kù)?
  6. 什么是 ABI,ABI 不穩(wěn)定有什么問(wèn)題?
  7. 什么是 WebAssembly,為什么要推出這門技術(shù),用 C++ 代替 JavaScript 可行么?
  8. JavaScript 和 DOM API 是什么關(guān)系,JavaScript 可以讀寫(xiě)文件么?
  9. C++ 代碼可以自動(dòng)轉(zhuǎn)換成 Java 代碼么,任意兩種語(yǔ)言是否可以互轉(zhuǎn)?
  10. 為什么說(shuō) Python 是膠水語(yǔ)言,它可以用來(lái)開(kāi)發(fā) iOS/Android 么?

編譯原理

就像數(shù)學(xué)是一個(gè)公理體系,從簡(jiǎn)單的公理就能推導(dǎo)出各種高階公式一樣,我們從最基本的 C 語(yǔ)言和編譯說(shuō)起。

  1. int main(void) { 
  2.     int a = strlen("Hello world");  // 字符串的長(zhǎng)度是 11 
  3.     return 0; 

相關(guān)的介紹編譯過(guò)程的文章很多,讀者應(yīng)該都非常熟悉了,整個(gè)流程包括預(yù)處理、詞法分析、語(yǔ)法分析、生成中間代碼,生成目標(biāo)代碼,匯編,鏈接 等。已有的文章大多分析了每一步的邏輯,但很少談實(shí)現(xiàn)思路,我會(huì)盡量用簡(jiǎn)單的語(yǔ)言來(lái)描述每一步的實(shí)現(xiàn)思路,相信這樣有助于加深記憶。由于主要談的概念和思路,難免會(huì)有一些不夠準(zhǔn)確的抽象,讀者學(xué)會(huì)抓重點(diǎn)就行。

預(yù)處理是一個(gè)獨(dú)立的模塊,它放在***介紹,我們先看詞法分析。

詞法分析

***登場(chǎng)的是編譯器,它負(fù)責(zé)前五個(gè)步驟,也就是說(shuō)編譯器的輸入是源代碼,輸出是中間代碼。

編譯器不能像人一樣,一眼就看明白源代碼的內(nèi)容,它只能比較傻的逐個(gè)單詞分析。詞法分析要做的就是把源代碼分割開(kāi),形成若干個(gè)單詞。這個(gè)過(guò)程并不像想象的那么簡(jiǎn)單。比如舉幾個(gè)例子:

  1. int t 表示一個(gè)整數(shù),而 intt 只是一個(gè)變量名。
  2. int a() 表示一個(gè)函數(shù)而非整數(shù) a,int a () 也是一個(gè)函數(shù)。
  3. a = 沒(méi)有具體價(jià)值,它可以是一個(gè)賦值語(yǔ)句,還可以是 a == 1 的前綴,表示一個(gè)判斷。

詞法分析的主要難點(diǎn)在于,前綴無(wú)法決定一個(gè)完整字符串的含義,通常需要看完整句以后才知道每個(gè)單詞的具體含義。同時(shí),C 語(yǔ)言的語(yǔ)法也不簡(jiǎn)單,各種關(guān)鍵字,括號(hào),逗號(hào),語(yǔ)法等等都會(huì)給詞法分析的實(shí)現(xiàn)增加難度。

詞法分析的主要實(shí)現(xiàn)原理是狀態(tài)機(jī),它逐個(gè)讀取字符,然后根據(jù)讀到的字符的特點(diǎn)轉(zhuǎn)換狀態(tài)。比如這是 GCC 的詞法分析狀態(tài)機(jī)(引用自《編譯系統(tǒng)透視》):   

 

如果自己實(shí)現(xiàn)的話,思路也不難。外面包一個(gè)循環(huán),然后各種 switch...case 就完事了。詞法分析應(yīng)該算是最簡(jiǎn)單的一節(jié)。

語(yǔ)法分析

經(jīng)過(guò)詞法分析以后,編譯器已經(jīng)知道了每個(gè)單詞,但這些單詞組合起來(lái)表示的語(yǔ)法還不清楚。一個(gè)簡(jiǎn)單的思路是模板匹配,比如有這樣的語(yǔ)句:

  1. int a = 10; 

它其實(shí)表示了這么一種通用的語(yǔ)法格式:

類型 變量名 = 常量;

所以 int a = 10; 當(dāng)然可以匹配上這種模式。同理,它不可能匹配 類型 函數(shù)名(參數(shù)); 這種函數(shù)定義模式,因?yàn)閮烧呓Y(jié)構(gòu)不一致,等號(hào)無(wú)法被匹配。

語(yǔ)法分析比詞法分析更復(fù)雜,因?yàn)樗?C 語(yǔ)言支持的語(yǔ)法特性都必須被語(yǔ)法分析器正確的匹配,這個(gè)難度比純新手學(xué)習(xí) C 語(yǔ)言語(yǔ)法難上很多倍。不過(guò)這個(gè)屬于業(yè)務(wù)復(fù)雜性,無(wú)論采用哪種解決方案都不可避免,因?yàn)檎Z(yǔ)法規(guī)則的數(shù)量就是這么多。

在匹配模式的時(shí)候,另一個(gè)問(wèn)題在于上述的名詞,比如 類型、參數(shù),很難界定。比如int 是類型,long long 也是類型,unsigned long long 也是類型。(int a) 可以是參數(shù),(int a, int b) 也是參數(shù),(unsigned long long a, long long double b, int *p) 看起來(lái)能把人逼瘋。

下面舉一個(gè)簡(jiǎn)單的例子來(lái)解釋 int a = 10 是如何被解析的,總的思路是歸納與分解。我們把一個(gè)復(fù)雜的式子分割成若干部分,然后分析各個(gè)部分,這樣可以簡(jiǎn)化復(fù)雜度。對(duì)于 int a = 10 來(lái)說(shuō),他是一個(gè)聲明,聲明由兩部分組成,分別是聲明說(shuō)明符和初始聲明符列表。

聲明 聲明說(shuō)明符 初始聲明符列表
int a = 10 int a = 10
int fun(int a) int fun(int a)
int array[5] int array[5]

聲明說(shuō)明符比較簡(jiǎn)單,它其實(shí)是若干個(gè)類型的串聯(lián):

聲明說(shuō)明符 = 類型 + 類型的數(shù)組(長(zhǎng)度可以為 0)

而且我們知道若干個(gè)類型連在一起又變成了聲明說(shuō)明符,所以上述等式等價(jià)于:

聲明說(shuō)明符 = 類型 + 聲明說(shuō)明符(可選)

再嚴(yán)謹(jǐn)一些,聲明說(shuō)明符還可以包括 const 這樣的限定說(shuō)明符,inline 這樣的函數(shù)說(shuō)明符,和 _Alignas 這樣的對(duì)齊說(shuō)明符。借用書(shū)中的公式,它的完整表達(dá)如下:

 

這才僅僅是聲明語(yǔ)句中最簡(jiǎn)單的聲明說(shuō)明符,僅僅是幾個(gè)類型和關(guān)鍵字的組合而已。后面的初始聲明符列表的解析更復(fù)雜。如果有能力做完這些解析,恭喜你,成功的解析了聲明語(yǔ)句。你會(huì)發(fā)現(xiàn)什么定義語(yǔ)句啦,調(diào)用語(yǔ)句啦,正嫵媚的向你招手╮(╯▽╰)╭。

成功解析語(yǔ)法以后,我們會(huì)得到抽象語(yǔ)法樹(shù)(AST: Abstract Syntax Tree)。以這段代碼為例:

  1. int fun(int a, int b) { 
  2.     int c = 0; 
  3.     c = a + b; 
  4.     return c; 

它的語(yǔ)法樹(shù)如下: 

 

語(yǔ)法樹(shù)將字符串格式的源代碼轉(zhuǎn)化為樹(shù)狀的數(shù)據(jù)結(jié)構(gòu),更容易被計(jì)算機(jī)理解和處理。但它距離中間代碼還有一定的距離。

生成中間代碼

以 GCC 為例,生成中間代碼可以分為三個(gè)步驟:

  1. 語(yǔ)法樹(shù)轉(zhuǎn)高端 gimple
  2. 高端 gimple 轉(zhuǎn)低端 gimple
  3. 低端 gimple 經(jīng)過(guò) cfa 轉(zhuǎn) ssa 再轉(zhuǎn)中間代碼

簡(jiǎn)單的介紹一下每一步都做了什么。

語(yǔ)法樹(shù)轉(zhuǎn)高端 gimple

這一步主要是處理寄存器和棧,比如 c = a + b 并沒(méi)有直接的匯編代碼和它對(duì)應(yīng),一般來(lái)說(shuō)需要把 a + b 的結(jié)果保存到寄存器中,然后再把寄存器賦值給 c。所以這一步如果用 C 語(yǔ)言來(lái)表示其實(shí)是:

  1. int temp = a + b; // temp 其實(shí)是寄存器 
  2. c =  temp

另外,調(diào)用一個(gè)新的函數(shù)時(shí)會(huì)進(jìn)入到函數(shù)自己的棧,建棧的操作也需要在 gimple 中聲明。

高端 gimple 轉(zhuǎn)低端 gimple

這一步主要是把變量定義,語(yǔ)句執(zhí)行和返回語(yǔ)句區(qū)分存儲(chǔ)。比如:

  1. int a = 1; 
  2. a++; 
  3. int b = 1; 

會(huì)被處理成:

  1. int a = 1; 
  2. int b = 1; 
  3. a++; 

這樣做的好處是很容易計(jì)算一個(gè)函數(shù)到底需要多少??臻g。

此外,return 語(yǔ)句會(huì)被統(tǒng)一處理,放在函數(shù)的末尾,比如:

  1. if (1 > 0) { 
  2.     return 1; 
  3. else { 
  4.     return 0; 

會(huì)被處理成:

  1. if (1 > 0) { 
  2.     goto a; 
  3. else { 
  4.     goto b; 
  5. a: 
  6.     return 1; 
  7. b: 
  8.     return 0; 

低端 gimple 經(jīng)過(guò) cfa 轉(zhuǎn) ssa 再轉(zhuǎn)中間代碼

這一步主要是進(jìn)行各種優(yōu)化,添加版本號(hào)等,我不太了解,對(duì)于普通開(kāi)發(fā)者來(lái)說(shuō)也沒(méi)有學(xué)習(xí)的必要。

中間代碼的意義

其實(shí)中間代碼可以被省略,抽象語(yǔ)法樹(shù)可以直接轉(zhuǎn)化為目標(biāo)代碼(匯編代碼)。然而,不同的 CPU 的匯編語(yǔ)法并不一致,比如 AT&T與Intel匯編風(fēng)格比較 這篇文章所提到的,Intel 架構(gòu)和 AT&T 架構(gòu)的匯編碼中,源操作數(shù)和目標(biāo)操作數(shù)位置恰好相反。Intel 架構(gòu)下操作數(shù)和立即數(shù)沒(méi)有前綴但 AT&T 有。因此一種比較高效的做法是先生成語(yǔ)言無(wú)關(guān),CPU 也無(wú)關(guān)的中間代碼,然后再生成對(duì)應(yīng)各個(gè) CPU 的匯編代碼。

生成中間代碼是非常重要的一步,一方面它和語(yǔ)言無(wú)關(guān),也和 CPU 與具體實(shí)現(xiàn)無(wú)關(guān)??梢岳斫鉃橹虚g代碼是一種非常抽象,又非常普適的代碼。它客觀中立的描述了代碼要做的事情,如果用中文、英文來(lái)分別表示 C 和 Java 的話,中間碼某種意義上可以被理解為世界語(yǔ)。

另一方面,中間代碼是編譯器前端和后端的分界線。編譯器前端負(fù)責(zé)把源碼轉(zhuǎn)換成中間代碼,編譯器后端負(fù)責(zé)把中間代碼轉(zhuǎn)換成匯編代碼。

LLVM IR 是一種中間代碼,它長(zhǎng)成這樣:

  1. define i32 @square_unsigned(i32 %a) { 
  2.   %1 = mul i32 %a, %a 
  3.   ret i32 %1 

生成目標(biāo)代碼

目標(biāo)代碼也可以叫做匯編代碼。由于中間代碼已經(jīng)非常接近于實(shí)際的匯編代碼,它幾乎可以直接被轉(zhuǎn)化。主要的工作量在于兼容各種 CPU 以及填寫(xiě)模板。在最終生成的匯編代碼中,不僅有匯編命令,也有一些對(duì)文件的說(shuō)明。比如:

  1.   .file       "test.c"      # 文件名稱 
  2.     .global     m             # 全局變量 m 
  3.     .data                     # 數(shù)據(jù)段聲明 
  4.     .align      4             # 4 字節(jié)對(duì)齊 
  5.     .type       m, @objc 
  6.     .size       m, 4 
  7. m: 
  8.     .long       10            # m 的值是 10 
  9.     .text 
  10.     .global     main 
  11.     .type       main, @function 
  12. main: 
  13.     pushl   %ebp 
  14.     movl    %esp,   %ebp 
  15.     ... 

匯編

匯編器會(huì)接收匯編代碼,將它轉(zhuǎn)換成二進(jìn)制的機(jī)器碼,生成目標(biāo)文件(后綴是 .o),機(jī)器碼可以直接被 CPU 識(shí)別并執(zhí)行。從目標(biāo)代碼可以猜出來(lái),最終的目標(biāo)文件(機(jī)器碼)也是分段的,這主要有以下三個(gè)原因:

  1. 分段可以將數(shù)據(jù)和代碼區(qū)分開(kāi)。其中代碼只讀,數(shù)據(jù)可寫(xiě),方便權(quán)限管理,避免指令被改寫(xiě),提高安全性。
  2. 現(xiàn)代 CPU 一般有自己的數(shù)據(jù)緩存和指令緩存,區(qū)分存儲(chǔ)有助于提高緩存***率。
  3. 當(dāng)多個(gè)進(jìn)程同時(shí)運(yùn)行時(shí),他們的指令可以被共享,這樣能節(jié)省內(nèi)存。

段分離我們并不遙遠(yuǎn),比如命令行中的 objcopy 可以自行添加自定義的段名,C 語(yǔ)言的 __attribute((section(段名)))__ 可以把變量定義在某個(gè)特定名稱的段中。

對(duì)于一個(gè)目標(biāo)文件來(lái)說(shuō),文件的最開(kāi)頭(也叫作 ELF 頭)記錄了目標(biāo)文件的基本信息,程序入口地址,以及段表的位置,相當(dāng)于是對(duì)文件的整體描述。接下來(lái)的重點(diǎn)是段表,它記錄了每個(gè)段的段名,長(zhǎng)度,偏移量。比較常用的段有:

  • .strtab 段: 字符串長(zhǎng)度不定,分開(kāi)存放浪費(fèi)空間(因?yàn)樾枰獌?nèi)存對(duì)齊),因此可以統(tǒng)一放到字符串表(也就是 .strtab 段)中進(jìn)行管理。字符串之間用\0 分割,所以凡是引用字符串的地方用一個(gè)數(shù)字就可以代表。
  • .symtab: 表示符號(hào)表。符號(hào)表統(tǒng)一管理所有符號(hào),比如變量名,函數(shù)名。符號(hào)表可以理解為一個(gè)表格,每行都有符號(hào)名(數(shù)字)、符號(hào)類型和符號(hào)值(存儲(chǔ)地址)
  • .rel 段: 它表示一系列重定位表。這個(gè)表主要在鏈接時(shí)用到,下面會(huì)詳細(xì)解釋。

鏈接

在一個(gè)目標(biāo)文件中,不可能所有變量和函數(shù)都定義在文件內(nèi)部。比如 strlen 函數(shù)就是一個(gè)被調(diào)用的外部函數(shù),此時(shí)就需要把 main.o 這個(gè)目標(biāo)文件和包含了 strlen函數(shù)實(shí)現(xiàn)的目標(biāo)文件鏈接起來(lái)。我們知道函數(shù)調(diào)用對(duì)應(yīng)到匯編其實(shí)是 jump 指令,后面寫(xiě)上被調(diào)用函數(shù)的地址,但在生成 main.o 的過(guò)程中,strlen() 函數(shù)的地址并不知道,所以只能先用 0 來(lái)代替,直到***鏈接時(shí),才會(huì)修改成真實(shí)的地址。

鏈接器就是靠著重定位表來(lái)知道哪些地方需要被重定位的。每個(gè)可能存在重定位的段都會(huì)有對(duì)應(yīng)的重定位表。在鏈接階段,鏈接器會(huì)根據(jù)重定位表中,需要重定位的內(nèi)容,去別的目標(biāo)文件中找到地址并進(jìn)行重定位。

有時(shí)候我們還會(huì)聽(tīng)到動(dòng)態(tài)鏈接這個(gè)名詞,它表示重定位發(fā)生在運(yùn)行時(shí)而非編譯后。動(dòng)態(tài)鏈接可以節(jié)省內(nèi)存,但也會(huì)帶來(lái)加載的性能問(wèn)題,這里不詳細(xì)解釋,感興趣的讀者可以閱讀《程序員的自我修養(yǎng)》這本書(shū)。

預(yù)處理

***簡(jiǎn)單描述一下預(yù)處理。預(yù)處理主要是處理一些宏定義,比如#define、#include、#if 等。預(yù)處理的實(shí)現(xiàn)有很多種,有的編譯器會(huì)在詞法分析前先進(jìn)行預(yù)處理,替換掉所有 # 開(kāi)頭的宏,而有的編譯器則是在詞法分析的過(guò)程中進(jìn)行預(yù)處理。當(dāng)分析到 # 開(kāi)頭的單詞時(shí)才進(jìn)行替換。雖然先預(yù)處理再詞法分析比較符合直覺(jué),但在實(shí)際使用中,GCC 使用的卻是一邊詞法分析,一邊預(yù)處理的方案。

編譯 VS 解釋

總結(jié)一下,對(duì)于 C 語(yǔ)言來(lái)說(shuō),從源碼到運(yùn)行結(jié)果大致上需要經(jīng)歷編譯、匯編和鏈接三個(gè)步驟。編譯器接收源代碼,輸出目標(biāo)代碼(也就是匯編代碼),匯編器接收匯編代碼,輸出由機(jī)器碼組成的目標(biāo)文件(二進(jìn)制格式,.o 后綴),***鏈接器將各個(gè)目標(biāo)文件鏈接起來(lái),執(zhí)行重定位,最終生成可執(zhí)行文件。

編譯器以中間代碼為界限,又可以分前端和后端。比如 clang 就是一個(gè)前端工具,而 LLVM 則負(fù)責(zé)后端處理。另一個(gè)知名工具 GCC(GNU Compile Collection)則是一個(gè)套裝,包攬了前后端的所有任務(wù)。前端主要負(fù)責(zé)預(yù)處理、詞法分析、語(yǔ)法分析,最終生成語(yǔ)言無(wú)關(guān)的中間代碼。后端主要負(fù)責(zé)目標(biāo)代碼的生成和優(yōu)化。

關(guān)于編譯原理的基礎(chǔ)知識(shí)雖然枯燥,但掌握這些知識(shí)有助于我們理解一些有用的,但不太容易理解的概念。接下來(lái),我們簡(jiǎn)單看一下別的語(yǔ)言是如何運(yùn)行的。

Java

在 Java 代碼的執(zhí)行過(guò)程中,可以簡(jiǎn)單分為編譯和執(zhí)行兩步。Java 的編譯器首先會(huì)把 .java 格式的源碼編譯成 .class 格式的字節(jié)碼。字節(jié)碼對(duì)應(yīng)到 C 語(yǔ)言的編譯體系中就是中間碼,Java 虛擬機(jī)執(zhí)行這些中間碼得到最終結(jié)果。

回憶一下上文對(duì)中間碼的解釋,一方面它與語(yǔ)言無(wú)關(guān),僅僅描述客觀事實(shí)。另一方面它和目標(biāo)代碼的差距并不大,已經(jīng)包括了對(duì)寄存器和棧的處理,僅僅是抽象了 CPU 架構(gòu)而已,只要把它具體化成各個(gè)平臺(tái)下的目標(biāo)代碼,就可以交給匯編器了。

解釋型語(yǔ)言

一般來(lái)說(shuō)我們也把解釋型語(yǔ)言叫做腳本語(yǔ)言,比如 Python、Ruby、JavaScript 等等。這類語(yǔ)言的特點(diǎn)是,不需要編譯,直接由解釋器執(zhí)行。換言之,運(yùn)行流程變成了:

源代碼 -> 解釋器 -> 運(yùn)行結(jié)果

需要注意的是,這里的解釋器只是一個(gè)黑盒,它的實(shí)現(xiàn)方式可以是多種多樣的。舉個(gè)例子,它的實(shí)現(xiàn)可以非常類似于 Java 的執(zhí)行過(guò)程。解釋器里面可以包含一個(gè)編譯器和虛擬機(jī),編譯器把源碼轉(zhuǎn)化成 AST 或者字節(jié)碼(中間代碼)然后交給虛擬機(jī)執(zhí)行,比如 Ruby 1.9 以后版本的官方實(shí)現(xiàn)就是這個(gè)思路。

至于虛擬機(jī),它并不是什么黑科技,它的內(nèi)部可以編譯執(zhí)行,也可以解釋執(zhí)行。如果是編譯執(zhí)行,那么它會(huì)把字節(jié)碼編譯成當(dāng)前 CPU 下的機(jī)器碼然后統(tǒng)一執(zhí)行。如果是解釋執(zhí)行,它會(huì)逐條翻譯字節(jié)碼。

有意思的是,如果虛擬機(jī)是編譯執(zhí)行的,那么這套流程和 C 語(yǔ)言幾乎一樣,都滿足下面這個(gè)流程:

源代碼 -> 中間代碼 -> 目標(biāo)代碼 -> 運(yùn)行結(jié)果

下面是重點(diǎn)!!!

下面是重點(diǎn)!!!

下面是重點(diǎn)!!!

因此,解釋型語(yǔ)言和編譯型語(yǔ)言的根本區(qū)別在于,對(duì)于用戶來(lái)說(shuō),到底是直接從源碼開(kāi)始執(zhí)行,還是從中間代碼開(kāi)始執(zhí)行。以 C 語(yǔ)言為例,所有的可執(zhí)行程序都是二進(jìn)制文件。而對(duì)于傳統(tǒng)意義的 Python 或者 JavaScript,用戶并沒(méi)有拿到中間代碼,他們直接從源碼開(kāi)始執(zhí)行。從這個(gè)角度來(lái)看, Java 不可能是解釋型語(yǔ)言,雖然 Java 虛擬機(jī)會(huì)解釋字節(jié)碼,但是對(duì)于用戶來(lái)說(shuō),他們是從編譯好的 .class 文件開(kāi)始執(zhí)行,而非源代碼。

實(shí)際上,在 x86 這種復(fù)雜架構(gòu)下,二進(jìn)制的機(jī)器碼也不能被硬件直接執(zhí)行,CPU 會(huì)把它翻譯成更底層的指令。從這個(gè)角度來(lái)說(shuō),我們眼中的硬件其實(shí)也是一個(gè)虛擬機(jī),執(zhí)行了一些“抽象”指令,但我相信不會(huì)有人認(rèn)為 C 語(yǔ)言是解釋型語(yǔ)言。因此,有沒(méi)有虛擬機(jī),虛擬機(jī)是不是解釋執(zhí)行,會(huì)不會(huì)生成中間代碼,這些都不重要,重要的是如果從中間代碼開(kāi)始執(zhí)行,而且 AST 已經(jīng)事先生成好,那就是編譯型的語(yǔ)言。

如果更本質(zhì)一點(diǎn)看問(wèn)題,根本就不存在解釋型語(yǔ)言或者編譯型語(yǔ)言這種說(shuō)法。已經(jīng)有人證明,如果一門語(yǔ)言是可以解釋的,必然可以開(kāi)發(fā)出這門語(yǔ)言的編譯器。反過(guò)來(lái)說(shuō),如果一門語(yǔ)言是可編譯的,我只要把它的編譯器放到解釋器里,把編譯推遲到運(yùn)行時(shí),這么語(yǔ)言就可以是解釋型的。事實(shí)上,早有人開(kāi)發(fā)出了 C 語(yǔ)言的解釋器:

C 源代碼 -> C 語(yǔ)言解釋器(運(yùn)行時(shí)編譯、匯編、鏈接) -> 運(yùn)行結(jié)果

我相信這一點(diǎn)很容易理解,規(guī)范和實(shí)現(xiàn)是兩套分離的體系。我們平常說(shuō)的 C 語(yǔ)言的語(yǔ)法,實(shí)際上是一套規(guī)范。理論上來(lái)說(shuō)每個(gè)人都可以寫(xiě)出自己的編譯器來(lái)實(shí)現(xiàn) C 語(yǔ)言,只要你的編譯器能夠正確運(yùn)行,最終的輸出結(jié)果正確即可。而編譯型和解釋型說(shuō)的其實(shí)是語(yǔ)言的實(shí)現(xiàn)方案,是提前編譯以獲得***的性能提高,還是運(yùn)行時(shí)去解析以獲得靈活性,往往取決于語(yǔ)言的應(yīng)用場(chǎng)景。所以說(shuō)一門語(yǔ)言是編譯型還是解釋型的,這會(huì)非??尚?。一個(gè)標(biāo)準(zhǔn)怎么可能會(huì)有固定的實(shí)現(xiàn)呢?之所以給大家留下了 C 語(yǔ)言是編譯型語(yǔ)言,Python 是解釋型語(yǔ)言的印象,往往是因?yàn)檫@門語(yǔ)言的應(yīng)用場(chǎng)景決定了它是主流實(shí)現(xiàn)是編譯型還是解釋型。

自舉

不知道有沒(méi)有人思考過(guò),C 語(yǔ)言的編譯器是如何實(shí)現(xiàn)的?實(shí)際上它還是用 C 語(yǔ)言實(shí)現(xiàn)的。這種自己能編譯自己的神奇能力被稱為自舉(Bootstrap)。

乍一看,自舉是不可能的。因?yàn)?C 語(yǔ)言編譯器,比如 GCC,要想運(yùn)行起來(lái),必定需要 GCC 的編譯器將它編譯成二進(jìn)制的機(jī)器碼。然而 GCC 的編譯器又如何編譯呢……

解決問(wèn)題的關(guān)鍵在于打破這個(gè)循環(huán),我們可以先用一個(gè)比 C 語(yǔ)言低級(jí)的語(yǔ)言來(lái)實(shí)現(xiàn)一個(gè) C 語(yǔ)言編譯器。這件事是可能做到的,因?yàn)檫@個(gè)低級(jí)語(yǔ)言必然會(huì)比 C 語(yǔ)言簡(jiǎn)單,比如我們可以直接用匯編代碼來(lái)寫(xiě) C 語(yǔ)言的編譯器。由于越低級(jí)的語(yǔ)言越簡(jiǎn)單,但表達(dá)能力越弱,所以用匯編來(lái)寫(xiě)可能太復(fù)雜。這種情況下我們可以先用一個(gè)比 C 語(yǔ)言低級(jí)但比匯編高級(jí)的語(yǔ)言來(lái)實(shí)現(xiàn) C 語(yǔ)言的編譯器,同時(shí)用匯編來(lái)實(shí)現(xiàn)這門語(yǔ)言的編譯器??傊褪遣粩嘤玫图?jí)語(yǔ)言來(lái)寫(xiě)高級(jí)語(yǔ)言的編譯器,雖然語(yǔ)言越低級(jí),它的表達(dá)能力越弱,但是它要解析的語(yǔ)言也在不斷變簡(jiǎn)單,所以這件事是可以做到的。

有了低級(jí)語(yǔ)言寫(xiě)好的 C 語(yǔ)言編譯器以后,這個(gè)編譯器是二進(jìn)制格式的。此時(shí)就可以刪掉所有的低級(jí)語(yǔ)言,只留一個(gè)二進(jìn)制格式的 C 語(yǔ)言編譯器,接下來(lái)我們就可以用 C 語(yǔ)言寫(xiě)編譯器,再用這個(gè)二進(jìn)制格式的編譯器去編譯 C 語(yǔ)言實(shí)現(xiàn)的 C 語(yǔ)言編譯器了,于是完成了自舉。

以上邏輯描述起來(lái)比較繞,但我想多讀幾遍應(yīng)該可以理解。如果實(shí)在不理解也沒(méi)關(guān)系,我們只要明白 C 語(yǔ)言可以自舉是因?yàn)樗梢跃幾g成二進(jìn)制機(jī)器碼,只要用低級(jí)語(yǔ)言生成這個(gè)機(jī)器碼,就不再需要低級(jí)語(yǔ)言了,因?yàn)闄C(jī)器碼可以直接被 CPU 執(zhí)行。

從這個(gè)角度來(lái)看,解釋型語(yǔ)言是不可能自舉的。以 Python 為例,自舉要求它能用 Python 語(yǔ)言寫(xiě)出來(lái) Python 的解釋器,然而這個(gè)解釋器如何運(yùn)行呢,最終還是需要一個(gè)解釋器。而解釋器體系下, Python 都是從源碼經(jīng)過(guò)解釋器執(zhí)行,又不能留下什么可以直接被硬件執(zhí)行的二進(jìn)制形式的解釋器文件,自然是沒(méi)辦法自舉的。然而,就像前面說(shuō)的,Python 完全可以實(shí)現(xiàn)一個(gè)編譯器,這種情況下它就是可以自舉的。

所以一門語(yǔ)言能不能自舉,主要取決于它的實(shí)現(xiàn)形式能否被編譯并留下二進(jìn)制格式的可執(zhí)行文件。

運(yùn)行時(shí)

本文的讀者如果是使用 Objective-C 的 iOS 開(kāi)發(fā)者,想必都有過(guò)在面試時(shí)被 runtime 支配的恐懼。然而,runtime 并非是 Objective-C 的專利,絕大多數(shù)語(yǔ)言都有這個(gè)概念。所以有人說(shuō) Objective-C 具有動(dòng)態(tài)性是因?yàn)樗?runtime,這種說(shuō)法并不準(zhǔn)確,我覺(jué)得要把 Objective-C 的 runtime 和一般意義的運(yùn)行時(shí)庫(kù)區(qū)分開(kāi),認(rèn)識(shí)到它僅僅是運(yùn)行時(shí)庫(kù)的一個(gè)組成部分,同時(shí)還是要深入到方法調(diào)用的層面來(lái)談。

運(yùn)行時(shí)庫(kù)的基本概念

以 C 語(yǔ)言為例,有非常多的操作最終都依賴于 glibc 這個(gè)動(dòng)態(tài)鏈接庫(kù)。包括但不限于字符串處理(strlen、strcpy)、信號(hào)處理、socket、線程、IO、動(dòng)態(tài)內(nèi)存分屏(malloc)等等。這一點(diǎn)很好理解,如果回憶一下之前編譯器的工作原理,我們會(huì)發(fā)現(xiàn)它僅僅是處理了語(yǔ)言的語(yǔ)法,比如變量定義,函數(shù)聲明和調(diào)用等等。至于語(yǔ)言的功能, 比如內(nèi)存管理,內(nèi)建的類型,一些必要功能的實(shí)現(xiàn)等等。如果要對(duì)運(yùn)行時(shí)庫(kù)進(jìn)行分類,大概有兩類。一種是語(yǔ)言自身功能的實(shí)現(xiàn),比如一些內(nèi)建類型,內(nèi)置的函數(shù);另一種則是語(yǔ)言無(wú)關(guān)的基礎(chǔ)功能,比如文件 IO,socket 等等。

由于每個(gè)程序都依賴于運(yùn)行時(shí)庫(kù),這些庫(kù)一般都是動(dòng)態(tài)鏈接的,比如 C 語(yǔ)言的 (g)libc。這樣一來(lái),運(yùn)行時(shí)庫(kù)可以存儲(chǔ)在操作系統(tǒng)中,節(jié)省內(nèi)存占用空間和應(yīng)用程序大小。

對(duì)于 Java 語(yǔ)言來(lái)說(shuō),它的垃圾回收功能,文件 IO 等都是在虛擬機(jī)中實(shí)現(xiàn),并提供給 Java 層調(diào)用。從這個(gè)角度來(lái)看,虛擬機(jī)/解釋器也可以被看做語(yǔ)言的運(yùn)行時(shí)環(huán)境(庫(kù))。

swift 運(yùn)行時(shí)庫(kù)

經(jīng)過(guò)這樣的解釋,相信 swift 的運(yùn)行時(shí)庫(kù)就很容易理解了。一方面,swift 是絕對(duì)的靜態(tài)語(yǔ)言,另一方面,swift 毫無(wú)疑問(wèn)的帶有自己的運(yùn)行時(shí)庫(kù)。舉個(gè)最簡(jiǎn)單的例子,如果閱讀 swift 源碼就會(huì)發(fā)現(xiàn)某些類型,比如字符串(String),或者數(shù)組,再或者某些函數(shù)(print)都是用 swift 實(shí)現(xiàn)的,這些都是 swift 運(yùn)行時(shí)庫(kù)的一部分。按理說(shuō),運(yùn)行時(shí)庫(kù)應(yīng)該內(nèi)置于操作系統(tǒng)中并且和應(yīng)用程序動(dòng)態(tài)鏈接,然而坑爹的 Swift 在本文寫(xiě)作之時(shí)依然沒(méi)有穩(wěn)定 ABI,導(dǎo)致每個(gè)程序都必須自帶運(yùn)行時(shí)庫(kù),這也就是為什么目前 swift 開(kāi)發(fā)的 app 普遍會(huì)增加幾 Mb 包大小的原因。

說(shuō)到 ABI,它其實(shí)就是一個(gè)編譯后的 API。簡(jiǎn)單來(lái)說(shuō),API 是描述了在應(yīng)用程序級(jí)別,模塊之間的調(diào)用約定。比如某個(gè)模塊想要調(diào)用另一個(gè)模塊的功能,就必須根據(jù)被調(diào)用模塊提供的 API 來(lái)調(diào)用,因?yàn)?API 中規(guī)定了方法名、參數(shù)和返回結(jié)果的類型。而當(dāng)源碼被編譯成二進(jìn)制文件后,它們之間的調(diào)用也存在一些規(guī)則和約定。

比如模塊 A 有兩個(gè)整數(shù) a 和 b,它們的內(nèi)存布局如下:

模塊 A
初始地址
a
b

這時(shí)候別的模塊調(diào)用 A 模塊的 b 變量,可以通過(guò)初始地址加偏移量的方式進(jìn)行。

如果后來(lái)模塊 A 新增了一個(gè)整數(shù) c,它的內(nèi)存布局可能會(huì)變成:

模塊 A
初始地址
c
a
b

如果調(diào)用方還是使用相同的偏移量,可以想見(jiàn),這次拿到的就是變量 a 了。因此,每當(dāng)模塊 A 有更新,所有依賴于模塊 A 的模塊都必須重新編譯才能正確工作。如果這里的模塊 A 是 swift 的運(yùn)行時(shí)庫(kù),它內(nèi)置于操作系統(tǒng)并與其他模塊(應(yīng)用程序)動(dòng)態(tài)鏈接會(huì)怎么樣呢?結(jié)果就是每次更新系統(tǒng)后,所有的 app 都無(wú)法打開(kāi)。顯然這是無(wú)法接受的。

當(dāng)然,ABI 穩(wěn)定還包括其他的一些要求,比如調(diào)用和被調(diào)用者遵守相同的調(diào)用約定(參數(shù)和返回值如何傳遞)等。

JavaScript 那些事

我們繼續(xù)剛才有關(guān)運(yùn)行時(shí)的話題,先從 JavaScript 的運(yùn)行時(shí)聊起,再介紹 JavaScript 的相關(guān)知識(shí)。

JavaScript 是如何運(yùn)行的

JavaScript 和其他語(yǔ)言,無(wú)論是 C 語(yǔ)言,還是 Python 這樣的腳本語(yǔ)言,***的區(qū)別在于 JavaScript 的宿主環(huán)境比較奇怪,一般來(lái)說(shuō)是瀏覽器。

無(wú)論是 C 還是 Python,他們都有一個(gè)編譯器/解釋器運(yùn)行在操作系統(tǒng)上,直接把源碼轉(zhuǎn)換成機(jī)器碼。而 JavaScript 的解釋器一般內(nèi)置在瀏覽器中,比如 Chrome 就有一個(gè) V8 引擎可以解析并執(zhí)行 JavaScript 代碼。因此 JavaScript 的能力實(shí)際上會(huì)受到宿主環(huán)境的影響,有一些限制和加強(qiáng)。

首先來(lái)看看 DOM 操作,相關(guān)的 API 并沒(méi)有定義在 ECMAScript 標(biāo)準(zhǔn)中,因此我們常用的 window.xxx 還有 window.document.xxx 并非是 JavaScript 自帶的功能,這通常是由宿主平臺(tái)通過(guò) C/C++ 等語(yǔ)言實(shí)現(xiàn),然后提供給 JavaScript 的接口。同樣的,由于瀏覽器中的 JavaScript 只是一個(gè)輕量的語(yǔ)言,沒(méi)有必要讀寫(xiě)操作系統(tǒng)的文件,因此瀏覽器引擎一般不會(huì)向 JavaScript 提供文件讀寫(xiě)的運(yùn)行時(shí)組件,它也就不具備 IO 的能力。從這個(gè)角度來(lái)看,整個(gè)瀏覽器都可以看做 JavaScript 的虛擬機(jī)或者運(yùn)行時(shí)環(huán)境。

因此,當(dāng)我們換一個(gè)宿主環(huán)境,比如 Node.js,JavaScript 的能力就會(huì)發(fā)生變化。它不再具有 DOM API,但多了讀寫(xiě)文件等能力。這時(shí)候,Node.js 就更像是一個(gè)標(biāo)準(zhǔn)的 JavaScript 解析器了。這也是為什么 Node.js 讓 JavaScript 可以編寫(xiě)后端應(yīng)用的原因。

JIT 優(yōu)化

解釋執(zhí)行效率低的主要原因之一在于,相同的語(yǔ)句被反復(fù)解釋,因此優(yōu)化的思路是動(dòng)態(tài)的觀察哪些代碼是經(jīng)常被調(diào)用的。對(duì)于那些被高頻率調(diào)用的代碼,可以用編譯器把它編譯成機(jī)器碼并且緩存下來(lái),下次執(zhí)行的時(shí)候就不用重新解釋,從而提升速度。這就是 JIT(Just-In-Time) 的技術(shù)原理。

但凡基于緩存的優(yōu)化,一定會(huì)涉及到緩存***率的問(wèn)題。在 JavaScript 中,即使是同一段代碼,在不同上下文中生成的機(jī)器碼也不一定相同。比如這個(gè)函數(shù):

  1. function add(a, b) { 
  2.     return a + b; 

如果這里的 a 和 b 都是整數(shù),可以想見(jiàn)最終的代碼一定是匯編中的 add 命令。如果類似的加法運(yùn)算調(diào)用了很多次,解釋器可能會(huì)認(rèn)為它值得被優(yōu)化,于是編譯了這段代碼。但如果下一次調(diào)用的是 add("hello", "world"),之前的優(yōu)化就無(wú)效了,因?yàn)樽址臃ǖ膶?shí)現(xiàn)和整數(shù)加法的實(shí)現(xiàn)完全不同。

于是優(yōu)化后的代碼(二進(jìn)制格式)還得被還原成原先的形式(字符串格式),這樣的過(guò)程被稱為去優(yōu)化。反復(fù)的優(yōu)化 -> 去優(yōu)化 -> 優(yōu)化 …… 非常耗時(shí),大大降低了引入 JIT 帶來(lái)的性能提升。

JIT 理論上給傳統(tǒng)的 JavaScript 帶了了 20-40 倍的性能提升,但由于上述去優(yōu)化的存在,在實(shí)際運(yùn)行的過(guò)程中遠(yuǎn)遠(yuǎn)達(dá)不到這個(gè)理論上的性能天花板。

WebAssembly

前文說(shuō)過(guò),JavaScript 實(shí)際上是由瀏覽器引擎負(fù)責(zé)解析并提供一些功能的。瀏覽器引擎可能是由 C++ 這樣高效的語(yǔ)言實(shí)現(xiàn)的,那么為什么不用 C++ 來(lái)寫(xiě)網(wǎng)頁(yè)呢?實(shí)際上我認(rèn)為從技術(shù)角度來(lái)說(shuō)并不存在問(wèn)題,直接下發(fā) C++ 代碼,然后交給 C++ 解釋器去執(zhí)行,再調(diào)用瀏覽器的 C++ 組件,似乎更加符合直覺(jué)一些。

之所以選擇 JavaScript 而不是 C++,除了主流瀏覽器目前都只支持 JavaScript 而不支持 C++ 這個(gè)歷史原因以外,更重要的一點(diǎn)是一門語(yǔ)言的高性能和簡(jiǎn)單性不可兼得。JavaScript 在運(yùn)行速度方面做出了犧牲,但也具備了簡(jiǎn)單易開(kāi)發(fā)的優(yōu)點(diǎn)。作為通用編程語(yǔ)言,JavaScript 和 C++ 主要的性能差距就在于缺少類型標(biāo)注,導(dǎo)致無(wú)法進(jìn)行有效的提前編譯。之前說(shuō)過(guò) JIT 這種基于緩存去猜測(cè)類型的方式存在瓶頸,那么最精確的方式肯定還是直接加上類型標(biāo)注,這樣就可以直接編譯了,代表性的作品有 Mozilla 的Asm.js。

Asm.js 是 JavaScript 的一個(gè)子集,任何 JavaScript 解釋器都可以解釋它:

  1. function add(a, b) { 
  2.     a = a | 0  // 任何整數(shù)和自己做按位或運(yùn)算的結(jié)果都是自己 
  3.     b = b | 0  // 所以這個(gè)標(biāo)記不改變運(yùn)算結(jié)果,但是可以提示編譯器 a、b 都是整數(shù) 
  4.     return a + b | 0 

如果有 Asm.js 特定的解釋器,完全可以把它提前編譯出來(lái)。即使沒(méi)有也沒(méi)關(guān)系,因?yàn)樗耆?JavaScript 語(yǔ)法的子集,普通的解釋器也可以解釋。

然而,回顧一下我們最初對(duì)解釋器的定義: 解釋器是一個(gè)黑盒,輸入源碼,輸出運(yùn)行結(jié)果。Asm.js 其實(shí)是黑盒內(nèi)部的一個(gè)優(yōu)化,不同的黑盒(瀏覽器)無(wú)法共享這一優(yōu)化。換句話說(shuō) Asm.js 寫(xiě)成的代碼放到 Chrome 上面和普通的 JavaScript 毫無(wú)區(qū)別。

于是,包括微軟、谷歌和蘋果在內(nèi)的各大公司覺(jué)得,是時(shí)候搞個(gè)標(biāo)準(zhǔn)了,這個(gè)標(biāo)準(zhǔn)就是 WebAssembly 格式。它是介于中間代碼和目標(biāo)代碼之間的一種二進(jìn)制格式,借用WebAssembly 系列(四)WebAssembly 工作原理 一文的插圖來(lái)表示: 

 

通常從中間代碼到機(jī)器碼,需要經(jīng)過(guò)平臺(tái)具體化(轉(zhuǎn)目標(biāo)代碼)和二進(jìn)制化(匯編器把匯編代碼變?yōu)槎M(jìn)制機(jī)器碼)這兩個(gè)步驟。而 WebAssembly 首先完成了第二個(gè)步驟,即已經(jīng)是二進(jìn)制格式的,但只是一系列虛擬的通用指令,還需要轉(zhuǎn)換到各個(gè) CPU 架構(gòu)上。這樣一來(lái),從 WebAssembly 到機(jī)器碼其實(shí)是透明且統(tǒng)一的,各個(gè)瀏覽器廠商只需要考慮如何從中間代碼轉(zhuǎn)換 WebAssembly 就行了。

由于編譯器的前端工具 Clang 可以把 C/C++ 轉(zhuǎn)換成中間代碼,因此理論上它們都可以用來(lái)開(kāi)發(fā)網(wǎng)頁(yè)。然而誰(shuí)會(huì)這么這么做呢,放著簡(jiǎn)單快捷,現(xiàn)在又高效的 JavaScript 不寫(xiě),非要去啃 C++?

跨語(yǔ)言那些事兒

C++ 寫(xiě)網(wǎng)頁(yè)這個(gè)腦洞雖然比較大,但它啟發(fā)我思考一個(gè)問(wèn)題:“對(duì)于一個(gè)常見(jiàn)的可以由某個(gè)語(yǔ)言完成的任務(wù)(比如 JavaScript 寫(xiě)網(wǎng)頁(yè)),能不能換一個(gè)語(yǔ)言來(lái)實(shí)現(xiàn)(比如 C++),如果不能,制約因素在哪里”。

由于絕大多數(shù)主流語(yǔ)言都是圖靈完備的,也就是說(shuō)一切可計(jì)算的問(wèn)題,在這些語(yǔ)言層面都是等價(jià)的,都可以計(jì)算。那么制約語(yǔ)言能力的因素也就只剩下了運(yùn)行時(shí)的環(huán)境是否提供了相應(yīng)的功能。比如前文解釋過(guò)的,雖然瀏覽器中的 JavaScript 不能讀寫(xiě)文件,不能實(shí)現(xiàn)一個(gè)服務(wù)器,但這是瀏覽器(即運(yùn)行時(shí)環(huán)境)不行,不是 JavaScript 不行,只要把運(yùn)行環(huán)境換成 Node.js 就行了。

直接語(yǔ)法轉(zhuǎn)換

大部分讀者應(yīng)該接觸過(guò)簡(jiǎn)單的逆向工程。比如編譯后的 .o 目標(biāo)文件和 .class 字節(jié)碼都可以反編譯成源代碼,這種從中間代碼倒推回源代碼的技術(shù)也被叫做反編譯(decompile),反編譯器的工作流程基本上是編譯器的倒序,只不過(guò)***的反編譯一般來(lái)說(shuō)比較困難,這取決于中間代碼的實(shí)現(xiàn)。像 Java 字節(jié)碼這樣的中間代碼,由于信息比較全,所以反編譯就相對(duì)容易、準(zhǔn)確一些。C 代碼在生成中間代碼時(shí)丟失了很多信息,因此就幾乎不可能 100% 準(zhǔn)確的倒推回去,感興趣的讀者可以參考一下知名的反編譯工具Hex-Rays 的一篇博客。

前文說(shuō)過(guò),編譯器前端可以對(duì)多種語(yǔ)言進(jìn)行詞法分析和語(yǔ)法分析,并且生成一套語(yǔ)言無(wú)關(guān)的中間代碼,因此理論上來(lái)說(shuō),如果某個(gè)編譯器前端工具支持兩個(gè)語(yǔ)言 A 和 B 的解析,那么 A 和 B 是可以互相轉(zhuǎn)換的,流程如下:

A 源碼 <--> 語(yǔ)言無(wú)關(guān)的中間代碼 <--> B 源碼

其中從源碼轉(zhuǎn)換到中間代碼需要使用編譯器,從中間代碼轉(zhuǎn)換到源碼則使用反編譯器。

但在實(shí)際情況中,事情會(huì)略復(fù)雜一些,這是因?yàn)橹虚g代碼雖然是一套語(yǔ)言無(wú)關(guān)、CPU 也無(wú)關(guān)的指令集,但不代表不同語(yǔ)言生成的中間代碼就可以通用。比如中間代碼共有 1、2、3、……、6 這六個(gè)指令。A 語(yǔ)言生成的中間代碼僅僅是所有指令的一個(gè)子集,比如是 1-5 這 5 個(gè)指令;B 語(yǔ)言生成的中間代碼可能是所有指令的另一個(gè)子集,比如 2-6。這時(shí)候我們說(shuō)的 B 語(yǔ)言的反編譯器,實(shí)際上是從 2-6 的指令子集推導(dǎo)出 B 語(yǔ)言源碼,它對(duì)指令 1 可能無(wú)能為力。

以 GCC 的中間代碼 RTL: Register Transfer Language 為例,官方文檔 在對(duì) RTL 的解釋中,就明確的把 RTL 樹(shù)分為了通用的、C/C++ 特有的、Java 特有的等幾個(gè)部分。

 

 

具體來(lái)說(shuō),我們知道 Java 并不能直接訪問(wèn)內(nèi)存地址,這一點(diǎn)和瀏覽器上的 JavaScript 不能讀寫(xiě)文件很類似,都是因?yàn)樗鼈兊倪\(yùn)行環(huán)境(虛擬機(jī))具備這種能力,但沒(méi)有在語(yǔ)言層面提供。因此,含有指針?biāo)膭t運(yùn)算的 C 代碼無(wú)法直接被轉(zhuǎn)換成 Java 代碼,因?yàn)? Java 字節(jié)碼層面并沒(méi)有定義這樣的抽象,一種簡(jiǎn)單的方案是申請(qǐng)一個(gè)超大的數(shù)組,然后自己模擬內(nèi)存地址。

所以,即使編譯器前端同時(shí)支持兩種語(yǔ)言的解析,要想進(jìn)行轉(zhuǎn)換,還必須處理兩種語(yǔ)言在中間代碼層面的一些小差異,實(shí)際流程應(yīng)該是:

A 源碼 <--> 中間代碼子集(A) <--適配器--> 中間代碼子集(B) <--> B 源碼

這個(gè)思路已經(jīng)不僅僅停留在理論上了,比如 Github 上有一個(gè)庫(kù): emscripten 就實(shí)現(xiàn)了將任何 Clang 支持的語(yǔ)言(比如 C/C++ 等)轉(zhuǎn)換成 JavaScript,再比如 lljvm實(shí)現(xiàn)了 C 到 Java 字節(jié)碼的轉(zhuǎn)換。

然而前文已經(jīng)解釋過(guò),實(shí)現(xiàn)單純語(yǔ)法的轉(zhuǎn)換意義并不大。一方面,對(duì)于圖靈完備的語(yǔ)言來(lái)說(shuō),換一種表示方法(語(yǔ)言)去解決相同的問(wèn)題并沒(méi)有意義。另一方面,語(yǔ)言的真正功能絕不僅僅是語(yǔ)法本身,而在于它的運(yùn)行時(shí)環(huán)境提供了什么樣的功能。比如 Objective-C 的 Foundation 庫(kù)提供了字典類型 NSDictionary,它如果直接轉(zhuǎn)換成 C 語(yǔ)言,將是一個(gè)找不到的符號(hào)。因?yàn)?C 語(yǔ)言的運(yùn)行時(shí)環(huán)境根本就不提供對(duì)這種數(shù)據(jù)結(jié)構(gòu)的支持。因此凡是在語(yǔ)言層面進(jìn)行強(qiáng)制轉(zhuǎn)換的,要么利用反編譯器拿到一堆格式正確但無(wú)法運(yùn)行的代碼,要么就自行解析語(yǔ)法樹(shù)并為轉(zhuǎn)換后的語(yǔ)言添加對(duì)應(yīng)的能力,來(lái)實(shí)現(xiàn)轉(zhuǎn)換前語(yǔ)言的功能。

 

比如圖中就是一個(gè) C 語(yǔ)言轉(zhuǎn)換 Java 的工具,為了實(shí)現(xiàn) C 語(yǔ)言中的字符串申請(qǐng)和釋放內(nèi)存,這個(gè)工具不得不自己實(shí)現(xiàn)了 com.mtsystems.coot.String8 類。這樣巨大的成本,顯然不夠普適,應(yīng)用場(chǎng)景相對(duì)有限。

總之,直接的語(yǔ)法轉(zhuǎn)換是一個(gè)美好的想法,但實(shí)現(xiàn)起來(lái)難度大,收益有限,通常是為了移植已經(jīng)用某個(gè)語(yǔ)言寫(xiě)好的框架,或者開(kāi)個(gè)腦洞用于學(xué)習(xí),但實(shí)際應(yīng)用場(chǎng)景并不多。

膠水語(yǔ)言 Python

Python 一個(gè)很強(qiáng)大的特點(diǎn)是膠水語(yǔ)言,可以把 Python 理解為各種語(yǔ)言的粘合劑。對(duì)于 Python 可以處理的邏輯,用 Python 代碼即可完成。如果追求***的性能或者調(diào)用已經(jīng)實(shí)現(xiàn)的功能,也可以讓 Python 調(diào)用已經(jīng)由別的語(yǔ)言實(shí)現(xiàn)的模塊,以 Python 和 C 語(yǔ)言的交互解釋一下。

首先,如果是 C 語(yǔ)言要執(zhí)行 Python 代碼,顯然需要一個(gè) Python 的解釋器。由于在 Mac OS X 系統(tǒng)上,Python 解釋器是一個(gè)動(dòng)態(tài)鏈接庫(kù),所以只要導(dǎo)入一下頭文件即可,下面這段代碼可以成功輸出 “Hello Python!!!”:

  1. #include <stdio.h> 
  2. #import <Python/Python.h> 
  3.  
  4. int main(int argc, const char * argv[]) { 
  5.     Py_SetProgramName(argv[0]); 
  6.     Py_Initialize(); 
  7.     PyRun_SimpleString("print 'Hello Python!!!'\n"); 
  8.     Py_Finalize(); 
  9.     return 0; 

如果是在 iOS 應(yīng)用里,由于 iOS 系統(tǒng)沒(méi)有對(duì)應(yīng)的動(dòng)態(tài)庫(kù),所以需要把 Python 的解釋器打包成一個(gè)靜態(tài)庫(kù)并且鏈接到應(yīng)用中,網(wǎng)上已經(jīng)有人做好了: python-for-iphone,這就是為什么我們看到一些教育類的應(yīng)用模擬了 Python 解釋器,允許用戶編寫(xiě) Python 代碼并得到輸出。

Python 調(diào)用 Objective-C/C 也不復(fù)雜,只需要在 C 代碼中指定要暴露的模塊 A 和要暴露的方法 a,然后 Python 就可以直接調(diào)用了:

  1. import A 
  2. A.a() 

詳細(xì)的教程可以看這里: 如何實(shí)現(xiàn) C/C++ 與 Python 的通信?

有時(shí)候,如果能把自己熟悉的語(yǔ)言應(yīng)用到一個(gè)陌生的領(lǐng)域,無(wú)疑會(huì)大大降低上手的難度。以 iOS 開(kāi)發(fā)為例,開(kāi)發(fā)者的日常其實(shí)是利用 Objective-C 語(yǔ)法來(lái)描述一些邏輯,最終利用 UIKit 等框架完成和應(yīng)用的交互。 一種很自然而然的想法是,能不能用 Python 來(lái)實(shí)現(xiàn)邏輯,并且調(diào)用 Objective-C 的接口,比如 UIKit、Foundation 等。實(shí)際上前者是完全可以實(shí)現(xiàn)的,但是 Python 調(diào)用 Objective-C 遠(yuǎn)比調(diào)用 C 語(yǔ)言要復(fù)雜得多。

一方面從之前的分析中也能看出,并不是所有的源碼編譯成目標(biāo)文件都可以被 Python 引用;另一方面,最重要的是 Objective-C 方法調(diào)用的特性。我們知道方法調(diào)用實(shí)際上會(huì)被編譯成 msg_Send 并交給 runtime 處理,最終找到函數(shù)指針并調(diào)用。這里 Objective-C 的 runtime 其實(shí)是一個(gè)用 C 語(yǔ)言實(shí)現(xiàn)動(dòng)態(tài)鏈接庫(kù),它可以理解為 Objective-C 運(yùn)行時(shí)環(huán)境的一部分。換句話說(shuō),沒(méi)有 runtime 這個(gè)庫(kù),包含方法調(diào)用的 Objective-C 代碼是不可能運(yùn)行起來(lái)的,因?yàn)?msg_Send 這個(gè)符號(hào)無(wú)法被重定向,運(yùn)行時(shí)將找不到 msg_Send 函數(shù)的地址。就連原生的 Objective-C 代碼都需要依賴運(yùn)行時(shí),想讓 Python 直接調(diào)用某個(gè) Objective-C 編譯出來(lái)的庫(kù)就更不可能了。

想用 Python 寫(xiě)開(kāi)發(fā) iOS 應(yīng)用是有可能的,比如: PyObjc,但最終還是要依賴 Runtime。大概的思路是首先用 Python 拿到 runtime 這個(gè)庫(kù),然后通過(guò)這個(gè)庫(kù)去和 runtime 交互,進(jìn)而具備了調(diào)用 Objective-C 和各種框架的能力。比如我要實(shí)現(xiàn) Python 中的 UIView 這個(gè)類,代碼會(huì)變成這樣:

  1. import objc 
  2.  
  3. # 這個(gè) objc 是動(dòng)態(tài)加載 libobjc.dylib 得到的 
  4. # Python 會(huì)對(duì) objc 做一些封裝,提供調(diào)用 runtime 的能力 
  5. # 實(shí)際的工作還是交給 libobjc.dylib 完成 
  6.  
  7. class UIView: 
  8.     def __init__(self, param): 
  9.         objc.msgSend("UIView""init", param) 

這么做的性價(jià)比并不高,如果和 JSPatch 相比,JSPatch 使用了內(nèi)置的 JavaScriptCore 作為 JavaScript 的解析器,而 PyObjc 就得自己帶一個(gè) libPython.a 解釋器。此外,由于 iOS 系統(tǒng)的沙盒限制,非越獄機(jī)器并不能拿到 libobjc 庫(kù),所以這個(gè)工具只能在越獄手機(jī)上使用。

OCS

既然說(shuō)到了 JSPatch 這一類動(dòng)態(tài)化的 iOS 開(kāi)發(fā)工具,我就斗膽猜測(cè)一下騰訊 OCS 的實(shí)現(xiàn)原理,目前介紹 OCS 的文章***,由于蘋果公司的要求,原文已經(jīng)被刪除,從新浪博客上摘錄了一份: OCS ——史上最瘋狂的 iOS 動(dòng)態(tài)化方案。如果用一句話來(lái)概述,那么就是 OCS 是一個(gè) Objective-C 解釋器。

首先,OCS 基于 clang 對(duì)下發(fā)的 Objective-C 代碼做詞法、語(yǔ)法分析,生成 AST 然后轉(zhuǎn)化成自定義的一套中間碼(OSScript)。當(dāng)然,原生的 Objective-C 可以運(yùn)行,絕不僅僅是編譯器的功勞。就像之前反復(fù)強(qiáng)調(diào)的那樣,運(yùn)行時(shí)環(huán)境也必不可少,比如負(fù)責(zé) GCD 的 libdispatch 庫(kù),還有內(nèi)存管理,多線程等等功能。這些功能原來(lái)都由系統(tǒng)的動(dòng)態(tài)庫(kù)實(shí)現(xiàn),但現(xiàn)在必須由解釋器實(shí)現(xiàn),所以 OCS 的做法是開(kāi)發(fā)了一套自己的虛擬機(jī)去解釋執(zhí)行中間碼。這個(gè)運(yùn)行原理就和 JVM 非常類似了。

當(dāng)然,最終還是要和 Objective-C 的 Runtime 打交道,這樣才能調(diào)用 UIKit 等框架。由于對(duì)虛擬機(jī)的實(shí)現(xiàn)原理并不清楚,這里就不敢多講了,希望在學(xué)習(xí)完 JVM 以后再做分享。

參考資料

  1. AT&T與Intel匯編風(fēng)格比較
  2. glibc
  3. WebAssembly 系列(一)生動(dòng)形象地介紹 WebAssembly
  4. Decompilers and beyond
  5. python-for-iphone
  6. 如何實(shí)現(xiàn) C/C++ 與 Python 的通信?
  7. WebAssembly 系列(四)WebAssembly 工作原理
  8. 扯淡:大白話聊聊編譯那點(diǎn)事兒
  9. rubicon-objc
  10. OCS ——史上最瘋狂的 iOS 動(dòng)態(tài)化方案
  11. 虛擬機(jī)隨談(一):解釋器,樹(shù)遍歷解釋器,基于棧與基于寄存器,大雜燴
  12. JavaScript的功能是不是都是靠C或者C++這種編譯語(yǔ)言提供的?
  13. 計(jì)算機(jī)編程語(yǔ)言必須能夠自舉嗎?
  14. 如何評(píng)論瀏覽器***的 WebAssembly 字節(jié)碼技術(shù)?
  15. Objective-C Runtime —— From Build To Did Launch
  16. 10 GENERIC
  17. 寫(xiě)個(gè)編譯器,把C++代碼編譯到JVM的字節(jié)碼可不可行? 
責(zé)任編輯:龐桂玉 來(lái)源: bestswifter
相關(guān)推薦

2016-12-26 17:53:05

Java開(kāi)發(fā)者編程語(yǔ)言

2017-02-05 16:00:35

Java編程語(yǔ)言

2017-01-15 17:48:04

Java開(kāi)發(fā)者編程語(yǔ)言

2020-03-04 11:20:22

DSL開(kāi)發(fā)領(lǐng)域特定語(yǔ)言

2013-04-19 09:23:34

2013開(kāi)發(fā)者開(kāi)發(fā)趨勢(shì)和技能

2018-06-15 08:43:33

Java堆外內(nèi)存

2020-04-03 09:00:00

微服務(wù)前端架構(gòu)

2012-02-06 09:14:24

2021-05-10 10:01:04

JavaScript開(kāi)發(fā)技巧

2013-07-10 11:11:05

PythonGo語(yǔ)言

2011-09-20 09:27:50

Web

2025-01-03 11:43:53

2022-10-26 07:21:15

網(wǎng)絡(luò)視頻開(kāi)發(fā)

2019-03-12 10:38:18

前端開(kāi)發(fā)Nginx

2014-12-15 10:25:21

移動(dòng)開(kāi)發(fā)像素設(shè)計(jì)

2017-10-12 18:42:08

前端HTML5基礎(chǔ)知識(shí)

2017-10-29 06:50:30

前端開(kāi)發(fā)CSSWeb

2024-12-13 08:02:55

大模型GPT后端

2016-08-05 16:28:05

javascripthtml前端

2012-04-01 09:10:17

WEB設(shè)計(jì)師前端
點(diǎn)贊
收藏

51CTO技術(shù)棧公眾號(hào)