Fonctionnement de la trace de pile sur ARM

Bon après-midi Il y a quelques jours, j'ai rencontré un petit problème dans notre projet - dans le gestionnaire d'interruption gdb, la trace de pile pour Cortex-M n'était pas correctement affichée. Par conséquent, une fois de plus, il a été utile de le découvrir et de quelle manière puis-je obtenir une trace de pile pour ARM? Quels indicateurs de compilation affectent la traçabilité de la pile sur ARM? Comment est-ce implémenté dans le noyau Linux? Sur la base des recherches, j'ai décidé d'écrire cet article.

Examinons les deux principales méthodes de trace de pile dans le noyau Linux.

Empilez-vous dans les cadres


Commençons par une approche simple qui peut être trouvée dans le noyau Linux, mais qui a actuellement un statut obsolète dans GCC.

Imaginez qu'un certain programme s'exécute sur la pile en RAM, et à un moment donné, nous l'interrompons et voulons afficher la pile d'appels. Supposons que nous ayons un pointeur vers l'instruction actuelle qui est exécutée par le processeur (PC), ainsi que le pointeur actuel vers le haut de la pile (SP). Maintenant, afin de «sauter» la pile à la fonction précédente, vous devez comprendre de quel type de fonction il s'agissait et où nous devrions passer à cette fonction. ARM utilise Link Register (LR) à cet effet.
Le registre de liaison (LR) est le registre R14. Il stocke les informations de retour pour les sous-routines, les appels de fonction et les exceptions. Lors de la réinitialisation, le processeur définit la valeur LR sur 0xFFFFFFFF
Ensuite, nous devons remonter la pile et charger les nouvelles valeurs des registres LR à partir de la pile. La structure du cadre de pile pour le compilateur est la suivante:

/* The stack backtrace structure is as follows: fp points to here: | save code pointer | [fp] | return link value | [fp, #-4] | return sp value | [fp, #-8] | return fp value | [fp, #-12] [| saved r10 value |] [| saved r9 value |] [| saved r8 value |] ... [| saved r0 value |] r0-r3 are not normally saved in a C function. */ 

Cette description est tirée du fichier d'en-tête GCC gcc / gcc / config / arm / arm.h.

