7、断言与调试:assert宏的使用,自定义断言实现,调试模式与发布模式切换

调试信息管理,说白了就是给代码装上一双「眼睛」。你写代码的时候,能看见变量怎么变、逻辑怎么走。但发布之后,这些「眼睛」就得闭上,不然用户看到的全是乱码。

今天聊的断言,就是这双眼睛的核心。我个人习惯把断言叫做「代码的安检员」——它不负责业务逻辑,只负责检查「你觉得不可能发生的事情」。

7.1 assert宏:标准库里的安检员

C标准库提供了assert.h,里面就一个宏:assert(expression)。它的逻辑极其简单:

  • 如果表达式为真(非0),啥也不干
  • 如果表达式为假(0),打印错误信息,然后调用abort()终止程序

来看个例子:

#include <assert.h>
#include <stdio.h>

int divide(int a, int b) {
    assert(b != 0);  // 除数不能为0
    return a / b;
}

int main() {
    printf("%d\n", divide(10, 2));  // 正常
    printf("%d\n", divide(10, 0));  // 触发断言
    return 0;
}

运行到第二个divide时,程序会直接崩掉,并打印类似这样的信息:

Assertion failed: b != 0, file test.c, line 5

嗯,这里要注意:assert发布模式(NDEBUG定义时)会被完全移除。也就是说,你加再多断言,发布版本里一个都不会执行。这是标准行为,也是设计者的初衷——调试时严格检查,发布时追求性能。

⚠️ 警告:千万不要在assert里写有副作用的表达式!比如assert(++x > 0),调试版和发布版行为不一致,这种bug极难排查。

7.2 自定义断言:为什么需要自己造轮子?

标准assert够用吗?说实话,大部分场景够。但我在项目中遇到过几次「不够用」的情况:

  • 我想在断言失败时记录日志,而不是直接崩掉
  • 我想在嵌入式系统里输出到串口,而不是标准错误
  • 我想在断言失败时执行一些清理操作(比如关闭文件、释放内存)

这时候,就得自己写一个断言宏了。

一个基本的自定义断言长这样:

#include <stdio.h>
#include <stdlib.h>

