Buenas tardes Hace unos días me encontré con un pequeño problema en
nuestro proyecto : en el controlador de interrupciones gdb, el seguimiento de la pila para Cortex-M se mostraba incorrectamente. Por lo tanto, una vez más, fue útil averiguarlo, y ¿de qué manera puedo obtener un seguimiento de la pila para ARM? ¿Qué indicadores de compilación afectan la trazabilidad de la pila en ARM? ¿Cómo se implementa esto en el kernel de Linux? Basado en la investigación, decidí escribir este artículo.
Veamos los dos métodos principales de rastreo de pila en el kernel de Linux.
La pila se desenrolla a través de los marcos
Comencemos con un enfoque simple que se puede encontrar en el kernel de Linux, pero que actualmente tiene estado obsoleto en GCC.
Imagine que cierto programa se está ejecutando en la pila de RAM, y en algún momento lo interrumpimos y queremos que aparezca la pila de llamadas. Supongamos que tenemos un puntero a la instrucción actual que ejecuta el procesador (PC), así como el puntero actual a la parte superior de la pila (SP). Ahora, para "saltar" la pila a la función anterior, debe comprender qué tipo de función era y dónde deberíamos saltar a esta función. ARM usa Link Register (LR) para este propósito.
El Registro de enlaces (LR) es el registro R14. Almacena la información de retorno para subrutinas, llamadas a funciones y excepciones. Al reiniciar, el procesador establece el valor LR en 0xFFFFFFFF
A continuación, debemos subir la pila y cargar los nuevos valores de los registros LR de la pila. La estructura del marco de la pila para el compilador es la siguiente:
/* 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. */
Esta descripción está tomada del archivo de encabezado GCC gcc / gcc / config / arm / arm.h.
Es decir El compilador (en nuestro caso GCC) puede ser informado de alguna manera que queremos hacer un seguimiento de la pila. Y luego, en el prólogo de cada función, el compilador preparará algún tipo de estructura auxiliar. Puede notar que en esta estructura se encuentra el "siguiente" valor del registro LR que necesitamos y, lo más importante, contiene la dirección del siguiente cuadro
| return fp value | [fp, #-12]
| return fp value | [fp, #-12]
Este modo de compilador se especifica mediante la opción -mapcs-frame. Hay una mención en la descripción de la opción sobre "Especificar -fomit-frame-pointer con esta opción hace que los marcos de pila no se generen para las funciones de hoja". Aquí, se entiende que las funciones de hoja significan aquellas que no realizan ninguna llamada a otras funciones, por lo que se pueden hacer un poco más fáciles.
También puede preguntarse qué hacer con las funciones de ensamblador en este caso. En realidad, nada complicado: necesita insertar macros especiales. Desde el
archivo tools / objtool / Documentation / stack-validation.txt en el kernel de Linux:
Cada función invocable debe ser anotada como tal con el ELF
tipo de función En el código ASM, esto normalmente se hace usando el
ENTRADA / ENDPROC macros.
Pero el mismo documento discute que esto también es una desventaja obvia de este enfoque. La utilidad objtool verifica si todas las funciones en el núcleo están escritas en el formato correcto para el seguimiento de la pila.
La siguiente es la función de desenrollar una pila del kernel de 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; frame->fp = *(unsigned long *)(fp - 12); frame->sp = *(unsigned long *)(fp - 8); frame->pc = *(unsigned long *)(fp - 4); return 0; } #endif
Pero aquí quiero marcar la línea con
defined(CONFIG_ARM_UNWIND)
. Ella insinúa que el kernel de Linux también usa otra implementación de unwind_frame, y hablaremos de ello un poco más adelante.
La opción
-mapcs-frame solo es válida para el conjunto de instrucciones ARM. Pero se sabe que los microcontroladores ARM tienen otro conjunto de instrucciones: Thumb (Thumb-1 y Thumb-2, para ser más precisos), se utiliza principalmente para la serie Cortex-M. Para habilitar la generación de cuadros para el modo Thumb, use los
indicadores -mtpcs-frame y
-mtpcs-leaf-frame. En esencia, es un análogo de -mapcs-frame. Curiosamente, estas opciones actualmente solo funcionan para el Cortex-M0 / M1. Durante algún tiempo no pude entender por qué no pude compilar la imagen deseada para Cortex-M3 / M4 / .... Después de volver a leer todas las opciones de gcc para ARM y buscar en Internet, me di cuenta de que esto probablemente era un error. Por lo tanto, subí directamente al código fuente del compilador
arm-none-eabi-gcc . Después de estudiar cómo el compilador genera marcos para ARM, Thumb-1 y Thumb-2, llegué a la conclusión de que omitieron Thumb-2, es decir, en este momento, los marcos se generan solo para Thumb-1 y ARM. Después de crear los
errores , los desarrolladores de GCC explicaron que el estándar para ARM ya ha cambiado varias veces y que estos indicadores están muy desactualizados, pero por alguna razón todavía existen en el compilador. A continuación se muestra el desensamblador de la función para la que se genera el marco.
static int my_func(int a) { my_func2(7); return 0; }
00008134 <my_func>: 8134: b084 sub sp, #16 8136: b580 push 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 comparación, un desensamblador de la misma función para instrucciones 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
A primera vista, puede parecer que estas son cosas completamente diferentes. Pero, de hecho, los cuadros son exactamente iguales, el hecho es que en el modo Thumb, la instrucción push solo permite que se apilen registros bajos (r0 - r7) y el registro lr. Para todos los demás registros, esto debe hacerse en dos etapas a través de las instrucciones mov y str, como en el ejemplo anterior.
La pila se desenrolla a través de excepciones
Un enfoque alternativo es el desbobinado de la pila basado en el ABI de manejo de excepciones para el
estándar ARM Architecture (
EHABI ). De hecho, el principal ejemplo de uso de este estándar es el manejo de excepciones en lenguajes como C ++. La información preparada por el compilador para el manejo de excepciones también se puede utilizar para rastrear la pila. Este modo está habilitado con la opción GCC
-fexceptions (o
-funwind-frames ).
Echemos un vistazo más de cerca a cómo se hace esto. Para empezar, este documento (EHABI) impone ciertos requisitos en el compilador para generar tablas auxiliares .ARM.exidx y .ARM.extab. Así es como esta sección .ARM.exidx se define en las fuentes del kernel de Linux. Desde el archivo
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 = .; \ } \
El estándar "Manejo de excepciones ABI para la arquitectura ARM" define cada elemento de la tabla .ARM.exidx como la siguiente estructura:
struct unwind_idx { unsigned long addr_offset; unsigned long insn; };
El primer elemento es el desplazamiento relativo al comienzo de la función, y el segundo elemento es la dirección en la tabla de instrucciones que deben interpretarse de una manera especial para poder seguir girando la pila. En otras palabras, cada elemento de esta tabla es simplemente una secuencia de palabras y medias palabras, que son una secuencia de instrucciones. La primera palabra indica el número de instrucciones que se deben completar para hacer girar la pila al siguiente cuadro.
Estas instrucciones se describen en el estándar EHABI ya mencionado:

