vatt'ghern jaskier's ballads
本文 1 個互動圖表在手機上以重點摘要呈現,互動版請以桌面瀏覽器開啟。

把同一段轉型程式碼交給同一顆編譯器,只是換一顆 CPU 執行,x86 給你 INT_MIN,ARM64 給你貼著邊界的飽和值,NaN 甚至變成 0——三個答案背後是同一件事:C++ 標準本來就沒規定超出範圍的 float 轉 int 該怎麼辦,跟誰的 bug 無關。

一次轉型,兩種答案:C++ 浮點轉整數的 UB

一個 float 轉成 int,你腦中大概只有一個動作:砍掉小數點後面。這篇會拆掉這個直覺、把 C++ 標準實際寫的東西攤開、看 x86 與 ARM64 各自怎麼把「標準沒規定」填成兩種不同答案,最後落到一個你今天就能加進 build 的偵測指令。

你以為的轉型:把小數點後面砍掉就好

C++ 裡把 float 轉成 int 大概是最不起眼的一種轉型,三種寫法你大概都用過:

void foo(float f) {
    int i0 = f;
    int i1 = int(f);
    int i2 = static_cast<int>(f);
}

三行做的事完全一樣——implicit conversion、C-style cast、static_cast,殊途同歸。而且這段程式碼開最嚴格的警告也過得去:「This code does not generate any warnings, not even with -Wall and -Wextra.」編譯器不會提醒你任何事,因為在編譯器眼中,這行就是完全合法的程式碼。

三種寫法在語義上完全等價,這是 C++ 語言規則本身決定的,不是這篇文章特別強調的——選了看起來比較嚴謹的 static_cast,這次轉型並不會因此多了什麼保護。C++ 對窄化轉型的態度是「相信你知道自己在做什麼」,static_cast 只是清楚地標示了意圖,執行期沒有因此多一道檢查。至於 i0、i1、i2 編譯出來是不是真的同一段指令,要看實際用的編譯器與最佳化設定,這篇文章沒有談這一層。

直覺告訴你,只要輸入的浮點數不要太誇張,這段轉型就是把小數部分丟掉——42.7 變 42,負十三點二變負十三。這個直覺在大多數情況下確實成立,問題就出在「大多數情況」這四個字本身。更具體一點:只要輸入值的整數部分還落在目的型別的可表示範圍內,這個直覺完全正確,砍掉小數點後面就是答案。麻煩的是「落在範圍內」這個前提,程式碼本身看不出來,只有執行當下那個具體的數字知道。

這類轉型不是罕見的邊角案例。感測器讀回來的浮點電壓值要換算成整數刻度、一段浮點毫秒時間戳要轉成整數計時器、音訊取樣值要裁進整數緩衝區——全部都要走這條路。輸入通常不是你自己手打的常數,而是計算鏈路上游傳來的浮點結果,可能夾雜著感測器雜訊、可能累積了浮點誤差、也可能是某個係數乘出來意外爆量的中間值。光看程式碼本身看不出哪個 f 會出問題——寫法都一樣,差別藏在執行當下那個具體的數字裡。

範圍對不上的縫隙:float 能裝的數字比 int 大得多

32 位元 int 只能裝 -2147483648 到 2147483647,這是個大約四十三億寬的窗口。單精度 float 能表示的量級遠遠超出這個窗口,還有 int 完全沒有對應物的兩種狀態:無限大與 NaN。C++ 標準怎麼處理這個落差?文章引用 cppreference 的 Floating-integral conversions 一段話:

「A prvalue of floating-point type can be converted to a prvalue of any integer type. The fractional part is truncated, that is, the fractional part is discarded. If the value cannot fit into the destination type, the behavior is undefined (even when the destination type is unsigned, modulo arithmetic does not apply).」

拆開讀三件事:小數部分直接捨去,這是唯一「有定義」的動作;捨去之後的值如果裝不進目的型別,就是未定義行為;而且這條未定義行為不因為目的型別是無號數就自動變安全。無號整數溢位在 C++ 裡通常會 modulo 環繞——unsigned char 的 255 加 1 繞回 0,這是每個寫過 C++ 的人都內建的心理模型。但這句話明講:float 轉 int 溢位不適用那個保護。你不能套用「反正無號數會自己繞回來」這套直覺去猜答案。

回到最前面那句「即使目的型別是無號數,modulo arithmetic 也不適用」——這句話特別點名無號數,是因為無號數溢位在 C++ 裡本來就有一條安全網:結果永遠會落在型別可表示的範圍內,只是繞了一圈。這條安全網會讓工程師下意識覺得「無號整數不會出這種問題」。float 轉 int 溢位刻意把這條安全網收掉,就算目的型別寫的是 unsigned int,裝不下一樣是未定義行為,不會乖乖幫你繞回來。

