Embedded HandbookApps
All sections › XV. C in depth
STM32F103 · level intermediate · 22 min read
Open in the interactive handbook

129а. Unit tests for firmware on the PC: faking the hardware, a small runner of your own and coverage

Section 129 looked for bugs without running the code: the compiler, cppcheck, path analysis. Section 184 ran pure logic on the computer — a frame parser that contains not a single register. The next step is here: code that touches the hardware. A driver writes to USART registers and calls HAL — and it too can be run on the PC if you substitute three things: the peripheral addresses, the CMSIS core and some of the HAL functions. The driver sources do not change by a single letter: armclang builds the very same files for the board.

The test subject is the RS-485 driver from section 16а: interrupt-driven transmission, DE is released on the TC flag. There it was checked by a timed USART model; here it gets unit tests, each about one rule of the driver. Next to it is the same exchange on HAL, to show function substitution at link time.

Sources: the stm32f10x.h header from the Keil STM32F1xx_DFP 2.4.1 pack (the same one as in 16а), stm32f103xb.h and HAL from the STM32CubeF1 1.8.7 archive, the reference manual RM0008 (offsets and reset values of the USART and GPIO registers). Everything was built and run in the stm-lab:1 image (gcc 12), directory _verify/utest129b; the drivers were built with armclang for Cortex-M3. The well-known ready-made frameworks — Unity, the CMock fake generator and the Ceedling build tool — were not run here: everything needed fits into a runner of your own of 52 lines.

Terms used in the documentation

Term Meaning
unit test a test of one module in isolation
test runner a program that runs the tests and counts the failures
fake, stub, mock a working substitute, a canned-answer substitute, a substitute that checks the calls
test double any substitute for a dependency in a test
seam a place where a dependency can be substituted
weak symbol (__weak) a symbol that gives way to an ordinary definition
--wrap=symbol a linker option: calls go to __wrap_symbol
code coverage: line / branch coverage by lines, by branches
characterization test a test that pins down the behavior of someone else's code

129а.1 Where the seam between the driver and the hardware is

The driver has three dependencies on the board, and each has its own seam:

Dependency What it looks like in the code What replaces it on the PC
peripheral registers USART2->CR1, GPIOA->BSRR a structure in RAM instead of an address
the core NVIC_EnableIRQ, SysTick from core_cm3.h your own core_cm3.h earlier in the search path
HAL functions HAL_GPIO_WritePin, HAL_UART_Transmit your own function at link time

The first two seams are made by the preprocessor, the third by the linker. The driver code does not know where it is being built: there is not a single #ifdef TEST in it. If a #ifdef appears in the driver for the sake of tests, the tests no longer check the code that will go into the board.

Same sources, two builds: on the left gcc on the PC with the real device header, your own core_cm3.h, peripherals in RAM and part of HAL substituted at link time — 11 tests, each in its own process, gcov coverage; on the right armclang for Cortex-M3 with the same header, the real core_cm3.h and the real HAL, where HAL_GetTick and HAL_Delay are weak — STM32F103 firmware with no warnings; 6 bugs planted, the tests caught all 6, line coverage 100 %, branches taken 81.25 %


129а.2 A runner in 52 lines

The runner from 184.3 counted checks and failures in one process. For a driver that is not enough. A driver has static state — the busy flag tx_busy, a pointer to the data — and it cannot be reset between tests from outside: the variables are static. A test that leaves the flag raised hangs the next one. So every test runs in its own process:

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;
}

A process per test gives three things at once. Each test has its own static state, starting from zero. A hung test is killed by the alarm after one second — the runner prints HANG and moves on. Dereferencing a bad pointer crashes only the child — the runner prints CRASH. On failure, the CHECK and CHECK_EQ checks print the file, line, expression and both values and end the process. The command-line argument is a filter: ./t de_ ran three tests out of six. The exit code is non-zero on any failure — that is enough for the pipeline from section 110.

fork exists on Linux and in the container where the tests run. That is all the runner needs: it does not run on the board; on the board, the drivers run.


129а.3 Registers: a structure instead of an address

In the device header a peripheral is a type and an address:

#define USART2              ((USART_TypeDef *) USART2_BASE)

The USART_TypeDef type describes the register layout, the address says where it lies. For a test you need the same type, but in PC memory. Take the device header — the real one, from the pack — and include your own after it:

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)

