From 580e5f50d9cfb23dc2cc1dfe350686c800502543 Mon Sep 17 00:00:00 2001 From: Ezra Maccabee Date: Thu, 9 Jul 2026 19:07:11 +0300 Subject: [PATCH] # bootloader: Phase 2 - checked and done --- CMakeLists.txt | 3 + CMakePresets.json | 9 + bsp/qspi_flash/src/qspi_flash.c | 34 ++- cmake/linker/MIMXRT1052xxxxx_mcuboot_slot.ld | 130 ++++++++++ firmware/bootloader/CMakeLists.txt | 17 +- firmware/bootloader/PLAN.md | 201 ++++++++++++++- .../mcuboot_port/bootutil_sources.cmake | 26 +- .../mcuboot_port/flash_map_backend.c | 237 ++++++++++++++++++ firmware/bootloader/src/boot_select.c | 61 +++++ firmware/bootloader/src/boot_select.h | 21 ++ firmware/bootloader/src/main.c | 38 ++- firmware/bootloader/test_stub/CMakeLists.txt | 49 ++++ .../test_stub/HARDWARE_VERIFICATION_PHASE2.md | 120 +++++++++ firmware/bootloader/test_stub/main.c | 34 +++ just/build.just | 29 +++ tools/hil/README.md | 110 ++++++-- 16 files changed, 1049 insertions(+), 70 deletions(-) create mode 100644 cmake/linker/MIMXRT1052xxxxx_mcuboot_slot.ld create mode 100644 firmware/bootloader/mcuboot_port/flash_map_backend.c create mode 100644 firmware/bootloader/src/boot_select.c create mode 100644 firmware/bootloader/src/boot_select.h create mode 100644 firmware/bootloader/test_stub/CMakeLists.txt create mode 100644 firmware/bootloader/test_stub/HARDWARE_VERIFICATION_PHASE2.md create mode 100644 firmware/bootloader/test_stub/main.c diff --git a/CMakeLists.txt b/CMakeLists.txt index 84c4c8f..3cd41cc 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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() # ----------------------------------------------------------------------------- diff --git a/CMakePresets.json b/CMakePresets.json index 51d9582..b9e8c0b 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -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", diff --git a/bsp/qspi_flash/src/qspi_flash.c b/bsp/qspi_flash/src/qspi_flash.c index 3a0e359..07747c7 100644 --- a/bsp/qspi_flash/src/qspi_flash.c +++ b/bsp/qspi_flash/src/qspi_flash.c @@ -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; diff --git a/cmake/linker/MIMXRT1052xxxxx_mcuboot_slot.ld b/cmake/linker/MIMXRT1052xxxxx_mcuboot_slot.ld new file mode 100644 index 0000000..d4a1080 --- /dev/null +++ b/cmake/linker/MIMXRT1052xxxxx_mcuboot_slot.ld @@ -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") +} diff --git a/firmware/bootloader/CMakeLists.txt b/firmware/bootloader/CMakeLists.txt index 168d88e..854ee9f 100644 --- a/firmware/bootloader/CMakeLists.txt +++ b/firmware/bootloader/CMakeLists.txt @@ -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, ограниченным бюджетом diff --git a/firmware/bootloader/PLAN.md b/firmware/bootloader/PLAN.md index 721e27c..8f8ecd1 100644 --- a/firmware/bootloader/PLAN.md +++ b/firmware/bootloader/PLAN.md @@ -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/`, diff --git a/firmware/bootloader/mcuboot_port/bootutil_sources.cmake b/firmware/bootloader/mcuboot_port/bootutil_sources.cmake index 7ff1b67..0ce9993 100644 --- a/firmware/bootloader/mcuboot_port/bootutil_sources.cmake +++ b/firmware/bootloader/mcuboot_port/bootutil_sources.cmake @@ -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") - set_source_files_properties(${MCUBOOT_BOOTUTIL_SOURCES} - PROPERTIES COMPILE_OPTIONS - "-fno-sanitize=address,undefined") + list(APPEND MCUBOOT_VENDORED_COMPILE_OPTIONS -fno-sanitize=address,undefined) endif() +set_source_files_properties(${MCUBOOT_BOOTUTIL_SOURCES} + PROPERTIES COMPILE_OPTIONS + "${MCUBOOT_VENDORED_COMPILE_OPTIONS}") diff --git a/firmware/bootloader/mcuboot_port/flash_map_backend.c b/firmware/bootloader/mcuboot_port/flash_map_backend.c new file mode 100644 index 0000000..1e0e9f4 --- /dev/null +++ b/firmware/bootloader/mcuboot_port/flash_map_backend.c @@ -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 + +#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 может только сбрасывать биты 1→0, + * запись 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; +} diff --git a/firmware/bootloader/src/boot_select.c b/firmware/bootloader/src/boot_select.c new file mode 100644 index 0000000..879a92c --- /dev/null +++ b/firmware/bootloader/src/boot_select.c @@ -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); /* при успехе не возвращается */ +} diff --git a/firmware/bootloader/src/boot_select.h b/firmware/bootloader/src/boot_select.h new file mode 100644 index 0000000..e69c37f --- /dev/null +++ b/firmware/bootloader/src/boot_select.h @@ -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_ */ diff --git a/firmware/bootloader/src/main.c b/firmware/bootloader/src/main.c index 01b5ec6..855ca43 100644 --- a/firmware/bootloader/src/main.c +++ b/firmware/bootloader/src/main.c @@ -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 + * Последовательность старта: + * 1. board_hw_init() — тактирование, MPU, кэш, пины + * 2. bsp_led_init() — оба LED выключены + * 3. bsp_tick_init() — SysTick 1 мс + * 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); diff --git a/firmware/bootloader/test_stub/CMakeLists.txt b/firmware/bootloader/test_stub/CMakeLists.txt new file mode 100644 index 0000000..c16f87b --- /dev/null +++ b/firmware/bootloader/test_stub/CMakeLists.txt @@ -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 $ + ${CMAKE_BINARY_DIR}/${NAME}.bin + COMMAND ${CMAKE_SIZE} $ + 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) diff --git a/firmware/bootloader/test_stub/HARDWARE_VERIFICATION_PHASE2.md b/firmware/bootloader/test_stub/HARDWARE_VERIFICATION_PHASE2.md new file mode 100644 index 0000000..ba923d8 --- /dev/null +++ b/firmware/bootloader/test_stub/HARDWARE_VERIFICATION_PHASE2.md @@ -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 жив) | + +--- diff --git a/firmware/bootloader/test_stub/main.c b/firmware/bootloader/test_stub/main.c new file mode 100644 index 0000000..4699d91 --- /dev/null +++ b/firmware/bootloader/test_stub/main.c @@ -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); + } +} diff --git a/just/build.just b/just/build.just index 58f2f95..2c6a1c2 100644 --- a/just/build.just +++ b/just/build.just @@ -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 # ============================================================================= diff --git a/tools/hil/README.md b/tools/hil/README.md index c6ca913..3c91deb 100644 --- a/tools/hil/README.md +++ b/tools/hil/README.md @@ -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_, uart_) -├── env_config.py — конфигурация из os.environ / .env -├── pyocd_utils.py — FLEXRAM init, ELF loader, run_from_vectors -├── load_and_run.py — CLI-утилита: загрузить ELF в RAM вручную -├── 01_test_uart.py — HIL тест bsp_uart_host (без стенда) -├── 02_test_opto.py — HIL тест bsp_opto (через M5StampPLC) +├── conftest.py — pytest-фикстуры (m5, loaded_, uart_, usb_cdc_port, firmware_cdc) +├── env_config.py — конфигурация из os.environ / .env +├── 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 — 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) -│ ├── cli.py — интерактивный CLI для ручного тестирования стенда и таргета -│ ├── power.py — управление питанием таргета (RLY1) из командной строки -│ └── firmware/ — прошивки MicroPython для M5StampPLC -│ ├── v1.25/ — MicroPython 1.25 — используется (поддерживает CAN) -│ └── v1.27/ — MicroPython 1.27 — CAN не поддерживается, не использовать +│ ├── agent.py — MicroPython-агент на M5StampPLC (реле, входы, CAN) +│ ├── cli.py — интерактивный CLI для ручного тестирования стенда и таргета +│ ├── power.py — управление питанием таргета (RLY1) из командной строки +│ └── firmware/ — прошивки MicroPython для M5StampPLC +│ ├── 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`). --- @@ -38,11 +69,12 @@ 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_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) ```