# bootloader: Phase 3 - wdog testing

This commit is contained in:
Dmitry Akimov 2026-07-13 09:57:53 +03:00
parent 17698ce109
commit 1def117e44
23 changed files with 1054 additions and 175 deletions

View file

@ -2,10 +2,7 @@ if(BUILD_TESTS_HOST)
return() return()
endif() endif()
# ----------------------------------------------------------------------------- # bsp_board — cгенерированные Config Tools файлы, инициализация платы
# bsp_board — генерированные файлы Config Tools, инициализация платы
# -----------------------------------------------------------------------------
add_library(bsp_board STATIC generated/board.c generated/pin_mux.c) add_library(bsp_board STATIC generated/board.c generated/pin_mux.c)
add_subdirectory(common) add_subdirectory(common)
@ -15,6 +12,7 @@ add_subdirectory(uart_host)
add_subdirectory(opto) add_subdirectory(opto)
add_subdirectory(can) add_subdirectory(can)
add_subdirectory(button) add_subdirectory(button)
add_subdirectory(wdog)
add_subdirectory(display) add_subdirectory(display)
add_subdirectory(usb_cdc) add_subdirectory(usb_cdc)
add_subdirectory(sdram) add_subdirectory(sdram)
@ -23,7 +21,6 @@ add_subdirectory(sd)
add_subdirectory(mqs) add_subdirectory(mqs)
add_subdirectory(provisioning) add_subdirectory(provisioning)
# Подавляем предупреждения при компиляции собственных .c файлов библиотеки
target_compile_options(bsp_board PRIVATE -w) target_compile_options(bsp_board PRIVATE -w)
# SYSTEM подавляет предупреждения для всех внешних потребителей # SYSTEM подавляет предупреждения для всех внешних потребителей
@ -67,10 +64,6 @@ target_compile_definitions(
add_library(bsp_boot_ram INTERFACE) add_library(bsp_boot_ram INTERFACE)
target_compile_definitions(bsp_boot_ram INTERFACE SKIP_SYSCLK_INIT) 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) add_library(bsp_boot_xip_no_dcd INTERFACE)
target_compile_definitions( target_compile_definitions(
bsp_boot_xip_no_dcd INTERFACE XIP_EXTERNAL_FLASH=1 XIP_BOOT_HEADER_ENABLE=1 bsp_boot_xip_no_dcd INTERFACE XIP_EXTERNAL_FLASH=1 XIP_BOOT_HEADER_ENABLE=1

View file

@ -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_dma_buf_addr = 0U;
volatile uint32_t g_sdmmc_dbg_usdhc1_src_clock_hz = 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_04_USDHC1_DATA2, pad);
IOMUXC_SetPinConfig(IOMUXC_GPIO_SD_B0_05_USDHC1_DATA3, pad); IOMUXC_SetPinConfig(IOMUXC_GPIO_SD_B0_05_USDHC1_DATA3, pad);
/* /*
* CD_B pad config байт-в-байт как TFT_BOOTLOADER::BOARD_SD_Pin_Config() * CD (D13) здесь НЕ конфигурируем: пин на USDHC1_CD_B (см. BOARD_SD_Config),
* (board/sdmmc_config.c: IOMUXC_SetPinConfig(..., 0x10B0U)), не "разумная * pad задан в BOARD_InitPins() и на железе даёт корректный CINST. Прежняя
* по умолчанию" альтернатива. Отличие от прежней версии здесь: PKE=1 но * настройка pad'а GPIO2_IO28 убрана вместе с GPIO-детектом (item 1,
* PUE=0 это KEEPER, не активная подтяжка (PUS игнорируется в этом * DEBUG_LOG_PHASE3_SD.md).
* режиме); 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));
} }
/* --------------------------------------------------------------------------- /* ---------------------------------------------------------------------------
@ -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_dma_buf_addr = (uint32_t)(uintptr_t)s_dma_buf;
g_sdmmc_dbg_usdhc1_src_clock_hz = sd->host->hostController.sourceClock_Hz; 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.cdDebounce_ms = BOARD_SDMMC_SD_CD_DEBOUNCE_MS;
s_cd.type = BOARD_SDMMC_SD_CD_TYPE; s_cd.type = BOARD_SDMMC_SD_CD_TYPE; /* kSD_DetectCardByGpioCD → callback ниже */
s_cd.cardDetected = sd_card_detect_gpio; s_cd.cardDetected = sd_card_detect_prsstat;
s_cd.callback = cd; /* обычно NULL из bsp_sd */ s_cd.callback = cd; /* обычно NULL из bsp_sd */
s_cd.userData = user_data; 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 питания --- */ /* --- GPIO питания --- */
sd_power_init(); sd_power_init();
/* /*
* CD_B (GPIO_B1_12 / physical D13) на этой плате детект работает через * CD_B (GPIO_B1_12 / physical D13) остаётся на USDHC1_CD_B постоянно
* DVA механизма в разные моменты (см. DEBUG_LOG_PHASE3_SD.md, разрешение * единый механизм детекта через PRES_STATE.CINST (sd_card_detect_prsstat
* от 2026-07-10): * выше + гейт bsp_sd_is_inserted). Прежней двойной маршрутизации
* * (remux на GPIO2_IO28 для GPIO-чтения внутри f_mount) больше нет
* 1. НАШ гейт bsp_sd_is_inserted() (bsp/sd/src/sd.c) читает USDHC * см. DEBUG_LOG_PHASE3_SD.md, раунд 3 «консолидация детекта» (item 1);
* PRES_STATE.CINST (USDHC_GetPresentStatusFlags). Это и есть настоящий * она убирала латентную хрупкость: после первого bsp_sd_init() пин уходил
* фикс симптома 1: на пустом слоте BOARD_SD_Config() ещё не вызывался, * на GPIO2_IO28 и повторный PRSSTAT-скан ослеп бы. Явно переустанавливаем
* пин остаётся на USDHC1_CD_B (замаплен в BOARD_InitPins()), и PRSSTAT * альт-функцию (BOARD_InitPins() её тоже ставит так модуль не зависит от
* отражает реальность корректно гейт стабильно возвращает false, и * порядка инициализации). Pad этого пина оставляем как задал BOARD_InitPins:
* блокирующий SD_PollingCardInsert() без карты просто не достигается. * на железе CINST на нём читается корректно (Фаза 3, все сценарии).
* 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); IOMUXC_SetPinMux(IOMUXC_GPIO_B1_12_USDHC1_CD_B, 0U);
const gpio_pin_config_t cd_cfg = { /* Pad-конфиг линий SD (CMD/CLK/DATA) сразу для ранних CMD (CMD0/CMD8/CMD55/ACMD41). */
.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). */
sd_pin_config(400000U); sd_pin_config(400000U);
/* --- приоритет прерывания хоста --- */ /* --- приоритет прерывания хоста --- */

