尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

MicroPython存储与文件系统:从Flash物理特性到VFS层反直觉现象全解析

MicroPython存储与文件系统:从Flash物理特性到VFS层反直觉现象全解析 刚开始用 MicroPython 做开发板上的数据记录项目时我踩过一个特别典型的坑程序跑了一天多写了十几个日志文件想着把早期没用的文件删掉腾点空间。结果执行完os.remove()再去查剩余容量可用空间几乎纹丝不动。那一刻我第一反应是“文件系统出 bug 了”接着想到的是“是不是删除操作必须重启才生效”。后来才明白这不是 MicroPython 的问题而是我对它底层的 Flash 介质和文件系统机制完全没概念。PC 上删文件后看到空间立刻回来是因为 SSD 和操作系统替我们做了大量透明化处理而微控制器上的资源和逻辑完全暴露在开发者面前尤其 Flash 的“先擦后写”“扇区对齐”“掉电一致性”这些物理特性会直接反映到你看到的存储行为上。这篇文章不打算炒官方文档的冷饭而是从几个大家都会遇到的“反直觉现象”出发把 MicroPython 存储和文件系统从硬件介质到 VFS 层拆开串讲一遍。1. 删掉文件空间却没回来先搞懂 MicroPython 的“存储”到底由什么构成1.1 内部 Flash、外部 Flash 和 RAM嵌入式世界的三种“存储”很多新手一听“存储”二字脑子里自动映射到电脑的硬盘。但在 MicroPython 开发板上“存储”至少分成三种完全不同的东西它们的特性、用途和读写规则差别极大。第一种是内部 Flash也就是芯片自带的非易失性存储。以常见的 ESP32 为例模组上通常会有一颗 4MB 或 8MB 的 SPI FlashPython 解释器固件、你的main.py、以及默认的根文件系统都放在这颗 Flash 上。这颗 Flash 有一个非常核心的物理特性写入前必须先擦除而擦除的最小单位是一个扇区常见的 SPI Flash 扇区大小是 4KBPage 编程单位是 256B。所谓“擦除”就是把整个扇区写成0xFF这个动作是有寿命的典型数据手册标称 10 万次擦写周期。也就是说某个扇区写得太勤它是真的会“坏掉”的。第二种是外部存储最常见的是通过 SPI 或 SDIO 接口接的 SD 卡、TF 卡以及外挂 SPI Flash 芯片。SD 卡虽然也叫 Flash但它内部其实自带了一个控制器FTL负责把逻辑地址映射到物理页、做坏块管理和磨损均衡。这本来是好事可问题在于嵌入式环境里供电不稳、传输中断的情况太常见了SD 卡内部的元数据一旦写崩代价远比你想象的大后面第 6 章会专门讲。第三种是RAM也就是内存。它的速度比 Flash 快好几个数量级但掉电即失。很多人写程序时容易忽略一个事实bytearray、列表、字符串这些 Python 对象默认都占 RAM只有显式写入文件系统才叫“持久化”。开发板内存本来就小ESP32 经典款只有 520KB SRAM跑个 Python 解释器再处理数据如果不在代码里控制缓冲区的生命周期空间瞬间就能被吃光。import os # 查看当前系统信息 os.uname() # 输出示例ESP32 # (sysnameesp32, nodenameesp32, release1.23.0, versionv1.23.0 on 2024-06-02, machineESP32 module with ESP32)1.2 为什么 PC 的“存储清理”经验在这里全部失效PC 上删除文件后操作系统会立刻把对应簇标记为空闲资源管理器里的剩余空间马上变大。但在 MicroPython 的默认文件系统littlefs里os.remove()做的事只是把元数据标记为“已删除”空间是否立即回收、什么时候回收取决于文件系统的实现策略。尤其是 littlefs为了做到掉电安全和磨损均衡它倾向于延后空间回收让删除操作不引发立即的擦除动作。另一个 PC 经验失灵点是容量计算。你在os.statvfs()里看到的总块数和可用块数并不代表整颗 Flash 的真实容量因为文件系统会预留一部分块用于磨损均衡、掉电恢复日志和元数据管理。加上“写放大”的存在——也就是逻辑上写 1 个字节的数据物理上可能要先擦除整个 4KB 扇区——你会发现一个 1KB 的小文件写到最后可能占掉好几倍的实际 Flash 空间。这就引出一个核心认知MicroPython 的文件系统不是在“存数据”而是在“管理 Flash 的擦写”。后面所有看似奇怪的行为几乎都能从这句话推导出来。2. Flash 的脾气先擦后写、写放大和掉电敏感是文件系统存在的根本原因2.1 一个扇区的生与死擦除、写入、磨损要理解文件系统为什么长这样先得理解 Flash 的物理形态。SPI NOR Flash 的内部结构大致分三层扇区Sector擦除操作的最小单位常见 4KB。块Block由若干个扇区组成常见 64KB。页Page编程写入操作的最小单位常见 256B。读可以按字节随意读写却只能以页为单位而且写完一页后如果想修改其中几个字节不能只改这几个字节必须先把整个扇区擦掉再重新把新数据写进去。原因是 Flash 单元在物理上只能从1变成0编程要从0变回1只能靠擦除操作。这就好比在一张只能黑笔涂黑的答题卡上做修改铅笔写了还能擦但黑笔写错了就得把整张卡片换掉。所谓的“写放大”就是指逻辑上写少量数据物理上却要动用一个大得多的扇区。举个具体例子你有一个日志文件每次只追加一行 30 字节的内容。文件系统拿到这 30 字节后至少要把包含日志文件尾部的整个扇区读出来、合并新数据、擦除整个扇区、再写回去。这一个操作背后物理擦写可能是 4KB。如果每秒写一次同一个扇区的擦写寿命在 10 万次的情况下大概 28 小时就逼近极限了。磨损均衡Wear Leveling就是文件系统为了不让个别扇区过早报废而把擦写操作分散到整颗 Flash 上的机制。操作类型最小单位是否破坏已有数据寿命影响读字节否几乎无写编程页256B只能把 1 变成 0低擦除扇区4KB整个扇区变为 0xFF高10 万次左右2.2 字节序、地址映射和“读改写”陷阱“大端存储”和“小端存储”这两个词听起来像计算机组成原理的考试内容但在 MicroPython 文件系统底层其实会真实遇到。ARM Cortex-M 系列处理器默认是小端little-endian而 Flash 芯片本身没有字节序概念它只是一块按字节编址的线性空间。问题出在文件系统元数据的布局上littlefs 的超级块、目录项、属性块都是以结构体形式存放的结构体里的多字节整数比如块号、偏移量按小端写入你在 PC 上用十六进制编辑器看 Flash dump 时看到的字节顺序就和逻辑值相反。如果只是查看还好真正容易踩坑的是做底层数据恢复或者写自定义驱动。比如你想直接从 Flash 的某个偏移地址读取一个uint32_t块号直接按地址顺序取字节再转成整数很可能得到错误的数值。标准做法是用struct模块显式指定字节序来解析import struct # 假设从 Flash 读取到 4 个字节的原始数据 raw raw b\x34\x12\x00\x00 # 小端解析 number_le struct.unpack(I, raw)[0] # 0x1234 # 大端解析 number_be struct.unpack(I, raw)[0] # 0x34000000文件系统在操作 Flash 时还会遇到一个非常隐蔽的性能陷阱叫“读改写”。当你要往一个已经写过的区域内写入数据时因为不能直接覆盖写必须先读出整个扇区在内存里修改目标字节再擦除、再整扇区写回。如果在main.py里频繁以追加模式打开一个小文件并写入这个流程会被反复触发。我在 ESP32 上跑过一个测试循环写一个 1KB 文件 1000 次总时长比预想慢了一个数量级后来查下来就是因为每一次追加写入都触发了整扇区的擦写循环。2.3 为什么裸存不可行有人会问我不需要文件系统了直接把传感器数据按固定地址写进 Flash 行不行短时间可以长期一定出问题。一是修改任意字节的成本极高前面说过必须整扇区擦除重写二是没有坏块管理和磨损均衡热点区域很快就坏了三是一旦断电发生在擦除中途整个扇区会变成一个既不是旧数据也不是新数据的中间状态你连“恢复到上一个版本”的能力都没有。文件系统的本质就是在这个不友好的硬件之上提供三样东西逻辑地址映射把文件偏移量翻译成物理扇区、空间分配与管理哪些块空闲、哪些块占用、一致性保障掉电后至少回到一个可用的状态。MicroPython 的 VFS 层就是这三样东西的统一入口。3. FAT、littlefs、内建分区MicroPython 文件系统的选型逻辑3.1 littlefs 是怎样用 COW 和元数据日志保证掉电安全的MicroPython 在各个移植版本里选择的默认文件系统不完全一样但当前主流趋势是内部 Flash 默认使用littlefs v2SD 卡默认使用FAT/VFAT。这一选择背后是两种截然不同的设计哲学。littlefs 是 ARM 专为嵌入式设备设计的一个开源文件系统它有两个关键设计一个是COWCopy-On-Write写时复制另一个是元数据日志Metadata Log。COW 的意思是当你要修改文件数据时不直接在原来的块上改写而是先分配一个新块把修改后的数据写进新块再更新元数据指向新块。只有在元数据更新完成之后旧块才被标记为可回收。这样做的最大好处是任意时刻掉电要么看到旧版本文件要么看到新版本文件绝不会看到一个写了一半的损坏文件。元数据日志的机制也很有意思。littlefs 会把目录项、文件属性这类元数据放在一对交替使用的块中写入时总是先写“备份块”再切换“主块”配合内置的 CTZ skip-list 结构即使掉电发生在日志写入途中重启后也能从离得最近的完整状态恢复。这个设计对 MicroPython 极其重要因为开发板最常见的异常就是“直接断电”——你永远不会像 PC 一样先点“弹出 U 盘”再拔线。littlefs 的掉电一致性就是为这种场景量身定做的。import os # 在支持 littlefs 的固件上尝试格式化内部 Flash # 注意这会清空整个文件系统请确保没有重要数据 try: os.VfsLfs2.mkfs(os.flashbdev) os.mount(os.flashbdev, /) print(littlefs 格式化并挂载成功) except AttributeError: print(当前固件可能未包含 littlefs 模块)3.2 FAT 为什么还是被保留兼容性的价值FAT 文件系统是人类计算机历史上最成功的文件系统之一从 DOS 时代一路走到 SD 卡标准。MicroPython 的 SD 卡默认走 VfsFat核心原因就三个字兼容性。你插到电脑上要能直接读从电脑复制文件进 SD 卡后放进开发板要能直接读这个需求在 PC 与嵌入式之间来回搬运数据时是刚需。但 FAT 的缺陷同样明显它的文件分配表是集中式的掉电时表和目录项的更新顺序没有原子性保证很容易出现“有文件但打不开”“容量显示错误”等轻度损坏。而且 FAT 在设计之初根本没考虑过 Flash 磨损均衡如果你用 FAT 挂载一个裸 SPI Flash 并频繁写日志某几个 FAT 表扇区会迅速耗尽寿命。所以 MicroPython 社区的实际经验是**内部 Flash 用 littlefs 保命SD 卡用 FAT 保兼容。**如果你在 SD 卡上跑高频写入应用一个折中方案是给 SD 卡也建一个 littlefs 分区牺牲电脑直读的便利性换掉电安全。维度littlefs v2FAT/VFAT掉电一致性高COW 元数据日志低可能损坏目录项磨损均衡内建无电脑直读需要工具/驱动完全支持典型用途内部 Flash 根分区SD 卡、U 盘碎片处理CTZ 结构较抗碎片碎片化后读写变慢4. VFS根文件系统、挂载点和 /、/sd、/flash 背后的统一入口4.1 os.mount 和 os.umount 到底在干什么MicroPython 有一个叫VFSVirtual File System的层它的作用是让调用者的文件操作统一走open()、read()、write()这套接口至于底层是 littlefs、FAT还是一个完全自定义的块设备VFS 层帮你屏蔽了差异。os.mount()和os.umount()是理解 VFS 的钥匙。mount做两件事一是把一个块设备Block Device和一个文件系统驱动绑定二是把绑定后的文件系统挂载到一个目标路径上。比如你要把一张 SD 卡挂到/sdimport os from machine import SDCard # 初始化 SD 卡对象以 ESP32 为例实际参数依引脚而定 sd SDCard(slot1, sckPin(18), misoPin(19), mosiPin(23), csPin(5)) os.mount(sd, /sd)执行完这条命令之后/sd就成了 SD 卡文件系统的访问入口。此时用os.listdir(/)会看到根目录下除了内部 Flash 的内容外多了一个sd目录。但这个sd并不是 Flash 上的真实目录而是 VFS 挂载点所有对/sd的读写都会重定向到 SD 卡。如果你在 PC 上挂载过 Linux 的分区对这个概念一定不陌生。开发板上同样支持多个文件系统同时挂载典型结构是这样/ ├── boot.py ← 内部 Flash (littlefs) ├── main.py ├── lib/ │ └── utils.py └── sd/ ← SD 卡 (FAT)挂载点 ├── logs/ └── data.csv有一个新手经常踩的坑程序启动时如果os.mount()失败后续对/sd路径的所有操作都会报[Errno 19] ENODEV或OSError: [Errno 2] ENOENT。不是文件不存在而是挂载点压根没生效检查顺序应该是“块设备初始化 → 挂载 → 路径访问”。4.2 分区表、statvfs 和“可用空间”的计算os.statvfs()是查看文件系统容量的唯一标准接口但它返回的元组结构经常劝退新手。以 littlefs 为例返回值关键字段是import os info os.statvfs(/) print(info) # (bsize, frsize, blocks, bfree, bavail, files, ffree, favail, flag, namemax)bsize文件系统逻辑块大小littlefs 通常与 Flash 扇区对齐4KB。frsize基本块大小通常等于bsize。blocks总块数。bfree空闲块数。bavail可用块数一般等于bfree减去预留。总容量的计算公式是total_bytes info[0] * info[2] # bsize * blocks free_bytes info[0] * info[4] # bsize * bavail注意MicroPython 各版本这里的索引顺序和 CPython 不同务必以输出元组的实际顺序为准。另外blocks和bfree需要乘以frsize而不是 PPP 理解上的“扇区数”。我有一段时间直接拿info[2]当字节数用结果总容量显示为实际值的四分之一排查半天才发现是单位问题。多分区的场景下每个挂载点对应一套独立统计。比如根分区是只读固件区SD 卡是数据区你可以分别对/和/sd调用statvfs各自算各的互不干扰。5. 一条 open() 命令穿越的所有层级5.1 Python 接口 → VFS → 文件系统驱动 → Flash 驱动在 MicroPython 里执行一句最普通的with open(/data.txt, w) as f: f.write(hello)这一句背后其实穿越了至少四层。第一层是 MicroPython 运行时的open()系统调用封装它根据路径前缀决定走哪个挂载点第二层是 VFS 层负责把文件操作分发到挂载点对应的具体文件系统驱动VfsLfs2 或 VfsFat第三层是文件系统驱动自身它维护目录结构、分配块、写入页缓存第四层是底层块设备驱动把文件系统产生的读写请求翻译成 SPI/QSPI 时序信号最终作用到物理 Flash 单元。每一层都有可能出问题但报错信息往往只暴露最后一层的结果。我排查过很多案例典型的有调用open()报OSError: [Errno 28] No space left on device第一反应是 Flash 满了实际却是 littlefs 预留块耗尽触发了空间不足。write()成功返回了字节数但掉电后文件内容缺失因为数据还停在文件系统缓存没有真正落盘。close()之后立刻断电文件长度正确但内容异常因为文件元数据和数据块的写顺序没有保证。5.2 写入流程缓存、脏块、垃圾回收和 syncMicroPython 的文件写入流程并不是“写一句存一句”。为了减少 Flash 擦写次数littlefs 和 FAT 都有写缓存机制。write()调用后数据先进入一个内存页缓冲区缓冲区满了或者显式调用flush()/close()时文件系统才把脏数据打包写入物理存储。这个“延后写入”机制对嵌入式应用非常致命如果你的程序刚从write()返回就立马断电数据极可能还在内存缓冲里永远没有落盘。正确的做法是在关键节点调用os.sync()把当前所有文件系统的脏数据强制同步到底层设备with open(/log.txt, a) as f: f.write(payload \n) f.flush() os.sync() # 确保真正落盘flush()和os.sync()不是一回事。flush()只是把 Python 缓冲区的数据推给文件系统os.sync()才是在文件系统层面强制刷盘。两者配合使用才能保证断电后数据不丢。另一个影响写入行为的是垃圾回收GCGarbage Collection。littlefs 采用“标记-回收”的方式管理空间删除文件时只标记块为废弃真正的物理擦除留到后台或者下一次空间分配时触发。这就是文章开头那个现象的直接原因os.remove()之后可用空间没变大不是删除失败而是回收还没发生。import gc import os # 删除文件后触发垃圾回收再观察可用空间 os.remove(/temp.bin) gc.collect() info os.statvfs(/) print(info[0] * info[4], bytes free)5.3 文件碎片和 CTZ skip-listlittlefs 解决碎片的方式FAT 文件系统的老毛病是碎片文件反复增删后数据块散布在存储各处逻辑上连续读取时物理上要频繁跳跃速度越来越慢。littlefs 用了一种叫CTZ skip-list的结构每个文件的数据块通过指数递增的“跳指针”串联起来读取时能从近最近的位置快速定位后续块避免了长期使用后的严重退化。理解这一点对应用层有什么意义当你设计日志系统时如果每条日志都单独开一个小文件文件系统的元数据开销会非常大如果所有日志写进一个大文件并定期轮转littlefs 的顺序追加性能要远比随机写小文件优秀。这是文件系统特性倒逼出来的最佳实践。6. 掉电、sync、文件系统损坏真实嵌入式场景里的生存法则6.1 掉电丢数据的三个层次“掉电丢数据”这句话太笼统了实际至少要分三个层次看。第一层是RAM 里的数据丢失。这是最正常的变量、列表、缓冲区掉电即失谁也救不了。第二层是文件系统缓存中的数据未落盘。文件写入还停留在驱动缓冲区或 littlefs 的元数据日志中掉电后这部分数据彻底消失。对应前面说的flush()/os.sync()。第三层最隐蔽数据已经写入 Flash 物理扇区但文件系统元数据和数据本体的更新顺序不一致。比如数据块写了新版本但目录项还指向旧版本重启后文件系统通过一致性检查可能回滚到旧版本甚至丢弃新数据。littlefs 的 COW 设计大幅降低了第三层风险但并不能保证“所有已写入数据在任意断电时刻都保留”。它的保证是“文件系统结构一致不会变成无法挂载的状态”而不是“任何时刻掉电都不丢最新数据”。两个概念完全不同开发者心里要有数。6.2 内部 Flash 与 SD 卡的损坏差异内部 Flash 和 SD 卡虽然底层都是 NAND/NOR 类存储但损坏模式完全不同。内部 Flash 直连 MCU文件系统直接管理物理块磨损和坏块状态是可见的SD 卡内部有 FTL 控制器它对上层暴露的是一个“假装自己无限寿命”的通用块设备坏块管理、磨损均衡、垃圾回收全在卡内完成。SD 卡断电后的风险比内部 Flash 更高因为 SD 卡在执行写命令时内部有自己的缓存和映射更新流程断电发生在映射表更新的中间状态可能导致整个分区无法识别。我在调试一个数据采集器时因为供电方案做得潦草设备频繁在写入期间重启两周后 SD 卡插到电脑上提示“需要格式化”数据全部无法读取。后来改进方案是三条写重要数据时用独立电源域供电、每次写入后立刻os.sync()、SD 卡只存可丢失的缓存数据关键数据仍然写入内部 Flash。6.3 文件系统损坏的完整排查链路遇到“开机找不到文件”或者“挂载失败”类问题别急着格式化按下面链路排查。先确认块设备本身是否正常。在 REPL 里手动初始化 Flash 或 SD 卡并读取一个扇区看返回数据是否全0xFF或全0x00。全0xFF通常代表未格式化或者彻底擦除全0x00可能是硬件故障。再尝试手动挂载并观察报错import os try: os.mount(os.flashbdev, /) print(os.listdir(/)) except Exception as e: print(mount failed:, e)报错若指向“文件格式无效”或“期望 littlefs 但读到 FAT”多半是文件系统被误格式化或分区错位。此时如果能接受数据丢失重新格式化即可os.VfsLfs2.mkfs(os.flashbdev) os.mount(os.flashbdev, /)如果还能部分读取文件建议先把能抢救的文件通过复制或串口导出备份再执行格式化。格式化是最后手段不是第一手段。7. 实操分区、格式化、SD 卡接入和存储规划自查清单7.1 查看当前文件系统类型和切换方法固件默认是 littlefs 还是 FAT可以通过sys.implementation和 VFS 模块列表判断import sys print(sys.implementation) print(VfsFat:, hasattr(sys, VfsFat)) print(VfsLfs2:, hasattr(sys, VfsLfs2))切换文件系统类型的常规做法是在底层块设备上重新格式化。以内部 Flash 为例如果你当前是 FAT 想换成 littlefsimport os # 卸载当前文件系统 try: os.umount(/) except OSError: pass # 以 littlefs 格式化底层块设备 os.VfsLfs2.mkfs(os.flashbdev) # 重新挂载 os.mount(os.flashbdev, /)注意格式化会清空所有文件这个操作不可逆。另外不同开发板的底层块设备对象名字可能不同有些叫flashbdev有些叫bdev先打印dir(os)确认再动手。7.2 常见误区速查与我的踩坑记录最后整理一份自查清单每条都是我在真实项目里踩过的坑或者帮别人排查时反复见过的问题。现象真正原因正确做法删除文件后剩余空间没变大littlefs 延迟回收不是删除失败执行gc.collect()或者继续正常写入等待 GC文件写入成功但掉电后变空白数据停在缓存没落盘写入关键数据后执行f.flush()os.sync()mount()失败提示格式错误Flash 上是其他文件系统或未格式化确认文件系统类型后重新mkfsSD 卡插上后报ENODEV挂载点没有生效或 SD 初始化失败先初始化 SD 对象再os.mount()最后访问路径写文件速度越来越慢文件碎片化严重或频繁触发整扇区擦除改为日志追加模式或定期轮转文件总容量比 Flash 标称少很多文件系统本身有元数据和预留块开销这是正常现象可用空间以statvfs计算为准经验上我给 MicroPython 设备设计存储方案时总结了三句话。第一句内部 Flash 和 SD 卡分工内部放程序、配置、关键状态SD 卡放日志、缓存、可丢失数据。第二句任何可能触发掉电的操作之前先sync()宁可慢一点也不能赌断电不出事。第三句存储布局是整体架构设计的一部分不要在功能写完才回头补文件系统方案——那时候能改的余地已经很小了。最后再分享一个小技巧如果对 littlefs 的内部状态感到好奇MicroPython 的os模块默认不会暴露太多底层信息但你可以利用os.VfsLfs2的属性配合dir()来挖掘支持的方法。很多情况下一两条原本以为是无解的“玄学问题”翻到最后都是文件系统层面对 Flash 物理特性的折中处理。理解了底层很多问题就不是问题而是预期行为。
返回列表