日志系统架构:多级别日志、异步日志、日志轮转

日志这东西,说实话,很多嵌入式工程师一开始都不太重视。我早年也踩过这个坑——程序跑着跑着突然挂了,连个现场信息都没留下,那叫一个抓瞎。后来我养成了一个习惯:任何交付给客户的产品,日志系统必须到位。今天我们就来聊聊,怎么在嵌入式环境里,搭一套靠谱的日志系统。

为什么需要多级别日志?

你想想看,如果所有日志都用一个级别打印,调试信息、警告、错误全混在一起,那日志文件基本没法看。我见过一个项目,DEBUG 日志刷得飞起,结果真正有用的错误信息被淹没了。排查问题的时候,翻了几万行日志才找到关键线索。

所以,日志级别是刚需。常见的分级方式是这样的:

级别 含义 使用场景
FATAL 致命错误 系统无法继续运行,必须立即处理
ERROR 错误 功能异常,但系统还能跑
WARN 警告 潜在问题,值得关注
INFO 信息 关键流程节点,比如启动、初始化完成
DEBUG 调试 开发阶段用的详细输出
TRACE 跟踪 最细粒度,函数调用追踪

我个人习惯,发布版本只保留 INFO 及以上级别。DEBUG 和 TRACE 只在开发阶段打开。这样既能保证现场信息够用,又不会把存储空间撑爆。

异步日志:别让日志拖慢你的系统

很多新手写日志,直接 printf 或者 fprintf 到文件。这在简单场景下没问题,但一旦系统负载上来,同步写日志会严重拖慢主流程。我记得有一次做工业控制器,日志打印一多,控制周期直接超时了。查了半天,罪魁祸首就是同步日志。

解决方案很简单:异步日志。核心思路是:

  • 日志生产方(业务代码)只管把日志丢进缓冲区
  • 日志消费方(后台线程)负责从缓冲区取数据,写入文件
  • 生产者和消费者之间用队列解耦

来看一个简化版的实现:

// 日志队列结构
typedef struct {
    char buffer[LOG_BUF_SIZE][MAX_LOG_LEN];
    int head;
    int tail;
    int count;
    pthread_mutex_t lock;
    pthread_cond_t not_empty;
} log_queue_t;

// 生产者:写入日志
void log_write(const char *msg) {
    pthread_mutex_lock(&queue.lock);
    while (queue.count >= LOG_BUF_SIZE) {
        // 队列满了,可以丢弃或等待
        pthread_cond_wait(&queue.not_full, &queue.lock);
    }
    strncpy(queue.buffer[queue.tail], msg, MAX_LOG_LEN);
    queue.tail = (queue.tail + 1) % LOG_BUF_SIZE;
    queue.count++;
    pthread_cond_signal(&queue.not_empty);
    pthread_mutex_unlock(&queue.lock);
}

// 消费者:后台线程
void *log_flusher(void *arg) {
    while (1) {
        pthread_mutex_lock(&queue.lock);
        while (queue.count == 0) {
            pthread_cond_wait(&queue.not_empty, &queue.lock);
        }
        char msg[MAX_LOG_LEN];
        strncpy(msg, queue.buffer[queue.head], MAX_LOG_LEN);
        queue.head = (queue.head + 1) % LOG_BUF_SIZE;
        queue.count--;
        pthread_mutex_unlock(&queue.lock);
        // 写入文件
        fputs(msg, log_file);
        fflush(log_file);
    }
    return NULL;
}
核心要点:异步日志的关键是队列设计。队列大小要合理——太小容易丢日志,太大浪费内存。我一般根据系统最大日志产生速率来估算,留出 2 倍余量。

日志轮转:别让日志撑爆存储

嵌入式设备的存储空间通常有限。如果不做日志轮转,日志文件会一直增长,直到把 Flash 或 SD 卡写满。我遇到过一台设备,运行半年后日志文件占了 2GB,系统直接报存储不足。从那以后,日志轮转成了我项目的标配

常见的轮转策略有两种:

  • 按大小轮转:单个日志文件达到指定大小(比如 10MB),就自动切换到下一个文件
  • 按时间轮转:每天或每小时生成一个新的日志文件

实际项目中,我通常两种策略结合使用。比如:每个文件最大 10MB,最多保留 5 个文件。超过 5 个就覆盖最旧的那个。

代码实现思路:

#define MAX_LOG_FILES 5
#define MAX_FILE_SIZE (10 * 1024 * 1024)  // 10MB

void log_rotate(void) {
    // 检查当前文件大小
    fseek(log_file, 0, SEEK_END);
    long size = ftell(log_file);
    if (size < MAX_FILE_SIZE) return;

    // 关闭当前文件
    fclose(log_file);

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

    // 创建新文件
    current_index++;
    char new_name[64];
    snprintf(new_name, sizeof(new_name), "log_%d.txt", current_index);
    log_file = fopen(new_name, "w");
}
小技巧:轮转时别忘了加时间戳。我习惯在文件名里带上日期,比如 "log_2025-01-15_01.txt",这样排查问题时一眼就能找到对应时间段的日志。

整体架构图

下面这张图,展示了日志系统的整体流程。从应用层产生日志,到异步队列缓冲,再到后台线程写入文件,最后按策略轮转。一目了然。

日志系统架构图 应用层 产生日志 日志级别过滤 FATAL/ERROR/WARN/INFO/DEBUG 异步队列 生产者-消费者模型 后台线程 从队列取日志 文件写入 fputs + fflush 日志轮转 按大小/时间轮转 存储介质 Flash / SD卡 / 文件系统

避坑指南

我曾经踩过的坑:
  • 日志队列满了怎么办?我建议直接丢弃新日志,而不是阻塞业务线程。系统正常运行比记录日志更重要。
  • 多线程写日志要加锁吗?异步模式下,生产者之间需要互斥,但消费者只从队列取数据,不需要额外锁。
  • 日志文件不要放在根目录。我习惯单独建一个 /log 分区,防止日志写满导致系统崩溃。

总结

日志系统看似简单,但要做好其实不容易。多级别日志让你能快速定位问题,异步日志保证系统性能不受影响,日志轮转防止存储被撑爆。这三者结合起来,就是一套靠谱的嵌入式日志方案。

嗯,这套架构我在好几个项目里都用过,效果不错。你可以在自己的项目里试试,根据实际需求调整队列大小和轮转策略。记住,日志系统是为排查问题服务的,别让它成为新的问题来源


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