Bekannte Fallstricke¶
Probleme, die auf dieser Hardware bzw. mit diesem Framework-Stand schon einmal Zeit gekostet haben -- mit Symptom, Ursache und Regel.
PSRAM und Display-Flackern¶
Das RGB-Panel liest seinen Framebuffer per DMA in Echtzeit direkt aus dem
PSRAM, ohne Bounce-Buffer (fb_in_psram = 1, kein
bounce_buffer_size_px in TRGBArduinoSupport/src/TRGBSuppport.cpp).
Jeder konkurrierende PSRAM-Zugriff kann den DMA kurz aushungern und wird
als Bildverschiebung sichtbar. Zwei unabhängige Ursachen:
1. Häufiger PSRAM-Zugriff aus einem Hot-Path -- Symptom: Pixel
nach rechts verschoben, mit Umbruch, unregelmäßig.
Beispiel: Statistics::timeData.currentMinMax lag im PSRAM und wurde bei
jeder BLE-Sensor-Notification beschrieben → Flackern etwa 1×/s.
Regel: Vor dem Verschieben einer Datenstruktur ins PSRAM ihre
Zugriffshäufigkeit und ihren Auslöser prüfen, nicht nur die Größe.
- OK: groß, aber selten berührt (z. B. Verlaufs-Ring, alle 5-15 s).
- Nicht OK: auch kleine Felder, die aus BLE-Callbacks, pro LVGL-Frame oder
sonst unvorhersehbar oft geschrieben/gelesen werden -- inkl. Arrays, die
LVGL direkt referenziert (lv_chart_set_ext_y_array).
2. PSRAM-Allokation vor trgb.init() -- Symptom: Bild nach links
verschoben, leicht unregelmäßig, mit Pausen bis ~2 s.
TRGBSuppport.cpp legt die beiden 460-KB-LVGL-Draw-Buffer mit einfachem
heap_caps_malloc(..., MALLOC_CAP_SPIRAM) ohne Alignment an. Alles, was
vorher PSRAM belegt, verschiebt deren Lage. Statische Objekte (siehe
Singletons.cpp) werden vor main() konstruiert, also vor trgb.init().
Regel: Nie PSRAM im Konstruktor eines statisch angelegten Objekts
allokieren, egal wie selten die Daten benutzt werden. Stattdessen aus
einem setup(), das nach trgb.init() läuft (Muster:
Statistics::allocPsramBuffers()).
Diagnose: Serial-CLI mem gibt über WebInstr::reportDisplayBuffers()
Adressen und Alignment der Draw-Buffer aus. Den Diagnose-Build vor dem
Fix flashen und messen -- nur den reparierten Stand zu messen zeigt den
guten Zustand und führt zum falschen Schluss.
Offen: Ob der eigentliche Hebel das Alignment ist (dann würde ein Patch auf
heap_caps_aligned_alloc(64, ...) über apply_patches.py die ganze
Klasse erledigen) oder nur die Lage, ist nicht bewiesen.
Starkes Flackern während OTA-Updates ist unabhängig davon eine Eigenschaft
der Hardware: Solange der Flash geschrieben oder gelöscht wird, steht der
Bus, über den das Panel sein Bild aus dem PSRAM liest. UIFacade::otaStart() zeigt deshalb nur einen von unten steigenden Füllstand (vollbreite Zeilen,
ohne Text), damit der Fortschritt erkennbar bleibt. Der Effekt verschwindet dadurch nicht: Am Gerät
gemessen (18.10.2026) verrutscht das Bild weiterhin horizontal und vertikal und fällt während
des Uploads zeitweise schwarz aus. Eine niedrigere Bildwiederholrate hilft auch nicht, der
Pixeltakt liegt schon bei 8 MHz und der Stillstand (~100 ms) übersteigt jeden Puffer. Nicht
probierte Hebel, beide mit ~11 min IDF-Neubau und dem Rollback-Risiko weiter unten:
CONFIG_SPIRAM_XIP_FROM_PSRAM (Code/Konstanten im PSRAM) und CONFIG_SPI_FLASH_AUTO_SUSPEND
(braucht einen Flash-Chip mit Suspend). Dasselbe im Kleinen ist die dritte Ursache:
3. NVS-Schreiben -- Symptom: ein einzelnes kurzes Flackern, regelmäßig (vor 2026-10 alle 45-75 s beim Fahren, 2-3×/10 min im Stand). Siehe den nächsten Abschnitt.
NVS-Schreiben und Display-Flackern¶
Nicht der einzelne Schreibvorgang flackert, sondern das Löschen einer NVS-Seite (Sektor-Erase, ~100 ms gemessen). Das NVS hängt neue Werte nur an; ist die Seite voll, kopiert es die noch gültigen Einträge um und löscht eine Seite. Wie oft das passiert, lässt sich ausrechnen:
- Eine Seite fasst 126 Einträge. Eine Zahl (bis 64 Bit) kostet 1 Eintrag,
ein String oder Blob
2 + Größe/32(aufgerundet). EinputFloat()ist ein Blob, also 3 Einträge. - Pro Löschung werden etwa
126 × (1 − Füllgrad)Einträge frei. Der Füllgrad steht auf/debug/nvs. - Löschungen pro Zeit = geschriebene Einträge pro Zeit / frei werdende Einträge.
Beispiel vor 2026-10: Füllgrad ~70 % → ~36 Einträge pro Löschung (gemessen:
jeder zwölfte putFloat() dauerte ~100 ms länger). Die Statistik schrieb
beim Fahren ~48 Einträge/min → eine Löschung alle ~45 s.
Regeln:
- Nichts periodisch Schnelles ins NVS. Der Hebel ist die Schreibrate, nicht die Partitionsgröße: eine größere Partition senkt nur den Füllgrad und bringt höchstens Faktor 126/36.
- Zusammengehörige Werte als ein Struct-Blob speichern
(
NvsUtil::loadBlob()/saveBlob(),src/NvsUtil.h), nicht als einzelne Schlüssel -- vor allem keine einzelnen Floats. - Unveränderte Werte schreibt das NVS nicht (ESP-IDF vergleicht vor dem Schreiben). Ein eigenes „nur wenn geändert" spart nur den Lesezugriff.
- Die Fahrstatistik (
Statistics::persistNow()) schreibt einen Blob von 8 Einträgen alle 5 min, beim Anhalten und vor dem Ausschalten -- das ist rund eine Löschung in 30-50 min. Wer dort etwas hinzufügt, rechnet nach. - NVS nur aus Tasks mit genug Stack schreiben, nie aus dem UI-Task (siehe
„UI-Task: kaum Stack übrig"). Muster:
Statistics::requestPersist()setzt nur ein Flag, geschrieben wird im nächstencycle().
Vermutlich höchstens 3 BLE-Verbindungen (Verdacht, noch nicht gemessen)¶
Testfahrt 2026-10-04: TrailBridge, Brustgurt und Speed-Sensor waren verbunden, der
Cadence-Sensor (CYCPLUS C3) wurde 7-mal hintereinander gefunden und jedes connect()
scheiterte sofort („Can't connect"); nach einer Minute war er weg (eingeschlafen). Am 2026-09-27
scheiterte die 4. Verbindung genauso, damals der Speed-Sensor. NimBLE hat standardmäßig
CONFIG_BT_NIMBLE_MAX_CONNECTIONS = 3; der BikeComputer will vier Gegenstellen (Nav+GPS+Profil auf
einer, HR, zwei CSC). Den Slot zu löschen ändert nichts, wie das Log zeigt. Zum Prüfen: was
BLEClient::connect() zurückgibt bzw. NimBLE-Fehlercode und Zahl der verbundenen Clients loggen,
wenn ein Connect scheitert. Eine Lösung braucht eine andere sdkconfig (pioarduino
custom_sdkconfig, das die IDF-Bibliotheken neu baut) oder die NimBLE-Arduino-Bibliothek mit
-DCONFIG_BT_NIMBLE_MAX_CONNECTIONS=4 -- jede Verbindung kostet internes RAM. Die Logzeile eines gescheiterten Connects nennt jetzt, wie viele andere Gegenstellen verbunden
waren; Fehlschläge immer bei 3 würden den Verdacht bestätigen. Seit 2026-10-09 bauen alle
Environments über custom_sdkconfig mit CONFIG_BT_NIMBLE_MAX_CONNECTIONS=5 (nächster Abschnitt;
die fünfte ist für einen Leistungsmesser); das läuft auf dem Gerät, mehr als eine Gegenstelle
gleichzeitig ist noch nicht ausprobiert.
custom_sdkconfig (Hybrid-Compile) baut das gemeinsame Framework-Paket um¶
custom_sdkconfig im Abschnitt [env] der platformio.ini (fünf BLE-Verbindungen) lässt pioarduino
die IDF-Bibliotheken selbst kompilieren. Das lädt einmalig ESP-IDF, cmake und ninja nach (rund 1 GB
mehr im Core-Verzeichnis) und dauert etwa 11 Minuten (18 mit den Downloads). Das Ergebnis landet
im gemeinsamen Paket framework-arduinoespressif32-libs des PlatformIO-Core-Verzeichnisses,
markiert durch eine Datei sdkconfig dort. Der passende Hash steht in der ersten Zeile von
sdkconfig.defaults im Projektverzeichnis.
Gemessen am 2026-10-09 (jeweils kompletter Build der Anwendung): ohne custom_sdkconfig 3:16 min,
mit custom_sdkconfig und schon kompilierten Bibliotheken 2:39 bis 3:05 min. Sind die Bibliotheken
einmal da, dauert ein Build also so lange wie vorher. Neu kompiliert werden sie nur, wenn
- sich der Wert von
custom_sdkconfig, der Speichertyp des Boards oder die Plattform-Version ändert, sdkconfig.defaultsfehlt oder nicht passt. Deshalb ist die Datei eingecheckt: Ein frischer Checkout oder Worktree mit der Datei baut in 2:44 min, ohne sie wird das Framework neu installiert und die Bibliotheken werden neu kompiliert;- das Core-Verzeichnis zuletzt von einem Projekt (oder einem älteren Branch von diesem) ohne
custom_sdkconfigbenutzt wurde. So ein Build sieht die Markierung, meldet*** Reinstall Arduino framework ***, löscht beide Framework-Pakete und lädt sie neu; der nächste Build hier kompiliert die Bibliotheken wieder. Deshalb tragen alle Environments denselben Wert (er steht in[env]), und der Wechsel zwischentrgb-esp32-s3undtrgb-esp32-s3-siminstalliert nichts neu.
Eine Neuinstallation löscht Pakete, die ein anderer Build vielleicht gerade liest, und die Plattform
prüft ihre Tool-Pakete (darunter tool-scons) noch einmal aus dem laufenden Build heraus. Zwei
Builds mit demselben Core-Verzeichnis dürfen deshalb nicht gleichzeitig laufen, solange einer davon
das Framework neu installiert. Ein Build, der mit einem fehlenden Modul eines Tool-Pakets abbricht
(ModuleNotFoundError: No module named 'SCons.Tool.FortranCommon' beim Linken, gesehen am
2026-10-08), passt dazu; mit einem Core-Verzeichnis, das nur ein Build benutzt, ließ er sich nicht
nachstellen, weder mit einem frischen noch mit der Kopie einer bestehenden Installation. Für einen
Branch, der noch die Standard-Bibliotheken braucht, ein eigenes Core-Verzeichnis nehmen
(PLATFORMIO_CORE_DIR=...) -- und dazu ein eigenes Build-Verzeichnis (PLATFORMIO_BUILD_DIR=...),
denn PlatformIO leert .pio/build komplett, sobald ein Build mit einem anderen Core-Verzeichnis
läuft, und damit auch die firmware.elf, mit der sich ein Core-Dump der Firmware auf dem Gerät
auflösen lässt.
Ein Hybrid-Build braucht die PSRAM-Boot-Einstellungen ausdrücklich. Die Bibliotheken, die
pioarduino für qio_opi mitliefert, setzen CONFIG_SPIRAM_BOOT_HW_INIT, CONFIG_SPIRAM_BOOT_INIT
und CONFIG_SPIRAM_IGNORE_NOTFOUND; ein Hybrid-Build startet von der allgemeinen
ESP32-S3-Konfiguration, in der sie aus sind. Ohne sie stürzt die Firmware beim Booten ab. Von außen
sah das am 2026-10-09 so aus: Der OTA-Upload endet mit „OK", das Gerät antwortet wieder, aber es
läuft die vorherige Firmware -- Rollback ist aktiv (CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE), ein
Image, das keinen sauberen Start schafft, wird verworfen. Die einzigen Spuren sind „Last reset
PANIC" auf /debug/coredump (ohne neuen Core-Dump) und die alte Version auf dem Settings-Screen.
Deshalb nach jedem OTA eines Builds mit geänderter custom_sdkconfig dort Version und Build-Tags
prüfen (5xBLE I2C: Verbindungsgrenze der Bibliotheken, I2C-Sensoren; STD_BLE sind die
Standard-Bibliotheken, FL die Forumslader-Variante). Was sonst von den Standard-Bibliotheken abweicht, zeigt ein Diff
von esp32s3/qio_opi/include/sdkconfig.h der beiden framework-arduinoespressif32-libs-Pakete;
so belassen: statische statt dynamische WLAN-TX-Puffer, kein TinyUSB.
Gemessen am 2026-10-09 am Gerät (WLAN + TrailBridge mit laufender Navigation, Zeile MEM int=):
Standard-Bibliotheken (drei Verbindungen) 45 KB freier interner Heap, Tiefststand 27 KB, 6745 KB
PSRAM frei; Hybrid-Build mit fünf Verbindungen 46 KB, Tiefststand 35 KB, 6279 KB PSRAM frei. Die
zwei zusätzlichen Verbindungsplätze kosten keinen nennenswerten internen Heap, die
Hybrid-Bibliotheken belegen rund 470 KB mehr PSRAM. Was vier oder fünf verbundene Gegenstellen
kosten, ist noch offen.
Der Bibliotheks-Build hinterlässt außerdem sdkconfig.<env>, managed_components/ (570 MB) und
.dummy/ im Projektverzeichnis (von git ignoriert).
BLE-Stack ist NimBLE, nicht Bluedroid¶
Mit der pioarduino-Plattform (Arduino-ESP32 3.3.x) läuft der BLE-Stack auf
NimBLE. BLEAddress ist eine Kompatibilitätsschicht über beide Stacks und
verhält sich dort asymmetrisch:
getNative()liefert unter NimBLE die Bytes umgekehrt (Bluedroid: Anzeigereihenfolge).BLEAddress(uint8_t[6])kopiert unter NimBLE ebenfalls umgekehrt --getNative()raus / Konstruktor rein ist also kein Roundtrip.equals()/operator==vergleicht zusätzlich den Adresstyp.
Regel (src/BLEDevices.cpp): mit getNative() speichern, beim Laden per
memcpy in getNative() eines default-konstruierten BLEAddress
zurückschreiben; Peers mit dem dateilokalen sameAddress() vergleichen,
nicht mit equals(). Bei Problemen mit Peer-Identität oder
Adressanzeige zuerst hier suchen.
TrailBridge: keine gespeicherte Adresse, und kein Disconnect, auf den Verlass ist¶
Android wirbt unter einer Adresse, die wechselt -- auf diesem Handy bei jedem Neustart der App und nach einem fehlgeschlagenen Verbindungsversuch, nicht nur alle paar Minuten. Der TrailBridge-Slot merkt sich deshalb keine Adresse, auch nicht im RAM (bis 2026-10 tat er das und wies das Handy nach dem ersten Verbindungsabbruch bis zum Neustart ab). Er nimmt das erste gefundene Handy, solange er keine Verbindung hat, und weist jede andere TrailBridge-Adresse ab, solange er eine hat: Das verbundene Handy wirbt weiter, möglicherweise schon unter einer neuen Adresse.
Wird die App beendet oder neu gestartet, hält Android die Verbindung selbst aufrecht; es
kommt kein Disconnect. Die tote Verbindung wird stattdessen am Heartbeat des Protokolls
erkannt: 30 s ohne Nav- oder GPS-Frame beenden sie (BLEDevices::checkNavAlive()), der
nächste Scan verbindet neu. Gemessen 2026-10-02: 46 s vom Neustart der App bis zur neuen
Verbindung.
Eine Characteristic des TrailBridge-Peers lesen (Streckenübersicht)¶
Der Streckenübersicht-Service ist nur lesbar; ein Revisions-Tag in den Nav-Frames sagt, wann
gelesen wird. readValue() blockiert, bis der ganze Wert da ist (ein Long Read mit bis zu 512
Byte, mehrere Round Trips), und der BLE-Host-Task, der die Antwort liefert, ruft auch
onDisconnect() auf. Regeln (BLEDevices::overviewReaderTask()):
- Nie in einem Indicate-Callback lesen (er läuft im Host-Task: Deadlock) und auch nicht im Scan-Task (der schläft zwischen zwei Runden 20 s, eine Revision würde eine halbe Minute zu spät gelesen). Das Lesen hat einen eigenen Task, geweckt vom Nav-Frame.
xNavIoMutexhält das Lesen vom Verbinden/Abonnieren derselben Verbindung fern und vom Löschen des Clients: Der Characteristic-Zeiger gehört zumBLEClient; deshalb setzenonDisconnect()undcheckNavAlive()ihn unter diesem Mutex zurück, bevor sie den Client löschen. Reihenfolge der Locks:xNavIoMutexvorxDevMutex, nie umgekehrt.onDisconnect()nimmtxNavIoMutexnur für den TrailBridge-Client (Zeigervergleich). Bei jedem anderen Peer würde es den Host-Task aufhalten, während ein Read auf genau diesen Task wartet.- Am Gerät nicht probiert: der Long Read über eine echte Verbindung (aus dem Quelltext der
Bibliothek gelesen), und dass der laufende Read vor
onDisconnect()wirklich endet (sonst gilt dort die Zeitgrenze von 1,5 s, und der Client wird trotzdem gelöscht).
Arduino String und c_str()¶
BLE-readValue()/getValue() liefern unter Arduino-ESP32 3.x String.
Ein c_str()-Zeiger ist nur gültig, solange das String-Objekt lebt und
nicht verändert wird. Beim Weiterreichen an LVGL oder über
Task-/Queue-/Callback-Grenzen prüfen, dass niemand den Zeiger behält.
lv_label_set_text() kopiert, lv_label_set_text_static() nicht.
Zwei USB-Ports¶
Das Board hat einen nativen USB-CDC-Port (verschwindet bei Hard-Reset kurz
vom Bus) und einen externen USB-Seriell-Wandler (bleibt enumeriert). Ob ein
Reset stattgefunden hat, daher an Boot-Markern im Log erkennen (z. B.
"👨🏭 Start" aus BLEDevices::scanAndConnectTask()), nicht an
USB-Disconnect-Events.
Beim Öffnen von /dev/ttyACM0 aus eigenen Skripten nicht dtr = False/rts =
False vor open() setzen: pyserial schaltet danach erst DTR ab, und DTR=0 bei RTS=1
löst beim USB-JTAG-Port einen Reset aus ("Reset reason: USB"). Mit den Standardwerten
(beide gesetzt) bleibt das Board an.
UI-Task: kaum Stack übrig¶
Der UI-Task (UIFacade::initDisplay(), 4096 Byte) hat im Betrieb weniger als 1 KB frei
(mem → STACK UI Task). EEZ-Actions und LVGL-Timer laufen in diesem Task. Alles
Schwere dort nicht direkt ausführen, sondern einen eigenen kurzlebigen Task starten:
NVS-Schreiben, SD-Zugriffe, delay(), Rendern eines ganzen Screens (lv_snapshot legt
zusätzlich ~500 Byte Display-Strukturen auf den Stack). Muster: shutdownTask() in
src/ui/RimRidgeSettingsCustFunc.cpp, snapTask() in src/UiDebug.cpp
(mit UIFacade::runLocked()).
Fehlende Zeilen im Debug-Log¶
BCLogger::log() wartet höchstens 100 ms auf den Datei-Mutex und verwirft die Zeile
danach (nur "File Log output blocked" auf printf). Das passiert, wenn gerade ein
SD-Flush oder ein NVS-Schreibvorgang läuft, z. B. beim Speichern der Distanz. Eine
fehlende Logzeile beweist also nicht, dass der Code nicht lief; lieber eine Folgewirkung
prüfen (Reset-Grund, gespeicherter Wert, spätere Zeile).
EEZ Studio / LVGL-Build¶
src/ui_eez/wird bei jedem Export komplett überschrieben -- eigene Logik gehört nachsrc/ui/RimRidge*CustFunc.*.- EEZ-generierter Code inkludiert
<lvgl/lvgl.h>; der Shiminclude/lvgl/lvgl.hleitet auf<lvgl.h>um. - Bei der Umrechnung von SquareLine-Layouts gilt: Positionen von Kindern eines Flex-Containers sind bedeutungslos, LVGL rechnet sie zur Laufzeit.
LVGL-Widgets auf EEZ-Screens¶
Alle Punkte hier scheitern still: kein Build-Fehler, nur falsches Verhalten auf dem Gerät.
- Tastatur (
lv_keyboard) mit den RimRidge-Fonts: Diese Fonts haben keineLV_SYMBOL_*-Glyphen (Backspace, OK), die Tasten nutzen daher die eingebauteMONTSERRAT_22. Das Standardlayout hat 12 Tasten pro Reihe (hier 30 px);RimRidgeWifiCustFunc.cppsetzt eigene Maps und einen eigenen Tasten-Handler, der EEZ-Canvas zeigt deshalb das LVGL-Standardlayout, das Gerät das QWERTZ-Layout. - Gesten kommen nicht an. Zwei Bedingungen, beide nötig:
SCROLLABLEauf dem Screen-Root und auf dem Container löschen. EEZ lässt es auf Page-Roots standardmäßig an; ein scrollbarer Vorfahre unterdrückt die Gesten-Erkennung für den ganzen Druck.GESTURE_BUBBLEauf dem Container mit dem Handler löschen (Kinder dürfen es behalten). LVGL reicht die Geste an den ersten Vorfahren ohne dieses Flag weiter -- sonst landet sie am Screen-Root. Handler direkt auf einem Screen-Root brauchen das nicht.- Große Arcs/Slider schlucken Touches. Nur-Anzeige-Arcs (Speed-,
Distanz-Ring) brauchen
clickableFlag: false, sonst beanspruchen sie jeden Druck für ihr eigenes Ziehen, und Gesten/Klicks darunter feuern nie. zoom/anglewirkt nicht auf Bilder im FormatINDEXED_*oderALPHA_1/2/4BIT(LVGL 8.4 transformiert nur Formate, die der Decoder komplett dekodiert:TRUE_COLOR*,ALPHA_8BIT,RGB565A8). Betrifft die alten Nav-Icons insrc/ui/img/ui_img_nav_*allimages.c(1 bit) -- diese nur in nativer Größe verwenden. Umrechnen aufALPHA_8BITkostet ~4 KB pro 64-px-Icon; Flash ist knapp.- Icons rendern schwarz ohne
img_recolorimlocalStyles, weil die RimRidge-Icons reine Alpha-Masken sind. Bei jedem neuen Icon-Widget prüfen. Ebenso: Das Platzhalter-Bitmap im Canvas sollte ungefähr die native Auflösung des Bitmaps haben, das zur Laufzeit eingesetzt wird --zoomskaliert relativ dazu. - Platzhalter bleibt sichtbar. Widgets, die zur Laufzeit per
lv_img_set_src()befüllt werden, starten mit dem sichtbaren EEZ-Platzhalter. Wenn die C-Logik nur auf Übergänge reagiert (static bool shown), beim ersten Aufruf einmal explizit in den richtigen Anfangszustand zwingen. LV_LABEL_LONG_DOTbricht erst um. Es trennt den Text an einem Wort und setzt Punkte in die letzte Zeile, die passt: Eine einzeilige Zeile mit langem Namen zeigt „Kreuzung Alte...", obwohl mehr hineinpasste. Braucht außerdem eine feste Höhe, sonst bricht das Label einfach um. Für eine Zeile den Text selbst kürzen (fitText()insrc/ui/RimRidgeRouteCustFunc.cpp) undLV_LABEL_LONG_CLIPnehmen.- Geometrie zur Laufzeit. Grundregel: Position/Größe gehören ins
.eez-project, nicht in C. Bewusste Ausnahme: der "geschobene" Zustand vonrr_nav_pillbei sichtbarer Spur-Anzeige (EEZ kann keine zustandsabhängige Geometrie ausdrücken). Die Ruheposition im Canvas bleibt maßgeblich.
xUIDrawMutex und EEZ-Actions¶
EEZ-Action-Callbacks laufen synchron in lv_timer_handler(), und das ruft
UIFacade::updateHandler() bei gehaltenem xUIDrawMutex auf. Der Mutex
ist nicht rekursiv; ein zweites xSemaphoreTake aus demselben Task läuft
in den Timeout. Jede UIFacade-Methode, die eine Action erreichen kann,
braucht das Muster
bool uiTask = isDrawTask(); if (uiTask || xSemaphoreTake(...)).
LVGL nur unter xUIDrawMutex -- auch im UI-Task selbst¶
- Symptom: endlose Zeilen
lcd_panel: esp_lcd_panel_draw_bitmap(35): start position must be smaller than end positionauf der seriellen Konsole, danach „Task watchdog … async_tcp", laufendUI Task(Core-Dump 2026-10-02 00:22). - Ursache: Zwei Tasks tragen gleichzeitig eine Invalid-Fläche ein (
_lv_inv_area()), übrig bleibt eine aus beiden gemischte mity2 < y1. Daran läuftrefr_area()in LVGL 8.4 in einer Schleife mit negativer Zeilenzahl; jeder Durchlauf ruft den Display-Treiber mit einer ungültigen Fläche. - Der Fast- und der Slow-Block in
UIFacade::updateHandler()liefen ohne Mutex, weil sie im UI-Task laufen. Das schützt aber nur vorlv_timer_handler(), nicht vor dem BLE-Task (Nav-Frames) und demesp_timer-Task (Fahrzustand), die unter dem Mutex zeichnen. Aufgefallen ist es erst mit der TrailBridge-Testfahrt: Nav-Frame und simulierte Geschwindigkeit kommen beide im Sekundentakt. - Regel: jeder LVGL-Aufruf außerhalb von
lv_timer_handler()braucht den Mutex, auch im UI-Task. Der Core-Dump enthält nur Stacks; die Fläche steht insub_areaim Frame vonrefr_area.
Struct-Layouts: time_t ist 8 Byte¶
Auf dieser Toolchain ist time_t 8 Byte, nicht 4 -- relevant für
BCLogger::LogData und alles, was binär geschrieben und in Tools/
gelesen wird. Echte Offsets ermitteln statt zählen: absichtlich falsches
static_assert(offsetof(T, feld) == 999, "x") -- GCC meldet den echten
Wert in "the comparison reduces to ...".
WLAN: Geheimnisse, Hotspot-Speicher, Suchen¶
- Passwörter nur in POST-Bodies und nie in einer Logzeile.
WebInstrund die Konsole schreiben die URL bzw. die Befehlszeile auf die SD-Karte, die der Log-Dienst hochlädt./wifi/addund/wifi/apnehmen deshalb POST-Bodies, undSerialConsole::run()loggtwifi add|apsetohne Argumente. Alles Neue, das ein Geheimnis verarbeitet, braucht dasselbe. - Der Hotspot kostet ~14 KB internen Heap (AP + STA, DNS-Task, Clients). Im Leerlauf
bleiben ~26 KB; mit einem angeschlossenen Handy drückte
/debug/ui/snap(Screenshot über HTTP) das Minimum auf 3,8 KB (2026-10-03). Keine Screenshots über den Hotspot; normale Seiten sind unkritisch. WifiWebserver::cfgMutexnicht halten, währendui.*aufgerufen wird: Der UI-Task nimmt erstxUIDrawMutexund fragt dannwebservernach dem Status; die umgekehrte Reihenfolge verklemmt.- Eine Suche dauert ~10 s, solange BLE sucht (geteiltes Funkmodul), und lässt sich nicht starten, solange ein Verbindungsversuch läuft -- die automatische Verbindung nimmt dann das letzte Ergebnis.
- Android nutzt für den Browser weiter das Mobilfunknetz, wenn das WLAN kein Internet hat und
sich nicht als Captive Portal meldet. DNS + Umleitung des Hotspots (
startCaptiveDns(), Not-found-Handler) machenhttp://192.168.4.1/wifivom Handy aus erreichbar.
Serielle Konsole¶
src/SerialConsole.* hält die Eingabezeile (Prompt, halb getippter Befehl) als letzte
Bildschirmzeile. Zwei Regeln:
- Serial-Ausgaben aus anderen Tasks gehen über
bclogoder werden inSerialConsole::Output out(console);eingeschlossen -- sonst landen sie mitten in der Eingabezeile. Befehls-Callbacks laufen im Loop-Task ohne sichtbaren Prompt und dürfen direktSerial.printbenutzen, sollten aber mit Zeilenumbruch enden. - Keine ANSI-Escape-Sequenzen und kein
\aausgeben. Der Standardfilter von miniterm undpio device monitorzeigt ESC und die meisten Steuerzeichen als Symbol (␛[K). Die Konsole zeichnet deshalb nur mit\r,\bund Leerzeichen.
Befehle mit console.addCmd() registrieren, nicht mit cli.addCmd(), sonst gibt
es keine Tab-Completion (SimpleCLI bietet keine Liste der Befehle an).
Webserver: Regex-Routen und Stack-Größe¶
ASYNCWEBSERVER_REGEX bleibt bewusst aus: AsyncCallbackWebHandler::canHandle()
baut für jede Regex-Route bei jedem Request ein std::regex auf dem
internen Heap. Routen als Präfix-Routen anlegen (siehe
src/WifiWebserver.cpp).
CONFIG_ASYNC_TCP_STACK_SIZE ist auf gemessene Auslastung plus Reserve
eingestellt (Details im Kommentar in platformio.ini). Nach Änderungen an
Web-Handlern oder OTA neu messen (mem auf der Serial-CLI), bevor der
Wert weiter gesenkt wird.
Interner Heap ist das knappe Gut¶
PSRAM ist reichlich da, aber alles unter CONFIG_SPIRAM_MALLOC_ALWAYSINTERNAL
(4 KB) sowie Task-Stacks, FreeRTOS-Queues/-Puffer, offene SD-Dateien (~4 KB
je Datei), WLAN, BLE-Verbindungen und jede TCP-Verbindung des Webservers
kommen aus dem internen Heap. Gemessen 2026-09-26 (mem, Zeile MEM int=):
nach dem Boot mit WLAN + TrailBridge + HR ca. 50–64 KB frei; 8 parallele
HTTP-Requests ziehen davon ~45–60 KB ab. Läuft der interne Heap leer,
hängen Webserver/mDNS, und das Gerät kann abstürzen.
Regel: Jeden neuen dauerhaften Puffer/Stack/offenen File im internen RAM mit
mem vorher/nachher messen (Leerlauf und unter Web-Last). Dateien, die selten
geschrieben werden, nicht die ganze Sitzung offen halten. Die Middleware in
WifiWebserver.cpp lehnt Requests unter 20 KB freiem internem Heap mit 503
ab -- das schützt aber erst nach dem Annehmen der Verbindung.
TLS (Log-Push) passt nicht in den internen Heap. mbedTLS braucht pro Verbindung zwei
16-KB-Record-Puffer plus den Handshake-Zustand, und dieser Build legt alles intern an
(CONFIG_MBEDTLS_INTERNAL_MEM_ALLOC). LogPush stellt mbedTLS deshalb für die Dauer eines Laufs
mit mbedtls_platform_set_calloc_free() auf PSRAM um und danach zurück -- zur Laufzeit, nicht in
custom_sdkconfig, also ohne IDF-Neubau. Der Task-Stack (9 KB) liegt ebenfalls im PSRAM (siehe
unten) und existiert nur während eines Laufs; ein Lauf startet nur mit 28 KB frei und hört zwischen
zwei Dateien unter 24 KB auf. Am Ende loggt der Task seine Stack-Reserve (Log push task: ... byte stack left,
Debug-Level). Alles andere, was HTTPS will, braucht dieselbe Umstellung.
Task-Stacks im PSRAM (xTaskCreateWithCaps(..., MALLOC_CAP_SPIRAM), löschen mit
vTaskDeleteWithCaps(); der TCB bleibt intern): BLEOverview und LogPush seit 2026-10-24. Nur
erlaubt für einen Task, der nie in den internen Flash schreibt -- NVS, OTA und LittleFS schalten
den Cache ab, und IDF bricht ab, wenn der laufende Task seinen Stack im PSRAM hat -- und der nicht
auf einem heißen Pfad liegt (Display-Flackern, erster Abschnitt). Deshalb nicht: FlusherTask
(NVS-Sitzungszähler in startSession()), ImuTask (400 Hz), BLEScanConnect (speichert Adressen
im NVS), UI- und Loop-Task.
IRAM ist auch interner RAM. Auf dem S3 wird IRAM, in dem kein Code liegt, zu Heap (dort liegen
104 KB Code). CONFIG_BT_CTRL_RUN_IN_FLASH_ONLY (BLE-Controller, ~20 KB) funktioniert mit
pioarduino nicht: IDF linkt dafür libbtdm_app_flash.a, die Arduino-Linkzeile hat
-lbtdm_app fest eingetragen, und der Controller verweigert den Start („Controller lib error,
need flash lib“, esp_bt_controller_init -11). Dann schlägt BLEDevice::init() fehl,
getScan() liefert null, und BLEDevices::startBLEScan() stürzte ab -- eine endlose
Boot-Schleife nach dem OTA vom 24.10.2026, noch vor dem WLAN, also half nur USB. Seitdem prüft
BLEDevices::setup() BLEDevice::getInitialized() und läuft ohne BLE weiter.
CONFIG_HEAP_PLACE_FUNCTION_INTO_FLASH (~6,5 KB) ist gesetzt; Voraussetzung ist, dass keine
IRAM-ISR bei abgeschaltetem Cache Speicher holt oder freigibt.
OTA-Rollback: Arduino markiert ein neues Image am Anfang von initArduino() als gültig, wenn
verifyRollbackLater() nicht true liefert. src/main.cpp überschreibt die Funktion deshalb
(C-Linkage) und bestätigt das Image erst nach 60 s ohne Reset (Logzeile OTA: new firmware
confirmed); ein Absturz davor lässt den Bootloader zur vorigen Firmware zurückkehren.
Was eine BLE-Gegenstelle kostet (aus den Logs vom 23./24.10.2026, WLAN an): ohne Gegenstelle ~42 KB frei, TrailBridge -7,5 KB, Pulsgurt -7,5 KB, ein CSC-Sensor -11 KB. Die Arduino-BLE-Schicht legt für jeden Service der Gegenstelle und jede Characteristic eines benutzten Services ein Objekt an, jedes mit bis zu drei FreeRTOS-Semaphoren (immer intern), Map-Knoten und UUID-Strings.
SD-Karte: offene Dateien nicht löschen oder umbenennen¶
CONFIG_FATFS_FS_LOCK ist 0: FATFS verhindert nicht, dass eine Datei gelöscht
oder umbenannt wird, die ein anderer Task noch offen hat -- das Ergebnis ist ein
beschädigtes Dateisystem, kein Fehlercode. Die Dateien der laufenden Sitzung in
/BIKECOMP/CUR/ sind die ganze Fahrt offen. BCLogger::deleteFile() und der
Cleanup lassen sie deshalb aus (isActiveSessionFile()), ebenso alles in CUR/,
solange der Finalizer (LogSessions) läuft. Neue Wege, Dateien zu löschen oder
zu verschieben, brauchen dieselbe Prüfung.
I²C: Wire nur unter I2CBus::Guard¶
Touch-Controller (UI-Task), BME280 (esp_timer-Task und der Task, der die
Radumdrehungen liefert) und BMI160 (ImuTask) teilen sich Wire. Die eigene Sperre von
TwoWire deckt nur die Übertragung auf dem Bus ab; der Empfangspuffer wird gelesen,
nachdem sie freigegeben ist -- von available()/read() und vom Rückgabewert von
requestFrom(). Startet ein anderer Task dazwischen eine Übertragung, bekommt der
erste die Bytes des anderen Geräts oder eine falsche Länge. Steht der Lese-Index erst
einmal hinter der Länge, bleibt available() wahr, während read() nichts liefert:
while (Wire.available()) Wire.read(); in der SparkFun-BME280-Bibliothek endet dann
nie. Im esp_timer-Task war das ein Task-Watchdog-Reset (Core-Dump vom 2026-10-02), im
ImuTask zeigt es sich als „short read"-Fehler.
Regel: einen I2CBus::Guard (src/I2CBus.h) vom Beginn einer Übertragung halten, bis
ihre Bytes aus Wire abgeholt sind; Bibliotheksaufrufe als Ganzes einschließen. Der
Touch-Callback aus TRGBArduinoSupport bekommt die Sperre über I2CBus::guardTouch().
Ein neues I²C-Gerät oder ein neuer Bibliotheksaufruf braucht dasselbe.
BMI160: Register-Reads nie ungeprüft¶
BMI160Gen::serial_buffer_transfer() prüft nicht, ob requestFrom() alle
Bytes geliefert hat, und lässt bei einem kurzen Read alte Pufferbytes stehen.
Beim FIFO-Füllstand (2 Byte) ergab das sporadisch Werte von ~80 statt ~2
Frames; der leere FIFO liefert dann 0x8000-Frames (-16 g auf allen Achsen),
die als 31-g-Stöße erkannt wurden. Regel: FIFO-Zähler und -Daten über
I2CSensors::imuRead() lesen (Repeated Start, Längenprüfung) und
0x8000/0x8000/0x8000-Frames verwerfen -- beides ist dort umgesetzt, die
Zähler stehen auf /debug/imu.
Abstürze ohne USB: Neustart-Grund und Core-Dump¶
- Beim Boot steht im Debug-Log
Reset reason: …. Bei PANIC oder einem Watchdog liegt ein Core-Dump in der Partitioncoredump. Jeder Absturz überschreibt den vorigen. - Anzeige unter
/debug/coredump: Task, PC, Backtrace und ob der Dump zur laufenden Firmware passt. - Der Dump wird nur auf Anfrage gelesen, nie beim Boot. Ein früherer
Kopierversuch beim Boot hat eine Endlosschleife aus Abstürzen ausgelöst
(TODO in
BCLogger::setup()). Ohne USB wäre das Gerät dann nicht mehr erreichbar. - Auflösen geht nur mit der ELF genau der abgestürzten Firmware. Jeder Build
wird automatisch (
archive_firmware.py) unterfirmware-archive/builds/<id>/abgelegt;<id>ist die „Firmware ELF“ auf/debug/coredump.Tools/fwarchive.sh <id>entpackt die ELF und gibt den Pfad aus (ohne Argument: Liste der Builds). Befehl:curl -o coredump.bin http://<ip>/debug/coredump.elf esp-coredump info_corefile -t raw \ --gdb ~/.platformio/packages/tool-xtensa-esp-elf-gdb/bin/xtensa-esp32s3-elf-gdb \ -c coredump.bin firmware.elf-t raw, weil die Partition vor der ELF noch einen Kopf hat. - Bei „Task watchdog … IDLE0“ ist der Task mit dem Absturz meist nur der, der gerade lief.
Wichtig ist die Thread-Liste: Wer steht nicht in einer Wartefunktion
(
0x400559e0 in ??= blockiert)?
Nie vTaskDelay(0) in einer Task-Schleife mit hoher Priorität¶
vTaskDelay(0)gibt nur an Tasks gleicher oder höherer Priorität ab, nie an IDLE0.- Der UI-Task (Priorität 20) hat mit
vTaskDelay(next_ms)so lange gerechnet, wie LVGL0zurückgab (Rendering kam nicht hinterher, viele Updates nach BLE-Reconnects). Nach 5 s hat der Task-Watchdog das Gerät neu gestartet (Core-Dump 2026-09-27). - Jetzt gilt ein Minimum von 2 ms in
UIFacade::updateHandler(). Dasselbe gilt für jede eigene Schleife: immer mindestens 1 Tick warten.