15
bsp/wdog/CMakeLists.txt Normal file
View file

@ -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)

80
bsp/wdog/README.md Normal file
View file

@ -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()` |

View file

@ -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 <stdbool.h>
#include <stdint.h>
/*
* Взвести 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 */

79
bsp/wdog/src/wdog.c Normal file
View file

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

View file

@ -69,6 +69,8 @@ tft_app.
как обновляется (та же SD-логика, что и Slot A/Б, или отдельный механизм). как обновляется (та же SD-логика, что и Slot A/Б, или отдельный механизм).
- Точный layout `.ld`-скрипта tft_app для двух адресов слотов (Direct-XIP: код обычно не - Точный layout `.ld`-скрипта tft_app для двух адресов слотов (Direct-XIP: код обычно не
позиционно-независим — вероятно потребуется два варианта линковки под Slot A и Slot Б, либо PIC). позиционно-независим — вероятно потребуется два варианта линковки под Slot A и Slot Б, либо PIC).
**Разобрано** в [UPDATE_FLOW.md](UPDATE_FLOW.md): рекомендован dual-link (два подписанных бинаря на
версию, как `test_stub`), с выбором линковки по целевому слоту при SD-обновлении; PIC — не сейчас.
--- ---

View file

@ -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_<A|B>.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.

View file

@ -52,7 +52,7 @@ target_compile_definitions(
target_link_libraries(${TARGET_NAME} PRIVATE bsp_board bsp_led bsp_tick target_link_libraries(${TARGET_NAME} PRIVATE bsp_board bsp_led bsp_tick
bsp_usb_cdc bsp_qspi_flash bsp_usb_cdc bsp_qspi_flash
bsp_boot_xip_no_dcd bsp_button bsp_boot_xip_no_dcd bsp_button
bootloader_fatfs) bsp_wdog bootloader_fatfs)
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Linker script — вариант flexspi_nor с m_text, ограниченным бюджетом # Linker script — вариант flexspi_nor с m_text, ограниченным бюджетом

View file

@ -150,8 +150,11 @@ IOMUXC_SetPinMux(IOMUXC_GPIO_B1_12_USDHC1_CD_B, 0U);
> **РАЗРЕШЕНО 2026-07-10 (раунд 3).** Основной баг детекта закрыт PRSSTAT-чтением (см. секцию > **РАЗРЕШЕНО 2026-07-10 (раунд 3).** Основной баг детекта закрыт PRSSTAT-чтением (см. секцию
> «Обновление 2026-07-10 (раунд 3)» выше). Пункты 12 ниже сохранены как история; пункт 3 — > «Обновление 2026-07-10 (раунд 3)» выше). Пункты 12 ниже сохранены как история; пункт 3 —
> опровергнут; пункты 45 (devcontainer, watchdog) остаются актуальными как отдельные задачи. > опровергнут; пункты 45 (devcontainer, watchdog) остаются актуальными как отдельные задачи.
> Плюс два новых нереализованных пункта-рекомендации из раунда 3: (6) консолидация детекта на > Рекомендации (6) консолидация детекта на единый PRSSTAT и (7) ранний сэмпл кнопки даунгрейда —
> единый механизм PRSSTAT (убрать латентную хрупкость re-scan), (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`) — 1. ✅ **Применено 2026-07-10 (раунд 1).** Откатить pinmux CD на `GPIO2_IO28` (без `USDHC1_CD_B`) —
самый доверенный референс (`TFT_BOOTLOADER`) использует именно так. **Аппаратно проверено — самый доверенный референс (`TFT_BOOTLOADER`) использует именно так. **Аппаратно проверено —
@ -447,26 +450,28 @@ bool bsp_sd_is_inserted(void)
- `sd_power_control` возвращён в `static` (публичное имя `BOARD_SDCardPowerControl` из раунда 2 - `sd_power_control` возвращён в `static` (публичное имя `BOARD_SDCardPowerControl` из раунда 2
больше не нужно — side-effect, ради которого оно вводилось, заменён PRSSTAT-чтением). больше не нужно — side-effect, ради которого оно вводилось, заменён PRSSTAT-чтением).
### ⚠️ Латентная хрупкость re-scan (не мешает 5 сценариям, но стоит знать) ### ✅ Латентная хрупкость re-scan — УСТРАНЕНА (раунд 4, item 1)
Рабочая конфигурация опирается на то, что пин на `USDHC1_CD_B` в момент гейта. Но `BOARD_SD_Config()` **Была** (до раунда 4): рабочая конфигурация опиралась на то, что пин на `USDHC1_CD_B` в момент
перемапливает его на `GPIO2_IO28` при первом же `bsp_sd_init()` и обратно НЕ возвращает (ни гейта, но `BOARD_SD_Config()` перемапливал его на `GPIO2_IO28` при первом же `bsp_sd_init()` и
`bsp_sd_deinit()`, ни что-либо ещё). Значит: если `run_update()` был вызван (карта была на гейте), обратно НЕ возвращал. Если `run_update()` был вызван (карта была на гейте), но завершался без прыжка
но завершился без прыжка (например, на карте нет `TFT_APP.BIN``bsp_sd_deinit()` → return), то (например, на карте нет `TFT_APP.BIN`), то на СЛЕДУЮЩЕМ периодическом скане `bsp_sd_is_inserted()`
на СЛЕДУЮЩЕМ периодическом скане `bsp_sd_is_inserted()` прочитает PRSSTAT с пином уже на читал PRSSTAT с пином уже на `GPIO2_IO28` → ложный ноль → карта «исчезала» до перезагрузки.
`GPIO2_IO28` → снова получит ложный ноль → карта «исчезнет» до перезагрузки. В 5 сценариях это не
всплывает (везде, где карта валидна, происходит прыжок и сканы прекращаются; где карты нет —
`BOARD_SD_Config` не вызывался и пин остаётся на `USDHC1_CD_B`). Реальный, но узкий крайний случай.
**Рекомендованная консолидация (не реализована — требует повторной проверки на железе, а рабочее **Реализовано (раунд 4, вариант B — минимальный):** детект сведён к ЕДИНОМУ механизму, PRSSTAT
состояние трогать без запроса не стал):** свести детект к ЕДИНОМУ механизму — PRSSTAT везде. Либо везде. Callback `sd_card_detect_gpio``sd_card_detect_prsstat` (читает `USDHC_GetPresentStatusFlags
(вариант A) убрать remux на `GPIO2_IO28` из `BOARD_SD_Config()` (оставить `USDHC1_CD_B` как ставит & kUSDHC_CardInsertedFlag`); remux на `GPIO2_IO28` из `BOARD_SD_Config()` убран — пин остаётся на
`BOARD_InitPins()`) и переключить `s_cd.type` на `kSD_DetectCardByHostCD` (тогда внутренний `USDHC1_CD_B` постоянно (там же явно переустанавливается); мёртвый pad-конфиг GPIO2_IO28 из
`SD_PollingCardInsert` пойдёт через `SDMMCHOST_PollingCardDetectStatus` → тот же PRSSTAT); либо `sd_pin_config()` удалён. `s_cd.type` оставлен `kSD_DetectCardByGpioCD` — это не «GPIO» в смысле
(вариант B, минимальнее) оставить `kSD_DetectCardByGpioCD`, но заменить сам callback чтения, а «детект через callback» (polling); альтернатива `kSD_DetectCardByHostCD` дополнительно
`sd_card_detect_gpio` на PRSSTAT-чтение и тоже убрать remux. Оба варианта убирают двойную взвела бы 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. видимый результат (стирание+копирование+прыжок в старший образ) проявляется как раз к ~15 c.
**Практическая инструкция технологу** (в HARDWARE_VERIFICATION_PHASE3.md): держать `BSP_BUTTON_1` с **✅ Реализовано (раунд 4, item 2):** кнопка теперь сэмплится РАНО и защёлкивается. `main.c` читает
момента подачи питания и не отпускать, пока по CDC не придёт `status: installing` (или пока частота `bsp_button_read(BSP_BUTTON_1)` один раз сразу после `bsp_button_init()` (до `bsp_qspi_init()`/
мигания `LED_APP` не сменится на «старший» образ). Ориентир ~15 c — держать с запасом. SD-логики) в `const bool downgrade_held` и передаёт это значение в `sd_update_check(downgrade_held)`
на обеих точках вызова (первая попытка + пере-сканы цикла). `run_update()` больше не читает кнопку
сам (`bsp/button.h` из `sd_update.c` убран) — `update_policy_decide()` получает уже защёлкнутое
значение. Жест «удержание при включении» теперь ловится в детерминированный ранний момент, а не
через секунды слепого окна.
**Рекомендация по коду (не реализовано — поведение бы изменилось, требует запроса и ре-теста):** **Новая инструкция технологу** (обновлено в HARDWARE_VERIFICATION_PHASE3.md): достаточно держать
сэмплить кнопку РАНО — при `bsp_button_init()`/сразу в `board_hw_init()`-фазе, до медленной `BSP_BUTTON_1` нажатой **в момент подачи питания** (до/во время включения) — состояние считывается в
SD-инициализации и двойной крипто-валидации — и защёлкивать результат, вместо чтения в глубине первые миллисекунды после `board_hw_init()`, ещё до обращения к SD. Долгое «15 c с запасом» больше
`run_update()`. Тогда «удержание при старте» стало бы предсказуемым (нажал в момент включения — не требуется. **Требует аппаратной ре-проверки** сценария 3 (даунгрейд): удержать кнопку при
сработало), без многосекундного слепого окна. Сейчас это самый user-hostile момент SD-пути. включении → даунгрейд срабатывает; не удерживать → штатная загрузка более нового слота.
## Побочная находка (не блокирует, но стоит знать) ## Побочная находка (не блокирует, но стоит знать)

