14.3.3. Гигиена файловой системы

Флеш-память и SD-накопитель на поставляемой камере заполняются файлами, которые ни один оператор не будет вычищать вручную. Два решения относительно этого хранилища сопровождают продукт на протяжении всего срока его службы: какая поверхность хранит какой тип данных и как организованы каталоги, чтобы файловые операции продолжали работать по мере накопления записей приложением.

14.3.3.1. Что и где хранится

Код и ресурсы размещаются в замороженных модулях и ROMFS, которые сборка фиксирует на момент поставки. Состояние приложения – всё, что приложение записывает во время выполнения, всё, что растёт, всё, что меняется между загрузками – должно находиться где-то ещё. Камера предоставляет для этого две записываемые поверхности:

  • Внутренняя флеш-память в /flash: небольшая записываемая файловая система, монтируемая до запуска любого кода приложения. Подходящее место для небольших записей фиксированного размера, переживающих перезагрузки: конфигурация, которую приложение обновляет во время выполнения, последняя известная калибровка, циклический счётчик, однострочный файл-маркер, сообщающий «эта камера была инициализирована». Ограниченное число циклов записи – современная внутренняя флеш-память выдерживает тысячи и десятки тысяч записей на сектор, а не миллионы, поэтому записи должны быть нечастыми, а не на каждый кадр.

  • SD-карта в /sdcard: более крупная записываемая файловая система, монтируемая при наличии карты. Подходящее место для объёмных файлов переменного размера: захваты изображений и видео, файлы журналов, данные для дообучения модели, всё, что может вырасти до мегабайтов или гигабайтов. Более высокая ёмкость записи, чем у внутренней флеш-памяти, но всё же конечная; съёмная, заменяемая и наиболее вероятно исчезающая поверхность, когда приложение находится в середине записи.

Правильный ответ на вопрос куда что-то записывать почти всегда таков: «флеш для небольших фиксированных записей, SD для всего остального». Эти две поверхности не взаимозаменяемы: приложение, которое пишет свой циклический файл журнала в /flash, исчерпает ресурс записи флеш-памяти в развёртывании, которое прекрасно работало бы на SD.

14.3.3.2. Считайте обе поверхности способными отказать

/flash и /sdcard могут обе отказать. SD-карту можно извлечь, флеш-память может быть повреждена при потере питания в середине записи, на любой из них может закончиться место, и любая операция с любой из них может вызвать OSError по причинам, которые приложение не получит возможности диагностировать в полевых условиях.

Два паттерна позволяют приложению пережить это:

  • Заключайте монтирование и операции в блоки try. Каждый open(), os.listdir(), os.rename() для путей с пользовательскими данными потенциально может завершиться неудачей. Перехватывайте OSError, записывайте в журнал и переходите к заданной альтернативе – пишите в /flash, если /sdcard отсутствует, пропускайте операцию, если ни одна из поверхностей недоступна.

  • Атомарная запись для файлов, которые должны пережить потерю питания. Запишите во временный путь, закройте дескриптор, затем выполните 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. Ловушка медленного каталога

VFS в MicroPython не индексирует содержимое каталогов так, как это делает настольная файловая система. 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>/ – захваты изображений или видео, которые делает приложение. Та же форма дерева, та же причина.

Эта структура обходится приложению примерно в двадцать строк кода и спасает его от режимов отказа, которые выводят камеру из строя спустя месяцы после начала развёртывания.