Está en la página 1de 38

Tema 3: Desarrollo de aplicaciones para

sistemas empotrados basados en


microcontroladores Cortex M
(PARTE I)

Sistemas Empotrados
Grado en Ingeniería de Sistemas Electrónicos
Departamento de Tecnología Electrónica
Universidad de Málaga
Protección de datos, derechos de imagen y propiedad intelectual

Queda prohibida la difusión, distribución o


divulgación de:
• La grabación de clases
• Apuntes o presentaciones
• Relaciones de ejercicios
• Exámenes
y particularmente su publicación y/o
compartición en redes sociales y servicios
dedicados a compartir apuntes.
Imagen de Clker-Free-Vector-Images en Pixabay

Con estas acciones se atenta contra el derecho fundamental a la


protección de datos, el derecho a la propia imagen y los derechos de
propiedad intelectual.
Programación en C de microcontrolaores ARM
Cortex M
– C es el lenguaje mayoritariamente utilizado en la
programación de microcontroladores actualmente.
– Lenguaje de medio nivel.
• Más legible que ensamblador
• Oculta la mayoría de los detalles de la arquitectura de la CPU y del
código máquina/juego de instrucciones.
• A la vez que permite operaciones de bajo nivel como manipulación
de bits, acceso a zonas de memoria, etc.
– Aunque C oculta la mayoría de detalles de la arquitectura,
al programar microcontroladores, es
conveniente/necesario tener algún conocimiento de la
misma en algunas situaciones.
– También es conveniente conocer el comportamiento y
funcionamiento del propio compilador
Ecosistema de heramientas para Cortex M

Compilers,
Debuggers

Micriµm
RTOS

Stacks,
Specialty, Micriµm
“Middleware“

Programmers
Compiladores de C para ARM Cortex M
– Al conjunto de herramientas de desarrollo SW (ensamblador, compilador,
enlazador, bibliotecas estándar de soporte, etc.) se le suele llamar
“toolchain”

• Prebuilt

• Herramientas toolchain

A pesar de los estándares


algunos detalles de “bajo nivel”
dependen del “fabricante”.
Code Composer Studio 11.1.0.00011
• Entorno propietario de Texas Instruments (pero basada en herramientas
comunes a otros fabricantes)
– Soporta todas las líneas de microcontroladores de TI, incluido
MSP430.
– Soporta el hardware de depuración de los kits de desarrollo
“oficiales”
– No soporta microcontroladores de otros fabricantes
• Basada en eclipse (IDE muy utilizado en diversas toolchains).
– Similar a entornos de desarrollo de otros fabricantes, también basados en eclipse.
• Compatible con el EABI estándar de ARM
– Puede enlazar con código objeto creado por otras herramientas.
• Compatible con C99 y con la mayoría de extensiones de GCC
• Gestión de proyectos y espacios de trabajo
– Control de versiones.
Estructura básica de un programa (I)

• Programación con registros


#include "inc/lm4f120h5qr.h"
Fichero de cabecera con definiciones
int main(void)
{ del dispositivo
volatile unsigned long ulLoop;

// Enable the GPIO port that is used for the on-board LED.
SYSCTL_RCGC2_R = SYSCTL_RCGC2_GPIOF;
// Do a dummy read to insert a few cycles after enabling the peripheral.
ulLoop = SYSCTL_RCGC2_R;
/* Enable the GPIO pin for the LED (PF3). Set the direction as output, and
enable the GPIO pin for digital function.*/

GPIO_PORTF_DIR_R = 0x08; Los registros se acceden como


GPIO_PORTF_DEN_R = 0x08; variables previamente declaradas
// Loop forever. El programa siempre acaba con un bucle infinito
while(1)
{
GPIO_PORTF_DATA_R |= 0x08; // Turn on the LED.
for(ulLoop = 0; ulLoop < 200000; ulLoop++); // Delay for a bit.
GPIO_PORTF_DATA_R &= ~(0x08); // Turn off the LED.
for(ulLoop = 0; ulLoop < 200000; ulLoop++); // Delay for a bit.
}
}
Estructura básica de un programa (II)

