跟隨一條指令來看LLVM的基本結構

LLVM是一個很複雜的軟件,瞭解LLVM的工作原理不是很容易,然而,對於剛開始接觸LLVM整個框架的工作原理來說,詳細而深入,不如廣泛而淺顯,所以有了這一篇文章。

通過跟隨一條指令在LLVM中的各個passes中的狀態變化,從源程序開始,到目標代碼結束,可以讓我們對LLVM的整體框架有個大致的認識。

這篇文章基於Life of an instruction in LLVM,文章大部分內容與參考文章一致,但由於參考文章編輯於2012年11月,當時的LLVM版本是3.2,距現在新的LLVM版本已有一些差異,所以有部分內容我做了調整。

這篇文章不會詳細講解各個passes中的實現,儘量易於理解,儘量緊貼指令的變化過程。

有關於LLVM中的一些基本概念,可以參考:https://blog.csdn.net/SiberiaBear/article/details/103111028

輸入代碼

使用的輸入代碼與參考文章一致,選擇一段C語言來開始:

int foo(int aa, int bb, int cc) {
	int sum = aa + bb;
	return sum / cc;
}

我們focus的指令是除法指令,不關注其他代碼。

Clang

Clang是LLVM框架的前端,用來將C、C++、ObjectC的源代碼轉換爲LLVM IR結構,它最複雜的實現是處理C++中的一些特殊語法,對於我們這個簡單的C代碼來說,處理很簡單,按照詞法+語法+語義的方式走就可以。

Clang的parser會構建一個AST,並作爲它的中間表示,對於我們的除法操作,在AST中會生成一個BinaryOperator節點,承載一個B0_div的操作碼類型。通過clang自帶的ast dump插件,命令爲clang -Xclang -ast-dump -fsyntax-only test.c,可以查看AST的情況(參考文章中需更新)。Clang的代碼生成器會將AST轉換爲一個LLVM IR,這時我們的指令會生成爲一個sdiv的LLVM IR指令,這是一個有符號的除法指令。

LLVM IR

經過Clang處理後,輸出的是LLVM的IR表示:

define i32 @foo(i32 %aa, i32 %bb, i32 %cc) nounwind {
entry:
  %add = add nsw i32 %aa, %bb
  %div = sdiv i32 %add, %cc
  ret i32 %div
}

在LLVM IR中,sdiv是一個BinaryOperator對象,這是一個帶有SDiv操作數的Instruction類的子類。就像其他指令一樣,LLVM IR可以被LLVM的解析和轉換pass來處理,最終會通過LLVM代碼生成,進入下一個環節。

**代碼生成器(Code Generator)**是LLVM中一個很複雜的部分。它的工作是將高層級的、目標無關的LLVM IR下降爲低層級的、目標相關的機器指令,代碼生成通常也就認爲是LLVM的後端(注意不是LLVM/Clang的後端,LLVM的前端被認爲是LLVM IR的parser)。

代碼生成器有多個階段組成,包括指令選擇、指令調度、寄存器分配、寄存器分配後指令調度以及各階段可能有的優化和調整過程。其中很重要的一個步驟是類型合法化和指令合法化,使用了目標特殊的處理方法來將所有的指令和類型都轉換爲目標能夠支持的模式。從輸入LLVM IR之後,代碼生成器首先調用SelectionDAGBuilder將LLVM IR轉換爲SelectDAG,直到指令調度之前都是SelectionDAG格式表示作爲中間表示,之後到代碼發射之前,都是MI格式,代碼發射步驟中,會把MI翻譯爲MCInst,進而翻譯爲目標代碼文件或彙編代碼文件。

在下降的過程中,LLVM IR的指令會先變成selection DAG,下一節將會講解這一部分知識。

SelectionDAG

SelectionDAG是由SelectionDAGBuilder類生成的,它服務於SelectionDAGISel類,而這個類是用於指令選擇的重要類。SelectionDAGISel遍歷所有的LLVM IR指令,調用SelectionDAGBuilder::visit方法來解析它們。處理我們SDiv指令的方法是SelectionDAGBuilder::visitSDiv方法,它請求一個操作數爲ISD::SDIV的SDNode(SelectionDAG node),並添加到DAG中。

DAG叫做有向無環圖,是編譯器原理中很重要的數據結構,可以協助完成很多指令選擇中重要的工作,這裏它是我們代碼的一種中間表示,指令選擇的算法依賴於DAG形式作爲中間表示。

初始化DAG的過程是部分目標相關的,在LLVM的術語中,叫做非法的(illegal),原因是這時的DAG中還包含一部分目標無關的節點,這些節點對於目標來說不支持。

