Встраиваемые системы и робототехника Тестирование прошивки: хост, симулятор, стенд и статический анализ
0%

Тестирование прошивки: хост, симулятор, стенд и статический анализ

Тестирование прошивки: хост, симулятор, стенд и статический анализ

Вопрос «как вы тестируете прошивку?» на собеседовании во встраиваемую команду выдаёт зрелость проекта быстрее любого другого. Три типичных ответа: «руками, на плате», «есть отдел испытаний, они прогоняют регламент» и — гораздо реже — «две тысячи юнит-тестов на хосте плюс ночной прогон на стенде».

Причина, по которой первые два ответа так живучи, не в лени. Обычная пирамида тестов молча опирается на четыре допущения, и во встраиваемом мире не выполняется ни одно:

  • Код можно запустить на машине разработчика. Нельзя: он собран под другую архитектуру и обращается к регистрам, которых на хосте не существует.
  • Окружение воспроизводимо. Нет: датчик даёт разный шум, шина иногда сбоит, а батарея садится.
  • Тест дёшев. Нет: прогон на плате требует физической платы, прошивки, сброса — и плат столько, сколько людей в команде, а не сколько запусков в CI.
  • Состояние сбрасывается между тестами. Нет: у прошивки есть флеш, который переживает перезагрузку, и физический мир вокруг.

При этом цена пропущенной ошибки здесь выше, чем почти где угодно: обновление по воздуху есть не у всех, а откатить партию из десяти тысяч изделий нельзя в принципе. Отсюда практический вывод, вокруг которого построена вся статья: тестируемость прошивки — это в первую очередь свойство её архитектуры, и только во вторую — набор инструментов. Код, где расчёт скользящего среднего живёт в одном файле с записью в ADC->CR2, не тестируется ничем, кроме паяльника. Код с проведённым швом проверяется обычным gcc за миллисекунды — и большинство ошибок ловится там.

Три уровня, три разные цены

У проверки прошивки есть ровно три площадки, и выбор между ними механический — по тому, какое утверждение вы хотите доказать.

Площадка Что проверяет Цена прогона Сколько тестов реально
Хост (gcc, clang) логика: автоматы, парсеры, фильтры, расчёты, обработка ошибок миллисекунды сотни и тысячи
Симулятор (Renode, QEMU, native_sim) интеграция: порядок инициализации, драйвер против модели устройства, работа RTOS секунды десятки
Железный стенд (HIL) физика и время: сроки, ток, фронты, реальный датчик, сброс питания минуты единицы и десятки

Ключевая ошибка новичков — пытаться доказать всё на нижнем правом уровне: гонять на плате то, что прекрасно проверяется на хосте. Тесты становятся медленными, хрупкими и их перестают чинить. Обратная ошибка — считать, что раз логика покрыта на хосте, железо можно не трогать: ни один хостовый тест не поймает, что фронт I2C не успевает подняться из-за слишком крупной подтяжки.

Шов: почему это разговор про архитектуру

Тестируемый код отличается от нетестируемого одной вещью — наличием шва: места, где обращение к внешнему миру выражено интерфейсом, а не прямой записью в регистр.

Шов между логикой и железом: одна и та же логика собирается для хоста с подставными портами и для цели с настоящими драйверами

Плохой код и хороший отличаются буквально несколькими строчками:

/* НЕ ТЕСТИРУЕТСЯ: логика намертво сцеплена с чипом и с системным временем */
float read_pressure(void)
{
    ADC1->CR2 |= ADC_CR2_SWSTART;
    while (!(ADC1->SR & ADC_SR_EOC)) { }
    uint16_t raw = (uint16_t)ADC1->DR;
    if (HAL_GetTick() - g_last > 1000u) { g_offset = raw; g_last = HAL_GetTick(); }
    return (float)(raw - g_offset) * 0.0244f;
}
/* ТЕСТИРУЕТСЯ: порт — узкий интерфейс, время и АЦП пришли снаружи */
typedef struct {
    bool     (*adc_read)(void *ctx, uint16_t *out);   /* чем измеряем */
    uint32_t (*now_ms)(void *ctx);                    /* что считаем временем */
    void      *ctx;
} sensor_port_t;

