How-To-Tutorials · September 5, 2025

How to Create Unity/Ceedling Firmware Unit Tests for STM32 GPIO Toggle

how-to-create-unity-ceedling-firmware-unit-tests-for-stm32-gpio-toggle.png

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

  1. 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 ceedling

    Verify the install:

    ceedling version

    You 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.

  2. Create a Ceedling Project
    ceedling new gpio_test_project
    cd gpio_test_project

    This generates a project skeleton with src/, test/, and a project.yml config file. The structure is opinionated but sensible -- source in src/, tests in test/, and Ceedling handles the rest.

  3. 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_report

    The 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.

  4. 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);
    
    #endif

    Notice we're defining our own GPIO_TypeDef struct 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 a test/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.

  5. 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_HEX32 instead of TEST_ASSERT_EQUAL -- when a test fails, hex output makes it much easier to spot which bit is wrong.

  6. Run the Tests
    ceedling test:all

    You 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: 0

    To 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 the test/ 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 ceedling fails, try sudo gem install ceedling on 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.