[C 的那些眉角]回傳值不要亂丟 — 錯誤處理的設計

剛開始寫 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 處理
分類: 程式相關,標籤: , , , , , , , , , 。這篇內容的永久連結

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *