1. 程式人生 > >小明學C++第三篇:編譯原理

小明學C++第三篇:編譯原理

懶惰是人類第一生產力,抽象是計算機偉大的思想。我們都知道計算機只認識0和1,而且是按照馮諾依曼結構組織起來的。資料跟指令都存放在主存裡,根據不同的指令週期來區分是資料還是指令。在早期的計算機,人類必須編寫很長只有0和1的機器語言來跟計算機交流,但這是非常低效的,因為0和1只要稍不留神,就會寫錯,最後可能造成很大的程式錯誤。於是人們就發明了以助記符為標誌的組合語言,這個已經好多了,畢竟可以通過語句直接知道每條語句的意義,但是其實每一個助記符跟二進位制串是有唯一的對應關係的,也就是說,用匯編寫的程式碼還是很長很長,最後人們發明了高階語言C,才比較接近於人類的思維和語法特點。於是問題就來了,一邊接近於人類自然語言的高階語言,另一邊是接近於機器的01串,如何完成這個轉換呢?下面我們就來講講編譯器的那些事情。

背景

前面的一篇部落格裡面寫到小明寫了一個多邊形面積計算的C++程式(一個.cpp檔案),然後小明點選了編譯器的編譯按鈕,就生成了一個.exe可執行檔案(即二進位制程式碼)。下面我們就以面積計算函式calculate的這條語句為例,說明編譯器是如何將其轉化成二進位制程式碼的:

S=( (A.x-B.x)*(A.y-C.y) - (A.x-C.x)*(A.y-B.y) )/2;

算術表示式的形式定義

大家請注意形式二字,這是抽象的象徵。

好吧,怎麼定義算術表示式呢?

我們先來看看中文“我喜歡你”吧。
這四個字可以組成很多排列:我喜歡你、你喜歡我、我你喜歡、喜歡你我、我喜你歡…
上面的排列有些是有意義的,有些是沒有意義的。

我們是怎麼定義一個完整的句子(合法有意義)的?
我們可以用下列的結構來判斷句子是否合法:

定語 主語 狀語 謂語 定語 賓語

“我喜歡你”中,我是人稱代詞做主語,喜歡是一個動詞做謂語,你是人稱代詞做賓語,整個句子符合定義,因此是一個合法的句子。
而“喜歡你我”中,喜歡是動詞不能做主語,因此不是一個完整的句子。

偉大的喬姆斯基在研究了人類的語言以後,就從語言產生的角度去定義一門語言。
在他的理論中,它把語言分成四種,短語語言,上下文有關語言、上下文無關語言以及正則語言。而我們的C++語言是一種上下文無關語言。
至於具體他是怎麼分的,我就不講了,大家可以去找找有關形式語言的資料。

我們還是看看怎麼定義這個算術表示式吧。
大家根據中文句子的定義可以去定義一下。
下面是我定義出來的算術表示式的結構:

這裡寫圖片描述
上圖包含四個式子。第一個式子是一個賦值表示式,由變數=表示式構成。
而表示式又由一些中間變數和終結符(+、-、*、/、(、)、number構成)。
上圖叫做算術表示式的文法。
有了上面那個東西,就可以產生很多不同的算術表示式了。只有用上述表示式能夠生成的式子才叫算術表示式,其它的都不是。
比如a=3+2*4可以經過下列步驟產生:
這裡寫圖片描述
因此,式子a=3+2*4是算術表示式。
同樣式子S=( (A.x-B.x)(A.y-C.y) - (A.x-C.x)(A.y-B.y) )/2也可以由上述文法產生,也是算術表示式。你們可以自己去推導。

token的識別

用一個文法來定義算術表示式是為了識別一個句子是不是該文法產生的,到底屬不屬於算術表示式。

那麼為了識別一個句子,就必須識別句子裡面的每一個元素,然後再判斷是否符合句子的構成。
所以第一步是識別句子的元素,也就是token。
token就是句子中不同類別的一個整體,拿中文語法結構來講,主語、謂語、賓語就是不同的token。第一步我們就是要識別出這些token出來。

在C++中,不同的token是有不同的定義的。比如變數的命名不能由數字開頭,只能以字母或者下劃線開頭,而十進位制數字開頭必須為數字,而且不能為0,後面可以有小數點等。所以變數和數字是有差異的,根據這些差異,我們可以逐個字元讀取程式碼,當讀到數字時,它的期望是識別一個數字,當讀到字母時,它希望是一個變數。而完成token識別的過程需要使用一個機器,叫做自動機(講道理,圖靈機也是一種自動機,只不過功能強大一些)。

自動機有很多狀態,在眾多狀態之中,包含了開始狀態和終止狀態,每一個狀態根據當前的輸入,如果符合預期將會進入下一個狀態。最終能夠到達終止狀態的就說明這個東西能夠識別出來,是一個token。

