Unit Testing C Firmware with Ceedling and Unity

Unit Testing C Firmware with Ceedling and Unity

Embedded firmware has a reputation for being hard to test. The hardware isn't always available, the toolchain is different from your host machine, and most C codebases were never written with testability in mind. But unit testing firmware is not only possible — it is one of the highest-leverage things you can do for product quality. Ceedling and Unity make it practical.

This post walks through setting up Ceedling, writing your first Unity tests, structuring a testable firmware project, and integrating tests into CI. All examples use real embedded C patterns you will recognize from production code.

What Ceedling and Unity Are

Unity is a lightweight unit testing framework written in C. It provides assertion macros, test runner scaffolding, and output formatting. The entire framework fits in three files: unity.c, unity.h, and unity_internals.h. That minimalism is intentional — it runs on 8-bit microcontrollers with 2 KB of RAM.

CMock is a companion tool that auto-generates mock implementations of C modules from their header files. You pass it a header, it generates a .c file full of mock functions you can configure in tests.

Ceedling is the build and orchestration layer that ties everything together. It is a Ruby gem that reads a project.yml configuration file, discovers test files, generates runners, compiles everything for the host platform, and runs the resulting test binaries. You get a full test suite with one command: ceedling test:all.

Installation

You need Ruby (2.7+) and a host C compiler (GCC on Linux/macOS, MinGW on Windows).

gem install ceedling
ceedling new my_firmware
cd my_firmware

Ceedling creates this structure:

my_firmware/
  project.yml
  src/
  test/
  build/

The src/ directory holds your production firmware modules. The test/ directory holds your test files. Ceedling keeps them separate and never mixes test code into your production build.

Project Configuration

The project.yml file controls everything. A minimal working configuration:

---
:project:
  :build_root: build
  :release_build: FALSE
  :test_file_prefix: test_

:paths:
  :test:
    - test/**
  :source:
    - src/**
  :include:
    - src/**

:defines:
  :common: []
  :test:
    - UNIT_TEST

:plugins:
  :load_paths:
    - vendor/ceedling/plugins
  :enabled:
    - stdout_pretty_tests_report
    - module_generator

The UNIT_TEST define lets you #ifdef out hardware-specific code in your production sources when running under test.

A Real Firmware Module to Test

Consider a simple PID controller module — common in motor control, temperature regulation, and any closed-loop system:

/* src/pid.h */
#ifndef PID_H
#define PID_H

typedef struct {
    float kp;
    float ki;
    float kd;
    float integral;
    float prev_error;
    float output_min;
    float output_max;
} PID_Handle_t;

void PID_Init(PID_Handle_t *pid, float kp, float ki, float kd,
              float out_min, float out_max);
float PID_Update(PID_Handle_t *pid, float setpoint, float measurement, float dt);
void PID_Reset(PID_Handle_t *pid);

#endif
/* src/pid.c */
#include "pid.h"

void PID_Init(PID_Handle_t *pid, float kp, float ki, float kd,
              float out_min, float out_max) {
    pid->kp = kp;
    pid->ki = ki;
    pid->kd = kd;
    pid->output_min = out_min;
    pid->output_max = out_max;
    pid->integral = 0.0f;
    pid->prev_error = 0.0f;
}

float PID_Update(PID_Handle_t *pid, float setpoint, float measurement, float dt) {
    float error = setpoint - measurement;
    pid->integral += error * dt;
    float derivative = (error - pid->prev_error) / dt;
    pid->prev_error = error;

    float output = pid->kp * error
                 + pid->ki * pid->integral
                 + pid->kd * derivative;

    if (output > pid->output_max) output = pid->output_max;
    if (output < pid->output_min) output = pid->output_min;

    return output;
}

void PID_Reset(PID_Handle_t *pid) {
    pid->integral = 0.0f;
    pid->prev_error = 0.0f;
}

This module has zero hardware dependencies. It is pure computation. That is the ideal unit test target.

Writing Unity Tests

