14.3.3. 文件系统卫生

出厂摄像头上的闪存和 SD 存储会被无人手动清理的文件逐渐填满。关于这部分存储的两个决策会伴随产品的整个生命周期:哪个存储介质保存哪类数据,以及目录如何组织,才能在应用持续累积记录时让文件操作始终保持正常工作

14.3.3.1. 数据该放在哪里

代码和资源随构建时提交的冻结模块(frozen modules)和 ROMFS 一起出厂。应用状态——任何应用在运行时写入的内容、任何会增长的内容、任何在两次启动之间发生变化的内容——必须存放在别的地方。摄像头为此暴露了两个可写介质:

  • 内部闪存,位于 /flash:一个在任何应用代码运行之前就已挂载的小型可写文件系统。它适合存放能在重启后保留的、固定大小的小型记录:应用在运行时更新的配置、最近一次的标定结果、滚动计数器,或者一个写着“此摄像头已完成配置”的单行标记文件。写入次数有限——现代内部闪存每个扇区可承受数千到数万次写入,而非数百万次,因此写入操作必须保持低频,而不是每帧都写。

  • SD 卡,位于 /sdcard:一个在插入存储卡时挂载的较大可写文件系统。它适合存放体积较大的可变文件:图像和视频采集数据、日志文件、模型微调数据,以及任何可能增长到数兆字节或数吉字节的内容。它的写入容量高于内部闪存,但仍然有限;它可移除、可更换,并且是最有可能在应用写入过程中突然消失的介质。

对于某个内容该写到哪里这个问题,正确答案几乎总是“小型固定记录写入闪存,其他一切写入 SD”。两者并不可互换:把滚动日志文件乱写到 /flash 的应用,会在一个本可由 SD 轻松胜任的部署场景中耗尽闪存的写入寿命。

14.3.3.2. 把两者都当作可能失败来对待

/flash/sdcard 都可能失败。SD 卡可能被弹出,闪存可能因写入过程中断电而损坏,两者都可能耗尽空间,并且对两者中任意一个执行的任意操作都可能因某些原因抛出 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)
    

    这种模式在闪存和 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>/ —— 应用进行的图像或视频采集。相同的树形结构,相同的理由。

这套布局只让应用多花大约二十行代码,却能让它免于那些在部署数月之后才把摄像头拖垮的故障模式。