舉個例子,某個快遞公司的物流系統就是一個自動機。當一個商品從青島出發運往廣東虎門,那麼青島是初始狀態,表示我開始的時候是在青島的;廣東虎門是接收狀態,表示最終的歸屬是在廣東虎門。中途路過的所有地點都是中間狀態。如果商品從青島運到上海,而上海有到達廣東虎門的通路,那麼商品就會從上海運往下一個城市,比如是廈門。在這裡,當前狀態是在上海,輸入是去廣東虎門的商品,下一個狀態是在廈門。自動機其實也就是這麼一個東西。

那麼C++識別token的自動機是怎樣的呢?本人畫了一個圖可以參考一下:
這裡寫圖片描述

現在我們來看看錶達式S=( (A.x-B.x)(A.y-C.y) - (A.x-C.x)(A.y-B.y) )/2;是如何在自動機下識別出一個個token的。

首先我們在起始狀態,讀入一個S,就進入了INID的狀態,然後在讀入“=”,就進入了接收狀態,於是S被接收,S是一個屬於ID的token。同樣“=”被識別成等號的token,A.x被識別成為一個變數,2被識別成為一個數字。

這樣第一步工作就完成了。

語法樹的生成

每一個token輸出,作為語法分析器的輸入。
下一步工作是生成一顆語法樹。這顆語法樹的作用是讓機器明白表示式每個token的含義,它們的關係,以便生成中間程式碼。
下面就是表示式S=( (A.x-B.x)(A.y-C.y) - (A.x-C.x)(A.y-B.y) )/2;對應的語法樹:
這裡寫圖片描述
對於如何生成語法樹是一個比較複雜的過程。你可以跳過這一部分。只要知道它的輸出是語法樹就可以了。
生成語法樹的方法有很多,分為兩大類,每大類又有不同的分析方法:
(1)自頂向下的分析方法:
a.遞迴下降分析法;
b.LL(1)分析法;
(2)自底向上的分析方法:
a.LR分析法;
b.算符優先分析法;

其中自頂向下分析法和自底向上分析法是兩種相反的分析方法。自頂向下分析方法試圖利用現在的產生式去找到一個句子的推導,它專注於選擇使用哪個產生式。而自底向上分析方法是想找到句子的規約,它更傾向於找到一個控制代碼,也就是要進行規約的產生式的開頭。下面我就講一下LR分析法,它是現代編譯器的語法分析器的基礎。

LR分析法
LR分析法的關鍵在於構建一個LR語法分析自動機,再根據這個自動機構造出LR語法分析表,最後就根據這個表完成對某個句子的規約。
(1)LR語法分析自動機
(2)LR語法分析表
(3)對句子進行規約

為了簡單起見,我們把算術表示式的文法刪除減法和除法,剩下加法和乘法,變為以下的文法:
這裡寫圖片描述
現在我們來分析表示式i+i*i語法樹的構造過程:
(1)LR語法分析自動機(LR狀態轉移圖)
這裡寫圖片描述
你別看這個圖非常複雜,只要你懂得了其中的竅門,還是看得明白的。
我們先看看狀態,圖中的狀態包括I0-I11,一共有十二種狀態,這麼多狀態是怎麼來的呢?
原來我們先把I0狀態看成初始狀態,這個框框裡面的東西叫做項,項裡面有很多產生式,產生式有一個點。竅門來了。點表示當前狀態,而後面的字元表示它所期望接收的字元。最開始我們希望接收一個E,一個E表示一個表示式,也是E’->.E表示的就是這個意思。但是E還有產生式,比如E->T,那麼期望接收到E也意味著期望接收到E的產生式(E->T)的第一個字元T。於是E->.T也被加入到項集裡面了。就這樣,同一個項集的式子其實都表示同一種狀態。它們經過不同的輸入就會到達不同的狀態。就這樣不斷產生新的狀態,直到狀態都是可歸約的狀態,也就是那個點到達了產生式的右端。比如狀態I11:F->(E).表示可以規約了。
(2)LR語法分析表
這裡寫圖片描述
這個表有三列,分別為狀態、ACTION、GOTO。
這個表是根據步驟(1)的自動機得到的。
表的內容主要分為幾種不同的元素,包括
(1)s+數字:表示移進,並將狀態變成數字所代表的狀態,s5表示移進字母,狀態變成5;
(2)r+數字:表示規約,數字表示用第幾條產生式規約。
(3)數字:純數字表示直接改變狀態,不需要移進和規約。
(4)acc:這是接收狀態,到達這一狀態表示語法樹成功生成。
(5)內容為空:表示沒有合法的動作,語法樹生成失敗。

LR語法分析表的解讀:
我們來看一下狀態為0的那一行,當輸入為i的時候,我們看LR狀態轉移圖是不是移進i,並且狀態變成了I5了,因此當狀態為0時輸入i的動作是s5;
我們再來看看第四行,如果當前狀態為3,輸入+號時,根據LR狀態轉移圖,應該使用第四條產生式T->F進行規約,於是動作為r4;
其它的也跟上述情況類似,你們可以自己推導。

