80、日志系统:如何实现一个简单的日志库?
说实话,日志系统这东西,刚入行时我根本没当回事。觉得不就是打印点信息嘛,用printf不就完了?直到有一次,产品在客户现场出了个偶发性的死机问题,我远程连不上设备,只能让现场人员把日志文件发回来。结果一看,全是printf的杂乱输出,时间戳没有,模块名没有,严重等级也没有。我对着那堆信息看了整整两天,愣是没找出问题在哪。
从那以后,我彻底明白了——日志不是随便打几个字,它是嵌入式系统的“黑匣子”。今天咱们就来聊聊,怎么自己动手写一个简单又实用的日志库。
日志库的核心需求
一个能用的日志库,至少得满足这几点:
- 分级输出:DEBUG、INFO、WARN、ERROR,不同场景用不同级别
- 时间戳:精确到毫秒,方便定位问题发生的时间点
- 模块标识:知道这条日志是哪个模块打的
- 可配置性:能动态开关某些级别的输出
- 低开销:嵌入式系统资源有限,不能太吃CPU和内存
我个人习惯:在项目初期就把日志框架搭好。别等到出问题了再补,那时候就晚了。
日志级别的设计
先定义几个级别。我一般用枚举,简单明了:
typedef enum {
LOG_LEVEL_NONE = 0, // 关闭所有日志
LOG_LEVEL_ERROR = 1, // 仅错误
LOG_LEVEL_WARN = 2, // 警告及以上
LOG_LEVEL_INFO = 3, // 信息及以上
LOG_LEVEL_DEBUG = 4 // 全部输出
} log_level_t;
这里有个细节:级别值越大,输出的日志越多。调试阶段开到DEBUG,正式发布时开到INFO或WARN就行。
避坑指南:我曾经在正式版里忘了关DEBUG日志,结果串口被日志刷爆,导致正常通信都受影响。所以一定要留一个编译开关,一键关闭所有调试输出。
日志库的核心结构
一个日志库,说白了就三样东西:一个控制块、一个输出接口、一组宏定义。控制块用来存配置,输出接口负责把字符串送到串口或文件,宏定义让调用方用着方便。
下面这张图,把整个日志库的架构说清楚了:
代码实现:一个轻量级日志库
下面是我在实际项目中用的一套代码,去掉了一些平台相关的细节,保留了核心逻辑:
#include <stdio.h>
#include <stdarg.h>
#include <time.h>
// 日志控制块
typedef struct {
log_level_t level; // 当前允许输出的最低级别
void (*output)(const char*); // 输出回调函数
} log_control_t;
static log_control_t g_log = {
.level = LOG_LEVEL_DEBUG,
.output = NULL
};
// 初始化日志系统
void log_init(log_level_t level, void (*output)(const char*)) {
g_log.level = level;
g_log.output = output;
}
// 获取时间戳字符串
static void get_timestamp(char *buf, int len) {
// 这里用伪代码示意,实际需根据平台实现
// 比如用 time() + localtime() 或者 RTC 驱动
snprintf(buf, len, "[%lu]", (unsigned long)time(NULL));
}
// 核心输出函数
void log_output(log_level_t level, const char *module,
const char *fmt, ...) {
if (level > g_log.level || g_log.output == NULL) {
return; // 级别不够或未初始化,直接返回
}
char buffer[256];
char timestamp[32];
const char *level_str[] = {"", "ERR", "WRN", "INF", "DBG"};
get_timestamp(timestamp, sizeof(timestamp));
// 拼接格式:[时间戳][级别][模块] 消息
int pos = snprintf(buffer, sizeof(buffer),
"%s[%s][%s] ",
timestamp, level_str[level], module);
va_list args;
va_start(args, fmt);
vsnprintf(buffer + pos, sizeof(buffer) - pos, fmt, args);
va_end(args);
g_log.output(buffer);
}
注意:上面的代码里,buffer只有256字节。如果日志消息太长,会被截断。我建议根据你的平台内存情况,适当调整这个值。在资源紧张的MCU上,128字节也够用。
宏定义:让调用更优雅
直接调用log_output太啰嗦了。用宏包装一下,调用方写起来就舒服多了:
#define LOG_E(module, fmt, ...) \
log_output(LOG_LEVEL_ERROR, module, fmt, ##__VA_ARGS__)
#define LOG_W(module, fmt, ...) \
log_output(LOG_LEVEL_WARN, module, fmt, ##__VA_ARGS__)
#define LOG_I(module, fmt, ...) \
log_output(LOG_LEVEL_INFO, module, fmt, ##__VA_ARGS__)
#define LOG_D(module, fmt, ...) \
log_output(LOG_LEVEL_DEBUG, module, fmt, ##__VA_ARGS__)
用起来就是这样:
LOG_I("MAIN", "系统启动,版本: %s", "v2.1.0");
LOG_E("I2C", "通信超时,地址: 0x%02X", addr);
LOG_D("SENSOR", "温度值: %.2f °C", temp);
你看,是不是很清晰?模块名、级别、消息内容,一目了然。
输出接口的灵活设计
我故意把输出接口设计成回调函数的形式。为什么?因为不同项目的输出方式不一样。有的用串口,有的用LCD,有的写到SD卡。用回调函数,你只需要在初始化时传入不同的函数指针就行:
// 串口输出
void uart_output(const char *str) {
// 调用你的UART发送函数
uart_send_string(str);
}
// 文件输出
void file_output(const char *str) {
// 写入日志文件
fputs(str, log_file);
}
// 初始化时选择
log_init(LOG_LEVEL_INFO, uart_output); // 调试用串口
// log_init(LOG_LEVEL_WARN, file_output); // 正式用文件
我个人经验:在开发阶段,我习惯同时开两个输出——串口看实时信息,文件存完整日志。这样调试时方便,出问题了也有历史记录可查。
性能优化:别让日志拖慢系统
嵌入式系统里,日志如果写得不好,真能把系统拖垮。我遇到过最夸张的一次,一个日志打印函数里用了sprintf,结果每次调用耗时好几毫秒,直接把中断响应给耽误了。
几个优化建议:
- 关掉DEBUG级别的日志:发布版本里,把日志级别设到WARN或ERROR,减少输出量
- 用编译开关彻底移除:如果性能要求极高,可以用宏在编译阶段就把DEBUG日志删掉
- 异步输出:把日志先放到环形缓冲区里,由后台任务慢慢往外吐,不阻塞主流程
这里给一个编译开关的例子:
#ifdef RELEASE_VERSION
#define LOG_D(module, fmt, ...) // 空宏,编译时直接消失
#else
#define LOG_D(module, fmt, ...) \
log_output(LOG_LEVEL_DEBUG, module, fmt, ##__VA_ARGS__)
#endif
嗯,这样在正式版里,DEBUG日志连代码都不会生成,零开销。
实际项目中的使用建议
最后,分享几个我在项目中积累的经验:
- 日志要结构化:别光打文字,把关键数据用固定格式输出,方便后期写脚本分析
- 加一个“心跳”日志:每隔几秒打一条“系统正常运行”的信息,能帮你判断设备是不是死机了
- 日志文件要轮转:如果写到文件里,记得限制文件大小,满了就覆盖最旧的。不然一个日志文件撑爆存储,那就尴尬了
- 敏感信息别打出来:密码、密钥这些,千万别出现在日志里。我曾经见过有人把WiFi密码打到了日志里,结果代码开源后...你懂的
好了,关于日志库的实现,今天就聊到这儿。代码不复杂,但用好了,能帮你省下大把排查问题的时间。下次遇到bug,别急着怀疑硬件,先看看日志里有没有线索。