[英]Does the C++ standard allow for an uninitialized bool to crash a program?
我知道 C++ 中的“未定義行為”幾乎可以讓編譯器做任何它想做的事情。 然而,我遇到了一次讓我感到驚訝的崩潰,因為我認為代碼足夠安全。
在這種情況下,真正的問題只發生在使用特定編譯器的特定平台上,並且只有在啟用優化的情況下才會發生。
我嘗試了幾種方法來重現問題並將其簡化到最大程度。 這是一個名為Serialize
的函數的摘錄,它接受一個 bool 參數,並將字符串true
或false
復制到現有的目標緩沖區。
這個函數會在代碼審查中嗎,如果 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
中的任何垃圾值都無關緊要,因為無論如何它都會評估為 true 或 false。”
我已經設置了一個Compiler Explorer 示例,它顯示了反匯編中的問題,這里是完整的示例。 注意:為了重現這個問題,我發現有效的組合是使用帶有 -O2 優化的 Clang 5.0.0。
#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”的內部數字表示並以這種方式使用它?
或者這是實現定義的情況,在這種情況下,實現假設其所有布爾值只包含 0 或 1,並且任何其他值都是未定義的行為領域?
但另請注意,如果程序遇到UB,ISO C ++允許編譯器發出故意崩潰的代碼(例如,使用非法指令),例如,作為幫助您查找錯誤的方法。 (或者因為它是一個DeathStation 9000.嚴格遵守是不足以使C ++實現對任何真正的目的都有用)。 因此,即使在讀取未初始化的uint32_t
類似代碼上,ISO C ++也允許編譯器使asm崩潰(出於完全不同的原因)。 即使這需要是一個沒有陷阱表示的固定布局類型。
這是一個關於真實實現如何工作的有趣問題,但請記住,即使答案不同,您的代碼仍然不安全,因為現代C ++不是匯編語言的可移植版本。
你正在編譯為x86-64的系統V ABI ,它指定了一個bool
作為在寄存器中的功能ARG由位模式表示的false=0
和true=1
在寄存器1的低8位。 在內存中, bool
是1字節類型,同樣必須具有0或1的整數值。
(ABI是一組實現選擇,同一平台的編譯器同意這樣做,因此他們可以創建調用彼此函數的代碼,包括類型大小,結構布局規則和調用約定。)
ISO C ++沒有指定它,但是這個ABI決定很普遍,因為它使bool-> int轉換變得便宜(只是零擴展) 。 對於任何體系結構(不僅僅是x86),我都不知道任何不讓編譯器為bool
假設為0或1的ABI。 它允許像!mybool
和xor 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=0
和true=any non-zero value
。 然后clang可能不會使代碼崩潰為這個特定的UB實例。 (但是如果它想要它仍然會被允許。)我不知道任何其他選擇x86-64為bool
做什么的實現,但是C ++標准允許許多人沒有做甚至想做的事情在硬件上,就像當前的CPU一樣。
當您檢查或修改bool
的對象表示時,ISO C ++沒有指定您將找到的內容 。 (例如,通過memcpy
荷蘭國際集團的bool
成unsigned char
,你會允許這樣做,因為char*
可以別名任何東西,而且unsigned char
是保證沒有填充位,所以C ++標准並正式讓你進制打印對象表示沒有任何UB 。復制對象表示的指針轉換與賦值char foo = my_bool
,當然,布局化為0或1也不會發生,你將獲得原始對象表示。)
您使用noinline
從編譯器中部分 “隱藏”了UB 。 但是,即使它不是內聯的,過程間優化仍然可以使函數的版本依賴於另一個函數的定義。 (首先,clang正在創建一個可執行文件,而不是一個可以發生符號插入的Unix共享庫。其次, class{}
定義中的定義所以所有翻譯單元必須具有相同的定義。與inline
關鍵字一樣。)
所以編譯器只能發出一個ret
或ud2
(非法指令)作為main
的定義,因為從main
頂部開始執行的路徑不可避免地遇到Undefined Behavior。 (如果編譯器決定遵循通過非內聯構造函數的路徑,編譯器可以在編譯時看到。)
任何遇到UB的程序都是完全未定義的。 但是在函數或if()
分支中從不實際運行的UB不會破壞程序的其余部分。 在實踐中,這意味着編譯器可以決定發出非法指令或ret
,或者不發出任何內容並進入下一個塊/函數,以獲得可以在編譯時證明包含或導致UB的整個基本塊。
GCC和鏘在實踐中也確實有時發出ud2
上UB,而不是甚至還試圖生成,使沒有意義的執行路徑代碼。 或者對於非void
函數結束的情況,gcc有時會省略ret
指令。 如果你認為“我的函數只會返回RAX中的任何垃圾”,你就會非常錯誤。 現代C ++編譯器不再將語言視為可移植匯編語言。 你的程序實際上必須是有效的C ++,而不假設你的函數的獨立非內聯版本可能在asm中看起來如何。
另一個有趣的例子是為什么對mmap的內存進行未對齊訪問有時會在AMD64上出現段錯誤? 。 x86對未對齊的整數沒有錯,對吧? 那么為什么一個錯位的uint16_t*
會成為一個問題呢? 因為alignof(uint16_t) == 2
,並且違反該假設導致在使用SSE2自動向量化時出現段錯誤。
另請參閱 每個C程序員應該知道的關於未定義行為的內容#1/3 ,這是clang開發人員的一篇文章。
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
來實現小尺寸,從而節省了大量的分支。
在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級別,有關填充內容和實際值的一部分的信息已丟失。
允許編譯器假定作為參數傳遞的布爾值是有效的布爾值(即已初始化或轉換為true
或false
)。 true
值不必與整數1相同 - 實際上,可以有各種表示true
和false
- 但參數必須是這兩個值之一的有效表示,其中“有效表示”是實現定義的。
因此,如果您未能初始化bool
,或者如果您通過某個不同類型的指針成功覆蓋它,那么編譯器的假設將是錯誤的,並且隨后會出現未定義的行為。 你被警告過:
50)以本國際標准描述的方式將bool值用作“未定義”,例如通過檢查未初始化的自動對象的值,可能會使其表現為既不是真也不是假。 (§6.9.1第6段腳注,基本類型)
函數本身是正確的,但在測試程序中,調用函數的語句通過使用未初始化變量的值導致未定義的行為。
該錯誤在調用函數中,可以通過代碼檢查或調用函數的靜態分析來檢測。 使用編譯器資源管理器鏈接,gcc 8.2編譯器會檢測錯誤。 (也許你可以提交針對clang的bug報告,它沒有發現問題)。
未定義的行為意味着任何事情都可能發生,其中包括程序在觸發未定義行為的事件之后崩潰幾行。
NB。 答案“未定義的行為會導致_____嗎?” 總是“是”。 這就是未定義行為的定義。
bool只允許保存值0
或1
,生成的代碼可以假定它只保存這兩個值中的一個。 在賦值中為三元生成的代碼可以使用該值作為指向兩個字符串的指針數組的索引,即它可能轉換為類似的內容:
// 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
數組的邊界之外。
總結你的問題很多,你問的是C ++標准是否允許編譯器假設bool
只能有一個'0'或'1'的內部數字表示並以這種方式使用它?
該標准沒有說明bool
的內部表示。 它只定義了將bool
轉換為int
時會發生什么(反之亦然)。 大多數情況下,由於這些完整的轉換(以及人們非常依賴它們的事實),編譯器將使用0和1,但它不必(盡管它必須遵守它使用的任何較低級別ABI的約束) )。
所以,編譯器在看到bool
有權考慮所說的bool
包含' true
'或' false
'位模式中的任何一種並做任何感覺。 因此,如果true
和false
的值分別為1和0,則確實允許編譯器將strlen
優化為5 - <boolean value>
。 其他有趣的行為是可能的!
正如在此重復陳述的那樣,未定義的行為具有未定義的結果。 包括但不僅限於
C++ 標准是否允許編譯器假設 bool 只能具有“0”或“1”的內部數字表示並以這種方式使用它?
是的,如果它對任何人有用,這里是另一個發生在我身上的真實例子。
我曾經花了幾個星期來追蹤大型代碼庫中的一個不起眼的錯誤。 有幾個方面使它具有挑戰性,但根本原因是類變量的未初始化布爾成員。
有一個測試涉及一個涉及這個成員變量的復雜表達式:
if(COMPLICATED_EXPRESSION_INVOLVING(class->member)) {
...
}
我開始懷疑這個測試在它應該評估的時候沒有評估“真實”。 我不記得在調試器下運行是否不方便,或者我是否不信任調試器,或者什么,但我選擇了使用一些調試打印輸出來擴充代碼的蠻力技術:
printf("%s\n", COMPLICATED_EXPRESSION_INVOLVING(class->member) ? "yes" : "no");
if(COMPLICATED_EXPRESSION_INVOLVING(class->member)) {
printf("doing the thing\n");
...
}
想象一下當代碼打印“ no
”后跟“ doing the thing
”時我的驚訝。
反匯編顯示,有時,編譯器(即 gcc)通過將布爾成員與 0 進行比較來測試它,但其他時候,它使用的是測試最低有效位指令。 未初始化的布爾變量恰好包含值 2。因此,在機器語言中,測試等效於
if(class->member != 0)
成功,但測試相當於
if(class->member % 2 != 0)
失敗的。 該變量實際上同時是真和假! 如果這不是未定義的行為,我不知道是什么!
聲明:本站的技術帖子網頁,遵循CC BY-SA 4.0協議,如果您需要轉載,請注明本站網址或者原文地址。任何問題請咨詢:yoyou2525@163.com.