The layout matches by itself: it is one and the same type. To be safe, in the same file _Static_assert checks the offsets against the RM0008 register map: USART_DR is 0x04, USART_CR1 is 0x0C, GPIOx_BSRR is 0x10. If the header changes, the test will not build.

The device header pulls in core_cm3.h, and that contains inline assembly for Cortex-M — gcc for the PC will not build it. The pack header includes the core in quotes, so it is enough to put your own core_cm3.h into the host/ directory and place it in the search path before CMSIS. It contains only what is needed: the __IO, __I, __O qualifiers and their counterparts __IOM/__IM/__OM, the SysTick_Type structure and NVIC_EnableIRQ, which simply remembers the interrupt number. Building the driver on the PC:

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

Not a single warning — neither for the driver nor for the tests.

What the structure cannot do. It remembers only the last value written: the order of writes is not visible, and a test "DE is raised before the interrupt is enabled" cannot be written this way. It knows nothing about the side effects of the hardware: in the USART the pair "read SR — write DR" clears TC, but in the structure nothing is cleared. So the test sets the flags by hand before every call of the handler. Where timing and side effects matter, you need a peripheral model, as in 16а.4. A unit test checks something else: what the driver does in response to a given state of the registers.


129а.4 Tests of the RS-485 driver

The file rs485_f103.c is taken from 16а without changes. Six tests — one per rule of the driver:

Test Rule Checks
init_9600_8E1_listening BRR = 3750, 8E1, DE = 0, USART2 interrupt enabled 4
send_raises_de_and_mutes_rx when sending, DE = 1, reception off, no TCIE yet 4
bytes_in_order_de_held bytes go out in order, DE is still raised after the last one 6
de_drops_on_tc on TC, DE = 0, reception on, the driver is free 7
rx_byte_to_parser a received byte goes to the parser 2
zero_length_ignored an empty send touches nothing 2

Two of them in full:

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);
}

The flags in send_all are TXE and TC together, as in USART_SR after reset (0x00C0 per RM0008). This is not by chance: TC is raised from reset onward, and the driver must not mistake it for the end of its own transmission. Six tests — 25 checks, all passed.


129а.5 HAL: substitution at link time

The second driver is the same exchange on 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 in STM32CubeF1 is blocking and waits for UART_FLAG_TC itself at the end, so DE is released right after it. The timeout is 2 ms per byte plus 10 ms: eight bytes of 8E1 at 9600 baud take 9.17 ms (16а), the timeout is 26 ms.

The test build includes the real stm32f1xx_hal.c and stm32f1xx_hal_gpio.c from STM32CubeF1 — gcc builds them on the PC with the same substituted core without a single warning. Three HAL functions are substituted in three different ways:

Function Method When it fits
HAL_UART_Transmit your own function, stm32f1xx_hal_uart.c is left out of the build you do not need the whole HAL file
HAL_GetTick an ordinary definition in the test wins over __weak from stm32f1xx_hal.c the function is weak in HAL
HAL_GPIO_WritePin -Wl,--wrap=HAL_GPIO_WritePin: log, then the real one you need both a log and the real code
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 */
}

With --wrap, calls to HAL_GPIO_WritePin from the driver go to __wrap_HAL_GPIO_WritePin, and __real_… is the original HAL function. The test sees the order of calls — something the register structure could not see: DE = 1, HAL_UART_Transmit(n = 8, timeout 26), DE = 0. And it sees the result of the real HAL code: fake_gpioa.BSRR holds GPIO_PIN_1 << 16.

Five HAL tests — 269 checks (255 of them are the timeout for lengths from 1 to 255 bytes). The fifth test checks not our code but HAL: HAL_Delay(30) with the test clock, which adds 1 ms on every call to HAL_GetTick, reads 31 ticks. The function adds uwTickFreq to the delay to guarantee the minimum — so it waits one tick longer than requested. Such a test pins down the behavior of someone else's code: when HAL is updated, the test will tell you if the behavior has changed.

--wrap does not see calls inside their own file. The same fake clock, hooked up through --wrap=HAL_GetTick rather than an ordinary definition, hangs the HAL_Delay test: the runner prints HANG. HAL_Delay and HAL_GetTick live in the same stm32f1xx_hal.c, the reference between them is resolved already at compile time, and the linker has nothing to substitute — HAL_Delay spins on the real HAL_GetTick, which does not advance on the PC. A weak symbol has no such trap: nm shows the letter W for HAL_GetTick and HAL_Delay in stm32f1xx_hal.o, and an ordinary definition from the test replaces the function for all calls.


