[譯] 理解編譯器 —— 從人類的角度(版本 2)

Starrier發表於2018-12-12

理解編譯器 —— 從人類的角度(版本 2)

程式語言的工作原理

[譯] 理解編譯器 —— 從人類的角度(版本 2)

理解編譯器的內部原理會促使你更高效地使用它。在這個按時間排序的概要中,瞭解程式語言和編譯器的工作原理。(為此)編寫了大量的連結、示例程式碼和圖表來幫助你理解。


作者標註

理解編譯器 —— 從人類的角度(Version 2)是我在 Medium 上發表的第二篇文章(有超過 21000 的閱讀量)的後續。我很高興自己的內容對大家產生了積極的影響,我也很開心能根據從原文章收集到的意見來對其進行完整的重寫

我選擇 Rust 作為這篇文章的首選語言。因為它詳細、高效、現代化,而且從設計上看,編寫編譯器時會相對簡單。我非常喜歡它。www.rust-lang.org/

寫這篇文章的目的是為了保證讀者能集中精神,而不是 20 頁的精神疲憊閱讀。你可以在文中的許多連結中,選擇自己感興趣的內容,去了解相關內容的深層解讀。當然,大部分都是連結向維基百科的。

請隨意在文末進行評論,或者說出問題建議。感謝你的關注,希望你可以喜歡這篇文章。


簡介

什麼是編譯器

當然,你也可以認為程式語言就是叫做編譯器的軟體,它讀取文字檔案,對其進行大量的處理,然後生成二進位制檔案。由於計算機只能讀 1 和 0,比起二進位制,人類更擅長寫 Rust,所以編譯器將人類可讀的文字轉化為計算機可讀的機器程式碼。**

編譯器可以是將一個文字轉變成另一個文字的程式。比如,這裡有用 Rust 編寫的編譯器,它將 0 與 1 相互轉化:

// 一個示例編譯器,將 0 與 1 互換。
 
fn main() {
    let input = "1 0 1 A 1 0 1 3";
    
    // 對輸入的每個字元 `c` 進行迭代
    let output: String = input.chars().map(|c|
        if c == '1' { '0' }
        else if c == '0' { '1' }
        else { c } // 如果不是 0 或 1,就忽略它(不進行處理)
    ).collect();
    
    println!("{}", output); // 0 1 0 A 0 1 0 3
}
複製程式碼

儘管這個編譯器不讀取檔案,不生成 AST(抽象語法樹)或者二進位制檔案,但它仍然被看成是一個編譯器,原因很簡單,就是它可以翻譯輸入的內容。

編譯器會做什麼事情

簡而言之,編譯器讀取原始碼並生產二進位制檔案。由於直接從人類可讀的複雜程式碼轉換成 1 和 0 非常複雜,因此編譯器在執行之前會有幾個處理步驟:

  1. 讀取給定原始碼的每個字元。
  2. 將字元排序為字母、數字、符號和運算子。
  3. 獲取已排序的字元,通過將它們與已有模式匹配並建立操作樹來確定它們要執行的操作。
  4. 迭代上一步生成的樹中的每一個操作,並生成等效的二進位制檔案。

雖然我說編譯器會立即從運算子樹轉換為二進位制,但它實際上會生成彙編程式碼,然後組裝/編譯成二進位制程式碼,彙編是一個更高層次的、人類可讀的二進位制檔案。更多程式集的相關閱讀可在此查詢

[譯] 理解編譯器 —— 從人類的角度(版本 2)

直譯器是什麼

直譯器更像是編譯器,因為它們都讀取一種語言,然後對其進行處理。但是直譯器會跳過程式碼生成,即時生成 AST。對直譯器來說,最大的優點就是降低在除錯執行期間所花費時間。編譯器在執行前可能需要從一秒鐘到幾分鐘的時間來編譯程式,而直譯器則會立即開始執行,而不需要編譯。直譯器最大的缺點是需要在程式執行之前安裝在使用者的計算機上。

[譯] 理解編譯器 —— 從人類的角度(版本 2)

本文主要涉及編譯器,但應該清楚它們之間的區別以及編譯器之間的關係。

1. 詞法分析

第一步是將輸入的內容分割成字元。這一步稱為詞法分析,或標記化。主要目的是將字元組合在一起,形成我們的單詞、識別符號、符號等。詞法分析通常不處理任何邏輯上的問題,比如求解 2+2 —— 它只會說有三個標記:一個數字:2,一個加號,以及另一個數字:2

