Перейти к содержанию

Модульное тестирование прошивки

Большая часть ошибок прошивки — не в регистрах, а в логике: в разборе пакетов, границах буферов, переходах автомата, расчётах. Эту логику можно проверять на компьютере за секунды, без платы и программатора. Разбираем, как отделить логику от железа, как писать тесты на фреймворке Unity, как подменить регистры периферии структурой в памяти и время — управляемыми часами, чем помогают санитайзеры GCC и что всё-таки приходится проверять на плате.

Обновлено Редакция GAW

Отладка на плате медленная: собрать, прошить, воспроизвести условия, посмотреть в отладчик или в журнал. Ошибка в разборщике, которая проявляется только на пакете длиной 255 байт с определённой контрольной суммой, может прятаться неделями. Модульный тест на компьютере проверяет такой случай за миллисекунды и при каждой сборке. Цена — архитектура: код, который принимает решения, должен быть отделён от кода, который трогает регистры.

Коротко

  • Тестируют логику: разборщики, буферы, автоматы, фильтры, расчёты; регистры и тайминг проверяют на плате.
  • Разделение: модуль логики не подключает заголовки микроконтроллера; железо — за узким интерфейсом.
  • Unity: setUp()/tearDown(), функции test_…, UNITY_BEGIN(), RUN_TEST(), UNITY_END(); проверки TEST_ASSERT_….
  • Подмена регистров: драйвер получает указатель на структуру регистров; в тесте это переменная в ОЗУ.
  • Подмена времени: функция «сейчас» передаётся в модуль, и тест сам двигает часы — таймауты проверяются без ожидания.

Что можно проверить на компьютере

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

Граница проходит по зависимостям: модуль, который подключает stm32f1xx.h или avr/io.h, на компьютере не соберётся. Поэтому модули логики пишут так, чтобы они зависели только от стандартной библиотеки C, а всё аппаратное получали через параметры или через функции, которые в тесте заменяются.

Как отделить логику от железа

Три способа подмены, от простого к гибкому:

  1. Подмена при компоновке. Логика вызывает функции вида motor_set(int pwm), объявленные в заголовке. Для микроконтроллера компонуется motor_hw.c, который пишет в регистры таймера, для теста — motor_fake.c, который записывает вызовы в журнал. Код логики не меняется вообще. Так устроен тест таблицы переходов в статье о конечных автоматах.
  2. Указатель на регистры. Драйвер получает указатель на структуру регистров периферии. На плате это адрес из карты памяти, в тесте — переменная. Так тестируется сам драйвер.
  3. Внедрение зависимостей. Модуль получает указатели на функции: «который час», «отправить байт». Это удобно, когда одну и ту же логику нужно запускать с разными реализациями — например, разборщик протокола для двух UART.

Чего избегать — #ifdef UNIT_TEST внутри логики. Тогда тест проверяет не тот код, который работает на плате.

Unity

Unity — фреймворк модульного тестирования на C проекта ThrowTheSwitch под лицензией MIT. Это один файл unity.c и заголовки unity.h и unity_internals.h, которые копируют в проект; зависимостей нет. Тестовый файл по документации Unity устроен так: подключает unity.h и заголовок проверяемого модуля, определяет setUp() и tearDown() — они вызываются до и после каждого теста, — затем функции test_…, а main() вызывает UNITY_BEGIN(), RUN_TEST() для каждой функции и возвращает UNITY_END().

Основные проверки:

МакросЧто проверяет
TEST_ASSERT_TRUE(cond), TEST_ASSERT_FALSE(cond)условие
TEST_ASSERT_EQUAL_INT(exp, act)равенство целых, вывод в десятичном виде
TEST_ASSERT_EQUAL_UINT8(exp, act), …_UINT16, …_UINT32равенство беззнаковых заданной ширины
TEST_ASSERT_EQUAL_HEX8(exp, act), …_HEX16, …_HEX32равенство, вывод в шестнадцатеричном виде — удобно для регистров
TEST_ASSERT_BITS_HIGH(mask, act)все биты маски установлены
TEST_ASSERT_EQUAL_UINT8_ARRAY(exp, act, n)равенство массивов байт — для кадров протокола
TEST_ASSERT_EQUAL_STRING(exp, act)равенство строк
TEST_IGNORE()пропустить тест

