Embedded HandbookПриложения
Все разделы › XV. Язык C глубже
STM32F103 · уровень средний · 22 мин чтения
Открыть в интерактивном справочнике

129а. Модульные тесты прошивки на ПК: подмена железа, свой маленький раннер и покрытие

Раздел 129 искал ошибки, не запуская код: компилятор, cppcheck, анализ путей. Раздел 184 запускал на компьютере чистую логику — разборщик кадров, в котором нет ни одного регистра. Здесь следующий шаг: код, который трогает железо. Драйвер пишет в регистры USART, зовёт HAL — и его тоже можно гонять на ПК, если подменить три вещи: адреса периферии, ядро CMSIS и часть функций HAL. Исходники драйвера при этом не меняются ни на букву: те же файлы собирает armclang для платы.

Подопытный — драйвер RS-485 из раздела 16а: передача по прерываниям, DE снимается по флагу TC. Там его проверяла модель USART по времени; здесь — модульные тесты, каждый про одно правило драйвера. Рядом — тот же обмен на HAL, чтобы показать подмену функций при компоновке.

Опоры: заголовок stm32f10x.h из пака Keil STM32F1xx_DFP 2.4.1 (тот же, что в 16а), stm32f103xb.h и HAL из архива STM32CubeF1 1.8.7, справочное руководство RM0008 (смещения и сброс регистров USART и GPIO). Всё собрано и прогнано в образе stm-lab:1 (gcc 12), каталог _verify/utest129b; драйверы собраны armclang для Cortex-M3. Известные готовые каркасы — Unity, генератор подделок CMock и сборщик Ceedling — здесь не запускались: всё нужное умещается в свой раннер на 52 строки.

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

Оригинал Перевод
unit test модульный тест
test runner раннер: программа, которая запускает тесты и считает провалы
fake, stub, mock подделка, заглушка, подделка с проверкой вызовов
test double подмена зависимости в тесте
seam шов: место, где зависимость можно подменить
weak symbol (__weak) слабый символ: уступает обычному определению
--wrap=symbol ключ компоновщика: вызовы уходят в __wrap_symbol
code coverage: line / branch покрытие: по строкам, по ветвям
characterization test тест, который фиксирует поведение чужого кода

129а.1 Где шов между драйвером и железом

У драйвера три зависимости от платы, и у каждой свой шов:

Зависимость Как выглядит в коде Чем подменяется на ПК
регистры периферии USART2->CR1, GPIOA->BSRR структура в ОЗУ вместо адреса
ядро NVIC_EnableIRQ, SysTick из core_cm3.h свой core_cm3.h раньше в пути поиска
функции HAL HAL_GPIO_WritePin, HAL_UART_Transmit своя функция при компоновке

Первые два шва делает препроцессор, третий — компоновщик. Код драйвера не знает, где его собирают: ни одного #ifdef TEST в нём нет. Если в драйвере появляется #ifdef ради тестов, тесты проверяют уже не тот код, который уйдёт в плату.

Одни исходники — две сборки: слева gcc на ПК с настоящим заголовком устройства, своим core_cm3.h, периферией в ОЗУ и частью HAL, подменённой при компоновке, — 11 тестов, каждый в своём процессе, покрытие gcov; справа armclang для Cortex-M3 с тем же заголовком, настоящим core_cm3.h и настоящим HAL, где HAL_GetTick и HAL_Delay слабые, — прошивка STM32F103 без предупреждений; подложено 6 ошибок, тесты поймали все 6, покрытие строк 100 %, ветвей взято 81,25 %


129а.2 Раннер на 52 строки

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

