日志系统设计:分级日志、滚动日志、异步日志、日志性能优化
日志这东西,说实话,平时写 demo 的时候谁都不在意。但一旦产品上了线,出了问题查不到日志,那真是叫天天不应。我经历过好几次线上事故,最后都是靠日志定位的。所以今天咱们好好聊聊,怎么设计一个靠谱的嵌入式日志系统。
一、分级日志:别啥都往里面塞
日志分级的核心思想很简单——不同重要性的信息,用不同级别标记。我见过不少新手,所有打印都用 printf,调试完了也不删。结果正式运行时,日志文件里全是调试信息,真正有用的错误信息反而被淹没了。
常见的分级方式是这样的:
| 级别 | 含义 | 使用场景 |
|---|---|---|
| FATAL | 致命错误 | 系统无法继续运行,必须重启 |
| ERROR | 错误 | 功能异常,但系统还能跑 |
| WARN | 警告 | 潜在问题,值得关注 |
| INFO | 信息 | 关键流程节点,比如启动、连接 |
| DEBUG | 调试 | 开发阶段用,上线后关掉 |
| TRACE | 跟踪 | 最细粒度,函数进出都打 |
我个人习惯在代码里用宏定义来实现分级控制:
#define LOG_LEVEL LOG_LEVEL_INFO
#define LOG_FATAL(fmt, ...) \
do { if (LOG_LEVEL <= LOG_LEVEL_FATAL) \
log_output("FATAL", fmt, ##__VA_ARGS__); } while(0)
#define LOG_ERROR(fmt, ...) \
do { if (LOG_LEVEL <= LOG_LEVEL_ERROR) \
log_output("ERROR", fmt, ##__VA_ARGS__); } while(0)
#define LOG_INFO(fmt, ...) \
do { if (LOG_LEVEL <= LOG_LEVEL_INFO) \
log_output("INFO", fmt, ##__VA_ARGS__); } while(0)
#define LOG_DEBUG(fmt, ...) \
do { if (LOG_LEVEL <= LOG_LEVEL_DEBUG) \
log_output("DEBUG", fmt, ##__VA_ARGS__); } while(0)
你看,用 do-while 包一下,既保证了宏的完整性,又不会出分号问题。上线时把 LOG_LEVEL 改成 LOG_LEVEL_INFO,所有 DEBUG 和 TRACE 就自动消失了,编译期就优化掉了。
二、滚动日志:别让日志撑爆存储
嵌入式设备的存储空间通常很有限。日志如果不加控制,几天就能把 Flash 写满。滚动日志就是为了解决这个问题——只保留最近 N 个日志文件,旧的自动删除。
我常用的滚动策略有两种:
- 按大小滚动:每个日志文件固定大小(比如 1MB),写满就切下一个。文件数量到了上限,就覆盖最老的。
- 按时间滚动:每小时或每天生成一个新文件,保留最近 7 天的日志。
实际项目中,我更喜欢两者结合:
typedef struct {
char base_name[64]; // 日志文件前缀
int max_files; // 最多保留几个文件
int max_size; // 单个文件最大字节数
int current_index; // 当前写到哪个文件
int current_size; // 当前文件已写多少
} log_roller_t;
int log_roll_check(log_roller_t *roller) {
if (roller->current_size >= roller->max_size) {
// 当前文件写满了,切到下一个
roller->current_index = (roller->current_index + 1) % roller->max_files;
roller->current_size = 0;
// 打开新文件,准备写入
char filename[128];
snprintf(filename, sizeof(filename), "%s.%d.log",
roller->base_name, roller->current_index);
return open_new_file(filename);
}
return 0;
}
嗯,这里要注意一点——文件名的索引循环使用,最老的那个文件自然就被覆盖了。不需要手动删除,省事。
三、异步日志:别让日志拖慢主流程
同步写日志有个大问题——每次写文件都要等磁盘 I/O。在嵌入式系统里,如果主循环里频繁写日志,延时可能从微秒级飙升到毫秒级。你想想看,一个实时控制系统,因为写日志导致响应超时,这多冤啊。
异步日志的思路很简单:把日志先写到内存缓冲区,后台有个专门的线程或任务负责把缓冲区里的内容刷到磁盘。
核心结构大概这样:
#define LOG_BUF_SIZE 4096
#define LOG_BUF_NUM 4
typedef struct {
char buffer[LOG_BUF_NUM][LOG_BUF_SIZE];
int write_idx; // 生产者(主线程)当前写入哪个缓冲区
int read_idx; // 消费者(日志线程)当前读取哪个缓冲区
int write_pos; // 当前缓冲区内的写入位置
sem_t sem_empty; // 空缓冲区信号量
sem_t sem_full; // 满缓冲区信号量
} async_logger_t;
// 生产者:主线程调用
void async_log_write(async_logger_t *log, const char *msg) {
sem_wait(&log->sem_empty);
int len = strlen(msg);
memcpy(log->buffer[log->write_idx] + log->write_pos, msg, len);
log->write_pos += len;
if (log->write_pos + 128 >= LOG_BUF_SIZE) {
// 当前缓冲区快满了,切换
log->write_idx = (log->write_idx + 1) % LOG_BUF_NUM;
log->write_pos = 0;
sem_post(&log->sem_full);
}
}
// 消费者:日志线程调用
void async_log_flush(async_logger_t *log) {
while (1) {
sem_wait(&log->sem_full);
// 把 buffer[read_idx] 写入文件
write_to_file(log->buffer[log->read_idx]);
log->read_idx = (log->read_idx + 1) % LOG_BUF_NUM;
sem_post(&log->sem_empty);
}
}
说白了,这就是一个典型的生产者-消费者模型。主线程只管往缓冲区里塞数据,日志线程慢慢往磁盘写。主线程几乎不会被阻塞。
四、日志性能优化:别让日志成为瓶颈
日志写多了,性能肯定会下降。但有些优化手段能让影响降到最低。我总结了几条实战经验:
- 避免在日志路径里做复杂计算。比如 sprintf 格式化浮点数,这玩意儿很慢。能提前算好的就提前算好,日志里只记录结果。
- 批量写入。攒够一批日志再写文件,别来一条写一条。异步日志天然支持这个。
- 关掉文件系统缓存同步。在 Linux 下,write() 之后别立刻调 fsync(),除非你要求每条日志都必须落盘。大部分场景下,掉电丢几条日志是可以接受的。
- 用内存文件系统。如果日志只是临时调试用,直接写到 /tmp 或者 ramdisk 里,速度比 Flash 快一个数量级。
我给大家画个图,把整个日志系统的流程串起来:
从图里能看出来,整个流程是串起来的:应用代码打日志 → 分级过滤 → 异步缓冲区 → 滚动管理器 → 最终写到文件系统。每个环节都可以做优化,但最关键的还是异步和滚动这两块。
好了,关于日志系统设计,核心就是这四块。分级让你看得清,滚动让你存得下,异步让你跑得快,优化让你用得久。把这几点吃透了,你设计的日志系统在大部分嵌入式场景里都够用了。
公众号:蓝海资料掘金营,微信deep3321