typedef struct { uint16_t offset; uint32_t last_cal_ms; } pressure_state_t;

bool pressure_update(pressure_state_t *st, const sensor_port_t *port, float *out_kpa)
{
    uint16_t raw;
    if (!port->adc_read(port->ctx, &raw)) return false;   /* отказ датчика — часть логики */

    uint32_t now = port->now_ms(port->ctx);
    if ((uint32_t)(now - st->last_cal_ms) > 1000u) {      /* вычитание переживает переполнение */
        st->offset = raw;
        st->last_cal_ms = now;
    }
    *out_kpa = (float)((int32_t)raw - st->offset) * 0.0244f;
    return true;
}

Второй вариант компилируется обычным gcc без единого заголовка вендора. Это и есть рабочий критерий: если файл с логикой не собирается хостовым компилятором — шва нет. Стоимость шва — один косвенный вызов там, где следующая же строка ждёт ответа шины сотни микросекунд; на фоне физики это ничто. Там, где даже такой ценой платить нельзя (обработчик прерывания на сотню наносекунд), шов делают на этапе компоновки: одинаковые сигнатуры, разные файлы реализации для целевой и хостовой сборки. Про архитектурную сторону вопроса — слоистая и гексагональная архитектуры и приёмы отделения логики от ввода-вывода.

Три вещи, которые обязаны быть портами почти всегда: время (now_ms), энергонезависимая память (config_save) и шина к внешней микросхеме (i2c_xfer). Именно они делают тесты недетерминированными и требуют железа.

Юнит-тесты на хосте: две тысячи прогонов в секунду

Инструменты: Unity плюс Ceedling (генерация моков через CMock, минимум ручной работы), CppUTest (популярен благодаря книге Гренинга), GoogleTest для проектов на C++. Разница между ними меньше, чем кажется; выбирать стоит по тому, что команда готова поддерживать.

/* test_pressure.c — обычная хостовая сборка: gcc -std=c11 -O1 -g -fsanitize=address,undefined */
#include "unity.h"
#include "pressure.h"

/* Подставной порт: очередь заранее заданных отсчётов и часы, которые двигаем руками */
typedef struct { uint16_t samples[8]; size_t idx, n; uint32_t clock_ms; bool fail; } fake_t;

static bool fake_adc(void *c, uint16_t *out) {
    fake_t *f = c;
    if (f->fail || f->idx >= f->n) return false;
    *out = f->samples[f->idx++];
    return true;
}
static uint32_t fake_now(void *c) { return ((fake_t *)c)->clock_ms; }

void test_first_read_sets_zero(void)   /* первое измерение калибрует ноль */
{
    fake_t f = { .samples = {2048, 2148}, .n = 2, .clock_ms = 5000 };
    sensor_port_t port = { fake_adc, fake_now, &f };
    pressure_state_t st = { 0 };
    float kpa = 0.0f;

    TEST_ASSERT_TRUE(pressure_update(&st, &port, &kpa));
    TEST_ASSERT_EQUAL_FLOAT(0.0f, kpa);            /* первый отсчёт стал нулём шкалы */

    f.clock_ms += 100;                             /* окно калибровки ещё не прошло */
    TEST_ASSERT_TRUE(pressure_update(&st, &port, &kpa));
    TEST_ASSERT_FLOAT_WITHIN(0.01f, 2.44f, kpa);   /* 100 отсчётов * 0.0244 */
}

void test_sensor_failure_keeps_state(void)   /* отказ датчика не портит состояние */
{
    fake_t f = { .fail = true, .clock_ms = 1 };
    sensor_port_t port = { fake_adc, fake_now, &f };
    pressure_state_t st = { .offset = 2048, .last_cal_ms = 0 };
    float kpa = -1.0f;

    TEST_ASSERT_FALSE(pressure_update(&st, &port, &kpa));
    TEST_ASSERT_EQUAL_UINT16(2048, st.offset);     /* состояние не тронуто */
}

/* Отдельный тест на переполнение счётчика миллисекунд: раз в 49.7 суток
   HAL_GetTick обнуляется, и наивное сравнение now > last + T ломается. */
