Embedded HandbookПриложения
Все разделы › XXII. Программная инженерия
STM32F103, STM32F407, STM32G431, STM32L431 · уровень средний · 13 мин чтения
Открыть в интерактивном справочнике

184. Тестирование прошивки: уровни, покрытие, мутации, санитайзеры

Раздел 182 показал, сколько стоит ошибка, дожившая до поля. Здесь — чем её ловить и как понять, что набор тестов действительно что-то проверяет, а не просто «зелёный».

Всё на живом примере: разборщик кадров 0x55 — длина — данные — CRC-8, тот самый код, который в прошивке разбирает поток из UART. Он собирается и на плате, и на компьютере, поэтому все замеры ниже — настоящие: gcc 12 в образе stm-lab:1, покрытие через gcov, мутации и санитайзер.

Как это называется в документации

Оригинал Перевод
unit test модульный тест
integration / system test интеграционный, системный тест
test harness, fixture каркас тестов, подготовка окружения
code coverage: line / branch покрытие кода: по строкам, по ветвям
mutation testing мутационное тестирование
mutant killed / survived мутант убит, мутант выжил
sanitizer (ASan, UBSan) санитайзер: проверки памяти и неопределённого поведения
fuzzing подача случайных входов
stub, mock заглушка, подделка зависимости
regression test тест на возврат старой ошибки

184.1 Три уровня и что каждый ловит

Уровень Где идёт Что ловит Чем плох
модульный на компьютере, без платы логика: разбор, расчёт, состояния не видит железа
интеграционный на плате со стендом драйверы, тайминги, прерывания нужен стенд, идёт медленно
системный на готовом изделии сценарии целиком, питание, помехи самый дорогой и редкий

Смысл разделения — в цене прогона. Модульные тесты идут за доли секунды на каждое изменение, поэтому ошибку они ловят на этапе кода, где она стоит час (раздел 182.2). Системные проверки идут перед сдачей — там же и цена ошибки.

Для прошивки правило простое: логику отделяют от железа. Разбор кадра, расчёт калибровки, автомат состояний, кольцевой буфер — это обычный C без регистров, и он проверяется на компьютере. Всё, что трогает регистры, прячут за тонкой прослойкой, а в тестах подставляют заглушку.

Три уровня проверок: где идут, что ловят и что стоят


184.2 Модуль, который можно проверить без платы

Разборщик — автомат из четырёх состояний: ждём старт-байт, читаем длину, набираем данные, сверяем CRC. Ошибка возвращает разборщик в начало:

case FR_LEN:
    if (b == 0u || b > FRAME_MAX) {
        frame_reset(p);
        return FRAME_ERR;
    }
    p->len = b;
    p->got = 0;
    p->state = FR_DATA;
    return FRAME_NONE;

В нём нет ни одного обращения к регистрам: байты приходят параметром. На плате его кормит прерывание UART, в тестах — массив. По счёту gcov это 45 строк кода и 22 ветви.


184.3 Каркас тестов на двадцать строк

Никакой библиотеки не нужно: считаем проверки и провалы.

static int checks, failed;

static void check(int cond, const char *what)
{
    checks++;
    if (!cond) {
        failed++;
        printf("FAIL: %s\n", what);
    }
}

Тесты собраны в таблицу, а -DUPTO=k прогоняет только первые k — так видно, что даёт каждый следующий тест:

№ Тест
1 правильный кадр разобран
2 испорченная CRC отвергнута
3 длина 0 и длина больше предела отвергнуты
4 мусор до старт-байта не мешает
5 кадр предельной длины
6 старт-байт внутри данных не ломает кадр
7 два кадра подряд
8 CRC на известных данных

Весь набор — 8 тестов и 15 проверок, прогон занимает доли секунды.


184.4 Покрытие: что оно говорит и чего не говорит

gcc --coverage и gcov -b считают, какие строки и ветви выполнились:

Тестов Строк Ветвей Убито мутантов из 48
1 91,11 % 81,82 % 32
2 95,56 % 86,36 % 32
3 100,00 % 95,45 % 33
4 100,00 % 100,00 % 34
5 100,00 % 100,00 % 35
6 100,00 % 100,00 % 35
7 100,00 % 100,00 % 35
8 100,00 % 100,00 % 44