Пример — тест функции расчёта CRC-16/MODBUS. Контрольное значение для строки «123456789» — 0x4B37 (каталог параметризованных CRC; подробнее о CRC — в статье о реализации протоколов):

/* test_crc.c */
#include <string.h>
#include "unity.h"
#include "crc16.h"

void setUp(void) { }
void tearDown(void) { }

void test_crc16_modbus_check_value(void)
{
    const char *s = "123456789";
    TEST_ASSERT_EQUAL_HEX16(0x4B37, crc16_modbus((const uint8_t *)s, strlen(s)));
}

void test_crc16_modbus_empty_is_init(void)
{
    TEST_ASSERT_EQUAL_HEX16(0xFFFF, crc16_modbus(NULL, 0));
}

void test_crc16_appended_crc_gives_zero(void)
{
    uint8_t frame[] = { 0x01, 0x03, 0x00, 0x00, 0x00, 0x0A, 0, 0 };
    uint16_t crc = crc16_modbus(frame, 6);
    frame[6] = (uint8_t)(crc & 0xFFu);      /* low byte first, as Modbus sends it */
    frame[7] = (uint8_t)(crc >> 8);
    TEST_ASSERT_EQUAL_HEX16(0x0000, crc16_modbus(frame, sizeof frame));
}

int main(void)
{
    UNITY_BEGIN();
    RUN_TEST(test_crc16_modbus_check_value);
    RUN_TEST(test_crc16_modbus_empty_is_init);
    RUN_TEST(test_crc16_appended_crc_gives_zero);
    return UNITY_END();
}

Сборка и запуск на компьютере с GCC:

gcc -std=c11 -Wall -Wextra -Iunity/src unity/src/unity.c crc16.c test_crc.c -o test_crc
./test_crc

Unity печатает по строке на тест — имя файла, номер строки, имя теста и PASS или FAIL с причиной, — а в конце итог: «3 Tests 0 Failures 0 Ignored» и OK. Код возврата программы — число провалов, поэтому её можно запускать из make test или из CI: ненулевой код остановит сборку.

Третий тест проверяет свойство, а не конкретное значение: CRC-16/MODBUS кадра вместе с дописанной контрольной суммой (младшим байтом вперёд) равна нулю — у этого алгоритма нулевой остаток (residue = 0x0000 в каталоге CRC). Тесты на свойства ловят ошибки, которые пропустит одно контрольное значение: например, неправильный порядок байт CRC в кадре.

Писать main() с перечнем тестов вручную быстро надоедает. В Unity есть скрипт generate_test_runner.rb, который находит функции test_… и генерирует main(), а система сборки Ceedling того же проекта собирает тесты, раннеры и заглушки автоматически. Для небольших проектов достаточно правила в Makefile, которое собирает и запускает каждый тестовый файл.

Подмена регистров

Драйвер, который обращается к USART1->CR1, привязан к адресу. Если вместо этого передавать ему указатель на структуру регистров, тот же код можно запустить на переменной в памяти. Раскладка регистров USART у STM32F1 — из карты регистров RM0008 (27.6): SR по смещению 0x00, DR — 0x04, BRR — 0x08, CR1 — 0x0C, CR2 — 0x10, CR3 — 0x14, GTPR — 0x18.

/* usart_drv.h */
#ifndef USART_DRV_H
#define USART_DRV_H

#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>

typedef struct {                 /* STM32F1 USART layout, RM0008 27.6.8 */
    volatile uint32_t SR;        /* 0x00 */
    volatile uint32_t DR;        /* 0x04 */
    volatile uint32_t BRR;       /* 0x08 */
    volatile uint32_t CR1;       /* 0x0C */
    volatile uint32_t CR2;       /* 0x10 */
    volatile uint32_t CR3;       /* 0x14 */
    volatile uint32_t GTPR;      /* 0x18 */
} usart_regs_t;

_Static_assert(offsetof(usart_regs_t, GTPR) == 0x18, "USART register layout");

#define USART_SR_RXNE_BIT (1u << 5)
#define USART_SR_TXE_BIT  (1u << 7)
#define USART_CR1_RE_BIT  (1u << 2)
#define USART_CR1_TE_BIT  (1u << 3)
#define USART_CR1_UE_BIT  (1u << 13)

#define USART1_REGS ((usart_regs_t *)0x40013800u)   /* on the target only */

