日志系统设计:分级日志、滚动日志、异步日志、日志性能优化

日志这东西,说实话,平时写 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 就自动消失了,编译期就优化掉了。

小技巧:我建议在产品代码里保留 DEBUG 级别的日志宏,但通过编译开关控制。万一现场出了问题,重新编译一版带 DEBUG 的固件刷进去,就能拿到详细日志。比让现场工程师重新加打印快多了。

二、滚动日志:别让日志撑爆存储

嵌入式设备的存储空间通常很有限。日志如果不加控制,几天就能把 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;
}

嗯,这里要注意一点——文件名的索引循环使用,最老的那个文件自然就被覆盖了。不需要手动删除,省事。

踩坑提醒:我曾经在一个项目里,滚动日志的 max_files 设成了 3,结果日志文件被覆盖得太快,出问题时根本找不到历史记录。建议至少保留 5 个以上,关键系统可以保留 10 个。

三、异步日志:别让日志拖慢主流程

同步写日志有个大问题——每次写文件都要等磁盘 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);
    }
}

说白了,这就是一个典型的生产者-消费者模型。主线程只管往缓冲区里塞数据,日志线程慢慢往磁盘写。主线程几乎不会被阻塞。

核心要点:异步日志的缓冲区大小和数量要根据你的系统来调。缓冲区太小,主线程频繁等待;缓冲区太大,内存占用高。我一般从 4 个 4KB 的缓冲区开始调,根据实际压力测试再调整。

四、日志性能优化:别让日志成为瓶颈

日志写多了,性能肯定会下降。但有些优化手段能让影响降到最低。我总结了几条实战经验:

  • 避免在日志路径里做复杂计算。比如 sprintf 格式化浮点数,这玩意儿很慢。能提前算好的就提前算好,日志里只记录结果。
  • 批量写入。攒够一批日志再写文件,别来一条写一条。异步日志天然支持这个。
  • 关掉文件系统缓存同步。在 Linux 下,write() 之后别立刻调 fsync(),除非你要求每条日志都必须落盘。大部分场景下,掉电丢几条日志是可以接受的。
  • 用内存文件系统。如果日志只是临时调试用,直接写到 /tmp 或者 ramdisk 里,速度比 Flash 快一个数量级。

我给大家画个图,把整个日志系统的流程串起来:

日志系统整体架构 应用代码 LOG_INFO / LOG_ERROR 分级过滤层 FATAL > ERROR > WARN > INFO > DEBUG 异步缓冲区 生产者-消费者模型 滚动管理器 按大小 / 按时间滚动 文件系统 / Flash log.0.log ~ log.N.log 性能优化要点 • 避免日志路径中复杂计算 • 批量写入,减少 I/O 次数 • 谨慎使用 fsync • 调试日志用内存文件系统 • 分级编译时裁剪

从图里能看出来,整个流程是串起来的:应用代码打日志 → 分级过滤 → 异步缓冲区 → 滚动管理器 → 最终写到文件系统。每个环节都可以做优化,但最关键的还是异步和滚动这两块。

我的建议:别一开始就追求完美。先实现一个能用的分级日志,加上简单的滚动。等系统跑起来之后,用性能分析工具看看日志到底占了多少 CPU 时间。如果确实成了瓶颈,再上异步。过早优化是万恶之源嘛。

好了,关于日志系统设计,核心就是这四块。分级让你看得清,滚动让你存得下,异步让你跑得快,优化让你用得久。把这几点吃透了,你设计的日志系统在大部分嵌入式场景里都够用了。


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