為什麼標準會這樣寫?可以合理推想,float 與 int 在底層是完全不同的位元編碼——float 用符號位、指數、尾數三段式編碼一個很大的可表示區間,int 用二補數編碼一個固定寬度的連續區間,兩者的合法值集合從來就不是同一件事。截斷只解決小數部分,不解決兩個集合大小不對等這件事:只要輸入的整數部分本身就超出 int 能表示的區間,不管怎麼捨去小數,答案都裝不下。標準沒有規定裝不下時要怎麼辦,循著同樣的推測,不同硬體對「裝不下」的處理成本本來就不一樣,把這件事寫死在標準裡等於是替所有硬體背書同一種做法,而這件事標準沒有做。

這也是為什麼規格特別點名「prvalue」:轉型的結果是一次性用掉的值,沒有位址、也無法事後修改。這個分類本身不影響這條 UB 是否發生,但它解釋了為什麼你沒辦法在轉型當下插入某種「事後檢查」——轉型完成的瞬間,這個值已經被消費掉了,能檢查的視窗只有轉型之前。

這段規格裡有三個詞值得停下來看一眼: prvalue C++ 表達式分類之一,粗略理解成「可以拿來用一次的值」——沒有記憶體位址,用完即丟——float 轉 int 的轉型結果就是一個 prvalue。modulo arithmetic 無號整數溢位時的環繞規則:結果對 2 的型別位元數次方取模,例如 unsigned char 的 255 加 1 環繞成 0。這條規則存在,但明確不適用於 float 轉 int。、 以及 可表示範圍 一個型別能裝下的所有數值集合。int32 的可表示範圍是 -2147483648 到 2147483647;超出這個集合就是「裝不下」,也是這條 UB 的觸發條件。

把這條規則畫成一條曲線更直觀:「你以為的」轉型是一路平滑延伸到天邊的直線,實際的合法定義域卻只有中間那一小段。下面這個 widget 按一下,看這條線在超出範圍之後真正發生了什麼事。

click button to morph the assumed vs actual curve shape

目前顯示:你以為的(一路線性延伸) 輸入的 float 值(由小到大) 轉型結果 INT_MAX INT_MIN
橙線是這個轉型的曲線形狀。左邊「你以為的」版本一路線性往上;右邊「x86 實際」版本在中段離開合法範圍之後,直接被壓平到 INT_MIN,不再跟著輸入變化。

橙線是這個轉型的曲線形狀

你以為的截斷是一路平滑延伸的斜線,實際上一旦超出範圍,x86 直接把整條線拉平到 INT_MIN,不是漸進失真,是斷崖式塌縮。

標準把選擇權交給編譯器,編譯器把選擇權交給指令集

「未定義行為」不代表程式一定會當掉或印出亂碼——多數時候它只是「標準沒有規定結果是什麼」,編譯器可以自由選擇怎麼實作。對 float 轉 int 溢位,編譯器的自由發揮空間落在「選哪一條硬體指令」。x86 常用的是 CVTTSS2SI:「x86's CVTTSS2SI, which maps every unrepresentable input to the same placeholder integer INT_MIN.」不管輸入是正無限大還是負一百億,凡是裝不進 int32 的值,CVTTSS2SI 一律吐出同一個佔位答案 INT_MIN。

AArch64 走的是另一條路:「AArch64 has FCVTZS, which saturates and maps NaN to zero.」FCVTZS 的做法是飽和到目的型別能表示的邊界:超過上界就給上界,低於下界就給下界;NaN 則是特例,直接映射成 0,跟 INT_MIN 完全無關。

NaN 不是「很大的數字」,它是「不是一個數字」——這是 IEEE 754 本身的定義,不是這篇文章特別談的背景知識:一組特定的位元樣式表示這裡沒有合法的浮點值,可能來自 0 除以 0,也可能來自對負數開根號,把 NaN 拿去跟任何數字比大小,包括跟它自己比,答案永遠是 false。這也是為什麼「NaN 該轉成哪個整數」這個問題,連「裝不下就用邊界值」這個直覺都用不上——它從一開始就不在數線上,沒有「比較接近哪個邊界」這回事。換個角度想,兩顆 CPU 各自挑了一個常數收尾,比較像是圖省事的處理方式,而不是標準規定的必然結果。

這兩條指令的差別,是兩種完全不同的錯誤處理哲學:x86 選「所有壞輸入都變成同一個警示值」,ARM64 選「儘量貼近合理範圍,NaN 另外處理」。標準沒有規定該選哪一種,於是兩顆晶片各自選了一種,而且都完全遵守自己 ISA 的規格——規格本來就沒有替這個情境選邊,無關誰對誰錯。