Test files follow the naming convention test_<module>.c. Ceedling discovers them automatically:

/* test/test_pid.c */
#include "unity.h"
#include "pid.h"

static PID_Handle_t pid;

void setUp(void) {
    PID_Init(&pid, 1.0f, 0.0f, 0.0f, -100.0f, 100.0f);
}

void tearDown(void) {
    /* nothing to clean up */
}

void test_PID_Init_SetsCoefficients(void) {
    TEST_ASSERT_FLOAT_WITHIN(0.001f, 1.0f, pid.kp);
    TEST_ASSERT_FLOAT_WITHIN(0.001f, 0.0f, pid.ki);
    TEST_ASSERT_FLOAT_WITHIN(0.001f, 0.0f, pid.kd);
}

void test_PID_Init_ZerosState(void) {
    TEST_ASSERT_FLOAT_WITHIN(0.001f, 0.0f, pid.integral);
    TEST_ASSERT_FLOAT_WITHIN(0.001f, 0.0f, pid.prev_error);
}

void test_PID_Update_ProportionalOnly(void) {
    /* With ki=kd=0, output = kp * error */
    float output = PID_Update(&pid, 10.0f, 0.0f, 0.01f);
    TEST_ASSERT_FLOAT_WITHIN(0.001f, 10.0f, output);
}

void test_PID_Update_ClampsPositiveOutput(void) {
    float output = PID_Update(&pid, 200.0f, 0.0f, 0.01f);
    TEST_ASSERT_FLOAT_WITHIN(0.001f, 100.0f, output);
}

void test_PID_Update_ClampsNegativeOutput(void) {
    float output = PID_Update(&pid, -200.0f, 0.0f, 0.01f);
    TEST_ASSERT_FLOAT_WITHIN(0.001f, -100.0f, output);
}

void test_PID_Reset_ClearsIntegral(void) {
    /* Accumulate some integral */
    PID_Handle_t pid_i;
    PID_Init(&pid_i, 0.0f, 1.0f, 0.0f, -1000.0f, 1000.0f);
    PID_Update(&pid_i, 5.0f, 0.0f, 0.1f);  /* integral = 0.5 */
    PID_Reset(&pid_i);
    TEST_ASSERT_FLOAT_WITHIN(0.001f, 0.0f, pid_i.integral);
}

void test_PID_Update_IntegratorAccumulates(void) {
    PID_Handle_t pid_i;
    PID_Init(&pid_i, 0.0f, 1.0f, 0.0f, -1000.0f, 1000.0f);
    /* error=5, dt=0.1 → integral term = 0.5 per step */
    PID_Update(&pid_i, 5.0f, 0.0f, 0.1f);
    float output = PID_Update(&pid_i, 5.0f, 0.0f, 0.1f);
    /* integral = 0.5 + 0.5 = 1.0, output = 1.0 * 1.0 = 1.0 */
    TEST_ASSERT_FLOAT_WITHIN(0.01f, 1.0f, output);
}

Run the tests:

ceedling test:all

Output:

-------------------
FAILED TEST SUMMARY
-------------------
[none]
-------------------

OVERALL TEST SUMMARY
--------------------
TESTED:  6
PASSED:  6
FAILED:  0
IGNORED: 0

Testing Modules with Hardware Dependencies Using CMock

Most firmware modules call into hardware abstraction layers (HAL). The UART driver calls HAL_UART_Transmit. The sensor driver calls SPI_Transfer. You cannot run those on a host machine — the peripherals do not exist.

CMock generates mocks automatically. Suppose your LED driver depends on a GPIO HAL:

/* src/hal_gpio.h */
#ifndef HAL_GPIO_H
#define HAL_GPIO_H

typedef enum { GPIO_PIN_RESET = 0, GPIO_PIN_SET } GPIO_PinState;

void HAL_GPIO_WritePin(uint32_t pin, GPIO_PinState state);
GPIO_PinState HAL_GPIO_ReadPin(uint32_t pin);

