Встраиваемые платы годами работают без клавиатуры и монитора, и единственный повод для выезда с USB-кабелем обычно один: неудачное обновление. Пропало питание во время записи корневой системы, кончилось место на разделе, уехал на сотню устройств образ с ошибкой, и сервисная служба получает парк плат, которые не отвечают по сети. Recovery image на eMMC решает проблему системно: минимальный запасной образ живёт в отдельном загрузочном разделе чипа, рабочая система размечается двумя слотами, и обновление переключает их местами только после полной проверки. Разберём, как устроена такая схема на реальной плате и что делает её действительно атомарной: от скрытых разделов чипа до счётчиков загрузок и испытаний реле питания.
Анатомия eMMC и скрытые загрузочные разделы
Чип eMMC это не голый NAND, а контроллер с прошивкой, и внутри него есть области, которых не видно в таблице разделов пользовательской области. По спецификации JEDEC доступны два загрузочных раздела, boot0 и boot1, ядро показывает их устройствами /dev/mmcblk0boot0 и /dev/mmcblk0boot1. Размер задаётся регистром BOOT_SIZE_MULT с шагом 128 КиБ, типично выходит 4 МиБ, максимум 16 МиБ. Именно тут SoC-загрузчик ищет SPL и U-Boot, и именно сюда логично положить запасной образ.
Защита этих разделов сделана заботливо: запись запрещена по умолчанию, чтобы случайная команда не выводила плату из строя, а документация ядра прямо называет цель сбросом риска окирпичивания. Запись открывают на время операции:
echo 0 > /sys/block/mmcblk0boot0/force_ro
dd if=backup/recovery.img of=/dev/mmcblk0boot0 conv=fsync
echo 1 > /sys/block/mmcblk0boot0/force_ro
Есть и принудительная блокировка до следующего включения питания: запись единицы в файл ro_lock_until_next_power_on держит раздел недоступным до перезагрузки, если карта поддерживает функцию. Полная постоянная блокировка возможна только регистром EXT_CSD и сознательно не выведена в sysfs. Дисциплина тут простая: записал, закрыл обратно, проверил чтением. Внутри чипа есть и третья специальная область, RPMB, которая пишет только по аутентифицированной сессии и годится для счётчиков включений, но для загрузки она непригодна. Загрузочный ROM SoC читает данные из активного boot-раздела, а некоторые платформы при неудачном старте пробуют второй раздел, если установлен бит подтверждения. Полагаться на такое поведение вслепую нельзя: поведение ROM зависит от семейства процессора и проверяется по документации конкретной платы, иначе схема защиты остаётся половинчатой.
Схема разделов с двумя корневыми слотами
Рабочая разметка отделяет программу от данных. Стандартный набор: rootfs.0, rootfs.1 и общий раздел data, плюс каталог /boot с образом ядра в каждом слоте. Таблица GPT, размер слотов одинаковый, раздел данных живёт своей жизнью и переживает обновления без миграций. В командной строке ядра корень указывается через PARTUUID, а не через mmcblk0p2, потому что нумерация устройств при смене слотов и конфигурации чипа может плавать, а PARTUUID привязан к содержимому GPT и не меняется.
Индустрия давно отработала такой расклад в схеме A/B: одна копия работает, вторая получает обновление, и в любой момент существует хотя бы одна заведомо живая система. Корневые слоты монтируют только для чтения, squashfs в принципе не позволяет править файлы, а ext4 со строкой ro в fstab достигает того же. Пакетный менеджер на плате оказывается не нужен: новый выпуск собирается целиком, и на живой системе ничего не патчится. Типичная карта разделов после разметки выглядит так:
NAME MAJ:MIN RM SIZE RO TYPE MOUNTPOINTS
mmcblk0 179:0 0 14.6G 0 disk
|-mmcblk0p1 179:8 0 512M 0 part
|-mmcblk0p2 179:16 0 3.5G 0 part /
|-mmcblk0p3 179:24 0 3.5G 0 part
|-mmcblk0p4 179:32 0 6.8G 0 part /data
Первый раздел отдан ядру и дереву устройств текущего слота, второй и третий это взаимозаменяемые корни, четвёртый хранит конфигурацию и журналы. Сборщик образов генерирует такую разметку генератором genimage или recipe-файлом wic в Yocto, и тот же файл описания участвует в сборке recovery, чтобы все образы жили в одинаковой геометрии.
Переключение источника загрузки регистром PARTITION_CONFIG
Регистр EXT_CSD под номером 179 называется PARTITION_CONFIG и решает, откуда стартует чип после включения: раздел boot0, раздел boot1 или пользовательская область, плюс бит подтверждения ACK, который заставляет загрузчик сообщить ROM о старте. Значение 7 запрещает автозагрузку с устройства совсем. Утилита mmc из пакета mmc-utils делает переключение одной строкой:
mmc extcsd read /dev/mmcblk0 | grep PARTITION_CONFIG
mmc bootpart enable 1 0 /dev/mmcblk0
Первая команда показывает текущее состояние регистра, вторая ставит источником загрузки boot0 без подтверждения. Бит ACK означает, что ROM после чтения образа перепроверяет его контрольные данные и только затем передаёт управление, а без него процессор стартует всё, что прочитал. Изменение регистра вступает в силу после цикла питания, поэтому при отладке полезно иметь доступ к реле или управляемому разъёму. Отдельный нюанс связан с доступностью записи: пока система работает из пользовательской области, переключение регистра безопасно, но менять источник с работающей загрузки чипа следует осознанно, с сохранением текущего значения, чтобы не потерять плату при следующем включении. U-Boot умеет менять ту же настройку из командной строки mmc partconf, что и используется для программной переадресации на запасной образ без физического доступа.
Счётчик загрузок и автоматический откат
Сама по себе смена слотов ничего не стоит, если плата загрузит битый образ и зависнет навсегда. Поэтому в загрузчик включается счётчик попыток. В U-Boot за это отвечает конфигурация CONFIG_BOOTCOUNT_LIMIT: переменная bootlimit задаёт порог, bootcount растёт при каждой загрузке, а при превышении порога выполняется altbootcmd, которая обычно переключает источник на запасной слот или recovery. Счётчик хранится в защищённой переменной окружения с двумя копиями или в регистрах PMIC.
Альтернативный загрузчик barebox решает задачу фреймворком bootchooser: переменные BOOT_ORDER и счётчики попыток живут в отдельном state-бэкенде с избыточными копиями, чтобы сбой питания в момент записи не портил стратегию загрузки. На стороне системы здоровье подтверждается работой: сторожевой таймер с таймаутом, например WatchdogSec=30 в юните systemd, приложение регулярно сообщается со службой, и если основная процедура старта не завершилась, плата уходит в перезагрузку и счётчик докручивается до отката. Схема собирается в надёжную петлю: новый слот обязан доказать работоспособность за отведённое число попыток, иначе загрузчик возвращает прежний. Фрагмент окружения U-Boot для такой логики:
bootlimit=3
bootcount=0
upgrade_available=1
altbootcmd=run boot_recover
Здесь три попытки на новый слот, после чего выполняется сценарий boot_recover, который либо переключает корень, либо переходит в запасной образ. Флаг upgrade_available отделяет обычную перезагрузку от попытки после обновления, и его выставляет установщик перед переключением слота.
Атомарное обновление через неактивный слот
Полный цикл выпуска на плате выглядит так:
- Сборка: корневой образ новой версии собирается целиком вместе с ядром и модулями, а не патчится пакетами на живой системе;
- Доставка: пакет формата .raucb подписан сертификатом производителя, и подпись проверяется до начала записи;
- Запись: образ уезжает в неактивный слот, пока рабочая система продолжает отвечать датчикам;
- Контроль: целостность подтверждается хэш-деревом, squashfs-образ монтируется только после проверки verity;
- Переключение: загрузчику сообщается новый порядок слотов, счётчик попыток обнуляется;
- Подтверждение: после старта и проверки служб слот помечается хорошим, иначе откатит прежний.
Проект RAUC упаковывает эту логику в готовый инструмент: декларативное описание слотов живёт в system.conf, установщик сам считает, какой слот неактивен, зовёт загрузчик и ведёт статус. Конфигурация для платы с двумя корнями выглядит так:
[system]
compatible=example-cm4
bootloader=u-boot
[slot.rootfs.0]
device=/dev/mmcblk0p2
type=ext4
bootname=A
[slot.rootfs.1]
device=/dev/mmcblk0p3
type=ext4
bootname=B
Для сборки существует плагин meta-rauc в Yocto, поддержка Buildroot появилась в версии 2017.08, а PTXdist интегрирован с 2017.04.0. Схожие задачи решают SWUpdate с описанием потоков записи в sw-description и Mender со своей схемой двойного корня, выбор между ними дело вкуса и экосистемы, механика везде одна. Практическую ценность добавляют хуки: проверка совместимости отсекает пакет для чужой модели платы, скрипт перед записью бэкапит калибровки из data, обработчик после установки раскладывает их обратно и обновляет флаг ожидания подтверждения. Без этих мелочей первая же плата с нестандартной калибровкой датчиков превращает обновление в ручную работу.
Запасной образ в загрузочном разделе как страховка
Слоты A и B страхуют обновления, но редкий случай, когда испорчены обе копии, всё же случается: сбой при двух последовательных записях или рукотворная ошибка в конфигурации загрузчика. Тогда последняя линия защиты это минимальный recovery image в boot-разделе: busybox, модули ядра для сети и USB, клиент SSH, копия rauc, суммарно восемь-шестнадцать МиБ. ROM такой платы переключают на recovery регистром PARTITION_CONFIG, система стартует из области, которую почти никто не пишет, инженер заходит по сети и выкачивает свежий образ в слот.
Размер здесь ключевой: загрузочные разделы ограничены 16 МиБ, поэтому в recovery не место полноценному дистрибутиву. Сеть поднимается статическим адресом из переменных загрузчика, чтобы образ находился даже при молчащем DHCP-сервере, а вход по SSH ограничивается ключом производителя. Той же ветке логично доверить и возврат к заводскому состоянию: запись заводского образа из скрытой области или полная переразметка с нуля, если таблица GPT испорчена. Образ собирается из того же Buildroot с урезанной конфигурацией и хранится в артефактах выпуска рядом с основными образами, чтобы версия recovery совпадала с версией релиза. Отдельно проверяется, что recovery стартует при пустом разделе data, потому что именно с чистой памятью его чаще всего и будят.
Износ памяти и испытание отключением питания
Флеш-память eMMC рассчитана на конечное число циклов записи: MLC-ячейки потребительских чипов держат несколько тысяч перезаписей, а режим pSLC повышает ресурс примерно на порядок за счёт хранения одного бита в ячейке. Арифметика обновлений успокаивает: запись четырёхгигабайтного слота раз в месяц даёт двенадцать полных циклов в год, и до нескольких тысяч циклов остаются десятилетия. Износ опасен не от обновлений, а от постоянной записи мелочи, поэтому журналы уходят на отдельный раздел или в ОЗУ, а счётчикам загрузок выделяют область с pSLC-режимом, если чип её поддерживает.
Проверка атомарности делается грубо и честно: управляемое реле режет питание в случайные моменты цикла обновления, серия из десятков опытов обязана закончиться одинаково, плата всегда грузится либо со старого слота, либо с нового, но не с полуразрушенной записи. Перед каждой перезагрузкой прикладной слой обязан вызвать sync, и это правило проверяется код-ревью, потому что невыполненная команда сводит всю схему на нет. Раз в квартал полезно просматривать rauc status по всему парку: какие слоты активны, кто уже откатывался и по какой причине. Состояние самой памяти тоже читается: регистры PRE_EOL_INFO и DEVICE_LIFE_TIME_EST из EXT_CSD дают оценку износа в процентах ресурса, и утилита mmc extcsd read выводит их наравне с остальными полями. Дашборд с этими значениями по парку показывает платы, которым осталось меньше четверти ресурса, задолго до первых сбоев записи.
Итоговый расклад выглядит зрелым: два слота дают откат обновлений, счётчик попыток закрывает зависшие старты, recovery image в boot-разделе вытаскивает даже двойной сбой, а подписанные пакеты не позволяют записать на парк чужой образ. Всё это существует в открытом виде и собирается из компонентов, которые проверены годами на промышленных платах, так что услуга выезда с кабелем на складе просто перестаёт быть нужной.