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 日志系统的整体流程
下面这张图,把整个日志系统的数据流画清楚了:
18.8 避坑指南
最后,分享几个我踩过的坑:
- 不要用 printf 写日志——重定向到文件虽然能用,但没法控制格式和级别
- 日志文件路径要可配置——硬编码路径,换环境就得改代码
- 注意文件描述符泄漏——每次 fopen 后一定要 fclose,不然跑几天就报 "Too many open files"
- 日志里不要打印敏感信息——密码、密钥这些,写进去就是安全隐患
嗯,这套日志系统虽然简单,但足够应付大部分嵌入式和服务端场景了。你可以在它的基础上加更多功能,比如日志分级过滤、远程日志发送、日志压缩归档等等。核心骨架不变,往上加东西就稳了。