扯淡:大白話聊聊編譯那點事兒

折騰範兒_味精發表於2017-12-13

遷移一批老文章到掘金

notes:本篇其實是我自己的一篇讀書筆記,在看了一些書和部落格之後,想用大白話解釋一下,然後加強自己的知識記憶,只是想分享一下

很多細節知識點,在大白話後可能講的很糙,甚至掩蓋了很多技術細節,如有不足,希望指正

我會把看到的相關部落格,書籍,在文尾一一列出,其實還是看書好,更加系統

作為程式設計師的我們,每天寫各種語言的各種程式碼,點一下IDE環境裡的run,或者用一行命令一跑,一個程式就執行起來了。我們寫好的那一行行程式碼,其實就是最普通的文字字串,這些個文字字串是怎麼變成一個個漂亮的介面,一個個大資料量吞吐的伺服器,一個個聰明的人工智慧AI的?這裡面其實經歷了三個過程,編譯,連結,裝載(指令碼語言會特殊一些,本文後面也會提及)

  • 編譯:編譯系統會讀取我們寫的文字字串,去解讀這裡面程式碼所蘊含的意義,解讀出來後會翻譯成機器能看懂的組合語言,我們管這個結果叫目標檔案,這個過程叫編譯

  • 連結:每個類,每個檔案都會被編譯成不同的目標檔案,連結器把這每個目標檔案穿起來,讓他們之間能夠相互呼叫,最後生成可執行檔案,這個過程叫連結

  • 裝載:把已經生成的可執行檔案放到作業系統裡,在系統專屬的程式與記憶體控制下,找到機器可以識別的彙編程式碼入口,開始按著彙編去執行機器碼 ,並且能與作業系統級別的各種系統Api對接起來,這個過程叫裝載

這一篇主要聊聊編譯,但我會持續把大白話聊連結,裝載堅持寫完

編譯步驟

怎麼能讓編譯系統理解你寫出來的一行行字串,讓機器去讀懂你寫的程式碼?這裡面其實經歷了很多步驟,我之前從antlr扯淡到一點點編譯原理裡提到過一點點,這裡我們再扯一扯~

  • 預編譯
  • 詞法分析
  • 語法分析
  • 語義分析
  • 生成抽象語法樹
  • 生成中間碼
  • 目的碼生成
  • 目的碼優化

預編譯

在預編譯的過程中,會處理原始碼中的那些以#開頭的預編譯指令,比如#include,#define,#ifdef等,在編譯開始前就先對原始的程式碼檔案進行調整,經過了預編譯之後,你寫的程式碼其實已經改變了很多。不能說面目全非,但也變化很大,不再是你親手寫出來的樣子了

  • #define大家都知道是巨集定義,在預編譯環節會掃描所有的巨集,並將巨集展開成真正的原始程式碼

  • #include大家知道是引用的意思,但是引用在預編譯階段是怎麼操作的呢?其實這行預編譯指令就是原封不動的把.h檔案插入到寫include的位置,既然是原封不動的copy插入,這裡會涉及一些命名重定義問題,這就是為什麼有時候寫在.h裡面的定義需要加static關鍵字。

  • #indef大家都知道是條件編譯,通過上面講的幾種預編譯的實際操作過程,也能猜到其實就是字元層面的刪減,只有符合條件編譯的情況,這裡面的程式碼才會被保留,如果編譯條件為false,在輸入編譯器之前,你寫的那大段程式碼就已經被刪掉丟棄了

  • /* */ & //刪除註釋,是滴,註釋是對編譯完全沒用的,因此註釋是不會參與編譯的

  • #pragma是一種編譯器指令,這種編譯器指令會被保留,後續編譯有用

  • 新增行號,給每行程式碼新增行號,萬一編譯報錯,也方便追查

看到巨集的操作我們可以理解到,為什麼我們不推薦把一些業務中常用的常量用巨集來表示,如果一個巨集被修改,那麼相當於所有用巨集的地方,你寫出的程式碼都變了,要重新編譯

