diff --git a/CMakePresets.json b/CMakePresets.json index babf3d0..675efc3 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -128,11 +128,15 @@ }, { "name": "mcuboot-stub-debug", - "displayName": "mcuboot slot stub (Фаза 2 верификация) — Debug", + "displayName": "mcuboot slot stub (Фаза 2/6 верификация) — Debug", "configurePreset": "Debug", "targets": [ "test_slot_stub_a", - "test_slot_stub_b" + "test_slot_stub_b", + "test_slot_stub_a_hang", + "test_slot_stub_b_hang", + "test_slot_stub_a_confirm_hang", + "test_slot_stub_b_confirm_hang" ] }, { diff --git a/firmware/bootloader/BOOT_FLOW.md b/firmware/bootloader/BOOT_FLOW.md new file mode 100644 index 0000000..4cd4313 --- /dev/null +++ b/firmware/bootloader/BOOT_FLOW.md @@ -0,0 +1,299 @@ +# Загрузчик TFT — принцип работы + +Документ описывает поведение загрузчика: как устроена память, как выбирается и обновляется образ +приложения, правила версий, даунгрейд и режим восстановления. + +--- + +## 1. Карта памяти + +Приложение хранится в QSPI NOR Flash в **двух слотах** — A и Б. Каждый слот содержит **полную, +самостоятельно валидную** копию приложения. Загрузчик занимает начало flash, за слотами идёт +отдельная область под ассеты (спрайты, звук), которая к процессу загрузки отношения не имеет. + +| Область | Начало | Размер | +| ------------------------ | ------------ | ------------- | +| Загрузчик | `0x60000000` | 256 КБ | +| Slot A | `0x60040000` | 2 МБ | +| Slot Б | `0x60240000` | 2 МБ | +| Файловая система ассетов | `0x60440000` | до конца чипа | + +```mermaid +flowchart TB + BL["Загрузчик — 256 КБ
0x60000000"] + SA["Slot A — 2 МБ
0x60040000"] + SB["Slot Б — 2 МБ
0x60240000"] + FS["Файловая система ассетов
0x60440000 … конец чипа"] + BL --- SA --- SB --- FS +``` + +**Приложение исполняется прямо из flash** (XIP) — из того слота, который выбрал +загрузчик, без копирования в ОЗУ. Из этого следует ключевое свойство: образ жёстко привязан к адресу +своего слота при сборке. Поэтому **релиз приложения — это два бинарника на одну версию**: один собран +под адрес Slot A, второй — под адрес Slot Б. Образ, физически положенный не в «свой» слот, пройдёт +проверку подписи, но не запустится. + +Два слота нужны для безопасного обновления: пока приложение работает из одного слота, новый образ +пишется в другой. Рабочая копия никогда не затирается — на диске всегда есть чем загрузиться, даже +если обновление прервётся на середине. + +--- + +## 2. Выбор образа при старте + +На каждой подаче питания загрузчик решает, из какого слота запускать приложение. + +**Слот считается кандидатом, только если он валиден целиком**: корректная сигнатура формата, целый +хэш содержимого и верная криптографическая подпись (ECDSA-P256). Битый или неподписанный слот +игнорируется. + +Правила выбора: +- Оба слота валидны → активным становится слот с **большей версией**. +- Валиден только один → он и активен. +- Ни одного валидного → активного слота нет, загрузчик переходит в ожидание microSD. + +```mermaid +flowchart TD + START([Подача питания]) --> CHECK["Проверить оба слота:
сигнатура + хэш + подпись"] + CHECK --> CMP{Сколько валидных?} + CMP -->|Оба| HIGHER["Активный = слот
с большей версией"] + CMP -->|Один| ONE["Активный = он"] + CMP -->|Ни одного| NONE["Ожидание microSD"] + HIGHER --> JUMP([Запуск приложения]) + ONE --> JUMP +``` + +**Защитная сеть от битого обновления.** Свежеустановленный образ считается «непроверенным», пока сам +не подтвердит своё здоровье в рантайме. Если непроверенный образ запустился, но так и не подтвердился +(например, завис на старте) — при следующей загрузке он трактуется как неудавшееся обновление и +**автоматически стирается**, а загрузчик откатывается на прежний слот. Приложение обязано подтвердить +себя один раз, доказав работоспособность. + +--- + +## 3. Обновление через microSD + +**Единственный полевой канал обновления — карта microSD.** Загрузчик ищет в корне карты файл +`TFT_APP.BIN` — подписанный образ приложения. Карта проверяется при старте и периодически (примерно +раз в 1.5 с), пока загрузчик находится в ожидании, — карту можно вставить уже после включения. + +Приёмка кандидата — **двухступенчатая**: +1. **Проверка заголовка** (сигнатура формата + версия) прямо с карты, до касания flash — этого + достаточно, чтобы решить «ставить или пропустить». +2. **Полная криптографическая проверка** — уже после записи в целевой слот. Если подпись битая, + только что записанный слот просто не будет выбран при загрузке, и загрузчик останется на прежнем + валидном образе. + +Запись идёт **потоком по частям**, каждая записанная часть немедленно вычитывается обратно и +сверяется — битая страница ловится сразу. + +**Целевой слот установки — всегда НЕ активный.** Работающий/загружаемый слот не перезаписывается +никогда. Если активного слота нет вообще (чистая плата) — по умолчанию Slot A. + +```mermaid +flowchart TD + CARD([microSD + TFT_APP.BIN]) --> HDR["Пик заголовка:
сигнатура + версия"] + HDR -->|Сигнатура битая| REJ1["Отклонить
(candidate invalid)"] + HDR -->|OK| DEC{Решение по версии} + DEC -->|Ставить| WRITE["Записать в НЕактивный слот:
стереть → поток + verify"] + DEC -->|Пропустить| SKIP["Пропустить
(update skipped)"] + WRITE --> CRYPTO{"Крипто-проверка
записанного слота"} + CRYPTO -->|OK| DONE([Установлено → загрузка]) + CRYPTO -->|Подпись битая| REJ2["Отклонить
(install rejected)"] +``` + +**Заводской сценарий** — частный случай этой же логики: чистая плата с одним загрузчиком, оба слота +пусты. Загрузчик ждёт SD, при появлении `TFT_APP.BIN` ставит его в Slot A и загружается. Никакого +отдельного механизма первичной заливки нет. + +--- + +## 4. Правила версий + +Версия образа — **major.minor.revision**. Номер сборки (build) в сравнении **не участвует**: два +образа, отличающиеся только номером сборки, считаются равными. + +Версия используется дважды: +- при **выборе** активного слота — побеждает бо́льшая версия; +- при **решении об установке** кандидата с SD. + +Решение по кандидату (без удержания кнопки): + +| Кандидат относительно активного | Действие | +| ------------------------------- | ---------------------------- | +| Строго новее | Установить в неактивный слот | +| Активного слота нет вообще | Установить в Slot A | +| Старше или равен | Пропустить | + +При штатном обновлении (кандидат новее) прежний активный слот **не стирается** — он естественным +образом проиграет сравнение версий при следующей загрузке, новый образ победит сам. + +--- + +## 5. Даунгрейд + +Установить образ **старее** уже стоящего можно только с помощью оператора: **удержать `BTN 1` в +момент подачи питания**. Кнопка считывается один раз на старте и действует всю сессию. + +Ключевой момент: при обычном даунгрейде записать старый образ в свободный слот **недостаточно** — +прежний (более новый) активный слот остался бы валиден и снова победил бы по версии, и даунгрейд +физически лёг бы на flash, но не загрузился. Поэтому при форсированном даунгрейде прежний активный +слот **стирается** — но только **после** того, как новый образ уже записан и подтверждён валидным. +На диске никогда не бывает нуля рабочих слотов даже на середине операции. + +| Условие | Действие | +| ---------------------------------- | ----------------------------------------------------- | +| Кандидат старше + `BTN 1` удержана | Установить в свободный слот, стереть прежний активный | +| Кандидат равен активному + `BTN 1` | Пропустить (переустановку той же версии не форсируем) | + +--- + +## 6. Режим восстановления (Recovery Mode) + +Назначение — не дать полевой плате превратиться в «кирпич», если уже установленный образ зависает в +рантайме, и дать оператору ручной аварийный вход. + +### Аппаратный сторож + +Плата защищена аппаратным watchdog с таймаутом **10 секунд**. Если управление зависает где-либо +(включая рантайм приложения), через 10 с происходит аппаратный сброс. Watchdog взводится один раз и +до перезагрузки по питанию не выключается — он «переживает» переход в приложение, поэтому приложение +обязано периодически его «кормить». Зависание → гарантированный сброс, а не вечный локап. + +### Счётчик и порог + +Число **подряд идущих** watchdog-сбросов хранится в регистре, который переживает тёплый/watchdog-сброс +и обнуляется только при настоящей подаче питания (POR). Счётчик обнуляется также при успешной +установке нового образа и при откате на фолбэк. Порог срабатывания — **3** сброса подряд. + +### Классификация отказов и их обработка + +| Класс | Ситуация | Что срабатывает | +| ----- | ----------------------------------------------- | ----------------------------------------------------- | +| **A** | Новый образ завис, ещё не подтвердив себя | Watchdog-сброс + автоматический откат на прежний слот | +| **B** | Уже подтверждённый образ завис в рантайме | Счётчик сбросов достиг порога → фолбэк на второй слот | +| **C** | Откатываться некуда (единственный/оба зависают) | Recovery Mode | +| **D** | Оператор хочет чистый старт вручную | Recovery Mode по `BTN 2` | + +Класс A — самый частый — закрыт полностью автоматически: откат непроверенного образа не требует ни +счётчика, ни вмешательства. Класс B ловит то, что откат не покрывает (образ-то подтверждён): после +порога зависший слот стирается, и загружается второй, если он валиден. + +### Решение при старте + +```mermaid +flowchart TD + S([Начало попытки]) --> BTN{BTN 2 удержана?} + BTN -->|Да| REC[Recovery Mode] + BTN -->|Нет| CNT{"Счётчик сбросов
≥ порога (3)?"} + CNT -->|Нет| NORM[Обычная загрузка] + CNT -->|Да| FB{Второй слот валиден?} + FB -->|Да| ERASE["Стереть зависший слот →
обнулить счётчик →
загрузить второй"] + FB -->|Нет| REC +``` + +`BTN 2` проверяется **первым** — приоритет ручного входа выше и счётчика, и обычной загрузки, и +кнопки даунгрейда. Если `BTN 2` удержана, обычный путь загрузки не выполняется вообще, даже при +наличии валидного образа. + +### Поведение в Recovery Mode + +- **Прыжок в приложение подавлен** — это само по себе разрывает цикл зависаний. +- **Отдельная LED-индикация**: оба светодиода мигают синхронно, 100 мс включено / 100 мс выключено — + явно отличается от heartbeat и рабочих паттернов приложения. +- **Статус по USB**: `recovery_mode`. +- **Ослабленный контроль версий**: принимается **любой** подписанный образ с SD — без сравнения + версий и без кнопки. Проверка подписи при этом сохраняется всегда. +- При найденном валидном образе — **оба слота стираются**, образ ставится в Slot A, происходит + автоматический прыжок. «Чистый борт» достигается ровно тогда, когда есть чем заменить. + +### Семантика «на одну сессию» + +Счётчик не сохраняется во flash. На подаче питания он обнуляется, поэтому зависший слот **пробуется +заново** — если зависание было случайным (транзиентным), плата получает новый шанс. Если зависание +детерминированное, оператор жмёт `BTN 2` и входит в recovery немедленно, не дожидаясь порога. + +### Честная граница + +Если образ стабильно работает, обнуляет счётчик (доказав здоровье), и лишь **потом** ловит редкий баг +(конкретный файл на SD, конкретное входное сообщение) — счётчик каждый раз обнуляется до зависания, +автопорог не накапливается, и цикл автоматически не ловится. Это принципиально: по таймеру не отличить +«здоров» от «здоров, но потом словил редкое». В таком случае плата видимо циклится (watchdog + +recovery-индикация это показывают), лечится SD-фиксом или `BTN 2`. Watchdog как минимум не даёт плате +зависнуть намертво. + +--- + +## 7. Индикация и обратная связь + +### Светодиоды + +| Состояние | Паттерн | +| ---------------------- | ------------------------------------------------ | +| Загрузчик жив, ждёт SD | Один LED: короткий импульс ~50 мс, пауза ~450 мс | +| Приложение работает | Задаётся приложением | +| Recovery Mode | Оба LED синхронно: 100 мс вкл / 100 мс выкл | + +### USB (виртуальный COM-порт) + +Загрузчик поднимает USB-порт до обращения к SD, поэтому статусы видны, даже если оператор подключился +заранее. Обмен — текстовые JSON-строки. + +Состояния (`status`): + +| Значение | Когда | +| ---------------- | ------------------------------------------------- | +| `waiting_for_sd` | Нет валидного слота, ждём карту | +| `installing` | Принято решение установить кандидата, идёт запись | +| `update_skipped` | Кандидат отклонён по версии | +| `recovery_mode` | Плата в режиме восстановления | + +Ошибки установки: `SD_CANDIDATE_INVALID` (битый заголовок), `SD_INSTALL_WRITE_FAILED` (сбой записи), +`SD_INSTALL_REJECTED` (записан, но подпись не прошла), `SD_DOWNGRADE_ERASE_FAILED`. + +Статус сторожа (по запросу `wdog`): взведён ли watchdog, таймаут, был ли последний сброс по watchdog, +текущее значение счётчика сбросов и порог. + +--- + +## 8. Полный жизненный цикл + +```mermaid +stateDiagram-v2 + state "Загрузка" as BOOT + state "Приложение" as APP + state "Ожидание SD" as WAIT + state "Установка" as INST + state "Recovery Mode" as REC + + [*] --> BOOT: питание + BOOT --> APP: валидный образ выбран + BOOT --> WAIT: нет валидного слота + BOOT --> REC: порог сбросов / BTN 2 + WAIT --> INST: TFT_APP.BIN найден + INST --> APP: установлено + прыжок + INST --> WAIT: отклонено по версии/подписи + APP --> BOOT: watchdog-сброс при зависании + REC --> INST: образ с SD (любая подписанная версия) + REC --> REC: ждём SD +``` + +--- + +## 9. Сводка сценариев + +| Ситуация | Поведение загрузчика | +| ------------------------------------------------------ | --------------------------------------------------------------------- | +| Оба слота валидны | Загрузка слота с бо́льшей версией | +| Валиден один слот | Загрузка его | +| Чистая плата, оба слота пусты | Ожидание SD (бессрочно), heartbeat | +| SD с образом новее активного | Установка в свободный слот → загрузка | +| SD с образом старше/равным, кнопка не нажата | Пропуск, загрузка прежнего | +| SD с образом старше, `BTN 1` удержана | Даунгрейд: установка + стирание прежнего активного → загрузка старого | +| SD с битым заголовком / битой подписью | Отклонение, прежний валидный слот не тронут | +| Новый образ завис, не подтвердившись (Класс A) | Watchdog-сброс → автоматический откат на прежний слот | +| Подтверждённый образ завис, есть второй слот (Класс B) | 3 сброса → стирание зависшего слота → загрузка второго | +| Зависает, откатываться некуда (Класс C) | Recovery Mode | +| Оператор удержал `BTN 2` при старте (Класс D) | Recovery Mode немедленно, обычная загрузка подавлена | +| В recovery вставлена SD с подписанным образом | Оба слота стёрты, образ в Slot A, автоматический прыжок | +| POR после recovery без `BTN 2` | Счётчик обнулён, зависший слот пробуется заново | diff --git a/firmware/bootloader/DEBUG_LOG_PHASE3_SD.md b/firmware/bootloader/DEBUG_LOG_PHASE3_SD.md deleted file mode 100644 index 3148854..0000000 --- a/firmware/bootloader/DEBUG_LOG_PHASE3_SD.md +++ /dev/null @@ -1,516 +0,0 @@ -# Фаза 3 — аппаратная верификация SD-пути: лог (РАЗРЕШЁН 2026-07-10) - -**✅ РАЗРЕШЕНО (2026-07-10, раунд 3).** Настоящий фикс детекта — чтение **USDHC PRES_STATE.CINST** -(`USDHC_GetPresentStatusFlags` + `kUSDHC_CardInsertedFlag`) в `bsp_sd_is_inserted()`, а не GPIO. Это -подход (a) из самого первого лога, который тогда откатили как «CINST=0 даже со вставленной картой» — -но откатили ошибочно: CINST=0 читался потому, что в тот момент пин ещё не был на `USDHC1_CD_B`. -Все 5 сценариев Фазы 3 пройдены на железе. Детали — «Обновление 2026-07-10 (раунд 3)» ниже. Разделы -раундов 1–2 сохранены как история расследования (их выводы частично опровергнуты раундом 3 — см. -явные пометки). - -
История расследования (раунды 1–2, выводы частично опровергнуты) - -**2026-07-10, раунд 1**: pinmux CD откачен на `GPIO2_IO28` + явный power-cycle карты. Аппаратно -проверено — симптом 1 (SD не вставлена) **не устранён**, симптом 3→4 похоже устранён. См. -«Обновление 2026-07-10» ниже. **⚠️ Вывод «GPIO2_IO28 — верный pinmux» опровергнут раундом 3: для -работающего PRSSTAT-чтения пин нужен на `USDHC1_CD_B`.** - -**2026-07-10, раунд 2**: по прямому запросу пользователя — полное выравнивание SD-стека на -`TFT_BOOTLOADER`, не только pinmux. Три дополнительных расхождения найдены и закрыты (CD pad-config -байт-в-байт, SD_PWR side-effect в детекте, explicit HostInit/PollingCardInsert/power-cycle). См. -«Обновление 2026-07-10 (раунд 2)» ниже. **⚠️ CD pad-config и explicit-init остались в дереве и не -вредят; SD_PWR side-effect в `bsp_sd_is_inserted()` заменён финальным PRSSTAT-чтением раунда 3.** - -
- -Этот файл — снимок состояния расследования на момент передачи в отдельный тред. Код Фазы 3 -(`update_policy`, `slot_version`, `sd_update`) спроектирован и host-протестирован отдельно (см. -[PLAN.md](PLAN.md), раздел «Фаза 3») и сам по себе не пересматривается. Проблема — в аппаратном -bring-up SD/USDHC на конкретной плате, всплывшая только на реальном железе. - -## Симптомы (в порядке обнаружения) - -1. **Чистая плата, оба слота пусты, SD карта отсутствует** → плата зависала внутри - `run_update → ... → micro_sd_disk_initialize → SD_PollingCardInsert`. -2. После правок ниже (текущее состояние pinmux, см. «Файлы» ниже) то же исходное условие (нет SD, - пустые слоты) стало вместо зависания давать **падение в `DefaultISR`**. Стек вызовов (VSCode - Cortex-Debug): - ``` - main → sd_update_check → run_update → bsp_sd_deinit → SD_HostDeinit → - SDMMCHOST_Deinit → SDMMCHOST_Reset → USDHC_SelectVoltage → → DefaultISR - ``` -3. **SD вставлена при старте** → доходит до `jump_to_image` и успешно стартует (под отладчиком). - После этого: выключить питание, вынуть SD, включить снова → та же прошивка (slot-заглушка) **не - стартует** (возврат к симптому 1/2). -4. **SD оставлена в слоте**, повторный power cycle сразу после успешного случая (3) (то есть SD - физически всё ещё вставлена) → **зависание в `OSA_SemaphoreWait`**, отдельный стек: - ``` - main → sd_update_check → run_update → f_mount → mount_volume → disk_initialize → - microsd_disk_initialize → sd_disk_initialize → SD_CardInit → sdcard_init → SD_ReadStatus → - SD_Transfer → SDMMCHOST_TransferFunction → SDMMC_OSAEventWait → OSA_SemaphoreWait - ``` - Это происходит **до** попытки прыжка — на самой первой инициализации карты в этой сессии - питания. Прерывание, которое должно отпустить семафор (сигнал завершения транзакции), судя по - всему, не приходит. - -Симптомы 2 и 4 — из одного и того же похода тестирования, оба неразобраны на момент передачи. - -## Что уже пробовали (в хронологическом порядке) - -| # | Правка | Результат | -|---|---|---| -| a | `bsp_sd_is_inserted()` / внутренний callback переведены на USDHC `PRES_STATE.CINST` вместо GPIO | Неверно: `CINST` читался как 0 даже при физически вставленной карте (вероятно, пин на тот момент не был замаплен на альт-функцию USDHC). Откачено. | -| b | `cleanup_before_jump()` в `boot_select.c` — отключение всех NVIC IRQ + `SysTick->CTRL=0` + `SCB->VTOR` перед прыжком | Пользователь подтвердил на железе: **«Никаких изменений. Всё также»**. Оставлено в коде (не вредит, независимо подтверждено паттерном `jump_to_application()` в референсе `TFT_BOOTLOADER`), но не является фиксом текущего бага. | -| c | Детект возвращён на GPIO-чтение; pinmux CD-пина переключён с `GPIO2_IO28` на `USDHC1_CD_B` (основание — референс `TFT7_RX_wOS`) | Дало симптом 2 (падение в `DefaultISR` вместо зависания) — то есть что-то изменилось, но проблема не ушла. Появилась **третья**, ещё более авторитетная референсная кодовая база (`TFT_BOOTLOADER`), которая прямо противоречит этому выбору pinmux (см. ниже) — **не проверено на железе**, стоит откатить в первую очередь. | - -## Три референсных проекта — что каждый показывает про SD/CD - -Все три, со слов пользователя, работали на **той же физической плате**. Расхождения между ними и -есть ядро проблемы. - -1. **`TFT8_RX_wOS`** (`source/hal/sd.c/h`) — `bsp_sd_is_inserted()` через - `USDHC_GetPresentStatusFlags()` + `kUSDHC_CardInsertedFlag` (аппаратный регистр USDHC, не GPIO). -2. **`TFT7_RX_wOS`** (полный board-код) — CD-пин замаплен на `USDHC1_CD_B` (`board/pin_mux.c`), но - **читается** через обычный `GPIO_PinRead()` (`BOARD_SDCardGetDetectStatus()` в `board/sdmmc_config.c`) — - то есть USDHC-альтфункция выбрана, а не используется для самого чтения детекта. -3. **`TFT_BOOTLOADER`** (собственный, предыдущий, явно описанный пользователем как «без нареканий - работал на этой плате») — CD-пин замаплен на **простой `GPIO2_IO28`** (никакой USDHC-альтфункции - вообще), читается тем же `GPIO_PinRead`. Дополнительно, в `is_sdcard_present()`/`init_sd()`, есть - явное управление питанием карты: `SD_PWR_PIN` (GPIO1/19) выключается/включается в зависимости от - результата детекта, и явный **power-cycle карты** — `SD_SetCardPower(&g_sd, false)` → - `SD_SetCardPower(&g_sd, true)` — перед тем, как что-либо ещё делается с картой. Собственный - `jump_to_application()` тоже независимо переставляет `SCB->VTOR` перед прыжком (подтверждает - правку (b) выше, но никак не связано с текущим багом). - -**Проблема**: (2) и (3) прямо противоречат друг другу по pinmux того же физического пина -(`GPIO_B1_12` / физический пин D13) — мультиплексирование эксклюзивно, физически не может быть верно -одновременно и то, и другое. (3) — самый доверенный источник (собственный, явно проверенный продакшн -на этой же плате, не просто «легаси-референс»), и именно его выбор (`GPIO2_IO28`, без USDHC-альтфункции) -**сейчас не применён** в дереве — стоит откатить в первую очередь. - -## Текущее состояние кода (на момент передачи) - -`bsp/sd/src/sd.c::bsp_sd_is_inserted()` — GPIO-чтение: -```c -return GPIO_PinRead(BOARD_SDMMC_SD_CD_GPIO_BASE, BOARD_SDMMC_SD_CD_GPIO_PIN) == - BOARD_SDMMC_SD_CD_INSERT_LEVEL; -``` - -`bsp/generated/sdmmc_config.c::BOARD_SD_Config()` — pinmux **на `USDHC1_CD_B`** (см. выше — под -вопросом): -```c -IOMUXC_SetPinMux(IOMUXC_GPIO_B1_12_USDHC1_CD_B, 0U); -``` -внутренний детект-callback — `sd_card_detect_gpio()` (GPIO, не USDHC-регистр) — сам механизм чтения -не менялся, менялся только pinmux. - -`bsp/sd/src/sd.c::bsp_sd_init()`/`bsp_sd_deinit()` — **не делают explicit power-cycle карты** -(`SD_SetCardPower`) — в отличие от `TFT_BOOTLOADER::init_sd()`. `bsp_sd_init()` выставляет -`g_s_initialized = true` сразу после `BOARD_SD_Config()` (только конфигурация дескриптора хоста) — -**до** того, как реальный `SD_HostInit()` вообще вызывается (тот вызывается неявно позже, изнутри -`f_mount() → sd_disk_initialize() → SD_Init()`). Отсюда гипотеза по симптому 2 ниже. - -`firmware/bootloader/src/sd_update.c::run_update()` — если `f_mount()` возвращает ошибку (не -зависает, а именно быстро проваливается), путь `(void) bsp_sd_deinit(); return;` (строка ~160) -вызывается напрямую, не через `cleanup:` — это ровно та точка, где произошло падение в симптоме 2. - -## Рабочие гипотезы (не проверены на железе) - -1. **Симптом 2 (DefaultISR, SD физически отсутствует)**: при текущем pinmux (`USDHC1_CD_B`) - `GPIO_PinRead()` того же физического пина может отдавать некорректное/неопределённое значение - (частый эффект на i.MX-подобных SoC — когда пин отдан альтернативной функции, вход GPIO-регистра - не гарантированно отражает реальный внешний уровень). Это дало бы **ложное срабатывание - "карта вставлена"** даже без карты → `sd_update_check()` пропускает наш собственный gate и - заходит в `run_update()` → `f_mount()` внутри тоже видит ложный "card present" (тот же callback) - → пытается реальный протокол инициализации без карты → команды таймаутятся (не висят вечно, в - отличие от `SD_PollingCardInsert`) → `f_mount()` возвращает ошибку → `bsp_sd_deinit()` вызывается, - но `SD_HostInit()` либо не был вызван, либо card/voltage-состояние осталось в промежуточном виде - → `SDMMCHOST_Reset()/USDHC_SelectVoltage()` работает с невалидным состоянием → падение. - **Проверка**: откатить pinmux на `GPIO2_IO28` (референс `TFT_BOOTLOADER`) и повторить сценарий - "нет SD, пустые слоты" — если ложное срабатывание уйдёт, `run_update()` вообще не должен - вызываться, и падение исчезнет как следствие. -2. **Симптом 4 (OSA_SemaphoreWait, SD физически присутствует)** — не про card-detect вообще, а про - реальный обмен данными (`SD_ReadStatus`/`SD_Transfer`) — прерывание завершения транзакции не - приходит. Кандидат на причину: отсутствие power-cycle карты перед стартом обмена (в отличие от - `TFT_BOOTLOADER::init_sd()`) — карта могла остаться в состоянии, унаследованном от **предыдущей** - успешной сессии (та же карта, тот же физический power rail, если он не сбрасывается при - `bsp_sd_deinit()`/между сессиями), и не отвечать на новый протокол инициализации так, как ожидает - SDK-стек. **Не проверялось**, требует добавления explicit `SD_SetCardPower(false)` → - задержка → `SD_SetCardPower(true)` в `bsp_sd_init()` и повторного теста именно сценария «второй - power cycle подряд с той же картой». -3. Не проверено: действительно ли `bsp/qspi_flash`, `firmware_test`'ный стек SD (уже работающий в - продакшене через `test_usd.c`) отличается от bootloader-сценария именно временем между - power-on/детектом и первым обращением к карте — `firmware_test` ждёт explicit подтверждения - человека перед `usd_init()`, что даёт карте много времени "устояться" электрически; bootloader - делает это заметно быстрее после включения питания. Если гипотеза 1 не полностью объясняет - симптом 2, стоит проверить добавление небольшой выдержки после детекта перед `f_mount()`. - -## Открытые вопросы для нового треда - -> **РАЗРЕШЕНО 2026-07-10 (раунд 3).** Основной баг детекта закрыт PRSSTAT-чтением (см. секцию -> «Обновление 2026-07-10 (раунд 3)» выше). Пункты 1–2 ниже сохранены как история; пункт 3 — -> опровергнут; пункты 4–5 (devcontainer, watchdog) остаются актуальными как отдельные задачи. -> Рекомендации (6) консолидация детекта на единый PRSSTAT и (7) ранний сэмпл кнопки даунгрейда — -> **✅ реализованы в раунде 4** (см. секции «✅ Латентная хрупкость re-scan — УСТРАНЕНА» и «Тайминг -> удержания кнопки» выше); аппаратно ещё не проверены. Пункт (5) аппаратный watchdog — -> **✅ реализован** (`bsp/wdog`, см. PLAN.md «Реализовано: аппаратный watchdog» и чек-лист в -> test_stub/HARDWARE_VERIFICATION_PHASE3.md §9); аппаратно ещё не проверен. Открытых пунктов нет. - -1. ✅ **Применено 2026-07-10 (раунд 1).** Откатить pinmux CD на `GPIO2_IO28` (без `USDHC1_CD_B`) — - самый доверенный референс (`TFT_BOOTLOADER`) использует именно так. **Аппаратно проверено — - недостаточно само по себе**: симптом 1 (SD не вставлена) воспроизводится и с верным pinmux. См. - «Обновление 2026-07-10 (раунд 2)» — потребовалось довыровнять ещё и pad-config CD-пина. -2. ✅ **Применено 2026-07-10 (раунд 1).** Добавить explicit power-cycle SD-карты в `bsp_sd_init()` - (`SD_SetCardPower(false)` → задержка → `SD_SetCardPower(true)`), по образцу - `TFT_BOOTLOADER::init_sd()`. **Аппаратно похоже подтверждено** — симптом 3→4 (повторный power - cycle с картой в слоте) больше не зависает (~5 c вместо hang), хотя раунд 1 не изолировал, какой - именно из двух фиксов это дал. -3. Развести флаг `g_s_initialized` в `bsp_sd_init()`/`bsp_sd_deinit()` на два состояния — "хост - сконфигурирован" (`BOARD_SD_Config` вызван) vs "хост реально поднят" (`SD_HostInit` реально - отработал) — чтобы `bsp_sd_deinit()` не звал `SD_HostDeinit()`/`USDHC_Reset` на состоянии, - которое никогда полноценно не поднималось. **Не реализовано** — см. «Обновление 2026-07-10»: - source-level разбор показал, что в наблюдавшемся симптоме 2 это не изменило бы поведение - (`SD_HostInit()` к моменту падения уже отработал успешно). Понижено до фоновой гигиены. -4. Прогонять сборку/прошивку через devcontainer (стандартный путь проекта), не только через - локальный host-toolchain — держать в уме при следующей проверке (сама эта сессия строила и - прошивала с хоста напрямую). -5. После того как оба симптома закрыты и подтверждены на железе — реализовать аппаратный watchdog - (см. [PLAN.md](PLAN.md), раздел «Запланировано: аппаратный watchdog») **как отдельный шаг после**, - не вместо, диагностики первопричины — иначе watchdog будет просто маскировать/сбрасывать симптом - на каждом цикле. - -## Обновление 2026-07-10 — четвёртый референс, source-level трассировка, два фикса применены - -Пользователь предоставил **полный код** `TFT_BOOTLOADER` (ранее в этом логе упоминался только по -описанию) — `board/pin_mux.c`, `board/sdmmc_config.c`, `source/sd.c`, `source/gpio_setup.c`, -`source/main.c` и др. Это подтверждает и уточняет раздел «Три референсных проекта» выше, а не -меняет его выводы. - -### Пинмукс CD — подтверждено, откачено - -`TFT_BOOTLOADER/board/pin_mux.c`: -```c -IOMUXC_SetPinMux(IOMUXC_GPIO_B1_12_GPIO2_IO28, 0U);//SD check pin -``` -Плоский `GPIO2_IO28`, без какой-либо альт-функции USDHC. - -**Четвёртый источник**, найденный при подготовке фикса: NXP-овские -`sdk/boards/evkbimxrt1050/sdmmc_examples/*/pin_mux.c` (`sdcard_polling`, `sdcard_interrupt`, -`sdcard_freertos`, `sdcard_fatfs`, `mmccard_freertos` — уже вендорены в этом дереве) — **все** -используют `IOMUXC_GPIO_B1_12_GPIO2_IO28` для этого же физического пина (D13) на том же чипе -(MIMXRT1052/EVKB-семейство). Официальный NXP-референс для этой платы и этого пина ни разу не -использует `USDHC1_CD_B`. - -Итого — четыре независимых указания в одну сторону (`TFT_BOOTLOADER`; `TFT7_RX_wOS`, который хоть и -маплит на `USDHC1_CD_B`, но ЧИТАЕТ через GPIO, то есть де-факто не использует USDHC-функцию пина; -NXP `sdmmc_examples`; и само нестабильное поведение на железе под `USDHC1_CD_B`, из-за которого -этот лог вообще начался). - -**Применено**: `bsp/generated/sdmmc_config.c::BOARD_SD_Config()` — pinmux возвращён на -`IOMUXC_GPIO_B1_12_GPIO2_IO28`. Комментарии в этом файле и в `bsp/sd/src/sd.c::bsp_sd_is_inserted()` -(который раньше аргументировал ЗА `USDHC1_CD_B` задним числом, по результатам ещё не проверенного -на железе эксперимента (c)) переписаны под текущее состояние. Доктстринг `bsp_sd_is_inserted()` в -`bsp/sd/include/bsp/sd.h` тоже был стал устаревшим ещё раньше — утверждал чтение через регистр -USDHC PRSSTAT, оставшееся от давно откаченного эксперимента (a) — тоже исправлен. - -**Не тронуто намеренно** — `bsp/generated/board/pin_mux.c` (генерируется MCUXpresso Config Tools, -в этой сессии не редактировался) по-прежнему содержит -`IOMUXC_SetPinMux(IOMUXC_GPIO_B1_12_USDHC1_CD_B, 0U)` внутри `BOARD_InitPins()`, а сам -`TFT_Board.mex` (строка ~134) содержит `` для -этого пина — маршрутизация на уровне самого Config Tools проекта всё ещё «неправильная». -Функционально это не баг сейчас: `board_hw_init() → BOARD_InitPins()` отрабатывает раньше, чем -`bsp_sd_init() → BOARD_SD_Config()`, и именно вызов из `sdmmc_config.c` (уже исправленный) — самый -последний перед любой операцией с картой, то есть он и определяет реальное состояние железа. Но -это мина для будущего: перегенерация пинов через Config Tools перезапишет `pin_mux.c` обратно на -`USDHC1_CD_B`, а конфликт останется незаметным (`sdmmc_config.c` продолжит его перекрывать) — пока -кто-то не тронет порядок инициализации или не сочтёт повторный вызов в `sdmmc_config.c` -дублирующим и не уберёт его. **Рекомендация на будущее**: при следующем открытии `TFT_Board.mex` в -Config Tools — перемаршрутизировать D13/`GPIO_B1_12` на `GPIO2.gpio_io[28]` вместо -`USDHC1.usdhc_cd_b`. Не блокирует текущую аппаратную проверку. - -### Симптом 2 — механизм подтверждён трассировкой по исходникам SDK, не только гипотезой - -Прочитан `sdk/middleware/sdmmc/sd/fsl_sd.c`: - -- `SD_PollingCardInsert()` (используется для типа детекта `kSD_DetectCardByGpioCD`) — буквально - `do { ... } while (true)`, **без единого условия выхода по времени**. Если `cardDetected()` - (== `sd_card_detect_gpio()`, тот же нестабильный GPIO-read под `USDHC1_CD_B`) ни разу не даёт - связку значений, удовлетворяющую циклу, — зависание гарантированное и постоянное, не «иногда». - Ровно то же самое SDK-поведение отдельно задокументировано в `PLAN.md` под «Запланировано: - аппаратный watchdog» — тот же вывод, независимо подтверждён прямым чтением функции. -- `SD_Init()` **всегда** вызывает `SD_HostInit()` первым (`if (!card->isHostReady) { SD_HostInit(...); }`, - а `isHostReady` после `memset(&g_sd, 0, ...)` в `bsp_sd_init()` гарантированно `false`) — то есть - к моменту, когда `SD_Init()` доходит до `SD_PollingCardInsert()`/`SD_CardInit()` и потенциально - проваливается, хост уже реально поднят. Значит: наблюдаемый стек симптома 2 - (`SD_HostDeinit → SDMMCHOST_Deinit → SDMMCHOST_Reset → USDHC_SelectVoltage → DefaultISR`) - происходит **после** успешного `SD_HostInit()`, а не из-за его отсутствия — гипотеза 1 в - исходном логе («либо не был вызван `SD_HostInit()`...») технически неточна в части причины, но - итоговый вывод (откатить pinmux) от этого не меняется: без ложного детекта `run_update()` вообще - не входит в `f_mount()`, и весь этот путь (включая `SD_CardInit()` → протокольные команды к - несуществующей карте → `USDHC` остаётся в промежуточном по voltage-switch состоянии → падение при - последующем `SDMMCHOST_Reset()`) просто не выполняется. -- `SD_CardInit()` вызывает только `SD_SetCardPower(card, true)` — **не** делает `false→true` сама. - Явного power-cycle в штатном пути SDK нет вообще, если его не сделать самостоятельно (важно для - следующего раздела). - -### Симптом 4 — явный power-cycle добавлен (открытый вопрос 2, теперь применён) - -`bsp/sd/src/sd.c::bsp_sd_init()` — добавлен `SD_SetCardPower(&g_sd, false)` → -`SD_SetCardPower(&g_sd, true)` сразу после `ensure_host_configured()` (нужен `usrParam.pwr`, -который выставляет `BOARD_SD_Config()`), по образцу `TFT_BOOTLOADER::init_sd()`. Задержки — -встроенные в сам `SD_SetCardPower()` (`fsl_sd.c`): `SD_POWER_OFF_DELAY=100` мс, -`SD_POWER_ON_DELAY=400` мс, итого ~500 мс добавляется к каждой попытке `bsp_sd_init()` (не к -каждому проходу главного цикла — только когда карта физически детектится и `run_update()` реально -стартует). - -Механизм здесь остаётся гипотезой (в отличие от симптома 2 — прямого прочтения бага в исходниках -для этого случая нет): `SD_CardInit()` при обычном включении питания отработал бы одинаково что с -предварительным power-cycle, что без. Разница имела бы значение, только если карта физически не -успевает разрядиться между быстрыми power cycle платы (правдоподобно электрически — SD-рейл может -иметь свою постоянную разряда, отличную от MCU — но не подтверждено осциллографом). Аппаратный тест -покажет. - -### Открытый вопрос 3 (разделение флага `g_s_initialized`) — понижен в приоритете - -Source-level разбор показал: к моменту падения в симптоме 2 `SD_HostInit()` уже отработал успешно -(`isHostReady == true`) — разделение флага на «хост сконфигурирован» vs «хост реально поднят» **не -изменило бы поведение в этом конкретном наблюдавшемся случае**: `bsp_sd_deinit()` вызвал бы -`SD_HostDeinit()` в обоих вариантах флага одинаково. Ценность этого пункта — на пока не -пронаблюдавшемся крайнем случае (`SD_HostInit()` возвращает ошибку сам по себе, например -`SDMMCHOST_Init()` фейлится внутри) — не реализовано в этой сессии, чтобы не размывать набор -изменений перед проверкой на железе. Остаётся в очереди как фоновая гигиена, не как фикс под -конкретный симптом. - -### Сборка - -`just build::build-bootloader-debug` — зелёно (`m_text` 87304 Б / 247 КБ, 34.52% — чуть меньше, чем -87928 Б, отмеченные в PLAN.md до этой сессии). `just build::test-host` — 15/15. Оба прогнаны в -devcontainer (`docker exec -w /workspace tft-devcontainer ...`), не с хоста напрямую — см. открытый -вопрос 4 выше. - -### Чек-лист для аппаратной проверки (следующий шаг — на стороне пользователя) - -Оба фикса (pinmux + power-cycle) применены вместе — они независимы по механизму и по разным -симптомам, так что сигнатуры (зависание в `SD_PollingCardInsert`/`OSA_SemaphoreWait` vs падение в -`DefaultISR`) при повторном проявлении однозначно укажут, какой из двух фиксов не сработал (или -сработал не полностью), даже тестируя одним прогоном: - -1. **Симптом 1/2** (чистая плата, оба слота пусты, SD карта отсутствует) — ожидание: ни зависания, - ни падения; `sd_update_check()` должен быстро выйти (`bsp_sd_is_inserted()` теперь стабильно - `false`), дойти до `boot_select_and_jump()` → нет валидного образа → штатный ping/pong-цикл. -2. **Симптом 3** (SD вставлена при старте) — должен по-прежнему успешно доходить до - `jump_to_image`, как и раньше. -3. **Симптом 3→1** (после (2): выключить питание, вынуть SD, включить снова) — то же самое, что - симптом 1, теперь с картой, которая только что использовалась — ожидание: то же корректное - поведение, что в (1). -4. **Симптом 4 буквально** (SD оставлена в слоте, повторный power cycle сразу после успешного (2), - карта физически всё ещё вставлена) — тест именно на power-cycle-фикс; ожидание: не зависает в - `OSA_SemaphoreWait`, обновление/загрузка проходит штатно. - -Если что-то из этого всё ещё не проходит — сначала зафиксировать точную сигнатуру (стек в -отладчике, как и раньше в этом логе) до следующей правки; при необходимости фикс power-cycle и -фикс pinmux можно тестировать раздельно, закомментировав добавленный блок `SD_SetCardPower` в -`bsp_sd_init()` (единственное новое, легко изолируемое от pinmux-фикса изменение). - -## Обновление 2026-07-10 (раунд 2) — пинмукс-фикса недостаточно, полное выравнивание на TFT_BOOTLOADER - -**Аппаратный результат раунда 1** (пинмукс `GPIO2_IO28` + explicit power-cycle в `bsp_sd_init()`, -без изменения pad-config и без power-toggle side-effect): - -- **Симптом 1 (чистая плата, SD не вставлена) — не устранён.** Всё ещё висим внутри того же - `SD_PollingCardInsert()` `do{}while(true)`. Это означает: `bsp_sd_is_inserted()` всё ещё - возвращает ложный `true` без карты, ДАЖЕ с верным pinmux — вывод раунда 1 («нестабильное чтение - из-за неверной альт-функции») опровергнут этим результатом. Раз чтение теперь стабильно неверное - (не «шумит», а последовательно врёт), причина — не альт-функция сама по себе, а что-то ещё в - электрической конфигурации пина (см. ниже) либо в физической полярности детект-переключателя на - этой плате. -- **Симптом 3→4 (SD оставлена в слоте, повторный power cycle) — похоже, устранён.** Раньше — - зависание в `OSA_SemaphoreWait`. Теперь: загрузка (переиспользование уже установленного stub-а) - занимает ~5 секунд вместо зависания. Не финальное подтверждение (не изолировано, какой именно - из фиксов раунда 1 это дал — pinmux или power-cycle), но однозначно прогресс. - -**Важное уточнение механизма симптома 1**, важное для дальнейшей диагностики: `SD_PollingCardInsert(card, kSD_Inserted)` -в SDK — это `do{}while(true)` без ЕДИНОГО условия выхода, если карта не появляется. Это не -«нестабильность», это **ожидаемое поведение при вызове с картой, которой реально нет** — функция -единственная блокируется до появления карты, всегда, у обоих бутлоадеров (текущий и -`TFT_BOOTLOADER::init_sd()` вызывают её одинаково). Значит, единственное, что может предотвратить -зависание — гейт (`bsp_sd_is_inserted()`) ДОЛЖЕН быть на 100% верным при пустом слоте. Раз он -по-прежнему неверный — дело в самом детекте, не в том, как его результат используется. - -### Инструкция пользователя: полное выравнивание на TFT_BOOTLOADER, не только pinmux - -Пройден весь `TFT_BOOTLOADER` построчно на предмет расхождений с текущим SD-стеком, помимо -pinmux. Найдено и закрыто три конкретных расхождения: - -**1. Pad-config CD-пина — было НЕ то, что в референсе.** Раунд 1 откатил только pinmux -(`IOMUXC_SetPinMux`), но НЕ pad-config (`IOMUXC_SetPinConfig`) — тот со времён исходной правки -оставался «активная 47к подтяжка вверх + hysteresis» (`PKE|PUE|HYS|PUS(1)`). Побитовый разбор -`TFT_BOOTLOADER::BOARD_SD_Pin_Config()` (`0x10B0U`, с помощью распечатанных масок из -`PERI_IOMUXC.h`) даёt: `PKE=1, PUE=0 (KEEPER, не активная подтяжка — PUS в этом режиме -игнорируется), HYS=0 (выключен), SPEED=2, DSE=6`. Это **не** совпадает с тем, что было в дереве — -принципиальная разница (keeper vs. активный pull-up, hysteresis выкл vs. вкл). Применено побитово -точно как в референсе (`bsp/generated/sdmmc_config.c::sd_pin_config()`). **Это — наиболее -вероятный настоящий фикс** для устойчивого (не шумового) ложного детекта: если реальная физическая -конфигурация детект-цепи на этой плате рассчитана на keeper (или просто не рассчитана на активную -подтяжку в эту сторону), активная 47к подтяжка вверх могла систематически перетягивать/конфликтовать -с реальным сигналом. -- **Не проверено на железе.** - -**2. `bsp_sd_is_inserted()` не имела side-effect на SD_PWR.** `TFT_BOOTLOADER::is_sdcard_present()` -на каждый вызов не только читает CD, но и включает/выключает `SD_PWR` по результату — до этого -раунда `bsp_sd_is_inserted()` была чистым чтением без побочных эффектов. Добавлено: `sd_power_control()` -(`sdmmc_config.c`) сделана публичной под именем `BOARD_SDCardPowerControl()` (то же имя, что и в -стоковом `TFT_BOOTLOADER/board/sdmmc_config.c` — там это отдельная, не связанная с -`is_sdcard_present()` функция, но семантически та же роль) и вызывается из -`bsp_sd_is_inserted()` на каждый скан. -- **Механизм не подтверждён** — детект (механический контакт) физически не должен зависеть от - питания карты, но раз референс это делает, а референс проверенно работал на этой плате, — реплицировано - добросовестно, не только «на всякий случай», а по прямому запросу пользователя выровнять именно - настройку SD-стека на референс. - -**3. `bsp_sd_init()` теперь явно вызывает `SD_HostInit()` → `SD_PollingCardInsert()` → power-cycle**, -байт-в-байт порядок `TFT_BOOTLOADER::init_sd()`, вместо implicit-инициализации внутри -`f_mount() → SD_Init()`. Само по себе это НЕ предотвращает зависание при реальном отсутствии карты -(см. выше — блокировка без тайм-аута заложена в SDK и одинакова что в implicit, что в explicit -пути) — ценность изменения в точном соответствии референсу и в том, что `f_mount()` внутри теперь -дополнительно делает `SD_HostDoReset()` + повторный `SD_PollingCardInsert()`/`SD_CardInit()` уже -поверх реально поднятого хоста (`isHostReady == true`), тем же паттерном двойного вызова -`SD_PollingCardInsert`, что и в референсе. - -### Сборка - -Оба фикса раунда 2 собраны и проверены в devcontainer: `just build::build-bootloader-debug` — -зелёно (`m_text` 87352 Б / 34.54%), `just build::test-host` — 15/15. - -### Если симптом 1 всё ещё не уйдёт после раунда 2 - -Тогда pad-config — тоже не объяснение, и дальше гадать конфигурацией регистров вслепую -контрпродуктивно (уже два раунда фиксов на одних догадках без прямого замера). Следующий шаг в -этом случае — не третья попытка pin-config, а **прямой замер** физического пина `GPIO_B1_12` / -D13 (мультиметром или логическим анализатором через MCU-Link): уровень при вставленной карте vs. -без карты, и полярность самого механического CD-переключателя в слоте этой платы (нормально-открыт -или нормально-закрыт — `BOARD_SDMMC_SD_CD_INSERT_LEVEL=0U` предполагает нормально-открытый, -замыкающий на GND при вставке; если физически наоборот — весь код читает верно регистр, но с -неверным заранее предположением о полярности, и фикс — просто `BOARD_SDMMC_SD_CD_INSERT_LEVEL=1U`, -а не что-либо из pinmux/pad-config). - -## Обновление 2026-07-10 (раунд 3) — РАЗРЕШЕНО: детект через USDHC PRES_STATE.CINST - -Пользователь заменил `bsp_sd_is_inserted()` на чтение регистра USDHC вместо GPIO: - -```c -bool bsp_sd_is_inserted(void) -{ - CLOCK_EnableClock(kCLOCK_Usdhc1); - uint32_t ps = USDHC_GetPresentStatusFlags(BOARD_SDMMC_SD_HOST_BASEADDR); - return (ps & kUSDHC_CardInsertedFlag) != 0U; -} -``` - -**Все 5 сценариев Фазы 3 пройдены на железе.** - -### Почему это сработало (и почему раунды 1–2 искали не там) - -Это ровно подход **(a)** из таблицы «Что уже пробовали» в самом верху лога — тогда откачен как -«CINST читался как 0 даже при вставленной карте». Причина того провала теперь понятна: `PRSSTAT.CINST` -отражает реальное состояние CD **только когда физический пин D13 замаплен на `USDHC1_CD_B`** (сигнал -идёт в периферию USDHC, а не в GPIO). В момент пробы (a) пин был на `GPIO2_IO28` — поэтому CINST и -читался нулём. То есть (a) и (c) по отдельности не работают, а **вместе** — работают: `USDHC1_CD_B` -маршрутизация (c) + чтение через PRSSTAT (a). - -Ключевой поворот: **мой откат pinmux на `GPIO2_IO28` в раунде 1 был направлен не в ту сторону.** -Исходная правка (c) (`USDHC1_CD_B`) была верной — ей не хватало парного чтения через регистр, а не -через `GPIO_PinRead`. Вся линия рассуждений раундов 1–2 («три-четыре референса за GPIO2_IO28») -верна лишь для того механизма чтения, который эти референсы используют (GPIO), но не была -единственно возможной: PRSSTAT — четвёртый, не рассматривавшийся всерьёз в раундах 1–2 путь, -который и оказался рабочим на этой плате. - -### Почему это чинит симптом 1 конкретно - -`SD_PollingCardInsert(kSD_Inserted)` в SDK — `do{}while(true)` без тайм-аута (разобрано в раунде 1). -Единственная защита — гейт `bsp_sd_is_inserted()` обязан быть верным на пустом слоте. На старте с -пустым слотом `BOARD_SD_Config()` ещё не вызывался (его зовёт `bsp_sd_init()` уже внутри -`run_update()`, за гейтом) → пин остаётся на `USDHC1_CD_B`, как его поставил `BOARD_InitPins()` в -`board_hw_init()` → PRSSTAT.CINST корректно читает «карты нет» → гейт возвращает false → блокирующий -`SD_PollingCardInsert()` вообще не достигается. Симптом 1 закрыт по построению. - -### Текущее итоговое состояние кода (что осталось от раундов 1–3) - -- `bsp_sd_is_inserted()` — PRSSTAT-чтение (раунд 3, рабочее). **Это единственное, что реально - закрыло симптом 1.** -- `bsp_sd_init()` — explicit `SD_HostInit()` → `SD_PollingCardInsert()` → power-cycle (раунд 2). - Не вредит; power-cycle правдоподобно закрыл симптом 3→4 (не изолировано отдельно). -- CD pad-config `0x10B0` на `GPIO2_IO28` (раунд 2) — остаётся; применяется к пину в моменты, когда - он на `GPIO2_IO28` (внутренний GPIO-детект SDK, см. ниже). Не вредит. -- `BOARD_SD_Config()` маплит пин на `GPIO2_IO28` (раунд 1) — **остаётся**, потому что внутренний - детект SDK (`sd_card_detect_gpio`, `kSD_DetectCardByGpioCD`) внутри `f_mount()→SD_Init()` читает - GPIO, и к тому моменту гейт уже подтвердил карту. Комментарии в `sdmmc_config.c`/`sd.c`/`sd.h` - переписаны под это фактическое состояние (два механизма детекта на разной маршрутизации одного - пина в разные моменты). -- `sd_power_control` возвращён в `static` (публичное имя `BOARD_SDCardPowerControl` из раунда 2 - больше не нужно — side-effect, ради которого оно вводилось, заменён PRSSTAT-чтением). - -### ✅ Латентная хрупкость re-scan — УСТРАНЕНА (раунд 4, item 1) - -**Была** (до раунда 4): рабочая конфигурация опиралась на то, что пин на `USDHC1_CD_B` в момент -гейта, но `BOARD_SD_Config()` перемапливал его на `GPIO2_IO28` при первом же `bsp_sd_init()` и -обратно НЕ возвращал. Если `run_update()` был вызван (карта была на гейте), но завершался без прыжка -(например, на карте нет `TFT_APP.BIN`), то на СЛЕДУЮЩЕМ периодическом скане `bsp_sd_is_inserted()` -читал PRSSTAT с пином уже на `GPIO2_IO28` → ложный ноль → карта «исчезала» до перезагрузки. - -**Реализовано (раунд 4, вариант B — минимальный):** детект сведён к ЕДИНОМУ механизму, PRSSTAT -везде. Callback `sd_card_detect_gpio` → `sd_card_detect_prsstat` (читает `USDHC_GetPresentStatusFlags -& kUSDHC_CardInsertedFlag`); remux на `GPIO2_IO28` из `BOARD_SD_Config()` убран — пин остаётся на -`USDHC1_CD_B` постоянно (там же явно переустанавливается); мёртвый pad-конфиг GPIO2_IO28 из -`sd_pin_config()` удалён. `s_cd.type` оставлен `kSD_DetectCardByGpioCD` — это не «GPIO» в смысле -чтения, а «детект через callback» (polling); альтернатива `kSD_DetectCardByHostCD` дополнительно -взвела бы USDHC card-detect ПРЕРЫВАНИЯ (тот же PRSSTAT-бит, но лишний источник IRQ перед прыжком — -см. `SDMMCHOST_CardDetectInit` в `sdk/.../usdhc/non_blocking/fsl_sdmmc_host.c`), поэтому выбран -вариант B. Теперь и гейт, и внутренний `SD_PollingCardInsert` читают один регистр на одной -маршрутизации пина. - -**Требует аппаратной ре-проверки:** все 5 сценариев, плюс явно кейс, который раньше был сломан — -«карта вставлена, но без `TFT_APP.BIN`; ждём; кладём файл на карту БЕЗ извлечения/перезагрузки — -пере-скан должен его подхватить». - -### Тайминг удержания кнопки даунгрейда (симптом/вопрос пользователя) - -Наблюдение: удержание `BSP_BUTTON_1` 2 c при подаче питания → даунгрейд НЕ происходит; 15 c → -происходит (визуально — сразу после отпускания). Разобрано по коду: - -- `bsp_button_read()` (`bsp/button/src/button.c`) — **мгновенное сырое чтение GPIO**, без debounce, - без накопления, без latch. `bsp_button_poll()`/события в загрузчике **не используются вообще** - (единственное обращение к кнопке — `bsp_button_read(BSP_BUTTON_1)` в `sd_update.c:180`). -- Значит важен ровно один момент: физически ли нажата кнопка в ту единственную点, когда - `run_update()` её сэмплит. А сэмпл этот — **после** `bsp_sd_init()` (power-cycle-задержки ~0.5 c) + - `f_mount()` (полная инициализация карты: CMD0/CMD8/ACMD41-поллинг и т.д.) + `f_open()` + чтения - заголовка + **двух полных крипто-валидаций слотов** (`peek_slot(0)`/`peek_slot(1)` → - `slot_version_get` → `bootutil_img_validate`: SHA-256 + ECDSA-P256 по каждому слоту). Это секунды, - и всё это — ДО главного цикла, без LED-фидбэка (heartbeat мигает только в главном цикле, куда - управление ещё не дошло). -- Отсюда: кнопку надо держать НЕПРЕРЫВНО от подачи питания до момента сэмпла. Отпустил в 2 c → - сэмпл (несколькими секундами позже) поймал уже отпущенную → `update_policy_decide` видит - `button_held=false` → SKIP → штатная загрузка более нового слота (не даунгрейд). Держал 15 c → - на момент сэмпла ещё нажата → даунгрейд. «Сразу после отпускания» — это не триггер по отпусканию - (кода на отпускание нет); это совпадение: решение уже защёлкнулось на сэмпле (пока держал), а - видимый результат (стирание+копирование+прыжок в старший образ) проявляется как раз к ~15 c. - -**✅ Реализовано (раунд 4, item 2):** кнопка теперь сэмплится РАНО и защёлкивается. `main.c` читает -`bsp_button_read(BSP_BUTTON_1)` один раз сразу после `bsp_button_init()` (до `bsp_qspi_init()`/ -SD-логики) в `const bool downgrade_held` и передаёт это значение в `sd_update_check(downgrade_held)` -на обеих точках вызова (первая попытка + пере-сканы цикла). `run_update()` больше не читает кнопку -сам (`bsp/button.h` из `sd_update.c` убран) — `update_policy_decide()` получает уже защёлкнутое -значение. Жест «удержание при включении» теперь ловится в детерминированный ранний момент, а не -через секунды слепого окна. - -**Новая инструкция технологу** (обновлено в HARDWARE_VERIFICATION_PHASE3.md): достаточно держать -`BSP_BUTTON_1` нажатой **в момент подачи питания** (до/во время включения) — состояние считывается в -первые миллисекунды после `board_hw_init()`, ещё до обращения к SD. Долгое «15 c с запасом» больше -не требуется. **Требует аппаратной ре-проверки** сценария 3 (даунгрейд): удержать кнопку при -включении → даунгрейд срабатывает; не удерживать → штатная загрузка более нового слота. - -## Побочная находка (не блокирует, но стоит знать) - -`PLAN.md` в двух местах ссылается на `DEBUG_LOG_PHASE2.md`, которого фактически нет в дереве (не -найден ни в рабочей директории, ни в истории git) — судя по всему, файл планировался, но не был -создан. Не тема этого лога, но стоит либо создать его отдельно, либо убрать ссылки, когда дойдут руки. diff --git a/firmware/bootloader/PLAN.md b/firmware/bootloader/PLAN.md index 748c6db..397dcee 100644 --- a/firmware/bootloader/PLAN.md +++ b/firmware/bootloader/PLAN.md @@ -10,7 +10,7 @@ | 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» ниже | +| 6 — Устойчивость и восстановление (recovery) | ✅ полностью верифицирована (2026-07-14) | 6a (счётчик `SRC_GPR3` + фолбэк) + 6b (recovery-режим, BTN_2, ослабленный gate) + стенд `test_stub` реализованы; host-тесты 16/16, ARM+HAB зелёные. Все 6 сценариев чек-листа пройдены на реальной плате; по пути найдено/исправлено 4 бага (2 в коде, 2 в стенде/чек-листе — детали ниже). 6c (контракт tft_app) — документация под будущий план. Чек-лист — [test_stub/HARDWARE_VERIFICATION_PHASE6.md](test_stub/HARDWARE_VERIFICATION_PHASE6.md) | ## Контекст @@ -855,6 +855,125 @@ recovery-LED/CDC это показывают), лечится SD-фиксом и - Кормление watchdog в recovery — как везде (верх цикла + per-block при стирании; 2×2 МБ ~10 c накрыты поблочным refresh). +### ✅ Реализовано (2026-07-13): 6b — recovery_mode протянут, LED-паттерн, CDC-статус + +- **`update_policy_decide()`** — параметр `recovery_mode`: при `true` версия/кнопка игнорируются, + всегда `target_slot = SLOT_A`, `erase_previous_active = true` (стереть Slot Б) — независимо от + того, какой слот был активен (в т.ч. если активной была именно Slot A). 3 новых host-теста + (`tests/host/update_policy/`), включая регрессионный на «recovery целится в A, даже если A + активна». +- **`sd_update_check()`/`run_update()`** — сигнатура: добавлен `recovery_mode`, возврат сменён с + `void` на `bool` (`true` ⟺ `target_slot` подтверждён `slot_version_get()` после установки). + Порядок в `attempt_boot()` (`main.c`) специально **recovery_decide() → SD-скан**, не наоборот: + ослабленный gate должен быть известен ДО скана — иначе строгий gate никогда не пустит образ + поверх «активного» слота, и recovery-режим не имел бы смысла даже при найденном валидном + кандидате. +- **`main.c`** — `attempt_boot()` возвращает `bool` (recovery-состояние); при + `RECOVERY_ENTER_RECOVERY_MODE` + успешной установке — немедленный `jump_now()` (не дожидаясь + следующего пере-скана), иначе остаёмся в recovery. Новый `jump_now()` — общий `bsp_wdog_refresh()` + + `boot_select_and_jump()`, убрал дублирование (было 3 места). `in_recovery` — состояние между + попытками, читается каждую итерацию главного цикла (LED должен обновляться чаще, чем раз в + `SD_RETRY_PERIOD_MS`). +- **LED-паттерн recovery** — оба LED синхронно, 100/100 мс (решение зафиксировано в обсуждении + реализации выше). CDC — `status: recovery_mode` вместо `waiting_for_sd`, пока `in_recovery`. +- **Host-тесты**: `just build::test-host` — **16/16 зелёных** (15 в `test_update_policy` + 9 в + `test_recovery`). +- **ARM-сборка**: `build-bootloader-debug`/`hab-bootloader-debug` — зелёные, 0 warnings на чистой + пересборке. `m_text` — 90552 Б из 247 КБ (35.80%, было 90320 Б / 35.71% до этого шага). +- **Не реализовано в этом шаге**: `BSP_BUTTON_2` уже сэмплируется (готово с 6a) и передаётся как + `recovery_held` в `recovery_decide()` — ручной вход через удержание кнопки уже работает + архитектурно. Что осталось из 6b: явный чек-лист аппаратной верификации (см. общий чек-лист Фазы 6 + выше) — **аппаратно ещё не проверено**. + +### Найден и исправлен баг (до железа, разбором кода): счётчик рос и на пустой плате + +При подготовке стенда 6c (до запуска на железе) обнаружилось: в `attempt_boot()` (`main.c`) +`bsp_boot_attempt_inc()` вызывался **безусловно** в ветке `RECOVERY_NORMAL_BOOT`, даже когда `boot_go()` +заведомо нечего выбирать (оба слота пусты, SD не вставлена — штатное состояние "bootloader-only, +ждём первую установку", уже верифицированное в Фазе 3, сценарий 4). Счётчик рос на каждом пере-скане +(раз в 1.5 c) НЕЗАВИСИМО от того, было ли реальное зависание — через `threshold` (3) попыток +(~4.5 c) `recovery_decide()` видел `attempt_count >= threshold` и **ошибочно уводил бы плату в +recovery-режим** (LED-паттерн + CDC `recovery_mode`) при полностью нормальном, ожидаемом состоянии — +прямая регрессия к уже пройденному сценарию 4 Фазы 3 ("оба слота пусты" должен оставаться в обычном +ожидании SD бесконечно, без всякого recovery). + +**Исправлено**: инкремент теперь условный — `if (installed || slot_a.valid || slot_b.valid)`. Слоты +уже пикнуты (`peek_slot()`) для вызова `recovery_decide()` в начале функции, переиспользуются без +дополнительных чтений flash. Корректно обрабатывает все случаи: + - Оба слота пусты, SD не вставлена — счётчик НЕ растёт, `recovery_decide()` навсегда видит `0 < + threshold` → `NORMAL_BOOT`, плата остаётся в обычном ожидании (регрессия закрыта). + - Класс A/B (валидный по крипте слот, но виснет в рантайме) — `slot_a.valid`/`slot_b.valid` истинны + на момент пика (до того как `boot_go()` внутри `jump_now()` может стереть слот по штатному + revert'у Класса A) → инкремент происходит корректно, механизм таксономии не задет. + - Свежая установка с SD в этом же вызове — `installed=true` → инкремент (0→1) после уже случившегося + `bsp_boot_attempt_reset()` — то же самое поведение, что и раньше, просто явное условие вместо + неявного "всегда". + +Пойман до единого прогона на железе — чисто рассуждением о сценарии перед написанием чек-листа +верификации, см. ниже. ARM-сборка зелёная после фикса (`m_text` 90568 Б, было 90552 Б). + +### Найден и исправлен баг (на железе, сценарий 2): стенд обнулял счётчик раньше, чем зависание успевало засчитаться + +**Аппаратная верификация (2026-07-14).** Сценарий 1 (Класс A) прошёл штатно. Сценарий 2 (Класс B, +есть фолбэк) — **не сработал**: Slot Б (стаб `confirm_hang`) резетился по WDOG раз 5 подряд без +остановки (ожидалось — ровно 3, затем автоматический фолбэк на Slot A); оператору пришлось стереть +Slot Б вручную, после чего Slot A подхватился штатно (уже без участия recovery-логики — простой +`boot_go()` с единственным валидным слотом). + +**Причина — не в `recovery.c`/`main.c` (та логика верна), а в самом стенде `test_stub`**: +`bsp_boot_health_mark()` — это буквально `bsp_boot_attempt_reset()` (см. `bsp/boot_state.h`) — +вызывался в `main.c` стаба **безусловно**, сразу на входе, до всякой проверки `STUB_HANG_MODE`. Для +варианта `confirm_hang` (`HANG_MODE=3`) это означало: bootloader инкрементирует счётчик (0→1) перед +прыжком → стаб тут же обнуляет его обратно в 0 (health-mark) → мигает ~6 c → виснет → WDOG-сброс — +и по новой, **счётчик никогда не накапливался выше 1**, порог 3 не достигался никогда, resets шли +бесконечно. + +Это частный, детерминированный случай уже задокументированной в этом плане «честной границы» +(раздел «Честная граница») — только там речь про РЕДКИЙ баг, а тут health-mark и зависание +гарантированно чередуются на КАЖДОМ цикле, так что граница проявляется не «иногда», а всегда. + +**Исправлено**: в `test_stub/main.c` `bsp_boot_health_mark()` теперь не вызывается для +`HANG_MODE ∈ {2, 3}` (обе конфигурации гарантированно виснут — раз так, заявлять "здоров" не имеет +смысла, счётчик должен копиться). `HANG_MODE=0` (никогда не виснет) и `HANG_MODE=1` (виснет ДО этой +строки, недостижима) — не затронуты. Пересобрано (`just build::build-mcuboot-stub`), все 7 файлов +подписаны заново. + +**Важный урок для будущего 6c (контракт tft_app)**: если реальный tft_app зовёт `boot_set_confirmed`/ +health-mark слишком рано (сразу по входу в главный цикл, не дождавшись хоть какого-то подтверждения +стабильности) и затем детерминированно (не редко!) виснет на каждой сессии — recovery-фолбэк работать +не будет, счётчик будет обнуляться быстрее, чем успевает накопиться. Стоит явно прописать в 6c: +health-mark — не "дошёл до main()", а как минимум "пережил разумный интервал без явных проблем". + +### Найден и исправлен баг (на железе, сценарий 4): чек-лист сам предписывал не тот файл под recovery-установку + +**Аппаратная верификация (2026-07-14), продолжение.** Сценарии 1-3 пройдены. При проверке ручного +входа в recovery по BTN_2 с последующей установкой с SD: CDC показал `installing`, затем **USB CDC +пропал**, ничего не запустилось; после извлечения SD и перезагрузки плата **сама, без кнопки**, +через несколько циклов ушла в `recovery_mode`. + +**Причина — не новый баг кода, а ошибка в самом чек-листе** (`HARDWARE_VERIFICATION_PHASE6.md`, +сценарий 4, шаг 2): в качестве `TFT_APP.BIN` был явно предписан `stub_b_v2_confirmed.bin` — а +recovery-режим (`update_policy_decide(recovery_mode=true)`) **всегда** целится в Slot A. Файл +`stub_b_v2_confirmed.bin` линкован под адрес Slot Б (`0x60240000`); физически положенный в Slot A, +он проходит крипто-гейт (подпись валидна для содержимого файла независимо от адреса — тот же класс +находки, что уже был пойман в Фазе 3 §5, только теперь всплыл в recovery-пути) и "успешно +устанавливается", но исполняется с чужими абсолютными адресами → бутлоадер прыгает в нерабочий код → +CDC (обслуживаемый только бутлоадером) пропадает → WDOG циклически резетит → счётчик копится через +`NORMAL_BOOT`-путь (слот всё ещё крипто-валиден) → по достижении порога плата САМА уходит в recovery, +уже без кнопки, что и наблюдалось. + +**Исправлено** — только документация: `HARDWARE_VERIFICATION_PHASE6.md`, сценарий 4 дополнен явным +предупреждением (по образцу уже существующего для сценария 2) — `TFT_APP.BIN` для recovery-теста +обязан быть `stub_a_v1_confirmed.bin` (Slot A), не `stub_b_v2_confirmed.bin`. + +**Заодно, разбирая путь, нашёл и исправил смежный, реальный пробел в коде**: `attempt_boot()` в +`main.c` не инкрементировал счётчик попыток перед первым прыжком в СВЕЖЕустановленный +recovery-образ (в отличие от обычного пути установки, где `RECOVERY_NORMAL_BOOT`-ветка это уже +делала). Не меняет исход (тот же образ всё равно докопится до порога через обычные попытки), но +восстанавливает симметрию с обычным путём и сокращает число циклов до автоматического отката на +один. Добавлен `bsp_boot_attempt_inc()` перед `jump_now()` в recovery-ветке. ARM-сборка зелёная +(`m_text` 90576 Б). + **6c — Контракт tft_app** (документация под будущий план tft_app, кода в загрузчике нет) - tft_app ОБЯЗАН: (1) обслуживать watchdog (alive-flag идиом); (2) `boot_set_confirmed()` — отложенно, после доказанного здоровья; (3) `bsp_boot_health_mark()` — дойдя до устойчивого @@ -863,14 +982,36 @@ recovery-LED/CDC это показывают), лечится SD-фиксом и - Опционально: tft_app может переконфигурировать таймаут WDOG (`WCR.WT` переписываем, `WDE` — нет) под свои длинные операции. -### Стенд (доработки `test_stub`) +### ✅ Реализовано (2026-07-13): Стенд (доработки `test_stub`) -Опции компиляции/поведения заглушки, чтобы прогнать все классы на железе: -- confirm: сразу / отложенно (через N c) / никогда; -- зависнуть: никогда / до health-mark / после health-mark / после confirm; -- обслуживать watchdog: да / нет. +Три независимые оси, задаются компилятору (`add_mcuboot_stub()` в `test_stub/CMakeLists.txt`, +именованные параметры `CONFIRM_MODE`/`HANG_MODE`/`FEED_WDOG`, см. `main.c`): +- `STUB_CONFIRM_MODE` — 0 сразу / 1 отложенно (`STUB_CONFIRM_DELAY_MS`) / 2 никогда. +- `STUB_HANG_MODE` — 0 никогда / 1 до health-mark / 2 после health-mark, до confirm / 3 после confirm. +- `STUB_FEED_WDOG` — 1 кормить (дефолт) / 0 не кормить. -### Верификация (реальное железо — чек-лист, детали в отдельном HARDWARE_VERIFICATION при реализации) +Подтверждение — `boot_set_next(fap, true, true)` на **собственном** слоте (`STUB_OWN_SLOT_ID`), **не** +`boot_set_confirmed()`: та жёстко пишет в `FLASH_AREA_IMAGE_PRIMARY` (Slot A) независимо от того, откуда +реально исполняется код — для стаба в Slot Б это подтвердило бы чужой слот. Находка сделана чтением +самой реализации `boot_set_confirmed_multi()` в `bootutil_public.c`, до написания кода. + +Линкуется только `bootutil_public.c` (+ `flash_map_backend.c`, `bsp_qspi_flash`) — не весь +`MCUBOOT_BOOTUTIL_SOURCES` (loader.c/image_validate.c/TinyCrypt/ASN.1): стаб не валидирует образы, +только пишет свой трейлер, а бюджет `m_text` стаба всего 32 КБ — вся связка bootutil+крипто туда не +влезла бы. `bootutil_public.c` не зависит от FIH/крипто-путей (`boot_set_next()` возвращает простой +`int`, не `fih_ret`) — собралось и слинковалось с первой попытки, без единого missing symbol. + +Новые таргеты (`just build::build-mcuboot-stub`, добавлены в `mcuboot-stub-debug`): `test_slot_stub_ +{a,b}_hang` (Класс A — `CONFIRM_MODE=2, HANG_MODE=1`, подписан БЕЗ `--confirm`) и `test_slot_stub_ +{a,b}_confirm_hang` (Класс B — `CONFIRM_MODE=0, HANG_MODE=3`, подписан С `--confirm`). Оба варианта на +каждый физический слот — в разных сценариях чек-листа "подозреваемый" слот то A, то Б. `m_text`: +здоровые (a/b) — 20172-20196 Б (61.56-61.63%), hang-варианты — 13788 Б (42.08%, они короче — не доходят +до кода конфига встроенного подтверждения при `HANG_MODE=1`). Все 7 подписанных `.bin` — ровно по 2 МБ +(паддинг корректный, не повторилась находка Фазы 2 про потерянный `--pad`). + +Подробный чек-лист команд — [test_stub/HARDWARE_VERIFICATION_PHASE6.md](test_stub/HARDWARE_VERIFICATION_PHASE6.md). + +### Верификация (реальное железо — чек-лист, детали в [test_stub/HARDWARE_VERIFICATION_PHASE6.md](test_stub/HARDWARE_VERIFICATION_PHASE6.md)) 1. **Класс A** — свежий образ, `confirm=никогда`, `hang=после jump`: watchdog-сброс → MCUboot стирает → откат на прежний слот (или wait_for_sd, если прежнего нет). diff --git a/firmware/bootloader/src/main.c b/firmware/bootloader/src/main.c index 6729f86..432f9dc 100644 --- a/firmware/bootloader/src/main.c +++ b/firmware/bootloader/src/main.c @@ -68,14 +68,19 @@ #include #include -/* ── Одна попытка загрузки: SD-скан + recovery-гейт (Фаза 6) + прыжок ───── +/* ── Одна попытка загрузки: recovery-гейт (Фаза 6) → SD-скан → прыжок ───── * - * sd_update_check() — до чтения счётчика/состояния слотов: новый образ с SD - * заслуживает полный бюджет попыток независимо от текущего счётчика (см. - * "Правила обнуления счётчика" в PLAN.md), а не только после гейта. + * Порядок важен: recovery_decide() должна знать, входим ли мы в recovery, ДО + * SD-скана — от этого зависит, каким gate'ом сканировать SD (обычным строгим + * или ослабленным, update_policy_decide(recovery_mode), см. update_policy.h). + * Обратный порядок (сначала SD, потом решение) не дал бы recovery-режиму + * смысла: строгий gate никогда не поставит образ поверх "активного", даже + * если тот активный и есть подозреваемый в зависании слот. * * peek_slot() — та же логика, что private peek_slot() в sd_update.c: не - * шарим напрямую между модулями (см. update_policy.h), копия минимальна. */ + * шарим напрямую между модулями (см. update_policy.h), копия минимальна. + * Здесь — только для recovery_decide(); sd_update_check() независимо + * повторно пикает слоты внутри себя для update_policy_decide(). */ static update_policy_slot_state_t peek_slot(uint8_t fa_id) { update_policy_slot_state_t state; @@ -83,23 +88,55 @@ static update_policy_slot_state_t peek_slot(uint8_t fa_id) return state; } -static void attempt_boot(bool downgrade_held, bool recovery_held) +static void jump_now(void) { - sd_update_check(downgrade_held); /* no-op быстро, если SD не вставлена */ + bsp_wdog_refresh(); /* образ унаследует полное окно таймаута */ + boot_select_and_jump(); /* при успехе не возвращается */ +} +/** + * @return true, если по итогам этой попытки мы (остаёмся) в recovery-режиме + * — main() использует это для LED-паттерна/CDC-статуса (Фаза 6b). + */ +static bool attempt_boot(bool downgrade_held, bool recovery_held) +{ 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); + bool enter_recovery = (decision.action == RECOVERY_ENTER_RECOVERY_MODE); + + bool installed = sd_update_check(downgrade_held, enter_recovery); + if (installed) + { + bsp_boot_attempt_reset(); /* новый образ — новый полный бюджет попыток */ + } + + if (enter_recovery) + { + if (installed) + { + /* Recovery только что поставил валидный образ в Slot A — + * прыгаем немедленно, не дожидаясь следующего пере-скана. + * Инкремент — как и в обычном пути (см. RECOVERY_NORMAL_BOOT + * ниже): первая попытка прыжка в свежий образ тоже расходует + * бюджет попыток, симметрично обычной установке. */ + bsp_boot_attempt_inc(); + jump_now(); + } + /* Кандидата не нашлось/не прошёл гейт — остаёмся в recovery. */ + return true; + } + switch (decision.action) { case RECOVERY_ERASE_ACTIVE_THEN_BOOT_OTHER: { /* Подозреваемый в зависании слот — стереть, есть подтверждённый * фолбэк (recovery_decide() это уже проверила). boot_go() внутри - * boot_select_and_jump() ниже сам выберет оставшийся. */ + * jump_now() сам выберет оставшийся. */ const struct flash_area *p_fap; if (flash_area_open((uint8_t) decision.active_slot, &p_fap) == 0) { @@ -107,23 +144,26 @@ static void attempt_boot(bool downgrade_held, bool recovery_held) flash_area_close(p_fap); } bsp_boot_attempt_reset(); /* ситуация изменилась — новый полный бюджет */ - bsp_wdog_refresh(); /* образ унаследует полное окно таймаута */ - boot_select_and_jump(); /* при успехе не возвращается */ + jump_now(); 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(); /* при успехе не возвращается */ + /* Инкремент только если действительно ЕСТЬ что пытаться загрузить + * (слот уже валиден, либо только что установлен этим же вызовом) — + * иначе на чисто пустой плате без SD счётчик рос бы и на пустом + * месте, и через threshold попыток (несколько секунд) recovery_decide() + * ошибочно увела бы в recovery-режим при отсутствии какого-либо + * реального зависания (регрессия к сценарию 4 Фазы 3 — "оба слота + * пусты" должен оставаться в обычном ожидании SD бесконечно). */ + if (installed || slot_a.valid || slot_b.valid) + { + bsp_boot_attempt_inc(); /* перед попыткой — см. bsp/boot_state.h */ + } + jump_now(); break; } + return false; } int main(void) @@ -132,6 +172,11 @@ int main(void) const uint32_t SD_RETRY_PERIOD_MS = 1500U; const uint32_t HEARTBEAT_ON_MS = 50U; const uint32_t HEARTBEAT_PERIOD_MS = 500U; + /* Recovery-паттерн (Фаза 6b): оба LED синхронно, 100 мс вкл/100 мс выкл — + * чётко отличается от heartbeat (50/450, один LED) и app (500/250, + * один LED, см. test_stub) визуально, без дополнительной телеметрии. */ + const uint32_t RECOVERY_BLINK_ON_MS = 100U; + const uint32_t RECOVERY_BLINK_PERIOD_MS = 200U; /* Таймаут WDOG. С запасом над самым долгим НАКОРМЛЕННЫМ участком: между * соседними refresh худший легитимный интервал — одиночное стирание 64 КБ * блока (~0.15..2 c по даташиту W25Q) либо цепочка bsp_sd_init+f_mount+пик @@ -166,9 +211,15 @@ int main(void) bool qspi_ok = (bsp_qspi_init() == BSP_OK); bool cdc_ok = (bsp_usb_cdc_init() == BSP_OK); + /* Отслеживает recovery-состояние между попытками — main-loop использует + * его для LED-паттерна каждую итерацию, не только на попытках прыжка + * (attempt_boot() зовётся раз в SD_RETRY_PERIOD_MS, LED должен обновляться + * значительно чаще). */ + bool in_recovery = false; + if (qspi_ok) { - attempt_boot(DOWNGRADE_HELD, RECOVERY_HELD); + in_recovery = attempt_boot(DOWNGRADE_HELD, RECOVERY_HELD); } /* Нет валидного образа ни в одном слоте (или сбой QSPI) — @@ -200,23 +251,32 @@ int main(void) bsp_usb_cdc_poll(); cli_process(); - uint32_t heartbeat_phase_ms = bsp_tick_get_ms() % HEARTBEAT_PERIOD_MS; - if (heartbeat_phase_ms < HEARTBEAT_ON_MS) + if (in_recovery) { - bsp_led_on(LED_HEARTBEAT); + bool recovery_led_on = (bsp_tick_get_ms() % RECOVERY_BLINK_PERIOD_MS) < RECOVERY_BLINK_ON_MS; + bsp_led_set(LED_HEARTBEAT, recovery_led_on); + bsp_led_set(LED_APP, recovery_led_on); } else { - bsp_led_off(LED_HEARTBEAT); + uint32_t heartbeat_phase_ms = bsp_tick_get_ms() % HEARTBEAT_PERIOD_MS; + if (heartbeat_phase_ms < HEARTBEAT_ON_MS) + { + bsp_led_on(LED_HEARTBEAT); + } + else + { + bsp_led_off(LED_HEARTBEAT); + } } if (qspi_ok && ((int32_t) (bsp_tick_get_ms() - next_sd_retry_ms) >= 0)) { next_sd_retry_ms = bsp_tick_get_ms() + SD_RETRY_PERIOD_MS; - protocol_send_status("waiting_for_sd"); + protocol_send_status(in_recovery ? "recovery_mode" : "waiting_for_sd"); - attempt_boot(DOWNGRADE_HELD, RECOVERY_HELD); + in_recovery = attempt_boot(DOWNGRADE_HELD, RECOVERY_HELD); } } } diff --git a/firmware/bootloader/src/protocol.h b/firmware/bootloader/src/protocol.h index a4fadb6..4d6103f 100644 --- a/firmware/bootloader/src/protocol.h +++ b/firmware/bootloader/src/protocol.h @@ -16,8 +16,9 @@ * error — ошибка протокола или парсинга * * Типы исходящих событий (Фаза 3): - * status — top-level состояние bootloader (waiting_for_sd и т.д., - * см. protocol_send_status()) + * status — top-level состояние bootloader (waiting_for_sd, + * installing, update_skipped, recovery_mode — см. + * protocol_send_status()) * wdog — статус аппаратного watchdog (armed/timeout/recovered, * см. protocol_send_wdog_status()) * diff --git a/firmware/bootloader/src/sd_update.c b/firmware/bootloader/src/sd_update.c index 506f613..306b59d 100644 --- a/firmware/bootloader/src/sd_update.c +++ b/firmware/bootloader/src/sd_update.c @@ -15,6 +15,8 @@ * * Целевой слот при установке — всегда НЕ активный (см. update_policy.h): * уже выбранный на этот момент слот этой функцией никогда не стирается. + * Исключение — recovery-режим (Фаза 6b, update_policy_decide(recovery_mode)): + * там целевой слот всегда Slot A, независимо от того, что было активно. */ #include "sd_update.h" @@ -39,9 +41,7 @@ /** @brief Размер чанка потокового копирования SD → flash. */ #define SD_UPDATE_CHUNK_SIZE 4096U -/* ── Состояние модуля ────────────────────────────────────────────────────── - * Статические буферы — не на стеке (см. firmware/test/src/tests/test_usd.c - * про ограниченный стек bare-metal прошивок). */ +/* ── Состояние модуля ────────────────────────────────────────────────────── */ static FATFS g_s_fs; static FIL g_s_file; @@ -139,7 +139,11 @@ static bool erase_and_copy_candidate(const struct flash_area *p_fap, uint32_t fi /* ── Основной сценарий ────────────────────────────────────────────────── */ -static void run_update(bool button_held) +/** + * @return true, если target_slot после этого вызова содержит новый, + * подтверждённый (slot_version_get()) образ — см. sd_update.h. + */ +static bool run_update(bool button_held, bool recovery_mode) { struct image_version candidate_ver; struct image_version installed_ver; @@ -149,23 +153,24 @@ static void run_update(bool button_held) const struct flash_area *p_fap = NULL; uint32_t file_size; bool copy_ok; + bool result = false; if (bsp_sd_init() != BSP_OK) { - return; + return false; } if (f_mount(&g_s_fs, SD_UPDATE_MOUNT_POINT, 1) != FR_OK) { (void) bsp_sd_deinit(); - return; /* нет карты/файловой системы — штатно, не ошибка */ + return false; /* нет карты/файловой системы — штатно, не ошибка */ } if (f_open(&g_s_file, SD_UPDATE_FILE_PATH, FA_READ) != FR_OK) { (void) f_unmount(SD_UPDATE_MOUNT_POINT); (void) bsp_sd_deinit(); - return; /* TFT_APP.BIN отсутствует — тоже штатно */ + return false; /* TFT_APP.BIN отсутствует — тоже штатно */ } if (!read_candidate_header(&candidate_ver)) @@ -177,8 +182,10 @@ static void run_update(bool button_held) file_size = (uint32_t) f_size(&g_s_file); slot_a = peek_slot(0U); slot_b = peek_slot(1U); - /* button_held — сэмплирован при старте в main.c и передан сюда (см. sd_update.h). */ - decision = update_policy_decide(&slot_a, &slot_b, &candidate_ver, button_held); + /* button_held — сэмплирован при старте в main.c и передан сюда (см. + * sd_update.h). recovery_mode — ослабленный gate Фазы 6b, см. + * update_policy.h; решение "входить ли в recovery" — не здесь. */ + decision = update_policy_decide(&slot_a, &slot_b, &candidate_ver, button_held, recovery_mode); if (decision.action == UPDATE_POLICY_SKIP) { @@ -209,11 +216,17 @@ static void run_update(bool button_held) goto cleanup; } - /* Форс. даунгрейд (см. update_policy.h): без стирания прежнего активного - * слота он остался бы валиден и новее только что установленного, и - * снова выиграл бы в boot_go() — даунгрейд физически записался бы, но - * не загрузился. Стираем ТОЛЬКО теперь, когда новый образ уже подтверждён - * валидным — на диске никогда не бывает нуля рабочих слотов. */ + /* target_slot подтверждён валидным — установка состоялась независимо от + * исхода стирания "второго" слота ниже (оно диагностируется отдельным + * protocol_send_error, но не отменяет уже подтверждённый результат). */ + result = true; + + /* Форс. даунгрейд и recovery (см. update_policy.h): без стирания + * прежнего активного/Slot Б он остался бы валиден (и новее в обычном + * режиме) и снова выиграл бы в boot_go() — даунгрейд/recovery физически + * записались бы, но не загрузились. Стираем ТОЛЬКО теперь, когда новый + * образ уже подтверждён валидным — на диске никогда не бывает нуля + * рабочих слотов. */ if (decision.erase_previous_active) { const struct flash_area *p_peer_fap; @@ -238,16 +251,17 @@ cleanup: (void) f_close(&g_s_file); (void) f_unmount(SD_UPDATE_MOUNT_POINT); (void) bsp_sd_deinit(); + return result; } /* ── Public API ────────────────────────────────────────────────────────── */ -void sd_update_check(bool downgrade_button_held) +bool sd_update_check(bool downgrade_button_held, bool recovery_mode) { if (!bsp_sd_is_inserted()) { - return; + return false; } - run_update(downgrade_button_held); + return run_update(downgrade_button_held, recovery_mode); } diff --git a/firmware/bootloader/src/sd_update.h b/firmware/bootloader/src/sd_update.h index 5dcb0fb..885aee3 100644 --- a/firmware/bootloader/src/sd_update.h +++ b/firmware/bootloader/src/sd_update.h @@ -12,10 +12,12 @@ /** * @brief Одна попытка: смонтировать SD, найти TFT_APP.BIN, при необходимости - * установить его в неактивный слот. + * установить его в целевой слот (см. update_policy.h). * - * Пишет только в неактивный слот (см. update_policy.h) — уже выбранный/ - * загружаемый слот никогда не трогается. Сам не вызывает boot_go()/ + * Обычный режим (recovery_mode == false) пишет только в неактивный слот — + * уже выбранный/загружаемый слот никогда не трогается. Recovery-режим + * (Фаза 6b) — ослабленный gate: любой подписанный кандидат ставится в Slot A + * безусловно, Slot Б стирается. Сам не вызывает boot_go()/ * boot_select_and_jump() — решение "когда прыгать" остаётся за main.c, * которое обязано вызвать его ровно один раз за сессию питания (см. * slot_version.h о том, почему boot_go() нельзя звать повторно). @@ -29,9 +31,20 @@ * (см. update_policy.h). Передаётся, а не читается здесь, чтобы жест * "удержание при включении" ловился в предсказуемый ранний момент, а не * через несколько секунд внутри run_update() (DEBUG_LOG_PHASE3_SD.md). + * Игнорируется при recovery_mode == true. + * @param recovery_mode Ослабленный version-gate (Фаза 6b) — см. + * update_policy_decide(). Решение "входить ли в recovery" принимает + * вызывающий код (recovery_decide(), recovery.h), не эта функция. + * + * @retval true Кандидат успешно установлен и прошёл финальный крипто-гейт + * (slot_version_get() на целевом слоте) — в целевом слоте + * теперь новый валидный образ. + * @retval false Ничего не установлено (SD не вставлена/нет файла/skip) либо + * установка не удалась/кандидат отклонён — целевой слот не + * изменился относительно состояния до вызова. * * @pre bsp_qspi_init() уже вызван. */ -void sd_update_check(bool downgrade_button_held); +bool sd_update_check(bool downgrade_button_held, bool recovery_mode); #endif /* SD_UPDATE_H_ */ diff --git a/firmware/bootloader/src/update_policy.c b/firmware/bootloader/src/update_policy.c index 6315c71..c26e6d7 100644 --- a/firmware/bootloader/src/update_policy.c +++ b/firmware/bootloader/src/update_policy.c @@ -69,8 +69,18 @@ static update_policy_slot_t other_slot(update_policy_slot_t slot) update_policy_result_t update_policy_decide(const update_policy_slot_state_t *p_slot_a, const update_policy_slot_state_t *p_slot_b, const struct image_version *p_candidate_ver, - bool button_held) + bool button_held, bool recovery_mode) { + if (recovery_mode) + { + /* Ослабленный гейт: версия/кнопка не участвуют, целевой слот всегда + * A, Slot Б обязан быть стёрт (см. recovery.h — этот флаг не связан + * с recovery_decide() там). */ + return (update_policy_result_t) { .action = UPDATE_POLICY_INSTALL, + .target_slot = UPDATE_POLICY_SLOT_A, + .erase_previous_active = true }; + } + active_slot_info_t active = find_active_slot(p_slot_a, p_slot_b); if (!active.have_active) diff --git a/firmware/bootloader/src/update_policy.h b/firmware/bootloader/src/update_policy.h index 2476ba6..527caf4 100644 --- a/firmware/bootloader/src/update_policy.h +++ b/firmware/bootloader/src/update_policy.h @@ -50,7 +50,7 @@ typedef struct /** * @brief Решить, устанавливать ли SD-кандидат, и в какой слот. * - * Правила: + * Правила (обычный режим, recovery_mode == false): * - "Активный" слот — валидный слот с более высокой версией; если валиден * только один — он активный; если ни одного — активного слота нет. * - Целевой слот установки — всегда НЕ активный (активный не перезаписываем @@ -71,15 +71,36 @@ typedef struct * - Кандидат равен активному, кнопка удержана → SKIP (не форсируем * переустановку той же версии). * + * Recovery-режим (recovery_mode == true, Фаза 6b) — ослабленный version-gate: + * - Версия кандидата и button_held игнорируются целиком — ЛЮБОЙ кандидат, + * прошедший последующие (внешние по отношению к этой функции) проверки + * заголовка и крипто-гейта, принимается безусловно. + * - target_slot — всегда Slot A, независимо от того, какой слот был активен + * до входа в recovery (в отличие от обычного режима, где целевой слот + * вычисляется как "не активный"). + * - erase_previous_active — всегда true: вызывающий код обязан стереть + * Slot Б перед/после установки в Slot A (см. rationale выше про порядок + * "стереть только после подтверждения валидности нового образа") — вместе + * с тем, что erase_and_copy_candidate() и так стирает сам target_slot + * перед записью, это и даёт "чистый борт": оба слота гарантированно + * стёрты, в Slot A — только что установленный и провалидированный образ. + * * @param[in] p_slot_a Состояние Slot A. * @param[in] p_slot_b Состояние Slot Б. - * @param[in] p_candidate_ver Версия образа-кандидата на SD. + * @param[in] p_candidate_ver Версия образа-кандидата на SD. Не читается при + * recovery_mode == true. * @param[in] button_held Кнопка даунгрейда (BSP_BUTTON_1) удержана на старте. + * Не читается при recovery_mode == true. + * @param[in] recovery_mode Ослабленный version-gate (см. выше). Не путать с + * recovery_decide() (recovery.h) — та решает, + * входить ли в recovery-режим вообще (счётчик + * watchdog-сбросов), эта функция — что делать с + * SD-кандидатом, уже находясь в нём. */ update_policy_result_t update_policy_decide(const update_policy_slot_state_t *p_slot_a, const update_policy_slot_state_t *p_slot_b, const struct image_version *p_candidate_ver, - bool button_held); + bool button_held, bool recovery_mode); /** * @brief Сравнить версии образов: major.minor.revision, без build_num — то diff --git a/firmware/bootloader/test_stub/CMakeLists.txt b/firmware/bootloader/test_stub/CMakeLists.txt index 9e48e19..a404bcf 100644 --- a/firmware/bootloader/test_stub/CMakeLists.txt +++ b/firmware/bootloader/test_stub/CMakeLists.txt @@ -1,52 +1,185 @@ # firmware/bootloader/test_stub/CMakeLists.txt # -# Заглушка tft_app для аппаратной верификации Фазы 2 bootutil — см. -# firmware/bootloader/PLAN.md, "Аппаратная верификация Фазы 2". Не часть -# продукта — удалить, когда появится реальный tft_app. +# Заглушка tft_app для аппаратной верификации корректной работы загрузчика # -# Два таргета из одного main.c: разный адрес слота (--defsym __slot_base__) и -# разная частота мигания (STUB_BLINK_MS) — чтобы на глаз отличить, какой слот -# выбрал bootloader. Постройка .bin, дальше подписывается вручную imgtool'ом -# (см. PLAN.md) — .bin сюда не должен попасть без подписи. +# Один main.c, много таргетов: адрес слота (--defsym __slot_base__) + частота +# мигания (STUB_BLINK_MS) — чтобы на глаз отличить, какой слот выбрал bootloader +# плюс три независимые оси confirm/hang/watchdog — чтобы прогнать классы +# отказов A/B на реальном железе. Подписывается вручную imgtool. +# +include( + ${CMAKE_SOURCE_DIR}/firmware/bootloader/mcuboot_port/bootutil_sources.cmake) -function(add_mcuboot_stub NAME SLOT_BASE BLINK_MS) - add_executable(${NAME} main.c ${BSP_GENERATED}/clock_config.c - ${BSP_STARTUP_FILE} ${BSP_SYSCALLS_FILE}) +function(add_mcuboot_stub) + cmake_parse_arguments( + ARG "" + "NAME;SLOT_BASE;BLINK_MS;OWN_SLOT_ID;CONFIRM_MODE;HANG_MODE;FEED_WDOG" "" + ${ARGN}) - target_compile_definitions(${NAME} PRIVATE STUB_BLINK_MS=${BLINK_MS} - __STARTUP_CLEAR_BSS) + if(NOT DEFINED ARG_CONFIRM_MODE) + set(ARG_CONFIRM_MODE 0) # 0 сразу / 1 отложенно / 2 никогда — см. main.c + endif() + if(NOT DEFINED ARG_HANG_MODE) + set(ARG_HANG_MODE 0) # 0 никогда / 1 до health-mark / 2 после health-mark / + # 3 после confirm + endif() + if(NOT DEFINED ARG_FEED_WDOG) + set(ARG_FEED_WDOG 1) # 1 кормить (дефолт, как в проде) / 0 не кормить + endif() + + add_executable( + ${ARG_NAME} + main.c + ${BSP_GENERATED}/clock_config.c + ${BSP_STARTUP_FILE} + ${BSP_SYSCALLS_FILE} + ${CMAKE_SOURCE_DIR}/firmware/bootloader/mcuboot_port/flash_map_backend.c + ${MCUBOOT_BOOTUTIL_DIR}/src/bootutil_public.c) + + target_include_directories(${ARG_NAME} PRIVATE ${MCUBOOT_BOOTUTIL_INCLUDES}) + + target_compile_definitions( + ${ARG_NAME} + PRIVATE STUB_BLINK_MS=${ARG_BLINK_MS} + STUB_OWN_SLOT_ID=${ARG_OWN_SLOT_ID} + STUB_CONFIRM_MODE=${ARG_CONFIRM_MODE} + STUB_HANG_MODE=${ARG_HANG_MODE} + STUB_FEED_WDOG=${ARG_FEED_WDOG} + __STARTUP_CLEAR_BSS) + + # Вендоренный bootutil_public.c — не наш стиль/warnings, тот же обход, что и + # для остального bootutil (см. bootutil_sources.cmake). Наш код (main.c, + # flash_map_backend.c) остаётся под обычными warnings. + set_source_files_properties( + ${MCUBOOT_BOOTUTIL_DIR}/src/bootutil_public.c + PROPERTIES COMPILE_OPTIONS "${MCUBOOT_VENDORED_COMPILE_OPTIONS}") # bsp_wdog: загрузчик взводит WDOG перед прыжком, WDE — write-once, поэтому - # образ ОБЯЗАН его кормить (иначе reset-loop). Заглушка — стенд-ин tft_app, - # тоже кормит. См. bsp/wdog/README.md. - target_link_libraries(${NAME} PRIVATE bsp_board bsp_led bsp_tick - bsp_boot_xip_no_dcd bsp_wdog) + # образ ОБЯЗАН его кормить, если STUB_FEED_WDOG=1 (иначе reset-loop — либо + # намеренно, для теста самого механизма). bsp_boot_state — + # bsp_boot_health_mark(). bsp_qspi_flash — нужен flash_map_backend.c для + # confirm_self(). + target_link_libraries( + ${ARG_NAME} + PRIVATE bsp_board + bsp_led + bsp_tick + bsp_boot_xip_no_dcd + bsp_wdog + bsp_boot_state + bsp_qspi_flash) target_link_options( - ${NAME} + ${ARG_NAME} PRIVATE -Wl,--gc-sections -Wl,--print-memory-usage - -Wl,-Map=${CMAKE_BINARY_DIR}/${NAME}.map - -Wl,--defsym=__slot_base__=${SLOT_BASE} + -Wl,-Map=${CMAKE_BINARY_DIR}/${ARG_NAME}.map + -Wl,--defsym=__slot_base__=${ARG_SLOT_BASE} -Wl,--defsym=__stack_size__=0x400 -Wl,--defsym=__heap_size__=0x400 -T${CMAKE_SOURCE_DIR}/cmake/linker/MIMXRT1052xxxxx_mcuboot_slot.ld) - set_target_properties(${NAME} PROPERTIES RUNTIME_OUTPUT_DIRECTORY - ${CMAKE_BINARY_DIR}) + set_target_properties(${ARG_NAME} PROPERTIES RUNTIME_OUTPUT_DIRECTORY + ${CMAKE_BINARY_DIR}) add_custom_command( - TARGET ${NAME} + TARGET ${ARG_NAME} POST_BUILD - COMMAND ${CMAKE_OBJCOPY} -O binary $ - ${CMAKE_BINARY_DIR}/${NAME}.bin - COMMAND ${CMAKE_SIZE} $ - COMMENT "Generating ${NAME}.bin") + COMMAND ${CMAKE_OBJCOPY} -O binary $ + ${CMAKE_BINARY_DIR}/${ARG_NAME}.bin + COMMAND ${CMAKE_SIZE} $ + COMMENT "Generating ${ARG_NAME}.bin") endfunction() -# Slot A: 0x60040000, мигает раз в 500 мс ("версия 1") -add_mcuboot_stub(test_slot_stub_a 0x60040000 500) +# ───────────────────────────────────────────────────────────────────────── +# Фаза 2/3 — базовые "здоровые" заглушки: подтверждаются сразу, никогда не +# виснут. Slot A мигает раз в 500 мс ("версия 1"), Slot Б — раз в 250 мс +# ("версия 2"). CONFIRM_MODE/HANG_MODE/FEED_WDOG — дефолты (0/0/1). +# ───────────────────────────────────────────────────────────────────────── +add_mcuboot_stub( + NAME + test_slot_stub_a + SLOT_BASE + 0x60040000 + BLINK_MS + 500 + OWN_SLOT_ID + 0) +add_mcuboot_stub( + NAME + test_slot_stub_b + SLOT_BASE + 0x60240000 + BLINK_MS + 250 + OWN_SLOT_ID + 1) -# Slot Б: 0x60240000, мигает раз в 250 мс ("версия 2") -add_mcuboot_stub(test_slot_stub_b 0x60240000 250) +# ───────────────────────────────────────────────────────────────────────── +# Фаза 6 — стенд для чек-листа recovery (см. PLAN.md, "Стенд"). Оба варианта +# (Slot A / Slot Б), т.к. в разных сценариях чек-листа "подозреваемый" слот — то +# один, то другой. +# ───────────────────────────────────────────────────────────────────────── + +# Класс A: никогда не подтверждается, виснет СРАЗУ (до health-mark) — свежий +# неподтверждённый образ виснет до всякого прогресса. WDOG-сброс → штатный +# MCUboot revert (copy_done без image_ok) → откат, без участия recovery.c. +add_mcuboot_stub( + NAME + test_slot_stub_a_hang + SLOT_BASE + 0x60040000 + BLINK_MS + 500 + OWN_SLOT_ID + 0 + CONFIRM_MODE + 2 + HANG_MODE + 1) +add_mcuboot_stub( + NAME + test_slot_stub_b_hang + SLOT_BASE + 0x60240000 + BLINK_MS + 250 + OWN_SLOT_ID + 1 + CONFIRM_MODE + 2 + HANG_MODE + 1) + +# Класс B: подтверждается СРАЗУ (успевает помигать наблюдаемо — видно, что образ +# живой и подтверждён), виснет через STUB_CONFIRM_DELAY_MS + STUB_HANG_DELAY_MS +# (~6 c) после старта — уже ПОСЛЕ confirm. Штатный revert MCUboot тут не +# сработает (image_ok уже SET) — ловит именно recovery.c (счётчик +# watchdog-сбросов + фолбэк/recovery). +add_mcuboot_stub( + NAME + test_slot_stub_a_confirm_hang + SLOT_BASE + 0x60040000 + BLINK_MS + 500 + OWN_SLOT_ID + 0 + CONFIRM_MODE + 0 + HANG_MODE + 3) +add_mcuboot_stub( + NAME + test_slot_stub_b_confirm_hang + SLOT_BASE + 0x60240000 + BLINK_MS + 250 + OWN_SLOT_ID + 1 + CONFIRM_MODE + 0 + HANG_MODE + 3) diff --git a/firmware/bootloader/test_stub/HARDWARE_VERIFICATION_PHASE6.md b/firmware/bootloader/test_stub/HARDWARE_VERIFICATION_PHASE6.md new file mode 100644 index 0000000..1464b03 --- /dev/null +++ b/firmware/bootloader/test_stub/HARDWARE_VERIFICATION_PHASE6.md @@ -0,0 +1,330 @@ +# Фаза 6 (recovery) — аппаратная верификация + +Памятка с готовыми командами: сборка, прошивка, подготовка стенда, чек-лист. Продолжение +[HARDWARE_VERIFICATION_PHASE3.md](HARDWARE_VERIFICATION_PHASE3.md) — предполагает, что прошивка +bootloader и базовые заглушки слотов (`test_slot_stub_a`/`_b`) уже знакомы по Фазам 2/3. + +Все команды — из корня репозитория (`tft_manufacture_test/`). Сборка и host-тесты — в devcontainer; +прошивка/отладка/проверки на железе — на стороне пользователя. + +**Статус (2026-07-14): сценарии 1-4 пройдены.** По пути два найденных и исправленных пробела (см. +PLAN.md, разделы "Найден и исправлен баг (на железе, сценарий 2/4)"): +- Сценарий 2 сначала не проходил — стенд (`test_stub`) обнулял счётчик попыток раньше, чем + зависание успевало засчитаться; после фикса и переподписи (`just build::build-mcuboot-stub`) — + подтверждён. +- Сценарий 4 сначала не проходил — сам чек-лист (этот файл) предписывал не тот `TFT_APP.BIN` + (линкован под чужой слот); исправлен текст сценария 4 ниже + попутно найден и исправлен реальный + пробел в `main.c` (счётчик не инкрементировался на первом прыжке в свежий recovery-образ). После + фикса — подтверждён. + +Сценарий 5 (транзиент/per-session) — подтверждён: POR без BTN_2 обнуляет счётчик, зависший слот +пробуется заново, детерминированное зависание повторяет цикл 1→2→3 и снова приходит в +`recovery_mode` — ровно ожидаемая per-session семантика (не отдельный флеш-маркер). + +Сценарий 6 (регрессия) — подтверждён: чек-лист Фазы 3 + пустая плата 2+ мин без единого ложного +recovery/сброса. + +**✅ Все 6 сценариев Фазы 6 пройдены на реальной плате (2026-07-14).** + +--- + +## 0. Предпосылки + +- `firmware/bootloader` собран с кодом Фазы 6: `bsp/boot_state` (счётчик попыток в `SRC_GPR3`, + POR-детект), `recovery.c` (`recovery_decide()` — таксономия классов B/C/D), `update_policy.c` + (флаг `recovery_mode` — ослабленный gate), `main.c` (`attempt_boot()` — recovery-гейт → SD-скан → + прыжок, LED-паттерн, CDC-статус). +- Класс A (незавершённая установка) закрывается штатным revert MCUboot автоматически — код + загрузчика в этом не участвует, проверяется тем не менее (сценарий 1 ниже), т.к. счётчик всё равно + инкрементируется на каждой такой попытке (см. находку про фикс счётчика в PLAN.md). +- Watchdog (Фаза 3) уже аппаратно верифицирован — таймаут 10 c, см. + [HARDWARE_VERIFICATION_PHASE3.md](HARDWARE_VERIFICATION_PHASE3.md), раздел 9. Здесь ту же + 10-секундную защиту используем как механизм провокации, не проверяем заново. +- Порог фолбэка/recovery — `RECOVERY_DEFAULT_THRESHOLD = 3` (сбросов подряд). + +--- + +## 1. Сборка + +```bash +# Bootloader (Debug) + HAB-контейнер +just build::build-bootloader-debug +just build::hab-bootloader-debug + +# Стаб-образы (базовые Фазы 2/3 + новые Фазы 6) — одной командой +just build::build-mcuboot-stub +``` + +После этого в `build/Debug/signed/` — 7 подписанных файлов, все ровно по 2 МБ: + +| Файл | Версия | Слот (адрес линковки) | Подтверждён при прошивке | Поведение в рантайме | +|---|---|---|---|---| +| `stub_a_v1_confirmed.bin` | 1.0.0 | Slot A (`0x60040000`) | да (`--confirm`) | здоров, никогда не виснет, мигает ~1/сек | +| `stub_b_v2_confirmed.bin` | 2.0.0 | Slot Б (`0x60240000`) | да | здоров, никогда не виснет, мигает ~2/сек | +| `stub_a_v1_unconfirmed.bin` | 1.0.0 | Slot A | нет | здоров, но revert-тест Фазы 2 (не используется здесь) | +| `stub_a_hang_class_a.bin` | 1.0.0 | Slot A | нет | **Класс A**: никогда не подтверждается, виснет СРАЗУ | +| `stub_b_hang_class_a.bin` | 2.0.0 | Slot Б | нет | то же, для Slot Б | +| `stub_a_confirm_hang_class_b.bin` | 1.0.0 | Slot A | да | **Класс B**: подтверждается сразу, мигает ~6 c, виснет | +| `stub_b_confirm_hang_class_b.bin` | 2.0.0 | Slot Б | да | то же, для Slot Б | + +> ⚠️ **Версии фиксированы по слоту линковки (1.0.0 = A, 2.0.0 = Б) — это ограничивает выбор файлов +> для сценария "класс B, есть фолбэк".** `boot_go()` всегда выбирает более высокую версию как +> активную. Если положить `stub_a_confirm_hang_class_b.bin` (v1.0.0) в Slot A вместе со +> `stub_b_v2_confirmed.bin` (v2.0.0) в Slot Б — активным станет Slot Б (здоровый!), а зависающий +> Slot A вообще не будет выбран, и тест ничего не покажет. **Правильная комбинация для "есть +> фолбэк"**: `stub_b_confirm_hang_class_b.bin` (v2.0.0, виснет) в Slot Б + `stub_a_v1_confirmed.bin` +> (v1.0.0, здоров) в Slot A — тогда активный (Slot Б, выше версия) реально виснет, а фолбэк (Slot A, +> валиден) ждёт своей очереди. См. сценарий 2 ниже — именно эта комбинация. + +--- + +## 2. Прошивка bootloader + +```bash +just host::flash-swd-bootloader-debug +# обязателен power cycle платы после прошивки +``` + +--- + +## 3. Прошивка образов в слоты напрямую (pyOCD) + +Тот же способ, что в Фазах 2/3 — сырой подписанный `.bin` напрямую по адресу слота, путь абсолютный +(`"$(pwd)/..."`, команда — из корня репозитория). + +```bash +# Slot A (0x60040000) — любой из файлов таблицы выше с адресом Slot A +uv run --directory tools/hil pyocd flash --target mimxrt1050_quadspi --frequency 4000000 \ + --base-address 0x60040000 --erase sector "$(pwd)/build/Debug/signed/<файл>.bin" + +# Slot Б (0x60240000) — любой из файлов таблицы выше с адресом Slot Б +uv run --directory tools/hil pyocd flash --target mimxrt1050_quadspi --frequency 4000000 \ + --base-address 0x60240000 --erase sector "$(pwd)/build/Debug/signed/<файл>.bin" + +# Стереть слот целиком (адрес — позиционный аргумент, start+length) +uv run --directory tools/hil pyocd erase --target mimxrt1050_quadspi --frequency 4000000 \ + --sector 0x60040000+0x200000 # Slot A +uv run --directory tools/hil pyocd erase --target mimxrt1050_quadspi --frequency 4000000 \ + --sector 0x60240000+0x200000 # Slot Б +``` + +--- + +## 4. CDC — статусы и команды + +Подключиться (см. [../README.md](../README.md)): + +```bash +screen /dev/cu.usbmodemXXXX +``` + +- Живость: `{"type":"cmd","cmd":"ping"}` → `{"type":"pong"}`. +- Статус watchdog + счётчика: `{"type":"cmd","cmd":"wdog"}` → + `{"type":"wdog","armed":true,"timeout_s":10,"recovered":,"reset_count":,"threshold":3}`. + `reset_count` — растущий индикатор прогресса к порогу; главный источник диагностики для + сценариев 2/3 (растёт после каждого WDOG-сброса подряд, обнуляется на POR/новой установке/фолбэке). +- Статусы (`{"type":"status","state":"..."}`): `waiting_for_sd`, `installing`, `update_skipped` + (уже знакомы по Фазе 3) + новый **`recovery_mode`** — эмитится вместо `waiting_for_sd`, пока плата + в recovery-состоянии. + +--- + +## 5. Жест BTN_2 (ручной вход в recovery) + +**Держать `BSP_BUTTON_2` нажатой в момент подачи питания** — считывается один раз, сразу после +`bsp_button_init()` (`main.c`), до всей SD-логики, и защёлкивается на сессию — тот же приём, что +`BSP_BUTTON_1` в Фазе 3. Приоритет над `BSP_BUTTON_1` и над обычным boot-путём разрешается внутри +`recovery_decide()` (проверяется первым, независимо от счётчика попыток и состояния слотов). + +--- + +## 6. Индикация recovery-режима + +- **LED**: `LED_HEARTBEAT` и `LED_APP` мигают **синхронно, 100 мс вкл / 100 мс выкл** — чётко + отличается на глаз от обычного heartbeat (50/450 мс, только `LED_HEARTBEAT`) и от app-паттернов + (500/250 мс, только `LED_APP`, см. `stub_a`/`stub_b`). +- **CDC**: `status: recovery_mode` вместо `waiting_for_sd`, повторяется каждые ~1.5 c (тот же период, + что и обычный пере-скан SD). + +--- + +## 7. Чек-лист сценариев + +Между КАЖДЫМ сценарием — обязательный power cycle платы (SWD-запись/установка не ресетят +автоматически). Все тайминги — ориентировочные (наблюдать глазами/секундомером, точная синхронизация +не нужна). + +### Сценарий 1 — Класс A: свежий образ никогда не подтверждается, виснет сразу + +**Подготовка:** +```bash +uv run --directory tools/hil pyocd erase --target mimxrt1050_quadspi --frequency 4000000 --sector 0x60240000+0x200000 # Slot Б пуст +uv run --directory tools/hil pyocd flash --target mimxrt1050_quadspi --frequency 4000000 \ + --base-address 0x60040000 --erase sector "$(pwd)/build/Debug/signed/stub_a_hang_class_a.bin" +``` + +**Ожидаемо:** +1. Power on → bootloader выбирает Slot A (единственный валидный) → прыжок → `LED_APP` **не + загорается вообще** (зависание — до `bsp_led_init()`'s первого toggle в цикле, LED остаётся в + выключенном состоянии init'а). +2. Через ~10 c — аппаратный WDOG-сброс. +3. Bootloader стартует заново; `boot_go()` видит `copy_done=SET` (уже выставлен предыдущим выбором), + `image_ok` НЕ установлен (никогда не подтверждался) → штатный MCUboot-revert → **Slot A стирается + автоматически**. +4. Плата остаётся в обычном цикле ожидания: `LED_HEARTBEAT` — обычный heartbeat (50/450 мс), **не** + recovery-паттерн; CDC `ping`→`pong` живой; `status: waiting_for_sd`. +5. Запросить `{"type":"cmd","cmd":"wdog"}` — `"recovered":true` (последний сброс был по watchdog). + +**Это и есть регрессионный тест находки** (см. PLAN.md, "Найден и исправлен баг: счётчик рос и на +пустой плате") — без фикса плата вместо шага 4 ушла бы в `recovery_mode` через несколько секунд +(накопленный `reset_count`), хотя ей совершенно не о чем сигнализировать: обе полки пусты, это +штатное ожидание первой установки. + +--- + +### Сценарий 2 — Класс B, есть фолбэк: подтверждённый образ виснет в рантайме + +**Подготовка** (см. предупреждение о версиях в §1 — порядок важен): +```bash +uv run --directory tools/hil pyocd flash --target mimxrt1050_quadspi --frequency 4000000 \ + --base-address 0x60040000 --erase sector "$(pwd)/build/Debug/signed/stub_a_v1_confirmed.bin" +uv run --directory tools/hil pyocd flash --target mimxrt1050_quadspi --frequency 4000000 \ + --base-address 0x60240000 --erase sector "$(pwd)/build/Debug/signed/stub_b_confirm_hang_class_b.bin" +``` + +**Ожидаемо** (один длинный power on, ничего трогать не нужно): +1. Bootloader выбирает Slot Б (v2.0.0 > v1.0.0 — активный). `LED_APP` мигает ~2/сек первые ~6 c + (видимое подтверждение "образ жив и уже подтверждён" — `CONFIRM_MODE=0` отработал почти сразу). +2. `LED_APP` застывает (после ~6 c) — зависание после confirm. Через ~10 c от последнего кормления — + WDOG-сброс. +3. Bootloader стартует заново. `image_ok` УЖЕ был `SET` до зависания (confirm состоялся) — штатный + MCUboot-revert НЕ срабатывает (это не Класс A). Slot Б снова валиден и снова активен (та же + версия) → снова выбирается → снова виснет → снова WDOG-сброс. Счётчик (`SRC_GPR3`, переживает + тёплые/watchdog-сбросы) растёт: 1, 2, 3. +4. На **третьем** сбросе подряд (`reset_count == threshold == 3`) — `recovery_decide()` находит + фолбэк (Slot A, валиден) → **Slot Б стирается**, счётчик обнуляется, `boot_go()` выбирает Slot A → + `LED_APP` начинает мигать ~1/сек (Slot A, здоров, никогда не виснет) **и остаётся так постоянно**. +5. Наблюдать по CDC (подключиться в любой момент до/во время цикла): `{"cmd":"wdog"}` → + `reset_count` растёт 1→2→3, затем после фолбэка возвращается к состоянию нормальной работы + (счётчик обнулён — следующий запрос покажет `reset_count:0`, если плата уже перезагрузилась в + рамках здорового Slot A и что-то там его обнулило явно — иначе останется как есть до следующего + события; для стаба, в отличие от tft_app, health-mark/confirm уже случились в Slot Б, Slot A — + собственный образ, его confirm/health-mark произойдут в СВОЁМ прогоне при следующем прыжке в него). +6. Общее время до фолбэка — ориентировочно 3 × (~6 c мигания + ~10 c до сброса) ≈ **45-50 c**. Не + вмешиваться, просто наблюдать. + +--- + +### Сценарий 3 — Класс B, нет фолбэка: откатываться некуда + +**Подготовка:** +```bash +uv run --directory tools/hil pyocd erase --target mimxrt1050_quadspi --frequency 4000000 --sector 0x60040000+0x200000 # Slot A пуст +uv run --directory tools/hil pyocd flash --target mimxrt1050_quadspi --frequency 4000000 \ + --base-address 0x60240000 --erase sector "$(pwd)/build/Debug/signed/stub_b_confirm_hang_class_b.bin" +``` + +**Ожидаемо:** то же, что сценарий 2, шаги 1-3 (Slot Б виснет, WDOG-сбросы, счётчик растёт до 3) — но +на третьем сбросе `recovery_decide()` НЕ находит валидного фолбэка (Slot A пуст) → +**`RECOVERY_ENTER_RECOVERY_MODE`**: +- Прыжок подавлен (Slot Б НЕ стирается — «на диске никогда не бывает нуля рабочих слотов», хотя он + и зависал). +- `LED_HEARTBEAT`+`LED_APP` — recovery-паттерн (синхронно, 100/100 мс). +- CDC: `status: recovery_mode`, повторяется каждые ~1.5 c. +- Плата **не циклится дальше** — остаётся в этом состоянии бессрочно, ждёт SD (ослабленный gate) + или BTN_2 (см. сценарий 4). + +```bash +5:55.694 -> {"type":"status","state":"recovery_mode"} +14:35:57.932 -> {"type":"status","state":"recovery_mode"} +14:35:59.128 -> `{"type":"cmd","cmd":"wdog"}` + +14:36:00.170 -> {"type":"wdog","armed":true,"timeout_s":10,"recovered":true,"reset_count":3,"threshold":3} +14:36:00.170 -> {"type":"status","state":"recovery_mode"} +14:36:02.408 -> {"type":"status","state":"recovery_mode"} +14:36:04.646 -> {"type":"status","state":"recovery_mode"} + +# Перезапуск питания +5:55.694 -> {"type":"status","state":"recovery_mode"} +14:35:57.932 -> {"type":"status","state":"recovery_mode"} +14:35:59.128 -> `{"type":"cmd","cmd":"wdog"}` + +14:36:00.170 -> {"type":"wdog","armed":true,"timeout_s":10,"recovered":true,"reset_count":3,"threshold":3} +14:36:00.170 -> {"type":"status","state":"recovery_mode"} +14:36:02.408 -> {"type":"status","state":"recovery_mode"} +14:36:04.646 -> {"type":"status","state":"recovery_mode"} + + +``` + + + +--- + +### Сценарий 4 — Recovery по BTN_2 (ручной вход) + +**Подготовка:** валидный здоровый образ в любом слоте (например, `stub_a_v1_confirmed.bin` → Slot A, +Slot Б пуст) — специально не пустая плата, чтобы подтвердить: BTN_2 подавляет загрузку ДАЖЕ +валидного образа. + +> ⚠️ **Recovery всегда ставит в Slot A (см. update_policy.h) — `TFT_APP.BIN` ОБЯЗАН быть слинкован +> под адрес Slot A.** Использовать **`stub_a_v1_confirmed.bin`**, переименованный — **НЕ** +> `stub_b_v2_confirmed.bin` (тот линкован под Slot Б, `0x60240000`; физически положенный в Slot A он +> пройдёт крипто-гейт — подпись валидна для содержимого файла независимо от адреса — но исполняться +> будет с чужими абсолютными адресами: тот же класс проблемы, что и в Фазе 3 §5, только теперь для +> recovery-пути). Симптом при ошибке: CDC покажет `installing`, затем USB CDC-порт пропадёт (прыжок +> в нерабочий код), и через несколько автоматических WDOG-циклов плата САМА уйдёт в `recovery_mode` +> — без удержания кнопки, только потому что "успешно установленный" Slot A на самом деле не работает +> и продолжает и продолжает выбираться, копя `reset_count`. + +**Ожидаемо:** +1. **Удерживая `BSP_BUTTON_2`**, подать питание → recovery-режим немедленно (LED-паттерн, CDC + `recovery_mode`), **без попытки прыжка** в валидный Slot A — приоритет BTN_2 подтверждён. +2. Не отпуская последствия (в любой момент, кнопку уже можно отпустить — она сэмплируется один раз + на старте) вставить SD-карту с `TFT_APP.BIN` = **`stub_a_v1_confirmed.bin`**, переименованный (см. + предупреждение выше — не `stub_b_v2_confirmed.bin`). +3. В течение ~1.5 c (следующий пере-скан) — CDC: `installing` → оба слота стираются, новый образ + ставится в Slot A → **авто-прыжок**, `LED_APP` начинает мигать ~1/сек (паттерн `stub_a`). + +--- + +### Сценарий 5 — Транзиент (per-session семантика счётчика) + +**Подготовка:** сразу после сценария 3 (плата в recovery, `reset_count` было 3 на момент входа). + +**Действие:** power cycle БЕЗ удержания BTN_2 (обычное выключение/включение). + +**Ожидаемо:** POR обнуляет счётчик (`bsp_boot_state_init()` детектит `IPP_RESET_B`, чистит +`SRC->SRSR`, зовёт `bsp_boot_attempt_reset()`) → Slot Б (всё ещё физически там же, всё ещё виснет — +ничего не стёрлось в сценарии 3) **пробуется заново** — bootloader НЕ помнит о прошлой серии сбросов +как о чём-то, что сразу требует recovery. Наблюдать: `LED_APP` снова мигает ~2/сек первые ~6 c (Slot +Б снова выбран и снова "жив"), затем весь цикл сценария 2/3 повторяется с нуля (счётчик 1, 2, 3...). +Это подтверждает **per-session** семантику (Вариант 1 из решений Фазы 6: «забанен» = «счётчик ≥ +порога», не отдельный флеш-маркер) — если зависание было транзиентным, POR всегда даёт новый шанс. + +--- + +### Сценарий 6 — Регрессия: не мешает норме + +Прогнать чек-лист Фазы 3 (§8 в [HARDWARE_VERIFICATION_PHASE3.md](HARDWARE_VERIFICATION_PHASE3.md)) +как есть, **и дополнительно**: +- Сценарий 1 этого документа (выше) — сам по себе уже прямой регрессионный тест на находку + «счётчик рос и на пустой плате». +- Оставить чистую плату (оба слота пусты, без SD) в цикле ожидания на 2+ минуты — `reset_count` + должен оставаться `0` бессрочно (запросить `{"cmd":"wdog"}` в начале и в конце интервала, сверить). +- Прогнать сценарий 1 Фазы 3 (чистая плата + валидный образ на SD, авто-установка) — убедиться, что + установка проходит и прыгает штатно, `reset_count` после первого успешного прыжка — `0` или `1` + (инкремент на первую же попытку прыжка в свежеустановленный образ — ожидаемо, не баг). + +--- + +## 8. Итоговая таблица + +| № | Сценарий | Ожидаемый результат | +|---|---|---| +| 1 | Класс A: никогда не confirm, виснет сразу | WDOG-сброс → штатный MCUboot revert → стирание → обычное ожидание (НЕ recovery) | +| 2 | Класс B, есть фолбэк | 3 WDOG-сброса → зависший слот стёрт → фолбэк выбран и стабилен | +| 3 | Класс B, нет фолбэка | 3 WDOG-сброса → recovery-режим (LED-паттерн, CDC `recovery_mode`), не циклится дальше | +| 4 | Recovery по BTN_2 | Подавленный прыжок → recovery → SD найдена → оба слота стёрты → установка → авто-прыжок | +| 5 | Транзиент (per-session) | POR после recovery без BTN_2 → счётчик обнулён → зависший слот пробуется заново | +| 6 | Регрессия | Чек-лист Фазы 3 + пустая плата 2+ мин без ложного recovery | diff --git a/firmware/bootloader/test_stub/main.c b/firmware/bootloader/test_stub/main.c index da77621..9379797 100644 --- a/firmware/bootloader/test_stub/main.c +++ b/firmware/bootloader/test_stub/main.c @@ -1,42 +1,160 @@ /** * @file main.c - * @brief Заглушка tft_app для аппаратной верификации Фазы 2 bootutil. + * @brief Заглушка tft_app для аппаратной верификации bootutil (Фаза 2) и + * recovery-логики (Фаза 6). * * tft_app ещё не реализована — bootloader'у некуда прыгать. Этот образ — * минимальный, но настоящий imgtool-подписанный XIP-образ с корректным - * vector table по адресу слота: единственная задача — мигать LED_APP с - * периодом, зависящим от STUB_BLINK_MS (задаётся компилятору), чтобы по - * частоте мигания визуально отличить, какой слот реально выбрал - * bootloader. См. firmware/bootloader/PLAN.md, "Аппаратная верификация - * Фазы 2". + * vector table по адресу слота: базово мигает LED_APP с периодом, + * зависящим от STUB_BLINK_MS (задаётся компилятору), чтобы по частоте + * мигания визуально отличить, какой слот реально выбрал bootloader. См. + * firmware/bootloader/PLAN.md, "Аппаратная верификация Фазы 2". * - * Без USB/CDC — визуальной индикации достаточно, минимальный код. + * Фаза 6 добавила три независимые, настраиваемые компилятором оси — + * стенд-контракт tft_app (см. PLAN.md, 6c) минимально, но по-настоящему: + * - STUB_CONFIRM_MODE — 0 сразу / 1 отложенно (STUB_CONFIRM_DELAY_MS) / + * 2 никогда. Подтверждение — boot_set_next(fap, true, true) на + * СОБСТВЕННОМ слоте (STUB_OWN_SLOT_ID), НЕ boot_set_confirmed() — та + * жёстко пишет в FLASH_AREA_IMAGE_PRIMARY (Slot A) независимо от того, + * откуда реально исполняется код: для стаба в Slot Б это подтвердило бы + * чужой слот, а не себя. + * - STUB_HANG_MODE — 0 никогда / 1 до health-mark (сразу на входе) / + * 2 после health-mark, до confirm / 3 после confirm (через + * STUB_HANG_DELAY_MS после факта подтверждения — чтобы успеть увидеть + * мигание глазами перед тем как оно застынет). + * - STUB_FEED_WDOG — 1 (дефолт, как в проде) / 0 — не кормить watchdog, + * детерминированно проверить сам механизм сброса. + * + * Без USB/CDC — визуальной индикации (частота/застывание LED_APP) достаточно + * для чек-листа Фазы 6, минимальный код. * * Watchdog: загрузчик взводит аппаратный WDOG перед прыжком сюда, а WDE — - * write-once (выключить нельзя). Поэтому заглушка ОБЯЗАНА его кормить, иначе - * WDOG сбросит плату через таймаут и получится reset-loop — заглушка тут - * играет роль tft_app, которая в проде тоже будет кормить watchdog - * (см. bsp/wdog/README.md). Период мигания (250/500 мс) << таймаута (~10 c), - * так что refresh на каждой итерации — с огромным запасом. + * write-once (выключить нельзя). Поэтому заглушка, если сконфигурирована + * его кормить (STUB_FEED_WDOG=1, дефолт), обязана делать это в главном цикле + * — иначе WDOG сбросит плату через таймаут (см. bsp/wdog/README.md). */ #include "board.h" +#include "bootutil/bootutil_public.h" +#include "bsp/boot_state.h" #include "bsp/led.h" #include "bsp/tick.h" #include "bsp/wdog.h" +#include "flash_map.h" + +#include #ifndef STUB_BLINK_MS #error "STUB_BLINK_MS must be defined (see firmware/bootloader/test_stub/CMakeLists.txt)" #endif +#ifndef STUB_OWN_SLOT_ID +#error "STUB_OWN_SLOT_ID must be defined (0 = Slot A, 1 = Slot Б)" +#endif + +/* Confirm mode: 0 = сразу, 1 = отложенно, 2 = никогда. */ +#ifndef STUB_CONFIRM_MODE +#define STUB_CONFIRM_MODE 0 +#endif + +#ifndef STUB_CONFIRM_DELAY_MS +#define STUB_CONFIRM_DELAY_MS 3000U +#endif + +/* Hang mode: 0 = никогда, 1 = до health-mark, 2 = после health-mark (до + * confirm), 3 = после confirm. */ +#ifndef STUB_HANG_MODE +#define STUB_HANG_MODE 0 +#endif + +#ifndef STUB_HANG_DELAY_MS +#define STUB_HANG_DELAY_MS 3000U +#endif + +#ifndef STUB_FEED_WDOG +#define STUB_FEED_WDOG 1 +#endif + +/** + * @brief Подтвердить СОБСТВЕННЫЙ слот (STUB_OWN_SLOT_ID). + * + * boot_set_next(fap, active=true, confirm=true) — не boot_set_confirmed(): + * та жёстко работает с FLASH_AREA_IMAGE_PRIMARY (Slot A) вне зависимости от + * того, какой слот реально исполняется; для Direct-XIP с двумя равноправными + * слотами это подтвердило бы не тот слот при исполнении из Slot Б. + */ +static void confirm_self(void) +{ + const struct flash_area *p_fap; + if (flash_area_open((uint8_t) STUB_OWN_SLOT_ID, &p_fap) == 0) + { + (void) boot_set_next(p_fap, true, true); + flash_area_close(p_fap); + } +} + int main(void) { board_hw_init(); bsp_led_init(); bsp_tick_init(); +#if STUB_HANG_MODE == 1 + for (;;) { } /* до health-mark — Класс A: свежий образ виснет сразу */ +#endif + + /* health-mark == bsp_boot_attempt_reset() (см. bsp/boot_state.h) — вызывать + * его безусловно перед потенциальным зависанием НЕЛЬЗЯ: он обнулял бы + * счётчик попыток на КАЖДОМ цикле ДО того, как зависание успевает + * засчитаться, и recovery-фолбэк/recovery-режим не сработали бы никогда + * (найдено на железе — HANG_MODE=3 резетился бесконечно вместо остановки + * на пороге). Это тот же класс "честной границы", что уже описан в + * PLAN.md (образ обнуляет счётчик, ПОТОМ виснет — таймером не отличить + * "здоров" от "здоров, но детерминированно виснет"): здесь HANG_MODE + * снимает эту неоднозначность на этапе компиляции — если конфигурация + * гарантированно виснет (2 — после health-mark, до confirm; 3 — после + * confirm), health-mark не зовём вообще, счётчик копится корректно. + * Здоровый стаб (HANG_MODE 0) и "виснет до health-mark" (1, сюда и не + * доходит) — не затронуты. */ +#if (STUB_HANG_MODE != 2) && (STUB_HANG_MODE != 3) + bsp_boot_health_mark(); /* "дошёл до устойчивого состояния" (Фаза 6, 6c) */ +#endif + +#if STUB_HANG_MODE == 2 + for (;;) { } /* после (несостоявшегося) health-mark, до confirm */ +#endif + + bool confirmed = false; + +#if STUB_CONFIRM_MODE == 0 + confirm_self(); + confirmed = true; +#endif + + uint32_t start_ms = bsp_tick_get_ms(); + (void) start_ms; /* не используется, если ни один из режимов ниже её не читает */ + while (1) { +#if STUB_FEED_WDOG bsp_wdog_refresh(); /* обслуживаем унаследованный от загрузчика WDOG */ +#endif + +#if STUB_CONFIRM_MODE == 1 + if (!confirmed && ((bsp_tick_get_ms() - start_ms) >= STUB_CONFIRM_DELAY_MS)) + { + confirm_self(); + confirmed = true; + } +#endif + +#if STUB_HANG_MODE == 3 + if (confirmed && + ((bsp_tick_get_ms() - start_ms) >= (STUB_CONFIRM_DELAY_MS + STUB_HANG_DELAY_MS))) + { + for (;;) { } /* после confirm — Класс B: подтверждённый образ виснет в рантайме */ + } +#endif + bsp_led_toggle(LED_APP); bsp_delay(STUB_BLINK_MS); } diff --git a/just/build.just b/just/build.just index 2c6a1c2..b11a1fd 100644 --- a/just/build.just +++ b/just/build.just @@ -238,8 +238,9 @@ hab-verify project="firmware_test" type="release": # ============================================================================= # ГРУППА: mcuboot-stub — заглушка tft_app для аппаратной верификации bootutil -# (Фаза 2, firmware/bootloader/PLAN.md). Временное — удалить вместе с -# firmware/bootloader/test_stub/, когда появится реальный firmware/tft_app. +# (Фаза 2) и recovery-логики (Фаза 6, firmware/bootloader/PLAN.md). Временное — +# удалить вместе с firmware/bootloader/test_stub/, когда появится реальный +# firmware/tft_app. # ============================================================================= MCUBOOT_ROOT := justfile_directory() / 'sdk/middleware/mcuboot_opensource' @@ -249,7 +250,7 @@ MCUBOOT_IMGTOOL := MCUBOOT_ROOT / 'scripts/imgtool.py' # не трогая основной venv (см. firmware/bootloader/PLAN.md, "Аппаратная верификация Фазы 2"). MCUBOOT_UV := 'uv run --with cryptography --with intelhex --with click --with cbor2 --with pyyaml python3' -[doc('Собрать и подписать заглушки Slot A/Б для аппаратной верификации bootutil (Debug)')] +[doc('Собрать и подписать заглушки Slot A/Б для аппаратной верификации bootutil и recovery (Debug)')] [group('mcuboot-stub')] build-mcuboot-stub: _configure-debug cmake --build --preset mcuboot-stub-debug @@ -263,7 +264,25 @@ build-mcuboot-stub: _configure-debug {{ MCUBOOT_UV }} "{{ MCUBOOT_IMGTOOL }}" sign \ -k "{{ MCUBOOT_KEY }}" -H 0x200 -S 0x200000 -v 1.0.0 --align 1 --pad-header --pad \ "{{ BUILD_DIR }}/Debug/test_slot_stub_a.bin" "{{ BUILD_DIR }}/Debug/signed/stub_a_v1_unconfirmed.bin" - @echo " ✅ build/Debug/signed/stub_{a,b}_*.bin готовы — прошивка через just host::flash-swd (см. PLAN.md)" + # Фаза 6, Класс A — БЕЗ --confirm: проверяем именно штатный MCUboot revert + # (copy_done без image_ok → стирание на втором boot_go()), стаб сам тоже + # никогда не подтверждается (CONFIRM_MODE=2). + {{ MCUBOOT_UV }} "{{ MCUBOOT_IMGTOOL }}" sign \ + -k "{{ MCUBOOT_KEY }}" -H 0x200 -S 0x200000 -v 1.0.0 --align 1 --pad-header --pad \ + "{{ BUILD_DIR }}/Debug/test_slot_stub_a_hang.bin" "{{ BUILD_DIR }}/Debug/signed/stub_a_hang_class_a.bin" + {{ MCUBOOT_UV }} "{{ MCUBOOT_IMGTOOL }}" sign \ + -k "{{ MCUBOOT_KEY }}" -H 0x200 -S 0x200000 -v 2.0.0 --align 1 --pad-header --pad \ + "{{ BUILD_DIR }}/Debug/test_slot_stub_b_hang.bin" "{{ BUILD_DIR }}/Debug/signed/stub_b_hang_class_a.bin" + # Фаза 6, Класс B — С --confirm: образ уже "здоров" с момента прошивки + # (стаб и сам подтвердит себя рантаймом почти сразу, CONFIRM_MODE=0, но + # подпись с --confirm исключает даже стартовое окно до этого момента). + {{ MCUBOOT_UV }} "{{ MCUBOOT_IMGTOOL }}" sign \ + -k "{{ MCUBOOT_KEY }}" -H 0x200 -S 0x200000 -v 1.0.0 --align 1 --pad-header --pad --confirm \ + "{{ BUILD_DIR }}/Debug/test_slot_stub_a_confirm_hang.bin" "{{ BUILD_DIR }}/Debug/signed/stub_a_confirm_hang_class_b.bin" + {{ MCUBOOT_UV }} "{{ MCUBOOT_IMGTOOL }}" sign \ + -k "{{ MCUBOOT_KEY }}" -H 0x200 -S 0x200000 -v 2.0.0 --align 1 --pad-header --pad --confirm \ + "{{ BUILD_DIR }}/Debug/test_slot_stub_b_confirm_hang.bin" "{{ BUILD_DIR }}/Debug/signed/stub_b_confirm_hang_class_b.bin" + @echo " ✅ build/Debug/signed/stub_*.bin готовы — прошивка через just host::flash-swd (см. PLAN.md)" # ============================================================================= # ГРУППА: quality diff --git a/tests/host/update_policy/test_update_policy.c b/tests/host/update_policy/test_update_policy.c index c053ba8..32fe997 100644 --- a/tests/host/update_policy/test_update_policy.c +++ b/tests/host/update_policy/test_update_policy.c @@ -82,7 +82,7 @@ void test_no_valid_slots_installs_to_slot_a(void) struct image_version candidate = make_version(1, 0, 0, 0); update_policy_result_t result = - update_policy_decide(&K_INVALID_SLOT, &K_INVALID_SLOT, &candidate, false); + update_policy_decide(&K_INVALID_SLOT, &K_INVALID_SLOT, &candidate, false, false); TEST_ASSERT_EQUAL(UPDATE_POLICY_INSTALL, result.action); TEST_ASSERT_EQUAL(UPDATE_POLICY_SLOT_A, result.target_slot); @@ -95,7 +95,7 @@ void test_only_slot_b_valid_targets_slot_a(void) struct image_version candidate = make_version(2, 0, 0, 0); update_policy_result_t result = - update_policy_decide(&K_INVALID_SLOT, &slot_b, &candidate, false); + update_policy_decide(&K_INVALID_SLOT, &slot_b, &candidate, false, false); TEST_ASSERT_EQUAL(UPDATE_POLICY_INSTALL, result.action); TEST_ASSERT_EQUAL(UPDATE_POLICY_SLOT_A, result.target_slot); @@ -109,7 +109,7 @@ void test_candidate_newer_installs_to_inactive_slot(void) struct image_version candidate = make_version(2, 0, 0, 0); update_policy_result_t result = - update_policy_decide(&slot_a, &K_INVALID_SLOT, &candidate, false); + update_policy_decide(&slot_a, &K_INVALID_SLOT, &candidate, false, false); TEST_ASSERT_EQUAL(UPDATE_POLICY_INSTALL, result.action); TEST_ASSERT_EQUAL(UPDATE_POLICY_SLOT_B, result.target_slot); @@ -126,7 +126,7 @@ void test_candidate_older_without_button_skips(void) struct image_version candidate = make_version(1, 0, 0, 0); update_policy_result_t result = - update_policy_decide(&slot_a, &K_INVALID_SLOT, &candidate, false); + update_policy_decide(&slot_a, &K_INVALID_SLOT, &candidate, false, false); TEST_ASSERT_EQUAL(UPDATE_POLICY_SKIP, result.action); } @@ -137,7 +137,7 @@ void test_candidate_older_with_button_forces_install(void) struct image_version candidate = make_version(1, 0, 0, 0); update_policy_result_t result = - update_policy_decide(&slot_a, &K_INVALID_SLOT, &candidate, true); + update_policy_decide(&slot_a, &K_INVALID_SLOT, &candidate, true, false); TEST_ASSERT_EQUAL(UPDATE_POLICY_INSTALL, result.action); TEST_ASSERT_EQUAL(UPDATE_POLICY_SLOT_B, result.target_slot); @@ -153,7 +153,7 @@ void test_candidate_equal_with_button_still_skips(void) struct image_version candidate = make_version(1, 0, 0, 0); update_policy_result_t result = - update_policy_decide(&slot_a, &K_INVALID_SLOT, &candidate, true); + update_policy_decide(&slot_a, &K_INVALID_SLOT, &candidate, true, false); TEST_ASSERT_EQUAL(UPDATE_POLICY_SKIP, result.action); } @@ -167,7 +167,7 @@ void test_active_slot_is_the_higher_version_when_both_valid(void) struct image_version candidate = make_version(3, 0, 0, 0); /* Активный — Б (выше версия), значит целевой слот установки — А. */ - update_policy_result_t result = update_policy_decide(&slot_a, &slot_b, &candidate, false); + update_policy_result_t result = update_policy_decide(&slot_a, &slot_b, &candidate, false, false); TEST_ASSERT_EQUAL(UPDATE_POLICY_INSTALL, result.action); TEST_ASSERT_EQUAL(UPDATE_POLICY_SLOT_A, result.target_slot); @@ -191,7 +191,56 @@ void test_forced_downgrade_targets_and_erases_the_higher_version_slot(void) /* Активный — Б (v2, выше версия). Кандидат v1 старше активного, кнопка * удержана → форс. установка в Slot A (неактивный) + Slot Б обязан быть * стёрт, иначе Slot Б (всё ещё валидный v2) снова выиграет. */ - update_policy_result_t result = update_policy_decide(&slot_a, &slot_b, &candidate, true); + update_policy_result_t result = update_policy_decide(&slot_a, &slot_b, &candidate, true, false); + + TEST_ASSERT_EQUAL(UPDATE_POLICY_INSTALL, result.action); + TEST_ASSERT_EQUAL(UPDATE_POLICY_SLOT_A, result.target_slot); + TEST_ASSERT_TRUE(result.erase_previous_active); +} + +/* ── update_policy_decide — recovery_mode (Фаза 6b, ослабленный gate) ──── + * + * recovery_mode == true игнорирует версию/кнопку целиком и всегда целится в + * Slot A с erase_previous_active == true (Slot Б стирается вызывающим + * кодом) — независимо от того, какой слот "активен" по обычным правилам. + */ + +void test_recovery_mode_installs_to_slot_a_ignoring_candidate_version(void) +{ + update_policy_slot_state_t slot_a = make_valid_slot(make_version(5, 0, 0, 0)); + update_policy_slot_state_t slot_b = make_valid_slot(make_version(9, 0, 0, 0)); + /* Кандидат СТАРШЕ обоих слотов — в обычном режиме это был бы SKIP. */ + struct image_version candidate = make_version(1, 0, 0, 0); + + update_policy_result_t result = update_policy_decide(&slot_a, &slot_b, &candidate, false, true); + + TEST_ASSERT_EQUAL(UPDATE_POLICY_INSTALL, result.action); + TEST_ASSERT_EQUAL(UPDATE_POLICY_SLOT_A, result.target_slot); + TEST_ASSERT_TRUE(result.erase_previous_active); +} + +void test_recovery_mode_targets_slot_a_even_when_slot_a_is_the_active_one(void) +{ + /* Slot A — активный (выше версия). Обычный режим целил бы в Slot Б — + * recovery всё равно должен ставить в Slot A (см. rationale в + * update_policy.h: "прежде чем что-то заменить, нужно на что менять"). */ + update_policy_slot_state_t slot_a = make_valid_slot(make_version(9, 0, 0, 0)); + update_policy_slot_state_t slot_b = make_valid_slot(make_version(1, 0, 0, 0)); + struct image_version candidate = make_version(2, 0, 0, 0); + + update_policy_result_t result = update_policy_decide(&slot_a, &slot_b, &candidate, false, true); + + TEST_ASSERT_EQUAL(UPDATE_POLICY_INSTALL, result.action); + TEST_ASSERT_EQUAL(UPDATE_POLICY_SLOT_A, result.target_slot); + TEST_ASSERT_TRUE(result.erase_previous_active); +} + +void test_recovery_mode_installs_even_with_no_valid_slots(void) +{ + struct image_version candidate = make_version(1, 0, 0, 0); + + update_policy_result_t result = + update_policy_decide(&K_INVALID_SLOT, &K_INVALID_SLOT, &candidate, false, true); TEST_ASSERT_EQUAL(UPDATE_POLICY_INSTALL, result.action); TEST_ASSERT_EQUAL(UPDATE_POLICY_SLOT_A, result.target_slot); @@ -218,5 +267,9 @@ int main(void) RUN_TEST(test_active_slot_is_the_higher_version_when_both_valid); RUN_TEST(test_forced_downgrade_targets_and_erases_the_higher_version_slot); + RUN_TEST(test_recovery_mode_installs_to_slot_a_ignoring_candidate_version); + RUN_TEST(test_recovery_mode_targets_slot_a_even_when_slot_a_is_the_active_one); + RUN_TEST(test_recovery_mode_installs_even_with_no_valid_slots); + return UNITY_END(); } diff --git a/tools/service_tui/uv.lock b/tools/service_tui/uv.lock index af4ab47..bdf9c7e 100644 --- a/tools/service_tui/uv.lock +++ b/tools/service_tui/uv.lock @@ -1126,7 +1126,7 @@ wheels = [ [[package]] name = "service-tui" -version = "0.2.0" +version = "0.2.1" source = { virtual = "." } dependencies = [ { name = "pyinstaller" },