(3)對句子進行規約
有了狀態轉移表,就可以對i+i*i進行規約了,也就是生成語法分析樹。
這裡寫圖片描述
總體來說,LR分析器的結構如上圖所示,LR分析器的分析棧中有個初始狀態s0,然後順序讀入輸入串,依據LR分析表,分析棧的內容也做相應的變化。
比如句子i+i*i,開始時狀態是0,讀入i,根據LR分析表,執行的動作是s5,所以分析棧的狀態變成了5,分析棧的符號變成了i#;
然後再讀入+,根據LR分析表,執行的動作是r6,即用第6條產生式F->id進行規約,在規約的時候就可以生成一個樹結構。這個結構F是父節點,它有一個孩子id。然後當前狀態變成0,輸入F時,執行動作3,於是分析棧的狀態變成3。
下表是全部的LR分析過程:
這裡寫圖片描述

每一次規約過程都會構造出一顆樹(以之前構造的樹作為孩子),這顆樹越來越大,當分析棧的狀態只剩下01時,表示分析成功,這顆樹就是對應句子的語法分析樹了。

中間程式碼生成

按照正常的思路,構造出語法分析樹,我們就知道了語法結構了,如果給這個語法結構新增一些語義,就可以直接生產目標語言即二進位制程式碼了,可是為何還要生成中間程式碼呢?
主要原因是中間程式碼獨立於目標語言,便於編譯器的實現和移植,也便於進行機器無關的程式碼優化。
我們試著想象這樣的場景,小明寫了多邊形面積計算的C++程式碼,他想把這個程式碼發給另外兩個同學去執行,但是這兩個同學的機器(機器指令體系)是不同的,這時如果沒有中間程式碼的話,小明只能傳送原始碼讓這兩位同學重新編譯再執行。可是如果有中間程式碼的話,對token的識別和語法樹的構造只需進行一次,這樣就簡化了流程,加快了效率。
中間程式碼的形式有很多種,其中包括了三地址碼。
所謂三地址碼,是指這種程式碼的每條指令最多隻能包含三個地址,即兩個運算元地址和一個結果地址。
如x+y*z三地址碼為:t1 := y*z t2 := x+t1;
那麼表示式S=( (A.x-B.x)(A.y-C.y) - (A.x-C.x)(A.y-B.y) )/2;的三地址碼就可以是:

t1 = A.x-B.x;
t2 = A.y-C.y;
t3 = A.x-C.x;
t4 = A.y-B.y;
t5 = t1 * t2;
t6 = t3 * t4;
t7 = t5 -t6;
t8 = t7/2;
S = t8;

大家請注意,上述過程是根據語法分析樹來生成的,因為處於語法分析樹底端的分支,優先順序是最高的,需要事先進行計算。
這樣中間程式碼就生成了。

程式碼優化

程式碼優化分為機器無關的優化和機器有關的優化,機器無關的優化又分為區域性塊優化以及全域性優化。具體的優化有很多技術,本人也還沒有掌握,感興趣的同學可以去看看龍書:機械工業出版社的編譯原理。
在這裡我就舉一個我知道的優化吧。
乘法和除法的優化
在上述中間程式碼中:

t5=t1*t2;
t6=t3*t4;
t8=t7/2;

對二進位制熟悉的話,一個二進位制數乘以2相當於左移一位,除以2相當於右移一位。
所以上述程式碼可以變成移位操作。再後面的部落格當中,我們將會看到乘法和除法比移位操作將會耗費好幾倍的指令週期,因此移位操作更加快速。
所以
t8=t7/2 => t8 = t7>>1;
至於乘法的優化怎麼做,你自己思考一下吧。

二進位制程式碼生成

根據優化後的中間程式碼,就可以生成二進位制程式碼了。
這個過程其實可以比較簡單,就好像查字典一樣。
每一條中間程式碼,判斷是加法還是其它操作,然後找到相關的二進位制指令,
最後翻譯成二進位制程式碼。
下面是中間程式碼(跟上面程式碼稍有改動):

t1 = t9-t10;
t2 = t11-t12;
t3 = t9-t13;
t4 = t11-t14;
t5 = t1 << 2;
t6 = t3 << 2;
t7 = t5 -t6;
t8 = t7>>1;

MIPS指令集結構下翻譯得到的二進位制程式碼:

000000 00001 01001 01010 00000 100000
000000 00010 01011 01100 00000 100000
000000 01001 01101 00011 00000 100001
000000 01011 01110 00100 00000 100001
000000 00000 00001 00101 00010 000000
000000 00000 00011 00110 00010 000000
000000 00101 00110 00111 00000 100001
000000 00000 00111 01000 00001 000011

總結

編譯器所做的過程其實比較複雜,這篇部落格主要講了編譯器的前端工作,特別是token的識別和語法樹的構造,其中語法樹的構造是核心。對比最後的二進位制程式碼以及高階語言的程式碼,是不是覺得還是高階語言簡潔啊?正所謂磨刀不誤砍柴,高階語言的出現使得軟體快速發展。