在我剛剛進入大學,從零開始學習 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 與具體實現無關。可以理解為中間代碼是一種非常抽象,又非常普適的代碼。它客觀中立的描述了代碼要做的事情,如果用中文、英文來分別表示 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 段)中進行管理。字元串之間用
原創文章,作者:投稿專員,如若轉載,請註明出處:https://www.506064.com/zh-tw/n/269533.html