#define MY_ASSERT(expr) \
    do { \
        if (!(expr)) { \
            fprintf(stderr, "ASSERT FAILED: %s, file %s, line %d\n", \
                    #expr, __FILE__, __LINE__); \
            abort(); \
        } \
    } while(0)

int main() {
    int x = 0;
    MY_ASSERT(x != 0);  // 会触发
    return 0;
}

这里用了do { ... } while(0)包裹,这是个经典技巧。为什么?因为宏展开后,如果外面有if语句,不加这个包裹会导致语法错误。你想想看:

// 错误的宏定义
#define BAD_ASSERT(expr) if (!(expr)) abort()

if (condition)
    BAD_ASSERT(x > 0);  // 展开后:if (condition) if (!(x>0)) abort();
else
    printf("ok");       // 这个else匹配到哪个if?

do-while包裹后,宏就是一个完整的语句,不会出现这种问题。

💡 技巧:自定义断言里可以用#expr把表达式转成字符串打印出来,方便定位。这是C预处理器提供的「字符串化」操作。

7.3 增强版自定义断言:带日志和回调

我曾经在一个嵌入式项目里,需要断言失败时把错误信息通过串口发出去,同时记录到EEPROM里。标准assert做不到,所以我写了个增强版:

#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>

// 断言失败时的回调函数指针
typedef void (*assert_fail_handler_t)(const char* expr, const char* file, int line);

static assert_fail_handler_t g_assert_handler = NULL;

void set_assert_handler(assert_fail_handler_t handler) {
    g_assert_handler = handler;
}

// 默认的断言失败处理
static void default_assert_fail(const char* expr, const char* file, int line) {
    fprintf(stderr, "[ASSERT] %s failed at %s:%d\n", expr, file, line);
    // 这里可以加日志记录、串口输出等
    abort();
}

#define ASSERT(expr) \
    do { \
        if (!(expr)) { \
            if (g_assert_handler) { \
                g_assert_handler(#expr, __FILE__, __LINE__); \
            } else { \
                default_assert_fail(#expr, __FILE__, __LINE__); \
            } \
        } \
    } while(0)

// 使用示例
void my_handler(const char* expr, const char* file, int line) {
    printf("自定义处理: %s at %s:%d\n", expr, file, line);
    // 不abort,只记录
}

int main() {
    set_assert_handler(my_handler);
    int val = -1;
    ASSERT(val > 0);  // 触发自定义处理
    printf("程序继续运行\n");
    return 0;
}

这个版本的好处是:你可以注册自己的处理函数,断言失败时是终止、记录、还是重启,都由你说了算。

7.4 调试模式与发布模式切换

这是实际工程里必须面对的问题。调试时断言全开,发布时断言全关。标准做法是用预处理宏控制:

// debug_config.h
#pragma once

// 调试模式开关:1为调试,0为发布
#define DEBUG_MODE 1

#if DEBUG_MODE
    // 调试模式:断言启用
    #define ASSERT(expr) \
        do { \
            if (!(expr)) { \
                fprintf(stderr, "ASSERT: %s at %s:%d\n", \
                        #expr, __FILE__, __LINE__); \
                abort(); \
            } \
        } while(0)
    
    // 调试日志宏
    #define DEBUG_LOG(fmt, ...) \
        fprintf(stderr, "[DEBUG] %s:%d: " fmt "\n", \
                __FILE__, __LINE__, ##__VA_ARGS__)
#else
    // 发布模式:断言和日志全部消失
    #define ASSERT(expr)          ((void)0)
    #define DEBUG_LOG(fmt, ...)   ((void)0)
#endif

使用的时候,只需要在编译时定义DEBUG_MODE为0或1:

// 调试编译
gcc -DDEBUG_MODE=1 -o test test.c

// 发布编译
gcc -DDEBUG_MODE=0 -o test test.c

或者更常见的做法,用NDEBUG宏:

#ifndef NDEBUG
    // 调试模式
    #define ASSERT(expr) \
        do { \
            if (!(expr)) { \
                fprintf(stderr, "ASSERT: %s at %s:%d\n", \
                        #expr, __FILE__, __LINE__); \
                abort(); \
            } \
        } while(0)
#else
    // 发布模式
    #define ASSERT(expr) ((void)0)
#endif

编译时加-DNDEBUG就是发布模式,不加就是调试模式。这是C标准库assert.h的惯用做法,咱们直接沿用。

🔑 核心原则:调试代码(断言、日志)必须零成本地嵌入到发布版本中。用宏控制,让编译器在发布模式下直接「蒸发」这些代码,不产生任何运行时开销。

7.5 知识体系总览

下面这张图总结了本章的核心逻辑:

断言与调试模式切换 — 知识体系 标准 assert 宏 自定义断言宏 增强版断言(回调) • 标准库提供 • NDEBUG控制开关 • 失败即终止 • 可自定义输出 • do-while包裹防坑 • 支持字符串化#expr • 回调函数注册 • 日志/串口/EEPROM • 可自定义行为 调试模式 ↔ 发布模式(NDEBUG控制) 核心原则:调试代码零成本嵌入,发布版本完全蒸发

7.6 避坑指南与最佳实践

最后,分享几个我踩过的坑:

  • 不要在断言里写函数调用:比如assert(free(ptr) == 0),发布版里free不会执行,内存泄漏等着你。
  • 断言不是错误处理:断言检查的是「不应该发生」的情况,比如指针为NULL、数组越界。用户输入错误应该用if-else处理,不是断言。
  • 发布版里保留关键检查:有些检查即使发布版也不能丢,比如医疗设备、航空电子。这时候别用assert,用if-else加日志。
  • 我曾经在一个通信协议栈里,把断言写成了assert(packet != NULL),结果发布版里某个极端情况下packet真的为NULL,程序直接跑飞。后来改成if (packet == NULL) return ERROR;,问题解决。
💡 我的习惯:调试阶段断言全开,单元测试阶段断言全开,集成测试阶段断言全开,发布前做一次「断言压力测试」——故意制造错误条件,看断言能不能准确捕获。等发布时,用-DNDEBUG一刀切掉。

断言这东西,用好了是调试利器,用不好就是埋雷。记住一条:断言只检查「程序内部逻辑错误」,不检查「外部输入异常」。把握好这个边界,你的代码会越来越健壮。


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