ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

MicroPython块设备实战:SPI Flash挂载FAT文件系统

MicroPython块设备实战:SPI Flash挂载FAT文件系统 挂载 FAT 文件系统之前先理解 MicroPython 里的“块设备”是什么你可能在 Linux 上挂过 U 盘在 NAS 上挂过远程目录其实 MicroPython 里的os.mount也是同一个思路把一个文件系统挂到目录树的某个节点上。只不过在单片机上存储介质千奇百怪SD 卡、SPI Flash、内部 Flash、甚至一块 RAM都能成为“磁盘”而它们能不能被挂载取决于你是不是实现了 MicroPython 认识的那套块设备协议。三年前我在 ESP32 上折腾os.mountSD 卡插上去怎么都识别不了后来才发现问题根本不在卡片而在我抄来的驱动没有把块数量、块大小告诉 VFS 层。那一刻我才真正明白所谓“挂载”不是一句魔法咒语而是文件系统驱动在向一个块设备对象索要最基本的读写能力。这篇文章就围绕一块普通 SPI NOR Flash手把手实现一个 MicroPython 块设备类从最底层的readblocks、writeblocks、ioctl三个抽象接口讲起最终把它格式化成 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模块里的VfsFat、VfsLfs2是文件系统实现os.mount是挂载入口而dev这个参数就是一个块设备对象。只要一个类实现了readblocks、writeblocks、ioctl三个方法并且行为符合约定它就能被当作块设备交给文件系统。这就是整个设计的核心抽象接口是中间那根“万能接头”物理介质是什么不重要。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_num0表示读物理地址0*5120处的 512 字节block_num3表示读物理地址3*5121536处。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 02.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。不同固件版本可能略有差异最稳妥的方法是运行时临时打印op和arg看看文件系统到底在问什么。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]。到这里你已经完成了一次完整的“从块设备到文件系统”的闭环。值得强调一点mkfs和mount是两个动作。mkfs是格式化mount是挂载。RAM 里的数据会一直保留到复位或断电所以一上电只能看到格式化后的空盘。想要自动挂载可以在boot.py或main.py里重复上面这段代码但不能重复执行mkfs否则每次开机都会把文件系统抹掉。4. 实战给 SPI NOR Flash 写块设备类并挂载 FAT4.1 接线和 SPI 参数选择RAM 块设备能验证接口但实际项目里更多用的是 SPI NOR Flash。这类芯片常见的有 W25Q32、W25Q64、W25Q128便宜、容量适中非常适合存配置参数或日志。接线很简单4 根信号线加电源SCK 接 SPI 时钟MISO 接 SPI 主入从出MOSI 接 SPI 主出从入CS 接片选引脚低电平有效以 ESP32 为例可以这样初始化 SPIfrom machine import Pin, SPI spi SPI(1, baudrate20000000, polarity0, phase0) cs Pin(5, Pin.OUT, value1)polarity0, phase0对应 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_size4096, block_countNone): 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_ENABLE0x06完成一次操作后写使能锁存会被自动清零。代码里_erase_sector和_page_program内部各自处理了写使能顺序没问题。4.3 初始化、mkfs 和挂载步骤使用方式和 RAM 块设备完全一样只是多了一个硬件初始化spi SPI(1, baudrate20000000, polarity0, phase0) cs Pin(5, Pin.OUT, value1) 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)如果报错再考虑格式化。这里要注意SPIFlashBlockDev的block_count默认会从 JEDEC ID 自动识别容量。遇到不认识的 Flash 型号或者盗版芯片读 ID 不准可以在创建对象时手动传入块数flash SPIFlashBlockDev(spi, cs, block_count1024) # 4MB / 4KB 10245. 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_countFAT 挂载后写入很慢每次小写入都触发整块擦除考虑用 littlefs 或加写缓存6.2 排查思路和调试小技巧遇到挂载问题我建议按“接口-硬件-文件系统”三步排查。先验证接口。Flash 驱动写好之后不急着mkfs先用最原始的方式测读写flash.readblocks(0, buf) print(buf[:16])如果能读出一块内容基本说明 SPI 通信正常。再往一个空闲块写入一段固定字节读回来对比确认写功能正常。如果接口没问题但mkfs失败就到文件系统这层找问题。一种非常有效的做法是临时在ioctl里打印所有收到的op和argdef 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、还是扩展成多分区文件系统都只是在这个三方法协议上继续做填空题而已。
返回列表