編譯器構造/程式碼生成
編譯器通常被設計為輸出一個可執行程式,該程式將允許使用者執行你的程式,並被處理器直接執行,而無需像解釋過程那樣使用中間直譯器。然而,為了使你的程式能夠被處理器執行,你需要將你特定程式語言中的指令轉換為彙編程式碼,然後將其傳送到彙編工具以建立目的碼,然後將目的碼與特定庫連結起來以建立你的可執行程式碼。目前,我們只需要關心將指令轉換為彙編程式碼。這個過程是我們將在本節中討論的內容。你需要熟悉你想要輸出的組合語言。如果你希望你的程式在 x86 架構上執行,你需要熟悉 x86 彙編程式碼,等等。
程式碼生成發生在語義分析之後,語義分析為我們提供了足夠的資訊來生成更原始的具體程式碼。程式碼生成背後的基本思想是將語法樹的樹結構分解成一系列指令,無論指令集是什麼。在這個階段,由於我們已經完成了語義程式,我們對程式的語法和語義結構不感興趣,而是對指令執行的順序感興趣。
在生成實際機器程式碼之前,通常會生成某種中間程式碼。這樣做的好處是
- 更容易生成更抽象的程式碼,不必過於關注暫存器分配等問題,
- 可以進行獨立於機器架構的最佳化,以及
- 更容易發現編譯器錯誤。
然而,對於你的程式來說,直接輸出彙編程式碼可能更簡單,但你會失去上述優點。有關此方面的更多技術,請參見下一節。
在本章中,我們將使用三地址格式來表示中間程式碼。這種格式很有用,因為它類似於某些架構中的實際機器指令,更重要的是,它允許我們輕鬆地更改指令的執行順序,這比像 Java 位元組碼這樣的基於堆疊的中間程式碼具有巨大優勢。
雖然在名稱已經使用過之後重複使用它們並不是一個複雜的問題,但實際上每次需要時都分配一個新名稱是有益的,因為它允許我們形成呼叫圖並輕鬆最佳化,正如我們將在後面看到的那樣。出於這個原因,我們只簡要介紹了重複使用名稱的方法。你可以在最佳化章節中找到更多關於名稱分配最佳化的內容。
顧名思義,三地址程式碼由三個地址和操作碼組成,操作碼錶示要執行的操作型別。例如,表示式 (a + b) * 3 可以轉換為
temp1 := a + b; temp2 := temp1 * 3
在第一行中,temp1、a 和 b 是地址,+ 是操作碼,第二行類似於第一行。與載入-儲存機器不同,不需要將變數載入到暫存器中並存儲回暫存器。你明白為什麼三地址程式碼易於處理了。
選擇可移植、靈活和表達能力強的指令至關重要;指令不足會使生成的程式碼變得複雜,需要組合多個指令才能完成一項操作,而指令過多顯然會使維護變得更加艱鉅。也許最好的方法是檢查現有的機器程式碼。將接近底層機器程式碼的程式碼轉換為抽象程式碼比將抽象程式碼轉換為底層機器程式碼更直接。
代數表示式可以非常直接地轉換為三地址程式碼。這可以透過遞迴的方式完成,如下所示:假設兩個表示式 left 和 right 帶有一個操作 op-code,則結果應該是
code for left code for right temp = place for left + place for right
生成控制結構程式碼的基本思想與用匯編程式設計相同。也就是說,例如,if 語句將被轉換為使用條件跳轉和無條件跳轉的程式碼塊。
如果你的編譯器的輸出是組合語言程式碼,那麼理解組合語言程式設計的基本技術是必要的。大多數程式語言並不容易對映到大多數組合語言,因此在嘗試編寫將輸出彙編程式碼的程式碼之前,可能需要理解一些技術或技能。這些技術並不打算建立高度最佳化的程式碼——你將在後面學習最佳化技術——而是旨在確保你充分理解編譯器構造過程中資料和指令的管理方式。
許多程式使用數百個不同的變數(不包括陣列)。
大多數 計算機架構 提供的暫存器少於 32 個(MIPS 架構 和 ARM 提供將近 32 個指標暫存器;i386 僅提供約 4 個指標暫存器;PIC 微控制器 只有一個指標暫存器)。
由於我們無法將 100 個不同的變數塞入 32 個處理器暫存器中,因此必須使用記憶體來儲存大多數變數。
我們將從將幾乎所有變數儲存在記憶體中開始。稍後我們將介紹嘗試將盡可能多的變數保留在處理器暫存器中的最佳化技術。
你正在使用的彙編工具可能會保留助記符的名稱。例如,你的彙編器可能不允許使用名為add的變數,因為它已保留用於加法指令。
在這種情況下,可能需要為你的變數標籤使用字首。一些編譯器使用單個下劃線,但你可以選擇你喜歡的任何一個。