Embedded Unit Testing: Test Firmware Before the Board Arrives

Embedded Unit Testing: Test Firmware Before the Board Arrives

Flux is an AI hardware engineer, and it does embedded unit testing for you. Ask Flux, and it writes the driver and the host unit tests from your schematic, runs them on a project computer without a board, and reports the test results, the coverage, and the memory and undefined-behavior checks. It also writes the CI workflow that runs the tests on every change. Your firmware is tested before the board arrives.

Embedded unit testing runs your firmware logic on your computer, without the target board. You put the hardware access behind a thin interface, compile the rest of the firmware with a native compiler, and replace the hardware with fakes in the tests. Then you run the tests with sanitizers and coverage on every change. When the board arrives, you debug only what the hardware adds. Below is what Flux does for you, then the same method step by step on a worked example.

How Flux does embedded unit testing for you

Flux designs the schematic, the PCB, the enclosure, and the firmware of the device in one project, so the firmware and its tests start from the real design, not from a copy of it.

  • Firmware from the schematic. Flux reads the microcontroller, the sensor, the bus, and the strapping pins in the schematic. It writes the pin map, the driver, and the hardware interface for the tests.
  • Host unit tests without a board. Flux writes the tests and runs them on a project computer. It reports the test results, the coverage, the memory and undefined-behavior checks, and a static analysis of the source. When a test fails, Flux fixes the source or the test and runs them again.
  • A target build next to the tests. Flux compiles the firmware for STM32, nRF, ESP32-class, RP2040, RISC-V, AVR, and other ARM targets. It reports flash and RAM use and keeps the image files and the test results with the source in the project files.
  • Tests on every change. Connect your firmware repository, and Flux writes a CI workflow that builds the target and runs the host unit tests on every change.
  • Firmware that follows the hardware. When the schematic changes, for example a sensor address strap moves, Flux updates the firmware, the pin map, the tests, and the bring-up plan. Firmware constants that come from the mechanical model, such as the sensor position in the enclosure, stay in step with the model when it changes.

Ask for the result you want, in the chat of your project:

Write a TMP117 temperature sensor driver for this schematic behind an I2C interface. Add host unit tests with a fake bus for 25 °C, −40 °C, one LSB, full scale, and a NACK. Run them with the address and undefined-behavior sanitizers and report coverage.
Add host tests for the excursion alarm: 60 seconds above 8.0 °C sets it, a short spike does not, and it stays set until acknowledged.
Create a firmware CI workflow for this repository. Build the STM32L4 target and run the host unit tests on every change.
Use the sensor position in the enclosure from the mechanical model as a firmware constant, and add a test for it.

The steps are in the Flux docs on building and testing firmware. To see how Flux designs the electronics, the firmware, and the mechanical parts of a device together, go to Flux for electronics design.

Why test firmware before the board arrives

After the design is done, the bare boards, the parts, and the assembly take days or weeks to arrive. Teams that wait for the hardware start the firmware late, and then they debug the logic and the board at the same time. A wrong reading on the bench can be a bad solder joint, a wrong pull-up, or a sign error in the conversion code. You cannot tell which.

Most firmware defects do not need the silicon to show up:

  • Unit conversions, scaling, and sign extension.
  • Protocol parsers, checksums, and packet framing.
  • State machines, timeouts, and alarm logic.
  • Error paths: a NACK on the bus, a sensor that stops responding, a full buffer.

Host unit tests find these defects in seconds, on every commit. The board bring-up then tests only the hardware: the power rails, the clocks, the signal levels, and the timing.

The worked example: a cold-chain temperature logger

The device is a temperature logger for vaccine and food shipments. It has an STM32L4 microcontroller and a TI TMP117 digital temperature sensor on I2C, on a board inside an IP67 enclosure with a cable gland. The firmware reads the temperature once per second. It sets an excursion alarm when the temperature stays above 8.0 °C for 60 seconds.

The schematic gives the firmware three facts that the tests use:

  • The TMP117 ADD0 pin connects to GND, so the sensor address is 0x48.
  • The temperature result is register 0x00: a 16-bit two's complement value, MSB first.
  • One count is 7.8125 m°C.

The code below comes from a test suite that compiles and runs with GCC on Linux or macOS. It uses the open-source Unity test framework for C. Copy unity.c, unity.h, and unity_internals.h from its src folder into test/unity. Each test file also has the usual Unity setUp, tearDown, and a main that calls RUN_TEST for each test.

Step 1: Put the hardware behind a seam

The driver must not call the STM32 HAL directly. It calls a small bus interface. On the target, the interface wraps the HAL I2C functions. In the tests, it is a fake that you control.

