Depuración post mortem en Cortex-M

Depuración post mortem en Cortex-M



Antecedentes:


Recientemente participé en el desarrollo de un dispositivo atípico para mí de la clase de electrónica de consumo. Parece que no es nada complicado, una caja que a veces debería salir del modo de suspensión, informar al servidor y quedarse dormida nuevamente.


La práctica demostró rápidamente que el depurador no ayuda mucho cuando se trabaja con un microcontrolador que constantemente entra en modo de suspensión profunda o corta la energía. Básicamente, porque la caja en modo de prueba estaba sin un depurador y sin mí cerca y, a veces, tenía errores. Aproximadamente una vez cada pocos días.


El UART de depuración se atornilló en las boquillas, en las que comencé a generar registros. Se hizo más fácil, algunos de los problemas se resolvieron. Pero luego sucedió una afirmación y todo sucedió.


En mi caso, la macro para la afirmación se ve así:
#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) es un punto de interrupción de software; Si la afirmación se produce durante la depuración, entonces el depurador simplemente se detiene en la línea del problema, es muy conveniente.


Para algunas aserciones, queda claro de inmediato qué las causó, porque el registro muestra el nombre del archivo y el número de línea en que funcionó la aserción.


Pero de acuerdo con la afirmación, solo estaba claro que la matriz se estaba desbordando, más precisamente, un contenedor improvisado sobre la matriz, que verifica la salida. Debido a esto, solo el nombre del archivo "super_array.h" y el número de línea estaban visibles en el registro. Y qué matriz específica no está clara. De los registros circundantes, tampoco está claro.


Por supuesto, uno podría morder la bala e ir a leer su código, pero yo era demasiado vago, y luego el artículo no funcionó.


Como escribí en uVision Keil 5 con el compilador armcc, solo se verificó el código debajo de él. También usé C ++ 11, porque ya es 2019 en el patio, ya es hora.


Stacktrace


Por supuesto, lo primero que viene a la mente es, maldición, porque cuando ocurre una afirmación en una computadora de escritorio normal, se genera un seguimiento de la pila en la consola, como en KDPV. A partir del seguimiento de la pila, generalmente puede comprender qué secuencia de llamadas condujo al error.
De acuerdo, entonces también necesito una pista de sigilo. ¿Cómo hacerlo?


Tal vez si lanzas una excepción, se deducirá?


Lanzamos una excepción y no la _sys_exit , vemos la salida de "SIGABRT" y la llamada a _sys_exit . No es un viaje, bueno, está bien, en realidad no, y realmente quería permitir excepciones.


Busca en Google cómo lo hacen otras personas.


Todos los métodos son execinfo.h plataforma (no es demasiado sorprendente), para gcc en POSIX hay backtrace() y execinfo.h . No había nada inteligible para Cale. Dejamos caer una lágrima media. Tienes que subir a la pila con las manos.


Subimos a la pila con nuestras manos


Teóricamente, todo es bastante simple.


  1. La dirección de retorno de la función actual está en el registro LR, la dirección de la parte superior actual de la pila (en el sentido del último elemento en la pila) está en el registro SP, la dirección del comando actual está en el registro de la PC.
  2. De alguna manera, encontramos el tamaño del marco de la pila para la función actual, avanzamos a lo largo de la pila a esa distancia, encontramos la dirección de retorno de la función anterior allí y la repetimos hasta que pasemos por la pila hasta el final.
  3. De alguna manera, hacemos coincidir las direcciones de retorno con los números de línea en los archivos con el código fuente.

Ok, para empezar, ¿cómo sé el tamaño del marco de la pila?


En las opciones por defecto, aparentemente, nada en absoluto, el compilador simplemente lo codifica en el "prólogo" y el "epílogo" de cada función, en comandos que asignan y liberan una parte de la pila para el marco.
Pero, afortunadamente, armcc tiene la opción --use_frame_pointer , que asigna el registro R11 bajo el puntero de marco, es decir puntero al marco de la pila de la función anterior. Genial, ahora puedes caminar a través de todos los marcos de la pila.


Ahora, ¿cómo hacer coincidir las direcciones de retorno con las cadenas en los archivos de origen?


