MicroPython块设备实战:SPI Flash挂载FAT文件系统
2026/9/12 14:00:06 网站建设 项目流程

挂载 FAT 文件系统之前,先理解 MicroPython 里的“块设备”是什么

你可能在 Linux 上挂过 U 盘,在 NAS 上挂过远程目录,其实 MicroPython 里的os.mount也是同一个思路:把一个文件系统挂到目录树的某个节点上。只不过在单片机上,存储介质千奇百怪,SD 卡、SPI Flash、内部 Flash、甚至一块 RAM,都能成为“磁盘”,而它们能不能被挂载,取决于你是不是实现了 MicroPython 认识的那套块设备协议。

三年前我在 ESP32 上折腾os.mount,SD 卡插上去怎么都识别不了,后来才发现问题根本不在卡片,而在我抄来的驱动没有把块数量、块大小告诉 VFS 层。那一刻我才真正明白:所谓“挂载”不是一句魔法咒语,而是文件系统驱动在向一个块设备对象索要最基本的读写能力。这篇文章就围绕一块普通 SPI NOR Flash,手把手实现一个 MicroPython 块设备类,从最底层的readblockswriteblocksioctl三个抽象接口讲起,最终把它格式化成 FAT 文件系统并挂载到/flash

适合已经是 MicroPython 用户、但没深究过文件系统底层的人;如果你正想把配置、日志存在 Flash 里,又不想每次直接管理裸地址,这篇能让你少踩很多坑。

1. 整体设计与思路拆解:VFS、块设备和文件系统三层关系

1.1 为什么文件系统不直接操作硬件

很多新手第一次接触 MicroPython 文件系统时,会以为open("/sd/file.txt", "w")这个调用最终会直接往 SD 卡某个地址写数据。实际上中间隔了两层:块设备层负责把存储介质抽象成“一个个固定大小的数据块”,文件系统层负责管理这些块哪些是文件内容、哪些是目录信息、哪些是空闲空间。文件系统不关心底层介质是 SD 卡还是 SPI Flash,它只关心“这个设备能不能按块读写、一共有多少块、每块多大”。

这种拆分的价值在于可移植性。同一套 FAT 文件系统驱动,既能跑在 SD 卡上,也能跑在一块 4MB 的 SPI Flash 上;同一个 Flash 芯片,你今天挂 FAT,明天想换成 littlefs,物理硬件一行都不用改。我们平时说的os.mount(dev, "/flash"),实际上就是“把这个块设备对象交给 VFS,让它把文件系统挂到指定路径”。

MicroPython 内部把这些层组织得很清楚。os模块里的VfsFatVfsLfs2是文件系统实现;os.mount是挂载入口;而dev这个参数,就是一个块设备对象。只要一个类实现了readblockswriteblocksioctl三个方法,并且行为符合约定,它就能被当作块设备交给文件系统。这就是整个设计的核心:抽象接口是中间那根“万能接头”,物理介质是什么不重要。

1.2 块设备协议是一个“插座标准”

把块设备协议想象成电源插座。文件系统是插头上的三个引脚,块设备是墙上的插座。只要引脚定义匹配,不管是太阳能、火电还是水电,电器都不关心。MicroPython 的块设备协议同样只有三个“引脚”:

  • readblocks(block_num, buf):从一个逻辑块号开始,读出数据填进buf
  • writeblocks(block_num, buf):把buf里的数据写入从某个逻辑块号开始的位置。
  • ioctl(op, arg):返回设备信息,比如块数量、块大小。

有意思的是,MicroPython 并不强制你继承某个基类。官方虽然有一个AbstractBlockDev抽象类的概念,实际运行时更接近“鸭子类型”:你只要真的有这三个方法,VFS 就会当你是块设备。我做过实验,哪怕这个类内部只是一块bytearray内存,也能被格式化和挂载。

这种设计带来了很大的自由度。你可以给 RAM 做一个块设备,用来做临时文件系统或性能测试;也可以给外置 Flash、SD 卡甚至网络存储做块设备。本文后面会先实现一个 RAM 块设备来验证协议,再把它换成真正写 SPI Flash 的驱动。

