A simple error output method for embedded projects with (almost) no UI

last updated: Jun 13, 2025

Full SWD/JTAG debugging is great wherever possible, but sometimes it’s not available, or a device is being tested in a production environment, or is perhaps in production.

In these situations, any data more fine grained than “It stopped working” is helpful.

If the device has at least one LED, my preferred method of handling panic-like events is to output an 8-bit error code with it. This is done via simple dot-dash blinking.

The LED flashes the 8-bit number using a combination of short and long blinks to represent 0/1, pauses, and repeats.

Below is an example in C++:

#include <cstdint>
#include <concepts>

namespace err {

namespace code {
inline constexpr uint8_t invalid_handle = 0x08;
inline constexpr uint8_t alloc_fail     = 0x09;
}

template <
    std::invocable<bool> auto set_led,
    std::invocable<int> auto delay_ms>
class Error {
};
public:
    static constexpr uint16_t dot_duration_ms = 100;
    static constexpr uint16_t dash_duration_ms = 500;
    /// Pause between each 1-bit
    static constexpr uint16_t bit_gap_duration_ms = 500;
    /// Pause between each 8-bit output
    static constexpr uint16_t pause_duration_ms = 4000;

    /**
     * Flash the provided 8-bit code, pause for 3 seconds, and repeat
     * indefinitely - this function never returns.
     *
     * The code is shown by 8 flashes with a brief flash representing 0 and a
     * longer flash representing 1. LSB first
     *
     * @param code 8-bit error code
     */
    [[noreturn]]
    void halt(uint8_t code) const
    {
        while (true) {
            flashCode(code);
            delay_ms(pause_duration_ms - bit_gap_duration_ms);
        }
    }

    /**
     * Flash the provided 8-bit code once.
     *
     * The code is shown by 8 flashes with a brief flash representing 0 and a
     * longer flash representing 1. LSB first
     *
     * @param code 8-bit error code
     */
    void flashCode(uint8_t code) const
    {
        for (uint8_t i = 0; i < 8; i++) {
            set_led(true);
            delay_ms( (code & (1 << i)) ? dash_duration_ms : dot_duration_ms);
            set_led(false);
            delay_ms(bit_gap_duration_ms);
        }
    }
};

} // namespace err

Example (hosted) usage

#include <cstdint>
#include <cstdio>
#include "error_led.h"

static bool led_on = false;

void setLed(bool on)
{
    led_on = on;
}
void delayMs(uint32_t ms)
{
    for (uint32_t i = 0; i < ms/100; i++)
        putchar(led_on ? 'x' : '.');
}

constexpr err::Error<setLed, delayMs> error{};

int main()
{
    error.flashCode(err::code::alloc_fail);
}

Output

xxxxx.....x.....x.....xxxxx.....x.....x.....x.....x.....

Of course it’s always possible that whatever triggers the panic might also affect the timer interrupt or LED functionality, but there’s only so much you can do. The set_led function should be as low level as possible to reduce this possibility and delay_ms could even be a busy spin.