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

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

編程語言的工做原理

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


做者標註

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

我選擇 Rust 做爲這篇文章的首選語言。由於它詳細、高效、現代化,並且從設計上看,編寫編譯器時會相對簡單。我很是喜歡它。www.rust-lang.org/android

寫這篇文章的目的是爲了保證讀者能集中精神,而不是 20 頁的精神疲憊閱讀。你能夠在文中的許多連接中,選擇本身感興趣的內容,去了解相關內容的深層解讀。固然,大部分都是連接向維基百科的。ios

請隨意在文末進行評論,或者說出問題建議。感謝你的關注,但願你能夠喜歡這篇文章。git


簡介

什麼是編譯器

固然,你也能夠認爲編程語言就是叫作編譯器的軟件,它讀取文本文件,對其進行大量的處理,而後生成二進制文件。因爲計算機只能讀 1 和 0,比起二進制,人類更擅長寫 Rust,因此編譯器將人類可讀的文本轉化爲計算機可讀的機器代碼。**github

編譯器能夠是將一個文本轉變成另外一個文本的程序。好比,這裏有用 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. 迭代上一步生成的樹中的每個操做,並生成等效的二進制文件。

雖然我說編譯器會當即從運算符樹轉換爲二進制,但它實際上會生成彙編代碼,而後組裝/編譯成二進制代碼,彙編是一個更高層次的、人類可讀的二進制文件。更多程序集的相關閱讀可在此查詢bash

解釋器是什麼

解釋器更像是編譯器,由於它們都讀取一種語言,而後對其進行處理。可是解釋器會跳過代碼生成,即時生成 AST。對解釋器來講,最大的優勢就是下降在調試運行期間所花費時間。編譯器在執行前可能須要從一秒鐘到幾分鐘的時間來編譯程序,而解釋器則會當即開始執行,而不須要編譯。解釋器最大的缺點是須要在程序執行以前安裝在用戶的計算機上。

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

1. 詞法分析

第一步是將輸入的內容分割成字符。這一步稱爲詞法分析,或標記化。主要目的是將字符組合在一塊兒,造成咱們的單詞、標識符、符號等。詞法分析一般不處理任何邏輯上的問題,好比求解 2+2 —— 它只會說有三個標記:一個數字:2,一個加號,以及另外一個數字:2

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

若是你能夠看到代碼並以這種方式使其更具意義,那麼如下的 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 進行加減。

解析 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

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)的新文件。

**對象文件是機器碼,它們是不可執行的。**想讓它們成爲可執行文件,就須要將對象文件連接在一塊兒。連接器接受這個通用的機器代碼,並使它們成爲一個可執行文件,一個共享庫靜態庫更多連接器可在此查詢

連接器是基於操做系統變化而來實用性程序。一個獨立的第三方連接器應該能夠編譯後端生成的對象代碼。在生成編譯器時,再也不須要建立本身的連接器。

編譯器可能有一個代理中間件,或者是 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前端後端區塊鏈產品設計人工智能等領域,想要查看更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章
相關標籤/搜索