有一種 bug,你看了半天程式碼,
覺得「這裡不可能出問題」,
但它就是出問題了。
然後你加了一堆 printf,
把每個變數的值都印出來,
才發現某個「不可能是 NULL」的指標,
在某個罕見的情況下真的是 NULL。
如果當初在那裡加了 assert,
程式會在第一時間告訴你問題在哪,
而不是讓錯誤默默蔓延,
最後在完全不相關的地方 crash。
assert 就是做這件事的。
assert 是什麼
最基本的用法:
#include <assert.h>
void process_packet(Packet *pkt) {
assert(pkt != NULL); // 如果 pkt 是 NULL,程式立刻停止
// ...
}
當 assert 的條件為 false,
程式會:
- 印出錯誤訊息(包含檔案名稱、行號、條件式)
- 呼叫
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裡面沒有放有副作用的程式碼 - [ ] 嵌入式有自訂
asserthandler,行為符合需求 - [ ] 用
static_assert驗證編譯期的假設(型別大小、結構體大小) - [ ] 函式入口的前置條件有用
assert標示清楚 - [ ]
switch的default有加assert(0) - [ ] Release build 的
assert行為有確認過(是移除還是保留)