c++ - libasan - ubsan




C++標準是否允許未初始化的bool使程序崩潰? (4)

我知道C ++中的 “未定義行為” 幾乎可以讓編譯器做任何想做的事情。 但是,我遇到了讓我感到驚訝的崩潰,因為我認為代碼足夠安全。

在這種情況下,真正的問題僅發生在使用特定編譯器的特定平台上,並且僅在啟用了優化時才發生。

我嘗試了幾件事來重現問題並將其簡化到最大程度。 這是一個名為 Serialize 的函數的摘錄,它將獲取bool參數,並將字符串 truefalse 複製到現有的目標緩衝區。

如果bool參數是未初始化的值,那麼這個函數是否會在代碼審查中,沒有辦法告訴它實際上可能會崩潰?

// Zero-filled global buffer of 16 characters
char destBuffer[16];

void Serialize(bool boolValue) {
    // Determine which string to print based on boolValue
    const char* whichString = boolValue ? "true" : "false";

    // Compute the length of the string we selected
    const size_t len = strlen(whichString);

    // Copy string into destination buffer, which is zero-filled (thus already null-terminated)
    memcpy(destBuffer, whichString, len);
}

如果使用clang 5.0.0 +優化執行此代碼,它將/可能崩潰。

預期的三元運算符 boolValue ? "true" : "false" boolValue ? "true" : "false" 看起來對我來說足夠安全,我假設,“無論垃圾價值在 boolValue 中無關緊要,因為它無論如何都會評估為真或假。”

我已經設置了一個 Compiler Explorer示例 ,它顯示了反彙編中的問題,這裡是完整的示例。 注意:為了重現問題,我發現有效的組合是使用Clang 5.0.0和-O2優化。

#include <iostream>
#include <cstring>

// Simple struct, with an empty constructor that doesn't initialize anything
struct FStruct {
    bool uninitializedBool;

   __attribute__ ((noinline))  // Note: the constructor must be declared noinline to trigger the problem
   FStruct() {};
};

char destBuffer[16];

// Small utility function that allocates and returns a string "true" or "false" depending on the value of the parameter
void Serialize(bool boolValue) {
    // Determine which string to print depending if 'boolValue' is evaluated as true or false
    const char* whichString = boolValue ? "true" : "false";

    // Compute the length of the string we selected
    size_t len = strlen(whichString);

    memcpy(destBuffer, whichString, len);
}

int main()
{
    // Locally construct an instance of our struct here on the stack. The bool member uninitializedBool is uninitialized.
    FStruct structInstance;

    // Output "true" or "false" to stdout
    Serialize(structInstance.uninitializedBool);
    return 0;
}

問題出現的原因是優化器:它很聰明地推斷字符串“true”和“false”的長度只有1不同。所以它不是真正計算長度,而是使用bool本身的值,技術上可以是0或1,並且如下所示:

const size_t len = strlen(whichString); // original code
const size_t len = 5 - boolValue;       // clang clever optimization

雖然這很“聰明”,但可以這麼說,我的問題是: C ++標準是否允許編譯器假設bool只能有一個內部數字表示'0'或'1'並以這種方式使用它?

或者這是實現定義的情況,在這種情況下,實現假定它的所有bool只包含0或1,而任何其他值是未定義的行為區域?


是的,ISO C ++允許(但不要求)實現做出這種選擇。

但另請注意,如果程序遇到UB,ISO C ++允許編譯器發出故意崩潰的代碼(例如,使用非法指令),例如,作為幫助您查找錯誤的方法。 (或者因為它是一個DeathStation 9000.嚴格遵守是不足以使C ++實現對任何真正的目的都有用)。 因此,即使在讀取未初始化的 uint32_t 類似代碼​​上,ISO C ++也允許編譯器使asm崩潰(出於完全不同的原因)。 即使這需要是一個沒有陷阱表示的固定佈局類型。

這是一個關於真實實現如何工作的有趣問題,但請記住,即使答案不同,您的代碼仍然不安全,因為現代C ++不是彙編語言的可移植版本。

您正在編譯 x86-64 System V ABI ,它指定在寄存器 true=1 的低8位中 由位模式 false=0true=1 表示 bool 作為寄存器中的函數arg 。 在內存中, bool 是1字節類型,同樣必須具有0或1的整數值。

(ABI是一組實現選擇,同一平台的編譯器同意這樣做,因此他們可以創建調用彼此函數的代碼,包括類型大小,結構佈局規則和調用約定。)

ISO C ++沒有指定它,但是這個ABI決定很普遍,因為它使bool-> int轉換變得便宜(只是零擴展) 。 對於任何體系結構(不僅僅是x86),我都不知道任何不讓編譯器為 bool 假設為0或1的ABI。 它允許像 !myboolxor eax,1 這樣的優化來翻轉低位: 在單CPU指令中可以在0和1之間翻轉位/整數/布爾值的任何可能代碼 。 或者將 a&&b 編譯為 bool 類型的按位AND。 有些編譯器確實 在編譯器中 利用 布爾值作為8位。 對他們的操作是否效率低下?

通常,as-if規則允許編譯器利用 正在編譯的目標平台上的 事物 ,因為最終結果將是實現與C ++源相同的外部可見行為的可執行代碼。 (具有Undefined Behavior放置在實際上“外部可見”的所有限制:不是使用調試器,而是來自格式良好/合法的C ++程序中的另一個線程。)

