Die Status LED als Debugkanal: bitbang statt Trace, wenn nichts anderes mehr geht

Embedded Systems Projekte

Code Protection an. UART belegt. Debug Pins nicht herausgeführt. Trace Pads unter dem BGA.

Mehr als eine blinkende Status LED gibt das Gerät dann nicht her. Und genau diese eine LED reicht aus, wenn man sie als seriellen Kanal benutzt.

Wenn die üblichen Debugkanäle weg sind

Typische Konstellationen, in denen klassisches Debuggen scheitert:

Was bleibt, ist die Status LED. An einem ganz normalen GPIO. Ein dünner Draht ist schnell an die LED gelötet, das andere Ende geht direkt auf den Logic Analyzer.

Die LED ist ein 1 Bit Port

Ein GPIO einer 48 MHz CPU schaltet in unter 100 ns. Ein Saleae Logic Pro tastet mit 500 MS/s, also 2 ns pro Sample. Die Auflösung passt. An jedem interessanten Punkt der Firmware schiebt man einen kurzen Bitrahmen auf die LED:

Bits 15..12  Präambel    0b1010   (= 0xA)
Bits 11..4   Payload     8 Bit
Bits  3..0   Postambel   0b0101   (= 0x5)

Die Präambel toggelt dreimal, der Decoder verriegelt sich darauf und misst die Bitdauer. Die Postambel ist die Quittung, dass es ein echter Rahmen war. Bei jedem Init Schritt geht ein Rahmen raus: 0x01 für “main betreten”, 0x0F für “Modbus initialisiert”, und so weiter. Hängt der Boot bei Schritt 14 statt bei 17, sieht man auf einen Blick, welches Subsystem nicht gestartet hat.

Ein Format für Bootloader und Anwendung

Bootloader und Anwendung teilen sich denselben Rahmen. Der 8 Bit Code Namensraum hat 256 Werte, das reicht für eine kleine Firmware locker. Anwendung zählt von 0x01 aufwärts, Bootloader von 0xFF abwärts. Wo sie sich treffen, hängt von der Anzahl der tatsächlich verwendeten Codes ab. Der Bootloader braucht sicher weniger als die Anwendung.

Der Bootloader Sender ist eine Schleife in Assembler mit fester Zykluszahl pro Bit, unter 100 Byte. Die Anwendung entrollt die 16 Bit voll, rund 125 ns pro Bit. Der Decoder synchronisiert sich auf die Präambel und misst pro Rahmen die Bitdauer neu. Damit sind ihm unterschiedliche Taktraten zwischen Bootloader und Anwendung egal, ebenso CPU Drift oder ein Wechsel des Systemtakts zur Laufzeit. Beide Sender lassen sich im selben Trace lesen.

Der vollständige Bootloader Sender (MIPS, PIC32MX®, 8 Zyklen pro Bit):

// bl_pulse.c, läuft aus Boot Flash, Cache aus, Prefetch an
#include <xc.h>

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

__attribute__((noinline))
void BL_Pulse(uint8_t code)
{
    // 16 Bit Rahmen: 0xA Präambel | 8 Bit Code | 0x5 Postambel
    // links bündig im 32 Bit Register, MSB wird in der Schleife rausgeschoben
    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"   // Delay Slots selbst belegen
        ".set nomacro                 \n"
    "1:                               \n"
        "srl   %[ta], %[pat], 15      \n"   // ta = MSB von pat
        "sll   %[ta], %[ta], 14       \n"   // ta = LED_BIT  oder  0
        "xor   %[tb], %[ta], %[mask]  \n"   // tb = Komplement
        "sw    %[ta], 0(%[set])       \n"   // LATBSET = ta  (einer davon ist 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: pat fürs nächste Bit
        ".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"
    );
}

Der entsprechende Anwendungssender liegt im SRAM, damit jeder Zyklus ohne Flash Wait State läuft (bei einem grösseren SoC mit DDR sieht die Welt wieder anders aus, da hilft nur ein TCM oder gezielt gelocktes Cache Line):

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

#define LED_BIT  (1u << 14)

__attribute__((section(".ramfunc"), long_call, noinline))
void PMM_Pulse(uint8_t code)
{
    // gleicher Rahmenaufbau, aber Schleife voll entrollt
    // (16 Kopien des Set/Clear/Shift Blocks, rund 125 ns pro Bit)
    ...
}

Beim Boot kopiert der Linker .ramfunc von der LMA ins SRAM. Auf STM32® macht ein __attribute__((section(".RamFunc"))) dasselbe, das CubeIDE Startup Skript kümmert sich um die Kopie.

Wichtig: kein bedingter Sprung im Bitpfad. Die Schleife schreibt in jeder Iteration sowohl LATBSET als auch LATBCLR, einer der beiden Werte ist garantiert null und damit ein No-Op am Port. Die Zykluszahl ist für eine 0 und eine 1 identisch, die Bitdauer driftet nicht mit dem Datenmuster. Würde man stattdessen mit if bit then set else clear arbeiten, wäre jeder Bitwechsel ein paar Zyklen anders lang und der Decoder müsste mit unregelmässiger Bitdauer kämpfen.

