做MicroPython开发,你早晚会遇到几件跟存储有关的事:明明往板子上存了个文件,断电重启后数据却不见了;看着磁盘空间还剩几百KB,一写日志就开始报错;更头疼的是文件系统莫名其妙损坏,插上电脑能看到盘符却打不开。这些问题的根源,都在于大多数人只学会了“open 一个文件,然后 write”,却对底下那条“Flash硬件 → 文件系统 → VFS挂载层”的完整链路一无所知。这篇文章敢叫全网独一份,是因为我打算把MicroPython的存储体系从最底层一层层拆开讲,从Flash的物理特性讲到littlefs和FAT怎么选,再讲到根文件系统、sync、文件系统损坏恢复这些实操里绕不开的点。适合所有写过几行MicroPython但一直没搞懂“数据到底存哪去了”的开发者,哪怕是刚入门的新手,耐着性子看完也能对自己板子上的存储空间心里有底。
1. 先说清楚数据在板子上到底存哪儿
1.1 Flash、RAM 和“内存”的角色,别再搞混了
很多初学者会把开发板上的“内存”和“存储”当成一回事,这其实是第一道坎。简单说,RAM就是运行时的工作台,掉电瞬间所有变量、列表、字典全没;Flash就是仓库,断电之后固件、代码文件、数据文件还老老实实躺在里面。你可以把RAM想象成桌面上摊开的草稿纸,把Flash想象成抽屉里的笔记本——你在草稿纸上算来算去,最后要把结果抄进笔记本才不会丢。
MicroPython固件本身、你上传到板子上的.py脚本、程序运行时写出来的数据文件,全都存在Flash里。所以当我们在MicroPython里讨论“存储空间还有多少”时,说的就是这块Flash剩余多少。
常见开发板的Flash配置差异很大,我列了个表方便对照:
| 开发板 | 片内Flash | MicroPython可用存储(典型值) | 说明 |
|---|---|---|---|
| ESP32 | 4MB(部分模组8MB/16MB) | 约1.5MB~3MB | 取决于固件分区表,不同固件差异大 |
| ESP32-C3 | 4MB | 约1.5MB~2.5MB | 同上 |
| Raspberry Pi Pico (RP2040) | 2MB | 约1MB左右 | 受片内Flash大小限制 |
| STM32F407 | 1MB | 通常256KB~512KB | 文件系统分区大小由编译配置决定 |
| pyboard | 1MB | 约512KB~896KB | 主要表现为外置Flash |
注意,这个“可用存储”不是你买来就能看到的全部大小,它还取决于固件编译时怎么划分Flash区域。
1.2 板子上的 Flash 不是一整块,而是被分区的
我在调试ESP32时经常遇到一种情况:明明同一块Flash,擦除整个固件之后,连之前存的脚本和配置也不见了。原因就是板子上的Flash一般被划分成了好几个区域,比如固件区、文件系统区、配置区(NVS)——固件放着不会动,文件系统区专门用来存文件。
为什么不能把所有Flash都拿来做文件存储?因为固件升级时需要整体覆盖Flash,如果文件和固件混在一起,升级一次固件你的数据就全没了,谁也受不了。分区表本质上就是给Flash这块“大地皮”划分了不同用途的“功能区”,这个设计思路在嵌入式领域是通用的。
在MicroPython里,想查看当前文件系统的容量最直接的方式是调用os.statvfs("/")。我把常用代码写出来:
import os info = os.statvfs("/") # 返回元组:块大小、碎片大小、总块数、空闲块数、可用块数…… block_size = info[0] total_blocks = info[2] free_blocks = info[3] print("块大小: {} 字节".format(block_size)) print("总容量: {} 字节".format(block_size * total_blocks)) print("可用容量: {} 字节".format(block_size * free_blocks))运行之后你就能直观看到:这块板子的文件系统到底多大、现在还剩多少。这个命令在排查“空间为什么不够了”“文件写不进去”这类问题时非常常用,建议记下来。
2. 为什么嵌入式文件系统跟电脑完全不是一个玩法
2.1 Flash 的写入规矩:先擦除再写入
很多人默认Flash像硬盘一样,想改哪个字节就改哪个字节。但物理上Flash的工作方式极其“别扭”:读操作可以按字节/页进行,写操作只能把二进制位从1改成0,想把0改回1,就必须整块擦除。而且擦除不是按字节来的,是按照扇区(Sector)或块(Block)为单位,一个扇区可能是4KB甚至64KB。
打个比方:你在一张便签纸上写满了字,想修改其中一个字,不能拿橡皮擦掉那个字重新写,而是必须把整张纸撕掉换一张新的,然后再把整页内容抄上去。这个“整页重抄”的代价,就是Flash擦写性能比RAM低几个数量级,并且每次擦除都会消耗寿命。
NOR Flash的擦写寿命一般在10万次左右,听起来很多,但如果你用FAT文件系统频繁在同一个区域写小日志,很快就会出现某些扇区擦写次数飙升、整片Flash寿命被拉低的情况。所以嵌入式文件系统普遍需要“磨损均衡”,也就是尽量让所有扇区轮着被擦写,别盯着一块地方薅。
这带出一个新手经常忽略的结论:写文件慢,很多时候不是CPU慢,而是Flash的物理特性决定了它必须先擦除再写入。你体会到的“卡顿”,可能是底层在悄悄做整块擦除。
2.2 FAT 与 littlefs:MicroPython 里的两种文件系统怎么选
MicroPython里最常见的两种文件系统是FAT(os.VfsFat)和littlefs(os.VfsLfs2)。早期固件普遍用FAT,后来很多新固件默认改成了littlefs。两者差别很大,直接决定你的数据在掉电时是“稍微损坏”还是“安然无恙”。
| 特性 | FAT (VfsFat) | littlefs (VfsLfs2) |
|---|---|---|
| 电脑直接读取 | 支持,插卡/挂载盘符直接看 | 不支持,Windows/macOS不原生识别 |
| 掉电安全 | 较差,FAT表/目录项更新非原子,掉电易损坏 | 较好,采用写时复制和元数据对,掉电一致性更强 |
| 磨损均衡 | 无 | 有基本的磨损均衡 |
| 小文件存储效率 | 较低,按簇分配,小文件也占一个簇 | 较高,对小文件友好 |
| 适合场景 | 需要跟电脑交换文件、配置导出 | 日志记录、长时间运行的设备数据存储 |
为什么新固件普遍转向littlefs?原因很简单:嵌入式设备经常突然断电,电池没电、插头被拔、用户关机,FAT文件系统在这种场景下很容易出现目录项损坏,导致整个盘都打不开。littlefs牺牲了“电脑直接读取”的便利性,换来了更强的可靠性,对大多数物联网设备来说是更优的选择。
那要怎么知道自己当前固件用的是哪个文件系统?在MicroPython里没有一个直接命令能告诉你答案,最靠谱的办法是看固件文档或者烧录固件时的配置。如果你用某些工具烧录自定义固件,可以指定文件系统支持,比如烧录时带flash=8m之类的参数会影响分区但不一定影响文件系统类型。想真正确认,最稳的方式是查固件发布说明,或者反推:新发布的通用固件(2023年之后)大多数默认littlefs,老固件可能是FAT。
选型建议很简单:
- 如果你需要把SD卡拔出来插到电脑上读取数据,用FAT。
- 如果你在长期记录设备日志、环境数据,几乎不需要拔卡看内容,闭眼选littlefs。
3. 根文件系统、挂载与 VFS:文件路径是怎么找到设备的
3.1 根文件系统和 “/” 的关系
“根文件系统”这个概念在Linux里意义重大,在MicroPython里也同样重要。它的含义是:系统启动后挂载的第一个文件系统,是整个文件路径解析的起点。MicroPython里的“/”就是根文件系统,默认挂载的是内部Flash。
换句话说,当你执行open("/data.txt", "w")的时候,MicroPython会沿着路径解析:找到根目录“/”,再在当前挂载点对应的文件系统里去创建 data.txt。这个机制跟Linux的“挂载点”是同一套思想——一个存储设备必须挂载到某个路径下,之后读写这个路径,就等于读写这个设备。
我过去在调试时见过不少人直接在写os.mkdir("test"),结果文件建到了当前工作目录下,而不是自己想放的地方。MicroPython提供了os.getcwd()和os.chdir()可以查看/切换当前目录,但建议项目里尽量写完整路径,避免路径依赖。比如统一写成/data/temp.txt,别依赖当前目录。
根文件系统还牵扯到启动脚本main.py和boot.py的加载逻辑。固件启动时会先去根目录找boot.py,执行一遍,再去找main.py。如果你把根文件系统格式化了却没重新创建这两个文件,板子开机后就会一直报“无法导入main”,看起来像是“板子坏了”,其实只是启动脚本没了。
3.2 挂载外置存储:SD 卡与外部 Flash
MicroPython支持在一个系统里同时挂载多个存储设备。最常见的场景是给开发板插SD卡,再用os.mount()把SD卡挂载到某个路径下。挂载之后,访问/sd就等于访问SD卡。
以带SDIO接口的板子为例,挂载流程大致是:
import os from machine import SD, Pin # 不同板子的SD引脚和初始化方式差别很大,以官方库为准 sd = SD(slot=2, miso=Pin(2), mosi=Pin(15), sck=Pin(14), cs=Pin(13)) os.mount(sd, "/sd") # 挂载成功后就可以正常读写 print(os.listdir("/sd")) # 用完记得卸载 os.umount("/sd")注意,不同开发板的SD类名称不同。pyboard上叫pyb.SD,部分ESP32固件里叫machine.SD,还有不少板子需要用machine.SDCard。挂载路径也随你定义,你可以挂成/mmc、/card都行,只要不和已有路径冲突。
这里补充一个很多教程不会讲的细节:卸载之前,必须确保没有文件句柄还开着。如果你在/sd路径下打开了一个文件还没关闭就os.umount("/sd"),轻则卸载失败,重则文件系统状态异常。安全的做法是先执行f.close()再卸载。
如果你还想挂载外部SPI Flash,比如W25Q系列,就得自己写一个“块设备驱动”。MicroPython的VFS层不要求你直接操作扇区,只需要实现一个统一的块设备接口。骨架大概是:
import os class W25QFlash: def __init__(self): self.block_size = 4096 # 一个逻辑块大小 self.block_count = 1024 # 总块数(这里假设4MB Flash) def readblocks(self, block_num, buf): # 读取第 block_num 块,内容放到 buf 中 pass def writeblocks(self, block_num, buf): # 写入数据 pass def ioctl(self, req, arg): # req == 4 表示返回块数量 # req == 5 表示返回块大小 # req == 6 表示返回擦除块大小 if req == 4: return self.block_count elif req == 5: return self.block_size elif req == 6: return self.block_size return -1 flash = W25QFlash() os.VfsLfs2.mkfs(flash) # 格式化 os.mount(flash, "/ext") # 挂载当然这只是接口骨架,真实驱动还需要处理SPI通信、写使能、状态寄存器这些底层操作。对于大多数用户来说,这块不需要自己造轮子,但理解了这个协议,再看那些“外置Flash挂载教程”时就会有种豁然开朗的感觉。
4. 写入文件后,数据到底什么时候才真正落盘
4.1 flush、close、sync 分别干了什么
这是整个MicroPython存储体系里最容易被忽视、也是掉电丢数据最大的坑。很多人以为执行完f.write("hello")数据就已经写到Flash里了,其实完全不是。
写入操作要经过好几层缓冲:第一层是Python文件对象的用户缓冲,第二层是文件系统内部缓冲(比如FAT的缓存、littlefs的写入缓冲),最后才落到Flash物理介质。每层缓冲都可能把数据“扣留”在内存里,等到合适的时机再真正写盘。
with open("data.txt", "w") as f: f.write("hello") # 如果不做其他操作,立刻断电,这个文件可能不存在,也可能是旧内容我把几个关键操作的差异整理成表,方便你理解:
| 操作 | 作用 | 掉电后数据能保住吗 |
|---|---|---|
f.write() | 写入文件对象缓冲 | 不一定,可能随时丢 |
f.flush() | 将文件对象缓冲推到文件系统缓冲 | 不一定,还可能在文件系统缓存里 |
f.close() | 关闭文件,刷新该文件的所有缓冲到底层设备 | 通常可以,但极端掉电仍不保证 |
os.sync() | 强制同步所有已挂载文件系统的缓存到物理设备 | 能,只要设备本身没坏 |
我强烈建议在两类场景里使用os.sync():一是准备执行休眠、关机、断电前的最后一步;二是写完一批关键数据后。很多MicroPython开发板没有优雅的关机流程,用户拔电就像直接拔电脑电源,所以养成“重要数据写完必须sync”的习惯非常重要。
这里的误解最多:littlefs虽然掉电安全,但它保证的是“文件系统结构不损坏”,而不是“你write的数据不丢”。哪怕用littlefs,只要你没flush、没close、没sync,掉电后数据一样可能消失。保护文件系统结构和保护你的用户数据是两码事。
4.2 日志型应用怎么写才稳:控制频率与粒度
日志类应用是最常见的存储场景,也是踩坑重灾区。看过太多新手代码,每次传感器数据一出来就 open -> write -> close,循环往复。这样做的结果通常有两个:写入特别慢,Flash磨损特别快。
慢是因为每次 open/close 都会触发文件系统做目录更新和元数据刷新,这些操作在Flash上代价很高。磨损快是因为每次都落到同一片区域,没有磨损均衡的FAT文件系统尤其明显。
更合理的做法是“攒一批写一次”:
import os log_buf = [] def log_temperature(temp): log_buf.append("%.2f\n" % temp) if len(log_buf) >= 20: with open("/logs/temp.txt", "a") as f: f.write("".join(log_buf)) f.flush() os.sync() log_buf.clear() # 使用示例 log_temperature(25.3)这段代码的思路是:先用一个列表攒20条温度数据,满了再一次性写入文件并sync。好处有三点:减少open/close次数;减少文件系统元数据更新频率;降低Flash擦写次数。代价是断电时可能会丢失最多20条尚未写入的数据,具体攒多少条,要根据你的系统对可靠性和实时性的容忍度来权衡。
如果日志文件会持续增长,建议增加“轮转”逻辑——比如按天创建日志文件/logs/temp_20250101.txt,或者限制单个文件大小,超过大小就存成新文件。这样能避免单个文件无限膨胀,也方便后续清理。
4.3 文件系统报错排查:ENOSPC、EIO 与路径问题
我在论坛上经常看到有人被报错吓到,其实大部分都能按图索骥排查。下面是我遇到过最常见的几类:
| 异常信息 | 实际含义 | 常见处理 |
|---|---|---|
OSError: [Errno 28] No space left on device | 文件系统空间不足 | os.statvfs("/")查看空闲空间,清理大文件 |
OSError: [Errno 5] EIO | 底层I/O错误 | 检查SD卡接触、Flash坏块,必要时格式化 |
OSError: [Errno 2] ENOENT | 路径或文件不存在 | 检查目录是否创建、路径拼写、挂载点是否已挂载 |
ValueError: Filesystem must be mounted | 挂载的文件系统未挂载 | 先执行os.mount()再访问文件 |
关于“如果该文件位于远程文件系统,那么请检查你的网络连接”——这种提示通常出现在PC端工具或网络挂载环境里,跟MicroPython本地存储不是一回事。如果你是在电脑上远程操作MicroPython开发板时看到类似报错,先确认串口/网络链路是否正常,再怀疑板子上的文件系统。别把两个环境的问题混在一起排查,那样只会浪费时间。
另外还有一个很容易忽略的点:文件句柄泄漏。如果在循环里反复open()却不close(),最终文件系统会拒绝新写入,哪怕物理空间还有富余,因为文件系统层面的打开文件表已经被占满了。这种问题的特征是:os.statvfs显示还有空间,但写入就是失败。解决方式也简单——确保每个文件都被正确关闭,最好用with open(...)语法,它会在代码块结束后自动关闭文件。
5. 空间“删不掉”、文件系统损坏与格式化救场
5.1 删除文件后空间没回来?先排查这几个点
在用手机或平板时,你一定经历过“删了一堆照片,存储空间却不降反升”的情况。开发板上同样会发生“删了文件,但空间没回来”的现象,背后的原因虽不完全相同,但底层逻辑都是:删除操作没有真正完成物理介质层面的回收。
MicroPython里最常见的原因是文件对象没关闭。下面这段代码就是个典型:
f = open("big.bin", "w") f.write("x" * 100000) # 没有 close,也没有 sync os.remove("big.bin")你执行完os.remove("big.bin")后发现空间还是满的——因为那个文件对象仍然活着,文件系统层面认为它还没被完全释放。直到你执行f.close()或者重启板子,空间才会被回收。所以在删除大文件之前,先确认没有文件句柄还在占用它,否则就会出现“文件明明删了但空间没回来”。
第二个原因是日志文件隐藏增长。系统里可能有多个地方都在以追加模式写同一个日志文件,你以为删了某个大文件,几秒后它又被写程序重新创建出来了。排查方法是先停掉所有业务代码,再看空间有没有恢复。
第三个原因是异常断电造成的孤儿数据块。FAT或littlefs在断电瞬间可能没有完成块回收,导致一部分空间变成“谁也找不到但确实被占着”的状态。这种问题通过普通文件操作是看不到的,只能靠格式化解决。所以定期把数据备份、格式化一次文件系统,是不少嵌入式项目长期稳定运行的经验做法。
5.2 格式化文件系统的几种方式
格式化是最后的手段,也是很多问题的最终解药。MicroPython里没有全局的mkfs()函数,M必须明确指定文件系统类型和块设备对象。
pyboard这类能直接拿到内部Flash块设备的板子,可以在REPL里这样重建文件系统:
import os import pyb flash = pyb.Flash() os.VfsLfs2.mkfs(flash) os.mount(flash, "/")但ESP32这类板卡并没有公开一个叫machine.Flash()的块设备对象,所以想格式化内部Flash,最可靠的办法是擦除整片Flash再烧录固件。
无论用哪种方式,操作前都要备份所有需要保留的脚本和数据。格式化后,务必重新创建boot.py和main.py,否则系统无法正常启动。
5.3 用 ESP32 演示一次完整恢复流程
假设你现在文件系统完全打不开,板子启动就报错,最稳妥的恢复流程是这样的:
第一步,备份能救回的文件。如果还能用Thonny或mpremote看到文件列表,先把需要保留的脚本拉到电脑上。如果文件系统已经损坏到看不到,这个步骤就跳过。
第二步,擦除整片Flash:
esptool.py --port /dev/ttyUSB0 erase_flashWindows下端口一般是COM3、COM7这样的名字,根据自己的设备管理器修改。
第三步,烧录固件:
esptool.py --port /dev/ttyUSB0 write_flash -z 0x1000 firmware.bin第四步,重新上传boot.py和main.py,然后重启板子,文件系统会恢复成一个干净的状态。
这个方法会清掉板上所有文件,但也是让一块“假死”的开发板恢复生机的必经之路。经历过一次之后你就会明白,及时备份文件比什么技巧都重要。
6. 进阶实战:二进制存储、大小端与日志存储优化
6.1 struct 模块与大小端:存成自己说了算的格式
文本文件方便人类阅读,但存数值数据时效率不高。比如一个32位整数,如果转成字符串“12345678”,可能占用8个字节;但用二进制存就是固定的4个字节。更关键的是,文本解析在MCU上有不小开销,长期记录大量数据时,二进制格式几乎是必选项。
这就要提到“大小端”了。简单说:
- 大端(big-endian):高位字节在前。比如十六进制数
0x12345678,大端存储是12 34 56 78。 - 小端(little-endian):低位字节在前。比如
0x12345678,小端存储是78 56 34 12。
大部分MCU(比如ARM Cortex-M系列)默认是小端,x86 PC也是小端。但很多网络协议和文件格式习惯用大端。如果两个设备各自用默认格式写数值,换到对面设备上读就会得到完全错误的数据。
MicroPython提供了struct模块,可以显式指定字节序进行打包和解包:
import struct # ">" 表示大端,I=4字节无符号整数,H=2字节无符号整数,f=4字节浮点数 with open("sensor.bin", "wb") as f: f.write(struct.pack(">IHf", 123, 456, 23.5)) # 读取 with open("sensor.bin", "rb") as f: data = f.read() value1, value2, value3 = struct.unpack(">IHf", data) print(value1, value2, value3)我自己处理跨平台日志时,基本都会刻意固定用大端(>)格式,这样无论是拿到PC上解析,还是将来换一块不同架构的板子,都不需要担心字节序带来的坑。这个习惯让我的数据文件在多种设备之间保持兼容,省了无数次对比调试的时间。
6.2 固定大小环形日志:控制存储占用
如果你需要持续记录数据,又不想日志文件无限膨胀,可以借鉴“环形日志”的思路:固定一个日志文件大小,写满之后从头覆盖最老的数据。这样存储空间占用永远可控,特别适合长期无人值守的设备。
核心实现思路:
- 头部预留8字节,记录下一个写位置
write_pos。 - 每个记录前面加上长度字段,用于读取时定位。
- 写满尾部后,回到头部继续写,覆盖最旧的数据。
import os import struct LOG_SIZE = 0x10000 # 64KB环形日志 HEADER = 8 def write_record(f, pos, payload): rec = struct.pack(">H", len(payload)) + payload f.seek(pos) f.write(rec) f.flush() os.sync()真实项目里还得处理“断电时写了一半”的问题,比如在记录里加CRC校验,读的时候发现记录不完整就按废弃处理。这个方案的复杂度比较适合已经熟悉基础文件操作的读者,如果你的项目还在用简单追加日志,先把追加日志的可靠性和轮转做好,再考虑环形结构也不迟。
最后分享一个我实际调试时踩过的坑。有次我写数据采集程序,为了方便debug,每写一条数据就print一下,顺手调了一次os.sync(),结果本来流畅的采集流程被卡得一顿一顿。后来一测才发现,os.sync()的耗时远比我预想的高,频繁调用就是在给整个系统拖后腿。从那以后我改成攒一批数据再统一sync,整体吞吐明显改善,代价是掉电时最多丢一批数据。所以“多久落盘一次”永远是个权衡题,没有绝对正确的答案,只能根据你对实时性和可靠性的要求来定。我的习惯是:关键参数每次写完就sync,普通日志攒几十条再sync。另外每次格式化之前,先确认所有文件句柄都关干净,否则空间一乱,后续排查起来会让你很酸爽。希望这篇把存储链路拆开讲的指南,能帮你少走些弯路。