void usart_init(usart_regs_t *u, uint32_t pclk_hz, uint32_t baud);
bool usart_try_putc(usart_regs_t *u, uint8_t c);
int  usart_try_getc(usart_regs_t *u);

#endif
/* usart_drv.c */
#include "usart_drv.h"

void usart_init(usart_regs_t *u, uint32_t pclk_hz, uint32_t baud)
{
    u->CR1 = 0;                                  /* UE = 0 while configuring */
    u->BRR = (pclk_hz + baud / 2u) / baud;       /* 16 * USARTDIV, rounded */
    u->CR1 = USART_CR1_UE_BIT | USART_CR1_TE_BIT | USART_CR1_RE_BIT;
}

bool usart_try_putc(usart_regs_t *u, uint8_t c)
{
    if (!(u->SR & USART_SR_TXE_BIT))
        return false;                            /* transmitter busy */
    u->DR = c;
    return true;
}

int usart_try_getc(usart_regs_t *u)
{
    if (!(u->SR & USART_SR_RXNE_BIT))
        return -1;                               /* nothing received */
    return (int)(u->DR & 0xFFu);
}

Значение BRR равно 16 · USARTDIV = f_PCLK / скорость: старшие биты — мантисса, младшие четыре — дробная часть. Если считать его одним целым с округлением, перенос дробной части в мантиссу получается сам. Тесты:

/* test_usart_drv.c */
#include "unity.h"
#include "usart_drv.h"

static usart_regs_t fake;

void setUp(void)    { fake = (usart_regs_t){ 0 }; }
void tearDown(void) { }

void test_init_brr_72mhz_115200(void)
{
    usart_init(&fake, 72000000u, 115200u);
    TEST_ASSERT_EQUAL_HEX32(0x271, fake.BRR);    /* 39.0625: mantissa 39, fraction 1 */
    TEST_ASSERT_EQUAL_HEX32(USART_CR1_UE_BIT | USART_CR1_TE_BIT | USART_CR1_RE_BIT, fake.CR1);
}

void test_init_brr_rounds_36mhz(void)
{
    usart_init(&fake, 36000000u, 115200u);       /* 312.5 rounds up */
    TEST_ASSERT_EQUAL_HEX32(0x139, fake.BRR);
}

void test_putc_respects_txe(void)
{
    fake.DR = 0xDEADu;                           /* sentinel */
    TEST_ASSERT_FALSE(usart_try_putc(&fake, 'A'));
    TEST_ASSERT_EQUAL_HEX32(0xDEAD, fake.DR);    /* nothing written while busy */

    fake.SR = USART_SR_TXE_BIT;
    TEST_ASSERT_TRUE(usart_try_putc(&fake, 'A'));
    TEST_ASSERT_EQUAL_HEX32('A', fake.DR);
}

void test_getc_empty_and_full(void)
{
    TEST_ASSERT_EQUAL_INT(-1, usart_try_getc(&fake));
    fake.SR = USART_SR_RXNE_BIT;
    fake.DR = 0x1A5u;                            /* 9-bit garbage above the byte */
    TEST_ASSERT_EQUAL_INT(0xA5, usart_try_getc(&fake));
}

int main(void)
{
    UNITY_BEGIN();
    RUN_TEST(test_init_brr_72mhz_115200);
    RUN_TEST(test_init_brr_rounds_36mhz);
    RUN_TEST(test_putc_respects_txe);
    RUN_TEST(test_getc_empty_and_full);
    return UNITY_END();
}

setUp() обнуляет поддельные регистры присваиванием составного литерала, а не через memset: члены структуры volatile, и обращение к ним через указатель без volatile стандарт не определяет (C11, 6.7.3p6). Проверка _Static_assert с offsetof в заголовке гарантирует, что структура совпадает с картой регистров, — иначе на плате драйвер писал бы не туда, а тест бы этого не заметил.

Ограничение подхода видно сразу: поддельный регистр — просто память. Флаг RXNE в нём не сбросится от чтения DR, а TXE не исчезнет после записи. Тест проверяет, что драйвер делает при данных флагах, но не то, правильно ли он понимает поведение железа. Поэтому функции драйвера пишут неблокирующими (try_…) — их легко проверить за один вызов, — а циклы ожидания выносят на уровень выше. Правильность самих адресов и битов проверяют на плате, а для STM32 — ещё и сверкой с заголовком производителя: как соотносятся регистры, LL и HAL, рассказано в статье о HAL, LL и регистрах STM32.