static int tt_run(const struct tt_case *c, int n, int argc, char **argv)
{
    int run = 0, bad = 0;
    for (int i = 0; i < n; i++) {
        if (argc > 1 && strstr(c[i].name, argv[1]) == NULL)
            continue;                   /* фильтр по имени: ./tests uart */
        run++;
        fflush(stdout);
        pid_t pid = fork();
        if (pid == 0) {                 /* ребёнок: один тест */
            alarm(1);                   /* повис — через секунду SIGALRM */
            setup();
            c[i].fn();
            printf("ok   %s, проверок: %d\n", c[i].name, tt_checks);
            exit(0);                    /* exit, а не _exit: gcov допишет счётчики */
        }
        int st = 0;
        waitpid(pid, &st, 0);
        if (WIFEXITED(st) && WEXITSTATUS(st) == 0)
            continue;
        bad++;
        printf("%s %s\n", WIFSIGNALED(st) && WTERMSIG(st) == SIGALRM ? "HANG"
                          : WIFSIGNALED(st) ? "CRASH" : "FAIL", c[i].name);
    }
    printf("TOTAL tests=%d failed=%d\n", run, bad);
    return bad != 0;
}

Процесс на тест даёт сразу три вещи. Статическое состояние у каждого теста своё, с нуля. Зависший тест снимает будильник через секунду — раннер пишет HANG и идёт дальше. Обращение по битому указателю роняет только ребёнка — раннер пишет CRASH. Проверки CHECK и CHECK_EQ при провале печатают файл, строку, выражение и оба значения и завершают процесс. Аргумент командной строки — фильтр: ./t de_ запустил три теста из шести. Код возврата не ноль при любом провале — этого достаточно конвейеру из раздела 110.

fork есть в Linux и в контейнере, где идут тесты. Раннеру это и нужно: на плате он не работает, на плате работают драйверы.


129а.3 Регистры: структура вместо адреса

В заголовке устройства периферия — это тип и адрес:

#define USART2              ((USART_TypeDef *) USART2_BASE)

Тип USART_TypeDef описывает раскладку регистров, адрес — где она лежит. Для теста нужен тот же тип, но в памяти ПК. Заголовок устройства берём настоящий, из пака, и после него подключаем свой:

extern USART_TypeDef fake_usart2;
extern GPIO_TypeDef fake_gpioa;
extern RCC_TypeDef fake_rcc;

#undef USART2
#undef GPIOA
#undef RCC
#define USART2 (&fake_usart2)           /* было ((USART_TypeDef *) USART2_BASE) */
#define GPIOA  (&fake_gpioa)
#define RCC    (&fake_rcc)

Раскладка совпадает сама: тип один и тот же. Для верности в том же файле _Static_assert сверяет смещения с картой регистров RM0008: USART_DR — 0x04, USART_CR1 — 0x0C, GPIOx_BSRR — 0x10. Поменяется заголовок — тест не соберётся.

Заголовок устройства тянет core_cm3.h, а в нём ассемблерные вставки для Cortex-M — gcc для ПК их не соберёт. Заголовок пака подключает ядро в кавычках, поэтому достаточно положить свой core_cm3.h в каталог host/ и поставить его в пути поиска раньше CMSIS. В нём — только то, что нужно: квалификаторы __IO, __I, __O и их пары __IOM/__IM/__OM, структура SysTick_Type и NVIC_EnableIRQ, которая просто запоминает номер прерывания. Сборка драйвера на ПК:

gcc -std=c11 -Wall -Wextra -DSTM32F10X_MD -I host -I <пак>/Device/Include \
    -include stm32f10x.h -include fake_periph.h -c rs485_f103.c

Ни одного предупреждения — ни у драйвера, ни у тестов.

Чего структура не умеет. Она помнит только последнее записанное значение: порядок записей не виден, и тест «DE поднят раньше, чем разрешено прерывание» так не написать. Она не знает побочных действий железа: в USART пара «чтение SR — запись DR» сбрасывает TC, а у структуры ничего не сбрасывается. Поэтому флаги тест выставляет руками, перед каждым вызовом обработчика. Где важны время и побочные действия — нужна модель периферии, как в 16а.4. Модульный тест проверяет другое: что драйвер делает в ответ на данное состояние регистров.


129а.4 Тесты драйвера RS-485

Файл rs485_f103.c взят из 16а без правок. Шесть тестов — по одному на правило драйвера:

Тест Правило Проверок
init_9600_8E1_listening BRR = 3750, 8E1, DE = 0, прерывание USART2 разрешено 4
send_raises_de_and_mutes_rx при отправке DE = 1, приём выключен, TCIE ещё нет 4
bytes_in_order_de_held байты уходят по порядку, после последнего DE ещё поднят 6
de_drops_on_tc по TC DE = 0, приём включён, драйвер свободен 7
rx_byte_to_parser принятый байт уходит в разборщик 2
zero_length_ignored пустая отправка ничего не трогает 2

