Как мы переезжали с FatFS на LittleFS, и причем тут MTP

Всем привет! Меня зовут Андрей, я технический директор в компании “КЕДР Солюшенс”. Как коллеги вы, наверняка, разделяете мнение, что не стоит изобретать велосипед там, где можно обойтись стандартными, проверенными временем решениями. Но сегодня я хочу честно поделиться историей одного нашего проекта, в котором этот постулат дал сбой. Я покажу, как стандартная архитектура хранения данных загнала нас в тупик и какие шишки мы набили при попытке все исправить с наскоку.

С чего все началось

Один из проектов нашей команды – разработка графического калькулятора. Помимо прочего, от нас требовалось реализовать возможность хранить на устройстве пользовательские данные, а также настройки устройства и результаты его работы. Пользователь должен иметь возможность управлять данными, хранимыми на калькуляторе, с помощью компьютера.

Мы выбрали следующую архитектуру:

  • микроконтроллер GD32F470, работающий на частоте 240 МГц; 

  • флеш-память SPI NOR на 8MБ (этого объема достаточно для типового использования устройства); 

  • USB FULL SPEED.

Для решения поставленной задачи команда выбрала файловую систему FatFS – реализацию стандартов FAT12, FAT16 и FAT32 для встраиваемых систем, созданную разработчиком ChaN. Она предоставляет универсальную структуру хранения данных, способную работать на различных платформах. Управление устройством памяти берет на себя драйвер. Это позволяет иметь универсальный API для доступа к данным, что упрощает портирование прошивки на другие устройства и эмуляторы. К тому же, она занимает мало места (7 КБ).

Изначально пользователь мог получить доступ к файловой системе только при загрузке устройства в специальном режиме – Mass Storage. В этом режиме загрузчик не запускает основную прошивку, а передает управление драйверу Mass Storage, реализованному в SDK микроконтроллеров GD32F470. Данный драйвер предоставляет компьютеру низкоуровневый доступ к устройству хранения. Такое решение позволяет обновлять основную прошивку путем сохранения файла прошивки внутри файловой системы. При следующей перезагрузке, загрузчик проверяет файловую систему на наличие новой прошивки и обновляет ее, если это требуется.

Это, казалось бы, простое и эффективное решение впоследствии привело к множеству проблем.

Как разрастался снежный ком

Сокрытие критических данных

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

Чтобы такого не происходило, мы разделили память SPI флешки на две части: одну часть объемом 256 КБ (64 сектора) для RAW-данных (информации для внутренней обработки, формат которой известен только устройству) и оставшуюся для файловой системы. 

У такого подхода множество недостатков:

  • Введение дополнительных инструкций для работы с RAW-данными.

  • Постоянная перезапись всех данных, даже если изменения затронули только одну конкретную область (например, пользователь сохранил новые настройки).

  • Нельзя корректно эмулировать работу с RAW-данными на компьютере. Это требовалось для дополнительного тестирования и отлаживания прошивки.

  • Ограничение в размерах (256 КБ). Это было очень критично, т.к. размер истории операций упирался в это значение.

  • Усечение основной файловой системы на 256 КБ. Если история операций короткая, то RAW-файлы не используют место полностью. Часть памяти просто лежит мертвым грузом.

Доступ к файлам во время работы

Однажды к нам пришел заказчик и попросил добавить возможность управлять скриптовыми файлами пользователя и загружать обновления прошивки непосредственно во время работы устройства. Запрос оказался неожиданным. Пришлось прикручивать еще один велосипед, ведь Mass Storage для работы требует размонтированную файловую систему.

Команда добавила эмулятор последовательного порта с помощью драйвера CDC-ACM из SDK. Поверх реализовала кастомный протокол через Protobuf и добавила множество оверхэда, чтобы связать это с файловой системой. Дополнительно мы реализовали программу на QT для множества платформ, которая умеет работать с устройством. Кроме управления файлами, были добавлены некоторые дополнительные функции – например создание и восстановление бэкапа (в том числе системных файлов), управление настройками.

Это рабочее решение имеет следующие объективные недостатки:

  • Оверхэд над файловой системой (память на микроконтроллере не резиновая).

  • Самописное управление протоколом. Эта область имела множество неявных багов.

  • Создание, отладка и поддержка компьютерной программы на разных платформах: мы намучались с MacOS и Linux, а Windows 7 не имеет предустановленного драйвера CDC.

  • Ожидаемое в будущем несоответствие версий протоколов устройства и программы. Конечные пользователи часто обновляют прошивку и софт как попало.