• Extracto de cabecera:
//*****************************************************************************
//
// GPIO registers (PORTC)
//
//*****************************************************************************
#define GPIO_PORTC_DATA_BITS_R ((volatile unsigned long *)0x40006000)
#define GPIO_PORTC_DATA_R (*((volatile unsigned long *)0x400063FC))
#define GPIO_PORTC_DIR_R (*((volatile unsigned long *)0x40006400))
#define GPIO_PORTC_IS_R (*((volatile unsigned long *)0x40006404))
#define GPIO_PORTC_IBE_R (*((volatile unsigned long *)0x40006408))
#define GPIO_PORTC_IEV_R (*((volatile unsigned long *)0x4000640C))
#define GPIO_PORTC_IM_R (*((volatile unsigned long *)0x40006410))
#define GPIO_PORTC_RIS_R (*((volatile unsigned long *)0x40006414))
#define GPIO_PORTC_MIS_R (*((volatile unsigned long *)0x40006418))
#define GPIO_PORTC_ICR_R (*((volatile unsigned long *)0x4000641C))
#define GPIO_PORTC_AFSEL_R (*((volatile unsigned long *)0x40006420))
#define GPIO_PORTC_DR2R_R (*((volatile unsigned long *)0x40006500))
#define GPIO_PORTC_DR4R_R (*((volatile unsigned long *)0x40006504))
#define GPIO_PORTC_DR8R_R (*((volatile unsigned long *)0x40006508))
#define GPIO_PORTC_ODR_R (*((volatile unsigned long *)0x4000650C))
...............................

Cada registro se declara como un puntero a una dirección concreta del espacio de memoria
(según el mapa de memoria del microcontrolador)
Estructura básica de un programa (III)

• Programación con API


#include "inc/hw_types.h"
#include "inc/hw_memmap.h"
Fichero de cabecera con definiciones
#include "driverlib/sysctl.h"
#include "driverlib/gpio.h" de funciones dispositivo

int main(void)
{
int LED = 2;
SysCtlClockSet(SYSCTL_SYSDIV_4|SYSCTL_USE_PLL|SYSCTL_XTAL_16MHZ|SYSCTL_OSC_MAIN);
SysCtlPeripheralEnable(SYSCTL_PERIPH_GPIOF);
GPIOPinTypeGPIOOutput(GPIO_PORTF_BASE, GPIO_PIN_1|GPIO_PIN_2|GPIO_PIN_3);
while(1)
{
Los periféricos se programan
// Turn on the LED mediante funciones
GPIOPinWrite(GPIO_PORTF_BASE, GPIO_PIN_1|GPIO_PIN_2|GPIO_PIN_3, LED);
// Delay for a bit
SysCtlDelay(2000000);
// Cycle through Red, Green and Blue LEDs
if (LED == 8) {LED = 2;} else {LED = LED*2;}
}
}
Programación con API
• Biblioteca de funciones proporcionada por el fabricante del
microcontrolador.
– Ventajas:
• Funcionamiento “probado”
• Mayor legibilidad del código de la aplicación.
• Oculta detalles de implementación y complejidad de los periféricos
(¡algunos tienen casi 100 registros!!)
• Contiene workarrounds de posibles bugs de los dispositivos.
• Pregrabadas en ROM en algunos modelos
– Desventajas:
• Potencialmente menos eficiente.
• A veces puede ser necesario conocer detalles de su implementación
interna para evitar errores en su utilización.
• En el caso de los micros de la familia TIVA ,Texas
Instruments suministra la biblioteca TIVAWare
– Misma API para todos los microcontroladores de la familia.
Interrupciones y excepciones (I)

 Aspectos HW ya comentados en Tema2 (NVIC, prioridades, ciclo de


atención interrupción…)
– Información detallada (pag. 101 Data Sheet TM4C123GH6PM)
 Familia de funciones de API Int (TIVAWare Peripheral Driver Library User´s
guide, pag 349  NVIC))
 Añade inc/hw_ints.h, driverlib/interrupt.h y driverlib/interrupt.c en tu proyecto
 #include “driverlib/sysctl.h” e #include "inc/hw_ints.h" en tu programa
 Además, cada módulo tiene sus propias funciones asociadas también
a interrupciones  P.ej. Subfamilia GPIOIntxxx
Interrupciones y excepciones (II)

• En los compiladores para microcontroladores Cortex M las


