# tft-app — задачи FreeRTOS и их взаимодействие Документ описывает **реализованную** (Фаза 3.2.4) многозадачную структуру `tft_app`: какие задачи существуют, кто кого создаёт, как они обмениваются данными и почему приоритеты именно такие. Контракт между задачами — [app_tasks.h](../../firmware/tft_app/src/app/app_tasks.h) (единственный источник истины по приоритетам/разделяемому состоянию); тела задач — `firmware/tft_app/src/app/task_*.c`. Структура выстрадана на HW-верификации (два боевых регресса — см. PLAN.md, Фаза 3.2.4): объединение меню и рендера в одну задачу блокировало ввод, а неверный относительный приоритет `sul_rx`/`render` морозил экран при отсутствии CAN-трафика. Эталон разделения — `OLD_PROJECT_TFT8_UKL` (`BUTTONS_TASK` отдельно от `REFRESH_TASK`). --- ## 1. Состав | Задача | Файл | Приоритет (`app_tasks.h`) | Роль | Блокировки | | --- | --- | --- | --- | --- | | `bringup_task` | task_bringup.c | `+4` (высший, недолгоживущая) | одноразовая инициализация → создаёт три остальные → `vTaskDelete(NULL)` | QSPI/flash-операции | | `menu_task` | task_menu.c | `+3` | модель меню: кнопки, вход/навигация/правка/сохранение. **НЕ рисует** | `settings_store_save()` (flash) на выходе из меню | | `render_task` | task_render.c | `+2` | **единственный** владелец дисплея и вызывающий `gfx_present*()` | PXP busy-wait + ожидание FRAME_DONE | | `sul_rx_task` | task_sul_rx.c | `+1` (низший) | приём CAN → decode → controller; WDOG/heartbeat | busy-spin в `bsp_can_receive()` до 100 мс без трафика | | — демон таймеров | (FreeRTOS) | `configTIMER_TASK_PRIORITY` (высший в системе) | `input_poll_cb` каждые 5 мс: `bsp_button_poll()` (debounce) → `bsp_opto_process()` (debounce IN1/IN2) → `dispatcher_poll()` (§3.4, `app/dispatcher.c` — безусловный опрос `bsp_opto_read()`, НЕ колбэк, см. PLAN.md про баг реактивной версии; пишет `g_dispatcher_indication` и будит `render_task` прямо отсюда при изменении) | нет (все три коротких) | `main()` создаёт только очередь, софт-таймер ввода и `bringup_task` — остальное wiring делает сам `bringup_task`. --- ## 2. Старт системы ```mermaid sequenceDiagram participant M as main() participant B as bringup_task (+4) participant R as render_task (+2) participant S as sul_rx_task (+1) participant U as menu_task (+3) M->>M: board_hw_init, очередь, таймер ввода M->>B: xTaskCreate + vTaskStartScheduler B->>B: log_mutex → UART/лог → QSPI → settings → self-confirm B->>B: SDRAM → gfx (PXP+ELCDIF) → CAN B->>B: g_display_ready = true B->>R: xTaskCreate (хэндл → g_render_task_handle) B->>S: xTaskCreate B->>U: xTaskCreate B->>B: vTaskDelete(NULL) R->>R: первый кадр («--») + gfx_present() ``` Порядок создания (render первым) документирует зависимость: его хэндл нужен продюсерам для `xTaskNotifyGive`. Формально гонки нет — `bringup_task` выше всех по приоритету и монополизирует CPU, пока не создаст всех троих. --- ## 3. Взаимодействие (MPSC) Три продюсера, один консюмер (третий — dispatcher opto, §3.4). **Данные** и **сигнал пробуждения** разделены: ```mermaid flowchart LR TMR["демон таймеров
input_poll_cb 5 мс
bsp_button_poll + bsp_opto_process"] MT["menu_task (+3)
модель меню g_menu"] RX["sul_rx_task (+1)
CAN→decode→controller
WDOG безусловно"] RT["render_task (+2)
gfx_present*()"] DSP["dispatcher_poll()
(app/dispatcher.c)
g_dispatcher_indication"] TMR -."залатанные события кнопок".-> MT TMR -."безусловный опрос, каждый тик".-> DSP MT -->|"xTaskNotifyGive
(любое изменение)"| RT RX -->|"xQueueOverwrite (render_msg_t)
+ xTaskNotifyGive"| RT DSP -->|"xTaskNotifyGive
(изменение вызов/ответ)"| RT MT -."g_menu_active (мягкая пауза)".-> RX ``` - **Очередь `g_render_queue`** (глубина 1, `xQueueOverwrite`) — только плечо `sul_rx→render`, несёт `render_msg_t` (diff + результат). Семантика «важно только последнее состояние»: рендер не обязан успевать за каждым кадром CAN. У dispatcher СВОЕЙ очереди нет — состояние (`g_dispatcher_indication`) не история, читается напрямую (как `g_menu`), очередь тут не нужна (сравнение с прошлым значением — внутри `render_task`). - **`ulTaskNotifyTake(pdTRUE, portMAX_DELAY)`** в `render_task` — event-driven, без поллинга; несколько notify от ЛЮБОГО из трёх продюсеров схлопываются в одно пробуждение (та же семантика «важно только последнее»). `render_task` не различает, КТО его разбудил — каждую итерацию просто проверяет все три источника состояния заново. - **Приоритет «меню важнее индикации» не кодируется в уведомлении**: проснувшись, `render_task` первым делом проверяет `menu_is_open(&g_menu)` — если меню открыто, очередь индикации и dispatcher даже не читаются (см. §3.1 ниже — dispatcher копится, не теряется). - **Модель меню `g_menu`** мутирует только `menu_task`; `render_task` читает её для отрисовки после notify (happens-before через нотификацию — как у очереди). - **`g_dispatcher_indication`** мутирует только `dispatcher_poll()` (`app/dispatcher.c`), вызываемый БЕЗУСЛОВНО каждый тик из контекста демона таймеров — приоритет ВЫШЕ `menu_task` (не реактивно на колбэк bsp_opto — см. PLAN.md §3.4 про баг первой, реактивной версии). `render_task` снимает копию в локальную переменную ОДИН раз за итерацию, а не перечитывает несколько раз (иначе возможна гонка того же класса, что чинили для курсора меню при быстрой навигации, см. PLAN.md, Фаза 3.2.4). ### 3.1 Диспетчерский вход и пауза меню (§3.4) Приоритет диспетчерского сигнала («вызов»/«ответ») — **высший из всех режимов индикации**, безусловно перекрывает и обычную позицию, и любой режим СУЛ; работает независимо от связи со станцией (ARCH §8 п.3 — это «локальный вход», не данные СУЛ). Тем не менее, пока меню открыто, `render_task` НЕ применяет его к экрану — как в `OLD_PROJECT_TFT8_UKL` (`tft_refresh_task` целиком пропускает кадр, пока `in_menu_screen`), тот же принцип, что уже даёт мягкая пауза `sul_rx_task`: - **Debounce и опрос не паузятся** — `bsp_opto_process()` и `dispatcher_poll()` в `input_poll_cb` работают независимо от `g_menu_active`, дёшево (как и button). - **Применение к экрану — только на закрытии меню.** Пока меню открыто, оконный композит перерисовывает ТОЛЬКО окно 480×272 (Фаза 3.2.4) — полный кадр индикации вне окна заморожен; применить смену диспетчера немедленно значило бы либо сломать оконную оптимизацию (полный редрав ради маленькой иконки), либо рисовать поверх окна меню. `g_dispatcher_indication` тем временем просто держит ПОСЛЕДНЕЕ значение — ничего не теряется, отражается сразу по закрытии меню тем же путём, что уже восстанавливает индикацию (`ui_fallback_render_initial()`). ### Мягкая пауза `sul_rx_task` на время меню `menu_task` держит `g_menu_active=true`, пока меню открыто; `sul_rx_task` под этим флагом пропускает CAN-работу (decode/controller/очередь), но **WDOG/heartbeat кормит безусловно** — задача не suspend'ится, поэтому сторожевой таймер в безопасности по конструкции, что бы ни происходило с меню. Флаг обновляется **до** `settings_store_save()` (flash-запись небыстрая — иначе пауза держалась бы дольше нужного). На закрытие меню `render_task` немедленно восстанавливает индикацию из последнего известного состояния, не дожидаясь свежего CAN-кадра. --- ## 4. Почему приоритеты именно такие `menu (+3) > render (+2) > sul_rx (+1)` — оба соотношения выстраданы на железе: 1. **`render` ВЫШЕ `sul_rx`.** `bsp_can_receive()` — busy-spin без единого блокирующего FreeRTOS-вызова: без CAN-трафика `sul_rx_task` занимает CPU весь таймаут (100 мс) каждую итерацию, и `xTaskDelayUntil()` при просроченном дедлайне не блокирует вовсе — задача непрерывно READY. Более низкоприоритетный `render_task` в этой ситуации голодал: пустой экран при старте без связи, залипание индикации при обрыве. Обратный порядок приоритетов чинит это, не трогая общий bare-metal модуль `bsp_can`. 2. **`menu` ВЫШЕ `render`.** Блокировки рендера (PXP busy-wait + ожидание кадра) не должны придерживать обработку кнопок — то самое свойство, ради которого меню и рендер разведены по задачам (в объединённой задаче ввод был мёртв). 3. **`bringup` выше всех** — монополизирует CPU на время одноразовой инициализации. 4. **Демон таймеров — наивысший в системе** (`configTIMER_TASK_PRIORITY`): debounce-сэмплы не теряются, чем бы ни были заняты остальные. --- ## 5. Разделяемое состояние (`app_tasks.h`) | Объект | Пишет | Читает | Синхронизация | | --- | --- | --- | --- | | `g_render_queue` | sul_rx | render | FreeRTOS queue | | `g_render_task_handle` | bringup (до создания продюсеров) | sul_rx, menu | создание-до-использования | | `g_display_ready` | bringup | render, sul_rx | `volatile bool`, одно-writer | | `g_menu_active` | menu | sul_rx | `volatile bool`, одно-writer | | `g_menu` | menu | render | notify (happens-before) | | `g_dispatcher_indication` | dispatcher_poll() (демон таймеров, каждый тик) | render | `volatile enum`, один writer; render снимает копию один раз за итерацию (см. §3) | | лог-буфер `utils/log` | все задачи | — | мьютекс `port/log/src/log_mutex.c` (FreeRTOS strong-override; включён в сборку `app` — до Фазы 3.2.4 был `#if 0`, гонка) | --- ## 6. Кадр на экран (сводка) Рендер-пайплайн (детали — [FALLBACK.md](FALLBACK.md) §5, [MENU.md](MENU.md) §5): CPU рисует в AS (ARGB8888) → `gfx_present*()` в `render_task` = PXP-композит AS над чёрным PS → задний framebuffer **RGB565** → tear-free свап по FRAME_DONE. Гибрид bpp держит сканаут ELCDIF на половинной полосе SDRAM (63 МБ/с вместо 126). Индикация — всегда полный кадр (~31 мс PXP); меню после открытия — только окно 480×272 (~8 мс расчётно).