Embedded HandbookПриложения
Все разделы › VI. Практикум
STM32F407 · уровень средний · 14 мин чтения
Открыть в интерактивном справочнике

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)>;
        };

Флеш-память STM32F407 под MCUboot: загрузчик 32 КБ в секторах 0–1, слоты по 256 КБ в секторах 5–6 и 7–8, scratch 128 КБ в секторе 9; три шага обмена сектора через scratch; слот 0 изнутри — заголовок 512 байт, тело 16 744 байта, TLV 151 байт и хвост 432 байта

Загрузчику досталось 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 Итоги

Дальше: раздел 48в «Разностное обновление: переслать только изменения прошивки» — вместо целого образа по радио идёт разница: формат, наложение, обрыв питания.

Открыть в интерактивном справочнике