有一種 bug,你看了半天程式碼,
覺得「這裡不可能出問題」,
但它就是出問題了。
然後你加了一堆 printf,
把每個變數的值都印出來,
才發現某個「不可能是 NULL」的指標,
在某個罕見的情況下真的是 NULL。
如果當初在那裡加了 assert,
程式會在第一時間告訴你問題在哪,
而不是讓錯誤默默蔓延,
最後在完全不相關的地方 crash。
assert 就是做這件事的。
有一種 bug,你看了半天程式碼,
覺得「這裡不可能出問題」,
但它就是出問題了。
然後你加了一堆 printf,
把每個變數的值都印出來,
才發現某個「不可能是 NULL」的指標,
在某個罕見的情況下真的是 NULL。
如果當初在那裡加了 assert,
程式會在第一時間告訴你問題在哪,
而不是讓錯誤默默蔓延,
最後在完全不相關的地方 crash。
assert 就是做這件事的。
一聽到 Stack Overflow 這個名字,
大部分工程師第一個想到的是那個問答網站。
但在嵌入式開發,Stack Overflow 是一個真實會發生的災難,
而且症狀往往讓你完全摸不著頭緒。
程式跑著跑著突然 reset,
某個全域變數的值莫名其妙被改掉,
函式回傳之後跳到奇怪的位址,
或是程式直接進入 HardFault Handler,
然後你盯著 register dump 發愁。
這些症狀背後,很多時候都是同一個原因:
Stack 被寫爆了。
平常在嵌入式系統上用 malloc,
寫完之後覺得很爽,動態配置記憶體,好像很厲害。
uint8_t *buf = malloc(1024);
memset(buf, 0, 1024);
// 開始用 buf...
有一次朋友看了一眼問我:「malloc 失敗怎麼辦?」
我說:「會失敗嗎?記憶體應該夠吧?」
他說:「嵌入式的 heap 就那麼大,你確定嗎?」
我 ........ 當然不是很確定。
有一種 bug,我只要想到就頭皮發麻。
明明程式跑得好好的,突然在某個完全不相關的地方 crash,
或是資料莫名其妙被改掉,
或是在開發機上完全正常,到了產品上偶爾出問題。
很多時候,追到最後都是同一個兇手:
懸空指標(Dangling Pointer)。
話說有一種 bug 很特別。
不是邏輯寫錯,不是演算法有問題,
而是呼叫端用錯了你的函式。
參數順序傳反了、單位搞錯了、忘記先初始化就呼叫、
buffer 大小傳錯了……
這種 bug 有時候很難發現,因為程式可能還是跑起來,
只是結果不對,或是偶爾 crash。
我以前遇到這種情況,第一反應是「呼叫端的問題,他用錯了」。
但後來慢慢體會到:如果很多人都用錯,問題通常在介面設計,不在使用者。
const 這個關鍵字,我用了很久都只會這樣寫:
const int MAX_SIZE = 256;
然後在函式參數上偶爾加一下,
覺得「這樣比較專業」,但其實不太確定為什麼要加。
直到有一次,同事在 code review 留言:
「這個參數應該加
const,你這樣寫呼叫端不知道你會不會改它。」
我才開始認真研究 const 在參數傳遞上到底是什麼意思。
結果發現,const 跟指標放在一起,
光是位置不同,意思就完全不一樣。
剛開始寫 C 的時候,我的錯誤處理大概是這樣:
void init_device(void) {
i2c_init();
sensor_init();
uart_init();
// 完成,應該沒問題吧?
}
回傳 void,裡面每個函式的回傳值都不管。
反正在開發板上跑都正常,就這樣出貨了。
然後客戶回報說裝置偶爾會初始化失敗,
但 log 完全看不出來哪個步驟出問題,
因為根本沒有任何錯誤處理。
那次之後,我開始認真思考:錯誤處理不是可選的,是必須的。
「一個函式只做一件事。」
這句話我很早就聽過,覺得自己懂了,然後繼續寫出這種東西:
int process_sensor_data(void) {
// 讀取感測器
uint8_t raw[8];
i2c_read(SENSOR_ADDR, raw, sizeof(raw));
// 解析資料
int16_t temp = (raw[0] << 8) | raw[1];
int16_t humidity = (raw[2] << 8) | raw[3];
// 換算單位
float temp_c = temp / 100.0f;
float humi_pct = humidity / 100.0f;
// 檢查是否超過閾值
if (temp_c > 85.0f || humi_pct > 95.0f) {
trigger_alarm();
}
// 存到全域變數
g_temperature = temp_c;
g_humidity = humi_pct;
// 發送到 server
mqtt_publish("sensor/data", temp_c, humi_pct);
return 0;
}
這個函式做了幾件事?
讀取、解析、換算、判斷、存值、發送。六件事。
當時覺得「這樣寫很方便,一個函式搞定所有事情」。
直到需要修改的時候才發現,牽一髮動全身,
改個閾值要找半天,加個錯誤處理不知道要加在哪裡,
單元測試根本不知道從哪裡下手。
剛學 C 的時候,老師說過:「變數要記得初始化。」
我點點頭,然後還是繼續寫:
int count;
char buf[64];
int *ptr;
反正當下程式跑起來好像也沒問題。
直到有一天,發現程式出現一個「偶發性」的奇怪行為。
有時候正常,有時候不正常,完全沒有規律。
#define 可以說是 C 語言裡最早學到的東西之一。
#define MAX_SIZE 256
#define PI 3.14159
看起來很無害,對吧?
但用久了才發現,#define 其實是個很容易出事的工具。
它不是變數、不是函式、不遵守 scope、不做型別檢查,
就是單純的文字替換。
而「單純的文字替換」,在某些情況下會產生你完全沒預期到的結果。
這篇記錄幾個我自己踩過、或在 code review 看過的坑。