有一個 bug,我在 code review 的時候無意間看到,
當下覺得「這不會有問題吧」,
但仔細算了一下,發現真的會出問題。
uint16_t a = 60000;
uint16_t b = 10000;
uint16_t sum = a + b; // 你覺得 sum 是多少?
答案不是 70000。
uint16_t 最大值是 65535,
70000 超過了,發生溢位,
sum 實際上是 70000 - 65536 = 4464。
程式繼續跑,用著這個錯誤的值,
後續的計算全部都錯了,
但不會 crash,不會有任何警告。
這就是整數溢位的恐怖之處。
整數溢位的兩種情況
Unsigned Overflow(無號整數溢位)
無號整數溢位在 C 語言是定義行為,
結果是對 $$2^N$$ 取模(N 是位元數):
uint8_t x = 255;
x = x + 1; // 256 % 256 = 0,x 變成 0
uint16_t y = 65535;
y = y + 1; // 65536 % 65536 = 0,y 變成 0
有時候這是刻意的(例如計時器的 wrap-around),
但如果不是刻意的,就是 bug。
Signed Overflow(有號整數溢位)
有號整數溢位在 C 語言是未定義行為(Undefined Behavior),
這比無號溢位更危險:
int8_t x = 127;
x = x + 1; // 未定義行為!
// 在大部分平台上結果是 -128,但 C 標準不保證
「未定義行為」代表編譯器可以做任何事,
包括假設這種情況不會發生,
然後做出你意想不到的優化。
為什麼嵌入式特別容易踩
原因一:型別比你想的小
在 32-bit 的 PC 上,int 是 32 bits,
很多計算不容易溢位。
在 8-bit MCU(例如 AVR),int 是 16 bits,
uint8_t 更只有 8 bits,
稍微大一點的數字就溢位了。
// 在 8-bit MCU 上
uint8_t sensor_value = 200;
uint8_t threshold = 100;
// 看起來沒問題,但...
uint8_t diff = sensor_value - threshold; // 100,正確
// 如果順序反了
uint8_t diff2 = threshold - sensor_value;
// 100 - 200 = -100
// uint8_t 是無號的,-100 變成 256 - 100 = 156
// diff2 = 156,完全錯誤
原因二:計算中間值溢位
// 計算 ADC 的電壓值
uint16_t adc_raw = 4000; // 12-bit ADC,最大 4095
uint16_t vref_mv = 3300; // 參考電壓 3300mV
// ❌ 中間值溢位
// adc_raw * vref_mv = 4000 * 3300 = 13,200,000
// uint16_t 最大 65535,直接溢位
uint16_t voltage_mv = (adc_raw * vref_mv) / 4096;
// ✅ 先轉型再計算
uint32_t voltage_mv = ((uint32_t)adc_raw * vref_mv) / 4096;
// 13,200,000 在 uint32_t 的範圍內(最大約 42 億)
這個問題超級常見,
特別是 ADC 計算、感測器校正、濾波器係數這類地方。
原因三:長度和大小的計算
// 計算封包總長度
uint8_t header_len = 8;
uint8_t payload_len = 200;
uint8_t total_len = header_len + payload_len;
// 8 + 200 = 208,uint8_t 最大 255,這次剛好沒事
// 但如果 payload_len = 250
uint8_t total_len2 = header_len + payload_len;
// 8 + 250 = 258,uint8_t 溢位,total_len2 = 2
// 然後你用 total_len2 = 2 去分配記憶體或做其他事,全錯了
原因四:比較運算的陷阱
int8_t signed_val = -1;
uint8_t unsigned_val = 200;
// ❌ 有號和無號混合比較
if (signed_val < unsigned_val) {
// 你以為 -1 < 200,這個 if 應該成立
// 但 C 會把 signed_val 轉成 uint8_t
// -1 轉成 uint8_t 變成 255
// 255 < 200 是 false,if 不成立!
}
有號和無號混合比較,
C 會做隱式型別轉換,
結果往往不是你預期的。
開啟 -Wsign-compare 警告可以抓到這類問題。
怎麼防禦
方法一:計算前先想清楚型別
在做乘法或加法之前,
先想一下中間值的範圍,
確保不會超過型別的上限:
// 先問自己:
// adc_raw 最大多少?4095
// vref_mv 最大多少?3300
// 相乘最大多少?4095 * 3300 = 13,513,500
// uint16_t 夠嗎?不夠(最大 65535)
// uint32_t 夠嗎?夠(最大約 42 億)
uint32_t voltage_mv = ((uint32_t)adc_raw * vref_mv) / 4096;
養成習慣,乘法之前先做這個心算。
方法二:明確轉型,不依賴隱式轉換
uint8_t a = 200;
uint8_t b = 100;
// ❌ 依賴隱式轉換,行為不明確
uint16_t result = a * b;
// a * b 的計算是在 uint8_t 還是 int 裡做?
// 取決於平台和編譯器
// ✅ 明確轉型
uint16_t result = (uint16_t)a * (uint16_t)b;
// 明確告訴編譯器在 uint16_t 的範圍內計算
方法三:溢位前先檢查
如果需要確保計算不溢位,
可以在計算前先做範圍檢查:
#include <stdint.h>
#include <limits.h>
// 安全的加法,溢位時回傳錯誤
bool safe_add_u16(uint16_t a, uint16_t b, uint16_t *result) {
if (a > UINT16_MAX - b) {
// 會溢位
return false;
}
*result = a + b;
return true;
}
// 安全的乘法
bool safe_mul_u16(uint16_t a, uint16_t b, uint16_t *result) {
if (b != 0 && a > UINT16_MAX / b) {
// 會溢位
return false;
}
*result = a * b;
return true;
}
使用:
uint16_t total;
if (!safe_add_u16(header_len, payload_len, &total)) {
LOG_ERROR("length overflow");
return ERR_INVALID_LENGTH;
}
方法四:C 的整數提升規則要知道
C 在做運算的時候,
會把比 int 小的型別自動提升到 int,
這叫做「整數提升(Integer Promotion)」:
uint8_t a = 200;
uint8_t b = 200;
// a + b 的計算過程:
// 1. a 和 b 先被提升到 int(32-bit 平台上是 32-bit int)
// 2. 在 int 的範圍內計算:200 + 200 = 400
// 3. 結果存回 uint8_t:400 % 256 = 144
uint8_t result = a + b; // result = 144,不是 400
整數提升讓計算在 int 的範圍內進行,
避免了一些溢位,
但最後存回小型別的時候還是會截斷。
知道這個規則,可以幫你理解一些看起來奇怪的行為。
方法五:開啟編譯器警告
gcc -Wall -Wextra -Wconversion -Wsign-compare your_code.c
-Wconversion:隱式型別轉換可能改變值時警告-Wsign-compare:有號和無號混合比較時警告
這兩個警告會產生很多訊息,
但值得花時間處理,
很多潛在的溢位問題都能被抓到。
方法六:UBSan(Undefined Behavior Sanitizer)
專門用來抓有號整數溢位(Undefined Behavior):
gcc -fsanitize=undefined -fsanitize=signed-integer-overflow \
-g your_code.c -o your_program
./your_program
runtime error: signed integer overflow: 127 + 1 cannot be
represented in type 'signed char'
一個實際踩過的案例
之前在做一個溫度感測器的專案,
MCU 是 STM32,
溫度值用 int16_t 存,單位是 0.01°C。
計算平均溫度:
int16_t temps[8] = { 2500, 2510, 2490, 2505, 2495, 2508, 2502, 2498 };
// ❌ 有問題的寫法
int16_t sum = 0;
for (int i = 0; i < 8; i++) {
sum += temps[i]; // 2500 * 8 = 20000,int16_t 最大 32767,剛好沒事
}
int16_t avg = sum / 8;
這次剛好沒溢位,因為數值不大。
但後來需求改了,溫度範圍擴大到 -40°C 到 125°C,
單位還是 0.01°C,
最大值變成 12500,8 個加起來是 100000,
超過 int16_t 的 32767,溢位了。
// ✅ 正確寫法,用更大的型別做累加
int32_t sum = 0;
for (int i = 0; i < 8; i++) {
sum += temps[i];
}
int16_t avg = (int16_t)(sum / 8);
改動很小,但差別很大。
這個 bug 在需求沒有改之前完全不會出現,
需求改了之後才踩到,
而且症狀是平均溫度值莫名其妙是錯的,
不會 crash,很難發現。
說實話
整數溢位是那種「知道了之後覺得很簡單,
但寫程式的時候很容易忽略」的問題。
特別是在趕進度的時候,
看到一個加法或乘法,
不會特別停下來想「這個會不會溢位」。
我現在的習慣是,
每次看到乘法,就自動問自己:
「這兩個數字最大可能是多少?相乘之後超過型別上限嗎?」
乘法是最容易溢位的,
加法和減法次之,
除法通常不會溢位(但要注意除以零)。
另外,uint8_t 和 uint16_t 的減法要特別小心,
無號整數相減,如果結果是負數,
會 wrap-around 變成一個很大的正數,
這個坑我踩過不只一次。
這篇的 Checklist
- [ ] 乘法之前確認中間值不會超過型別上限
- [ ] 需要大範圍計算時,先轉型到
uint32_t或int32_t - [ ] 無號整數相減之前,確認被減數大於減數
- [ ] 有號和無號混合比較有用
-Wsign-compare警告檢查 - [ ] 來自外部的數值(封包長度、感測器值)在計算前有做範圍驗證
- [ ] 有開
-Wconversion警告,處理隱式型別轉換 - [ ] 有用 UBSan 跑過,確認沒有有號整數溢位
- [ ] 累加運算用比元素型別更大的型別做中間值