The status LED as a debug channel: bit-bang when nothing else is left
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:
- Code protection on — no debugger, no memory access.
- No spare GPIO for trace: cut during layout.
- BGA hides the pads — the probe cannot reach.
- UART is payload — Modbus or an application protocol owns it.
- No logger in the boot path — a few kilobytes of boot flash, no room for a log framework.
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
- Model Context Protocol
- Saleae Logic 2 Automation API
- Microchip MPLAB X —
.ramfuncattribute and LMA copy
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.