大前端開發者需要了解的基礎編譯原理和語言知識

bestswifter發表於2017-06-24

在我剛剛進入大學,從零開始學習 C 語言的時候,我就不斷的從學長的口中聽到一個又一個語言,比如 C++、Java、Python、JavaScript 這些大眾的,也有 Lisp、Perl、Ruby 這些相對小眾的。一般來說,當程式設計師討論一門語言的時候,預設的上下文經常是:“用 xxx 語言來完成 xxx 任務”。所以一直困擾著的我的一個問題就是,為什麼完成某個任務,一定要選擇特定的語言,比如安卓開發是 Java,前端要用 JavaScript,iOS 開發使用 Objective-C 或者 Swift。這些問題的答案非常複雜,有的是技術原因,有的是歷史原因,有的會考慮成本,很難得出統一的結論,只能 case-by-case 的分析。這篇文章並非專門解答上述問題,而是希望通過介紹一些通用的概念,幫助讀者掌握分析問題的能力,如果這個概念在實際程式設計中用得到,我也會舉一些具體的例子。

在閱讀本文前,不妨思考一下這幾個問題,如果沒有頭緒,建議看完文章以後再思考一遍。如果覺得答案顯而易見,恭喜你,這篇文章並非為你準備的:

  1. 什麼是編譯器,它以什麼為分界線,分為前端和後端?
  2. Java 是編譯型語言還是解釋型語言,Python 呢?
  3. C 語言的編譯器也是 C 語言,那它怎麼被編譯的?
  4. 目標檔案的格式是什麼樣的,段表、符號表、重定位表有什麼作用?
  5. Swift 是靜態語言,為什麼還有執行時庫?
  6. 什麼是 ABI,ABI 不穩定有什麼問題?
  7. 什麼是 WebAssembly,為什麼要推出這門技術,用 C++ 代替 JavaScript 可行麼?
  8. JavaScript 和 DOM API 是什麼關係,JavaScript 可以讀寫檔案麼?
  9. C++ 程式碼可以自動轉換成 Java 程式碼麼,任意兩種語言是否可以互轉?
  10. 為什麼說 Python 是膠水語言,它可以用來開發 iOS/Android 麼?

編譯原理

就像數學是一個公理體系,從簡單的公理就能推匯出各種高階公式一樣,我們從最基本的 C 語言和編譯說起。

int main(void) {
    int a = strlen("Hello world");  // 字串的長度是 11
    return 0;
}複製程式碼

相關的介紹編譯過程的文章很多,讀者應該都非常熟悉了,整個流程包括預處理詞法分析語法分析生成中間程式碼生成目的碼彙編連結 等。已有的文章大多分析了每一步的邏輯,但很少談實現思路,我會盡量用簡單的語言來描述每一步的實現思路,相信這樣有助於加深記憶。由於主要談的概念和思路,難免會有一些不夠準確的抽象,讀者學會抓重點就行。

預處理是一個獨立的模組,它放在最後介紹,我們先看詞法分析。

詞法分析

最先登場的是編譯器,它負責前五個步驟,也就是說編譯器的輸入是原始碼,輸出是中間程式碼。

編譯器不能像人一樣,一眼就看明白原始碼的內容,它只能比較傻的逐個單詞分析。詞法分析要做的就是把原始碼分割開,形成若干個單詞。這個過程並不像想象的那麼簡單。比如舉幾個例子:

  1. int t 表示一個整數,而 intt 只是一個變數名。
  2. int a() 表示一個函式而非整數 a,int a () 也是一個函式。
  3. a = 沒有具體價值,它可以是一個賦值語句,還可以是 a == 1 的字首,表示一個判斷。

詞法分析的主要難點在於,字首無法決定一個完整字串的含義,通常需要看完整句以後才知道每個單詞的具體含義。同時,C 語言的語法也不簡單,各種關鍵字,括號,逗號,語法等等都會給詞法分析的實現增加難度。

詞法分析的主要實現原理是狀態機,它逐個讀取字元,然後根據讀到的字元的特點轉換狀態。比如這是 GCC 的詞法分析狀態機(引用自《編譯系統透視》):

如果自己實現的話,思路也不難。外面包一個迴圈,然後各種 switch...case 就完事了。詞法分析應該算是最簡單的一節。

語法分析

經過詞法分析以後,編譯器已經知道了每個單詞,但這些單片語合起來表示的語法還不清楚。一個簡單的思路是模板匹配,比如有這樣的語句:

int a = 10;複製程式碼

它其實表示了這麼一種通用的語法格式:

型別 變數名 = 常量;

所以 int a = 10; 當然可以匹配上這種模式。同理,它不可能匹配 型別 函式名(引數); 這種函式定義模式,因為兩者結構不一致,等號無法被匹配。

語法分析比詞法分析更復雜,因為所有 C 語言支援的語法特性都必須被語法分析器正確的匹配,這個難度比純新手學習 C 語言語法難上很多倍。不過這個屬於業務複雜性,無論採用哪種解決方案都不可避免,因為語法規則的數量就是這麼多。

在匹配模式的時候,另一個問題在於上述的名詞,比如 型別引數,很難界定。比如 int 是型別,long long 也是型別,unsigned long long 也是型別。(int a) 可以是引數,(int a, int b) 也是引數,(unsigned long long a, long long double b, int *p) 看起來能把人逼瘋。