Два из них целиком:

static void send_all(void)                                  /* TXE и TC подняты, как после сброса */
{
    rs485_init();
    rs485_send(req, sizeof req);
    for (unsigned i = 0; i < sizeof req; i++) {
        fake_usart2.SR = USART_SR_TXE | USART_SR_TC;
        USART2_IRQHandler();
        CHECK_EQ(fake_usart2.DR, req[i]);
    }
}

TEST(bytes_in_order_de_held)
{
    send_all();
    CHECK_EQ(fake_gpioa.BSRR, 1u << 1);                       /* последний байт ещё в линии */
    CHECK_EQ(fake_usart2.CR1 & USART_CR1_TXEIE, 0);
    CHECK(fake_usart2.CR1 & USART_CR1_TCIE);
}

TEST(de_drops_on_tc)
{
    send_all();
    fake_usart2.SR = USART_SR_TXE | USART_SR_TC;
    USART2_IRQHandler();
    CHECK_EQ(fake_gpioa.BSRR, 1u << 17);                      /* DE = 0 */
    CHECK(fake_usart2.CR1 & USART_CR1_RE);
    CHECK_EQ(fake_usart2.CR1 & USART_CR1_TCIE, 0);
    rs485_send(req, 1);                                     /* не занят: не повиснет */
    CHECK_EQ(fake_gpioa.BSRR, 1u << 1);
}

Флаги в send_all — TXE и TC вместе, как в USART_SR после сброса (0x00C0 по RM0008). Это не случайность: TC поднят с самого сброса, и драйвер обязан не принять его за конец своей передачи. Шесть тестов — 25 проверок, все прошли.


129а.5 HAL: подмена при компоновке

Второй драйвер — тот же обмен на HAL:

HAL_StatusTypeDef rs485_hal_send(UART_HandleTypeDef *hu, const uint8_t *p, uint16_t n)
{
    HAL_StatusTypeDef st;

    if (n == 0u)
        return HAL_OK;
    HAL_GPIO_WritePin(DE_PORT, DE_PIN, GPIO_PIN_SET);
    st = HAL_UART_Transmit(hu, p, n, rs485_timeout_ms(n));
    HAL_GPIO_WritePin(DE_PORT, DE_PIN, GPIO_PIN_RESET);   /* иначе линия занята навсегда */
    return st;
}

HAL_UART_Transmit в STM32CubeF1 блокирующий и в конце сам ждёт UART_FLAG_TC, поэтому DE снимается сразу после него. Таймаут — 2 мс на байт плюс 10 мс: восемь байт 8E1 на 9600 бод идут 9,17 мс (16а), таймаут — 26 мс.

В тестовую сборку входят настоящие stm32f1xx_hal.c и stm32f1xx_hal_gpio.c из STM32CubeF1 — они собираются gcc на ПК с тем же подменённым ядром без единого предупреждения. Три функции HAL подменены тремя разными способами:

Функция Способ Когда годится
HAL_UART_Transmit своя функция, stm32f1xx_hal_uart.c в сборку не входит весь файл HAL не нужен
HAL_GetTick обычное определение в тесте побеждает __weak из stm32f1xx_hal.c функция в HAL слабая
HAL_GPIO_WritePin -Wl,--wrap=HAL_GPIO_WritePin: журнал, потом настоящая нужен и журнал, и настоящий код
void __real_HAL_GPIO_WritePin(GPIO_TypeDef *port, uint16_t pin, GPIO_PinState s);
void __wrap_HAL_GPIO_WritePin(GPIO_TypeDef *port, uint16_t pin, GPIO_PinState s)
{
    if (port == GPIOA && pin == GPIO_PIN_1 && nlog < 8)
        log_[nlog++].what = s == GPIO_PIN_SET ? DE_SET : DE_RESET;
    __real_HAL_GPIO_WritePin(port, pin, s);                 /* пишет в fake_gpioa.BSRR */
}

