STM32 HAL Unit Testing: Mocking STM32CubeHAL and Testing Peripheral Drivers

STM32 HAL Unit Testing: Mocking STM32CubeHAL and Testing Peripheral Drivers

```tldr STM32 HAL firmware can be unit tested on the host by mocking the STM32CubeHAL functions with CMock. Your application code links against mock HAL functions instead of real hardware peripherals — tests run fast, without an STM32 board, and integrate into CI/CD. ```

```takeaways Mock HAL functions, not registers. STM32CubeHAL provides function-level abstraction (HAL_GPIO_WritePin, HAL_I2C_Mem_Write) — mock those, not the raw peripheral registers.

Separate your application layer from HAL calls. Create a thin hardware abstraction layer (HAL wrapper) with your own function signatures. Your application calls your wrapper; your wrapper calls STM32 HAL. You mock the wrapper, not the STM32 HAL directly.

CMock auto-generates mocks from header files. Point CMock at stm32f4xx_hal_gpio.h — it generates mock_stm32f4xx_hal_gpio.c with expect_*, return_*, and call-count functions.

Use Unity for assertions. CMock and Unity are the standard C unit testing pair for embedded projects (same as used in FreeRTOS and Zephyr tests).

Test error paths explicitly. Make HAL functions return HAL_ERROR or HAL_TIMEOUT — most firmware bugs lurk in unhandled error returns from HAL calls. ```

Project Setup

Tools

# Install Ruby (required by CMock/Unity scripts)
brew install ruby   # macOS
apt-get install ruby  # Linux

# Install Ceedling (Unity + CMock test runner)
gem install ceedling

Initialize Project

cd my-stm32-project
ceedling new test_project

project.yml

:project:
  :build_root: build/test
  :test_file_prefix: test_
  :which_ceedling: gem

:environment: []

:extension:
  :header: .h
  :source: .c
  :unity_helper: .h

