4、日志采集与存储:本地日志存储、远程日志上报、日志轮转策略

好,咱们接着聊。上一节我们把日志系统的基础框架搭起来了,这一节要解决一个很实际的问题:日志写到哪里?写多少?什么时候删?

说白了,OTA升级过程中产生的日志,就是你的“黑匣子”。升级失败了,全靠它来还原现场。我见过太多团队,升级出问题后一脸懵,就是因为日志没存下来,或者被覆盖了。嗯,这节咱们就把这个坑填上。

4.1 本地日志存储:写在Flash还是SD卡?

先说说本地存储。OTA升级日志的存储介质,我一般分三种情况:

  • 内部Flash:适合小容量、高可靠性场景。比如MCU类设备,日志量不大,几百KB就够用。
  • 外部SPI Flash:性价比高,容量从几MB到几十MB。我项目中用的最多的是W25Q系列,16MB够跑好几轮升级日志。
  • SD卡/eMMC:适合Linux类设备,日志量大,但要注意掉电保护。

我个人习惯,在资源允许的情况下,优先用外部Flash。为什么?因为内部Flash擦写次数有限,而且万一日志写坏了,系统可能都起不来。你想想看,为了看个日志把设备搞成砖,这代价太大了。

这里给一个简单的日志写入示例,基于SPI Flash:

// 日志存储结构体
typedef struct {
    uint32_t magic;        // 魔数,用于校验
    uint32_t timestamp;    // 时间戳
    uint16_t length;       // 日志长度
    uint8_t  level;        // 日志级别
    uint8_t  reserved;     // 保留
    uint8_t  data[0];      // 日志内容,柔性数组
} log_entry_t;

// 写入一条日志到Flash
int log_write_to_flash(uint8_t level, const char *fmt, ...) {
    char buffer[LOG_MAX_LEN];
    va_list args;
    va_start(args, fmt);
    vsnprintf(buffer, sizeof(buffer), fmt, args);
    va_end(args);
    
    uint16_t len = strlen(buffer);
    uint32_t total_len = sizeof(log_entry_t) + len + 1; // +1 for '\0'
    
    // 检查剩余空间,不够则触发轮转
    if (flash_free_space() < total_len) {
        log_rotate();  // 轮转策略,后面讲
    }
    
    log_entry_t *entry = (log_entry_t *)malloc(total_len);
    entry->magic = LOG_MAGIC;
    entry->timestamp = get_system_tick();
    entry->length = len;
    entry->level = level;
    memcpy(entry->data, buffer, len + 1);
    
    // 写入Flash,注意要4字节对齐
    flash_write(entry, total_len);
    free(entry);
    
    return 0;
}
注意:Flash写入前一定要先擦除!我刚开始做的时候,忘了擦除直接写,结果读出来的数据全是乱的。排查了半天才发现是这个问题。另外,写入地址要4字节对齐,否则有些Flash会报错。

4.2 远程日志上报:什么时候传?传多少?

本地日志存好了,但总不能每次都去现场拿设备吧?远程上报才是王道。不过这里有个矛盾:日志越详细越好,但网络带宽和流量是有限的。

我在项目中遇到过这样的情况:设备上报了完整的升级日志,结果一个月流量费比设备本身还贵。后来我们改了策略,只上报关键信息。

远程上报我一般分三个等级:

等级 触发条件 上报内容 典型场景
L1 - 精简上报 升级成功 设备ID、版本号、耗时、结果码 正常升级,确认状态
L2 - 详细上报 升级失败 L1内容 + 最近20条错误日志 + 堆栈信息 故障排查,定位原因
L3 - 全量上报 严重错误/死机 整个日志分区内容 + 系统快照 灾难恢复,分析根因

上报协议我推荐用MQTT或者CoAP,轻量级,适合物联网设备。代码示例:

// 上报日志到云端
void report_log_to_cloud(log_report_level_t level) {
    // 构建上报数据包
    cJSON *root = cJSON_CreateObject();
    cJSON_AddStringToObject(root, "device_id", g_device_id);
    cJSON_AddNumberToObject(root, "timestamp", get_unix_timestamp());
    cJSON_AddNumberToObject(root, "report_level", level);
    
    if (level == REPORT_L1) {
        // 只上报关键字段
        cJSON_AddStringToObject(root, "result", g_ota_result);
        cJSON_AddNumberToObject(root, "cost_ms", g_ota_cost);
    } else if (level == REPORT_L2) {
        // 附加错误日志
        cJSON_AddItemToObject(root, "error_logs", get_recent_errors(20));
    } else if (level == REPORT_L3) {
        // 全量日志,压缩后上传
        char *compressed = compress_log_partition();
        cJSON_AddStringToObject(root, "full_log", compressed);
        free(compressed);
    }
    
    char *payload = cJSON_Print(root);
    mqtt_publish("ota/log/report", payload, QOS_1);
    cJSON_Delete(root);
    free(payload);
}
小技巧:上报失败时,不要立即重试。我一般用指数退避策略:第一次等10秒,第二次等30秒,第三次等90秒...最多重试5次。如果还失败,就把日志标记为“待上报”,等下次设备唤醒时再试。

4.3 日志轮转策略:存多少?什么时候删?

日志不能无限增长,否则Flash迟早被写满。轮转策略就是解决这个问题的。说白了,就是“旧的不去,新的不来”。

我常用的轮转策略有三种:

  • 基于大小轮转:日志分区固定大小,写满后删除最旧的日志。比如分区16MB,写满后从头部开始覆盖。
  • 基于时间轮转:保留最近N天的日志,超期的自动删除。适合需要回溯历史场景的设备。
  • 基于数量轮转:保留最近N条日志,超出的删除。适合日志条目数可控的场景。

我个人最推荐的是“大小+时间”混合策略。举个例子:

// 日志轮转配置
typedef struct {
    uint32_t partition_size;   // 分区总大小,单位字节
    uint32_t max_retain_days;  // 最大保留天数,0表示不限
    uint32_t min_free_space;   // 最小剩余空间,低于此值触发轮转
    uint8_t  rotate_policy;    // 轮转策略:0-大小,1-时间,2-混合
} log_rotate_config_t;

// 默认配置:16MB分区,保留30天,剩余空间低于1MB时触发轮转
log_rotate_config_t g_rotate_cfg = {
    .partition_size = 16 * 1024 * 1024,
    .max_retain_days = 30,
    .min_free_space = 1 * 1024 * 1024,
    .rotate_policy = 2  // 混合策略
};

// 执行轮转
void log_rotate(void) {
    uint32_t free_space = flash_get_free_space();
    
    // 检查是否需要轮转
    if (free_space >= g_rotate_cfg.min_free_space) {
        return;  // 空间充足,不轮转
    }
    
    // 需要轮转,找到最旧的日志块
    log_entry_t *oldest = find_oldest_entry();
    if (oldest == NULL) return;
    
    // 检查时间条件
    if (g_rotate_cfg.max_retain_days > 0) {
        uint32_t age_seconds = get_system_tick() - oldest->timestamp;
        if (age_seconds < g_rotate_cfg.max_retain_days * 86400) {
            // 最旧的日志还没超期,但空间不够了
            // 这时候只能强制删除,或者报警
            log_warning("日志分区即将写满,请及时处理!");
        }
    }
    
    // 删除最旧的日志块
    flash_erase(oldest->address, oldest->total_size);
    update_log_index();  // 更新索引
}
核心原则:日志轮转不能影响OTA升级本身。我见过一个设备,升级过程中日志轮转占用了大量CPU时间,导致升级超时。后来我们把轮转操作放到升级完成后的空闲时段执行,问题就解决了。

4.4 避坑指南:我踩过的几个坑

做日志存储这么多年,踩过的坑不少。挑几个典型的说说:

  • 坑一:Flash写入掉电——写入过程中掉电,日志写到一半,下次读取时校验失败。解决方案:先写完整数据,最后写校验位。如果校验位没写成功,说明数据不完整,直接丢弃。
  • 坑二:日志索引丢失——索引区损坏,所有日志都找不到了。我后来改用“链表式”存储,每条日志都包含下一条日志的地址,即使索引丢了,也能从头遍历。
  • 坑三:上报数据太大——全量日志压缩后还有好几MB,MQTT报文超限。解决方案:分片上传,每片最大1KB,云端再重组。
  • 坑四:日志时间戳不准——设备没有RTC,时间戳用的是系统启动后的tick。结果不同设备的时间戳对不上,排查问题很痛苦。后来我强制要求所有设备在升级前先同步NTP时间。

嗯,这一节的内容就这些。日志采集和存储看似简单,但细节很多。你想想看,如果日志本身出了问题,那还怎么用它来排查问题?所以这块值得花功夫做好。

下一节我们聊聊日志分析的具体方法,到时候我会拿几个真实的OTA失败案例来拆解,看看日志里到底藏着什么线索。