24、头文件中的日志系统:日志级别宏、日志输出接口设计

日志系统,说白了就是给程序装个“黑匣子”。

我刚开始做嵌入式那会儿,调试全靠串口打印。项目小还好说,等代码量一上来,满屏的 printf 根本分不清哪些是调试信息、哪些是错误报警。有一次客户现场出了问题,我愣是盯着几百行串口输出找了半天……嗯,从那以后,我下定决心,必须搞一套规范的日志系统。

为什么日志系统要放在头文件里?

你想想看,日志是全局都要用的东西。每个 .c 文件都可能需要打印信息。如果每个文件各自定义一套,那维护起来就是灾难。

我个人习惯,把日志系统的核心定义全部塞进一个头文件,比如 log.h。所有模块只要 #include "log.h",就能直接用。这样改日志格式、调级别,只改一个文件就行。

核心原则:日志系统 = 头文件定义接口 + 源文件实现底层。头文件只暴露宏和函数声明,不暴露实现细节。

日志级别宏:给信息分个三六九等

日志级别,说白了就是给信息贴标签。我一般用枚举或者宏定义,把级别分成几档:

/* log.h */
#ifndef LOG_H
#define LOG_H

/* 日志级别定义 */
#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   /* 调试+信息+警告+错误 */
#define LOG_LEVEL_VERBOSE  5   /* 全部输出 */

/* 当前编译的日志级别,默认 INFO */
#ifndef CURRENT_LOG_LEVEL
#define CURRENT_LOG_LEVEL  LOG_LEVEL_INFO
#endif

#endif /* LOG_H */

这里有个小技巧:CURRENT_LOG_LEVEL 可以在编译时通过 -D 选项覆盖。比如调试阶段用 LOG_LEVEL_DEBUG,发布版本改成 LOG_LEVEL_ERROR。我在项目中就吃过亏——发布版忘了关调试日志,结果串口被刷爆,系统卡死。后来强制在 Makefile 里加了一条:

# 发布版自动降级
ifeq ($(BUILD_TYPE), release)
CFLAGS += -DCURRENT_LOG_LEVEL=LOG_LEVEL_ERROR
endif

日志输出接口设计:宏 vs 函数

接口怎么设计?我推荐用“宏包装函数”的方式。为什么?

  • 宏可以自动携带文件名、行号——这是函数做不到的。
  • 宏可以在编译时裁剪代码——低级别日志直接消失,不占空间。
  • 函数负责真正的输出——方便统一管理,比如加时间戳、写文件、发网络。

来看一个经典实现:

/* log.h */
#include <stdio.h>

/* 底层输出函数声明 */
void log_output(int level, const char *file, int line, const char *fmt, ...);

