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日志,结果串口被日志刷爆,导致正常通信都受影响。所以一定要留一个编译开关,一键关闭所有调试输出。

日志库的核心结构

一个日志库,说白了就三样东西:一个控制块、一个输出接口、一组宏定义。控制块用来存配置,输出接口负责把字符串送到串口或文件,宏定义让调用方用着方便。

下面这张图,把整个日志库的架构说清楚了:

日志库核心架构 应用层调用 LOG_E("模块A", "错误码: %d", err); / LOG_I("模块B", "初始化完成"); 宏定义层(LOG_E / LOG_I / LOG_W / LOG_D) 自动添加文件名、行号、时间戳、模块名 日志核心函数 log_output(level, module, fmt, ...) 格式化字符串 → 拼接时间戳 + 级别标签 → 调用输出接口 串口输出(printf / UART) 实时查看,调试阶段常用 文件输出(SD卡 / Flash) 离线分析,现场问题排查

代码实现:一个轻量级日志库

下面是我在实际项目中用的一套代码,去掉了一些平台相关的细节,保留了核心逻辑:

#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日志连代码都不会生成,零开销。

实际项目中的使用建议

最后,分享几个我在项目中积累的经验:

  1. 日志要结构化:别光打文字,把关键数据用固定格式输出,方便后期写脚本分析
  2. 加一个“心跳”日志:每隔几秒打一条“系统正常运行”的信息,能帮你判断设备是不是死机了
  3. 日志文件要轮转:如果写到文件里,记得限制文件大小,满了就覆盖最旧的。不然一个日志文件撑爆存储,那就尴尬了
  4. 敏感信息别打出来:密码、密钥这些,千万别出现在日志里。我曾经见过有人把WiFi密码打到了日志里,结果代码开源后...你懂的

好了,关于日志库的实现,今天就聊到这儿。代码不复杂,但用好了,能帮你省下大把排查问题的时间。下次遇到bug,别急着怀疑硬件,先看看日志里有没有线索。


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