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_INFO("value=%d", a)没问题。但传LOG_INFO("pair=(%d,%d)", a, b)时,宏展开可能出错。解决办法:用##__VA_ARGS__这个 GNU 扩展,或者用__VA_OPT__(C99 之后)。 - 不要用 printf 直接做日志:printf 没有级别控制,没法裁剪。我曾经在一个项目里看到全篇 printf,想加个日志级别,改到吐血。
- 日志缓冲区溢出:嵌入式系统里,如果日志输出太快,串口缓冲区会满。我建议加个环形缓冲区,或者用 DMA 传输。别问我怎么知道的——有一次调试时串口卡死,系统 watchdog 复位,查了三天才发现是日志刷爆了。
总结一下
日志系统设计,说白了就是三件事:
- 定级别——用宏定义好 ERROR、WARN、INFO、DEBUG。
- 定接口——用宏包装函数,自动带文件名和行号。
- 定裁剪——编译时通过宏开关,让低级别日志彻底消失。
这套方案我用了快十年,从单片机到 Linux 应用层,基本没翻过车。你试试看,保证比满屏 printf 舒服得多。
公众号:蓝海资料掘金营,微信deep3321