/* 日志宏接口 */
#define LOG_ERROR(fmt, ...) \
    do { \
        if (CURRENT_LOG_LEVEL >= LOG_LEVEL_ERROR) \
            log_output(LOG_LEVEL_ERROR, __FILE__, __LINE__, fmt, ##__VA_ARGS__); \
    } while(0)

#define LOG_WARN(fmt, ...) \
    do { \
        if (CURRENT_LOG_LEVEL >= LOG_LEVEL_WARN) \
            log_output(LOG_LEVEL_WARN, __FILE__, __LINE__, fmt, ##__VA_ARGS__); \
    } while(0)

#define LOG_INFO(fmt, ...) \
    do { \
        if (CURRENT_LOG_LEVEL >= LOG_LEVEL_INFO) \
            log_output(LOG_LEVEL_INFO, __FILE__, __LINE__, fmt, ##__VA_ARGS__); \
    } while(0)

#define LOG_DEBUG(fmt, ...) \
    do { \
        if (CURRENT_LOG_LEVEL >= LOG_LEVEL_DEBUG) \
            log_output(LOG_LEVEL_DEBUG, __FILE__, __LINE__, fmt, ##__VA_ARGS__); \
    } while(0)
注意:宏里的 do { ... } while(0) 不是多余的。它保证宏在使用时像普通函数一样,后面可以加分号,不会跟 if/else 打架。我见过有人漏掉这个,结果代码编译通过但逻辑全乱——血的教训。

底层实现:log_output 怎么写?

宏只是壳,真正的输出在 log_output 函数里。我一般这样实现:

/* log.c */
#include "log.h"
#include <stdarg.h>
#include <time.h>

/* 级别名称映射 */
static const char *level_names[] = {
    [LOG_LEVEL_ERROR]   = "ERROR",
    [LOG_LEVEL_WARN]    = "WARN",
    [LOG_LEVEL_INFO]    = "INFO",
    [LOG_LEVEL_DEBUG]   = "DEBUG",
    [LOG_LEVEL_VERBOSE] = "VERBOSE"
};

void log_output(int level, const char *file, int line, const char *fmt, ...)
{
    va_list args;
    char time_buf[32];
    time_t now = time(NULL);
    struct tm *tm_info = localtime(&now);

    strftime(time_buf, sizeof(time_buf), "%H:%M:%S", tm_info);

    /* 输出格式:[时间] [级别] 文件名:行号 - 消息 */
    printf("[%s] [%s] %s:%d - ", time_buf, level_names[level], file, line);

    va_start(args, fmt);
    vprintf(fmt, args);
    va_end(args);

    printf("\n");
}

你看,底层函数可以随意扩展。比如加个文件输出、加个颜色控制、甚至加个环形缓冲区——只要接口不变,上层代码一行都不用改。

编译时裁剪:让日志“消失”

嵌入式系统资源有限。调试阶段日志满天飞,发布版必须干净。怎么做到?

答案就在宏的 if 判断里。当 CURRENT_LOG_LEVEL 设为 LOG_LEVEL_ERROR 时,LOG_DEBUG 宏里的 if 条件永远为假。现代编译器会直接优化掉整个 if 块——连函数调用都不生成。

我曾经在一个 STM32 项目上验证过:开启 LOG_LEVEL_VERBOSE 时,固件体积 128KB;改成 LOG_LEVEL_ERROR 后,直接降到 96KB。省出来的空间,够加个 bootloader 了。

知识体系结构图

下面这张图,帮你理清日志系统的整体脉络:

日志系统核心结构 头文件 log.h(接口层) • 日志级别宏:LOG_LEVEL_ERROR / WARN / INFO / DEBUG • 日志输出宏:LOG_ERROR / LOG_WARN / LOG_INFO / LOG_DEBUG • 编译开关:CURRENT_LOG_LEVEL 控制裁剪 源文件 log.c(实现层) • log_output() 函数:格式化输出、时间戳、级别名称 • 可扩展:文件输出、颜色控制、环形缓冲区 • 接口不变,底层随便改 各模块 .c 文件(使用层) 直接调用 LOG_INFO("传感器数据: %d", value); 即可

避坑指南:我踩过的几个坑

  • 宏参数里的逗号问题:如果你传 LOG_INFO("value=%d", a) 没问题。但传 LOG_INFO("pair=(%d,%d)", a, b) 时,宏展开可能出错。解决办法:用 ##__VA_ARGS__ 这个 GNU 扩展,或者用 __VA_OPT__(C99 之后)。
  • 不要用 printf 直接做日志:printf 没有级别控制,没法裁剪。我曾经在一个项目里看到全篇 printf,想加个日志级别,改到吐血。
  • 日志缓冲区溢出:嵌入式系统里,如果日志输出太快,串口缓冲区会满。我建议加个环形缓冲区,或者用 DMA 传输。别问我怎么知道的——有一次调试时串口卡死,系统 watchdog 复位,查了三天才发现是日志刷爆了。
警告:不要在中断服务函数里直接调用日志宏!中断里调用 printf 或 vprintf 可能导致死锁。如果非要在中断里打日志,请用无锁队列,把日志消息先存起来,等主循环再输出。

总结一下

日志系统设计,说白了就是三件事:

  1. 定级别——用宏定义好 ERROR、WARN、INFO、DEBUG。
  2. 定接口——用宏包装函数,自动带文件名和行号。
  3. 定裁剪——编译时通过宏开关,让低级别日志彻底消失。

这套方案我用了快十年,从单片机到 Linux 应用层,基本没翻过车。你试试看,保证比满屏 printf 舒服得多。

小提示:如果你用的是 C++,可以考虑用模板和流式输出替代宏。但嵌入式 C 项目里,宏依然是最简洁、最可控的方案。

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