如果一個巨集被寫入了.h檔案,這個.h檔案又被include到各種地方,甚至寫入了pch,那麼一個巨集修改,不管你用不用這個巨集,受影響的所有檔案都等同於你直接修改了程式碼,要重新編譯

詞法分析

詞法分析其實是用一個掃描器逐行去掃描整個程式碼檔案,通過一些演算法(有限狀態機演算法)把你寫的字串分割成一個個的記號token,你寫的關鍵字,識別符號,字面量,運算子號等,都會被詞法分析一一識別,分割,然後按順序排列成一個個的標記。

  • 關鍵詞:比如for while if static等會被詞法分析識別成單詞
  • 識別符號:你程式碼起名的常量名字,變數名字等
  • 字面量:你在程式碼裡寫死的一些值,數字,字元等,比如@“1”
  • 特殊符號:+ - * /等等

讓你的程式碼不再是一個char 一個char相互之間無關聯的字串,而變成了一個標記一個標記的標記流(你可以理解有獨立意義的單詞),每一個標記可能是個值,可能是個變數名字,可能是個運算子,可能是個語法單詞。

在詞法分析階段,掃描器是並不清楚這一個個標記是什麼意思的,他不需要知道for代表迴圈,int代表整形,他只需要知道for,int,是一個個獨立的單詞。

其實掃描器在經歷過詞法分析後,會簡單的對這些token進行歸類,符號,常量等可以簡單處理的會放入不同的常量區符號區。

語法分析

語法分析就是真正的編譯器在嘗試讀懂你寫的程式碼了,經過了詞法分析你得到的token流,會按順序輸入語法分析器,語法分析器會嘗試解讀,最終將我們希望表達的自然語義,構建成了一個邏輯上的計算機能識別,能執行,能遍歷的結構--樹狀結構。也就是抽象語法樹(Abstract Syntax Tree)

每一條語句都是一個表示式,複雜語句就是複雜表示式的組合,可以相互巢狀,語法分析會把token流中很明顯的表示式token識別出來,識別出表示式的核心意圖,識別出表示式的引數,同時語法分析器會按著自己內部的運算子優先順序規則,去調整表示式的執行順序。

  • 遇到了 = token,語法分析器會知道這是一個賦值表示式,左邊的token是賦值物件的表示式 or token,右邊是值的表示式 or token
  • 遇到了+ token,語法分析器會知道這是一個加法表示式
  • 按著語法分析器的內建規則,把一個又一個的表示式,按著語義去組合去巢狀,組合成一堆表示式的樹狀結構

如果此時我們寫程式碼不嚴謹,哪裡少了個括號,哪裡賦值沒寫值就直接回車,這時候語法樹都是無法生成的,就會直接編譯報錯。

大學的時候計算機有一門課程作業就是自己實現一個計算器,要求可以用字串連續輸入一串數學運算式,由我們自己用演算法來處理一級運算子 + - 與二級運算子 * / 甚至還要處理括號,最終得出計算器的最終結果。

這個處理過程很像,我們會識別出一行字串裡,優先計算出 * / 二級運算子的結果,用結果再去計算 + - ,最後得出了一個樹按著樹去深度遍歷,就能拿到計算結果

語義分析

我們已經初步得到了抽象語法樹,這個抽象語法樹每個節點都是一個表示式,但是這個表示式是否有意義,此時還並不確定,一個賦值表示式,我不能將一個數值賦值給一個物件指標,一個乘法表示式我不可能把一個地址指標與一個數值相乘。

語義分析就是進行這種靜態分析,會遍歷整個語法樹,把每個節點的表示式都標識型別,並且驗證是否合法,

抽象語法樹

從語法分析開始,就提到生成抽象語法樹(Abstract Syntax Tree),經過了語義分析,AST又變的更加完善,自此最終版的抽象語法樹已經建立完成

生成中間碼

不同硬體平臺的彙編處理都是不一樣的,這和硬體,CPU,匯流排的設計都有關係,一個AST如果想要在各個平臺都能執行,那麼就得生成很多個平臺的彙編碼。

電腦科學領域的任何問題都可以通過增加一個間接的中間層來解決