.set noreorder ist hier nicht Kosmetik. Beim ersten Versuch hat gcc den sll pat, pat, 1 hinter die Schleife gezogen und einen nop in den Branch Delay Slot gelegt. Resultat: dasselbe Bit sechzehnmal auf der LED. .set noreorder zwingt den Assembler, die Instruktion im Delay Slot stehen zu lassen, .set nomacro verhindert dass er aus einer Pseudo Instruktion mehrere Befehle macht und das Timing verschiebt.

Auf ARM® Cortex-M sind es ein paar Zeilen Inline Assembler mit str auf BSRR oder ODR. BSRR braucht kein Read Modify Write.

SRAM Funktion wegen Cache und Wait States. Code aus Flash hat Wait States, der Cache oder Prefetch Buffer entscheidet über die Zykluszahl. Erster Durchlauf wartet auf den Flash, zweiter kommt aus dem Cache, die Bitdauer schwankt. Die Präambel fängt das ab, sauber ist trotzdem SRAM Funktion (.ramfunc bei MPLAB X / GCC, __attribute__((section(".RamFunc"))) bei STM32CubeIDE), beim Boot ins SRAM kopiert. Im Bootloader läuft er aus dem Flash mit Prefetch an und Cache aus, dann sind die Wait States konstant. Bei einem Cortex-A oder anderen DDR basierten Systemen reicht SRAM nicht mehr, dort müsste man auf ein TCM zurückgreifen oder gezielt eine Cache Line locken.

Saleae plus Python

Logic 2 hat eine Automation API, der Capture exportiert als CSV oder binär. Ein kurzes Python Script findet die Präambel, misst die Bitdauer, liest die zwölf folgenden Bit, prüft die Postambel und schlägt den Code in der Namenstabelle nach.

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

def decode(edges):
    """edges: Liste (t, level) aus dem Saleae CSV Export."""
    i = 0
    while i < len(edges) - 16:
        # Bitdauer aus den ersten drei Wechseln der Präambel ableiten
        t0, t1, t2 = edges[i][0], edges[i+1][0], edges[i+2][0]
        bit = ((t1 - t0) + (t2 - t1)) / 2
        # 16 Bit in der Bitmitte abtasten
        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, kein Rahmen

Aus dem Rohcapture wird:

Bootloader:
   t=18.028   0xFF   166 ns/bit   BL_MAIN_ENTERED
   t=18.313   0xFB   166 ns/bit   BL_JUMP_TO_APP
Anwendung:
   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

Fehlt ein erwarteter Rahmen, weiss man, wo der Boot hängengeblieben ist.

Claude™ MCP als Auswerter

Hängt man die Auswertung an einen MCP Server, kann Claude über das Model Context Protocol direkt mit Decoder, Capture und Firmware Repo sprechen: aktuellen Trace gegen einen Good Trace vergleichen, die Stelle benennen, an der die Sequenz abweicht, die zugehörige Quelldatei öffnen und eine Hypothese vorschlagen.

Der MCP Server für Saleae ist noch Beta, läuft in der Praxis aber bereits sehr stabil. Claude startet und stoppt den Logic Analyzer selbständig, setzt die Sample Rate, holt sich den Export. Liegt kein passender Decoder bereit, schreibt er sich das Python Script zum Auslesen des Codes kurzerhand selbst. Auch mit dem MIPS Assembler des Senders hatte er keine Mühe: Disassemblat lesen, Zykluszahl pro Bit aus dem Code ableiten, Bitdauer gegen den Trace gegenprüfen.

Ersetzt keinen Engineer, verkürzt aber die ersten Minuten einer Fehlersuche erheblich.

Was kostet das?

Auf dem Mikrocontroller: 70 bis 100 Byte Boot Flash für den Bootloader Sender, rund 500 Byte SRAM Funktion für die Anwendung. Ein Rahmen ist 2 µs. In den meisten Anwendungen ist das auch als Laufzeit Trace unkritisch, die Verzögerung liegt im Bereich einer Cache Latenz und fällt gegenüber dem üblichen Jitter durch Interrupts und Cache Misses kaum auf. Auf dem Tisch: Saleae Logic Pro, Logic 2 mit Automation API, etwa 350 Zeilen Python. Deutlich billiger als ein BGA Rework oder ein Trace Adapter, der ohnehin nicht passt.

Was übrig bleibt

Der Trick selbst ist alt. Was sich lohnt, ist die Disziplin drumherum: stabiles Rahmenformat, gemeinsamer Codebereich für Bootloader und Anwendung, Decoder im Repo, ein Runner der Flashen, Capture und Decodierung in einem Aufruf erledigt. Steht die Infrastruktur einmal, bekommt jede neue Firmware Generation dieselbe Beobachtbarkeit umsonst. Und für den Tag, an dem CP an ist und nichts anderes mehr funktioniert, ist sie da.

Quellen

Schlüsselwörter: Status LED Debug, Saleae Logic Analyzer, PIC32MX Bootloader, STM32 Debug, Bitbang Trace, Debug ohne UART, MCP Server Saleae, Code Protection umgehen, Firmware Debugging Beratung, Embedded Consulting Schweiz, Elektronikentwicklung Zürich

Festgefahren bei einer Fehlersuche, wo die Standardwerkzeuge nicht mehr greifen? Wir bringen kreative Ideen mit, wenn Debugger, UART und Trace ausfallen, auch dann, wenn das Gerät nur noch eine blinkende LED hergibt.

Kontaktieren Sie uns →