From 894d0d56c07a404853200870c53b82378aaf9818 Mon Sep 17 00:00:00 2001 From: Ezra Maccabee Date: Mon, 13 Jul 2026 16:47:47 +0300 Subject: [PATCH] # bootloader: Phase 4 - preparing --- CMakePresets.json | 6 +- bsp/CMakeLists.txt | 1 + bsp/boot_state/CMakeLists.txt | 14 ++ bsp/boot_state/README.md | 79 +++++++++ bsp/boot_state/include/bsp/boot_state.h | 59 +++++++ bsp/boot_state/src/boot_state.c | 67 ++++++++ firmware/bootloader/CMakeLists.txt | 4 +- firmware/bootloader/PLAN.md | 94 +++++++++-- firmware/bootloader/src/main.c | 103 ++++++++++-- firmware/bootloader/src/protocol.c | 9 +- firmware/bootloader/src/protocol.h | 18 +- firmware/bootloader/src/recovery.c | 75 +++++++++ firmware/bootloader/src/recovery.h | 72 ++++++++ .../test_stub/HARDWARE_VERIFICATION_PHASE3.md | 8 +- sdk/CMakeLists.txt | 1 + tests/host/CMakeLists.txt | 16 ++ tests/host/recovery/README.md | 48 ++++++ tests/host/recovery/test_recovery.c | 156 ++++++++++++++++++ 18 files changed, 789 insertions(+), 41 deletions(-) create mode 100644 bsp/boot_state/CMakeLists.txt create mode 100644 bsp/boot_state/README.md create mode 100644 bsp/boot_state/include/bsp/boot_state.h create mode 100644 bsp/boot_state/src/boot_state.c create mode 100644 firmware/bootloader/src/recovery.c create mode 100644 firmware/bootloader/src/recovery.h create mode 100644 tests/host/recovery/README.md create mode 100644 tests/host/recovery/test_recovery.c diff --git a/CMakePresets.json b/CMakePresets.json index b4b7ad1..babf3d0 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -162,7 +162,8 @@ "test_timeout_pattern", "test_mcuboot_boot_select", "test_slot_version", - "test_update_policy" + "test_update_policy", + "test_recovery" ] }, { @@ -184,7 +185,8 @@ "test_timeout_pattern", "test_mcuboot_boot_select", "test_slot_version", - "test_update_policy" + "test_update_policy", + "test_recovery" ] }, { diff --git a/bsp/CMakeLists.txt b/bsp/CMakeLists.txt index d450fdb..44f070e 100644 --- a/bsp/CMakeLists.txt +++ b/bsp/CMakeLists.txt @@ -13,6 +13,7 @@ add_subdirectory(opto) add_subdirectory(can) add_subdirectory(button) add_subdirectory(wdog) +add_subdirectory(boot_state) add_subdirectory(display) add_subdirectory(usb_cdc) add_subdirectory(sdram) diff --git a/bsp/boot_state/CMakeLists.txt b/bsp/boot_state/CMakeLists.txt new file mode 100644 index 0000000..fd6f9e9 --- /dev/null +++ b/bsp/boot_state/CMakeLists.txt @@ -0,0 +1,14 @@ +if(BUILD_TESTS_HOST) + return() +endif() + +add_library(bsp_boot_state STATIC src/boot_state.c) + +target_include_directories( + bsp_boot_state + PUBLIC include/ + PRIVATE src/) + +target_link_libraries( + bsp_boot_state + PRIVATE sdk_src) diff --git a/bsp/boot_state/README.md b/bsp/boot_state/README.md new file mode 100644 index 0000000..fbba1c8 --- /dev/null +++ b/bsp/boot_state/README.md @@ -0,0 +1,79 @@ +# bsp_boot_state — счётчик попыток загрузки (SRC_GPR) + +Счётчик попыток загрузки поверх `SRC` General Purpose Register — переживает тёплый/watchdog-сброс, +обнуляется только на POR. Даёт коду восстановления прожить несколько сбросов подряд без +персистентного хранилища во flash. + +--- + +## Аппаратура + +| Параметр | Значение | +| ------------------------ | -------------------------------------------------------------- | +| Периферия | SRC (System Reset Controller) | +| Регистр счётчика | `SRC_GPR[2]` (GPR3, 0-based индекс `fsl_src` API) | +| Переживает | тёплый сброс, watchdog-сброс | +| Обнуляется | только POR (детект — `SRC->SRSR`, бит `IPP_RESET_B`) | +| Занято ROM (не трогать) | GPR1/2 (warm-boot entry/arg), GPR6/7/8/9 (ROM, explicit note в RM), GPR10 (альт. SBMR1) | +| Не занято ROM, но занято конвенцией | GPR5 — RM рекомендует под различение SYSRESETREQ/CPU lockup, не наша задача | + +--- + +## Контракт: разделение с `bsp_wdog` + +`SRC->SRSR` и `WDOG1->WRSR` — разные регистры с разной семантикой очистки. `WRSR` самоочищается на +каждый сброс (не требует явной очистки — см. `bsp_wdog_caused_last_reset()`). `SRSR` — +write-1-to-clear и **копит биты между тёплыми сбросами**, если их не чистить софтом: +`bsp_boot_state_init()` чистит `SRSR` при каждом вызове, поэтому вопрос «был ли сброс по watchdog» +остаётся за `bsp_wdog`, а не за этим модулем — этот модуль отвечает только за «был ли сброс POR». + +--- + +## API + +```c +void bsp_boot_state_init(void); /* взвести — читает/чистит SRC->SRSR, детектит POR */ +bool bsp_boot_state_was_por(void); /* true, если последний сброс — POR */ +uint32_t bsp_boot_attempt_count(void); /* текущее значение счётчика, 0 сразу после POR */ +void bsp_boot_attempt_inc(void); /* +1 — звать перед попыткой прыжка в образ */ +void bsp_boot_attempt_reset(void); /* обнулить — новый образ/фолбэк-стирание */ +void bsp_boot_health_mark(void); /* = bsp_boot_attempt_reset(), для вызова из приложения */ +``` + +`bsp_boot_state_init()` **не идемпотентна** — повторный вызов в той же сессии увидит уже очищенный +`SRSR` как «не POR». Звать ровно один раз, как можно раньше в `main()`. + +--- + +## Быстрый старт + +```c +#include "bsp/boot_state.h" + +/* main.c — после board_hw_init()/bsp_wdog_init(): */ +bsp_boot_state_init(); + +if (bsp_boot_attempt_count() >= THRESHOLD) +{ + /* серия сбросов подряд без здорового образа — решение о фолбэке/recovery + * принимает вызывающий код, не этот модуль */ +} + +bsp_boot_attempt_inc(); /* перед каждой попыткой прыжка */ +/* ... */ +bsp_boot_health_mark(); /* приложение подтвердило собственное здоровье */ +``` + +--- + +## CMake + +```cmake +target_link_libraries(firmware_bootloader PRIVATE bsp_boot_state) +``` + +**Зависимости модуля:** + +| Зависимость | Тип | Описание | +| ----------- | ------- | -------------------------------------------------------------- | +| `sdk_src` | PRIVATE | `fsl_src.h` — `SRC_Get/SetGeneralPurposeRegister`, `SRC_Get/ClearResetStatusFlags` | diff --git a/bsp/boot_state/include/bsp/boot_state.h b/bsp/boot_state/include/bsp/boot_state.h new file mode 100644 index 0000000..4f69388 --- /dev/null +++ b/bsp/boot_state/include/bsp/boot_state.h @@ -0,0 +1,59 @@ +/* + * bsp_boot_state — счётчик попыток загрузки поверх SRC General Purpose + * Register (MIMXRT1052). + * + * Назначение: пережить несколько сбросов подряд без реального + * персистентного хранилища во flash. SRC_GPR — retained-регистр: сохраняет + * значение через тёплый/watchdog-сброс, теряет его только на POR. + * + * Контракт: bsp_boot_state_init() сама детектирует POR по SRC->SRSR и, если + * это POR, обнуляет счётчик — вызывающему коду не нужно проверять причину + * сброса самостоятельно для этой цели. Причина «сброс по watchdog?» — не + * этот модуль, см. bsp_wdog_caused_last_reset() (bsp/wdog.h): SRC->SRSR и + * WDOG1->WRSR — разные регистры с разной семантикой очистки (WRSR + * самоочищается на каждый сброс, SRSR — write-1-to-clear, накапливает биты + * между сбросами без явной очистки). + */ + +#ifndef BSP_BOOT_STATE_H +#define BSP_BOOT_STATE_H + +#include +#include + +/* + * Инициализация. Вызывать один раз, как можно раньше в main() (после + * board_hw_init()/bsp_wdog_init()). Читает SRC->SRSR, определяет POR (бит + * IPP_RESET_B — на MIMXRT1052 отдельного бита POR нет, эту роль играет он), + * чистит SRSR (write-1-to-clear, иначе биты копятся между тёплыми сбросами) + * и, если это POR, обнуляет счётчик попыток. + * + * Повторный вызов в той же сессии — не идемпотентен (снова прочитает и + * очистит уже очищенный SRSR, увидит "не POR"): звать ровно один раз. + */ +void bsp_boot_state_init(void); + +/* true, если последний сброс МК (на момент bsp_boot_state_init()) был POR. */ +bool bsp_boot_state_was_por(void); + +/* Текущее значение счётчика попыток. 0 сразу после POR. */ +uint32_t bsp_boot_attempt_count(void); + +/* Инкремент счётчика попыток. Звать перед каждой попыткой прыжка в образ. */ +void bsp_boot_attempt_inc(void); + +/* + * Обнулить счётчик попыток. Звать при: успешной установке нового образа + * (свежему образу — полный бюджет попыток), фолбэк-стирании зависшего слота + * (ситуация изменилась). + */ +void bsp_boot_attempt_reset(void); + +/* + * Отметить образ здоровым — семантический алиас bsp_boot_attempt_reset() для + * вызова из приложения (tft_app), а не из загрузчика: тот же эффект (счётчик + * обнуляется), но имя называет намерение вызывающей стороны. + */ +void bsp_boot_health_mark(void); + +#endif /* BSP_BOOT_STATE_H */ diff --git a/bsp/boot_state/src/boot_state.c b/bsp/boot_state/src/boot_state.c new file mode 100644 index 0000000..2068854 --- /dev/null +++ b/bsp/boot_state/src/boot_state.c @@ -0,0 +1,67 @@ +/* + * bsp_boot_state — реализация поверх fsl_src (SRC_GPR5 + SRC->SRSR). + */ + +#include "bsp/boot_state.h" + +#include "fsl_src.h" + +#define BSP_BOOT_STATE_SRC_BASE SRC + +/* + * GPR-индекс счётчика попыток (0-based, index=0 -> GPR1). Сверено с i.MX RT1050 + * RM (SRC, гл. 21.8.5-21.8.13): GPR1/2 — ROM (entry/arg пробуждения из + * low-power), GPR6/7/8/9 — ТОЖЕ explicit "used by the ROM code, should not be + * used by application software" (несмотря на первоначальное предположение, + * что свободны только GPR1/2/10), GPR10 — ROM (альтернативный SBMR1 через бит + * [28]). GPR5 формально не ROM, но RM рекомендует именно его под отдельную + * задачу (различение SYSRESETREQ/CPU lockup) — не занимаем во избежание + * конфликта с этой конвенцией. GPR3 (index 2) — единственный не подписан + * НИКАКИМ примечанием в RM, описан просто как "arbitrary value". + */ +#define BSP_BOOT_STATE_GPR_INDEX 2U + +static bool g_s_was_por = false; + +void bsp_boot_state_init(void) +{ + uint32_t flags = SRC_GetResetStatusFlags(BSP_BOOT_STATE_SRC_BASE); + + g_s_was_por = (flags & (uint32_t) kSRC_IppResetPinFlag) != 0U; + + /* SRSR — write-1-to-clear, копит биты между тёплыми сбросами без явной + * очистки (в отличие от WDOG1->WRSR, который самоочищается). Чистим всё, + * что доступно, чтобы следующая загрузка увидела только свою причину. */ + SRC_ClearResetStatusFlags(BSP_BOOT_STATE_SRC_BASE, ~0U); + + if (g_s_was_por) + { + bsp_boot_attempt_reset(); + } +} + +bool bsp_boot_state_was_por(void) +{ + return g_s_was_por; +} + +uint32_t bsp_boot_attempt_count(void) +{ + return SRC_GetGeneralPurposeRegister(BSP_BOOT_STATE_SRC_BASE, BSP_BOOT_STATE_GPR_INDEX); +} + +void bsp_boot_attempt_inc(void) +{ + uint32_t count = bsp_boot_attempt_count(); + SRC_SetGeneralPurposeRegister(BSP_BOOT_STATE_SRC_BASE, BSP_BOOT_STATE_GPR_INDEX, count + 1U); +} + +void bsp_boot_attempt_reset(void) +{ + SRC_SetGeneralPurposeRegister(BSP_BOOT_STATE_SRC_BASE, BSP_BOOT_STATE_GPR_INDEX, 0U); +} + +void bsp_boot_health_mark(void) +{ + bsp_boot_attempt_reset(); +} diff --git a/firmware/bootloader/CMakeLists.txt b/firmware/bootloader/CMakeLists.txt index ccfe1e4..5d599d7 100644 --- a/firmware/bootloader/CMakeLists.txt +++ b/firmware/bootloader/CMakeLists.txt @@ -29,6 +29,7 @@ add_executable( src/update_policy.c src/slot_version.c src/sd_update.c + src/recovery.c mcuboot_port/flash_map_backend.c mcuboot_port/keys.c ${MCUBOOT_BOOTUTIL_SOURCES} @@ -52,7 +53,8 @@ target_compile_definitions( target_link_libraries(${TARGET_NAME} PRIVATE bsp_board bsp_led bsp_tick bsp_usb_cdc bsp_qspi_flash bsp_boot_xip_no_dcd bsp_button - bsp_wdog bootloader_fatfs) + bsp_wdog bsp_boot_state + bootloader_fatfs) # ----------------------------------------------------------------------------- # Linker script — вариант flexspi_nor с m_text, ограниченным бюджетом diff --git a/firmware/bootloader/PLAN.md b/firmware/bootloader/PLAN.md index 9ce30cf..748c6db 100644 --- a/firmware/bootloader/PLAN.md +++ b/firmware/bootloader/PLAN.md @@ -7,7 +7,7 @@ | 0 — Карта Flash | ✅ завершена | [docs/mimxrt1052/BOOTLOADER_FLASH_MAP.md](../../docs/mimxrt1052/BOOTLOADER_FLASH_MAP.md) | | 1 — Скелет (CDC + LED) | ✅ завершена | сборка/HAB/SWD-прошивка/ping-pong/debug — все пункты верификации пройдены на реальной плате, детали ниже | | 2 — bootutil (Direct-XIP) | ✅ завершена | host-тесты 5/5, аппаратная верификация — все 5 сценариев пройдены на реальной плате (детали и 3 найденных/исправленных бага — [DEBUG_LOG_PHASE2.md](DEBUG_LOG_PHASE2.md)) | -| 3 — SD-путь установки | ✅ верифицирована (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) | +| 3 — SD-путь установки | ✅ полностью верифицирована (2026-07-13) | все 5 сценариев + раунд 4 (консолидация детекта на единый PRSSTAT + ранний сэмпл кнопки) + **аппаратный watchdog** (`bsp/wdog`, см. ниже) пройдены на реальной плате. По пути найден и исправлен баг чек-листа сценария 1 (стабы не PIC, см. ниже) — не регресс кода. См. [DEBUG_LOG_PHASE3_SD.md](DEBUG_LOG_PHASE3_SD.md), чек-лист — [test_stub/HARDWARE_VERIFICATION_PHASE3.md](test_stub/HARDWARE_VERIFICATION_PHASE3.md) | | 4 — SDRAM/W25Q smoke-test + LED-паттерны | не начата | рекомендуется ПОСЛЕ Фазы 6 (см. ниже) | | 5 — HAB Release + service-tui | не начата | | | 6 — Устойчивость и восстановление (recovery) | спроектирована, код не начат | **рекомендованный следующий шаг** (раньше 4/5): закрывает зависание уже установленного образа и даёт ручной аварийный вход. Дизайн зафиксирован в обсуждении — см. раздел «Фаза 6» ниже | @@ -491,7 +491,7 @@ host-тестом Фазы 2 (`test_boot_go_reverts_unconfirmed_image` в `tests плате). Единственное отличие от `firmware_test_fatfs`: **`FF_FS_READONLY=1`** — bootloader только читает `TFT_APP.BIN`, никогда не пишет на SD; убирает весь write-путь FatFS из сборки. -### Итог (выполнено, аппаратно ещё не проверено) +### Итог (выполнено, аппаратно верифицировано) - `firmware/bootloader/src/update_policy.{c,h}` — чистая логика install/skip + выбор целевого слота (см. решения выше). `image_version_compare()` — свой аналог `boot_version_cmp()` (`static` в @@ -588,12 +588,18 @@ CDC. Сценарии 2/3 эту грань не задевали случайн `HARDWARE_VERIFICATION_PHASE3.md` §5 дополнен явным предупреждением о требовании линковка-под-слот-установки для любого файла на SD. -### ✅ Реализовано (2026-07-10): аппаратный watchdog +### ✅ Реализовано и аппаратно верифицировано (2026-07-13): аппаратный watchdog -**Статус:** реализован, собран (ARM + host зелёные), **аппаратно ещё не проверен** — провокацию -реального зависания и подтверждение восстановления см. в -[test_stub/HARDWARE_VERIFICATION_PHASE3.md](test_stub/HARDWARE_VERIFICATION_PHASE3.md), раздел -«Watchdog». Реализация против плана ниже: +**Статус:** реализован, собран (ARM + host зелёные), **аппаратно верифицирован** (2026-07-13) — +все пункты 9.1–9.5 чек-листа +[test_stub/HARDWARE_VERIFICATION_PHASE3.md](test_stub/HARDWARE_VERIFICATION_PHASE3.md#9-watchdog--провокация-зависания-и-подтверждение-восстановления) +пройдены: синтетическое зависание (механизм WDOG1 напрямую и в реалистичном SD-контексте) → +сброс ≈10 c → авто-восстановление в валидный слот без второго вмешательства; регрессия (чек-лист §8 +без патчей, 2+ мин в цикле ожидания) — ни одного ложного сброса; SWD-halt дольше 10 c не сбрасывает +плату. Дополнительно подтверждено прямым чтением регистров причины сброса через pyOCD (п. 9.3, +опционально) — `WDOG1->WRSR=0x0010` (бит `POR`, не `TOUT`) и `SRC->SRSR=0x00000001` (бит +`IPP_RESET_B`, не `WDOG_RST_B`) на момент чтения (после регрессии §9.4 — ожидаемо обычный POR, не +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** @@ -651,7 +657,8 @@ while (true)` без выхода по времени, если callback `cardDe -**Верификация (первая часть Фазы 3, требующая реального железа — ещё не проведена)**: +**Верификация (первая часть Фазы 3, требующая реального железа — пройдена, см. чек-лист выше и +[test_stub/HARDWARE_VERIFICATION_PHASE3.md](test_stub/HARDWARE_VERIFICATION_PHASE3.md))**: 1. Чистая плата (только bootloader, оба слота пустые) + SD с валидным подписанным образом → автоустановка, переход к загрузке (проверить по CDC-статусам и LED). 2. То же SD с образом версии ниже уже установленной, кнопка не нажата → отклонён @@ -808,6 +815,34 @@ recovery-LED/CDC это показывают), лечится SD-фиксом и - Расширить CDC `wdog`-статус: `{"type":"wdog",...,"reset_count":N,"threshold":M}` — диагностика в поле («сбрасывалась N из M»). +### ✅ Реализовано (2026-07-13): 6a — счётчик + фолбэк, host-тесты зелёные, ARM собирается + +- **`bsp/boot_state`** (не расширение `bsp_wdog` — разные периферии, см. решения выше): `SRC_GPR[2]` + (GPR3, исправлено после сверки с официальной RM — см. ниже) — счётчик, `SRC->SRSR` бит + `IPP_RESET_B` — POR-детект с обязательной + `SRC_ClearResetStatusFlags()` после чтения (иначе биты копятся между тёплыми сбросами — см. находку + про разделение `WRSR`/`SRSR` выше). API: `bsp_boot_state_init()`, `bsp_boot_state_was_por()`, + `bsp_boot_attempt_count/inc/reset()`, `bsp_boot_health_mark()`. Переиспользует вендоренный + `fsl_src.h` (новый `sdk_src` таргет в `sdk/CMakeLists.txt`, тот же `add_sdk_driver()` макрос, что и + у `sdk_wdog`) — регистры руками не трогаем. Документация — по строгому стандарту остальных `bsp/*`. +- **`firmware/bootloader/src/recovery.{c,h}`** — `recovery_decide(attempt_count, threshold, slot_a, + slot_b, btn2_held)`. `is_por` из исходной сигнатуры плана убран — POR-обнуление счётчика происходит + как побочный эффект `bsp_boot_state_init()`, чистой функции этот параметр не нужен. 9 host-тестов + (`tests/host/recovery/`) — все ветки таксономии (BTN_2 главнее счётчика, ниже/на/выше порога, есть + фолбэк/нет, оба слота невалидны). +- **`main.c`** — обе точки прыжка (до цикла ожидания и внутри пере-скана) объединены в один + `attempt_boot()`: `sd_update_check()` → `peek_slot()` обоих слотов → `recovery_decide()` → ветка. + `bsp_boot_state_init()` — сразу после `bsp_wdog_init()`. `BSP_BUTTON_2` сэмплируется тем же ранним + приёмом, что и `BSP_BUTTON_1`. Ветка `RECOVERY_ENTER_RECOVERY_MODE` в 6a — заглушка (не прыгаем, + падаем в существующий цикл ожидания); полноценное recovery-состояние — 6b. +- **CDC**: `protocol_send_wdog_status()` расширен полями `reset_count`/`threshold`. +- **Host-тесты**: `just build::test-host` — **16/16 зелёных** (было 15, +9 новых `test_recovery`, + один тест из старых 15 групп — не путать с количеством отдельных assert'ов). +- **ARM-сборка**: `just build::build-bootloader-debug` и `hab-bootloader-debug` — оба зелёные. + `m_text` — 90280 Б из 247 КБ бюджета (35.69%, было 87928 Б / 34.76% после Фазы 3 watchdog). +- **Аппаратно не проверено** — как и остальная Фаза 6, ждёт стенда `test_stub` (6c) и чек-листа § + «Верификация» выше. + **6b — Recovery-режим + ручной BTN_2** (тонкий аппаратный оркестратор) - Единое состояние recovery: подавить `boot_select_and_jump()`, отдельный LED-паттерн (узнаваемый «шиммер»/SOS обоими LED, явно отличный от heartbeat 50/450 и app 500/250 — сведётся в словарь @@ -849,12 +884,45 @@ recovery-LED/CDC это показывают), лечится SD-фиксом и → счётчик обнулён (POR), слот пробуется снова (подтверждает per-session-семантику). 6. **Не мешает норме** — прогнать сценарии Фазы 3 как есть: без ложных recovery/сбросов. -### Открытые вопросы к реализации +### Решения, принятые в обсуждении реализации Фазы 6 (2026-07-13, до кода — не пересматриваются) -- `SRC_GPR[n]`: индекс (свободный от ROM) + подтвердить переживание watchdog-сброса и обнуление на - POR (RM + железо). -- `SRC->SRSR` — корректно читать/чистить (w1c) причину сброса, различать POR vs WDOG (биты - `SRC_SRSR_WDOG_RST_B` / POR). +- **Новый модуль `bsp/boot_state`**, не расширение `bsp_wdog` — разные периферии (WDOG1 vs SRC), + разная ответственность. Документация — по тому же строгому стандарту, что и остальные `bsp/*` + (см. недавний рефактор `bsp/wdog/README.md`): `Аппаратура` → `Контракт`/`Архитектура` → `API` → + `Быстрый старт` → `CMake`, без ссылок на конкретных потребителей. +- **Переиспользуем вендоренный `sdk/devices/MIMXRT1052/drivers/fsl_src.h`**, не хардкодим регистры + `SRC` руками: `SRC_GetGeneralPurposeRegister`/`SRC_SetGeneralPurposeRegister` (индекс 0-based, + `index=0` → GPR1) для счётчика; `SRC_GetResetStatusFlags`/`SRC_ClearResetStatusFlags` для `SRSR` + (уже корректно реализует w1c через `SRC_SRSR_W1C_BITS_MASK`, ручной код не нужен). +- **`SRC_GPR[2]` (GPR3)** — счётчик попыток. **Исправлено после сверки с официальной i.MX RT1050 RM** + (гл. 21.8.5–21.8.13, PDF от пользователя) — первоначальная догадка по одним заголовкам + (`GPR5..8` "спокойные", заняты только `GPR1/2/10`) оказалась **неверной**: RM явно помечает + `SRC_GPR6`, `GPR7`, `GPR8`, `GPR9` тем же примечанием **"used by the ROM code and should not be + used by application software"**, что и `GPR1`/`GPR2` (entry/arg тёплой загрузки) — заголовки + `PERI_SRC.h` этого не показывают (там только имена битовых полей, без ROM-пометок из текста RM). + `GPR10` — тоже ROM (бит `[28]` включает трактовку `GPR9` как альтернативного `SBMR1`). + **`GPR5`** формально не ROM-зарезервирован, но RM прямо рекомендует именно его под свою задачу + (различение `SYSRESETREQ` vs CPU lockup перед программным сбросом) — заняли бы чужую конвенцию. + Единственные регистры **без единой пометки** в RM — `GPR3` и `GPR4` ("Read/write general purpose + bits used to store an arbitrary value"). Выбран `GPR3`. + **Всё ещё стоит подтвердить на железе** (RM говорит про GPR1/2 явно "SRC ensures that the register + value will persist across system resets" — вся физическая шина GPR та же, но не для каждого + регистра дословно повторено): реально ли значение переживает watchdog-сброс — первый шаг 6a. +- **Разделение ответственности WRSR vs SRSR** (важный нюанс, не был очевиден из плана): `SRSR` + накапливает биты между resets, если не чистить софтом (w1c) — эмпирически подтверждено (§9.3): + чистый POR обнулил `SRSR` целиком аппаратно, но между двумя warm-резетами подряд (класс B — серия + watchdog-сбросов без POR между ними) биты будут копиться без явной чистки. `WDOG1->WRSR` (уже + использует `bsp_wdog_caused_last_reset()`) — self-clean, накопления не даёт. Поэтому: + - **«Это был сброс по watchdog?»** → как и раньше, `bsp_wdog_caused_last_reset()` (WRSR) — код уже + есть, не трогаем. + - **«Это был POR (обнулить счётчик) или тёплый резет (не трогать)?»** → новый код в + `bsp/boot_state`, бит `IPP_RESET_B` в `SRC->SRSR` (для MIMXRT1052 `FSL_FEATURE_SRC_HAS_SRSR_POR=0` + — отдельного бита POR нет, роль POR играет `IPP_RESET_B`) — и **обязательная + `SRC_ClearResetStatusFlags()` сразу после чтения**, каждую загрузку. +- **LED-паттерн recovery** (не дожидаясь словаря Фазы 4): **оба LED вместе, синхронно, 100 мс + вкл/100 мс выкл** — чётко отличается от heartbeat (50/450 мс, один LED) и app (500/250 мс, один + LED). +- **Порог класса B — подтверждён: 3** (дефолт из исходного плана остаётся). - Word choice recovery-LED — согласовать со словарём Фазы 4, чтобы не переделывать. --- diff --git a/firmware/bootloader/src/main.c b/firmware/bootloader/src/main.c index 56e531e..6729f86 100644 --- a/firmware/bootloader/src/main.c +++ b/firmware/bootloader/src/main.c @@ -10,6 +10,14 @@ * ожидания, где SD периодически пере-сканируется (см. sd_update.h о том, * почему boot_go() нельзя звать без новой попытки установки между вызовами). * + * Фаза 6 (recovery, см. recovery.h): каждая попытка обёрнута в attempt_boot() + * — после SD-скана, но перед прыжком, recovery_decide() решает, обычная ли + * это загрузка, нужно ли стереть подозреваемый в зависании слот (счётчик + * bsp_boot_attempt_count() дошёл до порога, но есть валидный фолбэк), или + * входить в recovery (порог без фолбэка, или удержан BSP_BUTTON_2). Класс A + * таксономии (незавершённая установка) закрывается штатным revert MCUboot + * без участия этой логики. + * * USB CDC поднимается ДО SD-логики (не дожидаясь подключения хоста — * bsp_usb_cdc_write() не блокируется без хоста, см. bsp/usb_cdc/src/usb_cdc.c) * — чтобы статусы ("installing" и т.п.) были видны, если технолог уже @@ -18,15 +26,16 @@ * Последовательность старта: * 1. board_hw_init() — тактирование, MPU, кэш, пины * 2. bsp_wdog_init() — аппаратный watchdog как можно раньше (см. ниже) - * 3. bsp_led_init() — оба LED выключены - * 4. bsp_tick_init() — SysTick 1 мс - * 5. bsp_button_init() — для проверки удержания BSP_BUTTON_1 - * 6. bsp_qspi_init() — доступ к Slot A/Б - * 7. bsp_usb_cdc_init() — не блокирует, см. выше - * 8. sd_update_check() — no-op быстро, если SD не вставлена - * 9. boot_select_and_jump() — при успехе не возвращается + * 3. bsp_boot_state_init() — POR-детект + счётчик попыток (Фаза 6) + * 4. bsp_led_init() — оба LED выключены + * 5. bsp_tick_init() — SysTick 1 мс + * 6. bsp_button_init() — для проверки удержания BSP_BUTTON_1/2 + * 7. bsp_qspi_init() — доступ к Slot A/Б + * 8. bsp_usb_cdc_init() — не блокирует, см. выше + * 9. attempt_boot() — SD-скан + recovery-гейт + прыжок; при + * успехе не возвращается * 10. Цикл ожидания — CDC ping/pong + LED + периодический - * пере-скан SD (шаги 8-9 повторно) + * пере-скан SD (шаг 9 повторно) * * Watchdog (bsp_wdog): единственная защита от бесконечных зависаний в * блокирующих вызовах SDMMC-стека, не возвращающих управление в наш код @@ -42,6 +51,7 @@ */ #include "board.h" #include "boot_select.h" +#include "bsp/boot_state.h" #include "bsp/button.h" #include "bsp/led.h" #include "bsp/qspi_flash.h" @@ -49,12 +59,73 @@ #include "bsp/usb_cdc.h" #include "bsp/wdog.h" #include "cli.h" +#include "flash_map.h" #include "protocol.h" +#include "recovery.h" #include "sd_update.h" +#include "slot_version.h" #include #include +/* ── Одна попытка загрузки: SD-скан + recovery-гейт (Фаза 6) + прыжок ───── + * + * sd_update_check() — до чтения счётчика/состояния слотов: новый образ с SD + * заслуживает полный бюджет попыток независимо от текущего счётчика (см. + * "Правила обнуления счётчика" в PLAN.md), а не только после гейта. + * + * peek_slot() — та же логика, что private peek_slot() в sd_update.c: не + * шарим напрямую между модулями (см. update_policy.h), копия минимальна. */ +static update_policy_slot_state_t peek_slot(uint8_t fa_id) +{ + update_policy_slot_state_t state; + state.valid = slot_version_get(fa_id, &state.version); + return state; +} + +static void attempt_boot(bool downgrade_held, bool recovery_held) +{ + sd_update_check(downgrade_held); /* no-op быстро, если SD не вставлена */ + + update_policy_slot_state_t slot_a = peek_slot(0U); + update_policy_slot_state_t slot_b = peek_slot(1U); + + recovery_decision_t decision = recovery_decide( + bsp_boot_attempt_count(), RECOVERY_DEFAULT_THRESHOLD, &slot_a, &slot_b, recovery_held); + + switch (decision.action) + { + case RECOVERY_ERASE_ACTIVE_THEN_BOOT_OTHER: + { + /* Подозреваемый в зависании слот — стереть, есть подтверждённый + * фолбэк (recovery_decide() это уже проверила). boot_go() внутри + * boot_select_and_jump() ниже сам выберет оставшийся. */ + const struct flash_area *p_fap; + if (flash_area_open((uint8_t) decision.active_slot, &p_fap) == 0) + { + (void) flash_area_erase(p_fap, 0U, p_fap->fa_size); + flash_area_close(p_fap); + } + bsp_boot_attempt_reset(); /* ситуация изменилась — новый полный бюджет */ + bsp_wdog_refresh(); /* образ унаследует полное окно таймаута */ + boot_select_and_jump(); /* при успехе не возвращается */ + break; + } + case RECOVERY_ENTER_RECOVERY_MODE: + /* TODO(Фаза 6b): отдельное recovery-состояние — LED-паттерн, CDC + * status:recovery_mode, ослабленный version-gate на SD-установке. + * Пока просто не прыгаем — падаем в обычный цикл ожидания main(), + * как и при отсутствии валидного образа. */ + break; + case RECOVERY_NORMAL_BOOT: + default: + bsp_boot_attempt_inc(); /* перед попыткой — см. bsp/boot_state.h */ + bsp_wdog_refresh(); /* образ унаследует полное окно таймаута */ + boot_select_and_jump(); /* при успехе не возвращается */ + break; + } +} + int main(void) { const uint32_t ERROR_BLINK_MS = 250U; @@ -72,6 +143,10 @@ int main(void) /* Как можно раньше — до первой же SD-логики, которая может зависнуть. */ (void) bsp_wdog_init(WDOG_TIMEOUT_S); + /* Сразу после watchdog — сама операция дешёвая (пара регистров SRC), а + * решение recovery_decide() ниже нужно уже на первой попытке. */ + bsp_boot_state_init(); + bsp_led_init(); bsp_tick_init(); bsp_button_init(); @@ -84,14 +159,16 @@ int main(void) * Значение переиспользуется и первой попыткой, и пере-сканами в цикле. */ const bool DOWNGRADE_HELD = bsp_button_read(BSP_BUTTON_1); + /* Жест recovery (Фаза 6) — тот же приём, удержание BSP_BUTTON_2. Приоритет + * над BTN_1 разрешается внутри recovery_decide() (проверяется первым). */ + const bool RECOVERY_HELD = bsp_button_read(BSP_BUTTON_2); + bool qspi_ok = (bsp_qspi_init() == BSP_OK); bool cdc_ok = (bsp_usb_cdc_init() == BSP_OK); if (qspi_ok) { - sd_update_check(DOWNGRADE_HELD); /* no-op быстро, если SD не вставлена */ - bsp_wdog_refresh(); /* образ унаследует полное окно таймаута */ - boot_select_and_jump(); /* при успехе не возвращается */ + attempt_boot(DOWNGRADE_HELD, RECOVERY_HELD); } /* Нет валидного образа ни в одном слоте (или сбой QSPI) — @@ -139,9 +216,7 @@ int main(void) protocol_send_status("waiting_for_sd"); - sd_update_check(DOWNGRADE_HELD); - bsp_wdog_refresh(); /* образ унаследует полное окно таймаута */ - boot_select_and_jump(); /* при успехе не возвращается */ + attempt_boot(DOWNGRADE_HELD, RECOVERY_HELD); } } } diff --git a/firmware/bootloader/src/protocol.c b/firmware/bootloader/src/protocol.c index 9eeb98c..ce34efb 100644 --- a/firmware/bootloader/src/protocol.c +++ b/firmware/bootloader/src/protocol.c @@ -5,8 +5,10 @@ #include "protocol.h" +#include "bsp/boot_state.h" #include "bsp/wdog.h" #include "cli.h" +#include "recovery.h" #include @@ -49,9 +51,12 @@ 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", + "{\"type\":\"wdog\",\"armed\":%s,\"timeout_s\":%u,\"recovered\":%s," + "\"reset_count\":%u,\"threshold\":%u}\n", bsp_wdog_is_armed() ? "true" : "false", (unsigned) bsp_wdog_timeout_s(), - bsp_wdog_caused_last_reset() ? "true" : "false"); + bsp_wdog_caused_last_reset() ? "true" : "false", + (unsigned) bsp_boot_attempt_count(), + (unsigned) RECOVERY_DEFAULT_THRESHOLD); cli_send(buf); } diff --git a/firmware/bootloader/src/protocol.h b/firmware/bootloader/src/protocol.h index c2e6aab..a4fadb6 100644 --- a/firmware/bootloader/src/protocol.h +++ b/firmware/bootloader/src/protocol.h @@ -63,13 +63,19 @@ void protocol_send_error(const char *p_code); void protocol_send_status(const char *p_state); /** - * @brief Отправить статус аппаратного watchdog. + * @brief Отправить статус аппаратного watchdog и счётчика попыток загрузки + * (Фаза 6). * - * Формат: {"type":"wdog","armed":true,"timeout_s":10,"recovered":false} - * - armed — watchdog взведён (bsp_wdog_init выполнен); - * - timeout_s — сконфигурированный таймаут в секундах; - * - recovered — ПОСЛЕДНИЙ сброс МК был по таймауту watchdog (плата - * восстановилась после зависания). + * Формат: {"type":"wdog","armed":true,"timeout_s":10,"recovered":false, + * "reset_count":0,"threshold":3} + * - armed — watchdog взведён (bsp_wdog_init выполнен); + * - timeout_s — сконфигурированный таймаут в секундах; + * - recovered — ПОСЛЕДНИЙ сброс МК был по таймауту watchdog (плата + * восстановилась после зависания); + * - reset_count — bsp_boot_attempt_count(): сколько попыток подряд без + * подтверждения здоровья (health-mark/новая установка), 0 + * сразу после POR; + * - threshold — RECOVERY_DEFAULT_THRESHOLD: порог фолбэка/recovery. * * Эмитится один раз на старте, если recovered, и по команде "wdog". */ diff --git a/firmware/bootloader/src/recovery.c b/firmware/bootloader/src/recovery.c new file mode 100644 index 0000000..ff23d00 --- /dev/null +++ b/firmware/bootloader/src/recovery.c @@ -0,0 +1,75 @@ +/** + * @file recovery.c + * @brief Реализация — см. recovery.h. + */ + +#include "recovery.h" + +/* ── Определение активного слота ────────────────────────────────────────── + * Тот же приём, что find_active_slot() в update_policy.c (private там) — + * не шарим напрямую, копия минимальна и независимо тестируется здесь же, + * как уже принято в проекте для мелкой логики между модулями. */ + +typedef struct +{ + bool have_active; + update_policy_slot_t active_slot; + struct image_version active_ver; +} active_slot_info_t; + +static active_slot_info_t find_active_slot(const update_policy_slot_state_t *p_slot_a, + const update_policy_slot_state_t *p_slot_b) +{ + active_slot_info_t info = { .have_active = false }; + + if (p_slot_a->valid) + { + info.have_active = true; + info.active_slot = UPDATE_POLICY_SLOT_A; + info.active_ver = p_slot_a->version; + } + + if (p_slot_b->valid && + (!info.have_active || image_version_compare(&p_slot_b->version, &info.active_ver) > 0)) + { + info.have_active = true; + info.active_slot = UPDATE_POLICY_SLOT_B; + info.active_ver = p_slot_b->version; + } + + return info; +} + +static bool other_slot_valid(update_policy_slot_t active_slot, + const update_policy_slot_state_t *p_slot_a, + const update_policy_slot_state_t *p_slot_b) +{ + return (active_slot == UPDATE_POLICY_SLOT_A) ? p_slot_b->valid : p_slot_a->valid; +} + +/* ── Публичный API ─────────────────────────────────────────────────────── */ + +recovery_decision_t recovery_decide(uint32_t attempt_count, uint32_t threshold, + const update_policy_slot_state_t *p_slot_a, + const update_policy_slot_state_t *p_slot_b, bool btn2_held) +{ + if (btn2_held) + { + return (recovery_decision_t) { .action = RECOVERY_ENTER_RECOVERY_MODE }; + } + + if (attempt_count < threshold) + { + return (recovery_decision_t) { .action = RECOVERY_NORMAL_BOOT }; + } + + active_slot_info_t active = find_active_slot(p_slot_a, p_slot_b); + + if (active.have_active && other_slot_valid(active.active_slot, p_slot_a, p_slot_b)) + { + return (recovery_decision_t) { .action = RECOVERY_ERASE_ACTIVE_THEN_BOOT_OTHER, + .active_slot = active.active_slot }; + } + + return (recovery_decision_t) { .action = RECOVERY_ENTER_RECOVERY_MODE }; +} diff --git a/firmware/bootloader/src/recovery.h b/firmware/bootloader/src/recovery.h new file mode 100644 index 0000000..dd69b51 --- /dev/null +++ b/firmware/bootloader/src/recovery.h @@ -0,0 +1,72 @@ +/** + * @file recovery.h + * @brief Чистая логика решения "что делать с этой попыткой загрузки" — + * без аппаратных зависимостей (flash/SRC_GPR), полностью + * host-тестируема. + * + * См. firmware/bootloader/PLAN.md, Фаза 6, разбивка 6a. + */ + +#ifndef RECOVERY_H_ +#define RECOVERY_H_ + +#include "update_policy.h" + +#include +#include + +typedef enum +{ + RECOVERY_NORMAL_BOOT = 0, + RECOVERY_ERASE_ACTIVE_THEN_BOOT_OTHER, + RECOVERY_ENTER_RECOVERY_MODE, +} recovery_action_t; + +typedef struct +{ + recovery_action_t action; + + /** + * Слот, который нужно стереть перед прыжком. Значим только при + * action == RECOVERY_ERASE_ACTIVE_THEN_BOOT_OTHER. + */ + update_policy_slot_t active_slot; +} recovery_decision_t; + +/** + * @brief Решить, что делать с текущей попыткой загрузки — таксономия + * отказов Фазы 6 (классы B/C/D; класс A закрывается штатным revert + * MCUboot без участия этой функции). + * + * Правила (приоритет сверху вниз): + * - btn2_held → RECOVERY_ENTER_RECOVERY_MODE. Ручной триггер главнее + * счётчика — оператор может войти в recovery в любой момент, независимо + * от истории попыток. + * - attempt_count < threshold → RECOVERY_NORMAL_BOOT. Обычная загрузка, + * ничего не предпринимаем (счётчик инкрементирует вызывающий код перед + * прыжком). + * - attempt_count >= threshold и есть "активный" слот (валидный, более + * высокой версии — тот же приём, что update_policy_decide()) — он и + * есть подозреваемый в зависании: + * - другой слот валиден (есть фолбэк) → RECOVERY_ERASE_ACTIVE_THEN_ + * BOOT_OTHER(active_slot). Вызывающий код стирает active_slot и + * обнуляет счётчик — boot_go() сам выберет оставшийся слот. + * - другого валидного слота нет (стирать нечего — иначе ноль рабочих + * слотов) → RECOVERY_ENTER_RECOVERY_MODE. + * - attempt_count >= threshold и активного слота нет вообще → + * RECOVERY_ENTER_RECOVERY_MODE (нечего анализировать, нечего стирать). + * + * @param[in] attempt_count Текущее значение счётчика попыток (bsp_boot_attempt_count()). + * @param[in] threshold Порог срабатывания фолбэка/recovery (см. RECOVERY_DEFAULT_THRESHOLD). + * @param[in] p_slot_a Состояние Slot A. + * @param[in] p_slot_b Состояние Slot Б. + * @param[in] btn2_held BSP_BUTTON_2 удержана на старте — ручной вход в recovery. + */ +recovery_decision_t recovery_decide(uint32_t attempt_count, uint32_t threshold, + const update_policy_slot_state_t *p_slot_a, + const update_policy_slot_state_t *p_slot_b, bool btn2_held); + +/** @brief Дефолтный порог — 3 сброса подряд до фолбэка/recovery. */ +#define RECOVERY_DEFAULT_THRESHOLD 3U + +#endif /* RECOVERY_H_ */ diff --git a/firmware/bootloader/test_stub/HARDWARE_VERIFICATION_PHASE3.md b/firmware/bootloader/test_stub/HARDWARE_VERIFICATION_PHASE3.md index ecc93d4..b9523a2 100644 --- a/firmware/bootloader/test_stub/HARDWARE_VERIFICATION_PHASE3.md +++ b/firmware/bootloader/test_stub/HARDWARE_VERIFICATION_PHASE3.md @@ -7,9 +7,11 @@ bootloader и заглушки слотов (`test_stub`) уже знакомы Все команды — из корня репозитория (`tft_manufacture_test/`). Сборка и host-тесты — в devcontainer; прошивка/отладка/проверки на железе — на стороне пользователя. -**Статус: ✅ все 5 сценариев пройдены на реальной плате (2026-07-10).** Историю бага -card-detect и его разрешение (чтение USDHC `PRES_STATE.CINST`) см. в -[../DEBUG_LOG_PHASE3_SD.md](../DEBUG_LOG_PHASE3_SD.md). +**Статус: ✅ все 5 сценариев §8 пройдены на реальной плате (2026-07-10, повторно — с раундом 4 и +watchdog — 2026-07-13); watchdog §9 (9.1–9.5) пройден 2026-07-13.** Историю бага card-detect и его +разрешение (чтение USDHC `PRES_STATE.CINST`) см. в [../DEBUG_LOG_PHASE3_SD.md](../DEBUG_LOG_PHASE3_SD.md). +По пути найден и исправлен баг самого чек-листа (не кода) — сценарий 1 использовал не тот линкованный +стаб, см. [../PLAN.md](../PLAN.md), раздел «Найден и исправлен баг чек-листа». --- diff --git a/sdk/CMakeLists.txt b/sdk/CMakeLists.txt index 6b6615a..9661186 100644 --- a/sdk/CMakeLists.txt +++ b/sdk/CMakeLists.txt @@ -63,6 +63,7 @@ add_sdk_driver(edma fsl_edma.c) add_sdk_driver(dmamux fsl_dmamux.c) add_sdk_driver(xbara fsl_xbara.c) add_sdk_driver(wdog fsl_wdog.c) +add_sdk_driver(src fsl_src.c) # Драйверы которые зависят от clock target_link_libraries(sdk_lpuart PUBLIC sdk_clock) diff --git a/tests/host/CMakeLists.txt b/tests/host/CMakeLists.txt index 8ac374e..332a3a9 100644 --- a/tests/host/CMakeLists.txt +++ b/tests/host/CMakeLists.txt @@ -263,6 +263,22 @@ add_host_test( ${PROJECT_SOURCE_DIR}/firmware/bootloader/src ${MCUBOOT_BOOTUTIL_INCLUDES}) +# ----------------------------------------------------------------------------- +# recovery — чистая логика решения "что делать с этой попыткой загрузки" +# (Фаза 6, разбивка 6a). Переиспользует update_policy_slot_state_t/ +# image_version_compare() из update_policy.c — тот же набор INCLUDES. +# ----------------------------------------------------------------------------- +add_host_test( + NAME + test_recovery + SOURCES + recovery/test_recovery.c + ${PROJECT_SOURCE_DIR}/firmware/bootloader/src/recovery.c + ${PROJECT_SOURCE_DIR}/firmware/bootloader/src/update_policy.c + INCLUDES + ${PROJECT_SOURCE_DIR}/firmware/bootloader/src + ${MCUBOOT_BOOTUTIL_INCLUDES}) + # ----------------------------------------------------------------------------- # slot_version — read-only пик версии слота через bootutil_img_validate() # (Фаза 3). Реальный bootutil + TinyCrypt поверх fake_flash_map_backend.c — diff --git a/tests/host/recovery/README.md b/tests/host/recovery/README.md new file mode 100644 index 0000000..9d48bb1 --- /dev/null +++ b/tests/host/recovery/README.md @@ -0,0 +1,48 @@ +# test_recovery + +## Модуль под тестом + +`firmware/bootloader/src/recovery.c` (`recovery.h`) — чистая логика решения +«что делать с этой попыткой загрузки» (таксономия отказов Фазы 6, классы +B/C/D), без аппаратных зависимостей (`SRC_GPR`/flash). См. +`firmware/bootloader/PLAN.md`. + +## Категория + +A — платформонезависимый модуль. Переиспользует `update_policy_slot_state_t` +и `image_version_compare()` из `update_policy.c` (линкуется вместе с тестом), +сам `bootutil` не линкуется. + +## Моки + +Нет — тестируется напрямую, без фейков/стабов. + +## Что проверяется + +- **BSP_BUTTON_2 удержана** — `RECOVERY_ENTER_RECOVERY_MODE` независимо от + счётчика попыток и состояния слотов (ручной триггер главнее счётчика). +- **Счётчик ниже порога** — `RECOVERY_NORMAL_BOOT`, независимо от слотов. +- **Счётчик на пороге, есть фолбэк** (второй слот валиден) — + `RECOVERY_ERASE_ACTIVE_THEN_BOOT_OTHER` с правильным `active_slot` + (активный — слот с более высокой версией, тот же приём, что в + `update_policy_decide()`), проверено для обеих сторон (А активен/Б + активен). +- **Счётчик выше порога** — фолбэк продолжает срабатывать (не только ровно + на пороге). +- **Счётчик на пороге, фолбэка нет** (единственный валидный слот, или ни + одного валидного) — `RECOVERY_ENTER_RECOVERY_MODE` (стирать единственный + рабочий слот нельзя). + +## Гарантии + +- Класс A (незавершённая установка, `copy_done!=image_ok`) этой функцией не + закрывается — целиком авто-откат штатным revert MCUboot, без участия + загрузчика. +- Фолбэк никогда не оставляет ноль валидных слотов — стирается только + активный, и только когда другой уже подтверждён валидным. + +## Запуск + +```bash +ctest --preset host-debug-test -R test_recovery -V +``` diff --git a/tests/host/recovery/test_recovery.c b/tests/host/recovery/test_recovery.c new file mode 100644 index 0000000..d0f8b88 --- /dev/null +++ b/tests/host/recovery/test_recovery.c @@ -0,0 +1,156 @@ +/** + * @file test_recovery.c + * @brief Host-тесты чистой логики recovery_decide(). + * + * Без аппаратных зависимостей (SRC_GPR/flash) — сценарии зеркалят таксономию + * отказов Фазы 6 (firmware/bootloader/PLAN.md). + */ + +#include "recovery.h" +#include "unity.h" + +/* ── Вспомогательные конструкторы ─────────────────────────────────────── */ + +static struct image_version make_version(uint8_t major, uint8_t minor, uint16_t revision) +{ + struct image_version ver = { + .iv_major = major, + .iv_minor = minor, + .iv_revision = revision, + .iv_build_num = 0, + }; + return ver; +} + +static update_policy_slot_state_t make_valid_slot(struct image_version ver) +{ + update_policy_slot_state_t slot = { .valid = true, .version = ver }; + return slot; +} + +static const update_policy_slot_state_t K_INVALID_SLOT = { .valid = false }; +static const uint32_t K_THRESHOLD = 3U; + +void setUp(void) +{ +} + +void tearDown(void) +{ +} + +/* ── BTN_2 — главнее счётчика ─────────────────────────────────────────── */ + +void test_btn2_held_enters_recovery_even_with_zero_attempts(void) +{ + recovery_decision_t d = + recovery_decide(0U, K_THRESHOLD, &K_INVALID_SLOT, &K_INVALID_SLOT, true); + + TEST_ASSERT_EQUAL(RECOVERY_ENTER_RECOVERY_MODE, d.action); +} + +void test_btn2_held_enters_recovery_even_with_healthy_slots(void) +{ + update_policy_slot_state_t slot_a = make_valid_slot(make_version(1, 0, 0)); + update_policy_slot_state_t slot_b = make_valid_slot(make_version(2, 0, 0)); + + recovery_decision_t d = recovery_decide(0U, K_THRESHOLD, &slot_a, &slot_b, true); + + TEST_ASSERT_EQUAL(RECOVERY_ENTER_RECOVERY_MODE, d.action); +} + +/* ── Ниже порога — обычная загрузка ──────────────────────────────────── */ + +void test_below_threshold_is_normal_boot(void) +{ + recovery_decision_t d = recovery_decide(K_THRESHOLD - 1U, K_THRESHOLD, &K_INVALID_SLOT, + &K_INVALID_SLOT, false); + + TEST_ASSERT_EQUAL(RECOVERY_NORMAL_BOOT, d.action); +} + +void test_zero_attempts_is_normal_boot(void) +{ + update_policy_slot_state_t slot_a = make_valid_slot(make_version(1, 0, 0)); + + recovery_decision_t d = recovery_decide(0U, K_THRESHOLD, &slot_a, &K_INVALID_SLOT, false); + + TEST_ASSERT_EQUAL(RECOVERY_NORMAL_BOOT, d.action); +} + +/* ── На пороге, класс B, есть фолбэк ─────────────────────────────────── */ + +void test_at_threshold_with_fallback_erases_active_slot_a(void) +{ + update_policy_slot_state_t slot_a = make_valid_slot(make_version(2, 0, 0)); /* активный */ + update_policy_slot_state_t slot_b = make_valid_slot(make_version(1, 0, 0)); /* фолбэк */ + + recovery_decision_t d = recovery_decide(K_THRESHOLD, K_THRESHOLD, &slot_a, &slot_b, false); + + TEST_ASSERT_EQUAL(RECOVERY_ERASE_ACTIVE_THEN_BOOT_OTHER, d.action); + TEST_ASSERT_EQUAL(UPDATE_POLICY_SLOT_A, d.active_slot); +} + +void test_at_threshold_with_fallback_erases_active_slot_b(void) +{ + update_policy_slot_state_t slot_a = make_valid_slot(make_version(1, 0, 0)); /* фолбэк */ + update_policy_slot_state_t slot_b = make_valid_slot(make_version(2, 0, 0)); /* активный */ + + recovery_decision_t d = recovery_decide(K_THRESHOLD, K_THRESHOLD, &slot_a, &slot_b, false); + + TEST_ASSERT_EQUAL(RECOVERY_ERASE_ACTIVE_THEN_BOOT_OTHER, d.action); + TEST_ASSERT_EQUAL(UPDATE_POLICY_SLOT_B, d.active_slot); +} + +void test_above_threshold_still_triggers_fallback(void) +{ + update_policy_slot_state_t slot_a = make_valid_slot(make_version(2, 0, 0)); + update_policy_slot_state_t slot_b = make_valid_slot(make_version(1, 0, 0)); + + recovery_decision_t d = recovery_decide(K_THRESHOLD + 5U, K_THRESHOLD, &slot_a, &slot_b, false); + + TEST_ASSERT_EQUAL(RECOVERY_ERASE_ACTIVE_THEN_BOOT_OTHER, d.action); + TEST_ASSERT_EQUAL(UPDATE_POLICY_SLOT_A, d.active_slot); +} + +/* ── На пороге, класс C — откатываться некуда ────────────────────────── */ + +void test_at_threshold_without_fallback_enters_recovery(void) +{ + update_policy_slot_state_t slot_a = make_valid_slot(make_version(1, 0, 0)); /* активный, одинокий */ + + recovery_decision_t d = + recovery_decide(K_THRESHOLD, K_THRESHOLD, &slot_a, &K_INVALID_SLOT, false); + + TEST_ASSERT_EQUAL(RECOVERY_ENTER_RECOVERY_MODE, d.action); +} + +void test_at_threshold_with_no_valid_slots_enters_recovery(void) +{ + recovery_decision_t d = + recovery_decide(K_THRESHOLD, K_THRESHOLD, &K_INVALID_SLOT, &K_INVALID_SLOT, false); + + TEST_ASSERT_EQUAL(RECOVERY_ENTER_RECOVERY_MODE, d.action); +} + +/* ── Точка входа ───────────────────────────────────────────────────────── */ + +int main(void) +{ + UNITY_BEGIN(); + + RUN_TEST(test_btn2_held_enters_recovery_even_with_zero_attempts); + RUN_TEST(test_btn2_held_enters_recovery_even_with_healthy_slots); + + RUN_TEST(test_below_threshold_is_normal_boot); + RUN_TEST(test_zero_attempts_is_normal_boot); + + RUN_TEST(test_at_threshold_with_fallback_erases_active_slot_a); + RUN_TEST(test_at_threshold_with_fallback_erases_active_slot_b); + RUN_TEST(test_above_threshold_still_triggers_fallback); + + RUN_TEST(test_at_threshold_without_fallback_enters_recovery); + RUN_TEST(test_at_threshold_with_no_valid_slots_enters_recovery); + + return UNITY_END(); +}