129а.6 Planted bugs, coverage and the armclang build

Green tests prove nothing yet — you have to make sure they turn red. The mutants.py script makes one change in a copy of the driver and runs the tests (the approach of 184.5, but the changes here are hand-made — these are the bugs people really make with RS-485):

Bug Which test failed
DE released on TXE of the last byte bytes_in_order_de_held
TCIE enabled right away on send send_raises_de_and_mutes_rx
reception not re-enabled after transmission de_drops_on_tc
busy flag not cleared de_drops_on_tc — HANG
on HAL: on error, return before releasing DE de_released_on_timeout
on HAL: timeout with no margin, 1 ms per byte de_around_transmit, timeout_covers_frame

Six out of six. The message tells you right away what is wrong:

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

131072 is 1 << 17: DE was released while the last byte was still on the line. Without a process per test, the fourth bug would have hung the whole run; with it — one HANG line.

Coverage — gcc --coverage and gcov -b in the same image:

Run Lines out of 34 Branches taken out of 16
all 6 tests 100.00 % 81.25 %
without the reception test 97.06 % 68.75 %

Without the reception test, gcov marks exactly one line with ##### — the call to rs485_rx_byte. The full set goes through all lines, but three branches out of 16 it never takes: the body of the while (tx_busy) wait (in a single-threaded test the driver is never busy when sending) and two cases in the handler — "TXEIE enabled but TXE not set" and "TCIE enabled but TC not set". This is exactly what branch coverage is good for: it shows which combinations of flags were never tried. rs485_hal.c — 9 lines and 2 branches, everything covered.

llvm-cov exists only in the stm-rust:1 image — from the Rust toolchain, LLVM 22.1.8. It does not read gcc 14 coverage files from the same image ("Invalid .gcno File!"). clang 14 from stm-sys:1 builds and passes the tests, but coverage cannot be collected with it: the libclang_rt.profile library is missing. So the coverage here is gcov.

The build for the board — the same files, armclang, Cortex-M3, -Wall -Wextra: rs485_f103.c with stm32f10x.h from the Keil pack and the real core_cm3.h from CMSIS; rs485_hal.c and stm32f1xx_hal.c with the STM32CubeF1 headers and the same stm32f1xx_hal_conf.h as the tests. Not a single warning. In the HAL object built by armclang, HAL_GetTick and HAL_Delay are weak too (Wk in fromelf -s): substitution through an ordinary definition works in the firmware as well. That is exactly why HAL makes them weak — the comment in stm32f1xx_hal.c says the timing functions are declared __weak so that they can be replaced with your own.


129а.7 Pitfalls

A test hangs the next one. The driver's static state (tx_busy) survives a test if all tests run in one process. A process per test or a reset function in the driver itself — there is no third option.

#ifdef TEST in the driver. The tests check a build that will never get into the board. The seams belong in headers and in linking, not in the driver code.

Your own register header instead of the real one. A structure rewritten by hand will one day drift away from the pack. The real header and _Static_assert on the offsets from RM0008 will not let that happen.

The register structure is taken for a model of the hardware. It does not clear TC on a write to DR and does not remember the order of writes. Timing and side effects need a model, as in 16а.4; the order of calls needs the fake's log.

--wrap on a function that is called from the same file. Calls from inside stm32f1xx_hal.c bypass the wrapper: HAL_Delay hangs on the real HAL_GetTick. For weak HAL functions, use an ordinary definition.

The expected value in the test is computed with the same formulas as in the code. The timeout test compares against a frame time computed separately — that is why the "1 ms per byte" timeout did not pass.

_exit in the child process. gcov counters are written on an ordinary exit; with _exit the coverage will silently be zero.


129а.8 Summary

Full sources

tiny_test.h — the unit test runner: each test in its own process.

/* 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 — the peripherals as ordinary structures in the computer's RAM.

/* 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 — tests of the RS-485 driver from section 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 — the same RS-485 on 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 — tests of rs485_hal.c with HAL functions substituted at link time.

/* 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);
}

Next: section 129б "Static analysis of firmware: the clang analyzer, gcc -fanalyzer and cppcheck in the pipeline" — how to run the analyzers over the real Cortex-M build and make them a gate in the pipeline.

Open in the interactive handbook