| .. | ||
| fatfs | ||
| src | ||
| CMakeLists.txt | ||
| PLAN.md | ||
| README.md | ||
firmware_test
Диагностическая прошивка для плат MIMXRT1052CVJ5B, вернувшихся по рекламации. Запускается сервисным инженером через USB CDC ACM без предварительной прошивки загрузчика.
Версия прошивки:
0.1.0| Протокол: v2
Содержание
- Быстрый старт
- Архитектура
- Протокол v2
- Матрица тестов
- Как добавить новый тест
- Host unit-тесты
- Версионирование
Быстрый старт
1. Сборка (devcontainer)
just build::build-firmware-test-debug
just build::hab-firmware-test-debug
2. Прошивка (хост)
# Перевести плату в SDP-режим: BOOT_MOD_1 → 3V3 → Reset
just host::flash-test-debug
# Или через SWD (power cycle после)
just host::flash-swd-test-debug
3. Подключение
Подключить USB к разъёму J2 (USB CDC ACM). Открыть любой терминал:
# macOS
screen /dev/cu.usbmodemXXXX
# Linux
screen /dev/ttyACM0
4. Работа с прошивкой
После подключения таргет сразу присылает:
{"type":"session_start","fw":"0.1.0","target":"IMXRT1052","uptime_ms":0}
Проверка связи:
→ {"type":"cmd","cmd":"ping"}
← {"type":"pong"}
Запуск одного теста:
→ {"type":"cmd","cmd":"run","id":"sdram"}
← {"type":"test_begin","id":"sdram","name":"SDRAM 32 MB","critical":true}
← {"type":"test_result","id":"sdram","status":"pass","ms":312,"detail":""}
Запуск всех тестов:
→ {"type":"cmd","cmd":"run_all"}
← ... (события каждого теста) ...
← {"type":"summary","passed":7,"failed":0,"skipped":1,"overall":"pass"}
5. Host unit-тесты (devcontainer)
just build::test-host
Архитектура
Стенд
[Хост-ПК сервисного инженера]
│ USB CDC ACM (J2) — единственный канал
│ JSON-lines, 1 строка = 1 сообщение
▼
[Плата MIMXRT1052 с firmware_test]
│ GPIO / LPUART / SEMC / FlexSPI / USDHC
▼
[Периферия: SDRAM, QSPI Flash, uSD, Display, Кнопки, CAN, UART, Opto]
▲
[M5StampPLC — управление внешними сигналами для HIL тестов]
(реле → EXT_IN1/IN2, RS_RX, CAN, UART echo)
Принцип разделения ответственности:
- Вся тест-логика живёт на таргете (
test_runner.c,tests/*.c). - Хост — тонкий клиент: отправляет команды, отображает события, управляет
интерактивными шагами через
confirm. - Тесты атомарны: инженер запускает один тест или все сразу — порядок не фиксирован.
Модульная структура
firmware/test/
├── CMakeLists.txt
└── src/
├── main.c — инициализация BSP, главный цикл
│
├── cli.h / cli.c — IO-слой
│ буферизация строк, парсинг "type",
│ диспатч на test_runner / protocol
│
├── protocol.h / .c — сериализация исходящих событий
│ все protocol_send_*() → cli_send()
│
├── test_module.h — интерфейс тест-модуля
│ test_module_t, test_result_t,
│ confirm_params_t, test_status_t
│
├── test_runner.h / .c — реестр + state machine
│ IDLE → PRE_CONFIRM → RUNNING → IDLE
│ test_runner_wait_confirm() для display
│
└── tests/
├── test_sdram.c — SDRAM 32 MB (self)
├── test_qspi.c — QSPI Flash 8 MB (self)
├── test_usd.c — uSD SDIO (interactive)
├── test_display.c — Display RGB888 (interactive)
├── test_buttons.c — Test_But_1/2 (interactive)
├── test_can.c — CAN loopback (HIL)
├── test_uart_ttl.c — UART TTL (HIL)
├── test_uart_iso.c — UART ISO / RS_RX Opto (HIL)
└── test_opto.c — Opto-in EXT_IN1/IN2 (HIL)
Граф зависимостей
main.c
├── bsp_board (тактирование, MPU, кэш, пины)
├── bsp_tick (SysTick 1 мс)
├── bsp_led (LED_HEARTBEAT, LED_APP)
├── bsp_usb_cdc (USB CDC ACM, единственный транспорт)
├── cli.c
│ └── bsp_usb_cdc (read / write)
│ └── protocol.c (send_error, send_pong)
│ └── test_runner.c (run_single, run_all, on_confirm)
├── protocol.c
│ └── cli.c (cli_send)
│ └── bsp_tick (bsp_tick_get_ms — для uptime)
└── test_runner.c
└── protocol.c (все protocol_send_*)
└── bsp_tick (bsp_tick_get_ms — таймауты confirm)
└── bsp_usb_cdc (bsp_usb_cdc_poll — в wait_confirm)
└── cli.c (cli_process — в wait_confirm)
└── tests/*.c (тест-модули через реестр)
BSP-зависимости тест-модулей:
| Тест | BSP модуль |
|---|---|
test_sdram |
bsp_sdram |
test_qspi |
bsp_qspi |
test_usd |
bsp_usd |
test_display |
существующий display BSP |
test_buttons |
bsp_button ✅ |
test_can |
bsp_can ✅ |
test_uart_ttl |
bsp_uart_host ✅ |
test_uart_iso |
bsp_opto (rs_as_gpio=true) ✅ |
test_opto |
bsp_opto ✅ |
State machine test_runner
cmd: run / run_all
│
▼
┌─────────────────────────────────────┐
│ IDLE │◄──────────────────────────┐
│ Ждём команду от хоста │ │
└──────────────────┬──────────────────┘ │
│ │
pre_confirm_prompt != NULL? │
│ │
YES │ NO │
▼ │ ▼ │
┌───────────────┐ │ ┌──────────────────────────────────────┐ │
│ PRE_CONFIRM │ │ │ RUNNING │ │
│ │ │ │ protocol_send_test_begin() │ │
│ confirm_req │ │ │ mod->init() (если задан) │ │
│ отправлен, │ │ │ result = mod->run() ← блокирует │ │
│ ждём JSON │ │ │ mod->deinit() (если задан) │ │
│ от хоста │ └─►│ protocol_send_test_result() │ │
└──────┬────────┘ └──────────────────┬───────────────────┘ │
│ │ │
confirmed=true ──────────────────────► │ │
confirmed=false → SKIP │ │
timeout → SKIP │ │
│ │
run: IDLE ──┘ │
run_all: следующий тест в реестре ───┘
run_all done: protocol_send_summary()
Ключевые свойства state machine:
RUNNING— защита от ложногоis_busy()==falseво время blockingrun(). Пока тест выполняется, новые команды получаютBUSY.test_runner_wait_confirm()— вызывается изrun()интерактивных тестов (display). Внутри polling loop:bsp_usb_cdc_poll()+cli_process(). USB-стек остаётся живым, confirm приходит без возврата в главный цикл.critical=true+FAILвrun_all→ все оставшиеся тесты получаютSKIPнемедленно,summary.overall = "fail".
Протокол v2
Транспорт
| Параметр | Значение |
|---|---|
| Интерфейс | USB CDC ACM, разъём J2 |
| Кодировка | UTF-8 |
| Фреймирование | JSON-lines: одна строка = одно сообщение, завершается \n |
| Максимальная длина строки | 128 байт включая \n |
| CR+LF | Принимается (таргет отбрасывает \r) |
Нет хэндшейка, нет sequence number, нет подтверждений доставки.
Таргет идемпотентен для ping и run — при потере строки хост повторяет.
Жизненный цикл сессии
Хост Таргет
│ │
│ [USB SDP: прошивка загружена] │
│ [CDC ACM: порт открыт] │
│◄─── {"type":"session_start","fw":"0.1.0",...} │ автоматически
│ │
│──── {"type":"cmd","cmd":"ping"} ─────────────►│
│◄─── {"type":"pong"} │
│ │
│──── {"type":"cmd","cmd":"run","id":"sdram"} ──►│
│◄─── {"type":"test_begin","id":"sdram",...} │
│◄─── {"type":"test_result","id":"sdram",...} │
│ │
│──── {"type":"cmd","cmd":"run_all"} ───────────►│
│◄─── {"type":"test_begin","id":"sdram",...} │
│◄─── {"type":"test_result",...} │
│ … по одному для каждого теста … │
│◄─── {"type":"summary","overall":"pass",...} │
session_start отправляется автоматически при каждом старте, до получения
первой команды. Хост должен быть готов принять его сразу после открытия порта.
Команды хоста → таргет
Все команды имеют "type":"cmd". Поле "cmd" определяет действие.
ping — проверка связи
→ {"type":"cmd","cmd":"ping"}
← {"type":"pong"}
run — запуск одного теста
→ {"type":"cmd","cmd":"run","id":"sdram"}
← {"type":"test_begin","id":"sdram","name":"SDRAM 32 MB","critical":true}
← {"type":"test_result","id":"sdram","status":"pass","ms":312,"detail":""}
Если id не найден:
← {"ok":false,"error":"UNKNOWN_TEST"}
run_all — запуск всех тестов по реестру
→ {"type":"cmd","cmd":"run_all"}
← {"type":"test_begin","id":"sdram",...}
← {"type":"test_result","id":"sdram","status":"pass","ms":312,"detail":""}
← {"type":"test_begin","id":"qspi",...}
← {"type":"test_result","id":"qspi","status":"pass","ms":88,"detail":""}
← ... (остальные тесты) ...
← {"type":"summary","passed":7,"failed":0,"skipped":1,"overall":"pass"}
confirm — ответ оператора на интерактивный шаг
→ {"type":"confirm","id":"display_red","confirmed":true}
Поле id должно совпадать с id из confirm_request.
Ответ после истечения timeout_ms игнорируется — таргет уже перешёл в SKIP.
События таргета → хост
session_start
{
"type": "session_start",
"fw": "0.1.0",
"target": "IMXRT1052",
"uptime_ms": 0
}
test_begin
{
"type": "test_begin",
"id": "sdram",
"name": "SDRAM 32 MB",
"critical": true
}
test_result
{
"type": "test_result",
"id": "sdram",
"status": "pass",
"ms": 312,
"detail": ""
}
status |
Смысл |
|---|---|
"pass" |
Тест пройден |
"fail" |
Тест провален; detail содержит описание (до 95 символов) |
"skip" |
Пропущен: нет оборудования, таймаут, отказ оператора, critical fail выше |
Примеры detail: "addr=0x80001000 expected=0xA5 got=0x00", "JEDEC ID mismatch".
confirm_request
{
"type": "confirm_request",
"id": "display_red",
"prompt": "Экран залит красным цветом?",
"timeout_ms": 15000
}
Хост отображает prompt оператору. Авторитетный таймаут — на таргете.
Хост может дублировать countdown для UX.
summary
{
"type": "summary",
"passed": 6,
"failed": 1,
"skipped": 0,
"overall": "fail"
}
"overall":"fail" — если хотя бы один critical тест провален.
"overall":"pass" — все critical тесты прошли (non-critical могут fail).
Ошибки протокола
← {"ok":false,"error":"PARSE_ERR"} — строка не распознана как JSON-lines
← {"ok":false,"error":"UNKNOWN_CMD"} — неизвестный "cmd" или "type"
← {"ok":false,"error":"UNKNOWN_TEST"} — "id" не найден в реестре
← {"ok":false,"error":"LINE_TOO_LONG"} — строка превысила 128 байт
← {"ok":false,"error":"BUSY"} — таргет выполняет тест
Интерактивные тесты
uSD — вставить карту
← {"type":"confirm_request","id":"usd_insert","prompt":"Вставьте microSD","timeout_ms":30000}
→ {"type":"confirm","id":"usd_insert","confirmed":true}
← {"type":"test_begin","id":"usd",...}
← {"type":"test_result","id":"usd","status":"pass","ms":541,"detail":""}
Если оператор отказался или таймаут:
← {"type":"test_result","id":"usd","status":"skip","ms":0,"detail":"operator skipped"}
Display RGB888 — подтвердить цвета
Четыре шага R/G/B/W. Итог — AND всех подтверждений.
← {"type":"test_begin","id":"display",...}
← {"type":"confirm_request","id":"display_red","prompt":"Экран красный?","timeout_ms":15000}
→ {"type":"confirm","id":"display_red","confirmed":true}
← {"type":"confirm_request","id":"display_green",...}
→ {"type":"confirm","id":"display_green","confirmed":true}
← {"type":"confirm_request","id":"display_blue",...}
→ {"type":"confirm","id":"display_blue","confirmed":true}
← {"type":"confirm_request","id":"display_white",...}
→ {"type":"confirm","id":"display_white","confirmed":false}
← {"type":"test_result","id":"display","status":"fail","ms":22103,
"detail":"display_white not confirmed"}
Кнопки — нажать физически
Особый случай: confirm_request используется как инструкция оператору,
но хост не отправляет confirm. Таргет сам детектирует нажатие через
bsp_button и переходит к следующему событию.
← {"type":"test_begin","id":"buttons",...}
← {"type":"confirm_request","id":"btn1_press","prompt":"Нажмите Test_But_1","timeout_ms":10000}
[таргет ждёт bsp_button — без JSON confirm от хоста]
← {"type":"confirm_request","id":"btn2_press","prompt":"Нажмите Test_But_2","timeout_ms":10000}
← {"type":"test_result","id":"buttons","status":"pass","ms":3821,"detail":""}
Матрица тестов
| ID | Название | Тип | Critical | M5 HIL | Confirm |
|---|---|---|---|---|---|
sdram |
SDRAM 32 MB | self | ✅ | ❌ | ❌ |
qspi |
QSPI Flash 8 MB | self | ✅ | ❌ | ❌ |
usd |
uSD SDIO | self+interactive | ❌ | ❌ | ✅ pre_confirm |
display |
Display RGB888 | interactive | ❌ | ❌ | ✅ 4×в run() |
buttons |
Test_But_1/2 | interactive | ❌ | ❌ | prompt only |
can |
CAN loopback | HIL | ❌ | ✅ | ❌ |
uart_ttl |
UART TTL | HIL | ❌ | ✅ | ❌ |
uart_iso |
UART ISO / RS_RX | HIL | ❌ | ✅ | ❌ |
opto |
Opto EXT_IN1/IN2 | HIL | ❌ | ✅ | ❌ |
Типы confirm:
- pre_confirm — test_runner отправляет
confirm_requestдо вызоваrun(), ждёт JSON-ответ через state machine (асинхронно). - в run() — тест сам вызывает
test_runner_wait_confirm()изнутриrun(), блокируется до ответа (синхронно). - prompt only —
protocol_send_confirm_request()отправляется как UI-подсказка, хост не отвечает JSON, таргет ждёт физического события.
Как добавить новый тест
Шаг 1 — Создать файл теста
/* firmware/test/src/tests/test_foo.c */
#include "test_module.h"
#include "bsp/foo.h" /* BSP модуль тестируемой периферии */
#include "bsp/usb_cdc.h" /* bsp_usb_cdc_poll() для длинных тестов */
#include <stdint.h>
#include <stdio.h>
static test_result_t test_foo_run(void)
{
test_result_t result = { .status = TEST_STATUS_PASS, .duration_ms = 0U };
result.detail[0] = '\0';
/* Длинные операции должны периодически звать bsp_usb_cdc_poll(),
* чтобы USB-стек оставался живым пока run() блокирует главный цикл. */
bsp_foo_status_t status = bsp_foo_test();
if (status != BSP_OK)
{
result.status = TEST_STATUS_FAIL;
(void) snprintf(result.detail, TEST_DETAIL_SIZE,
"bsp_foo_test returned %d", (int) status);
}
return result;
}
const test_module_t k_test_foo = {
.id = "foo", /* короткий ASCII-ключ */
.name = "Foo Peripheral",
.critical = false, /* true → run_all стопится при fail */
.requires_hil = false, /* true → нужен M5StampPLC */
.pre_confirm_prompt = NULL, /* строка → pre_confirm механизм */
.init = NULL, /* bsp_foo_init если нужен */
.run = test_foo_run,
.deinit = NULL,
};
Шаг 2 — Зарегистрировать в реестре
Файл: firmware/test/src/test_runner.c
/* Forward declarations */
extern const test_module_t k_test_sdram;
extern const test_module_t k_test_foo; /* ← добавить */
static const test_module_t *const k_registry[] = {
&k_test_sdram,
&k_test_foo, /* ← добавить */
};
Шаг 3 — Добавить в CMakeLists.txt
Файл: firmware/test/CMakeLists.txt
add_executable(
${TARGET_NAME}
src/main.c
src/cli.c
src/protocol.c
src/test_runner.c
src/tests/test_sdram.c
src/tests/test_foo.c # ← добавить
...
)
target_link_libraries(
${TARGET_NAME} PRIVATE
...
bsp_foo # ← добавить BSP модуль
)
Шаг 4 — Обновить матрицу тестов
Добавить строку в таблицу в этом README.
Шаблоны для разных типов тестов
Self-тест с инициализацией
static void test_foo_init(void)
{
bsp_foo_init();
}
static void test_foo_deinit(void)
{
bsp_foo_deinit();
}
const test_module_t k_test_foo = {
.id = "foo",
.init = test_foo_init,
.run = test_foo_run,
.deinit = test_foo_deinit,
...
};
Интерактивный тест (confirm внутри run)
#include "test_runner.h" /* test_runner_wait_confirm() */
#include "protocol.h" /* protocol_send_confirm_request() */
static test_result_t test_foo_run(void)
{
test_result_t result = { .status = TEST_STATUS_PASS };
confirm_params_t step = {
.id = "foo_step1",
.prompt = "Выполните действие и подтвердите",
.timeout_ms = 15000U,
};
if (!test_runner_wait_confirm(&step))
{
/* таймаут или отказ */
result.status = TEST_STATUS_SKIP;
(void) snprintf(result.detail, TEST_DETAIL_SIZE, "%s", "foo_step1 not confirmed");
return result;
}
/* продолжаем тест */
return result;
}
Тест с pre_confirm (вставить карту, подключить кабель)
const test_module_t k_test_foo = {
.id = "foo",
.pre_confirm_prompt = "Подключите кабель к разъёму X и нажмите OK",
.run = test_foo_run,
...
};
/* test_runner сам отправит confirm_request перед вызовом run() */
Host unit-тесты
Фреймворк модулей firmware_test покрыт host unit-тестами (Unity + fff). Тесты компилируются clang-17 на хосте без ARM-специфики.
Что покрыто
| Таргет | Что тестирует | Тест-файл |
|---|---|---|
test_protocol |
сериализация JSON (все event types) | tests/host/protocol/test_protocol.c |
test_cli |
парсинг входящих строк, диспатч по type | tests/host/cli/test_cli.c |
test_firmware_runner |
state machine (IDLE/PRE_CONFIRM/RUNNING), реестр | tests/host/runner/test_firmware_runner.c |
Запуск
# Все host-тесты
just build::test-host
# Только один тест (вербозный вывод Unity)
ctest --preset host-debug-test -R test_cli -V
# Напрямую
./build/host-debug/tests/host/test_cli
Добавление host-теста для нового тест-модуля
Host unit-тесты для test_sdram.c и подобных — опциональны. Тест-модули
проверяются через HIL pytest (tools/hil/). Если в тест-модуле есть
нетривиальная логика (парсинг результатов, конечный автомат, retry) —
стоит добавить host-тест.
Гайд: docs/testing/host/HOST_CREATE_TEST.md.
Моки и UNIT_TEST seam
test_runner.c компилируется с -DUNIT_TEST — это открывает seam для
подстановки тестового реестра без изменения production-кода:
/* В тест-файле предоставляем свои модули */
const test_module_t *g_unit_test_registry[8];
size_t g_unit_test_registry_size = 0U;
static void set_registry(const test_module_t **pp_mods, size_t count) { ... }
void test_run_all_critical_fail_skips_remaining(void)
{
const test_module_t *mods[] = { &K_MOD_PASS, &K_MOD_CRIT_FAIL, &K_MOD_PASS };
set_registry(mods, 3U);
test_runner_run_all();
/* assertions... */
}
Стандартный список моков для каждого теста:
| Зависимость | fff fake |
|---|---|
cli_send() |
FAKE_VOID_FUNC(cli_send, const char *) + custom_fake с копией |
bsp_tick_get_ms() |
FAKE_VALUE_FUNC(uint32_t, bsp_tick_get_ms) |
bsp_usb_cdc_poll() |
FAKE_VOID_FUNC(bsp_usb_cdc_poll) |
cli_process() |
FAKE_VOID_FUNC(cli_process) |
protocol_send_test_result() |
custom_fake — копируем *p_result по значению |
Ловушка dangling pointer:
protocol_send_test_resultполучает указатель на стековую переменную внутриexecute_test(). После возврата указатель инвалиден — используйcustom_fakeсs_captured = *p_resultпока стек жив.
Версионирование
FIRMWARE_TEST_VERSION в protocol.h — единственная точка правды о версии.
Поле "fw" в session_start несёт эту строку.
При несовместимых изменениях протокола (новое обязательное поле, изменение семантики) — bumping версии + обновление этого документа.
Хост должен сверять "fw" при подключении и предупреждать оператора при
несовпадении ожидаемой версии.
Архитектурные решения (закрыты)
Не пересматривать без явного запроса.
| Решение | Обоснование |
|---|---|
| Единственный транспорт — USB CDC ACM | HIL ELF-прошивки используют отдельный канал (UART + bsp_uart_host) |
| Парсинг JSON — strstr без cJSON | Схема фиксирована, cJSON избыточен |
| SDRAM тест — через firmware_test, не HIL ELF | Тест идёт командами по USB CDC |
| IR и RTC — не реализуются | Вне scope рекламационной диагностики |
| Тесты атомарны | Инженер сам решает что проверять |
| Тест-логика на таргете | Хост — тонкий клиент, нет дублирования логики |