接口契约与断言:用断言守护你的代码边界

做嵌入式开发这些年,我踩过最大的坑,往往不是算法多难、逻辑多复杂,而是——调用者传了个不该传的参数,而接收方毫无防备

你想想看,一个函数接收了 NULL 指针,然后直接解引用。轻则跑飞,重则整个系统挂掉。这种问题,说白了就是接口的边界没守住

今天我们就聊聊怎么用断言(assert)来做契约式设计。这不是什么新概念,但很多嵌入式工程师用得不够狠、不够系统。

什么是契约式设计?

契约式设计(Design by Contract)最早由 Bertrand Meyer 提出。核心思想很简单:每个函数都是一个契约

  • 前置条件(Precondition):调用者必须满足的条件。比如参数不能为 NULL、数值必须在有效范围内。
  • 后置条件(Postcondition):函数执行后必须保证的结果。比如返回值不能为 NULL、缓冲区已被清零。
  • 不变式(Invariant):在整个执行过程中始终成立的条件。比如链表不能成环、队列长度不能为负。

我个人习惯把这三者写在函数注释里,然后用断言在代码里强制执行

核心原则:断言检查的是不应该发生的错误,而不是可能发生的错误。

前者是 bug,后者是运行时异常(比如文件不存在、网络断开)。

断言 vs 错误处理:别搞混了

很多新手会问:断言和 if-else 有什么区别?

我举个例子你就明白了。

// 错误用法:用断言处理用户输入
void set_temperature(int temp) {
    assert(temp >= -40 && temp <= 125);  // ❌ 用户输入不可控
    // ...
}

// 正确用法:用 if 处理用户输入
void set_temperature(int temp) {
    if (temp < -40 || temp > 125) {
        log_error("Invalid temperature: %d", temp);
        return;
    }
    // ...
}

为什么会这样?因为断言在 release 版本里会被移除(通过定义 NDEBUG)。你想想看,如果用户输入依赖断言来检查,那发布后的产品就完全没有保护了。

场景 使用断言 使用 if-else
内部函数参数检查 ✅ 推荐 ❌ 冗余
外部输入校验 ❌ 危险 ✅ 必须
硬件状态检查 ✅ 调试用 ✅ 生产用
内存分配失败 ❌ 不可用 ✅ 必须

前置条件检查:把问题扼杀在入口

我在项目中遇到过最典型的场景:DMA 传输函数的缓冲区对齐检查

/**
 * @brief 启动 DMA 传输
 * @param buf  源缓冲区,必须 4 字节对齐
 * @param size 传输字节数,必须为 4 的倍数
 * @pre  buf != NULL
 * @pre  ((uint32_t)buf & 0x3) == 0
 * @pre  (size & 0x3) == 0
 * @pre  size > 0
 */
void dma_transfer(uint32_t *buf, uint32_t size) {
    // 前置条件断言
    assert(buf != NULL);
    assert(((uint32_t)buf & 0x3) == 0);
    assert((size & 0x3) == 0);
    assert(size > 0);

    // 实际传输逻辑...
}

你看,四个断言把调用者的所有可能违规行为都堵死了。如果有人在调试阶段传了未对齐的地址,程序会立刻崩溃,而不是在某个角落里静默地跑飞

我的习惯:前置条件断言放在函数最前面,紧跟在变量声明之后。这样阅读代码的人一眼就能看到「这个函数要求什么」。

后置条件检查:确保你承诺的做到了

后置条件往往被忽略。但说实话,后置条件才是契约的「交付物」

/**
 * @brief 计算 CRC32 校验值
 * @param data 输入数据
 * @param len  数据长度
 * @return CRC32 值
 * @post 返回值 != 0(对于非空输入)
 */
uint32_t crc32_calc(const uint8_t *data, uint32_t len) {
    assert(data != NULL);
    assert(len > 0);

    uint32_t crc = 0xFFFFFFFF;
    // ... CRC 计算过程 ...

    // 后置条件断言
    assert(crc != 0);  // 对于非空输入,CRC 不可能为全 0

    return crc;
}

我曾经在一个通信协议栈里加了一堆后置条件断言。结果调试阶段抓到了三次因为编译器优化导致寄存器被意外修改的 bug。嗯,这种问题靠看代码是看不出来的,但断言一跑就现原形。

不变式检查:在复杂状态机里保命

不变式适合用在数据结构操作状态机里。比如一个环形缓冲区,任何时候 headtail 都不能越界。

typedef struct {
    uint8_t *buffer;
    uint32_t size;
    uint32_t head;
    uint32_t tail;
} ring_buffer_t;

// 不变式检查函数
static inline void ring_buffer_invariant(const ring_buffer_t *rb) {
    assert(rb != NULL);
    assert(rb->buffer != NULL);
    assert(rb->size > 0);
    assert(rb->head < rb->size);
    assert(rb->tail < rb->size);
}

