簡體   English   中英

為什么整數除以 -1(負一)會導致 FPE?

[英]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行為解釋了您的兩個版本之間的區別: idivneg (雖然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 語句。 這允許gdbjump命令安全地工作,讓您跳轉到函數內的不同行,並表現得就像您真的在 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/bgcc -O0必須發出代碼以從內存中重新加載ab ,並且不對它們的值做出任何假設。

但是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 盡可能像源代碼的“愚蠢和文字”模式。


86 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:

  • 除數 = 0
  • 簽名結果(商)對於目標來說太大了。

如果您使用全范圍的除數,則很容易發生溢出,例如,對於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。)


C 未定義的行為。

正如PSkocik 解釋的那樣INT_MIN / -1是 C 中未定義的行為,就像所有有符號整數溢出一樣。 這允許編譯器在 x86 等機器上使用硬件除法指令,而無需檢查這種特殊情況。 如果它沒有故障,未知輸入將需要運行時比較和分支檢查,而沒有人希望 C 要求這樣做。


更多關於UB的后果:

啟用優化,編譯器可以假定ab仍然有其設定值時, 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除法未定義:

  1. 除數為零,或
  2. 被除數是INT_MIN (== 0x80000000如果intint32_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 的博客,您絕對應該閱讀)

在 x86 上,如果您通過實際使用idiv操作進行除法(這對於常量參數並不是真正必需的,甚至對於已知為常量的變量也不是必需的,但它無論如何都會發生), INT_MIN / -1是以下情況之一導致#DE(除法錯誤)。 這確實是商超出范圍的特殊情況,通常這是可能的,因為idiv將超寬被除數除以除數,如此多的組合會導致溢出 - 但INT_MIN / -1是唯一不是您通常可以從更高級別的語言訪問的 div-by-0,因為它們通常不公開超寬紅利功能。

Linux 令人討厭地將 #DE 映射到 SIGFPE,這可能讓第一次處理它的每個人都感到困惑。

這兩種情況都很奇怪,因為第一種情況是將-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...0000x800...000二進制補碼體系結構中的0x800...000應該是,因為它具有該類型整數的最大絕對值,因此在除以一個數字時給出的結果為0 但是硬件實現通常不允許除以這樣的數字(因為它們中的許多甚至沒有實現有符號整數除法,而是從無符號除法中模擬它,所以許多只是提取符號並進行無符號除法)這需要一個在除法之前檢查,正如標准所說的Undefined behavior ,允許實現自由地避免這種檢查,並且不允許除以該數字。 他們只是選擇整數范圍從0x8000...0010xffff...fff ,然后從0x000..00000x7fff...ffff ,不允許值0x8000...0000無效。

暫無
暫無

聲明:本站的技術帖子網頁,遵循CC BY-SA 4.0協議,如果您需要轉載,請注明本站網址或者原文地址。任何問題請咨詢:yoyou2525@163.com.

 
粵ICP備18138465號  © 2020-2024 STACKOOM.COM