# bootloader: Phase 6 - done
This commit is contained in:
parent
cc6f29b8f4
commit
8cb1cb143c
16 changed files with 1333 additions and 633 deletions
|
|
@ -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"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
|
|
|||
299
firmware/bootloader/BOOT_FLOW.md
Normal file
299
firmware/bootloader/BOOT_FLOW.md
Normal file
|
|
@ -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 КБ<br/>0x60000000"]
|
||||
SA["Slot A — 2 МБ<br/>0x60040000"]
|
||||
SB["Slot Б — 2 МБ<br/>0x60240000"]
|
||||
FS["Файловая система ассетов<br/>0x60440000 … конец чипа"]
|
||||
BL --- SA --- SB --- FS
|
||||
```
|
||||
|
||||
**Приложение исполняется прямо из flash** (XIP) — из того слота, который выбрал
|
||||
загрузчик, без копирования в ОЗУ. Из этого следует ключевое свойство: образ жёстко привязан к адресу
|
||||
своего слота при сборке. Поэтому **релиз приложения — это два бинарника на одну версию**: один собран
|
||||
под адрес Slot A, второй — под адрес Slot Б. Образ, физически положенный не в «свой» слот, пройдёт
|
||||
проверку подписи, но не запустится.
|
||||
|
||||
Два слота нужны для безопасного обновления: пока приложение работает из одного слота, новый образ
|
||||
пишется в другой. Рабочая копия никогда не затирается — на диске всегда есть чем загрузиться, даже
|
||||
если обновление прервётся на середине.
|
||||
|
||||
---
|
||||
|
||||
## 2. Выбор образа при старте
|
||||
|
||||
На каждой подаче питания загрузчик решает, из какого слота запускать приложение.
|
||||
|
||||
**Слот считается кандидатом, только если он валиден целиком**: корректная сигнатура формата, целый
|
||||
хэш содержимого и верная криптографическая подпись (ECDSA-P256). Битый или неподписанный слот
|
||||
игнорируется.
|
||||
|
||||
Правила выбора:
|
||||
- Оба слота валидны → активным становится слот с **большей версией**.
|
||||
- Валиден только один → он и активен.
|
||||
- Ни одного валидного → активного слота нет, загрузчик переходит в ожидание microSD.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
START([Подача питания]) --> CHECK["Проверить оба слота:<br/>сигнатура + хэш + подпись"]
|
||||
CHECK --> CMP{Сколько валидных?}
|
||||
CMP -->|Оба| HIGHER["Активный = слот<br/>с большей версией"]
|
||||
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["Пик заголовка:<br/>сигнатура + версия"]
|
||||
HDR -->|Сигнатура битая| REJ1["Отклонить<br/>(candidate invalid)"]
|
||||
HDR -->|OK| DEC{Решение по версии}
|
||||
DEC -->|Ставить| WRITE["Записать в НЕактивный слот:<br/>стереть → поток + verify"]
|
||||
DEC -->|Пропустить| SKIP["Пропустить<br/>(update skipped)"]
|
||||
WRITE --> CRYPTO{"Крипто-проверка<br/>записанного слота"}
|
||||
CRYPTO -->|OK| DONE([Установлено → загрузка])
|
||||
CRYPTO -->|Подпись битая| REJ2["Отклонить<br/>(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{"Счётчик сбросов<br/>≥ порога (3)?"}
|
||||
CNT -->|Нет| NORM[Обычная загрузка]
|
||||
CNT -->|Да| FB{Второй слот валиден?}
|
||||
FB -->|Да| ERASE["Стереть зависший слот →<br/>обнулить счётчик →<br/>загрузить второй"]
|
||||
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` | Счётчик обнулён, зависший слот пробуется заново |
|
||||
|
|
@ -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 — см.
|
||||
явные пометки).
|
||||
|
||||
<details><summary>История расследования (раунды 1–2, выводы частично опровергнуты)</summary>
|
||||
|
||||
**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.**
|
||||
|
||||
</details>
|
||||
|
||||
Этот файл — снимок состояния расследования на момент передачи в отдельный тред. Код Фазы 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 → <signal handler called> → 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) содержит `<pin peripheral="USDHC1" signal="usdhc_cd_b" .../>` для
|
||||
этого пина — маршрутизация на уровне самого 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) — судя по всему, файл планировался, но не был
|
||||
создан. Не тема этого лога, но стоит либо создать его отдельно, либо убрать ссылки, когда дойдут руки.
|
||||
|
|
@ -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, если прежнего нет).
|
||||
|
|
|
|||
|
|
@ -68,14 +68,19 @@
|
|||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
|
||||
/* ── Одна попытка загрузки: 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:
|
||||
/* Инкремент только если действительно ЕСТЬ что пытаться загрузить
|
||||
* (слот уже валиден, либо только что установлен этим же вызовом) —
|
||||
* иначе на чисто пустой плате без SD счётчик рос бы и на пустом
|
||||
* месте, и через threshold попыток (несколько секунд) recovery_decide()
|
||||
* ошибочно увела бы в recovery-режим при отсутствии какого-либо
|
||||
* реального зависания (регрессия к сценарию 4 Фазы 3 — "оба слота
|
||||
* пусты" должен оставаться в обычном ожидании SD бесконечно). */
|
||||
if (installed || slot_a.valid || slot_b.valid)
|
||||
{
|
||||
bsp_boot_attempt_inc(); /* перед попыткой — см. bsp/boot_state.h */
|
||||
bsp_wdog_refresh(); /* образ унаследует полное окно таймаута */
|
||||
boot_select_and_jump(); /* при успехе не возвращается */
|
||||
}
|
||||
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,6 +251,14 @@ int main(void)
|
|||
bsp_usb_cdc_poll();
|
||||
cli_process();
|
||||
|
||||
if (in_recovery)
|
||||
{
|
||||
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
|
||||
{
|
||||
uint32_t heartbeat_phase_ms = bsp_tick_get_ms() % HEARTBEAT_PERIOD_MS;
|
||||
if (heartbeat_phase_ms < HEARTBEAT_ON_MS)
|
||||
{
|
||||
|
|
@ -209,14 +268,15 @@ int main(void)
|
|||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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_ */
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 — то
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
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
|
||||
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 $<TARGET_FILE:${NAME}>
|
||||
${CMAKE_BINARY_DIR}/${NAME}.bin
|
||||
COMMAND ${CMAKE_SIZE} $<TARGET_FILE:${NAME}>
|
||||
COMMENT "Generating ${NAME}.bin")
|
||||
COMMAND ${CMAKE_OBJCOPY} -O binary $<TARGET_FILE:${ARG_NAME}>
|
||||
${CMAKE_BINARY_DIR}/${ARG_NAME}.bin
|
||||
COMMAND ${CMAKE_SIZE} $<TARGET_FILE:${ARG_NAME}>
|
||||
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)
|
||||
|
|
|
|||
330
firmware/bootloader/test_stub/HARDWARE_VERIFICATION_PHASE6.md
Normal file
330
firmware/bootloader/test_stub/HARDWARE_VERIFICATION_PHASE6.md
Normal file
|
|
@ -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":<bool>,"reset_count":<N>,"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 |
|
||||
|
|
@ -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 <stdbool.h>
|
||||
|
||||
#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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1126,7 +1126,7 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "service-tui"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "pyinstaller" },
|
||||
|
|
|
|||
Loading…
Reference in a new issue