2021年4月2日 星期五

LLVM 是如何處理呼叫慣例(Calling Convention) (2)

Photo by Markus Spiske on Unsplash
前言

前一篇文章中,我們提到了 LLVM 後端中處理呼叫慣例的兩個關鍵檔案: <Arch>CallingConv.td 與 <Arch>ISelLowering.cpp 。不過在 <Arch>ISelLowering.cpp 中,我們只探討了 LowerFormalArguments() 函式。另外兩個函式 LowerReturn() 與 LowerCall() 將於本篇文章討論。

原始碼分析

1. LowerReturn()

這邊我們一樣以 Sparc 的原始碼來當做分析對象,首先來看看 LowerReturn()。這個函式主要是處理被呼叫端(callee)的返回值傳遞。與 LowerFormalArgunets() 雷同,首先會透過下面這段程式碼取得回傳值擺放位置的資訊。
CCInfo.AnalyzeReturn(Outs, RetCC_Sparc32);
其中,Outs 代表每一個回傳值的資訊,而 RetCC_Sparc32 則是 LLVM 透過
SparcCallingConv.td 中的 RetCC_Sparc32 所生成的一個函式。 AnalyzeReturn() 的任務就是掃描每一個回傳值,透過 RetCC_Sparc32 回傳的資訊來決定返回值要放在哪一個寄存器中。這些資訊會儲存在 RVLocs 容器中。

接著會走訪 RVLocs 容器以取得每個返回值要使用的寄存器,並生成 CopyToReg 節點。此節點的行為就是將虛擬寄存器中的值移動到實體寄存器中。如此一來,被呼叫端的返回值便傳遞完成。

最後,便是產生 SPISD::RET_FLAG 節點,此節點在之後指令選擇的階段會被轉換成 Sparc 的 retl 指令。關於 SPISD::RET_FLAG 的定義可以參考 SparcInstrInfo.td 的第300行。

2. LowerCall()

接著來看看 LowerCall()。LowerCall() 主要在處理呼叫端(caller)的參數傳遞、 SPISD::CALL節點之生成與返回值返回。
  • 參數傳遞
在上一篇文章我們提到,LowerFormalArguments() 做的事情是將實體寄存器或堆疊中的參數移動到虛擬寄存器中。而 LowerCall() 剛好相反,他做的事情是將虛擬寄存器中的參數移動到實體寄存器或堆疊中。首先,與 LowerFormalArguments() 一樣,需先透過以下程式碼將參數所存放的位置儲存在 Outs 這個容器中 
CCInfo.AnalyzeCallOperands(Outs, CC_Sparc32);

接著,掃描 Outs 容器中的每一個參數,這時會有兩種情況:

 aVA.isRegLoc() 回傳值為真: 代表參數要以寄存器傳遞

此時會產生 CopyToReg 節點,此節點的行為是將虛擬寄存器中的參數移動到實體寄存器中。對 Sparc 而言,這些實體寄存器就是 O0 到 O5 這六個寄存器。 

b.  VA.isRegLoc() 回傳值為真: 代表參數要以堆疊傳遞

首先會先計算參數在堆疊中的位置。舉個例子: 對 Sparc 而言,第8個參數會放在 %sp + 92 + 4,也就是 %sp + 96 的位置。算出堆疊中的位置後,接著便是產生 Store 節點,將參數從虛擬寄存器儲存到此位置裡。

  • SPISD::CALL 節點的生成
傳遞完參數之後,接著便是生成 SPISD::CALL 節點,節點在之後指令選擇的階段會被轉換成 Sparc 的 call 指令。關於 SPISD::CALL 節點的定義可以參照 SparcInstrInfo.td 的第549行。
  • 返回值返回

此步驟會針對每一個返回值生成一個 CopyFromReg 節點,此節點的行為便是將存於 O0 到 O6 的返回值移動到虛擬寄存器中。

OK,接下來我們透過實驗來看看 LowerCall() 產生的 DAG 節點長什麼樣子。

實驗觀察

首先,與之前一樣,為了方便觀察,我們先偷偷的將 CC_Sparc32 中的寄存器數量從6個減少到1個 (也就是只剩一個 I0)。
def CC_Sparc32 : CallingConv<[
  ...
  // i32 f32 arguments get passed in integer registers if there is space.
  CCIfType<[i32, f32], CCAssignToReg<[I0]>>,
  ...
]>;

接著,準備一份 LLVM IR,這份程式碼必須包含一個 main 函式(當作呼叫端)與一個 foo 函式(當作被呼叫端) 。foo 函式有兩個參數 %m 和 %n,並回傳0。因此,根據之前的分析,我們預期,第一個參數 %m 會以寄存器的方式傳遞,第二個參數 %n 會以堆疊的方式傳遞,而返回值會以寄存器的方式返回。

define i32 @foo(i32 %m, i32 %n) nounwind uwtable {
entry:
  %m.addr = alloca i32, align 4
  %n.addr = alloca i32, align 4
  store i32 %m, i32* %m.addr, align 4
  store i32 %n, i32* %n.addr, align 4
  ret i32 0
}

define i32 @main() nounwind uwtable {
entry:
  %a = alloca i32, align 4
  %b = alloca i32, align 4
  store i32 0, i32* %a, align 4
  store i32 5, i32* %b, align 4
  %0 = load i32* %a, align 4
  %1 = load i32* %b, align 4
  %call = call i32 @foo(i32 %0, i32 %1)
  ret i32 0
}

我們透過 llc 來編譯這份 LLVM IR,並將 main 函式 DAG 節點的資訊印出。因為版面的緣故,我這邊將整張圖拆成兩部分,我們先來看看參數傳遞的部分:


其中,紅色框框所標起來的 CopyToReg 節點即代表傳入 foo() 第一個參數 %m。而藍色框框標起來的 Store 節點則代表代表傳入 foo() 第二個參數 %n。而綠色框框則是代表所生成的 SPISD::CALL 節點。此結果符合我們之前的分析。

接著來看看返回值的部分:


其中,綠色框框一樣是代表所生成的 SPISD::CALL 節點,而橘色框框則是代表負責接收返回值的 CopyFromReg 節點。此結果同樣也符合我們之前的分析。

小結

最後來總結一下 LLVM 後端是如何處理呼叫慣例

1. 透過 <Arch>CallingConv.td 來定義參數與返回值存放位置
2. 在 <Arch>ISelLowering.cpp 中
  • LowerFormalArguments() 處理被呼叫端的參數傳遞,此步驟會生成 CopyFromReg 或 Load 節點。
  • LowerReturn() 處理被呼叫端的返回值,此步驟會生成 CopyToReg 節點。
  • LowerCall() 處理呼叫端的參數傳遞與返回值,其中,參數傳遞會以 CopyToReg 或 Store 節點來完成,而返回值則會透過 CopyFromReg 節點來接收。 
以上便是 LLVM 後端呼叫慣例的簡介。不過雖然 <Arch>ISelLowering.cpp 已經處理了大部份的事情,還是有些細節無法在 <Arch>ISelLowering.cpp 完成。比如: 堆疊位置的計算、prologue 和 epilogue 的機制等等。這主要是因為在 SelectionDAG 這個階段還沒有足夠的資訊來處理這些事情,必須等到寄存器分配之後才可以得到這些資訊。關於這部分的討論,就留到之後的文章介紹吧~~

沒有留言:

張貼留言