你的 byte 不是我的 byte

今天我們來討論一件很有趣的事情。文章開始之前,我們先來猜一下,下面這段 C 程式會不會印出 equal?

char value = 0xFD;
if (value == 0xFD) {
    puts("equal");
}

表面上看起來,將一個值(0xFD)存進變數(value),再拿來和同一個值做比較,好像沒有不相等的理由。奇妙的是,在 STM32F401 上,它確實會印出 equal;可是,同一段程式拿到 x86-64 Linux 上,用 GCC 編譯執行,卻什麼都不會印出來。

註:

  1. x86-64 的結果是用 Ubuntu 24.04 的 GCC 13.3.0 編譯執行得到的。加編譯旗標 -Wextra 時,GCC 會警告這個比較永遠不會成立。若旗標只開 -Wall,GCC 連警告都不會給,我們以為的邏輯就這樣悄悄地出錯了。
  2. 本文所使用的各 toolchain,plain char 有沒有正負號的設定(signedness)都採預設,未加上 -fsigned-char 或 -funsigned-char 覆寫;以下的 C 語言規則以 C11 為準。

問題到底出在哪裡?

從 CPU 開始看起

會有這篇文章,其實是某天我腦袋突然冒出一個很白痴的問題:「CPU 知道一個 value 是有號數還是無號數嗎?」於是和 AI 討論起來。文章開頭那段程式,正是在討論過程中 AI 拋出來的問題。它的關鍵其實在 plain char 有沒有正負號,和我原本好奇的問題不完全是同一件事,但我覺得兩者放在一起看,還是值得寫成一篇文章。