void test_tick_wraparound(void)   /* переполнение счётчика не ломает калибровку */
{
    fake_t f = { .samples = {100, 200}, .n = 2, .clock_ms = 0xFFFFFF00u };
    sensor_port_t port = { fake_adc, fake_now, &f };
    pressure_state_t st = { .last_cal_ms = 0xFFFFFF00u - 500u };
    float kpa = 0.0f;

    f.clock_ms += 1000;                            /* прошли через ноль */
    TEST_ASSERT_TRUE(pressure_update(&st, &port, &kpa));
    TEST_ASSERT_EQUAL_UINT32(f.clock_ms, st.last_cal_ms);   /* калибровка сработала */
}

Три приёма, которые делают такие тесты по-настоящему полезными:

  • Санитайзеры. Хостовая сборка с -fsanitize=address,undefined ловит выход за границу массива и знаковое переполнение — на целевом чипе те же ошибки просто портят соседнюю переменную и всплывают через месяц. Это едва ли не главный аргумент в пользу хостовых тестов вообще; подробнее про класс этих ошибок — в главе про неопределённое поведение.
  • Тесты на переполнение времени. Ошибка «работает 49 суток, потом зависает» находится за секунду, если время — параметр, и не находится никогда, если время берётся из HAL_GetTick().
  • Покрытие как навигатор, а не цель. gcc --coverage плюс gcovr показывает, какие ветки обработки ошибок никто ни разу не выполнил. Обычно это ровно те ветки, которые сработают в поле.
# Полный прогон хостовых тестов: сборка, санитайзеры, покрытие — секунды
gcc -std=c11 -O1 -g -Wall -Wextra -Werror -fsanitize=address,undefined --coverage \
    -Isrc -Itests/unity src/pressure.c tests/unity/unity.c tests/test_pressure.c -o build/tests
./build/tests && gcovr --root . --fail-under-line 70 --exclude tests/

Фаззинг: единственный способ проверить парсер кадров

Разбор пакета из внешнего мира — код, к которому противник (или просто сломанное устройство на шине) имеет прямой доступ, и одновременно самый плотный по ветвлениям. Тестами вручную его покрыть нельзя: интересные случаи — не «правильный кадр», а «длина 0xFFFF при буфере 64 байта».

/* fuzz_frame.c: собирается clang -g -O1 -fsanitize=fuzzer,address,undefined */
#include <stdint.h>
#include <stddef.h>
#include "frame.h"

int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size)
{
    frame_t out;
    (void)frame_parse(data, size, &out);   /* нас интересует не результат, а падение */
    return 0;
}

Полчаса такого прогона на ноутбуке обычно находит два-три способа выйти за границу буфера — в коде, который «работает в проде год». Это дёшево и делается один раз: фаззинг и его место описан в треке тестирования, а требования к разбору чужих данных — в главе про безопасное программирование.

Рядом стоят тесты свойств (property-based): для протокола проверяется инвариант «parse(serialize(x)) == x для любого допустимого x», для фильтра — «выход монотонной последовательности монотонен», для очереди — «после N записей и N чтений очередь пуста». Одна такая проверка заменяет двадцать примеров.

Симуляция: между хостом и платой

Хостовые тесты не видят прерываний, порядка инициализации периферии и планировщика RTOS. Между «логика верна» и «работает на плате» есть промежуточный слой, и он полезнее, чем принято думать.

  • Renode — функциональный симулятор целых плат: ядро, память, периферия, несколько устройств на одной шине и даже радиоканал между ними. Умеет исполнять ровно тот .elf, что уезжает в чип. Отдельная сила — детерминизм: гонка, воспроизводящаяся раз в час на столе, здесь воспроизводится каждый раз.
  • QEMU (-machine mps2-an385, netduinoplus2) — быстрее, но моделей периферии заметно меньше; хорош для проверки самого ядра и RTOS.
  • native_sim в Zephyr — сборка всей прошивки, включая планировщик и стек драйверов, в обычный процесс Linux. Пожалуй, лучший компромисс, если проект живёт на Zephyr.
  • Wokwi — браузерный симулятор для ESP32, RP2040 и AVR; в CI применим через свой раннер, удобен для демонстраций и учебных сборок.