一個說得通的解釋是,多插一個邊界檢查對效能敏感的程式碼是有代價的——每一次轉型都要多比較兩次、多一次分支,在緊湊迴圈裡跑的程式,這個成本會被放大。C++ 的哲學向來是「你不用的東西不用付費」,把檢查留給有需要的人自己加,而不是預設替所有人加上去,跟未定義行為在這個語言裡反覆出現的其他角落,是同一套邏輯。

這裡岔開一句我自己的類比,不是文章裡提到的:你可能更熟悉的 signed integer overflow,C++ 標準同樣選擇不規定結果,猜測理由多少也跟硬體差異與效能有關。差別在於,signed overflow 直覺上容易聯想到「數字太大了」,float 轉 int 溢位卻常被誤認成單純的四捨五入問題,警覺性因此低了一截。

這兩條指令各自有明確定義的映射——CVTTSS2SI 對裝不下的輸入固定映射成 INT_MIN,FCVTZS 固定飽和到邊界或把 NaN 映射成 0,不是隨機亂數。真正危險的地方,是「結果因平台而定」,而平台這件事往往在你寫程式碼的當下根本不在你的注意範圍內。

底下這個 widget 讓你直接拖一個浮點數的值——正常範圍內、超出上界、超出下界、或勾選 NaN——看同一份原始碼在三條路徑上各自算出什麼。

drag handle along the float value line · int32 destination

3,000,000,000
INT_MIN INT_MAX 0 你以為的(純截斷) x86 CVTTSS2SI ARM64 FCVTZS
你以為的
3,000,000,000
x86 CVTTSS2SI
-2,147,483,648
ARM64 FCVTZS
2,147,483,647
目的型別固定為 32 位元 int(範圍 -2147483648 到 2147483647)。「你以為的」欄位是純數學截斷,不受任何 ISA 限制——超出範圍時它照樣往外走,用虛線標示脫離可表示範圍的位置。

五個數字,三條路徑:邊界內外怎麼分岔

光看規格文字容易流於抽象,換幾個具體數字更清楚。下表列出五個輸入值,分別對照「你以為的」(純截斷)、x86(CVTTSS2SI)、ARM64(FCVTZS)三條路徑各自的結果——在合法範圍內三者永遠一致,一旦跨出範圍就分岔。

輸入值 f 你以為的(純截斷) x86 CVTTSS2SI ARM64 FCVTZS
42.7424242
-13.2-13-13-13
2147483648.5(剛超過上界)2147483648(裝不下)-21474836482147483647
-2147483649.9(剛超過下界)-2147483649(裝不下)-2147483648-2147483648
NaN未定義-21474836480
目的型別固定為 32 位元 int。前兩列在範圍內,三條路徑完全一致;後三列一旦裝不下,x86 全部收斂到同一個 INT_MIN,ARM64 依正負分別飽和到上下界,NaN 則各走各的路。

第三列最能說明問題——輸入值本身只是剛好超過上界一點點,數字並不誇張,但 x86 與 ARM64 給出的答案差了超過四十億。差距一過邊界就整個跳掉,跟超出的幅度沒有線性關係。第四列反而是另一個值得留意的地方:輸入同樣只是剛好超過下界一點點,這次 x86 與 ARM64 卻收斂到同一個 INT_MIN——兩顆 ISA 對負向溢位罕見地給出一致答案,正向溢位才是「飽和」與「收斂到單一佔位值」這兩種策略真正分道揚鑣的地方。

把這三條路徑攤開來看更完整。同一個超出範圍的輸入,走 x86 編譯出來的二進位、走 ARM64 編譯出來的二進位、以及走「先檢查再轉型」修過的程式碼,各自發生什麼事:

switch tabs to compare 3 paths · 同一個超出範圍的輸入

輸入 f = 3000000000.0(超出 int32 上界)
編譯器選擇 CVTTSS2SI 實作這次轉型
CVTTSS2SI 對「裝不下的輸入」一律回傳同一個佔位值
// 結果:i = INT_MIN = -2147483648
// 不管 f 超過上界一點點還是逼近無限大,答案都是這個數字
輸入 f = 3000000000.0(超出 int32 上界)
編譯器選擇 FCVTZS 實作這次轉型
FCVTZS 飽和到目的型別能表示的邊界
// 結果:i = INT_MAX = 2147483647
// 換成負向超界會飽和到 INT_MIN;換成 NaN 直接給 0
輸入 f = 3000000000.0
先檢查:截斷值是否落在 [INT_MIN, INT_MAX] 之內?
// 沒有 → 不執行這次未定義的轉型
// 自己決定:飽和到邊界、拋例外,或回傳錯誤碼
// 不管換到哪顆 CPU,這個決定都一樣

