13、宏实现断言与调试:assert宏的实现原理,自定义调试宏(DEBUG_PRINT),日志系统的宏设计
调试,是嵌入式开发里绕不开的苦活。说实话,我见过太多工程师在代码里写满 printf,发布时又一条条删掉,删不干净就留在那里吃灰。嗯,宏定义就是来解决这个问题的——用最轻量的方式,在需要时打开调试,在发布时彻底关闭。
这一章,我们聊聊断言、调试打印和日志系统。这三个东西,说白了就是一套「防守-侦察-记录」的组合拳。
13.1 assert 宏的实现原理
assert 是标准库 <assert.h> 提供的断言宏。它的作用很简单:如果条件为假,就打印错误信息并终止程序。
但它的实现,其实比你想的要精巧。我拆开给你看。
13.1.1 标准 assert 的典型实现
#ifdef NDEBUG
#define assert(condition) ((void)0)
#else
#define assert(condition) \
((condition) ? (void)0 : \
__assert_fail(#condition, __FILE__, __LINE__, __func__))
#endif
这里有几个关键点:
- NDEBUG 开关:定义了 NDEBUG,assert 就变成空操作。发布时加上
-DNDEBUG即可。 - 字符串化操作符 #:
#condition把条件表达式变成字符串,方便打印。 - 预定义宏:
__FILE__、__LINE__、__func__分别给出文件名、行号、函数名。 - void 转换:
(void)0是为了避免编译器警告,确保表达式被「消耗」掉。
核心思想:assert 不是用来处理运行时错误的,它是用来捕获「不应该发生」的逻辑错误。比如指针不该为 NULL,数组下标不该越界。
13.1.2 自定义 assert:嵌入式场景的改造
标准 assert 在 PC 上很好用,但在嵌入式环境里,abort() 可能根本不存在,或者你不想让系统直接死掉。我曾经在一个 RTOS 项目里,就遇到过 assert 触发后系统直接挂死,连最后的状态都来不及保存。
所以,我一般会自己写一个:
#ifdef NDEBUG
#define MY_ASSERT(condition) ((void)0)
#else
#define MY_ASSERT(condition) \
do { \
if (!(condition)) { \
my_assert_fail(#condition, __FILE__, __LINE__, __func__); \
} \
} while(0)
#endif
void my_assert_fail(const char *expr, const char *file, int line, const char *func) {
/* 记录错误到日志缓冲区 */
log_error("Assertion failed: %s, file %s, line %d, func %s",
expr, file, line, func);
/* 触发断点(如果调试器连接) */
__asm("BKPT #0");
/* 或者进入错误处理循环 */
while(1);
}
我的习惯:在自定义 assert 里,我会先记录日志,再触发硬件断点,最后进入死循环。这样既能在调试时停下来,又能在无调试器时留下线索。
13.2 自定义调试宏:DEBUG_PRINT
printf 在嵌入式里是个奢侈品。串口输出慢、占用栈空间、还可能引入浮点库。所以,我们需要一个轻量级的调试打印宏。
13.2.1 基础版 DEBUG_PRINT
#ifdef DEBUG_ENABLE
#define DEBUG_PRINT(fmt, ...) \
printf("[DEBUG] %s:%d: " fmt "\n", __FILE__, __LINE__, ##__VA_ARGS__)
#else
#define DEBUG_PRINT(fmt, ...) ((void)0)
#endif
这里用了 ##__VA_ARGS__,这是 GNU 扩展,允许可变参数为空。标准 C 里可以用 __VA_OPT__(C23 支持),但嵌入式编译器大多支持 GNU 语法。
13.2.2 带级别的调试宏
实际项目中,调试信息是有等级的。我个人习惯分三级:
#define LOG_LEVEL_NONE 0
#define LOG_LEVEL_ERROR 1
#define LOG_LEVEL_WARN 2
#define LOG_LEVEL_INFO 3
#define LOG_LEVEL_DEBUG 4
#ifndef LOG_LEVEL
#define LOG_LEVEL LOG_LEVEL_INFO
#endif
#define LOG_ERROR(fmt, ...) \
do { \
if (LOG_LEVEL >= LOG_LEVEL_ERROR) \
printf("[ERROR] %s:%d: " fmt "\n", __FILE__, __LINE__, ##__VA_ARGS__); \
} while(0)
#define LOG_WARN(fmt, ...) \
do { \
if (LOG_LEVEL >= LOG_LEVEL_WARN) \
printf("[WARN] %s:%d: " fmt "\n", __FILE__, __LINE__, ##__VA_ARGS__); \
} while(0)
#define LOG_INFO(fmt, ...) \
do { \
if (LOG_LEVEL >= LOG_LEVEL_INFO) \
printf("[INFO] %s:%d: " fmt "\n", __FILE__, __LINE__, ##__VA_ARGS__); \
} while(0)
#define LOG_DEBUG(fmt, ...) \
do { \
if (LOG_LEVEL >= LOG_LEVEL_DEBUG) \
printf("[DEBUG] %s:%d: " fmt "\n", __FILE__, __LINE__, ##__VA_ARGS__); \
} while(0)
注意:这里的 if 判断是在运行时进行的。如果你追求极致性能,可以在编译时用宏判断,把不满足等级的调用直接替换为空。但说实话,大多数场景下运行时判断的开销可以忽略。
13.3 日志系统的宏设计
调试打印是给开发者看的,日志系统是给系统看的。日志需要持久化、带时间戳、支持不同输出目标(串口、文件、网络)。
13.3.1 日志系统的核心结构
我设计日志系统时,会先定义日志级别和输出接口:
typedef enum {
LOG_LEVEL_TRACE = 0,
LOG_LEVEL_DEBUG,
LOG_LEVEL_INFO,
LOG_LEVEL_WARN,
LOG_LEVEL_ERROR,
LOG_LEVEL_FATAL
} log_level_t;
typedef void (*log_output_func_t)(const char *msg, int len);
typedef struct {
log_level_t min_level;
log_output_func_t output;
int include_timestamp;
int include_level;
} log_config_t;
然后,用宏来封装日志调用:
#define LOG_WRITE(level, fmt, ...) \
do { \
if (level >= g_log_config.min_level) { \
char _log_buf[LOG_BUF_SIZE]; \
int _len = log_format_message(_log_buf, sizeof(_log_buf), \
level, __FILE__, __LINE__, \
fmt, ##__VA_ARGS__); \
g_log_config.output(_log_buf, _len); \
} \
} while(0)
#define LOG_TRACE(fmt, ...) LOG_WRITE(LOG_LEVEL_TRACE, fmt, ##__VA_ARGS__)
#define LOG_DEBUG(fmt, ...) LOG_WRITE(LOG_LEVEL_DEBUG, fmt, ##__VA_ARGS__)
#define LOG_INFO(fmt, ...) LOG_WRITE(LOG_LEVEL_INFO, fmt, ##__VA_ARGS__)
#define LOG_WARN(fmt, ...) LOG_WRITE(LOG_LEVEL_WARN, fmt, ##__VA_ARGS__)
#define LOG_ERROR(fmt, ...) LOG_WRITE(LOG_LEVEL_ERROR, fmt, ##__VA_ARGS__)
#define LOG_FATAL(fmt, ...) LOG_WRITE(LOG_LEVEL_FATAL, fmt, ##__VA_ARGS__)
13.3.2 日志格式化函数
格式化是日志系统的核心。我一般会这样实现:
static int log_format_message(char *buf, int size, log_level_t level,
const char *file, int line,
const char *fmt, ...) {
int pos = 0;
va_list args;
/* 添加时间戳 */
if (g_log_config.include_timestamp) {
uint32_t ms = get_system_ms();
pos += snprintf(buf + pos, size - pos, "[%u.%03u]", ms / 1000, ms % 1000);
}
/* 添加日志级别 */
if (g_log_config.include_level) {
static const char *level_str[] = {
"TRACE", "DEBUG", "INFO", "WARN", "ERROR", "FATAL"
};
pos += snprintf(buf + pos, size - pos, "[%s]", level_str[level]);
}
/* 添加文件位置 */
pos += snprintf(buf + pos, size - pos, "[%s:%d] ", file, line);
/* 添加用户消息 */
va_start(args, fmt);
pos += vsnprintf(buf + pos, size - pos, fmt, args);
va_end(args);
/* 添加换行 */
pos += snprintf(buf + pos, size - pos, "\n");
return pos;
}
避坑指南:我曾经在日志缓冲区大小上吃过亏。如果 LOG_BUF_SIZE 太小,长消息会被截断。我建议至少 256 字节,如果日志内容复杂,512 或 1024 更稳妥。
13.4 知识体系图
下面这张图,把断言、调试宏、日志系统的关系梳理清楚了:
13.5 实战建议与避坑
最后,我分享几个实际项目中的经验:
- 断言不是错误处理:assert 是用来发现 bug 的,不是用来处理用户输入的。不要用 assert 检查外部数据。
- 调试宏要能彻底关闭:发布版本里,调试代码必须零开销。用宏开关,不要用 if 判断。
- 日志缓冲区要够大:我见过太多因为缓冲区溢出导致的诡异问题。建议至少 256 字节,多任务环境要加锁。
- 时间戳精度要匹配:如果系统时钟是毫秒级,就别假装能输出微秒。否则日志里的时间戳反而会误导人。
一句话总结:断言抓逻辑错误,调试宏辅助开发,日志系统记录运行状态。三者配合,能让你的嵌入式项目从「黑盒」变成「透明」。