# tests/renode/blink.robot — сценарий Renode: запустить .elf и проверить поведение вывода
# Тест проверяет то, чего хостовая сборка не видит: таймер, прерывание, реальный NVIC
*** Test Cases ***
Светодиод мигает раз в секунду
    Execute Command             mach create "stm32"
    Execute Command             machine LoadPlatformDescription @platforms/boards/stm32f4_discovery.repl
    Execute Command             sysbus LoadELF @build/firmware.elf
    Create Terminal Tester      sysbus.usart2
    Start Emulation
    Wait For Line On Uart       tick 1    timeout=1.5
    Wait For Line On Uart       tick 2    timeout=1.5

Границы честности симулятора надо понимать: он моделирует цифровую логику, а не физику. Просевшее питание, звон на фронте, дребезг контакта, нагрев и севшая батарея в Renode не существуют. Всё, что вы выясняете симуляцией, — «программа делает то, что задумано»; «устройство работает» доказывается только железом.

Стенд hardware-in-the-loop

HIL — это плата, окружённая приборами, которыми управляет тот же CI, что собирает прошивку. Минимальный состав, который окупается на второй месяц:

  • отладочный зонд (probe-rs, OpenOCD) — прошить, сбросить, забрать логи по RTT;
  • реле или управляемый источник питания — снять и подать питание, а не «нажать reset»: это единственный способ проверить холодный старт и запись во флеш при обрыве;
  • логический анализатор с программным интерфейсом (sigrok-cli) — доказать, что на шине действительно то, что задумано;
  • измеритель тока (например, Nordic PPK2 или INA219 на отдельной плате) — превратить энергопотребление в число, которое можно сравнивать между сборками;
  • стимул: вторая плата, изображающая датчик или мастера шины.
# tests/hil/test_boot.py — HIL-тест как обычный pytest
import subprocess, time, pytest

@pytest.fixture
def dut(power_relay, rtt):
    power_relay.off(); time.sleep(1.0)
    subprocess.run(["probe-rs", "download", "--chip", "STM32F411CEUx",
                    "build/firmware.elf"], check=True)
    power_relay.on()
    yield rtt
    power_relay.off()

def test_холодный_старт_за_500_мс(dut):
    t0 = time.monotonic()
    assert dut.wait_for("boot ok", timeout=2.0), "устройство не стартовало"
    assert time.monotonic() - t0 < 0.5, "старт дольше бюджета: проверьте задержки в init"

def test_обрыв_питания_при_записи_конфига(dut, power_relay):
    dut.send("cfg set threshold 42")
    time.sleep(0.003)                      # ровно в окно записи страницы флеша
    power_relay.off(); time.sleep(0.5); power_relay.on()
    assert dut.wait_for("boot ok", timeout=2.0)
    assert dut.query("cfg get threshold") in ("42", "41"), "конфиг повреждён обрывом"

Последнее утверждение — образец правильного HIL-теста: он допускает оба корректных исхода (новое значение записалось или осталось старое) и запрещает третий — повреждённую конфигурацию. Требовать от прошивки «всегда 42» здесь было бы неверно: обрыв в момент записи имеет право потерять транзакцию, но не имеет права оставить мусор. Механика такой записи разобрана в главе про питание и ограничения.

Организационные детали, о которых узнают на своих ошибках: стенд обязан уметь восстанавливаться сам (зависшую плату спасает реле, а не человек); тесты нумеруют платы и не делят одну между параллельными задачами; флейки не «перезапускают», а расследуют — на железе мигающий тест обычно означает настоящую гонку. Для сложных ферм есть LabGrid и pytest-embedded, покрывающие резервирование плат и удалённый доступ; общая дисциплина — в главах тесты в CI и стратегия автоматизации.

Инъекция отказов: проверяем то, ради чего всё затевалось

Прошивка почти всегда падает не на «нормальном» сценарии, а на отказе, который никто не проиграл. Хорошая новость: большинство отказов инжектируется дёшево, причём часть — прямо на хосте.