於是我們在AST與多個平臺的彙編程式碼中間,抽象出了一箇中間碼(Intermediate Representation),他與語言無關(過了AST之後,就和語言無關了),與平臺無關(他並沒有直接生成彙編,在中間碼的設計裡打平了平臺差異硬體差異)。

最後通過目標平臺的彙編器,由中間碼生成彙編

目的碼

計算機是通過彙編來執行操作的,機器能夠聽從並執行諸如,移動到記憶體某個地址位移多少個位元組寫入多少byte的資料,這種彙編指令,因此一個程式如果想讓硬體機器能夠執行,最終一定是通過這樣的彙編指令來實現。

我們手頭有了抽象語法樹,他是一個樹狀的結構,每個節點都是一個表示式的描述,但這畢竟不是機器能讀懂的彙編,因此我們需要遍歷這個AST,把AST的每一個表示式,每一層邏輯,都先轉化成上面提到的中間碼,在根據不同的硬體平臺,翻譯成機器可以讀懂的語言--彙編,最終翻譯成的組合語言檔案,就是目標檔案(以後的篇幅講連結會重點介紹)

這一部分還可以細分為

  • 目的碼生成
  • 目的碼優化

編譯前端-編譯後端

我們介紹了七個環節,預編譯詞法分析語法分析語義分析抽象語法樹中間碼生成目的碼生成目的碼優化,我們把中間碼生成當做一個分界線,前邊的環節就叫編譯前端,後面的環節叫做編譯後端

這麼區分有啥好處?這樣的設計就保證了中間碼這個東西,語言無關&平臺無關。

編譯前端

預編譯詞法分析語法分析語義分析抽象語法樹,這些環節共同組成了編譯前端,編譯前端專門去處理語言專屬的特性。不同的語言他的詞法關鍵字,他的語法規則,語義分析的函式型別校驗,都是不同的,甚至不是所有語言都有預編譯這個環節,但每個語言可以開發一個屬於自己語言的編譯前端,只要生成統一的標準的中間碼,就可以無縫對接給任意編譯後端,這就是語言無關

編譯後端

目的碼生成目的碼優化,這些環節共同組成了編譯後端,其實編譯後端還會有本文沒有深入講的連結環節(將目標檔案串聯成可執行檔案),編譯後端專門負責處理各個平臺的差異,根據不同平臺,編譯後端進行不同的彙編程式碼生成,無論是什麼語言生成的標準中間碼,只要編譯後端支援的硬體平臺,通過編譯後端都能直接生成對應的目標檔案,編譯後端不支援的硬體平臺?擴充套件編譯器,讓編譯器支援一下咯╮(╯_╰)╭,這就是平臺無關

編譯工具

GCC

GCC(GNU Compiler Collection,GNU編譯器套裝),既然是編譯器套裝,那麼其實GCC內部包含了編譯前端與編譯後端所有模組。

GCC的編譯前端部分原本只支援C,後來很快就擴充套件支援了C++,再到後來,GCC也擴充套件支援了很多包括FortranPascalObjective-CJava

GCC的編譯後端也是很強大的,移植各個平臺都支援,包括x86、mips、Alpha、ARM、AVR、IA-64、SPARC、PowerPC等30多種平臺

GCC雖然在被廣泛的使用,但目前也面臨了危機,後起之秀CLang/LLVM,大有全面趕超GCC之勢頭。

Clang/LLVM

先說LLVM吧,以下內容摘抄自百科

LLVM 命名最早源自於底層虛擬機器(Low Level Virtual Machine)的縮寫,由於命名帶來的混亂,目前LLVM就是該專案的全稱。LLVM 核心庫提供了與編譯器相關的支援,可以作為多種語言編譯器的後臺來使用。能夠進行程式語言的編譯期優化、連結優化、線上編譯優化、程式碼生成。LLVM的專案是一個模組化和可重複使用的編譯器和工具技術的集合

