Tareas dinámicas en FreeRTOS para Arduino

Tareas dinámicas en FreeRTOS para Arduino

Hola, en esta entrada te voy a mostrar cómo crear tareas dinámicas en el sistema operativo FreeRTOS para tus programas en Arduino Uno.

Tabla de contenidos

¿Qué es y qué necesito?

Existen muchas razones para utilizar un sistema operativo en nuestros programas:

  • Necesitas procesar eventos críticos lo más rápido posible (esta es la base de las aplicaciones de tiempo real fuerte, hard real time).
  • El modelo súper-loop de Arduino ya no te sirve.
  • Tu aplicación no es de tiempo real, pero tiene muchas tareas asíncronas que deben trabajar en conjunto (esta es la base de las aplicaciones de tiempo real suave, soft real time).
  • Quieres dejar de utilizar variables globales (aunque existen otras formas de lograrlo, a través de un sistema operativo es más divertido.

FreeRTOS es un sistema operativo en tiempo real escrito en C, pero que puedes utilizar con C++.

Lo primero que necesitas es el soporte de FreeRTOS para las tarjetas Arduino Uno, y este paso ya lo hice por tí. Descarga el framework Kleos para tu computadora (actualmente sólo está disponible para Linux y próximamente para Windows), descomprime el archivo en el lugar de tu preferencia, entra a la carpeta arduino-1.8.xx (al día de hoy xx=13), y finalmente arranca la IDE dando click en el archivo arduino (para Linux) o arduino.exe (para Windows). No debes instalar nada; en la página de descarga están las instrucciones.

Cuando escribes aplicaciones basadas en un sistema operativo, éstas deben ser descompuestas en tareas (o procesos. Aunque en la literatura el término más común es proceso, en FreeRTOS se le dice tarea, task en inglés). Crear tareas en FreeRTOS requiere algunos pasos:

  1. Escribir el código de las tareas.
  2. Registrar las tareas en el sistema operativo.
    1. Arrancar al sistema operativo.
  3. Configurar al sistema operativo.

Tareas

Una tarea en FreeRTOS es una función de C que no devuelve nada, tiene un sólo argumento, y muy importante, nunca debe terminar. Puedes pensar en ellas como las aplicaciones que están corriendo en tu computadora, aunque a una escala más pequeña.

A pesar de que una tarea es una función de C, existen dos características que las hacen diferentes del resto: como recién mencioné, nunca terminan, y sólo deben ser llamadas por el sistema operativo. Veamos una típica tarea:

void led_task( void* pvParameters )
{
   pinMode( 13, OUTPUT );
   // inicializamos al hardware que requiera la tarea

   const portTickType period = pdMS_TO_TICKS( 500 );
   // pdMS_TO_TICKS convierte milisegundos a ticks.
   // Internamente el sistema operativo trabaja con ticks.

   bool state = false;

   while( 1 )
   {
      digitalWrite( 13, state );
      state = !state;

      vTaskDelay( period );
      // manda "dormir" a la tarea por 'period' milisegundos
   }
}

El único parámetro que acepta la función, pvParameters, es del tipo void*. A través de este parámetro podremos pasarle datos a la tarea cuando la estemos registrando. Por lo pronto no lo utilizaremos, pero más adelante lo explicaré.

Luego, el cuerpo de la función tiene dos bloques. El bloque que está antes del ciclo infinito lo podemos utilizar para configurar hardware o variables, como lo muestra el ejemplo, o cualquier otra cosa que debe realizarse una sola vez. Después tenemos el segundo bloque el cual es un ciclo infinito; y es dentro de él que se lleva a cabo el grueso de la funcionalidad de la tarea. En el ejemplo un LED cambia de estado cada period milisegundos.

La función vTaskDelay() es parte del conjunto de funciones de FreeRTOS y se encarga de dormir a la tarea durante el tiempo indicado. Esta es una función no bloqueante, es decir, una vez que se manda llamar libera a la CPU para que otras tareas ejecuten su código. Para que te des una idea de la importancia de las funciones no bloqueantes, la función delay() de Arduino es bloqueante, es decir, nadie puede hacer nada (excepto las interrupciones) mientras el tiempo programado no haya transcurrido. Esta situación es muy mala para las aplicaciones de tiempo real.

Para indicar un ciclo infinito yo uso while( 1 ), pero también es muy común utilizar for(;;). Es cuestión de gustos, y ambas tienen el mismo resultado: la tarea nunca termina. Pero, mucha atención, aunque una tarea nunca sale propiamente de la función, sí que es posible borrarla (FreeRTOS deja de tomarla en cuenta) en caso de que no la necesitemos más. Por el momento no borraremos tareas.

En este ejemplo también podrás observar que FreeRTOS incluye algunos tipos de datos propios, como portTickType, y algunas macros que parecen funciones, como pdMS_TO_TICKS(). Es difícil anticipar a cuál tipo nativo de C pertenece portTickType, y si estás interesado deberás consultar la documentación oficial. Por otro lado, pdMS_TO_TICKS es una macro que convierte milisegundos a ticks, ya que la función vTaskDelay() utiliza ticks, pero para nosotros es más natural hablar en términos de (mili)segundos. ms. La duración del tick depende de cada aplicación, siendo valores comunes de 1ms o 10ms, aunque no es regla. El proyecto Molcajete utiliza un tick de aproximadamente 1ms, el cual es la resolución de la propia función de Arduino, delay().

Registrando las tareas de manera dinámica

Después de escribir la o las tareas, lo que sigue es registrarlas ante el FreeRTOS. A esta operación se le llama con mucha frecuencia crear la tarea, aunque la realidad es que se está registrando (sin embargo, a partir de este momento le estaré diciendo “crear” porque no quiero parecer un tipo raro). Y aquí se pone muy interesante tanto por la forma en que se crearán las tareas, como por los parámetros para su creación.

FreeRTOS permite crear tareas de dos formas diferentes: de manera dinámica o estática. Que sea una u otra depende de la forma en que se asigne la memoria para dos estructuras de datos fundamentales para cada tarea, la pila y el bloque de control (stack y Task Control Block (TCB), respectivamente).

La pila se utiliza para guardar las variables locales de la tarea, los parámetros de llamadas a funciones, la dirección de regreso de una función llamada, y en su caso, el valor devuelto por la función. Cada tarea necesita de una pila.

El bloque de control, TCB, guarda información importante de cada tarea. Por ejemplo, cuando una tarea se va a dormir debe guardar su estado. Esto es como tomar una foto (o una instantánea) de la tarea en ese preciso momento para cuando regrese más adelante. Recuerda que la tarea libera la CPU al irse a dormir para que otras tareas la usen, pero llegará el momento en que necesite ejecutarse otra vez desde el punto exacto en el que se quedó, como si nunca hubiera pasado nada. Esta información se extrae del TCB cuando la tarea está lista para ejecutarse.

En esta entrada nos concentraremos en la creación dinámica de tareas, lo cual implica que la memoria para la pila y el TCB se piden y se otorgan a través de llamadas a la función de biblioteca de C, malloc(), aunque tú no la vas a llamar directamente, sino que la función de FreeRTOS xTaskCreate() se encargará de ello, como veremos a continuación. Aquí lo importante es que estés consciente de las responsabilidades que involucra utilizar llamadas a malloc(), y quizás a free(). En una entrada posterior abundaré en este tema; y si empecé con tareas dinámicas en lugar de estáticas es porque fue la primer forma que FreeRTOS implementó tareas y porque requiere menos pasos para su creación, sin embargo, debes evitarlas en la medida de lo posible.

Una vez dicho lo anterior, veamos la firma de la función xTaskCreate() la cual crea tareas de manera dinámica, y a continuación explicaré cada uno de sus argumentos:

Función para crear tareas dinámicas.

pvTaskCode. Es el nombre de la función que implementa la tarea. En nuestro ejemplo, led_task (así, sin paréntesis). Es costumbre agregarle al nombre de la tarea la partícula _task sólo para diferenciarla con facilidad de las demás funciones.

pcName. Es una cadena de texto de C que representa un nombre que le quisiéramos dar a la tarea. Es opcional y lo puedes utilizar cuando estés depurando tus aplicaciones.

usStackDepth. Es la cantidad de memoria para la pila. Este valor está dado en words, es decir en grupos de 2 bytes o 4 bytes, o lo que es lo mismo, 16 bits o 32 bits respectivamente. Por ejemplo, en procesadores de 32 bits, como todos los de la familia ARM-Cortex, el tamaño de una word es de 32 bits, lo que significa que si tú escribes el valor 128 en este parámetro, realmente estarías pidiendo 512 bytes.

Pero hay buenas noticias. Para el caso que nos ocupa, el Arduino Uno que utiliza al procesador de 8 bits ATmega328, la conversión es uno a uno, ya que la pila de este chip es también de 8 bits. En términos prácticos, si tú escribes el valor 128 en este parámetro, realmente estás pidiendo 128 bytes.

¿Cuál es el tamaño ideal de la pila? Depende. Depende del trabajo que tenga que hacer la tarea (variables locales y profundidad de llamadas a funciones). Escoger el valor idóneo es resultado de intentar y equivocarse (trial and error). Con un valor pequeño la pila se desbordaría, y con un valor muy grande estarías desperdiciando valiosa memoria RAM. FreeRTOS incluye algunas funciones y macros que pueden ayudarte a seleccionar el valor más adecuado. Este es tema de otra entrada. Lo que sí te puedo comentar en este momento es que FreeRTOS incluye una constante con el valor mínimo para el tamaño de pila, configMINIMAL_STACK_SIZE, para el procesador ATmega328.

pvParameters. Si necesitas pasarle argumentos a la tarea aquí es donde lo haces. Si te fijas el tipo de dato de este parámetro es void*, lo que significa que puedes pasarle casi cualquier tipo válido en C, desde un simple entero hasta un tipo compuesto. En esta entrada no lo usaremos, pero en un siguiente post te explicaré cómo usarlo, tanto para pasarle información a la tarea, como para reutilizar el código de una misma tarea.

Cuando no lo utilices deberás escribir el valor NULL.

uxPriority. En un sistema operativo de tiempo real hay tareas más importantes que otras, y las tareas más importantes deben ejecutarse antes, o interrumpir, a las menos importantes. En este parámetro le indicas a FreeRTOS el nivel de importancia de la tarea. El nivel más bajo, o menos importante, es el cero, también indicado por la constante tskIDLE_PRIORITY, y el nivel más alto, o más importante, está dado por configMAX_PRIORITIES – 1. Tú decides cuántos niveles de prioridad vas a tener en tu aplicación dándole un valor a esta constante, la cual se encuentra en el archivo de configuración FreeRTOSConfig.h, del cual hablaré más adelante.

Vamos a suponer que configMAX_PRIORITIES tiene el valor 5; esto significa que tu prioridad más alta será de 4, ya que empezamos en cero. Y las tareas con prioridad 4 (o configMAX_PRIORITIES – 1) serán más importantes que las que tengan prioridad 3, o 2, o 1, o 0.

Y sí, sí es posible tener varias tareas con el mismo nivel de prioridad. Digamos que tienes 3 tareas con la misma prioridad, lo que significa que las 3 están listas para ejecutarse. En este caso FreeRTOS aplica el método Round robin (una tarea depués de la otra). Para que este esquema funcione es necesario que cada tarea libere a la CPU a través de alguno de los mecanismos de FreeRTOS para ello (dormirse, esperar por un evento, o por una liberación voluntaria a partir de una llamada a la función taskYIELD()).

pxCreatedTask. Es una referencia a la tarea recién creada. Esta referencia puede ser utilizada por otras tareas para operar sobre ella. Por ejemplo, una tarea puede decidir que otra ya no es necesaria y la quiere borrar; para ello necesitaría dicha referencia. También las funciones de comunicaciones y sincronización de FreeRTOS necesitan de esta referencia.

Cuando no lo utilices deberás escribir el valor NULL. Si sí lo vas a utilizar, pero no quieres usar una variable para guardarlo, porque además esta variable sería global, puedes pedir la referencia a la tarea justo en el lugar que la necesitas a través de una llamada a la función xTaskGetHandle() y almacenarla en una variable local. (Evita, por todos los medios posibles, utilizar variables globales.)

Valor devuelto. Si la tarea fue creada, entonces se devuelve el valor pdPASS; en caso contrario, se devuelve el valor errCOULD_NOT_ALLOCATE_REQUIRED_MEMORY. Recuerda que esta función pide memoria en tiempo de ejecución, y la memoria es finita, es decir, se va a terminar. Cuando esto sucede, entonces ya no es posible seguir otorgando memoria, y la llamada a la función xTaskCreate() falla.

Siempre que utilices funciones de creación dinámicas (xTaskCreate() es una de muchas) verifica que realmente se creó; no lo des por hecho, excepto para aplicaciones triviales.

Como habrás notado, no tuviste que llamar a la función de biblioteca malloc() para pedir la memoria de la pila y el TCB, ya que la función xTaskCreate() lo ha hecho por tí. Es por esta razón que comencé esta serie con tareas dinámicas; en una siguiente entrada hablaré de la creación de tareas estáticas, que requieren que tú crees por tu cuenta la memoria para ambos, la pila y el TCB, pero es más fácil de lo que te imaginas.

Después de tanta verborrea es momento de aplicar todo lo explicado. Vamos a registrar …, perdón, quise decir, vamos a crear la tarea asociada a nuestro ejemplo, led_task():

BaseType_t res =        // pdPASS significa que la tarea sí se creó
   xTaskCreate(
      led_task,         // es la función que implementa a la tarea
      "LED",            // es el nombre que le quisimos dar a la tarea
      128,              // es el tamaño de la pila
      NULL,             // no vamos a pasarle arguementos a la tarea
      tskIDLE_PRIORITY, // tskIDLE_PRIORITY es la prioridad más baja: 0
      NULL);            // referencia a la tarea. En este ejemplo no la usamos

   if( res != pdPASS ){

      while( 1 );
      // hubo un error creando a la tarea. No deberíamos continuar luego de este
      // punto.

   }

¿Ya podemos compilar? No, todavía no. Nos faltan dos pasos: arrancar al sistema operativo y configurarlo.

Iniciando al sistema operativo

A diferencia de las aplicaciones en nuestras computadoras de escritorio, donde el sistema operativo arranca apenas presionas el botón de encendido, en el caso de los sistemas embebidos somos nosotros quien lo debemos inicias, pero es muy simple, solamente debes llamar a la función vTaskStartScheduler().

Aún no podemos compilar, nos falta un paso muy importante: la configuración de FreeRTOS.

Configurando a FreeRTOS

FreeRTOS es un sistema operativo muy configurable, y toda la configuración y habilitación de funcionalidades se llevan a cabo en un archivo llamado FreeRTOSConfig.h. Éste lo puedes encontrar en el directorio /tu/ruta/de/instalación/arduino-1.8.13/libraries/FreeRTOS/src. Aunque el archivo incluído en el proyecto Molcajete muestra algunas opciones, en realidad hay muchas más, pero por el momento vamos a enfocarnos en las más importantes y las que tienen que ver con nuestro ejemplo.

configTOTAL_HEAP_SIZE. La memoria RAM pedida por las llamadas a la función malloc() debe salir de algún lugar. Este lugar maravilloso es el heap (o montón) y tú debes indicarle a FreeRTOS cuánta de la memoria RAM de tu chip estás dispuesto a otorgarle. Es mala idea entregar el 100% de tu RAM al heap ya que, seguramente, estarás declarando algunas variables globales (porque a veces no se pueden evitar) que necesitan guardarse en RAM. Además, el propio sistema operativo está lleno de variables, por lo que deberías dejar libre entre un 15% y un 20% de colchón.

El procesador ATmega328 tiene 2 Kilo bytes de RAM, y en el archivo de configuración solamente se le otorgaron 1500 bytes. Podrás elevar esta cifra en caso de que necesites más memoria para tu heap, pero siempre hazlo con mucho cuidado.

#define configTOTAL_HEAP_SIZE (( UBaseType_t )( 1500 ))

configSUPPORT_DYNAMIC_ALLOCATION. Esta constante debe estar puesta a 1 para que puedas utilizar tareas dinámicas (y en general, cualquier otro objeto creado con memoria dinámica, como semáforos y colas).

#define configSUPPORT_DYNAMIC_ALLOCATION 1

configMAX_PRIORITIES. Aquí estableces el número máximo de prioridades para tu aplicación. No obstante que puedes usar el número de niveles que tú quieras, debes saber que cada nivel ocupa memoria extra, por lo que este número deberías mantenerlo lo más bajo posible. También recuerda que las tareas pueden compartir el mismo nivel de prioridad.

#define configMAX_PRIORITIES 3

configMAX_TASK_NAME_LEN. ¿Recuerdas que en la función xTaskCreate() hay un parámetro para asociar un nombre (cadena de carácteres) a la tarea? Bueno, aquí estableces el número máximo de carácteres para el nombre de las tareas, incluyendo al carácter de final de cadena, \0. Puedes escribir el número que quieras, pero ten en cuenta que cada tarea creada estará reservando en memoria RAM esa cantidad de bytes. Por ejemplo, si planeas que los nombres tengan una longitud de 4 carácteres, deberas escribir 5 en esta constante. Si quisieras 8 carácteres, entonces escribirías 9, y así sucesivamente.

#define configMAX_TASK_NAME_LEN ( 5 )

INCLUDE_vTaskDelay. Escribiendo un 1 en esta constante estarás habilitando el uso de la función vTaskDelay() en tus programas. Para ahorrar memoria de programa (memoria Flash) FreeRTOS permite habilitar o deshabilitar diferentes funcionalidades. Acabas de ver la habilitación de una de ellas; y si, por ejemplo, no pretendes utilizar a la función vTaskDelayUntil(), entonces escribirías un 0 en la constante INCLUDE_vTaskDelayUntil:

#define INCLUDE_vTaskDelay      1
#define INCLUDE_vTaskDelayUntil 0

TIP: Puedes visitar este enlace para ver todas las opciones configurables posibles en FreeRTOS y su explicación.

TIP: El compilador de C sabe que si no usas una función en tu programa, entonces no debe generarle código. Esto significa que si a la constante INCLUDE_vTaskDelayUntil la pones a 1, pero nunca la llamas, entonces el compilador no le generará código, y ahorrarás un poco de memoria de programa.

Y como para este ejemplo no es necesario configurar ni habilitar más cosas llegamos al momento crucial de compilar el programa. Antes de que compiles revisemos un programa completo con la tarea de ejemplo led_task():

#include <FreeRTOS.h>
#include <task.h>

void led_task( void* pvParameters )
{
   pinMode( 13, OUTPUT );
   // inicializamos al hardware que requiera la tarea

   const portTickType period = pdMS_TO_TICKS( 500 );
   // pdMS_TO_TICKS convierte milisegundos a ticks.
   // Internamente el sistema operativo trabaja con ticks.

   bool state = false;

   while( 1 )
   {
      digitalWrite( 13, state );
      state = !state;

      vTaskDelay( period );
      // manda "dormir" a la tarea por 'period' milisegundos
   }
}

void setup() 
{
   BaseType_t res =     // pdPASS significa que la tarea sí se creó
   xTaskCreate(
      led_task,       // es la función que implementa a la tarea
      "LED",            // es el nombre que le quisimos dar a la tarea
      128,              // es el tamaño de la pila
      NULL,             // no vamos a pasarle argumentos a la tarea
      tskIDLE_PRIORITY, // tskIDLE_PRIORITY es la prioridad más baja: 0
      NULL);            // referencia a la tarea. En este ejemplo no la usamos

   if( res != pdPASS ){
      while( 1 );
      // hubo un error creando a la tarea. No deberíamos continuar luego de este
      // punto.
   }

   vTaskStartScheduler();
   // arrancamos a FreeRTOS. Éste toma control del sistema a partir de este
   // punto.


   // ¡si llegamos aquí es porque hubo un problema grave!
}

void loop()
{
   // el cuerpo de loop() queda vacío cuando usamos a FreeRTOS 
}

Este programa lo puedes encontrar como ejemplo en la IDE de Arduino en Archivo -> Ejemplo -> FreeRTOS ->dynamic_task_1, y lo puedes compilar y subir a tu tarjeta como siempre.

Presta atención a los siguientes puntos:

  • Todos los programas que usen a FreeRTOS deberán incluir a los archivos de encabezado FreeRTOS.h y task.h.
  • Si tu programa está basado en sketch, entonces deberás crear tus tareas en la función setup() de Arduino. Debes saber que es posible que una tarea cree a otra tarea, aunque ese tema lo dejaremos para después.
  • Para este ejemplo basado en sketch, en la misma función setup() inicias al sistema operativo.
  • ¡En la función loop() ya no tienes que escribir nada! A partir de ahora todo se lleva a cabo en el código de las tareas y en el propio sistema operativo.

¿Qué sigue?

¡Más ejemplos! Lo que he presentado hasta el momento es una introducción a la creación de tareas dinámicas en FreeRTOS, y en mi intento de explicar un montón de conceptos he alargado mucho esta entrada… y faltan muchos más conceptos que iré tocando en las siguientes entradas.

Pero tienes razón, necesitamos más ejemplos. Algunos de estos los encontras en la propia IDE, como ya lo expliqué, y también hay dos muy interesantes de cara a ser compilados en la consola, pero que como ejercicio los podrías adaptar a sketch. En /tu/ruta/de/instalación/arduino-1.8.13/libraries/FreeRTOS/examples hay dos ejemplos, Blink_DynamicTask y Blink_StaticTask. Este último en particular utiliza dos tareas estáticas: una que hace parpadear a un LED y otra que lee un canal analógico y transmite el valor por el puerto serial.

Espero que esta entrada haya sido de tu interés. Si fue así podrías suscribirte a mi blog, o escribirme a fjrg76 dot hotmail dot com, o compartir esta entrada con alguien que consideres que puede serle de ayuda.

Índice del curso


Fco. Javier Rodríguez
Escrito por:

Fco. Javier Rodríguez

Soy Ingeniero Electrónico con 20+ años de experiencia en el diseño y desarrollo de productos electrónicos de consumo y a medida, y 12+ años como profesor. Egresado de la UNAM, también tengo el grado de Maestro en Ingeniería por la misma universidad. Mi perfil completo lo puede encontrar en: https://www.linkedin.com/in/fjrg76-dot-com/

Ver todas las entradas

11 COMENTARIOS