IEEE 754 如何儲存實數?
理解 binary32、binary64、捨入、NaN 與累加誤差
IEEE 754 如何儲存實數?
本文以教學為目的。硬體、編譯選項與語言實作可能改變細節,重要行為請在自己的環境中確認。
初學者常對 0.1 + 0.2 不完全等於 0.3 感到意外。電腦並不是不會加法,而是必須把無限多個實數放進有限的位元模式,因此大多數值只能以鄰近值表示。
IEEE 754 不只規定位元排列,也定義運算、捨入、無限大、NaN、漸進式下溢、轉換與例外狀態。本文會拆解 binary32 與 binary64,再整理比較、累加、金額與序列化時的實務原則。
定點數與浮點數
定點數事先固定小數點位置。若整數 12345 永遠代表兩位小數,實際值就是 123.45。它適合固定尺度,但同一格式難以同時涵蓋極小與極大的數。
浮點數類似科學記號。
6.25 = 1.5625 × 2²
0.15625 = 1.25 × 2⁻³
有效數字與指數分開儲存,範圍因此很寬,但相鄰值之間的距離並不固定。
IEEE 754 涵蓋的內容
IEEE 754-2019 規範二進位與十進位格式、運算、轉換、捨入、例外條件及預設處理。實作可以使用硬體、軟體或兩者混合。
| 名稱 | 常見語言型別 | 總位元 | 符號 | 指數 | 儲存 fraction | 精度 |
|---|---|---|---|---|---|---|
binary32 | 通常是 float | 32 | 1 | 8 | 23 | 24 位元 |
binary64 | 通常是 double | 64 | 1 | 11 | 52 | 53 位元 |
「通常」很重要。C 與 C++ 的型別名稱本身不保證所有平台都使用這些格式。C++ 可透過 std::numeric_limits<T>::is_iec559 等屬性檢查實作。
binary32 的三個欄位
1 位元符號 | 8 位元指數 | 23 位元 fraction
一般有限值的公式是:
(-1)^s × (1.fraction)₂ × 2^(E - 127)
指數使用 127 的 bias。正規化二進位有效數字的開頭必定為 1,因此不必真的存下來。23 位元 fraction 因而提供 24 位元精度。binary64 的 bias 是 1023,52 位元 fraction 提供 53 位元精度。
手動編碼 6.25
6.25₁₀ = 110.01₂ = 1.1001₂ × 2²
符號為 0。儲存指數是 2 + 127 = 129,也就是 10000001₂。省略開頭的 1 後,fraction 是 1001000...0。
0 10000001 10010000000000000000000
十六進位為 0x40C80000。
float value = 6.25f;
std::uint32_t bits = std::bit_cast<std::uint32_t>(value);
特殊指數模式
| 指數 | fraction | 意義 |
|---|---|---|
| 全為 0 | 全為 0 | 帶符號的 0 |
| 全為 0 | 非 0 | subnormal 數 |
| 1 到 254 | 任意 | 正規化有限數 |
| 全為 1 | 全為 0 | 正或負無限大 |
| 全為 1 | 非 0 | NaN |
因此 binary32 的正規化實際指數介於 -126 與 127。
正零與負零
指數和 fraction 都為 0 時仍保留符號,所以同時存在 +0 與 -0。一般比較認為兩者相等,但某些運算會保留方向。
double p = 0.0;
double n = -0.0;
std::cout << (p == n) << '\n';
std::cout << std::signbit(n) << '\n';
std::cout << 1.0 / p << '\n';
std::cout << 1.0 / n << '\n';
一般 IEEE 行為下,最後兩個結果分別為正無限大與負無限大。
subnormal 與漸進式下溢
如果靠近 0 時仍要求開頭為 1,最小正規化數與 0 之間會留下空隙。儲存指數為 0 且 fraction 非 0 時改用:
(-1)^s × (0.fraction)₂ × 2^-126
subnormal 數以逐步失去精度的方式接近 0。binary32 最小正規化正數約為 1.17549435 × 10^-38,最小 subnormal 正數約為 1.40129846 × 10^-45。
部分 SIMD 或 GPU 環境為了效能會把 subnormal 直接視為 0,數值程式必須確認此模式。
無限大與 NaN
指數全為 1 且 fraction 為 0 時表示無限大。溢位或非零有限值除以 0 可能在預設處理下產生無限大。
fraction 非 0 時表示 NaN,Not a Number。0.0 / 0.0 或負數的實數平方根都可能產生 NaN。
NaN 沒有一般順序,甚至與自己比較也不相等。
double nan = std::numeric_limits<double>::quiet_NaN();
std::cout << (nan == nan) << '\n';
std::cout << std::isnan(nan) << '\n';
應使用 std::isnan 判斷。quiet NaN、signaling NaN 與 payload 的支援依語言及硬體而異。
為什麼 0.1 不能精確表示
0.1 等於 1 / 10。最簡分數要成為有限二進位小數,分母的質因數只能有 2。10 還含有 5,因此二進位表示會循環。
0.1₁₀ = 0.000110011001100110011...₂
binary64 只保留 53 位元有效數字,其餘部分必須捨入。
std::cout << std::setprecision(20) << 0.1 << '\n';
常見結果為 0.10000000000000000555。0.1 + 0.2 的差異來自輸入與加法結果各自的捨入。
數值間距會隨大小增加
浮點數並未均勻分布。binary32 有 24 位元精度,因此到 16,777,216 為止的整數都能精確表示。再大的區域中,相鄰值開始相差 2 或更多。
float a = 16'777'216.0f;
float b = a + 1.0f;
std::cout << (a == b) << '\n';
一般 binary32 會輸出真,因為該大小附近沒有獨立的 a + 1。
epsilon 不是萬用容許值
std::numeric_limits<double>::epsilon() 是 1 與下一個可表示 double 的距離,不適合直接套用到所有大小。實務上常結合接近 0 時的絕對誤差與其他範圍的相對誤差。
bool almostEqual(double a, double b,
double absoluteTolerance = 1e-12,
double relativeTolerance = 1e-9)
{
double difference = std::abs(a - b);
if (difference <= absoluteTolerance)
{
return true;
}
double scale = std::max(std::abs(a), std::abs(b));
return difference <= scale * relativeTolerance;
}
容許值必須根據座標、時間、物理量等問題領域決定。
捨入模式與五種例外狀態
精確結果落在兩個可表示值之間時,捨入方向決定儲存結果。常見預設是取最近值,完全居中時選擇最後一位為偶數的一側。
C 與 C++ 透過 <fenv.h> 和 <cfenv> 存取實作支援的捨入方向與狀態旗標。
五種 IEEE 例外狀態是 invalid operation、division by zero、overflow、underflow、inexact。這通常不是 C++ throw,而是產生預設結果並記錄旗標。
std::feclearexcept(FE_ALL_EXCEPT);
volatile double one = 1.0;
volatile double zero = 0.0;
volatile double result = one / zero;
if (std::fetestexcept(FE_DIVBYZERO))
{
std::cout << "division by zero flag\n";
}
運算順序與累加誤差
每個中間結果都會捨入,所以有限精度加法不一定符合結合律。
double large = 1e16;
double small = 1.0;
double first = (large + small) - large;
double second = small + (large - large);
第一個式子可能失去 small。大量累加時可考慮 Kahan summation、相近大小配對相加或使用更寬的累加型別。
金額與精確資料
二進位浮點數很適合圖形、座標與許多科學計算,但通常不適合需要精確十進位捨入的金額。
- 用整數儲存元、分等最小單位。
- 使用 decimal 或定點型別。
- 明確規定捨入的時機與方向。
識別碼和必須精確的計數器也應使用整數。
序列化與實作確認
跨越檔案或網路邊界時應明定 binary32 或 binary64、協定位元組順序、是否保留帶符號 0 與 NaN payload。文字往返可用 C++ max_digits10 決定足夠的位數。
std::numeric_limits 可檢查 is_iec559、radix、digits、min()、denorm_min() 與 max()。浮點型別的 min() 是最小正規化正值;最負的有限值應查 lowest()。
參考資料
- IEEE 754-2019 概要
- Oracle Numerical Computation Guide
- What Every Computer Scientist Should Know About Floating-Point Arithmetic
- C++ 0
- C++ floating-point environment
總結
IEEE 754 使用符號、有效數字與指數,在有限位元內涵蓋極大範圍。代價是近似、依數值大小改變的間距、特殊值,以及每一步可能發生的捨入。理解同一套模型後,0.1 + 0.2、NaN 比較、大座標抖動與累加誤差就不再是互不相關的怪現象。