48б. MCUboot на STM32: два слота, обмен образов и откат
В разделе 48 загрузчик свой: две области, заголовок с CRC-32, флаги «на
пробу» и «подтверждено». В 48а к нему добавлена подпись ECDSA P-256 на
mbedTLS. Раздел 48.11 упоминал готовые библиотеки безопасного обновления —
здесь самая распространённая из них, MCUboot. Он лежит в дереве Zephyr
v4.4.2 (раздел 34а) как модуль версии 2.4.0, вместе с утилитой подписи
imgtool.py. Раздел: раскладка флеш-памяти F407 под MCUboot, сборка
загрузчика и приложения одной командой, настоящий подписанный образ по
байтам, хвост слота с флагами, обмен образов через scratch с обрывом
питания и откат — и чем всё это отличается от своего загрузчика.
Опоры: документация MCUboot docs/design.md (формат образа, слоты, хвост,
типы обмена), исходники boot/bootutil/src/bootutil_public.c (таблица
выбора действия) и boot/zephyr/CMakeLists.txt (резерв в конце слота),
imgtool.py 2.4.0, заголовок Zephyr dfu/mcuboot.h, даташит DS8626
(стирание сектора и ресурс флеш-памяти F407). MCUboot и hello_world
собраны для платы stm32f4_disco (STM32F407); образ
разобран и проверен своим кодом; выбор действия и обмен с обрывом питания
прогнаны моделью на ПК. На плате не запускалось.
Как это называется в документации
Оригинал Перевод primary slot, secondary slot основной и второй слот scratch area промежуточная область для обмена image header заголовок образа TLV (type-length-value) запись «тип — длина — значение» после образа image trailer хвост слота: журнал обмена и флаги swap type: test, permanent, revert обмен на пробу, насовсем, возврат image ok, copy done «образ подтверждён», «копирование закончено» swap status журнал обмена sysbuild сборка нескольких образов одной командой в Zephyr swap using scratch / move / offset обмен через scratch / сдвигом / со смещением
48б.1 Раскладка флеш-памяти: слоты и scratch
MCUboot всегда запускает образ из основного слота (слот 0). Новый образ приходит во второй слот (слот 1); чтобы его запустить, загрузчик меняет содержимое слотов местами. Прежний образ при этом не пропадает — он оказывается во втором слоте, и к нему можно вернуться. Приложение собирается один раз, под адрес основного слота, — трудность 48.2 с образами под разные адреса здесь не возникает.
У F407 секторы разные: четыре по 16 КБ, один 64 КБ, дальше по 128 КБ.
Каждая область MCUboot должна начинаться и кончаться на границе сектора, а
scratch — вмещать самый большой сектор, который участвует в обмене
(design.md). Раскладку задаёт devicetree. У платы stm32f4_disco разделов
нет, у nucleo_f429zi они есть — первый мегабайт F429 устроен так же, и
раскладка взята оттуда (f407_parts.overlay):
boot_partition: partition@0 { /* секторы 0-1: загрузчик */
label = "mcuboot";
reg = <0x00000000 DT_SIZE_K(32)>;
read-only;
};
slot0_partition: partition@20000 { /* секторы 5-6: основной слот */
label = "image-0";
reg = <0x00020000 DT_SIZE_K(256)>;
};
slot1_partition: partition@60000 { /* секторы 7-8: второй слот */
label = "image-1";
reg = <0x00060000 DT_SIZE_K(256)>;
};
scratch_partition: partition@a0000 { /* сектор 9: промежуточный */
label = "image-scratch";
reg = <0x000a0000 DT_SIZE_K(128)>;
};
Загрузчику досталось 32 КБ, и он занял почти всё: 30 308 байт, 92,49 % (ОЗУ — 18 176 байт). Приложению hello_world из 256 КБ слота нужно 17 256 байт.
48б.2 Сборка: загрузчик и приложение одной командой
Zephyr собирает оба образа сразу — это sysbuild. Одна команда
(build48c.sh) даёт MCUboot, hello_world и подписанный образ
zephyr.signed.bin:
west build -p always --sysbuild -b stm32f4_disco -d "$OUT/$v" zephyr/samples/hello_world -- \
-DSB_CONFIG_BOOTLOADER_MCUBOOT=y -D"SB_CONFIG_$mode=y" -DSB_CONFIG_BOOT_SIGNATURE_TYPE_ECDSA_P256=y \
-DSB_CONFIG_BOOT_SIGNATURE_KEY_FILE="\"$HERE/test-ec256.pem\"" \
-Dmcuboot_EXTRA_DTC_OVERLAY_FILE="$HERE/f407_parts.overlay" \
-Dhello_world_EXTRA_DTC_OVERLAY_FILE="$HERE/f407_parts.overlay;$HERE/f407_app.overlay" \
Раскладку получают оба образа, а f407_app.overlay — только приложение:
он кладёт его код в slot0_partition. Сам MCUboot свой адрес берёт из
собственного app.overlay — boot_partition. Режим обмена здесь —
через scratch (SB_CONFIG_MCUBOOT_MODE_SWAP_SCRATCH); без этой строки
Zephyr 4.4.2 выбирает swap-using-offset. Подпись — ECDSA P-256, у
загрузчика она собрана на TinyCrypt (CONFIG_BOOT_ECDSA_TINYCRYPT=y), и
основной слот он проверяет на каждом старте (CONFIG_BOOT_VALIDATE_SLOT0=y).
Ключ — свой, тестовый: imgtool.py keygen -t ecdsa-p256. Без
SB_CONFIG_BOOT_SIGNATURE_KEY_FILE sysbuild подписал бы образ ключом
root-rsa-2048.pem из репозитория MCUboot — его закрытая часть лежит в
открытом доступе, и подпись им не значит ничего.
Подписывает sysbuild сам, вызовом из сборки:
imgtool.py sign --version 0.0.0+0 --header-size 0x200 --slot-size 262144 --align 1.
Сборка прошла, но с пятью предупреждениями MCUboot: размер сектора слотов
она узнать не смогла. У флеш-памяти F4 в stm32f4.dtsi задан только
write-block-size = <1>, а erase-block-size нет — секторы разные. Число
секторов на слот сборка приняла по умолчанию — 128, а размер сектора —
1 байт, «this is probably wrong». Для scratch это безвредно, для
swap-using-move — нет (48б.5).
48б.3 Заголовок и TLV: настоящий образ по байтам
hello.bin — 17 256 байт, и первые 512 из них нули: приложение собрано со
смещением CONFIG_ROM_START_OFFSET=0x200, место под заголовок оставлено
заранее. imgtool пишет туда заголовок, а после тела — записи TLV.
Разбор sign48c.py (поля — по структуре image_header из design.md):
| Поле | Значение |
|---|---|
ih_magic |
0x96f3b83d |
ih_load_addr |
0x0 — запуск на месте, из слота |
ih_hdr_size |
0x200 = 512 байт |
ih_protect_tlv_size |
0 — защищённых TLV нет |
ih_img_size |
0x4168 = 16 744 байта — тело без заголовка |
ih_flags |
0x0 |
ih_ver |
0.0.0+0 |
Заголовок в 512 байт — ровно как в 48.3, и по той же причине: таблица
векторов приложения начинается сразу за ним, с 0x08020200. За телом, со
смещения 0x4368, — область TLV: магия 0x6907 и 151 байт на три записи:
| Тип | Что | Длина |
|---|---|---|
| 0x10 SHA256 | хеш заголовка и тела | 32 байта |
| 0x01 KEYHASH | SHA-256 открытого ключа (91 байт DER) | 32 байта |
| 0x22 ECDSA_SIG | подпись ECDSA P-256 в DER | 71 байт |
Всё сверено своим кодом, а не только imgtool: SHA-256 первых 0x4368 байт
совпал с записью 0x10, хеш открытого ключа — с 0x01, подпись проверена
библиотекой cryptography. Подпись в DER — не 64 байта, как в 48а: два
числа по 32 байта с разметкой ASN.1, и длина от подписи к подписи плавает:
у этого образа — 71 байт, у того же тела из сборки move — 72.
Хеш открытого ключа в образе нужен загрузчику, где ключей несколько: он
сразу находит, каким проверять. Теперь попытки обмана, как в 48а.2,
проверкой imgtool verify:
| Что сделали с образом | Ответ imgtool verify |
|---|---|
| ничего | Image was correctly validated |
| изменили 1 бит тела | Image has an invalid hash |
| изменили версию в заголовке | Image has an invalid hash |
| проверили чужим ключом | No signature found for the given key |
Заголовок входит в хеш, поэтому версию не подменить — то же правило, что в 48а.1.
48б.4 Хвост слота и решение при старте
Всё, что MCUboot помнит между стартами, лежит в хвосте — в конце
каждого слота. Для этой сборки (запись по байту, BOOT_MAX_ALIGN = 8,
128 секторов) хвост занимает 432 байта = 0x1B0: журнал обмена 128 × 3 байта,
по 8 байт на размер обмена, swap_info, copy_done и image_ok, магия
16 байт. Та же цифра 0x1b0 стоит в выводе сборки как размер хвоста
обновления.
Флаги только дописываются: стёртый байт 0xFF значит «нет», 0x01 — «да».
imgtool sign --pad дополняет образ до размера слота и ставит магию в
последние 16 байт — ту, что design.md приводит для BOOT_MAX_ALIGN = 8;
с --confirm — ещё и image_ok:
imgtool sign --pad: код 0, файл 262144 байт, версия 1.2.3+4; последние 16 байт: 77 c2 95 f3 60 d2 ef 7f 35 52 50 0f 2c b6 79 80
image_ok (−24): 0xff, copy_done (−32): 0xff, swap_info (−40): 0xff
imgtool sign --pad --confirm: код 0, файл 262144 байт, версия 1.2.3+4; последние 16 байт: 77 c2 95 f3 60 d2 ef 7f 35 52 50 0f 2c b6 79 80
image_ok (−24): 0x01, copy_done (−32): 0xff, swap_info (−40): 0xff
При старте MCUboot сравнивает хвосты двух слотов с таблицей
boot_swap_tables в bootutil_public.c — строки проверяются по порядку,
первая подошедшая решает:
| Хвост второго слота | Хвост основного | Действие |
|---|---|---|
магия есть, image_ok = 0xFF |
любой | TEST — обменять на пробу |
магия есть, image_ok = 0x01 |
любой | PERM — обменять насовсем |
| любой | магия есть, image_ok = 0xFF, copy_done = 0x01 |
REVERT — вернуть прежний |
| иначе | NONE — запустить основной слот |
Приложение управляет этим через dfu/mcuboot.h Zephyr:
boot_request_upgrade(BOOT_UPGRADE_TEST) или BOOT_UPGRADE_PERMANENT —
после приёма образа во второй слот; boot_write_img_confirmed() — после
самопроверки нового образа. Модель swap48c.py берёт таблицу прямо из
исходника и прогоняет четыре случая:
| Случай | Старт 1 | Старт 2 | Старт 3 |
|---|---|---|---|
| проба без подтверждения | TEST, работает v2 | REVERT, работает v1 | NONE, работает v1 |
| проба, v2 подтвердил себя | TEST, работает v2 | NONE, работает v2 | |
сразу насовсем (--confirm) |
PERM, работает v2 | NONE, работает v2 | |
| испорченная подпись v2 | FAIL, работает v1 | NONE, работает v1 |
Откат — это второй обмен, а не переключение адреса: после REVERT загрузчик
сам ставит image_ok старому образу (так делает loader.c после REVERT и
PERM), и третий старт уже ничего не меняет. Образ с плохой подписью не
запускается вовсе: действие FAIL, основной образ помечен подтверждённым.
48б.5 Обмен: scratch, move и offset
Через scratch каждый сектор проходит три шага (design.md): сектор
основного слота копируется в scratch, сектор второго — в основной, scratch —
во второй. После каждого шага — отметка в журнале хвоста; отсюда три байта
на сектор и 128 × 3 в хвосте. Питание может пропасть где угодно: следующий
старт видит незаконченный обмен (swap_info есть, copy_done нет) и
продолжает с отмеченного шага. Модель обрывает питание после каждого шага
обмена — для образа в один сектор (3 шага) и в два (6 шагов) — и все 3 из 3
и 6 из 6 обменов доходят до конца с тем же результатом, что без обрыва.
Цена — стирания. На сектор образа за обмен — по одному стиранию в scratch,
основном и втором слоте, плюс одно во втором слоте при приёме. У F407
стирание сектора 128 КБ по DS8626 — 1 с типично и 2 с максимум при записи
по 32 бита, 2 и 4 с при записи по байту. Обмен одного сектора — 3 стирания:
от 3 до 6 с при x32 и от 6 до 12 с при x8, без учёта записи и хвостов.
Проба с откатом — два обмена. У самого MCUboot в stm32f4.dtsi записано
наибольшее время стирания сектора 128 КБ — max-erase-time = <4000> мс.
Ресурс сектора — 10 тысяч циклов, и scratch
стирается на каждом обмене: design.md советует считать число обновлений
как ресурс, делённый на отношение размера образа к размеру scratch.
Сам design.md предупреждает: обмен через scratch могут со временем убрать из MCUboot.
Swap-using-move обходится без scratch: сдвигает образ основного слота на
сектор вверх и меняет сектора по очереди. Swap-using-offset — наоборот,
держит лишний сектор во втором слоте; в Zephyr 4.4.2 это режим по
умолчанию, а design.md называет offset предпочтительнее move. Обоим нужен
запасной сектор, а хвост округляется до целого сектора. На секторах по
128 КБ это дорого — предел образа по формулам design.md для этой раскладки
(swap48c.py):
| Режим | Предел образа в слоте 256 КБ |
|---|---|
| scratch | 261 712 байт — слот минус хвост |
| offset | 131 072 байта — два сектора минус хвост, округлённый до сектора |
| move | 0 — (2 − 1) сектора минус хвост, округлённый до сектора |
То есть move на такой раскладке не вмещает ничего, а сборка move при этом
прошла. Она оставила в конце слота 583 байта вместо 582 у scratch: к хвосту
и оценке TLV добавлен «запасной сектор» размером 1 байт — тот самый, что
сборка не смогла узнать (48б.2). С настоящим сектором резерв был бы
262 294 байта — больше слота. На камнях с мелкими страницами move и offset
стоят страницу: у nucleo_f103rb второй слот больше основного ровно на
1 КБ.
48б.6 MCUboot против своего загрузчика
| Свой загрузчик (48, 48а) | MCUboot (эта сборка) | |
|---|---|---|
| проверка образа | CRC-32 (48), ECDSA P-256 + SHA-256 на mbedTLS (48а) | SHA-256 + ECDSA P-256 на TinyCrypt, запись KEYHASH |
| заголовок | 32 байта, дополнен до 512 | 32 байта, дополнен до 512 (--header-size 0x200) |
| флаги «проба» и «подтверждён» | в заголовке, ячейки дописываются | в хвосте слота, image_ok и copy_done |
| где работает образ | в своей области, образ под каждый адрес (48.2) | всегда в основном слоте, одна сборка |
| откат | запуск прежней области | обратный обмен (REVERT) |
| обрыв питания при обновлении | копирование «должно уметь продолжиться» — писать самим | журнал обмена в хвосте, продолжение с шага |
| защита от старой версии | сравнение версии после подписи (48а.2) | программная — только в режиме перезаписи (design.md); иначе аппаратный счётчик безопасности (MCUBOOT_HW_ROLLBACK_PROT) |
| размер | программа с проверкой подписи — 23 028 байт (48а.4) | загрузчик целиком — 30 308 байт |
| канал приёма образа | свой | приложение (Zephyr: dfu/mcuboot.h) или последовательный порт MCUboot |
Выводы. MCUboot даёт готовыми самые трудные части — журнал обмена, откат и проверку, которую уже проверяли многие. Цена — объём (почти весь сектор 0–1 у F407), время обмена на крупных секторах и чужие правила: формат заголовка, хвост в конце слота, раскладка в devicetree. Свой загрузчик оправдан, когда нужны свой формат, своя схема областей (например, два банка без обмена) или крошечный объём.
48б.7 Грабли
Образ подписан ключом из репозитория MCUboot. Sysbuild без
SB_CONFIG_BOOT_SIGNATURE_KEY_FILE берёт root-rsa-2048.pem — закрытый
ключ, известный всем. Свой ключ — сразу, и хранить его как в 48а.5.
Новый образ работает один старт, потом «откатывается». Приложение не
вызвало boot_write_img_confirmed(). Это не сбой, а проба: MCUboot делает
REVERT. Подтверждать — после самопроверки, а не первой строкой main.
Сборка прошла, а обновление не встаёт. Режим move на секторах по 128 КБ: сборка не знает размера сектора, резерв посчитан на 1 байт (48б.5). На F4 — scratch, либо разделы, где запасной сектор предусмотрен.
Загрузчик не влез после включения опций. 30 308 байт из 32 КБ — почти предел. Журнал, последовательный порт MCUboot или другая библиотека подписи потребуют раздел больше — до следующей границы сектора: 48 КБ (0xC000) или 64 КБ (0x10000).
Прибор «завис» на несколько секунд после обновления. Это обмен: три стирания по 128 КБ на сектор образа — до 6 с при x32. Сторожевой таймер загрузчика должен это пережить.
Приложение собрано не под тот адрес. Код не в slot0_partition —
MCUboot проверит подпись и прыгнет по адресу из таблицы векторов, где
нет кода. У приложения — zephyr,code-partition = &slot0_partition, у
MCUboot — boot_partition.
Старый подписанный образ принимается. Программная защита от старой версии в MCUboot есть только в режиме перезаписи; в режимах обмена — только аппаратный счётчик безопасности. Проверять версию самим, как в 48а.2, или включить счётчик.
48б.8 Итоги
- Раскладка F407 под MCUboot: загрузчик 32 КБ (секторы 0–1), слоты по 256 КБ (5–6 и 7–8), scratch 128 КБ (9). MCUboot занял 30 308 байт — 92,49 % раздела.
- Образ: заголовок 512 байт (магия 0x96f3b83d,
ih_img_size16 744), тело, TLV 151 байт — SHA-256, хеш ключа, подпись ECDSA 71 байт в DER. Заголовок входит в хеш. - Хвост слота — 432 байта: журнал 128 × 3, флаги и магия. Действие при старте выбирается по таблице из трёх строк: TEST, PERM, REVERT, иначе NONE.
- Проба без
boot_write_img_confirmed()откатывается на следующем старте; после REVERT старый образ помечается подтверждённым. - Обмен через scratch продолжает с отметки после обрыва питания — в модели 3 из 3 и 6 из 6 обрывов. Сектор 128 КБ — три стирания, 3–6 с при x32.
- На секторах 128 КБ предел образа: scratch — 261 712 байт, offset — 131 072, move — 0, хотя сборка move проходит.
Дальше: раздел 48в «Разностное обновление: переслать только изменения прошивки» — вместо целого образа по радио идёт разница: формат, наложение, обрыв питания.