JOVANA
Explore Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

溢位、回繞與位元組順序

固定寬度的整數有一道硬邊界——數過了頭它就無聲地回繞,而對有號數來說,那次回繞是個陷阱,不只是個意外。接著我們把一個多位元組的數打開來看,會發現位元組未必照你書寫的順序存放。

固定寬度數字的那道邊界

在前兩篇裡,你學會了讀一個二進位與十六進位位元模式,也學會了讓負數從二補數裡自然地掉出來。兩者都建立在一個我們現在必須正面面對的安靜假設上:一個數就只有這麼多位元。`unsigned char` 是 8 位元,`uint32_t` 剛好是 32 位元。那固定的寬度正是固定寬度型別的整個重點——但它也意味著 C 裡的每一個整數都住在一個有限的盒子裡,而盒子有牆。

拿最小的盒子來看——一個 8 位元的無號值。它能容納 0 一直到 255——也就是 256 種不同的模式,從 0x00 到 0xff,一個都不多。那麼當你把 255 加 1 時,硬體會怎麼做?沒有第 8 號位元可以進位過去;盒子滿了。答案就是這整篇的核心事實:它不會停下,不會警告你,它就只是無聲地回繞到 0。數過了範圍的頂端,會把你帶回底端,就像汽車里程表從 999999 翻回 000000 一樣。

最乾淨的畫面是一個環,而不是一條數線。把 0x00 到 0xff 這 256 種模式排在鐘面上:254、255,然後——不是什麼虛構的 256——你又回到了 0,後面跟著 1 和 2。從頂端「往上」踏出去(255 + 1)會落在 0;從底端「往下」踏出去(0 - 1)會落在 255。沒有邊緣可以掉下去,只有一道你跨過時感覺不到的接縫。把這個環記在心裡:本篇之後的一切,都是「整數是環狀而非無限延伸」這件事的後果。

無號回繞:有定義、可預測、模數運算

無號整數而言,那個環不是 bug——它正是 C 標準所承諾的。無號運算被定義為模數運算:每個結果都會對「2^N」取餘數,其中 N 是位元寬度。對我們的 8 位元值來說,每個答案都對 256 取餘。所以 255 + 1 是 0,0 - 1 是 255,而 200 + 100 是 300 - 256 = 44。這裡沒有任何未定義的東西;硬體只是保留低 N 個位元,把進位丟掉而已。這就是無號回繞,而你是被允許去依賴它的。

「0 - 1 == 255」這件事值得停下來想,因為它真的會咬到實際的程式。假設你用 `size_t i`(一個無號型別)寫迴圈,想用 `for (i = n - 1; i >= 0; i--)` 來「往下」數。既然 `i` 是無號的,那條件 `i >= 0` 就「永遠成立」——它不可能變成負的。當 `i` 是 0 而你做 `i--` 時,它會回繞成一個像 18446744073709551615 這樣的巨大數值,迴圈繼續跑,然後你就衝過了陣列的尾端。修法是改成往上數,或刻意改用有號的計數器。回繞本身定義良好;那個 bug 是「期待它表現得像紙上的數學」。

有號溢位:同一道牆,跌得卻慘得多

現在用有號型別做同樣的事,故事就完全變了。一個二補數的 `signed char` 範圍是 -128 到 +127。位元模式 0x7f 是 +127,是最大的正值;它的符號位元(最高位元)仍是 0。加 1 後模式變成 0x80——但在二補數裡 0x80 讀作 -128。所以在機器層面,`127 + 1` 把一個正數翻成了最負的那個數。這種突如其來的符號翻轉就是有號整數溢位,而它比無號回繞要凶險得多。

為何更凶險?因為在 C 裡,有號整數溢位是未定義行為——而那「不是」「像硬體那樣回繞到 -128」的同義詞。這是本篇最重要的一句誠實話。未定義行為意味著標準對發生什麼「不作任何」要求,而最佳化器被允許假設它根本不會發生。於是編譯器可以合法地把像 `if (x + 1 < x)` 這樣的檢查——你想用來「偵測」溢位的那一句——整個刪掉,理由是「有號溢位不可能發生,所以 `x + 1` 永遠大於 `x`,所以這個分支是死碼」。你的防護消失了,而它本來要防的那個 bug 就大搖大擺地走了進來。

藏在型別提升裡的陷阱

還有一個地方會伏擊新手:溢位可能在兩個運算元和結果「看起來」都夠小的時候照樣發生。在 C 做大多數算術之前,它會把小型別加寬成 `int`——這就是整數提升。把兩個 `unsigned char` 值相乘,例如 `200 * 200`,它們會先被提升成 `int`,所以這個乘法是 `int` 運算、得到 40000,而不是一個回繞過的 8 位元答案。這裡是幫了忙——但同一套提升規則也可能把一個無號比較變成有號的,或在一個你沒料到的寬度裡做運算,所以決定你會不會回繞、以及那次回繞是否有定義的,是「周圍整個運算式的型別」,而不只是那些變數。

實務上的防禦,是清楚知道你的寬度,並指名去用它們。`<stdint.h>` 裡的固定寬度型別——`uint8_t`、`int32_t`、`uint64_t`——精確地說出一個值在每個平台上有多大,所以你永遠不必去猜 `long` 是 4 位元組還是 8 位元組。當一個值可能超出某個寬度時,先在一個更寬的型別裡做算術(`(uint64_t)a * b`),然後才把它存回去。多數溢位 bug 其實都是「我在大小不對的盒子裡做了運算」這種 bug。

位元組順序:哪個位元組排前面

在一個暫存器內部,位元有清楚的順序——最高有效位在左、最低有效位在右。但一旦一個多位元組的值被寫出到記憶體裡,就冒出一個單一暫存器永遠不會引發的新問題:它的那些位元組,會以什麼「順序」坐落在連續的位址上?那個順序就是位元組順序(endianness),而它純粹是一個關於擺放方式的慣例,與數值本身無關。數字 0x0a0b0c0d 在每台機器上都是同一個數;分歧只在於它的四個位元組中,哪一個落在最低的位址上。

  store the 32-bit value 0x0a0b0c0d at address 0x1000:

  little-endian (x86-64, ARM default):   low byte first
     addr  0x1000  0x1001  0x1002  0x1003
     byte   0x0d    0x0c    0x0b    0x0a

  big-endian (network order, older RISC):  high byte first
     addr  0x1000  0x1001  0x1002  0x1003
     byte   0x0a    0x0b    0x0c    0x0d
同一個值、同樣四個位元組——只有它們在記憶體裡的順序不同。

小端序(低位位元組放在最低位址)是 x86-64 與多數 ARM 機器所用的,所以那是你日常會看到的。大端序(高位位元組在前)對人類來說讀起來比較自然,也在一些較舊的晶片裡留存下來。你幾乎永遠不會察覺這個差異——直到兩台順序不一致的機器試著交換原始位元組為止。在一台小端序機器上把一個 `uint32_t` 直接寫到檔案或通訊端,再在一台大端序機器上讀它,0x0a0b0c0d 就會變回 0x0d0c0b0a:一個完全不同的數,無聲無息。

這就是為什麼通訊協定要挑定一個固定的慣例。網際網路把大端序定為網路位元組順序,而 libc 的輔助函式 `htonl()` / `ntohl()`(主機轉網路、再轉回來)會在邊界處轉換你的整數,讓兩端取得一致。在單一程式內、在單一機器的記憶體裡,你完全可以無視位元組順序——CPU 一致地讀回它自己的位元組。它只在邊界處才要緊,也就是位元組從一台機器的記憶體跨越到檔案、通訊端、或另一顆 CPU 的時候。