Первый же тест — «правильный кадр» — даёт 91,11 % строк: счастливый путь проходит почти через весь модуль. На четвёртом тесте покрытие упирается в потолок: 100 % строк и 100 % ветвей. Дальше оно не растёт — и по нему кажется, что тесты писать больше незачем.

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

Покрытие упирается в потолок, а мутанты продолжают выживать


184.5 Мутационная проверка: тест на сами тесты

Идея простая: испортить код одной правкой и посмотреть, упадут ли тесты. Если не упали — эта правка тестами не проверяется. Мутации берутся по порядку, без случайности: сравнения меняются на соседние (== на !=, < на <=), целые литералы — на единицу больше. На нашем модуле получилось 48 мутантов.

При 100 % покрытия строк и ветвей (четыре теста) убито только 34 из 48 — 71 %. Полный набор из восьми тестов убивает 44 — 92 %.

Самое интересное — восьмой тест. Он добивает сразу девять мутантов, и все они про CRC: полином 0x07, начальное значение, счётчик битов. Почему их не ловили остальные тесты? Потому что они считали ожидаемую CRC тем же самым кодом:

buf[5] = frame_crc8(data, 3);      /* ожидание из проверяемого кода */
...
check(frame_crc8(d, 3) == 0xC0u, "CRC-8 на известных данных");

Первая строка ничего не проверяет в самой CRC: испортим полином — и кадр, и ожидание изменятся одинаково. Вторая — проверяет, потому что ответ записан числом, полученным независимо. Отсюда правило: хотя бы один тест на алгоритм должен сравнивать с числом, а не с вызовом того же кода.

Выжившие четыре мутанта разобрать тоже полезно:

Мутант Почему выжил
p->len = 0 → = 1 в сбросе поле всегда переписывается до чтения
p->got = 0 → = 1 в сбросе то же самое
p->crc = 0 → = 1 в сбросе то же самое
i < p->len → i <= p->len тесты не смотрят за границу массива

Первые три — это не дыра в тестах, а лишний код: сброс присваивает поля, которые и так задаются заново перед использованием. Четвёртый — настоящая дыра, и о ней дальше.


184.6 Санитайзеры и случайные входы

Мутант i <= p->len копирует на один байт больше, чем есть в кадре. При длине 32 это запись за границу массива — и тесты её не замечают: лишний байт попадает в соседнюю память и ничего не ломает. Тот же прогон, собранный с проверками -fsanitize=undefined -fsanitize=bounds-strict, падает сразу:

runtime error: index 32 out of bounds for type 'uint8_t [32]'

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

Заодно выясняется, откуда берутся «плавающие» тесты. Другой мутант — i < n на i <= n в расчёте CRC — читает один байт за границей массива, и обычная сборка убивает его через раз: что оказалось в соседней памяти, то и попало в CRC. Поэтому все замеры собираются с -ftrivial-auto-var-init=zero — локальные переменные обнуляются, и прогон повторяется число в число. В настоящей прошивке этот же мутант — чтение лишнего байта, и санитайзеры его не видят: проверки undefined и bounds не знают размера за указателем, а -fsanitize=address молчит, потому что байт за data лежит внутри той же структуры. Ловят его только тесты.

Второе дешёвое средство — случайные входы. Прогон 2 000 000 случайных байт через разборщик (генератор свой, чтобы прогон повторялся):

Что считали Значение
байт подано 2 000 000
собралось кадров 4
отвергнуто как ошибка 7 786
кадров с неверной длиной 0

Санитайзер за весь прогон не сказал ни слова. Четыре случайных кадра на два миллиона байт — это нормально: чтобы кадр собрался, случайные данные должны сойтись с CRC. Ценность прогона в другом: разборщик 7 786 раз корректно отверг мусор и ни разу не вышел за массив и не завис.


184.7 Что из этого держать в конвейере

Окупаемость считается ровно так, как в 182.3: вложения в каркас тестов против часов, сэкономленных на ошибках, не доехавших до поля.


184.8 Грабли

Тест сравнивает с вызовом того же кода. Девять мутантов CRC выжили именно поэтому. Хотя бы одно ожидание должно быть независимым числом.

100 % покрытия принимают за качество. При 100 % строк и ветвей выживали 14 мутантов из 48.

