把main.py写进MicroPython板子,断电再上电,代码照常跑——这是很多人第一次接触MicroPython存储与文件系统底层原理的起点。RAM断电即失忆,为什么脚本还在?是谁把Flash划分成了“程序区”和“文件区”的?为什么有些板子文件系统叫FAT,有些叫littlefs?删除一个大文件后,剩余空间为什么没有立刻变大?这些问题的答案,全藏在存储层级和文件系统的设计细节里。
这篇文章不打算写那种“照着敲一遍就完事”的用法教程,而是把整条链路拆开:从Flash芯片的物理特性,到MicroPython的VFS抽象层,再到FAT和littlefs的取舍逻辑,最后给出格式化、SD卡挂载、同步落盘这些真实项目里每天都在用的操作。全文尽量说人话,新手可以当入门地图,老手可以当一次系统性复盘。
1. 掉电不丢数据的秘密:Flash 芯片的物理脾气
1.1 RAM和Flash的分工
先从一个最简单的区分开始:板子上的RAM和Flash,一个负责“正在发生的事情”,一个负责“被记住的事情”。RAM速度快,可以随机读写,但一断电里面就全部清零;Flash虽然慢一些,数据却能保持很多年,断电也不丢。MicroPython的main.py并不存在RAM里,而是存在Flash的文件系统里。开机时MicroPython解释器会去文件系统里找boot.py和main.py,再把它们读进RAM执行。所以断电丢不丢代码,取决于你是不是真的把文件写进了Flash。
很多新手查内存占用,看到gc.mem_free()觉得存储空间变小了,其实这个函数查的是RAM,和文件系统能放多少文件完全是两码事。RAM相当于办公桌,Flash才相当于抽屉。知道这个区别之后,很多“为什么我内存明明很多,却写不了大文件”的疑问就迎刃而解了。
1.2 先擦后写与寿命约束
Flash有一个反直觉的物理特性:它不能像RAM那样按字节把0改成1或把1改成0。写入Flash之前,必须先对一大块区域做“擦除”,擦除会把整块区域恢复成全1,之后才能把需要的地方写成0。这个擦除操作的最小单元通常叫扇区(sector)或块(block),大小从4KB到几十KB不等。
这带来两个直接影响。第一,如果文件系统只修改一个文件末尾的几十个字节,底层可能要把整个扇区读出来,在内存里改好,再整块擦除并写回去。这也是为什么Flash不适合高频小量写入。第二,Flash的擦写次数有上限,寿命通常在几千到几万次左右。如果每次写日志都落在同一个扇区,那个位置很快会报废。文件系统里的“磨损均衡”就是尽量让所有扇区被擦写的次数均匀,从而延长整体寿命,这也是文件系统存在的意义之一。
MicroPython本身不直接管理这些物理扇区,而是通过文件系统去管理。文件系统把一堆扇区组合成逻辑上的“文件”和“目录”,负责分配空间、记录哪些块空闲、哪些块被占用。文件系统选得好不好,直接决定了你的数据在断电和长期写入下能不能活下来。
1.3 Flash不一定就是一颗独立芯片
还要澄清一个常见误解:很多板子的Flash并不是一颗外置小芯片。ESP32和STM32把Flash集成在主控内部(或封装在模块里),树莓派Pico则是外挂QSPI Flash芯片。SD卡本质上也是Flash,只是多了一层控制器和磨损均衡逻辑。
对MicroPython来说,不管是内置Flash还是外挂SD卡,最终都会抽象成“块设备”:一个能按固定大小读写数据块的设备。块设备之上再套文件系统。这个层次关系是后面所有内容的地基。
2. 固件、文件系统、用户代码:Flash 里的“三块地盘”
2.1 先有固件,才有文件系统
你烧录MicroPython固件时,实际上是把一整套解释器代码写进了Flash的固定区域,这部分叫固件区。固件区前面通常还有Bootloader和分区表。分区表就像一张地图,告诉主控Flash的哪一段是Bootloader、哪一段是固件、哪一段留给文件系统。
以常见的ESP32为例,烧录后的Flash默认会划分出nvs(保存校准数据和密钥)、otadata、OTA应用区,以及vfs文件系统区。文件系统区专门用来存放boot.py、main.py和你后面自己创建的任何文件。不同板子、不同固件版本的分区大小差别很大,这也是为什么同样写着“8MB Flash”的两块板子,实际能存文件的容量可能完全不一样。
有些固件的分区表把文件系统分区命名为spiffs,这是历史遗留的叫法,实际文件系统类型由MicroPython源码里的初始化代码决定,现在主流已经迁移到littlefs。
2.2 boot.py 和 main.py:开机代码的寻找路线
MicroPython上电后,固件里的解释器会做两件事:先尝试挂载根文件系统,再依次自动执行根目录下的boot.py和main.py。boot.py通常用来做硬件初始化、挂载SD卡、设置网络;main.py则放你的业务主逻辑。
这样拆开的好处是,即使main.py写崩了,你还能在REPL里手动修复,因为boot.py不一定受影响。如果main.py一启动就崩溃,MicroPython大多会停在REPL,把问题暴露出来。我自己习惯在改代码之前先把“最后一次正常运行”的版本备份到PC上,不然改崩了想回滚只能重新敲,这个教训很深刻。
2.3 用os模块查看真实存储布局
想知道板子现在文件系统多大、还剩多少空间,不要靠猜,直接在REPL里跑:
import os info = os.statvfs('/') # f_bsize 是块大小,f_blocks 是总块数 total = info[0] * info[2] free = info[0] * info[3] print('total bytes:', total) print('free bytes :', free)os.statvfs('/')返回的是一个元组,不同位置的数字含义在官方文档里有说明,最常见的用法就是拿它算容量和剩余空间。要查整颗Flash总大小,ESP32可以用import esp; esp.flash_size(),STM32则用pyb.flash_size()。有些固件还提供flashbdev模块,里面带bdev对象,能直接告诉你在哪里、有多大。这些命令组合起来,你就能知道自己手里这块板子的“硬盘”到底是什么状态。
2.4 为什么删除文件后剩余空间“没回来”
我见过很多新手在论坛问:删了一个大文件,为什么statvfs看到的剩余空间还是没变?如果你在删除后马上同步,文件系统已经标记空间为可用,但块设备层的缓存或littlefs的垃圾回收还没及时体现出来,统计就会滞后。最直接的验证办法是调用os.sync(),或者把文件系统重新挂载一下再看。
这里其实和电脑上“删除文件后存储没释放”的现象是同一个逻辑:数据不是立刻被物理抹掉,文件系统只是把空间标记成可复用。延后释放很正常,但如果长时间不释放,那就要查是不是有另一个打开的文件句柄还占着空间,或者日志策略里存在大量碎片文件。
3. VFS:让“文件”这个抽象概念统一一切设备
3.1 没有VFS的时候有多痛苦
假设没有抽象层,你要操作内置Flash,就得用Flash的读写函数;要操作SD卡,又得用SD卡的读写函数。每换一种存储介质,所有代码都得改。MicroPython用VFS(Virtual File System)解决了这个问题:VFS把“文件”这个东西抽象成统一接口,不管底层是Flash、SD卡,还是你自己实现的一块内存,用户看到的都是open()、read()、write()、os.listdir()。
这不是MicroPython自己的发明,操作系统几十年一直这么干。但MicroPython的VFS非常轻量,它只负责两件事:管理挂载点,以及把路径请求分发到对应的文件系统实现。
3.2 挂载点、文件系统驱动、块设备三层关系
VFS模型可以拆成三层:最底层是块设备(Block Device),负责和物理介质打交道;中间层是文件系统驱动,如FAT、littlefs,负责把块设备上的数据组织成文件和目录;最上层是VFS挂载点,负责把某个文件系统挂到路径上。
你在MicroPython里执行os.mount(sd, '/sd'),意思就是把SD卡的块设备交给文件系统驱动,并且让/sd这个路径指向它。之后你写open('/sd/data.txt', 'w'),VFS会先解析出/sd的挂载点,再调用对应文件系统驱动去处理。而/根目录下面的main.py,走的也是同一套路径,只不过它挂载的是内置Flash的文件系统。
3.3 自己做一个块设备,直观理解文件系统从零诞生
想真正理解这层抽象,最直接的办法是自己实现一个块设备。这里的块设备可以是一块RAM区域,也就是传说中的“内存盘”。
import os class BlockDev: def __init__(self, n): self.data = bytearray(n * 512) def readblocks(self, block_num, buf): for i in range(len(buf)): buf[i] = self.data[block_num * 512 + i] def writeblocks(self, block_num, buf): for i in range(len(buf)): self.data[block_num * 512 + i] = buf[i] def ioctl(self, op, arg): if op == 3: # 块数量 return len(self.data) // 512 if op == 4: # 块大小 return 512 bdev = BlockDev(128) # 128个512字节的“扇区” os.VfsFat.mkfs(bdev) # 在上面创建FAT文件系统 os.mount(bdev, '/ramdisk') # 挂载这段代码的意思是:先把一块64KB的内存初始化为块设备,然后让FAT文件系统把它格式化成一张“磁盘”,最后挂到/ramdisk目录。执行完你会发现可以往里面读写文件,但一掉电数据就没了,因为内存不保电。这个练习虽然不能持久化,却把块设备和文件系统两个概念拆得干干净净:块设备提供扇区读写能力,文件系统提供名字、目录、空间分配能力。
4. FAT 还是 littlefs?两个主流文件系统的选型博弈
4.1 FAT:为了和PC兼容而存在的老将
FAT源自上世纪个人电脑的软盘时代,结构简单,Windows、Linux、Mac都能直接识别。MicroPython之所以还在不少板子上采用FAT,一个重要原因是方便:你把SD卡从板子上拔下来插到电脑上,文件直接就能读,不需要任何额外工具。
不过FAT的缺陷也很明显:没有掉电保护。写入过程中断电,FAT表可能和实际数据不一致,轻则出现损坏文件,重则整个目录结构乱掉。FAT的元数据集中分布,反复修改同一个目录会导致这些区域的Flash磨损特别快,也没有设计磨损均衡。再加上FAT里多字节字段按小端字节序组织,这在和PC交互时是优点,但在嵌入式世界里只是历史包袱。
4.2 littlefs:为“随时可能断电”而生的现代方案
littlefs是ARM为嵌入式设备设计的开源文件系统,目标就是解决FAT在Flash上的两大痛点:掉电安全与磨损均衡。它的设计思路可以粗略理解为:写入新数据时不原地覆盖,而是先写入一个新块,等数据完整确认后再把元数据指针切换过去;元数据本身也是成对保存,提交时带校验,任一瞬间掉电,下次挂载都能回滚到最近一个完整状态。
这套机制换来的是更强的可靠性,代价是运行时的CPU和内存开销比FAT大,PC也没法直接识别。所以littlefs非常适合作为板载Flash的文件系统:系统永远知道哪些文件是完整的,断电不会把main.py写烂。MicroPython里它分VfsLfs1和VfsLfs2两代,目前绝大多数官方固件推荐使用VfsLfs2。
4.3 场景化选型:不是越新越好,而是匹配使用场景
我整理了一份简单的选型参考:
| 场景 | 推荐文件系统 | 原因 |
|---|---|---|
| 板载Flash存main.py和配置 | littlefs | 掉电安全、磨损均衡,优先保护系统关键文件 |
| SD卡存日志或数据,需要PC读取 | FAT | PC直接兼容,处理起来方便 |
| 大量高频写入日志 | 看情况 | 优先用SD卡+FAT,并批量写入;板载Flash应减少频繁写 |
| 临时缓存(掉电无所谓) | FAT/littlefs均可 | 用内存盘甚至不用文件系统 |
实际项目里我见过最难受的配置是:把日志写到板载Flash的FAT文件系统上,每几秒就open/write/close一次。这样既容易把Flash磨损死,又容易在断电时损坏FAT。后来改成“日志存SD卡+FAT,关键配置存Flash+littlefs”之后,问题一下子少了非常多。
5. 实操指南:格式化、SD卡挂载和落盘同步
5.1 格式化板载文件系统:两种可行路线
格式化板载文件系统属于“高危动作”,因为它会把里面的boot.py、main.py全清掉。但有时候你又必须做,比如文件系统逻辑损坏、无法挂载,或者想从FAT换成littlefs。
一个可靠思路是使用官方工具mpremote连接板子,在REPL里执行格式化。以常见的ESP32等固件为例:
mpremote connect /dev/ttyUSB0 exec "import os, flashbdev; os.VfsLfs2.mkfs(flashbdev.bdev)"这个命令会调用flashbdev模块里的块设备对象,在上面创建littlefs文件系统。执行完重启,板子会重新生成空的文件系统。如果你手里的固件没有flashbdev这个模块,或命令报错,那就换一条更暴力的路线:直接用esptool.py erase_flash擦除整片Flash,再重新烧录MicroPython固件。这样所有分区都会重置,文件系统也一定会被重建。
这里必须再强调一遍:格式化之前先把板子里所有要留的文件备份出来,否则一路清空,想后悔都没机会。
5.2 给板子挂SD卡
SD卡的价值在于容量大、可拆卸、可以快速插到PC上分析数据。硬件接法一般用SPI模式或SDMMC模式,不同主控接线也不一样。ESP32用SDMMC时可以这么挂:
import os, machine sd = machine.SDCard(slot=2, width=4) os.mount(sd, '/sd') print(os.listdir('/sd'))SPI模式的SD模块更常见,接好MOSI、MISO、SCK、CS之后,配合sdcard驱动库:
from machine import SPI, Pin import sdcard, os spi = SPI(1, baudrate=4000000, polarity=0, phase=0) sd = sdcard.SDCard(spi, Pin(5)) # Pin(5) 是CS os.mount(sd, '/sd')这块有个细节值得注意:SD卡一般格式化成FAT,不是为了什么“高级特性”,而是为了你能把卡拔下来插电脑直接看数据。挂载SD卡后,/sd下读写文件的方式和根目录一模一样,因为VFS把差异全部抹平了。如果你希望上电自动挂载,把挂载逻辑放进boot.py,并做好异常处理,避免SD卡没插时开机直接报错卡住。
5.3 sync:什么时候调、为什么调、副作用是什么
MicroPython的open()和write()不是每次调用都直接把数据写进Flash。为了性能,文件系统可能会有缓存区,真正落盘要等close、flush或sync。close能保证你这一次写入的文件结构完整,但如果你要保持文件长期打开、边采集边写,那就得手动调os.sync()把缓存刷下去。
你可能会问:那我每写一行就sync一次行不行?能行,但代价很大。Flash先擦后写,高频sync会让文件系统频繁操作底层块设备,磨损加速,写入速度也明显变慢。正确的姿势应该是:按业务节奏批量写入,比如采集10条数据、或每隔1秒,累积成一小块再write一次;在需要保证数据不丢的关键节点(比如刚写完配置、刚记录完异常)才调用sync。别看这个习惯小,它直接决定设备是“偶尔丢几秒数据”还是“经常坏文件系统”。
如果写的是日志类数据,我更推荐用SD卡+FAT,并且按天生成文件名,比如log_20250617.csv。这样单文件不会无限膨胀,查找方便,删除旧文件也不会影响当前日志。
6. 我踩过的那些“存储玄学”坑和排查链路
6.1 “删除文件后空间没回来”的完整排查过程
这个问题我在开发日志存储功能时真真切切遇到过。现象是:设备上删除了一批旧日志,os.statvfs('/')显示可用空间还是老样子。第一次遇到时我甚至怀疑是文件系统坏了。
排查链路是这样的:
- 先执行
os.sync(),再读statvfs,看空间是否恢复。 - 如果没恢复,检查是不是有任务还持有被删文件的句柄。MicroPython里如果你
open了一个文件没有close,删除后文件系统可能认为它还被占用,空间不会完全释放。 - 再考虑是不是文件系统碎片或littlefs的垃圾回收还没做。可以
os.umount('/')再重新挂载,一般会触发清理和重新统计。 - 如果依然没恢复,用工具把文件系统整个导出来,检查是否大量空间被坏块或隐藏的临时文件占掉。
最后发现我的问题出在“删除后没有sync”上,加上统计模块读的是缓存的容量信息,刷新一下就好了。这个排查思路对PC上的“删除后存储还在”也有参考价值——先看是不是回收站,再看有没有进程占用,再看文件系统是否需要整理。
6.2 “写了一半文件,重启后文件丢失”的根因
另一个高频现象是:程序运行中突然断电,再上电发现日志文件变成0字节,或者压根不存在。很多人第一反应是Flash坏了,其实大概率是写入流程没走完。如果你的代码是f.write()之后马上断电,而数据还在缓存层,那确实会丢。解决方式就是前面说的:在关键点os.sync();或者把写入改成原子式:先写临时文件,等完整写完后用os.rename()替换旧文件。这样即使中途断电,旧文件依然是上一次的完整版本。
这种“临时文件+rename”的思路,在配置类数据上特别管用。我自己做设备配置存储时,向来都是config.tmp写完后os.rename('config.tmp', 'config.json'),再配合sync。文件系统只要不是彻底损坏,总能保证至少有一个可用的配置文件。
6.3 格式化后“找不到设备”或“REPL都不见了”
格式化板载文件系统时最怕的是写到一半断电,或者用错了块设备对象。出现这种情况,板子可能表现为上电后卡在启动阶段,或者无法挂载根文件系统。不要慌,这通常不是硬件损坏,而是文件系统元数据写烂了。
处理路径很明确:如果能进REPL,尝试用mpremote执行格式化;如果完全连不上,就用esptool整片擦除后重刷固件。重新刷完固件之后,文件系统会回到初始状态,但boot.py和main.py也没了,相当于一台“全新设备”。所以再次强调:任何涉及板载文件系统的操作之前,先把文件全部备份到PC。
6.4 为什么日志写久了“性能越来越差”
最后说一个不那么致命但很磨人的问题:日志写了几百个小时后,明显感觉文件读写变慢了。FAT文件系统下,文件长期append容易导致数据链跨多个分散的区域,形成碎片;littlefs则可能在垃圾回收时做大量块搬移,同样拖慢性能。
我的处理经验是:能写SD卡就不要写板载Flash;日志按时间或大小滚动;定期清理旧日志;如果必须要长时间写板载Flash,尽量把数据批量累积成较大的块再写,减小碎片和磨损。把这些策略加进去之后,我手上的设备连续运行几个月的稳定性比之前强了一个量级。
最后再送你一个我自己的习惯:任何板载文件系统的大动作之前,先mpremote cp -r :/ ./backup把整盘拉到电脑上。备份成本只有几十秒,但能省掉无数次想砸板的冲动。存储和文件系统不像写代码那样有即时反馈,它的坑都埋在时间后面。你在这上面多花的十分钟思考,设备会替你还回来的。