絕對允許編譯器在其代碼中充分利用ABI保證,並使您發現的代碼優化 strlen(whichString)
5U - boolValue (順便說一句,這種優化有點聰明,但可能是近視與分支和內聯 memcpy 作為即時數據存儲 2。

或者編譯器可以創建一個指針表並用 bool 的整數值對其進行索引,再次假設它是0或1.( 這種可能性是@Barmar的答案所建議的 。)

啟用了優化的 __attribute((noinline)) 構造函數導致clang只是從堆棧加載一個字節以用作 uninitializedBool 。 它使用 push rax main 對象創建了空間(由於各種原因,它與 sub rsp, 8 一樣有效 sub rsp, 8 ),因此在進入 main AL中的垃圾是用於 uninitializedBool 的值。 這就是為什麼你實際上得到的值不僅僅是 0

5U - random garbage 可以很容易地換成大的無符號值,導致memcpy進入未映射的內存。 目標位於靜態存儲中,而不是堆棧中,因此您不會覆蓋返回地址或其他內容。

其他實現可以做出不同的選擇,例如 false=0true=any non-zero value 然後clang可能不會使代碼崩潰為 這個 特定的UB實例。 (但是如果它想要它仍然會被允許。) 我不知道任何其他選擇x86-64為 bool 做什麼的實現,但是C ++標准允許許多人沒有做甚至想做的事情在硬件上,就像當前的CPU一樣。

當您檢查或修改 bool 的對象表示時,ISO C ++沒有指定您將找到的內容 。 (例如,通過將 boolunsigned char ,你可以這樣做,因為 char* 可以別名。並且 unsigned char 保證沒有填充位,所以C ++標準正式允許你在沒有任何UB的情況下進行hexdump對象表示。複製對象表示的指針轉換與賦值 char foo = my_bool ,當然,佈局化為0或1也不會發生,你將獲得原始對象表示。)

您使用 noinline 從編譯器中 部分 “隱藏”了UB 。 但是,即使它不是內聯的,過程間優化仍然可以使函數的版本依賴於另一個函數的定義。 (首先,clang正在創建一個可執行文件,而不是一個可以發生符號插入的Unix共享庫。其次, class{} 定義中的定義所以所有翻譯單元必須具有相同的定義。與 inline 關鍵字一樣。)

所以編譯器只能發出一個 retud2 (非法指令)作為 main 的定義,因為從 main 頂部開始執行的路徑不可避免地遇到Undefined Behavior。 (如果編譯器決定遵循通過非內聯構造函數的路徑,編譯器可以在編譯時看到。)

任何遇到UB的程序都是完全未定義的。 但是在函數或 if() 分支中從不實際運行的UB不會破壞程序的其餘部分。 在實踐中,這意味著編譯器可以決定發出非法指令或 ret ,或者不發出任何內容並進入下一個塊/函數,以獲得可以在編譯時證明包含或導致UB的整個基本塊。

實際上GCC和Clang實際上有時會在UB上發出 ud2 ,而不是甚至試圖為沒有意義的執行路徑生成代碼。 或者對於非 void 函數結束的情況,gcc有時會省略 ret 指令。 如果你認為“我的函數只會返回RAX中的任何垃圾”,你就會非常錯誤。 現代C ++編譯器不再將語言視為可移植彙編語言。 你的程序實際上必須是有效的C ++,而不假設你的函數的獨立非內聯版本可能在asm中看起來如何。

另一個有趣的例子是 為什麼對mmap的內存進行未對齊訪問有時會在AMD64上出現段錯誤? 。 x86對未對齊的整數沒有錯,對吧? 那麼為什麼一個錯位的 uint16_t* 會成為一個問題呢? 因為 alignof(uint16_t) == 2 ,並且違反該假設導致在使用SSE2自動向量化時出現段錯誤。

另請參閱 每個C程序員應該知道的關於未定義行為的內容#1/3 ,這是clang開發人員的一篇文章。

關鍵點:如果編譯器在編譯時注意到UB,它 可以 “破壞”(發出令人驚訝的asm)通過代碼的路徑導致UB,即使針對ABI,其中任何位模式都是 bool 的有效對象表示。

期待程序員對許多錯誤的完全敵意,特別是現代編譯器警告的事情。 這就是你應該使用 -Wall 並修復警告的原因。 C ++不是一種用戶友好的語言,即使在你編譯的目標上asm是安全的,C ++中的東西也是不安全的。 (例如,簽名溢出是C ++中的UB,編譯器會認為它不會發生,即使編譯2的補碼x86,除非你使用 clang/gcc -fwrapv 。)

編譯時可見的UB總是危險的,並且很難確定(使用鏈接時優化)你真的從編譯器中隱藏了UB,因此可以推斷出它會產生什麼樣的asm。

不要過於戲劇化; 通常編譯器會讓你逃避一些事情並發出像你期望的那樣的代碼,即使是某些東西是UB。 但是,如果編譯器開發人員實現一些優化以獲得關於值範圍的更多信息(例如,變量是非負的,可能允許它優化符號擴展以在x86上釋放零擴展),那麼將來可能會出現問題。 64)。 例如,在當前的gcc和clang中,執行 tmp = a+INT_MIN 並不 tmp = a+INT_MIN a<0 始終為false,而只是 tmp 始終為負。 (因為 INT_MIN + a=INT_MAX 在這個2的補碼目標上是負的,並且 a 不能高於此值。)

所以gcc / clang目前沒有回溯來計算輸入計算的範圍信息,只是基於沒有簽名溢出的假設的結果: 例如Godbolt 。 我不知道這是優化是故意“錯過”的用戶友好的名義或什麼。

另請注意, 允許實現(也稱為編譯器)定義ISO C ++未定義的行為 。 例如,支持Intel內在函數的所有編譯器(如 _mm_add_ps(__m128, __m128) 用於手動SIMD向量化)必須允許形成錯誤對齊的指針,即使您 沒有 取消引用它們,也是C ++中的UB。 __m128i _mm_loadu_si128(const __m128i *) 通過取錯 __m128i* arg而不是 void*char* 執行未對齊的加載。 在硬件向量指針和相應類型之間`reinterpret_cast`是一個未定義的行為嗎?

