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_firmwareCeedling 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_generatorThe 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:allOutput:
-------------------
FAILED TEST SUMMARY
-------------------
[none]
-------------------
OVERALL TEST SUMMARY
--------------------
TESTED: 6
PASSED: 6
FAILED: 0
IGNORED: 0Testing 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.hIn 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: detailedRun with:
ceedling gcov:all utils:gcovCoverage 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.