The status LED as a debug channel: bit-bang when nothing else is left

Embedded Systems Projekte

Code protection on. UART busy. Debug pins not routed. Trace pads under the BGA.

The device gives you nothing but a small status LED that blinks slowly or not at all. And that single LED is enough, once you treat it as a serial channel.

When the usual debug channels are gone

Typical constellations where classical debugging fails:

What is left is the status LED. On a perfectly ordinary GPIO. A thin wire soldered to the LED pad, the other end straight into the logic analyzer.

The LED is a 1-bit port

A GPIO on a 48 MHz CPU toggles in under 100 ns. A Saleae Logic Pro samples at 500 MS/s, i.e. 2 ns per sample. The resolution fits. At every interesting point in the firmware, push a short bit frame onto the LED:

Bits 15..12  preamble    0b1010   (= 0xA)
Bits 11..4   payload     8 bits
Bits  3..0   postamble   0b0101   (= 0x5)

The preamble toggles three times, the decoder locks onto it and measures the bit period. The postamble confirms that what was decoded is a real frame. At every init step a frame goes out: 0x01 for “main entered”, 0x0F for “modbus initialized”, and so on. If the boot hangs at step 14 instead of 17, you see at a glance which subsystem failed.

One format for bootloader and application

Bootloader and application share the same frame. The 8-bit code namespace has 256 values, plenty for a small firmware. The application counts up from 0x01, the bootloader counts down from 0xFF. Where they meet depends on how many codes each side actually uses — the bootloader needs far fewer than the application.

The bootloader emitter is an assembly loop with a fixed cycle count per bit, under 100 bytes. The application fully unrolls the 16 bits, around 125 ns per bit. The decoder synchronizes on the preamble and re-measures the bit period for every frame. Different clock rates between bootloader and application therefore do not matter, nor does CPU drift or a runtime change of the system clock. Both emitters read from the same capture.

The full bootloader emitter (MIPS, PIC32MX®, 8 cycles per bit):

// bl_pulse.c — runs from boot flash, cache off, prefetch on
#include <xc.h>

#define LED_BIT  (1u << 14)        // RB14 = status LED

__attribute__((noinline))
void BL_Pulse(uint8_t code)
{
    // 16-bit frame: 0xA preamble | 8-bit code | 0x5 postamble
    // left-justified in a 32-bit register, MSB shifted out by the loop
    uint32_t pat   = ((uint32_t)0xAu << 28)
                   | ((uint32_t)code << 20)
                   | ((uint32_t)0x5u << 16);
    uint32_t mask  = LED_BIT;
    volatile uint32_t *set = &LATBSET;
    volatile uint32_t *clr = &LATBCLR;
    uint32_t nbits = 16;
    uint32_t ta, tb;

    __asm__ volatile (
        ".set push                    \n"
        ".set noreorder               \n"   // we fill the delay slots ourselves
        ".set nomacro                 \n"
    "1:                               \n"
        "srl   %[ta], %[pat], 15      \n"   // ta = MSB of pat
        "sll   %[ta], %[ta], 14       \n"   // ta = LED_BIT  or  0
        "xor   %[tb], %[ta], %[mask]  \n"   // tb = complement
        "sw    %[ta], 0(%[set])       \n"   // LATBSET = ta  (one of them is 0)
        "sw    %[tb], 0(%[clr])       \n"   // LATBCLR = tb
        "addiu %[n],  %[n],   -1      \n"
        "bne   %[n],  $zero,  1b      \n"
        "sll   %[pat], %[pat], 1      \n"   // delay slot: pre-shift pat
        ".set pop                     \n"
        : [pat]"+r"(pat), [n]"+r"(nbits),
          [ta]"=&r"(ta),  [tb]"=&r"(tb)
        : [mask]"r"(mask), [set]"r"(set), [clr]"r"(clr)
        : "memory"
    );
}

The matching application emitter lives in SRAM so every cycle runs without a flash wait state (on a larger SoC with DDR the picture changes, there only TCM or a locked cache line will give the same determinism):

// pmm_pulse.c — application, SRAM resident
#include <xc.h>

#define LED_BIT  (1u << 14)

__attribute__((section(".ramfunc"), long_call, noinline))
void PMM_Pulse(uint8_t code)
{
    // same frame, but the loop is fully unrolled
    // (16 copies of the set/clear/shift block, around 125 ns per bit)
    ...
}

At boot the linker copies .ramfunc from its LMA into SRAM. On STM32® __attribute__((section(".RamFunc"))) does the same, the CubeIDE startup script handles the copy.

Important: no conditional branch in the bit path. Every iteration writes both LATBSET and LATBCLR; one of the two values is guaranteed zero and therefore a no-op at the port. The cycle count is identical for a 0 and a 1, so the bit period does not drift with the data pattern. With if bit then set else clear the two branches would differ by a few cycles and the decoder would have to fight uneven bit periods.