/* i2c_bus.h */
typedef struct {
    int (*read_reg)(void *ctx, uint8_t addr, uint8_t reg, uint8_t *buf, size_t len);
    void *ctx;
} i2c_bus_t;

The first version of the driver reads two bytes and converts them to millidegrees Celsius:

/* tmp117.c, first version */
#define TMP117_ADDR     0x48 /* ADD0 tied to GND in the schematic */
#define TMP117_REG_TEMP 0x00

int tmp117_read_mc(const i2c_bus_t *bus, int32_t *millicelsius)
{
    uint8_t raw[2];
    if (bus->read_reg(bus->ctx, TMP117_ADDR, TMP117_REG_TEMP, raw, sizeof raw) != 0) {
        return -1;
    }
    int16_t counts = (int16_t)((raw[0] << 8) | raw[1]);
    *millicelsius = counts * 78125 / 10000; /* 7.8125 m°C per LSB */
    return 0;
}

It looks correct. It gives the correct result at room temperature. The tests show that it is not correct.

Step 2: Write a fake for the bus

The fake returns the bytes that a test sets, records the address and the register that the driver asked for, and can simulate a bus error.

/* fake_i2c.c */
typedef struct {
    uint8_t temp[2];   /* bytes the sensor returns, MSB first */
    int fail;          /* nonzero: simulate a NACK */
    uint8_t last_addr;
    uint8_t last_reg;
} fake_i2c_t;

static int fake_read_reg(void *ctx, uint8_t addr, uint8_t reg, uint8_t *buf, size_t len)
{
    fake_i2c_t *fake = ctx;
    fake->last_addr = addr;
    fake->last_reg = reg;
    if (fake->fail) {
        return -1;
    }
    memcpy(buf, fake->temp, len);
    return 0;
}

i2c_bus_t fake_i2c_bus(fake_i2c_t *fake)
{
    return (i2c_bus_t){.read_reg = fake_read_reg, .ctx = fake};
}

Step 3: Test the edges, not only the typical value

Each test sets the raw bytes and checks the result. Test the values that a datasheet table gives you, plus the edges: one count, the negative range, full scale, and a bus error.

/* test_logger.c */
static fake_i2c_t fake;
static i2c_bus_t bus;

void setUp(void)
{
    fake = (fake_i2c_t){0};
    bus = fake_i2c_bus(&fake);
}

static int32_t read_with(uint8_t msb, uint8_t lsb)
{
    int32_t mc = 0;
    fake.temp[0] = msb;
    fake.temp[1] = lsb;
    TEST_ASSERT_EQUAL_INT(0, tmp117_read_mc(&bus, &mc));
    return mc;
}

void test_reads_the_address_from_the_schematic(void)
{
    read_with(0x0C, 0x80);
    TEST_ASSERT_EQUAL_HEX8(0x48, fake.last_addr);
    TEST_ASSERT_EQUAL_HEX8(TMP117_REG_TEMP, fake.last_reg);
}

void test_converts_25_degrees(void)  { TEST_ASSERT_EQUAL_INT32(25000, read_with(0x0C, 0x80)); }
void test_converts_minus_40(void)    { TEST_ASSERT_EQUAL_INT32(-40000, read_with(0xEC, 0x00)); }
void test_converts_one_lsb(void)     { TEST_ASSERT_EQUAL_INT32(7, read_with(0x00, 0x01)); }
void test_converts_full_scale(void)  { TEST_ASSERT_EQUAL_INT32(255992, read_with(0x7F, 0xFF)); }

void test_bus_error_keeps_the_last_value(void)
{
    int32_t mc = 4200;
    fake.fail = 1;
    TEST_ASSERT_EQUAL_INT(-1, tmp117_read_mc(&bus, &mc));
    TEST_ASSERT_EQUAL_INT32(4200, mc);
}

Step 4: Run the tests with sanitizers and coverage

Compile the firmware logic, the fake, the tests, and Unity for the host. Turn on the address and undefined-behavior sanitizers, and make every sanitizer finding stop the run:

gcc -std=c11 -Wall -Wextra -Werror -g -O0 -Isrc -Itest -Itest/unity \
    -fsanitize=address,undefined -fno-sanitize-recover=all --coverage \
    src/tmp117.c src/excursion.c test/fake_i2c.c test/test_logger.c test/unity/unity.c \
    -o build/test_logger
./build/test_logger

The full-scale test stops the first version of the driver:

src/tmp117.c:10:28: runtime error: signed integer overflow: 32767 * 78125 cannot be represented in type 'int'

