W25Q128 + LittleFS + 异步日志:Flash 持久化全栈实现
STM32F1 自身没有可用的非易失存储,掉电后日志和运行状态全部丢失。为了在工业现场能追溯告警和故障,外挂一片 W25Q128 SPI NOR Flash(16MB),在上面跑 LittleFS 文件系统,再叠一层按天滚动的日志服务。本文记录这套从 SPI 比特流到日志文本的完整实现。
四层垂直架构
整套栈从下到上分四层,每层只关心自己的职责,接口清晰得可以单独替换:
┌─────────────────────────────────────────────┐
│ 应用层 DataLogger / LogService │
│ (/meta.dat + /log/000001.log) │
├─────────────────────────────────────────────┤
│ 文件系统 LittleFS 核心 (lfs.c) │
│ 磨损均衡 + 掉电保护 + 元数据 │
├─────────────────────────────────────────────┤
│ 适配层 lfs_port.c │
│ read/prog/erase/sync 四回调 │
├─────────────────────────────────────────────┤
│ 驱动层 W25Q128.c (SPI2 + GPIO) │
│ 页编程/扇区擦除/WIP 轮询 │
├─────────────────────────────────────────────┤
│ 硬件层 STM32F1 SPI2 + W25Q128 │
└─────────────────────────────────────────────┘
上层调用 lfs_file_write,LittleFS 核心把请求拆成块读写,通过回调走进 lfs_port.c,再下到 W25Q_ReadData / W25Q_WriteData / W25Q_SectorErase,最后落到 SPI2 外设的 MOSI/MISO 引脚上。替换 Flash 型号时,只改 W25Q128.c 和 lfs_port.h 里的几何参数;换文件系统时,只改 lfs_port.c 的四个回调。
W25Q128 SPI 驱动
时钟与引脚
W25Q128 最高支持 104MHz SPI 时钟,但 STM32F1 的 SPI2 挂在 APB1 上(36MHz),分频 4 后是 9MHz。9MHz 对页编程和扇区擦除的耗时影响可以忽略,因为等待 WIP 清零的时间远大于数据传输时间。引脚分配:
| 引脚 | 功能 | 模式 |
|---|---|---|
| PB12 | CS | 推挽输出,软件控制 |
| PB13 | SCK | 复用推挽 |
| PB14 | MISO | 浮空输入 |
| PB15 | MOSI | 复用推挽 |
SPI2 总线和 NRF24L01 无线模块共用,两个设备用各自的 CS 独立选片,互不干扰。访问 W25Q128 前要保证 NRF24L01 的 CS 处于高电平。
JEDEC ID 三家厂商兼容
读取 JEDEC ID 用 0x9F 指令,连续读 3 字节:厂商 ID + 内存类型 + 容量。W25Q128 标准返回 0xEF4018。
国产兼容片货源充足,代码里同时识别三家:
- Winbond
0xEF+0x4018 - GigaDevice
0xC8+0x4018(GD25Q128 与 Winbond ID 完全相同) - 武汉新芯 XMC
0x52+0x2118(XM25QH128C)
初始化时连读两次 ID 做一致性校验,过滤 SPI 总线毛刺或片选异常。两次不一致直接报错退出,避免后续在异常总线上狂擦 Flash。
页编程与跨页写入
页编程指令 0x02 一次最多写 256 字节,且不能跨页边界。超出页尾会回卷覆盖页首,这是 Flash 硬件行为,不能依赖。
W25Q_WriteData 把跨页写入拆成多次 PageProgram:
while (len > 0) {
uint32_t page_off = addr % W25Q_PAGE_SIZE;
uint32_t chunk = W25Q_PAGE_SIZE - page_off;
if (chunk > len) chunk = len;
W25Q_PageProgram(addr, buf, (uint16_t)chunk);
addr += chunk; buf += chunk; len -= chunk;
}
每次算出当前页剩余空间,写满后地址前移继续下一页。上层不用关心页对齐。
擦除粒度
提供三级擦除:4KB 扇区(0x20)、32KB 块(0x52)、64KB 块(0xD8)。LittleFS 的块大小是 4KB,所以实际只用到 SectorErase。块擦除保留给将来全盘整理或出厂清空。
擦除前必须先发 0x06 写使能,写使能状态在操作完成后自动清除。擦除指令发出后调用 W25Q_WaitBusy 轮询 SR1 的 WIP 位,超时分别给到 1s/3s/4s。
WIP 轮询
W25Q_WaitBusy 是整个驱动里最关键的安全阀:
while (W25Q_ReadStatusReg1() & STATUS1_WIP_BIT) {
W25Q_KickWatchdog();
if ((HAL_GetTick() - start) > timeout_ms)
return -1;
}
页编程典型 0.7ms、扇区擦除典型 45ms,但裸机架构下 CPU 在这期间完全被占用,Modbus 通信会暂停。所以轮询循环里每个迭代都喂一次狗(IWDG->KR = 0xAAAA),避免擦除期间把系统看门狗喂饿死导致复位。
LittleFS 块设备适配层
四个回调
LittleFS 核心只认四个函数指针:read、prog、erase、sync。适配层把逻辑块号和块内偏移换算成 Flash 物理地址,然后调用 W25Q 驱动:
static int lfs_bd_read(const struct lfs_config *c, lfs_block_t block,
lfs_off_t off, void *buffer, lfs_size_t size) {
uint32_t addr = LFS_FLASH_BASE_OFFSET + block * LFS_PORT_BLOCK_SIZE + off;
W25Q_ReadData(addr, (uint8_t *)buffer, size);
return LFS_ERR_OK;
}
prog 和 erase 同理。sync 留空——因为每次 prog 和 erase 内部都调用了 W25Q_WaitBusy,写操作在下发时就已持久化,sync 无事可做。这是同步 SPI 的好处:语义简单,坏处是阻塞。
静态缓冲 544B RAM
LittleFS 编译时定义了 LFS_NO_MALLOC,所有缓存必须静态分配。三个缓冲区加起来 544 字节:
| 缓冲 | 大小 | 用途 |
|---|---|---|
s_read_buf | 256B | 读缓存 |
s_prog_buf | 256B | 写缓存 |
s_lookahead_buf | 32B | 磨损均衡前瞻位图 |
加上 lfs_t 实例本身约 80 字节,整个文件系统固定占 RAM 约 624 字节,跟一次栈帧差不多。
几何参数
配置结构体里每个参数都要和 Flash 物理特性对齐:
.read_size = 256, // 最小读粒度
.prog_size = 256, // 最小编程粒度 = Flash 页大小
.block_size = 4096, // 块大小 = Flash 扇区大小
.block_count = 4080, // 总块数 = 分区大小 / 块大小
.cache_size = 256, // 必须 >= read/prog_size
.lookahead_size = 32, // 8×32 = 256 块前瞻
block_count 是从 LFS_FLASH_SIZE / LFS_PORT_BLOCK_SIZE 算出来的,不能瞎填——填大了 LittleFS 会访问到不存在的地址,填小了浪费容量。
Flash 分区布局
W25Q128 总共 16MB,前 64KB 保留给 OTA 暂存和 KV 存储,后面 ~16MB 全部给 LittleFS:
0x000000 ──────────────── 64KB 保留区 (OTA / KV)
0x010000 ──────────────── LittleFS 分区起始
│
│ block 0 (4KB) 元数据 + 根目录
│ block 1 (4KB) /meta.dat
│ block 2 (4KB) /log/ 目录
│ block 3+ (4KB) /log/000001.log ...
│
0xFFFFFF ──────────────── 分区结束 (~16MB)
起始偏移 LFS_FLASH_BASE_OFFSET = 0x00010000。这样 OTA 升级时可以直接写前 64KB 而不影响日志分区,日志分区出问题也不会污染 bootloader 区。
磨损均衡:block_cycles = 500
NOR Flash 单块额定擦写寿命 10 万次。如果不做磨损均衡,频繁更新 /meta.dat 会很快把某个块擦穿。
LittleFS 的 block_cycles 参数控制迁移阈值:每块被擦除指定次数后,文件系统会把这个块的数据搬到别的块,让原本的热点块休息。设成 500 是个偏保守的值——10 万次 / 500 = 200 倍余量,对应到实际场景足够撑到设备退役。
lookahead_size = 32 意味着每次扫描 256 个块(32 字节 × 8 位)寻找空闲块。整个分区 4080 块,前瞻覆盖约 6%,对一般写入模式够用。
空白分区检测 + 自动格式化保护
首次烧录的 Flash 是全 0xFF,LittleFS 挂载会失败。直接自动格式化听起来合理,但很危险——如果 SPI 总线异常导致读到全 0xFF(比如 CS 没拉低、MISO 悬空),自动格式化会把用户数据全部擦掉。
LfsPort_Init 的保护逻辑:
int err = lfs_mount(&s_lfs, &s_lfs_cfg);
if (err) {
if (LfsPort_IsPartitionBlank() == 0) {
// 分区不是空白,但挂载失败——文件系统损坏或总线异常
printf("[LFS] Mount failed, partition not blank, skip auto-format\r\n");
return -2;
}
// 只有分区明显空白才格式化
err = lfs_format(&s_lfs, &s_lfs_cfg);
...
}
LfsPort_IsPartitionBlank 读取分区首块和第二块的前 16 字节,全部是 0xFF 才判定为空白。这是个弱判据——如果用户数据恰好首部是 0xFF,可能误判。但对首次烧录场景已经足够安全,比无脑格式化强得多。
DataLogger 按天日志
文件布局
/meta.dat 元数据(magic + boot_count + day_index)
/log/000001.log 第一天的日志
/log/000002.log 第二天的日志
/log/000003.log ...
/meta.dat 是固定 16 字节的结构体:
typedef struct {
uint32_t magic; // "MLOG" = 0x4D4C4F47
uint32_t boot_count; // 累计启动次数
uint32_t day_index; // 当前日序号
uint32_t reserved;
} Metadata;
项目没有 RTC,用”日序号”替代日历日期——每调用一次 DataLogger_RollDay,day_index 加一,对应日志文件名也加一。boot_count 每次初始化时递增,用来追踪设备总启动次数。
日志行格式
每条日志一行,前缀是系统启动以来的秒数 + 级别:
[1234] [WARN] sensor 3 reading out of range: 65535
[1235] [ERROR] modbus rtu timeout, slave=0x10
秒数来自 HAL_GetTick() / 1000U。没有 RTC 的情况下这是最可靠的相对时间戳——重启后从 0 开始,至少能看出故障发生时设备已经运行了多久。
行缓冲 LINE_BUF_SIZE = 160,超过会被截断。文件用 LFS_O_APPEND 追加打开,保证多线程追加不交错。
自定义文件缓冲
DataLogger 自己提供文件缓冲,避免 LittleFS 内部动态分配:
static uint8_t s_file_buf[LFS_PORT_CACHE_SIZE];
static struct lfs_file_config s_file_cfg = {
.buffer = s_file_buf,
.attrs = NULL,
.attr_count = 0,
};
每次 lfs_file_opencfg 传入这个配置,文件操作走静态缓冲。注意所有文件共用一个 s_file_cfg,所以不能同时打开多个文件——这符合本项目的单线程串行写入模式。
日志轮转与过期清理
DataLogger_RollDay 只是把 day_index 加一并保存元数据,下一条日志会写到新文件。真正的清理在 DataLogger_Cleanup:
int DataLogger_Cleanup(uint32_t keep_days) {
if (keep_days == 0u || keep_days >= s_meta.day_index) return 0;
uint32_t keep_from = s_meta.day_index - keep_days;
return cleanup_dir(LOG_DIR, keep_from);
}
cleanup_dir 遍历 /log 目录,把每个 .log 文件名解析成数字,小于 keep_from 的全部 lfs_remove。这是个笨办法——文件名必须是纯数字才能被正确解析。但好处是简单可靠,不需要维护额外的索引文件。
策略上,Cleanup 应该在 RollDay 之后调用,或者定期由上层触发。删除文件后,LittleFS 会把对应的块标记为空闲,下次写入时复用。
空间查询:lfs_fs_size
DataLogger_GetFreeBytes 调用 LittleFS 的 lfs_fs_size 拿到已分配块数,再换算成字节:
uint32_t DataLogger_GetFreeBytes(void) {
lfs_ssize_t used = lfs_fs_size(lfs);
if (used < 0) return 0;
uint32_t total_bytes = LFS_PORT_BLOCK_COUNT * LFS_PORT_BLOCK_SIZE;
uint32_t used_bytes = (uint32_t)used * LFS_PORT_BLOCK_SIZE;
return (total_bytes > used_bytes) ? (total_bytes - used_bytes) : 0u;
}
lfs_fs_size 返回的是”已分配的块数”,不是”已使用的字节数”。LittleFS 以块为单位分配空间,一个块哪怕只写了 1 字节也算占用。所以返回值是块级别的,对监控”还能写多少天日志”已经够用。
初始化日志里会打印剩余空间:
[LOG] Ready. boot=42 day=15 free=15728 KB
15MB 可用,按每天 100KB 日志算能存 150 天,远超设备维护周期。
LogService 异步消息队列
为什么需要异步
DataLogger 直接写 Flash,一次追加可能触发擦除,耗时几十到几百毫秒。如果业务线程直接调用 DataLogger_LogError,会被阻塞,Modbus 轮询会超时。
LogService 在中间加一层消息队列,把”提交日志”和”写 Flash”解耦:
业务线程 LogService 线程
│ │
│ SubmitWarnf("...") │
├──────────► 消息队列 ────────►│
│ 立即返回 │
│ ├─► DataLogger_LogWarning
│ │ (阻塞写 Flash)
│ │
业务线程只把格式化后的字符串塞进队列就返回,实际写盘由专门的日志线程串行执行。
消息结构
typedef struct {
rt_uint8_t type; // WARN / ERROR / FLUSH
rt_uint8_t reserved[3]; // 对齐
char text[96]; // 日志正文
} LogServiceMessage;
text 固定 96 字节,短日志会浪费空间,但避免动态分配。队列深度 16,意味着最多缓存 16 条待写日志——超过就丢。
非阻塞与阻塞两种提交策略
rt_err_t LogService_SubmitWarnf(const char *fmt, ...) {
// 非阻塞:队列满直接丢
return LogService_SubmitV(LOGSERVICE_MSG_WARNING, "WARN", fmt, args, RT_WAITING_NO);
}
rt_err_t LogService_SubmitErrorf(const char *fmt, ...) {
// 阻塞 50ms:错误日志尽量不丢
return LogService_SubmitV(LOGSERVICE_MSG_ERROR, "ERR", fmt, args,
rt_tick_from_millisecond(50));
}
WARN 用非阻塞——警告很频繁,丢几条无所谓。ERROR 阻塞 50ms——错误往往意味着要排查故障,尽量保住。50ms 是经验值,既给队列腾出空间的机会,又不至于拖死业务。
丢包统计
if (err != RT_EOK) {
s_log_drop_count++;
}
rt_mq_send 失败就累加丢包计数。这个计数目前没对外暴露接口读取,但可以通过调试器或后续加个查询 API 暴露出来。生产环境如果发现丢包数持续增长,说明日志写入速度跟不上产生速度,要么提高队列深度,要么减少日志量。
60 秒自动 Flush
#define LOGSERVICE_FLUSH_PERIOD_MS 60000u
while (1) {
rt_mq_recv(&s_log_queue, &msg, sizeof(msg), rt_tick_from_millisecond(1000));
// 处理消息...
if ((now - last_flush_tick) >= LOGSERVICE_FLUSH_PERIOD_MS) {
DataLogger_Flush();
last_flush_tick = now;
}
}
rt_mq_recv 的超时设成 1 秒,没消息时每秒醒来一次检查是否到 60 秒。到点就调一次 DataLogger_Flush。
实际上 DataLogger_Flush 目前是个空函数——因为每次 lfs_file_close 都会把数据刷到 Flash。这个 60 秒 Flush 是为将来改成”批量缓存写入”预留的接口。现在保留它只是为了让时间戳定期更新。
Flash 长操作期间喂狗
裸机 + RT-Thread 混合架构下,最容易被忽视的是 Flash 长操作期间的喂狗。STM32F1 的独立看门狗 IWDG 一旦启动就必须持续喂,否则超时复位。
W25Q128 的几个长操作:
| 操作 | 典型耗时 | 最长耗时 |
|---|---|---|
| 扇区擦除 | 45ms | 400ms |
| 32KB 块擦除 | 120ms | 1600ms |
| 64KB 块擦除 | 150ms | 2000ms |
| 全片擦除 | 40s | 200s |
| 页编程 | 0.7ms | 3ms |
如果 IWDG 超时设成 4 秒,单次扇区擦除不会触发复位。但如果连续擦除多个扇区(比如格式化时),累计时间就会超。
W25Q_WaitBusy 的轮询循环里每次迭代都喂一次狗:
while (W25Q_ReadStatusReg1() & STATUS1_WIP_BIT) {
W25Q_KickWatchdog(); // IWDG->KR = 0xAAAA
if ((HAL_GetTick() - start) > timeout_ms) return -1;
}
W25Q_ChipErase 的 120 秒等待期间,循环每几百微秒喂一次狗,保证全片擦除也能安全完成。这是把”喂狗”和”等待 Flash”绑在一起,不让任何一方饿死。
几个工程上的取舍
SPI 9MHz 够用吗? 够。瓶颈不是带宽,是擦除等待。9MHz 下读 256 字节约 230μs,而扇区擦除要 45ms 起。把 SPI 提到 50MHz 只能让数据传输从 230μs 降到 40μs,对整体吞吐几乎没影响。
为什么不用 FATFS? FATFS 元数据复杂、掉电易损坏、RAM 占用大(要开 FAT 表缓存)。LittleFS 的 COW(写时复制)机制保证掉电时文件系统不会损坏,最坏情况只丢最后一条未关闭的日志。对工业日志场景,这个保证比性能更重要。
为什么没有 RTC? 项目硬件上确实没贴 RTC 晶振,用 HAL_GetTick 秒数 + 启动次数已经够用。如果将来加 RTC,只要把 day_index 换成 YYYYMMDD 格式的日期字符串,文件名改成 /log/20260522.log 即可,其他逻辑不动。
为什么 DataLogger 只记 WARN/ERROR? 传感器采样数据量大且有时序要求,走另一条路径直接裸写 Flash 块更合适。日志只是用来排查故障的,不需要全量记录。把高频数据和低频日志分开,各自走最合适的存储路径。
总结
这套实现的几个关键点:
- 分层清晰:SPI 驱动、块设备适配、文件系统、应用层各司其职,单层替换不影响其他层
- RAM 占用低:LittleFS 静态缓冲 544B + DataLogger 文件缓冲 256B + LogService 队列约 1.7KB,总共不到 3KB
- 掉电安全:LittleFS COW + 每次写入即 close 刷盘,最坏只丢最后一条
- 磨损均衡:block_cycles=500,10 万次寿命下 200 倍余量
- 异步解耦:LogService 队列把业务线程和 Flash 写盘隔离,Modbus 实时性不受影响
- 看门狗友好:所有 Flash 长操作期间持续喂狗,不会因为擦除超时复位
16MB Flash、544B RAM、一条消息队列,撑起了一套能用的嵌入式日志系统。代码量不大,但每个层级的取舍都有道理。