Débogage post mortem sur Cortex-M

Contexte:
J'ai récemment participé au développement d'un appareil atypique pour moi de la classe électronique grand public. Cela ne semble rien de compliqué, une boîte qui devrait parfois sortir du mode veille, se présenter au serveur et s'endormir.
La pratique a rapidement montré que le débogueur n'aide pas beaucoup lorsque l'on travaille avec un microcontrôleur qui passe constamment en mode veille profonde ou coupe son alimentation. Fondamentalement, parce que la boîte en mode test était sans débogueur et sans moi à proximité et parfois elle était buggée. Environ une fois tous les quelques jours.
L'UART de débogage a été vissé sur les buses, dans lesquelles j'ai commencé à produire des journaux. C'est devenu plus facile, certains des problèmes ont été résolus. Mais alors une affirmation s'est produite et tout s'est passé.
Dans mon cas, la macro de l'assertion ressemble à ceci:#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)
est un point d'arrêt logiciel; si l'assertion se produit lors du débogage, le débogueur s'arrête simplement à la ligne de problème, c'est très pratique.
Pour certaines assertions, il est immédiatement clair ce qui les a causées - car le journal affiche le nom de fichier et le numéro de ligne sur lesquels l'assertion a fonctionné.
Mais selon l'affirmation, il était clair que le tableau débordait - plus précisément, un wrapper de fortune sur le tableau, qui vérifie la sortie. Pour cette raison, seul le nom de fichier «super_array.h» et le numéro de ligne qu'il contenait étaient visibles dans le journal. Et quel tableau spécifique n'est pas clair. Parmi les journaux environnants, il n'est pas clair non plus.
Bien sûr, on pouvait juste mordre la balle et aller lire votre code, mais j'étais trop paresseux, et alors l'article ne fonctionnerait pas.
Depuis que j'écris dans uVision Keil 5 avec le compilateur armcc, un code supplémentaire n'a été vérifié qu'en dessous. J'ai également utilisé C ++ 11, car c'est déjà 2019 dans la cour, il est déjà temps.
Stacktrace
Bien sûr, la première chose qui me vient à l'esprit est, bon sang, car quand une assertion se produit sur un ordinateur de bureau normal, une trace de pile est sortie sur la console, comme sur KDPV. À partir de la trace de pile, vous pouvez généralement comprendre quelle séquence d'appels a conduit à l'erreur.
D'accord, j'ai donc aussi besoin d'une piste furtive. Comment le faire?
Peut-être que si vous jetez une exception, il sera déduit?
Nous lançons une exception et ne l'attrapons pas, nous voyons la sortie de «SIGABRT» et l'appel à _sys_exit
. Pas un tour, eh bien, d'accord, pas vraiment, et je voulais vraiment autoriser les exceptions.
Googler comment les autres le font.
Toutes les méthodes sont execinfo.h
plateforme (pas trop surprenant), pour gcc sous POSIX il y a backtrace()
et execinfo.h
. Il n'y avait rien d'intelligible pour Cale. Nous lâchons une larme moyenne. Vous devez monter sur la pile avec vos mains.
Nous montons dans la pile avec nos mains
Théoriquement, tout est assez simple.
- L'adresse de retour de la fonction actuelle est dans le registre LR, l'adresse du sommet actuel de la pile (dans le sens du dernier élément de la pile) est dans le registre SP, l'adresse de la commande actuelle est dans le registre PC.
- D'une manière ou d'une autre, nous trouvons la taille du cadre de pile pour la fonction actuelle, parcourons la pile à une telle distance, y trouvons l'adresse de retour pour la fonction précédente et la répétons jusqu'à ce que nous parcourions la pile jusqu'à la fin.
- D'une certaine manière, nous faisons correspondre les adresses de retour avec les numéros de ligne dans les fichiers avec le code source.
D'accord, pour commencer - comment connaître la taille du cadre de pile?
Sur les options par défaut - apparemment, rien du tout, il est simplement codé en dur par le compilateur dans le "prologue" et l '"épilogue" de chaque fonction, en commandes qui allouent et libèrent un morceau de la pile pour le cadre.
Mais, heureusement, armcc a l'option --use_frame_pointer
, qui alloue le registre R11 sous le pointeur de trame - c'est-à-dire pointeur sur le cadre de pile de la fonction précédente. Super, vous pouvez maintenant parcourir tous les cadres de pile.
Maintenant - comment faire correspondre les adresses de retour avec des chaînes dans les fichiers source?
Merde, aucun moyen à nouveau. Les informations de débogage ne sont pas flashées dans le microcontrôleur (ce qui n'est pas surprenant, car elles occupent des emplacements décents). Cale peut-elle encore la faire flasher là-bas, je ne sais pas, je n'ai pas pu trouver.
Nous soupirons. Cela signifie qu'une trace de pile honnête - telle que les noms de fonction et les numéros de ligne sont immédiatement sortis dans la sortie de débogage - ne fonctionnera pas. Mais vous pouvez afficher les adresses, puis sur l'ordinateur les comparer avec les fonctions et les numéros de ligne, car il existe toujours des informations de débogage dans le projet.
Mais cela semble très triste, car vous devez analyser le fichier .map, qui indique les plages d'adresses que chaque fonction occupe. Ensuite, analysez séparément les fichiers avec du code désassemblé pour trouver une ligne spécifique. Il y a une forte envie de marquer.
De plus, la --use_frame_pointer
attentive de la documentation de l'option --use_frame_pointer
permet de voir cette page , qui indique que cette option peut entraîner des plantages dans HardFault à des moments aléatoires. Hmm.
D'accord, réfléchissez plus loin.
Comment le débogueur fait-il cela?
Mais le débogueur affiche en quelque sorte la pile des appels même sans frame pointer'a
. Eh bien, il est clair que l'IDE a toutes les informations de débogage à portée de main, il lui est facile de comparer les adresses et les noms des fonctions. Hm.
Dans le même temps, le même Visual Studio a une telle chose - minidump - lorsque l'application crashée génère un petit fichier, que vous alimentez ensuite le studio et il restaure l'état de l'application au moment du crash. Et vous pouvez considérer toutes les variables, marcher confortablement sur la pile. Hm encore.
Mais c'est assez simple. Juste besoin frotter chaque jour une épaisse continuation soviétique dans les fesses remplissez la pile avec les valeurs qui étaient là au moment de la chute et, apparemment, restaurez l'état des registres. Et c’est tout, semble-t-il?
Encore une fois, divisez cette idée en sous-tâches.
- Sur le microcontrôleur, vous devez parcourir la pile, pour cela, vous devez obtenir la valeur SP actuelle et l'adresse du début de la pile.
- Sur le microcontrôleur, vous devez afficher les valeurs des registres.
- Dans l'EDI, vous devez en quelque sorte repousser toutes les valeurs du "minidump" sur la pile. Et les valeurs des registres aussi.
Comment obtenir la valeur actuelle de SP?
De préférence, ne maraudez pas les mains sur l'assembleur. Dans Cale, heureusement, il existe une fonction spéciale (intrinsèque) - __current_sp()
. Gcc ne fonctionnera pas, mais je n'en ai pas besoin.
Comment obtenir l'adresse du début de la pile? Puisque j'utilise mon script pour me protéger contre le débordement (dont j'ai parlé ici ), ma pile se trouve dans une section de l'éditeur de liens distincte, que j'ai appelée REGION_STACK
.
Cela signifie que son adresse peut être trouvée dans l'éditeur de liens en utilisant des variables étranges avec des dollars dans les noms .
Par essais et erreurs, nous sélectionnons le nom souhaité - Image$$REGION_STACK$$ZI$$Limit
, vérifiez, cela fonctionne.
ExplicationC'est un symbole magique qui est créé à l'étape de la liaison, donc à proprement parler, ce n'est pas une constante de l'étape de compilation.
Pour l'utiliser, vous devez déréférencer:
extern unsigned int Image$$REGION_STACK$$ZI$$Limit; using MemPointer = const uint32_t *;
Si vous n'avez pas envie de déranger, vous pouvez simplement coder en dur la taille de la pile, car elle change assez rarement. Dans le pire des cas, nous voyons dans la fenêtre de pile d'appels non pas tous les appels, mais un talon.
Comment afficher les valeurs de registre?
Au début, je pensais qu'il était nécessaire d'afficher tous les registres à usage général en général, j'ai commencé à m'embrouiller avec l'assembleur, mais je me suis vite rendu compte que cela n'aurait aucun sens. Après tout, la sortie du minidump se fera par une fonction spéciale pour moi, il n'y a aucun sens dans les valeurs de registre dans son contexte.
Nous n'avons vraiment besoin que de Link Register (LR), qui stocke l'adresse de retour de la fonction actuelle, SP, que nous avons déjà traitée, et Program Counter (PC), qui stocke l'adresse de la commande actuelle.
Encore une fois, je n'ai pas pu trouver une option qui fonctionnerait avec n'importe quel compilateur, mais il existe encore des fonctions intrinsèques pour Cale: __return_address()
pour LR et __current_pc()
pour PC.
Super. Il reste à repousser toutes les valeurs du minidump sur la pile, et les valeurs des registres dans les registres.
Comment charger un minidump en mémoire?
Au début, j'avais prévu d'utiliser la commande de débogage LOAD, qui permet de charger des valeurs à partir d'un fichier .hex ou .bin dans la mémoire, mais j'ai rapidement découvert que LOAD pour une raison quelconque ne charge pas les valeurs dans la RAM.
Et je ne serais toujours pas en mesure de compléter les registres avec cette commande.
Bon, d'accord, il faudrait encore trop de gestes, convertir du texte en bin, convertir bin en hex ...
Heureusement, Cale a un simulateur, et pour le simulateur, vous pouvez écrire des scripts dans un langage misérable de type C. Et dans cette langue, il est possible d'écrire en mémoire! Il existe des fonctions spéciales comme _WDWORD
et _WBYTE
. Nous rassemblons toutes les idées dans un tas et obtenons un tel code.
Tout le 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)
Pour charger le minidump, nous devons créer un fichier .ini, copier la fonction __load_minidump
dedans, ajouter ce fichier à l'exécution automatique - Project -> Options for Target -> Debug
et écrire ce fichier .ini dans la section "Fichier d'initialisation" de la section Utiliser le simulateur.
Nous passons maintenant au débogage sur le simulateur et, sans démarrer le débogage, appelons la fonction __load_minidump()
dans la fenêtre de commande.
Et voila, nous nous téléportons vers la fonction print_minidump
sur la ligne où le PC a été enregistré. Et dans la fenêtre Callstack + Locals, vous pouvez voir la pile d'appels.
Remarque:La fonction est spécifiquement nommée avec deux traits de soulignement au début, car si le nom de la fonction ou de la variable dans le script de simulation coïncide accidentellement avec le nom dans le code du projet, Cale ne pourra pas l'appeler. La norme C ++ interdit l'utilisation de noms avec deux traits de soulignement au début, de sorte que la probabilité de correspondance de noms est réduite.
En principe, c'est tout. Autant que j'ai pu vérifier, le minidump fonctionne à la fois pour les fonctions régulières et les gestionnaires d'interruption. Si cela fonctionnera pour toutes sortes de perversions avec setjmp/longjmp
ou alloca
- je ne sais pas, parce que je ne pratique pas les perversions.
Je suis très content de ce qui s'est passé; peu de code, frais généraux - macro légèrement gonflée pour assert. Dans ce cas, tout le travail ennuyeux sur l'analyse de la pile est tombé sur les épaules de l'IDE, où il appartient.
Ensuite, j'ai googlé un peu et trouvé une chose similaire pour gcc et gdb - CrashCatcher .
Je comprends que je n'ai rien inventé de nouveau, mais je n'ai pas pu trouver de recette toute faite menant à un résultat similaire. Je serais reconnaissant s'ils me disent ce qui pourrait être mieux fait.