En esta entrada vamos a ver distintas aproximaciones a la ejecución multitarea en un procesador como Arduino o, como se le conoce habitualmente, el problema de blink sin delay.
En primer lugar, vamos a rebajar las expectativas respecto a este comportamiento “asíncrono”. En un procesador de pequeño tamaño como Arduino, con un único núcleo y sin sistema operativo, la ejecución de dos tareas simultáneas es imposible.
Cuando nos referimos a “multitarea” o “comportamiento asíncrono” en realidad nos estamos refiriendo a la posibilidad de temporizar tareas de forma no bloqueante. Es decir, ejecutar una o varias tareas cada cierto tiempo, sin que ello suponga que no podamos hacer nada más.
Lo vamos a entender mucho mejor si lo ilustramos con el ejemplo de blink sin delay, así que vamos a dejar de hablar y meternos en harina (en el código, más bien).
Blink con delay
Empezamos recordando el archiconocido Blink, el equivalente al “Hola mundo” en el mundo de Arduino, que simplemente hace parpadear el Led de la placa cada segundo.
void setup()
{
pinMode(LED_BUILTIN, OUTPUT);
}
void loop()
{
digitalWrite(LED_BUILTIN, HIGH);
delay(1000);
digitalWrite(LED_BUILTIN, LOW);
delay(1000);
}
Como sabemos, empleamos la función delay para marcar los tiempos. El problema es que delay es una espera bloqueante, es decir, el procesador detiene el bucle de control principal durante este tiempo.
¿Qué pasa si queremos realizar otras funciones mientras parpadea el Led, como leer un sensor, recibir datos por puerto serie, activar un motor? ¿Por hacer parpadear un Led tengo que parar toda la placa?
Bueno, si esto fuera así los procesadores no serían muy interesantes. Tenemos varios mecanismos para lidiar con esto. Entre ellos están las interrupciones y los timers, pero estos están indicados para funciones más específicas.
Además, en un procesador como Arduino los timers e interrupciones son recursos valiosos y escasos. Por no decir que modificarlos puede provocar conflictos con otras funciones y librerías.
Blink sin delay
Si no tenemos unos requisitos temporales estrictos, si lo único que necesitamos es temporizar una serie de funciones que se ejecuten en un determinado instante, lo más sencillo (y habitual) es hacer una aproximación basada en el tiempo transcurrido entre eventos.
En primer lugar, como no nos apetece dejarnos los ojos mirando un Led, vamos a modificar el ejemplo de Blink para que en lugar de parpadear un Led muestre por puerto serie “ON”, “OFF”. Así podemos aprovechar para mostrar también los tiempos de disparo.
También vamos a cambiar el encendido y apagado del Led por una función toggleLed(), que simplemente cambia el estado del Led de encendido a apagado. Hacemos esto únicamente para que los ejemplos sean más sencillos, porque tenemos llamada a una única acción.
El código sería el siguiente, que básicamente es idéntico en funciones al anterior Blink con unos cambios mínimos para que sea más fácil ilustrar el ejemplo.
bool state = false;
// Muestra valores para el debug
void debug(char* text)
{
Serial.print(millis());
Serial.print('\t');
Serial.println(text);
}
void setup()
{
Serial.begin(115200);
}
void loop()
{
toggleLed()
delay(1000);
}
// Cambia el estado del Led
void toggleLed()
{
state = !state;
if (state) debug("ON");
if (!state) debug("OFF");
}
Y aquí tenemos la salida del sistema.
Ahora vamos a ver cómo hacer lo mismo que el código anterior pero sin usar la función delay(), es decir, el esperado blink sin delay.
La idea general es que, en lugar de detener la ejecución durante un determinado tiempo, vamos a dejar que este corra normalmente. En un cierto punto comprobamos el tiempo transcurrido entre disparos. Si el tiempo transcurrido es mayor que el intervalo deseado, realizaremos la acción establecida. En este ejemplo, cambiar el estado del Led con toggleLed().
//Blink without delay
unsigned long interval = 1000;
unsigned long previousMillis;
bool state = false;
void debug(String text)
{
Serial.print(millis());
Serial.print('\t');
Serial.println(text);
}
void setup()
{
Serial.begin(115200);
// Capturar el primer millis
previousMillis = millis();
}
void loop()
{
// Esta es la parte importante del ejemplo
unsigned long currentMillis = millis();
if ((unsigned long)(currentMillis - previousMillis) >= interval)
{
switchLed();
previousMillis = millis();
}
}
void switchLed()
{
state = !state;
if (state) debug("ON");
if (!state) debug("OFF");
}
Y esta es la respuesta del sistema.
Por supuesto esto tiene varias consecuencias, como que tenemos un mayor consumo energético porque el bucle de control se ejecuta continuamente, sin entrar en un estado de baja energía.
Por otro lado, si tenemos tareas con alto tiempo de ejecución, puede causar que nuestra tarea temporizada se retrase porque el procesador esté ocupado en el momento en que debería disparar nuestra acción.
Esto nos llevaría a reflexionar sobre cómo queremos que se comporte el sistema si ocurre un retraso. Si es preferible que se mantenga el tiempo entre acciones (millis() + interval) o el tiempo entre disparos (previousMillis + interval).
Multitarea en Arduino
¿Qué pasaría si tenemos que temporizar más de una acción con intervalos distintos? Pues que el código es muy similar, simplemente ahora tendremos dos fragmentos de código iguales, con su intervalo, su previousMillis, y su action() para cada tarea.
Aquí tenemos un ejemplo donde tenemos dos tareas temporizadas que simplemente muestran por puerto serie Action1 y Action2, respectivamente, en intervalos de 1000 y 800ms.
//Blink without delay multitarea
unsigned long interval1 = 1000;
unsigned long interval2 = 800;
unsigned long previousMillis1;
unsigned long previousMillis2;
void debug(String text)
{
Serial.print(millis());
Serial.print('\t');
Serial.println(text);
}
void setup() {
Serial.begin(115200);
previousMillis1 = millis();
previousMillis2 = millis();
}
void loop() {
unsigned long currentMillis = millis();
// Gestionar el desbordamiento
if ((unsigned long)(currentMillis - previousMillis1) >= interval1)
{
action1();
previousMillis1 = millis();
}
if ((unsigned long)(currentMillis - previousMillis2) >= interval2)
{
action2();
previousMillis2 = millis();
}
}
void action1()
{
debug("Action1");
}
void action2()
{
debug("Action2");
}
Aquí tenemos la salida del sistema.
Multitarea en una clase
En ejemplo anterior nos permite ver el código para temporizar una tarea tiene la misma estructura, y es susceptible de encapsularlo en una clase.
Aquí tenemos las librerías AsyncTask, MultiTask, StoryBoard, que permiten añadir tareas temporizadas de forma sencilla. Por su parte, tenemos AsyncServo y AsyncStepper para mover un servo y un motor paso a paso siguiendo la misma filosofía.
Por ejemplo, con AsyncTask el ejemplo de blink sin delay quedaría así.
#include "AsyncTaskLib.h"
AsyncTask asyncTask1(1000, []() { digitalWrite(LED_BUILTIN, HIGH); });
AsyncTask asyncTask2(2000, []() { digitalWrite(LED_BUILTIN, LOW); });
void setup()
{
pinMode(LED_BUILTIN, OUTPUT);
asyncTask1.Start();
}
void loop()
{
asyncTask1.Update(asyncTask2);
asyncTask2.Update(asyncTask1);
}
Que estaremos de acuerdo es mucho más compacto y conveniente.
FreeRTOS
La última opción que vamos a ver para conseguir multitarea en un micro procesador es emplear un SO operativo para sistemas embebidos como FreeRTOS.
FreeRTOS es un micro sistema operativo en tiempo real diseñado para sistemas embebidos, de uso gratuito, escrito en C, y compatible con más de 30 procesadores.
La filosofía general de FreeRTOS es similar a la que hemos visto en esta entrada, en la que el bucle principal se convierte en un Schedule en el que hemos registrado tareas, y FreeRTOS se encarga de dispararlas en el momento oportuno.
FreeRTOS, por supuesto, añade muchas más funciones, como la priorización de tareas o el pase de parámetros a las acciones. Además, al controlar por completo los timings, permite aprovechar los estados de baja energía y conseguir una alta eficiencia.
Sin embargo, aunque es muy ligero, en una placa como Arduino Nano ocupa más del 20% de la memoria. Pero, por ejemplo, en un ESP32 encaja perfectamente, y de hecho es la base de muchas placas basadas en este SoC.
Su uso es más complejo que lo que hemos visto en esta entrada, y merece que en el futuro le dedicaremos su propia entrada.
Descarga el código
Todo el código de esta entrada está disponible para su descarga en Github.