
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 網盤你都能很快套上合適的塊設備適配層。