把這件事講白一點:這不是編譯器的 bug,也不是某顆 CPU「做錯了」。CVTTSS2SI 和 FCVTZS 都完全遵守各自 ISA 的規格,C++ 標準本身就沒有替這個情境選邊——選擇權下放到了硬體。同一份原始碼、同一個輸入值,只因為換了一顆 CPU 執行,答案就不一樣,而且兩邊都不會有任何警告或例外提醒你這件事發生了。

常見的情況是,開發機與 CI 多半還是跑在 x86 上,這條路徑在這些機器上恰好得到一個確定的整數答案。等到部署到 ARM64 環境——這幾年愈來愈常見的伺服器與筆電——同一個溢位輸入才第一次表現出不同的行為。推測起來,這正是這類 bug 特別容易漏網的原因:它是決定性的,只是決定它的東西換了平台,不是隨機出現的。這也是為什麼「在 x86 上測試通過」在這條 UB 面前沒有太多意義——測試通過只證明了在這顆 CPU、這次跑到的那個輸入值上答案是這個,沒有證明所有平台、所有可能輸入都會是這個。這條界線在其他大多數 bug 上模糊,在這裡卻異常清楚:規格本身就告訴你,答案是允許因平台而異的。

修法:先檢查,再轉型

作者給的修法很直接:「The correct fix is to bounds check before casting.」在轉型之前,先確認這個浮點數截斷之後真的裝得進目的型別的範圍——裝不下就自己決定要飽和、要拋錯,還是要用別的方式處理,而不是把這個決定丟給硬體去賭。

作者把這個想法做成一個概念驗證:「I turned this into a proof of concept library based on Rust's saturating approach.」Rust 的飽和轉換是一個現成的參照模型——轉型結果永遠落在目的型別的合法範圍內,超過上界就是上界,低於下界就是下界,不會因為換一顆 CPU 就換一個答案。下面這個 widget 示範同一個超出範圍的輸入,套用「先檢查、再飽和」之後的結果是否還會因平台而異:

drag slider across the same range · 先檢查再轉型的結果

3,000,000,000

先檢查、再轉型之後的結果:2,147,483,647(任何平台都一樣)

把同一個 f 拖過整個範圍,這個結果永遠落在 [INT_MIN, INT_MAX] 之內,不會因為換一顆 CPU 就換一個數字——決定權從硬體指令收回到你自己寫的邊界檢查。

把決定權收回來,不代表每個轉型都要手寫一次邊界比較。常見的做法是把這個邏輯包成一個共用的轉換函式,呼叫端只需要決定裝不下的時候要飽和、要丟例外,還是要回傳一個表示失敗的值——重點是這個決定變成程式碼裡看得到、審得到的一行,而不是藏在某條 CPU 指令裡,換一個部署環境才會現形。

想在自己的專案裡抓出這類轉型,不用等到它在某個平台上炸開才發現。作者提到的偵測工具是:「You can also detect the undefined behavior with Clang's and GCC's Undefined Behavior Sanitizer, see -fsanitize=float-cast-overflow.」在測試環境跑一輪帶這個 flag 的 build,任何一次裝不下的 float 轉 int 都會在執行期被攔下來,而不是安靜地滑過去,直到你在另一顆 CPU 上看到不一樣的數字才發現問題。

這是 sanitizer 一般的使用方式,這篇文章本身沒有細談:通常只在測試或除錯 build 開啟,因為插入額外檢查會拖慢執行速度,不會出現在正式上線的 binary 裡——它抓到的是測試涵蓋到的那條路徑,涵蓋不到的輸入範圍,還是要靠邊界檢查本身把關,不能只靠事後偵測。

回頭看最開始那三行程式碼:i0、i1、i2 全部長得人畜無害,也全部通過 -Wall -Wextra。它們是一段在你手上這顆 CPU 上「剛好能動」的程式碼,不是寫錯的程式碼——先做邊界檢查,或者跑一輪 -fsanitize=float-cast-overflow,是把「剛好能動」變成「保證能動」的最短距離。

這篇文章談的是一個很具體的轉型,但背後的提醒更廣:C++ 標準裡沒有規定的角落,不會因為你沒踩到就不存在,也不會因為在你手上這台機器看起來沒事就真的沒事。看起來沒事,往往只是因為你還沒換過平台、還沒餵過那個剛好卡在邊界上的輸入。

Take-away:float 轉 int 不是「截斷」,是「截斷,前提是裝得下」——裝不下的時候,答案由你手上這顆 CPU 的轉型指令決定,C++ 標準本身沒有置喙。