С --wrap вызовы HAL_GPIO_WritePin из драйвера уходят в __wrap_HAL_GPIO_WritePin, а __real_… — это исходная функция HAL. Тест видит порядок вызовов — то, чего не видела структура регистров: DE = 1, HAL_UART_Transmit(n = 8, таймаут 26), DE = 0. И видит результат настоящего кода HAL: в fake_gpioa.BSRR лежит GPIO_PIN_1 << 16.

Пять тестов HAL — 269 проверок (255 из них — таймаут для длин от 1 до 255 байт). Пятый тест проверяет не наш код, а HAL: HAL_Delay(30) с часами теста, которые прибавляют 1 мс на каждый вызов HAL_GetTick, читает 31 тик. Функция добавляет к задержке uwTickFreq, чтобы гарантировать минимум, — ждать приходится на тик больше заказанного. Такой тест фиксирует поведение чужого кода: обновится HAL — тест скажет, если поведение поменялось.

--wrap не видит вызовов внутри своего файла. Та же подделка часов, подключённая через --wrap=HAL_GetTick, а не обычным определением, вешает тест HAL_Delay: раннер пишет HANG. HAL_Delay и HAL_GetTick лежат в одном stm32f1xx_hal.c, ссылка между ними разрешена ещё при компиляции, и компоновщику подменять нечего — HAL_Delay крутится на настоящем HAL_GetTick, который на ПК не растёт. Слабый символ такой ловушки не имеет: nm показывает у HAL_GetTick и HAL_Delay в stm32f1xx_hal.o букву W, и обычное определение из теста заменяет функцию для всех вызовов.


129а.6 Подложенные ошибки, покрытие и сборка под armclang

Зелёные тесты ещё ничего не доказывают — надо убедиться, что они краснеют. Скрипт mutants.py вносит в копию драйвера одну правку и гоняет тесты (подход 184.5, но правки здесь ручные — это ошибки, которые на RS-485 делают на самом деле):

Ошибка Какой тест упал
DE снят по TXE последнего байта bytes_in_order_de_held
TCIE разрешён сразу при отправке send_raises_de_and_mutes_rx
приём не включён после передачи de_drops_on_tc
флаг занятости не сброшен de_drops_on_tc — HANG
на HAL: при ошибке выход до снятия DE de_released_on_timeout
на HAL: таймаут без запаса, 1 мс на байт de_around_transmit, timeout_covers_frame

Шесть из шести. Сообщение сразу говорит, что не так:

  /v/test_rs485.c:64: fake_gpioa.BSRR == 1u << 1: 131072 != 2

131072 — это 1 << 17: DE сняли, пока последний байт ещё в линии. Четвёртая ошибка без процесса на тест повесила бы весь прогон; с ним — одна строка HANG.

Покрытие — gcc --coverage и gcov -b в том же образе:

Прогон Строк из 34 Ветвей взято из 16
все 6 тестов 100,00 % 81,25 %
без теста приёма 97,06 % 68,75 %

Без теста приёма gcov помечает ##### ровно одну строку — вызов rs485_rx_byte. Полный набор проходит все строки, но три ветви из 16 не берёт ни разу: тело ожидания while (tx_busy) (в однопоточном тесте драйвер никогда не занят при отправке) и два случая в обработчике — «TXEIE разрешён, а TXE нет» и «TCIE разрешён, а TC нет». Это и есть польза покрытия по ветвям: оно показывает, какие сочетания флагов не пробовали. rs485_hal.c — 9 строк и 2 ветви, покрыто всё.

llvm-cov есть только в образе stm-rust:1 — из набора Rust, LLVM 22.1.8. Файлы покрытия gcc 14 из того же образа он не читает («Invalid .gcno File!»). clang 14 из stm-sys:1 тесты собирает и проходит, но покрытие в нём не собрать: нет библиотеки libclang_rt.profile. Поэтому покрытие здесь — gcov.