2. 核心接口拆解:readblocks / writeblocks / ioctl 到底在说什么

2.1 readblocks:从第几个块开始读

readblocks(block_num, buf)的作用很直白:从逻辑块号block_num开始,把数据读入buf。假设块大小是 512 字节,block_num=0表示读物理地址0*512=0处的 512 字节;block_num=3表示读物理地址3*512=1536处。buf是 MicroPython 替你分配好的bytearray,你要做的就是往里面填数据。

地址换算公式固定为:

物理地址 = block_num * 每块字节数

我在初始版本里犯过一个低级错误:把block_num直接当成了物理地址,结果格式化的内容全写到了前三块,索引目录全乱套。只要记住这个公式,readblocks基本就是一次“按地址读长度”的操作。

buf的长度通常就是一块大小,但也不要写死成固定值。FAT 驱动有时会一次读多个相连的块,所以readblocks里应该以len(buf)为准,而不是假设它等于块大小。用切片的方式可以一次性填充:

def readblocks(self, block_num, buf): start = block_num * self.block_size end = start + len(buf) buf[:] = self.data[start:end] return 0

2.2 writeblocks:写入数据与 NOR Flash 的擦改矛盾

writeblocks(block_num, buf)是写入接口,逻辑上和读取对称:从block_num位置的块开始,把buf写入。返回 0 表示成功。对 RAM 来说,直接赋值即可。

但对 NOR Flash 这类介质,这里藏着一个大坑:NOR Flash 的写操作只能把 1 变成 0,不能把 0 变成 1。想把 0 改回 1,必须先擦除,而擦除的基本单位通常是一个大扇区,比如 4KB。所以实现写函数时,不能简单地先把目标字节写了,而需要考虑“擦除后再写”。这也是自写 Flash 驱动最容易出问题的地方。

我在后面的 SPI Flash 驱动里会用一个简单策略:逻辑块大小直接等于物理擦除扇区大小。这样一来,FAT 驱动写一个块,就等于擦除一个物理扇区并写回,不需要额外做“读-改-写”。如果将来想把块大小设成 512 字节,而物理扇区是 4KB,那就必须在写 512 字节时先读出所在 4KB 扇区、修改对应偏移、再整体擦除写回,代码会更复杂,但原理一致。

2.3 ioctl:告诉 VFS 这块设备长什么样

ioctl(op, arg)是控制接口,最重要的工作是返回块数量和块大小。MicroPython 文档和官方示例里,块数量通常对应op == 4,块大小对应op == 5。不同固件版本可能略有差异,最稳妥的方法是运行时临时打印oparg,看看文件系统到底在问什么。

def ioctl(self, op, arg): if op == 4: return self.block_count elif op == 5: return self.block_size return 0

块数量和块大小这两个值必须准确。块数量决定文件系统最大容量,块大小决定逻辑扇区大小。如果返回错误值,mkfs阶段可能直接报错,更常见的是写完数据后发现容量计算错乱。

ioctl里还会收到其他控制字,比如同步请求。对于没有缓存的简单块设备,直接返回 0 即可,不影响功能。真正要关心的是:你返回的块大小必须和writeblocks里使用的地址换算一致,否则文件系统会写串。

3. 先用 RAM 块设备验证整套流程

3.1 15 行 RAM 块设备类

在没有 Flash 硬件的情况下,可以先写一个 RAM 块设备,把整套“实现协议 -> 格式化 -> 挂载”流程跑通。RAM 的读写没有擦除概念,最能还原块设备协议的本质。

import os class RAMBlockDev: def __init__(self, block_size, block_count): self.block_size = block_size self.block_count = block_count self.data = bytearray(block_size * block_count) def readblocks(self, block_num, buf): start = block_num * self.block_size end = start + len(buf) buf[:] = self.data[start:end] return 0 def writeblocks(self, block_num, buf): start = block_num * self.block_size end = start + len(buf) self.data[start:end] = buf return 0 def ioctl(self, op, arg): if op == 4: return self.block_count elif op == 5: return self.block_size return 0

