4. 文件日志输出:将日志写入文件,文件打开与关闭管理,日志文件滚动策略

日志光打在终端上,说实话,只能应付开发阶段。一旦程序部署到嵌入式设备上,你不可能天天盯着串口看。我早期做的一个车载项目,设备跑在路测车上,日志全往终端打,结果现场出了问题,什么痕迹都没留下。从那以后,我养成了一个习惯——所有正式版本,必须配文件日志输出。

这一章,我们就来聊聊怎么把日志老老实实写到文件里。包括文件怎么打开、怎么关闭、怎么防止日志把磁盘撑爆。嗯,都是实战里踩过的坑。

4.1 文件日志的基本流程

文件日志的核心逻辑其实不复杂:

  1. 打开一个日志文件(追加模式)
  2. 把格式化好的日志字符串写进去
  3. 写完及时关闭(或者用缓冲区批量刷)
  4. 当日志文件太大时,做滚动处理

你想想看,如果每次写日志都打开关闭一次文件,性能会非常差。尤其是嵌入式设备,Flash写入次数有限,频繁操作会缩短寿命。所以,我一般会维护一个全局的文件指针,在系统初始化时打开,在系统退出时关闭。

核心原则:日志文件句柄的生命周期,应该与日志系统的生命周期一致。不要每条日志都 fopen/fclose。

4.2 文件打开与关闭管理

先看一个基础实现。我习惯把文件操作封装在日志模块内部,对外只暴露日志写入接口。

/* log_file.c */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>

static FILE *g_log_fp = NULL;
static char g_log_path[256] = {0};

/* 初始化日志文件 */
int log_file_init(const char *filepath) {
    if (g_log_fp != NULL) {
        fclose(g_log_fp);
        g_log_fp = NULL;
    }

    strncpy(g_log_path, filepath, sizeof(g_log_path) - 1);
    g_log_fp = fopen(filepath, "a");  /* 追加模式 */
    if (g_log_fp == NULL) {
        perror("fopen log file failed");
        return -1;
    }

    /* 设置行缓冲,减少写入次数 */
    setvbuf(g_log_fp, NULL, _IOLBF, 0);
    return 0;
}

/* 关闭日志文件 */
void log_file_deinit(void) {
    if (g_log_fp) {
        fclose(g_log_fp);
        g_log_fp = NULL;
    }
}

/* 写入一行日志 */
void log_file_write(const char *level, const char *msg) {
    if (g_log_fp == NULL) return;

    time_t now = time(NULL);
    struct tm *tm_info = localtime(&now);
    char time_buf[32];
    strftime(time_buf, sizeof(time_buf), "%Y-%m-%d %H:%M:%S", tm_info);

    fprintf(g_log_fp, "[%s] [%s] %s\n", time_buf, level, msg);
    /* 行缓冲模式下,遇到'\n'会自动刷,但保险起见可以手动fflush */
    /* fflush(g_log_fp); */
}

我的经验:setvbuf 设置 _IOLBF(行缓冲)是个好习惯。每条日志以换行结尾,写入后自动刷到磁盘。既保证了性能,又不会因为掉电丢太多数据。如果你用全缓冲,记得在关键日志后手动 fflush。

这里有个细节——文件打开模式。我推荐用 "a"(追加),而不是 "w"(覆盖)。为什么?因为程序可能多次重启,覆盖模式会把之前的日志清掉,现场排查时你就抓瞎了。我曾经在调试一个通信协议栈时,忘了改模式,结果每次重启日志都从零开始,查了半天才发现问题。

4.3 日志文件滚动策略

日志一直写下去,磁盘总有满的一天。嵌入式设备的存储空间本来就金贵,所以必须做滚动——说白了,就是日志文件到了大小上限,自动换一个新文件,旧的保留几个备份。

常见的滚动策略有两种:

策略 原理 适用场景
按大小滚动 文件达到指定字节数(如1MB),自动切换 日志量可控,关注磁盘占用
按时间滚动 每小时/每天生成一个新文件 需要按时间段归档,便于回溯

我个人更常用按大小滚动,因为嵌入式设备的存储空间是硬约束。下面是一个实现示例:

/* 日志滚动配置 */
#define LOG_MAX_SIZE  (1024 * 1024)   /* 1MB */
#define LOG_MAX_BACKUP  3             /* 保留3个备份 */

