第十二章:日志模块的设计
日志模块,说白了就是给程序装个「黑匣子」。
我做了十几年嵌入式开发,可以负责任地告诉你:一个设计良好的日志模块,能帮你省下至少一半的调试时间。相反,如果日志写得乱七八糟,那它不但帮不上忙,还会拖慢系统、撑爆存储。
今天我们就来聊聊,怎么设计一个真正能用的日志模块。
12.1 日志级别:不是越多越好
日志级别这东西,很多新手喜欢搞七八个级别。我个人习惯是:够用就行,别花里胡哨。
我常用的日志级别定义如下:
typedef enum {
LOG_LEVEL_NONE = 0, // 关闭日志
LOG_LEVEL_ERROR = 1, // 错误
LOG_LEVEL_WARN = 2, // 警告
LOG_LEVEL_INFO = 3, // 信息
LOG_LEVEL_DEBUG = 4, // 调试
LOG_LEVEL_TRACE = 5 // 跟踪
} log_level_t;
这里我解释一下每个级别的使用场景:
- ERROR:系统出错了,必须人工介入。比如内存分配失败、硬件初始化失败。
- WARN:有问题但系统还能跑。比如某个传感器读数异常,但用了默认值。
- INFO:记录系统关键状态变化。比如「系统启动完成」「网络连接成功」。
- DEBUG:开发阶段用的,发布时通常会关掉。比如某个函数的入参和返回值。
- TRACE:最细粒度的日志,一般只在定位极难复现的bug时才开。
我的经验: 在产品发布时,我通常只保留 ERROR 和 WARN 级别。INFO 看情况保留,DEBUG 和 TRACE 必须关掉。否则日志量会大到让你怀疑人生。
12.2 日志输出接口:统一且灵活
接口设计上,我推荐用宏封装。为什么用宏?因为宏可以保留 __FILE__、__LINE__ 这些编译期信息。
#define LOG_ERROR(fmt, ...) \
log_output(LOG_LEVEL_ERROR, __FILE__, __LINE__, fmt, ##__VA_ARGS__)
#define LOG_WARN(fmt, ...) \
log_output(LOG_LEVEL_WARN, __FILE__, __LINE__, fmt, ##__VA_ARGS__)
#define LOG_INFO(fmt, ...) \
log_output(LOG_LEVEL_INFO, __FILE__, __LINE__, fmt, ##__VA_ARGS__)
#define LOG_DEBUG(fmt, ...) \
log_output(LOG_LEVEL_DEBUG, __FILE__, __LINE__, fmt, ##__VA_ARGS__)
#define LOG_TRACE(fmt, ...) \
log_output(LOG_LEVEL_TRACE, __FILE__, __LINE__, fmt, ##__VA_ARGS__)
核心输出函数长这样:
void log_output(log_level_t level, const char *file, int line,
const char *fmt, ...) {
// 如果当前级别低于全局设置,直接丢弃
if (level > g_log_level) {
return;
}
va_list args;
va_start(args, fmt);
// 格式化时间戳
char time_buf[32];
get_timestamp(time_buf, sizeof(time_buf));
// 组装日志头:[时间][级别][文件名:行号]
printf("[%s][%s][%s:%d] ",
time_buf, level_to_string(level), file, line);
// 输出用户消息
vprintf(fmt, args);
printf("\n");
va_end(args);
}
你可能会问:为什么不用 fprintf(stderr, ...)?嗯,在嵌入式环境里,标准输出可能被重定向到串口、LCD、SD卡或者网络。所以最好让底层输出可配置。
12.3 日志模块的线程安全性
这是个大坑。我曾经在一个多线程项目里,日志没加锁,结果两个线程同时写日志,输出全乱套了——一行日志被撕成两半,混在一起根本没法看。
注意: 在多线程环境下,printf 本身不是线程安全的。如果你用的是 RTOS,每个任务都在抢着写日志,不加锁一定会出问题。
解决方案其实不复杂:加一个互斥锁。
static mutex_t log_mutex;
void log_init(void) {
mutex_init(&log_mutex);
g_log_level = LOG_LEVEL_INFO;
}
void log_output(log_level_t level, const char *file, int line,
const char *fmt, ...) {
if (level > g_log_level) {
return;
}
mutex_lock(&log_mutex); // 加锁
va_list args;
va_start(args, fmt);
char time_buf[32];
get_timestamp(time_buf, sizeof(time_buf));
printf("[%s][%s][%s:%d] ",
time_buf, level_to_string(level), file, line);
vprintf(fmt, args);
printf("\n");
va_end(args);
mutex_unlock(&log_mutex); // 解锁
}
但这里有个性能问题:如果日志输出很频繁,锁竞争会拖慢系统。我见过一个极端案例,日志模块占了CPU 30%的时间。
怎么优化?我常用的方法有两种:
- 日志缓冲区:先把日志写到环形缓冲区里,由专门的日志任务负责输出。这样写日志几乎不阻塞。
- 分级开关:在宏层面就做级别判断,避免进入函数调用。比如:
#define LOG_DEBUG(fmt, ...) do { \
if (LOG_LEVEL_DEBUG <= g_log_level) { \
log_output(LOG_LEVEL_DEBUG, __FILE__, __LINE__, fmt, ##__VA_ARGS__); \
} \
} while(0)
这样,如果 DEBUG 级别被关闭,连函数调用都不会发生,锁也不会被争用。
12.4 日志模块的整体架构
下面这张图,是我个人比较推荐的日志模块分层结构:
这个架构的好处是:每一层职责清晰,替换底层输出方式时,上层代码完全不用改。
12.5 避坑指南
最后,我把自己踩过的坑总结一下:
- 别在中断里打日志:中断里调用 printf 或加锁,很容易死锁。我建议在中断里只设标志位,由任务去输出日志。
- 日志缓冲区别太小:我曾经设了个 128 字节的环形缓冲区,结果日志一多就覆盖了,关键信息全丢了。建议至少 4KB 起步。
- 日志别带敏感信息:有个同事把 WiFi 密码打进了日志,结果日志文件被上传到服务器……嗯,你懂的。
- 发布版本记得关 DEBUG:我见过一个产品,DEBUG 日志没关,结果 Flash 被写报废了。
日志模块看起来简单,但真正做好并不容易。它就像你代码里的「仪表盘」——平时不觉得重要,出问题的时候才知道它有多关键。