:paths:
  :test:
    - test/**
  :source:
    - src/**
  :include:
    - include/**
    - stm32/Inc/**    # STM32CubeIDE include path

:defines:
  :common:
    - STM32F401xE
    - USE_HAL_DRIVER

:cmock:
  :mock_prefix: mock_
  :when_no_prototypes: :warn
  :enforce_strict_ordering: true
  :plugins:
    - :ignore
    - :callback
    - :return_thru_ptr

:tools:
  :test_compiler:
    :executable: gcc
    :arguments:
      - -std=c11
      - -Wall

:plugins:
  :load_paths:
    - "#{Ceedling.load_path}"
  :enabled:
    - stdout_pretty_tests_report
    - module_generator

Your HAL Wrapper (Thin Abstraction)

Don't call STM32 HAL directly from application logic. Wrap it:

// include/bsp.h — your hardware abstraction
#ifndef BSP_H
#define BSP_H

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

// GPIO
void bsp_gpio_set(uint32_t pin, bool state);
bool bsp_gpio_get(uint32_t pin);

// I2C
typedef enum { BSP_OK, BSP_ERROR, BSP_TIMEOUT } BspStatus;
BspStatus bsp_i2c_write(uint8_t addr, uint8_t reg, const uint8_t *data, uint16_t len);
BspStatus bsp_i2c_read(uint8_t addr, uint8_t reg, uint8_t *data, uint16_t len);

// UART
BspStatus bsp_uart_transmit(const uint8_t *data, uint16_t len, uint32_t timeout_ms);

#endif
// src/bsp.c — real STM32 HAL implementation (not compiled in tests)
#include "bsp.h"
#include "stm32f4xx_hal.h"

extern I2C_HandleTypeDef hi2c1;
extern UART_HandleTypeDef huart2;

void bsp_gpio_set(uint32_t pin, bool state) {
    HAL_GPIO_WritePin(GPIOC, pin, state ? GPIO_PIN_SET : GPIO_PIN_RESET);
}

BspStatus bsp_i2c_write(uint8_t addr, uint8_t reg, const uint8_t *data, uint16_t len) {
    HAL_StatusTypeDef status = HAL_I2C_Mem_Write(
        &hi2c1, addr << 1, reg, I2C_MEMADD_SIZE_8BIT,
        (uint8_t*)data, len, 1000
    );
    return status == HAL_OK ? BSP_OK : BSP_ERROR;
}

Your Application Driver

// include/bme280.h
#include "bsp.h"

typedef struct {
    float temperature;
    float humidity;
    float pressure;
} Bme280Data;

BspStatus bme280_init(uint8_t i2c_addr);
BspStatus bme280_read_data(uint8_t i2c_addr, Bme280Data *out);
// src/bme280.c
#include "bme280.h"

#define BME280_CHIP_ID_REG    0xD0
#define BME280_CHIP_ID        0x60
#define BME280_CTRL_MEAS_REG  0xF4
#define BME280_STATUS_REG     0xF3

BspStatus bme280_init(uint8_t addr) {
    uint8_t chip_id;
    BspStatus s = bsp_i2c_read(addr, BME280_CHIP_ID_REG, &chip_id, 1);
    if (s != BSP_OK) return s;
    if (chip_id != BME280_CHIP_ID) return BSP_ERROR;

    uint8_t ctrl = 0b00100111;  // normal mode, oversampling x1
    return bsp_i2c_write(addr, BME280_CTRL_MEAS_REG, &ctrl, 1);
}

Writing Tests

// test/test_bme280.c
#include "unity.h"
#include "bme280.h"
#include "mock_bsp.h"  // CMock-generated mock

#define SENSOR_ADDR 0x76

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

void test_bme280_init_reads_chip_id(void) {
    uint8_t chip_id_response = 0x60;

    // Expect I2C read for chip ID
    bsp_i2c_read_ExpectWithArrayAndReturn(
        SENSOR_ADDR, BME280_CHIP_ID_REG,
        NULL, 1, 1,  // out buffer, size
        BSP_OK
    );
    bsp_i2c_read_ReturnThruPtr_data(&chip_id_response);

    // Expect write to ctrl_meas register
    uint8_t expected_ctrl = 0b00100111;
    bsp_i2c_write_ExpectWithArrayAndReturn(
        SENSOR_ADDR, BME280_CTRL_MEAS_REG,
        &expected_ctrl, 1, 1,
        BSP_OK
    );

    BspStatus result = bme280_init(SENSOR_ADDR);

    TEST_ASSERT_EQUAL(BSP_OK, result);
}

void test_bme280_init_fails_on_wrong_chip_id(void) {
    uint8_t wrong_id = 0xFF;

    bsp_i2c_read_ExpectAnyArgsAndReturn(BSP_OK);
    bsp_i2c_read_ReturnThruPtr_data(&wrong_id);

    BspStatus result = bme280_init(SENSOR_ADDR);

    TEST_ASSERT_EQUAL(BSP_ERROR, result);
}

void test_bme280_init_propagates_i2c_error(void) {
    bsp_i2c_read_ExpectAnyArgsAndReturn(BSP_TIMEOUT);

    BspStatus result = bme280_init(SENSOR_ADDR);

    TEST_ASSERT_EQUAL(BSP_TIMEOUT, result);
}

Run:

ceedling test:all

Mocking GPIO

// test/test_led_driver.c
#include "unity.h"
#include "led_driver.h"
#include "mock_bsp.h"

#define LED_PIN 0x2000  // PC13

void test_led_on_sets_pin_high(void) {
    bsp_gpio_set_Expect(LED_PIN, true);

    led_on();

    // CMock verifies bsp_gpio_set was called with exactly these args
}

void test_led_off_sets_pin_low(void) {
    bsp_gpio_set_Expect(LED_PIN, false);
    led_off();
}

void test_led_toggle_calls_gpio_twice(void) {
    // First toggle
    bsp_gpio_get_ExpectAndReturn(LED_PIN, false);
    bsp_gpio_set_Expect(LED_PIN, true);
    led_toggle();

    // Second toggle
    bsp_gpio_get_ExpectAndReturn(LED_PIN, true);
    bsp_gpio_set_Expect(LED_PIN, false);
    led_toggle();
}

Testing UART Communication

// test/test_at_command.c
void test_at_command_sends_correct_bytes(void) {
    const uint8_t expected[] = "AT+CPIN?\r\n";

    bsp_uart_transmit_ExpectWithArrayAndReturn(
        expected, strlen((char*)expected),
        strlen((char*)expected),
        100,  // timeout
        BSP_OK
    );

    BspStatus s = at_send_command("AT+CPIN?");
    TEST_ASSERT_EQUAL(BSP_OK, s);
}

void test_at_command_returns_error_on_uart_fail(void) {
    bsp_uart_transmit_IgnoreAndReturn(BSP_ERROR);

    BspStatus s = at_send_command("AT+CPIN?");
    TEST_ASSERT_EQUAL(BSP_ERROR, s);
}

CMock Patterns Reference

CMock pattern Meaning
func_Expect(args...) Expect exactly one call with these args
func_ExpectAndReturn(args..., ret) Expect call, return value
func_ExpectAnyArgsAndReturn(ret) Expect call, don't check args
func_Ignore() Allow any number of calls
func_IgnoreAndReturn(ret) Allow any calls, return value
func_ReturnThruPtr_param(&val) Write value to output pointer
func_StubWithCallback(cb) Replace with custom callback
func_CallCount() Get number of times called

Running in CI

# .github/workflows/unit-tests.yml
name: STM32 Unit Tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Install dependencies
        run: |
          sudo apt-get install -y gcc ruby
          gem install ceedling

      - name: Run unit tests
        run: ceedling test:all

      - name: Generate coverage report
        run: ceedling gcov:all utils:gcov

No ARM cross-compiler needed — tests compile with host GCC.

Common Mistakes

Mocking the STM32 HAL directly instead of your wrapper. CMock-generated mocks for HAL functions work, but you'll re-mock the same HAL calls in every test. Mock your wrapper instead — it's stable, and tests become readable.

Not testing HAL_ERROR and HAL_TIMEOUT returns. Most firmware ignores error returns from HAL calls. A mock that always returns HAL_OK misses the most common production bugs.

Including hardware-specific headers in test builds. Your test build shouldn't need stm32f4xx.h. Mock the BSP wrapper and keep hardware headers out of tests.

TEST_ASSERT without message. Use TEST_ASSERT_EQUAL_MESSAGE(expected, actual, "description") — helps identify which assertion failed in a test with multiple asserts.

Start now free