这个类内部用一块连续的bytearray模拟磁盘。readblocks通过切片把这部分数据拷贝到buf里,writeblocks反向写入。逻辑非常透明。

3.2 格式化、挂载、读写文件

有了块设备对象,接下来就可以调用os.VfsFat.mkfs把它格式化成 FAT 文件系统。这一步会往“磁盘”上写入引导扇区、FAT 表、根目录区,相当于给一块空白存储介质装上文件系统。

dev = RAMBlockDev(512, 512) os.VfsFat.mkfs(dev) os.mount(dev, "/ram") with open("/ram/hello.txt", "w") as f: f.write("hello from ram disk\n") print(os.listdir("/ram")) with open("/ram/hello.txt", "r") as f: print(f.read())

如果一切顺利,你会看到["hello.txt"]。到这里,你已经完成了一次完整的“从块设备到文件系统”的闭环。

值得强调一点:mkfsmount是两个动作。mkfs是格式化,mount是挂载。RAM 里的数据会一直保留到复位或断电,所以一上电只能看到格式化后的空盘。想要自动挂载,可以在boot.pymain.py里重复上面这段代码,但不能重复执行mkfs,否则每次开机都会把文件系统抹掉。

4. 实战:给 SPI NOR Flash 写块设备类并挂载 FAT

4.1 接线和 SPI 参数选择

RAM 块设备能验证接口,但实际项目里更多用的是 SPI NOR Flash。这类芯片常见的有 W25Q32、W25Q64、W25Q128,便宜、容量适中,非常适合存配置参数或日志。

接线很简单,4 根信号线加电源:

  • SCK 接 SPI 时钟
  • MISO 接 SPI 主入从出
  • MOSI 接 SPI 主出从入
  • CS 接片选引脚,低电平有效

以 ESP32 为例,可以这样初始化 SPI:

from machine import Pin, SPI spi = SPI(1, baudrate=20000000, polarity=0, phase=0) cs = Pin(5, Pin.OUT, value=1)

polarity=0, phase=0对应 SPI Mode 0,大部分 Winbond Flash 都支持。如果你的开发板没有硬件 SPI,也可以直接用SoftSPI,只是速度和稳定性会差一点。

4.2 完整 SPI Flash 块设备代码

下面这个类封装了 W25Q 系列 NOR Flash 的常见操作,实现了 MicroPython 块设备协议。它把逻辑块大小设置为 4096 字节,正好等于一个大扇区,因此每次写块都对应一次物理扇区擦除和写回。

