LLVM IR 未定義行為 (UB) 手冊

摘要

本文檔描述 LLVM IR 中的未定義行為 (UB),包括未定義值和毒化值,以及 freeze 指令。我們還提供了關於何時使用每種形式 UB 的指南。

簡介

未定義行為 (UB) 用於指定我們不希望明確指定具體結果的邊緣情況的行為。UB 也用於為最佳化器提供額外的約束(例如,前端通過語言類型系統或運行時保證的假設)。例如,我們可以將除以零的結果指定為零,但由於我們實際上對結果不感興趣,因此我們說它是 UB。

LLVM 中存在兩種形式的未定義行為:立即性 UB 和延遲性 UB。後者有兩種變體:未定義值和毒化值。還有一個 freeze 指令來控制延遲性 UB 的傳播。LLVM 中的值格狀結構為:立即性 UB > 毒化值 > 未定義值 > freeze(毒化值) > 具體值。

我們將在下面詳細解釋每個概念。

立即性 UB

立即性 UB 是最嚴重的 UB 形式。應盡可能避免使用。立即性 UB 應僅用於在 LLVM 支援的大多數 CPU 中會導致陷阱的操作。範例包括除以零、取消引用空指標等。

應避免使用立即性 UB 的原因是它使諸如提升之類的最佳化變得更加困難。考慮以下範例

define i32 @f(i1 %c, i32 %v) {
  br i1 %c, label %then, label %else

then:
  %div = udiv i32 3, %v
  br label %ret

else:
  br label %ret

ret:
  %r = phi i32 [ %div, %then ], [ 0, %else ]
  ret i32 %r
}

我們可能會想通過刪除分支並推測性地執行除法來簡化此函數,因為大多數情況下 %c 為真。我們將獲得以下 IR

define i32 @f(i1 %c, i32 %v) {
  %div = udiv i32 3, %v
  %r = select i1 %c, i32 %div, i32 0
  ret i32 %r
}

但是,此轉換是不正確的!由於當除數為零時,除法會觸發 UB,因此只有在確定我們不會遇到該條件時,我們才能推測性地執行。上面的函數在作為 f(false, 0) 調用時,在最佳化之前將返回 0,而在最佳化之後將觸發 UB。

此範例突顯了為什麼我們應盡可能減少觸發立即性 UB 的情況。作為經驗法則,僅對於在大多數支援架構的 CPU 上會導致陷阱的情況使用立即性 UB。

時間旅行

LLVM IR 中的立即性 UB 允許所謂的時間旅行。這意味著,如果程式觸發了 UB,那麼我們不需要保留其任何可觀察的行為,包括 I/O。例如,以下函數在調用 printf 後觸發 UB

define void @fn() {
  call void @printf(...) willreturn
  unreachable
}

由於我們知道 printf 始終會返回,並且由於 LLVM 的 UB 可以時間旅行,因此完全可以刪除對 printf 的調用,並將函數最佳化為簡單的

define void @fn() {
  unreachable
}

延遲性 UB

延遲性 UB 是一種較輕形式的 UB。它使指令能夠推測性地執行,同時將某些邊緣情況標記為具有錯誤的值。延遲性 UB 應適用於常見 CPU 提供的語義不同的情況,但 CPU 不會陷阱。

例如,考慮移位指令。x86 和 ARM 架構在移位量等於或大於位寬時提供不同的語義。我們可以通過以下兩種方式之一解決此張力:1) 為 LLVM 選擇 x86/ARM 語義之一,這將使為另一個架構發出的代碼速度變慢;2) 將這種情況定義為產生 poison。LLVM 選擇了後一種選項。對於 C 或 C++ 等語言的前端(例如,clang),它們可以將原始程式碼中的移位直接映射到 LLVM IR 中的移位,因為 C 和 C++ 的語義將此類移位定義為 UB。對於提供強語義的語言,它們必須有條件地使用移位的值,例如

define i32 @x86_shift(i32 %a, i32 %b) {
  %mask = and i32 %b, 31
  %shift = shl i32 %a, %mask
  ret i32 %shift
}

LLVM 中有兩個延遲性 UB 值:undefpoison,我們將在下面介紹。

未定義值

警告

未定義值已被棄用,應僅在絕對必要時使用。未定義值的使用應限制為表示未初始化記憶體的載入。這是 IR 語義中唯一尚未被替代方案取代的部分(正在進行中)。

