How to Create Unity/Ceedling Firmware Unit Tests for STM32 GPIO Toggle
Unit Testing Firmware Without Hardware
Testing embedded code on the host machine -- without flashing anything -- is one of the best habits you can build. The Unity test framework combined with Ceedling (and its mocking tool CMock) lets you verify your firmware logic in seconds instead of the flash-debug-repeat cycle.
This walkthrough shows how to set up Ceedling for an STM32 project and write a test for a simple GPIO toggle function. The approach scales to much more complex modules too. Once you see how mocking hardware dependencies works, you'll want to test everything this way.
Prerequisites
- Solid C programming skills
- Ruby installed (Ceedling is a Ruby gem)
- Basic familiarity with STM32 register-level or HAL programming
- A terminal you're comfortable in
Parts/Tools
- Computer (no STM32 board needed -- that's the whole point)
- Ceedling (includes Unity and CMock)
- A native GCC compiler (the one already on your system is fine)
- Your STM32 project source files (or we'll create minimal ones)
Steps
- Install Ceedling
Ceedling is distributed as a Ruby gem. If you don't have Ruby, install it first (most macOS and Linux systems have it already).
gem install ceedlingVerify the install:
ceedling versionYou should see Ceedling 0.32+ (or newer). The tool bundles Unity for assertions and CMock for automatic mock generation, so you don't need to install those separately.
- Create a Ceedling Project
ceedling new gpio_test_project cd gpio_test_projectThis generates a project skeleton with
src/,test/, and aproject.ymlconfig file. The structure is opinionated but sensible -- source insrc/, tests intest/, and Ceedling handles the rest. - Configure project.yml for STM32 Headers
The default config works for simple projects. For STM32 code, you need to tell Ceedling where to find your CMSIS and HAL headers. Edit
project.yml::paths: :test: - test/** :source: - src/** :include: - src/** - test/support/** :defines: :test: - STM32F407xx - USE_HAL_DRIVER - UNIT_TEST :plugins: :enabled: - stdout_pretty_tests_reportThe key insight: you're compiling with your host GCC, not arm-none-eabi-gcc. You won't (and shouldn't) include the real CMSIS/HAL headers directly -- they pull in ARM-specific intrinsics that won't compile on x86. Instead, you'll create lightweight stubs or use CMock to mock the HAL functions.
- Write the GPIO Toggle Module
Create
src/gpio_toggle.h:#ifndef GPIO_TOGGLE_H #define GPIO_TOGGLE_H #include <stdint.h> typedef struct { volatile uint32_t MODER; volatile uint32_t OTYPER; volatile uint32_t OSPEEDR; volatile uint32_t PUPDR; volatile uint32_t IDR; volatile uint32_t ODR; volatile uint32_t BSRR; volatile uint32_t LCKR; volatile uint32_t AFR[2]; } GPIO_TypeDef; void gpio_toggle_pin(GPIO_TypeDef *port, uint16_t pin); #endifNotice we're defining our own
GPIO_TypeDefstruct that mirrors the STM32 register layout. In a real project, you'd probably pull this from a stripped-down version of the CMSIS headers or use atest/support/directory for test-only stubs.Now create
src/gpio_toggle.c:#include "gpio_toggle.h" void gpio_toggle_pin(GPIO_TypeDef *port, uint16_t pin) { port->ODR ^= pin; }Simple XOR toggle on the output data register. This is the function we want to verify.
- Write the Unit Test
Create
test/test_gpio_toggle.c:#include "unity.h" #include "gpio_toggle.h" static GPIO_TypeDef test_port; void setUp(void) { // Reset the mock port before each test test_port.ODR = 0x0000; } void tearDown(void) { // Nothing to clean up } void test_toggle_pin_sets_bit_when_clear(void) { test_port.ODR = 0x0000; gpio_toggle_pin(&test_port, 0x0001); TEST_ASSERT_EQUAL_HEX32(0x0001, test_port.ODR); } void test_toggle_pin_clears_bit_when_set(void) { test_port.ODR = 0x0001; gpio_toggle_pin(&test_port, 0x0001); TEST_ASSERT_EQUAL_HEX32(0x0000, test_port.ODR); } void test_toggle_pin_only_affects_target_pin(void) { test_port.ODR = 0x00FF; gpio_toggle_pin(&test_port, 0x0010); // Toggle pin 4 TEST_ASSERT_EQUAL_HEX32(0x00EF, test_port.ODR); } void test_double_toggle_restores_original(void) { test_port.ODR = 0x1234; gpio_toggle_pin(&test_port, 0x0080); gpio_toggle_pin(&test_port, 0x0080); TEST_ASSERT_EQUAL_HEX32(0x1234, test_port.ODR); }Four tests that cover the real behavior: toggling on, toggling off, making sure other pins aren't affected, and verifying that a double toggle is a no-op. Use
TEST_ASSERT_EQUAL_HEX32instead ofTEST_ASSERT_EQUAL-- when a test fails, hex output makes it much easier to spot which bit is wrong. - Run the Tests
ceedling test:allYou should see output like:
-------------------- TEST OUTPUT -------------------- test/test_gpio_toggle.c test_toggle_pin_sets_bit_when_clear: PASS test_toggle_pin_clears_bit_when_set: PASS test_toggle_pin_only_affects_target_pin: PASS test_double_toggle_restores_original: PASS -------------------- OVERALL TEST SUMMARY -------------------- TESTED: 4 PASSED: 4 FAILED: 0 IGNORED: 0To run just one test file:
ceedling test:test_gpio_toggle
Going Further: Mocking HAL Functions with CMock
The example above tests a register-level function directly. But what if your code calls HAL functions like HAL_GPIO_TogglePin()? That's where CMock shines.
Create a header for the HAL function you want to mock (e.g., src/stm32f4xx_hal_gpio.h with just the function prototypes you use). Then in your test file, include the mock:
#include "mock_stm32f4xx_hal_gpio.h"
Ceedling auto-generates the mock from the header. You can then set expectations like:
HAL_GPIO_TogglePin_Expect(GPIOA, GPIO_PIN_5);
This verifies your code calls the HAL function with the right arguments, without needing any real hardware.
Troubleshooting
- "No test runners generated": Make sure your test file starts with
test_and is in thetest/directory. Ceedling uses naming conventions to discover tests. - Include path errors for STM32 headers: Don't include the real CMSIS headers in your test builds. Create minimal stubs in
test/support/with just the typedefs and defines you need. - Ruby/gem issues: If
gem install ceedlingfails, trysudo gem install ceedlingon macOS/Linux, or use a Ruby version manager like rbenv. - Tests pass but you're not sure they're actually running: Add a deliberately failing assertion like
TEST_ASSERT_EQUAL(1, 0)and re-run. If it doesn't fail, your test isn't being picked up.
Why This Matters
Firmware bugs found at the unit test stage cost minutes to fix. The same bugs found during integration testing cost hours. Found in the field? Days or weeks. Ceedling makes it low-friction enough that there's no excuse not to test your logic on the host before it ever touches real hardware. Start with simple modules like this GPIO example, and expand from there.