我們先用一個 8-bit 的小模型來幫助思考。0xFD 的 8 個 bit 是 1111 1101:

  • 當成無號數,它是 253。
  • 當成有號數(two's complement),最高位的權重代表 −128,其餘七位是 125,加起來是 −3。

來做個加法

現在把 0xFD 加上 0x05。若當成無號數相加,和是 0x102,需要 9-bit 才放得下,8-bit 的格子只留得下 02:

      1111 1101      FD
  +   0000 0101      05
  -------------
    1 0000 0010    1 02
    ↑
    9th-bit 放不下,8-bit 的格子只留下 0000 0010(02)

這個 0x02 能保留完整的和嗎?我們來看一下,把它當成「無號數」跟「有號數」的解讀結果:

讀法 算式 留下來的 02
無號數 253 + 5 = 258,超過 255 放不下,繞回 2 繞回了(有進位)
有號數 −3 + 5 = 2 沒有溢位

無號數的 2 是 modulo 256 的結果,只是它已經無法保留完整的數學和 258。同一個 0x02,當成無號數看時有進位,當成有號數看時沒有溢位。由於 CPU 暫存器裡沒有型別的標記,會更新旗標的加法指令,就把兩種判定都記在旗標裡,留給後面的指令自己挑選。

記錄判定的地方叫 APSR(Application Program Status Register),這些判定旗標稱為 condition flags。以下是 Cortex-M4 加法更新旗標時的意思:

旗標 設為 1 的意思 給誰看
C(Carry) 當成無號數看,結果超出範圍,繞回來了 無號數
V(Overflow) 當成有號數看,結果超出範圍,正負號錯了 有號數
N(Negative) 結果的最高位是 1,當成有號數看是負的 有號數
Z(Zero) 結果是 0 兩者都用

註:

  1. 旗標的正式定義見 DUI 0553B §3.3.7 Conditional execution;「給誰看」一欄是依條件碼的用法整理的(DUI 0553B Table 3-4)。
  2. 減法的 C 旗標意思不同:沒有借位時設為 1。
  3. 大多數指令只有加上 S 後綴(例如 ADDS)時,才會更新 APSR 裡的旗標。旗標是留給後面的條件判斷(例如 if、比較大小)或進位傳遞(例如 64 位元的加法)用的;像 result = a + b; 這種只要結果的運算,compiler 可以使用不設旗標的 ADD,但也可能因指令編碼或最佳化選擇 ADDS。

APSR 本身也是 CPU 暫存器的一部分。在〈CPU 暫存器和周邊暫存器差在哪裡?〉,我們把 CPU 暫存器比喻成工作台。Cortex-M4 的工作台上有 R0 ~ R12 這 13 個通用暫存器,加上 SP、LR、PC,以及一個 Program Status Register(PSR)。PSR 的 32 bits 切成三個部分:APSR 把 N、Z、C、V 放在最高的 4 bits;IPSR 記錄 CPU 目前正在處理哪一個中斷或 exception;EPSR 記錄一些執行狀態。後面兩個和這篇無關,故不在此贅述。

Cortex-M4 的通用暫存器是 32-bit,裡面同樣沒有任何標記說明「這是有號數」,旗標也是依 32-bit 的結果來判斷,道理和 8-bit 小模型一樣。把 0xFFFFFFFD(當成有號數就是 −3)加上 5,用 ADDS 來算:

  • 結果是 0x00000002。
  • 當成無號數,0xFFFFFFFD 加 5 超出了 32 位元,繞回成 2,此時 C = 1。
  • 當成有號數,−3 + 5 = 2,沒有出錯,此時 V = 0。

這也說明了為什麼有號與無號加法可以共用 ADD:在 two's complement 下,兩種讀法相加後留下的低 32 bits 完全一樣。若還需要進位與溢位的判定,就使用 ADDS,把旗標更新到 APSR 裡。


讀法由指令決定

既然 CPU 不知道型別,那麼「用哪一種讀法」是在哪裡決定的呢?答案是指令。


把值存進暫存器

由於暫存器是 32 位元的,從記憶體讀一個 byte 要放進暫存器時,高位的 24 bits 要補什麼就要確定下來。Cortex-M4 為此準備了兩條指令:

  • LDRB(load register with byte)把 byte 當成無號數,高位補 0(zero extend)。0xFD 進到暫存器變成 0x000000FD,也就是 253。
  • LDRSB(load register with signed byte)把 byte 當成有號數,高位補上最高位的值(sign extend)。0xFD 進到暫存器變成 0xFFFFFFFD,也就是 −3。

所以從記憶體載入一個 8 位元資料時,按指令就能選擇延伸方式。不過,資料進暫存器後,仍可以用 SXTB(sign extend a byte)把低 8 bits 做 sign extend,或用 UXTB(zero extend a byte)做 zero extend。例如 0x000000FD 經過 SXTB,就會變成 0xFFFFFFFD;讀法可以在後續指令裡繼續選擇。


計算結果

前面加法的例子,CPU 將運算的旗標記下來之後,由後面使用旗標的指令決定要看哪一個「結果」。比較大小時,CMP 其實就是一次不會存下計算結果的減法。接在它後面的條件碼,如果用 LO(看 C 旗標),就是無號的「小於」;用 LT(看 N 和 V 旗標),就是有號的「小於」。要用哪一條指令、看哪一個旗標,會由 compiler 依照 C 的型別與轉換規則替我們挑選。


例如,假設 R0 裡是 0xFFFFFFFD,R1 裡是 0x00000001,可以用下面的組合語言示意兩種判斷(這裡刻意把兩種分支並列,並非 compiler 的實際輸出):

CMP R0, R1         ; R0 = 0xFFFFFFFD, R1 = 0x00000001
BLO unsigned_less  ; R0 < R1 嗎? C = 0 時跳躍:無號小於,本例不跳
BLT signed_less    ; R0 < R1 嗎? N != V 時跳躍:有號小於,本例跳躍

當成無號數,4294967293 大於 1;當成有號數,−3 小於 1。兩個分支都讀取同一次 CMP 留下的旗標,中間沒有其他指令改寫旗標。

至於開場的 ==,判斷相等用的是 Z 旗標,有號與無號共用同一種條件。那個例子的差異,來自比較前的型別轉換,我們最後再回來看。


Byte 本身代表什麼值?

走到這裡,可以把話說清楚了:記憶體裡的 0xFD 本身代表哪一個值?253 還是 −3 呢?由於 CPU 不知道型別,compiler 會依型別選擇對應的指令,讓這 8 bits 按選定的讀法處理,此時型別才讓這 8 bits 的值被確定下來。

借佛法的「空性」來看,事物本身沒有自己固定不變的意義,它之所以顯現成某個樣子,是因為有某種認知套用在它身上。Byte 也是這樣:8 bits 本身不帶任何意義,套上某種解讀後,它才「顯現出」某個數。


讀法從哪裡來

那麼這些讀法又是誰定的?針對本文的例子,可以問:這個結果的保證來自哪裡?

由 C 定的

例如 uint8_t u = -3;,u 一定是 253。C 規定,一個值放不進無號型別時,就反覆加上或減去「這個型別的最大值加 1」,直到它落進範圍為止(C11 §6.3.1.3)。−3 加上 256,得到 253。這條規則只跟數學有關,和負數在機器裡怎麼表示無關,換到哪一個平台都一樣。


由平台定的

C 標準在不少地方刻意留白,寫成 implementation-defined:交給實作去決定,但實作必須寫明自己的選擇(C11 §3.4.1)。這裡的「實作」,在 STM32F401 上是由 compiler、ABI、硬體三方一起構成的,各有各的文件。plain char 有沒有正負號就是一例:C 沒有固定指定,GCC 手冊寫的是「Determined by ABI」,並說明可以用 -fsigned-char 或 -funsigned-char 改變預設值。ARM 的 ABI(AAPCS32)把 char 定為 unsigned byte,備註欄還寫著「LDRB is unsigned」,正好對上前一節的那條指令。


C 標準不保證的

例如 int x = INT_MAX; x = x + 1;。這個有號整數加法的結果超出了型別能表示的範圍,C 說這時的行為是 undefined(C11 §6.5);undefined behavior 的意思是,標準對行為沒有任何要求(C11 §3.4.3)。

你可能會想:在 STM32F401 上,INT_MAX 是 0x7FFFFFFF,INT_MIN 是 −2147483648,以 32-bit two's complement 表示就是 0x80000000。硬體若對 0x7FFFFFFF 執行 32-bit 加法,加 1 會得到 0x80000000,以 two's complement 解讀就是 INT_MIN;若使用 ADDS,還會設起 V 旗標,結果一樣是 INT_MIN。但光知道硬體怎麼加,仍無法保證 C 程式的結果。當實作沒有額外承諾時,compiler 可以假設程式不會發生有號整數溢位,再依這個前提產生指令,而 CPU 只會執行 compiler 編完的指令。所以問題可能會發生在 compiler 編譯出不符合我們預期的指令。

C 定的規則,可以依標準推導;implementation-defined 的選擇,則要查實作文件與編譯設定。遇到 undefined behavior,若實作沒有額外保證,實測沒事也只代表這一次剛好沒事,不能據此推論下一次的行為。

註:

  1. 文中的 C11 指 ISO/IEC 9899:2011,條號與公開的委員會草案 N1570 相同。
  2. 分類也會隨著標準改版移動。C11 允許有號整數用三種表示法,用哪一種由實作決定(C11 §6.2.6.2),GCC 只支援 two's complement;到了 C23,標準直接規定只能用 two's complement(GCC 手冊〈Integers implementation〉)。
  3. 上面只整理本文例子涉及的情況。C 還有 unspecified behavior:標準容許多種可能,但不要求實作記錄每次採用哪一種,例如函式引數的求值順序(C11 §3.4.4)。這與 undefined behavior 不同,仍受標準容許的範圍約束。

回到那行 if

現在可以解開開場的謎題了。

char value = 0xFD;
if (value == 0xFD) {
    puts("equal");
}

0xFD 是一個值為 253 的 int,把它存進 char value 時,要先看 char 有沒有正負號,而這要看平台與編譯設定。在 STM32F401 的 AAPCS32 預設設定下,char 是 unsigned,253 放得下,value 就是 253。在 x86-64 Linux 的 System V AMD64 ABI 預設設定下,char 是 signed,253 放不下(只能放到 127);這時的結果由實作決定。GCC 的做法是保留低 8 bits(0xFD),再照有號的 char 來讀,因此 value 成了 −3。

接著比較 value == 0xFD。比較之前,value 會先升成 int(C11 §6.3.1.1),這一步是 C 定的。在 STM32F401 上是 253 和 253 比,條件成立;在 x86-64 Linux 上是 −3 和 253 比,條件不成立。

想確認自己的 toolchain 怎麼定,可以問 compiler 預先定義了哪些巨集;查詢時要帶上專案實際使用的編譯選項:

arm-none-eabi-gcc -mcpu=cortex-m4 -dM -E - < /dev/null | grep __CHAR_UNSIGNED__

使用預設採 unsigned char 的 arm-none-eabi-gcc 時,會看到 #define __CHAR_UNSIGNED__ 1。若加上 -fsigned-char,這個巨集就不會定義。

如果把宣告改成 uint8_t value = 0xFD;,兩個平台都會印出 equal。uint8_t 一定是無號的 8-bit 型別,正負號從平台定的,變成了 C 定的。


想一想

{collapse}1. uint8_t u = 0x80; int8_t s = u; 執行後,s 是多少?這個答案是誰定的?
128 放不進 int8_t,轉成有號型別時值超出範圍,結果由實作決定(C11 §6.3.1.3)。GCC 保留低 8 bits(0x80),再照 int8_t 來讀,所以 s 是 −128。換一個 compiler,就要重查它的手冊。
{collapse}2. 用 ADDS 計算 0x80000000 + 0x80000000,結果和 C、V 旗標各是什麼?
結果是 0x00000000。當成無號數,2147483648 + 2147483648 = 2³²,超出 32 位元,所以 C = 1;當成有號數,兩個負數相加得到 0,正負號錯了,所以 V = 1。結果是 0,Z 也會是 1。

回到 0xFD 這個 byte,它是 253 還是 −3,取決於套上去的讀法。Byte 本身是空性的,讀法才讓它成為某個數;而這些讀法與運算的保證,有的來自 C,有的來自平台與編譯設定,也有 C 標準不作保證的情況。分得出自己依賴的是哪一種,就知道換了平台要重查什麼。if (value == 0xFD) 眼見不一定為真,我的眼睛業障重啊!是不是非常有趣呢?



本文屬於「補破網」專欄:由我和 AI 協作寫成,內容經把關後發佈。關於本專欄,請見〈補破網〉。

simen

An enthusiastic engineer with a passion for learning. After completing my academic journey, I worked as an engineer in Hsinchu Science Park. Later, I ventured into academia to teach at a university. However, I have now returned to the industry as an engineer, again.

張貼留言 (0)
較新的 較舊