# bootloader: Phase 4 - done

This commit is contained in:
Dmitry Akimov 2026-07-20 11:40:36 +03:00
parent acb1fed1f2
commit 6c564f1638
6 changed files with 309 additions and 24 deletions

View file

@ -8,7 +8,7 @@
| 1 — Скелет (CDC + LED) | ✅ завершена | сборка/HAB/SWD-прошивка/ping-pong/debug — все пункты верификации пройдены на реальной плате, детали ниже |
| 2 — bootutil (Direct-XIP) | ✅ завершена | host-тесты 5/5, аппаратная верификация — все 5 сценариев пройдены на реальной плате (детали и 3 найденных/исправленных бага — [DEBUG_LOG_PHASE2.md](DEBUG_LOG_PHASE2.md)) |
| 3 — SD-путь установки | ✅ полностью верифицирована (2026-07-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 (см. ниже) |
| 4 — SDRAM/W25Q smoke-test + LED-паттерны | ✅ полностью верифицирована (2026-07-20) | `bsp_sdram_configure()` (C-порт DCD) + `qspi_info` (JEDEC → чип/ёмкость) + словарь LED-паттернов. Host-тесты 16/16 (без изменений — вся новая логика аппаратная, host-тестировать нечего). Аппаратная верификация: SDRAM 4-фазный dev-тест (`sdram_test`) все фазы green; сценарии LED 1-5 из чек-листа пройдены (6 сознательно не гонялся — реальная проверка smoke-тестов идёт через service-tui сразу после прошивки, не через синтетический чек-лист). По пути найдено/исправлено 3 бага — детали ниже. Чек-лист — [test_stub/HARDWARE_VERIFICATION_LED_PATTERNS.md](test_stub/HARDWARE_VERIFICATION_LED_PATTERNS.md), словарь для сервисных инженеров — [docs/bootloader/LED_PATTERNS.md](../../docs/bootloader/LED_PATTERNS.md) |
| 5 — HAB Release + service-tui | не начата | |
| 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) |
@ -699,6 +699,101 @@ while (true)` без выхода по времени, если callback `cardDe
Полноценной аппаратной инъекции неисправностей не делаем (в проекте и для остальных HIL-тестов это не
принято) — ограничиваемся проверкой на реальной плате в штатном состоянии.
### ✅ Реализовано (2026-07-20): SDRAM/QSPI smoke-test + словарь LED-паттернов
**`bsp_sdram_configure()` — C-порт DCD.** DCD (`tools/host/dcd/dcd.bin`) — источник истины, 4 года в
производстве. Порт побитовый, не пересчитанный из формул SDK: значения регистров SEMC — дословно из
DCD, тактовая цепочка — через типизированный CCM API (лосслесс перенос значений), init-последовательность
SDRAM — через `SEMC_SendIPCommand()` (командные слова DCD `0xA55A000F/000C/000A` decode ровно в
`kSEMC_SDRAMCM_Prechargeall/AutoRefresh/Modeset`, подтверждено). Первый SEMC-блок DCD — мёртвый код
(полностью перезаписывается вторым до какого-либо использования) — не портирован. Секция «Clock Init»
DCD — наоборот НЕ дублирование: `SKIP_SYSCLK_INIT` (`bsp/CMakeLists.txt`) гасит PLL2+PFD2+SEMC-mux в
штатном `BOARD_BootClockRUN()` для всех таргетов, включая bootloader — без DCD и без своего кода эту
цепочку не поднимает никто; `bsp_sdram_configure()` обязана делать это сама (SEMC ≈135.77 МГц, PLL2
528 → PFD2 `FRAC=35` ≈271.54 → `SEMC_PODF` ÷2). Детали и вся математика — [bsp/sdram/README.md](../../bsp/sdram/README.md).
**Найденный и исправленный баг: AXI-QoS регистры валили C-код фолтом (не DCD).** Хвост DCD пишет
NIC-301 GPV-регистры приоритета шины (`0x41044100+`, `0x41442100+`). Первая гипотеза («такой периферии
на этом кристалле нет») оказалась неверной — пользователь предоставил выжимку из RM (гл. 29 "Network
Interconnect Bus System"), адреса совпали день-в-день. Настоящая причина —
[bsp/generated/board.c](../../bsp/generated/board.c) `board_mpu_init()`: Region 0 (deny-all, errata
1013783-B воркэраунд) перехватывает весь диапазон 0x0..0xFFFFFFFF, а Region 10 (периферия) покрывает
только 4 МБ от `0x40000000` — GPV-регистры на `0x41xxxxxx` мимо. DCD это переживает (пишет до включения
MPU), C-код — нет. Исправлено: Region 11 (`0x41000000`, 8 МБ, Device/AP_FULL) добавлен в
`board_mpu_init()` — общий для всех прошивок код, задет один раз, не только для bootloader.
**Dev-only глубокий тест (`{"type":"cmd","cmd":"sdram_test"}`, только Debug,
`BOOTLOADER_DEV_DIAGNOSTICS`).** [dev_sdram_test.c](dev_sdram_test.c) — 4 фазы 1:1 портированы из
`firmware_test/src/tests/test_sdram.c` (address_bus/data_bus/sequential/retention), но против нового
C-порта, не против DCD напрямую. **Все фазы зелёные на железе** — реальный прогон ~4.2 с (комментарий
в оригинале про «~30 с» оказался завышен раза в 6 — запись садится в D-Cache почти мгновенно, реальная
задержка SDRAM только на `flush_dcache()` и чтение при верификации; поправлено в коде).
**`qspi_info` — идентификация чипа QSPI.** `bsp_qspi_decode_chip()` — новая функция в
[bsp/qspi_flash](../../bsp/qspi_flash/README.md) (capacity byte JEDEC → `"W25Q64/128/256/512"` + МБ,
работает и без успешного `bsp_qspi_init()` — LUT для JEDEC грузится безусловным первым шагом внутри
неё). Ёмкость сверяется с минимумом `BOOTLOADER_FLASH_MAP.md` (16 МБ, W25Q128) — не только
идентификация, а реальный BOM/QC-гейт: W25Q64 драйвер поддерживает технически, но для карты флеша этой
платы это неверная деталь, а не "чуть меньше запас".
**Smoke-test в `main.c`** (после `bsp_qspi_init()`/`bsp_usb_cdc_init()`, до `attempt_boot()`,
неблокирующий): `bsp_sdram_configure()`+`bsp_sdram_init()` и чтение JEDEC. **Найдена и исправлена
проблема видимости**: результаты (`smoke_pass`/`smoke_fail`, `qspi_info`) изначально шлются один раз
сразу после соответствующего `init()` — хост почти никогда не успевает открыть порт к этому моменту
(USB enumeration), `cli_send()` неблокирующий и теряет запись. Решение — тот же паттерн, что уже был у
`wdog`: `protocol_set_smoke_result()`/`protocol_set_qspi_info()` кэширует исход,
`protocol_send_smoke_status()`/`protocol_send_qspi_info()` переспрашивает в любой момент сессии по
командам `"smoke_status"`/`"qspi_info"`.
**Словарь LED-паттернов.** Новый модуль [led_status.{c,h}](src/led_status.c) — единственный владелец
всех паттернов. Состояния: норма (ждём SD, APP выкл) / установка (APP соло 250/250) / железо не в
порядке (объединяет `!qspi_ok`, недостаточную ёмкость QSPI и провал SDRAM smoke-test в один паттерн
APP соло 100/100 — деталь причины только по CDC) / recovery (оба LED синхронно 100/100, без изменений
с Фазы 6) / разовая вспышка 4×(80/80) на отклонённый SD-образ. Полное описание для сервисных
инженеров, без ссылок и деталей реализации — [docs/bootloader/LED_PATTERNS.md](../../docs/bootloader/LED_PATTERNS.md).
Паттерн «установка» рисуется изнутри самой (блокирующей) установки — окно `led_status_install_begin/end()`
в `sd_update.c`, `led_status_tick_install()` (no-op вне окна) вызывается и из цикла копирования чанков,
и из поблочного стирания слота в `flash_map_backend.c` (общий файл — понадобилась правка
`test_stub/CMakeLists.txt`, добавить include-path и сам `led_status.c` в `add_mcuboot_stub()`, иначе
`flash_map_backend.c` там не собирался).
**Найденное и задокументированное (не баг, ограничение архитектуры XIP-safety): APP подвисает во время
стирания блока.** Аппаратно проверено — во время ~150 мс стирания каждого 64 КБ блока
(`bsp_qspi_erase_block_64k()`) паттерн «установка» не гладкий 250/250, а дёрганый/подвисающий.
Причина: `qspi_irq_lock()` держит `__disable_irq()` на всю длительность IP-команды (обязательно —
иначе прерывание может зафетчить код из flash прямо во время стирания → HardFault, см.
[bsp/qspi_flash/README.md](../../bsp/qspi_flash/README.md), «XIP-безопасность»), а под этим глушится и
SysTick, на котором целиком держится `bsp_tick_get_ms()` (`bsp/tick/src/tick.c`) — часы паттерна на
это время фактически стоят. Во время копирования (страницы по ~3 мс) то же самое явление на порядок
короче — там мигание заметно ровнее. Осознанно не чинится — маскировать IRQ на время IP-команды нужно
по конструкции. Задокументировано в `@note` у `led_status_tick_install()` и в чек-листе.
**Найденное и подтверждённое штатным (не баг): повтор отклонения при вставленной плохой SD-карте.**
Раз файл на карте не меняется, каждый периодический пере-скан (`SD_RETRY_PERIOD_MS`, Фаза 3) заново
получает `SD_CANDIDATE_INVALID` — LED-вспышка и CDC-ошибка повторяются каждые ~1.5 с, пока карта не
вынута. Это взаимодействие с уже существующей с Фазы 3 логикой периодического пере-скана, не регрессия
этой фазы — оставлено как есть.
**Аппаратная верификация** —
[test_stub/HARDWARE_VERIFICATION_LED_PATTERNS.md](test_stub/HARDWARE_VERIFICATION_LED_PATTERNS.md):
сценарии 1-5 (норма / битый файл / неверная подпись / успешная установка / recovery) пройдены на
плате. Сценарий 6 (искусственная неисправность через временный порог в коде) сознательно не прогонялся
— реальная проверка smoke-тестов на производстве идёт через service-tui сразу после прошивки
загрузчика, не через синтетический чек-лист.
**Host-тесты**: `just build::test-host` — по-прежнему **16/16** (новых нет: вся логика этой фазы —
тонкая аппаратная оркестрация без доступа к железу не протестируешь, толстой host-тестируемой логики
здесь не появилось — соответствует принципу проекта, см. низ файла).
**ARM-сборка**: `just build::build-bootloader-debug`/`hab-bootloader-debug` и
`build-bootloader-release`/`hab-bootloader-release` — все зелёные. `m_text`: Debug 96512 Б из 247 КБ
(38.16%, было 90576 Б / 35.69% после Фазы 6), Release 47232 Б (18.67%) — запас по-прежнему большой.
**Не сделано, сознательно отложено**: событие `"booting"` на CDC (упоминалось в исходном плане) — не
реализовано; ценность низкая (переход к прыжку — микросекунды, отдельным CDC-событием почти нечего
добавить к уже наблюдаемому факту прыжка), добавим по факту реальной необходимости, не заранее.
---
## Фаза 5 — HAB, релизные пресеты, интеграция в service-tui

View file

@ -61,8 +61,10 @@ watchdog приостановлен, пошаговая отладка сбро
## Архитектура
Загрузчик работает **без SDRAM** (SEMC поднимает само приложение в своём раннем startup) и без
дисплея/RTOS: инициализация, доступ к QSPI-flash, чтение FatFS с SD, проверка и выбор образа, прыжок.
Загрузчик **не зависит от SDRAM** для своей работы (XIP только из W25Q, без DCD) и без дисплея/RTOS:
инициализация, доступ к QSPI-flash, чтение FatFS с SD, проверка и выбор образа, прыжок. SEMC/SDRAM
трогаются только диагностически (`bsp_sdram_configure()`, boot-time smoke-test) — реально их поднимает
для себя уже само приложение в своём раннем startup.
Линкер жёстко ограничивает код бюджетом области загрузчика (256 КБ) с `ASSERT` на границу Slot A —
превышение становится ошибкой сборки, а не тихим заездом в чужую область.
@ -80,6 +82,8 @@ firmware/bootloader/
│ │ установить в целевой слот с потоковой verify-записью
│ ├── cli.* — построчный IO + диспетчеризация команд
│ ├── protocol.* — сериализация исходящих событий
│ ├── led_status.* — словарь LED-паттернов (см. ../../docs/bootloader/LED_PATTERNS.md)
│ ├── dev_sdram_test.* — [DEV-ONLY, Debug] глубокий тест SDRAM по команде "sdram_test"
│ └── version.h.in — шаблон версии (CMake → generated/version.h)
├── mcuboot_port/ — интеграция bootutil (MCUboot) поверх bsp_qspi_flash:
@ -96,30 +100,44 @@ firmware/bootloader/
## Протокол
USB CDC ACM, JSON-строки, максимум 128 байт на строку.
USB CDC ACM, JSON-строки. Входящая строка — до `CLI_LINE_BUF_SIZE` (128) байт, исходящее сообщение —
до `PROTO_BUF_SIZE` (192) байт (несимметрично: `qspi_info`/`sdram_test` длиннее старых сообщений).
**Команды хоста:**
| Команда | Ответ |
| ------------------------------------ | ------------------------------------------------------------------------------------- |
| `{"type":"cmd","cmd":"ping"}` | `{"type":"pong"}` |
| `{"type":"cmd","cmd":"get_version"}` | `{"type":"version_response","fw":"X.Y.Z"}` |
| `{"type":"cmd","cmd":"wdog"}` | `{"type":"wdog","armed":…,"timeout_s":…,"recovered":…,"reset_count":…,"threshold":…}` |
| Команда | Ответ |
| ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| `{"type":"cmd","cmd":"ping"}` | `{"type":"pong"}` |
| `{"type":"cmd","cmd":"get_version"}` | `{"type":"version_response","fw":"X.Y.Z"}` |
| `{"type":"cmd","cmd":"wdog"}` | `{"type":"wdog","armed":…,"timeout_s":…,"recovered":…,"reset_count":…,"threshold":…}` |
| `{"type":"cmd","cmd":"smoke_status"}` | `{"type":"status","state":"smoke_pass"}` / `"smoke_fail"` — результат boot-time smoke-теста SDRAM/SEMC (переспрос, см. ниже) |
| `{"type":"cmd","cmd":"qspi_info"}` | `{"type":"qspi_info","chip":"W25Q128","mfr":"0xEF","cap_byte":"0x18","size_mb":16,"pass":true}` (переспрос, см. ниже) |
| `{"type":"cmd","cmd":"sdram_test"}` <br> **[DEV-ONLY, Debug-сборка]** | серия из 6 `{"type":"sdram_test","phase":"…","pass":…,"duration_ms":…,"fail_addr":"…","expected":"…","got":"…"}` (`configure`/`address_bus`/`data_bus`/`sequential`/`retention`/`summary`) — блокирует главный цикл на ~4 с. Нет в Release/HAB (`BOOTLOADER_DEV_DIAGNOSTICS`) |
`smoke_status`/`qspi_info` ничего не отвечают, если соответствующий boot-time чек ещё не отработал —
в штатной последовательности `main.c` такого не бывает.
**Исходящие статусы** `{"type":"status","state":"…"}`:
| Состояние | Когда |
| ---------------- | ------------------------------- |
| `waiting_for_sd` | нет валидного слота, ждём карту |
| `installing` | идёт запись образа в слот |
| `update_skipped` | кандидат отклонён по версии |
| `recovery_mode` | плата в режиме восстановления |
| Состояние | Когда |
| ---------------- | ------------------------------------------ |
| `waiting_for_sd` | нет валидного слота, ждём карту |
| `installing` | идёт запись образа в слот |
| `update_skipped` | кандидат отклонён по версии |
| `recovery_mode` | плата в режиме восстановления |
| `smoke_pass` | boot-time smoke-тест SDRAM/SEMC прошёл |
| `smoke_fail` | boot-time smoke-тест SDRAM/SEMC провалился |
**Ошибки** `{"ok":false,"error":"…"}`: `SD_CANDIDATE_INVALID`, `SD_INSTALL_WRITE_FAILED`,
`SD_INSTALL_REJECTED`, `SD_DOWNGRADE_ERASE_FAILED`, `PARSE_ERR`, `UNKNOWN_CMD`, `LINE_TOO_LONG`.
Событие `wdog` эмитится также автоматически один раз на старте, если предыдущий сброс был по
watchdog (`recovered:true`).
**Автоматически на старте, без команды:** `wdog` — если предыдущий сброс был по watchdog
(`recovered:true`); `qspi_info` и `smoke_pass`/`smoke_fail` — сразу после соответствующей проверки.
Все три — best-effort: хост почти никогда не успевает открыть порт к этому моменту (USB enumeration),
поэтому у `qspi_info`/`smoke_status` (но не у одноразового boot-time `wdog`) есть команда-переспрос
в таблице выше.
LED-индикация, соответствующая этим состояниям, — [LED_PATTERNS.md](../../docs/bootloader/LED_PATTERNS.md).
---

View file

@ -153,10 +153,13 @@ int flash_area_erase(const struct flash_area *area, uint32_t off, uint32_t len)
* Зависание самого стирания флеша всё равно ловится: refresh по
* ЗАВЕРШЕНИИ блока, а не перед ним. */
bsp_wdog_refresh();
/* Прогресс-хук индикации — no-op вне окна установки (см.
* led_status.h). Нужен, чтобы APP не замирал на ~5 c стирания слота
* при установке; на revert/recovery-стирании (тоже зовут эту
* функцию) ничего не рисует. */
/* Прогресс-хук индикации — no-op вне окна установки, ничего не
* рисует на revert/recovery-стирании (та же функция). Вызывается
* исправно каждый блок, но САМ блок (~150 мс) идёт под
* qspi_irq_lock() глушит SysTick, от которого тикает
* bsp_tick_get_ms(); на глаз это видно как подвисание/дёрганое
* мигание, не гладкие 250/250 (известное ограничение, см. @note
* у led_status_tick_install() в led_status.h). */
led_status_tick_install();
if (bsp_qspi_erase_block_64k(block_addr) != BSP_OK)
{

View file

@ -47,8 +47,21 @@ void led_status_install_end(void);
* APP 250/250). No-op вне окна install.
*
* Звать из ВСЕХ блокирующих циклов установки и поблочного стирания слота
* (flash_area_erase, ~5 c на 2 МБ), и копирования чанков (sd_update), иначе
* APP замирал бы на время стирания.
* (flash_area_erase, ~5 c на 2 МБ), и копирования чанков (sd_update).
*
* @note [ИЗВЕСТНОЕ ОГРАНИЧЕНИЕ, подтверждено на железе] Во время самого
* стирания блока (~150 мс на 64 КБ, bsp_qspi_erase_block_64k())
* qspi_irq_lock() держит __disable_irq() на всю длительность busy-wait
* это глушит и SysTick, на котором держится bsp_tick_get_ms()
* (см. bsp/tick/src/tick.c). Часы, от которых считается фаза мигания,
* не идут внутри каждого такого окна тик вызывается исправно между
* блоками, но «сейчас» между вызовами почти не меняется, поэтому
* глазом видно подвисание/дёрганое мигание, а не плавные 250/250.
* Во время копирования чанков (страницы по ~3 мс) окна намного короче
* там мигание заметно более гладкое. Осознанно не чиним (означало бы
* не маскировать IRQ на время IP-команды прямой путь к HardFault,
* см. bsp/qspi_flash/README.md, «XIP-безопасность»); фиксируем как
* факт в HARDWARE_VERIFICATION_LED_PATTERNS.md.
*/
void led_status_tick_install(void);

View file

@ -34,9 +34,16 @@ function(add_mcuboot_stub)
${BSP_STARTUP_FILE}
${BSP_SYSCALLS_FILE}
${CMAKE_SOURCE_DIR}/firmware/bootloader/mcuboot_port/flash_map_backend.c
${CMAKE_SOURCE_DIR}/firmware/bootloader/src/led_status.c
${MCUBOOT_BOOTUTIL_DIR}/src/bootutil_public.c)
target_include_directories(${ARG_NAME} PRIVATE ${MCUBOOT_BOOTUTIL_INCLUDES})
# led_status.h — flash_map_backend.c (общий с bootloader) зовёт
# led_status_tick_install() из поблочного erase (см. led_status.h) — не
# добавляет стабу нового поведения (окно установки здесь никогда не
# открыто, tick — no-op), просто нужен для компиляции общего файла.
target_include_directories(
${ARG_NAME} PRIVATE ${MCUBOOT_BOOTUTIL_INCLUDES}
${CMAKE_SOURCE_DIR}/firmware/bootloader/src)
target_compile_definitions(
${ARG_NAME}

View file

@ -0,0 +1,149 @@
# Аппаратная верификация — словарь LED-паттернов (Фаза 4)
Чек-лист проверяет `led_status.c` на реальной плате: 5 сценариев из
[docs/bootloader/LED_PATTERNS.md](../../docs/bootloader/LED_PATTERNS.md) + опционально «неисправность
железа» (требует временной правки кода — раздел 6). Между сценариями 1-3 SWD-перепрошивка/power cycle
не нужны — бутлоадер сам пере-сканирует SD раз в ~1.5 с, доставать/менять файл на карте можно на ходу.
---
## 0. Подготовка (один раз)
```bash
# Собрать и прошить bootloader
just build::build-bootloader-debug
just host::flash-swd-bootloader-debug
# ОБЯЗАТЕЛЕН power cycle платы после прошивки — SWD не ресетит автоматически
# Собрать подписанные образы-заглушки (test_stub) — понадобится stub_a_v1_confirmed.bin
just build::build-mcuboot-stub
ls build/Debug/signed/ # ожидаем stub_a_v1_confirmed.bin, stub_b_v2_confirmed.bin, ...
# Стереть оба слота — чистая плата для сценариев 1-4
uv run --directory tools/hil pyocd erase --target mimxrt1050_quadspi --frequency 4000000 \
--sector 0x60040000+0x200000
uv run --directory tools/hil pyocd erase --target mimxrt1050_quadspi --frequency 4000000 \
--sector 0x60240000+0x200000
# Подготовить 2 тестовых файла для SD-карты (кладём рядом, на карту переносим по одному под именем TFT_APP.BIN)
head -c 512 /dev/urandom > /tmp/TFT_APP_garbage.bin
cp build/Debug/signed/stub_a_v1_confirmed.bin /tmp/TFT_APP_badsig.bin
python3 -c "
with open('/tmp/TFT_APP_badsig.bin', 'r+b') as f:
f.seek(0x1000) # далеко за 32-байтным заголовком — magic/версия целы
b = f.read(1)
f.seek(0x1000)
f.write(bytes([b[0] ^ 0xFF])) # один перевёрнутый байт ломает hash/подпись
"
# Открыть монитор порта — держать открытым все сценарии, смотреть и на CDC, и на LED
just host::uart-monitor
```
Power cycle после прошивки → плата должна войти в сценарий 1 сама.
---
## 1. Норма — ждём SD
**Подготовка:** SD-карта НЕ вставлена (или без файла `TFT_APP.BIN`).
**Смотреть:** HEARTBEAT мигает 50 мс горит / 450 мс не горит. APP не горит вообще.
**В мониторе:** каждые ~1.5 с`{"type":"status","state":"waiting_for_sd"}`.
---
## 2. Образ отклонён — битый файл (`SD_CANDIDATE_INVALID`)
**Подготовка:** на SD-карту, под именем `TFT_APP.BIN`, положить `/tmp/TFT_APP_garbage.bin`. Вставить
карту (если уже была вставлена — просто заменить файл, ждать до ~1.5 с).
**Смотреть:** APP делает ровно 4 быстрых вспышки (80 мс горит / 80 мс не горит), затем гаснет —
плата возвращается к паттерну «Норма» (пункт 1). HEARTBEAT всё это время не меняется.
**В мониторе:** `{"ok":false,"error":"SD_CANDIDATE_INVALID"}`, следом снова `waiting_for_sd`.
---
## 3. Образ отклонён — неверная подпись (`SD_INSTALL_REJECTED`)
**Подготовка:** заменить файл на SD-карте на `/tmp/TFT_APP_badsig.bin` (переименовать в `TFT_APP.BIN`).
**Смотреть:** сначала на секунду-две APP переходит в паттерн «Установка» (250/250, см. пункт 4 ниже —
файл пишется в Slot A, только потом проверка подписи проваливается), затем — те же 4 быстрых вспышки,
затем возврат к «Норме».
**В мониторе:** `{"type":"status","state":"installing"}`, затем `{"ok":false,"error":"SD_INSTALL_REJECTED"}`,
следом снова `waiting_for_sd`.
---
## 4. Установка — успешная (включая стирание слота)
**Подготовка:** заменить файл на SD-карте на `build/Debug/signed/stub_a_v1_confirmed.bin` (переименовать
в `TFT_APP.BIN`). Это единственный образ, который можно ставить на чистую плату (см. §5.1
`PLAN.md` — цель установки всегда Slot A, `stub_a_*` слинкован под её адрес; `stub_b_*` здесь
использовать нельзя — прыгнет в нерабочий код).
**Смотреть:**
1. APP переходит в 250 мс вкл / 250 мс выкл.
2. Пока идёт стирание слота (~5 с) — мигание подвисает/дёргается, ровным 250/250 не выглядит.
**Это ожидаемо, не баг**: `bsp_qspi_erase_block_64k()` держит `__disable_irq()` на все ~150 мс
каждого блока — глушится и SysTick, от которого тикает `bsp_tick_get_ms()`, часы паттерна на это
время фактически стоят. Известное ограничение, см. `@note` у `led_status_tick_install()`
(`led_status.h`) — сознательно не чиним (означало бы не маскировать IRQ на время IP-команды
стирания, прямой путь к HardFault на XIP).
3. Во время копирования (после стирания, несколько секунд) — мигание заметно более гладкое, чем на
шаге 2: там каждая запись страницы держит IRQ всего ~3 мс, а не ~150 мс.
4. По завершении — оба LED гаснут (бутлоадер передал управление), затем APP начинает мигать в паттерне
самого `test_stub` (500 мс вкл / 250 мс выкл) — это уже не бутлоадер, а другая прошивка.
**В мониторе:** `{"type":"status","state":"installing"}`, дальше тишина по протоколу бутлоадера (CDC
переходит под управление `test_stub`, если он вообще что-то шлёт).
---
## 5. Recovery Mode
**Подготовка:** удержать `BSP_BUTTON_2` в момент подачи питания (после сценария 4 в Slot A уже валидный
образ — это не мешает, `BTN 2` проверяется раньше выбора слота).
**Смотреть:** оба светодиода горят и гаснут ОДНОВРЕМЕННО: 100 мс горят / 100 мс не горят.
**В мониторе:** `{"type":"status","state":"recovery_mode"}`.
**Дополнительно (не обязательно):** если в этот момент вставить SD с валидным образом — на несколько
секунд паттерн переключится на «Установка» (250/250, как в пункте 4), затем плата стирает оба слота и
прыгает. Это ожидаемо, не баг: пока установка идёт, экран рисует именно она, recovery-паттерн
возвращается только если установка не состоялась.
---
## 6. (Опционально) Неисправность железа
Настоящую неисправность QSPI/SDRAM на живой плате не устроить без пайки — этот пункт использует
временную правку одной строки, чтобы искусственно провалить проверку ёмкости QSPI. **Не забыть
откатить после проверки.**
**Подготовка:**
1. В `firmware/bootloader/src/main.c` найти строку:
```c
const uint32_t QSPI_MIN_FLASH_SIZE_MB = 16U;
```
Временно поднять порог выше реальной ёмкости чипа на плате (если на плате W25Q128 — 16 МБ,
поставить, например, `32U`).
2. Пересобрать и прошить: `just build::build-bootloader-debug && just host::flash-swd-bootloader-debug`.
3. Power cycle.
**Смотреть:** HEARTBEAT мигает как обычно (50/450). APP мигает быстро без остановки: 100 мс горит /
100 мс не горит — и не останавливается (устойчиво до следующего POR).
**В мониторе:** `{"type":"qspi_info","chip":"W25Q128","...","pass":false}`.
**Откатить обязательно:**
1. Вернуть строку в `main.c` к `16U`.
2. Пересобрать и прошить заново: `just build::build-bootloader-debug && just host::flash-swd-bootloader-debug`.
3. Power cycle, убедиться что плата снова показывает «Норма» (пункт 1) — не оставлять плату/дерево
с искусственно применённым порогом.