Además, la implementación principal de este intérprete en Linux se encuentra en el archivo
arch / arm / kernel / unwind.cImplementación de la función unwind_frame int unwind_frame(struct stackframe *frame) { unsigned long low; const struct unwind_idx *idx; struct unwind_ctrl_block ctrl; 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) return -URC_FAILURE; else if ((idx->insn & 0x80000000) == 0) ctrl.insn = (unsigned long *)prel31_to_addr(&idx->insn); else if ((idx->insn & 0xff000000) == 0x80000000) ctrl.insn = &idx->insn; else { pr_warn("unwind: Unsupported personality routine %08lx in the index at %p\n", idx->insn, idx); return -URC_FAILURE; } 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; }
Esta es una implementación de la función unwind_frame, que se usa si la opción CONFIG_ARM_UNWIND está habilitada. Inserté los comentarios con explicaciones en ruso directamente en el texto fuente.
El siguiente es un ejemplo de cómo el elemento de tabla .ARM.exidx busca la función kernel_start en 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 <...>
Y aquí está su desensamblador:
00001c3c <kernel_start>: void kernel_start(void) { 1c3c: e92d4800 push {fp, lr} 1c40: e28db004 add fp, sp, #4 <...>
Veamos los pasos. Vemos la asignación
vps = r11
. (R11 esto es FP) y luego
vps = vps - 4
. Esto corresponde a la instrucción
add fp, sp, #4
. Luego viene pop {r11, r14}, que corresponde a la instrucción
push {fp, lr}
. La última instrucción de
finish
informa el final de la ejecución (para ser sincero, todavía no entiendo por qué hay dos instrucciones de finalización allí).
Ahora veamos cuánta memoria
come el ensamblaje con la bandera
-funwind-frames.Para el experimento, compilé Embox para la plataforma STM32F4-Discovery. Aquí están los resultados objdump:
Con la bandera -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
Sin bandera: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
Es fácil calcular que las secciones .ARM.exidx y .ARM.extab ocupan aproximadamente 1/10 del tamaño de .text. Después de eso, recolecté una imagen más grande, para ARM Integrator CP basado en ARM9, y allí estas secciones tenían 1/12 del tamaño de la sección .text. Pero está claro que esta relación puede variar de un proyecto a otro. También resultó que el tamaño de la imagen que agrega el indicador -macps-frame es más pequeño que la opción de excepción (que se espera). Entonces, por ejemplo, cuando el tamaño de la sección .text fue de 600 Kb, el tamaño total de .ARM.exidx + .ARM.extab fue de 50 Kb, y el tamaño del código adicional con el indicador -mapcs-frame fue de solo 10 Kb. Pero si miramos arriba, qué gran prólogo se generó para Cortex-M1 (recuerde, a través de mov / str?), Entonces queda claro que en este caso prácticamente no habrá diferencia, lo que significa que es poco probable que use
-mtpcs-frame para el modo Thumb tiene al menos algo de sentido.
¿Se necesita ese seguimiento de pila para ARM ahora? Cuales son las alternativas?
Un tercer enfoque es rastrear la pila utilizando un depurador. Parece que muchos sistemas operativos para trabajar con FreeRTOS, los microcontroladores NuttX actualmente
sugieren esta
opción de rastreo particular u ofrecen ver un desensamblador.
Como resultado, llegamos a la conclusión de que la traza de la pila para los brazos en tiempo de ejecución no se usa en ninguna parte. Esto es probablemente una consecuencia del deseo de crear el código más eficiente mientras se trabaja y eliminar las acciones de depuración (que incluyen la promoción de la pila) fuera de línea. Por otro lado, si el sistema operativo ya usa el código C ++, entonces es bastante posible usar la implementación del rastreo a través de .ARM.exidx.
Bueno, sí, el problema con la salida incorrecta de la pila en la interrupción en
Embox se resolvió de manera muy simple, resultó ser suficiente para guardar el registro LR en la pila.