1. 程式人生 > >編譯原理之程式碼生成

編譯原理之程式碼生成

前面提到了經過了詞法分析->語法分析->語義分析->中間程式碼優化,最後的階段便是在目標機器上執行的目的碼的生成了。目的碼生成階段的任務是:將此前的中間程式碼轉換成特定機器上的機器語言或組合語言,這種轉換程式便被稱為程式碼生成器。

1. 程式移植性和編譯器模組設計的關係

之所以將編譯原理分成這種多階段多模組的組織形式,本質的考慮其實只有兩個方面:
一、程式碼複用:儘可能在不增加程式設計師工作量的前提下,增加應用程式的可移植性。
可我們知道不同機器的機器指令集和硬體結構,千差萬別,而前面又提到了為了將程式的執行效率提升到最大,顯然是需要將軟體按照硬體特性極盡可能地定製化優化,其實現在AI晶片的發展趨勢也是這種思想的另一面體現。現今AI晶片為了加速深度學習為程式碼的超強度計算效率,出現了針對具體程式架構定製化晶片的需求,如CPU->GPU的發展,便是GPU迎合了深度學習單輪多次簡單運算的需求,CPU->FPGA的矩陣運算側重也是Facebook引領的另一硬體繫結具體軟體計算需求的工程案例,而最為極端者莫過於當下Google推出的TPU計算晶片,完全剔除不必要的硬體裝置,根據Google的深度學習Tensorflow架構定製化的計算晶片,更是以極致的定製化獲取比CPU計算速度高200倍的顯著成果。
)。

所以再一次驗證了“銀彈”理論,如果有問題解決不了,那就加一層抽象層了。所以提高程式碼移植性的集大成者Java,便是通過引入JVM將應用程式層和具體硬體層隔開。


圖1. Java-多目標機

顯然對於Java程式可以共享一套“詞法分析+語法分析+語義分析+中間程式碼優化引擎”這一套前端套件,而針對不同機型的定製化目的碼生成器則可以通過官方渠道的支援和更新來組裝成自己所需的後端套件組。這樣便可以讓程式設計師儘可能地有較多的自由編寫的空間,極大地提升程式碼可移植性,而不像C++編寫的DLL庫,即便採用複雜的COM規則,極為小心地編寫,但也可能因為編譯器版本不同導致移植性不通過。

二、特定機器的優化器和生成器複用:針對特定機器的目的碼生成和優化器的工作極為複雜精深,有一套成功的,當然要儘可能複用它。

由於程式碼生成階段的目的碼和具體計算機的結構有關,如指令格式、字長以及暫存器的個數和種類,並與指令的語義和所用作業系統等都密切相關,特別是高階語言的語義功能複雜,並且計算機硬體結構多樣性都給程式碼生成的理論研究帶來很大的複雜性,因此實際實現起來是非常困難的。所以難得生成一款後端的程式碼生成器,當然是想讓它可以獨立出來,被多次組裝參與其他編譯器的生產過程。


圖2. 多種語言-一種目標機型

這種情況被稱為定計算機情況:當限定某種計算機時,而高階語言為多種情況下所設計的中間語言,應能充分反映限定計算機的特點稱MSIL(Machine Specific Intermediate language)。對這種機器的所有編譯程式在分析階段都生成MSIL,在實現一個編譯程式時,儘量把編譯過程的大量工作放在程式碼生成階段,即MSIL到目標程式的翻譯上,以減輕不同語言翻譯的分析任務。因不管多少種高階語言,MSIL到目標程式的程式碼生成只需做一次即可。

當然也正是這種組織特性,讓本來是集團作戰的編譯器生成工作,現如今變得不再是難以企及。同時也讓各行各業根據自身需求和側重點不同,甚至可以定製化自己的行業的專屬語言(Domain Specific Language)變得可實現。

2. 程式碼生成的優化手段:暫存器分配

說了這麼多,其實只需要明白程式碼生成器是結合目標機器平臺定製化的一套後端套件,這也是為何業界的主流計算機都是按照x86、x64_86這些業界標準來設計的,如果你家公司另闢蹊徑自己設計出一套硬體體系,即便你能設計生產出來,但你確定IT界會有下游供應商給你設計類似於程式碼生成器這種配套套件嗎?

衡量最終生成的目的碼的質量無非是從兩個方面來進行評估:空間佔用和執行效率,這其中涉及到諸多和具體硬體繫結的細節,大多數都屬於難度高且並不通用的範疇。而暫存器的使用規則則是少數具有通用的手段,故而可以借分析暫存器分配來分析一下目的碼優化和生成過程。

Q: 為什麼在程式碼生成時要考慮充分利用暫存器?
A: 因為當變數值存在暫存器時,引用的變數值可直接從暫存器中取,減少對記憶體的存取次數,這樣便可提高執行速度。因此如何充分利用暫存器是提高目的碼執行效率的重要途徑。

