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(++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 知识体系总览
下面这张图总结了本章的核心逻辑:
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