# bootloader: Phase 3 - checked and done 580e5f5

This commit is contained in:
Dmitry Akimov 2026-07-10 15:02:18 +03:00
parent 580e5f50d9
commit 17698ce109
36 changed files with 2562 additions and 818 deletions

View file

@ -160,7 +160,9 @@
"uart_host_mock_example",
"test_ring_buffer",
"test_timeout_pattern",
"test_mcuboot_boot_select"
"test_mcuboot_boot_select",
"test_slot_version",
"test_update_policy"
]
},
{
@ -180,7 +182,9 @@
"uart_host_mock_example",
"test_ring_buffer",
"test_timeout_pattern",
"test_mcuboot_boot_select"
"test_mcuboot_boot_select",
"test_slot_version",
"test_update_policy"
]
},
{

View file

@ -49,7 +49,8 @@ static uint32_t get_usdhc1_src_clock_hz(void)
}
/*
* Управление питанием карты: GPIO1[19] (SdPwr), active-high.
* Управление питанием карты: GPIO1[19] (SdPwr). Регистрируется как
* usrParam.pwr SDK дёргает её из SD_SetCardPower().
*/
static void sd_power_control(bool enable)
{
@ -123,10 +124,20 @@ static void sd_pin_config(uint32_t freq)
IOMUXC_SetPinConfig(IOMUXC_GPIO_SD_B0_03_USDHC1_DATA1, pad);
IOMUXC_SetPinConfig(IOMUXC_GPIO_SD_B0_04_USDHC1_DATA2, pad);
IOMUXC_SetPinConfig(IOMUXC_GPIO_SD_B0_05_USDHC1_DATA3, pad);
/* CD_B в GPIO-режиме: подтяжка вверх + hysteresis для стабильного уровня. */
IOMUXC_SetPinConfig(IOMUXC_GPIO_B1_12_GPIO2_IO28,
IOMUXC_SW_PAD_CTL_PAD_PKE_MASK | IOMUXC_SW_PAD_CTL_PAD_PUE_MASK |
IOMUXC_SW_PAD_CTL_PAD_HYS_MASK | IOMUXC_SW_PAD_CTL_PAD_PUS(1));
/*
* CD_B pad config байт-в-байт как TFT_BOOTLOADER::BOARD_SD_Pin_Config()
* (board/sdmmc_config.c: IOMUXC_SetPinConfig(..., 0x10B0U)), не "разумная
* по умолчанию" альтернатива. Отличие от прежней версии здесь: PKE=1 но
* PUE=0 это KEEPER, не активная подтяжка (PUS игнорируется в этом
* режиме); HYS выключен. Прежняя версия (активная 47к подтяжка вверх +
* hysteresis) не была проверена на этой плате и не объяснила устойчивый
* ложный "card present" без карты на реальном железе (Фаза 3, симптом 1,
* см. DEBUG_LOG_PHASE3_SD.md) pinmux сам по себе (GPIO2_IO28) это не
* лечит, раз симптом воспроизводится и после его отката.
*/
IOMUXC_SetPinConfig(IOMUXC_GPIO_B1_12_GPIO2_IO28, IOMUXC_SW_PAD_CTL_PAD_PKE_MASK |
IOMUXC_SW_PAD_CTL_PAD_SPEED(2U) |
IOMUXC_SW_PAD_CTL_PAD_DSE(6U));
}
/* ---------------------------------------------------------------------------
@ -166,7 +177,30 @@ void BOARD_SD_Config(void *card, sd_cd_t cd, uint32_t host_irq_priority, void *u
/* --- GPIO питания --- */
sd_power_init();
/* CD_B: переводим в GPIO2_IO28 и настраиваем вход */
/*
* CD_B (GPIO_B1_12 / physical D13) на этой плате детект работает через
* DVA механизма в разные моменты (см. DEBUG_LOG_PHASE3_SD.md, разрешение
* от 2026-07-10):
*
* 1. НАШ гейт bsp_sd_is_inserted() (bsp/sd/src/sd.c) читает USDHC
* PRES_STATE.CINST (USDHC_GetPresentStatusFlags). Это и есть настоящий
* фикс симптома 1: на пустом слоте BOARD_SD_Config() ещё не вызывался,
* пин остаётся на USDHC1_CD_B (замаплен в BOARD_InitPins()), и PRSSTAT
* отражает реальность корректно гейт стабильно возвращает false, и
* блокирующий SD_PollingCardInsert() без карты просто не достигается.
* 2. Внутренний детект SDK (SD_PollingCardInsert внутри f_mount) через
* GPIO-callback sd_card_detect_gpio() (GPIO_PinRead(GPIO2, 28)). Он
* достигается только ПОСЛЕ того, как гейт уже подтвердил карту, то
* есть уже после этого IOMUXC_SetPinMux ниже тогда пин на GPIO2_IO28
* и GPIO-чтение корректно.
*
* Отсюда remux ниже на GPIO2_IO28: он нужен именно для (2). Пин остаётся
* эксклюзивным (одна альт-функция разом), поэтому (1) и (2) физически
* работают в разные моменты на разной маршрутизации одного пина это
* подтверждено на железе (все 5 сценариев Фазы 3 пройдены), но хрупко:
* см. "латентная хрупкость re-scan" в DEBUG_LOG_PHASE3_SD.md и там же
* рекомендованная консолидация на единый механизм (PRSSTAT везде).
*/
IOMUXC_SetPinMux(IOMUXC_GPIO_B1_12_GPIO2_IO28, 0U);
const gpio_pin_config_t cd_cfg = {
.direction = kGPIO_DigitalInput,

View file

@ -116,8 +116,7 @@
* @brief Размер читаемого буфера для однобайтных SR-команд.
*
* FlexSPI FIFO работает минимальными единицами в 4 байта. Читаем 4 байта,
* используем только byte[0]. (Про размер watermark-юнита IPRXFSTS.FILL
* см. QSPI_WM_UNIT_BYTES и комментарий в qspi_read_tail().)
* используем только byte[0].
*/
#define SR_READ_LEN 4U
@ -339,13 +338,6 @@ AT_QUICKACCESS_SECTION_CODE(static status_t qspi_read_tail(uint8_t *p_dst, uint3
while (!done)
{
/* 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_UNITS * QSPI_WM_UNIT_WORDS) >= WORDS_NEEDED)
@ -516,19 +508,7 @@ 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))
{
/* 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-байт молча дренируется вместе со словом и отбрасывается. */
/* IDATSZ округляем вверх до кратного QSPI_RFDR_WORD_BYTES (4) */
const uint32_t IDATSZ_ALIGNED =
(data_len + (QSPI_RFDR_WORD_BYTES - 1U)) & ~(QSPI_RFDR_WORD_BYTES - 1U);

View file

@ -26,9 +26,10 @@ bsp_status_t bsp_sd_init(void);
bsp_status_t bsp_sd_deinit(void);
/*
* Проверить физическое наличие карты через регистр USDHC PRSSTAT.
* Не требует предварительного вызова bsp_sd_init().
* Включает тактирование USDHC1 на время чтения регистра.
* Проверить физическое наличие карты через USDHC PRES_STATE.CINST
* (USDHC_GetPresentStatusFlags). Включает тактирование USDHC1 на время
* чтения; не требует предварительного вызова bsp_sd_init(). Корректно
* пока пин D13 замаплен на USDHC1_CD_B.
*/
bool bsp_sd_is_inserted(void);

View file

@ -5,15 +5,11 @@
#include "bsp/sd.h"
#include "fsl_sd.h"
#include "fsl_usdhc.h" /* USDHC_Reset — аппаратный сброс FIFO/state machine */
#include "sdmmc_config.h" /* BOARD_SD_Config, BOARD_SDMMC_SD_HOST_BASEADDR */
#include "fsl_usdhc.h"
#include "sdmmc_config.h"
#include <string.h>
#include <string.h> /* memset */
/* ---------------------------------------------------------------------------
* Глобальный дескриптор карты нужен SDK-стеку (передаётся по указателю
* в BOARD_SD_Config и sd_disk_initialize через g_sd).
* Объявлен без static fsl_sd_disk.c ссылается на него как extern sd_card_t g_sd.
* ------------------------------------------------------------------------- */
extern sd_card_t g_sd;
/* ---------------------------------------------------------------------------
@ -37,7 +33,8 @@ static void ensure_host_configured(void)
{
return;
}
/* cd=NULL, userData=NULL: CD управляется хостом через PRSSTAT */
/* cd=NULL, userData=NULL: детект — GPIO-callback внутри BOARD_SD_Config(),
* не внешний callback сюда (см. bsp_sd_is_inserted() тот же механизм). */
BOARD_SD_Config(&g_sd, NULL, BOARD_SDMMC_SD_HOST_IRQ_PRIORITY, NULL);
g_s_host_configured = true;
}
@ -54,19 +51,8 @@ bsp_status_t bsp_sd_init(void)
}
/*
* Аппаратный сброс USDHC FIFO + command/data state machine ПЕРЕД
* повторной инициализацией. Без этого non-blocking host driver SDK
* (fsl_sdmmc_host.c) может остаться в состоянии "ожидание завершения
* предыдущей транзакции" после SD_HostDeinit() на прошлом прогоне —
* физическая транзакция уже умерла вместе с deinit, но внутренний
* флаг ожидания interrupt остаётся выставленным, и следующий f_mount()
* блокируется навсегда в ожидании события, которое никогда не придёт.
*
* USDHC_Reset с маской kUSDHC_ResetAll сбрасывает контроллер на
* регистровом уровне, не полагаясь на состояние, оставленное
* предыдущей сессией. Безопасно вызывать даже при первом запуске
* базовый адрес уже доступен через BOARD_SDMMC_SD_HOST_BASEADDR
* (clock на этот момент должен быть включён, см. ниже).
* Аппаратный сброс USDHC FIFO + command/data state machine перед
* повторной инициализацией.
*/
CLOCK_EnableClock(kCLOCK_Usdhc1); /* тактирование нужно ДО сброса регистров */
USDHC_Reset(BOARD_SDMMC_SD_HOST_BASEADDR, kUSDHC_ResetAll, 100U);
@ -80,12 +66,20 @@ bsp_status_t bsp_sd_init(void)
(void) memset(&g_sd, 0, sizeof(g_sd));
g_s_host_configured = false; /* форсируем повторный BOARD_SD_Config ниже */
ensure_host_configured(); /* только BOARD_SD_Config — заполняет g_sd */
ensure_host_configured(); /* BOARD_SD_Config — заполняет g_sd, включая usrParam.pwr */
/*
* Полный init (host + card) происходит в sd_disk_initialize SD_Init,
* который вызывается из f_mount disk_initialize.
*/
if (SD_HostInit(&g_sd) != kStatus_Success)
{
return BSP_ERR_HW;
}
if (SD_PollingCardInsert(&g_sd, kSD_Inserted) != kStatus_Success)
{
return BSP_ERR_HW;
}
SD_SetCardPower(&g_sd, false);
SD_SetCardPower(&g_sd, true);
g_s_initialized = true;
return BSP_OK;
@ -101,12 +95,6 @@ bsp_status_t bsp_sd_deinit(void)
SD_HostDeinit(&g_sd);
SD_SetCardPower(&g_sd, false);
/*
* Дополнительный аппаратный сброс сразу после deinit гарантирует,
* что FIFO и state machine USDHC не останутся в промежуточном
* состоянии независимо от того, что делает (или не делает)
* SD_HostDeinit() из SDK на уровне регистров.
*/
USDHC_Reset(BOARD_SDMMC_SD_HOST_BASEADDR, kUSDHC_ResetAll, 100U);
g_s_initialized = false;
@ -116,6 +104,8 @@ bsp_status_t bsp_sd_deinit(void)
bool bsp_sd_is_inserted(void)
{
return GPIO_PinRead(BOARD_SDMMC_SD_CD_GPIO_BASE, BOARD_SDMMC_SD_CD_GPIO_PIN) ==
BOARD_SDMMC_SD_CD_INSERT_LEVEL;
CLOCK_EnableClock(kCLOCK_Usdhc1);
uint32_t ps = USDHC_GetPresentStatusFlags(BOARD_SDMMC_SD_HOST_BASEADDR);
return (ps & kUSDHC_CardInsertedFlag) != 0U;
}

View file

@ -72,7 +72,51 @@ tft_app.
---
## 5. Ссылки
## 5. Ограничения на runtime-доступ к flash из tft_app (Direct-XIP) — решено
Boot-стратегия (Direct-XIP и для bootloader, и для tft_app — см. `firmware/bootloader/PLAN.md`)
пересмотрена и подтверждена в обсуждении Фазы 3, с учётом требований tft_app: (1) кеширование
спрайтов в SDRAM, (2) хранение и изменение настроек во flash, (3) проигрывание WAV с flash.
**Найденный механизм риска** — [bsp/qspi_flash/README.md](../../bsp/qspi_flash/README.md), раздел
"XIP-безопасность": любая IP-команда FlexSPI блокирует AHB-путь — если в этот момент CPU фетчит
инструкцию из Flash, происходит HardFault. Защита в `bsp_qspi_flash` — IRQ lock на всё время операции
(стирание сектора ~45 мс, блока 64 КБ ~150 мс). Для однопоточного блокирующего bootloader'а это не
проблема; для tft_app (FreeRTOS, конкурентные задачи) любая такая операция глушит **все** прерывания
в системе на своё время, включая аудио DMA-колбэк.
**Почему не перешли на `MCUBOOT_RAM_LOAD`** (альтернатива, устраняющая конфликт полностью — код
перестаёт исполняться через flash-AHB вообще): в вендоренном bootutil этот режим **не имеет аналога
`MCUBOOT_DIRECT_XIP_REVERT`** — `boot_select_or_erase()` (`copy_done`/`image_ok`, автоматический откат
неподтверждённого образа) гейтится `#if defined(MCUBOOT_DIRECT_XIP) && defined(MCUBOOT_DIRECT_XIP_REVERT)`
в `loader.c` и не вызывается в ветке `MCUBOOT_RAM_LOAD`. Переход потерял бы anti-brick гарантию,
аппаратно проверенную в Фазе 2 (сценарий 5 её чек-листа), без готовой замены в самом bootutil —
пришлось бы реализовывать такой механизм самостоятельно, без прецедента.
**Референс для калибровки** — легаси-реализация (`TFT8_RX_wOS`, исполняется из SDRAM):
её `audio_player.c` стримит блоками `MONO_READ_SIZE=256` Б через кольцевой буфer `BUFFER_NUM=3` — на
руках держится всего ~8.7 мс аудио (128 сэмплов / 44100 Гц × 3 блока). Этого достаточно при исполнении
из RAM (конкуренции за flash-AHB нет вообще), но недостаточно при Direct-XIP.
**Решение — Direct-XIP остаётся, при двух обязательных ограничениях для tft_app** (переносятся в его
будущий план, не в план bootloader'а):
1. **Аудио — глубоко буферизировать в SDRAM**, не стримить малыми порциями, как в легаси-версии.
Целевая глубина — заведомо больше худшей flash-операции (например, ≥300 мс — это ~26 КБ моно PCM16
44.1 кГц, ничто относительно 32 МБ SDRAM). При такой глубине редкая конкурентная запись настроек не
создаёт слышимого дропаута.
2. **Любое чтение/запись flash, способное совпасть по времени с другой flash-операцией, обязано идти
через защищённый IP-command драйвер** (аналог `bsp_qspi_read()`: ITCM + IRQ lock, как уже сделано в
`bsp_qspi_flash`), а не через сырой XIP `memcpy`, как в легаси `settings_manager.c` (там это было
безопасно только потому что код исполнялся из RAM). Актуально для будущей FatFS-прослойки над
областью ассетов (см. п.4 выше) — она должна использовать тот же паттерн, что `port/fatfs/sd`
использует поверх `bsp_sd`.
3. Запись настроек остаётся редкой, явной, инициированной пользователем (не периодический автосейв) —
короткий блокирующий фриз (десятки мс) на сохранение ожидаем и допустим в UI.
---
## 6. Ссылки
- [BOOT_FLAGS.md](BOOT_FLAGS.md) — XIP/DCD/сценарии исполнения кода.
- [HAB_GUIDE.md](HAB_GUIDE.md) — подпись bootloader (HAB) vs подпись образов tft_app (`imgtool`).

View file

@ -17,12 +17,18 @@ configure_file("${CMAKE_CURRENT_SOURCE_DIR}/src/version.h.in"
# Фаза 2.
include(${CMAKE_CURRENT_SOURCE_DIR}/mcuboot_port/bootutil_sources.cmake)
# bootloader_fatfs — bare-metal FatFS для чтения TFT_APP.BIN с SD (Фаза 3).
add_subdirectory(fatfs)
add_executable(
${TARGET_NAME}
src/main.c
src/cli.c
src/protocol.c
src/boot_select.c
src/update_policy.c
src/slot_version.c
src/sd_update.c
mcuboot_port/flash_map_backend.c
mcuboot_port/keys.c
${MCUBOOT_BOOTUTIL_SOURCES}
@ -40,11 +46,13 @@ target_compile_definitions(
__STARTUP_INITIALIZE_NONCACHEDATA)
# -----------------------------------------------------------------------------
# Зависимости. bsp_button (downgrade-override) добавится в Фазе 3.
# Зависимости. bsp_button — downgrade-override (удержание BSP_BUTTON_1).
# bootloader_fatfs — чтение TFT_APP.BIN с SD (Фаза 3, firmware/bootloader/fatfs/).
# -----------------------------------------------------------------------------
target_link_libraries(${TARGET_NAME} PRIVATE bsp_board bsp_led bsp_tick
bsp_usb_cdc bsp_qspi_flash
bsp_boot_xip_no_dcd)
bsp_boot_xip_no_dcd bsp_button
bootloader_fatfs)
# -----------------------------------------------------------------------------
# Linker script — вариант flexspi_nor с m_text, ограниченным бюджетом

View file

@ -0,0 +1,507 @@
# Фаза 3 — аппаратная верификация SD-пути: лог (РАЗРЕШЁН 2026-07-10)
**✅ РАЗРЕШЕНО (2026-07-10, раунд 3).** Настоящий фикс детекта — чтение **USDHC PRES_STATE.CINST**
(`USDHC_GetPresentStatusFlags` + `kUSDHC_CardInsertedFlag`) в `bsp_sd_is_inserted()`, а не GPIO. Это
подход (a) из самого первого лога, который тогда откатили как «CINST=0 даже со вставленной картой» —
но откатили ошибочно: CINST=0 читался потому, что в тот момент пин ещё не был на `USDHC1_CD_B`.
Все 5 сценариев Фазы 3 пройдены на железе. Детали — «Обновление 2026-07-10 (раунд 3)» ниже. Разделы
раундов 12 сохранены как история расследования (их выводы частично опровергнуты раундом 3 — см.
явные пометки).
<details><summary>История расследования (раунды 12, выводы частично опровергнуты)</summary>
**2026-07-10, раунд 1**: pinmux CD откачен на `GPIO2_IO28` + явный power-cycle карты. Аппаратно
проверено — симптом 1 (SD не вставлена) **не устранён**, симптом 3→4 похоже устранён. См.
«Обновление 2026-07-10» ниже. **⚠️ Вывод «GPIO2_IO28 — верный pinmux» опровергнут раундом 3: для
работающего PRSSTAT-чтения пин нужен на `USDHC1_CD_B`.**
**2026-07-10, раунд 2**: по прямому запросу пользователя — полное выравнивание SD-стека на
`TFT_BOOTLOADER`, не только pinmux. Три дополнительных расхождения найдены и закрыты (CD pad-config
байт-в-байт, SD_PWR side-effect в детекте, explicit HostInit/PollingCardInsert/power-cycle). См.
«Обновление 2026-07-10 (раунд 2)» ниже. **⚠️ CD pad-config и explicit-init остались в дереве и не
вредят; SD_PWR side-effect в `bsp_sd_is_inserted()` заменён финальным PRSSTAT-чтением раунда 3.**
</details>
Этот файл — снимок состояния расследования на момент передачи в отдельный тред. Код Фазы 3
(`update_policy`, `slot_version`, `sd_update`) спроектирован и host-протестирован отдельно (см.
[PLAN.md](PLAN.md), раздел «Фаза 3») и сам по себе не пересматривается. Проблема — в аппаратном
bring-up SD/USDHC на конкретной плате, всплывшая только на реальном железе.
## Симптомы (в порядке обнаружения)
1. **Чистая плата, оба слота пусты, SD карта отсутствует** → плата зависала внутри
`run_update → ... → micro_sd_disk_initialize → SD_PollingCardInsert`.
2. После правок ниже (текущее состояние pinmux, см. «Файлы» ниже) то же исходное условие (нет SD,
пустые слоты) стало вместо зависания давать **падение в `DefaultISR`**. Стек вызовов (VSCode
Cortex-Debug):
```
main → sd_update_check → run_update → bsp_sd_deinit → SD_HostDeinit →
SDMMCHOST_Deinit → SDMMCHOST_Reset → USDHC_SelectVoltage → <signal handler called> → DefaultISR
```
3. **SD вставлена при старте** → доходит до `jump_to_image` и успешно стартует (под отладчиком).
После этого: выключить питание, вынуть SD, включить снова → та же прошивка (slot-заглушка) **не
стартует** (возврат к симптому 1/2).
4. **SD оставлена в слоте**, повторный power cycle сразу после успешного случая (3) (то есть SD
физически всё ещё вставлена) → **зависание в `OSA_SemaphoreWait`**, отдельный стек:
```
main → sd_update_check → run_update → f_mount → mount_volume → disk_initialize →
microsd_disk_initialize → sd_disk_initialize → SD_CardInit → sdcard_init → SD_ReadStatus →
SD_Transfer → SDMMCHOST_TransferFunction → SDMMC_OSAEventWait → OSA_SemaphoreWait
```
Это происходит **до** попытки прыжка — на самой первой инициализации карты в этой сессии
питания. Прерывание, которое должно отпустить семафор (сигнал завершения транзакции), судя по
всему, не приходит.
Симптомы 2 и 4 — из одного и того же похода тестирования, оба неразобраны на момент передачи.
## Что уже пробовали (в хронологическом порядке)
| # | Правка | Результат |
|---|---|---|
| a | `bsp_sd_is_inserted()` / внутренний callback переведены на USDHC `PRES_STATE.CINST` вместо GPIO | Неверно: `CINST` читался как 0 даже при физически вставленной карте (вероятно, пин на тот момент не был замаплен на альт-функцию USDHC). Откачено. |
| b | `cleanup_before_jump()` в `boot_select.c` — отключение всех NVIC IRQ + `SysTick->CTRL=0` + `SCB->VTOR` перед прыжком | Пользователь подтвердил на железе: **«Никаких изменений. Всё также»**. Оставлено в коде (не вредит, независимо подтверждено паттерном `jump_to_application()` в референсе `TFT_BOOTLOADER`), но не является фиксом текущего бага. |
| c | Детект возвращён на GPIO-чтение; pinmux CD-пина переключён с `GPIO2_IO28` на `USDHC1_CD_B` (основание — референс `TFT7_RX_wOS`) | Дало симптом 2 (падение в `DefaultISR` вместо зависания) — то есть что-то изменилось, но проблема не ушла. Появилась **третья**, ещё более авторитетная референсная кодовая база (`TFT_BOOTLOADER`), которая прямо противоречит этому выбору pinmux (см. ниже) — **не проверено на железе**, стоит откатить в первую очередь. |
## Три референсных проекта — что каждый показывает про SD/CD
Все три, со слов пользователя, работали на **той же физической плате**. Расхождения между ними и
есть ядро проблемы.
1. **`TFT8_RX_wOS`** (`source/hal/sd.c/h`) — `bsp_sd_is_inserted()` через
`USDHC_GetPresentStatusFlags()` + `kUSDHC_CardInsertedFlag` (аппаратный регистр USDHC, не GPIO).
2. **`TFT7_RX_wOS`** (полный board-код) — CD-пин замаплен на `USDHC1_CD_B` (`board/pin_mux.c`), но
**читается** через обычный `GPIO_PinRead()` (`BOARD_SDCardGetDetectStatus()` в `board/sdmmc_config.c`) —
то есть USDHC-альтфункция выбрана, а не используется для самого чтения детекта.
3. **`TFT_BOOTLOADER`** (собственный, предыдущий, явно описанный пользователем как «без нареканий
работал на этой плате») — CD-пин замаплен на **простой `GPIO2_IO28`** (никакой USDHC-альтфункции
вообще), читается тем же `GPIO_PinRead`. Дополнительно, в `is_sdcard_present()`/`init_sd()`, есть
явное управление питанием карты: `SD_PWR_PIN` (GPIO1/19) выключается/включается в зависимости от
результата детекта, и явный **power-cycle карты**`SD_SetCardPower(&g_sd, false)`
`SD_SetCardPower(&g_sd, true)` — перед тем, как что-либо ещё делается с картой. Собственный
`jump_to_application()` тоже независимо переставляет `SCB->VTOR` перед прыжком (подтверждает
правку (b) выше, но никак не связано с текущим багом).
**Проблема**: (2) и (3) прямо противоречат друг другу по pinmux того же физического пина
(`GPIO_B1_12` / физический пин D13) — мультиплексирование эксклюзивно, физически не может быть верно
одновременно и то, и другое. (3) — самый доверенный источник (собственный, явно проверенный продакшн
на этой же плате, не просто «легаси-референс»), и именно его выбор (`GPIO2_IO28`, без USDHC-альтфункции)
**сейчас не применён** в дереве — стоит откатить в первую очередь.
## Текущее состояние кода (на момент передачи)
`bsp/sd/src/sd.c::bsp_sd_is_inserted()` — GPIO-чтение:
```c
return GPIO_PinRead(BOARD_SDMMC_SD_CD_GPIO_BASE, BOARD_SDMMC_SD_CD_GPIO_PIN) ==
BOARD_SDMMC_SD_CD_INSERT_LEVEL;
```
`bsp/generated/sdmmc_config.c::BOARD_SD_Config()` — pinmux **на `USDHC1_CD_B`** (см. выше — под
вопросом):
```c
IOMUXC_SetPinMux(IOMUXC_GPIO_B1_12_USDHC1_CD_B, 0U);
```
внутренний детект-callback — `sd_card_detect_gpio()` (GPIO, не USDHC-регистр) — сам механизм чтения
не менялся, менялся только pinmux.
`bsp/sd/src/sd.c::bsp_sd_init()`/`bsp_sd_deinit()` — **не делают explicit power-cycle карты**
(`SD_SetCardPower`) — в отличие от `TFT_BOOTLOADER::init_sd()`. `bsp_sd_init()` выставляет
`g_s_initialized = true` сразу после `BOARD_SD_Config()` (только конфигурация дескриптора хоста) —
**до** того, как реальный `SD_HostInit()` вообще вызывается (тот вызывается неявно позже, изнутри
`f_mount() → sd_disk_initialize() → SD_Init()`). Отсюда гипотеза по симптому 2 ниже.
`firmware/bootloader/src/sd_update.c::run_update()` — если `f_mount()` возвращает ошибку (не
зависает, а именно быстро проваливается), путь `(void) bsp_sd_deinit(); return;` (строка ~160)
вызывается напрямую, не через `cleanup:` — это ровно та точка, где произошло падение в симптоме 2.
## Рабочие гипотезы (не проверены на железе)
1. **Симптом 2 (DefaultISR, SD физически отсутствует)**: при текущем pinmux (`USDHC1_CD_B`)
`GPIO_PinRead()` того же физического пина может отдавать некорректное/неопределённое значение
(частый эффект на i.MX-подобных SoC — когда пин отдан альтернативной функции, вход GPIO-регистра
не гарантированно отражает реальный внешний уровень). Это дало бы **ложное срабатывание
"карта вставлена"** даже без карты → `sd_update_check()` пропускает наш собственный gate и
заходит в `run_update()``f_mount()` внутри тоже видит ложный "card present" (тот же callback)
→ пытается реальный протокол инициализации без карты → команды таймаутятся (не висят вечно, в
отличие от `SD_PollingCardInsert`) → `f_mount()` возвращает ошибку → `bsp_sd_deinit()` вызывается,
но `SD_HostInit()` либо не был вызван, либо card/voltage-состояние осталось в промежуточном виде
`SDMMCHOST_Reset()/USDHC_SelectVoltage()` работает с невалидным состоянием → падение.
**Проверка**: откатить pinmux на `GPIO2_IO28` (референс `TFT_BOOTLOADER`) и повторить сценарий
"нет SD, пустые слоты" — если ложное срабатывание уйдёт, `run_update()` вообще не должен
вызываться, и падение исчезнет как следствие.
2. **Симптом 4 (OSA_SemaphoreWait, SD физически присутствует)** — не про card-detect вообще, а про
реальный обмен данными (`SD_ReadStatus`/`SD_Transfer`) — прерывание завершения транзакции не
приходит. Кандидат на причину: отсутствие power-cycle карты перед стартом обмена (в отличие от
`TFT_BOOTLOADER::init_sd()`) — карта могла остаться в состоянии, унаследованном от **предыдущей**
успешной сессии (та же карта, тот же физический power rail, если он не сбрасывается при
`bsp_sd_deinit()`/между сессиями), и не отвечать на новый протокол инициализации так, как ожидает
SDK-стек. **Не проверялось**, требует добавления explicit `SD_SetCardPower(false)`
задержка → `SD_SetCardPower(true)` в `bsp_sd_init()` и повторного теста именно сценария «второй
power cycle подряд с той же картой».
3. Не проверено: действительно ли `bsp/qspi_flash`, `firmware_test`'ный стек SD (уже работающий в
продакшене через `test_usd.c`) отличается от bootloader-сценария именно временем между
power-on/детектом и первым обращением к карте — `firmware_test` ждёт explicit подтверждения
человека перед `usd_init()`, что даёт карте много времени "устояться" электрически; bootloader
делает это заметно быстрее после включения питания. Если гипотеза 1 не полностью объясняет
симптом 2, стоит проверить добавление небольшой выдержки после детекта перед `f_mount()`.
## Открытые вопросы для нового треда
> **РАЗРЕШЕНО 2026-07-10 (раунд 3).** Основной баг детекта закрыт PRSSTAT-чтением (см. секцию
> «Обновление 2026-07-10 (раунд 3)» выше). Пункты 12 ниже сохранены как история; пункт 3 —
> опровергнут; пункты 45 (devcontainer, watchdog) остаются актуальными как отдельные задачи.
> Плюс два новых нереализованных пункта-рекомендации из раунда 3: (6) консолидация детекта на
> единый механизм PRSSTAT (убрать латентную хрупкость re-scan), (7) ранний сэмпл кнопки даунгрейда.
1. ✅ **Применено 2026-07-10 (раунд 1).** Откатить pinmux CD на `GPIO2_IO28` (без `USDHC1_CD_B`) —
самый доверенный референс (`TFT_BOOTLOADER`) использует именно так. **Аппаратно проверено —
недостаточно само по себе**: симптом 1 (SD не вставлена) воспроизводится и с верным pinmux. См.
«Обновление 2026-07-10 (раунд 2)» — потребовалось довыровнять ещё и pad-config CD-пина.
2. ✅ **Применено 2026-07-10 (раунд 1).** Добавить explicit power-cycle SD-карты в `bsp_sd_init()`
(`SD_SetCardPower(false)` → задержка → `SD_SetCardPower(true)`), по образцу
`TFT_BOOTLOADER::init_sd()`. **Аппаратно похоже подтверждено** — симптом 3→4 (повторный power
cycle с картой в слоте) больше не зависает (~5 c вместо hang), хотя раунд 1 не изолировал, какой
именно из двух фиксов это дал.
3. Развести флаг `g_s_initialized` в `bsp_sd_init()`/`bsp_sd_deinit()` на два состояния — "хост
сконфигурирован" (`BOARD_SD_Config` вызван) vs "хост реально поднят" (`SD_HostInit` реально
отработал) — чтобы `bsp_sd_deinit()` не звал `SD_HostDeinit()`/`USDHC_Reset` на состоянии,
которое никогда полноценно не поднималось. **Не реализовано** — см. «Обновление 2026-07-10»:
source-level разбор показал, что в наблюдавшемся симптоме 2 это не изменило бы поведение
(`SD_HostInit()` к моменту падения уже отработал успешно). Понижено до фоновой гигиены.
4. Прогонять сборку/прошивку через devcontainer (стандартный путь проекта), не только через
локальный host-toolchain — держать в уме при следующей проверке (сама эта сессия строила и
прошивала с хоста напрямую).
5. После того как оба симптома закрыты и подтверждены на железе — реализовать аппаратный watchdog
(см. [PLAN.md](PLAN.md), раздел «Запланировано: аппаратный watchdog») **как отдельный шаг после**,
не вместо, диагностики первопричины — иначе watchdog будет просто маскировать/сбрасывать симптом
на каждом цикле.
## Обновление 2026-07-10 — четвёртый референс, source-level трассировка, два фикса применены
Пользователь предоставил **полный код** `TFT_BOOTLOADER` (ранее в этом логе упоминался только по
описанию) — `board/pin_mux.c`, `board/sdmmc_config.c`, `source/sd.c`, `source/gpio_setup.c`,
`source/main.c` и др. Это подтверждает и уточняет раздел «Три референсных проекта» выше, а не
меняет его выводы.
### Пинмукс CD — подтверждено, откачено
`TFT_BOOTLOADER/board/pin_mux.c`:
```c
IOMUXC_SetPinMux(IOMUXC_GPIO_B1_12_GPIO2_IO28, 0U);//SD check pin
```
Плоский `GPIO2_IO28`, без какой-либо альт-функции USDHC.
**Четвёртый источник**, найденный при подготовке фикса: NXP-овские
`sdk/boards/evkbimxrt1050/sdmmc_examples/*/pin_mux.c` (`sdcard_polling`, `sdcard_interrupt`,
`sdcard_freertos`, `sdcard_fatfs`, `mmccard_freertos` — уже вендорены в этом дереве) — **все**
используют `IOMUXC_GPIO_B1_12_GPIO2_IO28` для этого же физического пина (D13) на том же чипе
(MIMXRT1052/EVKB-семейство). Официальный NXP-референс для этой платы и этого пина ни разу не
использует `USDHC1_CD_B`.
Итого — четыре независимых указания в одну сторону (`TFT_BOOTLOADER`; `TFT7_RX_wOS`, который хоть и
маплит на `USDHC1_CD_B`, но ЧИТАЕТ через GPIO, то есть де-факто не использует USDHC-функцию пина;
NXP `sdmmc_examples`; и само нестабильное поведение на железе под `USDHC1_CD_B`, из-за которого
этот лог вообще начался).
**Применено**: `bsp/generated/sdmmc_config.c::BOARD_SD_Config()` — pinmux возвращён на
`IOMUXC_GPIO_B1_12_GPIO2_IO28`. Комментарии в этом файле и в `bsp/sd/src/sd.c::bsp_sd_is_inserted()`
(который раньше аргументировал ЗА `USDHC1_CD_B` задним числом, по результатам ещё не проверенного
на железе эксперимента (c)) переписаны под текущее состояние. Доктстринг `bsp_sd_is_inserted()` в
`bsp/sd/include/bsp/sd.h` тоже был стал устаревшим ещё раньше — утверждал чтение через регистр
USDHC PRSSTAT, оставшееся от давно откаченного эксперимента (a) — тоже исправлен.
**Не тронуто намеренно** — `bsp/generated/board/pin_mux.c` (генерируется MCUXpresso Config Tools,
в этой сессии не редактировался) по-прежнему содержит
`IOMUXC_SetPinMux(IOMUXC_GPIO_B1_12_USDHC1_CD_B, 0U)` внутри `BOARD_InitPins()`, а сам
`TFT_Board.mex` (строка ~134) содержит `<pin peripheral="USDHC1" signal="usdhc_cd_b" .../>` для
этого пина — маршрутизация на уровне самого Config Tools проекта всё ещё «неправильная».
Функционально это не баг сейчас: `board_hw_init() → BOARD_InitPins()` отрабатывает раньше, чем
`bsp_sd_init() → BOARD_SD_Config()`, и именно вызов из `sdmmc_config.c` (уже исправленный) — самый
последний перед любой операцией с картой, то есть он и определяет реальное состояние железа. Но
это мина для будущего: перегенерация пинов через Config Tools перезапишет `pin_mux.c` обратно на
`USDHC1_CD_B`, а конфликт останется незаметным (`sdmmc_config.c` продолжит его перекрывать) — пока
кто-то не тронет порядок инициализации или не сочтёт повторный вызов в `sdmmc_config.c`
дублирующим и не уберёт его. **Рекомендация на будущее**: при следующем открытии `TFT_Board.mex` в
Config Tools — перемаршрутизировать D13/`GPIO_B1_12` на `GPIO2.gpio_io[28]` вместо
`USDHC1.usdhc_cd_b`. Не блокирует текущую аппаратную проверку.
### Симптом 2 — механизм подтверждён трассировкой по исходникам SDK, не только гипотезой
Прочитан `sdk/middleware/sdmmc/sd/fsl_sd.c`:
- `SD_PollingCardInsert()` (используется для типа детекта `kSD_DetectCardByGpioCD`) — буквально
`do { ... } while (true)`, **без единого условия выхода по времени**. Если `cardDetected()`
(== `sd_card_detect_gpio()`, тот же нестабильный GPIO-read под `USDHC1_CD_B`) ни разу не даёт
связку значений, удовлетворяющую циклу, — зависание гарантированное и постоянное, не «иногда».
Ровно то же самое SDK-поведение отдельно задокументировано в `PLAN.md` под «Запланировано:
аппаратный watchdog» — тот же вывод, независимо подтверждён прямым чтением функции.
- `SD_Init()` **всегда** вызывает `SD_HostInit()` первым (`if (!card->isHostReady) { SD_HostInit(...); }`,
а `isHostReady` после `memset(&g_sd, 0, ...)` в `bsp_sd_init()` гарантированно `false`) — то есть
к моменту, когда `SD_Init()` доходит до `SD_PollingCardInsert()`/`SD_CardInit()` и потенциально
проваливается, хост уже реально поднят. Значит: наблюдаемый стек симптома 2
(`SD_HostDeinit → SDMMCHOST_Deinit → SDMMCHOST_Reset → USDHC_SelectVoltage → DefaultISR`)
происходит **после** успешного `SD_HostInit()`, а не из-за его отсутствия — гипотеза 1 в
исходном логе («либо не был вызван `SD_HostInit()`...») технически неточна в части причины, но
итоговый вывод (откатить pinmux) от этого не меняется: без ложного детекта `run_update()` вообще
не входит в `f_mount()`, и весь этот путь (включая `SD_CardInit()` → протокольные команды к
несуществующей карте → `USDHC` остаётся в промежуточном по voltage-switch состоянии → падение при
последующем `SDMMCHOST_Reset()`) просто не выполняется.
- `SD_CardInit()` вызывает только `SD_SetCardPower(card, true)`**не** делает `false→true` сама.
Явного power-cycle в штатном пути SDK нет вообще, если его не сделать самостоятельно (важно для
следующего раздела).
### Симптом 4 — явный power-cycle добавлен (открытый вопрос 2, теперь применён)
`bsp/sd/src/sd.c::bsp_sd_init()` — добавлен `SD_SetCardPower(&g_sd, false)`
`SD_SetCardPower(&g_sd, true)` сразу после `ensure_host_configured()` (нужен `usrParam.pwr`,
который выставляет `BOARD_SD_Config()`), по образцу `TFT_BOOTLOADER::init_sd()`. Задержки —
встроенные в сам `SD_SetCardPower()` (`fsl_sd.c`): `SD_POWER_OFF_DELAY=100` мс,
`SD_POWER_ON_DELAY=400` мс, итого ~500 мс добавляется к каждой попытке `bsp_sd_init()` (не к
каждому проходу главного цикла — только когда карта физически детектится и `run_update()` реально
стартует).
Механизм здесь остаётся гипотезой (в отличие от симптома 2 — прямого прочтения бага в исходниках
для этого случая нет): `SD_CardInit()` при обычном включении питания отработал бы одинаково что с
предварительным power-cycle, что без. Разница имела бы значение, только если карта физически не
успевает разрядиться между быстрыми power cycle платы (правдоподобно электрически — SD-рейл может
иметь свою постоянную разряда, отличную от MCU — но не подтверждено осциллографом). Аппаратный тест
покажет.
### Открытый вопрос 3 (разделение флага `g_s_initialized`) — понижен в приоритете
Source-level разбор показал: к моменту падения в симптоме 2 `SD_HostInit()` уже отработал успешно
(`isHostReady == true`) — разделение флага на «хост сконфигурирован» vs «хост реально поднят» **не
изменило бы поведение в этом конкретном наблюдавшемся случае**: `bsp_sd_deinit()` вызвал бы
`SD_HostDeinit()` в обоих вариантах флага одинаково. Ценность этого пункта — на пока не
пронаблюдавшемся крайнем случае (`SD_HostInit()` возвращает ошибку сам по себе, например
`SDMMCHOST_Init()` фейлится внутри) — не реализовано в этой сессии, чтобы не размывать набор
изменений перед проверкой на железе. Остаётся в очереди как фоновая гигиена, не как фикс под
конкретный симптом.
### Сборка
`just build::build-bootloader-debug` — зелёно (`m_text` 87304 Б / 247 КБ, 34.52% — чуть меньше, чем
87928 Б, отмеченные в PLAN.md до этой сессии). `just build::test-host` — 15/15. Оба прогнаны в
devcontainer (`docker exec -w /workspace tft-devcontainer ...`), не с хоста напрямую — см. открытый
вопрос 4 выше.
### Чек-лист для аппаратной проверки (следующий шаг — на стороне пользователя)
Оба фикса (pinmux + power-cycle) применены вместе — они независимы по механизму и по разным
симптомам, так что сигнатуры (зависание в `SD_PollingCardInsert`/`OSA_SemaphoreWait` vs падение в
`DefaultISR`) при повторном проявлении однозначно укажут, какой из двух фиксов не сработал (или
сработал не полностью), даже тестируя одним прогоном:
1. **Симптом 1/2** (чистая плата, оба слота пусты, SD карта отсутствует) — ожидание: ни зависания,
ни падения; `sd_update_check()` должен быстро выйти (`bsp_sd_is_inserted()` теперь стабильно
`false`), дойти до `boot_select_and_jump()` → нет валидного образа → штатный ping/pong-цикл.
2. **Симптом 3** (SD вставлена при старте) — должен по-прежнему успешно доходить до
`jump_to_image`, как и раньше.
3. **Симптом 3→1** (после (2): выключить питание, вынуть SD, включить снова) — то же самое, что
симптом 1, теперь с картой, которая только что использовалась — ожидание: то же корректное
поведение, что в (1).
4. **Симптом 4 буквально** (SD оставлена в слоте, повторный power cycle сразу после успешного (2),
карта физически всё ещё вставлена) — тест именно на power-cycle-фикс; ожидание: не зависает в
`OSA_SemaphoreWait`, обновление/загрузка проходит штатно.
Если что-то из этого всё ещё не проходит — сначала зафиксировать точную сигнатуру (стек в
отладчике, как и раньше в этом логе) до следующей правки; при необходимости фикс power-cycle и
фикс pinmux можно тестировать раздельно, закомментировав добавленный блок `SD_SetCardPower` в
`bsp_sd_init()` (единственное новое, легко изолируемое от pinmux-фикса изменение).
## Обновление 2026-07-10 (раунд 2) — пинмукс-фикса недостаточно, полное выравнивание на TFT_BOOTLOADER
**Аппаратный результат раунда 1** (пинмукс `GPIO2_IO28` + explicit power-cycle в `bsp_sd_init()`,
без изменения pad-config и без power-toggle side-effect):
- **Симптом 1 (чистая плата, SD не вставлена) — не устранён.** Всё ещё висим внутри того же
`SD_PollingCardInsert()` `do{}while(true)`. Это означает: `bsp_sd_is_inserted()` всё ещё
возвращает ложный `true` без карты, ДАЖЕ с верным pinmux — вывод раунда 1 («нестабильное чтение
из-за неверной альт-функции») опровергнут этим результатом. Раз чтение теперь стабильно неверное
(не «шумит», а последовательно врёт), причина — не альт-функция сама по себе, а что-то ещё в
электрической конфигурации пина (см. ниже) либо в физической полярности детект-переключателя на
этой плате.
- **Симптом 3→4 (SD оставлена в слоте, повторный power cycle) — похоже, устранён.** Раньше —
зависание в `OSA_SemaphoreWait`. Теперь: загрузка (переиспользование уже установленного stub-а)
занимает ~5 секунд вместо зависания. Не финальное подтверждение (не изолировано, какой именно
из фиксов раунда 1 это дал — pinmux или power-cycle), но однозначно прогресс.
**Важное уточнение механизма симптома 1**, важное для дальнейшей диагностики: `SD_PollingCardInsert(card, kSD_Inserted)`
в SDK — это `do{}while(true)` без ЕДИНОГО условия выхода, если карта не появляется. Это не
«нестабильность», это **ожидаемое поведение при вызове с картой, которой реально нет** — функция
единственная блокируется до появления карты, всегда, у обоих бутлоадеров (текущий и
`TFT_BOOTLOADER::init_sd()` вызывают её одинаково). Значит, единственное, что может предотвратить
зависание — гейт (`bsp_sd_is_inserted()`) ДОЛЖЕН быть на 100% верным при пустом слоте. Раз он
по-прежнему неверный — дело в самом детекте, не в том, как его результат используется.
### Инструкция пользователя: полное выравнивание на TFT_BOOTLOADER, не только pinmux
Пройден весь `TFT_BOOTLOADER` построчно на предмет расхождений с текущим SD-стеком, помимо
pinmux. Найдено и закрыто три конкретных расхождения:
**1. Pad-config CD-пина — было НЕ то, что в референсе.** Раунд 1 откатил только pinmux
(`IOMUXC_SetPinMux`), но НЕ pad-config (`IOMUXC_SetPinConfig`) — тот со времён исходной правки
оставался «активная 47к подтяжка вверх + hysteresis» (`PKE|PUE|HYS|PUS(1)`). Побитовый разбор
`TFT_BOOTLOADER::BOARD_SD_Pin_Config()` (`0x10B0U`, с помощью распечатанных масок из
`PERI_IOMUXC.h`) даёt: `PKE=1, PUE=0 (KEEPER, не активная подтяжка — PUS в этом режиме
игнорируется), HYS=0 (выключен), SPEED=2, DSE=6`. Это **не** совпадает с тем, что было в дереве —
принципиальная разница (keeper vs. активный pull-up, hysteresis выкл vs. вкл). Применено побитово
точно как в референсе (`bsp/generated/sdmmc_config.c::sd_pin_config()`). **Это — наиболее
вероятный настоящий фикс** для устойчивого (не шумового) ложного детекта: если реальная физическая
конфигурация детект-цепи на этой плате рассчитана на keeper (или просто не рассчитана на активную
подтяжку в эту сторону), активная 47к подтяжка вверх могла систематически перетягивать/конфликтовать
с реальным сигналом.
- **Не проверено на железе.**
**2. `bsp_sd_is_inserted()` не имела side-effect на SD_PWR.** `TFT_BOOTLOADER::is_sdcard_present()`
на каждый вызов не только читает CD, но и включает/выключает `SD_PWR` по результату — до этого
раунда `bsp_sd_is_inserted()` была чистым чтением без побочных эффектов. Добавлено: `sd_power_control()`
(`sdmmc_config.c`) сделана публичной под именем `BOARD_SDCardPowerControl()` (то же имя, что и в
стоковом `TFT_BOOTLOADER/board/sdmmc_config.c` — там это отдельная, не связанная с
`is_sdcard_present()` функция, но семантически та же роль) и вызывается из
`bsp_sd_is_inserted()` на каждый скан.
- **Механизм не подтверждён** — детект (механический контакт) физически не должен зависеть от
питания карты, но раз референс это делает, а референс проверенно работал на этой плате, — реплицировано
добросовестно, не только «на всякий случай», а по прямому запросу пользователя выровнять именно
настройку SD-стека на референс.
**3. `bsp_sd_init()` теперь явно вызывает `SD_HostInit()``SD_PollingCardInsert()` → power-cycle**,
байт-в-байт порядок `TFT_BOOTLOADER::init_sd()`, вместо implicit-инициализации внутри
`f_mount() → SD_Init()`. Само по себе это НЕ предотвращает зависание при реальном отсутствии карты
(см. выше — блокировка без тайм-аута заложена в SDK и одинакова что в implicit, что в explicit
пути) — ценность изменения в точном соответствии референсу и в том, что `f_mount()` внутри теперь
дополнительно делает `SD_HostDoReset()` + повторный `SD_PollingCardInsert()`/`SD_CardInit()` уже
поверх реально поднятого хоста (`isHostReady == true`), тем же паттерном двойного вызова
`SD_PollingCardInsert`, что и в референсе.
### Сборка
Оба фикса раунда 2 собраны и проверены в devcontainer: `just build::build-bootloader-debug`
зелёно (`m_text` 87352 Б / 34.54%), `just build::test-host` — 15/15.
### Если симптом 1 всё ещё не уйдёт после раунда 2
Тогда pad-config — тоже не объяснение, и дальше гадать конфигурацией регистров вслепую
контрпродуктивно (уже два раунда фиксов на одних догадках без прямого замера). Следующий шаг в
этом случае — не третья попытка pin-config, а **прямой замер** физического пина `GPIO_B1_12` /
D13 (мультиметром или логическим анализатором через MCU-Link): уровень при вставленной карте vs.
без карты, и полярность самого механического CD-переключателя в слоте этой платы (нормально-открыт
или нормально-закрыт — `BOARD_SDMMC_SD_CD_INSERT_LEVEL=0U` предполагает нормально-открытый,
замыкающий на GND при вставке; если физически наоборот — весь код читает верно регистр, но с
неверным заранее предположением о полярности, и фикс — просто `BOARD_SDMMC_SD_CD_INSERT_LEVEL=1U`,
а не что-либо из pinmux/pad-config).
## Обновление 2026-07-10 (раунд 3) — РАЗРЕШЕНО: детект через USDHC PRES_STATE.CINST
Пользователь заменил `bsp_sd_is_inserted()` на чтение регистра USDHC вместо GPIO:
```c
bool bsp_sd_is_inserted(void)
{
CLOCK_EnableClock(kCLOCK_Usdhc1);
uint32_t ps = USDHC_GetPresentStatusFlags(BOARD_SDMMC_SD_HOST_BASEADDR);
return (ps & kUSDHC_CardInsertedFlag) != 0U;
}
```
**Все 5 сценариев Фазы 3 пройдены на железе.**
### Почему это сработало (и почему раунды 12 искали не там)
Это ровно подход **(a)** из таблицы «Что уже пробовали» в самом верху лога — тогда откачен как
«CINST читался как 0 даже при вставленной карте». Причина того провала теперь понятна: `PRSSTAT.CINST`
отражает реальное состояние CD **только когда физический пин D13 замаплен на `USDHC1_CD_B`** (сигнал
идёт в периферию USDHC, а не в GPIO). В момент пробы (a) пин был на `GPIO2_IO28` — поэтому CINST и
читался нулём. То есть (a) и (c) по отдельности не работают, а **вместе** — работают: `USDHC1_CD_B`
маршрутизация (c) + чтение через PRSSTAT (a).
Ключевой поворот: **мой откат pinmux на `GPIO2_IO28` в раунде 1 был направлен не в ту сторону.**
Исходная правка (c) (`USDHC1_CD_B`) была верной — ей не хватало парного чтения через регистр, а не
через `GPIO_PinRead`. Вся линия рассуждений раундов 12 («три-четыре референса за GPIO2_IO28»)
верна лишь для того механизма чтения, который эти референсы используют (GPIO), но не была
единственно возможной: PRSSTAT — четвёртый, не рассматривавшийся всерьёз в раундах 12 путь,
который и оказался рабочим на этой плате.
### Почему это чинит симптом 1 конкретно
`SD_PollingCardInsert(kSD_Inserted)` в SDK — `do{}while(true)` без тайм-аута (разобрано в раунде 1).
Единственная защита — гейт `bsp_sd_is_inserted()` обязан быть верным на пустом слоте. На старте с
пустым слотом `BOARD_SD_Config()` ещё не вызывался (его зовёт `bsp_sd_init()` уже внутри
`run_update()`, за гейтом) → пин остаётся на `USDHC1_CD_B`, как его поставил `BOARD_InitPins()` в
`board_hw_init()` → PRSSTAT.CINST корректно читает «карты нет» → гейт возвращает false → блокирующий
`SD_PollingCardInsert()` вообще не достигается. Симптом 1 закрыт по построению.
### Текущее итоговое состояние кода (что осталось от раундов 13)
- `bsp_sd_is_inserted()` — PRSSTAT-чтение (раунд 3, рабочее). **Это единственное, что реально
закрыло симптом 1.**
- `bsp_sd_init()` — explicit `SD_HostInit()``SD_PollingCardInsert()` → power-cycle (раунд 2).
Не вредит; power-cycle правдоподобно закрыл симптом 3→4 (не изолировано отдельно).
- CD pad-config `0x10B0` на `GPIO2_IO28` (раунд 2) — остаётся; применяется к пину в моменты, когда
он на `GPIO2_IO28` (внутренний GPIO-детект SDK, см. ниже). Не вредит.
- `BOARD_SD_Config()` маплит пин на `GPIO2_IO28` (раунд 1) — **остаётся**, потому что внутренний
детект SDK (`sd_card_detect_gpio`, `kSD_DetectCardByGpioCD`) внутри `f_mount()→SD_Init()` читает
GPIO, и к тому моменту гейт уже подтвердил карту. Комментарии в `sdmmc_config.c`/`sd.c`/`sd.h`
переписаны под это фактическое состояние (два механизма детекта на разной маршрутизации одного
пина в разные моменты).
- `sd_power_control` возвращён в `static` (публичное имя `BOARD_SDCardPowerControl` из раунда 2
больше не нужно — side-effect, ради которого оно вводилось, заменён PRSSTAT-чтением).
### ⚠️ Латентная хрупкость re-scan (не мешает 5 сценариям, но стоит знать)
Рабочая конфигурация опирается на то, что пин на `USDHC1_CD_B` в момент гейта. Но `BOARD_SD_Config()`
перемапливает его на `GPIO2_IO28` при первом же `bsp_sd_init()` и обратно НЕ возвращает (ни
`bsp_sd_deinit()`, ни что-либо ещё). Значит: если `run_update()` был вызван (карта была на гейте),
но завершился без прыжка (например, на карте нет `TFT_APP.BIN``bsp_sd_deinit()` → return), то
на СЛЕДУЮЩЕМ периодическом скане `bsp_sd_is_inserted()` прочитает PRSSTAT с пином уже на
`GPIO2_IO28` → снова получит ложный ноль → карта «исчезнет» до перезагрузки. В 5 сценариях это не
всплывает (везде, где карта валидна, происходит прыжок и сканы прекращаются; где карты нет —
`BOARD_SD_Config` не вызывался и пин остаётся на `USDHC1_CD_B`). Реальный, но узкий крайний случай.
**Рекомендованная консолидация (не реализована — требует повторной проверки на железе, а рабочее
состояние трогать без запроса не стал):** свести детект к ЕДИНОМУ механизму — PRSSTAT везде. Либо
(вариант A) убрать remux на `GPIO2_IO28` из `BOARD_SD_Config()` (оставить `USDHC1_CD_B` как ставит
`BOARD_InitPins()`) и переключить `s_cd.type` на `kSD_DetectCardByHostCD` (тогда внутренний
`SD_PollingCardInsert` пойдёт через `SDMMCHOST_PollingCardDetectStatus` → тот же PRSSTAT); либо
(вариант B, минимальнее) оставить `kSD_DetectCardByGpioCD`, но заменить сам callback
`sd_card_detect_gpio` на PRSSTAT-чтение и тоже убрать remux. Оба варианта убирают двойную
маршрутизацию и латентную хрупкость. Оба меняют путь, проверенный сейчас только в текущей
двухмеханизменной форме, — отсюда обязательная повторная аппаратная проверка перед принятием.
### Тайминг удержания кнопки даунгрейда (симптом/вопрос пользователя)
Наблюдение: удержание `BSP_BUTTON_1` 2 c при подаче питания → даунгрейд НЕ происходит; 15 c →
происходит (визуально — сразу после отпускания). Разобрано по коду:
- `bsp_button_read()` (`bsp/button/src/button.c`) — **мгновенное сырое чтение GPIO**, без debounce,
без накопления, без latch. `bsp_button_poll()`/события в загрузчике **не используются вообще**
(единственное обращение к кнопке — `bsp_button_read(BSP_BUTTON_1)` в `sd_update.c:180`).
- Значит важен ровно один момент: физически ли нажата кнопка в ту единственную点, когда
`run_update()` её сэмплит. А сэмпл этот — **после** `bsp_sd_init()` (power-cycle-задержки ~0.5 c) +
`f_mount()` (полная инициализация карты: CMD0/CMD8/ACMD41-поллинг и т.д.) + `f_open()` + чтения
заголовка + **двух полных крипто-валидаций слотов** (`peek_slot(0)`/`peek_slot(1)` →
`slot_version_get``bootutil_img_validate`: SHA-256 + ECDSA-P256 по каждому слоту). Это секунды,
и всё это — ДО главного цикла, без LED-фидбэка (heartbeat мигает только в главном цикле, куда
управление ещё не дошло).
- Отсюда: кнопку надо держать НЕПРЕРЫВНО от подачи питания до момента сэмпла. Отпустил в 2 c →
сэмпл (несколькими секундами позже) поймал уже отпущенную → `update_policy_decide` видит
`button_held=false` → SKIP → штатная загрузка более нового слота (не даунгрейд). Держал 15 c →
на момент сэмпла ещё нажата → даунгрейд. «Сразу после отпускания» — это не триггер по отпусканию
(кода на отпускание нет); это совпадение: решение уже защёлкнулось на сэмпле (пока держал), а
видимый результат (стирание+копирование+прыжок в старший образ) проявляется как раз к ~15 c.
**Практическая инструкция технологу** (в HARDWARE_VERIFICATION_PHASE3.md): держать `BSP_BUTTON_1` с
момента подачи питания и не отпускать, пока по CDC не придёт `status: installing` (или пока частота
мигания `LED_APP` не сменится на «старший» образ). Ориентир ~15 c — держать с запасом.
**Рекомендация по коду (не реализовано — поведение бы изменилось, требует запроса и ре-теста):**
сэмплить кнопку РАНО — при `bsp_button_init()`/сразу в `board_hw_init()`-фазе, до медленной
SD-инициализации и двойной крипто-валидации — и защёлкивать результат, вместо чтения в глубине
`run_update()`. Тогда «удержание при старте» стало бы предсказуемым (нажал в момент включения —
сработало), без многосекундного слепого окна. Сейчас это самый user-hostile момент SD-пути.
## Побочная находка (не блокирует, но стоит знать)
`PLAN.md` в двух местах ссылается на `DEBUG_LOG_PHASE2.md`, которого фактически нет в дереве (не
найден ни в рабочей директории, ни в истории git) — судя по всему, файл планировался, но не был
создан. Не тема этого лога, но стоит либо создать его отдельно, либо убрать ссылки, когда дойдут руки.

View file

@ -7,7 +7,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-тесты 5/5, аппаратная верификация — все 5 сценариев пройдены на реальной плате (детали и 3 найденных/исправленных бага — [DEBUG_LOG_PHASE2.md](DEBUG_LOG_PHASE2.md)) |
| 3 — SD-путь установки | не начата | |
| 3 — SD-путь установки | ✅ аппаратно верифицирована (2026-07-10) | все 5 сценариев пройдены на реальной плате; баг SD/card-detect закрыт чтением USDHC PRES_STATE.CINST в `bsp_sd_is_inserted()` (см. [DEBUG_LOG_PHASE3_SD.md](DEBUG_LOG_PHASE3_SD.md), раунд 3; чек-лист — [test_stub/HARDWARE_VERIFICATION_PHASE3.md](test_stub/HARDWARE_VERIFICATION_PHASE3.md)). Осталось нереализованным (отдельными задачами): аппаратный watchdog (см. ниже), консолидация детекта на единый PRSSTAT, ранний сэмпл кнопки даунгрейда |
| 4 — SDRAM/W25Q smoke-test + LED-паттерны | не начата | |
| 5 — HAB Release + service-tui | не начата | |
@ -23,7 +23,9 @@
- **Boot-стратегия**: и bootloader, и tft_app исполняются XIP из W25Q. Bootloader без ITCM-копирования,
без DCD — он не трогает SDRAM. tft_app сама поднимает SEMC в своём раннем startup (SDRAM — под её XIP,
см. отдельный будущий план на tft_app).
см. отдельный будущий план на tft_app). Пересмотрена и переподтверждена в обсуждении Фазы 3 (риск
конфликта flash-AHB с runtime-записью настроек/проигрыванием аудио в tft_app vs `MCUBOOT_RAM_LOAD`
см. [BOOTLOADER_FLASH_MAP.md §5](../../docs/mimxrt1052/BOOTLOADER_FLASH_MAP.md#5-ограничения-на-runtime-доступ-к-flash-из-tft_app-direct-xip--решено)).
- **Схема обновления**: MCUboot **Direct-XIP**, два слота (A/Б) с полностью валидными образами каждый,
без swap/scratch. Единственный полевой канал обновления — microSD. USB как канал заливки *образа*
сознательно не делаем (SDP/blhost на производстве — это отдельный, не зависящий от кода bootloader,
@ -418,31 +420,192 @@ bsp_qspi_read → qspi_ip_read → qspi_read_fifo → qspi_read_tail`) видн
**Цель**: сканирование microSD, установка образа в слот, top-level состояние "нет валидного образа".
- `firmware/bootloader/fatfs/` — bare-metal FatFS-таргет по образцу `firmware/test/fatfs/`
(свой `ffconf.h`, не шарить `firmware_test_fatfs` — bootloader и firmware_test взаимоисключающие
прошивки на одной плате, зависимость от таргета с именем "firmware_test" в trust-anchor была бы
неверной связью; переиспользуется общий `port/fatfs` уровнем ниже).
- `firmware/bootloader/src/sd_update.c` — на старте и опционально периодически: смонтировать SD,
найти `TFT_APP.BIN` (imgtool-подписанный) в корне, распарсить заголовок (через bootutil), сравнить
версию с активным слотом:
- новее → стереть неактивный (или любой, если оба невалидны) слот, записать постранично через
`bsp_qspi_write_page`, вычитать обратно и сверить хэш перед тем как считать установку завершённой;
- старше/равно и кнопка `BSP_BUTTON_1` не удержана при старте → пропустить;
- старше и кнопка удержана → тот же путь установки, что и "новее" (подпись всё равно проверяется).
- Top-level состояние "нет валидного слота": цикл ожидания SD с периодическим статусом на CDC
(`status: waiting_for_sd`) и характерным LED-паттерном (Фаза 4 уточняет полный словарь паттернов) —
выхода из цикла нет, пока установка не пройдёт успешно.
### Ключевая находка (определила архитектуру, до всякого кода)
**Верификация (первая фаза, требующая реального железа)**:
**`boot_go()` нельзя вызывать дважды за одну сессию питания.** В `loader.c::boot_select_or_erase()`
(Direct-XIP-Revert) при выборе ещё не подтверждённого образа функция сразу пишет `copy_done=SET` **в
трейлер во flash** и продолжает грузить его. Если в той же сессии вызвать `boot_go()` повторно (напр.
чтобы сначала "подсмотреть" активную версию, а потом прыгнуть) — второй вызов увидит `copy_done=SET`,
`image_ok` ещё не выставлен (приложение не успело подтвердиться) — и **сотрёт только что установленный
образ**, посчитав это неудавшимся revert'ом. Это же поведение уже неявно доказано существующим
host-тестом Фазы 2 (`test_boot_go_reverts_unconfirmed_image` в `tests/host/mcuboot_port/`): второй
`boot_go()` на неподтверждённом образе стирает его.
Следствие: версию уже активного слота для сравнения с SD-кандидатом нужно узнавать **не через
`boot_go()`**, а отдельным read-only способом (`slot_version.c`, ниже) — сам `boot_go()` (через
`boot_select_and_jump()`) вызывается в `main.c` ровно один раз за попытку, в самом конце, уже после
того как вся SD-логика отработала и решение об установке принято.
### Решения, принятые в обсуждении Фазы 3 (не пересматриваются)
- **Пик версии активного слота — полная криптографическая проверка**, не пик только заголовка.
`slot_version_get()` (`firmware/bootloader/src/slot_version.c`) читает заголовок слота и вызывает
`bootutil_img_validate()` напрямую (тот же вызов, что `loader.c::boot_image_check()` использует
внутри `boot_go()`, вне state-машины `context_boot_go()`) — hash+ECDSA проверяются по-настоящему,
не только magic. `bootutil_img_validate()` не пишет в flash (проверено по `image_validate.c`) и
безопасна вызывать сколько угодно раз, включая до первого `boot_go()` и на ещё не подтверждённых
образах — в отличие от `boot_go()`. `FIH_CALL`-обёртка (CFI-счётчик, `FIH_ENABLE_CFI` под профилем
LOW) самобалансируется на каждый вызов независимо (save/increment перед вызовом, decrement/verify
после — см. `fault_injection_hardening.h`), поэтому несколько вызовов подряд (Slot A, Slot Б,
повторно после установки) и последующий отдельный `boot_go()` не влияют друг на друга.
- **Гейт принятия SD-кандидата — двухступенчатый**, чтобы не строить отдельный flash_area-шim над
SD-файлом: (1) лёгкий пик заголовка (magic + версия) прямо с SD, до касания flash — этого достаточно
для решения install/skip; (2) после записи в целевой слот — тот же `slot_version_get()` на этом
слоте как финальный крипто-гейт. Если подпись кандидата битая — `slot_version_get()` вернёт false
(событие `SD_INSTALL_REJECTED`), а последующий единственный `boot_go()` просто не выберет этот слот
и останется на прежнем валидном — сама проверка подписи не дублируется нигде отдельно.
- **Стирание целого слота — fast-path на 64 КБ блоках** внутри самого `flash_area_erase()`
(`mcuboot_port/flash_map_backend.c`): если стирается вся область целиком (`off=0`, `len=fa_size`,
кратно 64 КБ) — `bsp_qspi_erase_block_64k()` (~4.8 с на 2 МБ) вместо посекторного пути (~23 с).
Частичное/невыровненное стирание — прежний посекторный путь. Выигрыш получает и `sd_update`, и
штатный revert-erase внутри `boot_go()` (`boot_select_or_erase()`) — оба уже зовут
`flash_area_erase(fap, 0, flash_area_get_size(fap))` без каких-либо изменений в bootutil-коде.
- **Верификация записи — немедленный `memcmp` на каждый чанк**, не хэш всего образа. Сразу после
`flash_area_write()` очередного чанка (4 КБ) — `flash_area_read()` того же диапазона и побайтовое
сравнение. Дёшево (буфер уже есть), ловит битую страницу сразу, без второго прохода по SD/flash и
без дублирования того, что `bootutil_img_validate()` и так проверит через TLV-хэш на шаге 2 гейта
выше.
- **Цикл ожидания SD — периодический пере-скан**, не одноразовая проверка при холодном старте.
Существенно для производственного сценария "bootloader-only → потом массовая SD-установка" — SD-путь
не должен требовать, чтобы карта уже стояла в момент включения питания. Дросселирован
`SD_RETRY_PERIOD_MS = 1500` мс через `bsp_tick_get_ms()`, не завязан на период мигания LED.
- **Целевой слот установки — всегда НЕ активный.** Активный слот (валидный, с более высокой версией;
если валиден только один — он активный; если ни одного — активного слота нет) этой логикой никогда
не стирается и не перезаписывается, независимо от исхода сравнения версий и от кнопки. Если
активного слота нет вообще — по умолчанию Slot A. Инвариант живёт в чистой функции
`update_policy_decide()` (`firmware/bootloader/src/update_policy.c`), полностью host-тестируемой (без
флеша/SD).
- **USB CDC поднимается до SD-логики**, не дожидаясь подключения хоста — `bsp_usb_cdc_write()` не
блокируется без хоста (см. `bsp/usb_cdc/src/usb_cdc.c:585-617`, безопасно проверено по коду), поэтому
статусы (`status: installing`, `waiting_for_sd`) видны технологу, если он уже подключён, в т.ч. на
самой первой попытке (чек-лист, сценарий 1). Небольшой сопутствующий эффект: `bsp_usb_cdc_init()`
теперь вызывается на каждой загрузке (раньше — только если `boot_select_and_jump()` уже провалился);
сама инициализация нерегистрозатратна и не ждёт хоста, так что "быстрая загрузка без кабеля" не
нарушена.
- **`bootloader_fatfs`** — bare-metal FatFS-таргет по образцу `firmware/test/fatfs/` (свой `ffconf.h`,
не шарить `firmware_test_fatfs` — bootloader и firmware_test взаимоисключающие прошивки на одной
плате). Единственное отличие от `firmware_test_fatfs`: **`FF_FS_READONLY=1`** — bootloader только
читает `TFT_APP.BIN`, никогда не пишет на SD; убирает весь write-путь FatFS из сборки.
### Итог (выполнено, аппаратно ещё не проверено)
- `firmware/bootloader/src/update_policy.{c,h}` — чистая логика install/skip + выбор целевого слота
(см. решения выше). `image_version_compare()` — свой аналог `boot_version_cmp()` (`static` в
`loader.c`, не экспортируется): major.minor.revision, без build_num, то же соглашение по умолчанию.
12 host-тестов (`tests/host/update_policy/`) — все сценарии чек-листа ниже плюс сравнение версий.
- `firmware/bootloader/src/slot_version.{c,h}` — read-only пик версии слота (см. находку выше).
4 host-теста (`tests/host/slot_version/`), переиспользуют `fake_flash_map_backend.c` и фикстуры
`tests/host/mcuboot_port/fixtures/` из Фазы 2 (`valid_v1/v2`, `corrupt_v1`,
`valid_v2_unconfirmed` — последний доказывает, что пик не задет revert-состоянием, в отличие от
`boot_go()`).
- `firmware/bootloader/mcuboot_port/flash_map_backend.c` — fast-path 64К в `flash_area_erase()`.
- `firmware/bootloader/fatfs/``bootloader_fatfs` (CMakeLists.txt, `include/ffconf.h`, `src/diskio.c`),
подключена через `add_subdirectory(fatfs)` в `firmware/bootloader/CMakeLists.txt`.
- `firmware/bootloader/src/sd_update.{c,h}` — оркестрация: `bsp_sd_is_inserted()``bsp_sd_init()`
`f_mount("2:/")``f_open("2:/TFT_APP.BIN")` → лёгкий пик заголовка → `slot_version_get()` на
обоих слотах → `update_policy_decide()` → при INSTALL: `protocol_send_status("installing")`
`flash_area_erase` (fast-path) + потоковое копирование чанками 4 КБ (`flash_area_write` +
немедленный `memcmp`-verify) → финальный `slot_version_get()` на целевом слоте как крипто-гейт.
Коды ошибок на CDC: `SD_CANDIDATE_INVALID`, `SD_INSTALL_WRITE_FAILED`, `SD_INSTALL_REJECTED`. Не
host-тестируется напрямую (тонкий ARM-only оркестратор над уже протестированными
`update_policy`/`slot_version`+ реальной FatFS/SD) — соответствует установленному в проекте принципу
"толстый слой логики тестируем на хосте, тонкий аппаратный оркестратор — только на плате".
- `firmware/bootloader/src/protocol.{c,h}` — новое событие `status`
(`{"type":"status","state":"..."}`) — `protocol_send_status()`. Пока используется для `installing` и
`waiting_for_sd`; полный словарь состояний — Фаза 4.
- `firmware/bootloader/src/main.c` — переупорядочен: `bsp_button_init()` добавлен;
`bsp_usb_cdc_init()` поднимается до `sd_update_check()`/`boot_select_and_jump()` (см. решение о CDC
выше); один комбинированный цикл ожидания вместо прежних двух (ожидание CDC / серв-цикл) — совмещает
CDC ping/pong, LED (`LED_APP`, если CDC готов, иначе мигание `LED_HEARTBEAT`) и периодический
пере-скан SD с повторным `boot_select_and_jump()`, если что-то установилось.
- `firmware/bootloader/CMakeLists.txt` — добавлены `bsp_button`, `bootloader_fatfs`,
`src/update_policy.c`, `src/slot_version.c`, `src/sd_update.c`.
- **Host-тесты**: `just build::test-host`**15/15 таргетов зелёных** (13 существовавших до Фазы 3 +
новые `test_update_policy` [12 тестов] и `test_slot_version` [4 теста]).
- **ARM-сборка**: `just build::build-bootloader-debug` и `hab-bootloader-debug`оба зелёные.
`m_text` — 87928 Б из 247 КБ бюджета (34.76%, был 20.05% после Фазы 2 — рост за счёт FatFS+SDMMC,
запас всё ещё больше половины).
### Найденный и исправленный пробел: форс. даунгрейд не имел бы эффекта
При проектировании чек-листа ниже (сценарий 3) обнаружилось: `update_policy_decide()` изначально
только писала более старый образ в неактивный слот, но **не трогала прежний активный**а `boot_go()`
всегда выбирает более высокую версию среди валидных слотов. Значит, форс. даунгрейд физически
записался бы на flash, но реально не загрузился бы, пока прежний (более новый) активный слот не станет
невалидным сам по себе — не то поведение, которое ожидает технолог, держащий кнопку.
**Исправлено**: `update_policy_result_t` получила поле `erase_previous_active` (`true` только для
форс. даунгрейда — не для обычного "кандидат новее", там прежний активный и так проиграет сравнение
версий естественным путём). `sd_update.c` стирает прежний активный слот **только после** того, как
`slot_version_get()` подтвердил валидность только что установленного образа — на диске никогда не
бывает нуля рабочих слотов даже на середине операции. Регрессионный тест
`test_forced_downgrade_targets_and_erases_the_higher_version_slot` — зелёный.
### Найден и исправлен баг CI (не связан с Фазой 3 по сути, но всплыл при её работе)
`test-host-release` в GitHub Actions падал на компиляции вендоренного
`sdk/middleware/mcuboot_opensource/boot/bootutil/src/fault_injection_hardening.c`:
`fih_panic_loop()` использует inline-asm `__asm volatile("b fih_panic_loop")` — валидная мнемоника
только для ARM/Thumb. На x86_64-раннере GitHub Actions ассемблер падает
(`invalid instruction mnemonic 'b'`); в devcontainer на arm64 та же мнемоника случайно ассемблируется
(AArch64 тоже использует `b <label>`), маскируя проблему — отсюда расхождение "в devcontainer всё
хорошо". Исправлено точечным патчем вендоренного файла: `__asm` под `#if defined(__arm__)` (реальный
ARM-таргет — без изменений), иначе (host, любая архитектура) — портируемый `for (;;) {}`. Подтверждено
кросс-компиляцией под `-target x86_64-unknown-linux-gnu` (0 ошибок после фикса, была именно эта ошибка
до него).
### Запланировано (обязательно к реализации): аппаратный watchdog
Найдено при аппаратной верификации (см. [DEBUG_LOG_PHASE3_SD.md](DEBUG_LOG_PHASE3_SD.md)): у
`SD_PollingCardInsert()` (SDK, вызывается изнутри `SD_Init()`, который вызывается изнутри
`f_mount()`) при типе детекта `kSD_DetectCardByGpioCD` **нет тайм-аута вообще** — это `do {...}
while (true)` без выхода по времени, если callback `cardDetected()` ни разу не вернёт ожидаемое
для текущего условия ожидания значение. На реальном железе уже дважды пойман сценарий, когда
исполнение зависает внутри вендоренного SD/USDHC-стека (не только в этой функции — второй найденный
зависон, в `SD_ReadStatus`/`OSA_SemaphoreWait`, тоже блокирующий и без видимого тайм-аута) — то есть
риск не ограничивается одной конкретной причиной, которую можно точечно пропатчить, а системный:
любой blocking-вызов в SD/USDHC-стеке потенциально способен подвесить весь bootloader навсегда.
Для загрузчика это неприемлемо: зависание до `boot_select_and_jump()` означает, что даже валидный
образ в Slot A никогда не загрузится без внешнего вмешательства (SWD-сброс/перезагрузка вручную) —
на производстве это означает бракованную единицу без возможности восстановления штатными средствами.
**Решение — аппаратный WDOG (WDOG1 или WDOG2, MIMXRT1052)**:
- Инициализация с разумным тайм-аутом (ориентир 5-8 сс запасом над самой длинной легитимной
операцией, потоковым копированием чанка 4 КБ SD→flash) как можно раньше в `main.c`, после
`board_hw_init()`.
- "Кормление" (`WDOG_Refresh`/эквивалент) — только в точках, где мы **уверены**, что прогресс
реален: начало каждой итерации главного цикла `main.c`, и внутри `erase_and_copy_candidate()`
(`sd_update.c`) на каждой итерации цикла копирования чанков (там уже есть `bsp_usb_cdc_poll()`
рядом). **Не кормить** непосредственно перед вызовом функций SDK, которые могут заблокироваться
бесконечно (`f_mount`, `SD_Init` и всё, что внутри) — иначе watchdog перестаёт быть защитой именно
от них.
- Ресет по watchdog безопасен по построению: ни один слот не стирается до подтверждения валидности
только что записанного образа (см. `erase_previous_active` выше) — WDOG-ресет в худшем случае
просто откатывает к состоянию "начать сессию заново", как обычный power cycle.
- Не реализовано в рамках текущей сессии — статус явно "запланировано", реализация и аппаратная
проверка (спровоцировать реальное зависание и убедиться, что WDOG восстанавливает работу) —
следующий шаг после того, как будет закрыт текущий SD/card-detect баг (иначе watchdog будет
маскировать/сбрасывать симптом на каждом цикле, не давая найти первопричину).
**Верификация (первая часть Фазы 3, требующая реального железа — ещё не проведена)**:
1. Чистая плата (только bootloader, оба слота пустые) + SD с валидным подписанным образом →
автоустановка, переход к загрузке (проверить по CDC-статусам и LED).
2. То же SD с образом версии ниже уже установленной, кнопка не нажата → отклонён, лог на CDC.
3. То же, кнопка `BSP_BUTTON_1` удержана при старте → установлен.
4. SD с образом без подписи/битым TLV → отклонён, плата остаётся на последнем валидном слоте (если был)
или продолжает ждать (если не было), характерный error-паттерн LED.
2. То же SD с образом версии ниже уже установленной, кнопка не нажата → отклонён
(`status: update_skipped` на CDC).
3. То же, кнопка `BSP_BUTTON_1` удержана при старте → установлен И загружен (прежний активный слот
стирается автоматически — см. находку выше).
4. SD с образом без подписи/битым TLV → отклонён (`error: SD_CANDIDATE_INVALID` или
`SD_INSTALL_REJECTED`, в зависимости от того, где именно битый образ — в заголовке или в TLV/подписи),
плата остаётся на последнем валидном слоте (если был) или продолжает ждать (если не было).
5. Сценарий 2 (бандл залит сразу в Slot A через SWD/SDP на этапе тестирования): SD не участвует,
bootloader должен просто загрузить Slot A без обращения к SD-логике вообще.
Понадобится тестовый образ на SD — переиспользовать `test_stub` из Фазы 2 (`stub_a_v1_confirmed.bin`
и т.п., уже собираются `just build::build-mcuboot-stub`), переименованный/скопированный в
`TFT_APP.BIN`, а не дожидаться реального `tft_app`. Точный порядок команд — см. чат/раздел ниже
(зависит от того, что именно уже стоит на конкретной тестовой плате).
---
## Фаза 4 — SDRAM/W25Q smoke-test + словарь LED-паттернов

View file

@ -5,18 +5,24 @@
> SWD — не в поле. Единственный канал диагностики: USB CDC ACM (JSON-lines,
> тот же стиль протокола, что у `firmware_test`).
>
> Версия: `0.1.0` | Статус: Фаза 1 (скелет) завершена — см. [PLAN.md](PLAN.md)
> Версия: `0.1.0` | Статус: Фаза 2 (bootutil / MCUboot Direct-XIP) завершена — см. [PLAN.md](PLAN.md)
---
## Содержание
- [Быстрый старт](#быстрый-старт)
- [Архитектура](#архитектура)
- [Карта Flash](#карта-flash)
- [Протокол](#протокол)
- [Roadmap](#roadmap)
- [Версионирование](#версионирование)
- [bootloader](#bootloader)
- [Содержание](#содержание)
- [Быстрый старт](#быстрый-старт)
- [1. Сборка](#1-сборка)
- [2. Прошивка](#2-прошивка)
- [3. Подключение](#3-подключение)
- [4. Отладка](#4-отладка)
- [Архитектура](#архитектура)
- [Карта Flash](#карта-flash)
- [Протокол](#протокол)
- [Roadmap](#roadmap)
- [Версионирование](#версионирование)
---
@ -46,6 +52,11 @@ just host::flash bootloader debug
### 3. Подключение
CDC поднимается, только если в обоих слотах (A/Б) нет валидного образа — иначе
bootloader сразу выбирает слот и прыгает в `tft_app` (Direct-XIP), не поднимая
USB вообще. Для проверки диагностического режима слоты должны быть пустыми
или содержать только невалидные образы.
USB CDC ACM (тот же порядок, что у `firmware_test`):
```bash
@ -73,17 +84,31 @@ VSCode → `🐛 Debug: bootloader` (`.vscode/launch.json`) — пересоби
## Архитектура
Bring-up идентичен `firmware_test` (`firmware/test/src/main.c`), но без
тестового раннера — bootloader не запускает тесты, только диагностический
CLI:
Bring-up идентичен `firmware_test` (`firmware/test/src/main.c`), но вместо
тестового раннера — сразу попытка выбрать и запустить `tft_app` из одного из
двух слотов (MCUboot Direct-XIP, `bootutil`), и только если валидного образа
нет ни в одном слоте — диагностический CLI:
```bash
firmware/bootloader/
├── CMakeLists.txt
├── mcuboot_port/ — интеграция bootutil (MCUboot) с bsp_qspi_flash
│ ├── flash_map_backend.c — flash_area_* поверх bsp_qspi_flash (2 слота)
│ ├── keys.c — публичный ключ ECDSA-P256 для imgtool-подписи
│ ├── bootutil_sources.cmake — общий список исходников bootutil+TinyCrypt+
│ │ ASN.1 (используется и host-тестами)
│ └── mcuboot_config/, sysflash/, flash_map_backend/, keys/ — конфиг-заголовки
└── src/
├── main.c — board_hw_init → led/tick/usb_cdc init →
│ ожидание CDC (LED_HEARTBEAT мигает) →
│ LED_APP on → главный цикл (poll + cli_process)
├── main.c — board_hw_init → led/tick/qspi init →
│ boot_select_and_jump() (при успехе не
│ возвращается) → иначе usb_cdc init → ожидание
│ CDC (LED_HEARTBEAT мигает) → LED_APP on →
│ главный цикл (poll + cli_process)
├── boot_select.c/.h — обёртка над bootutil: `boot_go()` (выбор
│ валидного слота по подписи/версии) + прыжок в
│ выбранный образ (`jump_to_image`)
├── version.h.in — шаблон версии (CMake → generated/version.h)
@ -98,8 +123,13 @@ Bootloader **без SDRAM** — DCD не используется (`bsp_boot_xip
(см. [BOOTLOADER_FLASH_MAP.md](../../docs/mimxrt1052/BOOTLOADER_FLASH_MAP.md)).
Линкер: `cmake/linker/MIMXRT1052xxxxx_bootloader_flexspi_nor.ld``m_text`
жёстко ограничен 247 КБ, с `ASSERT` на границу Slot A. Превышение бюджета —
ошибка линковки, а не тихий выход кода за пределы своей области.
жёстко ограничен 247 КБ, с `ASSERT` на границу Slot A.
Верификация образов — подпись ECDSA-P256 (bootutil/TinyCrypt), два независимых
слота (A/Б) по 2 МБ каждый, без swap/scratch (Direct-XIP). Аппаратно
верифицировано все 5 сценариев чек-листа (единственный валидный слот, выбор
более новой версии, откат при повреждении, оба слота пусты, revert
неподтверждённого образа)
---
@ -109,11 +139,11 @@ Bootloader **без SDRAM** — DCD не используется (`bsp_boot_xip
ёмкость чипа" — в
[docs/mimxrt1052/BOOTLOADER_FLASH_MAP.md](../../docs/mimxrt1052/BOOTLOADER_FLASH_MAP.md).
| Область | Смещение | Размер |
|---|---|---|
| Bootloader | `0x60000000` | 256 КБ |
| Slot A (tft_app) | `0x60040000` | 2 МБ |
| Slot Б (tft_app) | `0x60240000` | 2 МБ |
| Область | Смещение | Размер |
| --------------------------- | ------------ | ------------------- |
| Bootloader | `0x60000000` | 256 КБ |
| Slot A (tft_app) | `0x60040000` | 2 МБ |
| Slot Б (tft_app) | `0x60240000` | 2 МБ |
| ФС ассетов (спрайты/музыка) | `0x60440000` | остальное (рантайм) |
---
@ -126,9 +156,9 @@ JSON-lines, максимум 128 байт на строку.
**Реализовано (Фаза 1):**
| Команда хоста | Ответ |
|---|---|
| `{"type":"cmd","cmd":"ping"}` | `{"type":"pong"}` |
| Команда хоста | Ответ |
| ------------------------------------ | ------------------------------------------ |
| `{"type":"cmd","cmd":"ping"}` | `{"type":"pong"}` |
| `{"type":"cmd","cmd":"get_version"}` | `{"type":"version_response","fw":"X.Y.Z"}` |
Ошибки: `{"ok":false,"error":"PARSE_ERR"}` / `"UNKNOWN_CMD"` / `"LINE_TOO_LONG"`.
@ -147,14 +177,14 @@ JSON-lines, максимум 128 байт на строку.
Полный план по фазам с целями и критериями верификации — [PLAN.md](PLAN.md).
| Фаза | Что делает |
|---|---|
| 0 ✅ | Карта Flash, регистрация в сборке |
| 1 ✅ | Скелет: bring-up, USB CDC, ping/get_version, LED heartbeat |
| 2 | bootutil (MCUboot Direct-XIP) — выбор слота, верификация подписи |
| 3 | Установка образа с microSD, состояние "нет валидного слота" |
| 4 | SDRAM/W25Q smoke-test, словарь LED-паттернов |
| 5 | HAB Release, интеграция в service-tui |
| Фаза | Что делает |
| ---- | ---------------------------------------------------------------- |
| 0 ✅ | Карта Flash, регистрация в сборке |
| 1 ✅ | Скелет: bring-up, USB CDC, ping/get_version, LED heartbeat |
| 2 | bootutil (MCUboot Direct-XIP) — выбор слота, верификация подписи |
| 3 | Установка образа с microSD, состояние "нет валидного слота" |
| 4 | SDRAM/W25Q smoke-test, словарь LED-паттернов |
| 5 | HAB Release, интеграция в service-tui |
---

View file

@ -0,0 +1,35 @@
# bootloader_fatfs — FatFS, скомпилированный с bare-metal ffconf.h для
# bootloader.
#
# ff.c, fsl_sd_disk.c, diskio_sd.c включают ff.h → ffconf.h. Все три
# компилируются здесь, чтобы видели один и тот же ffconf.h из
# ${CMAKE_CURRENT_SOURCE_DIR}/include.
#
# Не шарить с firmware_test_fatfs: bootloader и firmware_test —
# взаимоисключающие прошивки одной платы (см. firmware/bootloader/PLAN.md,
# Фаза 3) — зависимость от таргета с именем "firmware_test" была бы неверной
# связью. tft_app заведёт свой аналогичный таргет с FreeRTOS ffconf.h.
#
# Отличие от firmware_test_fatfs: FF_FS_READONLY=1 — bootloader только читает
# TFT_APP.BIN, никогда не пишет на SD (см. include/ffconf.h).
add_library(
bootloader_fatfs STATIC
${SDK_FATFS_FF_SRC} # sdk/middleware/fatfs/source/ff.c
${SDK_FATFS_SD_DISK_SRC} # sdk/middleware/fatfs/source/fsl_sd_disk/fsl_sd_disk.c
${PORT_FATFS_SD_SRC} # port/fatfs/sd/src/diskio_sd.c
src/diskio.c)
# include/ первым — ffconf.h отсюда должен перекрыть любой шаблонный
target_include_directories(
bootloader_fatfs
PUBLIC include # ffconf.h, виден потребителям (sd_update.c)
PRIVATE src)
target_link_libraries(
bootloader_fatfs
PUBLIC port_fatfs_sd # diskio_sd.h, ff.h, fsl_sd_disk.h, bsp_sd
PRIVATE bsp_sdmmc_config) # SD_ENABLED транзитивно из bsp_sd, но явно для
# ясности
target_compile_options(bootloader_fatfs PRIVATE -w)

View file

@ -0,0 +1,86 @@
/*
* ffconf.h конфигурация FatFS для bootloader (bare-metal, только SD).
*
* Копия паттерна firmware/test/fatfs/include/ffconf.h не шарить: bootloader
* и firmware_test взаимоисключающие прошивки одной платы (см.
* firmware/bootloader/PLAN.md, Фаза 3). tft_app заведёт свой аналогичный
* таргет с FreeRTOS ffconf.h.
*
* Ключевое отличие от firmware_test:
* FF_FS_READONLY = 1 bootloader только читает TFT_APP.BIN с SD, никогда
* не пишет на карту; убирает f_write и весь путь
* записи FatFS из сборки.
* FF_FS_REENTRANT = 0 нет RTOS, нет мьютексов
* FF_VOLUMES = 3 0: зарезервирован, 1: зарезервирован, 2: SD
* FF_MAX_SS = 512 SD всегда 512 байт/сектор, ioctl не нужен
*/
#ifndef _FFCONF_H_
#define _FFCONF_H_
#define FFCONF_DEF 80286
/*---------------------------------------------------------------------------/
/ MSDK adaptation
/---------------------------------------------------------------------------*/
#define SD_DISK_ENABLE 1
/*---------------------------------------------------------------------------/
/ Function Configurations
/---------------------------------------------------------------------------*/
#define FF_FS_READONLY 1 /* bootloader никогда не пишет на SD */
#define FF_FS_MINIMIZE 0
#define FF_USE_FIND 0
#define FF_USE_MKFS 0 /* f_mkfs не нужна — карта уже отформатирована */
#define FF_USE_FASTSEEK 0
#define FF_USE_EXPAND 0
#define FF_USE_CHMOD 0
#define FF_USE_LABEL 0
#define FF_USE_FORWARD 0
#define FF_USE_STRFUNC 0
#define FF_PRINT_LLI 0
#define FF_PRINT_FLOAT 0
#define FF_STRF_ENCODE 3
/*---------------------------------------------------------------------------/
/ Locale
/---------------------------------------------------------------------------*/
#define FF_CODE_PAGE 437 /* U.S. — минимальный, имена файлов ASCII */
#define FF_USE_LFN 0 /* только 8.3 — достаточно для TFT_APP.BIN */
#define FF_MAX_LFN 255
#define FF_LFN_UNICODE 0
#define FF_LFN_BUF 255
#define FF_SFN_BUF 12
#define FF_FS_RPATH 0 /* относительные пути не нужны */
/*---------------------------------------------------------------------------/
/ Drive/Volume Configurations
/---------------------------------------------------------------------------*/
#define FF_VOLUMES 3 /* 0: зарезервирован, 1: зарезервирован, 2: SD */
#define FF_STR_VOLUME_ID 0
#define FF_MULTI_PARTITION 0
#define FF_MIN_SS 512
#define FF_MAX_SS 512 /* SD: всегда 512, GET_SECTOR_SIZE не нужен */
#define FF_LBA64 0
#define FF_MIN_GPT 0x10000000
#define FF_USE_TRIM 0
/*---------------------------------------------------------------------------/
/ System Configurations
/---------------------------------------------------------------------------*/
#define FF_FS_TINY 0
#define FF_FS_EXFAT 0 /* exFAT требует LFN — оба отключены */
#define FF_FS_NORTC 1
#define FF_NORTC_MON 1
#define FF_NORTC_MDAY 1
#define FF_NORTC_YEAR 2024
#define FF_FS_NOFSINFO 0
#define FF_FS_LOCK 0
#define FF_FS_REENTRANT 0 /* bare-metal: нет RTOS, нет мьютексов */
/* FF_FS_TIMEOUT и FF_SYNC_t не нужны */
#endif /* _FFCONF_H_ */

View file

@ -0,0 +1,53 @@
/*
* diskio.c FatFS diskio диспетчер для bootloader.
*
* FF_VOLUMES=3: диски 0 и 1 заглушки, диск 2 = SDDISK (microSD).
* W25Q и RAM-диск отсутствуют нет зависимости на bsp_qspi_flash (слоты A/Б
* читаются/пишутся напрямую через flash_area_*, в обход FatFS).
*/
#include "diskio.h"
#include "port/fatfs/diskio_sd.h"
#define SDDISK 2U
DSTATUS disk_initialize(BYTE pdrv)
{
if (pdrv == SDDISK)
{
return microsd_disk_initialize(pdrv);
}
return STA_NOINIT;
}
DSTATUS disk_status(BYTE pdrv)
{
if (pdrv == SDDISK)
{
return microsd_disk_status(pdrv);
}
return STA_NOINIT;
}
DRESULT disk_read(BYTE pdrv, BYTE *buff, LBA_t sector, UINT count)
{
if (pdrv == SDDISK)
{
return microsd_disk_read(pdrv, buff, sector, count);
}
return RES_PARERR;
}
DRESULT disk_ioctl(BYTE pdrv, BYTE cmd, void *buff)
{
if (pdrv == SDDISK)
{
return microsd_disk_ioctl(pdrv, cmd, buff);
}
return RES_PARERR;
}

View file

@ -14,11 +14,10 @@
* (main.c, до boot_go()).
*/
#include "bsp/qspi_flash.h"
#include "flash_map.h"
#include "sysflash/sysflash.h"
#include "bsp/qspi_flash.h"
#include <string.h>
#define ERASED_VAL 0xFFU
@ -26,10 +25,16 @@
/* 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 МБ */
{ .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) ─────────
@ -128,6 +133,28 @@ int flash_area_erase(const struct flash_area *area, uint32_t off, uint32_t len)
return -1;
}
/* Fast-path: стирание всей области целиком (off=0, len=fa_size), кратно
* 64 КБ блочное стирание ~5x быстрее посекторного (2 МБ: ~4.8 с против
* ~23 с, см. docs/mimxrt1052/BOOTLOADER_FLASH_MAP.md, "Обоснование
* размеров"). Ускоряет и sd_update (Фаза 3), и штатный revert-erase
* bootutil (boot_select_or_erase() в loader.c) они уже зовут
* flash_area_erase(fap, 0, flash_area_get_size(fap)) без изменений.
* Частичное/невыровненное стирание (напр. один трейлер) прежний
* посекторный путь ниже. */
if ((off == 0U) && (len == area->fa_size) && ((len % BSP_QSPI_BLOCK_64K_SIZE) == 0U))
{
uint32_t block_addr = area->fa_off;
for (; len > 0U; len -= BSP_QSPI_BLOCK_64K_SIZE)
{
if (bsp_qspi_erase_block_64k(block_addr) != BSP_OK)
{
return -1;
}
block_addr += BSP_QSPI_BLOCK_64K_SIZE;
}
return 0;
}
uint32_t addr = area->fa_off + off;
for (; len > 0U; len -= BSP_QSPI_SECTOR_SIZE)
{

View file

@ -4,7 +4,9 @@
*
* Референс sdk/middleware/mcuboot_opensource/boot/nxp_mcux_sdk/boot.c::do_boot():
* та же последовательность (flash_device_base вычислить адрес vector table
* __set_MSP __ISB прыжок на Reset_Handler), CMSIS-интринсики, без ассемблера.
* cleanup __set_MSP __ISB прыжок на Reset_Handler), CMSIS-интринсики, без
* ассемблера. cleanup_before_jump()/VTOR добавлены в Фазе 3 см. её docstring
* про найденный на железе баг (прыжок сразу после bsp_usb_cdc_init()).
*/
#include "boot_select.h"
@ -12,7 +14,6 @@
#include "bootutil/bootutil.h"
#include "bootutil/fault_injection_hardening.h"
#include "flash_map.h"
#include "fsl_common.h"
struct arm_vector_table
@ -21,6 +22,53 @@ struct arm_vector_table
uint32_t reset;
};
/**
* @brief Вернуть NVIC/SysTick в состояние "как после аппаратного сброса"
* перед прыжком целевой образ не должен унаследовать прерывания,
* включённые bootloader'ом.
*
* Найдено на реальном железе (Фаза 3): Slot A с уже валидным подтверждённым
* образом не загружался, когда прыжок происходил сразу после
* bsp_usb_cdc_init() (USB ещё в процессе enumeration прерывания частые), но
* загружался, когда прыжок происходил позже (после цикла ожидания SD USB
* уже в устоявшемся состоянии). Причина: bootloader (в отличие от Фазы 1/2,
* где до прыжка включался только bsp_qspi_init() без единого постоянно
* включённого NVIC IRQ) теперь включает USB CDC и, при вставленной SD,
* USDHC оба взводят свои NVIC IRQ. jump_to_image() не переключал VTOR
* прерывание, сработавшее в узком окне между прыжком и тем, как целевой
* образ успеет настроить свою таблицу векторов в Reset_Handler/SystemInit(),
* уходило по ещё активной (bootloader'овской) таблице векторов с чужим
* стеком/контекстом.
*
* Портировано по мотивам cleanup()/SBL_DisablePeripherals() в референсном
* sdk/middleware/mcuboot_opensource/boot/nxp_mcux_sdk/boot.c::do_boot() не
* скопировано напрямую (SBL_DisablePeripherals extern, платформенно-
* специфичная функция, не вендоренная в нашем дереве); здесь общий,
* не завязанный на конкретную периферию эквивалент через NVIC/SysTick.
*
* НЕ трогает PRIMASK (в отличие от более ранней, откаченной в Фазе 2
* версии) SysTick_Handler целевого образа должен сработать после того как
* образ сам вызовет bsp_tick_init(), а PRIMASK обычный Reset_Handler не
* восстанавливает (см. bsp_delay()-зависание, найденное в Фазе 2).
* Отключение конкретных источников (NVIC ICER/ICPR, SysTick->CTRL) не
* то же самое, что глобальная маскировка: раз выключенный SysTick просто не
* тикает, пока образ не включит его сам, и не блокирует его же будущий
* запуск.
*/
static void cleanup_before_jump(void)
{
for (uint32_t i = 0U; i < (sizeof(NVIC->ICER) / sizeof(NVIC->ICER[0])); i++)
{
NVIC->ICER[i] = 0xFFFFFFFFU; /* запретить все внешние IRQ */
NVIC->ICPR[i] = 0xFFFFFFFFU; /* сбросить pending — не унаследовать флаг */
}
SysTick->CTRL = 0U; /* SysTick — не в NVIC->ICER, отдельный системный таймер */
__DSB();
__ISB();
}
static void jump_to_image(const struct boot_rsp *p_rsp)
{
uintptr_t flash_base;
@ -29,18 +77,18 @@ static void jump_to_image(const struct boot_rsp *p_rsp)
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);
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. */
cleanup_before_jump();
/* Образ сам переставит VTOR в своём Reset_Handler/SystemInit() — но до
* этого момента (первые же инструкции после прыжка) он уже должен быть
* валиден, на случай если что-то прервёт выполнение раньше. */
SCB->VTOR = (uint32_t) p_vt;
/* Намеренно НЕ __disable_irq()/PRIMASK здесь — см. cleanup_before_jump(). */
__set_CONTROL(0U);
__set_MSP(p_vt->msp);
__ISB();

View file

@ -2,74 +2,113 @@
* @file main.c
* @brief bootloader точка входа.
*
* Фаза 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-логика ещё не добавлена.
* Фаза 3: перед выбором образа (bootutil, Direct-XIP) проверяется microSD
* если вставлена, sd_update_check() при необходимости ставит более новый
* (или, при удержании BSP_BUTTON_1, принудительно более старый) подписанный
* образ в неактивный слот. boot_select_and_jump() вызывается РОВНО ОДИН РАЗ
* за попытку если валидного образа нет, main() возвращается в цикл
* ожидания, где SD периодически пере-сканируется (см. sd_update.h о том,
* почему boot_go() нельзя звать без новой попытки установки между вызовами).
*
* USB CDC поднимается ДО SD-логики (не дожидаясь подключения хоста
* bsp_usb_cdc_write() не блокируется без хоста, см. bsp/usb_cdc/src/usb_cdc.c)
* чтобы статусы ("installing" и т.п.) были видны, если технолог уже
* подключён, в т.ч. на самой первой попытке (чек-лист Фазы 3, сценарий 1).
*
* Последовательность старта:
* 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
* 4. bsp_button_init() для проверки удержания BSP_BUTTON_1
* 5. bsp_qspi_init() доступ к Slot A/Б
* 6. bsp_usb_cdc_init() не блокирует, см. выше
* 7. sd_update_check() no-op быстро, если SD не вставлена
* 8. boot_select_and_jump() при успехе не возвращается
* 9. Цикл ожидания CDC ping/pong + LED + периодический
* пере-скан SD (шаги 7-8 повторно)
*
* Bootloader без SDRAM (см. docs/mimxrt1052/BOOTLOADER_FLASH_MAP.md) DCD
* не используется (bsp_boot_xip_no_dcd).
*/
#include "board.h"
#include "boot_select.h"
#include "bsp/button.h"
#include "bsp/led.h"
#include "bsp/qspi_flash.h"
#include "bsp/tick.h"
#include "bsp/usb_cdc.h"
#include "cli.h"
#include "protocol.h"
#include "sd_update.h"
#include <stdbool.h>
#include <stdint.h>
int main(void)
{
const uint32_t CONNECT_BLINK_MS = 200U;
const uint32_t ERROR_BLINK_MS = 250U;
const uint32_t ERROR_BLINK_MS = 250U;
const uint32_t SD_RETRY_PERIOD_MS = 1500U;
const uint32_t HEARTBEAT_ON_MS = 50U;
const uint32_t HEARTBEAT_PERIOD_MS = 500U;
board_hw_init();
bsp_led_init();
bsp_tick_init();
bsp_button_init();
if (bsp_qspi_init() == BSP_OK)
bool qspi_ok = (bsp_qspi_init() == BSP_OK);
bool cdc_ok = (bsp_usb_cdc_init() == BSP_OK);
if (qspi_ok)
{
sd_update_check(); /* no-op быстро, если SD не вставлена */
boot_select_and_jump(); /* при успехе не возвращается */
}
/* Нет валидного образа ни в одном слоте — диагностический режим. */
if (bsp_usb_cdc_init() != BSP_OK)
/* Нет валидного образа ни в одном слоте (или сбой QSPI) —
* диагностический режим. */
if (!cdc_ok)
{
bsp_led_toggle(LED_HEARTBEAT);
bsp_delay(ERROR_BLINK_MS);
}
/* Ожидать подключения хоста. LED_HEARTBEAT мигает — bootloader жив. */
while (!bsp_usb_cdc_is_ready())
{
bsp_usb_cdc_poll();
bsp_led_toggle(LED_HEARTBEAT);
bsp_delay(CONNECT_BLINK_MS);
}
bsp_led_on(LED_APP);
cli_init();
/* Готово немедленно — первая попытка сразу извещает "жду SD", не ждёт
* SD_RETRY_PERIOD_MS. Дальнейшие попытки уже дросселируются периодом. */
uint32_t next_sd_retry_ms = bsp_tick_get_ms();
while (1)
{
bsp_usb_cdc_poll();
cli_process();
/* "Загрузчик жив" — короткий импульс (50 мс) + долгая пауза (450 мс),
* безусловно, вне зависимости от CDC. Специально отличим от ровного
* 50/50 мигания образа в слоте (LED_APP, 500/250 мс) иначе на глаз
* не отличить "жив загрузчик" от "прыгнули в образ". Не blocking
* bsp_delay() здесь не используется, иначе не успевали бы poll'ить
* CDC/cli с достаточной частотой. */
uint32_t heartbeat_phase_ms = bsp_tick_get_ms() % HEARTBEAT_PERIOD_MS;
if (heartbeat_phase_ms < HEARTBEAT_ON_MS)
{
bsp_led_on(LED_HEARTBEAT);
}
else
{
bsp_led_off(LED_HEARTBEAT);
}
if (qspi_ok && ((int32_t) (bsp_tick_get_ms() - next_sd_retry_ms) >= 0))
{
next_sd_retry_ms = bsp_tick_get_ms() + SD_RETRY_PERIOD_MS;
protocol_send_status("waiting_for_sd");
sd_update_check(); /* no-op быстро, если SD не вставлена */
boot_select_and_jump(); /* при успехе не возвращается */
}
}
}

View file

@ -36,3 +36,10 @@ void protocol_send_error(const char *p_code)
(void) snprintf(buf, sizeof(buf), "{\"ok\":false,\"error\":\"%s\"}\n", p_code);
cli_send(buf);
}
void protocol_send_status(const char *p_state)
{
char buf[PROTO_BUF_SIZE];
(void) snprintf(buf, sizeof(buf), "{\"type\":\"status\",\"state\":\"%s\"}\n", p_state);
cli_send(buf);
}

View file

@ -15,7 +15,12 @@
* version_response ответ на {"type":"cmd","cmd":"get_version"}
* error ошибка протокола или парсинга
*
* status-события (waiting_for_sd/installing/smoke_pass/...) добавятся в Фазах 3-4.
* Типы исходящих событий (Фаза 3):
* status top-level состояние bootloader (waiting_for_sd и т.д.,
* см. protocol_send_status())
*
* Полный словарь состояний status (smoke_pass/smoke_fail/booting/...)
* появится в Фазе 4.
*/
#ifndef PROTOCOL_H_
@ -45,4 +50,14 @@ void protocol_send_version_response(void);
*/
void protocol_send_error(const char *p_code);
/**
* @brief Отправить событие status top-level состояние bootloader.
*
* Формат: {"type":"status","state":"waiting_for_sd"}
*
* @param[in] p_state Короткий ASCII-идентификатор состояния,
* напр. "waiting_for_sd", "installing".
*/
void protocol_send_status(const char *p_state);
#endif /* PROTOCOL_H_ */

View file

@ -0,0 +1,253 @@
/**
* @file sd_update.c
* @brief Реализация см. sd_update.h.
*
* Гейт принятия кандидата двухступенчатый:
* 1. Лёгкий пик заголовка (magic + версия) сразу с SD, до касания flash
* достаточно для решения install/skip (update_policy_decide()).
* 2. Полная криптографическая проверка (hash+ECDSA) уже ПОСЛЕ записи в
* целевой слот, переиспользованием slot_version_get() (та же функция,
* что и для пика активного слота). Если кандидат подписан неверно
* slot_version_get() на целевом слоте вернёт false, а последующий
* (единственный) boot_go() в main.c просто не выберет этот слот и
* останется на прежнем валидном отдельный shim "flash_area поверх SD-
* файла" не нужен.
*
* Целевой слот при установке всегда НЕ активный (см. update_policy.h):
* уже выбранный на этот момент слот этой функцией никогда не стирается.
*/
#include "sd_update.h"
#include "bootutil/image.h"
#include "bsp/button.h"
#include "bsp/sd.h"
#include "bsp/usb_cdc.h"
#include "ff.h"
#include "flash_map.h"
#include "protocol.h"
#include "slot_version.h"
#include "update_policy.h"
#include <string.h>
/* ── Константы ─────────────────────────────────────────────────────────── */
#define SD_UPDATE_MOUNT_POINT "2:/"
#define SD_UPDATE_FILE_PATH "2:/TFT_APP.BIN"
/** @brief Размер чанка потокового копирования SD → flash. */
#define SD_UPDATE_CHUNK_SIZE 4096U
/* ── Состояние модуля ──────────────────────────────────────────────────────
* Статические буферы не на стеке (см. firmware/test/src/tests/test_usd.c
* про ограниченный стек bare-metal прошивок). */
static FATFS g_s_fs;
static FIL g_s_file;
static uint8_t g_s_chunk_buf[SD_UPDATE_CHUNK_SIZE];
static uint8_t g_s_verify_buf[SD_UPDATE_CHUNK_SIZE];
/* ── Вспомогательные функции ───────────────────────────────────────────── */
static update_policy_slot_state_t peek_slot(uint8_t fa_id)
{
update_policy_slot_state_t state;
state.valid = slot_version_get(fa_id, &state.version);
return state;
}
/**
* @brief Прочитать заголовок кандидата с начала уже открытого файла.
* @retval true magic верный версия в *p_out_ver, позиция файла = sizeof(header).
* @retval false Ошибка чтения либо неверный magic.
*/
static bool read_candidate_header(struct image_version *p_out_ver)
{
struct image_header hdr;
UINT br = 0U;
FRESULT fr = f_read(&g_s_file, &hdr, sizeof(hdr), &br);
if ((fr != FR_OK) || (br != sizeof(hdr)) || (hdr.ih_magic != IMAGE_MAGIC))
{
return false;
}
*p_out_ver = hdr.ih_ver;
return true;
}
/**
* @brief Стереть целевой слот и потоково скопировать в него файл-кандидат
* (с начала файла заголовок читается заново), сверяя каждый
* записанный чанк немедленным обратным чтением.
*
* @return true при успехе (весь файл скопирован и каждый чанк совпал).
*/
static bool erase_and_copy_candidate(const struct flash_area *p_fap, uint32_t file_size)
{
if (file_size > p_fap->fa_size)
{
return false;
}
if (flash_area_erase(p_fap, 0U, p_fap->fa_size) != 0)
{
return false;
}
if (f_lseek(&g_s_file, 0) != FR_OK)
{
return false;
}
uint32_t offset = 0U;
while (offset < file_size)
{
bsp_usb_cdc_poll();
uint32_t want = file_size - offset;
if (want > SD_UPDATE_CHUNK_SIZE)
{
want = SD_UPDATE_CHUNK_SIZE;
}
UINT br = 0U;
if ((f_read(&g_s_file, g_s_chunk_buf, want, &br) != FR_OK) || (br != want))
{
return false;
}
if (flash_area_write(p_fap, offset, g_s_chunk_buf, want) != 0)
{
return false;
}
if ((flash_area_read(p_fap, offset, g_s_verify_buf, want) != 0) ||
(memcmp(g_s_chunk_buf, g_s_verify_buf, want) != 0))
{
return false;
}
offset += want;
}
bsp_usb_cdc_poll();
return true;
}
/* ── Основной сценарий ────────────────────────────────────────────────── */
static void run_update(void)
{
struct image_version candidate_ver;
struct image_version installed_ver;
update_policy_slot_state_t slot_a;
update_policy_slot_state_t slot_b;
update_policy_result_t decision;
const struct flash_area *p_fap = NULL;
uint32_t file_size;
bool button_held;
bool copy_ok;
if (bsp_sd_init() != BSP_OK)
{
return;
}
if (f_mount(&g_s_fs, SD_UPDATE_MOUNT_POINT, 1) != FR_OK)
{
(void) bsp_sd_deinit();
return; /* нет карты/файловой системы — штатно, не ошибка */
}
if (f_open(&g_s_file, SD_UPDATE_FILE_PATH, FA_READ) != FR_OK)
{
(void) f_unmount(SD_UPDATE_MOUNT_POINT);
(void) bsp_sd_deinit();
return; /* TFT_APP.BIN отсутствует — тоже штатно */
}
if (!read_candidate_header(&candidate_ver))
{
protocol_send_error("SD_CANDIDATE_INVALID");
goto cleanup;
}
file_size = (uint32_t) f_size(&g_s_file);
slot_a = peek_slot(0U);
slot_b = peek_slot(1U);
button_held = bsp_button_read(BSP_BUTTON_1);
decision = update_policy_decide(&slot_a, &slot_b, &candidate_ver, button_held);
if (decision.action == UPDATE_POLICY_SKIP)
{
protocol_send_status("update_skipped");
goto cleanup;
}
protocol_send_status("installing");
if (flash_area_open((uint8_t) decision.target_slot, &p_fap) != 0)
{
protocol_send_error("SD_INSTALL_WRITE_FAILED");
goto cleanup;
}
copy_ok = erase_and_copy_candidate(p_fap, file_size);
flash_area_close(p_fap);
if (!copy_ok)
{
protocol_send_error("SD_INSTALL_WRITE_FAILED");
goto cleanup;
}
if (!slot_version_get((uint8_t) decision.target_slot, &installed_ver))
{
protocol_send_error("SD_INSTALL_REJECTED");
goto cleanup;
}
/* Форс. даунгрейд (см. update_policy.h): без стирания прежнего активного
* слота он остался бы валиден и новее только что установленного, и
* снова выиграл бы в boot_go() даунгрейд физически записался бы, но
* не загрузился. Стираем ТОЛЬКО теперь, когда новый образ уже подтверждён
* валидным на диске никогда не бывает нуля рабочих слотов. */
if (decision.erase_previous_active)
{
const struct flash_area *p_peer_fap;
uint8_t peer_slot = (decision.target_slot == UPDATE_POLICY_SLOT_A) ? UPDATE_POLICY_SLOT_B
: UPDATE_POLICY_SLOT_A;
if (flash_area_open(peer_slot, &p_peer_fap) != 0)
{
protocol_send_error("SD_DOWNGRADE_ERASE_FAILED");
}
else
{
if (flash_area_erase(p_peer_fap, 0U, p_peer_fap->fa_size) != 0)
{
protocol_send_error("SD_DOWNGRADE_ERASE_FAILED");
}
flash_area_close(p_peer_fap);
}
}
cleanup:
(void) f_close(&g_s_file);
(void) f_unmount(SD_UPDATE_MOUNT_POINT);
(void) bsp_sd_deinit();
}
/* ── Public API ────────────────────────────────────────────────────────── */
void sd_update_check(void)
{
if (!bsp_sd_is_inserted())
{
return;
}
run_update();
}

View file

@ -0,0 +1,28 @@
/**
* @file sd_update.h
* @brief Оркестрация установки образа tft_app с microSD в неактивный слот.
*
* См. firmware/bootloader/PLAN.md, Фаза 3.
*/
#ifndef SD_UPDATE_H_
#define SD_UPDATE_H_
/**
* @brief Одна попытка: смонтировать SD, найти TFT_APP.BIN, при необходимости
* установить его в неактивный слот.
*
* Пишет только в неактивный слот (см. update_policy.h) уже выбранный/
* загружаемый слот никогда не трогается. Сам не вызывает boot_go()/
* boot_select_and_jump() решение "когда прыгать" остаётся за main.c,
* которое обязано вызвать его ровно один раз за сессию питания (см.
* slot_version.h о том, почему boot_go() нельзя звать повторно).
*
* Ничего не делает, если SD не вставлена (bsp_sd_is_inserted() == false)
* безопасно вызывать многократно, в т.ч. из цикла ожидания в main.c.
*
* @pre bsp_qspi_init() уже вызван.
*/
void sd_update_check(void);
#endif /* SD_UPDATE_H_ */

View file

@ -0,0 +1,65 @@
/**
* @file slot_version.c
* @brief Реализация см. slot_version.h.
*
* Вызов bootutil_img_validate() зеркалит loader.c::boot_image_check()
* (enc_state=NULL шифрование образов не используется, image_index=0
* MCUBOOT_IMAGE_NUMBER=1, seed=NULL/0 FIH_PROFILE_LOW не использует RNG-
* задержку, out_hash=NULL хэш нам не нужен, только факт валидности+версия).
*
* FIH_CALL безопасен вне boot_go(): CFI-счётчик (FIH_ENABLE_CFI под LOW
* профилем) сохраняется/инкрементируется в FIH_CFI_PRECALL_BLOCK и
* проверяется/возвращается к сохранённому значению в FIH_CFI_POSTCALL_BLOCK
* пара самобалансирующаяся на каждый вызов, не накапливающееся состояние
* между вызовами (см. fault_injection_hardening.h). Несколько вызовов подряд
* (Slot A, Slot Б, кандидат) и последующий отдельный boot_go() не влияют друг
* на друга через этот счётчик.
*/
#include "slot_version.h"
#include "bootutil/fault_injection_hardening.h"
#include "flash_map.h"
#include <stddef.h> /* NULL */
/*
* Совпадает с BOOT_TMPBUF_SZ в sdk/middleware/mcuboot_opensource/boot/bootutil/
* src/bootutil_priv.h приватный заголовок bootutil (в src/, не в include/),
* поэтому не включаем его напрямую. Тот же размер, что loader.c использует
* для этого же вызова.
*/
#define SLOT_VERSION_TMPBUF_SIZE 256U
bool slot_version_get(uint8_t fa_id, struct image_version *p_out_ver)
{
const struct flash_area *p_fap;
if (flash_area_open(fa_id, &p_fap) != 0)
{
return false;
}
struct image_header hdr;
bool read_ok = (flash_area_read(p_fap, 0U, &hdr, sizeof(hdr)) == 0);
if (!read_ok || (hdr.ih_magic != IMAGE_MAGIC))
{
flash_area_close(p_fap);
return false;
}
static uint8_t s_tmpbuf[SLOT_VERSION_TMPBUF_SIZE];
fih_ret fih_rc;
FIH_CALL(bootutil_img_validate, fih_rc, NULL, 0, &hdr, p_fap, s_tmpbuf, sizeof(s_tmpbuf), NULL,
0, NULL);
flash_area_close(p_fap);
if (!FIH_EQ(fih_rc, FIH_SUCCESS))
{
return false;
}
*p_out_ver = hdr.ih_ver;
return true;
}

View file

@ -0,0 +1,37 @@
/**
* @file slot_version.h
* @brief Read-only "пик" версии образа в слоте bootutil без побочных
* эффектов на flash.
*
* В отличие от boot_go(): для образа, ни разу не подтверждённого приложением,
* bootutil (Direct-XIP-Revert) пишет copy_done в трейлер слота уже на этапе
* выбора вызов boot_go() второй раз за одну сессию питания принял бы этот
* флаг за "образ уже грузился и не подтвердился" и стёр бы его (см.
* firmware/bootloader/PLAN.md, Фаза 3). slot_version_get() читает и валидирует
* слот через flash_area_read()/bootutil_img_validate() напрямую оба
* read-only (image_validate.c не пишет в flash), в обход boot_go().
*
* Валидация полная (hash + ECDSA-подпись через bootutil_img_validate()),
* тот же путь, что loader.c::boot_image_check() использует для каждого слота
* при штатной загрузке, а не самодельная проверка одного заголовка.
*/
#ifndef SLOT_VERSION_H_
#define SLOT_VERSION_H_
#include "bootutil/image.h"
#include <stdbool.h>
#include <stdint.h>
/**
* @brief Прочитать и провалидировать образ в слоте fa_id.
*
* @param[in] fa_id ID области (см. sysflash.h) 0 = Slot A, 1 = Slot Б.
* @param[out] p_out_ver Версия образа при успехе. Не тронут при false.
* @retval true Валидный образ (magic, hash и подпись прошли) версия в *p_out_ver.
* @retval false Слот пуст/повреждён/подпись не прошла, либо ошибка чтения/открытия.
*/
bool slot_version_get(uint8_t fa_id, struct image_version *p_out_ver);
#endif /* SLOT_VERSION_H_ */

View file

@ -0,0 +1,100 @@
/**
* @file update_policy.c
* @brief Реализация см. update_policy.h.
*/
#include "update_policy.h"
/* ── Сравнение версий ──────────────────────────────────────────────────── */
int image_version_compare(const struct image_version *p_ver1, const struct image_version *p_ver2)
{
if (p_ver1->iv_major != p_ver2->iv_major)
{
return (p_ver1->iv_major > p_ver2->iv_major) ? 1 : -1;
}
if (p_ver1->iv_minor != p_ver2->iv_minor)
{
return (p_ver1->iv_minor > p_ver2->iv_minor) ? 1 : -1;
}
if (p_ver1->iv_revision != p_ver2->iv_revision)
{
return (p_ver1->iv_revision > p_ver2->iv_revision) ? 1 : -1;
}
return 0;
}
/* ── Определение активного слота ──────────────────────────────────────── */
typedef struct
{
bool have_active;
update_policy_slot_t active_slot;
struct image_version active_ver;
} active_slot_info_t;
static active_slot_info_t find_active_slot(const update_policy_slot_state_t *p_slot_a,
const update_policy_slot_state_t *p_slot_b)
{
active_slot_info_t info = { .have_active = false };
if (p_slot_a->valid)
{
info.have_active = true;
info.active_slot = UPDATE_POLICY_SLOT_A;
info.active_ver = p_slot_a->version;
}
if (p_slot_b->valid &&
(!info.have_active || image_version_compare(&p_slot_b->version, &info.active_ver) > 0))
{
info.have_active = true;
info.active_slot = UPDATE_POLICY_SLOT_B;
info.active_ver = p_slot_b->version;
}
return info;
}
static update_policy_slot_t other_slot(update_policy_slot_t slot)
{
return (slot == UPDATE_POLICY_SLOT_A) ? UPDATE_POLICY_SLOT_B : UPDATE_POLICY_SLOT_A;
}
/* ── Публичный API ─────────────────────────────────────────────────────── */
update_policy_result_t update_policy_decide(const update_policy_slot_state_t *p_slot_a,
const update_policy_slot_state_t *p_slot_b,
const struct image_version *p_candidate_ver,
bool button_held)
{
active_slot_info_t active = find_active_slot(p_slot_a, p_slot_b);
if (!active.have_active)
{
return (update_policy_result_t) { .action = UPDATE_POLICY_INSTALL,
.target_slot = UPDATE_POLICY_SLOT_A,
.erase_previous_active = false };
}
int cmp = image_version_compare(p_candidate_ver, &active.active_ver);
bool candidate_newer = (cmp > 0);
bool forced_downgrade = (cmp < 0) && button_held;
if (!candidate_newer && !forced_downgrade)
{
return (update_policy_result_t) { .action = UPDATE_POLICY_SKIP };
}
/* Форс. даунгрейд без стирания прежнего активного слота не имел бы
* эффекта он остаётся валиден и новее, и снова выиграет в boot_go().
* "Новее" не требует стирания прежний активный сам проиграет
* сравнение версий естественным путём. */
return (update_policy_result_t) { .action = UPDATE_POLICY_INSTALL,
.target_slot = other_slot(active.active_slot),
.erase_previous_active = forced_downgrade };
}

View file

@ -0,0 +1,95 @@
/**
* @file update_policy.h
* @brief Чистая логика решения "устанавливать ли SD-кандидат" без
* аппаратных зависимостей (flash/FatFS), полностью host-тестируема.
*
* См. firmware/bootloader/PLAN.md, Фаза 3.
*/
#ifndef UPDATE_POLICY_H_
#define UPDATE_POLICY_H_
#include "bootutil/image.h"
#include <stdbool.h>
/** @brief Логический слот (индекс сисфлеша, не физический адрес). */
typedef enum
{
UPDATE_POLICY_SLOT_A = 0,
UPDATE_POLICY_SLOT_B = 1,
} update_policy_slot_t;
/** @brief Состояние одного слота глазами вызывающего (см. slot_version.h). */
typedef struct
{
bool valid; /**< Есть валидный (прошедший bootutil_img_validate) образ. */
struct image_version version; /**< Значимо только если valid == true. */
} update_policy_slot_state_t;
typedef enum
{
UPDATE_POLICY_SKIP = 0,
UPDATE_POLICY_INSTALL,
} update_policy_action_t;
typedef struct
{
update_policy_action_t action;
update_policy_slot_t target_slot; /**< Значим только если action == UPDATE_POLICY_INSTALL. */
/**
* Значим только при action == UPDATE_POLICY_INSTALL. Если true
* вызывающий код обязан, ПОСЛЕ успешной установки и пост-записи
* валидации target_slot, стереть слот, который был активным ДО
* установки (см. rationale ниже про форс. даунгрейд).
*/
bool erase_previous_active;
} update_policy_result_t;
/**
* @brief Решить, устанавливать ли SD-кандидат, и в какой слот.
*
* Правила:
* - "Активный" слот валидный слот с более высокой версией; если валиден
* только один он активный; если ни одного активного слота нет.
* - Целевой слот установки всегда НЕ активный (активный не перезаписываем
* никогда, независимо от исхода сравнения версий). Если активного слота
* нет по умолчанию Slot A.
* - Кандидат новее активного (или активного слота нет вообще) INSTALL,
* erase_previous_active = false. Прежний активный слот сам проиграет
* сравнение версий в boot_go() стирать его не нужно.
* - Кандидат старше или равен активному, кнопка не удержана SKIP.
* - Кандидат старше активного, кнопка удержана INSTALL,
* erase_previous_active = true. Без этого форс. даунгрейд не имел бы
* эффекта: прежний (более новый) активный слот остался бы валиден и
* снова выиграл бы сравнение версий в boot_go(), несмотря на успешную
* запись более старого образа в другой слот. Стирание обязанность
* вызывающего кода и только ПОСЛЕ подтверждения, что только что
* установленный образ валиден (иначе на короткое время не осталось бы
* ни одного рабочего слота).
* - Кандидат равен активному, кнопка удержана SKIP (не форсируем
* переустановку той же версии).
*
* @param[in] p_slot_a Состояние Slot A.
* @param[in] p_slot_b Состояние Slot Б.
* @param[in] p_candidate_ver Версия образа-кандидата на SD.
* @param[in] button_held Кнопка даунгрейда (BSP_BUTTON_1) удержана на старте.
*/
update_policy_result_t update_policy_decide(const update_policy_slot_state_t *p_slot_a,
const update_policy_slot_state_t *p_slot_b,
const struct image_version *p_candidate_ver,
bool button_held);
/**
* @brief Сравнить версии образов: major.minor.revision, без build_num то
* же соглашение, что boot_version_cmp() в sdk/.../bootutil/loader.c
* (static там, не экспортируется здесь свой аналог).
*
* @retval <0 p_ver1 < p_ver2
* @retval 0 p_ver1 == p_ver2
* @retval >0 p_ver1 > p_ver2
*/
int image_version_compare(const struct image_version *p_ver1, const struct image_version *p_ver2);
#endif /* UPDATE_POLICY_H_ */

View file

@ -0,0 +1,197 @@
# Фаза 3 (SD-путь установки) — аппаратная верификация
Памятка с готовыми командами: сборка, прошивка, подготовка SD-карты, чек-лист. Продолжение
[HARDWARE_VERIFICATION_PHASE2.md](HARDWARE_VERIFICATION_PHASE2.md) — предполагает, что прошивка
bootloader и заглушки слотов (`test_stub`) уже знакомы по Фазе 2.
Все команды — из корня репозитория (`tft_manufacture_test/`). Сборка и host-тесты — в devcontainer;
прошивка/отладка/проверки на железе — на стороне пользователя.
**Статус: ✅ все 5 сценариев пройдены на реальной плате (2026-07-10).** Историю бага
card-detect и его разрешение (чтение USDHC `PRES_STATE.CINST`) см. в
[../DEBUG_LOG_PHASE3_SD.md](../DEBUG_LOG_PHASE3_SD.md).
---
## 0. Предпосылки
- `firmware/bootloader` собран с ARM-стороной Фазы 3 (`update_policy` + `slot_version` + `sd_update` +
`bootloader_fatfs`). `main.c` перед выбором слота сканирует microSD: если вставлена и на ней лежит
подписанный `TFT_APP.BIN` — по политике версий (или по удержанию кнопки) ставит его в НЕактивный
слот, затем `boot_select_and_jump()`.
- Заглушка вместо ещё не существующего `tft_app` — та же `firmware/bootloader/test_stub/`, что и в
Фазе 2: два образа, различающиеся частотой мигания `LED_APP` (по частоте видно, какой слот реально
выбран). Оба подписаны тестовым sample-ключом MCUboot.
- **Card detect** — через регистр USDHC `PRES_STATE.CINST`, не GPIO (см. DEBUG_LOG). На пустом слоте
гейт `bsp_sd_is_inserted()` обязан быть верным, иначе блокирующий `SD_PollingCardInsert()` без
тайм-аута подвесит загрузчик; PRSSTAT это обеспечивает.
- **Обратная связь** — USB CDC ACM, тот же JSON-lines протокол, что в Фазе 1/2, плюс события
`status` (см. §6).
---
## 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/` — те же файлы, что в Фазе 2:
```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)
```
Частоты мигания `LED_APP` (по ним отличаем слот): **stub_a (v1) ≈ 1 раз/сек**, **stub_b (v2) ≈ 2
раза/сек**.
---
## 2. Прошивка bootloader
```bash
just host::flash-swd-bootloader-debug
# обязателен power cycle платы после прошивки — SWD-запись не ресетит автоматически
```
---
## 3. Прошивка образов в слоты напрямую (pyOCD)
Нужно только для сценариев, где слот должен быть заполнен ДО теста (2, 3, 5) — установка с SD слоты
заполняет сама. Способ идентичен Фазе 2 (сырой подписанный `.bin` по адресу слота, не через
`flash_swd.py`).
**Важно:** `uv run --directory tools/hil` меняет рабочую директорию у самого `pyocd` — путь к `.bin`
должен быть абсолютным (`"$(pwd)/..."`).
```bash
# Slot A (0x60040000)
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)
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"
```
---
## 4. Стирание слота
Адрес — позиционный аргумент, формат диапазона `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. Подготовка SD-карты
Карта — FAT32/FAT16. Bootloader ищет в корне файл **`TFT_APP.BIN`** (путь `2:/TFT_APP.BIN`, диск `2:`
— внутренняя нумерация FatFS, физически это корень карты). Файл — подписанный образ-заглушка,
просто переименованный:
```bash
# Кандидат-"новее" (v2) — для авто-установки/сравнения версий
cp build/Debug/signed/stub_b_v2_confirmed.bin /Volumes/<SD>/TFT_APP.BIN
# Кандидат-"старше" (v1) — для теста даунгрейда (сценарий 3)
cp build/Debug/signed/stub_a_v1_confirmed.bin /Volumes/<SD>/TFT_APP.BIN
# Кандидат с битой ПОДПИСЬЮ (magic цел, TLV/хэш испорчен) — сценарий 4b (SD_INSTALL_REJECTED)
cp build/Debug/signed/stub_b_v2_confirmed.bin /Volumes/<SD>/TFT_APP.BIN
# затем поменять один байт в СЕРЕДИНЕ файла (payload), напр. в hex-редакторе — magic в начале не трогать
# Кандидат с битым ЗАГОЛОВКОМ (magic) — сценарий 4a (SD_CANDIDATE_INVALID)
# поменять один из первых 4 байт файла (ih_magic)
```
Берём `--confirm`-варианты: иначе установленный с SD образ на следующем power cycle
откатится (Direct-XIP Revert, см. Фаза 2 / сценарий 5 там).
> ⚠️ **Про извлечение/повторный скан карты без ресета.** Детект на пустом слоте читает PRSSTAT при
> пине на `USDHC1_CD_B`; после первого же `bsp_sd_init()` пин перемапливается на `GPIO2_IO28` и
> обратно не возвращается до перезагрузки. Практическое следствие для теста: **после каждого
> сценария делай power cycle** (и так обязателен, см. ниже), не рассчитывай на «вынул/вставил карту
> на живом загрузчике». Подробности и рекомендованная консолидация — в DEBUG_LOG (раунд 3,
> «латентная хрупкость re-scan»).
---
## 6. CDC-статусы и проверка "bootloader жив"
Подключиться к USB CDC ACM платы любым терминалом (см. [../README.md](../README.md)):
```bash
screen /dev/cu.usbmodemXXXX # macOS, порт свой у каждого подключения
```
- Живость: отправить `{"type":"cmd","cmd":"ping"}` → ответ `{"type":"pong"}`.
- События SD-пути (`{"type":"status","state":"..."}`):
| state | когда |
| ----------------- | ------------------------------------------------------------------ |
| `waiting_for_sd` | периодически в цикле ожидания (нет валидного слота, ждём SD) |
| `installing` | принято решение установить кандидата, начинается запись в слот |
| `update_skipped` | кандидат отклонён по версии (старше/равен, кнопка не удержана) |
- Ошибки (`{"ok":false,"error":"..."}`):
| error | когда |
| --------------------------- | -------------------------------------------------------------- |
| `SD_CANDIDATE_INVALID` | битый magic заголовка кандидата (до записи во flash) |
| `SD_INSTALL_WRITE_FAILED` | сбой записи/verify чанка во flash |
| `SD_INSTALL_REJECTED` | образ записан, но пост-проверка (hash+ECDSA) не прошла |
| `SD_DOWNGRADE_ERASE_FAILED` | не удалось стереть прежний активный слот при форс. даунгрейде |
---
## 7. Тайминг удержания кнопки даунгрейда (BSP_BUTTON_1)
**Держать кнопку с момента подачи питания и не отпускать, пока по CDC не придёт
`status: installing`** (или пока `LED_APP` не замигает с частотой более старого образа). Ориентир —
**~15 секунд, с запасом**; отпускание на 2 c даунгрейд НЕ вызывает.
Почему так (разбор по коду — [../DEBUG_LOG_PHASE3_SD.md](../DEBUG_LOG_PHASE3_SD.md), раунд 3):
`bsp_button_read()` — мгновенное сырое чтение, без debounce и без latch. Кнопка сэмплится ровно
один раз, в `sd_update.c` (`run_update()`), и этот момент наступает **после** `bsp_sd_init()`,
`f_mount()` (полная инициализация карты), `f_open()` и **двух полных крипто-валидаций слотов**
(SHA-256 + ECDSA по каждому). Это несколько секунд слепого окна без LED-фидбэка (heartbeat идёт
только в главном цикле, до которого управление ещё не дошло). Отпустишь раньше сэмпла — прочитается
"не нажата" → штатная загрузка нового слота. «Срабатывает сразу после отпускания» — это не триггер
по отпусканию (кода на отпускание нет), а совпадение: решение защёлкивается на сэмпле, пока кнопка
ещё нажата.
> Известное неудобство (в очереди на доработку, не реализовано): сэмплить кнопку РАНО, при старте,
> до медленной SD-инициализации — тогда «удержание при включении» стало бы предсказуемым.
---
## 8. Чек-лист сценариев
Между КАЖДЫМ сценарием — обязательный power cycle платы (SWD-запись/установка не ресетят
автоматически). Зеркалит план верификации Фазы 3 (см. [../PLAN.md](../PLAN.md)).
| № | Сценарий | Подготовка | Ожидаемый результат |
| --- | -------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| 1 | Чистая плата + валидный образ на SD | Erase Slot A и Slot Б (§4); SD с `TFT_APP.BIN` = `stub_b_v2_confirmed.bin` (§5); вставить карту | CDC: `installing`; авто-установка в Slot A → прыжок; `LED_APP` мигает ~2/сек (v2) |
| 2 | Кандидат старше установленного, без кнопки | Slot Б = `stub_b_v2_confirmed.bin` (§3); SD `TFT_APP.BIN` = `stub_a_v1_confirmed.bin` (v1); кнопку НЕ трогать | CDC: `update_skipped`; прыжок в существующий Slot Б; `LED_APP` ~2/сек (v2 не тронут) |
| 3 | Форс. даунгрейд по кнопке | Как сценарий 2, но **удерживать `BSP_BUTTON_1`** при включении (см. §7 — до `installing`, ~15 c) | CDC: `installing`; v1 ставится в Slot A, прежний активный Slot Б стирается; прыжок в v1; `LED_APP` ~1/сек |
| 4a | Битый заголовок кандидата | SD `TFT_APP.BIN` с испорченным magic (§5); слоты — как удобно (напр. Slot A = v1) | CDC: `error: SD_CANDIDATE_INVALID`; плата остаётся на прежнем валидном слоте (или ждёт SD, если слотов нет) |
| 4b | Битая подпись/TLV кандидата | SD `TFT_APP.BIN` с целым magic, но испорченным payload (§5) | CDC: `installing` затем `error: SD_INSTALL_REJECTED`; прежний валидный слот не тронут (записанный кандидат не выбирается) |
| 5 | Бандл залит в слот заранее (SD не участвует) | `stub_a_v1_confirmed.bin` → Slot A (§3); карту НЕ вставлять | SD-логика не входит; прыжок в Slot A; `LED_APP` ~1/сек. `ping`→`pong` при желании |
**Инструмент подтверждения «прыжок реально произошёл»** — тот же, что в Фазе 2: частота мигания
`LED_APP` (500 мс vs 250 мс) однозначно указывает на выбранный слот, верифицируемо глазами.
---

View file

@ -65,6 +65,7 @@ __attribute__((used))
__attribute__((noinline))
void fih_panic_loop(void)
{
#if defined(__arm__)
__asm volatile ("b fih_panic_loop");
__asm volatile ("b fih_panic_loop");
__asm volatile ("b fih_panic_loop");
@ -74,5 +75,17 @@ void fih_panic_loop(void)
__asm volatile ("b fih_panic_loop");
__asm volatile ("b fih_panic_loop");
__asm volatile ("b fih_panic_loop");
#else
/* Host-порт (см. tests/host/mcuboot_port/): "b fih_panic_loop" — валидная
* мнемоника только для ARM/Thumb. На x86_64 ассемблер падает ("invalid
* instruction mnemonic 'b'") — отсюда падение test-host-release в CI
* (x86_64-раннер), которого нет в devcontainer на arm64 (там та же
* мнемоника случайно ассемблируется, но не несёт смысла это не
* реальный fault-injection путь, а хостовая сборка). Реальный ARM-таргет
* (__arm__ определён) использует оригинальный код выше без изменений. */
for (;;)
{
}
#endif
}
#endif /* FIH_ENABLE_GLOBAL_FAIL */

View file

@ -244,3 +244,57 @@ target_compile_definitions(
PRIVATE FIXTURES_DIR="${CMAKE_CURRENT_SOURCE_DIR}/mcuboot_port/fixtures")
target_compile_options(test_mcuboot_boot_select PRIVATE -w)
# -----------------------------------------------------------------------------
# update_policy — чистая логика решения install/skip + целевой слот (Фаза 3,
# SD-путь установки). Нужен только заголовок bootutil/image.h (struct
# image_version) — без линковки самого bootutil, но fault_injection_hardening.h
# (включается из image.h) требует mcuboot_config/mcuboot_config.h в include-
# путях, поэтому переиспользуем MCUBOOT_BOOTUTIL_INCLUDES целиком (уже задан
# выше через bootutil_sources.cmake).
# -----------------------------------------------------------------------------
add_host_test(
NAME
test_update_policy
SOURCES
update_policy/test_update_policy.c
${PROJECT_SOURCE_DIR}/firmware/bootloader/src/update_policy.c
INCLUDES
${PROJECT_SOURCE_DIR}/firmware/bootloader/src
${MCUBOOT_BOOTUTIL_INCLUDES})
# -----------------------------------------------------------------------------
# slot_version — read-only пик версии слота через bootutil_img_validate()
# (Фаза 3). Реальный bootutil + TinyCrypt поверх fake_flash_map_backend.c —
# переиспользует те же фикстуры, что и test_mcuboot_boot_select (Фаза 2).
# -----------------------------------------------------------------------------
add_host_test(
NAME
test_slot_version
SOURCES
slot_version/test_slot_version.c
${PROJECT_SOURCE_DIR}/firmware/bootloader/src/slot_version.c
mcuboot_port/fake_flash_map_backend.c
mcuboot_port/host_link_shims.c
${CMAKE_SOURCE_DIR}/firmware/bootloader/mcuboot_port/keys.c
${MCUBOOT_BOOTUTIL_SOURCES}
INCLUDES
${PROJECT_SOURCE_DIR}/firmware/bootloader/src
${MCUBOOT_BOOTUTIL_INCLUDES}
${CMAKE_CURRENT_SOURCE_DIR}/mcuboot_port)
target_compile_definitions(
test_slot_version PRIVATE FIXTURES_DIR="${CMAKE_CURRENT_SOURCE_DIR}/mcuboot_port/fixtures")
target_compile_options(test_slot_version PRIVATE -w)
# slot_version.c зовёт FIH_CALL(bootutil_img_validate, ...) напрямую — тот же
# баг clang 22.1.8 (Homebrew) с генерацией CFI-директив под ASan/UBSan, что
# уже обойдён для вендоренных файлов bootutil в bootutil_sources.cmake
# ("invalid CFI advance_loc expression"); здесь тот же обход для нашего
# файла, единственного, где эта макро-развёртка встречается вне bootutil.
if(CMAKE_C_COMPILER_ID MATCHES "Clang")
set_source_files_properties(
${PROJECT_SOURCE_DIR}/firmware/bootloader/src/slot_version.c
PROPERTIES COMPILE_OPTIONS "-fno-sanitize=address,undefined")
endif()

View file

@ -0,0 +1,62 @@
# test_slot_version
## Модуль под тестом
`firmware/bootloader/src/slot_version.c` (`slot_version.h`) — read-only
«пик» версии образа в слоте bootutil (Фаза 3), без побочных эффектов на
flash: в отличие от `boot_go()`, не проверяет и не изменяет статус
confirm/copy_done.
## Категория
B — интеграционный host-тест: реальный `bootutil`
(`bootutil_img_validate()`, полная проверка hash + ECDSA-подписи)
линкуется поверх фейкового flash-бэкенда вместо `bsp_qspi_flash`.
Переиспользует фейковый backend и фикстуры из `tests/host/mcuboot_port/`
те же, что и `test_mcuboot_boot_select`.
## Моки
- `fake_flash_map_backend.c/.h` (переиспользуется из `mcuboot_port/`) —
тестовая реализация контракта `flash_map.h` поверх памяти хоста вместо
`bsp_qspi_flash`. Не fff-мок — полноценная тестовая реализация backend'а.
- `host_link_shims.c` (переиспользуется из `mcuboot_port/`) — заглушки
символов, недостающих только при линковке `bootutil` на хосте; не
участвуют в тестируемой логике.
- `fixtures/*.bin` (общие с `test_mcuboot_boot_select`) — реальные,
подписанные `imgtool` тестовым ключом MCUboot образы (`valid_v1`,
`corrupt_v1`, `valid_v2_unconfirmed`).
## Что проверяется
- **Валидный слот**`slot_version_get()` возвращает `true` и версию,
совпадающую с версией в заголовке фикстуры (major/minor/revision).
- **Повреждённый слот** (битый хэш/подпись) — возвращает `false`.
- **Пустой слот** (стёрт в `0xFF`, magic не совпадает с `IMAGE_MAGIC`) —
возвращает `false`.
- **Неподтверждённый, но валидный образ** (`valid_v2_unconfirmed.bin`) —
версия всё равно читается, и повторный вызов `slot_version_get()` на том
же слоте по-прежнему успешен — слот не «стирается», как это сделал бы
повторный `boot_go()`.
## Гарантии
- `slot_version_get()` не имеет побочных эффектов на flash — не читает и
не изменяет статус confirm/copy_done, поэтому безопасен для
многократного вызова и вызова до первого `boot_go()`.
- Валидация полная — тот же путь `bootutil_img_validate()`, что
использует `loader.c` для каждого слота при штатной загрузке, а не
упрощённая проверка одного заголовка.
- Идемпотентность: повторные вызовы для одного и того же
неподтверждённого образа дают одинаковый результат — в отличие от
`boot_go()`, который стирает неподтверждённый образ при повторном
вызове (см. `test_mcuboot_boot_select::test_boot_go_reverts_unconfirmed_image`).
## Запуск
```bash
ctest --preset host-debug-test -R test_slot_version -V
```
Путь к фикстурам (`FIXTURES_DIR`) общий с `test_mcuboot_boot_select` и
прокидывается автоматически через `tests/host/CMakeLists.txt`.

View file

@ -0,0 +1,138 @@
/**
* @file test_slot_version.c
* @brief Host-тесты slot_version_get() read-only пик версии слота.
*
* Переиспользует фейковый flash-бэкенд и фикстуры
* tests/host/mcuboot_port/ (Фаза 2) реальный bootutil (hash+ECDSA-подпись)
* поверх in-memory буфера вместо bsp_qspi_flash, те же подписанные imgtool
* образы.
*/
#include "unity.h"
#include "fake_flash_map_backend.h"
#include "slot_version.h"
#include <stdio.h>
#include <string.h>
#ifndef FIXTURES_DIR
#error "FIXTURES_DIR must be defined by CMake (see tests/host/CMakeLists.txt)"
#endif
static uint8_t g_s_fixture_buf[FAKE_FLASH_SLOT_SIZE];
static size_t load_fixture(const char *p_name)
{
char path[256];
(void) snprintf(path, sizeof(path), "%s/%s", FIXTURES_DIR, p_name);
FILE *p_file = fopen(path, "rb");
TEST_ASSERT_NOT_NULL_MESSAGE(p_file, path);
size_t n = fread(g_s_fixture_buf, 1U, sizeof(g_s_fixture_buf), p_file);
(void) fclose(p_file);
TEST_ASSERT_EQUAL_UINT32(FAKE_FLASH_SLOT_SIZE, n);
return n;
}
/* Версия зашита в первых байтах фикстуры (image_header) — читаем её
* напрямую из буфера вместо того чтобы угадывать/дублировать число в тесте. */
static struct image_version fixture_header_version(void)
{
struct image_header hdr;
memcpy(&hdr, g_s_fixture_buf, sizeof(hdr));
return hdr.ih_ver;
}
void setUp(void)
{
fake_flash_reset();
}
void tearDown(void)
{
}
/* ── Валидный слот ─────────────────────────────────────────────────────── */
void test_valid_slot_returns_its_header_version(void)
{
size_t len = load_fixture("valid_v1.bin");
struct image_version expected_ver = fixture_header_version();
fake_flash_write_slot(0, g_s_fixture_buf, len);
struct image_version actual_ver;
bool ok = slot_version_get(0U, &actual_ver);
TEST_ASSERT_TRUE(ok);
TEST_ASSERT_EQUAL_UINT8(expected_ver.iv_major, actual_ver.iv_major);
TEST_ASSERT_EQUAL_UINT8(expected_ver.iv_minor, actual_ver.iv_minor);
TEST_ASSERT_EQUAL_UINT16(expected_ver.iv_revision, actual_ver.iv_revision);
}
/* ── Повреждённый слот ─────────────────────────────────────────────────── */
void test_corrupt_slot_returns_false(void)
{
size_t len = load_fixture("corrupt_v1.bin");
fake_flash_write_slot(1, g_s_fixture_buf, len);
struct image_version ver;
bool ok = slot_version_get(1U, &ver);
TEST_ASSERT_FALSE(ok);
}
/* ── Пустой слот ───────────────────────────────────────────────────────── */
void test_empty_slot_returns_false(void)
{
/* fake_flash_reset() в setUp уже оставил слот стёртым (0xFF) — magic
* не совпадёт с IMAGE_MAGIC. */
struct image_version ver;
bool ok = slot_version_get(0U, &ver);
TEST_ASSERT_FALSE(ok);
}
/* ── Неподтверждённый, но валидный образ ──────────────────────────────────
*
* Ключевая гарантия slot_version_get(): в отличие от boot_go(), статус
* confirm/copy_done не проверяется и не изменяется вызов идемпотентен,
* безопасен сколько угодно раз и до первого boot_go() (см. slot_version.h).
*/
void test_unconfirmed_valid_image_still_reports_version(void)
{
size_t len = load_fixture("valid_v2_unconfirmed.bin");
struct image_version expected_ver = fixture_header_version();
fake_flash_write_slot(0, g_s_fixture_buf, len);
struct image_version actual_ver;
TEST_ASSERT_TRUE(slot_version_get(0U, &actual_ver));
TEST_ASSERT_EQUAL_UINT8(expected_ver.iv_major, actual_ver.iv_major);
/* Повторный вызов — по-прежнему успешен, слот не "стёрт", в отличие от
* повторного boot_go() (см. test_mcuboot_boot_select::
* test_boot_go_reverts_unconfirmed_image, где второй boot_go() как раз
* стирает такой же образ). */
TEST_ASSERT_TRUE(slot_version_get(0U, &actual_ver));
}
/* ── Точка входа ───────────────────────────────────────────────────────── */
int main(void)
{
UNITY_BEGIN();
RUN_TEST(test_valid_slot_returns_its_header_version);
RUN_TEST(test_corrupt_slot_returns_false);
RUN_TEST(test_empty_slot_returns_false);
RUN_TEST(test_unconfirmed_valid_image_still_reports_version);
return UNITY_END();
}

View file

@ -0,0 +1,55 @@
# test_update_policy
## Модуль под тестом
`firmware/bootloader/src/update_policy.c` (`update_policy.h`) — чистая
логика решения «устанавливать ли SD-кандидат и в какой слот», без
аппаратных зависимостей (flash/FatFS). Часть Фазы 3 (SD update path), см.
`firmware/bootloader/PLAN.md`.
## Категория
A — платформонезависимый модуль. Зависит только от типа
`struct image_version` из `bootutil/image.h`, сам `bootutil` не линкуется.
Только Unity.
## Моки
Нет — тестируется напрямую, без фейков/стабов. Для сборки нужны только
include-пути до `bootutil/image.h` (`mcuboot_config`), реальный `bootutil`
в тест не линкуется.
## Что проверяется
- **image_version_compare()** — старшинство `major` побеждает `minor`
побеждает `revision`; `build_num` игнорируется (версии, отличающиеся
только номером сборки, считаются равными).
- **update_policy_decide() — нет валидных слотов** — установка по
умолчанию в Slot A.
- **Валиден только Slot Б** — установка всё равно в Slot A, т.к. активный
слот — валидный Slot Б, а целевой слот установки всегда не активный.
- **Кандидат новее активного** — установка в неактивный слот.
- **Кандидат старше/равен активному без удержания кнопки**`SKIP`.
- **Кандидат старше активного с удержанием кнопки** (форсированный
даунгрейд) — `INSTALL`.
- **Кандидат равен активному с удержанием кнопки** — всё равно `SKIP`
(переустановка той же версии не форсируется).
- **Оба слота валидны** — активным считается слот с более высокой версией,
установка идёт в другой.
## Гарантии
- Целевой слот установки всегда противоположен активному — активный слот
решением никогда не затрагивается.
- При отсутствии валидных слотов по умолчанию выбирается Slot A.
- Форсированный даунгрейд возможен только явным удержанием кнопки и только
если кандидат строго старше активного — при равенстве версий
переустановка не форсируется.
- Сравнение версий игнорирует `build_num` — тот же контракт, что у
`boot_version_cmp()` в `bootutil/loader.c`.
## Запуск
```bash
ctest --preset host-debug-test -R test_update_policy -V
```

View file

@ -0,0 +1,222 @@
/**
* @file test_update_policy.c
* @brief Host-тесты чистой логики update_policy_decide()/image_version_compare().
*
* Без аппаратных зависимостей сценарии зеркалят чек-лист аппаратной
* верификации Фазы 3 (firmware/bootloader/PLAN.md), но проверяются здесь
* без реального SD/flash.
*/
#include "unity.h"
#include "update_policy.h"
/* ── Вспомогательные конструкторы ─────────────────────────────────────── */
static struct image_version make_version(uint8_t major, uint8_t minor, uint16_t revision,
uint32_t build_num)
{
struct image_version ver = {
.iv_major = major,
.iv_minor = minor,
.iv_revision = revision,
.iv_build_num = build_num,
};
return ver;
}
static update_policy_slot_state_t make_valid_slot(struct image_version ver)
{
update_policy_slot_state_t slot = { .valid = true, .version = ver };
return slot;
}
static const update_policy_slot_state_t K_INVALID_SLOT = { .valid = false };
void setUp(void)
{
}
void tearDown(void)
{
}
/* ── image_version_compare ─────────────────────────────────────────────── */
void test_compare_major_wins(void)
{
struct image_version v1 = make_version(2, 0, 0, 0);
struct image_version v2 = make_version(1, 9, 9, 9);
TEST_ASSERT_TRUE(image_version_compare(&v1, &v2) > 0);
TEST_ASSERT_TRUE(image_version_compare(&v2, &v1) < 0);
}
void test_compare_minor_wins_when_major_equal(void)
{
struct image_version v1 = make_version(1, 2, 0, 0);
struct image_version v2 = make_version(1, 1, 9, 9);
TEST_ASSERT_TRUE(image_version_compare(&v1, &v2) > 0);
}
void test_compare_revision_wins_when_major_minor_equal(void)
{
struct image_version v1 = make_version(1, 0, 5, 0);
struct image_version v2 = make_version(1, 0, 3, 100);
TEST_ASSERT_TRUE(image_version_compare(&v1, &v2) > 0);
}
void test_compare_ignores_build_num(void)
{
struct image_version v1 = make_version(1, 0, 0, 1);
struct image_version v2 = make_version(1, 0, 0, 999);
TEST_ASSERT_EQUAL_INT(0, image_version_compare(&v1, &v2));
}
/* ── update_policy_decide — нет валидных слотов ──────────────────────── */
void test_no_valid_slots_installs_to_slot_a(void)
{
struct image_version candidate = make_version(1, 0, 0, 0);
update_policy_result_t result =
update_policy_decide(&K_INVALID_SLOT, &K_INVALID_SLOT, &candidate, false);
TEST_ASSERT_EQUAL(UPDATE_POLICY_INSTALL, result.action);
TEST_ASSERT_EQUAL(UPDATE_POLICY_SLOT_A, result.target_slot);
TEST_ASSERT_FALSE(result.erase_previous_active); /* нечего стирать */
}
void test_only_slot_b_valid_targets_slot_a(void)
{
update_policy_slot_state_t slot_b = make_valid_slot(make_version(1, 0, 0, 0));
struct image_version candidate = make_version(2, 0, 0, 0);
update_policy_result_t result =
update_policy_decide(&K_INVALID_SLOT, &slot_b, &candidate, false);
TEST_ASSERT_EQUAL(UPDATE_POLICY_INSTALL, result.action);
TEST_ASSERT_EQUAL(UPDATE_POLICY_SLOT_A, result.target_slot);
}
/* ── update_policy_decide — кандидат новее активного ─────────────────── */
void test_candidate_newer_installs_to_inactive_slot(void)
{
update_policy_slot_state_t slot_a = make_valid_slot(make_version(1, 0, 0, 0));
struct image_version candidate = make_version(2, 0, 0, 0);
update_policy_result_t result =
update_policy_decide(&slot_a, &K_INVALID_SLOT, &candidate, false);
TEST_ASSERT_EQUAL(UPDATE_POLICY_INSTALL, result.action);
TEST_ASSERT_EQUAL(UPDATE_POLICY_SLOT_B, result.target_slot);
/* "Новее" не требует стирания прежнего активного — он сам проиграет
* сравнение версий в boot_go(). */
TEST_ASSERT_FALSE(result.erase_previous_active);
}
/* ── update_policy_decide — кандидат старше/равен активному ──────────── */
void test_candidate_older_without_button_skips(void)
{
update_policy_slot_state_t slot_a = make_valid_slot(make_version(2, 0, 0, 0));
struct image_version candidate = make_version(1, 0, 0, 0);
update_policy_result_t result =
update_policy_decide(&slot_a, &K_INVALID_SLOT, &candidate, false);
TEST_ASSERT_EQUAL(UPDATE_POLICY_SKIP, result.action);
}
void test_candidate_older_with_button_forces_install(void)
{
update_policy_slot_state_t slot_a = make_valid_slot(make_version(2, 0, 0, 0));
struct image_version candidate = make_version(1, 0, 0, 0);
update_policy_result_t result =
update_policy_decide(&slot_a, &K_INVALID_SLOT, &candidate, true);
TEST_ASSERT_EQUAL(UPDATE_POLICY_INSTALL, result.action);
TEST_ASSERT_EQUAL(UPDATE_POLICY_SLOT_B, result.target_slot);
/* Без стирания прежнего активного (Slot A, v2) даунгрейд не имел бы
* эффекта Slot A остался бы валиден и новее, снова выиграл бы в
* boot_go(), несмотря на успешную установку v1 в Slot Б. */
TEST_ASSERT_TRUE(result.erase_previous_active);
}
void test_candidate_equal_with_button_still_skips(void)
{
update_policy_slot_state_t slot_a = make_valid_slot(make_version(1, 0, 0, 0));
struct image_version candidate = make_version(1, 0, 0, 0);
update_policy_result_t result =
update_policy_decide(&slot_a, &K_INVALID_SLOT, &candidate, true);
TEST_ASSERT_EQUAL(UPDATE_POLICY_SKIP, result.action);
}
/* ── update_policy_decide — оба слота валидны ─────────────────────────── */
void test_active_slot_is_the_higher_version_when_both_valid(void)
{
update_policy_slot_state_t slot_a = make_valid_slot(make_version(1, 0, 0, 0));
update_policy_slot_state_t slot_b = make_valid_slot(make_version(2, 0, 0, 0));
struct image_version candidate = make_version(3, 0, 0, 0);
/* Активный — Б (выше версия), значит целевой слот установки — А. */
update_policy_result_t result = update_policy_decide(&slot_a, &slot_b, &candidate, false);
TEST_ASSERT_EQUAL(UPDATE_POLICY_INSTALL, result.action);
TEST_ASSERT_EQUAL(UPDATE_POLICY_SLOT_A, result.target_slot);
TEST_ASSERT_FALSE(result.erase_previous_active);
}
/* ── update_policy_decide — форс. даунгрейд должен реально загрузиться ───
*
* Регрессионный тест: без erase_previous_active даунгрейд был бы запятан на
* flash, но никогда не загружался бы активный слот Б (v2) остаётся
* валиден и новее, снова выигрывает в boot_go(). Проверяем инвариант на
* уровне решения, не дожидаясь аппаратной верификации.
*/
void test_forced_downgrade_targets_and_erases_the_higher_version_slot(void)
{
update_policy_slot_state_t slot_a = make_valid_slot(make_version(1, 0, 0, 0));
update_policy_slot_state_t slot_b = make_valid_slot(make_version(2, 0, 0, 0));
struct image_version candidate = make_version(1, 0, 0, 0);
/* Активный — Б (v2, выше версия). Кандидат v1 старше активного, кнопка
* удержана форс. установка в Slot A (неактивный) + Slot Б обязан быть
* стёрт, иначе Slot Б (всё ещё валидный v2) снова выиграет. */
update_policy_result_t result = update_policy_decide(&slot_a, &slot_b, &candidate, true);
TEST_ASSERT_EQUAL(UPDATE_POLICY_INSTALL, result.action);
TEST_ASSERT_EQUAL(UPDATE_POLICY_SLOT_A, result.target_slot);
TEST_ASSERT_TRUE(result.erase_previous_active);
}
/* ── Точка входа ───────────────────────────────────────────────────────── */
int main(void)
{
UNITY_BEGIN();
RUN_TEST(test_compare_major_wins);
RUN_TEST(test_compare_minor_wins_when_major_equal);
RUN_TEST(test_compare_revision_wins_when_major_minor_equal);
RUN_TEST(test_compare_ignores_build_num);
RUN_TEST(test_no_valid_slots_installs_to_slot_a);
RUN_TEST(test_only_slot_b_valid_targets_slot_a);
RUN_TEST(test_candidate_newer_installs_to_inactive_slot);
RUN_TEST(test_candidate_older_without_button_skips);
RUN_TEST(test_candidate_older_with_button_forces_install);
RUN_TEST(test_candidate_equal_with_button_still_skips);
RUN_TEST(test_active_slot_is_the_higher_version_when_both_valid);
RUN_TEST(test_forced_downgrade_targets_and_erases_the_higher_version_slot);
return UNITY_END();
}

View file

@ -1,90 +0,0 @@
# Windows Host Results
## Step 1: spike_hab.py
```bash
production git:(feature-tui-monolith) uv run pytest spike/spike_hab.py -v -s
================================================================================================================ test session starts ================================================================================================================
platform darwin -- Python 3.14.6, pytest-9.1.1, pluggy-1.6.0 -- /Users/von_akimow/Desktop/TFT_ENV/tft_manufacture_test/tools/production/.venv/bin/python3
cachedir: .pytest_cache
rootdir: /Users/von_akimow/Desktop/TFT_ENV/tft_manufacture_test/tools/production
configfile: pyproject.toml
collected 2 items
spike/spike_hab.py::test_golden_hab_no_dcd PASSED
spike/spike_hab.py::test_golden_hab_with_dcd PASSED
================================================================================================================= 2 passed in 1.07s =================================================================================================================
```
## Step 2: spike_flash.py --ports-only
```bash
# подключены M5StamPLC + плата с firmware_test
➜ production git:(feature-tui-monolith) ✗ uv run python spike/spike_flash.py --ports-only
=== Serial-порты (serial.tools.list_ports.comports) ===
/dev/cu.debug-console VID:PID=----:---- 'n/a' serial=None
/dev/cu.Bluetooth-Incoming-Port VID:PID=----:---- 'n/a' serial=None
/dev/cu.usbmodem11101 VID:PID=303A:4001 'Espressif Device' serial='3cdc75edcf6c0000'
/dev/cu.usbmodemGUXFBWDJBWTGQ3 VID:PID=1FC9:0143 'MCU-LINK (r0FB) CMSIS-DAP V3.172' serial='GUXFBWDJBWTGQ'
/dev/cu.usbmodem11301 VID:PID=1996:00AD 'MU LLC' serial=None
Сверь: firmware_test ожидается как 1996:00AD. Для M5StampPLC запиши VID:PID из вывода выше — это и есть измерение О1 (см. MONOLITH_APP_PLAN.md §О1 и DEV_ARCH.md §5).
```
## Step 3: spike_flash.py
```bash
# подключены M5StamPLC + плата в режиме SDP
➜ production git:(feature-tui-monolith) ✗ uv run python spike/spike_flash.py
=== Serial-порты (serial.tools.list_ports.comports) ===
/dev/cu.debug-console VID:PID=----:---- 'n/a' serial=None
/dev/cu.Bluetooth-Incoming-Port VID:PID=----:---- 'n/a' serial=None
/dev/cu.usbmodem11101 VID:PID=303A:4001 'Espressif Device' serial='3cdc75edcf6c0000'
/dev/cu.usbmodemGUXFBWDJBWTGQ3 VID:PID=1FC9:0143 'MCU-LINK (r0FB) CMSIS-DAP V3.172' serial='GUXFBWDJBWTGQ'
Сверь: firmware_test ожидается как 1996:00AD. Для M5StampPLC запиши VID:PID из вывода выше — это и есть измерение О1 (см. MONOLITH_APP_PLAN.md §О1 и DEV_ARCH.md §5).
=== SDP/Flashloader (SDP=0x1fc9:0x0130, Flashloader=0x15a2:0x0073) ===
Загрузка Flashloader через SDP (0x1fc9:0x0130)
Ожидание Flashloader (до 10с).... OK
✅ get_property(CURRENT_VERSION) = [1258424320]
=== configure_memory (FlexSPI NOR) ===
configure_memory: OK
✅ Гейт 0 (SDP/Flashloader): пройден
```
## Step 4: spike_flash.py --erase-all
```bash
# Подключены M5StamPLC + плата в режиме SDP
➜ production git:(feature-tui-monolith) ✗ uv run python spike/spike_flash.py --erase-all
=== Serial-порты (serial.tools.list_ports.comports) ===
/dev/cu.debug-console VID:PID=----:---- 'n/a' serial=None
/dev/cu.Bluetooth-Incoming-Port VID:PID=----:---- 'n/a' serial=None
/dev/cu.usbmodem11101 VID:PID=303A:4001 'Espressif Device' serial='3cdc75edcf6c0000'
/dev/cu.usbmodemGUXFBWDJBWTGQ3 VID:PID=1FC9:0143 'MCU-LINK (r0FB) CMSIS-DAP V3.172' serial='GUXFBWDJBWTGQ'
Сверь: firmware_test ожидается как 1996:00AD. Для M5StampPLC запиши VID:PID из вывода выше — это и есть измерение О1 (см. MONOLITH_APP_PLAN.md §О1 и DEV_ARCH.md §5).
=== SDP/Flashloader (SDP=0x1fc9:0x0130, Flashloader=0x15a2:0x0073) ===
Flashloader уже запущен — пропускаем загрузку
✅ get_property(CURRENT_VERSION) = [1258424320]
=== configure_memory (FlexSPI NOR) ===
configure_memory: OK
⚠️ Chip erase сотрёт FCB — плата не загрузится до повторной прошивки.
Наберите ERASE для подтверждения: ERASE
Выставляю timeout=200000мс (эквивалент blhost -t 200000)
flash_erase_all: OK
✅ Гейт 0 (SDP/Flashloader): пройден
```

View file

@ -1,80 +0,0 @@
# Windows Host Results
## Step 1: spike_hab.py
```bash
PS C:\Development\IMXRT\tft_manufacture_test\tools\production> uv run pytest .\spike\spike_hab.py -v -s
============================================================ test session starts =============================================================
platform win32 -- Python 3.13.14, pytest-9.1.1, pluggy-1.6.0 -- C:\Development\IMXRT\tft_manufacture_test\tools\production\.venv\Scripts\python.exe
cachedir: .pytest_cache
rootdir: C:\Development\IMXRT\tft_manufacture_test\tools\production
configfile: pyproject.toml
collected 2 items
spike/spike_hab.py::test_golden_hab_no_dcd PASSED
spike/spike_hab.py::test_golden_hab_with_dcd PASSED
```
## Step 2: spike_flash.py --ports-only
```bash
# подключены M5StamPLC + плата с firmware_test
PS C:\Development\IMXRT\tft_manufacture_test\tools\production> uv run python .\spike\spike_flash.py --ports-only
=== Serial-порты (serial.tools.list_ports.comports) ===
COM10 VID:PID=1996:00AD 'Устройство с последовательным интерфейсом USB (COM10)' serial=''
COM8 VID:PID=303A:4001 'Устройство с последовательным интерфейсом USB (COM8)' serial='3CDC75EDCF6C0000'
Сверь: firmware_test ожидается как 1996:00AD. Для M5StampPLC запиши VID:PID из вывода выше — это и есть измерение О1 (см. MONOLITH_APP_PLAN.md §О1 и DEV_ARCH.md §5).
```
## Step 3: spike_flash.py
```bash
# подключены M5StamPLC + плата в режиме SDP
PS C:\Development\IMXRT\tft_manufacture_test\tools\production> uv run python .\spike\spike_flash.py
=== Serial-порты (serial.tools.list_ports.comports) ===
COM8 VID:PID=303A:4001 'Устройство с последовательным интерфейсом USB (COM8)' serial='3CDC75EDCF6C0000'
Сверь: firmware_test ожидается как 1996:00AD. Для M5StampPLC запиши VID:PID из вывода выше — это и есть измерение О1 (см. MONOLITH_APP_PLAN.md §О1 и DEV_ARCH.md §5).
=== SDP/Flashloader (SDP=0x1fc9:0x0130, Flashloader=0x15a2:0x0073) ===
Загрузка Flashloader через SDP (0x1fc9:0x0130)
Ожидание Flashloader (до 10с).... OK
✅ get_property(CURRENT_VERSION) = [1258424320]
=== configure_memory (FlexSPI NOR) ===
configure_memory: OK
✅ Гейт 0 (SDP/Flashloader): пройден
```
## Step 4: spike_flash.py --erase-all
```bash
# Подключены M5StamPLC + плата в режиме SDP
PS C:\Development\IMXRT\tft_manufacture_test\tools\production> uv run python .\spike\spike_flash.py --erase-all
=== Serial-порты (serial.tools.list_ports.comports) ===
COM8 VID:PID=303A:4001 'Устройство с последовательным интерфейсом USB (COM8)' serial='3CDC75EDCF6C0000'
Сверь: firmware_test ожидается как 1996:00AD. Для M5StampPLC запиши VID:PID из вывода выше — это и есть измерение О1 (см. MONOLITH_APP_PLAN.md §О1 и DEV_ARCH.md §5).
=== SDP/Flashloader (SDP=0x1fc9:0x0130, Flashloader=0x15a2:0x0073) ===
Загрузка Flashloader через SDP (0x1fc9:0x0130)
Ожидание Flashloader (до 10с).... OK
✅ get_property(CURRENT_VERSION) = [1258424320]
=== configure_memory (FlexSPI NOR) ===
configure_memory: OK
⚠️ Chip erase сотрёт FCB — плата не загрузится до повторной прошивки.
Наберите ERASE для подтверждения: ERASE
Выставляю timeout=200000мс (эквивалент blhost -t 200000)
flash_erase_all: OK
✅ Гейт 0 (SDP/Flashloader): пройден
```

View file

@ -1,190 +0,0 @@
#!/usr/bin/env python3
"""
spike_flash.py Фаза 0 (де-риск): SDP/Flashloader/McuBoot через spsdk
Python API вместо subprocess sdphost/blhost.
Закрывает:
В2 способ задать таймаут 200с для flash_erase_all (эквивалент
`blhost -t 200000`): interface.device.timeout МИЛЛИСЕКУНДЫ
(проверено чтением spsdk/utils/interfaces/device/usb_device.py;
докстрока UsbDevice.scan() говорит "секунды" по факту мс,
default=2000, что явно мс, а не секунды).
Р7 сигнатуры SdpUSBInterface.scan()/MbootUSBInterface.scan()
(device_id="0xVVVV:0xPPPP", HID-транспорт через libusbsio,
pyusb в детекте не участвует).
О1 вывод list_ports.comports() для CDC-устройств (firmware_test,
M5StampPLC) с явными VID:PID для ручной сверки на Windows.
Прогнать на Windows-машине БЕЗ Zadig это и есть проверка Р7.
По умолчанию скрипт НЕ деструктивен: детект SDP загрузка Flashloader
get_property configure_memory. Chip erase только с --erase-all и
интерактивным подтверждением (как в flash_usb.py).
Запуск:
uv run python spike_flash.py # детект + get_property
uv run python spike_flash.py --ports-only # только serial-порты (О1)
uv run python spike_flash.py --erase-all # + chip erase (ДЕСТРУКТИВНО)
"""
from __future__ import annotations
import argparse
import os
import sys
import time
from pathlib import Path
from typing import Optional
from spsdk.mboot import McuBoot, MbootUSBInterface
from spsdk.mboot.properties import PropertyTag
from spsdk.sdp import SDP, SdpUSBInterface
# tools/service_tui/spike/spike_flash.py → корень репозитория
REPO_ROOT = Path(__file__).resolve().parents[3]
FLASHLOADER_BIN = REPO_ROOT / "tools" / "host" / "dcd" / "ivt_flashloader.bin"
FLASHLOADER_LOAD_ADDR = 0x20001C00
FLEXSPI_OPTION_ADDR = 0x2000
FLEXSPI_OPTION_VALUE = 0xC0000007
FLEXSPI_MEMORY_ID = 9
ERASE_ALL_TIMEOUT_MS = 200_000 # эквивалент blhost -t 200000, см. docstring
def _device_id(vid_env: str, vid_default: str, pid_env: str, pid_default: str) -> str:
"""Тот же паттерн, что _usb() в tools/host/flash_usb.py, но в формате
spsdk USBDeviceFilter: "0xVVVV:0xPPPP"."""
vid = os.environ.get(vid_env, vid_default).strip().lower().removeprefix("0x")
pid = os.environ.get(pid_env, pid_default).strip().lower().removeprefix("0x")
return f"0x{vid}:0x{pid}"
SDP_ID = _device_id("BOOTROM_VID", "1fc9", "BOOTROM_PID", "0130")
FLASHLOADER_ID = _device_id("FLASHLOADER_VID", "15a2", "FLASHLOADER_PID", "0073")
def enumerate_serial_ports() -> None:
"""О1 + вопрос про Windows CDC: печатает VID:PID/описание всех serial-
портов. firmware_test (1996:00AD) class-compliant CDC ACM, драйвер
не нужен ни на одной ОС (Windows 10+ грузит usbser.sys по классу
интерфейса, не по VID:PID). M5StampPLC открытый вопрос О1: нативный
ESP32-S3 CDC (VID Espressif 303A, драйвер тоже не нужен) или мост
CH9102/CP210x (на изолированной Windows потребует один вендорский
драйвер честная ОС-необходимость, не Zadig-костыль)."""
import serial.tools.list_ports as list_ports
print("=== Serial-порты (serial.tools.list_ports.comports) ===")
ports = list(list_ports.comports())
if not ports:
print(" (ничего не найдено)")
return
for info in ports:
vidpid = (
f"{info.vid:04X}:{info.pid:04X}" if info.vid is not None else "----:----"
)
print(
f" {info.device:20s} VID:PID={vidpid} "
f"{info.description!r} serial={info.serial_number!r}"
)
print(
"\n Сверь: firmware_test ожидается как 1996:00AD. Для M5StampPLC "
"запиши VID:PID из вывода выше — это и есть измерение О1 "
"(см. MONOLITH_APP_PLAN.md §О1 и DEV_ARCH.md §5)."
)
def wait_for_flashloader(timeout_s: float = 10.0) -> Optional[MbootUSBInterface]:
print(f" Ожидание Flashloader (до {timeout_s:.0f}с)...", end="", flush=True)
deadline = time.monotonic() + timeout_s
while time.monotonic() < deadline:
found = MbootUSBInterface.scan(device_id=FLASHLOADER_ID)
if found:
print(" OK")
return found[0]
time.sleep(0.5)
print(".", end="", flush=True)
print(" TIMEOUT")
return None
def load_flashloader() -> Optional[MbootUSBInterface]:
"""SDP.write_file + jump_and_run — прямой аналог load_flashloader()
из tools/host/flash_usb.py, но через spsdk API вместо sdphost."""
already = MbootUSBInterface.scan(device_id=FLASHLOADER_ID)
if already:
print(" Flashloader уже запущен — пропускаем загрузку")
return already[0]
sdp_devices = SdpUSBInterface.scan(device_id=SDP_ID)
if not sdp_devices:
print(f" SDP-устройство не найдено ({SDP_ID}). Плата в BootROM-режиме?")
return None
if not FLASHLOADER_BIN.exists():
print(f" Не найден: {FLASHLOADER_BIN}")
return None
print(f" Загрузка Flashloader через SDP ({SDP_ID})")
data = FLASHLOADER_BIN.read_bytes()
with SDP(sdp_devices[0]) as sdp:
sdp.write_file(FLASHLOADER_LOAD_ADDR, data)
sdp.jump_and_run(FLASHLOADER_LOAD_ADDR)
return wait_for_flashloader()
def main() -> int:
parser = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
)
parser.add_argument(
"--ports-only",
action="store_true",
help="Только serial-порты (О1), без SDP/Flashloader",
)
parser.add_argument(
"--erase-all", action="store_true", help="ДЕСТРУКТИВНО: chip erase после detect"
)
args = parser.parse_args()
enumerate_serial_ports()
if args.ports_only:
return 0
print(f"\n=== SDP/Flashloader (SDP={SDP_ID}, Flashloader={FLASHLOADER_ID}) ===")
iface = load_flashloader()
if iface is None:
print("\n❌ Flashloader не поднялся — дальнейшие шаги пропущены")
return 1
with McuBoot(iface) as mboot:
version = mboot.get_property(PropertyTag.CURRENT_VERSION)
print(f"\n✅ get_property(CURRENT_VERSION) = {version}")
print("\n=== configure_memory (FlexSPI NOR) ===")
mboot.fill_memory(FLEXSPI_OPTION_ADDR, 4, FLEXSPI_OPTION_VALUE)
ok = mboot.configure_memory(FLEXSPI_OPTION_ADDR, FLEXSPI_MEMORY_ID)
print(f"configure_memory: {'OK' if ok else 'FAILED'}")
if args.erase_all:
print(
"\n⚠️ Chip erase сотрёт FCB — плата не загрузится до повторной прошивки."
)
confirm = input("Наберите ERASE для подтверждения: ")
if confirm != "ERASE":
print("Отменено.")
return 0
print(
f" Выставляю timeout={ERASE_ALL_TIMEOUT_MS}мс (эквивалент blhost -t 200000)"
)
iface.device.timeout = ERASE_ALL_TIMEOUT_MS
ok = mboot.flash_erase_all(mem_id=FLEXSPI_MEMORY_ID)
print(f"flash_erase_all: {'OK' if ok else 'FAILED'}")
mboot.reset(reopen=False)
print("\n✅ Гейт 0 (SDP/Flashloader): пройден")
return 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -1,216 +0,0 @@
#!/usr/bin/env python3
"""
spike_hab.py Фаза 0 (де-риск): HAB через HabImage (spsdk Python API)
вместо subprocess `nxpimage hab export`.
Закрывает В1: подтверждает, что HabImage.load_from_config(...).export()
даёт побайтно идентичный `nxpimage hab export` результат с тем же
конфигом, для DCD on/off.
Реальный вызов подсмотрен в исходнике самого nxpimage
spsdk/apps/nxpimage_apps/nxpimage_hab.py::hab_export() делает ровно
следующее (это не внутренний вызов apps-модуля, а повтор того же кода
на публичных классах Config/HabImage):
cfg = Config.create_from_file(yaml_path)
schemas = HabImage.get_validation_schemas_from_cfg(cfg)
cfg.check(schemas, check_unknown_props=True)
hab = HabImage.load_from_config(cfg)
hab.post_export(cfg.config_dir)
data = hab.export()
Временный YAML пишется в системный tmp (Р6) с АБСОЛЮТНЫМИ путями
inputImageFile/DCDFilePath в отличие от текущего flasher.py, который
полагается на cwd=tools/host/hab/ + относительный "../dcd/dcd.bin".
Config.get_input_file_name() резолвит путь через find_file(search_paths=
[cfg_dir]), а абсолютный путь find_file отдаёт как есть независимо от
search_paths (проверено чтением spsdk/utils/config.py) значит схема
Р6 (temp где угодно, не обязательно рядом с hab/) технически безопасна.
Содержимое "прошивки" для golden-теста не имеет значения сравнивается
результат УПАКОВКИ (IVT/BDT/[DCD]/APP), а не семантика кода, поэтому
используется детерминированный dummy-бинарь. DCD, наоборот, должен быть
настоящей HAB DCD-командной последовательностью (SegDCD.parse иначе
падает) поэтому DCD-кейс использует штатный tools/host/dcd/dcd.bin;
если его нет рядом тест скипается, а не подделывается фиктивным DCD.
Запуск:
uv run pytest spike_hab.py -v -s # golden-тест (без железа)
uv run python spike_hab.py # то же + подробный вывод в консоль
uv run python spike_hab.py --dcd-bin /path/to/other_dcd.bin
"""
from __future__ import annotations
import argparse
import hashlib
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
from typing import Optional
import pytest
from spsdk.image.hab.hab_image import HabImage
from spsdk.utils.config import Config
# tools/service_tui/spike/spike_hab.py → корень репозитория
REPO_ROOT = Path(__file__).resolve().parents[3]
REAL_DCD_BIN = REPO_ROOT / "tools" / "host" / "dcd" / "dcd.bin"
_HAB_OPTIONS = [
"options:",
" flags: 0x00",
" startAddress: 0x60000000",
" ivtOffset: 0x1000",
" initialLoadSize: 0x2000",
" family: mimxrt1050",
]
def _make_dummy_app(size: int = 512) -> bytes:
"""Детерминированные псевдослучайные байты фиксированного размера.
Не настоящая прошивка см. docstring модуля: для golden-теста важна
побайтная идентичность УПАКОВКИ, а не валидность кода приложения.
"""
seed = hashlib.sha256(b"tft-monolith-phase0-golden-hab").digest()
return (seed * (size // len(seed) + 1))[:size]
def _write_config(input_bin: Path, dcd_bin: Optional[Path], work_dir: Path) -> Path:
"""Собрать YAML-конфиг nxpimage hab (абсолютные пути, см. docstring модуля)."""
lines = list(_HAB_OPTIONS)
if dcd_bin is not None:
lines.append(f" DCDFilePath: {dcd_bin.resolve().as_posix()}")
lines.append(f'inputImageFile: "{input_bin.resolve().as_posix()}"')
lines.append("sections: []")
suffix = "dcd" if dcd_bin is not None else "nodcd"
yaml_path = work_dir / f"hab_golden_{suffix}.yaml"
yaml_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
return yaml_path
def build_via_api(yaml_path: Path) -> bytes:
"""Собрать HAB-образ через spsdk Python API — то же самое, что делает
`nxpimage hab export` изнутри (см. docstring модуля)."""
cfg = Config.create_from_file(str(yaml_path))
schemas = HabImage.get_validation_schemas_from_cfg(cfg)
cfg.check(schemas, check_unknown_props=True)
hab = HabImage.load_from_config(cfg)
hab.post_export(cfg.config_dir)
return hab.export()
def _require_nxpimage() -> str:
path = shutil.which("nxpimage")
if path is None:
raise RuntimeError(
"nxpimage не найден в PATH — активирован ли venv tools/service_tui "
"(spsdk кладёт nxpimage как console_script)?"
)
return path
def build_via_cli(yaml_path: Path, out_bin: Path) -> bytes:
"""Собрать тот же образ через `nxpimage hab export` (эталон сравнения)."""
nxpimage = _require_nxpimage()
subprocess.run(
[
nxpimage,
"hab",
"export",
"--force",
"-c",
str(yaml_path),
"-o",
str(out_bin),
],
check=True,
capture_output=True,
text=True,
)
return out_bin.read_bytes()
def _run_golden(work_dir: Path, dcd_bin: Optional[Path]) -> tuple[bytes, bytes]:
input_bin = work_dir / "dummy_app.bin"
input_bin.write_bytes(_make_dummy_app())
yaml_path = _write_config(input_bin, dcd_bin, work_dir)
api_bytes = build_via_api(yaml_path)
cli_bytes = build_via_cli(yaml_path, work_dir / "out_cli.bin")
return api_bytes, cli_bytes
# ── pytest: остаётся навсегда как регрессия на апгрейды spsdk (Гейт 0) ──
# Примечание: сейчас лежит в spike/ (временная директория по плану Фазы 0);
# в Фазе 1 у tools/service_tui/ появляется tests/ — тогда этот файл стоит
# туда перенести (или вынести тесты в отдельный test_hab_golden.py).
def test_golden_hab_no_dcd(tmp_path: Path) -> None:
api_bytes, cli_bytes = _run_golden(tmp_path, dcd_bin=None)
assert api_bytes == cli_bytes, (
f"HabImage API разошёлся с nxpimage CLI (DCD off): "
f"{len(api_bytes)} vs {len(cli_bytes)} байт"
)
def test_golden_hab_with_dcd(tmp_path: Path) -> None:
if not REAL_DCD_BIN.exists():
pytest.skip(f"Реальный DCD не найден: {REAL_DCD_BIN}")
api_bytes, cli_bytes = _run_golden(tmp_path, dcd_bin=REAL_DCD_BIN)
assert api_bytes == cli_bytes, (
f"HabImage API разошёлся с nxpimage CLI (DCD on): "
f"{len(api_bytes)} vs {len(cli_bytes)} байт"
)
# ── standalone запуск: то же самое, но с подробным выводом в консоль ────
def main() -> int:
parser = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
)
parser.add_argument(
"--dcd-bin", type=Path, default=REAL_DCD_BIN, help="Путь к реальному dcd.bin"
)
args = parser.parse_args()
try:
_require_nxpimage()
except RuntimeError as exc:
print(f"{exc}")
return 1
work_dir = Path(tempfile.mkdtemp(prefix="spike_hab_"))
print(f"Рабочая директория: {work_dir}")
ok = True
for dcd_bin, label in [(None, "DCD off"), (args.dcd_bin, "DCD on")]:
print(f"\n--- {label} ---")
if dcd_bin is not None and not dcd_bin.exists():
print(f" ПРОПУЩЕНО: DCD-файл не найден: {dcd_bin}")
continue
try:
api_bytes, cli_bytes = _run_golden(work_dir, dcd_bin)
match = api_bytes == cli_bytes
print(f" API: {len(api_bytes)} байт, CLI: {len(cli_bytes)} байт")
print(f" {'✅ ПОБАЙТНО СОВПАДАЕТ' if match else '❌ РАСХОЖДЕНИЕ'}")
ok = ok and match
except subprocess.CalledProcessError as exc:
print(f" ❌ nxpimage CLI упал: {exc.stderr}")
ok = False
except Exception as exc: # noqa: BLE001 — спайк, важен весь контекст ошибки
print(f"{type(exc).__name__}: {exc}")
ok = False
print(f"\nРезультат: {'✅ Гейт 0 (HAB) пройден' if ok else '❌ Стоп-условие В1'}")
return 0 if ok else 1
if __name__ == "__main__":
sys.exit(main())

View file

@ -1,70 +0,0 @@
#!/usr/bin/env python3
"""
spike_readback.py диагностика Гейта 3: читает Flash обратно и сравнивает
с ожидаемыми файлами (FCB-блоб @0x60000000, HAB-образ @0x60001000).
Запуск (плата в SDP или с уже поднятым Flashloader):
uv run python spike/spike_readback.py \
--expect-fcb ../host/dcd/w25q128_fdcb.bin \
--expect-image /путь/к/собранному.hab.bin
Дампы кладёт рядом: readback_fcb.bin, readback_image.bin.
"""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from app import flash_backend as fb # noqa: E402
from spsdk.mboot import McuBoot # noqa: E402
def _cmp(label: str, actual: bytes, expected: bytes) -> bool:
if actual == expected:
print(f" {label}: ✅ ПОБАЙТНО СОВПАДАЕТ ({len(actual)} байт)")
return True
n = min(len(actual), len(expected))
diff_at = next((i for i in range(n) if actual[i] != expected[i]), n)
print(
f" {label}: ❌ РАСХОЖДЕНИЕ с офсета 0x{diff_at:X} "
f"(len actual={len(actual)}, expected={len(expected)})"
)
print(f" actual [{diff_at:#x}]: {actual[diff_at : diff_at + 16].hex()}")
print(f" expected[{diff_at:#x}]: {expected[diff_at : diff_at + 16].hex()}")
return False
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--expect-fcb", type=Path, required=True)
ap.add_argument("--expect-image", type=Path, required=True)
args = ap.parse_args()
exp_fcb = args.expect_fcb.read_bytes()
exp_img = args.expect_image.read_bytes()
iface = fb.load_flashloader(progress_cb=lambda p: print(f" {p.message}"))
ok = True
with McuBoot(iface) as mboot:
fb.configure_flexspi(mboot)
fcb = mboot.read_memory(fb.FLASH_BASE, max(len(exp_fcb), 512), mem_id=0)
img = mboot.read_memory(fb.FLASH_BASE + fb.HAB_OFFSET, len(exp_img), mem_id=0)
if fcb is None or img is None:
print("❌ read_memory вернул None")
return 1
Path("readback_fcb.bin").write_bytes(fcb)
Path("readback_image.bin").write_bytes(img)
ok = _cmp("FCB @0x60000000", fcb[: len(exp_fcb)], exp_fcb) and ok
ok = _cmp("Image @0x60001000", img, exp_img) and ok
return 0 if ok else 1
if __name__ == "__main__":
sys.exit(main())