Q: 暫存器分配的原則是什麼?
A: (1)邏輯有效範圍內儘量保留: 當生成某變數的目的碼時,儘量讓變數的值或計算結果保留在暫存器中,直到暫存器不夠分配時為止。
 (2) 邏輯塊出口處,和記憶體中的源資料同步:當到基本塊出口時,將變數的值存放在記憶體中,因為一個基本塊可能有多個後繼結點或多個前驅結點,同一個變數名在不同前驅結點的基本塊內出口前存放的R可能不同,或沒有定值,所以應在出口前把暫存器的內容放在記憶體中,這樣從基本塊外入口的變數值都在記憶體中
 (3) 及時釋放,提升暫存器使用效率:對於在一個基本塊內後邊不再被引用的變數所佔用的暫存器應儘早釋放,以提高暫存器的利用效率。

可以看到在進行快取淘汰更新時我們採用了LRU策略,LRU策略是屬於經典的“線性思維”–即過去常被使用的,在未來也會常被使用。而這裡暫存器則不能採用“線性思維”,因為我們看自己的程式碼都知道,變數的使用雖然在區域性範圍還算符合“區域性性原理”,但是稍微放寬到一定尺度上的變數集使用情況,則發現更多是無序的。所以在進行暫存器分配策略的研究上,只能比較粗暴點,事先將目的碼再次遍歷一遍,將變數的使用情況事先整理好,並用所謂“待用資訊鏈”的資料結構來儲存每個變數的使用情況。這種策略並非LRU那種後視推導邏輯,而是耍賴的先知般論斷邏輯,但是考慮到一旦將目的碼的暫存器使用效率最大化,則無疑是一勞永逸的,故而也是划算的。

變數的待用資訊鏈計算方法
前面根據暫存器的使用原則可以看到,暫存器的分配是以基本塊為單位的,因為基本塊作為程式流的最小單元,存在著資料同步和非同步的問題,故而在進行暫存器分配時,要稽核的程式碼範圍只需要涉及到當前基本塊即可。

首先為任一變數設定兩個資訊鏈:待用資訊鏈和活躍變數資訊鏈。

考慮到處理的方便,可假定對基本塊中的變數在出口處都是活躍的,而對基本塊內的臨時變數可分為兩種情況處理。
  a) 一般情況下基本塊內的臨時變數在出口處都認為是不活躍的。
  b) 如果中間程式碼生成時的演算法允許某些臨時變數在基本塊外可以被引用時,則這些臨時變數也是活躍的。
  
在基本塊內計算變數的使用資訊鏈(覺得采用棧式更符合這種資訊鏈的更新情況),步驟如下:

① 對各基本塊的符號表中的”待用資訊”欄和”活躍資訊”欄置初值,即把”待用資訊”欄置”非待用”,對”活躍資訊”欄按在基本塊出口處是否為活躍而置成”活躍”或”非活躍”。現假定變數都是活躍的,臨時變數都是非活躍的。  
② 倒著來從基本塊出口到基本塊入口由後向前依次處理每個四元式。對每個四元式i:A:=B op C,依次執行下述步驟:
  a) 把符號表中變數A的待用資訊和活躍資訊附加到四元式i上。
  b) 把符號表中變數A的待用資訊欄和活躍資訊欄分別置為 “非待用” 和 “非 活躍”。由於在i中對A的定值只能在i以後的四元式才能引用,因而對i以前的四元式來說A是不活躍也不可能是待用的。
  c) 把符號表中B和C的待用資訊和活躍資訊附加到四元式i上。
  d) 把符號表中B和C的待用資訊欄置為”i”,活躍資訊欄置為”活躍”。

說的麻煩,舉個例子就好了:

  (1) T∶=A-B
  (2) U∶=A-C
  (3) V∶=T+U
  (4) D∶=V+U

加上資訊鏈條之後,則標記情況如下

  (1) T【(3)L】:= A【(2)L】 - B【FL】
  (2) U【(3)L】:= A【FL】 - C【FL】
  (3) V【(4)L】:= T【FF】 + U【(4)L】
  (4) D【FL】:= V【FF】 + U【FF】

這樣根據資訊連結串列法,在每次運算到一個表示式時,如果暫存器數目不夠,即無空餘暫存器可用,則可以遍歷一下當前在暫存器中的變數的待用資訊鏈,然後選擇接下來最遠將被呼叫的變數釋放其所佔用暫存器,分配暫存器的演算法為:

  ① 如果B的現行值在某暫存器Ri中,且該暫存器只包含B的值,或者BA是同一識別符號,或B在該四元式後不會再被引用,則可選取Ri作為所需的暫存器R,並轉(4);
  ② 如果有尚未分配的暫存器,則從中選用一個Ri為所需的暫存器R,並轉(4);
  ③ 從已分配的暫存器中選取一個Ri作為所需暫存器R,其選擇原則為:佔用該暫存器的變數值同時在主存中,或在基本塊中引用的位置最遠,這樣對暫存器Ri所含的變數和變數在主存中的情況必須先做如下調整:即對RVALUE[Ri]中的每一變數M,如果M不是AAVALUE[M]不包含M,則需完成以下處理;
  a) 生成目的碼ST Ri,M即把不是A的變數值由Ri中送入記憶體中;
  b) 如果M不是B,則令AVALUE[M]={M},否則,令AVALUE[M]={M, Ri};
  c) 刪除RVALUE[Ri]中的M;
  ④ 給出R,返回。

至此可以看到,單純是暫存器分配便是需要較多資料結構配合和工作時間消耗的,管中窺豹,可以看到程式碼生成器的一個部件工作量便是很複雜的。