Тесты только на счастливый путь. Один тест «правильный кадр» даёт 91,11 % строк — и не ловит ни испорченную CRC, ни неверную длину.

Логика перемешана с регистрами. Такой модуль нельзя собрать на компьютере, и он остаётся без модульных тестов вовсе.

Тесты не гоняют с санитайзером. Выход за границу массива на один байт обычный прогон не замечает годами — пока не переставят поля структуры.

Плавающий тест оставляют «пока так». Тест, который иногда падает, через неделю никто не читает, и вместе с ним перестают читать весь набор.


184.9 Итоги

Полные исходники

frame.c — разбор кадра — код под тестами.

#include "frame.h"

void frame_reset(frame_parser_t *p)
{
    p->state = FR_WAIT;
    p->len = 0;
    p->got = 0;
    p->crc = 0;
}

uint8_t frame_crc8(const uint8_t *data, size_t n)
{
    uint8_t crc = 0x00u;
    for (size_t i = 0; i < n; i++) {
        crc ^= data[i];
        for (int bit = 0; bit < 8; bit++) {
            if (crc & 0x80u) {
                crc = (uint8_t)((crc << 1) ^ 0x07u);
            } else {
                crc = (uint8_t)(crc << 1);
            }
        }
    }
    return crc;
}

int frame_feed(frame_parser_t *p, uint8_t b, frame_t *out)
{
    switch (p->state) {
    case FR_WAIT:
        if (b == FRAME_START) {
            p->state = FR_LEN;
        }
        return FRAME_NONE;

    case FR_LEN:
        if (b == 0u || b > FRAME_MAX) {
            frame_reset(p);
            return FRAME_ERR;
        }
        p->len = b;
        p->got = 0;
        p->state = FR_DATA;
        return FRAME_NONE;

    case FR_DATA:
        p->data[p->got] = b;
        p->got++;
        if (p->got == p->len) {
            p->state = FR_CRC;
        }
        return FRAME_NONE;

    case FR_CRC:
    default:
        p->crc = frame_crc8(p->data, p->len);
        if (p->crc != b) {
            frame_reset(p);
            return FRAME_ERR;
        }
        out->len = p->len;
        for (uint8_t i = 0; i < p->len; i++) {
            out->data[i] = p->data[i];
        }
        frame_reset(p);
        return FRAME_READY;
    }
}

tests.c — набор тестов к разбору кадра.

/* Набор тестов к разбору кадра. UPTO=k — прогнать только первые k тестов. Раздел 184.
   Разборщик, кадр и данные объявлены static с запасным нулевым байтом в конце: тогда
   мутант, залезающий за границу массива, ведёт себя одинаково от прогона к прогону. */
#include <stdio.h>
#include <string.h>
#include "frame.h"

static int checks, failed;
static frame_parser_t p;
static frame_t out;

static void check(int cond, const char *what)
{
    checks++;
    if (!cond) {
        failed++;
        printf("FAIL: %s\n", what);
    }
}

/* прогнать массив байт через разборщик; вернуть последний результат */
static int feed_all(const uint8_t *b, size_t n)
{
    int r = FRAME_NONE;
    for (size_t i = 0; i < n; i++) {
        r = frame_feed(&p, b[i], &out);
    }
    return r;
}

static void test_good(void)
{
    static const uint8_t data[4] = {0x01, 0x02, 0x03, 0x00};
    static uint8_t buf[6] = {FRAME_START, 3, 0x01, 0x02, 0x03, 0};
    buf[5] = frame_crc8(data, 3);
    frame_reset(&p);
    check(feed_all(buf, 6) == FRAME_READY, "правильный кадр принят");
    check(out.len == 3 && memcmp(out.data, data, 3) == 0, "данные кадра совпали");
}

static void test_bad_crc(void)
{
    static const uint8_t data[3] = {0x10, 0x20, 0x00};
    static uint8_t buf[5] = {FRAME_START, 2, 0x10, 0x20, 0};
    buf[4] = (uint8_t)(frame_crc8(data, 2) ^ 0xFFu);
    frame_reset(&p);
    check(feed_all(buf, 5) == FRAME_ERR, "неверная CRC отвергнута");
    check(p.state == FR_WAIT, "после ошибки разборщик ждёт старт-байт");
}

