Post-Mortem-Debugging auf Cortex-M

Hintergrund:
Ich habe kürzlich an der Entwicklung eines für mich untypischen Geräts aus der Klasse der Unterhaltungselektronik teilgenommen. Es scheint nichts Kompliziertes zu sein, eine Box, die manchmal den Ruhemodus verlassen, sich beim Server melden und wieder einschlafen sollte.
Die Praxis hat schnell gezeigt, dass der Debugger bei der Arbeit mit einem Mikrocontroller, der ständig in den Tiefschlafmodus wechselt oder die Stromversorgung abschaltet, nicht viel hilft. Grundsätzlich, weil die Box im Testmodus ohne Debugger und ohne mich in der Nähe war und manchmal fehlerhaft war. Ungefähr alle paar Tage.
Der Debugging-UART wurde an die Düsen geschraubt, in die ich Protokolle auszugeben begann. Es wurde einfacher, einige der Probleme wurden gelöst. Aber dann passierte eine Behauptung und alles passierte.
In meinem Fall sieht das Makro für die Zusicherung ungefähr so aus:#define USER_ASSERT( statement ) \ do \ { \ if(! (statement) ) \ { \ DEBUG_PRINTF_ERROR( "Assertion on line %d in file %s!\n", \ __LINE__, __FILE__ ); \ \ __disable_irq(); \ while(1) \ { \ __BKPT(0xAB); \ if(0) \ break; \ } \ } \ } while(0)
__BKPT(0xAB)
ist ein Software-Haltepunkt. Wenn beim Debuggen eine Bestätigung erfolgt, stoppt der Debugger nur an der Problemzeile. Dies ist sehr praktisch.
Bei einigen Zusicherungen ist sofort klar, was sie verursacht hat - da das Protokoll den Dateinamen und die Zeilennummer anzeigt, an denen die Zusicherung gearbeitet hat.
Der Behauptung zufolge war jedoch nur klar, dass das Array überlief - genauer gesagt, ein provisorischer Wrapper über dem Array, der den Ausweg überprüft. Aus diesem Grund waren im Protokoll nur der Dateiname „super_array.h“ und die darin enthaltene Zeilennummer sichtbar. Und welches spezifische Array ist nicht klar. Von den umliegenden Protokollen ist es ebenfalls unklar.
Natürlich könnte man einfach in die Kugel beißen und Ihren Code lesen, aber ich war zu faul, und dann würde der Artikel nicht funktionieren.
Da ich in uVision Keil 5 mit dem armcc-Compiler schreibe, wurde weiterer Code nur darunter überprüft. Ich habe auch C ++ 11 verwendet, weil es bereits 2019 auf dem Hof ist, es ist schon Zeit.
Stacktrace
Das erste, was mir in den Sinn kommt, ist natürlich, verdammt noch mal, denn wenn eine Zusicherung auf einem normalen Desktop-Computer erfolgt, wird eine Stapelverfolgung an die Konsole ausgegeben, beispielsweise auf KDPV. Anhand der Stapelverfolgung können Sie normalerweise nachvollziehen, welche Reihenfolge der Aufrufe zu dem Fehler geführt hat.
Okay, ich brauche auch einen Stealth-Track. Wie macht man das?
Wenn Sie eine Ausnahme auslösen, wird er möglicherweise abgeleitet?
Wir lösen eine Ausnahme aus und fangen sie nicht ab. Wir sehen die Ausgabe von "SIGABRT" und den Aufruf von _sys_exit
. Keine Fahrt, na gut, nicht wirklich, und ich wollte wirklich Ausnahmen zulassen.
Googeln, wie andere es machen.
Alle Methoden sind plattformspezifisch (nicht allzu überraschend), für gcc unter POSIX gibt es backtrace()
und execinfo.h
. Für Cale war nichts verständlich. Wir lassen eine gemeine Träne fallen. Sie müssen mit Ihren Händen auf den Stapel klettern.
Wir klettern mit unseren Händen in den Stapel
Theoretisch ist alles ganz einfach.
- Die Rücksprungadresse der aktuellen Funktion befindet sich im LR-Register, die Adresse der aktuellen Oberseite des Stapels (im Sinne des letzten Elements im Stapel) befindet sich im SP-Register, die Adresse des aktuellen Befehls befindet sich im PC-Register.
- Irgendwie finden wir die Größe des Stapelrahmens für die aktuelle Funktion, gehen in einem solchen Abstand entlang des Stapels, finden dort die Rücksprungadresse für die vorherige Funktion und wiederholen sie, bis wir den Stapel bis zum Ende durchlaufen.
- Irgendwie stimmen wir die Absenderadressen mit den Zeilennummern in Dateien mit dem Quellcode überein.
Ok, für den Anfang - woher weiß ich die Größe des Stapelrahmens?
Standardmäßig werden die Optionen - anscheinend überhaupt nichts - vom Compiler einfach in den "Prolog" und "Epilog" jeder Funktion fest codiert, in Befehle, die einen Teil des Stapels für den Frame zuweisen und freigeben.
Glücklicherweise hat armcc die Option --use_frame_pointer
, die das R11-Register unter dem Frame Pointer --use_frame_pointer
- d. H. Zeiger auf den Stapelrahmen der vorherigen Funktion. Großartig, jetzt können Sie durch alle Stapelrahmen gehen.
Nun - wie werden Rücksprungadressen mit Zeichenfolgen in Quelldateien abgeglichen?
Verdammt, auf keinen Fall wieder. Debugging-Informationen werden nicht in den Mikrocontroller geflasht (was nicht überraschend ist, da er anständige Plätze einnimmt). Kann Cale sie immer noch dazu bringen, dort zu blinken? Ich weiß nicht, ich konnte es nicht finden.
Wir seufzen. Daher funktioniert ein ehrlicher Stackrace - so dass Funktionsnamen und Zeilennummern sofort zur Debug-Ausgabe ausgegeben werden - nicht. Sie können die Adressen jedoch anzeigen und dann auf dem Computer mit Funktionen und Zeilennummern vergleichen, da das Projekt noch Debugging-Informationen enthält.
Aber es sieht sehr traurig aus, weil Sie die .map-Datei analysieren müssen, die die Adressbereiche angibt, die jede Funktion belegt. Analysieren Sie dann Dateien mit zerlegtem Code separat, um eine bestimmte Zeile zu finden. Es besteht ein scharfer Wunsch zu punkten.
--use_frame_pointer
die Dokumentation für die Option --use_frame_pointer
genau --use_frame_pointer
können --use_frame_pointer
diese Seite --use_frame_pointer
, die besagt, dass diese Option zu zufälligen Abstürzen in HardFault führen kann. Hmm.
Okay, denk weiter nach.
Wie macht der Debugger das?
Aber der Debugger zeigt den Aufrufstapel auch ohne frame pointer'a
irgendwie an. Nun, es ist klar, wie die IDE alle Debug-Informationen zur Hand hat und es für sie einfach ist, die Adressen und Namen von Funktionen zu vergleichen. Hm.
Gleichzeitig hat dasselbe Visual Studio so etwas - Minidump -, wenn die abgestürzte Anwendung eine kleine Datei generiert, die Sie dann dem Studio zuführen und den Status der Anwendung zum Zeitpunkt des Absturzes wiederherstellen. Und Sie können alle Variablen berücksichtigen und bequem auf dem Stapel laufen. Hm schon wieder.
Aber es ist ziemlich einfach. Ich brauche nur reibe jeden Tag eine dicke sowjetische Fortsetzung in das Gesäß Füllen Sie den Stapel mit den Werten, die zum Zeitpunkt des Sturzes vorhanden waren, und stellen Sie anscheinend den Status der Register wieder her. Und das ist alles, wie es scheint?
Teilen Sie diese Idee erneut in Unteraufgaben auf.
- Auf dem Mikrocontroller müssen Sie den Stapel durchlaufen. Dazu müssen Sie den aktuellen SP-Wert und die Adresse des Stapelanfangs abrufen.
- Auf dem Mikrocontroller müssen Sie die Werte der Register anzeigen.
- In der IDE müssen Sie irgendwie alle Werte aus dem "Minidump" zurück auf den Stapel schieben. Und die Werte der Register auch.
Wie erhalte ich den aktuellen Wert von SP?
Am besten keine Hände auf dem Monteur plündern. In Cale gibt es glücklicherweise eine spezielle Funktion (intrinsisch) - __current_sp()
. Gcc wird nicht funktionieren, aber ich muss nicht.
Wie erhalte ich die Adresse des Stapelanfangs? Da ich mein Skript zum Schutz vor Überlauf verwende (worüber ich hier geschrieben habe ), befindet sich mein Stack in einem separaten Linker-Bereich, den ich REGION_STACK
genannt REGION_STACK
.
Dies bedeutet, dass seine Adresse beim Linker unter Verwendung seltsamer Variablen mit Dollar in den Namen gefunden werden kann .
Durch Versuch und Irrtum wählen wir den gewünschten Namen aus - Image$$REGION_STACK$$ZI$$Limit
, überprüfen Sie, es funktioniert.
ErklärungDies ist ein magisches Symbol, das in der Verknüpfungsphase erstellt wird. Genau genommen ist es keine Konstante der Kompilierungsphase.
Um es zu verwenden, benötigen Sie eine Dereferenzierung:
extern unsigned int Image$$REGION_STACK$$ZI$$Limit; using MemPointer = const uint32_t *;
Wenn Sie keine Lust haben, können Sie die Größe des Stapels einfach fest codieren, da er sich nur sehr selten ändert. Im schlimmsten Fall sehen wir im Call-Stack-Fenster nicht alle Calls, sondern einen Stub.
Wie werden Registerwerte angezeigt?
Zuerst dachte ich, dass es notwendig sei, alle Allzweckregister im Allgemeinen anzuzeigen. Ich fing an, mich mit dem Assembler zu beschäftigen, erkannte aber schnell, dass dies keinen Sinn ergeben würde. Immerhin wird die Ausgabe des Minidumps durch eine spezielle Funktion für mich erfolgen, es macht keinen Sinn, Registerwerte in ihrem Kontext zu verwenden.
Wir brauchen wirklich nur das Link Register (LR), in dem die Rücksprungadresse der aktuellen Funktion SP gespeichert ist, mit dem wir uns bereits befasst haben, und den Programmzähler (PC), in dem die Adresse des aktuellen Befehls gespeichert ist.
Wieder konnte ich keine Option finden, die mit jedem Compiler funktionieren würde, aber es gibt wieder intrinsische Funktionen für Cale: __return_address()
für LR und __current_pc()
für PC.
Großartig. Es bleibt, alle Werte aus dem Minidump zurück auf den Stapel und die Werte der Register in die Register zu schieben.
Wie lade ich einen Minidump in den Speicher?
Zuerst wollte ich den Befehl LOAD debugger verwenden, mit dem Sie Werte aus einer .hex- oder .bin-Datei in den Speicher laden können, stellte jedoch schnell fest, dass LOAD aus irgendeinem Grund keine Werte in den RAM lädt.
Und ich wäre immer noch nicht in der Lage, die Register mit diesem Befehl zu vervollständigen.
Okay, es würde immer noch zu viele Gesten erfordern, Text in Bin konvertieren, Bin in Hex konvertieren ...
Glücklicherweise hat Cale einen Simulator, und für den Simulator können Sie Skripte in einer elenden C-ähnlichen Sprache schreiben. Und in dieser Sprache gibt es die Möglichkeit, in Erinnerung zu schreiben! _WDWORD
gibt es spezielle Funktionen wie _WDWORD
und _WBYTE
. Wir sammeln alle Ideen auf einem Haufen und erhalten einen solchen Code.
Alle Code: #define USER_ASSERT( statement ) \ do \ { \ if(! (statement) ) \ { \ DEBUG_PRINTF_ERROR( "Assertion on line %d in file %s!\n", \ __LINE__, __FILE__ ); \ \ print_minidump(); \ __disable_irq(); \ while(1) \ { \ __BKPT(0xAB); \ if(0) \ break; \ } \ } \ } while(0)
Um den Minidump zu laden, müssen wir eine INI-Datei erstellen, die Funktion __load_minidump
in sie kopieren, diese Datei zum Autorun hinzufügen - Project -> Options for Target -> Debug
Debuggen und diese INI-Datei im Abschnitt „Initialisierungsdatei“ im Abschnitt „Simulator verwenden“ schreiben.
Jetzt gehen wir einfach zum Debuggen auf dem Simulator und rufen, ohne mit dem Debuggen zu beginnen, die Funktion __load_minidump()
im Befehlsfenster auf.
Und voila, wir teleportieren uns zur Funktion print_minidump
in der Zeile, in der der PC gespeichert wurde. Im Fenster Callstack + Locals sehen Sie den Call Stack.
Hinweis:Die Funktion wird am Anfang speziell mit zwei Unterstrichen benannt. Wenn der Name der Funktion oder Variablen im Simulationsskript versehentlich mit dem Namen im Projektcode übereinstimmt, kann Cale ihn nicht aufrufen. Der C ++ - Standard verbietet die Verwendung von Namen mit zwei Unterstrichen am Anfang, sodass die Wahrscheinlichkeit, dass Namen übereinstimmen, verringert wird.
Im Prinzip ist das alles. Soweit ich überprüfen konnte, funktioniert der Minidump sowohl für reguläre Funktionen als auch für Interrupt-Handler. Ob es für alle Arten von Perversionen mit setjmp/longjmp
oder alloca
- ich weiß es nicht, weil ich keine Perversionen übe.
Ich bin ziemlich zufrieden mit dem, was passiert ist; kleiner Code, Overhead - leicht geschwollenes Makro zum Durchsetzen. In diesem Fall fiel die langweilige Arbeit beim Parsen des Stapels auf die Schultern der IDE, zu der er gehört.
Dann habe ich ein bisschen gegoogelt und eine ähnliche Sache für gcc und gdb gefunden - CrashCatcher .
Ich verstehe, dass ich nichts Neues erfunden habe, aber ich konnte kein fertiges Rezept finden, das zu einem ähnlichen Ergebnis führte. Ich wäre dankbar, wenn sie mir sagen würden, was besser gemacht werden könnte.