/* 检查文件大小,决定是否滚动 */
int log_file_roll_if_needed(void) {
    if (g_log_fp == NULL) return -1;

    long cur_size = ftell(g_log_fp);
    if (cur_size < LOG_MAX_SIZE) {
        return 0;  /* 没超限,不用滚动 */
    }

    /* 关闭当前文件 */
    fclose(g_log_fp);
    g_log_fp = NULL;

    /* 删除最旧的备份,依次重命名 */
    char old_path[256], new_path[256];

    /* 删除最老的备份文件 */
    snprintf(old_path, sizeof(old_path), "%s.%d", g_log_path, LOG_MAX_BACKUP);
    remove(old_path);

    /* 依次重命名:.2 -> .3, .1 -> .2, .0 -> .1 */
    for (int i = LOG_MAX_BACKUP - 1; i >= 0; i--) {
        snprintf(old_path, sizeof(old_path), "%s.%d", g_log_path, i);
        snprintf(new_path, sizeof(new_path), "%s.%d", g_log_path, i + 1);
        rename(old_path, new_path);
    }

    /* 将当前日志文件重命名为 .0 */
    rename(g_log_path, old_path);  /* old_path 现在是 xxx.log.0 */

    /* 重新打开新文件 */
    g_log_fp = fopen(g_log_path, "a");
    if (g_log_fp == NULL) {
        perror("fopen after roll failed");
        return -1;
    }

    setvbuf(g_log_fp, NULL, _IOLBF, 0);
    return 1;  /* 表示发生了滚动 */
}

注意:滚动操作涉及文件重命名和删除,在多线程环境下必须加锁。否则,两个线程同时触发滚动,文件状态就乱套了。我见过一个案例,滚动时没加锁,结果日志文件被删了,新文件又没创建成功,后续所有日志全部丢失。嗯,教训深刻。

4.4 滚动策略的改进:按时间+大小混合

实际项目中,我更喜欢混合策略。举个例子:

  • 每天生成一个日志文件,文件名带日期(如 app_2025-01-15.log
  • 单个文件超过1MB时,自动滚动到 app_2025-01-15.log.1.log.2……
  • 最多保留7个备份,超过则删除最老的

这样做的好处是:按日期归档方便查找,按大小控制又防止单个文件过大。代码实现上,只需要在滚动前先判断日期是否变化,如果跨天了,就生成新日期文件,而不是继续用旧文件名。

下面我用一张流程图,把整个文件日志的写入和滚动逻辑串起来:

文件日志写入与滚动流程图 日志写入请求 文件已打开? 打开/创建文件 写入日志内容 文件超限? 返回 执行文件滚动

4.5 避坑指南

文件日志看着简单,但坑不少。我把自己踩过的几个典型问题列出来:

  • 文件描述符泄漏: 如果程序异常崩溃,fclose 没执行,文件句柄就丢了。我建议在 log_file_init 里先检查旧句柄,有则关闭。另外,可以用 atexit 注册清理函数。
  • 写入性能: 每条日志都 fflush 会拖慢系统。行缓冲是个折中方案。如果日志量极大(比如调试网络协议),可以考虑异步写入——日志先入内存队列,后台线程批量刷盘。
  • 并发写入: 多线程写同一个文件,不加锁会导致内容交错。我一般用互斥锁保护 fprintf 调用。注意,锁的粒度要细,别把整个日志系统锁死了。
  • 磁盘满: 滚动策略只能延缓,不能根治。我习惯在写入前检查磁盘剩余空间,低于阈值时直接丢弃日志,或者只保留 ERROR 级别。

一个小技巧: 在嵌入式 Linux 上,可以用 logrotate 工具做滚动,省得自己写代码。但如果是裸机或 RTOS,还是得手撸。我曾在 FreeRTOS 上实现过一套轻量级滚动,核心代码不到 100 行,够用就好。

4.6 小结

文件日志输出,说白了就是三个事:打开、写入、滚动。打开要选对模式,写入要注意缓冲,滚动要防丢数据。我见过太多项目,日志系统写得花里胡哨,结果现场一跑,要么文件打不开,要么磁盘撑爆,要么日志乱成一锅粥。

稳一点,简单一点。把基础逻辑做扎实了,比什么都强。


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