ESP32 Arduino: interrupciones Timer

Temporizadores en ESP32

El objetivo de este post es explicar cómo configurar las interrupciones del temporizador en el ESP32, usando el núcleo de Arduino. Las pruebas se realizaron en un dispositivo ESP-WROOM-32 de DFRobot integrado en un tablero ESP32 FireBeetle.

El código que se muestra aquí está basado en este ejemplo de las bibliotecas del núcleo de Arduino, que os animamos a probar. Así que, en este tutorial, comprobaremos cómo configurar el temporizador para generar periódicamente una interrupción y cómo manejarla.

Las alarmas

El ESP32 tiene dos grupos de temporizadores, cada uno con dos temporizadores de hardware de propósito general. Todos los temporizadores se basan en contadores de 64 bits y preescaladores de 16 bits.

El preescalador se utiliza para dividir la frecuencia de la señal de base (normalmente 80 MHz), que luego se utiliza para incrementar/disminuir el contador de tiempo. Dado que el prescalificador tiene 16 bits, puede dividir la frecuencia de la señal del reloj por un factor de 2 a 65536, lo que da mucha libertad de configuración.

Los contadores de tiempo pueden ser configurados para contar hacia arriba o hacia abajo y apoyar la recarga automática y la recarga de software. También pueden generar alarmas cuando alcanzan un valor específico, definido por el software [2]. El valor del contador puede ser leído por el programa de software.

Variables globales

Comenzamos nuestro código declarando algunas variables globales. La primera será un contador que será usado por la rutina de servicio de interrupción para señalar al bucle principal que ha ocurrido una interrupción.

Los ISR deben funcionar lo más rápido posible y no deben realizar operaciones largas, como escribir en el puerto serie. Por lo tanto, una buena manera de implementar el código de manejo de interrupciones es hacer que el ISR sólo señale la ocurrencia de la interrupción y difiera el manejo real (que puede contener operaciones que toman un tiempo) al bucle principal.

El contador también es útil porque si por alguna razón el manejo de una interrupción en el bucle principal toma más tiempo de lo esperado y mientras tanto se producen más interrupciones, éstas no se pierden porque el contador se incrementará en consecuencia. Por otra parte, si se utiliza una bandera como mecanismo de señalización, entonces ésta se mantendrá ajustada a la realidad y se perderán las interrupciones, ya que el bucle principal sólo supondrá que se ha producido una adicional.

Como de costumbre, ya que esta variable contador será compartida entre el bucle principal y la ISR, entonces necesita ser declarada con la palabra clave volátil, lo que evita que sea eliminada debido a las optimizaciones del compilador.

volatile int interruptCounter;

Tendremos un contador adicional para registrar cuántas interrupciones han ocurrido ya desde el comienzo del programa. Este sólo será usado por el bucle principal y por lo tanto no necesita ser declarado como volátil.

int totalInterruptCounter;

Para configurar el temporizador, necesitaremos apuntar a una variable de tipo hw_timer_t, que luego usaremos en la función de configuración de Arduino.

hw_timer_t * timer = NULL;

Finalmente, necesitaremos declarar una variable de tipo portMUX_TYPE, que utilizaremos para encargarnos de la sincronización entre el bucle principal y la ISR, cuando modifiquemos una variable compartida.

portMUX_TYPE timerMux = portMUX_INITIALIZER_UNLOCKED;

Setup function

Como de costumbre, iniciaremos nuestra función de configuración abriendo una conexión en serie, para que más tarde podamos emitir los resultados de nuestro programa para que estén disponibles en el monitor de serie IDE de Arduino.

Serial.begin(115200);

A continuación, inicializaremos nuestro temporizador con una llamada a la función timerBegin, que devolverá un puntero a una estructura de tipo hw_timer_t, que es la de la variable global timer que declaramos en la sección anterior.

Como entrada, esta función recibe el número del timer que queremos utilizar (de 0 a 3, ya que tenemos 4 timers de hardware), el valor del preescalador y un flag que indica si el contador debe contar hacia arriba (true) o hacia abajo (false).

