diff --git a/bsp/CMakeLists.txt b/bsp/CMakeLists.txt index e0af941..d450fdb 100644 --- a/bsp/CMakeLists.txt +++ b/bsp/CMakeLists.txt @@ -2,10 +2,7 @@ if(BUILD_TESTS_HOST) return() endif() -# ----------------------------------------------------------------------------- -# bsp_board — генерированные файлы Config Tools, инициализация платы -# ----------------------------------------------------------------------------- - +# bsp_board — cгенерированные Config Tools файлы, инициализация платы add_library(bsp_board STATIC generated/board.c generated/pin_mux.c) add_subdirectory(common) @@ -15,6 +12,7 @@ add_subdirectory(uart_host) add_subdirectory(opto) add_subdirectory(can) add_subdirectory(button) +add_subdirectory(wdog) add_subdirectory(display) add_subdirectory(usb_cdc) add_subdirectory(sdram) @@ -23,7 +21,6 @@ add_subdirectory(sd) add_subdirectory(mqs) add_subdirectory(provisioning) -# Подавляем предупреждения при компиляции собственных .c файлов библиотеки target_compile_options(bsp_board PRIVATE -w) # SYSTEM подавляет предупреждения для всех внешних потребителей @@ -67,10 +64,6 @@ target_compile_definitions( add_library(bsp_boot_ram INTERFACE) target_compile_definitions(bsp_boot_ram INTERFACE SKIP_SYSCLK_INIT) -# bsp_boot_xip без DCD — для firmware/bootloader: XIP из Flash, но без -# инициализации SDRAM (bootloader SDRAM не использует, см. -# docs/mimxrt1052/BOOTLOADER_FLASH_MAP.md). В отличие от bsp_boot_xip не -# определяет XIP_BOOT_HEADER_DCD_ENABLE. add_library(bsp_boot_xip_no_dcd INTERFACE) target_compile_definitions( bsp_boot_xip_no_dcd INTERFACE XIP_EXTERNAL_FLASH=1 XIP_BOOT_HEADER_ENABLE=1 diff --git a/bsp/generated/sdmmc_config.c b/bsp/generated/sdmmc_config.c index 14c2df1..24976ca 100644 --- a/bsp/generated/sdmmc_config.c +++ b/bsp/generated/sdmmc_config.c @@ -28,9 +28,27 @@ static sd_io_voltage_t s_io_voltage = { volatile uint32_t g_sdmmc_dbg_dma_buf_addr = 0U; volatile uint32_t g_sdmmc_dbg_usdhc1_src_clock_hz = 0U; -static bool sd_card_detect_gpio(void) +/* + * Детект карты через USDHC PRES_STATE.CINST — ЕДИНЫЙ механизм и для нашего + * гейта bsp_sd_is_inserted() (bsp/sd/src/sd.c), и для внутреннего + * SD_PollingCardInsert() SDK (тот зовёт этот callback при kSD_DetectCardByGpioCD). + * Не GPIO_PinRead — исторически детект через GPIO2/28 давал ложный "card + * present" на пустом слоте (Фаза 3, симптом 1; DEBUG_LOG_PHASE3_SD.md, раунд 3). + * Работает, пока пин D13 замаплен на USDHC1_CD_B (см. BOARD_SD_Config ниже: + * прежний remux на GPIO2_IO28 убран, пин остаётся на USDHC1_CD_B постоянно — + * консолидация, item 1). Тактирование USDHC1 включается идемпотентно на случай + * вызова до полного SD_HostInit(). + * + * Тот же PRSSTAT-бит читает и штатный host-CD путь SDK + * (SDMMCHOST_CardDetectStatus), но kSD_DetectCardByHostCD дополнительно взводит + * USDHC card-detect ПРЕРЫВАНИЯ — не нужны загрузчику (лишний источник IRQ перед + * прыжком), поэтому оставляем polling через callback (kSD_DetectCardByGpioCD). + */ +static bool sd_card_detect_prsstat(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); + return (USDHC_GetPresentStatusFlags(BOARD_SDMMC_SD_HOST_BASEADDR) & + (uint32_t) kUSDHC_CardInsertedFlag) != 0U; } /* --------------------------------------------------------------------------- @@ -125,19 +143,11 @@ static void sd_pin_config(uint32_t freq) IOMUXC_SetPinConfig(IOMUXC_GPIO_SD_B0_04_USDHC1_DATA2, pad); IOMUXC_SetPinConfig(IOMUXC_GPIO_SD_B0_05_USDHC1_DATA3, pad); /* - * 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) это не - * лечит, раз симптом воспроизводится и после его отката. + * CD (D13) здесь НЕ конфигурируем: пин на USDHC1_CD_B (см. BOARD_SD_Config), + * pad задан в BOARD_InitPins() и на железе даёт корректный CINST. Прежняя + * настройка pad'а GPIO2_IO28 убрана вместе с GPIO-детектом (item 1, + * DEBUG_LOG_PHASE3_SD.md). */ - 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)); } /* --------------------------------------------------------------------------- @@ -162,10 +172,10 @@ void BOARD_SD_Config(void *card, sd_cd_t cd, uint32_t host_irq_priority, void *u g_sdmmc_dbg_dma_buf_addr = (uint32_t)(uintptr_t)s_dma_buf; g_sdmmc_dbg_usdhc1_src_clock_hz = sd->host->hostController.sourceClock_Hz; - /* --- card detect: GPIO CD (active-low) --- */ + /* --- card detect: USDHC PRES_STATE.CINST через callback (polling, без IRQ) --- */ s_cd.cdDebounce_ms = BOARD_SDMMC_SD_CD_DEBOUNCE_MS; - s_cd.type = BOARD_SDMMC_SD_CD_TYPE; - s_cd.cardDetected = sd_card_detect_gpio; + s_cd.type = BOARD_SDMMC_SD_CD_TYPE; /* kSD_DetectCardByGpioCD → callback ниже */ + s_cd.cardDetected = sd_card_detect_prsstat; s_cd.callback = cd; /* обычно NULL из bsp_sd */ s_cd.userData = user_data; @@ -178,37 +188,19 @@ void BOARD_SD_Config(void *card, sd_cd_t cd, uint32_t host_irq_priority, void *u /* --- GPIO питания --- */ sd_power_init(); /* - * 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 везде). + * CD_B (GPIO_B1_12 / physical D13) остаётся на USDHC1_CD_B постоянно — + * единый механизм детекта через PRES_STATE.CINST (sd_card_detect_prsstat + * выше + гейт bsp_sd_is_inserted). Прежней двойной маршрутизации + * (remux на GPIO2_IO28 для GPIO-чтения внутри f_mount) больше нет — + * см. DEBUG_LOG_PHASE3_SD.md, раунд 3 «консолидация детекта» (item 1); + * она убирала латентную хрупкость: после первого bsp_sd_init() пин уходил + * на GPIO2_IO28 и повторный PRSSTAT-скан ослеп бы. Явно переустанавливаем + * альт-функцию (BOARD_InitPins() её тоже ставит — так модуль не зависит от + * порядка инициализации). Pad этого пина оставляем как задал BOARD_InitPins: + * на железе CINST на нём читается корректно (Фаза 3, все сценарии). */ - IOMUXC_SetPinMux(IOMUXC_GPIO_B1_12_GPIO2_IO28, 0U); - const gpio_pin_config_t cd_cfg = { - .direction = kGPIO_DigitalInput, - .outputLogic = 0U, - .interruptMode = kGPIO_NoIntmode, - }; - GPIO_PinInit(BOARD_SDMMC_SD_CD_GPIO_BASE, BOARD_SDMMC_SD_CD_GPIO_PIN, &cd_cfg); - /* Важно: применяем pad-конфиг сразу для ранних CMD (CMD0/CMD8/CMD55/ACMD41). */ + IOMUXC_SetPinMux(IOMUXC_GPIO_B1_12_USDHC1_CD_B, 0U); + /* Pad-конфиг линий SD (CMD/CLK/DATA) сразу для ранних CMD (CMD0/CMD8/CMD55/ACMD41). */ sd_pin_config(400000U); /* --- приоритет прерывания хоста --- */ diff --git a/bsp/wdog/CMakeLists.txt b/bsp/wdog/CMakeLists.txt new file mode 100644 index 0000000..bfa3915 --- /dev/null +++ b/bsp/wdog/CMakeLists.txt @@ -0,0 +1,15 @@ +if(BUILD_TESTS_HOST) + return() +endif() + +add_library(bsp_wdog STATIC src/wdog.c) + +target_include_directories( + bsp_wdog + PUBLIC include/ + PRIVATE src/) + +target_link_libraries( + bsp_wdog + PUBLIC bsp_status + PRIVATE sdk_wdog) diff --git a/bsp/wdog/README.md b/bsp/wdog/README.md new file mode 100644 index 0000000..0bf497f --- /dev/null +++ b/bsp/wdog/README.md @@ -0,0 +1,80 @@ +# bsp_wdog — аппаратный watchdog (WDOG1) + +Аппаратный сброс МК по таймауту. Защищает от зависаний в блокирующих вызовах, +которые не возвращают управление в код приложения — там, где программный +watchdog (флаг + проверка в основном цикле) бессилен, поскольку сам цикл не +выполняется. + +--- + +## Аппаратура + +| Параметр | Значение | +| ---------------------- | ---------------------------------------------- | +| Периферия | WDOG1 | +| Шаг таймаута | 0.5 c | +| Диапазон таймаута | 1..128 c | +| Причина сброса | `WDOG1->WRSR.TOUT` (1 — сброс был по watchdog) | +| Поведение под SWD-halt | Приостановлен (`enableDebug = false`) | + +--- + +## Контракт: WDE — write-once + +`WDOG_WCR.WDE` (enable) — бит однократной записи: после `bsp_wdog_init()` +watchdog нельзя выключить программно до следующего POR. Он остаётся взведённым +и после любого перехода управления внутри той же сессии питания (переход в +другой образ прыжком, а не через ресет). Любой код, к которому управление +переходит после инициализации watchdog в этой же сессии, обязан периодически +вызывать `bsp_wdog_refresh()` не реже периода таймаута — иначе неизбежен +reset-loop. + +--- + +## API + +```c +bsp_status_t bsp_wdog_init(uint32_t timeout_s); /* взвести, once; захватывает причину предыдущего сброса */ +void bsp_wdog_refresh(void); /* сбросить счётчик таймаута */ +bool bsp_wdog_caused_last_reset(void); /* true, если последний сброс МК — по таймауту WDOG */ +bool bsp_wdog_is_armed(void); /* true после успешного init() */ +uint32_t bsp_wdog_timeout_s(void); /* сконфигурированный таймаут (0 до init) */ +``` + +`bsp_wdog_refresh()` безопасно звать даже до `bsp_wdog_init()` — no-op. +Звать только в точках подтверждённого прогресса, не непосредственно перед +вызовом, от зависания в котором watchdog и защищает. + +--- + +## Быстрый старт + +```c +#include "bsp/wdog.h" + +/* main.c — как можно раньше после board_hw_init(): */ +bsp_wdog_init(10U); /* c запасом над самой длинной легитимной операцией */ + +if (bsp_wdog_caused_last_reset()) +{ + /* предыдущая сессия закончилась таймаутом — восстановились после зависания */ +} + +/* в основном цикле и в точках подтверждённого прогресса: */ +bsp_wdog_refresh(); +``` + +--- + +## CMake + +```cmake +target_link_libraries(firmware_test PRIVATE bsp_wdog) +``` + +**Зависимости модуля:** + +| Зависимость | Тип | Описание | +| ------------ | ------- | ------------------------------------ | +| `bsp_status` | PUBLIC | `bsp_status_t` в публичном API | +| `sdk_wdog` | PRIVATE | `fsl_wdog.h` — `WDOG_Init/Refresh()` | diff --git a/bsp/wdog/include/bsp/wdog.h b/bsp/wdog/include/bsp/wdog.h new file mode 100644 index 0000000..cfdd0c8 --- /dev/null +++ b/bsp/wdog/include/bsp/wdog.h @@ -0,0 +1,60 @@ +/* + * bsp_wdog — аппаратный watchdog (WDOG1, MIMXRT1052). + * + * Назначение: аппаратный сброс МК по таймауту. Защищает от зависаний в + * блокирующих вызовах, которые не возвращают управление в код приложения — + * там, где программный watchdog (флаг + проверка в основном цикле) бессилен, + * поскольку сам цикл не выполняется. + * + * Контракт: WDOG Enable (WDE) — write-once бит. После bsp_wdog_init() watchdog + * нельзя выключить программно до следующего POR; он остаётся взведённым в + * течение всей сессии питания, включая любую передачу управления внутри неё + * (не только через ресет). Любой код, к которому управление переходит после + * инициализации watchdog в этой же сессии, обязан периодически звать + * bsp_wdog_refresh() не реже периода таймаута — иначе reset-loop. Под + * отладчиком (SWD halt) watchdog приостанавливается (enableDebug=false), так + * что пошаговая отладка не сбивается сбросами. + */ + +#ifndef BSP_WDOG_H +#define BSP_WDOG_H + +#include "bsp/status.h" + +#include +#include + +/* + * Взвести WDOG1 с таймаутом timeout_s секунд (аппаратно округляется до шага + * 0.5 с: реальный таймаут = (2*timeout_s) * 0.5 c). Разумный диапазон 1..128. + * + * Побочно захватывает причину ПРЕДЫДУЩЕГО сброса (WDOG-таймаут vs прочее) для + * bsp_wdog_caused_last_reset() — читать до/после безразлично, но делается тут. + * + * Вызывать один раз, как можно раньше в main() (после board_hw_init()). + * Повторный вызов — no-op (WDE уже взведён). + */ +bsp_status_t bsp_wdog_init(uint32_t timeout_s); + +/* + * "Погладить" watchdog — сбросить счётчик таймаута. Дёшево; безопасно звать + * даже если WDOG не взведён (запись refresh-последовательности безвредна). + * Звать только в точках РЕАЛЬНОГО прогресса, НЕ перед блокирующими вызовами, + * от зависания в которых watchdog и защищает. + */ +void bsp_wdog_refresh(void); + +/* + * true, если ПОСЛЕДНИЙ сброс МК был вызван таймаутом WDOG (а не power-on / + * software / прочим). Валидно после bsp_wdog_init(). Для диагностики: показать + * технологу через USB-CDC, что плата восстановилась после зависания. + */ +bool bsp_wdog_caused_last_reset(void); + +/* true после успешного bsp_wdog_init(). */ +bool bsp_wdog_is_armed(void); + +/* Сконфигурированный таймаут в секундах (0, если ещё не взведён). */ +uint32_t bsp_wdog_timeout_s(void); + +#endif /* BSP_WDOG_H */ diff --git a/bsp/wdog/src/wdog.c b/bsp/wdog/src/wdog.c new file mode 100644 index 0000000..99e1b34 --- /dev/null +++ b/bsp/wdog/src/wdog.c @@ -0,0 +1,79 @@ +/* + * bsp_wdog — реализация поверх fsl_wdog (WDOG1). + */ + +#include "bsp/wdog.h" + +#include "fsl_wdog.h" + +#define BSP_WDOG_BASE WDOG1 + +/* Границы поля WCR.WT (8 бит): таймаут = (WT+1) * 0.5 c, максимум 128 c. */ +#define BSP_WDOG_TIMEOUT_S_MIN 1U +#define BSP_WDOG_TIMEOUT_S_MAX 128U + +static bool g_s_armed = false; +static bool g_s_last_reset_was_wdog = false; +static uint32_t g_s_timeout_s = 0U; + +bsp_status_t bsp_wdog_init(uint32_t timeout_s) +{ + if (g_s_armed) + { + return BSP_OK; /* WDE — write-once; повторно не взводим */ + } + + if ((timeout_s < BSP_WDOG_TIMEOUT_S_MIN) || (timeout_s > BSP_WDOG_TIMEOUT_S_MAX)) + { + return BSP_ERR_PARAM; + } + + /* + * Причина предыдущего сброса: читаем WDOG1->WRSR до настройки. WRSR + * read-only, отражает последний сброс (TOUT=WDOG-таймаут, POR=power-on), + * стабилен до следующего сброса. + */ + g_s_last_reset_was_wdog = (BSP_WDOG_BASE->WRSR & WDOG_WRSR_TOUT_MASK) != 0U; + + wdog_config_t cfg; + WDOG_GetDefaultConfig(&cfg); + + /* WT = 2*timeout_s - 1 → таймаут = (WT+1)*0.5 c = timeout_s c. */ + cfg.timeoutValue = (uint16_t) ((timeout_s * 2U) - 1U); + + /* + * КРИТИЧНО для рабочего процесса: не сбрасывать плату, когда ядро + * остановлено отладчиком (SWD halt) — иначе пошаговая отладка загрузчика + * невозможна. enableWait/enableStop оставляем как в дефолте: загрузчик и + * приложение в эти режимы не входят, но если войдут — пусть watchdog + * продолжает считать (безопаснее по умолчанию). + */ + cfg.workMode.enableDebug = false; + + cfg.enableWdog = true; + WDOG_Init(BSP_WDOG_BASE, &cfg); /* с этого момента WDE взведён навсегда */ + + g_s_timeout_s = timeout_s; + g_s_armed = true; + return BSP_OK; +} + +void bsp_wdog_refresh(void) +{ + WDOG_Refresh(BSP_WDOG_BASE); +} + +bool bsp_wdog_caused_last_reset(void) +{ + return g_s_last_reset_was_wdog; +} + +bool bsp_wdog_is_armed(void) +{ + return g_s_armed; +} + +uint32_t bsp_wdog_timeout_s(void) +{ + return g_s_timeout_s; +} diff --git a/docs/mimxrt1052/BOOTLOADER_FLASH_MAP.md b/docs/mimxrt1052/BOOTLOADER_FLASH_MAP.md index d594c50..e813302 100644 --- a/docs/mimxrt1052/BOOTLOADER_FLASH_MAP.md +++ b/docs/mimxrt1052/BOOTLOADER_FLASH_MAP.md @@ -69,6 +69,8 @@ tft_app. как обновляется (та же SD-логика, что и Slot A/Б, или отдельный механизм). - Точный layout `.ld`-скрипта tft_app для двух адресов слотов (Direct-XIP: код обычно не позиционно-независим — вероятно потребуется два варианта линковки под Slot A и Slot Б, либо PIC). + **Разобрано** в [UPDATE_FLOW.md](UPDATE_FLOW.md): рекомендован dual-link (два подписанных бинаря на + версию, как `test_stub`), с выбором линковки по целевому слоту при SD-обновлении; PIC — не сейчас. --- diff --git a/docs/mimxrt1052/UPDATE_FLOW.md b/docs/mimxrt1052/UPDATE_FLOW.md new file mode 100644 index 0000000..53bb424 --- /dev/null +++ b/docs/mimxrt1052/UPDATE_FLOW.md @@ -0,0 +1,138 @@ +# UPDATE_FLOW.md — производственная заливка и полевое обновление tft_app + +Разбор «что и куда заливать» для связки **bootloader → tft_app** (MCUboot Direct-XIP, два слота A/Б). +Дополняет [BOOTLOADER_FLASH_MAP.md](BOOTLOADER_FLASH_MAP.md) (адреса) и +[../../firmware/bootloader/PLAN.md](../../firmware/bootloader/PLAN.md) (фазы, recovery). + +--- + +## TL;DR (главное, что вызывает непонимание) + +**Direct-XIP: образ исполняется прямо из адреса своего слота, код обычно НЕ позиционно-независим → +под каждый слот нужна СВОЯ линковка.** Обновление, которое встанет в Slot Б, должно быть слинковано +под адрес Slot Б (`0x60240000`). Поэтому: + +> **Релиз одной версии tft_app = ДВА подписанных бинаря** (линковка под A + линковка под Б), из +> ОДНОГО исходника, параметризованным линкер-скриптом — ровно как уже устроен `test_stub` +> (`--defsym=__slot_base__=…`). Версия (`-v X.Y.Z`) одинаковая в обоих. + +Да, «собрать новую версию со своим линкер-скриптом под слот Б» — так и есть. Но не вручную по одному: +это один параметризованный `.ld` и один рецепт сборки, дающий оба бинаря. + +--- + +## 1. Почему так — это свойство Direct-XIP, не наша прихоть + +- **XIP** = eXecute In Place: CPU фетчит инструкции напрямую из флеша по адресу слота, образ никуда не + копируется (в отличие от swap/scratch-режимов MCUboot, которые мы сознательно НЕ используем). +- Абсолютные адреса — vector table, указатели на функции, литеральные пулы, адрес инициализации + `.data` — фиксируются на этапе **линковки** под конкретный базовый адрес. Образ, слинкованный под + Slot A (`0x60040000`), в Slot Б (`0x60240000`) поедет по чужим адресам и не запустится корректно. +- Это прямо зафиксированный «открытый вопрос §4» в [BOOTLOADER_FLASH_MAP.md](BOOTLOADER_FLASH_MAP.md): + *«код обычно не позиционно-независим — вероятно потребуется два варианта линковки под Slot A и + Slot Б, либо PIC»*. `test_stub` (Фаза 2) уже подтвердил two-slot-two-linkage на реальном железе. + +> ⚠️ **Почему на `test_stub` «одинаковый» бинарь как будто работал в обоих слотах.** Заглушка +> крошечная (только мигание LED): почти весь её код PC-relative, `.data` минимальна, а vector table +> релоцируется и `boot_select` (ставит `VTOR`), и самим образом в старте — поэтому она позиционно +> **терпима** по случайности. Реальный tft_app (большой, с `.data`, абсолютными указателями, +> шрифтами-C-массивами, framebuffer) терпимым **не будет** — ему нужны обе линковки по-настоящему. + +--- + +## 2. Карта слотов (напоминание) + +| Слот | База | Размер | ORIGIN образа (после imgtool-заголовка `0x200`) | +|---|---|---|---| +| Slot A | `0x60040000` | 2 МБ | `0x60040200` | +| Slot Б | `0x60240000` | 2 МБ | `0x60240200` | + +Линкер tft_app: `ORIGIN = __slot_base__ + 0x200` (образец — `cmake/linker/MIMXRT1052xxxxx_mcuboot_slot.ld` +у `test_stub`). + +--- + +## 3. Релизные артефакты (на каждую версию) + +Из одного исходника tft_app — **два подписанных бинаря** production-ключом: + +| Файл | Линковка (`__slot_base__`) | Назначение | +|---|---|---| +| `tft_app_slotA_vX.Y.Z.signed.bin` | `0x60040000` | ставится в Slot A | +| `tft_app_slotB_vX.Y.Z.signed.bin` | `0x60240000` | ставится в Slot Б | + +- Оба — **одна версия** `-v X.Y.Z` (version-gate сравнивает версию образа, а не его линковку). +- Оба подписаны production-ключом (не тестовым sample-ключом MCUboot; см. HAB SRK-церемонию как + аналог для bootloader). +- Рецепт — по образцу `just build::build-mcuboot-stub` (собирает оба слота + подписывает imgtool'ом), + но с production-ключом и реальным tft_app вместо заглушки. + +--- + +## 4. Кейсы + +| # | Ситуация | Куда/что | Как | +|---|---|---|---| +| 1 | **Начальная production-заливка** | A-линковка → Slot A; Slot Б пуст | SWD / USB ROM (blhost), `just host::flash-production`-путь | +| 2 | **Первое полевое обновление** v1→v2 | A активен → target = Slot Б → ставится **Б-линковка** v2 | SD, авто по version-gate; после — A=v1 (fallback), Б=v2 (active) | +| 3 | **Следующее обновление** v2→v3 | Б активен → target = Slot A → ставится **A-линковка** v3 | SD; после — A=v3 (active), Б=v2 (fallback) | +| 4 | **Даунгрейд** (BTN_1 при старте) | старая версия в inactive + стирание прежнего активного | SD + удержание BTN_1 (см. Фаза 3) | +| 5 | **Recovery** (BTN_2 / class C) | оба слота стёрты → чистый борт → A-линковка в Slot A | SD, ослабленный version-gate (см. Фаза 6) | +| 6 | **Образ завис после обновления** | авто-откат (class A) или счётчик+фолбэк (class B) | см. Фаза 6, taxonomy A/B | + +**Ключевой инвариант alternation A/Б:** обновление всегда идёт в НЕактивный слот, прежний рабочий слот +остаётся нетронутым как fallback (см. `update_policy` — «целевой слот всегда НЕ активный»). Поэтому в +поле почти всегда есть куда откатиться, если новая версия окажется плохой. + +--- + +## 5. Как обновление выбирает нужную линковку — ТЕКУЩИЙ GAP + +**Сейчас** ([sd_update.c](../../firmware/bootloader/src/sd_update.c)): ищется ОДИН файл +`2:/TFT_APP.BIN` и ставится в неактивный слот (`decision.target_slot`). Для позиционно-терпимого +`test_stub` это прошло все 5 сценариев Фазы 3 — но для реального tft_app **сломается**: если активен +Slot A, обновление идёт в Slot Б, а `TFT_APP.BIN` мог быть слинкован под A → в Slot Б не запустится. + +**Нужное расширение (часть включения реального tft_app):** +- SD несёт **оба** линкованных бинаря по соглашению имён, напр. `TFT_APP_A.BIN` / `TFT_APP_B.BIN`. +- `sd_update` открывает файл **по целевому слоту**: `TFT_APP_A.BIN`, если target = Slot A, иначе + `TFT_APP_B.BIN`. +- Оператор кладёт на SD **оба** и не думает, какой слот сейчас активен — bootloader сам берёт нужный + под инактивный слот. +- Version-gate: bootloader читает версию из выбранного файла (в обоих одна) — сравнение корректно. + +Это простое изменение (одна развилка имени файла по `target_slot`), но его **надо сделать до первого +реального полевого обновления tft_app**. Пока стоит `test_stub` — не мешает. + +--- + +## 6. Альтернатива — PIC (почему не сейчас) + +Позиционно-независимый образ (один бинарь на оба слота) — теоретически убирает дублирование линковки. +Но на bare-metal Cortex-M это **ROPI/RWPI**: флаги компилятора, PI-совместимый startup, `r9` как +static base для RW-данных, и **все** библиотеки (FreeRTOS, SDK-драйверы, шрифты) собранные в PI-режиме, +плюс runtime-оверхед. Объём работы и риск большие; для Direct-XIP индустрия стандартно выбирает +dual-link. Оставляем PIC на «если поддержка двух сборок станет реальной обузой» — не сейчас. + +--- + +## 7. Конкретные TODO для включения реального tft_app (сводка) + +1. **Параметризованный `.ld`** tft_app под два адреса слота (образец — `test_stub` + `MIMXRT1052xxxxx_mcuboot_slot.ld` + `--defsym=__slot_base__=`). +2. **Рецепт сборки+подписи** двух бинарей на релиз (по образцу `build-mcuboot-stub`, но production-ключ). +3. **Расширить `sd_update`**: `TFT_APP_.BIN` по `decision.target_slot` (§5). +4. **Контракт tft_app** (Фаза 6, 6c): обслуживание WDOG (alive-flag), отложенный `boot_set_confirmed()`, + `bsp_boot_health_mark()`. +5. **Решить**: production заливать только Slot A или A+Б? Достаточно A — первое обновление наполнит Б + и даст fallback *другой* версии (A+Б одной версией от class-B не спасает — тот же баг в обоих). + +--- + +## Связанные документы + +- [BOOTLOADER_FLASH_MAP.md](BOOTLOADER_FLASH_MAP.md) — адреса слотов, обоснование размеров. +- [../../firmware/bootloader/PLAN.md](../../firmware/bootloader/PLAN.md) — фазы; Фаза 6 (recovery, + taxonomy зависаний), Фаза 5 (HAB/production/service-tui). +- [HAB_GUIDE.md](HAB_GUIDE.md) — подпись самого bootloader (HAB), не путать с imgtool-подписью образов + tft_app. diff --git a/firmware/bootloader/CMakeLists.txt b/firmware/bootloader/CMakeLists.txt index 0667a74..ccfe1e4 100644 --- a/firmware/bootloader/CMakeLists.txt +++ b/firmware/bootloader/CMakeLists.txt @@ -52,7 +52,7 @@ target_compile_definitions( target_link_libraries(${TARGET_NAME} PRIVATE bsp_board bsp_led bsp_tick bsp_usb_cdc bsp_qspi_flash bsp_boot_xip_no_dcd bsp_button - bootloader_fatfs) + bsp_wdog bootloader_fatfs) # ----------------------------------------------------------------------------- # Linker script — вариант flexspi_nor с m_text, ограниченным бюджетом diff --git a/firmware/bootloader/DEBUG_LOG_PHASE3_SD.md b/firmware/bootloader/DEBUG_LOG_PHASE3_SD.md index 15e054b..3148854 100644 --- a/firmware/bootloader/DEBUG_LOG_PHASE3_SD.md +++ b/firmware/bootloader/DEBUG_LOG_PHASE3_SD.md @@ -150,8 +150,11 @@ IOMUXC_SetPinMux(IOMUXC_GPIO_B1_12_USDHC1_CD_B, 0U); > **РАЗРЕШЕНО 2026-07-10 (раунд 3).** Основной баг детекта закрыт PRSSTAT-чтением (см. секцию > «Обновление 2026-07-10 (раунд 3)» выше). Пункты 1–2 ниже сохранены как история; пункт 3 — > опровергнут; пункты 4–5 (devcontainer, watchdog) остаются актуальными как отдельные задачи. -> Плюс два новых нереализованных пункта-рекомендации из раунда 3: (6) консолидация детекта на -> единый механизм PRSSTAT (убрать латентную хрупкость re-scan), (7) ранний сэмпл кнопки даунгрейда. +> Рекомендации (6) консолидация детекта на единый PRSSTAT и (7) ранний сэмпл кнопки даунгрейда — +> **✅ реализованы в раунде 4** (см. секции «✅ Латентная хрупкость re-scan — УСТРАНЕНА» и «Тайминг +> удержания кнопки» выше); аппаратно ещё не проверены. Пункт (5) аппаратный watchdog — +> **✅ реализован** (`bsp/wdog`, см. PLAN.md «Реализовано: аппаратный watchdog» и чек-лист в +> test_stub/HARDWARE_VERIFICATION_PHASE3.md §9); аппаратно ещё не проверен. Открытых пунктов нет. 1. ✅ **Применено 2026-07-10 (раунд 1).** Откатить pinmux CD на `GPIO2_IO28` (без `USDHC1_CD_B`) — самый доверенный референс (`TFT_BOOTLOADER`) использует именно так. **Аппаратно проверено — @@ -447,26 +450,28 @@ bool bsp_sd_is_inserted(void) - `sd_power_control` возвращён в `static` (публичное имя `BOARD_SDCardPowerControl` из раунда 2 больше не нужно — side-effect, ради которого оно вводилось, заменён PRSSTAT-чтением). -### ⚠️ Латентная хрупкость re-scan (не мешает 5 сценариям, но стоит знать) +### ✅ Латентная хрупкость re-scan — УСТРАНЕНА (раунд 4, item 1) -Рабочая конфигурация опирается на то, что пин на `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`). Реальный, но узкий крайний случай. +**Была** (до раунда 4): рабочая конфигурация опиралась на то, что пин на `USDHC1_CD_B` в момент +гейта, но `BOARD_SD_Config()` перемапливал его на `GPIO2_IO28` при первом же `bsp_sd_init()` и +обратно НЕ возвращал. Если `run_update()` был вызван (карта была на гейте), но завершался без прыжка +(например, на карте нет `TFT_APP.BIN`), то на СЛЕДУЮЩЕМ периодическом скане `bsp_sd_is_inserted()` +читал PRSSTAT с пином уже на `GPIO2_IO28` → ложный ноль → карта «исчезала» до перезагрузки. -**Рекомендованная консолидация (не реализована — требует повторной проверки на железе, а рабочее -состояние трогать без запроса не стал):** свести детект к ЕДИНОМУ механизму — 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. Оба варианта убирают двойную -маршрутизацию и латентную хрупкость. Оба меняют путь, проверенный сейчас только в текущей -двухмеханизменной форме, — отсюда обязательная повторная аппаратная проверка перед принятием. +**Реализовано (раунд 4, вариант B — минимальный):** детект сведён к ЕДИНОМУ механизму, PRSSTAT +везде. Callback `sd_card_detect_gpio` → `sd_card_detect_prsstat` (читает `USDHC_GetPresentStatusFlags +& kUSDHC_CardInsertedFlag`); remux на `GPIO2_IO28` из `BOARD_SD_Config()` убран — пин остаётся на +`USDHC1_CD_B` постоянно (там же явно переустанавливается); мёртвый pad-конфиг GPIO2_IO28 из +`sd_pin_config()` удалён. `s_cd.type` оставлен `kSD_DetectCardByGpioCD` — это не «GPIO» в смысле +чтения, а «детект через callback» (polling); альтернатива `kSD_DetectCardByHostCD` дополнительно +взвела бы USDHC card-detect ПРЕРЫВАНИЯ (тот же PRSSTAT-бит, но лишний источник IRQ перед прыжком — +см. `SDMMCHOST_CardDetectInit` в `sdk/.../usdhc/non_blocking/fsl_sdmmc_host.c`), поэтому выбран +вариант B. Теперь и гейт, и внутренний `SD_PollingCardInsert` читают один регистр на одной +маршрутизации пина. + +**Требует аппаратной ре-проверки:** все 5 сценариев, плюс явно кейс, который раньше был сломан — +«карта вставлена, но без `TFT_APP.BIN`; ждём; кладём файл на карту БЕЗ извлечения/перезагрузки — +пере-скан должен его подхватить». ### Тайминг удержания кнопки даунгрейда (симптом/вопрос пользователя) @@ -490,15 +495,19 @@ bool bsp_sd_is_inserted(void) (кода на отпускание нет); это совпадение: решение уже защёлкнулось на сэмпле (пока держал), а видимый результат (стирание+копирование+прыжок в старший образ) проявляется как раз к ~15 c. -**Практическая инструкция технологу** (в HARDWARE_VERIFICATION_PHASE3.md): держать `BSP_BUTTON_1` с -момента подачи питания и не отпускать, пока по CDC не придёт `status: installing` (или пока частота -мигания `LED_APP` не сменится на «старший» образ). Ориентир ~15 c — держать с запасом. +**✅ Реализовано (раунд 4, item 2):** кнопка теперь сэмплится РАНО и защёлкивается. `main.c` читает +`bsp_button_read(BSP_BUTTON_1)` один раз сразу после `bsp_button_init()` (до `bsp_qspi_init()`/ +SD-логики) в `const bool downgrade_held` и передаёт это значение в `sd_update_check(downgrade_held)` +на обеих точках вызова (первая попытка + пере-сканы цикла). `run_update()` больше не читает кнопку +сам (`bsp/button.h` из `sd_update.c` убран) — `update_policy_decide()` получает уже защёлкнутое +значение. Жест «удержание при включении» теперь ловится в детерминированный ранний момент, а не +через секунды слепого окна. -**Рекомендация по коду (не реализовано — поведение бы изменилось, требует запроса и ре-теста):** -сэмплить кнопку РАНО — при `bsp_button_init()`/сразу в `board_hw_init()`-фазе, до медленной -SD-инициализации и двойной крипто-валидации — и защёлкивать результат, вместо чтения в глубине -`run_update()`. Тогда «удержание при старте» стало бы предсказуемым (нажал в момент включения — -сработало), без многосекундного слепого окна. Сейчас это самый user-hostile момент SD-пути. +**Новая инструкция технологу** (обновлено в HARDWARE_VERIFICATION_PHASE3.md): достаточно держать +`BSP_BUTTON_1` нажатой **в момент подачи питания** (до/во время включения) — состояние считывается в +первые миллисекунды после `board_hw_init()`, ещё до обращения к SD. Долгое «15 c с запасом» больше +не требуется. **Требует аппаратной ре-проверки** сценария 3 (даунгрейд): удержать кнопку при +включении → даунгрейд срабатывает; не удерживать → штатная загрузка более нового слота. ## Побочная находка (не блокирует, но стоит знать) diff --git a/firmware/bootloader/PLAN.md b/firmware/bootloader/PLAN.md index 8c11bac..9ce30cf 100644 --- a/firmware/bootloader/PLAN.md +++ b/firmware/bootloader/PLAN.md @@ -7,9 +7,10 @@ | 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-путь установки | ✅ аппаратно верифицирована (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-паттерны | не начата | | +| 3 — SD-путь установки | ✅ верифицирована (2026-07-10); раунд 4 + watchdog ждут ре-проверки | все 5 сценариев пройдены на плате; баг card-detect закрыт чтением USDHC PRES_STATE.CINST (раунд 3). Раунд 4 (консолидация детекта на единый PRSSTAT + ранний сэмпл кнопки) и **аппаратный watchdog** (`bsp/wdog`, см. ниже) реализованы, сборка зелёная, **аппаратно ещё не проверены**. См. [DEBUG_LOG_PHASE3_SD.md](DEBUG_LOG_PHASE3_SD.md), чек-лист — [test_stub/HARDWARE_VERIFICATION_PHASE3.md](test_stub/HARDWARE_VERIFICATION_PHASE3.md) | +| 4 — SDRAM/W25Q smoke-test + LED-паттерны | не начата | рекомендуется ПОСЛЕ Фазы 6 (см. ниже) | | 5 — HAB Release + service-tui | не начата | | +| 6 — Устойчивость и восстановление (recovery) | спроектирована, код не начат | **рекомендованный следующий шаг** (раньше 4/5): закрывает зависание уже установленного образа и даёт ручной аварийный вход. Дизайн зафиксирован в обсуждении — см. раздел «Фаза 6» ниже | ## Контекст @@ -29,7 +30,10 @@ - **Схема обновления**: MCUboot **Direct-XIP**, два слота (A/Б) с полностью валидными образами каждый, без swap/scratch. Единственный полевой канал обновления — microSD. USB как канал заливки *образа* сознательно не делаем (SDP/blhost на производстве — это отдельный, не зависящий от кода bootloader, - канал через NXP BootROM). + канал через NXP BootROM). **Direct-XIP → образ линкуется под адрес своего слота**: релиз tft_app = + два подписанных бинаря (линковка под A и под Б) на версию. Полный разбор производственной заливки и + полевого обновления, включая текущий gap в `sd_update` (один `TFT_APP.BIN`) — + [docs/mimxrt1052/UPDATE_FLOW.md](../../docs/mimxrt1052/UPDATE_FLOW.md). - **Верификация образов tft_app** — через **bootutil** (MCUboot), не самодельный верификатор (см. исследование ниже). Подпись — `imgtool` (не HAB; HAB — только для самого bootloader). - **Обратная связь** — USB CDC ACM с тем же JSON-lines протоколом (`ping`/`pong`, `get_version`), что уже @@ -554,7 +558,64 @@ ARM-таргет — без изменений), иначе (host, любая а кросс-компиляцией под `-target x86_64-unknown-linux-gnu` (0 ошибок после фикса, была именно эта ошибка до него). -### Запланировано (обязательно к реализации): аппаратный watchdog +### Найден и исправлен баг чек-листа (не бутлоадера): сценарий 1 не запускал установленный образ + +При аппаратной ре-верификации раунда 4 + watchdog (2026-07-13) сценарий 1 чек-листа +(HARDWARE_VERIFICATION_PHASE3.md) перестал проходить: консоль показывала `waiting_for_sd` → +`installing`, прыжок доходил до `p_vt->reset()` (подтверждено в отладчике), но установленный образ +не запускался — ни сразу, ни после power cycle; erase Slot A возвращал bootloader в обычный +ping/pong-цикл. + +**Причина — не регресс кода.** Стабы `test_stub` не PIC: `stub_a_*` линкован под адрес Slot A +(`0x60040000`), `stub_b_*` — под Slot Б (`0x60240000`), абсолютные адреса зашиты в бинарь при +линковке (тот же открытый вопрос, что и в Фазе 2 — "разный адрес — намеренно... открытый вопрос из +BOOTLOADER_FLASH_MAP.md §4"). Чек-лист сценария 1 предписывал класть на SD `stub_b_v2_confirmed.bin`, +а целевой слот на чистой плате (нет активного слота) по `update_policy_decide()` всегда Slot A — +несовпадение линковки и адреса установки. `slot_version_get()` (крипто-гейт) это не ловит: подпись +валидна для собственного содержимого файла независимо от того, где физически лежит слот; `boot_go()` +корректно выбирает и прыгает — исполняется код, рассчитанный на чужой адрес, без какой-либо ошибки на +CDC. Сценарии 2/3 эту грань не задевали случайно: там кандидат на понижение — всегда `stub_a`, а +целевой слот в этих сценариях (Slot Б уже активен) тоже всегда А — адрес и линковка совпадают +по совпадению постановки, не по общему принципу. + +Тот же класс несовпадения — уже задокументированный в проде gap одного `TFT_APP.BIN` +([docs/mimxrt1052/UPDATE_FLOW.md](../../docs/mimxrt1052/UPDATE_FLOW.md)): production-релиз собирается +как два линкованных под разные слоты бинаря на версию, а текущий `sd_update` полагается на единый +файл. Раунд 4 просто нащупал эту границу руками на стенде, а не создал новую. + +**Исправлено** — только чек-лист (не код): сценарий 1 теперь кладёт на SD `stub_a_v1_confirmed.bin` +(совпадает с гарантированным целевым слотом А), ожидаемая частота `LED_APP` — ~1/сек (v1), не ~2/сек. +`HARDWARE_VERIFICATION_PHASE3.md` §5 дополнен явным предупреждением о требовании +линковка-под-слот-установки для любого файла на SD. + +### ✅ Реализовано (2026-07-10): аппаратный watchdog + +**Статус:** реализован, собран (ARM + host зелёные), **аппаратно ещё не проверен** — провокацию +реального зависания и подтверждение восстановления см. в +[test_stub/HARDWARE_VERIFICATION_PHASE3.md](test_stub/HARDWARE_VERIFICATION_PHASE3.md), раздел +«Watchdog». Реализация против плана ниже: + +- **Модуль `bsp/wdog`** (WDOG1): `bsp_wdog_init(timeout_s)` / `bsp_wdog_refresh()` / + `bsp_wdog_caused_last_reset()` / `bsp_wdog_is_armed()` / `bsp_wdog_timeout_s()`. Таймаут **10 c** + (не 5-8 — самый долгий накормленный участок оказался блочным стиранием 2 МБ ~4.8 c плюс запас; + refresh теперь и внутри цикла стирания). `enableDebug=false` — под SWD-отладкой watchdog + приостановлен (иначе пошаговая отладка невозможна). +- **Взвод** — в `main.c` сразу после `board_hw_init()`. +- **Кормление** — верх главного цикла; поблочно/посекторно внутри `flash_area_erase()` + (`mcuboot_port/flash_map_backend.c`); на каждый чанк в `erase_and_copy_candidate()` + (`sd_update.c`); один раз прямо перед `boot_select_and_jump()`. **Не** кормим перед + `f_mount`/`SD_Init` — защита именно от них сохранена. +- **⚠️ Контракт handoff:** `WDE` (enable) — **write-once**, watchdog нельзя выключить и он переживает + прыжок. Поэтому **каждый образ после прыжка обязан кормить watchdog** (`tft_app` в проде; + `test_stub` уже кормит — иначе reset-loop и регрессия сценариев Фаз 2/3). Программный watchdog тут + невозможен: зависания — внутри вендоренного `do{}while(true)`, управление к нам не возвращается. +- **Статус по USB-CDC:** событие `{"type":"wdog","armed":..,"timeout_s":..,"recovered":..}` — + эмитится на старте, если предыдущий сброс был по watchdog, и по команде `{"type":"cmd","cmd":"wdog"}`. + `recovered:true` = плата восстановилась после зависания. +- Ресет по watchdog безопасен по построению (ни один слот не стирается до подтверждения валидности + нового образа — `erase_previous_active`): в худшем случае откат к «начать сессию заново». + +
Исходный план (до реализации) Найдено при аппаратной верификации (см. [DEBUG_LOG_PHASE3_SD.md](DEBUG_LOG_PHASE3_SD.md)): у `SD_PollingCardInsert()` (SDK, вызывается изнутри `SD_Init()`, который вызывается изнутри @@ -588,6 +649,8 @@ while (true)` без выхода по времени, если callback `cardDe следующий шаг после того, как будет закрыт текущий SD/card-detect баг (иначе watchdog будет маскировать/сбрасывать симптом на каждом цикле, не давая найти первопричину). +
+ **Верификация (первая часть Фазы 3, требующая реального железа — ещё не проведена)**: 1. Чистая плата (только bootloader, оба слота пустые) + SD с валидным подписанным образом → автоустановка, переход к загрузке (проверить по CDC-статусам и LED). @@ -652,6 +715,150 @@ while (true)` без выхода по времени, если callback `cardDe --- +## Фаза 6 — Устойчивость и восстановление (recovery) + +**Рекомендованный порядок исполнения: раньше Фаз 4/5** (см. статус-таблицу). Ядро (boot_select, +sd_update, update_policy, watchdog) свежее в контексте; закрывает уже случившуюся на практике боль — +окирпичивание платы зависшим образом. Полностью реализуема и железно проверяема уже сейчас через +`test_stub` (сторона tft_app — контракт, документируется в 6c под будущий план tft_app). + +**Цель**: гарантировать, что ни зависший в рантайме образ, ни кривая установка не превращают полевую +плату в кирпич, и дать оператору ручной аварийный вход. Аппаратный watchdog (Фаза 3) уже ловит +зависание — Фаза 6 надстраивает над ним **логику восстановления**: что делать ПОСЛЕ того, как +watchdog сбросил плату. + +### Таксономия отказов (что чем закрывается) + +| # | Сценарий | Механизм | Статус | +|---|---|---|---| +| A | Новый образ завис ДО подтверждения себя | watchdog-сброс + штатный revert MCUboot (`boot_select_or_erase`, [loader.c](../../sdk/middleware/mcuboot_opensource/boot/bootutil/src/loader.c) — стирает `copy_done==SET && image_ok!=SET`) → откат на прежний слот | **авто, кода в загрузчике не требует** (нужен лишь отложенный confirm в tft_app, 6c) | +| B | **Подтверждённый** образ завис в рантайме | счётчик watchdog-сбросов подряд + фолбэк (6a) | новый код 6a | +| C | Откатываться некуда (единственный/оба слота зависают) | recovery-режим: подавить загрузку, ждать SD (6b) | новый код 6b | +| D | Оператор хочет чистый старт вручную | recovery-режим по BTN_2 (6b) | новый код 6b | + +Ключ к классу A — **отложенное подтверждение**: если tft_app зовёт `boot_set_confirmed()` только +доказав здоровье (проработал T c, self-check пройден), любой битый новый образ, зависший в этом +окне, остаётся `image_ok!=SET` и откатывается MCUboot'ом автоматически. Самый частый класс закрыт +без единой строки в загрузчике. + +### Решения, принятые в обсуждении Фазы 6 (не пересматриваются) + +- **Отложенное подтверждение** (`boot_set_confirmed()` после доказанного здоровья) — контракт + tft_app. Переводит класс A в авто-откат. +- **WDOG переживает прыжок** (Фаза 3, `WDE` write-once) + **alive-flag идиом** в tft_app: главный + цикл ставит `alive=true`, таймерный ISR раз в ~1 c кормит watchdog только если `alive` был + выставлен (иначе главный поток завис → watchdog сработает). Одна строка в цикле, ловит зависания + главного потока, а не только полный локап. Таймаута 10 c хватает любому нормальному циклу с + запасом ×1000; отдельного внимания требуют только единичные блокирующие операции > таймаута. +- **Класс B — счётчик watchdog-сбросов подряд**, хранится в **retained-регистре `SRC_GPR`** + (переживает тёплый/watchdog-сброс, обнуляется на POR). Порог **настраиваемый `#define`, дефолт 3**. +- **Фолбэк класса B — Вариант A (стереть зависший слот)**: при достижении порога, ЕСЛИ второй слот + валиден (`slot_version_get`) → стереть активный (зависший) слот → `boot_go()` сам выберет второй → + прыжок. Если валидного второго слота НЕТ → **не стирать единственный образ** (иначе ноль рабочих + слотов) → recovery-режим. +- **Бан зависшего слота — per-session (Вариант 1)**: отдельного флеш-маркера НЕТ; «забанен» = «счётчик + ≥ порога». На POR счётчик обнуляется → на холодном старте зависший слот пробуется снова (даёт шанс, + если завис был транзиентным). Детерминированный завис одинокого слота → оператор жмёт **BTN_2** + (немедленный recovery), не дожидаясь порог×10 c. Флеш-маркер сознательно не делаем — если позже в + поле детерминированные зависы одинокого слота окажутся частыми, добавим как отдельный кусок (место + в дизайне оставлено). +- **Recovery-режим — единое состояние, два триггера**: авто (класс C — порог без валидного фолбэка) + и ручной (BTN_2 удержан при старте). Поведение: **прыжок подавлен** (это само рвёт loop), отдельный + LED-паттерн, CDC `recovery_mode`, ждём SD, **version-gate ослаблен** (принимаем любую подписанную + версию без кнопки — подпись проверяется всегда). +- **Стирание в recovery — отложенное** (на момент установки, не на входе): вход в recovery ничего не + стирает → случайное удержание BTN_2 безопасно (без SD-образа ничего не теряется); при найденном + валидном подписанном образе — стереть оба слота и поставить свежий в Slot A (sd_update и так стирает + целевой слот). «Чистый борт» достигается ровно тогда, когда есть чем заменить. +- **После recovery-установки — авто-прыжок** в свежепровалидированный образ (если и он зависнет — + отработает обычный класс A/B). +- **Приоритет триггеров при старте**: BTN_2 (recovery) проверяется ПЕРВЫМ — если удержан, в обычный + boot-путь не идём вообще. Затем BTN_1 (даунгрейд) в обычном пути. Recovery «главнее». +- **Правила обнуления счётчика**: (1) POR; (2) успешная установка нового образа (SD/recovery — свежему + образу полный бюджет попыток); (3) фолбэк-стирание (ситуация изменилась); (4) health-mark работающим + образом. + +### Честная граница (что НЕ восстанавливается автоматически — задокументировать) + +Если образ стабильно работает, **обнуляет счётчик** (health-mark), и лишь ПОТОМ ловит редкий баг +(конкретный файл на SD, конкретное CAN-сообщение) — счётчик каждый раз обнуляется до зависания, +автопорог не копится, loop автоматически не ловится. Это фундаментально (по таймеру не отличить +«здоров» от «здоров, но потом словил редкое»). Для такого класса: плата видимо циклится (watchdog + +recovery-LED/CDC это показывают), лечится SD-фиксом или BTN_2. Watchdog как минимум не даёт «намертво» +зависнуть. Принимаем и пишем в доке, не делаем вид, что решаем всё. + +### Разбивка + +**6a — Класс B: счётчик watchdog-сбросов + фолбэк A** (сторона загрузчика, host-тестируема) +- Расширить `bsp/wdog` (или новый `bsp/boot_state`): счётчик в `SRC_GPR[n]` (выбрать свободный — + `GPR1/2` заняты ROM под warm-boot entry/arg, `GPR10` под redundant/secondary boot; кандидаты + `GPR5..8`, **проверить по RM/на железе**, что переживает watchdog-сброс и что мы корректно обнуляем + на POR через `SRC->SRSR`). API: `bsp_boot_attempt_count()`, `bsp_boot_attempt_inc()`, + `bsp_boot_attempt_reset()`, `bsp_boot_health_mark()` (= reset, зовётся приложением). +- **Чистая host-тестируемая функция решения** (по образцу `update_policy_decide`), напр. + `recovery_decide(is_por, attempt_count, threshold, slot_a_state, slot_b_state, btn2_held)` → + `{NORMAL_BOOT | ERASE_ACTIVE_THEN_BOOT_OTHER(active_id) | RECOVERY}`. Активный слот вычисляется из + `slot_version_get()` обоих слотов (тот же приём, что уже в `update_policy`). Тесты в + `tests/host/recovery/` — все ветки таблицы таксономии. +- `main.c`: до `boot_select_and_jump()` — прочитать причину сброса (POR→reset счётчика), применить + `recovery_decide`. Ветки: NORMAL → `attempt_inc()` + прыжок; ERASE_OTHER → стереть активный слот + (`flash_area_erase`) + reset счётчика + прыжок (boot_go выберет оставшийся); RECOVERY → в состояние + 6b. **Инкремент — перед прыжком** (если образ зависнет, следующий старт увидит инкремент; здоровый + образ обнулит через health-mark). +- Расширить CDC `wdog`-статус: `{"type":"wdog",...,"reset_count":N,"threshold":M}` — диагностика в + поле («сбрасывалась N из M»). + +**6b — Recovery-режим + ручной BTN_2** (тонкий аппаратный оркестратор) +- Единое состояние recovery: подавить `boot_select_and_jump()`, отдельный LED-паттерн (узнаваемый + «шиммер»/SOS обоими LED, явно отличный от heartbeat 50/450 и app 500/250 — сведётся в словарь + Фазы 4), CDC `status: recovery_mode`. +- BTN_2 (`BSP_BUTTON_2`, свободен): ранний сэмпл + защёлкивание (как BTN_1 в Фазе 3, item 2), + приоритет над обычным boot-путём и над BTN_1. +- SD-скан с **ослабленным version-gate** (в `update_policy` — флаг `recovery`/`force_install`: + принять любую подписанную версию без кнопки; подпись/крипто-гейт остаются). Отложенное стирание: + на валидном образе — стереть оба слота, поставить в Slot A, авто-прыжок. +- Кормление watchdog в recovery — как везде (верх цикла + per-block при стирании; 2×2 МБ ~10 c + накрыты поблочным refresh). + +**6c — Контракт tft_app** (документация под будущий план tft_app, кода в загрузчике нет) +- tft_app ОБЯЗАН: (1) обслуживать watchdog (alive-flag идиом); (2) `boot_set_confirmed()` — + отложенно, после доказанного здоровья; (3) `bsp_boot_health_mark()` — дойдя до устойчивого + состояния (можно раньше confirm). (2) и (3) можно разнести: health-mark пораньше (дошёл до главного + цикла), confirm позже (полный self-test). +- Опционально: tft_app может переконфигурировать таймаут WDOG (`WCR.WT` переписываем, `WDE` — нет) + под свои длинные операции. + +### Стенд (доработки `test_stub`) + +Опции компиляции/поведения заглушки, чтобы прогнать все классы на железе: +- confirm: сразу / отложенно (через N c) / никогда; +- зависнуть: никогда / до health-mark / после health-mark / после confirm; +- обслуживать watchdog: да / нет. + +### Верификация (реальное железо — чек-лист, детали в отдельном HARDWARE_VERIFICATION при реализации) + +1. **Класс A** — свежий образ, `confirm=никогда`, `hang=после jump`: watchdog-сброс → MCUboot + стирает → откат на прежний слот (или wait_for_sd, если прежнего нет). +2. **Класс B, есть фолбэк** — `confirm=сразу`, `hang=после confirm`, второй слот валиден: после + порога — зависший слот стёрт, загрузка второго; CDC `reset_count` растёт до порога. +3. **Класс B, нет фолбэка** — то же, но второй слот пуст: после порога — recovery-режим (LED-шиммер, + CDC `recovery_mode`), плата не циклится дальше. +4. **Recovery по BTN_2** — валидный образ в слоте, удержать BTN_2 при старте → recovery-режим без + загрузки; вставить SD с образом → стирание обоих + установка + авто-прыжок. +5. **Транзиент (per-session)** — после класса B без фолбэка (recovery) сделать power cycle БЕЗ BTN_2 + → счётчик обнулён (POR), слот пробуется снова (подтверждает per-session-семантику). +6. **Не мешает норме** — прогнать сценарии Фазы 3 как есть: без ложных recovery/сбросов. + +### Открытые вопросы к реализации + +- `SRC_GPR[n]`: индекс (свободный от ROM) + подтвердить переживание watchdog-сброса и обнуление на + POR (RM + железо). +- `SRC->SRSR` — корректно читать/чистить (w1c) причину сброса, различать POR vs WDOG (биты + `SRC_SRSR_WDOG_RST_B` / POR). +- Word choice recovery-LED — согласовать со словарём Фазы 4, чтобы не переделывать. + +--- + ## Ключевые файлы для переиспользования (не изобретать заново) | Что нужно | Где смотреть образец | diff --git a/firmware/bootloader/mcuboot_port/flash_map_backend.c b/firmware/bootloader/mcuboot_port/flash_map_backend.c index e95c85a..19c5245 100644 --- a/firmware/bootloader/mcuboot_port/flash_map_backend.c +++ b/firmware/bootloader/mcuboot_port/flash_map_backend.c @@ -15,6 +15,7 @@ */ #include "bsp/qspi_flash.h" +#include "bsp/wdog.h" #include "flash_map.h" #include "sysflash/sysflash.h" @@ -146,6 +147,11 @@ int flash_area_erase(const struct flash_area *area, uint32_t off, uint32_t len) uint32_t block_addr = area->fa_off; for (; len > 0U; len -= BSP_QSPI_BLOCK_64K_SIZE) { + /* Кормим watchdog поблочно: стирание 2 МБ ~4.8 c — это реальный + * прогресс, но один блочный вызов не должен упереться в таймаут. + * Зависание самого стирания флеша всё равно ловится: refresh — по + * ЗАВЕРШЕНИИ блока, а не перед ним. */ + bsp_wdog_refresh(); if (bsp_qspi_erase_block_64k(block_addr) != BSP_OK) { return -1; @@ -158,6 +164,7 @@ int flash_area_erase(const struct flash_area *area, uint32_t off, uint32_t len) uint32_t addr = area->fa_off + off; for (; len > 0U; len -= BSP_QSPI_SECTOR_SIZE) { + bsp_wdog_refresh(); /* см. выше — посекторный путь тоже длинный */ if (bsp_qspi_erase_sector(addr) != BSP_OK) { return -1; diff --git a/firmware/bootloader/src/cli.c b/firmware/bootloader/src/cli.c index c159d60..f63861e 100644 --- a/firmware/bootloader/src/cli.c +++ b/firmware/bootloader/src/cli.c @@ -135,6 +135,12 @@ static void handle_cmd(const char *p_line) return; } + if (strcmp(cmd_name, "wdog") == 0) + { + protocol_send_wdog_status(); + return; + } + protocol_send_error("UNKNOWN_CMD"); } diff --git a/firmware/bootloader/src/main.c b/firmware/bootloader/src/main.c index 8269d41..56e531e 100644 --- a/firmware/bootloader/src/main.c +++ b/firmware/bootloader/src/main.c @@ -17,15 +17,25 @@ * * Последовательность старта: * 1. board_hw_init() — тактирование, MPU, кэш, пины - * 2. bsp_led_init() — оба LED выключены - * 3. bsp_tick_init() — SysTick 1 мс - * 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 повторно) + * 2. bsp_wdog_init() — аппаратный watchdog как можно раньше (см. ниже) + * 3. bsp_led_init() — оба LED выключены + * 4. bsp_tick_init() — SysTick 1 мс + * 5. bsp_button_init() — для проверки удержания BSP_BUTTON_1 + * 6. bsp_qspi_init() — доступ к Slot A/Б + * 7. bsp_usb_cdc_init() — не блокирует, см. выше + * 8. sd_update_check() — no-op быстро, если SD не вставлена + * 9. boot_select_and_jump() — при успехе не возвращается + * 10. Цикл ожидания — CDC ping/pong + LED + периодический + * пере-скан SD (шаги 8-9 повторно) + * + * Watchdog (bsp_wdog): единственная защита от бесконечных зависаний в + * блокирующих вызовах SDMMC-стека, не возвращающих управление в наш код + * (SD_PollingCardInsert / OSA_SemaphoreWait — см. DEBUG_LOG_PHASE3_SD.md). + * ⚠️ WDE — write-once: после взвода watchdog не выключить, он переживает прыжок, + * поэтому целевой образ (tft_app / test_stub) ОБЯЗАН его кормить (см. bsp/wdog). + * Кормим только в точках реального прогресса (верх цикла, циклы стирания/ + * копирования, перед прыжком) — НЕ перед f_mount/SD_Init, иначе watchdog + * перестаёт защищать именно от них. * * Bootloader без SDRAM (см. docs/mimxrt1052/BOOTLOADER_FLASH_MAP.md) — DCD * не используется (bsp_boot_xip_no_dcd). @@ -37,6 +47,7 @@ #include "bsp/qspi_flash.h" #include "bsp/tick.h" #include "bsp/usb_cdc.h" +#include "bsp/wdog.h" #include "cli.h" #include "protocol.h" #include "sd_update.h" @@ -50,19 +61,36 @@ int main(void) const uint32_t SD_RETRY_PERIOD_MS = 1500U; const uint32_t HEARTBEAT_ON_MS = 50U; const uint32_t HEARTBEAT_PERIOD_MS = 500U; + /* Таймаут WDOG. С запасом над самым долгим НАКОРМЛЕННЫМ участком: между + * соседними refresh худший легитимный интервал — одиночное стирание 64 КБ + * блока (~0.15..2 c по даташиту W25Q) либо цепочка bsp_sd_init+f_mount+пик + * слотов (~2-2.5 c). 10 c даёт кратный запас; зависание ловится ≤10 c. */ + const uint32_t WDOG_TIMEOUT_S = 10U; board_hw_init(); + /* Как можно раньше — до первой же SD-логики, которая может зависнуть. */ + (void) bsp_wdog_init(WDOG_TIMEOUT_S); + bsp_led_init(); bsp_tick_init(); bsp_button_init(); + /* Жест форс. даунгрейда — удержание BSP_BUTTON_1 при подаче питания. + * Сэмплируем РОВНО ЗДЕСЬ, до медленной SD-инициализации, и защёлкиваем на + * всю сессию: сама установка читает кнопку глубоко внутри run_update() + * (после mount + двух крипто-валидаций слотов, секунды спустя), поэтому + * читать её там — неинтуитивно (см. DEBUG_LOG_PHASE3_SD.md, тайминг кнопки). + * Значение переиспользуется и первой попыткой, и пере-сканами в цикле. */ + const bool DOWNGRADE_HELD = bsp_button_read(BSP_BUTTON_1); + 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 не вставлена */ + sd_update_check(DOWNGRADE_HELD); /* no-op быстро, если SD не вставлена */ + bsp_wdog_refresh(); /* образ унаследует полное окно таймаута */ boot_select_and_jump(); /* при успехе не возвращается */ } @@ -76,21 +104,25 @@ int main(void) cli_init(); + /* Если предыдущий сброс — по таймауту watchdog, известим (best-effort: + * если хост ещё не подключён, сообщение потеряется — состояние всегда + * доступно по команде "wdog", см. cli.c). */ + if (bsp_wdog_caused_last_reset()) + { + protocol_send_wdog_status(); + } + /* Готово немедленно — первая попытка сразу извещает "жду SD", не ждёт * SD_RETRY_PERIOD_MS. Дальнейшие попытки уже дросселируются периодом. */ uint32_t next_sd_retry_ms = bsp_tick_get_ms(); while (1) { + bsp_wdog_refresh(); /* начало итерации — точка реального прогресса */ + 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) { @@ -107,7 +139,8 @@ int main(void) protocol_send_status("waiting_for_sd"); - sd_update_check(); /* no-op быстро, если SD не вставлена */ + sd_update_check(DOWNGRADE_HELD); + bsp_wdog_refresh(); /* образ унаследует полное окно таймаута */ boot_select_and_jump(); /* при успехе не возвращается */ } } diff --git a/firmware/bootloader/src/protocol.c b/firmware/bootloader/src/protocol.c index 0fa7a3c..9eeb98c 100644 --- a/firmware/bootloader/src/protocol.c +++ b/firmware/bootloader/src/protocol.c @@ -5,6 +5,7 @@ #include "protocol.h" +#include "bsp/wdog.h" #include "cli.h" #include @@ -43,3 +44,14 @@ void protocol_send_status(const char *p_state) (void) snprintf(buf, sizeof(buf), "{\"type\":\"status\",\"state\":\"%s\"}\n", p_state); cli_send(buf); } + +void protocol_send_wdog_status(void) +{ + char buf[PROTO_BUF_SIZE]; + (void) snprintf(buf, sizeof(buf), + "{\"type\":\"wdog\",\"armed\":%s,\"timeout_s\":%u,\"recovered\":%s}\n", + bsp_wdog_is_armed() ? "true" : "false", + (unsigned) bsp_wdog_timeout_s(), + bsp_wdog_caused_last_reset() ? "true" : "false"); + cli_send(buf); +} diff --git a/firmware/bootloader/src/protocol.h b/firmware/bootloader/src/protocol.h index e6357bf..c2e6aab 100644 --- a/firmware/bootloader/src/protocol.h +++ b/firmware/bootloader/src/protocol.h @@ -18,6 +18,8 @@ * Типы исходящих событий (Фаза 3): * status — top-level состояние bootloader (waiting_for_sd и т.д., * см. protocol_send_status()) + * wdog — статус аппаратного watchdog (armed/timeout/recovered, + * см. protocol_send_wdog_status()) * * Полный словарь состояний status (smoke_pass/smoke_fail/booting/...) * появится в Фазе 4. @@ -60,4 +62,17 @@ void protocol_send_error(const char *p_code); */ void protocol_send_status(const char *p_state); +/** + * @brief Отправить статус аппаратного watchdog. + * + * Формат: {"type":"wdog","armed":true,"timeout_s":10,"recovered":false} + * - armed — watchdog взведён (bsp_wdog_init выполнен); + * - timeout_s — сконфигурированный таймаут в секундах; + * - recovered — ПОСЛЕДНИЙ сброс МК был по таймауту watchdog (плата + * восстановилась после зависания). + * + * Эмитится один раз на старте, если recovered, и по команде "wdog". + */ +void protocol_send_wdog_status(void); + #endif /* PROTOCOL_H_ */ diff --git a/firmware/bootloader/src/sd_update.c b/firmware/bootloader/src/sd_update.c index 36c11ad..506f613 100644 --- a/firmware/bootloader/src/sd_update.c +++ b/firmware/bootloader/src/sd_update.c @@ -20,9 +20,9 @@ #include "sd_update.h" #include "bootutil/image.h" -#include "bsp/button.h" #include "bsp/sd.h" #include "bsp/usb_cdc.h" +#include "bsp/wdog.h" #include "ff.h" #include "flash_map.h" #include "protocol.h" @@ -105,6 +105,7 @@ static bool erase_and_copy_candidate(const struct flash_area *p_fap, uint32_t fi while (offset < file_size) { bsp_usb_cdc_poll(); + bsp_wdog_refresh(); /* потоковое копирование — реальный прогресс на чанк */ uint32_t want = file_size - offset; if (want > SD_UPDATE_CHUNK_SIZE) @@ -138,7 +139,7 @@ static bool erase_and_copy_candidate(const struct flash_area *p_fap, uint32_t fi /* ── Основной сценарий ────────────────────────────────────────────────── */ -static void run_update(void) +static void run_update(bool button_held) { struct image_version candidate_ver; struct image_version installed_ver; @@ -147,7 +148,6 @@ static void run_update(void) 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) @@ -174,11 +174,11 @@ static void run_update(void) 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); + file_size = (uint32_t) f_size(&g_s_file); + slot_a = peek_slot(0U); + slot_b = peek_slot(1U); + /* button_held — сэмплирован при старте в main.c и передан сюда (см. sd_update.h). */ + decision = update_policy_decide(&slot_a, &slot_b, &candidate_ver, button_held); if (decision.action == UPDATE_POLICY_SKIP) { @@ -242,12 +242,12 @@ cleanup: /* ── Public API ────────────────────────────────────────────────────────── */ -void sd_update_check(void) +void sd_update_check(bool downgrade_button_held) { if (!bsp_sd_is_inserted()) { return; } - run_update(); + run_update(downgrade_button_held); } diff --git a/firmware/bootloader/src/sd_update.h b/firmware/bootloader/src/sd_update.h index a940e7d..5dcb0fb 100644 --- a/firmware/bootloader/src/sd_update.h +++ b/firmware/bootloader/src/sd_update.h @@ -8,6 +8,8 @@ #ifndef SD_UPDATE_H_ #define SD_UPDATE_H_ +#include + /** * @brief Одна попытка: смонтировать SD, найти TFT_APP.BIN, при необходимости * установить его в неактивный слот. @@ -21,8 +23,15 @@ * Ничего не делает, если SD не вставлена (bsp_sd_is_inserted() == false) — * безопасно вызывать многократно, в т.ч. из цикла ожидания в main.c. * + * @param downgrade_button_held Состояние BSP_BUTTON_1, сэмплированное ОДИН РАЗ + * при старте (main.c, до медленной SD-инициализации) и защёлкнутое. + * true → разрешён форс. даунгрейд более старого подписанного образа + * (см. update_policy.h). Передаётся, а не читается здесь, чтобы жест + * "удержание при включении" ловился в предсказуемый ранний момент, а не + * через несколько секунд внутри run_update() (DEBUG_LOG_PHASE3_SD.md). + * * @pre bsp_qspi_init() уже вызван. */ -void sd_update_check(void); +void sd_update_check(bool downgrade_button_held); #endif /* SD_UPDATE_H_ */ diff --git a/firmware/bootloader/test_stub/CMakeLists.txt b/firmware/bootloader/test_stub/CMakeLists.txt index c16f87b..9e48e19 100644 --- a/firmware/bootloader/test_stub/CMakeLists.txt +++ b/firmware/bootloader/test_stub/CMakeLists.txt @@ -4,10 +4,10 @@ # firmware/bootloader/PLAN.md, "Аппаратная верификация Фазы 2". Не часть # продукта — удалить, когда появится реальный tft_app. # -# Два таргета из одного main.c: разный адрес слота (--defsym __slot_base__) -# и разная частота мигания (STUB_BLINK_MS) — чтобы на глаз отличить, какой -# слот выбрал bootloader. Постройка .bin, дальше подписывается вручную -# imgtool'ом (см. PLAN.md) — .bin сюда не должен попасть без подписи. +# Два таргета из одного main.c: разный адрес слота (--defsym __slot_base__) и +# разная частота мигания (STUB_BLINK_MS) — чтобы на глаз отличить, какой слот +# выбрал bootloader. Постройка .bin, дальше подписывается вручную imgtool'ом +# (см. PLAN.md) — .bin сюда не должен попасть без подписи. function(add_mcuboot_stub NAME SLOT_BASE BLINK_MS) add_executable(${NAME} main.c ${BSP_GENERATED}/clock_config.c @@ -16,8 +16,11 @@ function(add_mcuboot_stub NAME SLOT_BASE BLINK_MS) target_compile_definitions(${NAME} PRIVATE STUB_BLINK_MS=${BLINK_MS} __STARTUP_CLEAR_BSS) + # bsp_wdog: загрузчик взводит WDOG перед прыжком, WDE — write-once, поэтому + # образ ОБЯЗАН его кормить (иначе reset-loop). Заглушка — стенд-ин tft_app, + # тоже кормит. См. bsp/wdog/README.md. target_link_libraries(${NAME} PRIVATE bsp_board bsp_led bsp_tick - bsp_boot_xip_no_dcd) + bsp_boot_xip_no_dcd bsp_wdog) target_link_options( ${NAME} @@ -39,7 +42,7 @@ function(add_mcuboot_stub NAME SLOT_BASE BLINK_MS) COMMAND ${CMAKE_OBJCOPY} -O binary $ ${CMAKE_BINARY_DIR}/${NAME}.bin COMMAND ${CMAKE_SIZE} $ - COMMENT "Generating ${NAME}.bin (не подписан — imgtool sign вручную, см. PLAN.md)") + COMMENT "Generating ${NAME}.bin") endfunction() # Slot A: 0x60040000, мигает раз в 500 мс ("версия 1") diff --git a/firmware/bootloader/test_stub/HARDWARE_VERIFICATION_PHASE3.md b/firmware/bootloader/test_stub/HARDWARE_VERIFICATION_PHASE3.md index 84ea4e1..ecc93d4 100644 --- a/firmware/bootloader/test_stub/HARDWARE_VERIFICATION_PHASE3.md +++ b/firmware/bootloader/test_stub/HARDWARE_VERIFICATION_PHASE3.md @@ -103,31 +103,46 @@ uv run --directory tools/hil pyocd erase --target mimxrt1050_quadspi --frequency Карта — FAT32/FAT16. Bootloader ищет в корне файл **`TFT_APP.BIN`** (путь `2:/TFT_APP.BIN`, диск `2:` — внутренняя нумерация FatFS, физически это корень карты). Файл — подписанный образ-заглушка, -просто переименованный: +просто переименованный. + +> ⚠️ **Стабы не PIC — файл на SD должен быть линкован под адрес слота, в который он реально попадёт.** +> `stub_a_*` линкован под `0x60040000` (Slot A), `stub_b_*` — под `0x60240000` (Slot Б); абсолютные +> адреса (таблица векторов, литералы) зашиты в бинарь при линковке. Установка "не того" файла в слот +> проходит crypto-гейт (подпись валидна для своего же содержимого) и доходит до прыжка, но +> исполняется код, рассчитанный на другой адрес — эффективно ничего не запускается, без ошибки на +> CDC. Тот же открытый вопрос, что и в Фазе 2 ([../PLAN.md](../PLAN.md), "разный адрес — намеренно... +> открытый вопрос из BOOTLOADER_FLASH_MAP.md §4") и уже задокументированный в проде как gap одного +> `TFT_APP.BIN` в [../../../docs/mimxrt1052/UPDATE_FLOW.md](../../../docs/mimxrt1052/UPDATE_FLOW.md). +> **Целевой слот определяет `update_policy_decide()`** (всегда НЕ активный слот; на пустой плате +> активного слота нет → по умолчанию Slot A) — файл на SD должен быть собран под этот же адрес. ```bash -# Кандидат-"новее" (v2) — для авто-установки/сравнения версий -cp build/Debug/signed/stub_b_v2_confirmed.bin /Volumes//TFT_APP.BIN - -# Кандидат-"старше" (v1) — для теста даунгрейда (сценарий 3) +# Сценарий 1 (чистая плата — целевой слот всегда А, т.к. активного слота нет) — +# ОБЯЗАТЕЛЬНО stub_a, не stub_b: stub_b линкован под Slot Б и не запустится в Slot A. cp build/Debug/signed/stub_a_v1_confirmed.bin /Volumes//TFT_APP.BIN -# Кандидат с битой ПОДПИСЬЮ (magic цел, TLV/хэш испорчен) — сценарий 4b (SD_INSTALL_REJECTED) +# Сценарий 2/3 (Slot Б уже занят под v2 — целевой слот снова А, т.к. Slot Б активен) — +# тот же stub_a, сценарии отличаются только удержанием кнопки, см. §7 и таблицу §8. +cp build/Debug/signed/stub_a_v1_confirmed.bin /Volumes//TFT_APP.BIN + +# Кандидат с битой ПОДПИСЬЮ (magic цел, TLV/хэш испорчен) — сценарий 4b (SD_INSTALL_REJECTED). +# Линковка тут неважна — кандидат отклоняется до записи/выбора, ни разу не исполняется. cp build/Debug/signed/stub_b_v2_confirmed.bin /Volumes//TFT_APP.BIN # затем поменять один байт в СЕРЕДИНЕ файла (payload), напр. в hex-редакторе — magic в начале не трогать -# Кандидат с битым ЗАГОЛОВКОМ (magic) — сценарий 4a (SD_CANDIDATE_INVALID) +# Кандидат с битым ЗАГОЛОВКОМ (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»). +> ℹ️ **Про повторный скан карты без ресета (раунд 4).** Детект сведён к единому механизму — пин +> постоянно на `USDHC1_CD_B`, и гейт, и внутренний детект SDK читают USDHC `PRES_STATE.CINST` (item +> 1, см. DEBUG_LOG). Прежняя хрупкость (после первого `bsp_sd_init()` пин уходил на `GPIO2_IO28` и +> пере-скан ослеп бы) устранена. **Стоит отдельно проверить** кейс, который раньше был сломан: карта +> вставлена, но `TFT_APP.BIN` появляется/кладётся на неё БЕЗ извлечения и перезагрузки — пере-скан в +> цикле ожидания (~1.5 c) должен его подхватить. Между полноценными сценариями power cycle всё равно +> обязателен (см. ниже). --- @@ -153,27 +168,29 @@ screen /dev/cu.usbmodemXXXX # macOS, порт свой у каждого по | `SD_INSTALL_WRITE_FAILED` | сбой записи/verify чанка во flash | | `SD_INSTALL_REJECTED` | образ записан, но пост-проверка (hash+ECDSA) не прошла | | `SD_DOWNGRADE_ERASE_FAILED` | не удалось стереть прежний активный слот при форс. даунгрейде | +- Watchdog (`{"type":"wdog",...}`): запросить `{"type":"cmd","cmd":"wdog"}` → + `{"type":"wdog","armed":true,"timeout_s":10,"recovered":false}`. `recovered:true` означает, что + предыдущий сброс был по таймауту watchdog (плата восстановилась после зависания) — то же событие + эмитится автоматически один раз при старте, если восстановление произошло. --- -## 7. Тайминг удержания кнопки даунгрейда (BSP_BUTTON_1) +## 7. Удержание кнопки даунгрейда (BSP_BUTTON_1) -**Держать кнопку с момента подачи питания и не отпускать, пока по CDC не придёт -`status: installing`** (или пока `LED_APP` не замигает с частотой более старого образа). Ориентир — -**~15 секунд, с запасом**; отпускание на 2 c даунгрейд НЕ вызывает. +**Держать `BSP_BUTTON_1` нажатой в момент подачи питания** (при включении). Состояние считывается +один раз в первые миллисекунды после `board_hw_init()` — сразу после `bsp_button_init()`, ДО +обращения к SD — и защёлкивается на всю сессию. Долгое удержание не требуется: важно, чтобы кнопка +была нажата на момент включения. -Почему так (разбор по коду — [../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 идёт -только в главном цикле, до которого управление ещё не дошло). Отпустишь раньше сэмпла — прочитается -"не нажата" → штатная загрузка нового слота. «Срабатывает сразу после отпускания» — это не триггер -по отпусканию (кода на отпускание нет), а совпадение: решение защёлкивается на сэмпле, пока кнопка -ещё нажата. +> **Изменение поведения (раунд 4, требует ре-проверки):** раньше кнопка читалась глубоко внутри +> `run_update()` — уже после `f_mount()` и двух крипто-валидаций слотов, то есть через несколько +> секунд слепого окна (приходилось держать ~15 c, пока по CDC не придёт `installing`). Теперь сэмпл +> ранний и защёлкнутый (`main.c` → `downgrade_held` → `sd_update_check(...)`), см. +> [../DEBUG_LOG_PHASE3_SD.md](../DEBUG_LOG_PHASE3_SD.md), раунд 4 / item 2. При проверке сценария 3 +> достаточно удерживать кнопку при включении. -> Известное неудобство (в очереди на доработку, не реализовано): сэмплить кнопку РАНО, при старте, -> до медленной SD-инициализации — тогда «удержание при включении» стало бы предсказуемым. +`bsp_button_read()` — мгновенное сырое чтение, без debounce и latch; жест — именно «нажато в момент +старта», не «нажать после». --- @@ -184,9 +201,9 @@ screen /dev/cu.usbmodemXXXX # macOS, порт свой у каждого по | № | Сценарий | Подготовка | Ожидаемый результат | | --- | -------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | -| 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) | +| 1 | Чистая плата + валидный образ на SD | Erase Slot A и Slot Б (§4); SD с `TFT_APP.BIN` = `stub_a_v1_confirmed.bin` (§5, **не** stub_b — см. предупреждение о PIC там); вставить карту | CDC: `installing`; авто-установка в Slot A → прыжок; `LED_APP` мигает ~1/сек (v1) | | 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/сек | +| 3 | Форс. даунгрейд по кнопке | Как сценарий 2, но **удерживать `BSP_BUTTON_1` нажатой в момент подачи питания** (см. §7) | 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` при желании | @@ -194,4 +211,197 @@ screen /dev/cu.usbmodemXXXX # macOS, порт свой у каждого по **Инструмент подтверждения «прыжок реально произошёл»** — тот же, что в Фазе 2: частота мигания `LED_APP` (500 мс vs 250 мс) однозначно указывает на выбранный слот, верифицируемо глазами. +> ⚠️ **Watchdog и заглушка.** Начиная с этого раунда загрузчик взводит аппаратный WDOG (10 c) перед +> прыжком, а `WDE` — write-once (выключить нельзя). Заглушка `test_stub` его кормит, поэтому мигает +> как раньше. Если прыгнуть в образ, который WDOG НЕ кормит, плата будет ресетиться каждые ~10 c — +> для будущего `tft_app` это обязательный контракт (см. [../../../bsp/wdog/README.md](../../../bsp/wdog/README.md)). + +--- + +## 9. Watchdog — провокация зависания и подтверждение восстановления + +Цель: убедиться, что аппаратный WDOG1 (a) реально сбрасывает плату при зависании в блокирующем коде, +(b) даёт восстановление (прыжок в валидный слот после сброса) без второго вмешательства оператора, +(c) корректно сообщает об этом по CDC, (d) не мешает нормальной работе и отладке. + +Раунд 3 закрыл конкретный баг card-detect (§0), поэтому исходный сценарий «пустой слот CD, физическое +выдёргивание карты» больше не воспроизводится детерминированно тем же путём. Вместо охоты за реальной +гонкой — **синтетическое зависание, включаемое временным патчем и гарантированно однократное**: код +проверяет `bsp_wdog_caused_last_reset()` и виснет только в том прогоне, где предыдущий сброс не был +watchdog-сбросом (т.е. только на первом POR-старте). Второй (WDOG-инициированный) сброс уже не +попадает в ветку зависания — плата продолжает штатный boot без ручного вмешательства между двумя +шагами. Один power cycle — весь тест, включая наблюдение восстановления. + +**Общие предпосылки для 9.1 и 9.2:** +- В Slot A — валидный подтверждённый `stub_a_v1_confirmed.bin` (см. §3), чтобы после WDOG-сброса + плате было куда восстанавливаться. +- Патчи в этом разделе — **временные, не коммитить**. После каждого теста — `git diff` / `git + checkout -- firmware/bootloader/src/...` перед переходом к следующему шагу. +- Секундомер (или просто ощущение "около 10 c") достаточен — таймаут не настолько короткий, чтобы + нужен был осциллограф. + +--- + +### 9.1 Тест A — механизм WDOG1 напрямую (без SD, без карты) + +Изолирует сам аппаратный таймер от всей SD/USDHC-логики: гарантированный ноль `bsp_wdog_refresh()` +после старта. + +**Патч** (`firmware/bootloader/src/main.c`, сразу после `bsp_wdog_init()`, до `bsp_led_init()`): + +```c + (void) bsp_wdog_init(WDOG_TIMEOUT_S); + + /* ВРЕМЕННО — тест 9.1, не коммитить */ + if (!bsp_wdog_caused_last_reset()) + { + for (;;) { } + } + + bsp_led_init(); +``` + +**Сборка и прошивка:** + +```bash +just build::build-bootloader-debug +just host::flash-swd-bootloader-debug +``` + +**Прогон (один power cycle):** + +1. Подать питание (или физический power cycle, не ресет через отладчик — нужен настоящий POR, чтобы + `WDOG1->WRSR`/`SRC->SRSR` были в чистом состоянии). +2. Наблюдение: ничего не мигает, ничего не инициализируется (зависание — до `bsp_led_init()`), + CDC-порт не появляется. +3. Через **~10 c** — аппаратный сброс. На этот раз `bsp_wdog_caused_last_reset()` вернёт `true` (сброс + был по WDOG), условие ложно — зависания не будет, выполнение идёт дальше штатно: + `bsp_led_init()` → ... → `boot_select_and_jump()` → прыжок в Slot A. `LED_APP` начинает мигать + ~1/сек (частота `stub_a`) — это и есть подтверждение восстановления, без второго power cycle. +4. Подключиться по CDC и запросить статус: + + ```bash + screen /dev/cu.usbmodemXXXX + {"type":"cmd","cmd":"wdog"} + ``` + + Ожидаемый ответ: `{"type":"wdog","armed":true,"timeout_s":10,"recovered":true}`. + +**Итог теста A:** таймаут ≈10 c подтверждён по секундомеру, `recovered:true` подтверждён по CDC, +восстановление в валидный слот подтверждено по LED — без ручного вмешательства между зависанием и +восстановлением. + +--- + +### 9.2 Тест B — реалистичный сценарий (внутри SD-пути, требует вставленной карты) + +Тот же приём gate-инга по `bsp_wdog_caused_last_reset()`, но зависание — в точке, где исторически уже +дважды зависал реальный SD/USDHC-стек (см. [../DEBUG_LOG_PHASE3_SD.md](../DEBUG_LOG_PHASE3_SD.md)): +сразу после успешного `bsp_sd_init()`, перед `f_mount()`. + +**Патч** (`firmware/bootloader/src/sd_update.c`, в `run_update()`, сразу после проверки +`bsp_sd_init()`): + +```c + if (bsp_sd_init() != BSP_OK) + { + return; + } + + /* ВРЕМЕННО — тест 9.2, не коммитить */ + if (!bsp_wdog_caused_last_reset()) + { + for (;;) { } + } + + if (f_mount(&g_s_fs, SD_UPDATE_MOUNT_POINT, 1) != FR_OK) +``` + +**Предпосылка, отличная от 9.1:** SD-карта должна быть **физически вставлена** при подаче питания — +иначе `sd_update_check()` вернётся по `bsp_sd_is_inserted() == false` ещё до `run_update()`, и +патч не сработает вообще. Содержимое карты не важно (можно пустую/неотформатированную — до чтения +файловой системы код не доходит). + +**Сборка, прошивка, прогон** — идентично 9.1 (`just build::build-bootloader-debug`, +`just host::flash-swd-bootloader-debug`, один power cycle с картой в слоте). Ожидаемая картина: LED +успевает пройти обычную раннюю инициализацию (heartbeat может пару раз моргнуть, пока не дошло до +`sd_update_check()`), дальше зависание на ~10 c, автоматический WDOG-сброс, на втором прогоне (тот же +power cycle, вмешательство не требуется) — штатное продолжение и прыжок в Slot A. CDC-проверка — +как в 9.1, п. 4. + +**Итог теста B:** подтверждает защиту именно того класса зависаний, ради которого watchdog и +проектировался — блокирующий вызов в SD-пути, не возвращающий управление. + +--- + +### 9.3 (опционально) Прямое чтение регистров через pyOCD + +Дополнительное, не обязательное подтверждение на уровне регистров — если хочется убедиться в причине +сброса, не полагаясь только на прошивку/CDC. **Осторожно:** подключение отладчика само по себе может +повлиять на состояние ядра в зависимости от режима коннекта — трактовать как вспомогательную, а не +основную проверку; основные критерии успеха — секундомер + LED + CDC-ответ из 9.1/9.2. + +```bash +# WDOG1->WRSR (0x400B8004, 16-бит) — бит1 (0x2) = TOUT, сброс был по watchdog +uv run --directory tools/hil pyocd commander --target mimxrt1050_quadspi --frequency 4000000 \ + -c "read16 0x400B8004" + +# SRC->SRSR (0x400F8008, 32-бит) — бит4 (0x10) = WDOG_RST_B, та же причина на уровне SRC +uv run --directory tools/hil pyocd commander --target mimxrt1050_quadspi --frequency 4000000 \ + -c "read32 0x400F8008" +``` + +Оба регистра read-only снимки последнего сброса — валидны сразу после WDOG-сброса, до следующего +любого сброса (в т.ч. до ресета самим отладчиком при коннекте — читать значение сразу первой командой +сессии, ничего не делать до этого). + +--- + +### 9.4 Регрессия — «не мешает нормальной работе» + +Обязательно после 9.1/9.2, перед закрытием пункта: + +1. Откатить оба временных патча (`git diff firmware/bootloader/src/main.c + firmware/bootloader/src/sd_update.c` должен быть пустым), пересобрать и перепрошить: + + ```bash + just build::build-bootloader-debug + just host::flash-swd-bootloader-debug + ``` + +2. Power cycle, подключиться по CDC, запросить `{"type":"cmd","cmd":"wdog"}` — ожидается + `"recovered":false` (последний сброс — обычный power-on, не watchdog). +3. Прогнать чек-лист §8 (сценарии 1–5) как есть — все должны проходить без единого неожиданного + сброса. WDOG кормится во всех легитимных долгих операциях (стирание слота, потоковое копирование, + цикл ожидания SD) — сценарии с самой длинной легитимной работой (установка образа, сценарии 1 и 3) + — лучший регрессионный индикатор: если бы кормление где-то пропустили, именно они словили бы + ложный сброс посреди операции. +4. Оставить плату в цикле ожидания (без SD, оба слота с валидными образами не трогать) на 2+ минуты, + убедиться, что `LED_HEARTBEAT`/`LED_APP` продолжают штатный паттерн без сбросов — таймаут 10 c + означает, что пропуск хотя бы одного кормления в цикле проявился бы в пределах первой минуты. + +--- + +### 9.5 Поведение под отладчиком (SWD halt) + +Подтвердить, что `enableDebug=false` действительно приостанавливает WDOG под отладкой — иначе +пошаговая отладка любого будущего кода после `bsp_wdog_init()` была бы невозможна: + +1. Запустить `🐛 Debug: bootloader` (см. Фаза 1), поставить брейкпоинт после `bsp_wdog_init()`. +2. Остановиться на нём и держать паузу **дольше 10 c** (просто подождать, не резюмировать). +3. Ожидаемо: сброса не происходит — после `Continue` выполнение продолжается с той же точки, как будто + таймер не тикал во время halt. + +--- + +### 9.6 Итоговый чек-лист + +| № | Тест | Ожидаемый результат | +|---|---|---| +| 9.1 | Синтетическое зависание сразу после `bsp_wdog_init()`, без SD | Сброс ≈10 c после POR; авто-восстановление в Slot A без второго power cycle; CDC `wdog` → `recovered:true` | +| 9.2 | Синтетическое зависание в `run_update()` после `bsp_sd_init()`, карта вставлена | То же поведение, но на пути, воспроизводящем исторический класс SD/USDHC-зависаний | +| 9.3 (опц.) | Чтение `WDOG1->WRSR`/`SRC->SRSR` через pyOCD | Бит причины сброса (`TOUT`/`WDOG_RST_B`) установлен сразу после WDOG-сброса | +| 9.4 | Регрессия: чек-лист §8 без патчей, ожидание 2+ мин в цикле | Ни одного ложного сброса; `wdog` → `recovered:false` | +| 9.5 | SWD-halt дольше 10 c | Сброса нет, отладка не сбивается | + --- diff --git a/firmware/bootloader/test_stub/main.c b/firmware/bootloader/test_stub/main.c index 4699d91..da77621 100644 --- a/firmware/bootloader/test_stub/main.c +++ b/firmware/bootloader/test_stub/main.c @@ -11,10 +11,18 @@ * Фазы 2". * * Без USB/CDC — визуальной индикации достаточно, минимальный код. + * + * Watchdog: загрузчик взводит аппаратный WDOG перед прыжком сюда, а WDE — + * write-once (выключить нельзя). Поэтому заглушка ОБЯЗАНА его кормить, иначе + * WDOG сбросит плату через таймаут и получится reset-loop — заглушка тут + * играет роль tft_app, которая в проде тоже будет кормить watchdog + * (см. bsp/wdog/README.md). Период мигания (250/500 мс) << таймаута (~10 c), + * так что refresh на каждой итерации — с огромным запасом. */ #include "board.h" #include "bsp/led.h" #include "bsp/tick.h" +#include "bsp/wdog.h" #ifndef STUB_BLINK_MS #error "STUB_BLINK_MS must be defined (see firmware/bootloader/test_stub/CMakeLists.txt)" @@ -28,6 +36,7 @@ int main(void) while (1) { + bsp_wdog_refresh(); /* обслуживаем унаследованный от загрузчика WDOG */ bsp_led_toggle(LED_APP); bsp_delay(STUB_BLINK_MS); } diff --git a/firmware/test/README.md b/firmware/test/README.md index ce7aefc..2837106 100644 --- a/firmware/test/README.md +++ b/firmware/test/README.md @@ -29,7 +29,6 @@ - [Как добавить новый тест](#как-добавить-новый-тест) - [Host unit-тесты](#host-unit-тесты) - [Версионирование](#версионирование) -- [Архитектурные решения (закрыты)](#архитектурные-решения-закрыты) --- @@ -199,16 +198,16 @@ main.c **BSP-зависимости тест-модулей** (по `target_link_libraries` в `CMakeLists.txt`): -| Тест | BSP модуль | -| -------------- | ------------------------------ | -| `test_sdram` | `bsp_sdram` | -| `test_qspi` | `bsp_qspi_flash` | +| Тест | BSP модуль | +| -------------- | ---------------------------------- | +| `test_sdram` | `bsp_sdram` | +| `test_qspi` | `bsp_qspi_flash` | | `test_usd` | `bsp_sd` (+ `firmware_test_fatfs`) | -| `test_display` | `bsp_display` | -| `test_buttons` | `bsp_button` | -| `test_opto` | `bsp_opto` (rs_as_gpio=true) | -| `test_can` | `bsp_can` | -| `test_mqs` | `bsp_mqs` | +| `test_display` | `bsp_display` | +| `test_buttons` | `bsp_button` | +| `test_opto` | `bsp_opto` (rs_as_gpio=true) | +| `test_can` | `bsp_can` | +| `test_mqs` | `bsp_mqs` | > `bsp_uart_host` также линкуется (используется вне тест-реестра); отдельного > UART-тест-модуля в текущем реестре нет (тестируется в `tests/target`). @@ -271,7 +270,7 @@ main.c | Интерфейс | USB CDC ACM, разъём J2 | | Кодировка | UTF-8 | | Фреймирование | JSON-lines: одна строка = одно сообщение, завершается `\n` | -| Максимальная длина строки | 128 байт включая `\n` (`CLI_LINE_BUF_SIZE`) | +| Максимальная длина строки | 128 байт включая `\n` (`CLI_LINE_BUF_SIZE`) | | CR+LF | Принимается (таргет отбрасывает `\r`) | Нет хэндшейка, нет sequence number, нет подтверждений доставки. @@ -551,16 +550,16 @@ confirm id для pre-confirm равен id теста (`usd`) — механи Порядок — как в реестре `k_registry[]` (`test_runner.c`). -| № | ID | Название | Тип | Critical | M5 HIL | Confirm | -| --- | --------- | ------------------ | ---------------------------- | -------- | ------ | --------------- | -| 1 | `sdram` | SDRAM 32 MB | self | ✅ | ❌ | ❌ | -| 2 | `qspi` | QSPI Flash W25Qxx | self | ✅ | ❌ | ❌ | -| 3 | `usd` | microSD (SDIO) | interactive | ❌ | ❌ | ✅ pre_confirm | -| 4 | `display` | TFT Display RGB888 | interactive | ❌ | ❌ | ✅ 6× в run() | -| 5 | `buttons` | Test Buttons | interactive | ❌ | ❌ | prompt only | -| 6 | `opto` | Opto Inputs | HIL | ❌ | ✅ | ✅ 6× (авто) | -| 7 | `can` | CAN loopback | HIL | ❌ | ✅ | ✅ 2× (авто) | -| 8 | `mqs` | MQS Audio Out | interactive | ❌ | ❌ | ✅ 1× в run() | +| № | ID | Название | Тип | Critical | M5 HIL | Confirm | +| --- | --------- | ------------------ | ----------- | -------- | ------ | ------------- | +| 1 | `sdram` | SDRAM 32 MB | self | ✅ | ❌ | ❌ | +| 2 | `qspi` | QSPI Flash W25Qxx | self | ✅ | ❌ | ❌ | +| 3 | `usd` | microSD (SDIO) | interactive | ❌ | ❌ | ✅ pre_confirm | +| 4 | `display` | TFT Display RGB888 | interactive | ❌ | ❌ | ✅ 6× в run() | +| 5 | `buttons` | Test Buttons | interactive | ❌ | ❌ | prompt only | +| 6 | `opto` | Opto Inputs | HIL | ❌ | ✅ | ✅ 6× (авто) | +| 7 | `can` | CAN loopback | HIL | ❌ | ✅ | ✅ 2× (авто) | +| 8 | `mqs` | MQS Audio Out | interactive | ❌ | ❌ | ✅ 1× в run() | **Типы confirm:** @@ -823,4 +822,4 @@ session_start / version_response: "fw":"0.1.2" с ожидаемой. При несовместимых изменениях протокола (новое обязательное поле, смена семантики) — bump версии + обновление этого документа и `README_TESTING.md`. -## + diff --git a/sdk/CMakeLists.txt b/sdk/CMakeLists.txt index 0f453e3..6b6615a 100644 --- a/sdk/CMakeLists.txt +++ b/sdk/CMakeLists.txt @@ -62,6 +62,7 @@ add_sdk_driver(sai_edma fsl_sai_edma.c) add_sdk_driver(edma fsl_edma.c) add_sdk_driver(dmamux fsl_dmamux.c) add_sdk_driver(xbara fsl_xbara.c) +add_sdk_driver(wdog fsl_wdog.c) # Драйверы которые зависят от clock target_link_libraries(sdk_lpuart PUBLIC sdk_clock)