18、日志文件系统实战:实现一个带时间戳的日志写入系统

日志,说白了就是程序的「黑匣子」。

我做了这么多年嵌入式开发,最怕的就是产品在现场出了问题,用户说「它突然就不动了」,而你手里连个日志都没有。那感觉,就像在黑暗中找一只黑猫——而且猫还不一定在屋里。

所以今天咱们就来亲手搭一个带时间戳的日志写入系统。别小看这个功能,我见过不少项目,日志写得乱七八糟,时间戳格式不统一,文件还动不动就写崩了。嗯,咱们今天一次搞定它。

18.1 日志系统要解决的核心问题

一个正经的日志系统,至少得做到这几点:

  • 每条日志都有时间戳——不然你根本不知道问题是什么时候发生的
  • 日志能追加写入——不能每次启动都覆盖掉之前的记录
  • 写入要稳定——程序崩溃了,日志不能跟着丢
  • 日志文件不能无限大——得有个大小控制或轮转机制

我个人习惯把日志系统分成三层:时间获取层格式化层文件写入层。每层各司其职,出了问题也好排查。

核心思路:日志系统 = 时间戳 + 格式化消息 + 可靠写入 + 文件管理

18.2 获取时间戳:time() 与 localtime()

C 标准库提供了两个函数,一个拿时间,一个转格式。

#include <stdio.h>
#include <time.h>

void get_timestamp(char *buf, size_t size) {
    time_t now = time(NULL);           // 获取当前时间(秒数)
    struct tm *t = localtime(&now);    // 转成本地时间结构体

    strftime(buf, size, "%Y-%m-%d %H:%M:%S", t);
}

这里有个坑,我踩过不止一次——localtime() 返回的指针指向一个静态缓冲区,不是线程安全的。如果你在多线程环境里同时调这个函数,时间戳可能会串。解决办法是用 localtime_r()(Linux 下可用),或者加锁。

注意:在多线程程序中,不要直接用 localtime(),改用 localtime_r() 或加互斥锁。

18.3 日志写入函数:fprintf 与 fflush

写日志最直接的方式就是用 fprintf()。但光写还不够,你得确保数据真的落盘了。

void write_log(const char *filename, const char *level, const char *msg) {
    FILE *fp = fopen(filename, "a");   // "a" 表示追加模式
    if (!fp) {
        perror("fopen");
        return;
    }

    char timestamp[64];
    get_timestamp(timestamp, sizeof(timestamp));

    fprintf(fp, "[%s] [%s] %s\n", timestamp, level, msg);
    fflush(fp);   // 强制刷新缓冲区,确保写入文件

    fclose(fp);
}

为什么要加 fflush()

因为 fprintf() 默认是带缓冲的。如果程序在写入后突然崩溃,缓冲区的数据就丢了。我曾经在一个数据采集项目里吃过这个亏——采集了一整天的数据,断电后日志文件里只有半天的记录。从那以后,凡是写日志,我必加 fflush()

小技巧:如果对性能要求高,可以每写 5~10 条日志才 flush 一次,或者用 setbuf() 设置无缓冲模式。

18.4 日志级别:让日志更有层次

日志不能只有一种级别。你想想看,调试信息和致命错误混在一起,排查问题的时候得翻到什么时候去?

我一般定义这几个级别:

级别 含义 使用场景
DEBUG 调试信息 开发阶段,跟踪变量、流程
INFO 普通信息 系统启动、配置加载等
WARN 警告 资源不足、配置异常但不致命
ERROR 错误 操作失败、连接断开等
FATAL 致命错误 程序无法继续运行

有了级别,你就可以在运行时控制日志的输出粒度。比如线上环境只输出 WARN 及以上,开发环境输出 DEBUG。

18.5 日志轮转:文件不能无限大

日志文件如果不加控制,几个月下来能撑爆硬盘。我见过一个服务器,日志文件 20 多个 G,打开都费劲。

简单的做法是:当文件超过一定大小,就重命名备份,然后新建一个日志文件

void rotate_log(const char *filename, long max_size) {
    FILE *fp = fopen(filename, "r");
    if (!fp) return;

    fseek(fp, 0, SEEK_END);
    long size = ftell(fp);
    fclose(fp);

    if (size > max_size) {
        char backup[256];
        snprintf(backup, sizeof(backup), "%s.old", filename);
        rename(filename, backup);   // 把当前日志重命名为 .old
    }
}

这个实现很简单,但够用。更复杂的轮转策略(比如按日期、保留多个备份)可以在此基础上扩展。

18.6 完整示例:一个可用的日志系统

把上面这些拼起来,就是一个能用的日志系统了:

#include <stdio.h>
#include <time.h>
#include <string.h>

#define LOG_FILE "app.log"
#define MAX_LOG_SIZE (1024 * 1024)  // 1MB

void get_timestamp(char *buf, size_t size) {
    time_t now = time(NULL);
    struct tm *t = localtime(&now);
    strftime(buf, size, "%Y-%m-%d %H:%M:%S", t);
}

void rotate_if_needed(const char *filename) {
    FILE *fp = fopen(filename, "r");
    if (!fp) return;
    fseek(fp, 0, SEEK_END);
    long size = ftell(fp);
    fclose(fp);

    if (size > MAX_LOG_SIZE) {
        char backup[256];
        snprintf(backup, sizeof(backup), "%s.bak", filename);
        rename(filename, backup);
    }
}

void log_msg(const char *level, const char *msg) {
    rotate_if_needed(LOG_FILE);

    FILE *fp = fopen(LOG_FILE, "a");
    if (!fp) {
        perror("fopen");
        return;
    }

    char ts[64];
    get_timestamp(ts, sizeof(ts));
    fprintf(fp, "[%s] [%s] %s\n", ts, level, msg);
    fflush(fp);
    fclose(fp);
}

int main() {
    log_msg("INFO", "系统启动");
    log_msg("DEBUG", "配置加载完成");
    log_msg("ERROR", "数据库连接失败");
    return 0;
}

运行后,日志文件内容大概长这样:

[2025-03-21 14:30:01] [INFO] 系统启动
[2025-03-21 14:30:01] [DEBUG] 配置加载完成
[2025-03-21 14:30:01] [ERROR] 数据库连接失败

18.7 日志系统的整体流程

下面这张图,把整个日志系统的数据流画清楚了:

日志消息 获取时间戳 (time + localtime) 格式化日志行 (fprintf) 写入文件 + 轮转检查 (fflush + rotate) 线程安全注意 追加模式 "a" 文件大小限制

18.8 避坑指南

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

  • 不要用 printf 写日志——重定向到文件虽然能用,但没法控制格式和级别
  • 日志文件路径要可配置——硬编码路径,换环境就得改代码
  • 注意文件描述符泄漏——每次 fopen 后一定要 fclose,不然跑几天就报 "Too many open files"
  • 日志里不要打印敏感信息——密码、密钥这些,写进去就是安全隐患

嗯,这套日志系统虽然简单,但足够应付大部分嵌入式和服务端场景了。你可以在它的基础上加更多功能,比如日志分级过滤、远程日志发送、日志压缩归档等等。核心骨架不变,往上加东西就稳了。


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