Para este ejemplo utilizaremos el primer temporizador y pasaremos true al último parámetro, de modo que el contador cuente hacia arriba.

En cuanto al prescaler, hemos dicho en la sección introductoria que típicamente la frecuencia de la señal de base utilizada por los contadores del ESP32 es de 80 MHz (esto es cierto para la placa FireBeetle). Este valor es igual a 80 000 000 Hz, lo que significa que la señal haría que el contador de tiempo se incrementara 80 000 000 veces por segundo.

Aunque podríamos hacer los cálculos con este valor para establecer el número de contador para generar la interrupción, aprovecharemos el preescalador para simplificarlo. Así, si dividimos este valor por 80 (usando 80 como valor del prescaler), obtendremos una señal con una frecuencia de 1 MHz que hará que el contador de tiempo se incremente 1 000 000 veces por segundo.

A partir del valor anterior, si lo invertimos, sabemos que el contador se incrementará en cada microsegundo. Y así, utilizando un prescaler de 80, cuando llamemos a la función para establecer el valor del contador para generar la interrupción, estaremos especificando ese valor en microsegundos.

timer = timerBegin(0, 80, true);

Pero antes de habilitar el temporizador, debemos vincularlo a una función de manejo, que se ejecutará cuando se genere la interrupción. Esto se hace con una llamada a la función timerAttachInterrupt.

Esta función recibe como entrada un puntero al temporizador inicializado, que almacenamos en nuestra variable global, la dirección de la función que manejará la interrupción y una bandera que indica si la interrupción que se va a generar es de flanco (verdadero) o de nivel (falso). Puedes leer más sobre la diferencia entre las interrupciones de borde y de nivel aquí.

Así que, como se ha mencionado, pasaremos nuestra variable global de temporizador como primera entrada, como segunda la dirección de una función llamada onTimer que especificaremos más tarde, y como tercera el valor true, por lo que la interrupción generada es de tipo borde.

timerAttachInterrupt(timer, &onTimer, true);

A continuación usaremos la función timerAlarmWrite para especificar el valor del contador en el que se generará la interrupción del temporizador. Así, esta función recibe como primera entrada el puntero al temporizador, como segunda el valor del contador en el que se debe generar la interrupción, y como tercera una bandera que indica si el temporizador debe recargarse automáticamente al generar la interrupción.

Así, como primer argumento pasamos de nuevo nuestra variable global del temporizador, y como tercer argumento pasaremos true, de modo que el contador se recargará y así la interrupción se generará periódicamente.

En cuanto al segundo argumento, recuerde que configuramos el preescalador para que éste signifique el número de microsegundos después de los cuales debe ocurrir la interrupción. Así, para este ejemplo, asumimos que queremos generar una interrupción cada segundo, y por lo tanto pasamos el valor de 1 000 000 microsegundos, que es igual a 1 segundo.

Ten en cuenta que este valor se especifica en microsegundos sólo si especificamos el valor 80 para el preescalador. Podemos utilizar diferentes valores del prescaler y en ese caso necesitamos hacer los cálculos para saber cuándo el contador alcanzará un determinado valor.

timerAlarmWrite(timer, 1000000, true);

Terminamos nuestra función de configuración habilitando el temporizador con una llamada a la función TimerAlarmEnable, pasando como entrada nuestra variable de temporizador.

timerAlarmEnable(timer);

El código final de la función de configuración se puede ver a continuación.

void setup() {
 
  Serial.begin(115200);
 
  timer = timerBegin(0, 80, true);
  timerAttachInterrupt(timer, &onTimer, true);
  timerAlarmWrite(timer, 1000000, true);
  timerAlarmEnable(timer);
 
}

El bucle principal, main loop

Como ya se ha dicho, el bucle principal será donde realmente manejemos la interrupción del temporizador, después de que sea señalado por el ISR. Para simplificar, usaremos el sondeo para comprobar el valor del contador de la interrupción, pero naturalmente un enfoque mucho más eficiente sería usar un semáforo para bloquear el bucle principal, que luego sería desbloqueado por la ISR. Este es el enfoque utilizado en el ejemplo original.