GNU C / C ++還定義了左移一個負的有符號數(即使沒有 -fwrapv )的行為,與普通的有符號溢出UB規則分開。 ( 這是ISO C ++中的UB ,而有符號數的右移是實現定義的(邏輯與算術);高質量的實現選擇具有算術右移的HW算術,但ISO C ++沒有指定)。 這在 GCC手冊的Integer部分中有記錄 ,同時定義了C標準要求實現以這種或那種方式定義的實現定義的行為。

編譯器開發人員肯定會關注實現的質量問題; 他們通常不會 試圖 製造故意不利的編譯器,但是利用C ++中的所有UB坑洼(除了他們選擇定義的那些)來進行更好的優化,有時幾乎無法區分。

腳註1 :高56位可以是被調用者必須忽略的垃圾,通常用於比寄存器窄的類型。

其他ABI在這裡做了不同的選擇 。有些確實要求窄整數類型為零或符號擴展,以便在傳遞給函數或從函數返回時填充寄存器,如MIPS64和PowerPC64。請參閱 x86-64答案 的最後一部分 與早期的ISA相比較 。)

例如,在調用 bool_func(a&1) 之前,調用者可能已在RDI中計算 a & 0x01010101 並將其用於其他內容。 調用者可以優化掉 &1 因為它已經作為 and edi, 0x01010101 一部分對低字節進行了 and edi, 0x01010101 ,並且它知道被調用者需要忽略高字節。

或者如果bool作為第3個arg傳遞,也許優化代碼大小的調用者使用 mov dl, [mem] 而不是 movzx edx, [mem] 加載它,節省1個字節,代價是對舊的錯誤依賴RDX的值(或其他部分寄存器效果,取決於CPU型號)。 或者對於第一個arg, mov dil, byte [r10] 而不是 movzx edi, byte [r10] ,因為兩者都需要REX前綴。

這就是為什麼clang在 Serialize 發出 movzx eax, dil ,而不是 sub eax, edi 。 (對於整數args,clang違反了這個ABI規則,而是取決於gcc和clang的未記錄行為,將零或符號擴展為窄整數為32位。 當向指針添加32位偏移時,是否需要符號或零擴展x86-64 ABI? 所以我有興趣看到它對 bool 沒有做同樣的事情。)

腳註2: 分支後,你只需要一個4字節的 mov -immediate,或一個4字節+ 1字節的存儲。 長度隱含在商店寬度+偏移中。

OTOH,glibc memcpy會做兩個4字節的加載/存儲,其重疊取決於長度,所以這確實最終使得整個事物在布爾值上沒有條件分支。 請參閱glibc的memcpy / memmove中的 L(between_4_7): 。 或者至少,對於memcpy分支中的任一布爾值選擇塊大小,以同樣的方式。

如果內聯,您可以使用2x mov -immediate + cmov 和條件偏移量,或者您可以將字符串數據保留在內存中。

或者,如果調整Intel Ice Lake( 使用Fast Short REP MOV功能 ),實際的 rep movsb 可能是最佳的。 glibc memcpy 可能會開始在具有該功能的CPU上使用 rep movsb 來實現小尺寸,從而節省了大量的分支。

用於檢測UB和未初始化值的使用的工具

在gcc和clang中,您可以使用 -fsanitize=undefined 進行編譯,以添加將在運行時發生的UB上發出警告或錯誤的運行時檢測。 但是,這不會捕獲單元化變量。 (因為它不會增加類型大小以為“未初始化”位騰出空間)。

請參閱 https://developers.redhat.com/blog/2014/10/16/gcc-undefined-behavior-sanitizer-ubsan/