扯點歷史原因,最早蘋果也是用GCC進行一整套的編譯連結的,但據說蘋果對Objective-C語言打算加入很多新特性,但GCC開發者並不是很買賬,一度導致蘋果用的GCC版本與GCC主版本的分支割裂。隨著2005年Chris大神加入蘋果,蘋果決定徹底放棄GCC作為編譯後端,採用了全新的,高效的、模組化的、協議更放鬆的LLVM作為編譯後端,蘋果處於一個GCC編譯前端/LLVM編譯後端的狀態。

並且GCC/LLVM的使用起來依然無法滿足蘋果的需求,甚至在蘋果的GCC分支版本擴充套件都無法滿足蘋果的要求,於是蘋果乾脆從零去開發一個編譯前端Clang,目的就是幹掉越來越用的不順手的GCC

於是形成了蘋果現在的Clang/LLVM的編譯前端+編譯後端的編譯體系。

有一種ClangPlugin的外掛開發模式

可以支援在Clang編譯出AST的時候,開發輔助外掛去幹預或者處理Clang生成的AST

比如一些OCLint這種,OC編碼規範靜態檢查

比如直接把OC的AST轉化成JS程式碼甚至類JSPatch程式碼(想到了什麼?滴滴的DynamicCocoa)

一些關於ClangPlugin開發的介紹文章

一些開源社群很火的Clang轉JS的專案

LLVM的發展

隨著LLVM的發展,他模組化、高效的設計收到越來越多的組織青睞,不只是蘋果,越來越多的語言,都開始選擇用LLVM當做編譯後端

如果你要開發一種新的程式語言,在詞法語法解析完成後,你要做什麼,肯定是生成中間程式碼,然後優化,最後編譯成目標機器碼。但是llvm 的中間程式碼不僅效率高而且可讀性很好。那我們就直接拿LLVM過來用就好了,按照你的AST語法樹,利用llvm給你的操作IR的介面,生成等價的IR中間碼,生成IR了,之後所有的事情就交給llvm吧。

編譯器發展故事一則:

看到了一條微博上面講了一個故事

五十年代美國女程式設計師 grace hopper 發明第一個 compiler 之後,遭到頑固牴觸,很多程式設計師情願費時費力的把程式用人工翻譯成機器程式碼,也不願用她的發明。早期的機器程式碼就是像 A4 83 E7 C5 這類如看天書般的東西,使用 compiler 編譯器後工作效率提高几十倍。但是一直到五十年代中期很多程式猿對編譯器仍然強烈牴觸。

當時程式猿的主流觀點是,“讓一個機械的程式,去完成編譯高效程式碼這樣一個偉大的工作,顯然是個愚蠢和傲慢的白日夢”.

今天許多科學家和工程師,看輕 Ai 和自動駕駛技術部署實施的速度,是不是在犯同樣的錯誤?

先不說後面對人工智慧的評論,在計算機的遠古時代,程式設計師們還在人工去寫機器碼,這簡直是一個不敢想象的可怕的事情,而現在編譯器已經發展到,我們程式設計師完全不需要掌握如此底層的知識就能讓各種各樣的程式執行起來,改變我們的世界。

虛擬機器體系

那麼Java呢?Java是這麼操作的麼?大家都知道Java有虛擬機器JVM。

那麼JavaScript呢?大家都知道指令碼都是輸入指令碼引擎去run的,js有jscore or V8引擎。

他們還是遵循一樣的流程嗎?

Java

我們上面提到過,其實GCC後來也支援編譯java,也就是說我們寫的java程式碼也是要經過編譯前端的全部流程,只不過到中間碼這一步產生了分歧

  • C系的編譯語言,生成中間碼後,最終目標是生成彙編,也就是可執行檔案
  • Java的程式碼經過編譯前端後,會生成一種和中間碼類似概念的位元組碼(ByteCode)

我們在前面建立過一個認知,機器能夠識別能夠執行的程式碼,是彙編那種的可執行檔案,中間碼還是位元組碼這種東西,我們的程式認識能夠識別,能夠遍歷,但是機器是不認識的。

所以Java需要一個Java Runtime Envirnment,這裡面就有JVM,Java執行環境就是可以識別這種位元組碼的執行環境,如果一個裝置,內部安裝好了Java Runtime Envirnment,他不需要通過彙編來執行程式碼,Java執行環境可以直接將位元組碼輸入,然後在java自己的虛擬機器JVM裡來執行。