Отказ Как воспроизвести Что обязано произойти
Датчик не отвечает подставной порт возвращает ошибку; на стенде — вынуть перемычку деградация, а не зависание в while (!flag)
Шина зависла в низком уровне стенд: замкнуть SDA на землю таймаут, переинициализация, счётчик в журнале
Обрыв питания при записи реле в момент записи страницы конфиг цел: старая или новая версия, не мусор
Просадка питания источник опускает напряжение до порога контролируемый сброс, а не непредсказуемое поведение
Мусор в эфире фаззинг парсера кадров на хосте кадр отброшен, состояние не изменилось
Зависание задачи тестовая сборка не кормит сторожевой таймер сброс и запись причины в журнал
Флеш исчерпал ресурс подставной драйвер возвращает ошибку записи переход в режим только чтения, диагностика наружу

Отдельный обязательный тест — проверка самого сторожевого таймера. Устройство должно иметь отладочную команду «зависнуть навсегда», и HIL-тест обязан убедиться, что после неё плата перезагружается за отведённое время. Ненастроенный или неправильно кормимый watchdog — классика: он «есть» три года и ни разу не срабатывал, потому что кормится из таймерного прерывания, которое живо даже при мёртвом приложении.

Регрессии, которые не выражаются словом «упало»

У прошивки есть метрики, которые ухудшаются постепенно и никогда не дают красный тест сами по себе. Их нужно измерять и сравнивать с порогом на каждом изменении:

  • размер прошивкиsize из прошлой главы, порог в процентах от флеша;
  • глубина стека — сумма .su по графу вызовов и watermark с прогона на стенде;
  • худшее время цикла управления — счётчик DWT->CYCCNT вокруг критичного участка, максимум за прогон;
  • средний ток — измеритель на стенде, сравнение с бюджетом из главы про энергию;
  • время холодного старта — от подачи питания до готовности.
# metrics.sh — выгружаем метрики сборки в JSON и сравниваем с базовой линией
arm-none-eabi-size -A build/firmware.elf | awk '/\.text|\.data|\.bss/ {s[$1]=$2}
  END {printf "{\"flash\":%d,\"ram\":%d}\n", s[".text"]+s[".data"], s[".data"]+s[".bss"]}' \
  > build/metrics.json
python3 tools/compare_metrics.py build/metrics.json baseline.json --max-growth 3%

Правило, экономящее нервы: порог должен быть относительным (рост более 3 % за один PR) и обсуждаемым. Абсолютный лимит «не больше 200 КБ» либо не срабатывает годами, либо блокирует релиз в самый неудобный день. Методика измерений вообще — в главе про измерение производительности.

Компилятор и статический анализ: самый дешёвый тест

Прежде чем писать первый юнит-тест, стоит включить то, что уже куплено и ничего не стоит.

# Набор, который окупается на первой же неделе
-Wall -Wextra -Werror -Wconversion -Wsign-conversion -Wshadow -Wundef
-Wdouble-promotion -Wstack-usage=1024 -Wformat=2 -Wswitch-enum -Wnull-dereference

clang-tidy src/*.c --checks=bugprone-*,cert-*,clang-analyzer-* -- -Isrc
cppcheck --enable=warning,style --error-exitcode=1 --std=c11 src/

-Wconversion при первом включении даёт сотни предупреждений и вызывает желание его выключить. Не выключайте: каждое из них — место, где uint16_t молча превратился в int8_t, и половина «плавающих» багов встраиваемых проектов живёт именно там. Для критичных изделий сверху ложатся отраслевые своды правил (MISRA C, -fanalyzer в GCC, коммерческие анализаторы) — они дороги и шумны, но в медицине, автомобиле и авиации обсуждению не подлежат.

Отдельный разговор про assert. Стандартный тянет строки в .rodata и в релизе обычно выключается — а зря: проверка инварианта в поле полезнее, чем непредсказуемое поведение. Компромисс — свой макрос, который сохраняет файл и строку в компактном виде и перезагружает устройство с записью причины в журнал:

#define FW_ASSERT(cond) do { if (!(cond)) fw_assert_fail(__LINE__, FILE_ID); } while (0)
/* fw_assert_fail пишет строку и идентификатор файла в .noinit, затем NVIC_SystemReset().
   После перезагрузки причина уезжает наружу — это и есть чёрный ящик из главы 11. */

