STM32 Source-Code über den STLink mit VS Code / Platform IO debuggen
Hier nochmal eine kurze Einleitung dazu, was Debugging eigentlich ist und welchen Zweck es erfüllt:Selten kompiliert und läuft ein Programm gleich so, wie man es will. Auf die meisten Fehler macht einen der Compiler aufmerksam, doch der kann logische und Denkfehler nicht finden. Bisher haben wir uns dann immer damit beholfen, mit Serial.print() Variablen auszugeben und im Serial Monitor dessen Inhalte anzuzeigen, um zu gucken, ob sie auch den erwarteten Wert haben.
Bei der Typisierung von C kann man schon mal einen Fehler machen und z. B. eine Variable zu klein machen (z. B. int8_t statt int16_t oder int32_t). Beim Durchsehen des Codes übersieht man dann wieder und wieder, dass die Variable nicht breit genug ist und ein Überlauf auftritt. Erst bei der Ausgabe bemerkt man, dass etwa keien Werte über 255 ausgegeben werden, obwohl dem so sein sollte.
Mit einem Debugger kann ich ein Programm jederzeit anhalten und mir die Variablenwerte anzeigen lassen. Damit kann ich mir die vielen Serial.print-Zeilen sparen, die ich ständig ein- und ausbaue.
Ein weiterer Vorteil eines Debuggers ist, dass ich Befehle schrittweise ausführen kann oder den Debugger an bestimmten Stellen anhalten kann. Stürzt mein Programm z. B. immer wieder ab, weil ich z. B. einen Pointer nicht richtig gesetzt habe und etwa bei NULL und nicht im gewollten Speicherbereich rummache, dann kann ich dort einen Breakpoint setzen, wo ich den Fehler vermute und Schritt für Schritt durch den Source gehen. Bei dem Schritt, an dem dann der Absturz ist, ist dann der Fehler, den ich dann korrigieren kann.
Dieses Debuggen ist aber nur möglich, wenn ich a) den Source-Code habe und b) VS Code mit Platform IO (oder eine andere professionelle Entwicklungsumgebung) benutze. Die Arduino IDE kann nicht mit einem Debugger aufwarten.
Fast jeder Mikrocontroller lässt sich über das JTAG (Joint Test Action Group)-Interface (oder ein ähnliches) debuggen. Man kann damit auch fremden Geräten auf die Finger schauen. Viele haben sogar die Pins für das JTAG-Interface irgendwo auf der Platine herausgeführt, etwa WLAN-Steckdosen.
Das Debuggen ohne Source-Code ist allerdings eine zeitraubende und mühselige Angelegenheit und man sollte schon ein wenig Assembler können.
Nachdem wir uns das letzte Mal mit dem Debuggen von Source-Code mit OpenOCD beschäftigt haben, geht es heute um das Debuggen von selbst entwickelten Programmen in Visual Studio Code mit Platform IO. Das gestaltet sich doch wesentlich einfacher, weil wir uns nicht mit Assembler herumschlagen müssen und den Source-Code - von dem wir schließlich wissen, was er tun soll - vor uns haben.
Als Beispiel dient uns wieder der Source-Code aus dem letzten Teil:
Demo-Sourcecode zum Debuggen
#include <Arduino.h>
const char *username = "wichtigerUser";
const char *password = "geheimesPasswort";
const char PROGMEM *username2 = "wichtigerUser2";
const char PROGMEM *password2 = "geheimesPasswort2";
void einloggen (const char *user, const char *pass) {
char url [300];
snprintf (url, 300, "https://admin.cool1.de/administration/login.php?user=%s&pass=%s", user, pass);
// z. B. URL aufrufen zum Login
}
void setup() {
pinMode(LED_BUILTIN, OUTPUT);
einloggen (username, password);
einloggen (username2, password2);
}
void loop() {
int state=0;
while (1) {
state = digitalRead(LED_BUILTIN);
digitalWrite(LED_BUILTIN, !state);
delay(250);
}
}
In diesem kleinen Source, der sich sowohl in der Arduino IDE als auch in VS Code/PIO kompilieren und hochladen lässt, habe ich zur Demonstration einmal ein paar Passwörter und Anmeldedaten hinterlegt, von denen wir mal ausgehen, dass sie möglichst niemand wissen soll. Das können SSID und Passwort für das WLAN sein oder die Zugangsdaten zu einem Administrationsinterface im Internet.Das letzte Mal haben wir ja herausgefunden, dass es ein Leichtes ist, diese Daten wieder aufzuspüren, falls einem der Mikrocontroller in die Hände fällt, auch wenn man den Source-Code nicht hat.
Es kommt wieder eine STM32 Blue Pill zum Einsatz, weil die sich leicht mit dem STLink debuggen lässt. Im Loop-Teil des Sources ist nur ein einfaches Programm mit einer Variable, die die eingebaute LED blinken lässt und die wir dann in VS Code debuggen und nachverfolgen wollen.
Zum Debuggen benutzen wir diesmal die VS Code mit Platform IO, die beim Debuggen übrigens auch auf OpenOCD aufsetzt. Wie man OpenOCD direkt benutzt, dazu mehr hier.
Anpassung der platformio.ini
Normalerweise sollte VSC/PIO (Visual Code / Platform IO) beim Kompilieren die Release-Version erstellen. Diese enthält alle Informationen, die das Programm zum Ablauf benötigt, aber auch nicht mehr.Wählen wir in VSC/PIO den Debug-Start, wird eine Debug-Version kompiliert. Diese enthält noch ein paar zusätzliche Debug-Informationen, damit PIO beim Debuggen die Verbindung z. B. zu Variablennamen herstellen kann.
Das letzte Mal war mir bei der Analyse des Image folgender Abschnitt aufgefallen:
Ich war mir nicht sicher, ob es sich dabei um Debug-Informationen handelte, obwohl: selbst zum Debuggen werde die lokalen Pfade nicht benötigt. Wir wollen der Sache mal auf den Grund gehen und dabei noch ein wenig über die Konfiguration der platformio.ini lernen.
Noch weiter unten im Code habe ich dann noch ASCII-Folgen gefunden, die von alten, vormals auf den STM32 hochgeladenen Programmen stammten. Auch hier könnte das Binär-Image Dinge ausplaudern, von denen man es nicht glauben würde.
Um ganz sicher zu stellen, dass auch ja die Release-Version auf den STM32 geflasht würde, habe ich die platformio.ini um die letzten zwei Zeilen ergänzt:
[env:bluepill_f103c8_128k]
platform = ststm32
board = bluepill_f103c8_128k
framework = arduino
debug_tool = stlink
upload_protocol = stlink
build_type = release
build_flags = -D RELEASE
Wie gesagt, die Release-Version sollte eigentlich automatisch ausgewählt sein, wenn man normal (ohne Debugging Run) kompiliert. Aber sicher ist sicher.Zum zweiten habe ich den gesamten Speicher vor dem Flashen gelöscht, also mit 0xFF überschrieben. Das geht ganz gut mit dem STM32 ST-Link Utility von STM (im Menü Target: Erase Chip). Installation und Bedienung erkläre ich in diesem Artikel.
Danach habe ich einmal die Release und einmal die Debug-Version geflasht, in ein Binärfile gedumpt und angeschaut.
In beiden Dumps, also auch in der Release-Version ist der lokale Pfad zum Framework der Platform IO vorhanden. Das hat dort echt nichts zu suchen und es ist wohl ein Bug der Platform IO, dass das dort mit abgelegt wird.
Nachdem jetzt klar ist, dass es sich um einen Bug der PIO und nicht um einen eigenen Fehler handelt, können wir die build_type und build_flags wieder auskommentieren, damit die Automatik wieder greift und wir diese Zeilen nicht ständig anpassen müssen. Mit dem Fehler müssen wir so oder so leben.
Wichtig sind in der platformio.ini noch die Zeilen debug_tool = stlink und upload_protocol = stlink, die der Platform IO sagen, dass wir mit dem STLink hochladen und auch debuggen.
Breakpoints setzen und Debugging starten
Mit den obigen Einstellungen weiß VSC/PIO, dass wir debuggen können und gibt uns die Möglichkeit Breakpoint, also Haltepunkte zu setzen. Dazu einfach vor die Zeilennummer, in der angehalten wird, klicken. Es erscheint ein roter Kreis, der mit nochmaligen Anklicken wieder verschwindet. Setzen wir einen Breakpoint auf die Zeile state = digitalRead(LED_BUILTIN); in der loop() Funktion:Wenn wir jetzt auf einen der Debug-Run-Buttons
oder
klicken, wird ein Debug-Binary kompiliert, hochgeladen und das Debugging gestartet.
Das Programm hält automatisch in main() bei initVariant() an. Wenn wir uns den Source hier anschauen
/*
* \brief Main entry point of Arduino application
*/
int main(void)
{
initVariant();
setup();
for (;;) {
#if defined(CORE_CALLBACK)
CoreCallback();
#endif
loop();
if (serialEventRun) {
serialEventRun();
}
}
return 0;
}
erkennen wir unsere alten Bekannten setup() und loop() wieder, die unser eigenes Programm ausmachen. Drumherum werden noch ein paar Verwaltungsaufgaben ausgeführt.Hier ist es nicht so interessant. Bewegen wir uns also im Code weiter. Dazu dient die Debug-Leite:
Diese bietet (von links nach rechts) folgende Funktionen:
- |>/|| (F5) Continue: Programm weiterlaufen lassen bzw. sofort anhalten
- .-> (F10) Step over: einen Schritt weiter, in angezeigte Funktionen wird nicht verzweigt
- .v (F11) Step into: einen Schritt weiter, in angezeigte Funktionen wird verzweigt
- .^ Step out: die aktuelle Funktion wird zuende ausgeführt und dann verlassen
- <O Restart: Das Programm wird neu gestartet
- [_] Stop: Ausführung beenden und Debug-Modus verlassen
An dem gelben Dreieck vor Zeile 27 sehen wir, dass dort angehalten wurde. Die Zeile wurde noch nicht ausgeführt. state ist noch undefiniert bzw. 0 wie ein Blick nach links auf VARIABLES zeigt:
"optimized out" meint wohl, dass die Variable keinen definierten Inhalt hat. Unsere Zeile int state=0; hat der Compiler wohl weg-optimiert, weil er festgestellt hat, dass es keine Verwendung der Variablen state gibt, bevor sie hier gesetzt wird.
Klicken wir jetzt auf "Step over" bzw. drücken F10, damit die Zeile ausgeführt wird. Dadurch nimmt die Variable state einen Wert an, und zwar 0 oder 1, je nachdem, ob die interne LED an- oder ausgeschaltet ist:
Auf der linken Seite finden wir noch viele weitere Informationen:
- Variables: Zeigt alle Variablen im jeweiligen Kontext an
- Watch: Sind viele Variablen vorhanden, wird es schnell unübersichtlich. Hier kann man einzelne Variablen hinzufügen, um sie zu beobachten.
- Call Stack: Zeit an, wo in der Funktionsaufrufhierarchie man sich befindet, also welche Funktion welche Unterfunktion aufgerufen hat.
- Breakpoints: Zeigt die Haltepunkte an
- Peripherals: Hier kann man sich die Werte die GPIO-Ports und anderer Peripherie anzeigen lassen
- Registers: Zeigt die STM32-Register an (Erklärung hier)
- Memory: Hier kann man Speicherbereiche definieren, um deren Inhalt oben rechts anzeigen zu lassen
- Disassembly: Hier kann man Funktion nach Assembler portieren, um sie z. B. mit anderen Debug-Sessions zu vergleichen
Richtig Debuggen
Wer richtig programmiert, muss weniger debuggen und spart unterm Strich Zeit und schafft auch noch robustere Programme.Zum richtigen, also vorausschauenden Programmieren gehört:
- Wir überprüfen die Rückgabewerte von Funktionsausfrufen, ob auch das passiert ist, was wir erwarten. Läuft etwas nicht wie erwartet, geben wir eine Fehlermeldung aus, an der wir später noch erkennen können, was schief gelaufen ist und führen das Programm geordnet weiter bzw. beenden das Programm geordnet, wenn es keine sinnvolle Weiterführung gibt. Wir überprüfen auch die Returnwerte von Funktionsaufrufen, die nur in ganz seltenen Fällen danebengehen. Dann müssen wir uns später nicht fragen, warum "es nicht funktioniert" hat und vermeiden, in völliger Ahnungslosigkeit dazustehen und Hunderte Stellen zu überprüfen, woran es denn nun lag.
- Wir programmieren in C. In C ist es ein leichtes, benachbarten Speicher zu überschreiben, weil eine Variable zu klein ist oder wir zuviele Bytes kopieren. Darum benutzen wir die overflow-sicheren Funktionen bei denen die maximale Byteanzahl mit angegeben wird, d. h. strncpy statt strcpy, oder snprintf statt sprintf. Bei Aufrufen von memset oder memcpy gucken wir zweimal hin, ob wir alles richtig gemacht haben. Keine Faulheit, nur um ein wenig Tipperei zu sparen. Speicherüberschreiber sind sehr schwer zu finden und können sich auch erst sehr viel später oder nur sporadisch negativ auswirken, etwa erst wenn man die überschriebene Nachbarvariable benutzt.
- Wir versuchen, unser Programm in einzelne Funktionen zu modularisieren, bei denen klar ist, was genau sie machen, die einen sprechenden Namen haben und die einen eindeutigen Rückgabewert haben, falls etwas schief geht. Unübersichtlicher, langer Spaghetti-Code ist zu vermeiden, wenn möglich.
- Wir vermeiden redundaten Code. Wenn wir mehrere Zeilen Code (5+) an mehrere Stellen hinschreiben, dann gehört so etwas in eine Funktion, wenn möglich. Wir vermeiden dait bei einer Änderung x Stellen anzupassen, dabei eine zu übersehen und dann einen Fehler im Programm zu haben. Allerdings muss man die Modularisierung auch nicht übertreiben. 50 Funktionen zu 5 Zeilen sind auch nicht übersichtlicher als 10 Funktionen zu 25 Zeilen. Es sollte klar bleiben, was für eine Aufgabe eine Funktion hat. Außerdem gehen hunderte Mini-Funktionen auf die Performance (ständiges auf den Stack legen und wiederholen).
- Wir sparen nicht an Kommentaren. Besonders wenn es mal komplizierter wird oder wir einen Kniff in einem Algorithmus anwenden, der nicht sofort ersichtlich ist. Auch, wenn man nur für sich selbst programmiert. Wenn man in ein paar Jahren ein Programm anpassen muss, kann man sich bei sich selbst bedanken, wenn man das Programm dann immer noch versteht. Auch hier: unterm Strich wieder Zeit gespart.
- Wir machen uns Gedanken bei den Variablen, wie groß dessen Inhalt im Extremfall werden kann und wählen dann den richtigen Variablentypen. Lieber eine Nummer größer als irgendwann mal zu klein und dann auf der Suche nach einem Überlauf. Wenn wir das Program erweitern, checken wir ggf. noch einmal die Variablen, ob sie noch ausreichen. Wir wollen zwar keinen Speicher verschwenden - gerade bei Mikrocontrollern ist der knapp - aber wir wollen auch nicht nach einem Überlauf-Fehler suchen. Die sind schwer zu finden.
Breakpoints
In dieser Funktion, in der der Fehler aufgetreten sein muss, setzt man dann einen Breakpoint, geht den Code noch einmal genau durch und schaut, wo es denn nun hapern könnte. An diese Stellen setzt man dann auch Breakpoints. Mit F5 springt man dann die Stellen an und beobachtet dabei die im Kontext interessanten Variablen. Eventuell setzt mal die heißen Kandidaten auf die Watchlist, der Übersicht halber.Zur Veranschaulichung nehmen wir mal dieses leicht abgewandelte Programm:
void loop() {
int state=0;
byte cnt=0;
while (1) {
state = digitalRead(LED_BUILTIN);
digitalWrite(LED_BUILTIN, !state);
for (cnt=0; cnt < 500; cnt++) {
delay(1);
}
}
}
Normalerweise gehen wir davon aus, dass die LED im Halbsekundentakt blinken sollte. Das tut sie aber nicht. Sie bleibt aber einfach aus. Woran kann das nur liegen?Klar ist, dass der Fehler in der Loop-Funktion sein muss. Also setzen wir einen Breakpoint auf die Zeile 29 und lassen das Programm rennen. Unser Watch zeigt uns an, das state gleich 0 ist. Okay, drücken wir nocheinmal F5, damit es weitergeht.
Jetzt dürften wir uns wundern, dass das Programm gar nicht mehr anhält. Nach einer halben Sekunde sollte es doch wieder auf Zeile 29 sein, so war es jedenfalls gedacht. Wir steppen also mit F10 über Zeile 29 und steppen weiter und bleiben dann in der For-Schleife gefangen.
Wir setzen cnt auf die Watchlist und können zusehen, wie sich cnt jeweils um eins erhöht und eine Millsekunde gewartet wird. So langsam rubbelt sich die Beschriftung von unserer F10-Taste ab, da wir hier wieder und wieder durchsteppen...
250, 251, 252, 253, 254, 255, 0.. Moment! Null? Wieso dass? Was ist hier los? Na, ist doch klar, dass cnt niemals 500 erreicht, wenn er nach 255 mit 0 statt mit 256 weiterzählt. Sicher ist es euch schon aufgefallen: cnt ist mit dem Variablentyp byte definiert und das kann halt nur Zustände von 0 bis 255 einnehmen.
Wir sind hier in der C-Welt. Die C-Welt ist schnell und unsicher, wie ein Supersportwagen im Supersport-Mode, bei dem alle Assistenz-Systeme ausgeschaltet sind. Superschnell aber gefääährlich. Damit C schnell fallen sämtliche Üverprüfungen weg. Wenn die 8 Bits dey Bytes durchaddiert sind, steht binär eine 1 und 8 Nullen im Register, macht 256 dezimal. Das wird zurück in cnt verschoben und zwar nur die 8 Bits, die es fassen kann und das macht eine dezimale Null. Bei der nächsten Addition steht dann wieder 1 in cnt und so weiter. cnt kann so nie über 255 hinauskommen, nie die 500 erreichen und die Schleife läuft endlos weiter.
Fehler gefunden. Aber geht das nicht auch einfacher? Das ist echt mühsam, so oft auf F10 zu hämmern...
Bedingte Breakpoints
Es geht. Mit bedingten Haltepunkten (oder conditional Breakpoints).Dazu müssen wir erst einmal einen Breakpoint auf eine Zeile setzen, in der wir anhalten wollen; wie sonst auch immer. Damit haben wir einen unbedingten Breakpoint, es wird also immer hier angehalten. Setzen wir ihn auf Zeile 31.
Dann klicken wir rechts auf den roten Kreis und dann auf Edit Breakpoint
wählen Expression aus und geben dort ein: cnt==490. Stellen wir uns dumm, führen das Programm mit F5 wieder aus und wundern uns, warum niemals an diesem Breakpoint gestoppt wird. Spätestens jetzt sollte und der Fehler eigentlich auffallen.
Nehmem wir an, wir haben Tomaten auf den Augen, versuchen wir es also mit cnt==250. Ah jetzt wird angehalten. Noch ein paar mal auf F10 gedrückt und wir sollten den Sprung von 255 auf 0 bemerken. Allerspätestens da sollte uns dämmern, dass in ein Byte nicht mehr rein passt als 255 und wir besser ein int verwenden sollten.
Es ist aber auch ein bisschen verwirrend mit dem Fassungsvermögen von den Variablentypen und man erlebt immer mal wieder eine Überraschung, wie zum Beispiel den Typen bit, bei dem man zwar nur ein Bit benutzen kann, dass aber trotzdem ein ganzes Byte (gleich 8 Bit) Platz wegnimmt, siehe dazu auch meinen Artikel Tricks und Fallstricke bei der Speicherplatzoptimierung auf dem Arduino.
Mal ist ein int 2 Bytes, also 16 Bit breit (auf dem Arduino) und kann Zahlen von -32768 bis 32767 aufnehmen, dann wieder 4 Bytes, also 32 Bit (auf dem STM32) und kann Zahlen von -2.147 Mrd. bis +2.147 Mrd. aufnehmen. Bei einer Registerbreite von 32 Bit bei dem 32-bit-CPU-STM werden eh immer 32 bit durch die Register geschoben, aber kann man das als Default nehmen. Bei der 8-bit-Atmel-CPU des Arduino sind die Register nur 8 bzw. 16 bit breit. Da nimmt man die kleinere Breite als Default.
Am besten ist es mit int8_t, int16_t, int32_t und int64_t bzw. uint8_t, uint16_t, uint32_t und uint64_t (dann ohne negative Zahlen) die Breite explizit anzugeben und int, long, long long, unsigned int und dergleichen außen vorzulassen. Dann weiß man, was man hat.
Mit Breakpoints und Watches ist man ganz gut bedient und sollte seine Programme ganz gut debuggen können. Hat man einen Fehler im Source korrigiert, sollte man speichern, den Debugger mit dem Quadrat-Icon verlassen und dann das Debugging neu starten und schauen, ob alle Fehler nun ausgemerzt sind.
Deassembling
Nur wenn man sich auf Assembler-Ebene begibt, das heißt, die Ebene seines eigenen Codes verlässt und auch die darunterliegende Ebene der Libraries, zu denen man einen Source-Code hat verlässt, braucht man die Anzeigen für Register, Memory und Disassembly.Oder wenn man Assembler lernen will und mal schauen, wie der eigene C-Source denn als Assembler aussieht. Dazu klickt man im Tab Disassembly auf Disassemble funtion und gibt dann einen Funktionsnamen ein, z. B. loop.
Die Assembler-Befehle kann man nachlesen, sie sind aber nicht ganz einfach zu verstehen. Darum habe ich das Assembler-Listing von Loop hier einmal kommentiert.
0x08003520: 10 b5 push {r4, lr} ; Rücksprungadresse auf Stack legen
0x08003522: 11 20 movs r0, #17 ; #17 (das ist PC13) in Register 0 schieben
0x08003524: ff f7 98 ff bl 0x8003458 <digitalRead> ; digitalRead aufrufen (benutzt r0 als Parameter)
0x08003528: b0 fa 80 f1 clz r1, r0 ; Count Leading Zeros von r0 und Ergebnis nach r1
0x0800352c: 49 09 lsrs r1, r1, #5 ; Logical Shift Right 5x von r1, Ergebnis nach r1
0x0800352e: 11 20 movs r0, #17 ; #17, das ist PC13 in Register 0 schieben
0x08003530: ff f7 6c ff bl 0x800340c <digitalWrite> ; digitalWrite aufrufen (benutzt r0)
0x08003534: 00 24 movs r4, #0 ; 0 in r4 schreiben
0x08003536: b4 f5 fa 7f cmp.w r4, #500 ; 0x1f4 ; compare. Vergleiche r4 mit 500
0x0800353a: f2 da bge.n 0x8003522 <loop()+2> ; bedingte verzweigung
0x0800353c: 01 20 movs r0, #1 ; 1 nach r0 = delay-wert
0x0800353e: ff f7 b5 ff bl 0x80034ac <delay> ; delay aufrufen
0x08003542: 01 34 adds r4, #1 ; 1 nach r4 schreiben
0x08003544: f7 e7 b.n 0x8003536 <loop()+22> ; bedingte verzweigung
Zum Vergleich noch einmal die Funktion in C:
void loop() {
int state=0;
int cnt=0;
while (1) {
state = digitalRead(LED_BUILTIN); // movs r0, #17; bl 0x8003458 <digitalRead>
digitalWrite(LED_BUILTIN, !state); // clz r1, r0; lsrs r1, r1, #5; movs r0, #17; bl 0x800340c <digitalWrite>
for (cnt=0; cnt < 500; cnt++) { // movs r4, #0; cmp.w r4, #500 ; 0x1f4; bge.n 0x8003522 <loop()+2>
delay(1); // movs r0, #1; bl 0x80034ac <delay>
} // b.n 0x8003536 <loop()+22>
}
}
Ist man in fremden Code, kann man so in etwa nachvollziehen, was vor sich geht. Die Registerwerte links helfen einem zusätlich dabei, hier den Überblick zu behalten.Vielleicht ist es auch einfach mal interessant, bis auf die Hardware-Ebene herunterzugehen und zu schauen, was eigentlich hinter einem bestimmten Befehl steht, etwa dem digitalWrite()
So kann man bestimmt das eine oder andere erfahren, wie der Mikrocontroller genau funktioniert - vorausgesetzt, es interessiert einen.