下面舉一個簡單的例子來解釋 int a = 10 是如何被解析的,總的思路是歸納與分解。我們把一個複雜的式子分割成若干部分,然後分析各個部分,這樣可以簡化複雜度。對於 int a = 10 來說,他是一個宣告,宣告由兩部分組成,分別是宣告說明符和初始宣告符列表。

宣告 宣告說明符 初始宣告符列表
int a = 10 int a = 10
int fun(int a) int fun(int a)
int array[5] int array[5]

宣告說明符比較簡單,它其實是若干個型別的串聯:

宣告說明符 = 型別 + 型別的陣列(長度可以為 0)

而且我們知道若干個型別連在一起又變成了宣告說明符,所以上述等式等價於:

宣告說明符 = 型別 + 宣告說明符(可選)

再嚴謹一些,宣告說明符還可以包括 const 這樣的限定說明符,inline 這樣的函式說明符,和 _Alignas 這樣的對齊說明符。借用書中的公式,它的完整表達如下:

這才僅僅是宣告語句中最簡單的宣告說明符,僅僅是幾個型別和關鍵字的組合而已。後面的初始宣告符列表的解析更復雜。如果有能力做完這些解析,恭喜你,成功的解析了宣告語句。你會發現什麼定義語句啦,呼叫語句啦,正嫵媚的向你招手╮(╯▽╰)╭。

成功解析語法以後,我們會得到抽象語法樹(AST: Abstract Syntax Tree)。以這段程式碼為例:

int fun(int a, int b) {
    int c = 0;
    c = a + b;
    return c;
}複製程式碼

它的語法樹如下:

語法樹將字串格式的原始碼轉化為樹狀的資料結構,更容易被計算機理解和處理。但它距離中間程式碼還有一定的距離。

生成中間程式碼

以 GCC 為例,生成中間程式碼可以分為三個步驟:

  1. 語法樹轉高階 gimple
  2. 高階 gimple 轉低端 gimple
  3. 低端 gimple 經過 cfa 轉 ssa 再轉中間程式碼

簡單的介紹一下每一步都做了什麼。

語法樹轉高階 gimple

這一步主要是處理暫存器和棧,比如 c = a + b 並沒有直接的彙編程式碼和它對應,一般來說需要把 a + b 的結果儲存到暫存器中,然後再把暫存器賦值給 c。所以這一步如果用 C 語言來表示其實是:

int temp = a + b; // temp 其實是暫存器
c =  temp;複製程式碼

另外,呼叫一個新的函式時會進入到函式自己的棧,建棧的操作也需要在 gimple 中宣告。

高階 gimple 轉低端 gimple

這一步主要是把變數定義,語句執行和返回語句區分儲存。比如:

int a = 1;
a++;
int b = 1;複製程式碼

會被處理成:

int a = 1;
int b = 1;
a++;複製程式碼

這樣做的好處是很容易計算一個函式到底需要多少棧空間。

此外,return 語句會被統一處理,放在函式的末尾,比如:

if (1 > 0) {
    return 1;
}
else {
    return 0;
}複製程式碼

會被處理成:

if (1 > 0) {
    goto a;
}
else {
    goto b;
}
a:
    return 1;
b:
    return 0;複製程式碼

低端 gimple 經過 cfa 轉 ssa 再轉中間程式碼

這一步主要是進行各種優化,新增版本號等,我不太瞭解,對於普通開發者來說也沒有學習的必要。

中間程式碼的意義

其實中間程式碼可以被省略,抽象語法樹可以直接轉化為目的碼(彙編程式碼)。然而,不同的 CPU 的彙編語法並不一致,比如 AT&T與Intel彙編風格比較 這篇文章所提到的,Intel 架構和 AT&T 架構的彙編碼中,源運算元和目標運算元位置恰好相反。Intel 架構下運算元和立即數沒有字首但 AT&T 有。因此一種比較高效的做法是先生成語言無關,CPU 也無關的中間程式碼,然後再生成對應各個 CPU 的彙編程式碼。

生成中間程式碼是非常重要的一步,一方面它和語言無關,也和 CPU 與具體實現無關。可以理解為中間程式碼是一種非常抽象,又非常普適的程式碼。它客觀中立的描述了程式碼要做的事情,如果用中文、英文來分別表示 C 和 Java 的話,中間碼某種意義上可以被理解為世界語。

另一方面,中間程式碼是編譯器前端和後端的分界線。編譯器前端負責把原始碼轉換成中間程式碼,編譯器後端負責把中間程式碼轉換成彙編程式碼。

LLVM IR 是一種中間程式碼,它長成這樣:

define i32 @square_unsigned(i32 %a) {
  %1 = mul i32 %a, %a
  ret i32 %1
}複製程式碼

生成目的碼

目的碼也可以叫做彙編程式碼。由於中間程式碼已經非常接近於實際的彙編程式碼,它幾乎可以直接被轉化。主要的工作量在於相容各種 CPU 以及填寫模板。在最終生成的彙編程式碼中,不僅有彙編命令,也有一些對檔案的說明。比如:

    .file       "test.c"      # 檔名稱
    .global     m             # 全域性變數 m
    .data                     # 資料段宣告
    .align      4             # 4 位元組對齊
    .type       m, @objc
    .size       m, 4
m:
    .long       10            # m 的值是 10
    .text
    .global     main
    .type       main, @function
main:
    pushl   %ebp
    movl    %esp,   %ebp
    ...複製程式碼

彙編

