Catch2 Advanced: Testing Embedded Targets and Bare-Metal C++
Catch2 is popular for desktop C++ testing, but embedded developers often hit its limits when dealing with constrained targets. This guide covers advanced Catch2 configurations for bare-metal systems, cross-compilation pipelines, and testing on real or emulated hardware.
Catch2 v3 vs v2 for Embedded
Catch2 v3 is a header + source split (not header-only), which gives you more compile-time control. For embedded, v3 is preferable:
- Smaller binary footprint with selective compilation
- Better support for custom main entry points
- Improved compile times
CATCH_CONFIG_NO_POSIX_SIGNALSworks reliably
Minimal Catch2 Configuration for Bare-Metal
On systems without a filesystem, C++ exceptions, or RTTI, you need a stripped-down build:
// catch2_config.h - include before catch2 headers
#define CATCH_CONFIG_NO_POSIX_SIGNALS // No signal handling
#define CATCH_CONFIG_DISABLE_EXCEPTIONS // No exception throwing
#define CATCH_CONFIG_NO_CPP17_OPTIONAL // If targeting C++14# CMakeLists.txt for bare-metal tests (compiled for host)
add_library(catch2_embedded STATIC)
target_sources(catch2_embedded PRIVATE
${catch2_SOURCE_DIR}/src/catch2/catch_session.cpp
${catch2_SOURCE_DIR}/src/catch2/catch_test_case_info.cpp
# Add only what you need
)
target_compile_definitions(catch2_embedded PUBLIC
CATCH_CONFIG_NO_POSIX_SIGNALS
)Custom Reporter for Serial Output
Most embedded targets can't write to stdout. A custom reporter writes test results over UART or a debug interface:
// serial_reporter.h
#pragma once
#include <catch2/catch_reporter_registrars.hpp>
#include <catch2/reporters/catch_reporter_bases.hpp>
// Provided by the BSP layer
extern "C" void bsp_serial_write(const char* str, size_t len);
class SerialReporter : public Catch::StreamingReporterBase {
public:
using StreamingReporterBase::StreamingReporterBase;
static std::string getDescription() {
return "Reporter for serial/UART output on embedded targets";
}
void testCaseStarting(Catch::TestCaseInfo const& info) override {
std::string msg = "RUNNING: " + std::string(info.name) + "\n";
bsp_serial_write(msg.c_str(), msg.size());
}
void assertionEnded(Catch::AssertionStats const& stats) override {
if (!stats.assertionResult.isOk()) {
auto const& result = stats.assertionResult;
std::string msg = "FAIL: "
+ std::string(result.getSourceInfo().file) + ":"
+ std::to_string(result.getSourceInfo().line) + "\n"
+ " " + std::string(result.getExpressionInMacro()) + "\n";
bsp_serial_write(msg.c_str(), msg.size());
}
}
void testCaseEnded(Catch::TestCaseStats const& stats) override {
std::string msg = stats.totals.assertions.allPassed()
? "PASS\n"
: "FAIL\n";
bsp_serial_write(msg.c_str(), msg.size());
}
void testRunEnded(Catch::TestRunStats const& stats) override {
std::string msg = "\nResults: "
+ std::to_string(stats.totals.testCases.passed) + " passed, "
+ std::to_string(stats.totals.testCases.failed) + " failed\n";
bsp_serial_write(msg.c_str(), msg.size());
}
};
CATCH_REGISTER_REPORTER("serial", SerialReporter)// main_embedded.cpp - custom main for bare-metal
#include <catch2/catch_session.hpp>
int main() {
// Initialize BSP (clocks, UART, etc.)
bsp_init();
// Run tests with serial reporter
Catch::Session session;
const char* argv[] = {"firmware", "--reporter", "serial"};
session.applyCommandLine(3, argv);
int result = session.run();
// Signal pass/fail via LED or debug pin
if (result == 0) {
bsp_led_green();
} else {
bsp_led_red();
}
// Halt
while (1) {}
}QEMU Integration for ARM Cortex-M
Test on emulated hardware using QEMU before flashing:
# CMakeLists.txt for ARM cross-compilation
set(CMAKE_SYSTEM_NAME Generic)
set(CMAKE_SYSTEM_PROCESSOR arm)
set(CMAKE_C_COMPILER arm-none-eabi-gcc)
set(CMAKE_CXX_COMPILER arm-none-eabi-g++)
set(ARM_FLAGS
-mcpu=cortex-m4
-mthumb
-mfloat-abi=hard
-mfpu=fpv4-sp-d16
-fno-exceptions
-fno-rtti
-nostdlib
)
add_executable(firmware_tests_arm
main_embedded.cpp
tests/test_motor_controller.cpp
tests/test_pid_controller.cpp
src/motor_controller.cpp
src/pid_controller.cpp
bsp/stm32_startup.s
bsp/bsp_uart.cpp
)
target_compile_options(firmware_tests_arm PRIVATE ${ARM_FLAGS})
target_link_options(firmware_tests_arm PRIVATE
${ARM_FLAGS}
-T${CMAKE_SOURCE_DIR}/linker/stm32f4.ld
)# Run under QEMU and capture serial output
qemu-system-arm \
-machine netduinoplus2 \
-cpu cortex-m4 \
-nographic \
-semihosting-config <span class="hljs-built_in">enable=on,target=native \
-kernel firmware_tests_arm.elf \
-serial stdio 2>&1 <span class="hljs-pipe">| <span class="hljs-built_in">tee test_output.txt
<span class="hljs-comment"># Parse results
grep -E <span class="hljs-string">"PASS|FAIL|Results:" test_output.txtBDD-Style Tests for Embedded Protocols
Catch2's SCENARIO/GIVEN/WHEN/THEN macros work well for protocol testing:
// tests/test_modbus_protocol.cpp
#include <catch2/catch_test_macros.hpp>
#include <catch2/catch_approx.hpp>
#include "protocol/modbus_rtu.h"
#include "fakes/fake_uart.h"
SCENARIO("ModBus RTU reads holding registers", "[modbus][protocol]") {
GIVEN("A ModBus RTU instance connected to a fake UART") {
FakeUart uart;
ModbusRtu modbus(uart, 1); // slave address = 1
WHEN("A valid read request for 2 registers at address 0x0010 is received") {
// FC03 request: addr=1, fc=3, reg=0x0010, count=2, crc
uint8_t request[] = {0x01, 0x03, 0x00, 0x10, 0x00, 0x02, 0xC5, 0xCE};
uart.inject(request, sizeof(request));
THEN("ModBus responds with the register values") {
// Pre-populate register map
modbus.setRegister(0x0010, 0x1234);
modbus.setRegister(0x0011, 0x5678);
modbus.poll();
auto response = uart.getOutput();
REQUIRE(response.size() == 9); // addr + fc + byteCount + 4 bytes + 2 CRC
CHECK(response[0] == 0x01); // slave addr
CHECK(response[1] == 0x03); // function code
CHECK(response[2] == 0x04); // byte count
CHECK(response[3] == 0x12); // register 0x0010 high byte
CHECK(response[4] == 0x34); // register 0x0010 low byte
CHECK(response[5] == 0x56); // register 0x0011 high byte
CHECK(response[6] == 0x78); // register 0x0011 low byte
}
}
WHEN("A request with invalid CRC is received") {
uint8_t bad_request[] = {0x01, 0x03, 0x00, 0x10, 0x00, 0x02, 0xFF, 0xFF};
uart.inject(bad_request, sizeof(bad_request));
THEN("No response is sent") {
modbus.poll();
CHECK(uart.getOutput().empty());
}
}
}
}Testing PID Controllers with Catch2 Approx
Control systems require floating-point comparisons with tolerances:
#include <catch2/catch_test_macros.hpp>
#include <catch2/catch_approx.hpp>
#include "control/pid.h"
using Catch::Approx;
TEST_CASE("PID controller output", "[control][pid]") {
PidController pid(
/*kp=*/1.0f,
/*ki=*/0.1f,
/*kd=*/0.05f,
/*dt_ms=*/10
);
SECTION("P-only response to step input") {
pid.setSetpoint(100.0f);
// Measured = 0, error = 100, P output = 100
float output = pid.compute(0.0f);
REQUIRE(output == Approx(100.0f).epsilon(0.01f));
}
SECTION("Integral accumulates over time") {
pid.setSetpoint(100.0f);
// After 10 samples with constant error of 100:
// I = Ki * sum(error * dt) = 0.1 * 100 * 10 * 0.01 = 1.0
for (int i = 0; i < 9; i++) pid.compute(0.0f);
float output = pid.compute(0.0f);
// P = 100, I = 1.0 (approx), D varies
CHECK(output > 100.0f); // I term added
}
SECTION("Output clamps to saturation limits") {
pid.setSaturation(-100.0f, 100.0f);
pid.setSetpoint(1000.0f); // huge setpoint
float output = pid.compute(0.0f);
REQUIRE(output == Approx(100.0f).epsilon(0.01f));
}
}Fake/Stub Pattern for Hardware Peripherals
More complex than simple mocks, fakes implement real behavior:
// fakes/fake_uart.h
class FakeUart : public UartInterface {
std::vector<uint8_t> rx_buffer_;
std::vector<uint8_t> tx_buffer_;
public:
void inject(const uint8_t* data, size_t len) {
rx_buffer_.insert(rx_buffer_.end(), data, data + len);
}
std::vector<uint8_t> getOutput() { return tx_buffer_; }
void clearOutput() { tx_buffer_.clear(); }
// UartInterface implementation
bool write(const uint8_t* data, size_t len) override {
tx_buffer_.insert(tx_buffer_.end(), data, data + len);
return true;
}
size_t read(uint8_t* buf, size_t maxLen) override {
size_t to_read = std::min(maxLen, rx_buffer_.size());
std::copy(rx_buffer_.begin(), rx_buffer_.begin() + to_read, buf);
rx_buffer_.erase(rx_buffer_.begin(), rx_buffer_.begin() + to_read);
return to_read;
}
bool isRxAvailable() override { return !rx_buffer_.empty(); }
void flush() override { rx_buffer_.clear(); }
};CI Pipeline with OpenOCD for Hardware-in-the-Loop
When QEMU isn't sufficient, run tests on actual hardware in CI:
# .github/workflows/embedded-tests.yml
name: Embedded Tests
on: [push]
jobs:
host-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build and run host tests
run: |
cmake -B build/host -DTARGET=host
cmake --build build/host
./build/host/firmware_tests
hardware-tests:
runs-on: self-hosted # Runner with STM32 attached
needs: host-tests
steps:
- uses: actions/checkout@v4
- name: Install ARM toolchain
run: sudo apt-get install -y gcc-arm-none-eabi openocd
- name: Build for ARM
run: |
cmake -B build/arm -DTARGET=stm32f4
cmake --build build/arm
- name: Flash and run tests
run: |
# Flash firmware
openocd -f interface/stlink.cfg \
-f target/stm32f4x.cfg \
-c "program build/arm/firmware_tests.elf verify reset exit"
# Capture serial output for 30 seconds
timeout 30 cat /dev/ttyACM0 | tee test_output.txt || true
# Check results
grep "Results:" test_output.txt
! grep "failed" test_output.txtMemory Usage Analysis
Embedded systems have strict memory limits. Track test binary size:
# Add size reporting after build
add_custom_command(TARGET firmware_tests_arm POST_BUILD
COMMAND arm-none-eabi-size $<TARGET_FILE:firmware_tests_arm>
COMMENT "Firmware size:"
)
# Fail build if too large (64KB limit example)
add_custom_command(TARGET firmware_tests_arm POST_BUILD
COMMAND ${CMAKE_COMMAND} -E cmake_echo_color --cyan
"Checking binary size limit..."
COMMAND bash -c "
SIZE=$(arm-none-eabi-size $<TARGET_FILE:firmware_tests_arm> | awk 'NR==2{print $4}')
if [ $$SIZE -gt 65536 ]; then
echo 'Binary too large: '$SIZE' bytes (limit: 65536)'
exit 1
fi
"
)Summary
Advanced Catch2 usage for embedded targets requires:
- Custom reporters — write test output over serial/debug interface instead of stdout
- Stripped configuration — disable exceptions, signals, and optional features for bare-metal
- QEMU integration — run ARM binaries on emulated hardware in CI
- BDD-style tests — SCENARIO/GIVEN/WHEN/THEN maps naturally to protocol and state machine testing
- Fake peripherals — implement real UART/SPI/I2C behavior in software for deterministic tests
The payoff: tests that run in CI without physical hardware, and a verification path that covers both simulation and real silicon.