# bootloader: Phase 2 - checked and done

This commit is contained in:
Dmitry Akimov 2026-07-09 19:07:11 +03:00
parent db2c573642
commit 580e5f50d9
16 changed files with 1049 additions and 70 deletions

View file

@ -47,6 +47,9 @@ add_subdirectory(lib)
if(NOT BUILD_TESTS_HOST)
add_subdirectory(firmware/test)
add_subdirectory(firmware/bootloader)
# Заглушка tft_app для аппаратной верификации bootutil (Фаза 2) — удалить,
# когда появится реальный firmware/tft_app. См. firmware/bootloader/PLAN.md.
add_subdirectory(firmware/bootloader/test_stub)
endif()
# -----------------------------------------------------------------------------

View file

@ -126,6 +126,15 @@
"app"
]
},
{
"name": "mcuboot-stub-debug",
"displayName": "mcuboot slot stub (Фаза 2 верификация) — Debug",
"configurePreset": "Debug",
"targets": [
"test_slot_stub_a",
"test_slot_stub_b"
]
},
{
"name": "app-release",
"displayName": "app — Release",

View file

@ -115,8 +115,9 @@
/**
* @brief Размер читаемого буфера для однобайтных SR-команд.
*
* FlexSPI FIFO работает минимальными единицами в 4 байта (RXWMRK=0, 1 FILL
* unit = 4 bytes). Читаем 4 байта, используем только byte[0].
* FlexSPI FIFO работает минимальными единицами в 4 байта. Читаем 4 байта,
* используем только byte[0]. (Про размер watermark-юнита IPRXFSTS.FILL
* см. QSPI_WM_UNIT_BYTES и комментарий в qspi_read_tail().)
*/
#define SR_READ_LEN 4U
@ -338,9 +339,16 @@ AT_QUICKACCESS_SECTION_CODE(static status_t qspi_read_tail(uint8_t *p_dst, uint3
while (!done)
{
const uint32_t FILL =
/* IPRXFSTS.FILL считает в watermark-юнитах (1 unit = QSPI_WM_UNIT_WORDS
* слов = QSPI_WM_UNIT_BYTES байт), а не в словах напрямую см.
* FLEXSPI_GetFifoCounts() в sdk/.../drivers/fsl_flexspi.h, которая
* домножает это же поле на 8 при переводе в байты. Без домножения
* ниже FILL=1 (один готовый юнит, 2 слова уже в RFDR[0..1]) никогда
* не проходил сравнение с WORDS_NEEDED=2 busy-wait висел вечно,
* хотя нужные данные уже лежали в FIFO. */
const uint32_t FILL_UNITS =
(QSPI_BASE->IPRXFSTS & FLEXSPI_IPRXFSTS_FILL_MASK) >> FLEXSPI_IPRXFSTS_FILL_SHIFT;
if (FILL >= WORDS_NEEDED)
if ((FILL_UNITS * QSPI_WM_UNIT_WORDS) >= WORDS_NEEDED)
{
done = true;
}
@ -508,7 +516,23 @@ AT_QUICKACCESS_SECTION_CODE(static void qspi_ip_setup(uint32_t seq_idx, uint32_t
AT_QUICKACCESS_SECTION_CODE(static status_t qspi_ip_read(uint32_t seq_idx, uint32_t addr,
uint8_t *p_rx, uint32_t data_len))
{
qspi_ip_setup(seq_idx, addr, data_len);
/* IDATSZ округляем вверх до кратного QSPI_RFDR_WORD_BYTES (4).
*
* Наблюдение: при data_len, не кратном 4 (напр. 7 хвост hash-цикла
* bootutil), контроллер отдавал ровно один "бит" LUT-инструкции
* READ_SDR (operand=4 байта, см. LSEQ_IP_READ) и выставлял IPCMDDONE,
* не дотягивая до второго (частичного) слова IPRXFSTS.FILL зависал
* на 1 навсегда. JEDEC/status-регистры (SR_READ_LEN=4) этой границы
* никогда не касались отсюда и не было заметно раньше.
*
* qspi_read_fifo()/qspi_read_tail() ниже вызываются с ИСХОДНЫМ
* data_len из задренированного (возможно на слово большего) FIFO
* извлекается по-прежнему ровно запрошенное число байт, лишний
* padding-байт молча дренируется вместе со словом и отбрасывается. */
const uint32_t IDATSZ_ALIGNED =
(data_len + (QSPI_RFDR_WORD_BYTES - 1U)) & ~(QSPI_RFDR_WORD_BYTES - 1U);
qspi_ip_setup(seq_idx, addr, IDATSZ_ALIGNED);
const status_t RESULT = qspi_read_fifo(p_rx, data_len);
qspi_wait_idle();
return RESULT;

View file

@ -0,0 +1,130 @@
/*
** ###################################################################
** Linker file for the GNU C Compiler
**
** Abstract:
** Тестовая заглушка для аппаратной верификации Фазы 2 bootutil
** (firmware/bootloader/PLAN.md) — минимальный XIP-образ, который
** boot_go() выбирает и в который bootloader реально прыгает.
** НЕ boot-образ для BootROM (нет FCB/IVT/DCD секций — Slot A/Б не
** видны BootROM напрямую, только software jump из bootloader).
**
** Базовый адрес слота передаётся через -Wl,--defsym=__slot_base__=0x...
** (Slot A: 0x60040000, Slot Б: 0x60240000, см.
** docs/mimxrt1052/BOOTLOADER_FLASH_MAP.md). imgtool header (0x200)
** зарезервирован перед vector table — boot_select.c вычисляет адрес
** прыжка как flash_base + fa_off + ih_hdr_size.
** ###################################################################
*/
ENTRY(Reset_Handler)
HEAP_SIZE = DEFINED(__heap_size__) ? __heap_size__ : 0x400;
STACK_SIZE = DEFINED(__stack_size__) ? __stack_size__ : 0x400;
SLOT_BASE = DEFINED(__slot_base__) ? __slot_base__ : 0x60040000;
IMGTOOL_HDR_SZ = 0x200; /* -H 0x200 при imgtool sign, см. PLAN.md */
MEMORY
{
m_interrupts (RX) : ORIGIN = SLOT_BASE + IMGTOOL_HDR_SZ, LENGTH = 0x00000400
m_text (RX) : ORIGIN = SLOT_BASE + IMGTOOL_HDR_SZ + 0x400, LENGTH = 0x00008000 /* 32 KB — с большим запасом для мигалки */
m_data (RW) : ORIGIN = 0x20000000, LENGTH = 0x00020000 /* SRAM_DTC 128KB */
m_data2 (RW) : ORIGIN = 0x20200000, LENGTH = 0x00040000 /* SRAM_OC 256KB — не используется стабом, нужна board_mpu_init() */
}
SECTIONS
{
__NCACHE_REGION_START = ORIGIN(m_data2);
__NCACHE_REGION_SIZE = 0x2000; /* 8 KB non-cacheable, как в остальных линкер-скриптах */
.interrupts :
{
__VECTOR_TABLE = .;
__Vectors = .;
. = ALIGN(4);
KEEP(*(.isr_vector))
. = ALIGN(4);
} > m_interrupts
.text :
{
. = ALIGN(4);
*(.text)
*(.text*)
*(.rodata)
*(.rodata*)
*(.glue_7)
*(.glue_7t)
*(.eh_frame)
KEEP (*(.init))
KEEP (*(.fini))
. = ALIGN(4);
} > m_text
.ARM.extab : { *(.ARM.extab* .gnu.linkonce.armextab.*) } > m_text
.ARM :
{
__exidx_start = .;
*(.ARM.exidx*)
__exidx_end = .;
} > m_text
.init_array :
{
PROVIDE_HIDDEN (__init_array_start = .);
KEEP (*(SORT(.init_array.*)))
KEEP (*(.init_array*))
PROVIDE_HIDDEN (__init_array_end = .);
} > m_text
__etext = .;
__DATA_ROM = .;
.data : AT(__DATA_ROM)
{
. = ALIGN(4);
__data_start__ = .;
*(.data)
*(.data*)
. = ALIGN(4);
__data_end__ = .;
} > m_data
.bss :
{
. = ALIGN(4);
__bss_start__ = .;
*(.bss)
*(.bss*)
*(COMMON)
. = ALIGN(4);
__bss_end__ = .;
} > m_data
.heap :
{
. = ALIGN(8);
__end__ = .;
PROVIDE(end = .);
__HeapBase = .;
. += HEAP_SIZE;
__HeapLimit = .;
__heap_limit = .;
} > m_data
.stack :
{
. = ALIGN(8);
. += STACK_SIZE;
} > m_data
__StackTop = ORIGIN(m_data) + LENGTH(m_data);
__StackLimit = __StackTop - STACK_SIZE;
PROVIDE(__stack = __StackTop);
.ARM.attributes 0 : { *(.ARM.attributes) }
ASSERT(__StackLimit >= __HeapLimit, "region m_data overflowed with stack and heap")
}

View file

@ -12,16 +12,25 @@ set(TARGET_NAME bootloader)
configure_file("${CMAKE_CURRENT_SOURCE_DIR}/src/version.h.in"
"${CMAKE_CURRENT_BINARY_DIR}/generated/version.h" @ONLY)
# bootutil (MCUboot Direct-XIP) + TinyCrypt + ASN.1 — общий список с
# host-тестами (tests/host/mcuboot_port/), см. firmware/bootloader/PLAN.md,
# Фаза 2.
include(${CMAKE_CURRENT_SOURCE_DIR}/mcuboot_port/bootutil_sources.cmake)
add_executable(
${TARGET_NAME}
src/main.c
src/cli.c
src/protocol.c
src/boot_select.c
mcuboot_port/flash_map_backend.c
mcuboot_port/keys.c
${MCUBOOT_BOOTUTIL_SOURCES}
${BSP_GENERATED}/clock_config.c
${BSP_STARTUP_FILE}
${BSP_SYSCALLS_FILE})
target_include_directories(${TARGET_NAME} PRIVATE src/)
target_include_directories(${TARGET_NAME} PRIVATE src/ ${MCUBOOT_BOOTUTIL_INCLUDES})
target_include_directories(${TARGET_NAME}
PRIVATE "${CMAKE_CURRENT_BINARY_DIR}/generated")
@ -31,11 +40,11 @@ target_compile_definitions(
__STARTUP_INITIALIZE_NONCACHEDATA)
# -----------------------------------------------------------------------------
# Зависимости — минимум для Фазы 1 (bring-up + CDC). bsp_qspi_flash (доступ к
# слотам) и bsp_button (downgrade-override) добавятся в Фазах 2-3.
# Зависимости. bsp_button (downgrade-override) добавится в Фазе 3.
# -----------------------------------------------------------------------------
target_link_libraries(${TARGET_NAME} PRIVATE bsp_board bsp_led bsp_tick
bsp_usb_cdc bsp_boot_xip_no_dcd)
bsp_usb_cdc bsp_qspi_flash
bsp_boot_xip_no_dcd)
# -----------------------------------------------------------------------------
# Linker script — вариант flexspi_nor с m_text, ограниченным бюджетом

View file

@ -6,7 +6,7 @@
|---|---|---|
| 0 — Карта Flash | ✅ завершена | [docs/mimxrt1052/BOOTLOADER_FLASH_MAP.md](../../docs/mimxrt1052/BOOTLOADER_FLASH_MAP.md) |
| 1 — Скелет (CDC + LED) | ✅ завершена | сборка/HAB/SWD-прошивка/ping-pong/debug — все пункты верификации пройдены на реальной плате, детали ниже |
| 2 — bootutil (Direct-XIP) | 🟡 host-тесты завершены | реальный bootutil+TinyCrypt+imgtool-фикстуры, 5/5 тестов зелёные; ARM-сторона (flash_map_backend над bsp_qspi_flash, boot_select.c/jump, сборка/железо) — впереди |
| 2 — bootutil (Direct-XIP) | ✅ завершена | host-тесты 5/5, аппаратная верификация — все 5 сценариев пройдены на реальной плате (детали и 3 найденных/исправленных бага — [DEBUG_LOG_PHASE2.md](DEBUG_LOG_PHASE2.md)) |
| 3 — SD-путь установки | не начата | |
| 4 — SDRAM/W25Q smoke-test + LED-паттерны | не начата | |
| 5 — HAB Release + service-tui | не начата | |
@ -209,18 +209,197 @@
`test_boot_go_ignores_corrupted_slot`, `test_boot_go_no_valid_image`,
`test_boot_go_reverts_unconfirmed_image`).
**Осталось для Фазы 2 (ARM-сторона, ещё не сделано):**
- `firmware/bootloader/mcuboot_port/flash_map_backend.c` — реальный шим над `bsp_qspi_flash`
(`bsp_qspi_read`/`bsp_qspi_write_page`/`bsp_qspi_erase_sector`), по образцу
`sdk/middleware/mcuboot_opensource/boot/nxp_mcux_sdk/flashapi/flash_api.c`.
- `firmware/bootloader/src/boot_select.c``boot_go()` + прыжок в выбранный образ. Референс —
**ARM-сторона (выполнено):**
- `firmware/bootloader/mcuboot_port/flash_map_backend.c` — реальный шим над `bsp_qspi_flash`.
`bsp_qspi_read/write_page/erase_sector()` принимают flash-relative адрес (0-based, `IPCR0` FlexSPI
IP-команд), НЕ XIP-адрес — `flash_area.fa_off` тоже flash-relative (Slot A `0x00040000`, Slot Б
`0x00240000`). `flash_device_base()` — единственное место с XIP-адресом `0x60000000`, нужен только
`boot_select.c` для вычисления адреса прыжка. Постраничная запись (`write_page_chunked`) — порт
логики `flash_area_write_internal()` из NXP-референса: 0xFF поверх уже запрограммированных байт не
меняет их (NOR program только сбрасывает биты 1→0), поэтому безопасно перезатирать буфером с
ERASED_VAL в нетронутой части страницы.
- `firmware/bootloader/src/boot_select.{c,h}``boot_go()` + `jump_to_image()`. Портировано с
`sdk/middleware/mcuboot_opensource/boot/nxp_mcux_sdk/boot.c::do_boot()`: `flash_device_base()`
`vt = flash_base + rsp->br_image_off + rsp->br_hdr->ih_hdr_size``__set_MSP(vt->msp)`
`((void(*)(void))vt->reset)()`. CMSIS-интринсики, ассемблер не нужен.
- Подключить `bsp_qspi_flash` в `firmware/bootloader/CMakeLists.txt`, собрать под ARM (проверить что
`m_text` укладывается в 247 КБ бюджет Фазы 1).
- Аппаратная проверка: образ-заглушка (просто зажигает LED), подписанный тем же тестовым ключом, залит
вручную через SWD в Slot A (`0x60040000`) — подтвердить что bootloader реально в него прыгает.
`((void(*)(void))vt->reset)()`. CMSIS-интринсики (`fsl_common.h`), ассемблер не понадобился.
- `firmware/bootloader/CMakeLists.txt``bsp_qspi_flash` в зависимостях,
`include(mcuboot_port/bootutil_sources.cmake)` (тот же список bootutil+TinyCrypt+ASN.1, что и у
host-тестов — не дублируется). `-w`/`-fno-sanitize` для вендоренного кода перенесены в сам
`bootutil_sources.cmake` (`set_source_files_properties`), не блэнкетом на весь таргет — наш код
(`main.c`, `boot_select.c`, `flash_map_backend.c`) остаётся под обычными warnings.
- `main.c`: после `board_hw_init()`/`bsp_led_init()`/`bsp_tick_init()`/`bsp_qspi_init()` — сразу
`boot_select_and_jump()`, **до** поднятия USB CDC. Так плата грузится в tft_app и без подключённого
кабеля (нормальный полевой сценарий) — ждать хоста для проверки образа было бы неправильно. Провал
**не падать**, продолжить в уже существующий ping/pong-цикл Фазы 1 — прообраз будущего состояния
"жду SD" из Фазы 3, без самого SD-сканирования. Сценарий "оба слота пусты" на железе проверяется тем
же CDC-ping, что и в Фазе 1.
- Собрано под ARM: `m_text` — 50712 Б из 247 КБ бюджета (20.05%, был 12.85% в Фазе 1 — рост за счёт
bootutil+TinyCrypt+ASN.1, запас всё ещё большой). `just build::build-bootloader-debug` и
`hab-bootloader-debug`оба зелёные.
### Аппаратная верификация Фазы 2 — все 5 сценариев пройдены на железе
**Зачем отдельная заглушка.** `tft_app` не существует — нечего класть в слоты для проверки прыжка.
Нужен минимальный, независимо собираемый "образ", который bootloader может реально выбрать и в
который может реально прыгнуть — не файл с мусором, а настоящий imgtool-подписанный образ с корректным
vector table по адресу слота.
**Реализовано в `firmware/bootloader/test_stub/`** (не `tests/target/` — тот масштабируется под
RAM-загрузку через pyOCD для HIL, наш стаб — XIP из Flash, ближе по духу к самому bootloader; удалить
директорию целиком, когда появится реальный `firmware/tft_app`):
- `main.c``board_hw_init()``bsp_led_init()``bsp_tick_init()` → бесконечный цикл
`bsp_led_toggle(LED_APP)` с периодом `STUB_BLINK_MS` (компилируется в двух вариантах). Без USB/CDC.
- `cmake/linker/MIMXRT1052xxxxx_mcuboot_slot.ld` — параметризован через `-Wl,--defsym=__slot_base__=`,
один файл на оба таргета вместо двух копий. `ORIGIN = SLOT_BASE + 0x200` (после imgtool header),
`m_text` 32 КБ (с большим запасом для мигалки в 2-МБ слоте). Пришлось добавить `m_data2`/
`__NCACHE_REGION_START/SIZE``board_mpu_init()` (общий для всех прошивок, включён транзитивно
через `bsp_board`) их безусловно требует, даже когда некэшируемый регион не используется.
- Два CMake-таргета из одного `main.c` (`add_mcuboot_stub()` в `test_stub/CMakeLists.txt`):
`test_slot_stub_a` (`__slot_base__=0x60040000`, `STUB_BLINK_MS=500` — мигает ~1 раз/сек, "версия 1"),
`test_slot_stub_b` (`__slot_base__=0x60240000`, `STUB_BLINK_MS=250` — ~2 раза/сек, "версия 2").
Разный адрес — намеренно: MCUboot Direct-XIP код обычно не позиционно-независим (открытый вопрос из
BOOTLOADER_FLASH_MAP.md §4), это первая реальная проверка two-slot-two-linkage подхода.
- Оба собраны и подписаны тем же тестовым ключом (`sdk/middleware/mcuboot_opensource/root-ec-p256.pem`),
реальные параметры слота (`-H 0x200 -S 0x200000 --align 1 --pad-header --pad`) — **одной командой**:
```bash
just build::build-mcuboot-stub
```
Рецепт (`just/build.just`, группа `mcuboot-stub`) сам собирает `test_slot_stub_a`/`test_slot_stub_b`
(пресет `mcuboot-stub-debug`, targets в `CMakePresets.json`, т.к. в `bootloader-debug` их нет — иначе
собирались бы всегда вместе с bootloader) и подписывает imgtool'ом (эфемерный venv через
`uv run --with cryptography --with intelhex --with click --with cbor2 --with pyyaml` — эти зависимости
не в `tools/host` (spsdk), туда их специально не добавляли, чтобы не раздувать основной venv ради
временной тестовой оснастки). **Важно**: сначала это было проделано вручную в чате и `signed/` не
появлялась при обычной пересборке — это и вскрылось при попытке воспроизвести. Плюс была реальная
ошибка в первой версии рецепта: потерян `--pad` у `unconfirmed`-варианта (файл получался 16 КБ вместо
2 МБ — без trailer'а в конце слота revert-сценарий не работал бы). Исправлено, проверено размерами
файлов (все три — по 2 МБ).
- `build/Debug/signed/stub_a_v1_confirmed.bin``-v 1.0.0 --confirm` (сценарии 1, 3, 4)
- `build/Debug/signed/stub_b_v2_confirmed.bin``-v 2.0.0 --confirm` (сценарий 2)
- `build/Debug/signed/stub_a_v1_unconfirmed.bin``-v 1.0.0`, без `--confirm` (сценарий 5, revert)
**Прошивка в слот напрямую по адресу** — не через `flash_swd.py` (тот собирает FCB+IVT+HAB под
`0x60000000`, слотам это не нужно — они не самостоятельный boot-образ для BootROM, а данные, которые
читает `boot_go()`). Пишем сырой подписанный `.bin` напрямую по адресу слота через pyOCD (тот же
`tools/hil` venv, что уже использует `flash_swd.py` — см. `run_pyocd_flash()`):
**Важно**: `uv run --directory tools/hil` меняет рабочую директорию у самого `pyocd`, а не только у
`uv` — относительный путь к `.bin` резолвится от `tools/hil/`, не от корня репозитория. Путь к образу
должен быть абсолютным (`"$(pwd)/build/..."`, запускать из корня репо).
```bash
# Slot A — валидный, confirmed (сценарии 1, 3, 4)
uv run --directory tools/hil pyocd flash --target mimxrt1050_quadspi --frequency 4000000 \
--base-address 0x60040000 --erase sector "$(pwd)/build/Debug/signed/stub_a_v1_confirmed.bin"
# Slot Б — валидный, confirmed, новее (сценарий 2)
uv run --directory tools/hil pyocd flash --target mimxrt1050_quadspi --frequency 4000000 \
--base-address 0x60240000 --erase sector "$(pwd)/build/Debug/signed/stub_b_v2_confirmed.bin"
# Slot A — неподтверждённый, для проверки revert (сценарий 5, вместо confirmed-варианта выше)
uv run --directory tools/hil pyocd flash --target mimxrt1050_quadspi --frequency 4000000 \
--base-address 0x60040000 --erase sector "$(pwd)/build/Debug/signed/stub_a_v1_unconfirmed.bin"
# Стереть слот (для сценария 4 — "оба слота пусты")
# Адрес диапазона — позиционный аргумент, не через -a; start+length, не @.
uv run --directory tools/hil pyocd erase --target mimxrt1050_quadspi --frequency 4000000 \
--sector 0x60040000+0x200000
uv run --directory tools/hil pyocd erase --target mimxrt1050_quadspi --frequency 4000000 \
--sector 0x60240000+0x200000
```
**Чек-лист (зеркалит 5 host-тестов, но на реальном железе и с реальным `bsp_qspi_flash`) — все 5
сценариев пройдены на плате (2026-07-09):**
| № | Сценарий | Подготовка | Ожидаемый результат |
|---|---|---|---|
| 1 | Валиден только Slot A | Erase Slot Б, `stub_a_v1_confirmed.bin` → Slot A | LED_APP мигает ~1/сек (частота stub_a) |
| 2 | Оба валидны, побеждает версия Б | + `stub_b_v2_confirmed.bin` → Slot Б | LED_APP мигает ~2/сек (частота stub_b) |
| 3 | Slot Б повреждён | В Slot Б — испорченный файл (например, скопировать `stub_b_v2_confirmed.bin`, поменять байт в payload, прошить) | LED_APP возвращается к ~1/сек (Slot A) |
| 4 | Оба слота пусты | Erase Slot A и Slot Б целиком | LED_APP не мигает по образцу заглушки; `ping` по CDC отвечает `pong` — bootloader не прыгнул, остался в своём цикле |
| 5 | Revert неподтверждённого образа | `stub_a_v1_unconfirmed.bin` → Slot A; power cycle (1) → LED мигает (выбран впервые, `copy_done` выставляется); power cycle (2) БЕЗ вмешательства → Slot A должен быть стёрт bootutil'ом | После второго ресета — как сценарий 4 (LED не мигает, CDC ping жив) |
Между сценариями — обязательный power cycle (SWD-запись не ресетит автоматически, как и в Фазе 1).
**Инструмент подтверждения "прыжок реально произошёл", а не просто "LED мигает случайно":** частота
мигания однозначно указывает на конкретный слот (500 мс vs 250 мс визуально различимы), так что
чек-лист верифицируем глазами без дополнительной телеметрии. Если нужна более строгая проверка —
можно снять частоту осциллографом/логическим анализатором через MCU-Link, но для Фазы 2 визуального
контроля достаточно.
### Найденный баг: сценарий 1 не проходил — LED мигал один раз и замирал
`jump_to_image()` (`boot_select.c`) вызывал `__disable_irq()` перед прыжком — "для чистоты", не было
в референсном `do_boot()` NXP. `bsp_delay()` (`bsp/tick/src/tick.c`) — busy-wait на `g_s_tick_ms`,
инкрементируемом только внутри `SysTick_Handler` (ISR). Обычный `Reset_Handler` целевого образа не
трогает PRIMASK — рассчитывает, что прерывания уже разрешены, как после настоящего аппаратного
ресета. Замаскировав IRQ прыжком и не восстановив их нигде, мы гарантированно вешали `bsp_delay()` в
любом образе, куда прыгает bootloader: `LED_APP` успевал toggle'нуться один раз (до первого
`bsp_delay()` внутри цикла стаба) и застывал — неотличимо на глаз от "не мигает вообще".
**Исправлено**: убрали `__disable_irq()` из `jump_to_image()` — как и в референсе, трогаем только
`__set_CONTROL(0)`/`__set_MSP`/`__ISB`. Пересобрано (`m_text` уменьшился на 272 Б), HAB
пересгенерирован. Стаб-образы (`build/Debug/signed/*.bin`) пересборки не требуют — баг был чисто на
стороне bootloader, не заглушки.
### Второй найденный баг: сценарий 1 всё ещё не проходил — реальный завис в `bsp_qspi_flash`
После фикса `jump_to_image()` завис уже сам bootloader, **до** прыжка — в отладчике (стек
`boot_select_and_jump → boot_go → ... → bootutil_img_validate → bootutil_img_hash → flash_area_read →
bsp_qspi_read → qspi_ip_read → qspi_read_fifo → qspi_read_tail`) видно бесконечный busy-wait на
`IPRXFSTS.FILL`. Значения на момент зависания: `remain=7` (внутри `qspi_read_tail`, т.е. запрошено
`WORDS_NEEDED=2` слова), `FILL` стабильно `1`, `QSPI_BASE->INTR=0x61` — расшифровка битов
(`PERI_FLEXSPI.h`): bit0 `IPCMDDONE` **уже установлен**. Контроллер считает IP-команду завершённой,
реально доставив только одно слово (4 байта) из требуемых двух.
`bootutil_img_hash()` (`image_validate.c:125-129`) читает образ кусками до `BOOT_TMPBUF_SZ=256` байт;
последняя итерация цикла — остаток `size - off`, в данном случае 7 байт (последние байты хэшируемой
области `header+img_size+protected_tlv`). Эта короткая, **не кратная 4** длина ни разу не встречалась
раньше: JEDEC ID и чтения статус-регистров всегда используют `SR_READ_LEN=4` (уже word-aligned), а
`firmware_test`/`test_qspi.c` тоже, судя по всему, ни разу не запрашивал не кратный 4 размер. LUT
`LSEQ_IP_READ` (`qspi_flash.c`) использует `READ_SDR` с operand `0x04` — похоже, что при `IDATSZ`, не
кратном 4, контроллер отдаёт ровно один "бит" этой инструкции и не дотягивает до второго (частичного)
слова.
**Исправлено** в `bsp/qspi_flash/src/qspi_flash.c::qspi_ip_read()``IDATSZ`, который уходит в
железо (`qspi_ip_setup`), теперь округляется вверх до кратного `QSPI_RFDR_WORD_BYTES` (4); из FIFO
`qspi_read_fifo()`/`qspi_read_tail()` по-прежнему извлекают ровно исходное (не округлённое) число
байт — лишний padding-байт молча дренируется вместе со словом и отбрасывается существующей логикой
извлечения, менять её не понадобилось. Затрагивает **все** IP-чтения произвольной длины через
`bsp_qspi_read()`, не только bootutil — потенциально тот же баг мог бы всплыть и в `firmware_test`,
если бы там когда-нибудь понадобилось прочитать не кратное 4 число байт.
**Важно**: после этого фикса на сценарии 1 всплыл ещё один, третий баг (ниже) — оба фикса стоят в
дереве вместе, изолированно друг от друга на железе не перепроверялись.
**Урок**: любая ручная "гигиена" вокруг прыжка (маскирование прерываний, сброс периферии и т.п.),
не присутствующая в проверенном референсе, — повод для отдельного вопроса "а точно ли это
симметрично восстанавливается на другой стороне", а не молчаливого добавления "на всякий случай".
### Третий найденный баг: зависание переехало на чтение подписи — `qspi_read_tail` сравнивал FILL в юнитах напрямую со счётчиком слов
После фикса второго бага зависание не пропало, а переехало дальше по стеку — тот же паттерн
(`qspi_read_tail`, `FILL` намертво на `1`), но уже на чтении TLV с ECDSA-P256 подписью в
`bootutil_img_validate` (не в `bootutil_img_hash`). Ключевой момент: для этого чтения `IDATSZ`,
уходящий в железо, уже был кратен 4 (72 байта) — гипотеза про word-alignment из второго бага оказалась
неполной.
Настоящая причина: `IPRXFSTS.FILL` считает не слова, а watermark-юниты по 8 байт (2 слова) — как и
документирует `FLEXSPI_GetFifoCounts()` в SDK-драйвере (`fsl_flexspi.h`, домножает то же поле на 8
при переводе в байты). `qspi_read_tail()` сравнивал `FILL` напрямую со счётчиком нужных слов, без
перевода единиц — зависал именно тогда, когда хвост требовал ровно 2 слова (невидимо для всех чтений,
которым достаточно 1 слова, включая все SR/JEDEC-чтения — отсюда и не проявлялось раньше).
**Исправлено** в той же функции `qspi_read_tail()` — сравнение переведено в слова
(`FILL_UNITS * QSPI_WM_UNIT_WORDS >= WORDS_NEEDED`), тем же паттерном, что уже использовался в
`qspi_read_fifo()`. Полный разбор, включая то, как гипотеза была подтверждена без доступа к живому
регистру через отладчик (упёрлись в ограничение карты памяти pyOCD-таргета `mimxrt1050_quadspi`) —
[DEBUG_LOG_PHASE2.md](DEBUG_LOG_PHASE2.md).
**Подтверждено на железе**: со всеми тремя фиксами сценарий 1, а следом и оставшиеся четыре сценария
чек-листа прошли (2026-07-09). Фаза 2 аппаратно верифицирована полностью.
**Верификация — до всякого железа** (план, для истории):
- Host-юнит-тесты (`tests/host/`, Unity + fff, по образцу существующих `tests/host/protocol/`,

View file

@ -52,16 +52,22 @@ set(MCUBOOT_BOOTUTIL_INCLUDES
)
# ------------------------------------------------------------------------
# Обход бага clang 22.1.8 (Homebrew): -fsanitize=address,undefined ломает
# генерацию CFI-директив на некоторых больших функциях bootutil (напр.
# loader.c::context_boot_go) "invalid CFI advance_loc expression" на
# этапе ассемблирования. Без санитайзеров те же файлы собираются чисто
# похоже на баг конкретной версии тулчейна, а не проблему в bootutil или
# нашем коде. Отключаем санитайзеры только для вендоренных исходников
# bootutil/TinyCrypt/ASN.1 (не для нашего кода там ASan/UBSan остаются).
# Опции компиляции только для вендоренных исходников (не для нашего кода
# main.c/cli.c/boot_select.c и т.д. компилируются с обычными warnings/ASan
# консьюмера):
# -w вендоренный код, не наш стиль/lint
# -fno-sanitize=address,undefined обход бага clang 22.1.8 (Homebrew):
# -fsanitize=address,undefined ломает генерацию CFI-директив на
# некоторых больших функциях bootutil (напр. loader.c::context_boot_go)
# "invalid CFI advance_loc expression" на этапе ассемблирования. Без
# санитайзеров те же файлы собираются чисто похоже на баг конкретной
# версии тулчейна. Не применимо к arm-none-eabi-gcc (ARM-таргет не
# использует ASan) гейтим по Clang.
# ------------------------------------------------------------------------
set(MCUBOOT_VENDORED_COMPILE_OPTIONS -w)
if(CMAKE_C_COMPILER_ID MATCHES "Clang")
list(APPEND MCUBOOT_VENDORED_COMPILE_OPTIONS -fno-sanitize=address,undefined)
endif()
set_source_files_properties(${MCUBOOT_BOOTUTIL_SOURCES}
PROPERTIES COMPILE_OPTIONS
"-fno-sanitize=address,undefined")
endif()
"${MCUBOOT_VENDORED_COMPILE_OPTIONS}")

View file

@ -0,0 +1,237 @@
/**
* @file flash_map_backend.c
* @brief Реализация flash_map.h поверх bsp_qspi_flash Slot A/Б из
* docs/mimxrt1052/BOOTLOADER_FLASH_MAP.md.
*
* bsp_qspi_read/write_page/erase_sector() принимают flash-relative адрес
* (0-based от начала чипа, IPCR0 FlexSPI IP-команд) НЕ XIP-адрес
* (0x60000000+). fa_off здесь то же самое flash-relative смещение.
* flash_device_base() единственное место, где встречается XIP-адрес
* 0x60000000: он нужен boot_select.c для вычисления адреса прыжка
* (flash_base + fa_off + hdr_size), но не самим read/write/erase.
*
* @pre bsp_qspi_init() должен быть вызван до любой flash_area_* функции
* (main.c, до boot_go()).
*/
#include "flash_map.h"
#include "sysflash/sysflash.h"
#include "bsp/qspi_flash.h"
#include <string.h>
#define ERASED_VAL 0xFFU
/* Slot A/Б — см. docs/mimxrt1052/BOOTLOADER_FLASH_MAP.md. Смещения —
* flash-relative (от начала чипа), не XIP-адрес. */
static const struct flash_area g_s_areas[2] = {
{ .fa_id = 0U, .fa_device_id = FLASH_DEVICE_ID, .pad16 = 0U,
.fa_off = 0x00040000UL, .fa_size = 0x00200000UL }, /* Slot A: 0x60040000, 2 МБ */
{ .fa_id = 1U, .fa_device_id = FLASH_DEVICE_ID, .pad16 = 0U,
.fa_off = 0x00240000UL, .fa_size = 0x00200000UL }, /* Slot Б: 0x60240000, 2 МБ */
};
/* ── Постраничная запись (аналог NXP flash_area_write_internal) ─────────
*
* bsp_qspi_write_page() пишет ровно BSP_QSPI_PAGE_SIZE (256) байт по
* странично-выровненному адресу. Запись 0xFF поверх уже запрограммированных
* байт не изменяет их (NOR program может только сбрасывать биты 10,
* запись 0xFF не запрашивает сброс ни одного бита) поэтому безопасно
* "перезатирать" уже записанную часть страницы буфером, где нетронутая
* часть заполнена ERASED_VAL: bootutil пишет монотонно возрастающими
* смещениями, повторно данные не перезаписывает.
*/
static int write_page_chunked(uint32_t dst_addr, const uint8_t *p_src, uint32_t len)
{
uint8_t page_buf[BSP_QSPI_PAGE_SIZE];
uint32_t chunk_ofs = dst_addr % BSP_QSPI_PAGE_SIZE;
uint32_t page_addr = dst_addr - chunk_ofs;
uint32_t chunk_size = BSP_QSPI_PAGE_SIZE - chunk_ofs;
while (len > 0U)
{
if (chunk_size > len)
{
chunk_size = len;
}
memset(page_buf, ERASED_VAL, BSP_QSPI_PAGE_SIZE);
memcpy(page_buf + chunk_ofs, p_src, chunk_size);
if (bsp_qspi_write_page(page_addr, page_buf) != BSP_OK)
{
return -1;
}
p_src += chunk_size;
len -= chunk_size;
chunk_ofs = 0U;
chunk_size = BSP_QSPI_PAGE_SIZE;
page_addr += BSP_QSPI_PAGE_SIZE;
}
return 0;
}
/* ── flash_map.h contract ─────────────────────────────────────────────── */
int flash_device_base(uint8_t fd_id, uintptr_t *ret)
{
if (fd_id != FLASH_DEVICE_ID)
{
return -1;
}
*ret = 0x60000000UL; /* XIP-mapped база — только для вычисления адреса прыжка */
return 0;
}
int flash_area_open(uint8_t id, const struct flash_area **area)
{
if (id >= 2U)
{
return -1;
}
*area = &g_s_areas[id];
return 0;
}
void flash_area_close(const struct flash_area *area)
{
(void) area;
}
int flash_area_read(const struct flash_area *area, uint32_t off, void *dst, uint32_t len)
{
if (off + len > area->fa_size)
{
return -1;
}
return (bsp_qspi_read(area->fa_off + off, (uint8_t *) dst, len) == BSP_OK) ? 0 : -1;
}
int flash_area_write(const struct flash_area *area, uint32_t off, const void *src, uint32_t len)
{
if (off + len > area->fa_size)
{
return -1;
}
return write_page_chunked(area->fa_off + off, (const uint8_t *) src, len);
}
int flash_area_erase(const struct flash_area *area, uint32_t off, uint32_t len)
{
if ((off + len > area->fa_size) || ((off % BSP_QSPI_SECTOR_SIZE) != 0U) ||
((len % BSP_QSPI_SECTOR_SIZE) != 0U))
{
return -1;
}
uint32_t addr = area->fa_off + off;
for (; len > 0U; len -= BSP_QSPI_SECTOR_SIZE)
{
if (bsp_qspi_erase_sector(addr) != BSP_OK)
{
return -1;
}
addr += BSP_QSPI_SECTOR_SIZE;
}
return 0;
}
uint8_t flash_area_align(const struct flash_area *area)
{
(void) area;
return 1U;
}
uint8_t flash_area_erased_val(const struct flash_area *area)
{
(void) area;
return ERASED_VAL;
}
int flash_area_read_is_empty(const struct flash_area *area, uint32_t off, void *dst, uint32_t len)
{
if (flash_area_read(area, off, dst, len) != 0)
{
return -1;
}
const uint8_t *p_buf = (const uint8_t *) dst;
for (uint32_t i = 0U; i < len; i++)
{
if (p_buf[i] != ERASED_VAL)
{
return 0;
}
}
return 1;
}
int flash_area_get_sector(const struct flash_area *fa, uint32_t off, struct flash_sector *sector)
{
if (off >= fa->fa_size)
{
return -1;
}
sector->fs_off = (off / BSP_QSPI_SECTOR_SIZE) * BSP_QSPI_SECTOR_SIZE;
sector->fs_size = BSP_QSPI_SECTOR_SIZE;
return 0;
}
int flash_area_get_sectors(int fa_id, uint32_t *count, struct flash_sector *sectors)
{
const struct flash_area *fa;
uint32_t max_cnt = *count;
if (flash_area_open((uint8_t) fa_id, &fa) != 0)
{
return -1;
}
uint32_t rem_len = fa->fa_size;
*count = 0U;
while ((rem_len > 0U) && (*count < max_cnt))
{
sectors[*count].fs_off = BSP_QSPI_SECTOR_SIZE * (*count);
sectors[*count].fs_size = BSP_QSPI_SECTOR_SIZE;
(*count)++;
rem_len -= BSP_QSPI_SECTOR_SIZE;
}
return 0;
}
int flash_area_id_from_multi_image_slot(int image_index, int slot)
{
switch (slot)
{
case 0:
return FLASH_AREA_IMAGE_PRIMARY(image_index);
case 1:
return FLASH_AREA_IMAGE_SECONDARY(image_index);
default:
return -1;
}
}
int flash_area_id_from_image_slot(int slot)
{
return flash_area_id_from_multi_image_slot(0, slot);
}
int flash_area_id_to_multi_image_slot(int image_index, int area_id)
{
if (area_id == FLASH_AREA_IMAGE_PRIMARY(image_index))
{
return 0;
}
if (area_id == FLASH_AREA_IMAGE_SECONDARY(image_index))
{
return 1;
}
return -1;
}

View file

@ -0,0 +1,61 @@
/**
* @file boot_select.c
* @brief bootutil boot_go() + прыжок в выбранный образ.
*
* Референс sdk/middleware/mcuboot_opensource/boot/nxp_mcux_sdk/boot.c::do_boot():
* та же последовательность (flash_device_base вычислить адрес vector table
* __set_MSP __ISB прыжок на Reset_Handler), CMSIS-интринсики, без ассемблера.
*/
#include "boot_select.h"
#include "bootutil/bootutil.h"
#include "bootutil/fault_injection_hardening.h"
#include "flash_map.h"
#include "fsl_common.h"
struct arm_vector_table
{
uint32_t msp;
uint32_t reset;
};
static void jump_to_image(const struct boot_rsp *p_rsp)
{
uintptr_t flash_base;
if (flash_device_base(p_rsp->br_flash_dev_id, &flash_base) != 0)
{
return;
}
const struct arm_vector_table *p_vt = (const struct arm_vector_table *) (flash_base +
p_rsp->br_image_off +
p_rsp->br_hdr->ih_hdr_size);
/* Намеренно НЕ __disable_irq() здесь (в отличие от более ранней версии).
* bsp_delay() в целевом образе busy-wait на счётчике, инкрементируемом
* из SysTick_Handler (см. bsp/tick/src/tick.c); обычный Reset_Handler не
* трогает PRIMASK, рассчитывая, что прерывания уже разрешены (как после
* реального аппаратного ресета). Если замаскировать IRQ здесь и не
* восстановить в целевом образе SysTick не сработает ни разу, и любой
* bsp_delay() внутри целевого образа зависнет навсегда. NXP-референс
* (do_boot()) тоже не трогает PRIMASK только __set_CONTROL/__set_MSP. */
__set_CONTROL(0U);
__set_MSP(p_vt->msp);
__ISB();
((void (*)(void)) p_vt->reset)();
}
void boot_select_and_jump(void)
{
struct boot_rsp rsp;
fih_ret fih_rc = boot_go(&rsp);
if (!FIH_EQ(fih_rc, FIH_SUCCESS))
{
return; /* нет валидного образа — main.c продолжит ping/pong-цикл */
}
jump_to_image(&rsp); /* при успехе не возвращается */
}

View file

@ -0,0 +1,21 @@
/**
* @file boot_select.h
* @brief Выбор и запуск образа tft_app через bootutil (Direct-XIP).
*/
#ifndef BOOT_SELECT_H_
#define BOOT_SELECT_H_
/**
* @brief Выбрать образ (bootutil boot_go, Direct-XIP) и прыгнуть в него.
*
* При успехе не возвращается управление переходит в выбранный образ.
* При провале (нет валидного образа ни в одном слоте, или оба слота стёрты
* bootutil'ом из-за незавершённого revert) возвращается, чтобы main.c
* мог продолжить в ping/pong-цикл (задел на состояние "жду SD" Фазы 3).
*
* @pre bsp_qspi_init() уже вызван.
*/
void boot_select_and_jump(void);
#endif /* BOOT_SELECT_H_ */

View file

@ -2,26 +2,32 @@
* @file main.c
* @brief bootloader точка входа.
*
* Фаза 1 (скелет): bring-up + USB CDC ACM + ping/get_version. Без доступа
* к Flash-слотам, без bootutil, без SD это добавится в Фазах 2-3.
* Фаза 2: минимальный bring-up сразу попытка boot_go() (bootutil,
* Direct-XIP) так плата грузится в tft_app и без подключённого USB
* (нормальный полевой сценарий). Только если валидного образа нет ни в
* одном слоте поднимаем USB CDC ACM для диагностики (JSON-lines, cli.c,
* урезанное подмножество протокола firmware_test) и остаёмся в ping/pong
* цикле. Это прообраз будущего состояния "жду SD" из Фазы 3 сама
* SD-логика ещё не добавлена.
*
* Единственный канал хостплата: USB CDC ACM. Протокол: JSON-lines через
* cli.c (урезанное подмножество протокола firmware_test).
*
* Последовательность старта (зеркалит firmware/test/src/main.c):
* Последовательность старта:
* 1. board_hw_init() тактирование, MPU, кэш, пины
* 2. bsp_led_init() оба LED выключены
* 3. bsp_tick_init() SysTick 1 мс
* 4. bsp_usb_cdc_init() PHY + стек + NVIC
* 5. Ожидание CDC ready LED_HEARTBEAT мигает
* 6. cli_init() сброс буфера
* 7. Главный цикл poll + cli_process
* 4. bsp_qspi_init() доступ к Slot A/Б
* 5. boot_select_and_jump() при успехе не возвращается
* 6. bsp_usb_cdc_init() только если п.5 не сработал
* 7. Ожидание CDC ready LED_HEARTBEAT мигает
* 8. cli_init() сброс буфера
* 9. Главный цикл poll + cli_process
*
* Bootloader без SDRAM (см. docs/mimxrt1052/BOOTLOADER_FLASH_MAP.md) DCD
* не используется (bsp_boot_xip_no_dcd).
*/
#include "board.h"
#include "boot_select.h"
#include "bsp/led.h"
#include "bsp/qspi_flash.h"
#include "bsp/tick.h"
#include "bsp/usb_cdc.h"
#include "cli.h"
@ -38,6 +44,12 @@ int main(void)
bsp_led_init();
bsp_tick_init();
if (bsp_qspi_init() == BSP_OK)
{
boot_select_and_jump(); /* при успехе не возвращается */
}
/* Нет валидного образа ни в одном слоте — диагностический режим. */
if (bsp_usb_cdc_init() != BSP_OK)
{
bsp_led_toggle(LED_HEARTBEAT);

View file

@ -0,0 +1,49 @@
# firmware/bootloader/test_stub/CMakeLists.txt
#
# Заглушка tft_app для аппаратной верификации Фазы 2 bootutil — см.
# firmware/bootloader/PLAN.md, "Аппаратная верификация Фазы 2". Не часть
# продукта — удалить, когда появится реальный tft_app.
#
# Два таргета из одного main.c: разный адрес слота (--defsym __slot_base__)
# и разная частота мигания (STUB_BLINK_MS) — чтобы на глаз отличить, какой
# слот выбрал bootloader. Постройка .bin, дальше подписывается вручную
# imgtool'ом (см. PLAN.md) — .bin сюда не должен попасть без подписи.
function(add_mcuboot_stub NAME SLOT_BASE BLINK_MS)
add_executable(${NAME} main.c ${BSP_GENERATED}/clock_config.c
${BSP_STARTUP_FILE} ${BSP_SYSCALLS_FILE})
target_compile_definitions(${NAME} PRIVATE STUB_BLINK_MS=${BLINK_MS}
__STARTUP_CLEAR_BSS)
target_link_libraries(${NAME} PRIVATE bsp_board bsp_led bsp_tick
bsp_boot_xip_no_dcd)
target_link_options(
${NAME}
PRIVATE
-Wl,--gc-sections
-Wl,--print-memory-usage
-Wl,-Map=${CMAKE_BINARY_DIR}/${NAME}.map
-Wl,--defsym=__slot_base__=${SLOT_BASE}
-Wl,--defsym=__stack_size__=0x400
-Wl,--defsym=__heap_size__=0x400
-T${CMAKE_SOURCE_DIR}/cmake/linker/MIMXRT1052xxxxx_mcuboot_slot.ld)
set_target_properties(${NAME} PROPERTIES RUNTIME_OUTPUT_DIRECTORY
${CMAKE_BINARY_DIR})
add_custom_command(
TARGET ${NAME}
POST_BUILD
COMMAND ${CMAKE_OBJCOPY} -O binary $<TARGET_FILE:${NAME}>
${CMAKE_BINARY_DIR}/${NAME}.bin
COMMAND ${CMAKE_SIZE} $<TARGET_FILE:${NAME}>
COMMENT "Generating ${NAME}.bin (не подписан — imgtool sign вручную, см. PLAN.md)")
endfunction()
# Slot A: 0x60040000, мигает раз в 500 мс ("версия 1")
add_mcuboot_stub(test_slot_stub_a 0x60040000 500)
# Slot Б: 0x60240000, мигает раз в 250 мс ("версия 2")
add_mcuboot_stub(test_slot_stub_b 0x60240000 250)

View file

@ -0,0 +1,120 @@
# Фаза 2 (bootutil / MCUboot Direct-XIP) — аппаратная верификация
Памятка с готовыми командами: сборка, прошивка, стирание, чек-лист.
Все команды — из корня репозитория (`tft_manufacture_test/`).
---
## 0. Предпосылки
- `firmware/bootloader` собран с ARM-стороной Фазы 2 (bootutil + `flash_map_backend.c` над
`bsp_qspi_flash` + `boot_select.c`). `main.c` пытается `boot_go()` сразу после минимального
bring-up, до подъёма USB CDC — так что плата грузит tft_app и без подключённого кабеля.
- Заглушка вместо ещё не существующего `tft_app``firmware/bootloader/test_stub/`: два образа,
различающиеся частотой мигания `LED_APP` (по частоте видно, какой слот реально выбрал bootloader).
- Оба образа подписаны тестовым sample-ключом MCUboot (`sdk/middleware/mcuboot_opensource/root-ec-p256.pem`)
— публичный, не для продакшена, годится только для этой проверки.
---
## 1. Сборка
```bash
# Bootloader (Debug) + HAB-контейнер
just build::build-bootloader-debug
just build::hab-bootloader-debug
# Заглушки Slot A/Б — собрать И подписать imgtool'ом одной командой
just build::build-mcuboot-stub
```
После этого в `build/Debug/` должны появиться:
```bash
bootloader_hab.bin
signed/stub_a_v1_confirmed.bin (2 МБ, v1.0.0, --confirm)
signed/stub_b_v2_confirmed.bin (2 МБ, v2.0.0, --confirm)
signed/stub_a_v1_unconfirmed.bin (2 МБ, v1.0.0, без --confirm — для revert)
```
Если файлов нет или размер не 2 МБ — пересобрать:
`rm -rf build/Debug && just build::build-bootloader-debug && just build::hab-bootloader-debug && just build::build-mcuboot-stub`.
---
## 2. Прошивка bootloader
```bash
just host::flash-swd-bootloader-debug
# обязателен power cycle платы после прошивки — SWD-запись не ресетит автоматически
```
---
## 3. Прошивка образов в слоты (pyOCD)
Не через `flash_swd.py` — тот собирает FCB+IVT+HAB под `0x60000000`, слотам это не нужно (не
самостоятельный boot-образ для BootROM, а данные, которые читает `boot_go()`). Пишем сырой
подписанный `.bin` напрямую по адресу слота.
**Важно:** `uv run --directory tools/hil` меняет рабочую директорию у самого `pyocd`, не только у
`uv` — путь к `.bin` должен быть абсолютным (`"$(pwd)/..."`), иначе резолвится от `tools/hil/` и
получите `No such file`.
```bash
# Slot A (0x60040000) — валидный, confirmed
uv run --directory tools/hil pyocd flash --target mimxrt1050_quadspi --frequency 4000000 \
--base-address 0x60040000 --erase sector "$(pwd)/build/Debug/signed/stub_a_v1_confirmed.bin"
# Slot Б (0x60240000) — валидный, confirmed, версия новее
uv run --directory tools/hil pyocd flash --target mimxrt1050_quadspi --frequency 4000000 \
--base-address 0x60240000 --erase sector "$(pwd)/build/Debug/signed/stub_b_v2_confirmed.bin"
# Slot A — неподтверждённый вариант, для проверки revert (использовать ВМЕСТО confirmed-варианта)
uv run --directory tools/hil pyocd flash --target mimxrt1050_quadspi --frequency 4000000 \
--base-address 0x60040000 --erase sector "$(pwd)/build/Debug/signed/stub_a_v1_unconfirmed.bin"
```
---
## 4. Стирание слота
Адрес — позиционный аргумент (не `-a`), формат диапазона `start+length` (не `start@length`).
```bash
uv run --directory tools/hil pyocd erase --target mimxrt1050_quadspi --frequency 4000000 \
--sector 0x60040000+0x200000 # Slot A, 2 МБ
uv run --directory tools/hil pyocd erase --target mimxrt1050_quadspi --frequency 4000000 \
--sector 0x60240000+0x200000 # Slot Б, 2 МБ
```
---
## 5. Проверка "bootloader жив" (CDC)
Актуально для сценария 4 (оба слота пусты — bootloader не прыгает, остаётся в диагностическом цикле).
Подключиться к USB CDC ACM платы (см. [README.md](README.md#3-подключение)) любым терминалом:
```bash
screen /dev/cu.usbmodemXXXX # macOS, порт свой у каждого подключения
```
Отправить `{"type":"cmd","cmd":"ping"}` — в ответ должно прийти `{"type":"pong"}`.
---
## 6. Чек-лист сценариев
Между КАЖДЫМ сценарием — обязательный power cycle платы (SWD-запись не ресетит автоматически).
| № | Сценарий | Подготовка | Ожидаемый результат |
| --- | ------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
| 1 | Валиден только Slot A | Erase Slot Б (шаг 4), `stub_a_v1_confirmed.bin` → Slot A (шаг 3) | `LED_APP` мигает ~1 раз/сек |
| 2 | Оба валидны, побеждает версия Б | + `stub_b_v2_confirmed.bin` → Slot Б | `LED_APP` мигает ~2 раза/сек |
| 3 | Slot Б повреждён | В Slot Б — испорченный файл (скопировать `stub_b_v2_confirmed.bin`, поменять один байт в payload, прошить тем же способом что и в шаге 3) | `LED_APP` возвращается к ~1 разу/сек (снова Slot A) |
| 4 | Оба слота пусты | Erase Slot A и Slot Б целиком (шаг 4, оба вызова) | `LED_APP` не мигает по образцу заглушки; `ping` по CDC (шаг 5) отвечает `pong` — bootloader не прыгнул, остался в своём цикле |
| 5 | Revert неподтверждённого образа | `stub_a_v1_unconfirmed.bin` → Slot A; power cycle (1) → LED мигает (образ выбран впервые, `copy_done` выставляется); power cycle (2) БЕЗ каких-либо действий между ними → Slot A должен быть стёрт bootutil'ом | После второго ресета — как сценарий 4 (LED не мигает, CDC ping жив) |
---

View file

@ -0,0 +1,34 @@
/**
* @file main.c
* @brief Заглушка tft_app для аппаратной верификации Фазы 2 bootutil.
*
* tft_app ещё не реализована bootloader'у некуда прыгать. Этот образ
* минимальный, но настоящий imgtool-подписанный XIP-образ с корректным
* vector table по адресу слота: единственная задача мигать LED_APP с
* периодом, зависящим от STUB_BLINK_MS (задаётся компилятору), чтобы по
* частоте мигания визуально отличить, какой слот реально выбрал
* bootloader. См. firmware/bootloader/PLAN.md, "Аппаратная верификация
* Фазы 2".
*
* Без USB/CDC визуальной индикации достаточно, минимальный код.
*/
#include "board.h"
#include "bsp/led.h"
#include "bsp/tick.h"
#ifndef STUB_BLINK_MS
#error "STUB_BLINK_MS must be defined (see firmware/bootloader/test_stub/CMakeLists.txt)"
#endif
int main(void)
{
board_hw_init();
bsp_led_init();
bsp_tick_init();
while (1)
{
bsp_led_toggle(LED_APP);
bsp_delay(STUB_BLINK_MS);
}
}

View file

@ -236,6 +236,35 @@ hab-verify project="firmware_test" type="release":
echo " ✅ ${OUT}"
grep -E "(entry|csf|tag)" "${OUT}" || true
# =============================================================================
# ГРУППА: mcuboot-stub — заглушка tft_app для аппаратной верификации bootutil
# (Фаза 2, firmware/bootloader/PLAN.md). Временное — удалить вместе с
# firmware/bootloader/test_stub/, когда появится реальный firmware/tft_app.
# =============================================================================
MCUBOOT_ROOT := justfile_directory() / 'sdk/middleware/mcuboot_opensource'
MCUBOOT_KEY := MCUBOOT_ROOT / 'root-ec-p256.pem'
MCUBOOT_IMGTOOL := MCUBOOT_ROOT / 'scripts/imgtool.py'
# imgtool не входит в зависимости tools/host (spsdk) — ставится эфемерно через uv --with,
# не трогая основной venv (см. firmware/bootloader/PLAN.md, "Аппаратная верификация Фазы 2").
MCUBOOT_UV := 'uv run --with cryptography --with intelhex --with click --with cbor2 --with pyyaml python3'
[doc('Собрать и подписать заглушки Slot A/Б для аппаратной верификации bootutil (Debug)')]
[group('mcuboot-stub')]
build-mcuboot-stub: _configure-debug
cmake --build --preset mcuboot-stub-debug
mkdir -p "{{ BUILD_DIR }}/Debug/signed"
{{ MCUBOOT_UV }} "{{ MCUBOOT_IMGTOOL }}" sign \
-k "{{ MCUBOOT_KEY }}" -H 0x200 -S 0x200000 -v 1.0.0 --align 1 --pad-header --pad --confirm \
"{{ BUILD_DIR }}/Debug/test_slot_stub_a.bin" "{{ BUILD_DIR }}/Debug/signed/stub_a_v1_confirmed.bin"
{{ MCUBOOT_UV }} "{{ MCUBOOT_IMGTOOL }}" sign \
-k "{{ MCUBOOT_KEY }}" -H 0x200 -S 0x200000 -v 2.0.0 --align 1 --pad-header --pad --confirm \
"{{ BUILD_DIR }}/Debug/test_slot_stub_b.bin" "{{ BUILD_DIR }}/Debug/signed/stub_b_v2_confirmed.bin"
{{ MCUBOOT_UV }} "{{ MCUBOOT_IMGTOOL }}" sign \
-k "{{ MCUBOOT_KEY }}" -H 0x200 -S 0x200000 -v 1.0.0 --align 1 --pad-header --pad \
"{{ BUILD_DIR }}/Debug/test_slot_stub_a.bin" "{{ BUILD_DIR }}/Debug/signed/stub_a_v1_unconfirmed.bin"
@echo " ✅ build/Debug/signed/stub_{a,b}_*.bin готовы — прошивка через just host::flash-swd (см. PLAN.md)"
# =============================================================================
# ГРУППА: quality
# =============================================================================

View file

@ -1,7 +1,7 @@
# tools/hil
Python-окружение на базе [uv](https://docs.astral.sh/uv/) для HIL-тестов,
GDB-сервера отладки и SWD-прошивки через `flash_swd.py`.
Python-окружение на базе [uv](https://docs.astral.sh/uv/) для HIL-тестов и
отладки MIMXRT1052 через pyOCD (SWD: загрузка ELF в RAM, GDB-сервер).
Запускается на **хост-машине** — не внутри devcontainer.
@ -11,26 +11,57 @@ GDB-сервера отладки и SWD-прошивки через `flash_swd.
```bash
tools/hil/
├── conftest.py — pytest-фикстуры (m5, loaded_<n>, uart_<n>)
├── conftest.py — pytest-фикстуры (m5, loaded_<n>, uart_<n>, usb_cdc_port, firmware_cdc)
├── env_config.py — конфигурация из os.environ / .env
├── pyocd_utils.py — FLEXRAM init, ELF loader, run_from_vectors
├── pyocd_utils.py — FLEXRAM init, ELF loader, run_from_vectors (использует conftest.py и load_and_run.py)
├── load_and_run.py — CLI-утилита: загрузить ELF в RAM вручную
├── 01_test_uart.py — HIL тест bsp_uart_host (без стенда)
├── 02_test_opto.py — HIL тест bsp_opto (через M5StampPLC)
├── 01_test_uart.py — bsp_uart_host, без внешнего стенда
├── 02_test_opto.py — bsp_opto через реле M5StampPLC
├── 03_test_can.py — bsp_can, M5StampPLC как второй узел CAN-шины
├── 04_test_button.py — bsp_button, интерактивный (кнопки нажимает оператор)
├── 05_test_usb_cdc.py — bsp_usb_cdc, требует физический USB-порт таргета
├── 06_test_firmware_can.py — test_can внутри firmware_test (протокол v2 через CDC)
├── 06_test_firmware_opto.py — test_opto внутри firmware_test (протокол v2 через CDC)
├── m5/
│ ├── agent.py — MicroPython агент на M5StampPLC (реле, входы, CAN)
│ ├── agent.py — MicroPython-агент на M5StampPLC (реле, входы, CAN)
│ ├── cli.py — интерактивный CLI для ручного тестирования стенда и таргета
│ ├── power.py — управление питанием таргета (RLY1) из командной строки
│ └── firmware/ — прошивки MicroPython для M5StampPLC
│ ├── v1.25/ — MicroPython 1.25 — используется (поддерживает CAN)
│ └── v1.27/ — MicroPython 1.27 — CAN не поддерживается, не использовать
│ ├── esp32s3_bl-v1.25.0_twai.bin — используется (поддерживает CAN)
│ └── esp32s3_bl-v1.27.0.bin — CAN не поддерживается, не использовать
├── pyproject.toml
└── uv.lock
```
> **MicroPython на M5StampPLC:** использовать прошивку из `m5/firmware/v1.25/`.
> В v1.27 модуль CAN недоступен — `agent.py` инициализирует CAN при старте,
> тесты с CAN не пройдут. Загрузить прошивку можно с помощью [утилиты](https://github.com/esp-rs/espflash)
> Числовой префикс в имени файла (`NN_test_*.py`, паттерн задан в
> `pyproject.toml``python_files`) фиксирует порядок диагностики стенда —
> от простого автономного теста к более сложным и зависящим от внешнего
> оборудования.
> **MicroPython на M5StampPLC:** использовать
> `m5/firmware/esp32s3_bl-v1.25.0_twai.bin`. В прошивке 1.27 модуль CAN
> недоступен — `agent.py` инициализирует CAN при старте, тесты с CAN не
> пройдут. Загрузить прошивку можно с помощью
> [espflash](https://github.com/esp-rs/espflash).
---
## Тестовые файлы
| Файл | Что проверяет | Внешний стенд | Маркер |
| --------------------------- | ------------------------------------------------- | ---------------------------------- | ------------- |
| `01_test_uart.py` | `bsp_uart_host` | нет (M5 только включает питание) | — |
| `02_test_opto.py` | `bsp_opto` | M5StampPLC (реле → оптопары) | — |
| `03_test_can.py` | `bsp_can` | M5StampPLC (второй узел CAN-шины) | — |
| `04_test_button.py` | `bsp_button` | нет, кнопки нажимает оператор | `interactive` |
| `05_test_usb_cdc.py` | `bsp_usb_cdc` | физический USB-порт таргета | `usb_vcom` |
| `06_test_firmware_can.py` | `test_can` внутри `firmware_test` (протокол v2) | M5StampPLC | `usb_vcom` |
| `06_test_firmware_opto.py` | `test_opto` внутри `firmware_test` (протокол v2) | M5StampPLC | `usb_vcom` |
Подробности о том, что именно проверяется и какие гарантии даёт каждый
тест — в `tests/target/*/README.md` рядом с соответствующей HIL-прошивкой
(`06_test_firmware_*.py` — исключение: они гоняют `test_can`/`test_opto`
внутри `firmware_test`, а не прошивки из `tests/target`).
---
@ -39,10 +70,11 @@ tools/hil/
Полное описание стека HIL-тестирования — в `docs/testing/hil/`:
| Документ | Содержимое |
| --------------------------------------------------------------- | ---------------------------------------------- |
| ------------------------------------------------------------------ | ----------------------------------------------- |
| [HIL_HOW_TO.md](../../docs/testing/hil/HIL_HOW_TO.md) | Как запускать HIL-тесты (пошагово) |
| [HIL_BENCH.md](../../docs/testing/hil/HIL_BENCH.md) | Стенд: оборудование, подключение, маппинг реле |
| [HIL_CREATE_TEST.md](../../docs/testing/hil/HIL_CREATE_TEST.md) | Как добавить новый HIL-тест |
| [HIL_FIXTURES.md](../../docs/testing/hil/HIL_FIXTURES.md) | Памятка по pytest-фикстурам применительно к HIL |
---
@ -52,15 +84,39 @@ tools/hil/
# Первый раз: установить зависимости
cd tools/hil && uv sync
# Заполнить .env в корне репозитория (порты MCU-Link и M5)
# Заполнить .env в корне репозитория (см. .env.example — порты MCU-Link и M5)
# Задеплоить агент на M5 (после изменений agent.py — повторить)
just host::m5-deploy
# devcontainer: собрать HIL ELF
# devcontainer: собрать HIL-прошивки
just build::build-hil
# хост: запустить тесты
just host::hil-run
just host::hil-uart # только UART тесты
just host::hil-opto # только opto тесты
just host::hil-run # все не интерактивные и не usb_vcom тесты
just host::hil-run-interactive # интерактивные (кнопки) — нужен оператор у стенда
just host::hil-uart
just host::hil-opto
just host::hil-can
just host::hil-button
just host::hil-usb-cdc
just host::hil-firmware-opto # test_opto через firmware_test (протокол v2)
just host::hil-firmware-can # test_can через firmware_test (протокол v2)
just host::hil-firmware # оба сразу
```
### Отладка без pytest
```bash
just host::debug-server # GDB-сервер pyOCD — оставить запущенным, подключаться из VSCode
just host::debug-list-targets # доступные builtin-таргеты pyOCD для MIMXRT
uv run python load_and_run.py firmware_test.elf # загрузить ELF в RAM вручную
```
### Работа со стендом M5StampPLC
```bash
just host::m5-scan # показать подключённые M5Stack-устройства
just host::m5-cli # интерактивный CLI для ручного тестирования агента
just host::m5-power on # управление питанием таргета через RLY1 (on|off)
```