彙編器會接收匯編程式碼,將它轉換成二進位制的機器碼,生成目標檔案(字尾是 .o),機器碼可以直接被 CPU 識別並執行。從目的碼可以猜出來,最終的目標檔案(機器碼)也是分段的,這主要有以下三個原因:

  1. 分段可以將資料和程式碼區分開。其中程式碼只讀,資料可寫,方便許可權管理,避免指令被改寫,提高安全性。
  2. 現代 CPU 一般有自己的資料快取和指令快取,區分儲存有助於提高快取命中率。
  3. 當多個程式同時執行時,他們的指令可以被共享,這樣能節省記憶體。

段分離我們並不遙遠,比如命令列中的 objcopy 可以自行新增自定義的段名,C 語言的 __attribute((section(段名)))__ 可以把變數定義在某個特定名稱的段中。

對於一個目標檔案來說,檔案的最開頭(也叫作 ELF 頭)記錄了目標檔案的基本資訊,程式入口地址,以及段表的位置,相當於是對檔案的整體描述。接下來的重點是段表,它記錄了每個段的段名,長度,偏移量。比較常用的段有:

  • .strtab 段: 字串長度不定,分開存放浪費空間(因為需要記憶體對齊),因此可以統一放到字串表(也就是 .strtab 段)中進行管理。字串之間用 \0 分割,所以凡是引用字串的地方用一個數字就可以代表。
  • .symtab: 表示符號表。符號表統一管理所有符號,比如變數名,函式名。符號表可以理解為一個表格,每行都有符號名(數字)、符號型別和符號值(儲存地址)
  • .rel 段: 它表示一系列重定位表。這個表主要在連結時用到,下面會詳細解釋。

連結

在一個目標檔案中,不可能所有變數和函式都定義在檔案內部。比如 strlen 函式就是一個被呼叫的外部函式,此時就需要把 main.o 這個目標檔案和包含了 strlen 函式實現的目標檔案連結起來。我們知道函式呼叫對應到彙編其實是 jump 指令,後面寫上被呼叫函式的地址,但在生成 main.o 的過程中,strlen() 函式的地址並不知道,所以只能先用 0 來代替,直到最後連結時,才會修改成真實的地址。

連結器就是靠著重定位表來知道哪些地方需要被重定位的。每個可能存在重定位的段都會有對應的重定位表。在連結階段,連結器會根據重定位表中,需要重定位的內容,去別的目標檔案中找到地址並進行重定位。

有時候我們還會聽到動態連結這個名詞,它表示重定位發生在執行時而非編譯後。動態連結可以節省記憶體,但也會帶來載入的效能問題,這裡不詳細解釋,感興趣的讀者可以閱讀《程式設計師的自我修養》這本書。

預處理

最後簡單描述一下預處理。預處理主要是處理一些巨集定義,比如 #define#include#if 等。預處理的實現有很多種,有的編譯器會在詞法分析前先進行預處理,替換掉所有 # 開頭的巨集,而有的編譯器則是在詞法分析的過程中進行預處理。當分析到 # 開頭的單詞時才進行替換。雖然先預處理再詞法分析比較符合直覺,但在實際使用中,GCC 使用的卻是一邊詞法分析,一邊預處理的方案。

編譯 VS 解釋

總結一下,對於 C 語言來說,從原始碼到執行結果大致上需要經歷編譯、彙編和連結三個步驟。編譯器接收原始碼,輸出目的碼(也就是彙編程式碼),彙編器接收匯編程式碼,輸出由機器碼組成的目標檔案(二進位制格式,.o 字尾),最後連結器將各個目標檔案連結起來,執行重定位,最終生成可執行檔案。

編譯器以中間程式碼為界限,又可以分前端和後端。比如 clang 就是一個前端工具,而 LLVM 則負責後端處理。另一個知名工具 GCC(GNU Compile Collection)則是一個套裝,包攬了前後端的所有任務。前端主要負責預處理、詞法分析、語法分析,最終生成語言無關的中間程式碼。後端主要負責目的碼的生成和優化。

關於編譯原理的基礎知識雖然枯燥,但掌握這些知識有助於我們理解一些有用的,但不太容易理解的概念。接下來,我們簡單看一下別的語言是如何執行的。

Java

在 Java 程式碼的執行過程中,可以簡單分為編譯和執行兩步。Java 的編譯器首先會把 .java 格式的原始碼編譯成 .class 格式的位元組碼。位元組碼對應到 C 語言的編譯體系中就是中間碼,Java 虛擬機器執行這些中間碼得到最終結果。

回憶一下上文對中間碼的解釋,一方面它與語言無關,僅僅描述客觀事實。另一方面它和目的碼的差距並不大,已經包括了對暫存器和棧的處理,僅僅是抽象了 CPU 架構而已,只要把它具體化成各個平臺下的目的碼,就可以交給彙編器了。

解釋型語言

一般來說我們也把解釋型語言叫做指令碼語言,比如 Python、Ruby、JavaScript 等等。這類語言的特點是,不需要編譯,直接由直譯器執行。換言之,執行流程變成了:

原始碼 -> 直譯器 -> 執行結果

需要注意的是,這裡的直譯器只是一個黑盒,它的實現方式可以是多種多樣的。舉個例子,它的實現可以非常類似於 Java 的執行過程。直譯器裡面可以包含一個編譯器和虛擬機器,編譯器把原始碼轉化成 AST 或者位元組碼(中間程式碼)然後交給虛擬機器執行,比如 Ruby 1.9 以後版本的官方實現就是這個思路。

