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>/—— 应用进行的图像或视频采集。相同的树形结构,相同的理由。
这套布局只让应用多花大约二十行代码,却能让它免于那些在部署数月之后才把摄像头拖垮的故障模式。