Модульное тестирование прошивки
Большая часть ошибок прошивки — не в регистрах, а в логике: в разборе пакетов, границах буферов, переходах автомата, расчётах. Эту логику можно проверять на компьютере за секунды, без платы и программатора. Разбираем, как отделить логику от железа, как писать тесты на фреймворке Unity, как подменить регистры периферии структурой в памяти и время — управляемыми часами, чем помогают санитайзеры GCC и что всё-таки приходится проверять на плате.
Обновлено Редакция GAW
Отладка на плате медленная: собрать, прошить, воспроизвести условия, посмотреть в отладчик или в журнал. Ошибка в разборщике, которая проявляется только на пакете длиной 255 байт с определённой контрольной суммой, может прятаться неделями. Модульный тест на компьютере проверяет такой случай за миллисекунды и при каждой сборке. Цена — архитектура: код, который принимает решения, должен быть отделён от кода, который трогает регистры.
Коротко
- Тестируют логику: разборщики, буферы, автоматы, фильтры, расчёты; регистры и тайминг проверяют на плате.
- Разделение: модуль логики не подключает заголовки микроконтроллера; железо — за узким интерфейсом.
- Unity:
setUp()/tearDown(), функцииtest_…,UNITY_BEGIN(),RUN_TEST(),UNITY_END(); проверкиTEST_ASSERT_…. - Подмена регистров: драйвер получает указатель на структуру регистров; в тесте это переменная в ОЗУ.
- Подмена времени: функция «сейчас» передаётся в модуль, и тест сам двигает часы — таймауты проверяются без ожидания.
Что можно проверить на компьютере
| Проверяется на компьютере | Только на плате |
|---|---|
| разбор пакетов и строк, контрольные суммы | тайминг: время реакции, длительность обработчиков |
| кольцевые буферы и очереди — границы, переполнение, порядок | реальное поведение периферии: флаги, которые сбрасываются чтением, задержки |
| конечные автоматы — все переходы, таймауты | гонки между обработчиком и основным циклом |
| фильтры, ПИД, пересчёт единиц, фиксированная точка | энергопотребление, режимы сна |
| вычисление настроек: делители, таймауты, скорости | электрические проблемы: помехи, уровни, длинные линии |
| логику драйвера при заданных значениях регистров | правильность адресов и битов в заголовке производителя |
Граница проходит по зависимостям: модуль, который подключает stm32f1xx.h или avr/io.h, на компьютере не соберётся. Поэтому модули логики пишут так, чтобы они зависели только от стандартной библиотеки C, а всё аппаратное получали через параметры или через функции, которые в тесте заменяются.
Как отделить логику от железа
Три способа подмены, от простого к гибкому:
- Подмена при компоновке. Логика вызывает функции вида
motor_set(int pwm), объявленные в заголовке. Для микроконтроллера компонуетсяmotor_hw.c, который пишет в регистры таймера, для теста —motor_fake.c, который записывает вызовы в журнал. Код логики не меняется вообще. Так устроен тест таблицы переходов в статье о конечных автоматах. - Указатель на регистры. Драйвер получает указатель на структуру регистров периферии. На плате это адрес из карты памяти, в тесте — переменная. Так тестируется сам драйвер.
- Внедрение зависимостей. Модуль получает указатели на функции: «который час», «отправить байт». Это удобно, когда одну и ту же логику нужно запускать с разными реализациями — например, разборщик протокола для двух 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-gccint16-битный, на компьютере и на 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() байт действительно уходит на линию, что прерывание приёма заполняет буфер на максимальной скорости без потерь, что время обработчика укладывается в бюджет.
Тесты в процессе разработки
Тесты окупаются, когда запускаются сами. Минимальная схема:
make testсобирает и запускает все тесты на компьютере; ненулевой код возврата — провал.- Тот же
make testвыполняется в CI при каждом коммите вместе со сборкой прошивки для микроконтроллера и проверкой её размера. - Каждая найденная ошибка сначала воспроизводится тестом, потом исправляется — и больше не возвращается.
Тесты для разборщика протокола и кольцевого буфера, написанные по этой схеме, есть в статьях о реализации протоколов и о кольцевом буфере для 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, а промежуточные произведения явно приводят к нужной разрядности. Вторая причина — порядок байт и выравнивание при разборе пакетов через приведение указателей.
Источники
- 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
- 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
- GCC 16.2 Manual: Instrumentation Options (-fsanitize=address, -fsanitize=undefined), Warning Options — AddressSanitizer — обнаружение обращений за границы и после освобождения; UndefinedBehaviorSanitizer — проверки неопределённого поведения во время выполнения
- 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
- 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
- GCC Wiki: avr-gcc — ABI; Arm AAPCS32 (github.com/ARM-software/abi-aa, коммит 7850367) — int в avr-gcc — 16 бит, в AAPCS32 — 32 бита
Нашли ошибку?
Смотрите также
- Программирование МКC для МК, битовые операции, фиксированная точка, регистры
- Конечные автоматы на Cswitch-case, таблица переходов, функции-состояния, таймауты, очередь событий
- Кольцевой буфер UARTБуфер SPSC с маской, ISR приёма и передачи на AVR и STM32, ORE и DOR, размер
- Реализация протокола обменаКадры по паузе, длине и разделителю, COBS, CRC-16, таймауты, разбор потока
- C для микроконтроллеровТипы, stdint, volatile, атомарность, регистры, константы во Flash
- Переполнение стека и памятьОценка стека, раскраска ОЗУ, защитная область MPU, HardFault, куча
- UART STM32BRR и OVER8, допуск по частоте, флаги, прерывания, DMA с IDLE, полудуплекс, RS-485
- HAL, LL или регистрыУровни STM32Cube, что делает HAL, правила совмещения, один код тремя способами