至於虛擬機器,它並不是什麼黑科技,它的內部可以編譯執行,也可以解釋執行。如果是編譯執行,那麼它會把位元組碼編譯成當前 CPU 下的機器碼然後統一執行。如果是解釋執行,它會逐條翻譯位元組碼。

有意思的是,如果虛擬機器是編譯執行的,那麼這套流程和 C 語言幾乎一樣,都滿足下面這個流程:

原始碼 -> 中間程式碼 -> 目的碼 -> 執行結果

下面是重點!!!
下面是重點!!!
下面是重點!!!

因此,解釋型語言和編譯型語言的根本區別在於,對於使用者來說,到底是直接從原始碼開始執行,還是從中間程式碼開始執行。以 C 語言為例,所有的可執行程式都是二進位制檔案。而對於傳統意義的 Python 或者 JavaScript,使用者並沒有拿到中間程式碼,他們直接從原始碼開始執行。從這個角度來看, Java 不可能是解釋型語言,雖然 Java 虛擬機器會解釋位元組碼,但是對於使用者來說,他們是從編譯好的 .class 檔案開始執行,而非原始碼。

實際上,在 x86 這種複雜架構下,二進位制的機器碼也不能被硬體直接執行,CPU 會把它翻譯成更底層的指令。從這個角度來說,我們眼中的硬體其實也是一個虛擬機器,執行了一些“抽象”指令,但我相信不會有人認為 C 語言是解釋型語言。因此,有沒有虛擬機器,虛擬機器是不是解釋執行,會不會生成中間程式碼,這些都不重要,重要的是如果從中間程式碼開始執行,而且 AST 已經事先生成好,那就是編譯型的語言。

如果更本質一點看問題,根本就不存在解釋型語言或者編譯型語言這種說法。已經有人證明,如果一門語言是可以解釋的,必然可以開發出這門語言的編譯器。反過來說,如果一門語言是可編譯的,我只要把它的編譯器放到直譯器裡,把編譯推遲到執行時,這麼語言就可以是解釋型的。事實上,早有人開發出了 C 語言的直譯器:

C 原始碼 -> C 語言直譯器(執行時編譯、彙編、連結) -> 執行結果

我相信這一點很容易理解,規範和實現是兩套分離的體系。我們平常說的 C 語言的語法,實際上是一套規範。理論上來說每個人都可以寫出自己的編譯器來實現 C 語言,只要你的編譯器能夠正確執行,最終的輸出結果正確即可。而編譯型和解釋型說的其實是語言的實現方案,是提前編譯以獲得最大的效能提高,還是執行時去解析以獲得靈活性,往往取決於語言的應用場景。所以說一門語言是編譯型還是解釋型的,這會非常可笑。一個標準怎麼可能會有固定的實現呢?之所以給大家留下了 C 語言是編譯型語言,Python 是解釋型語言的印象,往往是因為這門語言的應用場景決定了它是主流實現是編譯型還是解釋型。

自舉

不知道有沒有人思考過,C 語言的編譯器是如何實現的?實際上它還是用 C 語言實現的。這種自己能編譯自己的神奇能力被稱為自舉(Bootstrap)。

乍一看,自舉是不可能的。因為 C 語言編譯器,比如 GCC,要想執行起來,必定需要 GCC 的編譯器將它編譯成二進位制的機器碼。然而 GCC 的編譯器又如何編譯呢……

解決問題的關鍵在於打破這個迴圈,我們可以先用一個比 C 語言低階的語言來實現一個 C 語言編譯器。這件事是可能做到的,因為這個低階語言必然會比 C 語言簡單,比如我們可以直接用匯編程式碼來寫 C 語言的編譯器。由於越低階的語言越簡單,但表達能力越弱,所以用匯編來寫可能太複雜。這種情況下我們可以先用一個比 C 語言低階但比彙編高階的語言來實現 C 語言的編譯器,同時用匯編來實現這門語言的編譯器。總之就是不斷用低階語言來寫高階語言的編譯器,雖然語言越低階,它的表達能力越弱,但是它要解析的語言也在不斷變簡單,所以這件事是可以做到的。

有了低階語言寫好的 C 語言編譯器以後,這個編譯器是二進位制格式的。此時就可以刪掉所有的低階語言,只留一個二進位制格式的 C 語言編譯器,接下來我們就可以用 C 語言寫編譯器,再用這個二進位制格式的編譯器去編譯 C 語言實現的 C 語言編譯器了,於是完成了自舉。

以上邏輯描述起來比較繞,但我想多讀幾遍應該可以理解。如果實在不理解也沒關係,我們只要明白 C 語言可以自舉是因為它可以編譯成二進位制機器碼,只要用低階語言生成這個機器碼,就不再需要低階語言了,因為機器碼可以直接被 CPU 執行。

從這個角度來看,解釋型語言是不可能自舉的。以 Python 為例,自舉要求它能用 Python 語言寫出來 Python 的直譯器,然而這個直譯器如何執行呢,最終還是需要一個直譯器。而直譯器體系下, Python 都是從原始碼經過直譯器執行,又不能留下什麼可以直接被硬體執行的二進位制形式的直譯器檔案,自然是沒辦法自舉的。然而,就像前面說的,Python 完全可以實現一個編譯器,這種情況下它就是可以自舉的。

所以一門語言能不能自舉,主要取決於它的實現形式能否被編譯並留下二進位制格式的可執行檔案。