static void test_bad_len(void)
{
    static const uint8_t zero[2] = {FRAME_START, 0};
    static const uint8_t big[2] = {FRAME_START, FRAME_MAX + 1};
    frame_reset(&p);
    check(feed_all(zero, 2) == FRAME_ERR, "длина 0 отвергнута");
    frame_reset(&p);
    check(feed_all(big, 2) == FRAME_ERR, "длина больше предела отвергнута");
}

static void test_garbage_before_start(void)
{
    static const uint8_t data[2] = {0xAA, 0x00};
    static uint8_t buf[7] = {0x00, 0xFF, 0x12, FRAME_START, 1, 0xAA, 0};
    buf[6] = frame_crc8(data, 1);
    frame_reset(&p);
    check(feed_all(buf, 7) == FRAME_READY, "мусор до старт-байта не мешает");
    check(out.len == 1 && out.data[0] == 0xAA, "кадр после мусора разобран");
}

static void test_max_len(void)
{
    static uint8_t data[FRAME_MAX + 1];
    static uint8_t buf[FRAME_MAX + 3];
    for (unsigned i = 0; i < FRAME_MAX; i++) {
        data[i] = (uint8_t)(i * 7 + 1);
    }
    buf[0] = FRAME_START;
    buf[1] = FRAME_MAX;
    memcpy(buf + 2, data, FRAME_MAX);
    buf[FRAME_MAX + 2] = frame_crc8(data, FRAME_MAX);
    frame_reset(&p);
    check(feed_all(buf, FRAME_MAX + 3) == FRAME_READY, "кадр предельной длины принят");
    check(out.len == FRAME_MAX, "длина предельного кадра верна");
}

static void test_start_inside_data(void)
{
    static const uint8_t data[3] = {FRAME_START, 0x55, 0x00};
    static uint8_t buf[5] = {FRAME_START, 2, FRAME_START, 0x55, 0};
    buf[4] = frame_crc8(data, 2);
    frame_reset(&p);
    check(feed_all(buf, 5) == FRAME_READY, "старт-байт внутри данных не ломает кадр");
}

static void test_two_frames(void)
{
    static const uint8_t d1[2] = {0x41, 0x00};
    static const uint8_t d2[2] = {0x42, 0x00};
    static uint8_t buf[8] = {FRAME_START, 1, 0x41, 0, FRAME_START, 1, 0x42, 0};
    buf[3] = frame_crc8(d1, 1);
    buf[7] = frame_crc8(d2, 1);
    frame_reset(&p);
    check(feed_all(buf, 4) == FRAME_READY && out.data[0] == 0x41, "первый кадр");
    check(feed_all(buf + 4, 4) == FRAME_READY && out.data[0] == 0x42, "второй кадр подряд");
}

static void test_crc_known(void)
{
    static const uint8_t d[4] = {0x31, 0x32, 0x33, 0x00};
    check(frame_crc8(d, 3) == 0xC0u, "CRC-8 на известных данных");
    check(frame_crc8(d, 0) == 0x00u, "CRC-8 пустых данных");
}

#ifndef UPTO
#define UPTO 8
#endif

typedef void (*test_fn)(void);

static const test_fn TESTS[] = {
    test_good,                  /* 1 — счастливый путь */
    test_bad_crc,               /* 2 — испорченная CRC */
    test_bad_len,               /* 3 — длина 0 и больше предела */
    test_garbage_before_start,  /* 4 — мусор до старт-байта */
    test_max_len,               /* 5 — кадр предельной длины */
    test_start_inside_data,     /* 6 — старт-байт внутри данных */
    test_two_frames,            /* 7 — два кадра подряд */
    test_crc_known,             /* 8 — CRC на известных данных */
};

int main(void)
{
    unsigned n = UPTO;
    if (n > sizeof(TESTS) / sizeof(TESTS[0])) {
        n = sizeof(TESTS) / sizeof(TESTS[0]);
    }
    for (unsigned i = 0; i < n; i++) {
        TESTS[i]();
    }
    printf("тестов: %u, проверок: %d, провалов: %d\n", n, checks, failed);
    return failed ? 1 : 0;
}

Дальше: раздел 185 «Dart для инженера прошивки: байты, числа, разбор кадра».

Открыть в интерактивном справочнике