LLVM中支持一些顯示DAG的方法。一種方法是在llc中指定-debug參數,這可以在編譯時輸出各階段的DAG的文本信息(需要注意LLVM編譯時指定爲debug模式)。另一種方法是指定-view選項,這一類選項有很多,分別對應不同階段(這裏的不同階段是指從LLVM IR輸入代碼生成器,到指令調度之前的階段,這個階段的中間表示是DAG,同時這個階段也分爲好多步驟,後邊小節會講到,所以會有不同階段的DAG如是說)的DAG形式,它可以自動啓動系統的image瀏覽軟件,展示圖形化的DAG結構。比如:

llc的參數 對應顯示DAG的位置
-view-dag-combine1-dags DAG合併1之前的DAG
-view-dag-legalize-types-dags 類型合法化1之前的DAG
-view-dag-combine-lt-dags 類型合法化2之後、DAG合併之前的DAG
-view-legalize-dags DAG合法化之前的DAG
-view-dag-combine2-dags DAG合併2之前的DAG
-view-isel-dags 指令選擇之前的DAG
-view-sched-dags 指令選擇之後、指令調度之前的DAG

x86平臺上合法化sdiv到sdivrem

在x86平臺上,除法指令會同時計算商和餘數,並將結果分別存在兩個獨立的寄存器中。因爲在LLVM的指令選擇中,計算商的節點操作爲ISD::SDIV,而計算餘數的節點操作是另一個ISD::SDIVREM,所以我們需要使用合法化(legalized)來針對x86做特殊操作。

代碼生成的一個很重要的任務就是將目標無關的信息轉換爲目標相關的信息,這些算法通過TargetLowering類來實現,而這個過程就叫做合法化,DAG會從非法DAG變爲合法DAG,最終的合法DAG中的全部節點都是目標能夠支持的(這部分代碼很難理解)。x86平臺上這個實現類是X86TargetLowering,它的構造函數中指定了哪些操作數需要合法化展開,ISD::SDIV就是其中之一。代碼中有這麼一段註釋:

// 標量整數除法和求餘操作被下降爲能夠生成兩個結果的操作,用以匹配可用的指令。這將兩個結果的模式交由普通CSE處理,CSE能夠將x/y和x%y組合成一條指令。

SDIV節點中會包含有Expand標記,當SelectionDAGLegalize::LegalizeOp檢查到這個標記時,它將會用ISD::SDIVREM來替換ISD::SDIV節點。在合法化過程中,這是一個比較特殊且很有意思的例子,合法化在SelectionDAG結構階段多次出現,是爲了最優處理程序。

指令選擇

下一個步驟是指令選擇。LLVM提供了一套基於查詢表的指令選擇機制,這套查詢表通過TableGen來生成。很多目標平臺後端也會選擇編寫自定義的代碼來手動處理一些指令,通常在SelectionDAGISel::Select中實現。其他能夠自動生成的指令都是通過TableGen來完成,並且通過SelectCode來完成調用。

可以參考:https://blog.csdn.net/SiberiaBear/article/details/103319595簡單瞭解TableGen的概念。

x86平臺後端是手動處理ISD::SDIVREM節點的,主要是考慮到一些特殊的情況和優化。DAG節點在這個階段MachineSDNode,這個類是SDNode的子類,定義了一些與真實機器平臺相關的成員,但依然是DAG節點。經過指令選擇階段,我們的除法指令被選擇爲X86::IDIV32r

指令調度及發射MI

到目前爲止,我們的代碼依然是DAG格式,但是CPU不處理DAG,所以我們需要把DAG轉換爲線性的指令序列。指令調度的目的是序列化DAG,並且調整指令之間的先後順序,它使用一些啓發式的編排算法,比如register pressure reduction來嘗試輸出最佳的指令序列。

指令調度階段會儘可能的提高指令並行度,使用儘可能多的虛擬寄存器(虛擬寄存器後邊會講到),其目的是使代碼運行效率更高(插播一句,後邊的寄存器分配傾向於使指令串行化,這樣可以儘量少的使用寄存器,所以,指令調度和寄存器分配是兩個相互對立的階段,編譯器在雙手博弈中,實現編譯目標的最優化)。

目標特殊的一些調度實現算法會加在該過程中,從而影響調度的結果。

最終,指令調度會通過InstrEmitter發射出指令序列,這些序列放到一個MachineBasicBlock中,這種代碼表現形式叫做MachineInstr,簡稱爲MI,之後,DAG格式的代碼信息被銷燬。

通過給llc指定-print-machineinstrs參數可以指定打印出MI的信息:

# After Instruction Selection:
# Machine code for function foo: SSA
Function Live Ins: %EDI in %vreg0, %ESI in %vreg1, %EDX in %vreg2
Function Live Outs: %EAX

BB#0: derived from LLVM BB %entry
    Live Ins: %EDI %ESI %EDX
        %vreg2<def> = COPY %EDX; GR32:%vreg2
        %vreg1<def> = COPY %ESI; GR32:%vreg1
        %vreg0<def> = COPY %EDI; GR32:%vreg0
        %vreg3<def,tied1> = ADD32rr %vreg0<tied0>, %vreg1, %EFLAGS<imp-def,dead>; GR32:%vreg3,%vreg0,%vreg1
        %EAX<def> = COPY %vreg3; GR32:%vreg3
        CDQ %EAX<imp-def>, %EDX<imp-def>, %EAX<imp-use>
        IDIV32r %vreg2, %EAX<imp-def>, %EDX<imp-def,dead>, %EFLAGS<imp-def,dead>, %EAX<imp-use>, %EDX<imp-use>; GR32:%vreg2
        %vreg4<def> = COPY %EAX; GR32:%vreg4
        %EAX<def> = COPY %vreg4; GR32:%vreg4
        RET

# End machine code for function foo.

MI格式表示是一種類似於彙編代碼的形式,它採用三地址形式來表現指令信息,並序列化存儲信息,每一條MI指令包括有指令操作碼、以及一系列操作數。

寄存器分配

除了一些例外的情況,指令選擇步驟之後輸出的大多數DAG節點是SSA格式的,指令調度之後輸出的是SSA格式的MI序列,SSA格式全稱是static single assignment form,叫做靜態單賦值形式,是一種很常見的編譯器中間形式,在SSA中,UD鏈(use-define chain)是非常明確的,變量不會重複定義和賦值。比如:

x1 = y1 + 1;
x2 = y2 + 1;
x1 = x2;

上邊這個不是SSA形式,因爲x1被重複賦值,而下邊這個是SSA格式:

x2 = y2 + 1;
x1 = x2;

指令選擇時,使用了無限的虛擬寄存器集,但是目標平臺不可能識別這些虛擬寄存器,所以寄存器調度的一個工作就是將虛擬寄存器全部替換爲物理寄存器(它的另一個工作是一些優化過程)。

在一些目標架構下,一些指令需要使用指定的固定寄存器。一個例子就是我們X86平臺下的除法指令,這條除法指令要求它的輸入必須是EDX和EAX寄存器。指令選擇時就已經知道這個信息,並且輸出時就是物理寄存器,這個過程由X86DAGToDAGISel::Select完成。

寄存器分配處理所有的虛擬寄存器,並且會做一些優化,比如說僞指令展開,本文不詳細展開講解。同樣,這裏也不討論寄存器分配之後的一些步驟,這些步驟不會再改動代碼的表現形式(一直是MI),後續的步驟有寄存器分配後的指令調度、一些合法化的工作,目的是進一步的降級代碼,使之更接近目標指令,同時這中間還會涉及一些優化passes,通過在工程代碼中查找TargetPassConfig::addMachinePasses,能瞭解這些passes。

代碼發射

到現在爲止,我們已經將C源程序翻譯爲MI格式代碼。我們知道,目標代碼分爲彙編代碼和二進制可執行代碼。而現在的LLVM還提供了一種(傳統的)JIT的方式,這種JIT的目標輸出代碼可以直接在內存中執行,我理解爲類似Java字節碼的東西,而且,最初的LLVM(Low Level Virtual Machine)的目的也是做一個類似Java虛擬機的東西來研究優化問題,所以,這個輸出方式就保留下來了。另一種輸出方式是MC架構,這是一種非常讚的目標文件和彙編文件輸出框架,曾經的LLVM的彙編器功能很單一,後來,爲了兼顧目標碼的輸出,就重新設計了這一套MC框架,替代了之前的彙編器。現在大多數的用法都是從MC框架輸出目標碼,較少會走傳統JIT那條,可能是因爲LLVM不把Java作爲自己的競爭對手吧。

JIT

JIT代碼的輸出是通過LLVMTargetMachine::addPassesToEmitMachineCode來完成,它調用addPassesToGenerateCode,這個函數中定義了所有這篇文章提到的從IR到MI的各個passes。然後,它調用addCodeEmitter,這是一個目標特殊的pass,用來將MI轉換爲真實機器指令(當前CPU可執行的),實際上JIT執行的機器指令和MI已經很相似了,所以這部分的翻譯工作很直接。對於X86平臺,這些代碼寫在lib/Target/X86/X86CodeEmitter.cpp中,對於我們的除法指令,這裏沒有什麼特殊要講的,因爲MI指令中已經包含了最終目標相關的操作碼和操作數。最終,所有指令通過emitInstruction發射。