bool ring_buffer_push(ring_buffer_t *rb, uint8_t data) {
    // 进入时检查不变式
    ring_buffer_invariant(rb);

    if (ring_buffer_full(rb)) {
        return false;
    }

    rb->buffer[rb->head] = data;
    rb->head = (rb->head + 1) % rb->size;

    // 退出时检查不变式
    ring_buffer_invariant(rb);

    return true;
}

你看,每次操作前后都检查不变式。如果某个操作破坏了数据结构的一致性,断言会在第一时间告诉你,而不是等到系统崩溃才去排查。

断言的最佳实践:我踩过的坑

讲几个我亲身经历的教训。

教训一:不要在断言里写有副作用的表达式

// ❌ 危险!release 版本里这行代码会被移除
assert(do_something() == OK);

// ✅ 安全做法
int ret = do_something();
assert(ret == OK);

为什么?因为 NDEBUG 定义后,assert(do_something()) 整个被删掉了,do_something() 永远不会执行。我曾经在一个项目里看到同事这么写,结果 release 版本的功能完全不对,查了两天才找到原因。

教训二:区分调试断言和运行时检查

我建议在项目里定义两个宏:

// 调试断言:仅在 debug 版本生效
#define DBG_ASSERT(cond)  assert(cond)

// 运行时检查:始终生效
#define RUNTIME_CHECK(cond, msg) \
    do { \
        if (!(cond)) { \
            log_error("Runtime check failed: %s", msg); \
            error_handler(); \
        } \
    } while(0)

这样你就能清晰区分:哪些检查是「调试期抓 bug」用的,哪些是「生产期保命」用的。

教训三:断言失败后的处理要统一

默认的 assert() 会调用 abort(),这在嵌入式系统里可能不够友好。我一般会重写断言处理函数:

void my_assert_failed(const char *file, int line, const char *func) {
    // 记录错误信息到日志系统
    log_critical("Assertion failed: %s:%d in %s", file, line, func);

    // 保存现场信息,方便事后分析
    save_crash_dump();

    // 根据系统类型决定行为
    #ifdef SYSTEM_SAFE_MODE
        system_reset();  // 安全复位
    #else
        while(1);        // 死循环,等待调试器介入
    #endif
}

// 替换标准 assert
#undef assert
#define assert(cond) \
    do { \
        if (!(cond)) { \
            my_assert_failed(__FILE__, __LINE__, __func__); \
        } \
    } while(0)

重要提醒:在中断服务函数(ISR)里使用断言要格外小心。如果断言失败导致死循环,整个系统就卡死了。我建议在 ISR 里只用运行时检查,不要用断言。

契约式设计的完整示例

最后,给你看一个完整的模块接口设计。这是一个简单的定时器管理器

/**
 * @brief 注册一个定时器
 * @param timer_id  定时器 ID,必须在 [0, MAX_TIMERS) 范围内
 * @param callback  回调函数,不能为 NULL
 * @param interval  定时周期,单位 ms,必须大于 0
 * @param mode      定时模式,单次或周期
 * @pre  timer_id < MAX_TIMERS
 * @pre  callback != NULL
 * @pre  interval > 0
 * @pre  mode == TIMER_ONESHOT || mode == TIMER_PERIODIC
 * @post 定时器已注册但未启动
 * @post 定时器状态为 TIMER_STOPPED
 */
void timer_register(uint8_t timer_id, 
                    timer_callback_t callback,
                    uint32_t interval,
                    timer_mode_t mode) 
{
    // 前置条件检查
    assert(timer_id < MAX_TIMERS);
    assert(callback != NULL);
    assert(interval > 0);
    assert(mode == TIMER_ONESHOT || mode == TIMER_PERIODIC);

    // 检查该 ID 是否已被占用
    assert(timers[timer_id].state == TIMER_UNUSED);

    // 注册逻辑
    timers[timer_id].callback = callback;
    timers[timer_id].interval = interval;
    timers[timer_id].mode = mode;
    timers[timer_id].state = TIMER_STOPPED;

    // 后置条件检查
    assert(timers[timer_id].state == TIMER_STOPPED);
    assert(timers[timer_id].callback == callback);
}

你看,这个函数的契约非常清晰:

  • 调用者必须提供合法的参数
  • 函数保证定时器被正确注册
  • 任何违反契约的行为都会在调试阶段被立即发现

本章小结

契约式设计不是什么高深的理论,它就是一种编程纪律。用断言把接口的边界守好,你的代码就会更健壮、更容易调试。

记住三个要点:

  • 前置条件:调用者必须满足什么
  • 后置条件:函数保证什么
  • 不变式:整个过程中什么不能变

把这些写进代码里,用断言强制执行。相信我,你会少熬很多夜。

契约式设计核心流程 调用者 前置条件检查 assert(参数 != NULL) assert(范围合法) 函数执行 后置条件检查 assert(返回值 != NULL) assert(状态正确) 返回结果 不变式(贯穿始终) assert(数据结构一致性) — 每次进入和退出函数时检查 确保模块内部状态始终合法 契约式设计:前置条件 + 后置条件 + 不变式

公众号:蓝海资料掘金营,微信deep3321