Maldición, de ninguna manera otra vez. La información de depuración no se muestra en el microcontrolador (lo cual no es sorprendente, ya que ocupa lugares decentes). ¿Puede Cale hacer que ella parpadee allí? No lo sé, no pude encontrarlo.


Nosotros suspiramos Por lo tanto, el stackrace honesto, tal que los nombres de función y los números de línea se envían inmediatamente a la salida de depuración, no funcionará. Pero puede mostrar las direcciones, y luego en la computadora compararlas con funciones y números de línea, ya que todavía hay información de depuración en el proyecto.


Pero se ve muy triste, porque tiene que analizar el archivo .map, que indica los rangos de direcciones que ocupa cada función. Y luego analice por separado los archivos con código desmontado para encontrar una línea específica. Hay un fuerte deseo de anotar.


Además, si mira detenidamente la documentación de la opción --use_frame_pointer permite ver esta página , que dice que esta opción puede provocar bloqueos en HardFault en momentos aleatorios. Hmm
De acuerdo, piensa más.


¿Cómo hace esto el depurador?


Pero el depurador de alguna manera muestra la pila de llamadas incluso sin un frame pointer'a . Bueno, está claro cómo, el IDE tiene toda la información de depuración a mano, es fácil para ella comparar las direcciones y los nombres de las funciones. Hm.


Al mismo tiempo, el mismo Visual Studio tiene tal cosa, minivolcado, cuando la aplicación bloqueada genera un pequeño archivo, que luego alimenta al estudio y restaura el estado de la aplicación en el momento del bloqueo. Y puede considerar todas las variables, caminar sobre la pila con comodidad. Hm de nuevo.


Pero es bastante simple. Solo necesito frota una gruesa continuación soviética en las nalgas todos los días Rellene la pila con los valores que estaban allí en el momento de la caída y, aparentemente, restablezca el estado de los registros. Y eso es todo, ¿parece?


Nuevamente, divida esta idea en subtareas.


  1. En el microcontrolador, debe pasar por la pila, para esto necesita obtener el valor SP actual y la dirección del comienzo de la pila.
  2. En el microcontrolador, debe mostrar los valores de registro.
  3. En el IDE, necesita de alguna manera empujar todos los valores del "minivolcado" nuevamente en la pila. Y los valores de los registros también.

¿Cómo obtener el valor actual de SP?


Preferiblemente, no merodeando las manos en el ensamblador. En Cale, afortunadamente, hay una función especial (intrínseca): __current_sp() . Gcc no funcionará, pero no necesito hacerlo.


¿Cómo obtener la dirección del comienzo de la pila? Como uso mi script para proteger contra el desbordamiento (que escribí sobre aquí ), mi pila se encuentra en una sección de enlazador separada, que llamé REGION_STACK .
Esto significa que su dirección se puede encontrar en el enlazador utilizando variables extrañas con dólares en los nombres .


Por prueba y error, seleccionamos el nombre deseado: Image$$REGION_STACK$$ZI$$Limit , verifique, funciona.


Explicación

Este es un símbolo mágico que se crea en la etapa de vinculación, por lo que, estrictamente hablando, no es una constante de la etapa de compilación.
Para usarlo, necesita desreferenciar:


 extern unsigned int Image$$REGION_STACK$$ZI$$Limit; using MemPointer = const uint32_t *; //   ,   static const auto stack_upper_address = (MemPointer) &( Image$$REGION_STACK$$ZI$$Limit ); 

Si no tiene ganas de molestarse, simplemente puede codificar el tamaño de la pila, ya que cambia muy raramente. En el peor de los casos, vemos en la ventana de la pila de llamadas no todas las llamadas, sino un trozo.


¿Cómo mostrar los valores de registro?


Al principio pensé que era necesario mostrar todos los registros de propósito general en general, comencé a confundirme con el ensamblador, pero rápidamente me di cuenta de que no tendría sentido. Después de todo, la salida del minivolcado se realizará mediante una función especial para mí, no tiene sentido los valores de registro en su contexto.


Realmente solo necesitamos Link Register (LR), que almacena la dirección de retorno de la función actual, SP, con la que ya nos hemos ocupado, y Program Counter (PC), que almacena la dirección del comando actual.