執行時

本文的讀者如果是使用 Objective-C 的 iOS 開發者,想必都有過在面試時被 runtime 支配的恐懼。然而,runtime 並非是 Objective-C 的專利,絕大多數語言都有這個概念。所以有人說 Objective-C 具有動態性是因為它有 runtime,這種說法並不準確,我覺得要把 Objective-C 的 runtime 和一般意義的執行時庫區分開,認識到它僅僅是執行時庫的一個組成部分,同時還是要深入到方法呼叫的層面來談。

執行時庫的基本概念

以 C 語言為例,有非常多的操作最終都依賴於 glibc 這個動態連結庫。包括但不限於字串處理(strlenstrcpy)、訊號處理、socket、執行緒、IO、動態記憶體分屏(malloc)等等。這一點很好理解,如果回憶一下之前編譯器的工作原理,我們會發現它僅僅是處理了語言的語法,比如變數定義,函式宣告和呼叫等等。至於語言的功能, 比如記憶體管理,內建的型別,一些必要功能的實現等等。如果要對執行時庫進行分類,大概有兩類。一種是語言自身功能的實現,比如一些內建型別,內建的函式;另一種則是語言無關的基礎功能,比如檔案 IO,socket 等等。

由於每個程式都依賴於執行時庫,這些庫一般都是動態連結的,比如 C 語言的 (g)libc。這樣一來,執行時庫可以儲存在作業系統中,節省記憶體佔用空間和應用程式大小。

對於 Java 語言來說,它的垃圾回收功能,檔案 IO 等都是在虛擬機器中實現,並提供給 Java 層呼叫。從這個角度來看,虛擬機器/直譯器也可以被看做語言的執行時環境(庫)。

swift 執行時庫

經過這樣的解釋,相信 swift 的執行時庫就很容易理解了。一方面,swift 是絕對的靜態語言,另一方面,swift 毫無疑問的帶有自己的執行時庫。舉個最簡單的例子,如果閱讀 swift 原始碼就會發現某些型別,比如字串(String),或者陣列,再或者某些函式(print)都是用 swift 實現的,這些都是 swift 執行時庫的一部分。按理說,執行時庫應該內建於作業系統中並且和應用程式動態連結,然而坑爹的 Swift 在本文寫作之時依然沒有穩定 ABI,導致每個程式都必須自帶執行時庫,這也就是為什麼目前 swift 開發的 app 普遍會增加幾 Mb 包大小的原因。

說到 ABI,它其實就是一個編譯後的 API。簡單來說,API 是描述了在應用程式級別,模組之間的呼叫約定。比如某個模組想要呼叫另一個模組的功能,就必須根據被呼叫模組提供的 API 來呼叫,因為 API 中規定了方法名、引數和返回結果的型別。而當原始碼被編譯成二進位制檔案後,它們之間的呼叫也存在一些規則和約定。

比如模組 A 有兩個整數 a 和 b,它們的記憶體佈局如下:

模組 A
初始地址
a
b

這時候別的模組呼叫 A 模組的 b 變數,可以通過初始地址加偏移量的方式進行。

如果後來模組 A 新增了一個整數 c,它的記憶體佈局可能會變成:

模組 A
初始地址
c
a
b

如果呼叫方還是使用相同的偏移量,可以想見,這次拿到的就是變數 a 了。因此,每當模組 A 有更新,所有依賴於模組 A 的模組都必須重新編譯才能正確工作。如果這裡的模組 A 是 swift 的執行時庫,它內建於作業系統並與其他模組(應用程式)動態連結會怎麼樣呢?結果就是每次更新系統後,所有的 app 都無法開啟。顯然這是無法接受的。

當然,ABI 穩定還包括其他的一些要求,比如呼叫和被呼叫者遵守相同的呼叫約定(引數和返回值如何傳遞)等。

JavaScript 那些事

我們繼續剛才有關執行時的話題,先從 JavaScript 的執行時聊起,再介紹 JavaScript 的相關知識。

JavaScript 是如何執行的

JavaScript 和其他語言,無論是 C 語言,還是 Python 這樣的指令碼語言,最大的區別在於 JavaScript 的宿主環境比較奇怪,一般來說是瀏覽器。

無論是 C 還是 Python,他們都有一個編譯器/直譯器執行在作業系統上,直接把原始碼轉換成機器碼。而 JavaScript 的直譯器一般內建在瀏覽器中,比如 Chrome 就有一個 V8 引擎可以解析並執行 JavaScript 程式碼。因此 JavaScript 的能力實際上會受到宿主環境的影響,有一些限制和加強。

首先來看看 DOM 操作,相關的 API 並沒有定義在 ECMAScript 標準中,因此我們常用的 window.xxx 還有 window.document.xxx 並非是 JavaScript 自帶的功能,這通常是由宿主平臺通過 C/C++ 等語言實現,然後提供給 JavaScript 的介面。同樣的,由於瀏覽器中的 JavaScript 只是一個輕量的語言,沒有必要讀寫作業系統的檔案,因此瀏覽器引擎一般不會向 JavaScript 提供檔案讀寫的執行時元件,它也就不具備 IO 的能力。從這個角度來看,整個瀏覽器都可以看做 JavaScript 的虛擬機器或者執行時環境。

因此,當我們換一個宿主環境,比如 Node.js,JavaScript 的能力就會發生變化。它不再具有 DOM API,但多了讀寫檔案等能力。這時候,Node.js 就更像是一個標準的 JavaScript 解析器了。這也是為什麼 Node.js 讓 JavaScript 可以編寫後端應用的原因。