假設你是在給像 12+3 這樣的字串下定義:它會讀取字元 12+3。我們有單獨的字元,但我們必須將它們組合在一起;這是 tokenizer 的主要任務之一。比如,儘管我們將 12 最為單獨的字母,但我們最後還是要將它們組合在一起,然後解析成一個整數。+ 將被識別為一個加號,而不是它的字面量值 —— 字元碼 43。

[譯] 理解編譯器 —— 從人類的角度(版本 2)

如果你可以看到程式碼並以這種方式使其更具意義,那麼以下的 Rust 標記生成器可以將數字分成 32 位整數,並加上符號作為 TokenPlus

Rust Playground:play.rust-lang.org

你可以單擊 Rust Playground 左上角的 “RUn” 按鈕,在你的瀏覽器中編譯並執行程式碼。

在程式語言的編譯器中,lexer 詞法分析器可能需要幾種不同型別的標記。例如,符號、數字、識別符號、字串、運算子等。這完全取決於語言本身是否知道你需要從原始碼中提取什麼樣的標記。

int main() {
    int a;
    int b;
    a = b = 4;
    return a - b;
}

掃描生成內容:
[Keyword(Int), Id("main"), Symbol(LParen), Symbol(RParen), Symbol(LBrace), Keyword(Int), Id("a"), Symbol(Semicolon), Keyword(Int), Id("b"), Symbol(Semicolon), Id("a"), Operator(Assignment), Id("b"),
Operator(Assignment), Integer(4), Symbol(Semicolon), Keyword(Return), Id("a"), Operator(Minus), Id("b"), Symbol(Semicolon), Symbol(RBrace)]
複製程式碼

已進行詞法分析的 C 原始碼示例及其標記。

2. 解析

解析器確實是語法的核心。解析器獲取由 lexer 生成的標記,試圖檢視它們是否在某些模式中,然後將這些模式與諸如呼叫函式、回撥變數或者數學操作相關聯。解析器實際上定義了語言的語法。

在解析器中,片語 int a = 3a: int = 3 之間的區別。解析器決定了語法的外觀。它確保括號和大括號的平衡性,每個語句都以分號結尾,而且每個函式都有一個名稱。當程式碼不符合順序,標記與預期模式不符,解析器都會知道。

有幾種不同的型別解析器可以編寫。其中最常見的一種是自頂向下的 recursive-descent 解析器Recursive-descent 解析器使用和理解起來都是最簡單的方法。我建立的所有解析器示例都是基於 recursive-descent

解析器解析的語法可以使用語法進行概括。像 EBNF 這樣的語法可以描述像 12+3 這樣簡單數字操作的解析器:

expr = additive_expr ;
additive_expr = term, ('+' | '-'), term ;
term = number ;
複製程式碼

用於簡單加減表示式的 EBNF 語法。

請記住,語法檔案不是解析器,但是它是解析器所做工作的概要。你可以圍繞這樣的語法構建一個解析器。它將被人類使用,並且比直接檢視解析器的程式碼更容易閱讀和理解。

該語法的解析器是 expr 解析器,因為它是頂級內容,所以基本上所有的內容都與之相關。唯一有效的輸入必須是任意數字之間的加減。expr 期望 additive_expr 出現的主要是進行加減的地方。additive_expr 首先期望一個 term(一個數字),然後對另一個 term 進行加減。

[譯] 理解編譯器 —— 從人類的角度(版本 2)

解析 12 + 3 而生產的示例 AST。

解析器在解析過程生成的樹稱為抽象語法樹,或者 AST。AST 擁有所有的操作。解析器不計算操作,只保證按正確的順序記錄它們。

我將它們新增到之前的 lexer 程式碼中,這樣就可以匹配我們的語法,並且可以像圖表一樣生成 AST。我用註釋 // BEGIN PARSER //// END PARSER // 標記了新解析程式碼的開頭和結尾。

Rust 頁面:play.rust-lang.org

事實上我們可以瞭解的更深入。假設我們想要支援僅僅是沒有運算子的數字輸入,或者新增乘法和除法,甚至是新增優先順序。這可以快速更改語法檔案,並進行調整以將其反映在我們的解析器程式碼中。

expr = additive_expr ;
additive_expr = multiplicative_expr, { ('+' | '-'), multiplicative_expr } ;
multiplicative_expr = term, { ("*" | "/"), term } ;
term = number ;
複製程式碼

新語法。

Rust 頁面:play.rust-lang.org

[譯] 理解編譯器 —— 從人類的角度(版本 2)