Как это выглядит целиком в CI

Разделение принципиальное: на каждый PR — только то, что укладывается в 5–10 минут и не требует железа; всё, что требует плат и времени, уезжает в ночной прогон и на релизного кандидата. Иначе очередь к единственному стенду становится узким местом всей команды. Общая механика конвейеров — основы CI и современные платформы.

Минимальный набор для команды из трёх человек

Полный конвейер выше — цель, а не стартовая точка. Порядок внедрения по отдаче на вложенный час:

Чего сознательно не делать: гнаться за покрытием драйверов (там мало ветвлений и много обращений к железу — цена теста высока, отдача мала); писать моки на вендорский HAL целиком (тонете в поддержке); тестировать на плате то, что проверяется на хосте. И отдельно: не пытайтесь ввести всё сразу — набор из пяти хостовых тестов и одного HIL-сценария, который живёт год, полезнее сотни, заброшенной через месяц.

Типичные ошибки

  • Считать, что «нет тестов, потому что железо». Восемьдесят процентов прошивки — логика, которая не знает про регистры и проверяется на хосте.
  • Смешивать логику и доступ к регистрам в одной функции — единственная ошибка, из-за которой невозможно всё остальное.
  • Брать время из HAL_GetTick() внутри логики — тесты становятся недетерминированными, а переполнение счётчика не проверяется никогда.
  • Гонять на плате то, что проверяется на хосте — очередь к стенду и медленный CI, который перестают ждать.
  • Перезапускать флейки вместо расследования — на железе мигающий тест почти всегда означает настоящую гонку.
  • Проверять только счастливый путь. Ветки обработки отказов — те самые, что сработают в поле, и именно они не покрыты.
  • Не проверять сторожевой таймер — он «есть» три года и ни разу не сработал, потому что кормится из живого прерывания.
  • Игнорировать нефункциональные регрессии — прошивка вырастает по килобайту за спринт и упирается во флеш за неделю до релиза.
  • Требовать в HIL-тесте единственного исхода там, где корректны два — тест становится источником ложных срабатываний.
  • Выключить -Wconversion, потому что «слишком много ругани» — эта ругань и есть список будущих багов.

Мини-итог

Тестирование прошивки строится на трёх решениях, и первое из них — не про тесты.

  • Шов. Логика отделена от железа узкими портами: время, память, шина. Критерий проверяемый: файл с логикой собирается обычным gcc без заголовков вендора. Нет шва — нет тестов, и никакой инструмент этого не исправит.
  • Раскладка по цене. Сотни быстрых тестов логики на хосте с санитайзерами; десятки сценариев в симуляторе на порядок инициализации и драйверы; единицы сценариев на железе — там, где решает физика: холодный старт, обрыв питания, сроки, ток. Уровень выбирается по тому, что именно доказывается.
  • Метрики как тесты. Размер прошивки, глубина стека, худшее время цикла, средний ток и время старта измеряются на каждой сборке и сравниваются с базовой линией. Эти регрессии не дают красный тест сами по себе, но именно они убивают проекты за неделю до релиза.

Практический старт на завтра: возьмите самый запутанный файл проекта, вынесите из него обращения к регистрам за интерфейс из двух функций, напишите пять тестов на его логику и соберите их обычным gcc с -fsanitize=address,undefined. Почти наверняка первый же прогон найдёт ошибку, которую вы искали на плате.

Источники

Что дальше

Трек «Встраиваемые системы и робототехника» на этом закончен: от карты трека и железа через C без динамики, периферию, прерывания, протоколы, RTOS, энергию, IoT-связь, датчики и приводы, робототехнику и отладку с производством — до сборки прошивки и её тестирования. Куда идти дальше, зависит от того, какая сторона задачи оказалась ближе:

Чтобы выбрать следующий трек осознанно, загляните в дорожную карту портала.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов