把文件系统搬到外部 SPI Flash 实测:挂载从 2.4ms 变 18.2ms,第一次 compaction 就把看门狗喂崩了——W25Q64 的三本账得拆开算
上一篇《Flash 上跑 littlefs 落地实测》文末我留了个尾巴:把文件系统搬到外部 SPI Flash 上,同一套账重算一遍。当时我给的判断很轻率——”W25Q64 是 4KB 扇区、45ms 擦除、10 万次擦写,看着都比内部 Flash 好”。
真做了才发现,”看着都好”这三个字里藏了三处反向。写确实快,擦的典型值打平、最坏值差 5 倍,读慢了 12 倍——而 littlefs 的每一次元数据提交都要读一遍块、改、再写回去,读慢会沿着这条链路放大成挂载慢和 compaction 慢。
结论先摆出来,五条:
- 寿命这一栏基本退出讨论了。 96 条/天的常态负载,1MB 外部分区算下来是两万三千年量级。131 篇和 135 篇围着寿命转,搬完家之后真正咬人的换成了擦除时间和总线带宽。
- 擦除的典型值不能用来做设计。 45ms 是典型,400ms 是上限,差 8.9 倍;3 次擦除叠加的 compaction 最坏 1.205s,内部 Flash 同样路径只有 124ms。这个 10 倍只出现在手册上限里,实验室一辈子复现不了。
prog_size填页大小是个坑。 littlefs 把元数据提交对齐到prog_size,28B 的提交在 256 档占满 256B,一个 4KB 块只装 15 次——比内部 2KB 块的 63 次还少。参数的擦除次数因此是内部的 1.71 倍。改成 64,回到 63 次,是内部的 0.66 倍。- 挂载的瓶颈从 CPU 换成了总线。 内部 2.4ms 是 tag 解析(58%)拖的;外部 18.2ms 是 66% 花在等字节搬进来,tag 只剩 23%。同一个结论不能跨介质复用。
- 外部 NOR 比 NAND 好带的地方在掉电语义。 编程只能 1→0,半编程的页必然被 CRC 抓到,不会产生静默错误;代价是擦除中掉电一次丢 63 条(整块),内部只丢 31 条。
工具照旧全离线:drafts/_tools/spi_flash_fs_calc.py,15 个模式、43 项自检断言,文中每个数都能复算。
一、先把三本账分开:写、擦、读
内部 Flash 和外部 NOR 最容易被混为一谈的地方,是把它们当成”同一件事的两种容量”。它们其实是两种不同的物理:
| 维度 | 内部 Flash(F103) | 外部 NOR(W25Q64JV) | 差 |
|---|---|---|---|
| 读 | 总线直连,单周期取指,24.1MB/s | SPI 单线 18MHz,2.05MB/s | 慢 11 倍 |
| 写 64B | 半字串行 1.68ms | 一次 64B 编程 325us | 快 5.2 倍 |
| 擦单元 | 2KB 页 | 4KB 扇区 | 大 2 倍 |
| 擦典型 | 20ms(9.77us/B) | 45ms(10.99us/B) | 每字节贵 12% |
| 擦最坏 | 40ms(19.53us/B) | 400ms(97.66us/B) | 每字节贵 5 倍 |
| 擦写寿命 | 10k 次/页 | 100k 次/扇区 | 10 倍 |
这张表读下来只有一句结论:写变快,擦变慢,读变得很慢。 而 littlefs 的写路径是”读元数据块 → 在 RAM 里改 → 写回去”,三本账全在里面,不能只看”擦写次数”一项。
131 篇我写过”选型先算擦写账”,那句话对环形页成立,对文件系统只对了一半。文件系统的写路径里,读和写各占一半,擦在最坏路径上占九成。账要按路径拆,不能按操作平均。
二、几何:4KB 扇区改的不只是容量
先把器件参数钉死(W25Q64JV 手册口径):
| 项 | 值 |
|---|---|
| 容量 | 8MB = 2048 个 4KB 扇区 = 128 个 64KB 块 |
| 页 | 256B,编程粒度 1..256B |
| 扇区擦除 | 45ms 典型 / 400ms 最大 |
| 64KB 块擦除 | 150ms 典型 / 2s 最大 |
| 整片擦除 | 20s 典型 / 100s 最大 |
| 页编程 | 0.7ms 典型 / 3ms 最大(256B) |
| 擦写寿命 | 100k 次/扇区 |
| 数据保持 | 20 年 @85C |
| Erase Suspend | 20us 典型 / 50us 最大 |
有一条规则手册写得很清楚、但很多人是靠调试学到的:页编程不能跨页边界。如果你从地址 0x...FF 开始连发 4 个字节,第 1 个字节落在本页末,剩下 3 个不会落到下一页,而是绕回本页开头,把前 3 个字节覆盖掉。不是报错,不是回 0xFF,是静默覆盖。
所以驱动的编程函数必须自己拆页,且拆完要等 BUSY:
static int w25q_prog(const void *src, uint32_t addr, uint32_t size)
{
const uint8_t *p = src;
while (size) {
uint32_t room = W25Q_PAGE - (addr & (W25Q_PAGE - 1));
uint32_t n = size < room ? size : room;
if (wren_and_wait() != 0) return LFS_ERR_IO; /* 06h,且必须等 WIP=0 */
cs_low();
spi_xfer(0x02); spi_xfer(addr >> 16); spi_xfer(addr >> 8); spi_xfer(addr);
spi_write(p, n);
spi_wait_bsy_clear(); /* 先等 BSY,再抬 CS */
cs_high();
if (w25q_wait_ready(5) != 0) return LFS_ERR_IO; /* 3ms 上限,给 5ms */
addr += n; p += n; size -= n;
}
return 0;
}
两个细节都是踩过的:
spi_wait_bsy_clear()必须在抬 CS 之前。DMA 发完最后一字节时,SPI 移位寄存器里还有一个字节没出去,这时候抬 CS 会把那个字节截断——表现是”偶尔写坏一个字节”,概率跟时钟相位有关,换块板子就复现不了。WREN必须在 WIP=0 之后发。擦除刚结束时立刻发 06h,器件正忙,会把 WREN 忽略掉,于是紧接着的 02h 也被忽略,函数一路返回成功,读回来是 0xFF。这个坑不报错,只在下次挂载时以 CRC 失败的形式出现。
顺便说一件搬家的好处:外部 Flash 是独立器件,不用从 App 槽里抢空间。135 篇上 littlefs 的第一笔成本是两个 App 槽各让 42KB(130 篇那套 512KB 布局里,参数区原本只有 14KB),这次一分不用让,代价换成一颗料、四根线和 18ms 挂载。
littlefs 两侧的块尺寸也定了:数据块 4096B 扣掉 16B 的 CTZ 头记 4080B,装 63 条 64B 记录,尾部剩 48B;内部 2032B 装 31 条,尾部也剩 48B。块内记录数正好翻倍,这是外部器件唯一”自然变好”的一项。
三、prog_size 是三档选择题,而且是本篇最值钱的一节
移植的时候我照抄了一份常见的 HAL 例程,prog_size 填 256(页大小),cache_size 也填 256,跑起来一切正常。
第三周才开始怀疑:参数区的擦除次数涨了。查下去发现根因在 littlefs 的一个内部约定——元数据提交按 prog_size 对齐(lfs_dir_commit 里那句 align = lfs_min(prog_size, block_size))。28B 的提交在 prog_size = 256 下要占满一整页 256B,于是:
prog_size |
提交长度 | 提交/4KB 块 | 擦除次数/条记录 | 相对内部 2KB 页 |
|---|---|---|---|---|
| 16 | 32B | 127 | 0.02375 | 0.49 倍 |
| 64 | 64B | 63 | 0.03175 | 0.66 倍 |
| 256 | 256B | 15 | 0.08254 | 1.71 倍 |
三档之间差 3.5 倍,全都来自那 12 个字节的对齐余量。填 256 的时候,参数区的擦除次数比留在内部 Flash 上还多 71%。 这就是”搬了个家反而更费”的真实版本,而且它不报错、不告警,只在你三年后算寿命时出现。
三档怎么选,判据不是”越大越快”:
- 16:元数据最密(127 次/块),但 64B 的记录从偏移 240 开始写就会跨 256B 页边界,驱动必须实现拆页。拆页逻辑我上面已经写了,多 15 行。
- 64:64 整除 256,任何 64B 对齐的写都不会跨页,拆页代码可以永远不进这条分支;元数据密度 63 次/块,和内部 Flash 持平。这是本篇选的档。
- 256:只有”写整页、不写小记录”的场景(比如固件槽、图片资源)才合适。日志/参数这种小记录高频追加的负载,选它等于自罚三倍擦除。
配置长这样:
static struct lfs_config cfg = {
.read = w25q_read, .prog = w25q_prog,
.erase = w25q_erase, .sync = w25q_sync,
.read_size = 16,
.prog_size = 64, /* 不是 256!64 整除页大小,永不跨页 */
.block_size = 4096, /* 必须等于扇区,填小了会一块多擦 */
.block_count = 256, /* 1MB / 4KB */
.cache_size = 256,
.lookahead_size = 32, /* 256 块 / 8,正好 32B */
.block_cycles = 500,
};
block_size 填小于 4096 是最危险的一种错:littlefs 会把一个扇区当两块用,erase 回调对同一个扇区被调两次,第二次擦掉的是刚写完的数据。现象是”写进去读不出来”,而且只在块边界上偶发。
cache_size 小于 prog_size 时 lfs_init 直接返回 -22(135 篇踩过一次,cache 300 配 4KB 块也报 -22,因为 cache_size 必须是 read_size 和 prog_size 的整数倍)。RAM 静态占用:cache 256 + prog cache 256 + lookahead 32 + 结构体 632 = 1176B。
四、写账:外部确实快,但别拿”每字节”骗自己
按字节算,外部 NOR 是 2.73us/B(256B 页)到 5.08us/B(64B 编程),内部 Flash 是 26.25us/B(半字串行 52.5us)。差 5 到 10 倍,这个方向没争议。
但记录是 64B 一条,实际付的是这个数:
| 写入字节 | 外部典型 | 外部最坏 | 内部典型 | 内部最坏 | 典型倍数 |
|---|---|---|---|---|---|
| 16 | 231us | 656us | 420us | 560us | 1.8 倍 |
| 32 | 262us | 813us | 840us | 1.12ms | 3.2 倍 |
| 64 | 325us | 1.13ms | 1.68ms | 2.24ms | 5.2 倍 |
| 128 | 450us | 1.75ms | 3.36ms | 4.48ms | 7.5 倍 |
| 256 | 700us | 3.00ms | 6.72ms | 8.96ms | 9.6 倍 |
最坏列要单独看。 64B 的典型快 5.2 倍,最坏只快 1.5 倍——因为外部有 0.5ms 的固定开销和 3ms 的页编程上限,内部是纯线性。越小越快的优势,在最坏情况下会被固定开销吃掉。
一次 lfs_file_sync 是”数据 64B + 元数据提交”两次编程:
| 配置 | 典型 | 最坏 |
|---|---|---|
外部 prog_size = 16 |
588us | 1.94ms |
外部 prog_size = 64 |
650us | 2.25ms |
外部 prog_size = 256 |
1.02ms | 4.12ms |
内部 2KB 页 / prog 16 |
2.52ms | 3.36ms |
prog 64 比内部快 3.9 倍。但单次 sync 的差别不是重点——三档之间只差 1.7 倍,真正的差别在第五节:多久要做一次 compaction。
五、擦账:45ms 是典型,400ms 是上限,而设计只能按上限
这是全篇最反直觉的一段。按每字节算:
- 外部 45ms / 4096B = 10.99us/B;内部 20ms / 2048B = 9.77us/B。典型只贵 12%。
- 外部 400ms / 4096B = 97.66us/B;内部 40ms / 2048B = 19.53us/B。最坏贵 5 倍。
按每条记录摊销(含元数据块那一份):
| 配置 | 擦除次数/条 | 典型 | 最坏 |
|---|---|---|---|
外部 prog 64 |
0.03175 | 1.429ms | 12.70ms |
外部 prog 256 |
0.08254 | 3.714ms | 33.02ms |
| 内部 2KB 页 | 0.04813 | 0.963ms | 1.925ms |
最好的一档也是内部的 1.49 倍。 算术很简单:擦除次数/条降到内部的 0.66 倍(4KB 块装的记录多了一倍),单次擦除贵 2.25 倍(45ms 对 20ms),0.66 乘 2.25 就是 1.49。容量的增长追不上擦除时间的增长,这就是 4KB 扇区的固有成本,跟文件系统无关。
然后是真正的问题——compaction 不是一次擦除。littlefs 要擦新块、可能擦旧块、目录分裂还要再擦一块,135 篇现场抓到的 D 工况就是三擦叠加:
| 路径 | 典型 | 最坏 |
|---|---|---|
外部 prog 64 |
136.3ms | 1.205s |
外部 prog 256 |
137.8ms | 1.212s |
| 内部 2KB 页 | 63.2ms | 124.2ms |
典型值 136ms 是内部典型 63ms 的 2.2 倍,最坏值 1.205s 是内部 124ms 的 9.7 倍。
135 篇我记的现场实测是 118ms,现在能解释清楚:它落在内部典型 63ms 和最坏 124ms 之间,说明当时抓到的是一个擦除时间偏长的样本,而不是上限。一个 118ms 的样本让我以为”最坏大概就 120ms”,而 400ms 的上限从来没被触发过——因为它需要硅片的擦除时间落到分布尾部,实验室几百次擦除是撞不到的。
这就是第六节事故的伏笔。
六、事故复盘:IWDG 200ms,第一次 compaction 复位
现场
板子装到现场第七天,复位记录里有 11 条 IWDG 复位,全部集中在上午 9 点到 10 点——整点抄表最密集的时段。复位后系统正常起来,参数回退了一版(丢失最近 3 条未落盘记录),littlefs 挂载成功,没有文件系统损坏。
这个组合很关键:复位 + 文件系统完好 + 只丢缓存里的几条,说明不是介质坏了,是有人在擦除里卡了超过 200ms。
根因
代码是这样的:
watchdog_feed();
lfs_file_sync(&lfs, &f); /* 里面可能是 3 次擦除 */
watchdog_feed();
w25q_erase() 里用的是阻塞轮询:发 20h(4KB 扇区擦除)+ 24 位地址,然后死等 WIP 清零。等待循环里没有喂狗。 典型 45ms 没事,三次叠加 136ms 也还在 200ms 内,但只要有一次擦除落到 400ms 附近,1.2s 就直接把狗喂崩。
而它一年只来一次——所以实验室三天没事、产线抽检没事、客户现场第七天开始每天一两次。
修法一:在轮询里喂狗(一行)
static int w25q_wait_ready(uint32_t timeout_ms)
{
uint32_t t0 = tick_ms();
for (;;) {
if (!(w25q_rdsr1() & 0x01)) return 0; /* WIP=0 */
iwdg_feed(); /* 关键:等待是"活着的等" */
if (tick_ms() - t0 > timeout_ms) return LFS_ERR_IO;
}
}
这是最小改动,也是必须做的一条——不管后面怎么重构,等待循环里不喂狗就是埋雷。但要注意:喂狗只解决”不复位”,不解决”系统停了 1.2 秒”。1kHz 的控制环在这 1.2 秒里丢 1200 个周期,PWM 直接停更。
修法二:擦除改成状态机(架构上唯一正确的做法)
typedef enum { FS_IDLE, FS_ERASING, FS_PROGRAMMING } fs_state_t;
/* 100us 定时器里调用,不阻塞 */
void fs_poll(void)
{
switch (st) {
case FS_ERASING:
if (!(w25q_rdsr1() & 0x01)) { st = FS_IDLE; fs_done_cb(); }
break;
case FS_PROGRAMMING:
if (!(w25q_rdsr1() & 0x01)) { st = FS_IDLE; fs_done_cb(); }
break;
default: break;
}
}
代价是文件系统的所有写操作都变成异步,业务代码要接受”提交完成后回调”。收益是擦除期间 CPU 是空闲的,控制环照跑。
这两条不冲突:状态机是架构,喂狗是保险。先做喂狗止血,再做状态机根治。
轮询本身的代价
RDSR(05h)读一个字节,18MHz 下 977ns。按不同间隔算:
| 轮询间隔 | 45ms 擦除要问 | 总线占用 |
|---|---|---|
| 10us | 4500 次 | 9.77% |
| 50us | 900 次 | 1.95% |
| 100us | 450 次 | 0.98% |
| 500us | 90 次 | 0.20% |
50us 是个好折中:擦除结束到发现最多晚 50us,总线占用 2%。但这里有个硬约束:擦除期间器件只认 05h 和 75h,别的命令一律不理。 所以这条 SPI 上不能挂需要实时采样的传感器——挂上去的话,擦除那 45ms 里所有采样都会失败,而且不报错(从机不应答,读回来是 0xFF 或 0x00)。
三条容易踩的细则
- SPI NOR 没有 R/B# 引脚。 并行 NOR 有专用忙信号,SPI NOR 只有状态寄存器里的 WIP 位。所以”擦除完成中断”这种设计在硬件上不存在,只能定时轮询。想省 CPU 就把轮询放进 100us 定时器,别指望中断。
- CS 与共用总线。 如果这条 SPI 上还挂着别的从机,各自独立 CS 时互不干扰(忙的只是那片 Flash),但共用 CS 的菊花链不行。参数区建议独占一个 SPI 外设,最差也要独占 CS。
- 写保护位。 出厂或上次误操作可能把 SR1 的 BP0..BP2 置上,此时擦除不报错但无效(WIP 正常走完,内容不变)。初始化时读一次 SR1/SR2 打日志,这行日志救过我一次。
七、挂载 18.2ms 花在哪:瓶颈从 CPU 换成了总线
内部 Flash 上挂载是 2.4ms,外部变成 18.2ms,7.6 倍。拆开看:
| 项 | 内部(4 块 x 2KB) | 外部(6 块 x 4KB) |
|---|---|---|
| 字节搬运 | 340us(24.1MB/s) | 12.00ms(2.05MB/s) |
| CRC32 | 680us | 2.04ms |
| tag 解析 | 1.38ms | 4.14ms |
| 合计 | 2.40ms | 18.18ms |
占比反过来了:内部是 tag 解析 58%、搬运 14%;外部是搬运 66%、tag 解析 23%、CRC 11%。
135 篇那句”瓶颈是 tag 解析不是 CRC”在外部器件上不成立了。同一个 2.4ms,内部瓶颈在 CPU,外部瓶颈在字节根本没搬进来。结论不能跨介质复用,这是这一节唯一想说的事。
三个应对:
- CRC 与传输重叠。 现在这两项是串行算的(18.18ms 里 2.04ms 是纯 CRC)。改成边收边算,能压到 16.1ms。省 11%,不难,值得做。
- 别在启动路径上挂载。 18ms 对 72MHz 的 M3 是 130 万个周期,放在
main()里就是开机多等 18ms;放在低优先级任务里,控制环完全无感。 - 挂一次常驻,别挂卸载。 有些代码为了省 RAM 每次读写都 mount/unmount,内部 Flash 时代代价是 2.4ms 看不出来,外部就是每次 18ms。RAM 1176B 换 18ms,这笔账不用算。
如果确实需要更快,唯一的办法是提高总线带宽,见下一节。
八、QSPI 内存映射能不能跑文件系统
先说结论:能跑代码和只读资源,不能跑文件系统的写路径。
带宽前提
我们这块板子是 F103,SPI1 挂在 APB2 72MHz,4 分频 = 18MHz 硬上限,单线有效 2.05MB/s。F103 没有 QSPI 外设,四线只能软件模拟(GPIO 翻转根本到不了 100MHz),所以在这颗 MCU 上”四线”不是选项。
要四线就得换 MCU。H743 的 QUADSPI 104MHz 四线,有效 45.76MB/s,是现在这条路的 22 倍,4KB 读从 2.00ms 降到 0.090ms。但为了 46MB/s 换整颗主控,摊到项目上是几十万的重新认证成本,不划算。 这是最后没换的真实原因,不是技术做不到。
XIP 的三条限制
假设换了 H7,用内存映射(XIP)模式,有三条限制绕不过:
- 擦写期间器件忙,取指停摆。 页编程 0.7/3ms、扇区擦除 45/400ms 之内,器件对除 05h 和 75h 以外的命令一律不理。代码在外部 Flash 上跑,这 45ms 就是整机停摆——比内部 Flash 还糟,因为内部至少擦除和取指是同一个阵列,延迟确定;外部是”总线还在、从机不说话”。
- Erase Suspend(75h)能救,但控制器不会替你发。 挂起响应 20us 典型 / 50us 最大,挂起后可以读别的扇区。但 QSPI 控制器进入 memory-mapped 模式后是硬件自动发读命令的,它不知道”现在该发 75h”。要用手动挂起,就退回命令模式,XIP 的意义没了。
- 代码必须驻留内部 Flash 或 RAM。 擦写例程放 RAM(
.RamFunc段),擦除那 45ms 里不许有任何外部取指。做得到,但要求你把整个写路径(littlefs 的prog/erase/sync回调 + SPI 驱动)都搬进 RAM,代码量 3~5KB。
所以我们的分工是:代码和常量放内部 Flash,参数/日志放外部 SPI Flash,外部只做数据不做取指。 这条分工在 F103 上是被 18MHz 逼出来的,在 H7 上也依然成立——因为第 1 条限制不会因为带宽变高而消失。
九、掉电:损失半径从 31 条变成 63 条
擦除窗口的占空比很低:
| 负载 | compaction 次数/天 | 擦除占空比 | 100 次/年断电的命中率 |
|---|---|---|---|
| 96 条/天 | 1.5 | 3.1 ppm | 0.03%,约 3200 年一次 |
| 86400 条/天 | 33 | 68.7 ppm | 0.69%,约 145 年一次 |
统计上很安全。但工程上要按”一定命中”设计,因为落到单台设备上的后果是确定的:
- 擦除中掉电 → 整扇区变随机值。littlefs 靠 CTZ 头和 CRC 判定无效块并跳过,代价是这一块里 63 条记录全没了。内部的损失半径是 31 条,外部是 63 条——擦除单元翻倍,一次掉电的损失也翻倍。
- 编程中掉电 → NOR 的编程只能把位从 1 写成 0,半编程的页是”部分位已翻”。这看起来可怕,实际上是好消息:内容一定和 CRC 对不上,记录被判无效,不会出现”位翻转但 CRC 侥幸通过”的静默错误。
- 对比 NAND:NAND 会位翻转、需要 ECC 兜底、坏块要管理、写前必须按页对齐。NOR 的这些”缺点”(擦除慢、单元大)换来的是语义简单——这是选它做参数区的核心理由,不是容量也不是价格。
出厂测试沿用 135 篇那套继电器切 3.3V,但要加一条:每次断电后必须等器件 BUSY 结束(读一次 RDSR 直到 WIP=0)再上电重挂,否则你测的是”带电插拔”而不是”掉电恢复”,前者会把 0xFF 读成有效数据,测出来的通过率是假的。
十、验收清单
- ☐
block_size严格等于扇区 4096,且分区起止都在扇区边界 - ☐
prog_size不取页大小,取整除页大小的记录粒度(本篇 64) - ☐
cache_size >= prog_size,且是read_size的整数倍 - ☐ 编程函数自己拆页,跨页写不会绕回页首覆盖
- ☐ 抬 CS 之前等 BSY 清零,DMA 路径单独验一遍
- ☐ 每次
WREN前确认 WIP=0,WREN后确认 WEL=1 - ☐ 初始化时读 SR1/SR2 打日志,确认 BP 位与 QE 位符合预期
- ☐ 所有等待循环里喂狗,不只是擦写前后各一次
- ☐ 擦除与编程走状态机,不在控制任务里阻塞
- ☐ 参数区独占 SPI 或至少独占 CS,擦除期间不做别的采样
- ☐ compaction 次数打点上报(
prog_size选错时这个数会先涨) - ☐ 200 次随机时刻断电,每次等 BUSY 结束后重挂,挂载全部成功
最后两条是这套配置里唯一能提前发现问题的观测点。擦除次数不会告警,compaction 次数会。 一个每天 6.4 次 compaction 的参数区(prog_size = 256 的典型值)和一个每天 1.5 次的(prog_size = 64),在寿命表上差 4 倍,在故障率上差 4 倍,但在线上看起来都”正常工作”。
最后收三句话。第一,外部 Flash 不是内部 Flash 的放大版,它是挂在一条共享总线上的另一个器件。 写快 5 倍、擦最坏慢 5 倍、读慢 11 倍,三个方向不一致,任何”整体更快/更慢”的判断都是错的。
第二,prog_size 是这套系统里性价比最高的一行配置。 从 256 改到 64,元数据密度回到 63 次/块,擦除次数降到 0.66 倍,代价是零行代码。这一行值 3.5 倍寿命,比任何”优化擦写策略”的技巧都值。
第三,400ms 那个上限必须进架构。 它一年可能只来一次,但它来的时候是 1.2 秒。用典型值做设计的人,会在现场第七天拿到一份 IWDG 复位记录,而那份记录里没有任何一行指向 Flash。
工具:drafts/_tools/spi_flash_fs_calc.py(15 个模式、43 项自检断言,纯标准库、全离线)。口径:W25Q64JV 手册的 0.7/3ms 页编程、45/400ms 扇区擦除、100k 次擦写、20us 挂起;内部 Flash 沿用 135 篇的 F103 口径(20/40ms 擦 2KB、52.5/70us 每半字);总线按 SPI1 18MHz、效率 0.91;挂载按 6 个 4KB 块读、CRC 11.76MB/s、tag 解析 0.1725ms/KB(均由 135 篇实测值标定)。编程时间按”固定开销 + 按字节线性”的串行模型,实测会因 SPI 硬件流水略快;与你的器件手册冲突时以手册为准。
文中设备型号与参数为写作口径下的重构,不针对任何具体厂商产品;文内数字均可用配套工具复算,与你的器件数据手册冲突时以数据手册为准。
下一篇预告《参数区双备份与出厂恢复流程》:本篇第五节只说了”擦除单元越大,损失半径越大”,但没回答一个更实际的问题——参数区被写坏了,现场怎么恢复? 双备份 A/B 的有效标志怎么置、什么时候才算”写过”、出厂默认值放哪、恢复流程要不要留后门、以及”用户改过的参数被恢复成默认值”这种事故怎么防。这是 135 篇留下的最后一个空白,也是这套参数系统的收尾。