做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配置差异很大我列了个表方便对照开发板片内FlashMicroPython可用存储典型值说明ESP324MB部分模组8MB/16MB约1.5MB~3MB取决于固件分区表不同固件差异大ESP32-C34MB约1.5MB~2.5MB同上Raspberry Pi Pico (RP2040)2MB约1MB左右受片内Flash大小限制STM32F4071MB通常256KB~512KB文件系统分区大小由编译配置决定pyboard1MB约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 与 littlefsMicroPython 里的两种文件系统怎么选MicroPython里最常见的两种文件系统是FATos.VfsFat和littlefsos.VfsLfs2。早期固件普遍用FAT后来很多新固件默认改成了littlefs。两者差别很大直接决定你的数据在掉电时是“稍微损坏”还是“安然无恙”。特性FAT (VfsFat)littlefs (VfsLfs2)电脑直接读取支持插卡/挂载盘符直接看不支持Windows/macOS不原生识别掉电安全较差FAT表/目录项更新非原子掉电易损坏较好采用写时复制和元数据对掉电一致性更强磨损均衡无有基本的磨损均衡小文件存储效率较低按簇分配小文件也占一个簇较高对小文件友好适合场景需要跟电脑交换文件、配置导出日志记录、长时间运行的设备数据存储为什么新固件普遍转向littlefs原因很简单嵌入式设备经常突然断电电池没电、插头被拔、用户关机FAT文件系统在这种场景下很容易出现目录项损坏导致整个盘都打不开。littlefs牺牲了“电脑直接读取”的便利性换来了更强的可靠性对大多数物联网设备来说是更优的选择。那要怎么知道自己当前固件用的是哪个文件系统在MicroPython里没有一个直接命令能告诉你答案最靠谱的办法是看固件文档或者烧录固件时的配置。如果你用某些工具烧录自定义固件可以指定文件系统支持比如烧录时带flash8m之类的参数会影响分区但不一定影响文件系统类型。想真正确认最稳的方式是查固件发布说明或者反推新发布的通用固件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 卡与外部 FlashMicroPython支持在一个系统里同时挂载多个存储设备。最常见的场景是给开发板插SD卡再用os.mount()把SD卡挂载到某个路径下。挂载之后访问/sd就等于访问SD卡。以带SDIO接口的板子为例挂载流程大致是import os from machine import SD, Pin # 不同板子的SD引脚和初始化方式差别很大以官方库为准 sd SD(slot2, misoPin(2), mosiPin(15), sckPin(14), csPin(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看到文件列表先把需要保留的脚本拉到电脑上。如果文件系统已经损坏到看不到这个步骤就跳过。第二步擦除整片Flashesptool.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 # 表示大端I4字节无符号整数H2字节无符号整数f4字节浮点数 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。另外每次格式化之前先确认所有文件句柄都关干净否则空间一乱后续排查起来会让你很酸爽。希望这篇把存储链路拆开讲的指南能帮你少走些弯路。