JIT 優化

解釋執行效率低的主要原因之一在於,相同的語句被反覆解釋,因此優化的思路是動態的觀察哪些程式碼是經常被呼叫的。對於那些被高頻率呼叫的程式碼,可以用編譯器把它編譯成機器碼並且快取下來,下次執行的時候就不用重新解釋,從而提升速度。這就是 JIT(Just-In-Time) 的技術原理。

但凡基於快取的優化,一定會涉及到快取命中率的問題。在 JavaScript 中,即使是同一段程式碼,在不同上下文中生成的機器碼也不一定相同。比如這個函式:

function add(a, b) {
    return a + b;
}複製程式碼

如果這裡的 a 和 b 都是整數,可以想見最終的程式碼一定是彙編中的 add 命令。如果類似的加法運算呼叫了很多次,直譯器可能會認為它值得被優化,於是編譯了這段程式碼。但如果下一次呼叫的是 add("hello", "world"),之前的優化就無效了,因為字串加法的實現和整數加法的實現完全不同。

於是優化後的程式碼(二進位制格式)還得被還原成原先的形式(字串格式),這樣的過程被稱為去優化。反覆的優化 -> 去優化 -> 優化 …… 非常耗時,大大降低了引入 JIT 帶來的效能提升。

JIT 理論上給傳統的 JavaScript 帶了了 20-40 倍的效能提升,但由於上述去優化的存在,在實際執行的過程中遠遠達不到這個理論上的效能天花板。

WebAssembly

前文說過,JavaScript 實際上是由瀏覽器引擎負責解析並提供一些功能的。瀏覽器引擎可能是由 C++ 這樣高效的語言實現的,那麼為什麼不用 C++ 來寫網頁呢?實際上我認為從技術角度來說並不存在問題,直接下發 C++ 程式碼,然後交給 C++ 直譯器去執行,再呼叫瀏覽器的 C++ 元件,似乎更加符合直覺一些。

之所以選擇 JavaScript 而不是 C++,除了主流瀏覽器目前都只支援 JavaScript 而不支援 C++ 這個歷史原因以外,更重要的一點是一門語言的高效能和簡單性不可兼得。JavaScript 在執行速度方面做出了犧牲,但也具備了簡單易開發的優點。作為通用程式語言,JavaScript 和 C++ 主要的效能差距就在於缺少型別標註,導致無法進行有效的提前編譯。之前說過 JIT 這種基於快取去猜測型別的方式存在瓶頸,那麼最精確的方式肯定還是直接加上型別標註,這樣就可以直接編譯了,代表性的作品有 Mozilla 的 Asm.js

Asm.js 是 JavaScript 的一個子集,任何 JavaScript 直譯器都可以解釋它:

function add(a, b) {
    a = a | 0  // 任何整數和自己做按位或運算的結果都是自己
    b = b | 0  // 所以這個標記不改變運算結果,但是可以提示編譯器 a、b 都是整數
    return a + b | 0
}複製程式碼

如果有 Asm.js 特定的直譯器,完全可以把它提前編譯出來。即使沒有也沒關係,因為它完全是 JavaScript 語法的子集,普通的直譯器也可以解釋。

然而,回顧一下我們最初對直譯器的定義: 直譯器是一個黑盒,輸入原始碼,輸出執行結果。Asm.js 其實是黑盒內部的一個優化,不同的黑盒(瀏覽器)無法共享這一優化。換句話說 Asm.js 寫成的程式碼放到 Chrome 上面和普通的 JavaScript 毫無區別。

於是,包括微軟、谷歌和蘋果在內的各大公司覺得,是時候搞個標準了,這個標準就是 WebAssembly 格式。它是介於中間程式碼和目的碼之間的一種二進位制格式,借用 WebAssembly 系列(四)WebAssembly 工作原理 一文的插圖來表示:

通常從中間程式碼到機器碼,需要經過平臺具體化(轉目的碼)和二進位制化(彙編器把彙編程式碼變為二進位制機器碼)這兩個步驟。而 WebAssembly 首先完成了第二個步驟,即已經是二進位制格式的,但只是一系列虛擬的通用指令,還需要轉換到各個 CPU 架構上。這樣一來,從 WebAssembly 到機器碼其實是透明且統一的,各個瀏覽器廠商只需要考慮如何從中間程式碼轉換 WebAssembly 就行了。

由於編譯器的前端工具 Clang 可以把 C/C++ 轉換成中間程式碼,因此理論上它們都可以用來開發網頁。然而誰會這麼這麼做呢,放著簡單快捷,現在又高效的 JavaScript 不寫,非要去啃 C++?

跨語言那些事兒

C++ 寫網頁這個腦洞雖然比較大,但它啟發我思考一個問題:“對於一個常見的可以由某個語言完成的任務(比如 JavaScript 寫網頁),能不能換一個語言來實現(比如 C++),如果不能,制約因素在哪裡”。

由於絕大多數主流語言都是圖靈完備的,也就是說一切可計算的問題,在這些語言層面都是等價的,都可以計算。那麼制約語言能力的因素也就只剩下了執行時的環境是否提供了相應的功能。比如前文解釋過的,雖然瀏覽器中的 JavaScript 不能讀寫檔案,不能實現一個伺服器,但這是瀏覽器(即執行時環境)不行,不是 JavaScript 不行,只要把執行環境換成 Node.js 就行了。

