14.3.3. 檔案系統衛生

出貨相機上的 flash(快閃記憶體)與 SD 儲存空間會被沒有任何操作人員會去手動清理的檔案塞滿。關於這些儲存空間的兩項決定會伴隨產品的整個生命週期:哪一個介面存放哪一類資料,以及目錄如何組織,好讓檔案操作在應用程式不斷累積記錄時仍能持續運作

14.3.3.1. 東西該放哪裡

程式碼與資產搭載於建置在出貨時提交的凍結模組與 ROMFS 中。應用程式的狀態——任何應用程式在執行階段寫入的內容、任何會成長的內容、任何在每次開機之間會變化的內容——都必須存放在別處。相機為此提供了兩個可寫入的介面:

  • 內部 flash,位於 /flash:這是一個在任何應用程式碼執行之前就掛載好的小型可寫入檔案系統。它最適合存放能在重新開機後留存下來的小型固定大小記錄:應用程式在執行階段更新的組態、最後一次的已知校正值、一個滾動計數器,或是一個寫著「這台相機已完成佈建」的單行標記檔。寫入週期有限——現代的內部 flash 每個磁區能承受數千到數萬次寫入,而非數百萬次,因此寫入需要不頻繁,而非每影格都寫。

  • SD 卡,位於 /sdcard:這是一個在有插卡時才掛載的較大型可寫入檔案系統。它最適合存放龐大的可變檔案:影像與影片擷取、記錄檔、模型微調資料,以及任何可能成長到數 MB 或數 GB 的內容。它的寫入容量比內部 flash 高,但仍然有限;它是可移除、可更換的,也是最有可能在應用程式正在寫入途中消失的介面。

對於該把某個東西寫到哪裡,正確答案幾乎總是「小型固定記錄寫到 flash,其他一切寫到 SD」。這兩者並不能互換:一個把滾動記錄檔亂寫到 /flash 的應用程式,在一個原本用 SD 就沒問題的部署中,會把 flash 的寫入耐久度耗盡。

14.3.3.2. 將兩者都視為可能失敗

/flash/sdcard 都可能失敗。SD 卡可能被退出,flash 可能因為寫入途中斷電而損毀,兩者都可能耗盡空間,而且對任一者的任何操作都可能基於應用程式在現場沒有機會診斷的原因而引發 OSError

有兩種模式能讓應用程式在這種情況下存活下來:

  • 將掛載與操作包在 try 區塊裡。 每一個針對使用者資料路徑的 open()os.listdir()os.rename() 都有可能失敗。要捕捉 OSError、記錄它,並退回到一個既定的替代方案——如果 /sdcard 不在了就寫到 /flash,如果兩者都不可用就跳過該操作。

  • 對於必須在斷電中存活下來的檔案採用原子寫入。 先寫入一個暫存路徑,關閉控制代碼,然後用 os.rename() 覆蓋到正式名稱上。結果要嘛是重新命名成功、檔案是新版本,要嘛是沒成功、檔案是舊版本。不存在檔案只寫了一半的第三種狀態:

    import os
    
    def write_config_atomic(path, contents):
        tmp = path + '.tmp'
        with open(tmp, 'w') as f:
            f.write(contents)
            f.flush()
        os.rename(tmp, path)
    

    這個模式在 flash 與 SD 上都適用。但對於大到使暫存檔會耗盡檔案系統可用空間的檔案,它並不適用;請將它保留給小型記錄使用。

14.3.3.3. 緩慢目錄的陷阱

MicroPython 的 VFS 並不像桌面檔案系統那樣為目錄內容建立索引。os.listdir()os.stat() 會線性走訪底層的檔案表。一個有一百個檔案的目錄沒問題;但一個有一萬個檔案的目錄會慢到無法使用,每一次 os.listdir() 都要花上好幾秒,而每一次 open() 在過程中都要對照那張表進行檢查。

把記錄或擷取寫入磁碟的應用程式最快碰到這個問題。一個每分鐘開啟一個新檔案的天真 /sdcard/logs/<timestamp>.log 方案,會在一年的部署中用五十萬個檔案塞滿 logs/ 目錄。早在那之前,應用程式就會開始跟不上它的影格率,因為每一次開檔所花的時間都比一個影格間隔還久。

正確的模式是將檔案分散到一棵以日期分層的子目錄樹中,這樣任何單一目錄都不會存放超過幾百個項目:

import os
import time

LOG_ROOT = '/sdcard/logs'

def log_path(now=None):
    if now is None:
        now = time.localtime()
    year, month, day, hour = now[0], now[1], now[2], now[3]
    directory = '{}/{:04d}/{:02d}/{:02d}'.format(
        LOG_ROOT, year, month, day)
    _makedirs(directory)
    return '{}/{:02d}.log'.format(directory, hour)

def _makedirs(path):
    # os.makedirs equivalent -- create each level if missing
    parts = path.split('/')
    for i in range(2, len(parts) + 1):
        sub = '/'.join(parts[:i])
        try:
            os.mkdir(sub)
        except OSError:
            pass

一年每小時一個檔案的記錄,現在分散在 365 個日目錄中,每個目錄最多包含 24 個檔案;針對任何單一目錄的 os.listdir() 都維持低成本,而且隨著部署時間推移,應用程式的影格迴圈也不會因為檔案操作而停頓。

同樣的原則也適用於影像擷取、感測器軌跡,或任何其他應用程式每次事件就寫入一個檔案的情況。如果事件率高,這棵樹就應該更深(年/月/日/時,或年/月/日/時/分),好讓每個葉目錄都維持得很小。如果事件率低,一棵年/月的樹就足夠了。

14.3.3.4. 每台裝置的路徑

在一個由多台相機組成的機隊中,記錄檔需要識別它們來自哪一台實體裝置。machine.unique_id() 會回傳一個在出廠時就烙印在相機上的硬體識別碼;它在重新開機之間、韌體更新之間,以及 SD 卡更換之間都是同一個值。把它嵌入記錄路徑或記錄內容中,這樣一位面對一堆 SD 卡或一份集中式記錄的操作人員就能分辨出哪一個是哪一個:

import binascii
import machine

UNIT_ID = binascii.hexlify(machine.unique_id()).decode()

LOG_ROOT = '/sdcard/logs/' + UNIT_ID

結合以日期分層的子目錄模式後,版面配置會變成 /sdcard/logs/<unit-id>/2026/06/09/14.log——某一台裝置某一小時的記錄,存放在一個淺到足以走訪的目錄中,且其路徑就在檔案系統上標明了是哪一台裝置。

14.3.3.5. 整合起來

一台出貨相機的可寫入儲存空間大致長這樣:

  • /flash——組態、校正、一個佈建標記。寫入次數很少,讀取次數很多。對於任何遺失就會導致下次無法開機的檔案,採用原子重新命名模式。

  • /sdcard/logs/<unit-id>/<year>/<month>/<day>/<hour>.log——營運記錄。持續寫入,依路徑輪替,絕不透過一個有數千個同層項目的目錄來寫入。

  • /sdcard/captures/<unit-id>/<year>/<month>/<day>/——應用程式所做的影像或影片擷取。相同的樹狀結構,相同的理由。

這套版面配置讓應用程式多花大約二十行程式碼,卻能讓它免於那些在部署數個月後才讓相機當機的失敗模式。