Медленная скорость работы и неэкономное хранение файлов

Архитектура хранения данных, которая у нас получилась, полностью выполняла свою работу по сохранению данных, но делала это крайне неэффективно. При выключении устройства все данные (настройки, истории операций, таблицы и массивы информации) постоянно перезаписывались из оперативной памяти микроконтроллера на флеш-память. Алгоритм прост и надежен, как швейцарские часы. И отлично справлялся со своей задачей – когда на устройстве практически не было данных. Проблемы появлялись, когда разрасталась история с длинными операциями или когда пользователь создавал множество больших таблиц. В таких случаях выключение занимало около 6–7 секунд!

Дополнительную проблему вносило то, что история хранилась в виде RAW-данных. Это требовало полной перезаписи всей информации, даже если к ней добавлялся всего лишь один новый блок, а таблицы хранились в одном файле, большие и маленькие. Небольшое изменение в маленькой таблице, требовало переписать все значения из больших таблиц!

Простое решение разделить таблицы на отдельные файлы принесло новую проблему: файл стал занимать объем, кратный размеру кластера. При сохранении 10 таблиц размером 100 байт флеш-пространство уменьшалось на 40 КБ, а время записи составляло 4 секунды!

Это и стало последней каплей.

Переезд на LittleFS

На первый взгляд (да и на второй тоже), LittleFS оказалась идеальной файловой системой для нашего проекта. Быстрая скорость работы, устойчивость к внезапному отключению питания, динамическое выравнивание износа флеш-памяти с возможностью обхода битых блоков, а также малые требования к оперативной памяти и размеру стека (динамическая память и рекурсия не используются).

Интегрировав в устройство новую файловую систему, мы смогли избавиться от системных RAW-файлов. Теперь история операций не ограничена 256 КБ и может занимать очень малый размер на физическом носителе. LittleFS не работает с кластерами, поэтому 10 файлов с таблицами будут занимать около 1 физического килобайта.

Эксперименты с настройками позволили нам получить следующие скоростные результаты.

Но вместе с улучшениями пришли и новые проблемы.

Увеличенный размер инструкций

Устройство LittleFS намного сложнее, чем у FatFs. Поэтому исходный код и, соответственно, размер скомпилированных инструкций выросли более чем в два раза. Данный фактор критичен для нас, потому что файловая система работает также и в загрузчике (для обновления прошивки), который ограничен 64 КБ. 

Медленный поиск файлов

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

Например, в коде у нас было множество проверок на существование объекта, а затем проверка на тип объекта – папка это или файл. Это две достаточно дорогие операции, которые мы заменили одной – получение типа (“неизвестно”, т.е. объект отсутствует, “каталог” и “файл”). 

Дополнительно требовалось найти и изменить такие участки кода, где сначала получается список файлов путем обхода директории, а затем на основе имени и пути извлекаются метаданные (размер, тип и пр.), поскольку итератор директории уже имеет в себе необходимые метаданные.

Медленная работа с метаданными

Когда мы избавлялись от RAW-файлов, перед командой встала задача оптимизировать хранение истории операции. По своей сути, история является ограниченной очередью: новые операции добавляются в начало, старые со временем удаляются. Также пользователь может самостоятельно удалять некоторые блоки истории. 

Решением было разбить отдельные блоки на файлы и переименовывать их в соответствии с их положением в очереди. Это позволило бы при включении загружать историю основываясь на порядке файлов. Операция переименования/перемещения намного дешевле операции копирования и удаления, т.к. идет работа с метаданными, а не с содержимым файла. Но не в LittleFS! 

Каждое переименование переносит блоки метаданных в новые позиции для выравнивания износа, дополнительно стирая другие, если это требуется. В итоге последовательное переименование приблизительно 30 файлов занимало порядка 6 секунд (через несколько переименований, LittleFS тратит порядка 2 секунд на перенос внутренних данных).

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

Атрибуты файлов

Следующая проблема, с которой мы столкнулись – это отсутствие времени создания и изменения файла. Неприятно, но решаемо. LittleFS позволяет дополнительно сохранить пользовательские атрибуты, чем мы и воспользовались для сохранения времени изменения файла. Но доступ к атрибутам возможен только через полный путь к файлу, итератор по директории не извлекает атрибуты. Это накладывает дополнительные расходы на время обращения к атрибуту и на потребляемую оперативную память: кроме дескриптора открытого файла, требуется также хранить полный путь до него.

