剛開始寫 C 的時候,我的錯誤處理大概是這樣:
void init_device(void) {
i2c_init();
sensor_init();
uart_init();
// 完成,應該沒問題吧?
}
回傳 void,裡面每個函式的回傳值都不管。
反正在開發板上跑都正常,就這樣出貨了。
然後客戶回報說裝置偶爾會初始化失敗,
但 log 完全看不出來哪個步驟出問題,
因為根本沒有任何錯誤處理。
那次之後,我開始認真思考:錯誤處理不是可選的,是必須的。
最常見的壞習慣
1. 完全不處理回傳值
// ❌ 呼叫了但不管結果
malloc(256);
fwrite(buf, 1, len, fp);
pthread_mutex_lock(&mutex);
這些函式都可能失敗。
不檢查回傳值,就是假設它們永遠成功。
在嵌入式環境,記憶體不足、硬體異常、timeout,
這些情況比你想的更常發生。
2. 回傳值設計隨意
// ❌ 這個函式失敗的時候回傳什麼?
int read_sensor(float *value) {
// 成功回傳 0
// 失敗回傳... -1? 1? 99?
}
沒有一致的規則,呼叫端不知道怎麼判斷,
久了就乾脆不判斷了。
3. 錯誤被吞掉
// ❌ 錯誤發生了,但沒有往上傳
int process_data(void) {
if (read_sensor(&value) != 0) {
return 0; // 假裝成功?
}
// ...
}
這種最難 debug,因為錯誤在某一層被靜默吃掉,
上層完全不知道發生了什麼事。
建立一致的錯誤碼系統
我在嵌入式專案裡習慣用這種方式定義錯誤碼:
// error_code.h
typedef enum {
ERR_OK = 0, // 成功
ERR_FAIL = -1, // 一般性失敗
ERR_INVALID_PARAM = -2, // 參數錯誤
ERR_TIMEOUT = -3, // 逾時
ERR_NOT_READY = -4, // 裝置未就緒
ERR_NO_MEMORY = -5, // 記憶體不足
ERR_BUSY = -6, // 資源忙碌中
ERR_NOT_SUPPORTED = -7, // 功能不支援
ERR_OVERFLOW = -8, // 緩衝區溢位
ERR_CHECKSUM = -9, // 校驗碼錯誤
} ErrorCode;
幾個設計原則:
- 成功永遠是 0,這是 C 的慣例,也跟 Linux 系統呼叫一致
- 錯誤用負值,這樣可以跟「有效的正整數回傳值」共存
- 用 enum 不用
#define,debugger 可以顯示名稱 - 錯誤碼要有意義,不是只有
ERR_FAIL一個
幾種常見的回傳值設計模式
模式一:純錯誤碼
最簡單,函式只回傳成功或失敗,
實際的結果透過指標參數傳出:
ErrorCode sensor_read_temperature(float *temperature) {
if (temperature == NULL) {
return ERR_INVALID_PARAM;
}
uint8_t raw[2];
if (i2c_read(SENSOR_ADDR, raw, sizeof(raw)) != ERR_OK) {
return ERR_TIMEOUT;
}
*temperature = ((raw[0] << 8) | raw[1]) / 100.0f;
return ERR_OK;
}
呼叫端:
float temp;
ErrorCode err = sensor_read_temperature(&temp);
if (err != ERR_OK) {
LOG_ERROR("sensor read failed: %d", err);
return err; // 往上傳,不要吞掉
}
這是我最常用的模式,清楚、一致、容易測試。
模式二:回傳值帶資料,負值代表錯誤
適合回傳值本身就是有意義的數字:
// 成功回傳讀到的位元組數(>= 0)
// 失敗回傳負值錯誤碼
int uart_receive(uint8_t *buf, size_t buf_size, uint32_t timeout_ms) {
if (buf == NULL || buf_size == 0) {
return ERR_INVALID_PARAM;
}
// ... 實作 ...
if (timeout) {
return ERR_TIMEOUT;
}
return bytes_received; // 正整數
}
呼叫端:
int result = uart_receive(buf, sizeof(buf), 1000);
if (result < 0) {
LOG_ERROR("uart receive error: %d", result);
return result;
}
// result 是實際收到的位元組數
process_data(buf, result);
Linux 系統呼叫大量使用這個模式,
習慣了之後很直覺。
模式三:用結構體回傳結果
當需要同時回傳錯誤碼和資料,
又不想用指標參數的時候:
typedef struct {
ErrorCode err;
float value;
} SensorResult;
SensorResult sensor_read_temperature(void) {
SensorResult result = { .err = ERR_OK, .value = 0.0f };
uint8_t raw[2];
if (i2c_read(SENSOR_ADDR, raw, sizeof(raw)) != ERR_OK) {
result.err = ERR_TIMEOUT;
return result;
}
result.value = ((raw[0] << 8) | raw[1]) / 100.0f;
return result;
}
呼叫端:
SensorResult r = sensor_read_temperature();
if (r.err != ERR_OK) {
LOG_ERROR("sensor error: %d", r.err);
return r.err;
}
use_temperature(r.value);
這個模式在 C 裡不太常見,但有時候比指標參數更清楚。
C++ 的 std::optional 或 Rust 的 Result<T, E> 就是這個概念。
錯誤要往上傳,不要吞掉
這是我覺得最重要的一點。
// ❌ 錯誤被吞掉,上層不知道發生了什麼
int init_system(void) {
if (sensor_init() != ERR_OK) {
LOG_ERROR("sensor init failed");
// 然後呢?繼續跑?
}
if (network_init() != ERR_OK) {
LOG_ERROR("network init failed");
// 一樣繼續?
}
return ERR_OK; // 明明有錯誤,還是回傳成功
}
// ✅ 錯誤往上傳
int init_system(void) {
ErrorCode err;
err = sensor_init();
if (err != ERR_OK) {
LOG_ERROR("sensor init failed: %d", err);
return err;
}
err = network_init();
if (err != ERR_OK) {
LOG_ERROR("network init failed: %d", err);
return err;
}
return ERR_OK;
}
當然,不是所有錯誤都要讓整個系統停下來。
有些錯誤是可以 retry 的,有些是可以降級處理的。
但這是業務邏輯的決定,應該在夠高的層級做判斷,
不是在底層函式裡默默吞掉。
嵌入式的特殊考量
不要在 ISR 裡做複雜的錯誤處理
中斷服務函式要越短越好,
錯誤處理留給 main loop:
// ISR 只設 flag
void UART_IRQHandler(void) {
if (UART->SR & UART_SR_ORE) {
// Overrun error
g_uart_error_flag = true;
UART->SR &= ~UART_SR_ORE; // 清除 flag
}
// 不要在這裡做複雜處理
}
// Main loop 處理錯誤
void uart_task(void) {
if (g_uart_error_flag) {
g_uart_error_flag = false;
handle_uart_overrun();
}
}
硬體操作失敗要考慮 retry
感測器偶爾 NAK、I2C 偶爾 timeout,
這些在嵌入式很常見,不一定是真的壞掉:
ErrorCode sensor_read_with_retry(float *value) {
for (int i = 0; i < MAX_RETRY; i++) {
ErrorCode err = sensor_read_temperature(value);
if (err == ERR_OK) {
return ERR_OK;
}
LOG_WARN("sensor read failed (attempt %d/%d): %d",
i + 1, MAX_RETRY, err);
delay_ms(10);
}
LOG_ERROR("sensor read failed after %d retries", MAX_RETRY);
return ERR_TIMEOUT;
}
但 retry 的邏輯要在適當的層級,
不是每個底層函式都自己 retry,
不然錯誤真的發生的時候,你會等很久才知道。
說實話
完整的錯誤處理,寫起來真的很囉嗦。
每個函式呼叫後面都要 if (err != ERR_OK),
程式碼的「正常流程」被一堆錯誤檢查淹沒,
有時候讀起來比較像在讀錯誤處理,不是在讀業務邏輯。
我也沒有完美的解法,這是 C 語言本身的限制。
但我的底線是:至少要知道哪裡出錯了。
就算不能優雅地處理,至少要有 log,
讓你在 debug 的時候有線索可以追。
一個沉默失敗的系統,比一個大聲報錯的系統難 debug 一百倍。
這篇的 Checklist
- [ ] 函式回傳值有一致的設計(成功 0,錯誤負值)
- [ ] 錯誤碼用 enum 定義,有意義的名稱
- [ ] 每個函式呼叫的回傳值都有檢查
- [ ] 錯誤有往上傳,沒有被靜默吞掉
- [ ] 錯誤發生時有 log,包含錯誤碼
- [ ] Retry 邏輯在適當的層級,不是每層都 retry
- [ ] ISR 裡的錯誤用 flag 傳給 main loop 處理