.set noreorder is not cosmetic here. On the first try gcc moved sll pat, pat, 1 after the loop and dropped a nop into the branch delay slot. Result: the same bit emitted sixteen times. .set noreorder forces the assembler to leave the instruction in the delay slot, .set nomacro keeps it from expanding a pseudo instruction into several real ones and shifting the timing.

On ARM® Cortex-M it is a few lines of inline assembly with str to BSRR or ODR. BSRR needs no read-modify-write.

SRAM function because of cache and wait states. Flash code has wait states; cache or prefetch decides the cycle count. First pass waits on flash, second comes from cache, bit period varies. The preamble absorbs that, but it is cleaner to declare the emitter as an SRAM function (.ramfunc in MPLAB X / GCC, __attribute__((section(".RamFunc"))) in STM32CubeIDE) and copy it into SRAM at boot. In the bootloader it runs from flash with prefetch on and cache off, so the wait states stay constant. On a Cortex-A or any other DDR-based system SRAM is no longer enough; there you would fall back to TCM or pin a cache line.

Saleae plus Python

Logic 2 has an automation API, the capture exports as CSV or binary. A short Python script finds the preamble, measures the bit period, reads the next twelve bits, verifies the postamble, and looks the code up in the name table.

PREAMBLE  = 0xA   # 1010
POSTAMBLE = 0x5   # 0101

def decode(edges):
    """edges: list of (t, level) from the Saleae CSV export."""
    i = 0
    while i < len(edges) - 16:
        # derive bit period from the first three preamble transitions
        t0, t1, t2 = edges[i][0], edges[i+1][0], edges[i+2][0]
        bit = ((t1 - t0) + (t2 - t1)) / 2
        # sample 16 bits at the middle of each bit period
        frame = 0
        for k in range(16):
            t_sample = t0 + (k + 0.5) * bit
            frame = (frame << 1) | level_at(edges, t_sample, i)
        if (frame >> 12) == PREAMBLE and (frame & 0xF) == POSTAMBLE:
            code = (frame >> 4) & 0xFF
            name = (BL_LVL if code & 0x80 else PMM_LVL).get(code, "?")
            yield t0, code, bit, name
            i += 16
        else:
            i += 1   # resync, no frame here

The raw capture becomes:

Bootloader:
   t=18.028   0xFF   166 ns/bit   BL_MAIN_ENTERED
   t=18.313   0xFB   166 ns/bit   BL_JUMP_TO_APP
App:
   t=19.091   0x01   125 ns/bit   MAIN_ENTERED
   t=19.091   0x02   125 ns/bit   SYSTEM_INIT
   ...
   t=19.733   0x11   124 ns/bit   ADC_INIT

When an expected frame is missing, you know where the boot got stuck.

Claude™ MCP as the evaluator

Wire the evaluation to an MCP server and Claude can talk directly to the decoder, the capture and the firmware repo through the Model Context Protocol: compare the current trace against a known good trace, name the point where the sequence diverges, open the relevant source file and suggest a hypothesis.

The MCP server for Saleae is still beta, but in practice it already runs reliably. Claude starts and stops the logic analyzer on its own, sets the sample rate, fetches the export. If no decoder is at hand, it just writes the Python script that reads the code out itself. The MIPS assembly of the emitter did not faze it either — read the disassembly, derive the cycles per bit from the code, cross-check the bit period against the trace.

Does not replace the engineer, but shortens the first minutes of a failure analysis considerably.

What does it cost?

On the microcontroller: 70 to 100 bytes of boot flash for the bootloader emitter, around 500 bytes SRAM function for the application. A frame is 2 µs. In most applications that is fine even as a runtime trace — the delay is in the range of a cache latency and disappears against the usual jitter from interrupts and cache misses. On the bench: Saleae Logic Pro, Logic 2 with the automation API, around 350 lines of Python. Much less than a BGA rework or a parallel trace adapter that does not fit anyway.

What stays

The trick itself is old. What pays off is the discipline around it: a stable wire format, a shared code space for bootloader and application, a decoder in the repo, a runner that turns flashing, capture and decode into a single command. Once that infrastructure is in place, every new firmware generation gets the same observability for free. And for the day when code protection is on and nothing else works, it is there.

Sources

Keywords: status LED debug, Saleae Logic Analyzer, PIC32MX bootloader, STM32 debug, bitbang trace, debug without UART, MCP server Saleae, work around code protection, firmware debugging consulting, embedded consulting Switzerland, electronics engineering Zurich

Stuck on a failure analysis where the standard tools no longer reach? We bring creative ideas to the bench when debugger, UART and trace are gone — even when the device only gives you a blinking LED.

Contact us →