双层存储需求
PipeMonitor 有两种存储需求:
- CSV 历史记录:供现场人员用读卡器导出,做离线分析和溯源
- 上行帧缓存:网络中断时暂存未送达的遥测/报警帧,恢复后自动补传
两者都用 LittleFS 文件系统,挂在 SPI NOR Flash 上,通过 FAL(Flash Abstraction Layer)管理分区。
存储线程:批量刷盘
采集线程每 2 秒产生一条测量快照,如果每次都写 Flash,写入频率太高会加速 NOR Flash 磨损。所以引入一个独立的存储线程,做批量刷盘:
#define STORAGE_BATCH_COUNT 4U /* 最多攒 4 条一起写 */
#define STORAGE_FLUSH_PERIOD_MS 5000U /* 最长 5 秒刷一次 */
存储线程从消息队列里收快照,攒够 4 条或超时 5 秒后,一次性写入 CSV:
while (1)
{
received = rt_mq_recv(&g_storage_mq, &measurement, sizeof(measurement),
rt_tick_from_millisecond(STORAGE_FLUSH_PERIOD_MS));
if (received == sizeof(measurement))
{
/* 攒到 batch_buffer 里 */
memcpy(&batch_buffer[batch_length], line_buffer, line_length);
batch_length += line_length;
batch_count++;
if (batch_count >= STORAGE_BATCH_COUNT)
{
app_storage_write_batch(batch_buffer, batch_length);
batch_length = 0;
batch_count = 0;
}
}
else if (batch_length > 0)
{
/* 超时,把剩余的刷出去 */
app_storage_write_batch(batch_buffer, batch_length);
batch_length = 0;
batch_count = 0;
}
}
这样 Flash 写入频率从每 2 秒一次降低到每 8-10 秒一次,同时保证断电最多丢 4 条数据。
CSV 文件格式
CSV 文件路径 /data/measurements.csv,表头:
timestamp,uptime_ms,flow_lpm,total_l,velocity_ms,pxw_temp_c,pxw_pres_mpa,
temp_4_1_c,temp_4_2_c,temp_5_1_c,temp_5_2_c,temp_5_3_c,temp_5_4_c
每行一条采样记录,时间戳用 YYYY-MM-DD HH:MM:SS 格式。如果 RTC 没同步,用 boot+Ns 格式表示启动后秒数。
static void app_storage_format_timestamp(char *buffer, rt_size_t size,
time_t timestamp, rt_tick_t tick)
{
if (timestamp <= 0 || local_tm.tm_year < (2024 - 1900))
{
rt_snprintf(buffer, size, "boot+%lus", app_tick_to_millisecond(tick) / 1000);
return;
}
strftime(buffer, size, "%Y-%m-%d %H:%M:%S", &local_tm);
}
无效传感器通道输出空字段,用连续逗号表示缺失值。
CSV Schema 迁移
如果表头格式变了(比如新增传感器通道),旧文件的表头和新代码不匹配。启动时会检查:
static int app_storage_prepare_file_schema(const char *csv_header)
{
/* 读文件前几个字节,和期望的表头比较 */
read(fd, header_buffer, expected_length);
if (memcmp(header_buffer, csv_header, expected_length) == 0)
return 0; /* 匹配,继续追加 */
/* 不匹配,把旧文件重命名为 legacy */
app_storage_rotate_legacy_file();
return 1; /* 需要写新表头 */
}
旧文件重命名为 measurements_legacy.csv、measurements_legacy_1.csv……最多保留 10 个。
离线上行缓存
当云端 ACK 超时或串口写入失败时,上行帧被追加到 /data/pending.jsonl:
int storage_pending_append(const char *line)
{
fd = open("/data/pending.jsonl", O_WRONLY | O_CREAT | O_APPEND, 0);
write(fd, line, len);
if (line[len-1] != '\n')
write(fd, "\n", 1); /* 确保每行以换行结尾 */
close(fd);
}
每行一条完整的 JSON 帧,和上行时发送的格式完全一样。
补传策略
网络恢复后,上行线程每轮循环补传一条缓存帧:
int storage_pending_replay_one(storage_pending_send_cb_t send_cb, void *user_data)
{
/* 读第一条 */
storage_pending_read_first_line(line, sizeof(line));
/* 尝试发送 */
if (send_cb(line, user_data) != 0)
return -1; /* 发送失败,保留缓存 */
/* 发送成功,删除第一条 */
storage_pending_drop_first_line();
return 1;
}
删除采用”读剩余 → 写临时文件 → rename 替换”的方式:
static int storage_pending_drop_first_line(void)
{
/* 跳过第一行 */
while (read(in_fd, &ch, 1) == 1 && ch != '\n') { }
/* 把剩余内容写到 pending.tmp */
while ((n = read(in_fd, buf, sizeof(buf))) > 0)
write(out_fd, buf, n);
close(in_fd);
close(out_fd);
/* LittleFS 的 rename 是断电安全的 */
rename("/data/pending.tmp", "/data/pending.jsonl");
}
LittleFS 的 rename 操作是原子性的,即使在 rename 过程中断电,文件系统也能保持一致。
断电安全分析
| 操作 | 断电风险 | 后果 |
|---|---|---|
| CSV 追加写入 | 丢最后一批未刷盘的数据 | 最多丢 4 条采样 |
| pending 追加写入 | 帧可能不完整 | 补传时 JSON 解析失败,丢弃该行 |
| pending 删除第一条 | rename 中断 | LittleFS 保证一致性,不会损坏 |
msh 调试命令
storage_pending— 查看缓存行数storage_pending clear— 清空缓存storage_rawtest— NOR Flash 原始读写测试storage_fstest— LittleFS 文件读写测试
这些命令在部署时很有用,可以验证 Flash 和文件系统是否正常工作。
存储容量估算
假设每条 JSON 帧 ~200 字节,每 10 秒一帧:
- 1 小时:360 帧 × 200B = 72KB
- 24 小时:8640 帧 × 200B = 1.7MB
- 7 天:60480 帧 × 200B = 12MB
如果网络中断 7 天,pending 缓存会占用约 12MB。通常 SPI NOR Flash 容量在 1-16MB 之间,需要根据实际 Flash 大小评估缓存上限。
CSV 文件增长速度类似,可以通过 measurements_legacy 轮转机制控制单文件大小。
小结
双层存储设计:
- CSV:面向人,供离线分析,批量写入减少 Flash 磨损
- pending.jsonl:面向机器,供自动补传,逐条追加逐条删除
两者都依赖 LittleFS 的断电安全特性,保证在任何时刻断电都不会损坏文件系统。补传机制配合云端 ACK,实现了”至少一次”的送达语义。