Catch2 Advanced: Testing Embedded Targets and Bare-Metal C++

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_SIGNALS works 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.txt

BDD-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.txt

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

Read more