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 ceedlingInitialize Project
cd my-stm32-project
ceedling new test_projectproject.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_generatorYour 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:allMocking 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:gcovNo 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.