Así, comprobaremos si la variable interruptCounter es mayor que cero y si lo es, introduciremos el código de manejo de la interrupción. Allí, lo primero que haremos es disminuir este contador, señalando que la interrupción ha sido reconocida y será manejada.

Como esta variable es compartida con la ISR, lo haremos dentro de una sección crítica, que especificamos usando un portENTER_CRITICAL y un macro portEXIT_CRITICAL. Ambas llamadas reciben como argumento la dirección de nuestra variable global portMUX_TYPE.

if (interruptCounter > 0) {
 
    portENTER_CRITICAL(&timerMux);
    interruptCounter--;
    portEXIT_CRITICAL(&timerMux);
 
    // Interrupt handling code
  }

El manejo real de las interrupciones consistirá simplemente en incrementar el contador con el número total de interrupciones que ocurrieron desde el comienzo del programa e imprimirlo en el puerto serie. Puedes comprobar abajo el código completo del bucle principal, que ya incluye esta llamada.

void loop() {
 
  if (interruptCounter > 0) {
 
    portENTER_CRITICAL(&timerMux);
    interruptCounter--;
    portEXIT_CRITICAL(&timerMux);
 
    totalInterruptCounter++;
 
    Serial.print("An interrupt as occurred. Total number: ");
    Serial.println(totalInterruptCounter);
 
  }
}

El código ISR

La rutina del servicio de interrupción debe ser una función que devuelva el vacío y no reciba argumentos.

Nuestra función será tan simple como incrementar el contador de interrupciones que señalará al bucle principal que una interrupción ha ocurrido. Esto se hará dentro de una sección crítica, declarada con las macros portENTER_CRITICAL_ISR y portEXIT_CRITICAL_ISR, que reciben ambas como parámetros de entrada la dirección de la variable global portMUX_TYPE que declaramos anteriormente.

La rutina de manejo de interrupciones debe tener el atributo IRAM_ATTR, para que el compilador pueda colocar el código en IRAM. Además, las rutinas de manejo de interrupciones sólo deben llamar a funciones también colocadas en IRAM, como se puede ver aquí en la documentación del IDF.

El código completo de esta función se puede ver a continuación.

void IRAM_ATTR onTimer() {
  portENTER_CRITICAL_ISR(&timerMux);
  interruptCounter++;
  portEXIT_CRITICAL_ISR(&timerMux);
 
}

El código final

El código fuente final de nuestro programa de interrupción periódica del temporizador se puede ver a continuación.

volatile int interruptCounter;
int totalInterruptCounter;
 
hw_timer_t * timer = NULL;
portMUX_TYPE timerMux = portMUX_INITIALIZER_UNLOCKED;
 
void IRAM_ATTR onTimer() {
  portENTER_CRITICAL_ISR(&timerMux);
  interruptCounter++;
  portEXIT_CRITICAL_ISR(&timerMux);
 
}
 
void setup() {
 
  Serial.begin(115200);
 
  timer = timerBegin(0, 80, true);
  timerAttachInterrupt(timer, &onTimer, true);
  timerAlarmWrite(timer, 1000000, true);
  timerAlarmEnable(timer);
 
}
 
void loop() {
 
  if (interruptCounter > 0) {
 
    portENTER_CRITICAL(&timerMux);
    interruptCounter--;
    portEXIT_CRITICAL(&timerMux);
 
    totalInterruptCounter++;
 
    Serial.print("An interrupt as occurred. Total number: ");
    Serial.println(totalInterruptCounter);
 
  }
}

Probando el código probando código ESP32

Para probar el código, simplemente súbelo a tu placa ESP32 y abre el monitor serial IDE de Arduino. Debería obtener una salida similar a la de la figura anterior, donde los mensajes deben ser impresos con una periodicidad de 1 segundo.

Pin It on Pinterest

Shares