日志系统设计:分级日志、时间戳、文件滚动、异步写入

日志系统这东西,说简单也简单,说复杂能写出一本书来。我早年做嵌入式项目时,一开始觉得日志嘛,printf 一下不就完了?结果产品上线后,客户报了个偶发死机的问题,我翻遍串口输出,全是乱糟糟的调试信息,根本定位不到问题。从那以后,我彻底明白了——日志不是随便打几个字,它是一门系统工程。

今天咱们就聊聊,怎么设计一个真正能用的嵌入式日志系统。核心就四个点:分级日志、时间戳、文件滚动、异步写入。一个一个来拆解。

1. 分级日志:别让信息淹死你

你想想看,如果所有日志都混在一起,调试信息、错误信息、普通运行状态全往一个地方堆,那跟垃圾场有什么区别?

我个人习惯把日志分成这么几级:

级别 宏定义 说明
ERROR LOG_LEVEL_ERROR 致命错误,必须处理
WARN LOG_LEVEL_WARN 警告,不影响运行但需关注
INFO LOG_LEVEL_INFO 正常状态信息
DEBUG LOG_LEVEL_DEBUG 调试用,发布时关闭
TRACE LOG_LEVEL_TRACE 最细粒度,函数调用跟踪

代码里怎么用?我一般这样定义宏:

#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__)

嗯,这里要注意:__FILE____LINE__ 是编译器内置宏,能帮你快速定位日志来源。我在项目中遇到过,有人嫌麻烦没加这两个信息,结果出问题后满世界找代码,那叫一个痛苦。

小技巧: 发布版本时,把 DEBUG 和 TRACE 级别的日志直接编译掉,用 #ifdef 包裹。这样既能减小代码体积,又能提升运行效率。

2. 时间戳:精确到毫秒

日志没有时间戳,就像历史书没有年份。你只能知道发生了什么,却不知道什么时候发生的。对于排查时序相关的问题,这简直是灾难。

我建议时间戳格式为:2025-03-15 14:23:45.678。为什么精确到毫秒?因为嵌入式系统里很多问题发生在毫秒级,比如中断抢占、任务切换。

获取时间戳的代码大致这样:

void get_timestamp(char *buf, size_t len) {
    struct timeval tv;
    struct tm *tm_info;

    gettimeofday(&tv, NULL);
    tm_info = localtime(&tv.tv_sec);

    snprintf(buf, len, "%04d-%02d-%02d %02d:%02d:%02d.%03ld",
             tm_info->tm_year + 1900,
             tm_info->tm_mon + 1,
             tm_info->tm_mday,
             tm_info->tm_hour,
             tm_info->tm_min,
             tm_info->tm_sec,
             tv.tv_usec / 1000);
}

我曾经在一个项目中,因为时间戳只精确到秒,结果两个事件的时间戳一模一样,根本分不清先后顺序。后来改成毫秒级,问题迎刃而解。

注意: 如果你的系统没有 RTC(实时时钟),或者没有联网同步时间,那么时间戳可能不准。这种情况下,可以考虑用系统启动后的相对时间(比如 ticks 数)作为替代方案。

3. 文件滚动:别让日志撑爆存储

嵌入式设备的存储空间通常有限。如果不做文件滚动,日志文件会一直增长,直到把 Flash 或 SD 卡写满。到时候系统可能直接挂掉。

文件滚动的核心思路是:保留最近 N 个日志文件,旧的自动删除。常见的策略有两种:

  • 按大小滚动: 单个日志文件达到指定大小(比如 1MB)后,自动切换到下一个文件。比如 log_0.txt、log_1.txt……最多保留 5 个。
  • 按时间滚动: 每天或每小时生成一个新文件。比如 log_20250315.txt、log_20250316.txt……保留最近 7 天。

我个人更倾向于按大小滚动,因为嵌入式系统的存储空间是固定的,按大小滚动更容易控制总容量。代码实现大致如下:

#define MAX_LOG_FILES  5
#define MAX_LOG_SIZE   (1024 * 1024)  // 1MB

void log_rotate(void) {
    // 检查当前文件大小
    long size = get_file_size(current_log_file);
    if (size < MAX_LOG_SIZE) return;

    // 删除最旧的文件
    char oldest[64];
    snprintf(oldest, sizeof(oldest), "log_%d.txt", 
             (current_index - MAX_LOG_FILES + 1) % MAX_LOG_FILES);
    remove(oldest);

    // 切换到下一个文件
    current_index = (current_index + 1) % MAX_LOG_FILES;
    snprintf(current_log_file, sizeof(current_log_file), 
             "log_%d.txt", current_index);
    // 打开新文件,准备写入
}
核心原则: 文件滚动不是锦上添花,而是雪中送炭。不做滚动的日志系统,迟早会出问题。