C'est-à-dire le compilateur (dans notre cas GCC) peut en quelque sorte être informé que nous voulons faire une trace de pile. Et puis, dans le prologue de chaque fonction, le compilateur préparera une sorte de structure auxiliaire. Vous pouvez remarquer que dans cette structure se trouve la valeur «suivante» du registre LR dont nous avons besoin et, surtout, elle contient l'adresse de la trame suivante | return fp value | [fp, #-12] | return fp value | [fp, #-12]

Ce mode de compilation est spécifié par l'option -mapcs-frame. Il y a une mention dans la description de l'option sur "La spécification de -fomit-frame-pointer avec cette option empêche les trames de pile d'être générées pour les fonctions feuilles." Ici, les fonctions feuilles sont comprises comme signifiant celles qui n'appellent pas d'autres fonctions, elles peuvent donc être rendues un peu plus faciles.

Vous pouvez également vous demander quoi faire avec les fonctions d'assembleur dans ce cas. En fait, rien de compliqué - vous devez insérer des macros spéciales. A partir du fichier tools / objtool / Documentation / stack-validation.txt du noyau Linux:
Chaque fonction appelable doit être annotée comme telle avec l'ELF
type de fonction. Dans le code asm, cela se fait généralement en utilisant le
Macros ENTRY / ENDPROC.
Mais le même document explique que c'est aussi un inconvénient évident de cette approche. L'utilitaire objtool vérifie si toutes les fonctions du noyau sont écrites au format correct pour la trace de pile.

La fonction suivante consiste à dérouler une pile du noyau Linux:

 #if defined(CONFIG_FRAME_POINTER) && !defined(CONFIG_ARM_UNWIND) int notrace unwind_frame(struct stackframe *frame) { unsigned long high, low; unsigned long fp = frame->fp; /*    ,    */ /* restore the registers from the stack frame */ frame->fp = *(unsigned long *)(fp - 12); frame->sp = *(unsigned long *)(fp - 8); frame->pc = *(unsigned long *)(fp - 4); return 0; } #endif 

Mais ici, je veux marquer la ligne avec defined(CONFIG_ARM_UNWIND) . Elle laisse entendre que le noyau Linux utilise également une autre implémentation de unwind_frame, et nous en parlerons un peu plus tard.

L'option -mapcs-frame n'est valide que pour le jeu d'instructions ARM. Mais il est connu que les microcontrôleurs ARM ont également un autre ensemble d'instructions - Thumb (Thumb-1 et Thumb-2, pour être plus précis), il est utilisé principalement pour la série Cortex-M. Pour activer la génération de trames pour le mode Thumb, utilisez les indicateurs -mtpcs-frame et -mtpcs-leaf-frame. Il s'agit essentiellement d'un analogue de -mapcs-frame. Fait intéressant, ces options ne fonctionnent actuellement que pour le Cortex-M0 / M1. Pendant un certain temps, je n'ai pas pu comprendre pourquoi je ne pouvais pas compiler l'image souhaitée pour Cortex-M3 / M4 / .... Après avoir relu toutes les options gcc pour ARM et cherché sur Internet, j'ai réalisé que c'était probablement un bug. Par conséquent, j'ai grimpé directement dans le code source du compilateur arm-none-eabi-gcc . Après avoir étudié comment le compilateur génère des images pour ARM, Thumb-1 et Thumb-2, je suis arrivé à la conclusion qu'ils contournaient Thumb-2, c'est-à-dire qu'au moment où les images sont générées uniquement pour Thumb-1 et ARM. Après avoir créé les bogues , les développeurs de GCC ont expliqué que la norme pour ARM a déjà changé plusieurs fois et que ces indicateurs sont très obsolètes, mais pour une raison quelconque, ils existent toujours dans le compilateur. Ci-dessous se trouve le désassembleur de la fonction pour laquelle le cadre est généré.

 static int my_func(int a) { my_func2(7); return 0; } 

 00008134 <my_func>: 8134: b084 sub sp, #16 8136: b580 push {r7, lr} 8138: aa06 add r2, sp, #24 813a: 9203 str r2, [sp, #12] 813c: 467a mov r2, pc 813e: 9205 str r2, [sp, #20] 8140: 465a mov r2, fp 8142: 9202 str r2, [sp, #8] 8144: 4672 mov r2, lr 8146: 9204 str r2, [sp, #16] 8148: aa05 add r2, sp, #20 814a: 4693 mov fp, r2 814c: b082 sub sp, #8 814e: af00 add r7, sp, #0 

En comparaison, un désassembleur de la même fonction pour les instructions ARM

 000081f8 <my_func>: 81f8: e1a0c00d mov ip, sp 81fc: e92dd800 push {fp, ip, lr, pc} 8200: e24cb004 sub fp, ip, #4 8204: e24dd008 sub sp, sp, #8 

À première vue, il peut sembler que ce sont des choses complètement différentes. Mais en fait, les trames sont exactement les mêmes, le fait est qu'en mode Thumb, l'instruction push ne permet d'empiler que les registres bas (r0 - r7) et le registre lr. Pour tous les autres registres, cela doit être fait en deux étapes à travers les instructions mov et str, comme dans l'exemple ci-dessus.

Pile se détendre grâce à des exceptions


Une approche alternative est le déroulement de pile basé sur la gestion des exceptions ABI pour la norme ARM Architecture ( EHABI ). En fait, le principal exemple d'utilisation de cette norme est la gestion des exceptions dans des langages tels que C ++. Les informations préparées par le compilateur pour la gestion des exceptions peuvent également être utilisées pour tracer la pile. Ce mode est activé avec l'option GCC -fexceptions (ou -funwind-frames ).

Examinons de plus près comment cela se fait. Pour commencer, ce document (EHABI) impose certaines exigences au compilateur pour générer les tables auxiliaires .ARM.exidx et .ARM.extab. C'est ainsi que cette section .ARM.exidx est définie dans les sources du noyau Linux. Depuis le fichier arch / arm / kernel / vmlinux.lds.h :

 /* Stack unwinding tables */ #define ARM_UNWIND_SECTIONS \ . = ALIGN(8); \ .ARM.unwind_idx : { \ __start_unwind_idx = .; \ *(.ARM.exidx*) \ __stop_unwind_idx = .; \ } \ 

La norme «Exception Handling ABI for the ARM Architecture» définit chaque élément de la table .ARM.exidx comme la structure suivante:

 struct unwind_idx { unsigned long addr_offset; unsigned long insn; }; 

Le premier élément est le décalage par rapport au début de la fonction, et le deuxième élément est l'adresse dans le tableau d'instructions qui doit être interprétée de manière spéciale afin de faire tourner la pile plus loin. En d'autres termes, chaque élément de ce tableau est simplement une séquence de mots et de demi-mots, qui sont une séquence d'instructions. Le premier mot indique le nombre d'instructions qui doivent être exécutées pour faire tourner la pile à la trame suivante.

Ces instructions sont décrites dans la norme EHABI déjà mentionnée:



De plus, l'implémentation principale de cet interpréteur sous Linux se trouve dans le fichier arch / arm / kernel / unwind.c

Implémentation de la fonction unwind_frame
 int unwind_frame(struct stackframe *frame) { unsigned long low; const struct unwind_idx *idx; struct unwind_ctrl_block ctrl; /*   ,   */ /*   ARM.exidx    ,   PC */ idx = unwind_find_idx(frame->pc); if (!idx) { pr_warn("unwind: Index not found %08lx\n", frame->pc); return -URC_FAILURE; } ctrl.vrs[FP] = frame->fp; ctrl.vrs[SP] = frame->sp; ctrl.vrs[LR] = frame->lr; ctrl.vrs[PC] = 0; if (idx->insn == 1) /* can't unwind */ return -URC_FAILURE; else if ((idx->insn & 0x80000000) == 0) /* prel31 to the unwind table */ ctrl.insn = (unsigned long *)prel31_to_addr(&idx->insn); else if ((idx->insn & 0xff000000) == 0x80000000) /* only personality routine 0 supported in the index */ ctrl.insn = &idx->insn; else { pr_warn("unwind: Unsupported personality routine %08lx in the index at %p\n", idx->insn, idx); return -URC_FAILURE; } /*       ,    - * ,       */ /* check the personality routine */ if ((*ctrl.insn & 0xff000000) == 0x80000000) { ctrl.byte = 2; ctrl.entries = 1; } else if ((*ctrl.insn & 0xff000000) == 0x81000000) { ctrl.byte = 1; ctrl.entries = 1 + ((*ctrl.insn & 0x00ff0000) >> 16); } else { pr_warn("unwind: Unsupported personality routine %08lx at %p\n", *ctrl.insn, ctrl.insn); return -URC_FAILURE; } ctrl.check_each_pop = 0; /* ,      */ while (ctrl.entries > 0) { int urc; if ((ctrl.sp_high - ctrl.vrs[SP]) < sizeof(ctrl.vrs)) ctrl.check_each_pop = 1; urc = unwind_exec_insn(&ctrl); if (urc < 0) return urc; if (ctrl.vrs[SP] < low || ctrl.vrs[SP] >= ctrl.sp_high) return -URC_FAILURE; } /*   */ /* ,       */ frame->fp = ctrl.vrs[FP]; frame->sp = ctrl.vrs[SP]; frame->lr = ctrl.vrs[LR]; frame->pc = ctrl.vrs[PC]; return URC_OK; } 


Il s'agit d'une implémentation de la fonction unwind_frame, qui est utilisée si l'option CONFIG_ARM_UNWIND est activée. J'ai inséré les commentaires avec des explications en russe directement dans le texte source.

Voici un exemple de la façon dont l'élément de table .ARM.exidx recherche la fonction kernel_start dans Embox:

 $ arm-none-eabi-readelf -u build/base/bin/embox Unwind table index '.ARM.exidx' at offset 0xaa6d4 contains 2806 entries: <...> 0x1c3c <kernel_start>: @0xafe40 Compact model index: 1 0x9b vsp = r11 0x40 vsp = vsp - 4 0x84 0x80 pop {r11, r14} 0xb0 finish 0xb0 finish <...> 

Et voici son démonteur:

 00001c3c <kernel_start>: void kernel_start(void) { 1c3c: e92d4800 push {fp, lr} 1c40: e28db004 add fp, sp, #4 <...> 

Passons en revue les étapes. Nous voyons l'affectation vps = r11 . (R11 c'est FP) puis vps = vps - 4 . Cela correspond à l'instruction add fp, sp, #4 . Vient ensuite pop {r11, r14}, qui correspond à l'instruction push {fp, lr} . La dernière instruction d' finish signale la fin de l'exécution (pour être honnête, je ne comprends toujours pas pourquoi il y a deux instructions d'arrivée).

Voyons maintenant la quantité de mémoire consommée par l'assembly avec l' indicateur -funwind-frames.
Pour l'expérience, j'ai compilé Embox pour la plate-forme STM32F4-Discovery. Voici les résultats de objdump:

Avec l'indicateur -funwind-frames:
Sections:
Idx Name Size VMA LMA File off Algn
0 .text 0005a600 08000000 08000000 00004000 2**14
CONTENTS, ALLOC, LOAD, CODE
1 .ARM.exidx 00003fd8 0805a600 0805a600 0005e600 2**2
CONTENTS, ALLOC, LOAD, READONLY, DATA
2 .ARM.extab 000049d0 0805e5d8 0805e5d8 000625d8 2**2
CONTENTS, ALLOC, LOAD, READONLY, DATA
3 .rodata 0003e380 08062fc0 08062fc0 00066fc0 2**5


Sans drapeau:
Sections:
Idx Name Size VMA LMA File off Algn
0 .text 00058b1c 08000000 08000000 00004000 2**14
CONTENTS, ALLOC, LOAD, CODE
1 .ARM.exidx 00000008 08058b1c 08058b1c 0005cb1c 2**2
CONTENTS, ALLOC, LOAD, READONLY, DATA
2 .rodata 0003e380 08058b40 08058b40 0005cb40 2**5


Il est facile de calculer que les sections .ARM.exidx et .ARM.extab occupent environ 1/10 de la taille .text. Après cela, j'ai collecté une image plus grande - pour ARM Integrator CP basée sur ARM9, et là, ces sections faisaient 1/12 de la taille de la section .text. Mais il est clair que ce ratio peut varier d'un projet à l'autre. Il s'est également avéré que la taille de l'image qui ajoute l'indicateur -macps-frame est plus petite que l'option d'exception (qui est attendue). Ainsi, par exemple, lorsque la taille de la section .text était de 600 Ko, la taille totale de .ARM.exidx + .ARM.extab était de 50 Ko et la taille du code supplémentaire avec l'indicateur -mapcs-frame n'était que de 10 Ko. Mais si nous regardons ci-dessus, quel gros prologue a été généré pour Cortex-M1 (rappelez-vous, via mov / str?), Ensuite, il devient clair que dans ce cas, il n'y aura pratiquement aucune différence, ce qui signifie qu'il est peu probable d'utiliser -mtpcs-frame pour le mode Thumb est au moins logique.

Une telle trace de pile est-elle nécessaire pour ARM maintenant? Quelles sont les alternatives?


Une troisième approche consiste à tracer la pile à l'aide d'un débogueur. Il semble que de nombreux systèmes d'exploitation pour travailler avec FreeRTOS, les microcontrôleurs NuttX suggèrent actuellement cette option de traçage particulière ou proposent de surveiller un désassembleur.

En conséquence, nous sommes arrivés à la conclusion que la trace de pile pour les armes en cours d'exécution n'est en fait utilisée nulle part. C'est probablement une conséquence du désir de rendre le code le plus efficace tout en travaillant, et de supprimer les actions de débogage (qui incluent la promotion de la pile) hors ligne. En revanche, si le système d'exploitation utilise déjà du code C ++, il est tout à fait possible d'utiliser l'implémentation du traçage via .ARM.exidx.

Eh bien et oui, le problème avec la sortie de pile incorrecte dans l'interruption dans Embox a été résolu très simplement, il s'est avéré être suffisant pour enregistrer le registre LR sur la pile.

Source: https://habr.com/ru/post/fr424365/


All Articles