要查找未初始化數據的用法,請在clang / LLVM中使用Address Sanitizer和Memory Sanitizer。 https://github.com/google/sanitizers/wiki/MemorySanitizer 顯示了 clang -fsanitize=memory -fPIE -pie 檢測未初始化內存讀取的示例。 如果您在 沒有 優化的 情況下進行 編譯,它可能效果最佳,因此所有變量讀取最終實際上都是從asm中的內存加載的。 他們表明,在負載無法優化的情況下,它在 -O2 處使用。 我自己沒試過。 (在某些情況下,例如,在對數組求和之前不初始化累加器,clang -O3將發出代碼,該代碼總和為從未初始化的向量寄存器。因此,通過優化,您可以得到一個沒有與UB關聯的內存讀取的情況但是 -fsanitize=memory 更改生成的asm,並可能導致對此進行檢查。)

它可以容忍複製未初始化的內存,也可以使用簡單的邏輯和算術運算。 通常,MemorySanitizer以靜默方式跟踪未初始化數據在內存中的傳播,並在根據未初始化值獲取(或不獲取)代碼分支時報告警告。

MemorySanitizer實現了Valgrind(Memcheck工具)中的一部分功能。

它應該適用於這種情況,因為使用從未初始化的內存計算的 length 調用glibc memcpy 將(在庫內)導致基於 length 的分支。 如果它內聯一個完全無 cmov 版本,只使用了 cmov ,索引和兩個商店,它可能沒有用。

Valgrind的 memcheck 也會尋找這種問題,如果程序只是簡單地複制未初始化的數據,也不會抱怨。 但它表示它會檢測“條件跳轉或移動取決於未初始化的值”,以嘗試捕獲依賴於未初始化數據的任何外部可見行為。

也許不標記一個加載背後的想法是結構可以有填充,並且使用寬向量加載/存儲複製整個結構(包括填充)不是錯誤,即使每個成員一次只寫一個。 在asm級別,有關填充內容和實際值的一部分的信息已丟失。


bool只允許保存值 01 ,生成的代碼可以假定它只保存這兩個值中的一個。 在賦值中為三元生成的代碼可以使用該值作為指向兩個字符串的指針數組的索引,即它可能轉換為類似的內容:

     // the compile could make asm that "looks" like this, from your source
const static char *strings[] = {"false", "true"};
const char *whichString = strings[boolValue];

如果 boolValue 未初始化,它實際上可以保存任何整數值,這將導致訪問 strings 數組的邊界之外。


函數本身是正確的,但在測試程序中,調用函數的語句通過使用未初始化變量的值導致未定義的行為。

該錯誤在調用函數中,可以通過代碼檢查或調用函數的靜態分析來檢測。 使用編譯器資源管理器鏈接,gcc 8.2編譯器會檢測錯誤。 (也許你可以提交針對clang的bug報告,它沒有發現問題)。

未定義的行為意味著 任何事情都 可能發生,其中包括程序在觸發未定義行為的事件之後崩潰幾行。

NB。 答案“未定義的行為會導致_____嗎?” 總是“是”。 這就是未定義行為的定義。


總結你的問題很多,你問的是C ++標準是否允許編譯器假設 bool 只能有一個'0'或'1'的內部數字表示並以這種方式使用它?

該標準沒有說明 bool 的內部表示。 它只定義了將 bool 轉換為 int 時會發生什麼(反之亦然)。 大多數情況下,由於這些完整的轉換(以及人們非常依賴它們的事實),編譯器將使用0和1,但它不必(儘管它必須遵守它使用的任何較低級別ABI的約束) )。

因此,編譯器在看到a時 bool 有權考慮所說的 bool 包含 true “或 false ”位模式中的任何一種並做任何感覺。 因此,如果對值 truefalse 為1和0,分別,編譯器確實允許優化 strlen5 - <boolean value> 。 其他有趣的行為是可能的!

正如在此重複陳述的那樣,未定義的行為具有未定義的結果。 包括但不僅限於

  • 您的代碼按預期工作
  • 您的代碼隨機失敗
  • 您的代碼根本沒有運行。

請參閱 每個程序員應該了解的未定義行為





abi