ISR/RTI son funciones normales.
– No necesitan modificador especial o directiva especial de compilación
• Esto es debido al diseño coordinado de:
– Juego de instrucciones
– Calling Convention/EABI(enhanced Aplication Binary Interface)
– Comportamiento del microcontrolador al salir y entrar en las
excepciones.
• La tabla de vectores es un array de punteros a funciones.
– Definida en el fichero xxxx_startup_ccs.c
• También figuran en dicha tabla el puntero de pila y la
dirección de comienzo del programa.
Interrupciones y excepciones (III)
• Tabla de vectores en xxxx_startup_ccs.c
#pragma DATA_SECTION(g_pfnVectors, ".intvecs")
void (* const g_pfnVectors[])(void) =
{
Cada entrada es un puntero a función
(void (*)(void))((unsigned long)&__STACK_TOP),
// The initial stack pointer
ResetISR, // The reset handler
NmiSR, // The NMI handler
FaultISR, // The hard fault handler
IntDefaultHandler, // The MPU fault handler
IntDefaultHandler, // The bus fault handler
IntDefaultHandler, // The usage fault handler
0, // Reserved
0, // Reserved
0, // Reserved
0, // Reserved
IntDefaultHandler, // SVCall handler
IntDefaultHandler, // Debug monitor handler
0, // Reserved
IntDefaultHandler, // The PendSV handler
IntDefaultHandler, // The SysTick handler
IntDefaultHandler, // GPIO Port A
IntDefaultHandler, // GPIO Port B
Manejador por defecto, permite
IntDefaultHandler, ... detectar errores parando el
IntDefaultHandler, ... programa de forma controlada
.......................... si se produce una interrupción
Interrupciones y excepciones (IV)
• xxxx_startup_ccs.c Llama a una función de la biblioteca
“runtime” del compilador que inicializa
Rutinas por defecto las variables globales y salta a la
void ResetISR(void) función main();
{

// Jump to the CCS C initialization routine. This will enable the


// floating-point unit as well, so that does not need to be done here.
__asm(" .global _c_int00\n"
" b.w _c_int00");
}

static void NmiSR(void)


{
Permiten detectar errores parando el
while(1){}; // Enter an infinite loop.
} programa de forma controlada si se
produce una interrupción o una
static void FaultISR(void) excepción
{
while(1){}; // Enter an infinite loop.
} Si se me “cuelga” el programa y al
pausar del depurador está detenido en
static void IntDefaultHandler(void) una de estas funciones, es que ha
{ ocurrido el correspondiente error
while(1){}; // Enter an infinite loop.
}
Secciones de código
• El enlazador (linker) define diferentes secciones en las que
distribuir el código y los datos.
– .cinit y .pinit: Código de inicialización y tablas de inicialización
– .data: variables globales y estáticas (locales y globales) inicializadas
– .cons: datos/cadenas constantes
– .text: código, cadenas constantes, tablas switch…
– .bss: variables globales y estáticas no inicializadas.
– .stack: zona reservada para la pila
• La pila se utiliza para el paso de parámetros, la preservación de los registros y del
estado, y para almacenar variables automáticas (variables locales no static).
– .sysmem: zona reservada para el montículo (heap)
• Aquí es donde va la memoria dinámica gestionada por las funciones malloc() y free().
• En sistemas con poca memoria y sin virtualización el uso de memoria dinámica
puede dar lugar a la FRAGMENTACIÓN del heap, y por tanto a que no se pueda
obtener memoria utilizando malloc() a pesar de haber espacio libre.
Fichero de comandos de enlazador
• Sirve para indicar al enlazador dónde debe almacenar las
diferentes secciones, en función del espacio de memoria del
microcontrolador.
• Ejemplo: tm4c123gh6pm.cmd
--retain=g_pfnVectors
MEMORY
{
FLASH (RX) : origin = 0x00000000, length = 0x00040000
SRAM (RWX) : origin = 0x20000000, length = 0x00008000
}
SECTIONS
{
.intvecs: > 0x00000000
.text : > FLASH
.const : > FLASH
.cinit : > FLASH
.pinit : > FLASH
Define el tamaño de la pila junto con
.vtable : > 0x20000000 otros parámetros del proyecto
.data : > SRAM
.bss : > SRAM
.sysmem : > SRAM (o poner __STACK_TOP=__stack+__STACK_SIZE)
.stack : > SRAM
}
__STACK_TOP = __stack + 256;
Tamaño de la pila

• Se configura entre el fichero de comandos del


enlazador y las opciones básicas del enlazador en el
proyecto

Tamaño asignado a la
pila

Tamaño asignado al
heap

Algunas funciones de la
librería estándar de C NO
FUNCIONAN sin memoria
dinámica
Más sobre la pila

• Un desbordamiento de la pila es crítico. Puede provocar un


comportamiento errático del sistema dependiendo de la posición en
memoria de la pila (sobreescritura de variables, etc).
• ARM Cortex M4 dispone de DOS punteros de pila:
– MSP: Puede ser utilizado por interrupciones (modo handler) y por programa
(modo thread).
– PSP: Sólo puede ser utilizada por el programa (modo thread)
– SP es un único registro, que “apunta” o se “mapea” en PSP o MSP según el
modo (thread/handler) y la configuración del registro CONTROL
– PSP o MSP sólo pueden accederse como tales a través de instrucciones
ensamblador especiales que sólo pueden utilizarse en modo handler o thread
privilegiado.
• ¿Qué puntero de pila/modo utiliza CCS?
– Por defecto utiliza MSP y modo thread privilegiado.
– Normalmente utiliza las posiciones más BAJAS de la RAM para la pila, de
forma que un desbordamiento normalmente producirá una excepción de error,
al implicar acceso a direcciones de memoria no válida.
Más sobre Interrupciones y excepciones (I)
• Reubicación de la tabla de vectores
– La tabla de vectores se sitúa por defecto en las posiciones bajas de la memoria
(al comienzo de la flash)
• Fichero xxxx_startup_ccs.c define un array constante de punteros a funciones que se
almacena en la sección “.int_vec”.
– La tabla de vectores en los microcontroladores Cortex M es reubicable en tiempo
de ejecución.
• Su posición viene determinada por el registro VTABLE (del NVIC/System Control), que
por defecto se inicializa a 0.
• Esto permite:
– Sustituir una tabla de vectores en ROM por otra tabla de vectores en ROM (aplicación en
bootloaders).
– Mover la tabla de vectores a la RAM y por tanto ser capaz de cambiar la función que gestiona
una interrupción o excepción de forma dinámica.
• En el caso del TIVAWare se suministra la función
IntRegister(VECTOR_INTERRUPCION,funcionManejadora)
– La primera vez que se ejecuta copia la tabla de vectores en ROM a la RAM, y cambia el
registro VTABLE.
– A partir de entonces sólo cambia la posición VECTOR_INTERRUPCION del array que está
almacenado en RAM.
Más sobre Interrupciones y excepciones (II)

• Tabla de vectores reubicable


void IntRegister(unsigned long ulInterrupt, void (*pfnHandler)(void))
{ Array de punteros a
unsigned long ulIdx, ulValue; funciones definido en el
mismo fichero
ASSERT(ulInterrupt < NUM_INTERRUPTS); // Check the arguments.
// Make sure that the RAM vector table is correctly aligned. (almacenado en RAM)
ASSERT(((unsigned long)g_pfnRAMVectors & 0x000003ff) == 0);

// See if the RAM vector table has been initialized.


if(HWREG(NVIC_VTABLE) != (unsigned long)g_pfnRAMVectors)
{
// Copy the vector table from the beginning of FLASH to the RAM vectortable.
ulValue = HWREG(NVIC_VTABLE);
for(ulIdx = 0; ulIdx < NUM_INTERRUPTS; ulIdx++)
{
g_pfnRAMVectors[ulIdx] = (void (*)(void))HWREG((ulIdx * 4) + ulValue);
}
// Point the NVIC at the RAM vector table.
HWREG(NVIC_VTABLE) = (unsigned long)g_pfnRAMVectors;
}

// Save the interrupt handler. guarda la dirección donde está


g_pfnRAMVectors[ulInterrupt] = pfnHandler;
}
almacenado el array en el
registro VTABLE
Excepciones y errores
• ¿Qué ocurre normalmente cuando se produce acceso a memoria
incorrecto?
– El microcontrolador dispone de un mecanismo para generar excepciones
asociadas a determinados errores.
– Salvo que se haya programado un comportamiento más complejo el programa
quedará detenido en la función FaultISR() que es el manejador por defecto.
– Si mi programa se queda “colgado “ y al pausar la depuración está detenido en
FaultISR() es que se ha producido algún error.
• ¿Qué excepciones soporta el Cortex M4?
– Disparadas por errores
• HardFault: Error durante la excepción o no se pudo gestionar de otra forma
• Memory Fault: Relacionadas con la protección de memoria (MMU,…)
• Bus Fault: Relacionadas con acceso incorrecto a memoria, desalineamiento,...
• Usage Fault: Relacionadas con la ejecución (código u operandos ilegales…)
– Otras:
• Interrupciones, SysTick
• PendSV: Petición de servicio al S.O. activando un bit del NVIC (cambio contexto).
• SVCall: Petición de servicio al S.O. mediante instrucción SVC.
• Reset
Posibles errores y excepciones asociadas
Excepciones y errores

• ¿Puedo depurar el error de alguna manera?


– Al pausar la depuración se pueden mirar los registros del NVIC/System
Control, algunos de los cuales contienen información sobre el error:
• NVIC_FAULT_ADDR
• NVIC_MM_ADDR
• NVIC_FAULT_STAT
– Mirando el PC almacenado en la pila cuando saltó la excepción puedo saber
en qué parte del código se produjo el posible error.
• Salvo si SP es menor que 0x20000000, ya que entonces se produjo un
desbordamiento de la pila.
• Un caso: Si se me nos olvida inicializar un periférico (por ejemplo el GPIO)
y accedemos a él, se produce un error de bus.
– Si no está habilitado el manejador de error de bus salta Hard Fault
• En una aplicación desplegada se deberían crear los manejadores de los
diferentes errores para gestionarlos en la medida de lo posible (por
ejemplo registrándolos).
Excepciones

• Entrada en excepción
– R0-R3, LR, PC y xPSR se guardan en la pila

– En LR aparece un valor especial que depende del


modo en el que se estaba ejecutando
(0xFFFFFFxx)
Excepciones

• Entrada en excepción (cont.)


– La dirección almacenada en el vector de
interrupción se almacena en PC (salto a la ISR)

Main
4

3
1
Exception Handler

Exception Vector

– Los registros no guardados automáticamente


deben ser preservados por la ISR (guardándolos
en la pila y restaurándolos antes de salir)
Excepciones
• Salida de una excepción
– De una excepción se sale escribiendo 0xFFFFFFxx en el PC.

– Puede hacerse de varias formas (LDR, LDM, POP), pero


normalmente se hace moviendo LR a PC con la instrucción de
salto “BX LR”, de forma que volvemos al mismo estado/modo que
antes de la excepción.
– Los registros R0-R3, R12, LR, xPSR y PC se restauran
automáticamente
Calling Convention
• Describe cómo el compilador implementa las llamadas a subrutinas.
– Descrito en ARM Architecture Procedure Calling Standard (AAPCS).
– Los registros R0-R3 y R12 son registros de “scratch” que se utilizan para el
paso de parámetros (y devolución de resultados).
• En el AAPCS se describe cómo se implementaría dependiendo de la cabecera de la función.
• En funciones con muchos parámetros, cuando no quedan registros de scratch los parámetros se
pasan almacenándolos en la pila.
• Estos registros de scratch pueden ser utilizados de forma temporal en la función llamada sin
salvarlos previamente porque ya los salvó la llamante
– El registro LR se utiliza para guardar la dirección de retorno (para saltar a una
subrutina se utilizan las instrucciones de branch with link como “BLX”.
– Si estaban en uso, los registros R0-R3,R12 y LR deben se guardados en la pila
por la subrutina “llamadora”
• LR se guarda en caso de anidar llamadas a subrutinas
– El resto de registros deben ser preservados por la rutina “llamada”
– El retorno desde una subrutina llamada a la subrutina llamante se realiza
normalmente moviendo el LR al PC mediante la instrucción de salto “BX LR”
• Si hay anidamiento de subrutinas al entrar se habrá salvado el LR en pila al principio,
en cuyo caso puede hacerse directamente un POP al PC.
Calling Convention (II)

Registros a
preservar que se
van a usar y que no
son de scratch (esos
están preservados
por la función que
llamó a esta función)
Ejemplo Calling Convention (I)
Codificación en ensamblador
Función C
(con nivel de optimización 3)
int funcion2(int a, int b, int c, int x) funcion2:
{ MLA.W R0, R3, R0, R1
MLA.W R0, R3, R0, R2
return (a*x*x+b*x+c); BX R14
//Polinomio grado 2, por poner un ejemplo
}

Como no hay llamadas a otras subrutinas, no salva


el LR en pila. Para salir y volver mueve el LR (R14)
a PC.

Al ser suficiente con R0-R3 que son los registros de


scratch (que contienen los parámetros a,b,c y x, no
se guarda nada en la pila.

R0 contiene el valor de vuelta


Ejemplo Calling Convention (II)
Codificación en ensamblador
Función C
(con nivel de optimización 3)
int funcion1(int a,int b,int *copia) funcion1:
{ STMDB.W R13!,{R4,R5,R6,R7,R8,R9,R10,R11,R14}
int i,j,k,l,m; SUB.W R13,R13,#44
int array[10]; MOV R10,R1
Deja espacio en la STR R2,[SP,#0x28]
m=a; pila para variables MOV R4, R0
locales (array) ..........................
for (i=0;i<10;i++)
Como llama a otras funciones, guarda el LR en pila
{ Variable local mR4
for (j=0;j<10;j++) (R13 = SP)
..........................
{ ..........................
for (k=0;k<10;k++) ..........................
{
for (l=0;l<b;l++)
{ Al salir carga el antiguo valor
m+=funcion_desconocida(i,j,k,l); del LR en el PC, volviendo a
} la subrutina llamante
} Valor devuelto se pasa por R0 ..........................
} MOV R0,R4
array[i]=m; ADD SP,#0x2C
} LDMIA.W R13!,{R4,R5,R6,R7,R8,R9,R10,R11,PC}
memcpy(copia,array,10*sizeof(int));
return m;
} Al salir libera el espacio en
pila correspondiente a las
variables locales
Calling Convention (III)
• Una función sin argumentos codificada siguiendo el AAPCS es válida como
subrutina de interrupción/excepción.
– Puede usar los registros R0-R3, R12 sin salvarlos, porque se han salvado de
forma automática
– El resto de registros son salvados antes de utilizarlos y restaurados antes de
salir.
– Finaliza moviendo LR a PC (o el LR almacenado en pila al PC en caso de
anidamiento de funciones). En un manejador de excepción esto produce la
transferencia del código expecial de salida de ISR al PC.

• ¿Y en el caso de utilizar la unidad de punto flotante?


– Los registros S0-S15 y FPSCR se guardan automáticamente al saltar a una
excepción. El resto deben ser preservados por la ISR.
– En el caso de llamada a subrutina según el AAPCS, los registros S0-S15 son
registros de scratch, que se utilizan para pasar parámetros y deben ser
guardados por la rutina llamante si estaban siendo utilizados. El resto de
registros (S16-S31) deben ser preservados por la rutina llamada.
– En cualquier caso, no suele ser habitual hacer operaciones de punto flotante
dentro de las ISR.
Depurando una excepción de error (I)
¿Tipo de error?
Depurando una excepción de error (II)
¿Dirección que lo ha producido?

Pierdo “el
árbol de
llamadas”

Dirección 0x40025500
Corresponde al GPIO F
Depurando una excepción de error (III)
¿Código que lo ha provocado (I)?

La sexta posición
contiene LR,
probablemente la
dirección de la
subrutina que llamó
a la que provocó el
error.
(0x18C5)

La séptima posición
de la pila es el PC
Que había cuando
saltó la excepción
(0x1466)
Depurando una excepción de error (III)
¿Código que lo ha provocado (II)?

PC Antiguo
Depurando una excepción de error (III)
¿Código que lo ha provocado (II)?

LR Antiguo
Depurando una excepción de error (IV)
Puedo recuperar el árbol de llamada si encuentro cualquier instrucción
que haga un BX LR ó BX R14. Anoto su dirección y mediante la
ventana de depuración escribo dicho valor en el registro PC. Al darle a
ejecutar paso a paso sale de la excepción (se mueve LR a PC) y
recupero la información del árbol de llamada.
Depurando una excepción de error (V)
Otra opción sería copiar manualmente el LR y el PC almacenados en
pila a los registros e incrementar SP en 8 posiciones (sumar 32).
Aunque no podré seguir depurando podemos ver el árbol de llamada y
tener una idea de dónde se ha producido el error

También podría gustarte