C 的掃描器(又名詞法分析器)和直譯器示例。從字元 "if(net>0.0)total+=net*(1.0+tax/100.0);" 開始,掃描器組成一系列標記,併為每個標記分類,例如,作為識別符號、保留字。數字文字或運算子。後一個序列由解析器轉化為語法樹,然後由其餘的編譯器階段處理。掃描器和解析器分別處理 C 語法中正常和適當上下文無關的部分。Credit:Jochen Burghardt。原件

3. 生成程式碼

程式碼生成器接受 AST,然後在程式碼或彙編中生成等效的程式碼程式碼生成器必須以遞迴下降的順序遍歷 AST 中的每一項 —— 就像解析器的工作原理 —— 然後在程式碼中發出等效項。

如果開啟上面的連結,你會看到左邊示例程式碼生成的程式集。彙編程式碼的第 3 行和第 4 行顯示了編譯器在 AST 中遇到常量時是如果生成常量程式碼的。

Godbolt 編譯器管理資源是一個優秀的工具,允許你用高階語言編寫程式碼並檢視其生產的彙編程式碼。你可以隨意檢視這些,看看應該編寫什麼樣的程式碼,但不要忘記將優化標誌新增到語言的編譯器中,看看它們有多高明(Rust 的 -O)。

如果你對編譯器如何在 ASM 中將區域性變數儲存到記憶體中感興趣,這篇文章(“程式碼生成”部分)詳細解釋了。在變數不是本地變數的多數情況下,高階編譯器將在堆上的為變數分配記憶體,並將它們儲存在堆中而不是棧中。你可以在 StackOverflow 上閱讀更多關於儲存變數的資訊。

由於組裝是一個完全不同的複雜主題,所以我不會詳細討論它。我只想強調程式碼生成器的重要性以及工作原理。此外,程式碼生成器可以產生的不僅僅是集合內容。Haxe 編譯器有一個 backend,可以生成六種以上不同的程式語言;包括 C++、Java 和 Python。

後端主要是編譯器的程式碼生成器或計算程式;因此,前端是 lexer 和解析器。還有一個與優化相關的中介軟體。IRs 將在本節末解釋。後端大部分與前端無關,它只關心接收到的 AST。這意味著可以為幾種不同的前端或語言重用相同的後端。GNU 編譯器集合就是這種情況。

我想,我再也找不大比我的 C 編譯器生成的後端程式碼更好的示例了:你應該可以找到

生成程式集之後,應該將其寫入一個新的組裝檔案(.s.asm)。然後彙編器(程式集的編譯器)會傳遞該檔案,並以二進位制形式生成等效的檔案。二進位制程式碼會寫入一個稱為物件檔案(.o)的新檔案。

**物件檔案是機器碼,它們是不可執行的。**想讓它們成為可執行檔案,就需要將物件檔案連結在一起。連結器接受這個通用的機器程式碼,並使它們成為一個可執行檔案,一個共享庫靜態庫更多連結器可在此查詢

連結器是基於作業系統變化而來實用性程式。一個獨立的第三方連結器應該可以編譯後端生成的物件程式碼。在生成編譯器時,不再需要建立自己的連結器。

[譯] 理解編譯器 —— 從人類的角度(版本 2)

編譯器可能有一個代理中介軟體,或者是 IR。IR 是為優化或翻譯成另一種語言而無損失的原生指令的表示。IR 不是原始碼,IR 是為了在程式碼中發現優化的可能性而進行的無損簡化。展開迴圈向量化是使用 IR 完成的。更多 IR 相關的優化示例可在此 PDF 參閱。

結論

在你瞭解編譯器之後,你的程式設計開發將會更加高效。將來的某一刻,你也許會對創造自己的程式語言感興趣。希望這篇文章能對你有所幫助。

資源和深入學習的相關文章

  • craftinginterpreters.com/ —— 指導你使用 C 和 Java 編寫編譯器。
  • norasandler.com/2017/11/29/… ——  對我來說,可能是最好的“編寫編譯器”教程。
  • 我的 C 編譯器和科學計算器解析器在這裡以及這裡
  • 另一種稱為 precedence climbing 解析器的示例,可以在這裡找到。Credit:Wesley Norris。

如果發現譯文存在錯誤或其他需要改進的地方,歡迎到 掘金翻譯計劃 對譯文進行修改並 PR,也可獲得相應獎勵積分。文章開頭的 本文永久連結 即為本文在 GitHub 上的 MarkDown 連結。


掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOS前端後端區塊鏈產品設計人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章