編譯原理概覽

書旅發表於2021-12-17

前言

Go編譯原理系列文章,試圖深入的搞清楚Go文字檔案(.go)被編譯器編譯的整個過程,也就是下邊這十一個過程

關注公眾號:IT猿圈,後臺回覆:Go編譯原理系列1,可獲得pdf版

Untitled.png
圖片來源:《Go語言底層原理剖析》

本系列文章會先從編譯原理的角度,分享一下編譯一門高階語言通常有哪些階段,以及各個階段在做什麼;然後切回到Go編譯器編譯Go文字檔案的過程,看它的編譯過程有哪些自己獨特的地方;最後,因為筆者對PHP語言也比較熟悉,會大致分享一下PHP程式碼的解析與執行過程,剛好它是一門解釋型語言,可以和Go這種編譯型語言做個對比

長文warning!!!

綜上,這個系列文章會包含以下幾個主題

  1. 編譯原理概覽
  2. 詞法分析&語法分析基礎知識
  3. Go編譯過程-詞法分析
  4. Go編譯過程-語法分析
  5. Go編譯過程-抽象語法樹構建
  6. Go編譯過程-型別檢查
  7. Go編譯過程-變數捕獲
  8. Go編譯過程-函式內聯
  9. Go編譯過程-逃逸分析
  10. Go編譯過程-閉包重寫
  11. Go編譯過程-遍歷函式
  12. Go編譯過程-SSA生成
  13. Go編譯過程-機器碼生成
  14. PHP程式碼的解釋與執行-詞法&語法分析
  15. PHP程式碼的解釋與執行-opcode
  16. PHP程式碼的解釋與執行-Zend
  17. 編譯型語言和解釋型語言對比
  18. 總結

為避免內容過於枯燥,相關地方會盡量畫圖

傳統編譯器的編譯階段介紹

我們知道一門高階語言編寫的程式碼,可以被我們自己看懂,但是計算機看不懂。因此,它首先需要被翻譯成一種能夠被計算機執行的形式。完成這項翻譯工作的軟體系統,統稱為編譯器(compiler)

而編譯原理,其實介紹的就是設計和實現編譯器的方法。編譯器設計的原理和技術,還可以應用於編譯器設計之外的很多領域

最熟悉的就比如PHP中會用到模板引擎實現介面設計與程式碼的分離,模板引擎對模板進行編譯,形成可執行的 PHP 程式碼。如果你瞭解編譯技術,會更容易掌握這些模板引擎,甚至寫出更符合領域需求的模板引擎

還有像資料庫軟體、大資料平臺,都會用到編譯原理中的思想。所以,學習編譯原理並不是為了寫一個編譯器,學習其它計算機基礎的東西,也是相同的道理

語言處理器

這部分主要是分享編譯器、直譯器是什麼?以及將源程式翻譯成目標機器的程式碼,中間還可能涉及哪些過程?以及這些過程都幹了什麼?

編譯器

編譯器其實就是一個程式,巨集觀上說,它可以閱讀某一種語言(源語言)編寫的程式,並把該程式翻譯成為一個等價的、用另一種語言(目標語言)編寫的程式
Untitled 1.png

注意:如果目標程式是一個可執行的機器語言程式,那麼它就可以被使用者呼叫,處理輸入併產生輸出

Untitled 2.png

直譯器

直譯器(interpreter)是另一種常見的語言處理器,它並不通過翻譯的方式生成目標程式。從使用者的角度看,直譯器直接利用使用者提供的輸入,執行源程式中指定的操作。在把使用者輸入對映成輸出的過程中,由一個編譯器產生的機器語言目標程式,通常比直譯器快很多。但是,直譯器的錯誤診斷效果,通常比編譯器更好,因為它逐個逐句地執行程式

Untitled 3.png

示例

Java語言處理器結合了編譯和解釋過程。一個Java源程式首先被編譯成一個稱為位元組碼(bytecode)的中間表示形式。然後由一個虛擬機器對得到的位元組碼加以解釋執行。這樣安排的好處之一是在一臺機器上編譯得到的位元組碼可以在另一臺機器上解釋執行。通過網路就可以完成機器之間的遷移

為了更快地完成輸入到輸出的處理,有些被稱為即時(just in time)編譯器的Java編譯器,在執行中間程式處理輸入的前一刻,首先把位元組碼翻譯成為機器語言,然後再執行程式

Untitled 4.png

除了編譯器之外,建立一個可執行的目標程式,還需要一些其它的程式。比如一個源程式可能被分割成多個模組,並存放在不同的檔案中。把原始檔聚合在一起的任務,通常由一個稱為前處理器(preprocessor)的程式完成。前處理器的職責還負責把那些稱為巨集的縮寫形式轉換成源語言的語句(C、C++)

