
1. 思路拆解为什么文件系统需要一个“块设备”1.1 从文件系统看块设备如果你用过电脑上的 U 盘、SD 卡会发现它们天生就能被 Windows、Linux 直接识别格式化成 FAT32 或者 exFAT 之后文件管理器里就能看到一个个文件夹和文件。但到 MicroPython 开发板上情况不太一样。板载 Flash 也好、外接 SPI Flash 也好底层都是按“地址”读写的存储芯片你给它一个字节地址它能读一个字节或者写一个字节这种按字节访问的存储介质和文件系统期望的“按扇区/按块访问”之间隔着一层抽象。这个抽象就是块设备。文件系统不关心你的存储芯片具体是 NOR Flash 还是 NAND Flash也不关心它是不是一个位于内存里的临时缓冲区文件系统只要求你提供一个“能按块读、按块写、能告诉它总共有多少块、每块多大”的对象。这就像图书馆的索引系统不需要知道藏书是在哪个仓库的哪个架子上只需要管理员能按“第几本”把书取出来或者放回去就行。块设备就是这个“管理员”。在 MicroPython 里VFS虚拟文件系统模块对上提供open、listdir、mkdir这些文件操作接口对下则通过一套固定的块设备协议去访问介质。你要在开发板上挂载 FAT 文件系统本质就是先写一个符合协议的块设备类再把它交给os.VfsFat去格式化、挂载然后就能像操作 U 盘一样操作底层存储了。1.2 MicroPython 里块设备的“契约”动手写代码之前必须先把协议搞清楚。MicroPython 并没有强制要求你用继承的方式实现块设备它更接近一个“鸭子类型”的约定只要你那个对象上有readblocks、writeblocks、ioctl这几个方法并且行为符合预期VFS 就认为它是一个块设备。方法签名是这样的readblocks(self, block_num, buf, offset0)读取从逻辑块号block_num开始的块内容读出来的数据放到buf字节串里。有些固件版本要求支持offset表示在块内偏移多少字节开始读。writeblocks(self, block_num, buf, offsetNone)把buf里的数据写到指定逻辑块。早期固件可能不带offset参数新版固件建议兼容offsetNone的情况。ioctl(self, op, arg)这个方法是块设备的“控制面板”负责给文件系统提供设备信息比如总共有多少块、每块多少字节、擦除某个块等。不同固件对操作码的定义有细微差别但遇到最多的是这几个4表示返回块数量5表示返回块大小6表示擦除指定块。除这三个方法外sync方法也建议实现。文件系统在刷新缓存时会调用它如果你的底层介质需要把缓冲区的数据真正落盘就在sync里处理。对于简单的 RAM 盘直接pass就行但为了以后迁移到 Flash 时不至于漏掉最好把这个方法留着。1.3 什么时候用得上自定义块设备很多人觉得MicroPython 开发板出厂不是已经带文件系统了吗何必还要自己写块设备类确实像 ESP32 这类开发板默认就挂载了一个 FAT 文件系统在/flash目录下可以直接读写文件。但那种情况下文件系统底层对应的块设备是厂商固件里写死的用户很难在运行时换一个存储介质。当你遇到下面这些场景时自定义块设备就是必须的你外接了一片 SPI Flash、FRAM、EEPROM或者普通的 SPI SD 卡模组希望把它格式化成 FAT 文件系统插到电脑上能直接读取。你想在内存里创建一个“虚拟磁盘”用于临时存放小文件、测试文件系统读写流程或者跑一些不希望在外部 Flash 上留下痕迹的验证逻辑。你想做一个带掉电保护的数据记录器底层存储的写策略需要你自己控制不能直接套用默认块设备的磨损均衡逻辑。你想把一块板子伪装成一个“通用的块存储设备”比如作为 USB 读卡器或者网络存储设备那内核里同样需要一套块接口实现。所以自定义块设备并不是只为了炫技它是嵌入式存储方案里一个相当基础且关键的能力。2. 从零写一个最简单的 RAM 块设备类2.1 类骨架与存储底层我习惯先从一个最小的 RAM 盘开始因为 RAM 盘不涉及擦除时间、坏块、磨损均衡这些复杂问题能把协议本身看得清清楚楚。类一开始只需要两个初始化参数块大小和块数量。块大小用 512 字节这是 FAT 文件系统最常用的扇区大小后续格式化时不容易出幺蛾子。class RAMBlockDev: def __init__(self, block_size512, num_blocks2048): self.block_size block_size self.data bytearray(block_size * num_blocks)这样一块 1MB 的 RAM 盘就出来了。num_blocks这里取 2048总容量正好 1MB足够放一批测试文件又不至于让内存吃紧。为什么要单独保存block_size因为ioctl里要返回给文件系统用。平时写代码容易犯的错是把块大小写死成 512后面真换了 4096 字节扇区的 Flash 就会炸。2.2 readblocks 和 writeblocks 怎么实现才稳妥这两个方法是块设备的核心也是最容易写错的地方。先看readblocksdef readblocks(self, block_num, buf, offset0): start block_num * self.block_size offset end start len(buf) buf[:] self.data[start:end]注意我用的是buf[:] ...而不是buf ...这一点特别关键。在 MicroPython 里buf是文件系统传入的一个可变 bytearray如果你直接赋值只会把形参重新指向新对象调用方拿到的还是旧数据。用切片赋值才能把数据真正拷贝到调用方提供的缓冲区里。再来看writeblocksdef writeblocks(self, block_num, buf, offsetNone): if offset is None: offset 0 start block_num * self.block_size offset end start len(buf) self.data[start:end] buf[:]为什么offset要做成可选参数因为不同版本的 MicroPython 对块设备协议的处理不一样。旧版可能直接调用writeblocks(block_num, buf)不传 offset新版则可能把跨块写拆成带 offset 的多次调用。为了让同一份代码在两个固件版本上都能跑这个兼容性处理就不要省。同样写入的时候也要注意边界。如果文件系统要求写的块号接近最后一块而start len(buf)超出了self.data长度切片开始和结束索引在 Python 里不会报 IndexError但会静默截断。真实设备上截断意味着数据悄悄丢失最好在开发时加上边界判断越界就直接抛异常不然排错会非常痛苦。2.3 ioctl 是块设备的“灵魂”ioctl虽然看起来只是几个if判断但真正决定块设备能不能被文件系统识别的就是它。我把常用操作码写成注释这样维护起来很清楚def ioctl(self, op, arg): if op 4: # 查询块数量 return len(self.data) // self.block_size if op 5: # 查询块大小 return self.block_size if op 6: # 擦除一个块 start arg * self.block_size self.data[start:start self.block_size] b\xff * self.block_size return 0操作码4和5是必须实现的否则文件系统连基本设备参数都拿不到。操作码6的擦除在 RAM 盘里其实只需要把那一块的数据全部置成 0xFF 就行因为大多数 Flash 就是这种擦除行为但要注意FAT 文件系统挂载时不一定每个块都会擦除所以返回值表示操作是否成功返回 0 表示成功。有的固件还支持操作码7或8分别表示“块擦除同步”和“设备同步”但这不是强制要求。如果你不确定自己的固件支持哪些操作码可以先用一个最简单的print(op, arg)把所有操作码打印出来然后再逐个补充对应逻辑这是排查问题最快的路子。对了有人会在ioctl里实现操作码1或2那是非常老的固件版本协议。新版本统一用4/5/6。如果你的开发板固件比较老挂载时报错可以把操作码都打印出来对照文档调整。我后面在问题排查章节会再说一次。3. 挂载 FAT 文件系统的完整实操3.1 用 os.VfsFat.mkfs 格式化写完了类接下来就是真正上场。先把刚才的类实例化然后格式化。import os bdev RAMBlockDev(block_size512, num_blocks2048) os.VfsFat.mkfs(bdev)这段代码执行后内存里的 1MB 区域就被写入了 FAT 文件系统的引导扇区、文件分配表、根目录区等结构。你可能会好奇mkfs是怎么知道块设备信息的其实就是调用了我们实现的ioctl拿到块数量、块大小然后调用writeblocks把 FAT 结构写进去。这里有几个坑要提醒有些 MicroPython 固件把os.VfsFat.mkfs放在os模块下有点绕但所有主流固件都支持。如果报AttributeError大概率是固件裁剪了 FAT 支持需要换一个带VfsFat的固件。格式化之前块设备对象必须已经分配好内部存储。比如我们的 RAM 盘self.data必须是一块可读写的 bytearray。你可以简单理解成先给“空白磁盘”装上再写文件系统。如果块数太少比如小于 256 块FAT 格式化可能会报空间不足。别问我是怎么知道的调小num_blocks之后格式化一直失败就是这个原因。RAM 盘至少给 1MB 比较省心。3.2 挂载到路径以及文件读写验证格式化完成后用os.mount把它挂到文件系统的某个路径下os.mount(bdev, /ram) print(os.listdir(/ram))挂载之后/ram就是一块全新的 FAT 磁盘。你可以像平时操作/flash一样对它读写# 写文件 with open(/ram/test.txt, w) as f: f.write(hello, block device!) # 读文件 with open(/ram/test.txt, r) as f: print(f.read()) # 创建目录 os.mkdir(/ram/data) # 写入一个二进制文件 with open(/ram/data/num.bin, wb) as f: f.write(bytes(range(256))) # 验证文件大小和内容 print(os.stat(/ram/data/num.bin)) with open(/ram/data/num.bin, rb) as f: data f.read() print(len(data), data[100], data[255])如果你对这个过程比较熟悉可能会注意到写入test.txt时文件系统会先更新 FAT 表再更新目录项最后在数据区写入内容。这些操作最终都被翻译成对readblocks、writeblocks的一串调用。可以在readblocks/writeblocks里临时加一个print会看到文件系统在底层发起的读取和写入请求这是一个非常直观的理解过程我建议你试一次。挂载路径不一定要是根下第一层你也可以挂到/mnt/sd这样的子路径只要这个路径下有对应的目录对象或是新建目录后挂载即可。不过有一点要记住同一个块设备不要重复挂载到多个路径FAT 并没有多挂载能力重复挂载轻则读文件出错重则直接崩掉。3.3 移植到 SPI Flash / SD 卡的关键改动RAM 盘跑通之后很多人想做的第一件事就是把它移植到真正的 SPI Flash 上。这个改动其实不多类里面的readblocks和writeblocks改成调用 Flash 的读写函数ioctl里的擦除操作改成调用 Flash 的擦除扇区函数sync改成把缓存写入 Flash 并等待完成。关键点在于块大小。很多 SPI Flash 的擦除粒度是 4KB而 FAT 文件系统默认的块大小是 512 字节两者不匹配。常见做法是让块设备类仍然对外暴露 512 字节的“逻辑块”内部再做一层映射每次擦除以 4KB 为单位读和写按 512 字节处理。如果你用的 Flash 有较大的编程页还需要注意 Page Program 不能跨页否则写完一轮你会发现文件内容有一部分是 0xFF。SD 卡模组则更简单因为 SD 卡本身就是标准的块设备块大小通常就是 512 字节。用 SPI 模式驱动时类里只需把读写块的操作转换成 CMD17/CMD24 命令即可。但 SD 卡数据线很多要小心接线和电平匹配很多板子用 3.3V 供电别直接怼 5V。4. 踩坑记录与问题排查4.1 格式化报错 OSError 的几种可能这是我遇到最多的问题尤其新手朋友拿到代码一跑os.VfsFat.mkfs(bdev)直接抛OSError: [Errno 22] Invalid argument。十个里面有七个是ioctl里的块数或块大小返回值不对。比如块大小返回了 0或者块数量返回了 0VFS 直接认为设备不合法。另外FAT 文件系统对设备容量的要求也比较挑剔。如果总容量小于 64KB格式化可能直接失败如果块数不是整数倍也会出现奇怪问题。为了保证成功率RAM 盘我建议至少用 256 个块每个块 512 字节也就是 128KB 起步。还有一个非常隐蔽的坑buf的长度可能与块大小不一致。在writeblocks和readblocks里buf的长度不一定正好等于block_size特别是在文件系统读写跨块数据时会传超过一个块大小的缓冲区。如果你在实现里假设缓冲区刚好等于块大小那么切片要么越界要么只处理了一部分最后目录区和 FAT 表就会写坏。4.2 ioctl 返回值不对导致挂不上格式化成功但挂载失败常见错误是OSError: [Errno 19] No such device或者ENODEV。这时候先别怀疑代码逻辑多半是ioctl的返回类型出了问题。MicroPython 对返回值类型有要求块数量和块大小必须返回整数而且是正数。如果你不小心在ioctl里加了个print返回None就会触发异常。见过一些人喜欢在ioctl里写调试信息结果正常返回路径也带着print的返回值这个低级错误排查起来非常费时间。更麻烦的一种情况是操作码映射不对。不同 MicroPython 分支对ioctl操作码的定义不同有的旧版本用 1 表示块数量2 表示块大小新版用 4/5/6。如果你是从网上抄的代码很可能抄到旧版协议跑在新固件上就是不对。我的建议是把op和arg打出来看一遍实际调用再对照自己的if分支。4.3 文件突然损坏擦除同步与写缓冲的坑RAM 盘没有掉电问题但移植到 Flash 后文件损坏的大部分原因都出在“擦除”和“同步”两个环节。FAT 文件系统会认为你需要“先擦后写”或“不支持部分写”时通过ioctl里的擦除操作告知块设备。如果你的 Flash 驱动没实现擦除而文件系统又恰好依赖这个流程写出来的数据就会有一部分是旧值和 0xFF 混杂。实现擦除时还有一点容易忽略擦除的单位是物理扇区不一定等于逻辑块大小。比如 Flash 的扇区是 4KB文件系统逻辑块是 512 字节能不能把擦除粒度也设置成 512 字节答案是几乎不可能。这时类里需要维护一个“脏块标记”或者说映射表标记哪些 512 字节逻辑块位于同一个 4KB 物理扇区里等到擦除时整片处理。这块逻辑要比 RAM 盘复杂不少但搞懂它基本上就吃透了块设备。另外writeblocks如果实现成“每次只写缓冲区长度”那么在文件系统传入长度小于扇区大小的数据时你要保证未覆盖的字节不会变成随机值。正式产品上至少要把扇区读出来改一部分再整块写回这就是经典的 Read-Modify-Write 流程。我见过好几个项目都是在这上面栽跟头文件目录偶尔坏一个条目就是局部写未处理干净导致的。5. 后续还能怎么玩5.1 做一个小而美的“日志记录器”一旦块设备类写好了你能做的就不只是挂个 RAM 盘。我用类似思路做过一个温湿度数据记录器外部挂一片 SPI FRAM块设备类包装成 512 字节逻辑块格式化成 FAT然后每隔一分钟打开一个 CSV 文件追加一行数据。FRAM 的优势是写入寿命长、速度快不需要擦除所以ioctl里的擦除操作可以直接返回 0 表示“擦除不需要做”这样文件系统少了很多底层操作跑起来特别顺。每次写日志时先open写一行再close虽然看着有点频繁但在 FRAM 上完全扛得住。如果你有兴趣甚至可以在ioctl里模拟一个“只读”开关把某些块写保护这样文件系统写的时候会立刻报错可以拿来做防误写测试。5.2 与 USB MSC 结合让电脑直接读取还有一个进阶方向是把自定义块设备暴露成 USB Mass Storage 设备。ST 和树莓派 Pico 上都有 USB 外设库只要你把块设备对象的readblocks、writeblocks、ioctl接到 MSC 回调接口上电脑插上 USB 线就能看到一个 FAT 移动盘。这样板子上写入的文件拿到电脑上直接就能读不需要串口工具或者脚本导出。这个方案我实测过最大的坑是 USB MSC 回调是在中断上下文里执行的不能用太复杂、带延时太高的 Flash 访问逻辑否则电脑端会报“设备未就绪”或者读写超时。一个有效的优化是加一个小型 RAM 缓存读写先走缓存再在sync时慢慢落盘。不过这已经是另一个项目体量了但起点还是你面前这个块设备类。5.3 最后再分享一个调试技巧如果你也和我一样没有专门的逻辑分析仪想观察文件系统对块设备的访问模式可以在readblocks、writeblocks、ioctl三个方法入口加一个print然后执行一条简单的文件写入命令观察调用顺序和参数。你会惊叹于一个open、write、close背后竟然有那么多底层块操作。等观察完再把打印去掉因为格式化或者大量读写时打印会极大地拖慢速度甚至导致超时。我个人的体会是写块设备类这件事难点不在于代码本身而在于你得同时理解“文件系统想要什么”和“底层存储能提供什么”。RAM 盘是一个非常好的试验田它把底层存储的成本降到最低让你可以专心把协议吃透。这套基础打牢之后不管以后是接 SD 卡、接 SPI Flash还是做 USB 网盘你都能很快套上合适的块设备适配层。