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失败案例来拆解,看看日志里到底藏着什么线索。