Подмена времени

Таймауты — самое неудобное для тестов: нельзя ждать 3 секунды в каждом тесте. Решение — не вызывать HAL_GetTick() или millis() изнутри модуля, а получать время снаружи:

/* the module takes "now" as a parameter */
void link_poll(link_t *l, uint32_t now_ms);

/* test: move the clock by hand */
void test_link_drops_after_3s_silence(void)
{
    link_t l;
    link_init(&l, 0);
    link_poll(&l, 2999);
    TEST_ASSERT_TRUE(link_is_up(&l));
    link_poll(&l, 3001);
    TEST_ASSERT_FALSE(link_is_up(&l));
}

void test_link_timeout_across_counter_wrap(void)
{
    link_t l;
    link_init(&l, 0xFFFFF000u);                 /* 4096 ms before the 32-bit wrap */
    link_poll(&l, 0x00000100u);                 /* 4352 ms later */
    TEST_ASSERT_FALSE(link_is_up(&l));
}

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

Типы, санитайзеры и предупреждения

Код, проверенный на компьютере, работает на другом процессоре. Различия, которые надо держать в голове:

  • Размер int. В avr-gcc int 16-битный, на компьютере и на ARM — 32-битный; стандарт C разрешает 16-битный int (C11, 5.2.4.2.1). Выражение adc * 1000 с adc типа uint16_t на компьютере вычисляется в 32 битах, а на AVR переполняется. Логику пишут на типах stdint.h и явно расширяют произведения: (uint32_t)adc * 1000u. Подробнее — в статье о C для микроконтроллеров.
  • Знаковость char разная на разных платформах — для байтов данных используют uint8_t.
  • Порядок байт и выравнивание: разбор пакета приведением указателя *(uint16_t *)&buf[1] зависит и от того, и от другого. Байты собирают сдвигами — тогда код одинаков везде.

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

Тесты на плате

Часть проверок имеет смысл только на реальном микроконтроллере: обработчики прерываний, драйверы с настоящей периферией, тайминг. Unity работает и там: вывод результатов направляют в UART, определив UNITY_OUTPUT_CHAR(a) своей функцией в файле unity_config.h и включив его макросом UNITY_INCLUDE_CONFIG_H (Unity Configuration Guide). Тестовая прошивка запускает тесты при старте и печатает тот же отчёт, что и на компьютере; скрипт на компьютере читает порт и ищет итоговую строку.

Такие тесты медленнее и требуют стенда — платы, подключённой к компьютеру сборки, — поэтому их меньше, и проверяют они именно взаимодействие с железом: что после usart_init() байт действительно уходит на линию, что прерывание приёма заполняет буфер на максимальной скорости без потерь, что время обработчика укладывается в бюджет.

Тесты в процессе разработки

Тесты окупаются, когда запускаются сами. Минимальная схема:

  1. make test собирает и запускает все тесты на компьютере; ненулевой код возврата — провал.
  2. Тот же make test выполняется в CI при каждом коммите вместе со сборкой прошивки для микроконтроллера и проверкой её размера.
  3. Каждая найденная ошибка сначала воспроизводится тестом, потом исправляется — и больше не возвращается.

Тесты для разборщика протокола и кольцевого буфера, написанные по этой схеме, есть в статьях о реализации протоколов и о кольцевом буфере для UART.

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

  • Заголовок микроконтроллера в модуле логики — модуль не собирается на компьютере, и тестировать его начинают «потом».
  • #ifdef UNIT_TEST внутри логики — тест проверяет другой код.
  • Тест проверяет реализацию, а не поведение — например, значения внутренних счётчиков вместо результата; любое изменение кода ломает тесты без причины.
  • Время берётся внутри модуля — таймауты не тестируются или тесты ждут реальные секунды.
  • Поддельные регистры считают настоящими — тест проходит, а на плате флаг сбрасывается чтением и драйвер теряет байт.
  • Только счастливый путь — нет тестов на пустой буфер, максимальную длину, неверную контрольную сумму, переход счётчика времени через ноль.
  • int вместо типов фиксированной ширины — тест на компьютере проходит, на AVR код переполняется.

Частые вопросы