MCInst

另一種輸出是MC框架,它的中間表示被稱爲MCInst。當LLVM被看作靜態編譯器時(比如clang的一部分),MI序列還會下降到MC層,用來輸出靜態編譯器會輸出的目標碼或彙編碼。MC框架的介紹可以參考官方的文章:Introduce to LLVM MC project,不過這篇文章也比較舊了,也僅供參考學習。

LLVMTargetMachine::addPassesToEmitFile方法負責定義發射目標代碼的passes,真正將MI翻譯爲MCInst的pass是AsmPrinter::EmitInstruction接口,雖然這個類看着像是彙編碼輸出的類,然而不是,我一直不是很喜歡MC框架的一些類的命名。對於X86平臺,會有個子類繼承這個AsmPrinter類,叫做X86AsmPrinter,從而發射MCInst的方法爲X86AsmPrinter::EmitInstruction,這個過程需要X86MCInstLower類的協助。和前邊DAG時期的Lower不一樣,當時協助的下降類是X86TargetLowering,用來提前下降一些必要DAG的,而這裏是MC框架下的InstLower,其實一些比較關鍵的算法都在這幾個類裏邊。

對於我們的除法指令,這裏也沒有什麼特殊要處理的地方。

通過給llc指定-show-mc-inst參數,可以打印出MC指令信息和彙編代碼:

foo:                                    # @foo
# BB#0:                                 # %entry
        movl    %edx, %ecx              # <MCInst #1483 MOV32rr
                                        #  <MCOperand Reg:46>
                                        #  <MCOperand Reg:48>>
        leal    (%rdi,%rsi), %eax       # <MCInst #1096 LEA64_32r
                                        #  <MCOperand Reg:43>
                                        #  <MCOperand Reg:110>
                                        #  <MCOperand Imm:1>
                                        #  <MCOperand Reg:114>
                                        #  <MCOperand Imm:0>
                                        #  <MCOperand Reg:0>>
        cltd                            # <MCInst #352 CDQ>
        idivl   %ecx                    # <MCInst #841 IDIV32r
                                        #  <MCOperand Reg:46>>
        ret                             # <MCInst #2227 RET>
.Ltmp0:
        .size   foo, .Ltmp0-foo

通過指定-show-mc-encoding參數,可以打印彙編代碼和二進制編碼(目標碼)的信息。

目標代碼或者彙編代碼發射是在MCStreamer類中實現的,這個類被兩個子類繼承,分別是發射目標代碼的MCObjectStreamer和發射彙編代碼的MCAsmStreamer。對於發射目標代碼,因爲我們針對不同操作系統平臺有不同的目標文件格式,比如Windows的COFF、Linux的ELF等,所以MCObjectStreamer被進一步繼承爲MCCOFFStreamerMCELFStreamer等子類。在這些子類中都重寫了父類的MCObjectStreamer::EmitInstruction,這個方法實現發射目標代碼的工作。輸出目標文件的過程還需要MCCodeEmitter的支持,最終輸出目標可執行文件。

到這一步,我們的除法指令就被輸出成彙編碼或者二進制編碼格式了,相對應的可執行文件也可以在X86平臺下跑起來(需要對應到操作系統),這條指令在LLVM的行程也就結束了。

彙編器與反彙編器

MCInst是一種簡單的代碼表現形式,它儘量屏蔽了語義上的信息,僅保留指令編碼和指令操作數,以及一些指令位置信息。和LLVM IR一樣,它是多種可能編碼形式的中間表示,可以理解爲LLVM後端的一種IR,比如說彙編代碼和二進制目標代碼都可以由它來表示。

llvm-mc工具是MC框架下邊的一個工具,clang工具在一次性驅動編譯器輸出彙編碼和二進制目標碼時不會調用llvm-mc,因爲我們知道LLVM的設計思想是一切都是庫,clang驅動工具和llvm-mc調用的是一樣的MC框架下的庫,而llvm-mc可以便於我們直接調用MC框架下的庫來實現功能,比如說彙編器和反彙編器(另外還有一些目標文件分析工具,比如llvm-objdumpllvm-readobj,也是調用了MC框架下邊的庫),所以,llvm-mc被可以看作是通常意義下的彙編器和反彙編器,對標gcc下的as和dis,可以輸入彙編碼吐出二進制可執行文件,或者輸入二進制可執行文件吐出彙編碼。

發佈了42 篇原創文章 · 獲贊 20 · 訪問量 7萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章