未定義值表示給定類型的任何值。此外,每次使用依賴於未定義值的指令都可以觀察到不同的值。例如

define i32 @fn() {
  %add = add i32 undef, 0
  %ret = add i32 %add, %add
  ret i32 %ret
}

毫不奇怪,第一個加法產生 undef。但是,第二個加法的結果更微妙。我們可能會認為它產生一個偶數。但它可能不是!由於每次(傳遞)使用 undef 都可以觀察到不同的值,因此第二個加法等效於 add i32 undef, undef,這等效於 undef。因此,上面的函數等效於

define i32 @fn() {
  ret i32 undef
}

每次調用此函數都可能觀察到不同的值,即任何 32 位元數字(偶數和奇數)。

由於每次使用未定義值都可以觀察到不同的值,因此如果我們不確定某個值不是未定義值,則某些最佳化是錯誤的。考慮一個將數字乘以 2 的函數

define i32 @fn(i32 %v) {
  %mul2 = mul i32 %v, 2
  ret i32 %mul2
}

即使 %v 是未定義值,此函數也保證返回一個偶數。但是,正如我們在上面看到的,以下函數不會

define i32 @fn(i32 %v) {
  %mul2 = add i32 %v, %v
  ret i32 %mul2
}

僅僅因為未定義值的存在,即使它們沒有在此程式碼部分中使用,這種最佳化也是錯誤的,因為 LLVM 無法判斷 %v 是否為未定義值。

查看值格狀結構,未定義值只能用 freeze 指令或具體值替換。一個結果是,將未定義值作為操作數提供給對於該操作數的某些值會觸發 UB 的指令會使程式碼 UB。例如,udiv %x, undef 是 UB,因為我們將未定義值替換為 0 (udiv %x, 0),這顯然是 UB。

毒化值

毒化值是比未定義值更強形式的延遲性 UB。它們仍然允許指令推測性地執行,但它們會污染整個表達式 DAG(除了一些例外),類似於浮點 NaN 值。

範例

define i32 @fn(i32 %a, i32 %b, i32 %c) {
  %add = add nsw i32 %a, %b
  %ret = add nsw i32 %add, %c
  ret i32 %ret
}

加法中的 nsw 屬性表示如果存在有號溢位,則操作產生毒化值。如果第一個加法溢位,則 %add 是毒化值,因此 %ret 也是毒化值,因為它污染了整個表達式 DAG。

毒化值可以用任何類型的值替換(未定義值、具體值或 freeze 指令)。

毒化值透過 Select 的傳播

如果任何輸入是毒化值,則大多數指令都會返回毒化值。一個值得注意的例外是 select 指令,它僅當條件是毒化值或選定的值是毒化值時才是毒化值。這表示 select 充當毒化值傳播的屏障,這會影響可以執行的最佳化。

例如,考慮以下函數

define i1 @fn(i32 %x, i32 %y) {
  %cmp1 = icmp ne i32 %x, 0
  %cmp2 = icmp ugt i32 %x, %y
  %and = select i1 %cmp1, i1 %cmp2, i1 false
  ret i1 %and
}

select 最佳化為 and 是不正確的,因為當 %cmp1 為 false 時,僅當 %x 是毒化值時,select 才是毒化值,而下面的 and 如果 %x%y 是毒化值,則為毒化值。

define i1 @fn(i32 %x, i32 %y) {
  %cmp1 = icmp ne i32 %x, 0
  %cmp2 = icmp ugt i32 %x, %y
  %and = and i1 %cmp1, %cmp2     ;; poison if %x or %y are poison
  ret i1 %and
}

但是,如果值的所有操作數都在條件中使用,則可以進行最佳化(請注意 select 中翻轉的操作數)

define i1 @fn(i32 %x, i32 %y) {
  %cmp1 = icmp ne i32 %x, 0
  %cmp2 = icmp ugt i32 %x, %y
  %and = select i1 %cmp2, i1 %cmp1, i1 false
  ; ok to replace with:
  %and = and i1 %cmp1, %cmp2
  ret i1 %and
}

Freeze 指令