直接語法轉換

大部分讀者應該接觸過簡單的逆向工程。比如編譯後的 .o 目標檔案和 .class 位元組碼都可以反編譯成原始碼,這種從中間程式碼倒推回原始碼的技術也被叫做反編譯(decompile),反編譯器的工作流程基本上是編譯器的倒序,只不過完美的反編譯一般來說比較困難,這取決於中間程式碼的實現。像 Java 位元組碼這樣的中間程式碼,由於資訊比較全,所以反編譯就相對容易、準確一些。C 程式碼在生成中間程式碼時丟失了很多資訊,因此就幾乎不可能 100% 準確的倒推回去,感興趣的讀者可以參考一下知名的反編譯工具 Hex-Rays 的一篇部落格

前文說過,編譯器前端可以對多種語言進行詞法分析和語法分析,並且生成一套語言無關的中間程式碼,因此理論上來說,如果某個編譯器前端工具支援兩個語言 A 和 B 的解析,那麼 A 和 B 是可以互相轉換的,流程如下:

A 原始碼 <--> 語言無關的中間程式碼 <--> B 原始碼

其中從原始碼轉換到中間程式碼需要使用編譯器,從中間程式碼轉換到原始碼則使用反編譯器。

但在實際情況中,事情會略複雜一些,這是因為中間程式碼雖然是一套語言無關、CPU 也無關的指令集,但不代表不同語言生成的中間程式碼就可以通用。比如中間程式碼共有 1、2、3、……、6 這六個指令。A 語言生成的中間程式碼僅僅是所有指令的一個子集,比如是 1-5 這 5 個指令;B 語言生成的中間程式碼可能是所有指令的另一個子集,比如 2-6。這時候我們說的 B 語言的反編譯器,實際上是從 2-6 的指令子集推匯出 B 語言原始碼,它對指令 1 可能無能為力。

以 GCC 的中間程式碼 RTL: Register Transfer Language 為例,官方文件 在對 RTL 的解釋中,就明確的把 RTL 樹分為了通用的、C/C++ 特有的、Java 特有的等幾個部分。

具體來說,我們知道 Java 並不能直接訪問記憶體地址,這一點和瀏覽器上的 JavaScript 不能讀寫檔案很類似,都是因為它們的執行環境(虛擬機器)具備這種能力,但沒有在語言層面提供。因此,含有指標四則運算的 C 程式碼無法直接被轉換成 Java 程式碼,因為 Java 位元組碼層面並沒有定義這樣的抽象,一種簡單的方案是申請一個超大的陣列,然後自己模擬記憶體地址。

所以,即使編譯器前端同時支援兩種語言的解析,要想進行轉換,還必須處理兩種語言在中間程式碼層面的一些小差異,實際流程應該是:

A 原始碼 <--> 中間程式碼子集(A) <--介面卡--> 中間程式碼子集(B) <--> B 原始碼

這個思路已經不僅僅停留在理論上了,比如 Github 上有一個庫: emscripten 就實現了將任何 Clang 支援的語言(比如 C/C++ 等)轉換成 JavaScript,再比如 lljvm 實現了 C 到 Java 位元組碼的轉換。

然而前文已經解釋過,實現單純語法的轉換意義並不大。一方面,對於圖靈完備的語言來說,換一種表示方法(語言)去解決相同的問題並沒有意義。另一方面,語言的真正功能絕不僅僅是語法本身,而在於它的執行時環境提供了什麼樣的功能。比如 Objective-C 的 Foundation 庫提供了字典型別 NSDictionary,它如果直接轉換成 C 語言,將是一個找不到的符號。因為 C 語言的執行時環境根本就不提供對這種資料結構的支援。因此凡是在語言層面進行強制轉換的,要麼利用反編譯器拿到一堆格式正確但無法執行的程式碼,要麼就自行解析語法樹併為轉換後的語言新增對應的能力,來實現轉換前語言的功能。

比如圖中就是一個 C 語言轉換 Java 的工具,為了實現 C 語言中的字串申請和釋放記憶體,這個工具不得不自己實現了 com.mtsystems.coot.String8 類。這樣巨大的成本,顯然不夠普適,應用場景相對有限。

總之,直接的語法轉換是一個美好的想法,但實現起來難度大,收益有限,通常是為了移植已經用某個語言寫好的框架,或者開個腦洞用於學習,但實際應用場景並不多。

膠水語言 Python

Python 一個很強大的特點是膠水語言,可以把 Python 理解為各種語言的粘合劑。對於 Python 可以處理的邏輯,用 Python 程式碼即可完成。如果追求極致的效能或者呼叫已經實現的功能,也可以讓 Python 呼叫已經由別的語言實現的模組,以 Python 和 C 語言的互動解釋一下。

首先,如果是 C 語言要執行 Python 程式碼,顯然需要一個 Python 的直譯器。由於在 Mac OS X 系統上,Python 直譯器是一個動態連結庫,所以只要匯入一下標頭檔案即可,下面這段程式碼可以成功輸出 “Hello Python!!!”:

#include <stdio.h>
#import <Python/Python.h>

int main(int argc, const char * argv[]) {
    Py_SetProgramName(argv[0]);
    Py_Initialize();
    PyRun_SimpleString("print 'Hello Python!!!'\n");
    Py_Finalize();
    return 0;
}複製程式碼