Сборка для платы — те же файлы, armclang, Cortex-M3, -Wall -Wextra: rs485_f103.c с stm32f10x.h из пака Keil и настоящим core_cm3.h из CMSIS; rs485_hal.c и stm32f1xx_hal.c — с заголовками STM32CubeF1 и тем же stm32f1xx_hal_conf.h, что у тестов. Ни одного предупреждения. В объекте HAL, собранном armclang, HAL_GetTick и HAL_Delay — тоже слабые (Wk в fromelf -s): подмена через обычное определение работает и в прошивке. HAL для того их и делает слабыми — в комментарии к stm32f1xx_hal.c сказано, что функции отсчёта времени объявлены __weak, чтобы их можно было заменить своими.


129а.7 Грабли

Тест вешает следующий. Статическое состояние драйвера (tx_busy) переживает тест, если все тесты идут в одном процессе. Процесс на тест или функция сброса в самом драйвере — третьего не дано.

В драйвере #ifdef TEST. Тесты проверяют сборку, которая в плату не попадёт. Швы — в заголовках и в компоновке, не в коде драйвера.

Свой заголовок регистров вместо настоящего. Переписанная руками структура однажды разойдётся с паком. Настоящий заголовок и _Static_assert на смещения по RM0008 этого не допустят.

Структура регистров принята за модель железа. Она не сбрасывает TC по записи в DR и не помнит порядок записей. Время и побочные действия — модель, как в 16а.4; порядок вызовов — журнал подделки.

--wrap на функцию, которую зовут из того же файла. Вызовы изнутри stm32f1xx_hal.c мимо обёртки: HAL_Delay висит на настоящем HAL_GetTick. Для слабых функций HAL — обычное определение.

Ожидание в тесте считают по тем же формулам, что и в коде. Тест таймаута сравнивает с временем кадра, посчитанным отдельно, — поэтому таймаут «1 мс на байт» не прошёл.

_exit в дочернем процессе. Счётчики gcov пишутся при обычном exit; с _exit покрытие молча окажется нулевым.


129а.8 Итоги

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

tiny_test.h — раннер модульных тестов: каждый тест в своём процессе.

/* 129а: маленький раннер модульных тестов. Каждый тест — в своём процессе (fork):
   статические переменные модуля не переходят из теста в тест, зависание ловит
   будильник, падение — сигнал. Провал проверки печатает файл и строку. */
#ifndef TINY_TEST_H
#define TINY_TEST_H
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/wait.h>
#include <unistd.h>

struct tt_case { const char *name; void (*fn)(void); };
#define TEST(n) static void n(void)
#define CASE(n) { #n, n }