32767 × 78125 is more than 2.5 billion, and a 32-bit int holds at most about 2.1 billion. Signed overflow is undefined behavior in C. The sensor reads at most 150 °C, so the bench would never show this defect. A corrupted transfer or a noisy bus returns 0x7FFF, and then the logger stores a random temperature, or the optimizer removes the code that depends on it.

The fix uses the exact fraction: 7.8125 m°C is 125/16 m°C, and 32767 × 125 fits easily in 32 bits.

*millicelsius = (int32_t)counts * 125 / 16; /* 7.8125 m°C = 125/16 m°C per LSB */

Run the tests again:

test_logger.c:test_reads_the_address_from_the_schematic:PASS
test_logger.c:test_converts_25_degrees:PASS
test_logger.c:test_converts_minus_40:PASS
test_logger.c:test_converts_one_lsb:PASS
test_logger.c:test_converts_full_scale:PASS
test_logger.c:test_bus_error_keeps_the_last_value:PASS
test_logger.c:test_alarm_sets_after_hold_time:PASS
test_logger.c:test_short_spike_does_not_alarm:PASS
test_logger.c:test_alarm_stays_latched:PASS

-----------------------
9 Tests 0 Failures 0 Ignored
OK

Then run gcov on the build directory to read the line coverage of each source file. Here, both the driver and the alarm logic are at 100%.

Step 5: Test the logic that has no hardware at all

The excursion alarm is pure logic. It takes one temperature each second and keeps a counter. The tests cover the three behaviors that a cold-chain audit asks about: the alarm sets after the hold time, a short spike does not set it, and the alarm stays set when the temperature comes back.

typedef struct {
    int32_t limit_mc;   /* upper limit, for example 8000 for 8.0 °C */
    uint32_t hold_s;    /* seconds above the limit before the alarm sets */
    uint32_t above_s;
    bool alarm;         /* stays set until the user acknowledges it */
} excursion_t;

void excursion_update(excursion_t *e, int32_t millicelsius)
{
    if (millicelsius > e->limit_mc) {
        e->above_s++;
        if (e->above_s >= e->hold_s) {
            e->alarm = true;
        }
    } else {
        e->above_s = 0;
    }
}

void test_alarm_sets_after_hold_time(void)
{
    excursion_t e = {.limit_mc = 8000, .hold_s = 60};
    for (int s = 0; s < 59; s++) excursion_update(&e, 8100);
    TEST_ASSERT_FALSE(e.alarm);
    excursion_update(&e, 8100);
    TEST_ASSERT_TRUE(e.alarm);
}

void test_short_spike_does_not_alarm(void)
{
    excursion_t e = {.limit_mc = 8000, .hold_s = 60};
    for (int s = 0; s < 59; s++) excursion_update(&e, 8100);
    excursion_update(&e, 7900);
    for (int s = 0; s < 59; s++) excursion_update(&e, 8100);
    TEST_ASSERT_FALSE(e.alarm);
}

void test_alarm_stays_latched(void)
{
    excursion_t e = {.limit_mc = 8000, .hold_s = 60};
    for (int s = 0; s < 60; s++) excursion_update(&e, 8100);
    excursion_update(&e, 4000);
    TEST_ASSERT_TRUE(e.alarm);
}

The test drives one simulated second per call, so 60 seconds of behavior runs in microseconds. Do the same for timeouts, retries, and debounce logic: give the code the time as an input, and the tests control the clock.

Step 6: Run the tests on every change

Host tests are useful only when they run on every commit. A short workflow builds the firmware for the target and runs the host tests in the same job, so a change that compiles for the STM32 but breaks the conversion fails the check.

name: firmware
on: [push, pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: sudo apt-get install -y gcc-arm-none-eabi
      - run: make target        # cross-compile for the STM32L4
      - run: make host-test     # host unit tests with sanitizers and coverage

What you test when the board arrives

The host tests prove the logic. The hardware tests then check what only the hardware has: the I2C pull-ups and the signal levels, the sensor response on the real bus, the sleep current, and the timing of the one-second wake-up. Because the logic already passes, a wrong reading on the bench points at the board, not at the code. Write these checks into a board bring-up plan before the boards ship.

Try it on your device

Open a project, describe your device, and ask Flux for the firmware and its tests. Start a free trial.

FAQ

About the author

Ryan Fitzgerald

Electronics Engineer at Flux

Ryan is an electronics and electrical systems engineer with a focus on bridging the gap between deep learning intelligent algorithms and innovative hardware design. Find him on Flux @ryanf

Let’s start building.

Let’s build your idea together. All you need to do is describe what you want to make, or what problem you want to solve. Flux will work with you to make it a reality.

Try it Free
Close-up of a Flux-designed printed circuit board