Финальный босс: невозможность подключения к ПК

За все нужно платить. Если в основной прошивке у нас осталась возможность управлять файлами с помощью разработанной программы и эмуляции последовательного интерфейса, то режим Mass Storage в загрузчике стал бесполезен. Компьютеры просто не знают и не понимают LittleFS. Мы вернулись к тому, от чего пытались уйти. 

Сейчас при запуске режима Mass Storage часть оперативной памяти выделяется для виртуальной файловой системы FatFS, размер которой достаточен для копирования файла прошивки. Записанные виртуальные файлы переносятся на флеш-память при выходе из режима Mass Storage. На текущий момент область загрузчика занята на 99,74%, свободными осталось 160 байт.

Но выход был найден! На помощь команде пришел спасительный MTP.

Изначально у команды было негативное отношение к протоколу MTP. Многие еще помнят те времена, когда Android-смартфоны подключались к компьютеру в режиме Mass Storage. По сравнению с MTP он был всем лучше: высокая скорость передачи данных, возможность работать с файлами в привычных программах, не копируя их, и работать с хранилищем в двухпанельных файловых менеджерах без каких либо проблем.

Но изучив протокол детальнее, мы поняли причину его возникновения. У него есть целый ряд неоспоримых плюсов:

  • У хоста нет низкоуровневого доступа к файловой системе устройства, как у Mass Storage. Это уменьшает вероятность повреждения файловой системы.

  • MTP использует API для доступа к файловой системе. Это позволяет устройству иметь любую реализацию файловой системы (LittleFS, FatFS, ext4, массив объектов в ОЗУ и пр.).

  • Возможность тонкого контроля операций над файлами. Например, можно создать два виртуальных раздела (System и Exchange), один из которых ReadOnly, а второй ReadWrite.

  • Возможность работы с активной файловой системой (отключение диска от основной прошивки не требуется). Устройство может уведомлять о внесенных изменениях в файловую систему.

  • Возможность реализовать дополнительные команды на усмотрение разработчиков (так называемые vendor operations).

  • Также устройство или хост способны поддерживать многосессионное подключение. То есть одним прибором могут управлять несколько хостов одновременно и наоборот – один хост может управлять несколькими устройствами.

Трудности с которыми мы столкнулись во время реализации

В стандартном SDK от GD32 нет поддержки MTP-устройств. Поэтому мы решили портировать готовый код из библиотеки tinyUSB – точнее, только ту его часть, которая отвечает за работу протокола MTP. Данная реализация содержит минимальные наборы инструкций, необходимые для обмена файлами (без многосессионности, уведомлений об изменениях в файловой системе, атрибутов файлов и пр.).

Мы заменили вызовы USB HAL от TinyUSB на HAL GD32, а также перенесли обработку протокола из прерывания (как это сделано по умолчанию в GD32) в основной цикл.

Для связывания протокола MTP с LittleFS потребовалось реализовать древовидную структуру виртуальной файловой системы, т.к. постоянное обращение к LittleFS занимает довольно много времени. Каждый узел виртуальной файловой системы должен получить свой уникальный идентификатор со временем жизни, равным времени жизни открытой сессии. Поскольку MTP работает с объектами и их идентификаторами, изначально в качестве идентификаторов были использованы 32-битные хэши от полного пути к файлу (функция xxhash). Но при более детальном изучении спецификации выяснилось, что повторное использование идентификатора при удалении файла запрещено. В итоге в качестве идентификатора мы решили использовать простой счетчик номера файла.

Для отладки протокола MTP мы использовали WireShark с мониторингом USB через usbmon. Для WireShark существует плагин tengelmeier/mtp-tools. Он способен парсить простейшие пакеты MTP, если они не разбиты на несколько транзакций. Если плагин не был способен распарсить пакеты, то из USB-трафика извлекался MTP payload. Далее эта информация обрабатывалась в редакторе 010 Editor. По мере портирования мы разрабатывали скрипт для этого редактора, который парсит бинарные файлы в соответствии с протоколом MTP. Это очень помогало в отладке. Например, у нас ломалась передача при вызове определенной операции. Проанализировав дамп, команда выяснила, что в структуру сообщения забыли добавить атрибут packed. Компилятор при ее создании добавил выравнивание данных, чем сломал протокол.

Итоги

Сейчас мы работаем над оптимизацией протокола MTP по размеру. Он имеет местами некоторую наивную реализацию, и при компиляции размер превышает размер области загрузчика. 

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