static int tt_checks;
#define TT_FAIL(...) do { printf("  %s:%d: ", __FILE__, __LINE__); printf(__VA_ARGS__); exit(1); } while (0)
#define CHECK(c) do { tt_checks++; if (!(c)) TT_FAIL("%s\n", #c); } while (0)
#define CHECK_EQ(a, b) do { long long a_ = (long long)(a), b_ = (long long)(b); tt_checks++; \
        if (a_ != b_) TT_FAIL("%s == %s: %lld != %lld\n", #a, #b, a_, b_); } while (0)

void setup(void);                       /* у каждого набора своя: сбросить подделки */

static int tt_run(const struct tt_case *c, int n, int argc, char **argv)
{
    int run = 0, bad = 0;
    for (int i = 0; i < n; i++) {
        if (argc > 1 && strstr(c[i].name, argv[1]) == NULL)
            continue;                   /* фильтр по имени: ./tests uart */
        run++;
        fflush(stdout);
        pid_t pid = fork();
        if (pid == 0) {                 /* ребёнок: один тест */
            alarm(1);                   /* повис — через секунду SIGALRM */
            setup();
            c[i].fn();
            printf("ok   %s, проверок: %d\n", c[i].name, tt_checks);
            exit(0);                    /* exit, а не _exit: gcov допишет счётчики */
        }
        int st = 0;
        waitpid(pid, &st, 0);
        if (WIFEXITED(st) && WEXITSTATUS(st) == 0)
            continue;
        bad++;
        printf("%s %s\n", WIFSIGNALED(st) && WTERMSIG(st) == SIGALRM ? "HANG"
                          : WIFSIGNALED(st) ? "CRASH" : "FAIL", c[i].name);
    }
    printf("TOTAL tests=%d failed=%d\n", run, bad);
    return bad != 0;
}
#endif

fake_periph.h — периферия как обычные структуры в ОЗУ компьютера.

/* 129а: периферия — обычные структуры в ОЗУ ПК. Тип тот же, что в заголовке
   пака, поэтому и раскладка та же; меняется только адрес. Подключается
   ПОСЛЕ заголовка устройства: -include stm32f10x.h -include fake_periph.h */
#ifndef FAKE_PERIPH_H
#define FAKE_PERIPH_H
#include <stddef.h>

extern USART_TypeDef fake_usart2;
extern GPIO_TypeDef fake_gpioa;
extern RCC_TypeDef fake_rcc;

#undef USART2
#undef GPIOA
#undef RCC
#define USART2 (&fake_usart2)           /* было ((USART_TypeDef *) USART2_BASE) */
#define GPIOA  (&fake_gpioa)
#define RCC    (&fake_rcc)

_Static_assert(offsetof(USART_TypeDef, DR) == 0x04 && offsetof(USART_TypeDef, CR1) == 0x0C,
               "смещения USART_DR и USART_CR1 — как в карте регистров RM0008");
_Static_assert(offsetof(GPIO_TypeDef, BSRR) == 0x10, "GPIOx_BSRR — смещение 0x10");
#endif

test_rs485.c — тесты драйвера RS-485 из раздела 16а.

/* 129а: модульные тесты драйвера RS-485 из раздела 16а (rs485_f103.c без правок).
   Сборка на ПК: -DSTM32F10X_MD -include stm32f10x.h -include fake_periph.h,
   каталог host/ с подменой core_cm3.h — раньше каталога CMSIS. */
#include "tiny_test.h"

USART_TypeDef fake_usart2;
GPIO_TypeDef fake_gpioa;
RCC_TypeDef fake_rcc;
int fake_nvic_irq;

void rs485_init(void);
void rs485_send(const uint8_t *p, uint32_t n);
void USART2_IRQHandler(void);

static uint8_t rx_log[8];
static int rx_count;
void rs485_rx_byte(uint8_t b) { rx_log[rx_count++ & 7] = b; }  /* подделка разборщика 16.8 */

void setup(void)
{
    memset(&fake_usart2, 0, sizeof fake_usart2);
    memset(&fake_gpioa, 0, sizeof fake_gpioa);
    memset(&fake_rcc, 0, sizeof fake_rcc);
    fake_nvic_irq = -1;
    rx_count = 0;
}

static const uint8_t req[3] = { 0x01, 0x03, 0x7F };

TEST(init_9600_8E1_listening)
{
    rs485_init();
    CHECK_EQ(fake_usart2.BRR, 3750);                          /* 36 МГц / 9600 */
    CHECK_EQ(fake_usart2.CR1, USART_CR1_UE | USART_CR1_M | USART_CR1_PCE | USART_CR1_TE
                              | USART_CR1_RE | USART_CR1_RXNEIE);
    CHECK_EQ(fake_gpioa.BSRR, (1u << 17) | (1u << 3));       /* DE = 0, подтяжка RX */
    CHECK_EQ(fake_nvic_irq, USART2_IRQn);
}

TEST(send_raises_de_and_mutes_rx)
{
    rs485_init();
    rs485_send(req, sizeof req);
    CHECK_EQ(fake_gpioa.BSRR, 1u << 1);                       /* DE = 1 */
    CHECK_EQ(fake_usart2.CR1 & USART_CR1_RE, 0);
    CHECK(fake_usart2.CR1 & USART_CR1_TXEIE);
    CHECK_EQ(fake_usart2.CR1 & USART_CR1_TCIE, 0);            /* TC = 1 с самого сброса */
}

static void send_all(void)                                  /* TXE и TC подняты, как после сброса */
{
    rs485_init();
    rs485_send(req, sizeof req);
    for (unsigned i = 0; i < sizeof req; i++) {
        fake_usart2.SR = USART_SR_TXE | USART_SR_TC;
        USART2_IRQHandler();
        CHECK_EQ(fake_usart2.DR, req[i]);
    }
}

TEST(bytes_in_order_de_held)
{
    send_all();
    CHECK_EQ(fake_gpioa.BSRR, 1u << 1);                       /* последний байт ещё в линии */
    CHECK_EQ(fake_usart2.CR1 & USART_CR1_TXEIE, 0);
    CHECK(fake_usart2.CR1 & USART_CR1_TCIE);
}

TEST(de_drops_on_tc)
{
    send_all();
    fake_usart2.SR = USART_SR_TXE | USART_SR_TC;
    USART2_IRQHandler();
    CHECK_EQ(fake_gpioa.BSRR, 1u << 17);                      /* DE = 0 */
    CHECK(fake_usart2.CR1 & USART_CR1_RE);
    CHECK_EQ(fake_usart2.CR1 & USART_CR1_TCIE, 0);
    rs485_send(req, 1);                                     /* не занят: не повиснет */
    CHECK_EQ(fake_gpioa.BSRR, 1u << 1);
}

TEST(rx_byte_to_parser)
{
    rs485_init();
    fake_usart2.SR = USART_SR_RXNE;
    fake_usart2.DR = 0x5A;
    USART2_IRQHandler();
    CHECK_EQ(rx_count, 1);
    CHECK_EQ(rx_log[0], 0x5A);
}

TEST(zero_length_ignored)
{
    rs485_init();
    uint32_t cr1 = fake_usart2.CR1, bsrr = fake_gpioa.BSRR;
    rs485_send(req, 0);
    CHECK_EQ(fake_usart2.CR1, cr1);
    CHECK_EQ(fake_gpioa.BSRR, bsrr);
}

static const struct tt_case cases[] = {
    CASE(init_9600_8E1_listening), CASE(send_raises_de_and_mutes_rx), CASE(bytes_in_order_de_held),
    CASE(de_drops_on_tc), CASE(rx_byte_to_parser), CASE(zero_length_ignored),
};

int main(int argc, char **argv)
{
    return tt_run(cases, (int)(sizeof cases / sizeof cases[0]), argc, argv);
}

rs485_hal.c — тот же RS-485 на HAL.

/* 129а: тот же RS-485 на HAL. HAL_UART_Transmit блокирующий и сам ждёт TC
   в конце кадра, поэтому DE снимается сразу после него — и при ошибке тоже. */
#include "stm32f1xx_hal.h"

#define DE_PORT GPIOA
#define DE_PIN  GPIO_PIN_1

uint32_t rs485_timeout_ms(uint16_t n)
{
    return 2u * n + 10u;                /* 8E1 на 9600: 1,146 мс на байт — запас вдвое и 10 мс */
}

HAL_StatusTypeDef rs485_hal_send(UART_HandleTypeDef *hu, const uint8_t *p, uint16_t n)
{
    HAL_StatusTypeDef st;

    if (n == 0u)
        return HAL_OK;
    HAL_GPIO_WritePin(DE_PORT, DE_PIN, GPIO_PIN_SET);
    st = HAL_UART_Transmit(hu, p, n, rs485_timeout_ms(n));
    HAL_GPIO_WritePin(DE_PORT, DE_PIN, GPIO_PIN_RESET);   /* иначе линия занята навсегда */
    return st;
}

test_rs485_hal.c — тесты rs485_hal.c с подменой функций HAL при компоновке.

/* 129а: тесты rs485_hal.c. В сборке — настоящие stm32f1xx_hal.c и stm32f1xx_hal_gpio.c
   из STM32CubeF1, и три способа подмены при компоновке:
   HAL_UART_Transmit — своя функция, файл stm32f1xx_hal_uart.c просто не собираем;
   HAL_GetTick       — сильное определение побеждает __weak из stm32f1xx_hal.c;
   HAL_GPIO_WritePin — -Wl,--wrap: журнал вызовов, затем настоящий код HAL. */
#include "tiny_test.h"

USART_TypeDef fake_usart2;
GPIO_TypeDef fake_gpioa;
RCC_TypeDef fake_rcc;
int fake_nvic_irq;
SysTick_Type fake_systick;

uint32_t rs485_timeout_ms(uint16_t n);
HAL_StatusTypeDef rs485_hal_send(UART_HandleTypeDef *hu, const uint8_t *p, uint16_t n);

enum { DE_SET = 1, DE_RESET, TX };
static struct { int what; uint16_t n; uint32_t timeout; } log_[8];
static int nlog;
static HAL_StatusTypeDef tx_result;
static uint32_t now;

void __real_HAL_GPIO_WritePin(GPIO_TypeDef *port, uint16_t pin, GPIO_PinState s);
void __wrap_HAL_GPIO_WritePin(GPIO_TypeDef *port, uint16_t pin, GPIO_PinState s)
{
    if (port == GPIOA && pin == GPIO_PIN_1 && nlog < 8)
        log_[nlog++].what = s == GPIO_PIN_SET ? DE_SET : DE_RESET;
    __real_HAL_GPIO_WritePin(port, pin, s);                 /* пишет в fake_gpioa.BSRR */
}

HAL_StatusTypeDef HAL_UART_Transmit(UART_HandleTypeDef *hu, const uint8_t *p, uint16_t n, uint32_t timeout)
{
    (void)hu;
    (void)p;
    if (nlog < 8) {
        log_[nlog].what = TX;
        log_[nlog].n = n;
        log_[nlog++].timeout = timeout;
    }
    return tx_result;
}

#ifndef WRAP_TICK
uint32_t HAL_GetTick(void) { return now++; }                /* часы теста: +1 мс на каждый вызов */
#else
uint32_t __wrap_HAL_GetTick(void) { return now++; }         /* та же подделка через --wrap */
#endif

void setup(void)
{
    memset(&fake_gpioa, 0, sizeof fake_gpioa);
    memset(log_, 0, sizeof log_);
    nlog = 0;
    tx_result = HAL_OK;
    now = 100;
}

static UART_HandleTypeDef hu;
static const uint8_t req[8] = { 0x01, 0x03, 0x00, 0x00, 0x00, 0x02, 0xC4, 0x0B };

TEST(de_around_transmit)
{
    CHECK_EQ(rs485_hal_send(&hu, req, 8), HAL_OK);
    CHECK_EQ(nlog, 3);
    CHECK_EQ(log_[0].what, DE_SET);
    CHECK_EQ(log_[1].what, TX);
    CHECK_EQ(log_[1].n, 8);
    CHECK_EQ(log_[1].timeout, 26);
    CHECK_EQ(log_[2].what, DE_RESET);
    CHECK_EQ(fake_gpioa.BSRR, (uint32_t)GPIO_PIN_1 << 16);  /* это записал настоящий HAL */
}

TEST(de_released_on_timeout)
{
    tx_result = HAL_TIMEOUT;
    CHECK_EQ(rs485_hal_send(&hu, req, 8), HAL_TIMEOUT);
    CHECK_EQ(nlog, 3);
    CHECK_EQ(log_[2].what, DE_RESET);
}

TEST(timeout_covers_frame)
{
    for (uint32_t n = 1; n <= 255; n++)                     /* кадр 8E1 — 11 бит на 9600 бод */
        CHECK(rs485_timeout_ms((uint16_t)n) * 9600u > n * 11u * 1000u);
}

TEST(zero_length_no_calls)
{
    CHECK_EQ(rs485_hal_send(&hu, req, 0), HAL_OK);
    CHECK_EQ(nlog, 0);
}

TEST(hal_delay_30_waits_31_ticks)
{
    HAL_Delay(30);                                          /* настоящий код из stm32f1xx_hal.c */
    CHECK_EQ(now - 1 - 100, 31);                            /* последний прочитанный тик минус первый */
}

static const struct tt_case cases[] = {
    CASE(de_around_transmit), CASE(de_released_on_timeout), CASE(timeout_covers_frame),
    CASE(zero_length_no_calls), CASE(hal_delay_30_waits_31_ticks),
};

int main(int argc, char **argv)
{
    return tt_run(cases, (int)(sizeof cases / sizeof cases[0]), argc, argv);
}

Дальше: раздел 129б «Статический анализ прошивки: clang-анализатор, gcc -fanalyzer и cppcheck в конвейере» — как прогнать анализаторы по настоящей сборке под Cortex-M и поставить их воротами в конвейер.

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