大前端開(kāi)發(fā)者需要了解的基礎(chǔ)編譯原理和語(yǔ)言知識(shí)
在我剛剛進(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)備的:
- 什么是編譯器,它以什么為分界線,分為前端和后端?
- Java 是編譯型語(yǔ)言還是解釋型語(yǔ)言,Python 呢?
- C 語(yǔ)言的編譯器也是 C 語(yǔ)言,那它怎么被編譯的?
- 目標(biāo)文件的格式是什么樣的,段表、符號(hào)表、重定位表有什么作用?
- Swift 是靜態(tài)語(yǔ)言,為什么還有運(yùn)行時(shí)庫(kù)?
- 什么是 ABI,ABI 不穩(wěn)定有什么問(wèn)題?
- 什么是 WebAssembly,為什么要推出這門技術(shù),用 C++ 代替 JavaScript 可行么?
- JavaScript 和 DOM API 是什么關(guān)系,JavaScript 可以讀寫(xiě)文件么?
- C++ 代碼可以自動(dòng)轉(zhuǎn)換成 Java 代碼么,任意兩種語(yǔ)言是否可以互轉(zhuǎn)?
- 為什么說(shuō) Python 是膠水語(yǔ)言,它可以用來(lái)開(kāi)發(fā) iOS/Android 么?
編譯原理
就像數(shù)學(xué)是一個(gè)公理體系,從簡(jiǎn)單的公理就能推導(dǎo)出各種高階公式一樣,我們從最基本的 C 語(yǔ)言和編譯說(shuō)起。
- int main(void) {
- int a = strlen("Hello world"); // 字符串的長(zhǎng)度是 11
- 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è)例子:
- int t 表示一個(gè)整數(shù),而 intt 只是一個(gè)變量名。
- int a() 表示一個(gè)函數(shù)而非整數(shù) a,int a () 也是一個(gè)函數(shù)。
- 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ǔ)句:
- 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)。以這段代碼為例:
- int fun(int a, int b) {
- int c = 0;
- c = a + b;
- return c;
- }
它的語(yǔ)法樹(shù)如下:
語(yǔ)法樹(shù)將字符串格式的源代碼轉(zhuǎn)化為樹(shù)狀的數(shù)據(jù)結(jié)構(gòu),更容易被計(jì)算機(jī)理解和處理。但它距離中間代碼還有一定的距離。
生成中間代碼
以 GCC 為例,生成中間代碼可以分為三個(gè)步驟:
- 語(yǔ)法樹(shù)轉(zhuǎn)高端 gimple
- 高端 gimple 轉(zhuǎn)低端 gimple
- 低端 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í)是:
- int temp = a + b; // temp 其實(shí)是寄存器
- c = temp;
另外,調(diào)用一個(gè)新的函數(shù)時(shí)會(huì)進(jìn)入到函數(shù)自己的棧,建棧的操作也需要在 gimple 中聲明。
高端 gimple 轉(zhuǎn)低端 gimple
這一步主要是把變量定義,語(yǔ)句執(zhí)行和返回語(yǔ)句區(qū)分存儲(chǔ)。比如:
- int a = 1;
- a++;
- int b = 1;
會(huì)被處理成:
- int a = 1;
- int b = 1;
- a++;
這樣做的好處是很容易計(jì)算一個(gè)函數(shù)到底需要多少??臻g。
此外,return 語(yǔ)句會(huì)被統(tǒng)一處理,放在函數(shù)的末尾,比如:
- if (1 > 0) {
- return 1;
- }
- else {
- return 0;
- }
會(huì)被處理成:
- if (1 > 0) {
- goto a;
- }
- else {
- goto b;
- }
- a:
- return 1;
- b:
- 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)成這樣:
- define i32 @square_unsigned(i32 %a) {
- %1 = mul i32 %a, %a
- ret i32 %1
- }
生成目標(biāo)代碼
目標(biāo)代碼也可以叫做匯編代碼。由于中間代碼已經(jīng)非常接近于實(shí)際的匯編代碼,它幾乎可以直接被轉(zhuǎn)化。主要的工作量在于兼容各種 CPU 以及填寫(xiě)模板。在最終生成的匯編代碼中,不僅有匯編命令,也有一些對(duì)文件的說(shuō)明。比如:
- .file "test.c" # 文件名稱
- .global m # 全局變量 m
- .data # 數(shù)據(jù)段聲明
- .align 4 # 4 字節(jié)對(duì)齊
- .type m, @objc
- .size m, 4
- m:
- .long 10 # m 的值是 10
- .text
- .global main
- .type main, @function
- main:
- pushl %ebp
- movl %esp, %ebp
- ...
匯編
匯編器會(huì)接收匯編代碼,將它轉(zhuǎn)換成二進(jìn)制的機(jī)器碼,生成目標(biāo)文件(后綴是 .o),機(jī)器碼可以直接被 CPU 識(shí)別并執(zhí)行。從目標(biāo)代碼可以猜出來(lái),最終的目標(biāo)文件(機(jī)器碼)也是分段的,這主要有以下三個(gè)原因:
- 分段可以將數(shù)據(jù)和代碼區(qū)分開(kāi)。其中代碼只讀,數(shù)據(jù)可寫(xiě),方便權(quán)限管理,避免指令被改寫(xiě),提高安全性。
- 現(xiàn)代 CPU 一般有自己的數(shù)據(jù)緩存和指令緩存,區(qū)分存儲(chǔ)有助于提高緩存***率。
- 當(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ù):
- function add(a, b) {
- 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 解釋器都可以解釋它:
- function add(a, b) {
- a = a | 0 // 任何整數(shù)和自己做按位或運(yùn)算的結(jié)果都是自己
- b = b | 0 // 所以這個(gè)標(biāo)記不改變運(yùn)算結(jié)果,但是可以提示編譯器 a、b 都是整數(shù)
- 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!!!”:
- #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 應(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)用了:
- import A
- 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ì)變成這樣:
- import objc
- # 這個(gè) objc 是動(dòng)態(tài)加載 libobjc.dylib 得到的
- # Python 會(huì)對(duì) objc 做一些封裝,提供調(diào)用 runtime 的能力
- # 實(shí)際的工作還是交給 libobjc.dylib 完成
- class UIView:
- def __init__(self, param):
- 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 以后再做分享。
參考資料
- AT&T與Intel匯編風(fēng)格比較
- glibc
- WebAssembly 系列(一)生動(dòng)形象地介紹 WebAssembly
- Decompilers and beyond
- python-for-iphone
- 如何實(shí)現(xiàn) C/C++ 與 Python 的通信?
- WebAssembly 系列(四)WebAssembly 工作原理
- 扯淡:大白話聊聊編譯那點(diǎn)事兒
- rubicon-objc
- OCS ——史上最瘋狂的 iOS 動(dòng)態(tài)化方案
- 虛擬機(jī)隨談(一):解釋器,樹(shù)遍歷解釋器,基于棧與基于寄存器,大雜燴
- JavaScript的功能是不是都是靠C或者C++這種編譯語(yǔ)言提供的?
- 計(jì)算機(jī)編程語(yǔ)言必須能夠自舉嗎?
- 如何評(píng)論瀏覽器***的 WebAssembly 字節(jié)碼技術(shù)?
- Objective-C Runtime —— From Build To Did Launch
- 10 GENERIC
- 寫(xiě)個(gè)編譯器,把C++代碼編譯到JVM的字節(jié)碼可不可行?