[C 的那些眉角]assert 是你的好朋友 — 但要用對地方

有一種 bug,你看了半天程式碼,
覺得「這裡不可能出問題」,
但它就是出問題了。

然後你加了一堆 printf
把每個變數的值都印出來,
才發現某個「不可能是 NULL」的指標,
在某個罕見的情況下真的是 NULL。

如果當初在那裡加了 assert
程式會在第一時間告訴你問題在哪,
而不是讓錯誤默默蔓延,
最後在完全不相關的地方 crash。

assert 就是做這件事的。


assert 是什麼

最基本的用法:

#include <assert.h>

void process_packet(Packet *pkt) {
    assert(pkt != NULL); // 如果 pkt 是 NULL,程式立刻停止
    // ...
}

assert 的條件為 false,
程式會:

  1. 印出錯誤訊息(包含檔案名稱、行號、條件式)
  2. 呼叫 abort() 終止程式
Assertion failed: pkt != NULL, file main.c, line 42

清楚、直接、不囉嗦。


assert 適合用在哪裡

檢查「不應該發生」的情況

assert 是用來表達你對程式狀態的假設
不是用來處理正常的錯誤情況。

// ✅ 適合用 assert:
// 「這個函式的設計前提是 buf 不能是 NULL,
// 如果是 NULL 代表呼叫端有 bug」
void encode_data(const uint8_t *buf, size_t len) {
    assert(buf != NULL);
    assert(len > 0);
    assert(len <= MAX_PACKET_SIZE);
    // ...
}
// ❌ 不適合用 assert:
// 「使用者輸入的資料可能是空的,這是正常情況」
int parse_user_input(const char *input) {
    assert(input != NULL); // 不對,這應該用 if 檢查並回傳錯誤
    // ...
}

簡單的判斷標準:

  • 呼叫端的 bug → 用 assert
  • 正常的錯誤情況 → 用 if + 回傳錯誤碼

檢查函式的前置條件(Precondition)

/**
 * 計算兩點之間的距離
 * 前置條件:count >= 2
 */
float calculate_distance(const Point *points, size_t count) {
    assert(points != NULL);
    assert(count >= 2); // 少於兩點沒有意義,是呼叫端的錯
    // ...
}

檢查不變量(Invariant)

程式中某些條件應該「永遠為真」,
assert 來驗證這些假設:

void state_machine_update(StateMachine *sm, Event event) {
    assert(sm != NULL);
    assert(sm->state >= STATE_IDLE && sm->state < STATE_MAX);
    // 狀態值應該永遠在合法範圍內,不然是哪裡的邏輯有問題

    switch (sm->state) {
        // ...
    }

    // 更新完之後再檢查一次
    assert(sm->state >= STATE_IDLE && sm->state < STATE_MAX);
}

檢查「不應該走到」的 code path

typedef enum {
    MODE_READ = 0,
    MODE_WRITE = 1,
    MODE_EXEC = 2,
} AccessMode;

void handle_access(AccessMode mode) {
    switch (mode) {
        case MODE_READ: do_read(); break;
        case MODE_WRITE: do_write(); break;
        case MODE_EXEC: do_exec(); break;
        default:
            assert(0); // 不應該走到這裡,走到了代表有 bug
            break;
    }
}

assert(0) 是「這裡不應該被執行到」的慣用寫法,
比直接 return 或什麼都不做更明確。


assert 不適合用在哪裡

Release Build 會把 assert 拿掉

這是最重要的一點,很多人不知道。

標準的 assert 在定義了 NDEBUG 的情況下,
會被完全移除,什麼都不剩:

// 在 Release Build(定義了 NDEBUG)
assert(pkt != NULL);
// 上面這行等同於什麼都沒有

所以,不要把有副作用的程式碼放在 assert

// ❌ 非常危險
assert(init_hardware() == 0);
// Release Build 這行消失了,init_hardware() 根本沒被呼叫!

// ✅ 正確寫法
int ret = init_hardware();
assert(ret == 0);

不要用來處理執行時期的錯誤

// ❌ 不對,malloc 失敗是正常的執行時期錯誤,不是 bug
uint8_t *buf = malloc(256);
assert(buf != NULL);

// ✅ 正確寫法
uint8_t *buf = malloc(256);
if (buf == NULL) {
    LOG_ERROR("malloc failed");
    return ERR_NO_MEMORY;
}

malloc 失敗不是 bug,是正常情況,
要用 if 處理,不是 assert


嵌入式的 assert — 要自己做

標準的 assert 在嵌入式不一定好用,
因為它呼叫 abort()
而嵌入式的 abort() 行為不一定是你想要的。

更常見的做法是自己實作一個:

// assert_handler.h
#ifdef DEBUG

  #define ASSERT(cond) \
      do { \
          if (!(cond)) { \
              assert_failed(__FILE__, __LINE__, #cond); \
          } \
      } while (0)

#else

  #define ASSERT(cond) ((void)(cond)) // Release 保留求值但不檢查
  // 或是完全移除:
  // #define ASSERT(cond) do {} while (0)

#endif
// assert_handler.c
void assert_failed(const char *file, int line, const char *expr) {
    // 關中斷,避免在損壞的狀態下繼續執行
    __disable_irq();

    // 印出錯誤資訊(如果 UART 還能用的話)
    log_error("ASSERT FAILED: %s, line %d: %s", file, line, expr);

    // 讓 debugger 可以在這裡停下來
    // 如果有 debugger 連接,會觸發斷點
#ifdef DEBUG
    __BKPT(0);
#endif

    // 重啟系統
    NVIC_SystemReset();
}

這樣做的好處:

  • 可以在 assert 失敗時印出 log
  • 可以讓 debugger 在這裡停下來
  • 可以決定要 reset 還是 hang 住
  • Debug / Release build 的行為可以分開控制

搭配 static_assert 做編譯期檢查

C11 之後有 static_assert(或 _Static_assert),
可以在編譯時期就抓出問題:

#include <assert.h>

// 確保結構體大小符合預期(例如要對齊到 4 bytes)
static_assert(sizeof(PacketHeader) == 8,
              "PacketHeader size mismatch, check padding");

// 確保 enum 的數量沒有超過陣列大小
static_assert(STATE_MAX <= 16,
              "Too many states, increase lookup table size");

// 確保型別大小符合假設
static_assert(sizeof(uint32_t) == 4,
              "uint32_t is not 4 bytes on this platform");

static_assert 失敗的話,編譯就過不了
不需要等到執行時期才發現問題。

這個我覺得非常好用,
特別是跨平台的程式碼,
可以確保型別大小、結構體佈局等假設在目標平台上成立。


一個實際的使用模式

把前置條件檢查、不變量檢查整合在一起:

typedef struct {
    uint8_t *data;
    size_t len;
    size_t capacity;
    bool is_valid;
} Buffer;

// 檢查 Buffer 的不變量
static void buffer_check_invariants(const Buffer *buf) {
    assert(buf != NULL);
    assert(buf->len <= buf->capacity);
    assert(buf->capacity == 0 || buf->data != NULL);
    assert(buf->is_valid == true || buf->len == 0);
}

int buffer_append(Buffer *buf, const uint8_t *data, size_t len) {
    // 前置條件
    assert(buf != NULL);
    assert(data != NULL);
    assert(len > 0);
    buffer_check_invariants(buf); // 進來時狀態要合法

    if (buf->len + len > buf->capacity) {
        return ERR_BUFFER_FULL; // 這是正常錯誤,不是 assert
    }

    memcpy(buf->data + buf->len, data, len);
    buf->len += len;

    buffer_check_invariants(buf); // 出去時狀態也要合法
    return OK;
}

這種寫法在 debug build 下,
每次呼叫 buffer_append 都會驗證 Buffer 的狀態,
任何違反假設的情況都會立刻被抓到。

Release build 把 assert 拿掉之後,
效能完全不受影響。


說實話

我以前很少用 assert
覺得「這段程式碼我很確定不會有問題,加 assert 幹嘛」。

後來被幾個「不可能出問題的地方」打臉之後,
才開始認真用。

現在的習慣是,每個函式的入口,
把我對參數的假設都用 assert 寫出來。
這不只是防禦,也是一種文件,
讓讀程式碼的人(包括未來的我)知道這個函式的使用前提。

但有一點要注意:
assert 加太多也會讓程式碼變得很吵,
要加在真正重要的假設上,
不是每一行都加。

判斷標準很簡單:
「如果這個條件不成立,代表某個地方有 bug,
而不是正常的錯誤情況。」
符合這個標準的,就加 assert


這篇的 Checklist

  • [ ] assert 只用來檢查「不應該發生的情況」,正常錯誤用 if 處理
  • [ ] assert 裡面沒有放有副作用的程式碼
  • [ ] 嵌入式有自訂 assert handler,行為符合需求
  • [ ] 用 static_assert 驗證編譯期的假設(型別大小、結構體大小)
  • [ ] 函式入口的前置條件有用 assert 標示清楚
  • [ ] switchdefault 有加 assert(0)
  • [ ] Release build 的 assert 行為有確認過(是移除還是保留)
分類: 程式相關,標籤: , , , , , , , , , , , , 。這篇內容的永久連結

發佈留言

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