View file

@ -7,9 +7,10 @@
| 0 — Карта Flash | ✅ завершена | [docs/mimxrt1052/BOOTLOADER_FLASH_MAP.md](../../docs/mimxrt1052/BOOTLOADER_FLASH_MAP.md) | | 0 — Карта Flash | ✅ завершена | [docs/mimxrt1052/BOOTLOADER_FLASH_MAP.md](../../docs/mimxrt1052/BOOTLOADER_FLASH_MAP.md) |
| 1 — Скелет (CDC + LED) | ✅ завершена | сборка/HAB/SWD-прошивка/ping-pong/debug — все пункты верификации пройдены на реальной плате, детали ниже | | 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)) | | 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, ранний сэмпл кнопки даунгрейда | | 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-паттерны | не начата | | | 4 — SDRAM/W25Q smoke-test + LED-паттерны | не начата | рекомендуется ПОСЛЕ Фазы 6 (см. ниже) |
| 5 — HAB Release + service-tui | не начата | | | 5 — HAB Release + service-tui | не начата | |
| 6 — Устойчивость и восстановление (recovery) | спроектирована, код не начат | **рекомендованный следующий шаг** (раньше 4/5): закрывает зависание уже установленного образа и даёт ручной аварийный вход. Дизайн зафиксирован в обсуждении — см. раздел «Фаза 6» ниже |
## Контекст ## Контекст
@ -29,7 +30,10 @@
- **Схема обновления**: MCUboot **Direct-XIP**, два слота (A/Б) с полностью валидными образами каждый, - **Схема обновления**: MCUboot **Direct-XIP**, два слота (A/Б) с полностью валидными образами каждый,
без swap/scratch. Единственный полевой канал обновления — microSD. USB как канал заливки *образа* без swap/scratch. Единственный полевой канал обновления — microSD. USB как канал заливки *образа*
сознательно не делаем (SDP/blhost на производстве — это отдельный, не зависящий от кода bootloader, сознательно не делаем (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), не самодельный верификатор - **Верификация образов tft_app** — через **bootutil** (MCUboot), не самодельный верификатор
(см. исследование ниже). Подпись — `imgtool` (не HAB; HAB — только для самого bootloader). (см. исследование ниже). Подпись — `imgtool` (не HAB; HAB — только для самого bootloader).
- **Обратная связь** — USB CDC ACM с тем же JSON-lines протоколом (`ping`/`pong`, `get_version`), что уже - **Обратная связь** — USB CDC ACM с тем же JSON-lines протоколом (`ping`/`pong`, `get_version`), что уже
@ -554,7 +558,64 @@ ARM-таргет — без изменений), иначе (host, любая а
кросс-компиляцией под `-target x86_64-unknown-linux-gnu` (0 ошибок после фикса, была именно эта ошибка кросс-компиляцией под `-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`): в худшем случае откат к «начать сессию заново».
<details><summary>Исходный план (до реализации)</summary>
Найдено при аппаратной верификации (см. [DEBUG_LOG_PHASE3_SD.md](DEBUG_LOG_PHASE3_SD.md)): у Найдено при аппаратной верификации (см. [DEBUG_LOG_PHASE3_SD.md](DEBUG_LOG_PHASE3_SD.md)): у
`SD_PollingCardInsert()` (SDK, вызывается изнутри `SD_Init()`, который вызывается изнутри `SD_PollingCardInsert()` (SDK, вызывается изнутри `SD_Init()`, который вызывается изнутри
@ -588,6 +649,8 @@ while (true)` без выхода по времени, если callback `cardDe
следующий шаг после того, как будет закрыт текущий SD/card-detect баг (иначе watchdog будет следующий шаг после того, как будет закрыт текущий SD/card-detect баг (иначе watchdog будет
маскировать/сбрасывать симптом на каждом цикле, не давая найти первопричину). маскировать/сбрасывать симптом на каждом цикле, не давая найти первопричину).
</details>
**Верификация (первая часть Фазы 3, требующая реального железа — ещё не проведена)**: **Верификация (первая часть Фазы 3, требующая реального железа — ещё не проведена)**:
1. Чистая плата (только bootloader, оба слота пустые) + SD с валидным подписанным образом → 1. Чистая плата (только bootloader, оба слота пустые) + SD с валидным подписанным образом →
автоустановка, переход к загрузке (проверить по CDC-статусам и LED). автоустановка, переход к загрузке (проверить по 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, чтобы не переделывать.
---
## Ключевые файлы для переиспользования (не изобретать заново) ## Ключевые файлы для переиспользования (не изобретать заново)
| Что нужно | Где смотреть образец | | Что нужно | Где смотреть образец |

View file

@ -15,6 +15,7 @@
*/ */
#include "bsp/qspi_flash.h" #include "bsp/qspi_flash.h"
#include "bsp/wdog.h"
#include "flash_map.h" #include "flash_map.h"
#include "sysflash/sysflash.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; uint32_t block_addr = area->fa_off;
for (; len > 0U; len -= BSP_QSPI_BLOCK_64K_SIZE) 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) if (bsp_qspi_erase_block_64k(block_addr) != BSP_OK)
{ {
return -1; 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; uint32_t addr = area->fa_off + off;
for (; len > 0U; len -= BSP_QSPI_SECTOR_SIZE) for (; len > 0U; len -= BSP_QSPI_SECTOR_SIZE)
{ {
bsp_wdog_refresh(); /* см. выше — посекторный путь тоже длинный */
if (bsp_qspi_erase_sector(addr) != BSP_OK) if (bsp_qspi_erase_sector(addr) != BSP_OK)
{ {
return -1; return -1;

View file

@ -135,6 +135,12 @@ static void handle_cmd(const char *p_line)
return; return;
} }
if (strcmp(cmd_name, "wdog") == 0)
{
protocol_send_wdog_status();
return;
}
protocol_send_error("UNKNOWN_CMD"); protocol_send_error("UNKNOWN_CMD");
} }

View file

@ -17,15 +17,25 @@
* *
* Последовательность старта: * Последовательность старта:
* 1. board_hw_init() тактирование, MPU, кэш, пины * 1. board_hw_init() тактирование, MPU, кэш, пины
* 2. bsp_led_init() оба LED выключены * 2. bsp_wdog_init() аппаратный watchdog как можно раньше (см. ниже)
* 3. bsp_tick_init() SysTick 1 мс * 3. bsp_led_init() оба LED выключены
* 4. bsp_button_init() для проверки удержания BSP_BUTTON_1 * 4. bsp_tick_init() SysTick 1 мс
* 5. bsp_qspi_init() доступ к Slot A/Б * 5. bsp_button_init() для проверки удержания BSP_BUTTON_1
* 6. bsp_usb_cdc_init() не блокирует, см. выше * 6. bsp_qspi_init() доступ к Slot A/Б
* 7. sd_update_check() no-op быстро, если SD не вставлена * 7. bsp_usb_cdc_init() не блокирует, см. выше
* 8. boot_select_and_jump() при успехе не возвращается * 8. sd_update_check() no-op быстро, если SD не вставлена
* 9. Цикл ожидания CDC ping/pong + LED + периодический * 9. boot_select_and_jump() при успехе не возвращается
* пере-скан SD (шаги 7-8 повторно) * 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 * Bootloader без SDRAM (см. docs/mimxrt1052/BOOTLOADER_FLASH_MAP.md) DCD
* не используется (bsp_boot_xip_no_dcd). * не используется (bsp_boot_xip_no_dcd).
@ -37,6 +47,7 @@
#include "bsp/qspi_flash.h" #include "bsp/qspi_flash.h"
#include "bsp/tick.h" #include "bsp/tick.h"
#include "bsp/usb_cdc.h" #include "bsp/usb_cdc.h"
#include "bsp/wdog.h"
#include "cli.h" #include "cli.h"
#include "protocol.h" #include "protocol.h"
#include "sd_update.h" #include "sd_update.h"
@ -50,19 +61,36 @@ int main(void)
const uint32_t SD_RETRY_PERIOD_MS = 1500U; const uint32_t SD_RETRY_PERIOD_MS = 1500U;
const uint32_t HEARTBEAT_ON_MS = 50U; const uint32_t HEARTBEAT_ON_MS = 50U;
const uint32_t HEARTBEAT_PERIOD_MS = 500U; 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(); board_hw_init();
/* Как можно раньше — до первой же SD-логики, которая может зависнуть. */
(void) bsp_wdog_init(WDOG_TIMEOUT_S);
bsp_led_init(); bsp_led_init();
bsp_tick_init(); bsp_tick_init();
bsp_button_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 qspi_ok = (bsp_qspi_init() == BSP_OK);
bool cdc_ok = (bsp_usb_cdc_init() == BSP_OK); bool cdc_ok = (bsp_usb_cdc_init() == BSP_OK);
if (qspi_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(); /* при успехе не возвращается */ boot_select_and_jump(); /* при успехе не возвращается */
} }
@ -76,21 +104,25 @@ int main(void)
cli_init(); cli_init();
/* Если предыдущий сброс — по таймауту watchdog, известим (best-effort:
* если хост ещё не подключён, сообщение потеряется состояние всегда
* доступно по команде "wdog", см. cli.c). */
if (bsp_wdog_caused_last_reset())
{
protocol_send_wdog_status();
}
/* Готово немедленно — первая попытка сразу извещает "жду SD", не ждёт /* Готово немедленно — первая попытка сразу извещает "жду SD", не ждёт
* SD_RETRY_PERIOD_MS. Дальнейшие попытки уже дросселируются периодом. */ * SD_RETRY_PERIOD_MS. Дальнейшие попытки уже дросселируются периодом. */
uint32_t next_sd_retry_ms = bsp_tick_get_ms(); uint32_t next_sd_retry_ms = bsp_tick_get_ms();
while (1) while (1)
{ {
bsp_wdog_refresh(); /* начало итерации — точка реального прогресса */
bsp_usb_cdc_poll(); bsp_usb_cdc_poll();
cli_process(); 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; uint32_t heartbeat_phase_ms = bsp_tick_get_ms() % HEARTBEAT_PERIOD_MS;
if (heartbeat_phase_ms < HEARTBEAT_ON_MS) if (heartbeat_phase_ms < HEARTBEAT_ON_MS)
{ {
@ -107,7 +139,8 @@ int main(void)
protocol_send_status("waiting_for_sd"); protocol_send_status("waiting_for_sd");
sd_update_check(); /* no-op быстро, если SD не вставлена */ sd_update_check(DOWNGRADE_HELD);
bsp_wdog_refresh(); /* образ унаследует полное окно таймаута */
boot_select_and_jump(); /* при успехе не возвращается */ boot_select_and_jump(); /* при успехе не возвращается */
} }
} }

View file

@ -5,6 +5,7 @@
#include "protocol.h" #include "protocol.h"
#include "bsp/wdog.h"
#include "cli.h" #include "cli.h"
#include <stdio.h> #include <stdio.h>
@ -43,3 +44,14 @@ void protocol_send_status(const char *p_state)
(void) snprintf(buf, sizeof(buf), "{\"type\":\"status\",\"state\":\"%s\"}\n", p_state); (void) snprintf(buf, sizeof(buf), "{\"type\":\"status\",\"state\":\"%s\"}\n", p_state);
cli_send(buf); 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);
}

View file

@ -18,6 +18,8 @@
* Типы исходящих событий (Фаза 3): * Типы исходящих событий (Фаза 3):
* status top-level состояние bootloader (waiting_for_sd и т.д., * status top-level состояние bootloader (waiting_for_sd и т.д.,
* см. protocol_send_status()) * см. protocol_send_status())
* wdog статус аппаратного watchdog (armed/timeout/recovered,
* см. protocol_send_wdog_status())
* *
* Полный словарь состояний status (smoke_pass/smoke_fail/booting/...) * Полный словарь состояний status (smoke_pass/smoke_fail/booting/...)
* появится в Фазе 4. * появится в Фазе 4.
@ -60,4 +62,17 @@ void protocol_send_error(const char *p_code);
*/ */
void protocol_send_status(const char *p_state); 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_ */ #endif /* PROTOCOL_H_ */

View file

@ -20,9 +20,9 @@
#include "sd_update.h" #include "sd_update.h"
#include "bootutil/image.h" #include "bootutil/image.h"
#include "bsp/button.h"
#include "bsp/sd.h" #include "bsp/sd.h"
#include "bsp/usb_cdc.h" #include "bsp/usb_cdc.h"
#include "bsp/wdog.h"
#include "ff.h" #include "ff.h"
#include "flash_map.h" #include "flash_map.h"
#include "protocol.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) while (offset < file_size)
{ {
bsp_usb_cdc_poll(); bsp_usb_cdc_poll();
bsp_wdog_refresh(); /* потоковое копирование — реальный прогресс на чанк */
uint32_t want = file_size - offset; uint32_t want = file_size - offset;
if (want > SD_UPDATE_CHUNK_SIZE) 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 candidate_ver;
struct image_version installed_ver; struct image_version installed_ver;
@ -147,7 +148,6 @@ static void run_update(void)
update_policy_result_t decision; update_policy_result_t decision;
const struct flash_area *p_fap = NULL; const struct flash_area *p_fap = NULL;
uint32_t file_size; uint32_t file_size;
bool button_held;
bool copy_ok; bool copy_ok;
if (bsp_sd_init() != BSP_OK) if (bsp_sd_init() != BSP_OK)
@ -174,11 +174,11 @@ static void run_update(void)
goto cleanup; goto cleanup;
} }
file_size = (uint32_t) f_size(&g_s_file); file_size = (uint32_t) f_size(&g_s_file);
slot_a = peek_slot(0U); slot_a = peek_slot(0U);
slot_b = peek_slot(1U); slot_b = peek_slot(1U);
button_held = bsp_button_read(BSP_BUTTON_1); /* button_held — сэмплирован при старте в main.c и передан сюда (см. sd_update.h). */
decision = update_policy_decide(&slot_a, &slot_b, &candidate_ver, button_held); decision = update_policy_decide(&slot_a, &slot_b, &candidate_ver, button_held);
if (decision.action == UPDATE_POLICY_SKIP) if (decision.action == UPDATE_POLICY_SKIP)
{ {
@ -242,12 +242,12 @@ cleanup:
/* ── Public API ────────────────────────────────────────────────────────── */ /* ── Public API ────────────────────────────────────────────────────────── */
void sd_update_check(void) void sd_update_check(bool downgrade_button_held)
{ {
if (!bsp_sd_is_inserted()) if (!bsp_sd_is_inserted())
{ {
return; return;
} }
run_update(); run_update(downgrade_button_held);
} }

View file

@ -8,6 +8,8 @@
#ifndef SD_UPDATE_H_ #ifndef SD_UPDATE_H_
#define SD_UPDATE_H_ #define SD_UPDATE_H_
#include <stdbool.h>
/** /**
* @brief Одна попытка: смонтировать SD, найти TFT_APP.BIN, при необходимости * @brief Одна попытка: смонтировать SD, найти TFT_APP.BIN, при необходимости
* установить его в неактивный слот. * установить его в неактивный слот.
@ -21,8 +23,15 @@
* Ничего не делает, если SD не вставлена (bsp_sd_is_inserted() == false) * Ничего не делает, если SD не вставлена (bsp_sd_is_inserted() == false)
* безопасно вызывать многократно, в т.ч. из цикла ожидания в main.c. * безопасно вызывать многократно, в т.ч. из цикла ожидания в 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() уже вызван. * @pre bsp_qspi_init() уже вызван.
*/ */
void sd_update_check(void); void sd_update_check(bool downgrade_button_held);
#endif /* SD_UPDATE_H_ */ #endif /* SD_UPDATE_H_ */

View file

@ -4,10 +4,10 @@
# firmware/bootloader/PLAN.md, "Аппаратная верификация Фазы 2". Не часть # firmware/bootloader/PLAN.md, "Аппаратная верификация Фазы 2". Не часть
# продукта — удалить, когда появится реальный tft_app. # продукта — удалить, когда появится реальный tft_app.
# #
# Два таргета из одного main.c: разный адрес слота (--defsym __slot_base__) # Два таргета из одного main.c: разный адрес слота (--defsym __slot_base__) и
# и разная частота мигания (STUB_BLINK_MS) — чтобы на глаз отличить, какой # разная частота мигания (STUB_BLINK_MS) — чтобы на глаз отличить, какой слот
# слот выбрал bootloader. Постройка .bin, дальше подписывается вручную # выбрал bootloader. Постройка .bin, дальше подписывается вручную imgtool'ом
# imgtool'ом (см. PLAN.md) — .bin сюда не должен попасть без подписи. # (см. PLAN.md) — .bin сюда не должен попасть без подписи.
function(add_mcuboot_stub NAME SLOT_BASE BLINK_MS) function(add_mcuboot_stub NAME SLOT_BASE BLINK_MS)
add_executable(${NAME} main.c ${BSP_GENERATED}/clock_config.c 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} target_compile_definitions(${NAME} PRIVATE STUB_BLINK_MS=${BLINK_MS}
__STARTUP_CLEAR_BSS) __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 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( target_link_options(
${NAME} ${NAME}
@ -39,7 +42,7 @@ function(add_mcuboot_stub NAME SLOT_BASE BLINK_MS)
COMMAND ${CMAKE_OBJCOPY} -O binary $<TARGET_FILE:${NAME}> COMMAND ${CMAKE_OBJCOPY} -O binary $<TARGET_FILE:${NAME}>
${CMAKE_BINARY_DIR}/${NAME}.bin ${CMAKE_BINARY_DIR}/${NAME}.bin
COMMAND ${CMAKE_SIZE} $<TARGET_FILE:${NAME}> COMMAND ${CMAKE_SIZE} $<TARGET_FILE:${NAME}>
COMMENT "Generating ${NAME}.bin (не подписан — imgtool sign вручную, см. PLAN.md)") COMMENT "Generating ${NAME}.bin")
endfunction() endfunction()
# Slot A: 0x60040000, мигает раз в 500 мс ("версия 1") # Slot A: 0x60040000, мигает раз в 500 мс ("версия 1")

View file

@ -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:` Карта — FAT32/FAT16. Bootloader ищет в корне файл **`TFT_APP.BIN`** (путь `2:/TFT_APP.BIN`, диск `2:`
— внутренняя нумерация FatFS, физически это корень карты). Файл — подписанный образ-заглушка, — внутренняя нумерация 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 ```bash
# Кандидат-"новее" (v2) — для авто-установки/сравнения версий # Сценарий 1 (чистая плата — целевой слот всегда А, т.к. активного слота нет) —
cp build/Debug/signed/stub_b_v2_confirmed.bin /Volumes/<SD>/TFT_APP.BIN # ОБЯЗАТЕЛЬНО stub_a, не stub_b: stub_b линкован под Slot Б и не запустится в Slot A.
# Кандидат-"старше" (v1) — для теста даунгрейда (сценарий 3)
cp build/Debug/signed/stub_a_v1_confirmed.bin /Volumes/<SD>/TFT_APP.BIN cp build/Debug/signed/stub_a_v1_confirmed.bin /Volumes/<SD>/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/<SD>/TFT_APP.BIN
# Кандидат с битой ПОДПИСЬЮ (magic цел, TLV/хэш испорчен) — сценарий 4b (SD_INSTALL_REJECTED).
# Линковка тут неважна — кандидат отклоняется до записи/выбора, ни разу не исполняется.
cp build/Debug/signed/stub_b_v2_confirmed.bin /Volumes/<SD>/TFT_APP.BIN cp build/Debug/signed/stub_b_v2_confirmed.bin /Volumes/<SD>/TFT_APP.BIN
# затем поменять один байт в СЕРЕДИНЕ файла (payload), напр. в hex-редакторе — magic в начале не трогать # затем поменять один байт в СЕРЕДИНЕ файла (payload), напр. в hex-редакторе — magic в начале не трогать
# Кандидат с битым ЗАГОЛОВКОМ (magic) — сценарий 4a (SD_CANDIDATE_INVALID) # Кандидат с битым ЗАГОЛОВКОМ (magic) — сценарий 4a (SD_CANDIDATE_INVALID), линковка тоже неважна
# поменять один из первых 4 байт файла (ih_magic) # поменять один из первых 4 байт файла (ih_magic)
``` ```
Берём `--confirm`-варианты: иначе установленный с SD образ на следующем power cycle Берём `--confirm`-варианты: иначе установленный с SD образ на следующем power cycle
откатится (Direct-XIP Revert, см. Фаза 2 / сценарий 5 там). откатится (Direct-XIP Revert, см. Фаза 2 / сценарий 5 там).
> ⚠️ **Про извлечение/повторный скан карты без ресета.** Детект на пустом слоте читает PRSSTAT при > **Про повторный скан карты без ресета (раунд 4).** Детект сведён к единому механизму — пин
> пине на `USDHC1_CD_B`; после первого же `bsp_sd_init()` пин перемапливается на `GPIO2_IO28` и > постоянно на `USDHC1_CD_B`, и гейт, и внутренний детект SDK читают USDHC `PRES_STATE.CINST` (item
> обратно не возвращается до перезагрузки. Практическое следствие для теста: **после каждого > 1, см. DEBUG_LOG). Прежняя хрупкость (после первого `bsp_sd_init()` пин уходил на `GPIO2_IO28` и
> сценария делай power cycle** (и так обязателен, см. ниже), не рассчитывай на «вынул/вставил карту > пере-скан ослеп бы) устранена. **Стоит отдельно проверить** кейс, который раньше был сломан: карта
> на живом загрузчике». Подробности и рекомендованная консолидация — в DEBUG_LOG (раунд 3, > вставлена, но `TFT_APP.BIN` появляется/кладётся на неё БЕЗ извлечения и перезагрузки — пере-скан в
> «латентная хрупкость re-scan»). > цикле ожидания (~1.5 c) должен его подхватить. Между полноценными сценариями power cycle всё равно
> обязателен (см. ниже).
--- ---
@ -153,27 +168,29 @@ screen /dev/cu.usbmodemXXXX # macOS, порт свой у каждого по
| `SD_INSTALL_WRITE_FAILED` | сбой записи/verify чанка во flash | | `SD_INSTALL_WRITE_FAILED` | сбой записи/verify чанка во flash |
| `SD_INSTALL_REJECTED` | образ записан, но пост-проверка (hash+ECDSA) не прошла | | `SD_INSTALL_REJECTED` | образ записан, но пост-проверка (hash+ECDSA) не прошла |
| `SD_DOWNGRADE_ERASE_FAILED` | не удалось стереть прежний активный слот при форс. даунгрейде | | `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 не придёт **Держать `BSP_BUTTON_1` нажатой в момент подачи питания** (при включении). Состояние считывается
`status: installing`** (или пока `LED_APP` не замигает с частотой более старого образа). Ориентир — один раз в первые миллисекунды после `board_hw_init()` — сразу после `bsp_button_init()`, ДО
**~15 секунд, с запасом**; отпускание на 2 c даунгрейд НЕ вызывает. обращения к SD — и защёлкивается на всю сессию. Долгое удержание не требуется: важно, чтобы кнопка
была нажата на момент включения.
Почему так (разбор по коду — [../DEBUG_LOG_PHASE3_SD.md](../DEBUG_LOG_PHASE3_SD.md), раунд 3): > **Изменение поведения (раунд 4, требует ре-проверки):** раньше кнопка читалась глубоко внутри
`bsp_button_read()` — мгновенное сырое чтение, без debounce и без latch. Кнопка сэмплится ровно > `run_update()` — уже после `f_mount()` и двух крипто-валидаций слотов, то есть через несколько
один раз, в `sd_update.c` (`run_update()`), и этот момент наступает **после** `bsp_sd_init()`, > секунд слепого окна (приходилось держать ~15 c, пока по CDC не придёт `installing`). Теперь сэмпл
`f_mount()` (полная инициализация карты), `f_open()` и **двух полных крипто-валидаций слотов** > ранний и защёлкнутый (`main.c` → `downgrade_held``sd_update_check(...)`), см.
(SHA-256 + ECDSA по каждому). Это несколько секунд слепого окна без LED-фидбэка (heartbeat идёт > [../DEBUG_LOG_PHASE3_SD.md](../DEBUG_LOG_PHASE3_SD.md), раунд 4 / item 2. При проверке сценария 3
только в главном цикле, до которого управление ещё не дошло). Отпустишь раньше сэмпла — прочитается > достаточно удерживать кнопку при включении.
"не нажата" → штатная загрузка нового слота. «Срабатывает сразу после отпускания» — это не триггер
по отпусканию (кода на отпускание нет), а совпадение: решение защёлкивается на сэмпле, пока кнопка
ещё нажата.
> Известное неудобство (в очереди на доработку, не реализовано): сэмплить кнопку РАНО, при старте, `bsp_button_read()` — мгновенное сырое чтение, без debounce и latch; жест — именно «нажато в момент
> до медленной SD-инициализации — тогда «удержание при включении» стало бы предсказуемым. старта», не «нажать после».
--- ---
@ -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 не тронут) | | 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, если слотов нет) | | 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`; прежний валидный слот не тронут (записанный кандидат не выбирается) | | 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` при желании | | 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: частота мигания **Инструмент подтверждения «прыжок реально произошёл»** — тот же, что в Фазе 2: частота мигания
`LED_APP` (500 мс vs 250 мс) однозначно указывает на выбранный слот, верифицируемо глазами. `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 (сценарии 15) как есть — все должны проходить без единого неожиданного
сброса. 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 | Сброса нет, отладка не сбивается |
--- ---

View file

@ -11,10 +11,18 @@
* Фазы 2". * Фазы 2".
* *
* Без USB/CDC визуальной индикации достаточно, минимальный код. * Без 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 "board.h"
#include "bsp/led.h" #include "bsp/led.h"
#include "bsp/tick.h" #include "bsp/tick.h"
#include "bsp/wdog.h"
#ifndef STUB_BLINK_MS #ifndef STUB_BLINK_MS
#error "STUB_BLINK_MS must be defined (see firmware/bootloader/test_stub/CMakeLists.txt)" #error "STUB_BLINK_MS must be defined (see firmware/bootloader/test_stub/CMakeLists.txt)"
@ -28,6 +36,7 @@ int main(void)
while (1) while (1)
{ {
bsp_wdog_refresh(); /* обслуживаем унаследованный от загрузчика WDOG */
bsp_led_toggle(LED_APP); bsp_led_toggle(LED_APP);
bsp_delay(STUB_BLINK_MS); bsp_delay(STUB_BLINK_MS);
} }

View file

@ -29,7 +29,6 @@
- [Как добавить новый тест](#как-добавить-новый-тест) - [Как добавить новый тест](#как-добавить-новый-тест)
- [Host unit-тесты](#host-unit-тесты) - [Host unit-тесты](#host-unit-тесты)
- [Версионирование](#версионирование) - [Версионирование](#версионирование)
- [Архитектурные решения (закрыты)](#архитектурные-решения-закрыты)
--- ---
@ -199,16 +198,16 @@ main.c
**BSP-зависимости тест-модулей** (по `target_link_libraries` в `CMakeLists.txt`): **BSP-зависимости тест-модулей** (по `target_link_libraries` в `CMakeLists.txt`):
| Тест | BSP модуль | | Тест | BSP модуль |
| -------------- | ------------------------------ | | -------------- | ---------------------------------- |
| `test_sdram` | `bsp_sdram` | | `test_sdram` | `bsp_sdram` |
| `test_qspi` | `bsp_qspi_flash` | | `test_qspi` | `bsp_qspi_flash` |
| `test_usd` | `bsp_sd` (+ `firmware_test_fatfs`) | | `test_usd` | `bsp_sd` (+ `firmware_test_fatfs`) |
| `test_display` | `bsp_display` | | `test_display` | `bsp_display` |
| `test_buttons` | `bsp_button` | | `test_buttons` | `bsp_button` |
| `test_opto` | `bsp_opto` (rs_as_gpio=true) | | `test_opto` | `bsp_opto` (rs_as_gpio=true) |
| `test_can` | `bsp_can` | | `test_can` | `bsp_can` |
| `test_mqs` | `bsp_mqs` | | `test_mqs` | `bsp_mqs` |
> `bsp_uart_host` также линкуется (используется вне тест-реестра); отдельного > `bsp_uart_host` также линкуется (используется вне тест-реестра); отдельного
> UART-тест-модуля в текущем реестре нет (тестируется в `tests/target`). > UART-тест-модуля в текущем реестре нет (тестируется в `tests/target`).
@ -271,7 +270,7 @@ main.c
| Интерфейс | USB CDC ACM, разъём J2 | | Интерфейс | USB CDC ACM, разъём J2 |
| Кодировка | UTF-8 | | Кодировка | UTF-8 |
| Фреймирование | JSON-lines: одна строка = одно сообщение, завершается `\n` | | Фреймирование | JSON-lines: одна строка = одно сообщение, завершается `\n` |
| Максимальная длина строки | 128 байт включая `\n` (`CLI_LINE_BUF_SIZE`) | | Максимальная длина строки | 128 байт включая `\n` (`CLI_LINE_BUF_SIZE`) |
| CR+LF | Принимается (таргет отбрасывает `\r`) | | CR+LF | Принимается (таргет отбрасывает `\r`) |
Нет хэндшейка, нет sequence number, нет подтверждений доставки. Нет хэндшейка, нет sequence number, нет подтверждений доставки.
@ -551,16 +550,16 @@ confirm id для pre-confirm равен id теста (`usd`) — механи
Порядок — как в реестре `k_registry[]` (`test_runner.c`). Порядок — как в реестре `k_registry[]` (`test_runner.c`).
| № | ID | Название | Тип | Critical | M5 HIL | Confirm | | № | ID | Название | Тип | Critical | M5 HIL | Confirm |
| --- | --------- | ------------------ | ---------------------------- | -------- | ------ | --------------- | | --- | --------- | ------------------ | ----------- | -------- | ------ | ------------- |
| 1 | `sdram` | SDRAM 32 MB | self | ✅ | ❌ | ❌ | | 1 | `sdram` | SDRAM 32 MB | self | ✅ | ❌ | ❌ |
| 2 | `qspi` | QSPI Flash W25Qxx | self | ✅ | ❌ | ❌ | | 2 | `qspi` | QSPI Flash W25Qxx | self | ✅ | ❌ | ❌ |
| 3 | `usd` | microSD (SDIO) | interactive | ❌ | ❌ | ✅ pre_confirm | | 3 | `usd` | microSD (SDIO) | interactive | ❌ | ❌ | ✅ pre_confirm |
| 4 | `display` | TFT Display RGB888 | interactive | ❌ | ❌ | ✅ 6× в run() | | 4 | `display` | TFT Display RGB888 | interactive | ❌ | ❌ | ✅ 6× в run() |
| 5 | `buttons` | Test Buttons | interactive | ❌ | ❌ | prompt only | | 5 | `buttons` | Test Buttons | interactive | ❌ | ❌ | prompt only |
| 6 | `opto` | Opto Inputs | HIL | ❌ | ✅ | ✅ 6× (авто) | | 6 | `opto` | Opto Inputs | HIL | ❌ | ✅ | ✅ 6× (авто) |
| 7 | `can` | CAN loopback | HIL | ❌ | ✅ | ✅ 2× (авто) | | 7 | `can` | CAN loopback | HIL | ❌ | ✅ | ✅ 2× (авто) |
| 8 | `mqs` | MQS Audio Out | interactive | ❌ | ❌ | ✅ 1× в run() | | 8 | `mqs` | MQS Audio Out | interactive | ❌ | ❌ | ✅ 1× в run() |
**Типы confirm:** **Типы confirm:**
@ -823,4 +822,4 @@ session_start / version_response: "fw":"0.1.2"
с ожидаемой. При несовместимых изменениях протокола (новое обязательное поле, с ожидаемой. При несовместимых изменениях протокола (новое обязательное поле,
смена семантики) — bump версии + обновление этого документа и `README_TESTING.md`. смена семантики) — bump версии + обновление этого документа и `README_TESTING.md`.
##

View file

@ -62,6 +62,7 @@ add_sdk_driver(sai_edma fsl_sai_edma.c)
add_sdk_driver(edma fsl_edma.c) add_sdk_driver(edma fsl_edma.c)
add_sdk_driver(dmamux fsl_dmamux.c) add_sdk_driver(dmamux fsl_dmamux.c)
add_sdk_driver(xbara fsl_xbara.c) add_sdk_driver(xbara fsl_xbara.c)
add_sdk_driver(wdog fsl_wdog.c)
# Драйверы которые зависят от clock # Драйверы которые зависят от clock
target_link_libraries(sdk_lpuart PUBLIC sdk_clock) target_link_libraries(sdk_lpuart PUBLIC sdk_clock)