如果是在 iOS 應用裡,由於 iOS 系統沒有對應的動態庫,所以需要把 Python 的直譯器打包成一個靜態庫並且連結到應用中,網上已經有人做好了: python-for-iphone,這就是為什麼我們看到一些教育類的應用模擬了 Python 直譯器,允許使用者編寫 Python 程式碼並得到輸出。

Python 呼叫 Objective-C/C 也不復雜,只需要在 C 程式碼中指定要暴露的模組 A 和要暴露的方法 a,然後 Python 就可以直接呼叫了:

import A
A.a()複製程式碼

詳細的教程可以看這裡: 如何實現 C/C++ 與 Python 的通訊?

有時候,如果能把自己熟悉的語言應用到一個陌生的領域,無疑會大大降低上手的難度。以 iOS 開發為例,開發者的日常其實是利用 Objective-C 語法來描述一些邏輯,最終利用 UIKit 等框架完成和應用的互動。 一種很自然而然的想法是,能不能用 Python 來實現邏輯,並且呼叫 Objective-C 的介面,比如 UIKit、Foundation 等。實際上前者是完全可以實現的,但是 Python 呼叫 Objective-C 遠比呼叫 C 語言要複雜得多。

一方面從之前的分析中也能看出,並不是所有的原始碼編譯成目標檔案都可以被 Python 引用;另一方面,最重要的是 Objective-C 方法呼叫的特性。我們知道方法呼叫實際上會被編譯成 msg_Send 並交給 runtime 處理,最終找到函式指標並呼叫。這裡 Objective-C 的 runtime 其實是一個用 C 語言實現動態連結庫,它可以理解為 Objective-C 執行時環境的一部分。換句話說,沒有 runtime 這個庫,包含方法呼叫的 Objective-C 程式碼是不可能執行起來的,因為 msg_Send 這個符號無法被重定向,執行時將找不到 msg_Send 函式的地址。就連原生的 Objective-C 程式碼都需要依賴執行時,想讓 Python 直接呼叫某個 Objective-C 編譯出來的庫就更不可能了。

想用 Python 寫開發 iOS 應用是有可能的,比如: PyObjc,但最終還是要依賴 Runtime。大概的思路是首先用 Python 拿到 runtime 這個庫,然後通過這個庫去和 runtime 互動,進而具備了呼叫 Objective-C 和各種框架的能力。比如我要實現 Python 中的 UIView 這個類,程式碼會變成這樣:

import objc

# 這個 objc 是動態載入 libobjc.dylib 得到的
# Python 會對 objc 做一些封裝,提供呼叫 runtime 的能力
# 實際的工作還是交給 libobjc.dylib 完成

class UIView:
    def __init__(self, param):
        objc.msgSend("UIView", "init", param)複製程式碼

這麼做的價效比並不高,如果和 JSPatch 相比,JSPatch 使用了內建的 JavaScriptCore 作為 JavaScript 的解析器,而 PyObjc 就得自己帶一個 libPython.a 直譯器。此外,由於 iOS 系統的沙盒限制,非越獄機器並不能拿到 libobjc 庫,所以這個工具只能在越獄手機上使用。

OCS

既然說到了 JSPatch 這一類動態化的 iOS 開發工具,我就斗膽猜測一下騰訊 OCS 的實現原理,目前介紹 OCS 的文章寥寥無幾,由於蘋果公司的要求,原文已經被刪除,從新浪部落格上摘錄了一份: OCS ——史上最瘋狂的 iOS 動態化方案。如果用一句話來概述,那麼就是 OCS 是一個 Objective-C 直譯器。

首先,OCS 基於 clang 對下發的 Objective-C 程式碼做詞法、語法分析,生成 AST 然後轉化成自定義的一套中間碼(OSScript)。當然,原生的 Objective-C 可以執行,絕不僅僅是編譯器的功勞。就像之前反覆強調的那樣,執行時環境也必不可少,比如負責 GCD 的 libdispatch 庫,還有記憶體管理,多執行緒等等功能。這些功能原來都由系統的動態庫實現,但現在必須由直譯器實現,所以 OCS 的做法是開發了一套自己的虛擬機器去解釋執行中間碼。這個執行原理就和 JVM 非常類似了。

當然,最終還是要和 Objective-C 的 Runtime 打交道,這樣才能呼叫 UIKit 等框架。由於對虛擬機器的實現原理並不清楚,這裡就不敢多講了,希望在學習完 JVM 以後再做分享。

參考資料

  1. AT&T與Intel彙編風格比較
  2. glibc
  3. WebAssembly 系列(一)生動形象地介紹 WebAssembly
  4. Decompilers and beyond
  5. python-for-iphone
  6. 如何實現 C/C++ 與 Python 的通訊?
  7. WebAssembly 系列(四)WebAssembly 工作原理
  8. 扯淡:大白話聊聊編譯那點事兒
  9. rubicon-objc
  10. OCS ——史上最瘋狂的 iOS 動態化方案
  11. 虛擬機器隨談(一):直譯器,樹遍歷直譯器,基於棧與基於暫存器,大雜燴
  12. JavaScript的功能是不是都是靠C或者C++這種編譯語言提供的?
  13. 計算機程式語言必須能夠自舉嗎?
  14. 如何評論瀏覽器最新的 WebAssembly 位元組碼技術?
  15. Objective-C Runtime —— From Build To Did Launch
  16. 10 GENERIC
  17. 寫個編譯器,把C++程式碼編譯到JVM的位元組碼可不可行?

相關文章