import os from machine import SPI, Pin _CMD_READ = const(0x03) _CMD_PAGE_PROGRAM = const(0x02) _CMD_SECTOR_ERASE = const(0x20) _CMD_READ_STATUS = const(0x05) _CMD_WRITE_ENABLE = const(0x06) _CMD_JEDEC_ID = const(0x9F) _STATUS_BUSY = const(0x01) _CAPACITY_BYTES = { b"\xef\x40\x14": 1 << 20, b"\xef\x40\x15": 2 << 20, b"\xef\x40\x16": 4 << 20, b"\xef\x40\x17": 8 << 20, b"\xef\x40\x18": 16 << 20, } class SPIFlashBlockDev: def __init__(self, spi, cs, sector_size=4096, block_count=None): self.spi = spi self.cs = cs self.sector_size = sector_size self.cs.value(1) jedec_id = self._read_jedec_id() if block_count is None: total = _CAPACITY_BYTES.get(jedec_id) if total is None: raise ValueError("unknown flash capacity, pass block_count") block_count = total // sector_size self.block_count = block_count def _read_jedec_id(self): self.cs.value(0) self.spi.write(bytes([_CMD_JEDEC_ID])) data = bytearray(3) self.spi.readinto(data) self.cs.value(1) return bytes(data) def _read_status(self): self.cs.value(0) self.spi.write(bytes([_CMD_READ_STATUS])) status = self.spi.read(1)[0] self.cs.value(1) return status def _wait_ready(self): while self._read_status() & _STATUS_BUSY: pass def _cmd_write_enable(self): self.cs.value(0) self.spi.write(bytes([_CMD_WRITE_ENABLE])) self.cs.value(1) def _erase_sector(self, addr): self._cmd_write_enable() self.cs.value(0) self.spi.write(bytes([_CMD_SECTOR_ERASE]) + addr.to_bytes(3, "big")) self.cs.value(1) self._wait_ready() def _page_program(self, addr, data): self._cmd_write_enable() self.cs.value(0) self.spi.write(bytes([_CMD_PAGE_PROGRAM]) + addr.to_bytes(3, "big") + data) self.cs.value(1) self._wait_ready() def readblocks(self, block_num, buf): addr = block_num * self.sector_size self.cs.value(0) self.spi.write(bytes([_CMD_READ]) + addr.to_bytes(3, "big")) self.spi.readinto(buf) self.cs.value(1) return 0 def writeblocks(self, block_num, buf): if len(buf) == self.sector_size: self._write_block(block_num, buf) else: for off in range(0, len(buf), self.sector_size): chunk = buf[off:off + self.sector_size] self._write_block(block_num + off // self.sector_size, chunk) return 0 def _write_block(self, block_num, buf): addr = block_num * self.sector_size if len(buf) != self.sector_size: old = bytearray(self.sector_size) self.readblocks(block_num, old) old[:len(buf)] = buf buf = old current = bytearray(self.sector_size) self.readblocks(block_num, current) if bytes(current) == bytes(buf): return self._erase_sector(addr) for offset in range(0, self.sector_size, 256): page = buf[offset:offset + 256] if page: self._page_program(addr + offset, page) def ioctl(self, op, arg): if op == 4: return self.block_count elif op == 5: return self.sector_size return 0

这段代码的关键在于_write_block里的擦除判断。我先读出当前内容,如果和要写的内容完全一致就跳过,避免无谓的擦写损耗。如果内容不同,就先发写使能命令、擦除整个扇区,再按 256 字节一页一页写回去。W25Q 系列页编程一次最多写 256 字节,所以不能一次性把 4KB 都丢进去。

另一个细节是写使能。NOR Flash 在每次擦除和编程之前都必须发WRITE_ENABLE(0x06),完成一次操作后写使能锁存会被自动清零。代码里_erase_sector_page_program内部各自处理了写使能,顺序没问题。

4.3 初始化、mkfs 和挂载步骤

使用方式和 RAM 块设备完全一样,只是多了一个硬件初始化:

spi = SPI(1, baudrate=20000000, polarity=0, phase=0) cs = Pin(5, Pin.OUT, value=1) flash = SPIFlashBlockDev(spi, cs) os.VfsFat.mkfs(flash) # 第一次必须格式化,后续注释掉 os.mount(flash, "/flash") with open("/flash/test.txt", "w") as f: f.write("spi flash filesystem works!\n") print(os.listdir("/flash"))

如果你的板子已经格式化过,就不要重复执行mkfs,否则会丢数据。想确认 Flash 里有没有内容,可以先挂载,再os.listdir("/flash"),如果报错再考虑格式化。

这里要注意:SPIFlashBlockDevblock_count默认会从 JEDEC ID 自动识别容量。遇到不认识的 Flash 型号,或者盗版芯片读 ID 不准,可以在创建对象时手动传入块数:

flash = SPIFlashBlockDev(spi, cs, block_count=1024) # 4MB / 4KB = 1024

5. FAT 之外的扩展:littlefs、缓存和掉电保护

5.1 同一个块设备,换一个文件系统

块设备协议最大的好处是文件系统可以替换。FAT 方便 PC 读取,但在 Flash 上频繁写日志时,目录项和 FAT 表的反复更新会带来比较多的擦写磨损。MicroPython 还提供了一个更适合 Flash 的 littlefs 文件系统,调用方式几乎一样:

os.VfsLfs2.mkfs(flash) os.mount(flash, "/flash")