所以Java是這樣一個編譯流程

  • 詞法分析
  • 語法分析
  • 生成AST
  • 生成位元組碼(這個東西其實對應的就類似LLVM中的IR中間碼)

這就完成了Java程式的編譯過程,我們就得到了位元組碼這個結果,就是jar包裡面的內容。

Java的執行流程

  • 目標裝置必須具備 Java Runtime Envirnment
  • 通過Java執行環境來執行位元組碼

JavaScript指令碼語言

都說js是指令碼語言,不需要編譯,是完全解釋執行的,真的是這樣嗎?

js也是需要進行編譯的,但是使用的不同引擎,可能內部的執行流程完全不一樣,拿JavaScriptCore來舉例。

js程式碼會直接在執行的時候輸入給JSCore,JSCore也會進行如下的步驟

  • 預處理
  • 詞法分析
  • 語法分析
  • 生成語法樹
  • 生成位元組碼
  • 用LLInt(Low Level Interpreter 直譯器)執行位元組碼
  • 更低階別的JIT執行(好像還有2種,在執行負擔變大的時候會用更厲害的JIT去執行)

看了這些怎麼感覺和Java那個流程差不多啊?為什麼這玩意叫解釋性語言?

我覺得這裡面有一個本質性區別,就是同一個位元組碼到底在什麼時候生成?

JAVA的機制是,一次編譯生成後,位元組碼可以每次執行的時候直接使用

JavaScript的機制是,每次執行的時候,再進行編譯生成位元組碼,然後執行,下次執行,又要重新編譯生成位元組碼,重新執行。

Web Assembly

令人激動的時候來了,web上的js每次執行都得實時編譯執行,就不能像Java那樣直接下發編好的位元組碼,一次編譯,N次執行呢?

這個腦洞就是目前炙手可熱的Web Assembly,WebAssembly是一種新的位元組碼格式。它的縮寫是".wasm", .wasm 為檔名字尾,是一種新的底層安全的二進位制語法。它被定義為“精簡、載入時間短的格式和執行模型”

各大瀏覽器廠商紛紛跟進,也就是說,直接在瀏覽器裡請求編譯好的wasm位元組碼,就可以直接執行,不必再每次執行的時候像javascriptcore一樣,每次都要編譯一次。

根據我們本文建立的編譯前端的認知,我們其實可以把任何語言(比如C++)經過詞法/語法/語法樹後生成wasm格式的位元組碼,換句話說,用C++開發web也不是不可能~

其實在Web裡執行C/C++也並不是一定只有最新的WebAssembly,那個是最新的高執行效率的一套新標準

通過詞法分析語法分析語法樹位元組碼直譯器的JavaScriptCore工作流程,我們可以重新設計

把面向JS語法的詞法分析語法分析,替換成C的語法

這樣不就實現了一個C/C++的虛擬機器了

已經有開源專案這麼做了

Github JSCPP專案

C-SMILE 一套支援C/C++ JS JAVA四種語言的scripting language

除此之外,這種自己定製一套位元組碼,自己實現一套可以執行位元組碼的虛擬機器,讓你想到了什麼?騰訊的OCS

參考文獻

強烈推薦閱讀文獻 《程式設計師的個人修養-裝載,連結與庫》

相關Link:

編譯器(GNU & GCC & clang & llvm)

gcc編譯器---前端和後端

計算機體系-編譯體系漫遊

llvm之旅第三站 - 認識LLVM IR

Kaileidoscope: LLVM Tutorial Chinese version(中文版)

LLVM和GCC的區別

LLVM相比於JVM,有哪些技術優勢?

JavaScript引擎深度解析--基礎篇(一)位元組碼生成及語法樹的構建詳情分析

虛擬機器隨談(一):直譯器,樹遍歷直譯器,基於棧與基於暫存器,大雜燴

我自己的相關文章Link:

技術爆炸

從antlr扯淡到一點點編譯原理

相關文章