#endif
/* src/led.h */
void LED_On(uint32_t pin);
void LED_Off(uint32_t pin);
void LED_Toggle(uint32_t pin);

In project.yml, tell CMock to mock the GPIO header:

:cmock:
  :mock_prefix: mock_
  :includes:
    - hal_gpio.h

In your test file:

/* test/test_led.c */
#include "unity.h"
#include "mock_hal_gpio.h"  /* CMock-generated */
#include "led.h"

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

void test_LED_On_SetsPin(void) {
    HAL_GPIO_WritePin_Expect(13, GPIO_PIN_SET);
    LED_On(13);
    /* CMock verifies the expectation automatically */
}

void test_LED_Off_ResetsPin(void) {
    HAL_GPIO_WritePin_Expect(13, GPIO_PIN_RESET);
    LED_Off(13);
}

void test_LED_Toggle_ReadsBeforeWriting(void) {
    HAL_GPIO_ReadPin_ExpectAndReturn(13, GPIO_PIN_RESET);
    HAL_GPIO_WritePin_Expect(13, GPIO_PIN_SET);
    LED_Toggle(13);
}

The _Expect macros tell CMock: "this function must be called with exactly these arguments." If the production code calls it differently, or does not call it at all, the test fails with a clear message.

Structuring Firmware for Testability

Testable firmware requires a discipline shift at the design level:

Inject dependencies through function pointers or structs rather than calling hardware functions directly. Instead of HAL_GetTick() buried in your timer module, pass a get_tick_fn pointer at initialization. Under test, you provide a fake that returns controlled values.

Keep ISR handlers thin. Interrupt service routines should do the minimum: read a register, set a flag, post to a queue. Put the business logic in a task or function that the ISR calls — and test that function directly without ever triggering an interrupt.

Use the UNIT_TEST define to compile out hardware initialization code that has no place in host-side tests:

void SystemClock_Config(void) {
#ifndef UNIT_TEST
    RCC_OscInitTypeDef osc = {0};
    /* ... real clock config ... */
#endif
}

Continuous Integration

Add Ceedling to your CI pipeline so tests run on every pull request. A GitHub Actions workflow:

name: Firmware Unit Tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Install Ruby
        uses: ruby/setup-ruby@v1
        with:
          ruby-version: '3.1'
      - name: Install Ceedling
        run: gem install ceedling
      - name: Run tests
        run: ceedling test:all
        working-directory: firmware/

This gives you a test gate that catches regressions before they reach hardware. A developer changing the PID clamping logic, breaking the integrator, or getting the sign of a derivative wrong gets immediate feedback in under 10 seconds — not after flashing a board and watching a motor spin the wrong way.

Code Coverage

Ceedling integrates with gcov for coverage reporting:

:plugins:
  :enabled:
    - gcov

:gcov:
  :html_report: TRUE
  :html_report_type: detailed

Run with:

ceedling gcov:all utils:gcov

Coverage reports live in build/artifacts/gcov/. Aim for 80%+ line coverage on pure logic modules. Do not waste time chasing 100% on modules that are trivially thin wrappers around hardware calls — those belong in integration tests.

What Ceedling Cannot Do

Ceedling runs on the host. It cannot test:

  • Real-time behavior — timing, interrupt latency, DMA transfers
  • Hardware-specific edge cases — brownout behavior, flash wear, peripheral register side effects
  • Multi-core synchronization on actual silicon

For those, you need hardware-in-the-loop testing or at minimum a QEMU-based simulation. Ceedling is your first line of defense — fast, cheap, runnable in CI — not your only line.

Summary

Ceedling and Unity give you a professional unit testing workflow for embedded C without requiring hardware. The setup takes under an hour. The payoff is immediate: logic bugs caught in CI instead of in the lab, confident refactoring, and a test suite that serves as living documentation of your module contracts. Start with your pure logic modules — math, state machines, protocol parsers — and work outward. The HAL boundary is not a wall; it is a seam you can mock.

Read more

Start now free