備:從RAM盤到FAT文件系統(tǒng)掛載實(shí)戰(zhàn))
1. 思路拆解為什么文件系統(tǒng)需要一個(gè)“塊設(shè)備”1.1 從文件系統(tǒng)看塊設(shè)備如果你用過電腦上的 U 盤、SD 卡會(huì)發(fā)現(xiàn)它們天生就能被 Windows、Linux 直接識(shí)別格式化成 FAT32 或者 exFAT 之后文件管理器里就能看到一個(gè)個(gè)文件夾和文件。但到 MicroPython 開發(fā)板上情況不太一樣。板載 Flash 也好、外接 SPI Flash 也好底層都是按“地址”讀寫的存儲(chǔ)芯片你給它一個(gè)字節(jié)地址它能讀一個(gè)字節(jié)或者寫一個(gè)字節(jié)這種按字節(jié)訪問的存儲(chǔ)介質(zhì)和文件系統(tǒng)期望的“按扇區(qū)/按塊訪問”之間隔著一層抽象。這個(gè)抽象就是塊設(shè)備。文件系統(tǒng)不關(guān)心你的存儲(chǔ)芯片具體是 NOR Flash 還是 NAND Flash也不關(guān)心它是不是一個(gè)位于內(nèi)存里的臨時(shí)緩沖區(qū)文件系統(tǒng)只要求你提供一個(gè)“能按塊讀、按塊寫、能告訴它總共有多少塊、每塊多大”的對(duì)象。這就像圖書館的索引系統(tǒng)不需要知道藏書是在哪個(gè)倉(cāng)庫(kù)的哪個(gè)架子上只需要管理員能按“第幾本”把書取出來或者放回去就行。塊設(shè)備就是這個(gè)“管理員”。在 MicroPython 里VFS虛擬文件系統(tǒng)模塊對(duì)上提供open、listdir、mkdir這些文件操作接口對(duì)下則通過一套固定的塊設(shè)備協(xié)議去訪問介質(zhì)。你要在開發(fā)板上掛載 FAT 文件系統(tǒng)本質(zhì)就是先寫一個(gè)符合協(xié)議的塊設(shè)備類再把它交給os.VfsFat去格式化、掛載然后就能像操作 U 盤一樣操作底層存儲(chǔ)了。1.2 MicroPython 里塊設(shè)備的“契約”動(dòng)手寫代碼之前必須先把協(xié)議搞清楚。MicroPython 并沒有強(qiáng)制要求你用繼承的方式實(shí)現(xiàn)塊設(shè)備它更接近一個(gè)“鴨子類型”的約定只要你那個(gè)對(duì)象上有readblocks、writeblocks、ioctl這幾個(gè)方法并且行為符合預(yù)期VFS 就認(rèn)為它是一個(gè)塊設(shè)備。方法簽名是這樣的readblocks(self, block_num, buf, offset0)讀取從邏輯塊號(hào)block_num開始的塊內(nèi)容讀出來的數(shù)據(jù)放到buf字節(jié)串里。有些固件版本要求支持offset表示在塊內(nèi)偏移多少字節(jié)開始讀。writeblocks(self, block_num, buf, offsetNone)把buf里的數(shù)據(jù)寫到指定邏輯塊。早期固件可能不帶offset參數(shù)新版固件建議兼容offsetNone的情況。ioctl(self, op, arg)這個(gè)方法是塊設(shè)備的“控制面板”負(fù)責(zé)給文件系統(tǒng)提供設(shè)備信息比如總共有多少塊、每塊多少字節(jié)、擦除某個(gè)塊等。不同固件對(duì)操作碼的定義有細(xì)微差別但遇到最多的是這幾個(gè)4表示返回塊數(shù)量5表示返回塊大小6表示擦除指定塊。除這三個(gè)方法外sync方法也建議實(shí)現(xiàn)。文件系統(tǒng)在刷新緩存時(shí)會(huì)調(diào)用它如果你的底層介質(zhì)需要把緩沖區(qū)的數(shù)據(jù)真正落盤就在sync里處理。對(duì)于簡(jiǎn)單的 RAM 盤直接pass就行但為了以后遷移到 Flash 時(shí)不至于漏掉最好把這個(gè)方法留著。1.3 什么時(shí)候用得上自定義塊設(shè)備很多人覺得MicroPython 開發(fā)板出廠不是已經(jīng)帶文件系統(tǒng)了嗎何必還要自己寫塊設(shè)備類確實(shí)像 ESP32 這類開發(fā)板默認(rèn)就掛載了一個(gè) FAT 文件系統(tǒng)在/flash目錄下可以直接讀寫文件。但那種情況下文件系統(tǒng)底層對(duì)應(yīng)的塊設(shè)備是廠商固件里寫死的用戶很難在運(yùn)行時(shí)換一個(gè)存儲(chǔ)介質(zhì)。當(dāng)你遇到下面這些場(chǎng)景時(shí)自定義塊設(shè)備就是必須的你外接了一片 SPI Flash、FRAM、EEPROM或者普通的 SPI SD 卡模組希望把它格式化成 FAT 文件系統(tǒng)插到電腦上能直接讀取。你想在內(nèi)存里創(chuàng)建一個(gè)“虛擬磁盤”用于臨時(shí)存放小文件、測(cè)試文件系統(tǒng)讀寫流程或者跑一些不希望在外部 Flash 上留下痕跡的驗(yàn)證邏輯。你想做一個(gè)帶掉電保護(hù)的數(shù)據(jù)記錄器底層存儲(chǔ)的寫策略需要你自己控制不能直接套用默認(rèn)塊設(shè)備的磨損均衡邏輯。你想把一塊板子偽裝成一個(gè)“通用的塊存儲(chǔ)設(shè)備”比如作為 USB 讀卡器或者網(wǎng)絡(luò)存儲(chǔ)設(shè)備那內(nèi)核里同樣需要一套塊接口實(shí)現(xiàn)。所以自定義塊設(shè)備并不是只為了炫技它是嵌入式存儲(chǔ)方案里一個(gè)相當(dāng)基礎(chǔ)且關(guān)鍵的能力。2. 從零寫一個(gè)最簡(jiǎn)單的 RAM 塊設(shè)備類2.1 類骨架與存儲(chǔ)底層我習(xí)慣先從一個(gè)最小的 RAM 盤開始因?yàn)?RAM 盤不涉及擦除時(shí)間、壞塊、磨損均衡這些復(fù)雜問題能把協(xié)議本身看得清清楚楚。類一開始只需要兩個(gè)初始化參數(shù)塊大小和塊數(shù)量。塊大小用 512 字節(jié)這是 FAT 文件系統(tǒng)最常用的扇區(qū)大小后續(xù)格式化時(shí)不容易出幺蛾子。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總?cè)萘空?1MB足夠放一批測(cè)試文件又不至于讓內(nèi)存吃緊。為什么要單獨(dú)保存block_size因?yàn)閕octl里要返回給文件系統(tǒng)用。平時(shí)寫代碼容易犯的錯(cuò)是把塊大小寫死成 512后面真換了 4096 字節(jié)扇區(qū)的 Flash 就會(huì)炸。2.2 readblocks 和 writeblocks 怎么實(shí)現(xiàn)才穩(wěn)妥這兩個(gè)方法是塊設(shè)備的核心也是最容易寫錯(cuò)的地方。先看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 ...這一點(diǎn)特別關(guān)鍵。在 MicroPython 里buf是文件系統(tǒng)傳入的一個(gè)可變 bytearray如果你直接賦值只會(huì)把形參重新指向新對(duì)象調(diào)用方拿到的還是舊數(shù)據(jù)。用切片賦值才能把數(shù)據(jù)真正拷貝到調(diào)用方提供的緩沖區(qū)里。再來看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要做成可選參數(shù)因?yàn)椴煌姹镜?MicroPython 對(duì)塊設(shè)備協(xié)議的處理不一樣。舊版可能直接調(diào)用writeblocks(block_num, buf)不傳 offset新版則可能把跨塊寫拆成帶 offset 的多次調(diào)用。為了讓同一份代碼在兩個(gè)固件版本上都能跑這個(gè)兼容性處理就不要省。同樣寫入的時(shí)候也要注意邊界。如果文件系統(tǒng)要求寫的塊號(hào)接近最后一塊而start len(buf)超出了self.data長(zhǎng)度切片開始和結(jié)束索引在 Python 里不會(huì)報(bào) IndexError但會(huì)靜默截?cái)唷U鎸?shí)設(shè)備上截?cái)嘁馕吨鴶?shù)據(jù)悄悄丟失最好在開發(fā)時(shí)加上邊界判斷越界就直接拋異常不然排錯(cuò)會(huì)非常痛苦。2.3 ioctl 是塊設(shè)備的“靈魂”ioctl雖然看起來只是幾個(gè)if判斷但真正決定塊設(shè)備能不能被文件系統(tǒng)識(shí)別的就是它。我把常用操作碼寫成注釋這樣維護(hù)起來很清楚def ioctl(self, op, arg): if op 4: # 查詢塊數(shù)量 return len(self.data) // self.block_size if op 5: # 查詢塊大小 return self.block_size if op 6: # 擦除一個(gè)塊 start arg * self.block_size self.data[start:start self.block_size] b\xff * self.block_size return 0操作碼4和5是必須實(shí)現(xiàn)的否則文件系統(tǒng)連基本設(shè)備參數(shù)都拿不到。操作碼6的擦除在 RAM 盤里其實(shí)只需要把那一塊的數(shù)據(jù)全部置成 0xFF 就行因?yàn)榇蠖鄶?shù) Flash 就是這種擦除行為但要注意FAT 文件系統(tǒng)掛載時(shí)不一定每個(gè)塊都會(huì)擦除所以返回值表示操作是否成功返回 0 表示成功。有的固件還支持操作碼7或8分別表示“塊擦除同步”和“設(shè)備同步”但這不是強(qiáng)制要求。如果你不確定自己的固件支持哪些操作碼可以先用一個(gè)最簡(jiǎn)單的print(op, arg)把所有操作碼打印出來然后再逐個(gè)補(bǔ)充對(duì)應(yīng)邏輯這是排查問題最快的路子。對(duì)了有人會(huì)在ioctl里實(shí)現(xiàn)操作碼1或2那是非常老的固件版本協(xié)議。新版本統(tǒng)一用4/5/6。如果你的開發(fā)板固件比較老掛載時(shí)報(bào)錯(cuò)可以把操作碼都打印出來對(duì)照文檔調(diào)整。我后面在問題排查章節(jié)會(huì)再說一次。3. 掛載 FAT 文件系統(tǒng)的完整實(shí)操3.1 用 os.VfsFat.mkfs 格式化寫完了類接下來就是真正上場(chǎng)。先把剛才的類實(shí)例化然后格式化。import os bdev RAMBlockDev(block_size512, num_blocks2048) os.VfsFat.mkfs(bdev)這段代碼執(zhí)行后內(nèi)存里的 1MB 區(qū)域就被寫入了 FAT 文件系統(tǒng)的引導(dǎo)扇區(qū)、文件分配表、根目錄區(qū)等結(jié)構(gòu)。你可能會(huì)好奇mkfs是怎么知道塊設(shè)備信息的其實(shí)就是調(diào)用了我們實(shí)現(xiàn)的ioctl拿到塊數(shù)量、塊大小然后調(diào)用writeblocks把 FAT 結(jié)構(gòu)寫進(jìn)去。這里有幾個(gè)坑要提醒有些 MicroPython 固件把os.VfsFat.mkfs放在os模塊下有點(diǎn)繞但所有主流固件都支持。如果報(bào)AttributeError大概率是固件裁剪了 FAT 支持需要換一個(gè)帶VfsFat的固件。格式化之前塊設(shè)備對(duì)象必須已經(jīng)分配好內(nèi)部存儲(chǔ)。比如我們的 RAM 盤self.data必須是一塊可讀寫的 bytearray。你可以簡(jiǎn)單理解成先給“空白磁盤”裝上再寫文件系統(tǒng)。如果塊數(shù)太少比如小于 256 塊FAT 格式化可能會(huì)報(bào)空間不足。別問我是怎么知道的調(diào)小num_blocks之后格式化一直失敗就是這個(gè)原因。RAM 盤至少給 1MB 比較省心。3.2 掛載到路徑以及文件讀寫驗(yàn)證格式化完成后用os.mount把它掛到文件系統(tǒng)的某個(gè)路徑下os.mount(bdev, /ram) print(os.listdir(/ram))掛載之后/ram就是一塊全新的 FAT 磁盤。你可以像平時(shí)操作/flash一樣對(duì)它讀寫# 寫文件 with open(/ram/test.txt, w) as f: f.write(hello, block device!) # 讀文件 with open(/ram/test.txt, r) as f: print(f.read()) # 創(chuàng)建目錄 os.mkdir(/ram/data) # 寫入一個(gè)二進(jìn)制文件 with open(/ram/data/num.bin, wb) as f: f.write(bytes(range(256))) # 驗(yàn)證文件大小和內(nèi)容 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])如果你對(duì)這個(gè)過程比較熟悉可能會(huì)注意到寫入test.txt時(shí)文件系統(tǒng)會(huì)先更新 FAT 表再更新目錄項(xiàng)最后在數(shù)據(jù)區(qū)寫入內(nèi)容。這些操作最終都被翻譯成對(duì)readblocks、writeblocks的一串調(diào)用??梢栽趓eadblocks/writeblocks里臨時(shí)加一個(gè)print會(huì)看到文件系統(tǒng)在底層發(fā)起的讀取和寫入請(qǐng)求這是一個(gè)非常直觀的理解過程我建議你試一次。掛載路徑不一定要是根下第一層你也可以掛到/mnt/sd這樣的子路徑只要這個(gè)路徑下有對(duì)應(yīng)的目錄對(duì)象或是新建目錄后掛載即可。不過有一點(diǎn)要記住同一個(gè)塊設(shè)備不要重復(fù)掛載到多個(gè)路徑FAT 并沒有多掛載能力重復(fù)掛載輕則讀文件出錯(cuò)重則直接崩掉。3.3 移植到 SPI Flash / SD 卡的關(guān)鍵改動(dòng)RAM 盤跑通之后很多人想做的第一件事就是把它移植到真正的 SPI Flash 上。這個(gè)改動(dòng)其實(shí)不多類里面的readblocks和writeblocks改成調(diào)用 Flash 的讀寫函數(shù)ioctl里的擦除操作改成調(diào)用 Flash 的擦除扇區(qū)函數(shù)sync改成把緩存寫入 Flash 并等待完成。關(guān)鍵點(diǎn)在于塊大小。很多 SPI Flash 的擦除粒度是 4KB而 FAT 文件系統(tǒng)默認(rèn)的塊大小是 512 字節(jié)兩者不匹配。常見做法是讓塊設(shè)備類仍然對(duì)外暴露 512 字節(jié)的“邏輯塊”內(nèi)部再做一層映射每次擦除以 4KB 為單位讀和寫按 512 字節(jié)處理。如果你用的 Flash 有較大的編程頁(yè)還需要注意 Page Program 不能跨頁(yè)否則寫完一輪你會(huì)發(fā)現(xiàn)文件內(nèi)容有一部分是 0xFF。SD 卡模組則更簡(jiǎn)單因?yàn)?SD 卡本身就是標(biāo)準(zhǔn)的塊設(shè)備塊大小通常就是 512 字節(jié)。用 SPI 模式驅(qū)動(dòng)時(shí)類里只需把讀寫塊的操作轉(zhuǎn)換成 CMD17/CMD24 命令即可。但 SD 卡數(shù)據(jù)線很多要小心接線和電平匹配很多板子用 3.3V 供電別直接懟 5V。4. 踩坑記錄與問題排查4.1 格式化報(bào)錯(cuò) OSError 的幾種可能這是我遇到最多的問題尤其新手朋友拿到代碼一跑os.VfsFat.mkfs(bdev)直接拋OSError: [Errno 22] Invalid argument。十個(gè)里面有七個(gè)是ioctl里的塊數(shù)或塊大小返回值不對(duì)。比如塊大小返回了 0或者塊數(shù)量返回了 0VFS 直接認(rèn)為設(shè)備不合法。另外FAT 文件系統(tǒng)對(duì)設(shè)備容量的要求也比較挑剔。如果總?cè)萘啃∮?64KB格式化可能直接失敗如果塊數(shù)不是整數(shù)倍也會(huì)出現(xiàn)奇怪問題。為了保證成功率RAM 盤我建議至少用 256 個(gè)塊每個(gè)塊 512 字節(jié)也就是 128KB 起步。還有一個(gè)非常隱蔽的坑buf的長(zhǎng)度可能與塊大小不一致。在writeblocks和readblocks里buf的長(zhǎng)度不一定正好等于block_size特別是在文件系統(tǒng)讀寫跨塊數(shù)據(jù)時(shí)會(huì)傳超過一個(gè)塊大小的緩沖區(qū)。如果你在實(shí)現(xiàn)里假設(shè)緩沖區(qū)剛好等于塊大小那么切片要么越界要么只處理了一部分最后目錄區(qū)和 FAT 表就會(huì)寫壞。4.2 ioctl 返回值不對(duì)導(dǎo)致掛不上格式化成功但掛載失敗常見錯(cuò)誤是OSError: [Errno 19] No such device或者ENODEV。這時(shí)候先別懷疑代碼邏輯多半是ioctl的返回類型出了問題。MicroPython 對(duì)返回值類型有要求塊數(shù)量和塊大小必須返回整數(shù)而且是正數(shù)。如果你不小心在ioctl里加了個(gè)print返回None就會(huì)觸發(fā)異常。見過一些人喜歡在ioctl里寫調(diào)試信息結(jié)果正常返回路徑也帶著print的返回值這個(gè)低級(jí)錯(cuò)誤排查起來非常費(fèi)時(shí)間。更麻煩的一種情況是操作碼映射不對(duì)。不同 MicroPython 分支對(duì)ioctl操作碼的定義不同有的舊版本用 1 表示塊數(shù)量2 表示塊大小新版用 4/5/6。如果你是從網(wǎng)上抄的代碼很可能抄到舊版協(xié)議跑在新固件上就是不對(duì)。我的建議是把op和arg打出來看一遍實(shí)際調(diào)用再對(duì)照自己的if分支。4.3 文件突然損壞擦除同步與寫緩沖的坑RAM 盤沒有掉電問題但移植到 Flash 后文件損壞的大部分原因都出在“擦除”和“同步”兩個(gè)環(huán)節(jié)。FAT 文件系統(tǒng)會(huì)認(rèn)為你需要“先擦后寫”或“不支持部分寫”時(shí)通過ioctl里的擦除操作告知塊設(shè)備。如果你的 Flash 驅(qū)動(dòng)沒實(shí)現(xiàn)擦除而文件系統(tǒng)又恰好依賴這個(gè)流程寫出來的數(shù)據(jù)就會(huì)有一部分是舊值和 0xFF 混雜。實(shí)現(xiàn)擦除時(shí)還有一點(diǎn)容易忽略擦除的單位是物理扇區(qū)不一定等于邏輯塊大小。比如 Flash 的扇區(qū)是 4KB文件系統(tǒng)邏輯塊是 512 字節(jié)能不能把擦除粒度也設(shè)置成 512 字節(jié)答案是幾乎不可能。這時(shí)類里需要維護(hù)一個(gè)“臟塊標(biāo)記”或者說映射表標(biāo)記哪些 512 字節(jié)邏輯塊位于同一個(gè) 4KB 物理扇區(qū)里等到擦除時(shí)整片處理。這塊邏輯要比 RAM 盤復(fù)雜不少但搞懂它基本上就吃透了塊設(shè)備。另外writeblocks如果實(shí)現(xiàn)成“每次只寫緩沖區(qū)長(zhǎng)度”那么在文件系統(tǒng)傳入長(zhǎng)度小于扇區(qū)大小的數(shù)據(jù)時(shí)你要保證未覆蓋的字節(jié)不會(huì)變成隨機(jī)值。正式產(chǎn)品上至少要把扇區(qū)讀出來改一部分再整塊寫回這就是經(jīng)典的 Read-Modify-Write 流程。我見過好幾個(gè)項(xiàng)目都是在這上面栽跟頭文件目錄偶爾壞一個(gè)條目就是局部寫未處理干凈導(dǎo)致的。5. 后續(xù)還能怎么玩5.1 做一個(gè)小而美的“日志記錄器”一旦塊設(shè)備類寫好了你能做的就不只是掛個(gè) RAM 盤。我用類似思路做過一個(gè)溫濕度數(shù)據(jù)記錄器外部掛一片 SPI FRAM塊設(shè)備類包裝成 512 字節(jié)邏輯塊格式化成 FAT然后每隔一分鐘打開一個(gè) CSV 文件追加一行數(shù)據(jù)。FRAM 的優(yōu)勢(shì)是寫入壽命長(zhǎng)、速度快不需要擦除所以ioctl里的擦除操作可以直接返回 0 表示“擦除不需要做”這樣文件系統(tǒng)少了很多底層操作跑起來特別順。每次寫日志時(shí)先open寫一行再close雖然看著有點(diǎn)頻繁但在 FRAM 上完全扛得住。如果你有興趣甚至可以在ioctl里模擬一個(gè)“只讀”開關(guān)把某些塊寫保護(hù)這樣文件系統(tǒng)寫的時(shí)候會(huì)立刻報(bào)錯(cuò)可以拿來做防誤寫測(cè)試。5.2 與 USB MSC 結(jié)合讓電腦直接讀取還有一個(gè)進(jìn)階方向是把自定義塊設(shè)備暴露成 USB Mass Storage 設(shè)備。ST 和樹莓派 Pico 上都有 USB 外設(shè)庫(kù)只要你把塊設(shè)備對(duì)象的readblocks、writeblocks、ioctl接到 MSC 回調(diào)接口上電腦插上 USB 線就能看到一個(gè) FAT 移動(dòng)盤。這樣板子上寫入的文件拿到電腦上直接就能讀不需要串口工具或者腳本導(dǎo)出。這個(gè)方案我實(shí)測(cè)過最大的坑是 USB MSC 回調(diào)是在中斷上下文里執(zhí)行的不能用太復(fù)雜、帶延時(shí)太高的 Flash 訪問邏輯否則電腦端會(huì)報(bào)“設(shè)備未就緒”或者讀寫超時(shí)。一個(gè)有效的優(yōu)化是加一個(gè)小型 RAM 緩存讀寫先走緩存再在sync時(shí)慢慢落盤。不過這已經(jīng)是另一個(gè)項(xiàng)目體量了但起點(diǎn)還是你面前這個(gè)塊設(shè)備類。5.3 最后再分享一個(gè)調(diào)試技巧如果你也和我一樣沒有專門的邏輯分析儀想觀察文件系統(tǒng)對(duì)塊設(shè)備的訪問模式可以在readblocks、writeblocks、ioctl三個(gè)方法入口加一個(gè)print然后執(zhí)行一條簡(jiǎn)單的文件寫入命令觀察調(diào)用順序和參數(shù)。你會(huì)驚嘆于一個(gè)open、write、close背后竟然有那么多底層塊操作。等觀察完再把打印去掉因?yàn)楦袷交蛘叽罅孔x寫時(shí)打印會(huì)極大地拖慢速度甚至導(dǎo)致超時(shí)。我個(gè)人的體會(huì)是寫塊設(shè)備類這件事難點(diǎn)不在于代碼本身而在于你得同時(shí)理解“文件系統(tǒng)想要什么”和“底層存儲(chǔ)能提供什么”。RAM 盤是一個(gè)非常好的試驗(yàn)田它把底層存儲(chǔ)的成本降到最低讓你可以專心把協(xié)議吃透。這套基礎(chǔ)打牢之后不管以后是接 SD 卡、接 SPI Flash還是做 USB 網(wǎng)盤你都能很快套上合適的塊設(shè)備適配層。