Nuevamente, no pude encontrar una opción que funcionara con ningún compilador, pero nuevamente hay funciones intrínsecas para Cale: __return_address() para LR y __current_pc() para PC.
Genial Queda por devolver todos los valores del minivolcado a la pila, y los valores de los registros a los registros.


¿Cómo cargar un minivolcado en la memoria?


Al principio, planeé usar el comando LOAD debugger, que le permite cargar valores de un archivo .hex o .bin en la memoria, pero descubrí rápidamente que LOAD por alguna razón no carga valores en la RAM.
Y todavía no podría completar los registros con este comando.


Bueno, está bien, todavía requeriría demasiados gestos, convertir texto a bin, convertir bin a hexadecimal ...


Afortunadamente, Cale tiene un simulador, y para el simulador puedes escribir scripts en algún lenguaje miserable tipo C. ¡Y en este idioma hay una oportunidad para escribir en la memoria! Hay funciones especiales como _WDWORD y _WBYTE . Recopilamos todas las ideas en un montón, y obtenemos dicho código.


Todo el código:
 #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) //   ,    //   ,         scatter- extern unsigned int Image$$REGION_STACK$$ZI$$Limit; void print_minidump() { //   - armcc  arm-clang #if __CC_ARM || ( (__ARMCC_VERSION) && (__ARMCC_VERSION >= 6010050)) using MemPointer = const uint32_t *; //   ,   static const auto stack_upper_address = (MemPointer) &(Image$$REGION_STACK$$ZI$$Limit ); //      , ..      //  SP  stack_upper_address auto LR = __return_address(); auto PC = __current_pc(); auto SP = __current_sp(); auto i = 0; DEBUG_PRINTF("\nCopy the following function for simulator to .ini-file, \n" "start fresh debug session in simulator and call __load_minidump() from command window.\n" "You should be able to see the call stack in CallStack window\n\n"); DEBUG_PRINTF("func void __load_minidump() {\n "); for( MemPointer stack = (MemPointer)SP; stack <= stack_upper_address; stack++ ) { DEBUG_PRINTF("_WDWORD (0x%p, 0x%08x); ", stack, *stack ); //         if( i == 1 ) { DEBUG_PRINTF("\n "); i=0; } else { i++; } } DEBUG_PRINTF("\n LR = 0x%08x;", LR ); DEBUG_PRINTF("\n PC = 0x%08x;", PC ); DEBUG_PRINTF("\n SP = 0x%08x;", SP ); DEBUG_PRINTF("\n}\n"); #endif } 

Para cargar el minivolcado, necesitamos crear un archivo .ini, copiar la función __load_minidump en él, agregar este archivo a la ejecución automática - Project -> Options for Target -> Debug y escribir este archivo .ini en la sección "Archivo de inicialización" en la sección Usar simulador.


Ahora solo vamos a la depuración en el simulador y, sin iniciar la depuración, llamamos a la función __load_minidump() en la ventana de comandos.
Y listo, nos teletransportamos a la función print_minidump en la línea donde se guardó la PC. Y en la ventana Callstack + Locals puede ver la pila de llamadas.


Nota:

La función se nombra específicamente con dos guiones bajos al principio, porque si el nombre de la función o variable en el script de simulación coincide accidentalmente con el nombre en el código del proyecto, Cale no podrá llamarlo. El estándar C ++ prohíbe el uso de nombres con dos guiones bajos al principio, por lo que se reduce la probabilidad de que coincidan los nombres.


En principio, eso es todo. Hasta donde pude verificar, el minivolcado funciona tanto para funciones regulares como para manejadores de interrupciones. Si funcionará para todo tipo de perversiones con setjmp/longjmp o alloca , no lo sé, porque no practico perversiones.


Estoy bastante satisfecho con lo que sucedió; pequeño código, sobrecarga - macro ligeramente hinchada para hacer valer. En este caso, todo el aburrido trabajo de analizar la pila cayó sobre los hombros del IDE, donde pertenece.


Luego busqué en Google un poco y encontré algo similar para gcc y gdb: CrashCatcher .


Entiendo que no inventé nada nuevo, pero no pude encontrar una receta preparada que conduzca a un resultado similar. Estaría agradecido si me dicen qué se podría hacer mejor.

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


All Articles