注意,同一块 Flash 上不能既存 FAT 又存 littlefs,切换文件系统必须重新格式化。我在实际项目里一般这样选:如果设备经常把文件拔下来插到电脑上读,就选 FAT;如果纯粹是设备内部用,比如存配置、日志、固件升级包,我更倾向于 littlefs,掉电恢复能力更强。

5.2 关于擦写寿命和掉电保护的几个提醒

NOR Flash 的擦写次数通常在十万次量级,看起来很多,但 FAT 写文件时同一块区域会被反复修改。日志文件如果每次追加几行就触发一次整块擦除,寿命损耗会很明显。

一个实用的缓解手段是给块设备加缓存:把要写的小数据先攒在 RAM 里,到一定量再一次性写到 Flash。但缓存也带来掉电丢数据的风险,所以适合“允许丢最近一小段数据”的场景。另一个更省心的办法是直接选 littlefs,它在设计上针对 Flash 做了平衡,掉电后文件系统损坏的概率比 FAT 低不少。

我在自己的项目里还会做一件小事:在写文件后调用同步操作。MicroPython 里如果有os.sync()就调用它,否则至少保证文件关闭。断电前如果能先os.umount("/flash"),能显著降低文件系统损坏的概率。

6. 常见问题与排查技巧实录

6.1 问题速查表

现象可能原因解决办法
OSError: [Errno 19] ENODEV块设备协议没实现好,或 ioctl 返回值错误检查 readblocks/writeblocks/ioctl,确认块数量和块大小
OSError: [Errno 22] EINVALmkfs 参数不对确认块大小和块数量合理,Flash 容量别太小
OSError: [Errno 5] EIO写入失败,常见于没有先擦除检查写使能、擦除命令和 SPI 接线
读出来全是 0xFF没有成功写入,或地址换算错误单条命令先测试读 JEDEC ID,再测单页写读
重启后文件丢失没有自动挂载,或文件没同步在 boot.py 里挂载,写完后 sync 再断电
格式化后容量不对block_count 自动识别错误手动传入 block_count
FAT 挂载后写入很慢每次小写入都触发整块擦除考虑用 littlefs 或加写缓存

6.2 排查思路和调试小技巧

遇到挂载问题,我建议按“接口->硬件->文件系统”三步排查。

先验证接口。Flash 驱动写好之后,不急着mkfs,先用最原始的方式测读写:

flash.readblocks(0, buf) print(buf[:16])

如果能读出一块内容,基本说明 SPI 通信正常。再往一个空闲块写入一段固定字节,读回来对比,确认写功能正常。

如果接口没问题,但mkfs失败,就到文件系统这层找问题。一种非常有效的做法是临时在ioctl里打印所有收到的oparg

def ioctl(self, op, arg): print("ioctl op =", op, ", arg =", arg) ...

这样你能亲眼看到 FAT 驱动在询问哪些参数,也能确认为什么返回值对不上。之前有人因为在别的文章里抄到op == 3返回块大小,结果在当前固件上报错,打印之后才发现这里的块大小控制字是 5。

最后检查硬件层面。SPI 接线松动是最常见的“薛定谔问题”:偶尔行偶尔不行。尽量用最短的杜邦线,CS 上拉到 VCC,上电顺序稳定。如果你用的是 ESP32-C3 这类新芯片,注意引脚能否用于 SPI,避免踩到复用冲突。

我在实际调试中还有一个习惯:在main.py里先尝试挂载,挂载失败就警告但不格式化,手动决定是否mkfs。自动格式化看起来很省事,但一旦 Flash 里有重要数据,格式化会把一切抹掉。

回看这个项目,我自己最深的体会是:MicroPython 的抽象接口并不复杂,真正难的是理解介质本身的物理特性。RAM 块设备 15 行就写完了,但换到 NOR Flash,就要面对擦除、写使能、页编程这些“底层脾气”。如果你照着上面的代码把os.mount跑通,再回头去看官方文档里的AbstractBlockDev,一定会觉得豁然开朗。之后想加 SD 卡驱动、换 littlefs、还是扩展成多分区文件系统,都只是在这个三方法协议上继续做填空题而已。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询