Можно ли тестировать прошивку без микроконтроллера?

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

Что такое Unity и зачем он нужен?

Unity — небольшой фреймворк модульного тестирования на C от проекта ThrowTheSwitch под лицензией MIT: один файл unity.c и два заголовка. Он даёт макросы проверок вроде TEST_ASSERT_EQUAL_HEX32, запускает тестовые функции и печатает итог. Работает и на компьютере, и на самом микроконтроллере — вывод направляют в UART макросом UNITY_OUTPUT_CHAR.

Как протестировать драйвер, который пишет в регистры?

Передавать драйверу указатель на структуру регистров, а не обращаться к фиксированному адресу. На микроконтроллере это адрес периферии, например 0x40013800 для USART1 у STM32F103, а в тесте — обычная переменная в памяти. Тест выставляет нужные флаги в поддельном регистре состояния, вызывает функцию драйвера и проверяет, что записано в регистры данных и управления.

Почему тест проходит на компьютере, а на AVR код работает неправильно?

Чаще всего из-за размеров типов: int на компьютере 32-битный, а в avr-gcc — 16-битный, и выражение вроде 300 * 200 переполняется только на AVR. Поэтому в логике используют типы фиксированной ширины из stdint.h, а промежуточные произведения явно приводят к нужной разрядности. Вторая причина — порядок байт и выравнивание при разборе пакетов через приведение указателей.

Источники

  1. ThrowTheSwitch. Unity Test Framework v2.7.0 (коммит b6763fb): src/unity.h, src/unity_internals.h, src/unity.c, docs/UnityGettingStartedGuide.md, docs/UnityAssertionsReference.md, docs/UnityConfigurationGuide.md — лицензия MIT; тестовый файл подключает unity.h, содержит setUp() и tearDown() (без генератора — хотя бы пустые), функции test_…; main() вызывает UNITY_BEGIN(), RUN_TEST для каждой функции и возвращает UNITY_END(); макросы TEST_ASSERT_TRUE, TEST_ASSERT_EQUAL_INT, TEST_ASSERT_EQUAL_UINT8, TEST_ASSERT_EQUAL_HEX32, TEST_ASSERT_BITS_HIGH, TEST_ASSERT_EQUAL_UINT8_ARRAY, TEST_ASSERT_EQUAL_STRING; результат — строки PASS или FAIL и итог «N Tests M Failures K Ignored» и OK или FAIL; UNITY_OUTPUT_CHAR(a) направляет вывод в свою функцию, например в UART, настройки — в unity_config.h при UNITY_INCLUDE_CONFIG_H; генератор раннеров generate_test_runner.rb и Ceedling
  2. STMicroelectronics. RM0008 Reference Manual STM32F101/102/103/105/107, Rev 21, 2021 — 3.3 — USART1 по адресам 0x40013800–0x40013BFF; 27.6 — смещения регистров SR 0x00, DR 0x04, BRR 0x08, CR1 0x0C, CR2 0x10, CR3 0x14, GTPR 0x18; SR: TXE — бит 7, RXNE — бит 5; CR1: UE — бит 13, TXEIE — бит 7, RXNEIE — бит 5, TE — бит 3, RE — бит 2
  3. GCC 16.2 Manual: Instrumentation Options (-fsanitize=address, -fsanitize=undefined), Warning Options — AddressSanitizer — обнаружение обращений за границы и после освобождения; UndefinedBehaviorSanitizer — проверки неопределённого поведения во время выполнения
  4. ISO/IEC 9899:201x (C11), Committee Draft N1570, April 2011 — 5.2.4.2.1 — минимальные пределы INT_MAX (32 767), int может быть 16-битным; 6.7.3p6 — обращение к volatile-объекту через не-volatile lvalue не определено; 6.7.10 — _Static_assert; 7.19 — offsetof
  5. G. Cook. Catalogue of parametrised CRC algorithms with 16 bits, reveng.sourceforge.io, обновлено 11 декабря 2024 — CRC-16/MODBUS: poly 0x8005, init 0xFFFF, refin и refout true, xorout 0x0000, check 0x4B37, residue 0x0000
  6. GCC Wiki: avr-gcc — ABI; Arm AAPCS32 (github.com/ARM-software/abi-aa, коммит 7850367) — int в avr-gcc — 16 бит, в AAPCS32 — 32 бита

Нашли ошибку?