然後,將經過預處理的源程式作為輸入傳遞給一個編譯器。 編譯器可能產生一個組合語言程式作為其輸出,因為組合語言比較容易輸出和除錯。然後,這個組合語言程式由稱為彙編器 (assembler)的程式進行處理,並生成可重定位的機器程式碼

彙編器生成的機器程式碼,在記憶體中存放的起始位置不是固定的,程式碼中的所有地址,都是相對於這個起始位置的相對地址。起始地址+相對地址 = 絕對地址(關於什麼是可重定位的機器程式碼可以參考這篇文章

大型程式經常被分成多個部分進行編譯,因此,可重定位的機器程式碼要和其他可重定位的目標檔案以及庫檔案連結到一起,形成真正在機器上執行的程式碼。一個檔案中的程式碼可能指向另一個檔案中的位置,而連結器(linker)能夠解決外部記憶體地址的問題(外部記憶體地址,是指,一個檔案中的程式碼,可能會引用另外一個檔案中的資料物件或過程,這些資料物件的地址或過程地址,相對於當前檔案來說,就是外部記憶體地址)。最後,載入器(loader)把所有的可執行目標檔案放到記憶體中執行

Untitled 5.png

一個編譯器的結構

這部分就是大致分享編譯器的編譯過程有哪幾步?以及每步在做的事情。這部分可能會偏理論,但是我會盡量的結合示例,方便理解。並且會在一些設計演算法或者設計的地方,分享它們在日常工作中的一些場景

編譯器結構概覽

下邊這個示例參考:編譯原理(哈工大)

編譯器是如何將一個高階的語言程式,翻譯成機器語言程式的?可以看一下我們是如何人工的將英語翻譯成漢語的

In the room, he broke a window with a hammer

這句英語就可以理解成是源語言,漢語就是目標語言。我們翻譯的過程,大致分為兩步

Untitled 6.png

通過分析源語言來獲得句子的語義過程,就是語義分析。語義分析通常是從劃分句子成分開始,首先是抓住句子的核心謂語動詞,因為謂語動詞的意思知道了,句子的一半意思就知道了。上邊這句的謂語動詞就是broke(打),知道打這個動作,我們就會想知道,是誰實施了打這個動作?誰是被打的物件?用什麼打的?為什麼打?打的結果如何等等

這些都可以通過分析broke的上下文來獲得。上邊的句子中,broke採用的是主動語態,所以它的主語he,就是動作的實事者,賓語window就是動作的受事者。反過來,如果broke採用的是被動語態be broken,那它的主語he就是動作的受事者

with a hammer是補語,表示動作使用的工具,in a room是狀語,表示動作發生的地點。這樣,我們就可以分析出broke前後的這些名詞性成分同謂語動詞broke之間的語義關係(這其實就是我們進行語義分析的過程)。比如下圖

Untitled 7.png

圖中央的節點,表示句子中描述的打這個動作,周圍的四個節點,對應著句子中的實體,分別是:he、window、hammer、room。從中間的結點,到周圍的四個節點,分別引出了四條邊,邊上的資訊表示這些實體同核心謂語動詞之間的一一對應關係,其中he是動作的實施者agent,window是動作的受事者object,hammer是動作採用的工具tool,room是動作發生的地點location

針對這個圖的意思,用漢語翻譯就是:在房間裡,他用錘子砸了一扇窗戶。這樣就完成了翻譯的過程。上邊的圖,就是一種中間表示它獨立於具體的語言,也就是說,英語可以用這個圖表示,漢語也可以用這個圖表示,日語、法語、義大利語都可以。有了這個圖,不管目標語言是什麼,都可以用這個圖來翻譯。所以中間表示很重要,它起到橋樑的作用

根據上邊的分析可以知道,要想進行語義分析,首先要劃分句子成分。我們知道,主語和賓語通常是由名詞短語構成的,狀語和補語通常由介詞短語構成的,因此,要想劃分句子成分,就需要識別出句子中的各類短語,這一過程稱為語法分析。要想識別句子中的各類短語,就需要知道詞性

Untitled 8.png

比如說一個冠詞+一個名詞,可以以構成一個名詞短語,一個代詞本身,也可以構成一個名詞短語。因此,要想識別句子中的各類短語,關鍵是要確定句子中各個單詞的詞性,這一過程就是詞法分析

綜上,我們就可以知道,要翻譯一個句子,首先需要進行詞法分析,才詞法分析的基礎上進行語法分析,然後進行語義分析,也就是說,具體的翻譯步驟就是,首先進行詞法分析,分析出句子中各個單詞的詞性

Untitled 9.png

然後進行語法分析

Untitled 10.png

然後是語義分析,根據句子的結構分析出各個短語在句子中充當什麼成分,從而確定各個名詞性成分,和核心謂語動詞之間的語義關係

Untitled 11.png

最後得到中間表示形式

Untitled 7.png

編譯器的編譯過程,也是經歷了以上幾個階段

Untitled 12.png

詞法分析、語法分析、語義分析、中間程式碼生成,組成編譯器前端,它與源語言相關。程式碼目的碼生成、機器相關程式碼優化,組成編譯器後端,它與目標語言相關

我們可以把編譯器看做是一個黑盒子,它可以把源程式對映為在語義上等價的目標程式。在這個對映的過程中,分為兩個組成部分:編譯器前端編譯器後端

編譯器前端

編譯器前端把源程式分解成為多個組成要素,並在這些要素之上加上語法結構。然後,使用這個結構來建立該源程式的一箇中間表示。如果編譯器前端部分檢查出源程式沒有按照正確的語法構成,或者語義上不一致,它就必須提供有用的資訊,使得使用者可以按此進行改正。編譯器前端部分還會收集有關源程式的資訊,並把資訊存放在一個稱為符號表(symbol table)的資料結構中。符號表將和中間表示形式一起傳送給編譯器後端部分

編譯器後端

編譯器後端部分根據中間表示和符號表中的資訊來構造使用者期待的目標程式

<aside>
? Tips:有些編譯器在前端和後端之間有一個與機器無關的優化步驟。這個優化步驟的目的是在中間表示之上進行轉換,以便後端程式能夠生成更好的目標程式。優化是可選的

Tips:上邊的這些階段是編譯器的邏輯組織方式,在實現的過程中,多個階段,可能會被組合在一起。比如語義分析的結果,通常直接表示成中間程式碼的形式,所以這兩個階段通常是放在一起實現的

</aside>

詞法分析

詞法分析的任務是從左往右逐行掃描源程式的字元,識別出各個單詞,確定單詞的型別(詞素)。將識別出的單詞轉換成統一的機內表示———詞法單元(token)形式

〈token-name, attribute-value〉 <種別碼, 屬性值>

這個詞法單元被傳送給下一個步驟,語法分析。在這個詞法單元中

  • token-name:這個就表示識別出的單詞的種別。比如自然語言中,每一個單詞都有一個詞性。程式設計語言中的單詞,基本上有下表中的幾種型別
序號單詞型別種別種別碼備註
1關鍵字if、else、for、then....一詞一碼如果程式設計語言給定了,那關鍵字就是確定的,所以可以為每一個關鍵字,分配一個種別碼(Go語言的種別碼都定義在這裡了src/cmd/compile/internal/syntax/tokens.go)
2識別符號變數名、陣列名、函式名...多詞一碼因為識別符號是開放的集合,事先是沒法列舉所有的識別符號的,因此是將所有的識別符號,統一分配同一個種別碼(Go裡邊這個種別碼是_Name)。為了區分不同的識別符號,就用到了token的第二個分量,屬性值。它其實是一個指標,指向的是符號表中的一條記錄(關於符號表,下邊會具體介紹)
3常量整形、浮點型、字元型、布林型...一型一碼常量和識別符號一樣,實現無法列舉所有的常量,但是常量的型別是有限的,所以每種型別的常量,分配一個種別碼。為了區分同一型別的不同常量,也是用了token的屬性值
4運算子算數運算子(+ - * / ...)

關係運算子( > < = ≠ ≤ ≥)
邏輯運算子(& | ~) | 一詞一碼

一型一碼 | 事先可確定 |
| 5 | 界限符 | ; ( ) { } ... | 一詞一碼 | 事先可確定 |

  • attribute-value:指向符號表中關於這個詞法單元的條目。符號表條目的資訊會被語義分析和程式碼生成步驟使用
將下邊這個語句進行詞法分析之後,得到的結果
for(i:=0;i<10-2.5;i=i-1){println(i)}

1      for      < _For, - >
2      (        < _Lparen, - >
3      i        < _Name, addr >
4      :=       < _Define, - >
5      0        < INT, addr>
6      ;        < _Semi, - >
......

語法分析

語法分析器從詞法分析器輸出的token序列中識別出各類短語,並構造語法分析樹

假設一個原始檔中包含下邊這個賦值語句

position = initial + rate * 60   (1.1)

這個賦值語句中的字元可以組合成如下詞素(單詞型別), 並對映成為如下詞法單元。這些詞法單元將被傳遞給語法分析階段

  1. position是一個詞素,被對映成詞法單元<id, 1 >,其中id是表示識別符號(identifier)的抽象符號,而1指向符號表中position對應的條目。一個識別符號對應的符號表條目存放該識別符號有關的資訊,比如它的名字和型別
  2. 賦值符號=是一個詞素,被對映成詞法單元〈=〉。因為這個詞法單元不需要屬性值,所 以我們省略了第二個分量。也可以使用assign這樣的抽象符號作為詞法單元的名字,但是為了 標記上的方便,我們選擇使用詞素本身作為抽象符號的名字
  3. initial是一個詞素,被對映成詞法單元<id, 2 >,其中2指向initial對應的符號表條目
  4. +是一個詞素,被對映成詞法單元<+>
  5. rate是一個詞素,被對映成詞法單元<id, 3 >,其中3指向rate對應的符號表條目
    • 是一個詞素,被對映成詞法單元<*>
  6. 60是一個詞素,被對映成詞法單元<60>

<aside>
? Tips:分隔詞素的空格會被詞法分析器忽略掉

</aside>

經過詞法分析之後,賦值語句(1.1)被表示成如下的詞法單元序列

**<id, 1> <=> <id, 2> <+> <id, 3> <*> <60>**   (1.2)

在這個表示中,詞法單元名=、+和分別是表示賦值、加法運算子、乘法運算子的抽象符號(比如在Go語言中,=、+、的抽象符號分別是_Assign_Operator

從圖中可以看出,一個識別符號或者一個常數本身,可以構成一個表示式,一個表示式加上另一個表示式、或乘上另一個表示式,可以構成一個更大的表示式。一個識別符號,連線一個賦值號,再連線上一個表示式,可以構成一個賦值語句

編譯器的後續步驟使用這個語法結構來幫助分析源程式,並生成目標程式

(下邊這個圖,你可以先不看)

Untitled 13.png

變數宣告語句的分析樹

文法(文法是由一系列的規則構成的):

<D> →  <T><IDS>;
<T> → int | real | char | bool
<IDS> → id | <IDS>, id

D:是declaration的首字母,宣告的意思,表示宣告語句
T:是type的首字母,型別的意思,表示型別
IDS:是Identifier Sequence的縮寫,表示識別符號序列

因此,從上邊的第一條規則可以看出,一個宣告語句D,是由一個型別T連線上一個識別符號序列和一個分號構成的。這裡的T可以是 int 或 real 或 char 或 bool,所以上邊第二條規則中的豎線,表示的是或。根據第三條規則可以看出,一個識別符號id本身,可以構成一個識別符號序列;一個識別符號序列,連線一個逗號,再連線一個識別符號id,也可以構成一個識別符號序列IDS

根據這個文法,假設有這麼一段程式碼

int a, b, c;

那根據上邊的文法,就可以得到它的分析樹

Untitled 14.png

從a可以看出,一個識別符號本身,可以構成一個識別符號序列IDS,一個IDS連線一個逗號,再連線一個識別符號,可以構成一個更大的IDS

關於這裡邊語法分析器如何根據語法規則為輸入的源程式構造分析樹?這個需要詳細的瞭解編譯原理中的文法相關規則,這裡不深入的去研究,感興趣的自己看編譯原理這本書的第四章。語法解析器在進行語法掃描的時候,用到的是自頂向下的遞迴下降演算法,實現無回溯的高效語法掃描

<aside>
? 意外收穫:碰巧上週刷LeetCode中二叉樹的一道題,就遇到了藉助編譯原理中的文法規則+遞迴下降演算法進行解題:297. 二叉樹的序列化與反序列化

</aside>

語義分析

語義分析器

語義分析器(semantic analyzer)使用語法樹和符號表中的資訊來檢查源程式是否和語言定義的語義一致。它同時也收集型別資訊,並把這些資訊存放在語法樹或符號表中,以便在隨後的中間程式碼生成過程中使用

高階語言程式中的語句,大體分為兩類:宣告語句可執行語句。在宣告語句中,會宣告一些資料物件或過程,並且為它們取名字,也就是識別符號

對於宣告語句來說,語義分析的主要任務就是,收集識別符號的屬性資訊。一個識別符號的屬性有:

  • 種屬:簡單變數、複合變數(陣列、map)、函式....
  • 型別:整形、字元型、布林型...
  • 儲存位置、長度:程式中宣告的資料物件和過程,都會為它在記憶體中分配一塊儲存空間,因此就會有儲存位置和所需的記憶體空間的大小
  • 作用域
  • 引數和返回值資訊:這個是針對函式的(引數個數、引數型別、引數傳遞方式、返回值型別等)

假設有這麼一段程式碼

var x[8] int
var i, j int
......

Untitled 15.png

語義分析階段,收集的這些識別符號的屬性資訊,都會存放在一個稱為符號表的資料結構中,每一個識別符號,都對應符號表中的一條記錄

Untitled 16.png

符號表通常帶有一個字串表,用來存放程式中用到的識別符號和字元常數,這樣就使Name分成了兩個部分。一部分存識別符號在字串表中的起始位置,第二部分存識別符號的長度(比如SIMPLE的長度是6個字元,識別符號SYMBLE的長度也是6個字元,識別符號TABLE的長度是5個字元)

<aside>
? Question:一個有意思的問題,符號表中為什麼要設計字串表這樣一種資料結構?而不是將Name字串,直接存放在Name欄位中?

</aside>

語義檢查

語義分析的一個重要部分是語義檢查

  • 變數或函式未經過宣告就使用
  • 變數或函式名重複宣告
  • 運算分量的型別不匹配(比如陣列的名字和函式的名字進行相加,當然也可能存在型別轉換)
  • 操作符與運算元之間的型別不匹配(陣列的下標不是整數、函式呼叫的引數型別或數目不匹配)

程式設計語言可能允許某些型別轉換,這被稱為自動型別轉換(coercion)。比如,一個二元算術運算子可以應用於一對整數或者一對浮點數。如果這個運算子應用於一個浮點數和一個整數,那麼編譯器可以把該整數轉換成為一個浮點數

在上邊的圖中,其實就存在自動型別轉換,假設position、initial和rate已被宣告為浮點數型別,而詞素60本身是一個整數。語義分析器的型別檢查程式發現運算子*被用於一個浮點數rate和一個整數60。在這種情況下,這個整數可以被轉換成為一個浮點數

中間程式碼生成

在把一個源程式翻譯成目的碼的過程中,一個編譯器可能構造出一個或多箇中間表示。 這些中間表示可以有多種形式。語法樹是一種中間表示形式,它們通常在語法分析和語義分析中使用。還有一種就是三地址程式碼

在源程式的語法分析和語義分析完成之後,很多編譯器生成一個明確的低階的或類機器語言的中間表示。我們可以把這個表示看作是某個抽象機器的程式。該中間表示應該具有兩個重要的性質:它應該易於生成,且能夠被輕鬆地翻譯為目標機器上的語言

三地址程式碼

這種中間表示由一組類似於組合語言的指令組成,每個指令具有三個運算分量(最多三個)。每個運算分量都像一個暫存器。上圖中的中間程式碼生成器的輸出是如下的三地址程式碼序列

position = initial + rate * 60

t1 = inttofloat(60)
t2 = id3 * t1
t3 = id2 + t2
id1 = t3                (1.3)
  1. 每個三地址賦值指令的右部最多隻有一 個運算子。因此這些指令確定了運算完成的順序。在源程式(1.1)中,乘法應該在加法之前完成
  2. 編譯器應該生成一個臨時名字以存放一個三地址指令計算得到的值
  3. 有些三地址指令的運算分量少於三個(比如上面的序列(1.3)中的第一個和最後一個指令)

常用的三地址指令(紅色為指令操作符)

序號指令型別指令形式備註
1賦值指令x = y op z
x = op yop是一個二元運算子,y和z是兩個運算分量的地址,x是運算結果的存放地址(下邊那個op是一元運算子)
2複製指令x = y
3條件跳轉指令if x relop y goto n
4非條件跳轉goto n
5引數傳遞param x
6函式呼叫call p, np是函式名字,n是引數的個數
7函式返回return x跳轉到地址x對應的指令
8陣列引用x = y[i]y是陣列的名字,表示陣列的基地址。i是陣列元素的偏移地址,而不是下標
9陣列賦值x[i] = y
10地址及指標操作x = &y

x = *y
*x = y | |

其實是可以用源程式中的名字(也就是識別符號)來作為三地址指令中的地址,因為每個識別符號的地址都存放在符號表中,所以通過名字就可以找到它們的地址。常量和編譯器生成的臨時變數也可以作為三地址指令的地址

三地址指令的表示

  • 四元式
  • 三元式
  • 間接三元式

這裡主要分享四元式,它長這樣(op, y, z, x),第一個分量,對應三地址指令中的操作符,後邊三個分量代表三地址指令中的三個運算元

三地址指令的四元式表示

三地址指令四元式表示備註
x = y op z(op, y, z, x)四元式的最後一個分量,表示三地址指令中的目標地址。倒數二三個分量表示運算元地址
x = op y(op, y, _, x)
x = y(=, y, _, x)
if x relop y goto n(relop, x, y, n)
goto n(goto, _, _, x)
param x(param, _, _, x)
call p, n(call, p, n, _)
return x(return, _, _, x)
x = y[i](=[], y, i, x)
x[i] = y([]=, y, x, i)
x = &y(=&, y, _, x)
x = *y(=*, y, _, x)
*x = y(*=, y, _, x)

其實三地址指令的四元式表示形式,和前邊的自然語言的中間表示形式有相似之處。比如說給定一個動作,那它就涉及到施事者、受事者、工具、地點。在三地址指令中,操作符就相當於句子的核心謂語動詞,運算元就相當於各個語義角色,只不過這裡的運算元最多有三個

會發現除了賦值指令之外,每一個指令都只有一個操作符,也就是說只能完成一個動作。因此,一個三地址指令序列,唯一確定了運算的完成順序

中間程式碼生成示例

while a < b do
    if c < 5 do
        while x > y do
            z = x + 1;
    else x = y;

上邊程式碼生成的分析樹如下:

Untitled 17.png

上邊的分析樹被翻譯成中間程式碼就是這樣

指令編號:指令   

100: (j<, a, b, 102)  //條件跳轉指令,j是jump的縮寫。意思就是,如果a < b就跳轉到102號指令。否則就往下執行101號指令    
101: (j, -, -, 112)   //無條件跳轉指令,也就是跳轉到112號指令(就跳出了整個while迴圈語句)
102: (j<, c, 5, 104)  //條件跳轉指令,如果c<5,跳轉到104號指令,否則往下執行103號指令
103: (j, -, -, 110)   //無條件跳轉指令,也就是跳轉到110號指令
104: (j>, x, y, 106)  //條件跳轉指令,如果x>y,跳轉到106號指令,否則往下執行105號指令
105: (j, -, -, 100)   //無條件跳轉指令,也就是跳轉到100號指令
106: (+, x, 1, t1)    //將x的值,加上1,然後賦給t1。然後往下執行107號指令
107: (=, t1, -, z)    //將t1的值,賦給z
108: (j, -, -, 104)   //無條件跳轉指令,也就是跳轉到104號指令
109: (j, -, -, 100)   //無條件跳轉指令,也就是跳轉到100號指令
110: (=, y, -, x)     //賦值指令,將y的值,賦給x。執行完之後,往下執行111號指令
111: (j, -, -, 100)   //執行110號指令
112: 

關於編譯器是如何根據分析樹生成中間程式碼的,這個涉及比較多文法上下文無關文法等抽象的概念以及規則表示式。我這裡主要的目的是分享每個過程在做什麼樣的事情,沒有深入研究實現。感興趣的可以自己看一下《編譯原理》的第六章

程式碼優化

機器無關的程式碼優化步驟,目的是改進中間程式碼,以便生成更好的目的碼。“更好”通常意味著更快,但是也可能會有其他目標,如更短的或能耗更低的目的碼。比如,一個簡單直接的演算法會生成中間程式碼(1.3)。它為由語義分析器得到的樹形中間表示中的每個運算子都使用一個指令

使用一個簡單的中間程式碼生成演算法,然後再進行程式碼優化步驟是生成優質目的碼的一個合理方法。優化器可以得出結論:把60從整數轉換為浮點數的運算可以在編譯時刻一勞永逸地完成。因此,用浮點數60.0來替代整數60就可以消除相應的inttofloat運算。而且,t3僅被使用一次,用來把它的值傳遞給id1。因此,優化器可以把序列(1.3)轉換為更短的指令序列

t1 = id3 * 60.0
id1 = id2 + t1       (1.4)

不同的編譯器所做的程式碼優化工作量相差很大。那些優化工作做得最多的編譯器,即所謂的 “優化編譯器”,會在優化階段花相當多的時間。有些簡單的優化方法可以極大地提高目標程式的執行效率而不會過多降低編譯的速度

程式碼生成

程式碼生成器以源程式的中間表示形式作為輸入,並把它對映到目標語言。如果目標語言是機器程式碼,那麼就必須為程式使用的每個變數選擇暫存器或記憶體位置。然後,中間指令被翻譯成為能夠完成相同任務的機器指令序列。程式碼生成的一個至關重要的方面是合理分配暫存器以存放變數的值

比如(1.4)中的中間程式碼,可以被翻譯成下邊的機器程式碼(R1、R2是暫存器)

LDF      R2,   id3
MULF     R2,   R2,  #60.0
LDF      R1,   id2
ADDF     R1,   R1,  R2
STF      id1,  R1                    (1.5)

每個指令的第一個運算分量指定了一個目標地址。各個指令中的F告訴我們它處理的是浮點數。程式碼(1.5)把地址id3中的內容載入到暫存器R2中,然後將其與浮點常數60.0相乘。井號“#”表示60.0應該作為一個立即數處理。第三個指令把id2移動到暫存器R1中,第四個指令把前面計算得到並存放在R2中的值加到R1上。最後,在暫存器R1中的值被存放到id1的地址中去

上面對程式碼生成的討論忽略了對源程式中的識別符號進行儲存分配的重要問題。實際上,執行時刻的儲存組織方法依賴於被編譯的語言。編譯器在中間程式碼生成或程式碼生成階段做出有關儲存分配的決定

符號表管理

編譯器的重要功能之一是記錄源程式中使用的變數的名字,並收集和每個名字的各種屬性有關的資訊。這些屬性可以提供一個名字的儲存分配、它的型別、作用域(即在程式的哪些地方可以使用這個名字的值)等資訊。對於函式名字,這些資訊還包括:它的引數數量和型別、每個引數的傳遞方法(比如傳值或傳引用)以及返回型別

符號表資料結構為每個變數名字建立了一個記錄條目。記錄的欄位就是名字的各個屬性。 這個資料結構應該允許編譯器迅速查詢到每個名字的記錄,並向記錄中快速存放和獲取記錄中的資料(快速的獲取和插入,你會想到哪種資料結構?)

符號表(symbol table)是一種供編譯器用於儲存有關源程式構造的各種資訊的資料結構。這些資訊在編譯器前端階段被逐步收集並放入符號表,它們在編譯器後端階段用於生成目的碼。符號表的每個條目中包含與一個識別符號相關的資訊,比如它的字串(或者詞素)、它的型別、它的儲存位置和其他相關資訊。符號表通常需要支援同一識別符號在一個程式中的多重宣告

一個宣告的作用域是指該宣告起作用的那一部分程式。它將為每個作用域建立一個單獨的符號表來實現作用域。每個帶有宣告的程式塊(比如C裡邊的程式塊,要麼是一個函式,要麼是函式中由大括號分隔的一部分)都會有自己的符號表,這個塊中的每個宣告都在此符號表中有一個對應的條目。這種方法對其他能夠設立作用域的程式設計語言構造同樣有效。例如,每個類也可以擁有自己的符號表,它的每個域和方法都 在此表中有一個對應的條目

<aside>
? Tips:符號表條目是在分析階段,由詞法分析器、語法分析器和語義分析器建立並使用的

</aside>

詞法分析詳解

詞法分析器的作用

詞法分析器的主要任務是讀入源程式的輸入字元、將它們組成詞素,生成並輸出一個詞法單元序列,每個詞法單元對應於一個詞素。這個詞法單元序列被輸出到語法分析器進行語法分析。詞法分析器通常還要和符號表進行互動。當詞法分析器發現了一個識別符號的詞素時,它要將這個詞素新增到符號表中。在某些情況下,詞法分析器會從符號表中讀取有關識別符號種類的資訊,以確定向語法分析器傳送哪個詞法單元

可以通過下邊這個圖瞭解詞法分析器和語法分析器的互動過程。通常,互動是由語法分析器呼叫詞法分析器來實現的。圖中的命令getNextToken所指示的呼叫,使得詞法分析器從它的輸入中不斷讀取字元,直到它識別出下 一個詞素為止。詞法分析器根據這個詞素生成下一個詞法單元並返回給語法分析器
Untitled 18.png

詞法分析器在編譯器中負責讀取源程式,它還會完成一些識別詞素之外的其它任務

  • 過濾掉源程式中的註釋和空白等
  • 將編譯器生成的錯誤訊息與源程式的位置聯絡起來。例如詞法分析器可以負責記錄遇到換行符的個數,以便給每個出錯訊息賦予一個行號
  • 如果源程式使用了一個巨集前處理器,則巨集的擴充套件也可以由詞法分析器完成

詞法分析和語法分析

把編譯過程的分析部分劃分為詞法分析和語法分析階段有如下幾個原因

  • 最重要的考慮是簡化編譯器的設計。將詞法分析和語法分析分離通常使我們至少可以簡化其中的一項任務。例如,如果一個語法分析器必須把空白符和註釋當作語法單元進行處理,那 麼它就會比那些假設空白和註釋已經被詞法分析器過濾掉的處理器複雜得多。如果我們正在設計一個新的語言,將詞法和語法分開考慮有助於我們得到一個更加清晰的語言設計方案(就很像網路分層)
  • 提高編譯器的效率。把詞法分析器獨立出來使我們能夠使用專用於詞法分析任務、不進行語法 分析的技術。此外,我們可以使用專門的用於讀取輸入字元的緩衝技術來顯著提高編譯器的速度(也是網路分層的原因之一)
  • 增強編譯器的可移植性。輸入裝置相關的特殊性可以被限制在詞法分析器中

詞法單元、模式、詞素

  • 詞法單元由一個詞法單元名和一個可選的屬性值組成。詞法單元名是一個表示某種詞法單位的抽象符號(在2.2.1的詞法分析部分有說明,比如Go語言中,賦值符號=的抽象符號是_Assign),比如一個特定的關鍵字,或者代表一個識別符號的輸入字元序列。詞法單元名字是由語法分析器處理的輸入符號。通常使用詞法單元的名字來引用一個詞法單元
  • 模式描述了一個詞法單元的詞素可能具有的形式。當詞法單元是一個關鍵字時,它的模式就是組成這個關鍵字的字元序列。對於識別符號和其他詞法單元,模式是一個更加複雜結構,它可以和很多符號串匹配(其實我覺得就可以理解成正規表示式的模式串,根據模式匹配不同型別的詞素,比如匹配變數名這類詞素、匹配表示式符號這類詞素,他們的模式是不一樣的)
  • 詞素是源程式中的一個字元序列,它和某個詞法單元的模式匹配,並被詞法分析器識別為該詞法單元的一個例項(這個很好理解,就是看源程式中的每個詞素能和那個模式匹配,如果和某個詞法單元的模式匹配,就識別為該詞法單元的一個例項)

示例

下圖中給出了一些常見的詞法單元、非正式描述的詞法單元的模式,並給出了一些示例詞素。用下邊這個示例來說明上邊幾個概念是如何應用的。假設有這麼一個C語句

printf("Total = %d\n", score);

Untitled 19.png
圖片來源:《編譯原理》

printf和score都是和詞法單元id的模式匹配的詞素,而"Total = %d\n"則是一個和literal匹配的詞素

在很多程式設計語言中,下面的類別覆蓋了大部分的詞法單元:

  1. 每個關鍵字有一個詞法單元。一個關鍵字的模式就是該關鍵字本身
  2. 表示運算子的詞法單元。它可以表示單個運算子,也可以像上圖中的canparison那樣,表示一類運算子
  3. 一個表示所有識別符號的詞法單元
  4. 一個或多個表示常量的詞法單元,比如數字和字面值字串
  5. 每一個標點符號有一個詞法單元,比如左右括號、逗號和分號

詞法單元的屬性

如果有多個詞素可以和一個模式匹配,那麼詞法分析器必須向編譯器的後續階段提供有關被匹配詞素的附加資訊。例如,0和1都能和詞法單元number的模式匹配,但是對於程式碼生成器而言,至關重要的是知道在源程式中找到了哪個詞素。因此,在很多情況下,詞法分析器不僅僅向語法分析器返回一個詞法單元名字,還會返回一個描述該詞法單元的詞素的屬性值。詞法單元的名字將影響語法分析過程中的決定,而這個屬性則會影響語法分析之後對這個詞法單元的翻譯

假設一個詞法單元至多有一個相關的屬性值,當然這個屬性值可能是一個組合了多種資訊的結構化資料。最重要的例子是詞法單元id,我們通常會將很多資訊和它關聯。一般來說,和一個識別符號有關的資訊——例如它的詞素、型別、它第一次出現的位置(在發出一個有關該識別符號的錯誤訊息時需要使用這個資訊)都儲存在符號表中。因此,一個識別符號的屬性值是一個指向符號表中該識別符號對應條目的指標

詞法錯誤

如果沒有其他元件的幫助,詞法分析器很難發現原始碼中的錯誤。比如,當詞法分析器在處理下邊這個C程式片斷時,就會有問題

fi(a == f(x))

第一次遇到fi時,它無法指出fi究竟是關鍵字if的誤寫還是一個未宣告的函式識別符號。由於fi是識別符號id的一個合法詞素,因此詞法分析器必須向語法分析器返回這個id詞法單元,而讓編譯器的另一個階段(在這個例子裡是語法分析器)去處理這個因為字母顛倒而引起的錯誤

然而,假設出現所有詞法單元的模式都無法和剩餘輸入的某個字首相匹配的情況,此時詞法分析器就不能繼續處理輸入。當出現這種情況時,最簡單的錯誤恢復策略是“恐慌模式”恢復。 從剩餘的輸入中不斷刪除字元,直到詞法分析器能夠在剩餘輸入的開頭發現一個正確的詞法單元為止。這個恢復技術可能會給語法分析器帶來混亂

可能採取的其他錯誤恢復動作包括:

  1. 從剩餘的輸入中刪除一個字元
  2. 向剩餘的輸入中插入一個遺漏的字元
  3. 用一個字元來替換另一個字元
  4. 交換兩個相鄰的字元

這些變換可以在試圖修復錯誤輸入時進行。最簡單的策略是看一下是否可以通過一次變換將剩餘輸入的某個字首變成一個合法的詞素。這種策略還是有道理的,因為在實踐中,大多數詞法錯誤只涉及一個字元。另外一種更加通用的改正策略是計算出最少需要多少次變換才能夠把 一個源程式轉換成為一個只包含合法詞素的程式。但是在實踐中發現這種方法的代價太高,不值得使用

參考資料

相關文章