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

在我剛剛進入大學,從零開始學習 C 語言的時候,我就不斷的從學長的口中聽到一個又一個語言,好比 C++、Java、Python、JavaScript 這些大衆的,也有 Lisp、Perl、Ruby 這些相對小衆的。通常來講,當程序員討論一門語言的時候,默認的上下文常常是:「用 xxx 語言來完成 xxx 任務」。因此一直困擾着的個人一個問題就是,爲何完成某個任務,必定要選擇特定的語言,好比安卓開發是 Java,前端要用 JavaScript,iOS 開發使用 Objective-C 或者 Swift。這些問題的答案很是複雜,有的是技術緣由,有的是歷史緣由,有的會考慮成本,很可貴出統一的結論,只能 case-by-case 的分析。這篇文章並不是專門解答上述問題,而是但願經過介紹一些通用的概念,幫助讀者掌握分析問題的能力,若是這個概念在實際編程中用獲得,我也會舉一些具體的例子。javascript

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

  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;
}複製代碼

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

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

詞法分析

最早登場的是編譯器,它負責前五個步驟,也就是說編譯器的輸入是源代碼,輸出是中間代碼。ios

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

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

詞法分析的主要難點在於,前綴沒法決定一個完整字符串的含義,一般須要看完整句之後才知道每一個單詞的具體含義。同時,C 語言的語法也不簡單,各類關鍵字,括號,逗號,語法等等都會給詞法分析的實現增長難度。程序員

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

若是本身實現的話,思路也不難。外面包一個循環,而後各類 switch...case 就完事了。詞法分析應該算是最簡單的一節。web

語法分析

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

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 也無關的指令集,但不表明不一樣語言生成的中間代碼就能夠通用。好比中間代碼共有 一、二、三、……、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的字節碼可不可行?
相關文章
相關標籤/搜索