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 知识体系图

下面这张图,把断言、调试宏、日志系统的关系梳理清楚了:

宏实现断言与调试:知识体系 断言 (assert) 调试宏 (DEBUG_PRINT) 日志系统 NDEBUG 开关 字符串化 # 可变参数 ##__VA_ARGS__ 日志级别控制 输出接口抽象 格式化与缓冲 设计原则 1. 编译时开关:通过宏定义在发布版本中彻底移除调试代码 2. 运行时控制:日志级别可在运行时动态调整,不影响性能 3. 信息丰富:文件名、行号、函数名、时间戳,一个都不能少

13.5 实战建议与避坑

最后,我分享几个实际项目中的经验:

  • 断言不是错误处理:assert 是用来发现 bug 的,不是用来处理用户输入的。不要用 assert 检查外部数据。
  • 调试宏要能彻底关闭:发布版本里,调试代码必须零开销。用宏开关,不要用 if 判断。
  • 日志缓冲区要够大:我见过太多因为缓冲区溢出导致的诡异问题。建议至少 256 字节,多任务环境要加锁。
  • 时间戳精度要匹配:如果系统时钟是毫秒级,就别假装能输出微秒。否则日志里的时间戳反而会误导人。

一句话总结:断言抓逻辑错误,调试宏辅助开发,日志系统记录运行状态。三者配合,能让你的嵌入式项目从「黑盒」变成「透明」。


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