4. 异步写入:别让日志拖慢系统

这是最容易被忽视的一点。很多人写日志时直接调用 fwrite 或 fprintf,结果日志一多,系统响应就变慢。为什么?因为写文件是 I/O 操作,耗时可能是微秒级甚至毫秒级。在实时性要求高的场景下,这绝对不能接受。

异步写入的思路是:日志先写入内存缓冲区,然后由专门的线程或任务负责写入文件。这样,业务代码只管往缓冲区里塞数据,不用等文件写完。

我常用的实现方式:

#define LOG_BUF_SIZE  4096
static char log_buffer[LOG_BUF_SIZE];
static int  buf_pos = 0;

void log_output(int level, const char *file, int line, const char *fmt, ...) {
    // 格式化日志内容
    char msg[256];
    va_list args;
    va_start(args, fmt);
    vsnprintf(msg, sizeof(msg), fmt, args);
    va_end(args);

    // 写入缓冲区(注意加锁)
    pthread_mutex_lock(&log_mutex);
    int len = snprintf(log_buffer + buf_pos, LOG_BUF_SIZE - buf_pos,
                       "[%s] [%s] %s:%d - %s\n",
                       get_level_str(level), get_timestamp_str(), 
                       file, line, msg);
    buf_pos += len;

    // 如果缓冲区快满了,触发写入
    if (buf_pos > LOG_BUF_SIZE - 256) {
        pthread_cond_signal(&log_cond);
    }
    pthread_mutex_unlock(&log_mutex);
}

// 专门的写入线程
void *log_writer_thread(void *arg) {
    while (1) {
        pthread_mutex_lock(&log_mutex);
        while (buf_pos == 0) {
            pthread_cond_wait(&log_cond, &log_mutex);
        }
        // 写入文件
        fwrite(log_buffer, 1, buf_pos, log_file);
        fflush(log_file);
        buf_pos = 0;
        pthread_mutex_unlock(&log_mutex);
    }
    return NULL;
}

你看,业务代码和文件写入完全解耦了。即使文件写入偶尔卡一下,也不会影响主流程。

注意: 异步写入虽然好,但有一个风险——如果系统突然掉电,缓冲区里的日志可能会丢失。对于关键日志(比如错误信息),可以考虑同步写入,或者增加掉电保护机制(比如使用电容保持供电几毫秒)。

整体架构图

说了这么多,咱们用一张图把整个日志系统的结构串起来:

日志系统整体架构 业务代码层 LOG_ERROR / LOG_WARN / LOG_INFO / LOG_DEBUG / LOG_TRACE 日志核心处理层 分级过滤 → 格式化(时间戳+文件名+行号)→ 写入缓冲区 缓冲区大小:4KB,满则触发写入信号 异步写入层(独立线程) 等待信号 → 加锁 → fwrite写入文件 → fflush → 解锁 文件滚动层 检查文件大小 → 超过1MB → 滚动到下一个文件 → 删除最旧文件 分级过滤 异步写入 文件滚动

从这张图可以看得很清楚:业务代码只管调用宏,核心层负责过滤和格式化,异步线程负责写入,文件滚动层负责管理存储。每一层各司其职,互不干扰。

避坑指南

最后,分享几个我踩过的坑:

  • 日志缓冲区太小: 我曾经设了个 256 字节的缓冲区,结果日志一多就溢出,导致数据错乱。建议至少 4KB,如果内存允许,8KB 更好。
  • 忘记加锁: 多线程环境下,日志缓冲区是共享资源。不加锁的话,两个线程同时写入,数据就乱了。我吃过这个亏,调试了整整两天才发现是日志系统本身的问题。
  • fprintf 的陷阱: fprintf 内部会做格式化,性能比 fwrite 差很多。异步写入时,建议先用 snprintf 格式化好,再用 fwrite 写入文件。
  • 日志级别设置不当: 发布版本时,DEBUG 和 TRACE 级别的日志一定要关掉。否则不仅浪费存储,还会拖慢系统。我见过一个产品,发布时忘了关 DEBUG,结果日志文件一天就写满了 128MB 的 Flash。

好了,日志系统设计就聊到这里。记住一句话:好的日志系统,平时感觉不到它的存在,出问题时它就是你最好的朋友。


专注资料整理