未定義值和毒化值有時會過度向下傳播表達式 DAG。未定義值是因為每次傳遞使用都可以觀察到不同的值,而毒化值是因為它們使整個 DAG 毒化。在某些情況下,停止這種傳播非常重要。這就是 freeze 指令的用途!

以下面的範例函數為例

 define i32 @fn(i32 %n, i1 %c) {
 entry:
   br label %loop

loop:
   %i = phi i32 [ 0, %entry ], [ %i2, %loop.end ]
   %cond = icmp ule i32 %i, %n
   br i1 %cond, label %loop.cont, label %exit

loop.cont:
   br i1 %c, label %then, label %else

 then:
   ...
   br label %loop.end

 else:
   ...
   br label %loop.end

 loop.end:
   %i2 = add i32 %i, 1
   br label %loop

 exit:
   ...
 }

想像一下,我們想要對上面的迴圈執行迴圈展開,因為迴圈內的 branch 條件是不變的。我們將獲得以下 IR

 define i32 @fn(i32 %n, i1 %c) {
 entry:
   br i1 %c, label %then, label %else

then:
   %i = phi i32 [ 0, %entry ], [ %i2, %then.cont ]
   %cond = icmp ule i32 %i, %n
   br i1 %cond, label %then.cont, label %exit

then.cont:
   ...
   %i2 = add i32 %i, 1
   br label %then

else:
   %i3 = phi i32 [ 0, %entry ], [ %i4, %else.cont ]
   %cond = icmp ule i32 %i3, %n
   br i1 %cond, label %else.cont, label %exit

else.cont:
   ...
   %i4 = add i32 %i3, 1
   br label %else

 exit:
   ...
 }

有一個微妙的陷阱:當使用 %n 為零調用函數時,原始函數不會在 %c 上分支,而最佳化後的函數會。在延遲性 UB 值上分支是立即性 UB,因此一般來說轉換是錯誤的,因為 %c 可能是未定義值或毒化值。

像這種情況需要一種方法來控制延遲性 UB 值。這正是 freeze 指令的用途!當給定具體值作為參數時,freeze 是無操作,按原樣返回參數。當給定未定義值或毒化值時,freeze 返回該類型的非確定性值。這與未定義值不同:freeze 返回的值對於所有使用者都是相同的。

freeze 返回的值上分支始終是安全的,因為它要麼始終如一地評估為 true,要麼始終如一地評估為 false。我們可以使上面的迴圈展開最佳化正確,如下所示

define i32 @fn(i32 %n, i1 %c) {
entry:
  %c2 = freeze i1 %c
  br i1 %c2, label %then, label %else

編寫不含未定義行為的測試

在編寫測試時,確保它們不會不必要地觸發 UB 非常重要。一些自動化測試縮減有時會使用未定義值或毒化值作為虛擬值,但如果這導致觸發 UB,則認為這是不良做法。

例如,假設我們想要編寫一個測試,並且我們不關心特定的除數值,因為我們的最佳化會無差別地啟動

 define i32 @fn(i8 %a) {
   %div = udiv i8 %a, poison
   ...
}

此測試的問題在於它會觸發立即性 UB。這阻止了 Alive 等驗證工具驗證最佳化的正確性。因此,擁有不必要的立即性 UB 的測試被認為是不良做法(除非這正是測試的目的)。上面的測試應使用虛擬函數參數而不是使用毒化值

 define i32 @fn(i8 %a, i8 %dummy) {
   %div = udiv i8 %a, %dummy
   ...
}

測試中立即性 UB 的常見來源包括在未定義值/毒化值條件上分支以及取消引用未定義值/毒化值/空指標。

注意

如果您需要一個佔位符值作為可能觸發 UB 的指令的參數傳遞,請向函數添加一個新參數,而不是使用未定義值或毒化值。

總結

LLVM IR 中的未定義行為 (UB) 由兩個明確定義的概念組成:立即性和延遲性 UB(未定義值和毒化值)。將延遲性 UB 值傳遞給某些操作會導致立即性 UB。在某些情況下,可以通過使用 freeze 指令來避免這種情況。

LLVM 中的值格狀結構為:立即性 UB > 毒化值 > 未定義值 > freeze(毒化值) > 具體值。僅將值從左向右轉換是有效的(例如,毒化值可以用具體值替換,但反之則不然)。

未定義值現在已被棄用,應僅用於表示未初始化記憶體的載入。