3. 日志系统架构:日志级别定义、日志存储机制、日志轮转策略

日志系统,说白了就是智能音箱的「黑匣子」。

我做了这么多年嵌入式,最怕的就是产品出了问题,结果日志一塌糊涂——要么没记,要么记太多把存储撑爆了。嗯,今天咱们就把日志系统的三个核心问题讲透:记什么、记在哪、记满了怎么办。

3.1 日志级别定义:别什么都往里塞

很多新手工程师喜欢把所有信息都打上 printf,结果线上产品日志里全是「WiFi连接成功」「音量调到50%」这种废话。真正出故障时,关键信息反而被淹没了。

我个人习惯,把日志分成5个级别。你想想看,这就像交通信号灯——不同颜色代表不同紧急程度。

级别 名称 含义 线上是否开启
0 FATAL 致命错误,系统无法继续运行 ✅ 必须开启
1 ERROR 功能异常,但系统可降级运行 ✅ 必须开启
2 WARN 潜在风险,需要关注 ✅ 建议开启
3 INFO 关键流程节点记录 ✅ 建议开启
4 DEBUG 调试信息,仅开发阶段使用 ❌ 关闭

核心原则:线上产品只开 FATAL、ERROR、WARN 和少量 INFO。DEBUG 日志只在开发板或远程调试时临时开启。

我在项目中遇到过一件事:某次智能音箱半夜频繁重启,线上日志全是 INFO 级别的「心跳包正常」。后来我强制把日志级别降到 DEBUG,才发现是某个传感器驱动在特定条件下触发了看门狗复位。你看,级别设错了,排查方向就全偏了。

代码实现上,我一般这样定义:

typedef enum {
    LOG_LEVEL_FATAL = 0,
    LOG_LEVEL_ERROR = 1,
    LOG_LEVEL_WARN  = 2,
    LOG_LEVEL_INFO  = 3,
    LOG_LEVEL_DEBUG = 4
} log_level_t;

#define LOG_PRINT(level, fmt, ...) \
    do { \
        if (level <= g_log_threshold) { \
            log_output(level, __FILE__, __LINE__, fmt, ##__VA_ARGS__); \
        } \
    } while(0)

这里有个小技巧:g_log_threshold 可以在运行时通过 AT 指令或云端配置动态修改。线上出问题时,远程把阈值降到 DEBUG,就能抓到现场信息。排查完再改回去,不影响性能。

避坑指南:我曾经在某个项目里把 DEBUG 日志的宏定义写成了 printf,结果线上版本忘记关掉,导致每秒输出几百条日志,Flash 芯片一个月就写报废了。所以一定要用宏开关控制,编译时就把 DEBUG 日志彻底去掉。

3.2 日志存储机制:写在哪儿是个学问

智能音箱不像服务器有海量硬盘。它的存储资源非常有限——可能只有 16MB 的 SPI Flash,还要分给固件、配置、语音数据。日志能占多少?我一般只给 1~2MB。

存储方案主要有三种,我按推荐程度排个序:

  1. 环形缓冲区(Ring Buffer):固定大小,写满后覆盖最旧的数据。适合记录最近一段时间的日志。
  2. 文件系统 + 日志文件:使用 LittleFS 或 SPIFFS 这类轻量文件系统,按文件管理日志。
  3. 直接写 Flash 扇区:绕过文件系统,直接操作 Flash 地址。性能高,但管理麻烦。

我个人最常用的是「环形缓冲区 + 定期转储」的组合方案。说白了就是:内存里维护一个环形缓冲区,实时写入日志;当缓冲区快满时,一次性刷到 Flash 里保存。

代码结构大概这样:

#define LOG_BUF_SIZE  (16 * 1024)  // 16KB 环形缓冲区
#define LOG_FLASH_SECTOR  (0x1F0000) // Flash 地址

static uint8_t log_buf[LOG_BUF_SIZE];
static uint32_t write_index = 0;

void log_write(const char *msg) {
    uint32_t len = strlen(msg);
    // 环形写入
    for (uint32_t i = 0; i < len; i++) {
        log_buf[(write_index + i) % LOG_BUF_SIZE] = msg[i];
    }
    write_index = (write_index + len) % LOG_BUF_SIZE;
}

void log_flush_to_flash(void) {
    // 将环形缓冲区内容写入 Flash 指定扇区
    flash_erase(LOG_FLASH_SECTOR);
    flash_write(LOG_FLASH_SECTOR, log_buf, LOG_BUF_SIZE);
}

注意:Flash 写入有寿命限制,一般 10 万次擦写。如果每 10 秒刷一次日志,两年就报废了。所以刷写频率要控制好——我一般设置成每小时一次,或者日志量达到 80% 时才触发。

3.3 日志轮转策略:别让日志撑死系统

日志轮转,说白了就是「旧的不去,新的不来」。

我记得有一次,客户反馈音箱用了一个月后变得特别卡。查了半天,发现是日志文件没有轮转,一直追加写入,把 Flash 空间占满了,导致文件系统无法分配新空间。

从那以后,我设计日志轮转时一定会考虑三个参数:

  • 最大文件数:比如保留最近 3 个日志文件
  • 单个文件大小:比如每个文件不超过 512KB
  • 轮转触发条件:写满就切,或者按时间切

具体实现上,我推荐「固定数量 + 覆盖最旧」的策略:

#define MAX_LOG_FILES  3
#define LOG_FILE_SIZE  (512 * 1024)  // 512KB

void log_rotate(void) {
    // 检查当前文件是否超过大小限制
    uint32_t file_size = get_file_size("log_0.txt");
    if (file_size < LOG_FILE_SIZE) {
        return;  // 没满,继续写
    }
    
    // 文件满了,执行轮转
    // log_2.txt -> 删除
    // log_1.txt -> 重命名为 log_2.txt
    // log_0.txt -> 重命名为 log_1.txt
    // 新建 log_0.txt
    remove("log_2.txt");
    rename("log_1.txt", "log_2.txt");
    rename("log_0.txt", "log_1.txt");
    create_new("log_0.txt");
}

关键点:轮转操作本身也要记录日志!我曾经遇到过轮转过程中突然断电,导致文件系统损坏。后来我加了一个「轮转标志位」——开始轮转前写一个标记,轮转完成后清除。下次启动时检查这个标记,如果发现异常,就重建日志系统。

另外,我建议在日志文件头部加一个固定格式的元信息:

[HEADER]
version=1.0
device_id=SN20240315001
boot_count=128
timestamp=1710567890
[END_HEADER]
2024-03-16 10:30:01 [INFO] 系统启动完成
2024-03-16 10:30:02 [WARN] 麦克风阵列校准偏差较大
...

这样做的好处是,即使日志文件被轮转覆盖了,你也能从元信息里知道这个文件对应哪个设备、哪次启动。我在远程排查问题时,经常靠这个快速定位。

一个小建议:日志轮转的时间点最好选在系统空闲时。比如凌晨 3 点,用户都睡了,音箱处于待机状态。这时候做轮转,既不影响用户体验,也能保证操作安全。

好了,日志系统的三个核心问题就讲到这里。总结一下:

  • 级别要分清楚,线上只开必要级别
  • 存储用环形缓冲区 + Flash 定期转储
  • 轮转策略要防写满、防丢失、防断电

下一章咱们聊聊「日志采集与上报机制」——怎么把设备端的日志安全、高效地传到云端。到时候我会分享一个我踩过的坑,关于 MQTT 传输日志时丢数据的血泪史。