![](/img/trans.png)
[英]Algorithm for integer rounding of result after division of one integer by another whose values may be negative
[英]Why does integer division by -1 (negative one) result in FPE?
我的任務是解釋 C 代碼(在 x86 上運行)的一些看似奇怪的行為。 我可以輕松完成其他所有事情,但這個讓我很困惑。
代碼片段 1 輸出
-2147483648
int a = 0x80000000; int b = a / -1; printf("%d\\n", b);
代碼片段 2 不輸出任何內容,並給出
Floating point exception
int a = 0x80000000; int b = -1; int c = a / b; printf("%d\\n", c);
我很清楚代碼片段 1 ( 1 + ~INT_MIN == INT_MIN
) 結果的原因,但我不太明白整數除以 -1 如何生成 FPE,也不能在我的 Android 手機上重現它 (AArch64 , 海灣合作委員會 7.2.0)。 代碼 2 的輸出與代碼 1 相同,沒有任何異常。 它是 x86 處理器的隱藏錯誤功能嗎?
作業沒有說明任何其他內容(包括 CPU 架構),但由於整個課程基於桌面 Linux 發行版,您可以放心地假設它是現代 x86。
編輯:我聯系了我的朋友,他在 Ubuntu 16.04(Intel Kaby Lake,GCC 6.3.0)上測試了代碼。 結果與任何指定的分配一致(代碼 1 輸出所說的東西,代碼 2 與 FPE 崩潰)。
這里發生了四件事:
gcc -O0
行為解釋了您的兩個版本之間的區別: idiv
與neg
。 (雖然clang -O0
恰好用idiv
編譯它們)。 以及為什么即使使用編譯時常量操作數也會得到這個。
x86 idiv
故障行為與 ARM 上除法指令的行為
如果整數數學導致信號被傳遞,POSIX 要求它是 SIGFPE: 在哪些平台上整數除以零會觸發浮點異常? 但是,POSIX並不需要捕捉任何特定整數操作。 (這就是為什么允許 x86 和 ARM 不同的原因)。
單一 Unix 規范將 SIGFPE 定義為“錯誤的算術運算”。 它以浮點命名令人困惑,但在 FPU 處於默認狀態的正常系統中,只有整數數學會提高它。 在 x86 上,只有整數除法。 在 MIPS 上,編譯器可以使用add
而不是addu
來進行有符號數學運算,因此您可能會在有符號加法溢出時遇到陷阱。 ( gcc 甚至對 signed 使用addu
,但未定義行為檢測器可能會使用add
。)
C 未定義的行為規則(有符號溢出,特別是除法),它讓 gcc 發出可以在這種情況下捕獲的代碼。
沒有選項的 gcc 與gcc -O0
相同。
-O0
減少編譯時間並使調試產生預期的結果。 這是默認設置。
這解釋了您的兩個版本之間的區別:
gcc -O0
不僅不嘗試優化,還主動去優化,使 asm 獨立實現函數內的每個 C 語句。 這允許gdb
的jump
命令安全地工作,讓您跳轉到函數內的不同行,並表現得就像您真的在 C 源代碼中跳轉一樣。 為什么 clang 使用 -O0 產生低效的 asm(對於這個簡單的浮點和)? 解釋了更多關於-O0
如何以及為什么編譯它的方式。
它也不能對語句之間的變量值做任何假設,因為您可以使用set b = 4
更改變量。 這顯然對性能來說是災難性的,這就是為什么-O0
代碼運行速度比普通代碼慢幾倍,以及為什么專門針對-O0
優化完全是胡說八道。 它還使-O0
asm 輸出非常嘈雜且難以讓人閱讀,因為所有的存儲/重新加載,甚至缺乏最明顯的優化。
int a = 0x80000000;
int b = -1;
// debugger can stop here on a breakpoint and modify b.
int c = a / b; // a and b have to be treated as runtime variables, not constants.
printf("%d\n", c);
我將您的代碼放在Godbolt 編譯器資源管理器上的函數中,以獲取這些語句的 asm。
要評估a/b
, gcc -O0
必須發出代碼以從內存中重新加載a
和b
,並且不對它們的值做出任何假設。
但是用int c = a / -1;
,您不能使用調試器更改-1
,因此 gcc 可以並且確實以與實現int c = -a;
相同的方式實現該語句int c = -a;
, 帶有 x86 neg eax
或 AArch64 neg w0, w0
指令,被 load(a)/store(c) 包圍。 在 ARM32 上,它是一個rsb r3, r3, #0
(反向減法: r3 = 0 - r3
)。
但是, clang5.0 -O0
不會進行該優化。 它仍然將idiv
用於a / -1
,因此兩個版本都會在 x86 上使用 clang 出錯。 為什么 gcc 會“優化”? 請參閱禁用 GCC 中的所有優化選項。 gcc 總是通過內部表示進行轉換,而 -O0 只是生成二進制文件所需的最少工作量。 它沒有試圖使 asm 盡可能像源代碼的“愚蠢和文字”模式。
idiv
與AArch64 sdiv
:x86-64:
# int c = a / b from x86_fault()
mov eax, DWORD PTR [rbp-4]
cdq # dividend sign-extended into edx:eax
idiv DWORD PTR [rbp-8] # divisor from memory
mov DWORD PTR [rbp-12], eax # store quotient
與imul r32,r32
不同,沒有沒有被除數上半部分輸入的 2 操作數idiv
。 無論如何,這並不重要; gcc 僅將它與edx
= eax
符號位的副本一起使用,因此它實際上是在執行 32b / 32b => 32b 商 + 余數。 如英特爾手冊中所述,idiv 在以下idiv
引發 #DE:
如果您使用全范圍的除數,則很容易發生溢出,例如,對於int result = long long / int
,只有一個 64b / 32b => 32b 除法。 但是 gcc 無法進行這種優化,因為不允許編寫會出錯的代碼,而不是遵循 C 整數提升規則並進行 64 位除法然后截斷為int
。 即使在已知除數足夠大以至於不能#DE
情況下,它也不會優化
在進行 32b / 32b 除法(使用cdq
)時,唯一可能溢出的輸入是INT_MIN / -1
。 “正確”商是一個 33 位有符號整數,即正0x80000000
帶有前導零符號位,使其成為正 2 的補符號整數。 由於這不適合eax
,因此idiv
會引發#DE
異常。 然后內核提供SIGFPE
。
AArch64:
# int c = a / b from x86_fault() (which doesn't fault on AArch64)
ldr w1, [sp, 12]
ldr w0, [sp, 8] # 32-bit loads into 32-bit registers
sdiv w0, w1, w0 # 32 / 32 => 32 bit signed division
str w0, [sp, 4]
ARM 硬件除法指令不會引發除以零或INT_MIN/-1
溢出的異常。 內特·埃爾德雷奇評論道:
完整的 ARM 架構參考手冊指出,UDIV 或 SDIV 在被零除時,只會返回零作為結果,“沒有任何跡象表明發生了被零除”(Armv8-A 版本中的 C3.4.8)。 沒有例外也沒有標志——如果你想捕捉除以零,你必須編寫一個顯式測試。 同樣,
INT_MIN
除以-1
符號除法返回INT_MIN
而不指示溢出。
AArch64 sdiv
文檔沒有提到任何例外。
但是,整數除法的軟件實現可能會引發: http : //infocenter.arm.com/help/index.jsp?topic=/com.arm.doc.faqs/ka4061.html 。 (默認情況下,gcc 在 ARM32 上使用庫調用進行除法,除非您設置了具有硬件除法的 -mcpu。)
正如PSkocik 解釋的那樣, INT_MIN
/ -1
是 C 中未定義的行為,就像所有有符號整數溢出一樣。 這允許編譯器在 x86 等機器上使用硬件除法指令,而無需檢查這種特殊情況。 如果它沒有故障,未知輸入將需要運行時比較和分支檢查,而沒有人希望 C 要求這樣做。
更多關於UB的后果:
啟用優化,編譯器可以假定a
和b
仍然有其設定值時, a/b
運行。 然后它可以看到程序有未定義的行為,因此可以做任何它想做的事情。 GCC選擇生產INT_MIN
像它會從-INT_MIN
。
在 2 的補碼系統中,最負數是它自己的負數。 對於 2 的補碼來說,這是一個令人討厭的極端情況,因為這意味着abs(x)
仍然可以是負數。 https://en.wikipedia.org/wiki/Two%27s_complement#Most_negative_number
int x86_fault() {
int a = 0x80000000;
int b = -1;
int c = a / b;
return c;
}
用gcc6.3 -O3
for x86-64 編譯到這個
x86_fault:
mov eax, -2147483648
ret
但是clang5.0 -O3
編譯為(即使使用 -Wall -Wextra` 也沒有警告):
x86_fault:
ret
未定義行為真的是完全未定義的。 編譯器可以做任何他們想做的事情,包括在函數入口返回eax
任何垃圾,或者加載 NULL 指針和非法指令。 例如,對於 x86-64,使用 gcc6.3 -O3:
int *local_address(int a) {
return &a;
}
local_address:
xor eax, eax # return 0
ret
void foo() {
int *p = local_address(4);
*p = 2;
}
foo:
mov DWORD PTR ds:0, 0 # store immediate 0 into absolute address 0
ud2 # illegal instruction
您使用-O0
情況沒有讓編譯器在編譯時看到 UB,因此您獲得了“預期的”asm 輸出。
另請參閱What Every C Programmer should Know About Undefined Behavior (Basile 鏈接的同一篇 LLVM 博客文章)。
在以下情況下,二進制補碼中的有符號int
除法未定義:
INT_MIN
(== 0x80000000
如果int
是int32_t
),除數是-1
(在二進制補碼中, -INT_MIN > INT_MAX
,這會導致整數溢出,這是 C 中未定義的行為)( https://www.securecoding.cert.org建議將整數運算包裝在檢查此類邊緣情況的函數中)
由於您通過違反規則 2 來調用未定義的行為,因此任何事情都可能發生,並且當它發生時,您平台上的特定任何事情恰好是您的處理器生成的 FPE 信號。
對於未定義的行為,可能會發生非常糟糕的事情,有時它們確實會發生。
你的問題在 C 中沒有意義(閱讀Lattner on UB )。 但是您可以獲得匯編代碼(例如由gcc -O -fverbose-asm -S
)並關心機器代碼行為。
在帶有 Linux 整數溢出(以及整數除以零,IIRC)的 x86-64 上,會給出SIGFPE
信號。 見信號(7)
順便說一句,傳聞在 PowerPC 整數除以零在機器級別給出 -1(但一些 C 編譯器會生成額外的代碼來測試這種情況)。
您問題中的代碼是 C 中的未定義行為。生成的匯編代碼具有一些已定義的行為(取決於ISA和處理器)。
(完成作業是為了讓您閱讀更多關於 UB 的信息,尤其是Lattner 的博客,您絕對應該閱讀)
這兩種情況都很奇怪,因為第一種情況是將-2147483648
除以-1
並且應該給出2147483648
,而不是您收到的結果。 除以-1
(作為乘法)應該將被除數的符號更改為正數。 但是int
沒有這樣的正數(這就是引起 UB 的原因)
0x80000000
在 32 位體系結構(如標准中所述)中不是有效的int
數字,它表示二進制補碼中的數字。 如果你計算它的負值,你會再次得到它,因為它在零附近沒有相反的數字。 當您使用有符號整數進行算術運算時,它適用於整數加法和減法(始終要小心,因為當您將最大值添加到某個 int 時很容易溢出),但您不能安全地使用它進行乘法或除法。 因此,在這種情況下,您正在調用Undefined Behavior 。 您總是在帶符號整數溢出時調用未定義的行為(或實現定義的行為,它們相似但不相同),因為實現方式在實現時差異很大。
我將嘗試解釋可能發生的事情(沒有信任),因為編譯器可以自由地做任何事情,或者什么都不做。
具體來說,用二進制補碼表示的0x80000000
是
1000_0000_0000_0000_0000_0000_0000
如果我們補充這個數字,我們得到(首先補充所有位,然后加一個)
0111_1111_1111_1111_1111_1111_1111 + 1 =>
1000_0000_0000_0000_0000_0000_0000 !!! the same original number.
令人驚訝的是相同的數字......你有一個溢出(這個數字沒有對應的正值,因為我們在改變符號時溢出)然后你取出符號位,用
1000_0000_0000_0000_0000_0000_0000 &
0111_1111_1111_1111_1111_1111_1111 =>
0000_0000_0000_0000_0000_0000_0000
這是您用作股息的數字。
但正如我之前所說,這就是您的系統上可能發生的情況,但不確定,因為標准說這是未定義的行為,因此,您可以從計算機/編譯器獲得任何不同的行為。
您獲得的不同結果可能是第一個操作由編譯器完成的結果,而第二個操作由程序本身完成。 在第一種情況下,您將0x8000_0000
分配給變量,而在第二種情況下,您正在計算程序中的值。 這兩種情況都是未定義的行為,您會看到它發生在您的眼前。
#注意 1
就編譯器而言,標准沒有說明必須實現的int
的有效范圍(標准通常不包括二進制補碼架構中的0x8000...000
) 0x800...000
二進制補碼體系結構中的0x800...000
應該是,因為它具有該類型整數的最大絕對值,因此在除以一個數字時給出的結果為0
。 但是硬件實現通常不允許除以這樣的數字(因為它們中的許多甚至沒有實現有符號整數除法,而是從無符號除法中模擬它,所以許多只是提取符號並進行無符號除法)這需要一個在除法之前檢查,正如標准所說的Undefined behavior ,允許實現自由地避免這種檢查,並且不允許除以該數字。 他們只是選擇整數范圍從0x8000...001
到0xffff...fff
,然后從0x000..0000
到0x7fff...ffff
,不允許值0x8000...0000
無效。
聲明:本站的技術帖子網頁,遵循CC BY-SA 4.0協議,如果您需要轉載,請注明本站網址或者原文地址。任何問題請咨詢:yoyou2525@163.com.