在我剛剛進入大學,從零開始學習 C 語言的時候,我就不斷的從學長的口中聽到一個又一個語言,比如 C++、Java、Python、JavaScript 這些大眾的,也有 Lisp、Perl、Ruby 這些相對小眾的。一般來說,當程序員討論一門語言的時候,默認的上下文經常是:“用 xxx 語言來完成 xxx 任務”。所以一直困擾著的我的一個問題就是,為什么完成某個任務,一定要選擇特定的語言,比如安卓開發是 Java,前端要用 JavaScript,iOS 開發使用 Objective-C 或者 Swift。這些問題的答案非常復雜,有的是技術原因,有的是歷史原因,有的會考慮成本,很難得出統一的結論,只能 case-by-case 的分析。這篇文章并非專門解答上述問題,而是希望通過介紹一些通用的概念,幫助讀者掌握分析問題的能力,如果這個概念在實際編程中用得到,我也會舉一些具體的例子。
在閱讀本文前,不妨思考一下這幾個問題,如果沒有頭緒,建議看完文章以后再思考一遍。如果覺得答案顯而易見,恭喜你,這篇文章并非為你準備的:
什么是編譯器,它以什么為分界線,分為前端和后端? Java 是編譯型語言還是解釋型語言,Python 呢? C 語言的編譯器也是 C 語言,那它怎么被編譯的? 目標文件的格式是什么樣的,段表、符號表、重定位表有什么作用? Swift 是靜態語言,為什么還有運行時庫? 什么是 ABI,ABI 不穩定有什么問題? 什么是 WebAssembly,為什么要推出這門技術,用 C++ 代替 JavaScript 可行么? JavaScript 和 DOM API 是什么關系,JavaScript 可以讀寫文件么? C++ 代碼可以自動轉換成 Java 代碼么,任意兩種語言是否可以互轉? 為什么說 Python 是膠水語言,它可以用來開發 iOS/Android 么?
編譯原理
就像數學是一個公理體系,從簡單的公理就能推導出各種高階公式一樣,我們從最基本的 C 語言和編譯說起。
int main(void) { int a = strlen(“Hello world”); // 字符串的長度是 11 return 0; }
相關的介紹編譯過程的文章很多,讀者應該都非常熟悉了,整個流程包括預處理、詞法分析、語法分析、生成中間代碼,生成目標代碼,匯編,鏈接 等。已有的文章大多分析了每一步的邏輯,但很少談實現思路,我會盡量用簡單的語言來描述每一步的實現思路,相信這樣有助于加深記憶。由于主要談的概念和思路,難免會有一些不夠準確的抽象,讀者學會抓重點就行。
預處理是一個獨立的模塊,它放在最后介紹,我們先看詞法分析。
詞法分析
最先登場的是編譯器,它負責前五個步驟,也就是說編譯器的輸入是源代碼,輸出是中間代碼。
編譯器不能像人一樣,一眼就看明白源代碼的內容,它只能比較傻的逐個單詞分析。詞法分析要做的就是把源代碼分割開,形成若干個單詞。這個過程并不像想象的那么簡單。比如舉幾個例子:
int t 表示一個整數,而 intt 只是一個變量名。 int a 表示一個函數而非整數 a,int a 也是一個函數。 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 為例,生成中間代碼可以分為三個步驟:
語法樹轉高端 gimple 高端 gimple 轉低端 gimple 低端 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 與具體實現無關??梢岳斫鉃橹虚g代碼是一種非常抽象,又非常普適的代碼。它客觀中立的描述了代碼要做的事情,如果用中文、英文來分別表示 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 識別并執行。從目標代碼可以猜出來,最終的目標文件(機器碼)也是分段的,這主要有以下三個原因:
分段可以將數據和代碼區分開。其中代碼只讀,數據可寫,方便權限管理,避免指令被改寫,提高安全性。 現代 CPU 一般有自己的數據緩存和指令緩存,區分存儲有助于提高緩存命中率。 當多個進程同時運行時,他們的指令可以被共享,這樣能節省內存。
段分離我們并不遙遠,比如命令行中的 objcopy 可以自行添加自定義的段名,C 語言的 __attribute((section(段名)))__ 可以把變量定義在某個特定名稱的段中。
對于一個目標文件來說,文件的最開頭(也叫作 ELF 頭)記錄了目標文件的基本信息,程序入口地址,以及段表的位置,相當于是對文件的整體描述。接下來的重點是段表,它記錄了每個段的段名,長度,偏移量。比較常用的段有:
.strtab 段: 字符串長度不定,分開存放浪費空間(因為需要內存對齊),因此可以統一放到字符串表(也就是 .strtab 段)中進行管理。字符串之間用
版權聲明:本文內容由互聯網用戶自發貢獻,該文觀點僅代表作者本人。本站僅提供信息存儲空間服務,不擁有所有權,不承擔相關法律責任。如發現本站有涉嫌抄襲侵權/違法違規的內容, 請發送郵件至 舉報,一經查實,本站將立刻刪除。