Colas

Hasta el momento en este curso hemos visto tres formas de comunicación inter-tareas:

  1. Con notificaciones directas a la tarea.
  2. Con flujos.
  3. Con mensajes.

Y nos queda una por explorar: las colas. Este fue el primer mecanismo que implementó FreeRTOS para pasar grandes cantidades de datos entre tareas o entre interrupciones y tareas.

La diferencia entre los 3 mecanismos listados y las colas es que éstas puedes ser escritas y leídas por dos o más tareas. Además, la escritura y lectura se realiza estrictamente elemento por elemento.

Aunque históricamente las colas fueron el primer mecanismo para la comunicación inter-tareas, con el paso del tiempo, la retroalimentación de los usuarios de FreeRTOS, y la evolución de éste, los administradores del proyecto se dieron cuenta que podían implementar un mecanismo más ligero para pasar datos de una tarea a otra, ya que casi todo el tiempo esta es la forma de trabajar.

Pero eso no quiere decir que las colas sean obsoletas o que deban ser descartadas; todo lo contrario. En algún momento te verás en la necesidad de utilizarlas. En la sección de ejemplos vamos a ver una tarea que la hace de «controlador» o «concentrador», mientras que dos tareas «producen» datos.

Aunque en el Arduino UNO no es posible lo siguiente, piensa en una interfaz gráfica. En éstas diversos periféricos generan eventos: una pantalla touch, algún botón o teclado físico, sensores de posición, temporizadores internos, puerto serial, etc. Todas estos eventos se guardan en una cola de eventos conforme van llegando. El «controlador» de la interfaz gráfica va despachando evento por evento, desde el más antiguo al más reciente (siempre en este orden).

Además, debes saber que las colas son la estructura de datos subyacente para otros mecanismos de FreeRTOS, como los semáforos (y su API primitiva) y los temporizadores por software.

Tabla de contenidos

¿Qué es y qué necesito?

Una cola (queue, en inglés, y pronunciada como [kiú]) es una estructura de datos con las siguientes características:

  • Es lineal. Es decir, todos los elementos, excepto el primero y el último, tienen un antecesor y un predecesor.
  • Se escribe por un extremo de la cola, y se lee por el extremo opuesto. Esto logra que el elemento que llegó primero sea el primero en ser atendido (al contrario de las pilas, donde el último elemento en llegar es el primero en ser atendido). Las colas también son conocidas como estructuras FIFO (first in, first out, primero en llegar, primero en salir).
  • El acceso a los elementos de enmedio, es decir, que no sean los extremos, está prohibido.
  • Aunque las colas son un tipo especial de listas, en las colas no se puede buscar un elemento ni se puede ordenar.

Las colas tienen dos operaciones básicas, más una auxiliar (muy útil), cuyos nombres varían de autor a autor, pero que significan lo mismo:

  • Insertar en la cola: Insert(), Insertar(), InsertFront(), Enqueue(), Push(), Send().
  • Extraer de la cola: Remove(), Remover(), RemoveBack(), Retrieve(), Dequeue(), Pop(), Receive().
  • Observar el elemento en el frente de la cola. Observar(), Peek().

(Los términos en negrita corresponden a los nombres que utiliza FreeRTOS para las respectivas operaciones, aunque yo en mis clases de Estructuras de Datos uso Enqueue(), Dequeue() y Peek(), pero como este es un curso de FreeRTOS, entonces utilizaré sus nombres para evitar confusiones.)

RECUERDA: Insertamos en la parte trasera (rear, back, tail) de la cola, y extraemos del frente (front, head) de la misma.

Al igual que con los mensajes, la operación Send() copia los datos en la cola, y Receive() copia los datos desde la cola. Si de plano es prohibitivo realizar las copias dado que cada dato sea muy grande, entonces puedes crear una cola de apuntadores (o referencias) y mantener los datos en búferes externos.

Para que puedas utilizar las colas en tus programas asegúrate incluir al encabezado #include <queue.h> en cada uno de ellos. Recuerda también que si vas a crear las colas con memoria dinámica, entonces deberás poner a 1 la constante configSUPPORT_DYNAMIC_ALLOCATION; y si vas a crear las colas con memoria estática, entonces deberás poner a 1 la constante configSUPPORT_STATIC_ALLOCATION, ambas en el archivo FreeRTOSConfig.h:

#define configSUPPORT_STATIC_ALLOCATION  1
#define configSUPPORT_DYNAMIC_ALLOCATION 1

Para facilitar el uso de las colas en nuestros programas escribí 4 constantes simbólicas, las cuales vamos a utilizar en los ejemplos de esta lección. De éstas tú estableces las dos primeras; las últimas 2 se calculan de manera automática. Por supuesto que puedes escribir directamente los valores donde sea necesario, pero en mi experiencia es preferible hacerlo de esta manera:

#define QUEUE_ELEMS ( 8 )

Es el número de elementos (no de bytes) de la cola.

#define QUEUE_TYPE uint16_t

Aquí estableces el tipo de dato del o de los elementos que va a guardar la cola. En este ejemplo se trata de variables uint16_t, pero podrían ser también de otros tipos básicos o tipos compuestos, como lo verás en los ejemplos.

#define QUEUE_TYPE_SIZE ( sizeof( QUEUE_TYPE ) )

Calcula el tamaño del tipo en bytes. No deberías manipularlo directamente.

#define QUEUE_BUFFER_SIZE ( ( QUEUE_ELEMS ) * ( QUEUE_TYPE_SIZE ) )

Calcula el número de bytes que necesita el búfer subyacente de la cola. No deberías manipularlo directamente.

Desarrollo

Las colas necesitan un búfer de datos interno, es decir, un lugar dónde guardar cada elemento. Este búfer es un arreglo de elementos uint8_t de tamaño ( QUEUE_ELEMS x QUEUE_TYPE_SIZE ) que estableces cuando creas a los objetos. Cuando utilizas creación dinámica de objetos este arreglo se crea de manera automática dentro de la función xQueueCreate(), por lo cual no debes preocuparte de nada (en apariencia. Siempre que te sea posible utiliza la creación estática de objetos, es más segura); mientras que si usas la creación estática de objetos, tú eres responsable de pasarle dicho arreglo a la función xQueueCreateStatic(). A continuación veremos ambas formas.

(Para conocer o recordar la diferencia y la configuración entre objetos dinámicos y estáticos, te recomiendo leer esta lección y esta otra lecciónrespectivamente. Aunque ambas hablan sobre tareas, la teoría aplica para cualquier tipo de objeto.)

CREACIÓN DINÁMICA

La función para crear mensajes dinámicos es:

uxQueueLength. Es el número máximo de elementos (no de bytes) que la cola almacenará. Puedes utilizar el valor QUEUE_ELEMS del que hablé hace un momento.

uxItemSize. Es el número de bytes que requiere un elemento. Puedes utilizar el valor QUEUE_TYPE_SIZE del que hablé hace un momento.

Valor devuelto. Si hubo memoria suficiente para crear la cola, entonces la función devuelve el handler; en caso contrario (no hubo memoria suficiente) devuelve NULL. Este handler lo debes de guardar, tanto para que el resto de funciones de flujos sepan sobre cuál van a operar, como para probar que efectivamente fue creado.

Por ejemplo, para crear una cola 10 elementos de tipo uint16_t, utilizando las constantes simbólicas anteriores, tendrías lo siguiente:

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

QueueHandle_t g_queue_handler = NULL;
// el handler debe ser global

#define QUEUE_ELEMS ( 10 )
#define QUEUE_TYPE uint16_t
#define QUEUE_TYPE_SIZE ( sizeof( QUEUE_TYPE ) )

void setup()
{
   // código ...

   g_queue_handler = xQueueCreate( QUEUE_ELEMS, QUEUE_TYPE_SIZE );

   configASSERT( g_queue_handler );
   // Error creando a la cola

   // más código ...
}

El handler, g_queue_buffer, es global porque debe ser visible tanto en la tarea productora como en la consumidora.

La creación dinámica de objetos no asegura que los objetos se creen, principalmente por escasez de memoria RAM. Por eso siempre deberías preguntar si el objeto realmente fue creado, y aunque existen diversas maneras de hacerlo, en este ejemplo escogí la macro configASSERT() que es una versión propia de la conocida assert() de C (en el archivo FreeRTOSConfig.h podrás ver la implementación que utilicé para el projecto Molcajete). Por supuesto que puedes usar al condicional if y tratar de recuperarte sin terminar el programa de manera abrupta.

Creación estática

Ahora veamos cómo crear a un mensaje de manera estática. Como es usual, esta forma de creación de objetos necesita más pasos, pero es mucho más segura ya que los objetos siempre serán creados.

uxQueueLength. Es el número máximo de elementos (no de bytes) que la cola almacenará. Puedes utilizar el valor QUEUE_ELEMS del que hablé hace un momento.

uxItemSize. Es el número de bytes que requiere un elemento. Puedes utilizar el valor QUEUE_TYPE_SIZE del que hablé hace un momento.

pucQueueStorageBuffer. Es el búfer interno de la cola donde se guardarán los datos, y es un arreglo de tamaño ( uxQueueLength * uxItemSize ) bytes de elementos de tipo uint8_t. Este arreglo deberá existir a lo largo del programa, por lo cual deberás crearlo de manera global al programa, o estática a la función donde fue llamada xQueueCreateStatic().

pxQueueBuffer. Esta es la variable que guarda el estado de la cola. Al igual que el parámetro pucQueueStorageBuffer, deberá existir a lo largo del programa.

Valor devuelto. El handler al mensaje. La creación estática de objetos nunca falla, a menos que el arreglo o la variable de estado sean NULL, cosa que tal vez nunca suceda.

Un ejemplo de su uso es:

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

QueueHandle_t g_queue_handler = NULL;
// el handler debe ser global

#define QUEUE_ELEMS ( 10 )
#define QUEUE_TYPE uint16_t
#define QUEUE_TYPE_SIZE ( sizeof( QUEUE_TYPE ) )

void setup()
{
   // código ...

   static uint8_t       queue_buffer_array[ QUEUE_BUFFER_SIZE ];
   static StaticQueue_t queue_buffer_struct;

   g_queue_handler = xQueueCreateStatic( 
         QUEUE_ELEMS, 
         QUEUE_TYPE_SIZE,
         queue_buffer_array,
         &queue_buffer_struct );


   // más código ...
}

En este ejemplo marqué a las variables queue_buffer_array y queue_buffer_struct como static; esto para que perduren durante todo el programa, pero que a la vez sean invisibles al resto de funciones. Una alternativa es que las declares globales, aunque entre menos variables globales tengas en tus programas, mejor. Sin embargo el handler, g_message_buffer, sí que debe ser global porque tanto la tarea productora como la consumidora deben tener acceso a él. Este es un ejemplo de las veces en que sí son necesarias las variables globales. El prefijo g_ nos recuerda que es una variable global y que debemos tener mucho cuidado con ella.

Escribiendo y leyendo hacia y desde la cola

Una vez que la cola fue creada ya puedes utilizarla escribiendo en y leyendo desde ella. Cuando escribes a la cola lo haces elemento por elemento (y por copia, no lo olvides); estos elementos son variables de tipos básicos o tipos compuestos. En el Ejemplo 2 más adelante utilizaremos estos últimos.

La función para escribir en la cola es:

xQueue. Es el handler a la cola.

pvItemToQueue. Es un apuntador al dato que quieres escribir. Éste debe ser promocionado a void* (apuntador a void). Esta función escribe en la parte trasera de la cola.

xTicksToWait. Si no hay espacio disponible en la cola para copiar un nuevo elemento, entonces la tarea productora (la que llama a esta función) entrará al estado Blocked por el tiempo determinado en este parámetro. Si el tiempo expira la tarea pasará al estado Ready. El tiempo de espera está en ticks y puedes usar la macro pdMS_TO_TICKS() para convertir milisegundos a ticks. Así mismo, si escribes 0 la función regresa inmediatamente, y si escribes portMAX_DELAY el tiempo de espera es infinito (siempre y cuando la constante simbólica INCLUDE_vTaskSuspend esté a 1).

Valor devuelto. Si el elemento se copió a la cola, entonces la función devuelve pdTRUE; en caso contrario devolverá errQUEUE_FULL.

Para leer un elemento desde la cola hacia el búfer de tu programa usamos la función xQueueReceive():

xQueue. Es el handler a la cola.

pvBuffer. Es un apuntador a la variable donde quieres guardar el elemento. Esta función lee del frente de la cola.

xTicksToWait. Si la cola está vacía, entonces no hay nada qué leer, por lo tanto la tarea consumidora (la que llama a esta función) entrará al estado Blocked por el tiempo determinado en este parámetro. Si el tiempo expira la tarea pasará al estado Ready. El tiempo de espera está en ticks y puedes usar la macro pdMS_TO_TICKS() para convertir milisegundos a ticks. Así mismo, si escribes 0 la función regresa inmediatamente, y si escribes portMAX_DELAY el tiempo de espera es infinito (siempre y cuando la constante simbólica INCLUDE_vTaskSuspend esté a 1).

Valor devuelto. Si el elemento se leyó de la cola, entonces la función devuelve pdTRUE; en caso contrario devolverá pdFALSE. (Esta es una inconsistencia en la documentación oficial de FreeRTOS, ya que si xQueueSend() devuelve el valor errQUEUE_FULL en caso de que la cola esté llena, entonces xQueueReceive() debería devolver errQUEUE_EMPTY cuando la cola esté vacía, la cual ¡sí existe! y además tiene el mismo valor que pdFALSE. En lo que obtengo una respuesta por parte de FreeRTOS usemos pdFALSE, o haz que tus condicionales se prueben contra pdTRUE.)

Ejemplos

Una vez que tenemos claro el concepto de cola, su uso es bastante simple. Vamos a ver 4 ejemplos que mostrarán diversos aspectos de la utilización de las colas:

  1. Creación dinámica de una cola de tipo simple. Una tarea produce y una tarea consume.
  2. Creación estática de una cola de tipo simple. Dos tareas producen y una tarea consume.
  3. Creación estática de una cola de tipo compuesto. Dos tareas producen y una tarea consume.
  4. Creación estática de una cola de tipo compuesto. Dos tareas producen y una tarea consume. El handler es pasado en el parámetro pvParameters.

Ejemplo 1: Creación dinámica de una cola de tipo simple

Este primer ejemplo la cola es creada de manera dinámica y la tarea productora (la cual es una nada más) escribe en bloques de QUEUE_ELEMS elementos, y la tarea consumidora los consume tan pronto como es posible. El uso del ciclo for no es necesario, todo depende de la fuente de datos que desees transmitir, pero para este ejemplo se me hizo conveniente simular la llegada de n elementos y luego hacer una pausa, y repetir el procedimiento:

#define PROGRAM_NAME "queues_1.ino"

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


QueueHandle_t g_queue_handler = NULL;
// el handler debe ser global


#define QUEUE_ELEMS ( 10 )
// ¿Cuántos elementos tendrá la cola?

#define QUEUE_TYPE uint16_t
// ¿De qué tipo es el búfer?

#define QUEUE_TYPE_SIZE ( sizeof( QUEUE_TYPE ) )
// ¿Cuántos bytes ocupa una variable de tipo QUEUE_TYPE?


void producer_task( void* pvParameters )
{
   QUEUE_TYPE data = 10;

   while( 1 )
   {
      for( uint8_t i = 0; i < QUEUE_ELEMS; ++i ){
         if( xQueueSend( g_queue_handler, (void*) &data, pdMS_TO_TICKS( 100 ) ) != errQUEUE_FULL ){

            data += 10;

            vTaskDelay( pdMS_TO_TICKS( 10 ) );
            // simulamos un retardo entre escritura y escritura
         } 
         else{ // timeout:
            Serial.println( "TO(W)" );
         }
      }

      vTaskDelay( pdMS_TO_TICKS( 500 ) );
   }
}

void consumer_task( void* pvParameters )
{
   while( 1 )
   {
      QUEUE_TYPE data;

      while( xQueueReceive( g_queue_handler, &data, pdMS_TO_TICKS( 500 ) ) != errQUEUE_FULL ){
         Serial.println( data );
      }
   }
}

void setup()
{
   xTaskCreate( producer_task, "PROD", 128 * 3, NULL, tskIDLE_PRIORITY + 1, NULL );

   xTaskCreate( consumer_task, "CONS", 128 * 3, NULL, tskIDLE_PRIORITY, NULL );

   g_queue_handler = xQueueCreate( QUEUE_ELEMS, QUEUE_TYPE_SIZE );

   configASSERT( g_queue_handler );
   // Error creando a la cola

   pinMode( 13, OUTPUT );

   Serial.begin( 115200 );

   Serial.println( PROGRAM_NAME );

   vTaskStartScheduler();
}

void loop() {}

De este ejemplo nota que utilicé las constantes simbólicas que describí hace un momento, y que la variable que deseas copiar a la cola debe ser promocionada a void*, como también ya lo había mencionado:

xQueueSend( g_queue_handler, (void*) &data, pdMS_TO_TICKS( 100 ) )

Así mismo, antes de salir de la función setup() he agregado una función que imprime el nombre del programa. Esto no es necesario, pero como para este curso he creado muchos programas, nunca estoy seguro cuál es el último que subí a mis tarjetas.

La salida de este programa es:

Ejemplo 2: Creación estática de una cola de tipo simple

En este ejemplo vamos a crear una cola de un tipo básico de manera estática. Además, dos tareas van a escribir en ella, y una tarea leerá de ella. Una tarea productora escribirá valores múltiplo de 10, mientras que la otra escribirá valores múltiplo de 7, y cuando llegue al valor arbitrario 350 (no tiene nada de especial), volverá a 0. Esto lo hice así para distinguir en la salida cuál tarea productora escribió cuál valor.

En los retardos notarás valores raros que realmente son números primos. Los utilicé para que no hubiera relación entre las diferentes temporizaciones tratando de simular un ambiente real. Debido a los diferentes tiempos de escritura y lectura la cola se llenará y presentarán los mensajes: TO(P2), el cual significa timeout en el productor 2; o el mensaje TO(P1), el cual significa timeout en el productor 1 (en ambos casos significa que la cola estaba llena y no pudieron escribir en ella).

Finalmente agregué una constante, USER_DBG, que permite habilitar (en diferentes grados) o deshabilitar información de depuración. Por supuesto que esto no sería necesario en un programa real (¿o tal vez sí?) pero como estamos experimentando es interesante saber lo que sucede en el programa.

#define PROGRAM_NAME "queues_2.ino"

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


QueueHandle_t g_queue_handler = NULL;


#define QUEUE_ELEMS ( 10 )
// ¿Cuántos elementos tendrá la cola?

#define QUEUE_TYPE uint16_t
// ¿De qué tipo es el búfer?

#define QUEUE_TYPE_SIZE ( sizeof( QUEUE_TYPE ) )
// ¿Cuántos bytes ocupa una variable de tipo QUEUE_TYPE?

#define QUEUE_BUFFER_SIZE ( ( QUEUE_ELEMS ) * ( QUEUE_TYPE_SIZE ) )
// ¿Cuántos bytes tiene el búfer subyacente?


#define USER_DBG 1
// Para habilitar/deshabilitar información de depuración:
// 0: Deshabilitada
// 1: Información básica
// 2: Más información, pero puede hacer que el sistema falle (debido a Serial.print())



void producer1_task( void* pvParameters )
{
   QUEUE_TYPE data = 10;

   while( 1 )
   {

#if USER_DBG > 1
      Serial.print( "TX1: " ); Serial.println( data );
#endif  

      if( xQueueSend( g_queue_handler, (void*) &data, pdMS_TO_TICKS( 100 ) ) == pdFALSE ){
         Serial.println( "TO(P1)" );
      } else{
         data += 10;
         if( data > 1000 ) data = 10;
      }

      vTaskDelay( pdMS_TO_TICKS( 439 ) );
   }
}

void producer2_task( void* pvParameters )
{
   QUEUE_TYPE data = 0;

   while( 1 )

   {
#if USER_DBG > 1
      Serial.print( "TX2: " ); Serial.println( data );
#endif  

      if( xQueueSend( g_queue_handler, (void*) &data, pdMS_TO_TICKS( 100 ) ) != errQUEUE_FULL ){

         data += 7;
         if( data > 350 ) data = 0;

      } 
      else{ //timeout:
         Serial.println( "TO(P2)" );
      }

      vTaskDelay( pdMS_TO_TICKS( 397 ) );
   }
}

void consumer_task( void* pvParameters )
{
   TickType_t last_wake_time = xTaskGetTickCount();

   while( 1 )
   {
      QUEUE_TYPE data;

      vTaskDelayUntil( &last_wake_time, pdMS_TO_TICKS( 311 ) );

      if( xQueueReceive( g_queue_handler, &data, pdMS_TO_TICKS( 509 ) ) == pdTRUE ){

#if USER_DBG > 0
         Serial.print( "RX: " ); Serial.println( data );
#endif  

#if USER_DBG > 1
         Serial.print( "SA: " ); Serial.println( uxQueueSpacesAvailable( g_queue_handler ) );
#endif  

      } else{ //timeout:
         Serial.println( "TO(R) " );
      }
   }
}

void setup()
{
   xTaskCreate( producer1_task, "PRD1", 128, NULL, tskIDLE_PRIORITY + 1, NULL );
   xTaskCreate( producer2_task, "PRD2", 128, NULL, tskIDLE_PRIORITY + 1, NULL );

   xTaskCreate( consumer_task, "CONS", 128, NULL, tskIDLE_PRIORITY, NULL );


   static uint8_t queue_buffer_array[ QUEUE_BUFFER_SIZE ];
   // búfer subyacente de la cola

   static StaticQueue_t queue_buffer_struct;
   // variable de estado de la cola

   g_queue_handler = xQueueCreateStatic( 
         QUEUE_ELEMS, 
         QUEUE_TYPE_SIZE,
         queue_buffer_array,
         &queue_buffer_struct );


   pinMode( 13, OUTPUT );

   Serial.begin( 115200 );

   Serial.println( PROGRAM_NAME );


   vTaskStartScheduler();
}

void loop() {}

La salida de este programa con USER_DBG puesta a 1 es:

Ejemplo 3: Creación estática de una cola de tipo compuesto

En ocasiones tendremos que transferir datos compuestos debido a que así está diseñado el sistema (no todo en la vida son tipos simples), o porque, como mencioné en la introducción, necesitamos saber quién escribió qué. En el ejemplo que dí sobre un controlador para una interfaz gráfica, el «controlador» debe conocer qué periférico generó el evento. Pero dado que el tipo de la cola es fijo debemos idear una manera de homogenizar la información; esto es, que cada periférico escriba información del mismo tipo.

El ejemplo que doy a continuación es una versión alterna y completa, pero simple, de un ejemplo que FreeRTOS ha incluído en su documentación oficial para encarar dicho problema.

Lo primero que vas a notar es la enumeración Event, la cual indica la fuente generó la información. Después viene el tipo compuesto, Data, que encapsula la fuente generadora y el dato generado:

typedef enum Event
{
   Sensor1,
   Sensor2,
} Event;

typedef struct Data
{
   Event    evt;
   uint16_t value;
} Data;

Luego cada una de las dos tareas que incluí en el ejemplo se identifican de manera permanente en el campo .evt de una variable data del tipo Data, dado que lo único que va a cambiar mientras el programa se esté ejecutando es el valor escrito en el campo .value de dicha variable (que hace las veces de búfer). Una tarea productora toma valores de temperatura, mientras que otra genera una secuencia numérica, y a diferencia de lo que hemos venido haciendo, ésta se puede bloquear (entrar al estado Blocked) por tiempo indefinido (solamente como ejemplo, en la vida real deberías evitar esta situación).

Finalmente, la tarea consumidora, que hace las veces de «controlador», implementa un switch sobre el campo .evt para determinar de dónde provienen los datos y proceder de manera correcta.

#define PROGRAM_NAME "queues_3.ino"

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

typedef enum Event
{
   Sensor1,
   Sensor2,
} Event;

typedef struct Data
{
   Event    evt;
   uint16_t value;
} Data;

QueueHandle_t g_queue_handler = NULL;


#define QUEUE_ELEMS ( 5 )
// ¿Cuántos elementos tendrá la cola?

#define QUEUE_TYPE Data
// ¿De qué tipo es el búfer?

#define QUEUE_TYPE_SIZE ( sizeof( QUEUE_TYPE ) )
// ¿Cuántos bytes ocupa una variable de tipo QUEUE_TYPE?

#define QUEUE_BUFFER_SIZE ( ( QUEUE_ELEMS ) * ( QUEUE_TYPE_SIZE ) )
// ¿Cuántos bytes ocupa el búfer subyacente?


#define USER_DBG 1

void sensor1_task( void* pvParameters )
{
   QUEUE_TYPE data = { .evt = Sensor1, .value = 0 };

   while( 1 )
   {
      data.value = analogRead( A0 ) & 0x03FF;
      // sensor de temperatura LM35
      
      if( xQueueSend( g_queue_handler, (void*) &data, pdMS_TO_TICKS( 100 ) ) == errQUEUE_FULL ){
         Serial.println( "TO(S1)" );
      }

      vTaskDelay( pdMS_TO_TICKS( 457 ) );
   }
}

void sensor2_task( void* pvParameters )
{
   QUEUE_TYPE data = { .evt = Sensor2, .value = 0 };

   while( 1 )
   {
      xQueueSend( g_queue_handler, (void*) &data, portMAX_DELAY );

      data.value += 10;
      if( data.value > 500 ) data.value = 0;

      vTaskDelay( pdMS_TO_TICKS( 397 ) );
   }
}

void controller_task( void* pvParameters )
{
   TickType_t last_wake_time = xTaskGetTickCount();

   while( 1 )
   {
      QUEUE_TYPE data;

      vTaskDelayUntil( &last_wake_time, pdMS_TO_TICKS( 257 ) );

      if( xQueueReceive( g_queue_handler, &data, pdMS_TO_TICKS( 509 ) ) == pdTRUE ){

         switch( data.evt )
         {
         case Sensor1:
            Serial.print( "SENSOR1: " );
            Serial.println( data.value );
         break;

         case Sensor2:
            Serial.print( "SENSOR2: " );
            Serial.println( data.value );
         break;

         default:
            Serial.println( "ERR" );
         break;
         }

      } else{ //timeout:
         Serial.println( "TO(R) " );
      }
   }
}

void setup()
{
   xTaskCreate( sensor1_task, "SNS1", 128, NULL, tskIDLE_PRIORITY + 1, NULL );
   xTaskCreate( sensor2_task, "SNS2", 128, NULL, tskIDLE_PRIORITY + 1, NULL );

   xTaskCreate( controller_task, "CTRL", 128, NULL, tskIDLE_PRIORITY, NULL );


   static uint8_t       queue_buffer_array[ QUEUE_BUFFER_SIZE ];
   static StaticQueue_t queue_buffer_struct;

   g_queue_handler = xQueueCreateStatic( 
         QUEUE_ELEMS, 
         QUEUE_TYPE_SIZE,
         queue_buffer_array,
         &queue_buffer_struct );


   pinMode( 13, OUTPUT );

   Serial.begin( 115200 );

   Serial.println( PROGRAM_NAME );


   vTaskStartScheduler();
}

void loop() {}

La salida de este programa es:

En el ejemplo anterior escribí el valor en bruto entregado por el sensor de temperatura LM35, que al momento de ejecutar el programa reportaba valores alrededor de 52-53. Pero si quisieras ver el valor correspondiente a la temperatura podrías modificar el case de Sensor1:

case Sensor1:
  Serial.print( "SENSOR1: " );
  Serial.println( (float) (data.value*500.0/1024.0) );
break;

(En esta entrada mi blog alternativo describí las diferentes partes de la fórmula, por si te interesa conocerlas.)

La salida del programa ya con la temperatura en grados centígrados es:

Ejemplo 4: Creación estática de una cola de tipo compuesto. El handler es pasado en el parámetro pvParameters.

NOTA: La siguiente solución no funciona cuando uno de los productores o uno de los consumidores es una interrupción, ya que la única forma de que ésta tenga acceso al handler es haciéndolo global.

En todos los ejemplos vistos el handler a la cola fue declarado como global y cualquier tarea podría escribir hacia o leer desde ella. En muchas situaciones esto quizás no sea importante, pero en otras sería mejor restringir qué tareas tienen acceso a la cola.

Ya en la lección sobre Semáforos Mutex exploramos cómo limitar el acceso a un monitor solamente por las tareas interesadas. Aquella vez usamos, y hoy lo volveremos a hacer, el parámetro pvParameters (que cada tarea incluye) para pasarle el handler. De esta manera solamente las tareas involucradas podrán accesar a la cola. Así mismo, el handler a la misma deja de ser global (entre menos globales mejor) ya que la declararemos static en la función setup().

En principio esta modificación es sencilla, pero el demonio está en los detalles, y como siempre sucede en C/C++, el demonio se presenta en forma de apuntador. Voy a diseccionar el código para intentar explicar el procedimiento; al final de la sección estará el programa completo. ¡No parpadees!

En la función setup() declaramos como static el handler a la cola. Luego le pasamos a las funciones de creación de tareas la dirección del handler promocionada a void*. (El handler ya es un apuntador, así que estamos pasando la dirección del apuntador.)

void setup()
{
   static QueueHandle_t queue_handler;
   // este es el handler (internamente es un apuntador)

   xTaskCreate( 
      sensor1_task, 
      "SNS1", 
      128, 
      (void*) &queue_handler, // aquí pasamos la dirección del handler
      tskIDLE_PRIORITY + 1, 
      NULL );

   xTaskCreate( sensor2_task, "SNS2", 128, (void*) &queue_handler, tskIDLE_PRIORITY + 1, NULL );

   xTaskCreate( controller_task, "CTRL", 128, (void*) &queue_handler, tskIDLE_PRIORITY, NULL );

   // más código ...
}

Después, en cada tarea que deba recibir al handler de la cola debemos crear una variable que guarde la dirección del handler. Finalmente tenemos que desreferenciar dicha variable para accesar al handler propiamente dicho:

void sensor1_task( void* pvParameters )
{
   QueueHandle_t* queue = (QueueHandle_t*) pvParameters;
   // queue guarda la dirección del handler

   while( 1 )
   {
      if( xQueueSend( 
           *queue,        // desreferenciamos la variable queue para obtener el handler
           (void*) &data, 
           pdMS_TO_TICKS( 100 ) ) == pdFALSE ){
         
           // más código ...
      }
      // más código ...
   }
}

¡Wow, esos apuntadores que nos vuelven locos! A continuación te presento el programa completo:

#define PROGRAM_NAME "queues_4.ino"

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

typedef enum Event
{
   Sensor1,
   Sensor2,
} Event;

typedef struct Data
{
   Event    evt;
   uint16_t value;
} Data;


#define QUEUE_ELEMS ( 5 )
// ¿Cuántos elementos tendrá la cola?

#define QUEUE_TYPE Data
// ¿De qué tipo es el búfer?

#define QUEUE_TYPE_SIZE ( sizeof( QUEUE_TYPE ) )
// ¿Cuántos bytes ocupa una variable de tipo QUEUE_TYPE?

#define QUEUE_BUFFER_SIZE ( ( QUEUE_ELEMS ) * ( QUEUE_TYPE_SIZE ) )
// ¿Cuántos bytes el búfer subyacente?


void sensor1_task( void* pvParameters )
{
   QueueHandle_t* queue = (QueueHandle_t*) pvParameters;

   QUEUE_TYPE data = { .evt = Sensor1, .value = 0 };

   while( 1 )
   {
      data.value = analogRead( A0 ) & 0x03FF;
      // sensor de temperatura LM35
      
      if( xQueueSend( *queue, (void*) &data, pdMS_TO_TICKS( 100 ) ) == pdFALSE ){
         Serial.println( "TO(S1)" );
      }

      vTaskDelay( pdMS_TO_TICKS( 457 ) );
   }
}

void sensor2_task( void* pvParameters )
{
   QueueHandle_t* queue = (QueueHandle_t*) pvParameters;

   QUEUE_TYPE data = { .evt = Sensor2, .value = 0 };

   while( 1 )
   {
      xQueueSend( *queue, (void*) &data, portMAX_DELAY );

      data.value += 10;
      if( data.value > 500 ) data.value = 0;

      vTaskDelay( pdMS_TO_TICKS( 397 ) );
   }
}

void controller_task( void* pvParameters )
{
   QueueHandle_t* queue = (QueueHandle_t*) pvParameters;

   TickType_t last_wake_time = xTaskGetTickCount();

   while( 1 )
   {
      vTaskDelayUntil( &last_wake_time, pdMS_TO_TICKS( 257 ) );

      QUEUE_TYPE peek;
      if( xQueuePeek( *queue, &peek, 0 ) == pdFALSE ){
         Serial.println( "NO EVENTS" );
      } else{
         Serial.print( "NEXT EVENT: " );
         Serial.println( peek.evt );
      }

      QUEUE_TYPE data;

      if( xQueueReceive( *queue, &data, pdMS_TO_TICKS( 509 ) ) == pdTRUE ){

         switch( data.evt )
         {
         case Sensor1:
            Serial.println( (float) ( data.value*500.0/1024.0) );
         break;

         case Sensor2:
            Serial.println( data.value );
         break;

         default:
            Serial.println( "ERR" );
         break;
         }

      } else{ //timeout:
         Serial.println( "TO(R) " );
      }
   }
}

void setup()
{
   static QueueHandle_t queue_handler;

   xTaskCreate( sensor1_task, "SNS1", 128, (void*) &queue_handler, tskIDLE_PRIORITY + 1, NULL );
   xTaskCreate( sensor2_task, "SNS2", 128, (void*) &queue_handler, tskIDLE_PRIORITY + 1, NULL );

   xTaskCreate( controller_task, "CTRL", 128, (void*) &queue_handler, tskIDLE_PRIORITY, NULL );


   static uint8_t       queue_buffer_array[ QUEUE_BUFFER_SIZE ];
   static StaticQueue_t queue_buffer_struct;

   queue_handler = xQueueCreateStatic( 
         QUEUE_ELEMS, 
         QUEUE_TYPE_SIZE,
         queue_buffer_array,
         &queue_buffer_struct );


   pinMode( 13, OUTPUT );

   Serial.begin( 115200 );

   Serial.println( PROGRAM_NAME );


   vTaskStartScheduler();
}

void loop() {}

La función controller_task() usa una operación muy importante de las colas que mencioné en la introducción, la operación xQueuePeek(), la cual no habíamos utilizado hasta ahora, cuya función es devolver la copia del elemento al frente de la cola, pero sin extraerlo. No era necesaria en el ejemplo, pero quise que vieras su utilización.

La salida del programa es:

Otras funciones

La API de colas es muy grande (la puedes revisar aquí), por lo que sólo te voy a platicar de algunas funciones que pudieran ser importantes.

Colas e interrupciones

Como ya es costumbre en FreeRTOS, debes tener mucho cuidado con las funciones que llames desde dentro de una interrupción. Cuando se dé el caso asegúrate que las funciones sean thread-safe, es decir, aquellas que terminan en FromISR. Las versiones thread-safe de las funciones que hemos vistos hasta el momento son:

BaseType_t xQueueSendFromISR(    QueueHandle_t xQueue, const void *pvItemToQueue, BaseType_t *pxHigherPriorityTaskWoken );
BaseType_t xQueueReceiveFromISR( QueueHandle_t xQueue, void *pvBuffer, BaseType_t *pxHigherPriorityTaskWoken );
BaseType_t xQueuePeekFromISR(    QueueHandle_t xQueue, void *pvBuffer );

Los primeros dos argumentos son idénticos a sus contrapartes; el último, pxHigherPriorityTaskWoken, indica si una tarea de mayor prioridad a la que está ejecutándose salió del estado Blocked cuando la respectiva función fue llamada y, en consecuencia, debe ser ejecutada a continuación.

Nota que ninguna de estas funciones incluye al parámetro de tiempo de espera, xTicksToWait, ya que no tiene sentido (y jamás debería suceder) que una tarea se bloquee dentro de una interrupción.

En esta lección podrás ver cómo se utiliza el parámetro pxHigherPriorityTaskWoken en una interrupción. No creo que tengas problemas en adaptar el ejemplo para usar colas.

Funciones para ver o alterar el estado de la cola

A veces es necesario preguntar por la cantidad de elementos que aún pueden ser insertados en la cola, o por la cantidad de elementos actualmente en la cola. FreeRTOS proporciona sendas funciones:

UBaseType_t uxQueueSpacesAvailable( QueueHandle_t xQueue );
// devuelve el número de elementos que aún pueden ser insertados

UBaseType_t uxQueueMessagesWaiting( QueueHandle_t xQueue );
// devuelve el número de elementos actualmente en la cola

UBaseType_t uxQueueMessagesWaitingFromISR( QueueHandle_t xQueue );
// es la versión thread-safe de uxQueueMessagesWaiting

IMPORTANTE: Nunca uses uxQueueMessagesWaiting() ni uxQueueSpacesAvailable() en situaciones como la siguiente, porque el programa no funcionará (ya le pasó al primo de un amigo. Aquí la discusión completa):

while( 1 )
{
  if( uxQueueMessagesWaiting( queue_handler ) > 0 ){
      // aquí consumes los datos
  } 
  // El programa llega aquí si no hay elementos en la cola, pero el proceso puede ser tan
  // rápido que no "preste" la CPU y el programa se quede colgado, dado que dentro de
  // uxQueueMessagesWaiting() las interrupciones se desactivan y reactivan (en ese orden)
}

En su lugar agrega un else que llame a una función bloqueante:

while( 1 )
{
  if( uxQueueMessagesWaiting( queue_handler ) > 0 ){
      // aquí consumes los datos
  } 
  else {
     vTaskDelay( 1 ); // o cualquier otra función que se bloquée
  }
}

Cuando necesites «limpiar» la cola, sin necesidad de destruirla y volverla a crear, puedes usar a la función xQueueReset:

BaseType_t xQueueReset( QueueHandle_t xQueue );
// siempre devuelve pdTRUE. Pone a su estado original a la cola

FreeRTOS incluye un par de funciones que deben ser usadas dentro de interrupciones para preguntar si una cola está llena o vacía:

BaseType_t xQueueIsQueueFullFromISR( const QueueHandle_t xQueue );
// devuelve pdTRUE si la cola está llena; o pdFALSE en caso contrario

BaseType_t xQueueIsQueueEmptyFromISR( const QueueHandle_t xQueue );
// devuelve pdTRUE si la cola está vacía; o pdFALSE en caso contrario

NOTA: De manera extraña no existen las contrapartes de estas funciones que se puedan usar en tareas regulares.

¿Qué sigue?

Hoy vimos el 4to mecanismo que FreeRTOS incluye para llevar a cabo la comunicación inter-tareas, la cual es una actividad fundamental en cualquier programa secuencial o concurrente.

Estudiamos las funciones más importantes de la API de las colas; sin embargo, siempre podrás estudiar por tu cuenta aquellas que no vimos.

RECUERDA: Utiliza las constantes que te di al principio de esta lección, en lugar de calcular por tu cuenta los diferentes tamaños de búferes.

RECUERDA: Siempre que te sea posible utiliza objetos estáticos.

RECUERDA: Si vas a llamar a una función de FreeRTOS desde una interrupción, verifica que ésta sea la versión thread safe, es decir, tenga terminación FromISR.

RECUERDA: Las colas se utilizan cuando en la transferencia de datos intervienen dos o más productores, o dos o más consumidores. Si la transferencia se va a llevar uno-a-uno, entonces podrías usar las versiones «ligeras», como los mensajes.

Índice del curso

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


Si encuentras este blog interesante, entonces podrías considerar suscribirte a él y recibir información relevante sobre tecnología y sistemas embebidos, y de vez en cuando, uno que otro regalo.

Comunicación inter-tareas (II): Mensajes (message buffers)

En la lección anterior estudiamos un mecanismo para pasar secuencias de bytes entre tareas, o de una interrupción a una tarea. Prácticamente todos los protocolos de comunicaciones son orientados al byte, lo que pone de manifiesto la importancia de este tipos de xxx.

Sin embargo, en casi todas nuestras aplicaciones vamos a necesitar transferir información de datos de tipos simples (int, float, etc) y de datos compuestos (struct), ya sea en forma de una variable o de un arreglo. Para estas ocasiones FreeRTOS incluyó los mensajes, cuya filosofía es la misma que la de los flujos, con la diferencia de que podrás transferir información de cualquier tipo, y no sólo bytes (por byte debemos entender: char, unsigned char, uint8_t, int8_t).

En la lección del día de hoy vamos a estudiar los mensajes explicando sus detalles y viendo 4 ejemplos representativos de su utilización.

Tabla de contenidos

¿Qué es y qué necesito?

Los mensajes (message buffers, en FreeRTOS) son un mecanismo que está basado en los flujos y que sirve para transferir información compleja (más de un byte), ya sea de tipos básicos del lenguaje C o de tipos compuestos.

Aunque similiares, los mensajes tienen diferencias importantes con respecto a los flujos. Una de éstas, y la más importante, es con respecto a la integridad de la información. Con los flujos puedes leer los bytes que tú quieras, pero esto no funciona con tipos diferentes a los bytes. Imagina que quieres usar un flujo para transmitir un entero de 32 bits (el cual requiere 4 bytes), pero a la hora de leer la información lees nada más 3 bytes. Esto es claramente un error que debemos evitar (pero que nos podría pasar), y los mensajes ya lo toman en cuenta; de hecho, en los mensajes no existe el punto de disparo (trigger point).

En uno de los ejemplos de la lección anterior la tarea consumidora escribía bytes individuales, y una vez que se alcanzaba un determinado número de ellos, la tarea consumidora era despertada y consumía todos. Bueno, esto no lo puedes hacer con los mensajes, afortunadamente. Esto es, si utilizando mensajes quieres recibir un arreglo de ints, entonces la tarea productora debe enviar un arreglo de ints con el mismo número de elementos. Si quieres transferir un dato de tipo float, entonces la tarea consumidora transmitiría exactamente 4 bytes y la tarea consumidora tomará exactamente los 4 bytes. Y esto es bueno.

Para que puedas utilizar los mensajes en tus programas asegúrate que el archivo stream_buffer.c sea parte del proceso de compilación. Este archivo ya está incluído cuando descargas FreeRTOS, y en consecuencia, en el proyecto Molcajete también está donde debe de estar, por lo que no debes procuparte por ello. Así mismo, en cada archivo fuente donde uses a los mensajes deberás agregar el encabezado #include <message_buffer.h>.

(Los mensajes están basados en los flujos, por lo que no existe un archivo message_buffer.c, pero lo que sí existe es el archivo de encabezado message_buffer.h.)

También es necesario que la constante INCLUDE_xTaskGetCurrentTaskHandle esté puesta a 1 (en el archivo de configuración FreeRTOSConfig.h):

#define INCLUDE_xTaskGetCurrentTaskHandle 1

Antes de ver las funciones de la API de los mensajes debes tener en cuenta en todo momento la integridad de la información, y para esto FreeRTOS impone unas reglas a los tamaños de los búferes de datos que vamos a utilizar; si estableces un búfer al que le falte un byte de lo que FreeRTOS espera, entonces no habrá transferencia de datos. Y eso es bueno. Los tamaños de los búferes se obtienen a partir de fórmulas simples, pero que hay que seguir al pie de la letra, ya que tienen que ver con la cantidad de elementos que quieres escribir/leer y con el tamaño en bytes de cada elemento. Si los calculas más pequeños de lo que tienen que ser, entonces no habrá transferencia de datos.

Para facilitar el uso de los mensajes en nuestros programas escribí 5 constantes numéricas, las cuales vamos a utilizar en los ejemplos de esta lección. De éstas tú estableces las dos primeras ; las últimas 3 se calculan de manera automática.

#define MESSAGE_ELEMS ( 8 )

Es el número de elementos (no de bytes) que deseas escribir/leer. Esta constante la debes utilizar en ambas funciones xMessageBufferSend() y xMessageBufferReceive() (las cuales explicaré más adelante). El número de elementos puede ir desde 1.

#define MESSAGE_TYPE float

Aquí estableces el tipo de dato del o de los elementos que deseas transmitir. En este ejemplo se trata de variables float, pero pueden ser de tipo compuesto, como lo verás en los ejemplos.

#define MESSAGE_TYPE_SIZE ( sizeof( MESSAGE_TYPE ) )

Calcula el tamaño del arreglo interno subyacente al mensaje. Esta constante la utilizarás en las funciones de creación del mensaje. Más adelante explicaré la fórmula. No deberías manipularlo directamente.

#define MESSAGE_SIZE ( MESSAGE_ELEMS ) * ( MESSAGE_TYPE_SIZE )

Calcula el número de bytes que necesita una transferencia. No deberías manipularlo directamente.

#define MESSAGE_BUFFER_SIZE ( ( MESSAGE_ELEMS * MESSAGE_TYPE_SIZE ) + 4 )

Calcula el número de bytes que necesita un elemento de tipo MESSAGE_TYPE. No deberías manipularlo directamente.

La constante MESSAGE_BUFFER_SIZE requiere su propia explicación. El «manual» (es decir, la documentación oficial) dice que FreeRTOS agrega a la secuencia a transferir el número de bytes del mensaje. Esto es, si tu transferencia es de 100 bytes, entonces se transmitirán 104 bytes (ahorita explico el ‘4’). El problema es el siguiente: imagina que vas a transferir un arreglo de 5 elementos, donde cada elemento ocupa 20 bytes. Y si tú estableces que MESSAGE_BUFFER_SIZE sea de 100 bytes, entonces solamente podrás transferir, en una pasada, 4 de los 5 elementos, y siempre quedarán 16 bytes inutilizables. La solución es establecer MESSAGE_BUFFER_SIZE a 5*20+4=104 (bytes). Con esto aseguras transferir los 5 elementos en una pasada.

¿Y de dónde salió ese ‘4’? El «manual» dice que el número de bytes agregados, esos que guardan el tamaño del mensaje, deben ser equivalentes al número de bytes que ocuparía una variable del tipo size_t (éste es un alias para unsigned int), y la fórmula quedaría así:

#define MESSAGE_BUFFER_SIZE ( ( MESSAGE_ELEMS * MESSAGE_TYPE_SIZE ) + sizeof( size_t ) )

El propio manual explica que un valor común para size_t es de 32 bits, o 4 bytes, lo cual es cierto para la mayoría de sistemas, pero no para el compilador GCC-AVR utilizado por Arduino. En esta plataforma, por lo menos para el chip ATmega328, ¡size_t es de 2 bytes! Y por supuesto, aunque los programas compilarían, no funcionarían como deben (ya le pasó al primo de un amigo). Por lo tanto, en lugar de utilizar size_t en la fórmula me ví obligado a escribir el valor 4. En otras plataformas no debería existir ese problema.

Desarrollo

Los mensajes necesitan un búfer de datos interno, es decir, un lugar dónde guardar cada byte. Este búfer es un arreglo de elementos uint8_t de tamaño MESSAGE_BUFFER_SIZE cuando creas a los objetos. Cuando utilizas creación dinámica de objetos este arreglo se crea de manera automática dentro de la función xMessageBufferCreate(), por lo cual no debes preocuparte de nada (en apariencia. Siempre que te sea posible utiliza la creación estática de objetos, es más segura); mientras que si usas la creación estática de objetos, tú eres responsable de pasarle dicho arreglo a la función xMessageBufferCreateStatic(). A continuación veremos ambas formas.

(Para conocer o recordar la diferencia y la configuración entre objetos dinámicos y estáticos, te recomiendo leer esta lección y esta otra lección, respectivamente. Aunque ambas hablan sobre tareas, la teoría aplica para cualquier tipo de objeto.)

Creación dinámica

La función para crear mensajes dinámicos es:

xBufferSizeBytes. Es el número máximo de bytes que el búfer puede alojar. Para realizar transferencias completas en una sola pasada recuerda agregar size_t bytes extras, es decir, sizeof( size_t ) bytes. Puedes utilizar el valor MESSAGE_BUFFER_SIZE del que hablé hace un momento.

Valor devuelto. Si hubo memoria suficiente para crear al mensaje, entonces la función devuelve el handler; en caso contrario (no hubo memoria suficiente) devuelve NULL. Este handler lo debes de guardar, tanto para que el resto de funciones de flujos sepan sobre cuál van a operar, como para probar que efectivamente fue creado.

Nota que esta función, a diferencia de la función xStreamBufferCreate(), no incluye al parámetro para el punto de disparo, xTriggerLevelBytes.

Por ejemplo, si en tu programa quisieras transferir un arreglo de 8 valores float, entonces tendrías lo siguiente:

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

MessageBufferHandle_t g_message_buffer = NULL;
// el handler debe ser global

#define MESSAGE_ELEMS ( 8 )
#define MESSAGE_TYPE float
#define MESSAGE_TYPE_SIZE ( sizeof( MESSAGE_TYPE ) )

#define MESSAGE_BUFFER_SIZE ( ( MESSAGE_ELEMS * MESSAGE_TYPE_SIZE ) + sizeof( size_t ) )
// forma general que NO funciona para el ATmega328, pero en el ejemplo
// completo dado más adelante, ya lo arreglamos

#define MESSAGE_SIZE ( MESSAGE_ELEMS ) * ( MESSAGE_TYPE_SIZE )

void setup()
{
   // código ...

   g_message_buffer = xMessageBufferCreate( MESSAGE_BUFFER_SIZE );

   configASSERT( g_message_buffer );
   // Error creando al mensaje

   // más código ...
}

El handler, g_message_buffer, es global porque debe ser visible tanto en la tarea productora como en la consumidora.

La creación dinámica de objetos no asegura que los objetos se creen, principalmente por escasez de memoria RAM. Por eso siempre deberías preguntar si el objeto realmente fue creado, y aunque existen diversas maneras de hacerlo, en este ejemplo escogí la macro configASSERT() que es una versión propia de la conocida assert() de C (en el archivo FreeRTOSConfig.h podrás ver la implementación que utilicé para el projecto Molcajete). Por supuesto que puedes usar al condicional if y tratar de recuperarte sin terminar el programa de manera abrupta.

Creación estática

Ahora veamos cómo crear a un mensaje de manera estática. Como es usual, esta forma de creación de objetos necesita más pasos, pero es mucho más segura ya que los objetos siempre serán creados.

xBufferSizeBytes. Es el número máximo de bytes que el búfer puede alojar. Para realizar transferencias completas en una sola pasada recuerda agregar size_t bytes extras, es decir, sizeof( size_t ) bytes. Puedes utilizar el valor MESSAGE_BUFFER_SIZE del que hablé hace un momento.

pucMessageBufferStorageArea. Es el búfer interno del mensaje donde se guardarán los datos, y no es otra cosa que un arreglo de elementos de tipo uint8_t de tamaño al menos xBufferSizeBytes + 1. Este arreglo deberá existir a lo largo del programa, por lo cual deberás crearlo de manera global o estática a la función donde fue llamada xMessageBufferCreateStatic().

pxStaticMessageBuffer. Esta es la variable que guarda el estado del mensaje, es decir, la estructura de datos que maneja al mensaje (el mensaje es algo más que un simple arreglo). Al igual que el parámetro pucMessageBufferStorageArea, deberá existir a lo largo del programa.

Valor devuelto. El handler al mensaje. La creación estática de objetos nunca falla, a menos que el arreglo o la variable de estado sean NULL, cosa que tal vez nunca suceda.

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

MessageBufferHandle_t g_message_buffer = NULL;
// el handler debe ser global

#define MESSAGE_ELEMS ( 8 )
#define MESSAGE_TYPE float
#define MESSAGE_TYPE_SIZE ( sizeof( MESSAGE_TYPE ) )

#define MESSAGE_BUFFER_SIZE ( ( MESSAGE_ELEMS * MESSAGE_TYPE_SIZE ) + sizeof( size_t ) )
// forma general que NO funciona para el ATmega328, pero en el ejemplo
// completo dado más adelante, ya lo arreglamos

#define MESSAGE_SIZE ( MESSAGE_ELEMS ) * ( MESSAGE_TYPE_SIZE )

void setup()
{
   // código

   static uint8_t message_buffer_array[ MESSAGE_BUFFER_SIZE + 1 ];
   // arreglo subyacente para el búfer interno

   static StaticMessageBuffer_t message_buffer_struct;
   // variable de estado que mantiene a la estructura del mensaje

   g_message_buffer =              // handler (debe ser global)
   xMessageBufferCreateStatic( 
         MESSAGE_BUFFER_SIZE,      // tamaño del búfer interno
         message_buffer_array,     // arreglo subyacente que hace las veces de búfer interno
         &message_buffer_struct ); // variable de estado

   // los objetos estáticos siempre se crean, así que no hay necesidad de
   // preguntar si el mensaje fue creado o no

   // más código ...
}

El tamaño del arreglo subyacente (message_buffer_array[]), es decir, el búfer donde se guardan los datos, debe ser un byte más grande que el establecido en el argumento xBufferSizeBytes. En este ejemplo marqué a las variables message_buffer_array y message_buffer_struct como static; esto para que perduren durante todo el programa, pero que a la vez sean invisibles al resto de funciones. Una alternativa es que las declares globales, aunque entre menos variables globales tengas en tus programas, mejor. Sin embargo el handler, g_message_buffer, sí que debe ser global porque tanto la tarea productora como la consumidora deben tener acceso a él. Este es un ejemplo de las veces en que sí son necesarias las variables globales. El prefijo g_ nos recuerda que es una variable global y que debemos tener mucho cuidado con ella.

Escribiendo y leyendo al mensaje

Una vez que el mensaje ha sido creado ya podemos utilizarlo escribiendo en y leyendo desde él. Los datos que deseas transmitir se deben encontrar en un búfer de tu aplicación, y dependiendo de lo que desees transmitir este búfer puede ser un arreglo o una variable, ya sean tipos básicos o tipos compuestos. En los ejemplos que veremos utilizamos ambas para que veas cómo se utilizan.

La función para escribir en el mensaje es:

xMessageBuffer. Es el handler del mensaje en el que queremos escribir.

pvTxData. Es un apuntador al búfer que queremos transmitir. Dicho búfer lo deberás promocionar a void*. Es importante que recuerdes que los elementos de este búfer son copiados al mensaje.

xDataLengthBytes. Es la cantidad máxima de bytes que deseas escribir al mensaje en una sola llamada. El número lo obtienes de multiplicar el número de elementos por el tamaño en bytes que ocupa cada elemento. Puedes utilizar el valor MESSAGE_SIZE del que hablé hace un momento.

xTicksToWait. Si no hay espacio disponible para una nueva copia de xDataLengthBytes bytes en el mensaje, entonces la tarea productora (la que llama a esta función) entrará al estado Blocked por el tiempo determinado en este parámetro. Si el tiempo expira la tarea pasará al estado Ready. El tiempo de espera está en ticks y puedes usar la macro pdMS_TO_TICKS() para convertir milisegundos a ticks. Así mismo, si escribes 0 la función regresa inmediatamente, y si escribes portMAX_DELAY el tiempo de espera es infinito (siempre y cuando la constante simbólica INCLUDE_vTaskSuspend esté a 1).

Valor devuelto. Esta función devuelve el número de bytes escritos al mensaje. Para asegurarte de mantener la integridad de la información, siempre deberías verificar este valor. En los ejemplos te muestro cómo hacerlo.

La función para leer desde el mensaje es:

xMessageBuffer. Es el handler del mensaje del que queremos leer.

pvRxData. Es un apuntador al búfer en el que los datos leídos se van a guardar. Dicho búfer lo deberás promocionar a void*. Es importante que recuerdes que los elementos de este búfer son copiados desde el mensaje al búfer.

xBufferLengthBytes. Es la cantidad máxima de bytes que deseas leer desde el mensaje en una sola llamada. El número lo obtienes de multiplicar el número de elementos por el tamaño en bytes que ocupa cada elemento. Puedes utilizar el valor MESSAGE_SIZE del que hablé hace un momento.

xTicksToWait. Es el tiempo de espera máximo que la tarea consumidora estará en el estado Blocked mientras arriban bytes a partir de que el búfer estuviera vacío. El tiempo de espera está en ticks y puedes usar la macro pdMS_TO_TICKS() para convertir milisegundos a ticks. Así mismo, si escribes 0 la función regresa inmediatamente, y si escribes portMAX_DELAY el tiempo de espera es infinito (siempre y cuando la constante simbólica INCLUDE_vTaskSuspend esté a 1).

Valor devuelto. Esta función devuelve el número de bytes leídos desde el flujo. Para asegurarte de mantener la integridad de la información, siempre deberías verificar este valor. En los ejemplos te muestro cómo hacerlo.

Ejemplos

Dada la importancia de transferir información de tipos básicos y compuestos creí conveniente realizar 4 ejemplos que muestran las diferentes formas en que podemos usar a los mensajes en nuestros programas:

  1. Transfiere un arreglo de valores float. El mensaje se crea de forma dinámica.
  2. Transfiere un valor float. El mensaje se crea de forma estática.
  3. Transfiere un arreglo de variables compuestas. El mensaje se crea de forma estática.
  4. Transfiere una variable compuesta. El mensaje se crea de forma estática.

Para los ejemplos 3 y 4 tomé como plantilla al ejemplo 2, de ahí que los 3 ejemplos (2, 3 y 4) utilicé la creación estática de mensajes. Sin embargo, con lo visto en el ejemplo 1 podrás crear mensajes dinámicos sin ningún inconveniente.

Nota cómo, cuándo y dónde utilicé las constantes simbólicas que vimos hace un momento. Utilizarlas te facilitará la programación y te ahorrará los problemas que yo enfrenté. En particular las funciones xStreamBufferSend() y xStreamBufferReceive() utilizan al mismo valor MESSAGE_SIZE para indicar el número de bytes que se escriben y se leen.

Hace un rato mencioné que la fórmula general para obtener el tamaño del búfer interno del mensaje es:

#define MESSAGE_BUFFER_SIZE ( ( MESSAGE_ELEMS * MESSAGE_TYPE_SIZE ) + sizeof( size_t ) )

Pero para el caso del ATmega328, utilizando al compilador GCC-AVR, en todos los ejemplos la he modificado a:

#define MESSAGE_BUFFER_SIZE ( ( MESSAGE_ELEMS * MESSAGE_TYPE_SIZE ) + 4 )

Si tu compilador te entrega como resultado de sizeof( size_t ) el valor 4, entonces utiliza la fórmula general (para que no te involucres con magic numbers).

En las funciones de creación de las tareas notarás que utilicé como tamaño de pila el valor 128*3:

void setup()
{
   xTaskCreate( producer_task, "PROD", 128 * 3, NULL, tskIDLE_PRIORITY + 1, NULL );
   xTaskCreate( consumer_task, "CONS", 128 * 3, NULL, tskIDLE_PRIORITY, NULL );
   // ...
}

En lecciones anteriores estuve usando el valor 128, porque a los más declarábamos una o dos variables dentro de las tareas. Sin embargo, en esta lección (y en la anterior) hemos estado declarando arreglos de valores dentro de las tareas, y la memoria para estos arreglos es tomada de la pila de la tarea, por lo cual el tamaño de la pila debe considerarlos. Quizás exageré en un valor tan grande, pero tú siempre podrás calcularlo de acuerdo a las necesidades de tus aplicaciones.

En la lección anterior ya había advertido de lo miserable que se comportan las funciones Serial.print() y similares en programas concurrentes. Por supuesto que este triste comportamiento también aparece con los mensajes. Por eso notarás que en los ejemplos agregué información de depuración que puede ser habilitada o deshabilitada a tu gusto, estableciendo un valor conveniente en la constante USER_DBG:

#define USER_DBG 1
// Para habilitar/deshabilitar información de depuración:
// 0: Deshabilitada
// 1: Información básica
// 2: Más información, pero puede hacer que el sistema falle (debido a Serial.print())

Ejemplo 1: Transfiere un arreglo de valores float. El mensaje se crea de forma dinámica.

Este ejemplo muestra cómo transferir un arreglo de valores float. Puedes modificarlo para usarlo con valores enteros, int, o reales dobles, double.

Así mismo, y como ya había mencionado, deberías de verificar que el número de bytes transmitidos efectivamente es el mismo que tú especificaste:

if( bytes_sent != MESSAGE_SIZE ){ // timeout:
   Serial.print( "TO(W): " );
} 

Obviamente debes hacer la misma prueba del lado de la función consumidora:

if( received_bytes < MESSAGE_SIZE ) { // timeover:
   Serial.println( "TO(R)" );
} 

El ejemplo completo es el siguiente:

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


MessageBufferHandle_t g_message_buffer = NULL;


#define MESSAGE_ELEMS ( 8 )
// ¿Cuántos elementos tendrá el búfer de mensajes del USUARIO?

#define MESSAGE_TYPE float
// ¿De qué tipo es el búfer de mensajes?

#define MESSAGE_TYPE_SIZE ( sizeof( MESSAGE_TYPE ) )
// ¿Cuántos bytes ocupa una variable de tipo MESSAGE_TYPE?

#define MESSAGE_BUFFER_SIZE ( ( MESSAGE_ELEMS * MESSAGE_TYPE_SIZE ) + 4 )
// ¿Cuántos bytes ocupa el búfer INTERNO de mensajes?

#define MESSAGE_SIZE ( MESSAGE_ELEMS ) * ( MESSAGE_TYPE_SIZE )
// ¿Cuántos bytes ocupa el stream de mensajes que se transmitirá/recibirá?


#define USER_DBG 1
// Para habilitar/deshabilitar información de depuración:
// 0: Deshabilitada
// 1: Información básica
// 2: Más información, pero puede hacer que el sistema falle (debido a Serial.print())


void producer_task( void* pvParameters )
{
   MESSAGE_TYPE out_buffer[ MESSAGE_ELEMS ];

   uint8_t index = 0;

   float data = 0.5;

   while( 1 )
   {
      vTaskDelay( pdMS_TO_TICKS( 500 ) );

      for( uint8_t i = 0; i < MESSAGE_ELEMS; ++i ){
         
         out_buffer[ i ] = data;

         data += 0.5;

      }

      size_t bytes_sent = 
         xMessageBufferSend( 
            g_message_buffer,
            (void*) out_buffer,
            MESSAGE_SIZE,
            pdMS_TO_TICKS( 50 ) );

#if USER_DBG > 0
      Serial.print( "ENVIADOS: " );
      Serial.println( bytes_sent );
#endif

      if( bytes_sent != MESSAGE_SIZE ){ // timeout:
         Serial.print( "TO(W): " );
      } 
   }
}

void consumer_task( void* pvParameters )
{
   MESSAGE_TYPE in_buffer[ MESSAGE_ELEMS ];

   while( 1 )
   {
      size_t received_bytes = 
         xMessageBufferReceive(
            g_message_buffer,
            (void*) in_buffer,
            MESSAGE_SIZE,
            pdMS_TO_TICKS( 1000 ) );

#if USER_DBG > 0
      Serial.print( "RECIBIDOS: " );
      Serial.println( received_bytes );
#endif

      if( received_bytes < MESSAGE_SIZE ) { // timeover:
         Serial.println( "TO(R)" );
      } 

#if USER_DBG > 1
      else{
         for( size_t i = 0; i < received_bytes; ++i ){
            Serial.println( in_buffer[ i ] );
         }
      }
#endif
   }
}


void setup()
{
   xTaskCreate( producer_task, "PROD", 128 * 3, NULL, tskIDLE_PRIORITY + 1, NULL );

   xTaskCreate( consumer_task, "CONS", 128 * 3, NULL, tskIDLE_PRIORITY, NULL );

   g_message_buffer = xMessageBufferCreate( MESSAGE_BUFFER_SIZE );

   configASSERT( g_message_buffer );
   // Error creando al flujo

   pinMode( 13, OUTPUT );

   Serial.begin( 115200 );

   vTaskStartScheduler();
}

void loop() {}

Una salida de este programa con la constante USER_DBG puesta a 1 es:

Ejemplo 2: Transfiere un valor float. El mensaje se crea de forma estática

Este ejemplo muestra cómo transferir un sólo valor float. Puedes modificarlo para usarlo con valores enteros, int, o reales dobles, double. Presta especial atención a la constante MESSAGE_ELEMS la cual está puesta a 1, ya que es éste el número de elementos que queremos transferir. Así mismo, como no tenemos necesidad de declarar un arreglo, pasaremos a las funciones de escritura/lectura la dirección de una variable float, la cual hace las veces de búfer (sí, una simple variable puede ser un búfer, todo depende del contexto).

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


MessageBufferHandle_t g_message_buffer = NULL;

#define MESSAGE_ELEMS ( 1 )
// ¿Cuántos elementos tendrá el búfer de mensajes del USUARIO?

#define MESSAGE_TYPE float
// ¿De qué tipo es el búfer de mensajes?

#define MESSAGE_TYPE_SIZE ( sizeof( MESSAGE_TYPE ) )
// ¿Cuántos bytes ocupa una variable de tipo MESSAGE_TYPE?

#define MESSAGE_BUFFER_SIZE ( ( MESSAGE_ELEMS * MESSAGE_TYPE_SIZE ) + 4 )
// ¿Cuántos bytes ocupa el búfer INTERNO de mensajes?

#define MESSAGE_SIZE ( MESSAGE_ELEMS ) * ( MESSAGE_TYPE_SIZE )
// ¿Cuántos bytes ocupa el stream de mensajes que se transmitirá/recibirá?


#define USER_DBG 2
// Para habilitar/deshabilitar información de depuración:
// 0: Deshabilitada
// 1: Información básica
// 2: Más información, pero puede hacer que el sistema falle (debido a Serial.print())


void producer_task( void* pvParameters )
{
   float data = 0.0;

   while( 1 )
   {

      vTaskDelay( pdMS_TO_TICKS( 500 ) );
      data += 0.5;

      size_t bytes_sent = 
         xMessageBufferSend( 
            g_message_buffer,
            (void*) &data,
            MESSAGE_SIZE,
            pdMS_TO_TICKS( 50 ) );

#if USER_DBG > 0
      Serial.print( "ENVIADOS: " );
      Serial.println( bytes_sent );
#endif

      if( bytes_sent != MESSAGE_SIZE ){ // timeout:
         Serial.print( "TO(W): " );
      } 
   }
}

void consumer_task( void* pvParameters )
{
   float data = 0.0;

   while( 1 )
   {
      size_t received_bytes = 
         xMessageBufferReceive(
            g_message_buffer,
            (void*) &data,
            MESSAGE_SIZE,
            pdMS_TO_TICKS( 1000 ) );

#if USER_DBG > 0
      Serial.print( "RECIBIDOS: " );
      Serial.println( received_bytes );
#endif

      if( received_bytes < MESSAGE_SIZE ) { // timeover:
         Serial.println( "TO(R)" );
      } 

#if USER_DBG > 1
      else{
         Serial.print( "DATA: " );
         Serial.println( data );
      }
#endif
   }
}

void setup()
{
   xTaskCreate( producer_task, "PROD", 128 * 3, NULL, tskIDLE_PRIORITY + 1, NULL );

   xTaskCreate( consumer_task, "CONS", 128 * 3, NULL, tskIDLE_PRIORITY, NULL );


   static uint8_t message_buffer_array[ MESSAGE_BUFFER_SIZE + 1 ];
   // arreglo subyacente para el búfer interno

   static StaticMessageBuffer_t message_buffer_struct;
   // variable de estado que mantiene a la estructura del mensaje

   g_message_buffer = 
   xMessageBufferCreateStatic( 
         MESSAGE_BUFFER_SIZE,
         message_buffer_array,
         &message_buffer_struct );


   pinMode( 13, OUTPUT );

   Serial.begin( 115200 );

   vTaskStartScheduler();
}

void loop() {}

La salida de este programa, con la constante USER_DBG puesta a 2 para imprimir el valor transferido, ya que la carga de Serial.print() no es tan alta (es sólo un valor float), es:

Ejemplo 3:Transfiere un arreglo de variables compuestas. El mensaje se crea de forma estática

Aunque es muy útil transferir variables o arreglos de tipos básicos, es igual de útil transferir información más compleja. En este ejemplo (y en el siguiente) tendremos un tipo compuesto Message (tú puedes darle el nombre que mejor se adapte en tu aplicación):

typedef struct Message_t
{
   uint16_t cont;
   float temp;
} Message;

La idea de este tipo compuesto es tener dos variables de diferentes tipos lógicamente relacionadas bajo un mismo nombre (lo cual coincide con la definición de estructura en C). El campo cont simplemente es un contador ascendente, mientras que el campo temp es la temperatura ambiente (a través de un sensor LM35). El ejemplo transferirá un arreglo de 4 elementos de tipo Message.

Nota que la constante MESSAGE_TYPE ahora está puesta al nombre que le dimos a la estructura, es decir:

#define MESSAGE_TYPE Message

El ejemplo completo es:

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


MessageBufferHandle_t g_message_buffer = NULL;

typedef struct Message_t
{
   uint16_t cont;
   float temp;
} Message;


#define MESSAGE_ELEMS ( 4 )
// ¿Cuántos elementos tendrá el búfer de mensajes del USUARIO?

#define MESSAGE_TYPE Message
// ¿De qué tipo es el búfer de mensajes?

#define MESSAGE_TYPE_SIZE ( sizeof( MESSAGE_TYPE ) )
// ¿Cuántos bytes ocupa una variable de tipo MESSAGE_TYPE?

#define MESSAGE_BUFFER_SIZE ( ( MESSAGE_ELEMS * MESSAGE_TYPE_SIZE ) + 4 )
// ¿Cuántos bytes ocupa el búfer INTERNO de mensajes?

#define MESSAGE_SIZE ( MESSAGE_ELEMS ) * ( MESSAGE_TYPE_SIZE )
// ¿Cuántos bytes ocupa el stream de mensajes que se transmitirá/recibirá?


#define USER_DBG 1
// Para habilitar/deshabilitar información de depuración:
// 0: Deshabilitada
// 1: Información básica
// 2: Más información, pero puede hacer que el sistema falle (debido a Serial.print())



void producer_task( void* pvParameters )
{
   Message data_tx[ MESSAGE_ELEMS ] = { 0 };

   while( 1 )
   {
      for( size_t i = 0; i < MESSAGE_ELEMS; ++i ){

         vTaskDelay( pdMS_TO_TICKS( 100 ) );

         data_tx[ i ].cont++;
         data_tx[ i ].temp = ( analogRead( A0 ) * 5.0 * 100.0 ) / 1024.0;
      }

      size_t bytes_sent = 
         xMessageBufferSend( 
            g_message_buffer,
            (void*) data_tx,
            MESSAGE_SIZE,
            pdMS_TO_TICKS( 50 ) );

#if USER_DBG > 0
      Serial.print( "ENVIADOS: " );
      Serial.println( bytes_sent );
#endif

      if( bytes_sent != MESSAGE_SIZE ){ // timeout:
         Serial.print( "TO(W): " );
      } 
   }
}

void consumer_task( void* pvParameters )
{
   Message data_rx[ MESSAGE_ELEMS ] = { 0 };

   while( 1 )
   {
      size_t received_bytes = 
         xMessageBufferReceive(
            g_message_buffer,
            (void*) data_rx,
            MESSAGE_SIZE,
            pdMS_TO_TICKS( 1000 ) );

#if USER_DBG > 0
      Serial.print( "RECIBIDOS: " );
      Serial.println( received_bytes );
#endif

      if( received_bytes < MESSAGE_SIZE ) { // timeover:
         Serial.println( "TO(R)" );
      } 

#if USER_DBG > 1
      else{
         for( size_t i = 0; i < MESSAGE_ELEMS; ++i ){

            Serial.print( "CONT: " );
            Serial.print( data_rx[ i ].cont );

            if( data_rx[ i ].temp > 23.0 ){
               Serial.print( ", TEMP: " );
               Serial.print( data_rx[ i ].temp );
            }
            Serial.print( "\n" );
         }
      }
#endif
   }
}


void setup()
{
   xTaskCreate( producer_task, "PROD", 128 * 3, NULL, tskIDLE_PRIORITY + 1, NULL );

   xTaskCreate( consumer_task, "CONS", 128 * 3, NULL, tskIDLE_PRIORITY, NULL );


   static uint8_t message_buffer_array[ MESSAGE_BUFFER_SIZE + 1 ];
   // arreglo subyacente para el búfer interno

   static StaticMessageBuffer_t message_buffer_struct;
   // variable de estado que mantiene a la estructura del mensaje


   g_message_buffer = 
   xMessageBufferCreateStatic( 
         MESSAGE_BUFFER_SIZE,
         message_buffer_array,
         &message_buffer_struct );


   pinMode( 13, OUTPUT );

   Serial.begin( 115200 );

   Serial.println( "\nRESET" );

   vTaskStartScheduler();
}

void loop() {}

La salida de este programa con la constante USER_DBG puesta a 1 es:

Ejemplo 4: Transfiere una variable compuesta. El mensaje se crea de forma estática

Este último ejemplo muestra cómo transferir una variable simple de tipo Message. Las tareas productora y consumidora declaran sendas variables del tipo mencionado, mientras que la constante MESSAGE_ELEMS toma el valor 1:

#define MESSAGE_ELEMS ( 1 )

void producer_task( void* pvParameters )
{
   Message data_tx = { 0 };
   // ...
}

void consumer_task( void* pvParameters )
{
   Message data_rx = { 0 };
   // ...
}

El ejemplo completo es:

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


MessageBufferHandle_t g_message_buffer = NULL;

typedef struct Message_t
{
   uint16_t cont;
   float temp;
} Message;


#define MESSAGE_ELEMS ( 1 )
// ¿Cuántos elementos tendrá el búfer de mensajes del USUARIO?

#define MESSAGE_TYPE Message
// ¿De qué tipo es el búfer de mensajes?

#define MESSAGE_TYPE_SIZE ( sizeof( MESSAGE_TYPE ) )
// ¿Cuántos bytes ocupa una variable de tipo MESSAGE_TYPE?

#define MESSAGE_BUFFER_SIZE ( ( MESSAGE_ELEMS * MESSAGE_TYPE_SIZE ) + 4 )
// ¿Cuántos bytes ocupa el búfer INTERNO de mensajes?

#define MESSAGE_SIZE ( MESSAGE_ELEMS ) * ( MESSAGE_TYPE_SIZE )
// ¿Cuántos bytes ocupa el stream de mensajes que se transmitirá/recibirá?


#define USER_DBG 1
// Para habilitar/deshabilitar información de depuración:
// 0: Deshabilitada
// 1: Información básica
// 2: Más información, pero puede hacer que el sistema falle (debido a Serial.print())


void producer_task( void* pvParameters )
{
   Message data_tx = { 0 };

   while( 1 )
   {
      vTaskDelay( pdMS_TO_TICKS( 500 ) );

      data_tx.cont++;
      data_tx.temp = ( analogRead( A0 ) * 5.0 * 100.0 ) / 1024.0;

      size_t bytes_sent = 
         xMessageBufferSend( 
            g_message_buffer,
            (void*) &data_tx,
            MESSAGE_SIZE,
            pdMS_TO_TICKS( 50 ) );

#if USER_DBG > 0
      Serial.print( "ENVIADOS: " );
      Serial.println( bytes_sent );
#endif

      if( bytes_sent != MESSAGE_SIZE ){ // timeout:
         Serial.print( "TO(W): " );
      } 
   }
}

void consumer_task( void* pvParameters )
{
   Message data_rx = { 0 };

   while( 1 )
   {
      size_t received_bytes = 
         xMessageBufferReceive(
            g_message_buffer,
            (void*) &data_rx,
            MESSAGE_SIZE,
            pdMS_TO_TICKS( 1000 ) );

#if USER_DBG > 0
      Serial.print( "RECIBIDOS: " );
      Serial.println( received_bytes );
#endif

      if( received_bytes < MESSAGE_SIZE ) { // timeover:
         Serial.println( "TO(R)" );
      } 

#if USER_DBG > 1
      else{
         Serial.print( "CONT: " );
         Serial.print( data_rx.cont );
         Serial.print( ", TEMP: " );
         Serial.println( data_rx.temp );
      }
#endif
   }
}


void setup()
{
   xTaskCreate( producer_task, "PROD", 128 * 3, NULL, tskIDLE_PRIORITY + 1, NULL );

   xTaskCreate( consumer_task, "CONS", 128 * 3, NULL, tskIDLE_PRIORITY, NULL );


   static uint8_t message_buffer_array[ MESSAGE_BUFFER_SIZE + 1 ];
   // arreglo subyacente para el búfer interno

   static StaticMessageBuffer_t message_buffer_struct;
   // variable de estado que mantiene a la estructura del mensaje

   g_message_buffer = 
   xMessageBufferCreateStatic( 
         MESSAGE_BUFFER_SIZE,
         message_buffer_array,
         &message_buffer_struct );


   pinMode( 13, OUTPUT );

   Serial.begin( 115200 );

   Serial.println( "\nRESET" );

   vTaskStartScheduler();
}

void loop() {}

La salida de este programa con la constante USER_DBG puesta a 1 es:

Otras funciones de la API

Hemos estudiado apenas las funciones más útiles de la API, sin embargo quedan algunas que es importante que las conozcas (aquí podrás ver la API completa).

ESCRITURA Y LECTURA DESDE INTERRUPCIONES

En lecciones anteriores hablé de funciones espejo que deben ser utilizadas cuando son llamadas desde dentro de una interrupción (ISR); a este tipo de funciones FreeRTOS le llama interrupt safe version. Los mensajes tienen dos:

Los tres primeros parámetros y el valor devuelto son lo mismo que sus contrapartes. Pero observa dos diferencias fundamentales:

  • No tienen al parámetro xTicksToWait; esto es así porque una ISR no debe bloquearse jamás.
  • Las funciones ponen a pdTRUE el parámetro pxHigherPriorityTaskWoken si la llamada a éstas implica que una tarea de alta prioridad pasó del estado Blocked al estado Ready y es necesario hacer un cambio de contexto, es decir, pasarla al estado Run.

El siguiente fragmento muestra su posible uso (no lo probé, pero así se usa):

void producer_ISR()
{
   isr_ack();
   // "reconocemos" la interrupción (ack -> acknowledge)


   BaseType_t pxHigherPriorityTaskWoken = pdFALSE;
   // siempre debe ser inicializada a pdFALSE

   static uint32_t data = 0;

   size_t bytes_sent = 
     xMessageBufferSendFromISR( 
        g_message_buffer,
        (void*) &data,
        MESSAGE_SIZE,
        &pxHigherPriorityTaskWoken );

   ++data;
   // simulamos la información

   if( bytes_sent != MESSAGE_SIZE ){
      // no hubo espacio en el flujo
   }

   if( pxHigherPriorityTaskWoken != pdFALSE ) taskYIELD;
   // cambio de contexto opcional, pero recomendable
}

FUNCIONES AUXILIARES

La API de los mensajes incluye una serie de funciones para preguntar o alterar el estado del flujo:

  • BaseType_t xMessageBufferIsEmpty( MessageBufferHandle_t xMessageBuffer ). Devuelve pdTRUE si el mensaje indicado en xMessageBuffer está vacío, o pdFALSE en caso contrario.
  • BaseType_t xMessageBufferIsFull( MessageBufferHandle_t xMessageBuffer ). Devuelve pdTRUE si el mensaje indicado en xMessageBuffer está lleno, o pdFALSE en caso contrario.
  • size_t xMessageBufferBytesAvailable( MessageBufferHandle_t xMessageBuffer ). Devuelve el número de elementos actualmente en el mensaje indicado por xMessageBuffer que se pueden leer.
  • size_t xMessageBufferSpacesAvailable( MessageBufferHandle_t xMessageBuffer ). Devuelve el número de elementos libres en el mensaje indicado por xMessageBuffer, es decir, cuánto espacio queda libre.
  • BaseType_t xMessageBufferReset( MessageBufferHandle_t xMessageBuffer ). Pone a su estado inicial al mensaje indicado por xMessageBuffer.

¿Qué sigue?

Transferir información de una tarea a otra, o de una interrupción a una tarea, es una actividad muy común y muy importante en nuestros sistemas embebidos. En la lección de hoy vimos cómo transferir datos de tipos simples (diferentes de bytes) y de tipos compuestos utilizando el mecanismo de FreeRTOS, mensajes (en su nomenclatura oficial, message buffers). Los mensajes están basados en los flujos, pero salvaguardan la integridad de la información evitando que leas 3 bytes de un entero de 32 bits, o 1 byte de un número real de 4 bytes.

RECUERDA: Utiliza las constantes que te di al principio de esta lección, en lugar de calcular por tu cuenta los diferentes tamaños de búferes y bytes a transferir.

RECUERDA: Siempre que te sea posible utiliza objetos estáticos.

RECUERDA: Si vas a llamar a una función de FreeRTOS desde una interrupción, verifica que ésta sea interrupt safe version, es decir, tenga terminación FromISR.

RECUERDA: Los mensajes se utilizan para transferir todos los tipos básicos de C y tipos compuestos. Si tu intención es transferir bytes sin ningún formato, ni inicio ni fin, entonces deberías utilizar los flujos.

En la siguiente lección estudiaremos los semáforos mutex, los cuales nos pueden ayudar a que un recurso no sea accesado por más de una tarea al mismo tiempo.

(En esta página encontrarás información de suma importancia sobre los flujos, mensajes y notificaciones. Deberías leerla en cuanto te sea posible.)

Índice del curso

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


Si encuentras este blog interesante, entonces podrías considerar suscribirte a él y recibir información relevante sobre tecnología y sistemas embebidos, y de vez en cuando, uno que otro regalo.

Comunicación inter-tareas (I): Flujos (stream buffers)

En los sistemas concurrentes y multitarea las diferentes tareas deben pasarse información; de otra manera el sistema no tendría utilidad. En los sistemas de cómputo tradicional con una o varias CPUs el sistema operativo reserva una área de la memoria RAM para escribir y leer información; a esta memoria se le conoce como memoria compartida, y dependiendo de la complejidad del sistema ésta podría estar protegida. En esta memoria compartida uno o varios procesos escriben, y uno o varios procesos leen, todo bajo la supervisión del sistema operativo, y quizás, de la propia CPU.

En nuestros sistemas embebidos de pequeña escala la memoria compartida podría tomar la forma de variables globales, arreglos globales o listas enlazadas globales, y casi siempre sin intervención ni supervisión de un sistema operativo. Esto es, podríamos declarar una variable global que sea escrita por una o más tareas y leída por una o más tareas, pero aunque este esquema es simple, es inseguro e ineficiente: ¿cómo sabemos que el dato está listo para ser leído?, ¿cómo evitamos que dos o más tareas escriban al mismo tiempo en la variable?

Para lograr una comunicación inter-tareas (inter-task communication, en inglés) segura y eficiente debemos utilizar esquemas más elaborados, de los cuales, afortunadamente, FreeRTOS incluye una cantidad de ellos, a saber:

  • Notificaciones. Sirven para pasar un dato de hasta 32 bits, y ya las estudiamos en esta lección.
  • Flujos. Sirven para pasar una cadena de bytes (sin formato) de una tarea a otra. Es el tema de la lección de hoy.
  • Mensajes. Sirven para pasar objetos (con formato) de una tarea a otra. Es el tema de la siguiente lección.
  • Colas. Igual que los mensajes. Históricamente aparecieron en FreeRTOS antes que los mensajes, y tienen ventajas sobre éstos, por ejemplo, diferentes tareas pueden escribir a la cola, y diferentes tareas pueden leer de la cola; esto no es posible ni con los fujos ni mensajes. Es el tema de una siguiente lección.

Las notificaciones, flujos y mensajes son de reciente aparición en FreeRTOS y han sido implementados como una opción ligera (consume menos recursos, pero con algunas restricciones) a las colas, las cuales existen prácticamente desde el inicio de de FreeRTOS.

Tabla de contenidos

¿Qué es y qué necesito?

Un flujo (stream buffer, en FreeRTOS) es una secuencia de bytes (es decir, datos de tipo uint8_t) para pasar información de una tarea a otra, o de una interrupción a una tarea. Como veremos más adelante, podemos pasar de una tarea a otra un arreglo completo de datos uint8_t, o juntar datos uint8_t individuales hasta completar un cierto número, después de lo cual la tarea consumidora los consumirá.

Hago énfasis en que el tipo de dato debe ser uint8_t, ya sea un arreglo o variable simple, porque pretender utilizar los flujos para pasar datos de tipos diferentes al mencionado (int, float, struct) conlleva una serie de problemas, que si no son resueltos, corromperán los datos (ya me pasó).

A TOMAR EN CUENTA: Los flujos (stream buffers, en FreeRTOS) se utilizan para pasar datos cuyo tipo nativo sea uint_8 (o su equivalente unsigned char). Si necesitas pasar tipos simples (int, float) o tipos compuestos (struct), entonces debes usar los mensajes (message buffers, en FreeRTOS).

Mucha de la información generada y procesada en nuestros sistemas embebidos es de tipo uint8_t:

  • Los datos que entran y salen por las UARTs.
  • Los datos que entran y salen por los canales SPI desde/hacia periféricos.
  • Los datos que entran y salen por los canales I2C e IIS desde/hacia periféricos.

Cuando te encuentres en uno de estos casos, entre otros muchos que no he enlistado, entonces utiliza los flujos.

Algo que debes saber cuando utilizas flujos (y mensajes y colas) es que cada byte se escribe y se lee por copia, en lugar de por referencia. Supogamos que en tu tarea tienes un búfer de datos para transmitir (un búfer es un arreglo) de 80 bytes. Cuando haces la llamada a la función xStreamBufferSend() (la cual veremos en un momento) los 80 bytes de tu búfer se copian al búfer interno del flujo (este búfer interno es un arreglo de tipo uint8_t). Cuando la tarea consumidora hace la llamada a la función xStreamBufferReceive() (la cual veremos en un momento) los 80 bytes del búfer interno del flujo se copian al arreglo que hayas declarado como búfer de recepción.

Otra cosa que debes saber cuando utilizas flujos y mensajes es que éstos están optmizados para operar tarea-a-tarea, es decir, una sola tarea escribe al flujo, y una sola tarea lee del flujo. No están hechos para que dos o más tareas escriban, o dos o más tareas lean desde el mismo flujo o mensaje, pero tampoco implementan un mecanismo que lo evite. Tú como programador eres responsable de que eso no pase. Pero si en verdad necesitas que un mismo flujo sea accesado por dos o más tareas, entonces deberás utilizar mecanismos que supervisen que solamente una tarea está accesando al flujo en un determinado momento. En FreeRTOS puedes utilizar los semáforos mutex (los cuales son tema de una próxima lección) para otorgar acceso exclusivo a una tarea a la vez.

Para que puedas utilizar los flujos en tus programas asegúrate que el archivo stream_buffer.c sea parte del proceso de compilación. Este archivo ya está incluído cuando descargas FreeRTOS, y en consecuencia, en el proyecto Molcajete también está donde debe de estar, por lo que no debes procuparte por ello. Así mismo, en cada archivo fuente donde uses a los flujos deberás agregar el encabezado #include <stream_buffer.h>.

También es necesario que la constante INCLUDE_xTaskGetCurrentTaskHandle esté puesta a 1 (en el archivo de configuración FreeRTOSConfig.h):

#define INCLUDE_xTaskGetCurrentTaskHandle 1

Los 3 mecanismos mencionados (flujos, mensajes y colas) necesitan un búfer de datos interno, es decir, un lugar dónde guardar cada byte. Este búfer es un arreglo de elementos uint8_t de un tamaño que tú estableces cuando creas a los objetos. Cuando utilizas creación dinámica de objetos este arreglo se crea de manera automática dentro de la función xStreamBufferCreate(), por lo cual no debes preocuparte de nada (en apariencia. Siempre que te sea posible utiliza la creación estática de objetos, es más segura); mientras que si usas la creación estática de objetos, tú eres responsable de pasarle dicho arreglo a la función xStreamBufferCreateStatic(). A continuación veremos ambas formas.

(Para conocer o recordar la diferencia y la configuración entre objetos dinámicos y estáticos, te recomiendo leer esta lección y esta otra lección, respectivamente. Aunque ambas hablan sobre tareas, la teoría aplica para cualquier tipo de objeto.)

Creación dinámica

Los flujos requieren ser creados, a diferencia de las notificaciones que son parte de las tareas. Como mencioné, podemos crearlos de forma dinámica o estática; veamos primero la forma dinámica:

xBufferSizeBytes. Es el número máximo de bytes que el búfer puede alojar. (size_t es un alias para unsigned long int, y lo usamos cuando se trata de tamaños y límites.)

xTriggerLevelBytes. Aquí especificas cuántos bytes se tienen que escribir al flujo para sacar del estado Blocked (es decir, despertar) a la tarea que estuviera esperando por la información. El valor mínimo es 1 y el máximo no puede ser mayor que xBufferSizeBytes. Por ejemplo, si el búfer estuviera vació y tú especificas el valor 10, entonces la tarea consumidora no saldrá del estado Blocked sino hasta que 10 bytes hayan sido escritos. Es posible cambiar este valor con la función xStreamBufferSetTriggerLevel(). Si la cantidad de bytes establecida en esta función no arriban dentro del tiempo establecido en la tarea consumidora, los n bytes que hubieran sido escritos estarán disponibles. En el ejemplo de los 10 bytes, si sólo llegaron 7 antes de que el tiempo de espera de la tarea consumidora expire, entonces ésta tendrá acceso a esos 7 bytes. Para saber cuántos bytes están disponibles usa la función xStreamBufferBytesAvailable().

Valor devuelto. Si hubo memoria suficiente para crear al flujo, entonces la función devuelve el handler; en caso contrario (no hubo memoria suficiente) devuelve NULL. Este handler lo debes de guardar, tanto para que el resto de funciones de flujos sepan sobre cuál van a operar, como para probar que efectivamente fue creado.

Por ejemplo, para crear un flujo de 128 elementos y que la tarea consumidora sea despertada (sacada del estado Blocked) cuando el flujo tenga al menos 12 bytes, el código nos queda así:

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

StreamBufferHandle_t g_stream_buffer = NULL;
// el handler debe ser global


#define MAX_CAP 128
#define TRIGGER_POINT 12

void setup()
{
   // código

   g_stream_buffer = xStreamBufferCreate( MAX_CAP, TRIGGER_POINT );

   configASSERT( g_stream_buffer );
   // Error creando al flujo

   // más código ...
}

¿Porqué el valor para despertar a la tarea consumidora es 12, mientras que el tamaño máximo es mucho mayor? Que hayan llegado los 12 bytes necesarios no significa que la tarea consumidora los procesará de manera inmediata, peor si ésta es de baja prioridad. Esto es, cuando los 12 bytes hayan llegado al flujo la tarea consumidora pasará del estado Blocked al estado Ready (elegible para ejecutarse), pero quizás no al estado Run (es decir, usar ciclos de CPU) de manera inmediata. Entonces más datos podrían estarse escribiendo en el búfer durante el tiempo que le tome pasar del estado Ready al estado Run. Por esta razón habrá que hacer al búfer un poco más grande (aunque en el ejemplo exageré).

El handler, g_stream_buffer, es global porque debe ser visible tanto en la tarea productora como en la consumidora.

La creación dinámica de objetos no asegura que los objetos se creen, principalmente por escasez de memoria RAM. Por eso siempre deberíamos preguntar si el objeto realmente fue creado, y aunque existen diversas maneras de hacerlo, en este ejemplo escogí la macro configASSERT() que es una versión propia de la conocida assert() de C (en el archivo FreeRTOSConfig.h podrás ver la implementación que utilicé para el projecto Molcajete). Por supuesto que puedes usar al condicional if y tratar de recuperarte sin terminar el programa de manera abrupta.

Creación estática

Ahora veamos cómo crear a un flujo de manera estática. Como es usual, esta forma de creación de objetos necesita más pasos, pero es mucho más segura ya que los objetos siempre serán creados.

Los primeros dos argumentos son idénticos a los de la función xStreamBufferCreate(), así que no los repetiré, y la explicación de los restantes aquí está:

pucStreamBufferStorageArea. Es el búfer interno del flujo donde se guardarán los datos, y no es otra cosa que un arreglo de elementos de tipo uint8_t de tamaño al menos xBufferSizeBytes + 1. Este arreglo deberá existir a lo largo del programa, por lo cual deberás crearlo de manera global o estática a la función donde fue llamada xStreamBufferCreateStatic().

pxStaticStreamBuffer. Esta es la variable que guarda el estado del flujo, es decir, la estructura de datos que maneja al flujo (el flujo es algo más que un simple arreglo). Al igual que el parámetro pucStreamBufferStorageArea deberá existir a lo largo del programa.

Valor devuelto. El handler al flujo. La creación estática de objetos nunca falla, a menos que el arreglo o la variable de estado sean NULL, cosa que tal vez nunca suceda.

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

StreamBufferHandle_t g_stream_buffer = NULL;
// el handler debe ser global

#define MAX_CAP 128
#define TRIGGER_POINT 12

void setup()
{
   xTaskCreate( producer_task, "PROD", 128, NULL, tskIDLE_PRIORITY + 1, NULL );

   xTaskCreate( consumer_task, "CONS", 128, NULL, tskIDLE_PRIORITY, &consumer_handler );

   static uint8_t stream_buffer_array[ MAX_CAP + 1 ];
   // el array subyacente debe ser un elemento más grande que el parámetro xBufferSizeBytes
   // es static para que el arreglo perdure durante todo el programa sin ser visible al resto de funciones

   static StaticStreamBuffer_t stream_buffer_struct;
   // es static para que la variable perdure durante todo el programa sin ser visible al resto de funciones

   g_stream_buffer = xStreamBufferCreateStatic( 
         MAX_CAP,
         TRIGGER_POINT,
         stream_buffer_array,     // arreglo que hace las veces de búfer interno
         &stream_buffer_struct ); // variable de estado del flujo

   // los objetos estáticos siempre se crean, así que no hay necesidad de
   // preguntar si el flujo fue creado o no

   pinMode( 13, OUTPUT );
   // emula a la luz de fondo del LCD

   Serial.begin( 115200 );
   // lo usa la tarea principal

   vTaskStartScheduler();
}

El tamaño del arreglo subyacente (stream_buffer_array[]), es decir, el búfer donde se guardan los datos, debe ser un elemento más grande que el establecido en el argumento xBufferSizeBytes. En este ejemplo marqué a las variables stream_buffer_array y stream_buffer_struct como static; esto para que perduren durante todo el programa, pero que a la vez sean invisibles al resto de funciones. Una alternativa es que las declares globales, aunque entre menos variables globales tengas en tus programas, mejor. Sin embargo el handler, g_stream_buffer, sí que debe ser global porque tanto la tarea productora como la consumidora deben tener acceso a él. Este es un ejemplo de las veces en que sí son necesarias las variables globales. El prefijo g_ nos recuerda que es una variable global y que debemos tener mucho cuidado con ella.

Escribiendo y leyendo al flujo

Una vez que el flujo ha sido creado ya podemos utilizarlo escribiendo en y leyendo desde él. Los datos que deseas transmitir se deben encontrar en un búfer de tu aplicación, y dependiendo de lo que desees transmitir este búfer puede ser un arreglo o una variable, ambos de tipo char, unsigned char, int8_t, o uint8_t. Recuerda que si quieres transmitir variables de tipos simples o de tipos compuestos, entonces deberías utilizar a los mensajes (tema de la siguiente lección).

La función para escribir en el flujo es:

xStreamBuffer. Es el handler del flujo en el que queremos escribir.

pvTxData. Es un apuntador al búfer que queremos transmitir. Dicho búfer lo deberás promocionar a void*. Es importante que recuerdes que los elementos de este búfer son copiados al flujo.

xTicksToWait. Si no hay espacio disponible para una nueva copia de xDataLengthBytes bytes en el flujo, entonces la tarea productora (la que llama a esta función) entrará al estado Blocked por el tiempo determinado en este parámetro. Si el tiempo expira la tarea pasará al estado Ready. El tiempo de espera está en ticks y puedes usar la macro pdMS_TO_TICKS() para convertir milisegundos a ticks. Así mismo, si escribes 0 la función regresa inmediatamente, y si escribes portMAX_DELAY el tiempo de espera es infinito (siempre y cuando la constante simbólica INCLUDE_vTaskSuspend esté a 1). Si el tiempo hubiese expirado, la función habrá escrito la mayor cantidad de bytes posible.

Valor devuelto. Esta función devuelve el número de bytes escritos al flujo.

La función para leer desde el flujo es:

xStreamBuffer. Es el handler del flujo del que queremos leer.

pvRxData. Es un apuntador al búfer en el que los datos leídos se van a guardar. Dicho búfer lo deberás promocionar a void*. Es importante que recuerdes que los elementos de este búfer son copiados desde el flujo al búfer.

xBufferLengthBytes. Es la cantidad máxima de bytes que deseas leer desde el flujo en una sola llamada. Para cualquier tipo diferente de char deberás multiplicar el número de elementos de ese tipo por el tamaño en bytes que ocupa una variable de ese tipo. Esta función devolverá tantos bytes como sea posible, teniendo como límite xBufferLengthBytes.

xTicksToWait. Es el tiempo de espera máximo que la tarea consumidora estará en el estado Blocked mientras arriban bytes a partir de que el búfer estuviera vacío. El tiempo de espera está en ticks y puedes usar la macro pdMS_TO_TICKS() para convertir milisegundos a ticks. Así mismo, si escribes 0 la función regresa inmediatamente, y si escribes portMAX_DELAY el tiempo de espera es infinito (siempre y cuando la constante simbólica INCLUDE_vTaskSuspend esté a 1). Si el tiempo hubiese expirado antes de completar la petición (), la función habrá leído la mayor cantidad de bytes posible.

Valor devuelto. Esta función devuelve el número de bytes leídos desde flujo.

Ejemplo 1

El primer ejemplo que vamos a ver muestra el uso más básico de los flujos: transmitir n bytes de la tarea productora a la consumidora; con n constante. Los flujos no imponen un inicio o un final en la secuencia de bytes, pero en muchas aplicaciones vamos a tomar 10 lecturas y transmitir ese mismo número, sin trucos ni cosas raras.

Con dicha restricción el ejemplo se simplifica ya que no tendremos que verificar si hay espacio en el flujo o no, ni preocuparnos por el punto de disparo (trigger point, en inglés) xTriggerLevelBytes. A éste lo podemos definir como 1 o como n. Recuerda que el punto de disparo es la cantidad de bytes que deben estar en el flujo para que la tarea consumidora sea sacada del estado Blocked. Pero como la copia del búfer de la tarea productora hacia el flujo es muy rápida, en muchas situaciones el resultado será el mismo con el punto de disparo a 1 o a n.

IMPORTANTE: La función de Arduino Serial.print() (y derivados) no son thread-safe; esto es, su comportamiento es miserable en ambientes multitarea (dos tareas tratando de imprimir), dado que Arduino nunca fue pensado para sistemas concurrentes. Esto quedó de manifiesto mientras desarrollaba el ejemplo, al grado que tuve que eliminar prácticamente todas las llamadas.

Mi primer idea para el ejemplo era leer del ADC, quitarle 2 bits a cada lectura (el ADC es de 10 bits) y transmitirlos; sin embargo, para ver que lo transmitido era idéntido a lo recibido debía imprimir los valores tanto en la tarea productora como en la consumidora (en el segundo ejemplo sí que uso al ADC). Pero dado lo que comenté en el recuadro anterior con respecto a Serial.print(), preferí irme por el lado seguro y utilizar un contador ascendente. De esta manera, lo impreso del lado de la tarea consumidora debía ser una secuencia de valores perfectamente definida. Sin más, aquí está el ejemplo:

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


StreamBufferHandle_t g_stream_buffer = NULL;

#define BUFFER_ELEMS ( 8 )
// número de elementos para cada uno de los búfers del usuario

#define STREAM_ELEMS ( 10 )
// número de elementos en el flujo. Hemos dejado un par de bytes para seguridad

#define STREAM_TRIGGER_POINT ( 8 )
// número de bytes escritos al flujo para que la tarea consumidora sea despertada


void producer_task( void* pvParameters )
{
   uint8_t out_buffer[ BUFFER_ELEMS ];

   uint8_t index = 0;

   uint8_t data = 0;

   while( 1 )
   {
      for( uint8_t i = 0; i < BUFFER_ELEMS; ++i ){
         vTaskDelay( pdMS_TO_TICKS( 50 ) );
       
         out_buffer[ i ] = data++;
      }

      size_t bytes_sent = 
         xStreamBufferSend( 
            g_stream_buffer,
            (void*) out_buffer,
            BUFFER_ELEMS,
            pdMS_TO_TICKS( 50 ) );

      if( bytes_sent != sizeof( out_buffer ) ){ // timeout:
         Serial.print( "TO(W): " );
         Serial.println( bytes_sent );
      } 
   }
}

void consumer_task( void* pvParameters )
{
   uint8_t in_buffer[ BUFFER_ELEMS ];

   while( 1 )
   {
      size_t received_bytes = 
         xStreamBufferReceive(
            g_stream_buffer,
            (void*) in_buffer,
            BUFFER_ELEMS,
            pdMS_TO_TICKS( 500 ) );

         Serial.print( "RECIBIDOS: " );
         Serial.println( received_bytes );

      if( received_bytes < BUFFER_ELEMS ) { // timeover:
         Serial.println( "TO(R)" );
      } else{

         for( size_t i = 0; i < received_bytes; ++i ){
            Serial.println( in_buffer[ i ] );
         }

      }
   }
}

void setup()
{
   xTaskCreate( producer_task, "PROD", 128 * 3, NULL, tskIDLE_PRIORITY + 1, NULL );

   xTaskCreate( consumer_task, "CONS", 128 * 3, NULL, tskIDLE_PRIORITY, NULL );

   g_stream_buffer = xStreamBufferCreate( STREAM_ELEMS, STREAM_TRIGGER_POINT );

   configASSERT( g_stream_buffer );
   // Error creando al flujo

   pinMode( 13, OUTPUT );
   // emula a la luz de fondo del LCD

   Serial.begin( 115200 );
   // lo usa la tarea principal

   vTaskStartScheduler();
}

void loop() {}

Una corrida de este ejemplo es:

Salida del programa. La información hasta el símbolo -> es parte de la terminal serial de la IDE de Arduino. Lo importante es la secuencia correcta de valores.

Lo primero que notarás en el ejemplo es que utilicé constantes simbólicas, #define XXX YYY, para establecer algunos tamaños, por seguridad. Imagina que usas números directamente, y luego cambias de 8 elementos a 6, pero se te olvida realizar la actualización en todos los lados donde el tamaño del búfer va a ser utilizado. Eso no es bueno, nada bueno. Establecí el punto de disparo en 8 elementos; esto es, cuando el flujo tenga 8 elementos, entonces sacará del estado Blocked a la tarea consumidora. El valor 1 entrega los mismos resultados.

Ejemplo 2

En este segundo ejemplo veremos algunas cosas interesantes:

  • La creación estática de los flujos.
  • Escribiremos al flujo dato por dato, es decir, la tarea productora no implementa un búfer, como en el ejemplo anterior, sino que una vez generada la información la escribiremos. Esto implica que en el parámetro xTicksToWait de la función xStreamBufferSend() podríamos escribir un tiempo de espera de 0; esto es, la tarea productora no se bloqueará en caso de que el flujo esté lleno, simplemente regresará. Usé 0 para variar al ejemplo.
  • Para este ejemplo sí utilicé un sensor de temperatura. El valor 11 que verás repetido en la imagen de la corrida del programa se corresponde con una temperatura de 21.48 grados.
  • Establecí el punto de disparo (trigger point) a 8. Esto significa que la tarea consumidora será despertada (es decir, sacada del estado Blocked) cada vez que se hayan realizado 8 lecturas del ADC.
  • El ADC del ATmega328 es de 10 bits, pero para meterlo en un dato de 8 bits le quité dos. En el código agregué la fórmula en caso de que quieras imprimir la temperatura en lugar del valor de lectura del ADC.
#include <FreeRTOS.h>
#include <task.h>
#include <stream_buffer.h>


StreamBufferHandle_t g_stream_buffer = NULL;

#define BUFFER_ELEMS ( 8 )
// número de elementos para cada uno de los búfers del usuario

#define STREAM_ELEMS ( 10 )
// número de elementos en el flujo. Hemos dejado un par de bytes para seguridad

#define STREAM_TRIGGER_POINT ( 8 )
// número de bytes escritos al flujo para que la tarea consumidora sea despertada

void producer_task( void* pvParameters )
{
   TickType_t xLastWakeTime = xTaskGetTickCount();

   uint8_t data = 0;

   while( 1 )
   {
      vTaskDelayUntil( &xLastWakeTime, pdMS_TO_TICKS( 50 ) );

      data = analogRead( A0 ) >> 2;
      // -- usé el sensor LM35
      // -- le quitamos dos bits a la lectura (el valor es correcto, pero con menos
      // resolución)

      size_t bytes_sent = xStreamBufferSend( g_stream_buffer, (void*) &data, 1, 0 );
      // escribimos un sólo byte y no hay necesidad de esperar

      if( bytes_sent == 0 ){ // timeout:
         Serial.println( "TO(W)" );
      }
   }
}

void consumer_task( void* pvParameters )
{
   uint8_t in_buffer[ BUFFER_ELEMS ];

   while( 1 )
   {
      size_t received_bytes = 
         xStreamBufferReceive(
            g_stream_buffer,
            (void*) in_buffer,
            BUFFER_ELEMS,
            pdMS_TO_TICKS( 500 ) );

         Serial.print( "RECIBIDOS: " );
         Serial.println( received_bytes );

      if( received_bytes < BUFFER_ELEMS ) { // timeover:
         Serial.println( "TO(R)" );
      } 
#if 1
      else{

         for( size_t i = 0; i < received_bytes; ++i ){

            Serial.println( in_buffer[ i ] );

            // si quieres imprimir la temperatura usa la siguiente fórmula:
            // temp = ( in_buffer[ i ] * 500 ) / 256.0;
            // Serial.println( temp );
         }
      }
#endif
   }
}


void setup()
{
   xTaskCreate( producer_task, "PROD", 128, NULL, tskIDLE_PRIORITY + 1, NULL );

   xTaskCreate( consumer_task, "CONS", 128, NULL, tskIDLE_PRIORITY, NULL );

   static uint8_t stream_buffer_array[ STREAM_ELEMS + 1 ];
   // el array subyacente debe ser un elemento más grande que el parámetro xBufferSizeBytes
   // es static para que el arreglo perdure durante todo el programa sin ser visible al resto de funciones

   static StaticStreamBuffer_t stream_buffer_struct;
   // es static para que la variable perdure durante todo el programa sin ser visible al resto de funciones


   g_stream_buffer = xStreamBufferCreateStatic( 
         STREAM_ELEMS,
         STREAM_TRIGGER_POINT,
         stream_buffer_array,
         &stream_buffer_struct );

   // los objetos estáticos siempre se crean, así que no hay necesidad de
   // preguntar si el flujo fue creado o no

   pinMode( 13, OUTPUT );
   // emula a la luz de fondo del LCD

   Serial.begin( 115200 );
   // lo usa la tarea principal

   vTaskStartScheduler();
}

void loop() {}

Una corrida de este programa es:

El valor 11 se corresponde a una temperatura de 21.48 grados.

Otras funciones de la API

Hemos estudiado apenas las funciones más útiles de la API, sin embargo quedan algunas que es importante que las conozcas (aquí podrás ver la API completa).

Escritura y lectura desde interrupciones

En lecciones anteriores hablé de funciones espejo que deben ser utilizadas cuando son llamadas desde dentro de una interrupción (ISR); a este tipo de funciones FreeRTOS le llama interrupt safe version. Los flujos tienen dos:

Los tres primeros parámetros y el valor devuelto son lo mismo que sus contrapartes. Pero observa dos diferencias fundamentales:

  • No tienen al parámetro xTicksToWait; esto es así porque una ISR no debe bloquearse jamás.
  • Las funciones ponen a pdTRUE el parámetro pxHigherPriorityTaskWoken si la llamada a éstas implica que una tarea de alta prioridad pasó del estado Blocked al estado Ready y es necesario hacer un cambio de contexto, es decir, pasarla al estado Run.

El siguiente fragmento muestra su posible uso (no lo probé, pero así se usa):

void producer_ISR()
{
   isr_ack();
   // "reconocemos" la interrupción (ack -> acknowledge)


   BaseType_t pxHigherPriorityTaskWoken = pdFALSE;
   // siempre debe ser inicializada a pdFALSE

   static uint8_t data = 0;

   size_t bytes_sent = 
     xStreamBufferSendFromISR( 
        g_stream_buffer,
        (void*) &data,
        1,
        &pxHigherPriorityTaskWoken );

   if( bytes_sent == 0 ){
      // no hubo espacio en el flujo
   }

   if( pxHigherPriorityTaskWoken != pdFALSE ) taskYIELD;
   // cambio de contexto opcional, pero recomendable
}

Funciones auxiliares

La API de los flujos incluye una serie de funciones para preguntar o alterar el estado del flujo:

  • BaseType_t xStreamBufferIsEmpty( StreamBufferHandle_t xStreamBuffer ). Devuelve pdTRUE si el flujo indicado en xStreamBuffer está vacío, o pdFALSE en caso contrario.
  • BaseType_t xStreamBufferIsFull( StreamBufferHandle_t xStreamBuffer ). Devuelve pdTRUE si el flujo indicado en xStreamBuffer está lleno, o pdFALSE en caso contrario.
  • BaseType_t xStreamBufferSetTriggerLevel( StreamBufferHandle_t xStreamBuffer, size_t xTriggerLevel ). Establece el nuevo punto de disparo xTriggerLevel para el flujo indicado en xStreamBuffer. Devuelve pdTRUE si xTriggerLevel es menor o igual al tamaño del flujo, o pdFALSE en caso contrario (el punto de disparo es mayor que el tamaño del flujo).
  • size_t xStreamBufferBytesAvailable( StreamBufferHandle_t xStreamBuffer ). Devuelve el número de elementos actualmente en el flujo indicado por xStreamBuffer que se pueden leer.
  • size_t xStreamBufferSpacesAvailable( StreamBufferHandle_t xStreamBuffer ). Devuelve el número de elementos libres en el flujo indicado por xStreamBuffer, es decir, cuánto espacio queda libre.
  • BaseType_t xStreamBufferReset( StreamBufferHandle_t xStreamBuffer ). Pone a su estado inicial al flujo indicado por xStreamBuffer.

¿Qué sigue?

En esta lección hemos estudiado el primero de 3 mecanismos que FreeRTOS incluye para pasar largas cantidades de información entre tareas, o entre una interrupción y una tarea. Vimos cómo crear flujos de manera dinámica y estática, y también cómo escribir y leer tanto arreglos como datos individuales.

RECUERDA: Siempre que te sea posible utiliza objetos estáticos.

RECUERDA: Si vas a llamar a una función de FreeRTOS desde una interrupción, verifica que ésta sea interrupt safe version, es decir, tenga terminación FromISR.

RECUERDA: Los flujos se utilizan para transmitir y recibir secuencias de bytes. Si tu intención es otra, entonces usa mensajes o colas.

En la siguiente lección estudiaremos a los mensajes, los cuales son una extensión lógica (y muy necesaria) de los flujos. Con los mensajes ya podremos transmitir y recibir datos más grandes que uint8_t, tales como int‘s, float‘s, y tipos compuestos, struct.

(En esta página encontrarás información de suma importancia sobre los flujos, mensajes y notificaciones. Deberías leerla en cuanto te sea posible.)

Índice del curso

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


Si encuentras este blog interesante, entonces podrías considerar suscribirte a él y recibir información relevante sobre tecnología y sistemas embebidos, y de vez en cuando, uno que otro regalo.

Software timers

Muchas de nuestras aplicaciones están comandadas por el tiempo: cada cierto tiempo se debe actualizar el display; cada cierto tiempo se debe leer el teclado, cada cierto tiempo se debe realizar una conversión en el ADC, etc.

En la programación convencional (sin sistema operativo) nos hemos visto obligados a crear máquinas de estado dentro de una interrupción periódica para procesar todas esas actividades. Y aunque que esta solución efectivamente funciona, es difícil que escale.

Por otro lado, cuando usamos un sistema operativo para el manejo de esas tareas periódicas debemos crear una tarea por cada una de ellas. Cada tarea, por simple que sea (tal como leer un teclado o actualizar un display), consume recursos. Y tarea a tarea, los recursos se terminan.

La idea de los software timers (temporizadores por software) es tener una tarea a nivel del sistema operativo que centralice y administre a diferentes temporizadores, reduciendo el costo en recursos y simplificando la programación. Tú como programador solamente te encargas de decirle a cada temporizador lo que debe hacer cuando el tiempo establecido expire.

En esta lección veremos cómo puedes utilizarlos en tus proyectos de tiempo real.

Tabla de contenidos

¿Qué es y qué necesito?

Los temporizadores por software (de aquí en adelante les diré simplemente temporizadores o timers) son un mecanismo de FreeRTOS que nos permite temporizar actividades de manera muy fácil y con un mínimo de recursos. Luego de activar esta funcionalidad, dentro de FreeRTOS existirá una tarea (oculta a nosotros) que administrará cada uno de los temporizadores que hayamos creado, ya sin nuestra intervención.

Cada temporizador puede ser iniciado, detenido o reiniciado. Cada uno de estas acciones (FreeRTOS les dice comandos) puede ser lanzado por la propia callback, por otra callback, o por otra tarea.

Un dato interesante es que los temporizadores no dependen de otras tareas en el sistema (las tuyas), lo cual permite que ¡tengas una aplicación completa basada en temporizadores sin tareas! Podrás imaginarte cómo lograrlo después de ver los ejemplos más adelante.

Otra característica de los temporizadores es que los puedes crear como de una sola vez (one-shot) o repetitivos (auto-reload), y después, si es necesario, cambiar este comportamiento (a esta característica le decimos el modo, o mode en inglés). Así mismo, es posible cambiar el periodo (es decir, el tiempo entre llamadas) cuando el programa se está ejecutando.

Un elemento central de los temporizadres son las funciones callback, por lo que es lo primero que debemos revisar.

Funciones callback

Una función callback es una función, Fa, que tú le pasas a otra función, Fb, como parámetro para que ésta la ejecute; es decir, la función Fb recibe y ejecuta el código de Fa.

Cada vez que creas un temporizador deberás pasar una callback para que cuando el tiempo expire dicho código sea ejecutado, en cualquiera de los dos modos de operación de los temporizadores (one-shot o auto-reload).

La firma de las callbacks que los temporizadores utilizan es así:

Esto es, la función que escribas para que se ejecute cada vez que el tiempo expire no devuelve ningún resultado (void) y tiene un parámetro (TimerHandle_t). Por ejemplo, una función update_display() que actualiza un display de 7 segmentos cada cierto tiempo se vería así (omitiendo el código real):

void update_display( TimerHandle_t xTimer )
{
    // aquí el código que actualiza al display
}

El parámetro xTimer (al cual puedes nombrar como tú quieras) sirve para saber cuál temporizador mandó llamar a la callback. FreeRTOS permite que diferentes temporizadores llamen a la misma callback, por lo tanto, ésta debe de saber quién la llamó para ejecutar el código correcto. Pero si tu callback es llamada por un único temporizador, entonces podrías ignorarlo, o darle otros usos, como lo explicaré más adelante.

Demonios y daemons

A la tarea administradora de los timers le llamamos daemon (demonio en español).

El término daemon ha sido utilizado desde el principio de las computadoras para indicar procesos (o tareas) del sistema operativo que no son interactivas y que se ejecutan de manera oculta al usuario.

Cuando habilitas los temporizadores en FreeRTOS, éste crea una tarea que estará corriendo sin que te des cuenta (de ahí el término daemon) verificando el tiempo de finalización de cada temporizador.

Habilitación de los temporizadores

Necesitas 4 simples pasos para habilitar esta funcionalidad en tus proyectos:

1er paso. En el archivo de configuración FreeRTOSConfig.h asegúrate de tener las siguientes líneas (en cualquier lugar del archivo):

#define configUSE_TIMERS                    1
#define configTIMER_TASK_PRIORITY           ( ( UBaseType_t ) 1 )
#define configTIMER_QUEUE_LENGTH            ( ( UBaseType_t ) 3 )
#define configTIMER_TASK_STACK_DEPTH        configMINIMAL_STACK_SIZE * 2

configUSE_TIMERS: La pones a 1 para activar la funcionalidad de timers en FreeRTOS.

configTIMER_TASK_PRIORITY: El daemon de los temporizadores es a final de cuentas una tarea (interna a FreeRTOS) por lo cual requiere una prioridad. Una baja prioridad podría dar lugar a que los comandos no se ejecuten ya que tareas con mayor prioridad no le presten la CPU al daemon. Una alta prioridad (junto con un mal diseño) podría dar lugar a que el daemon consuma todo el tiempo de CPU.

configTIMER_QUEUE_LENGTH: Esta es una cola de comandos. Un comando es una llamada a las funciones (las cuales veremos más adelante) que inician, detienen, reinician o destruyen a los temporizadores. El hecho de hacer la llamada no significa que la instrucción le llegue de inmediato al daemon, y en consecuencia, al temporizador referido. Si hubiera tareas de mayor prioridad al daemon ejecutándose, entonces cada comando se inserta en una cola, y una vez que el daemon tiene tiempo de CPU los comandos se extraen de la cola y, efectivamente, se ejecutan sobre el o los timers indicados. El valor para esta constante simbólica dependerá de la carga de trabajo del sistema, el diseño, y la prioridad del daemon.

configTIMER_TASK_STACK_DEPTH: Esta constante tiene sentido cuando está habilitada la creación dinámica de objetos (configSUPPORT_DYNAMIC_ALLOCATION está puesta a 1). Aquí estableces cuánta memoria le quieres dar al daemon. En el caso de que la creación dinámica estuviera deshabilitada, entonces tú deberás proporcionar la memoria para el daemon (revisa esta lección si no recuerdas cómo hacerlo).

2do paso. Asegúrate que los archivos timers.h y timers.c están en la carpeta src donde están el resto de archivos de FreeRTOS. En el proyecto Molcajete ya están donde deben de estar, así que no deberías preocuparte.

3er paso. Si decides utilizar la forma estática para crear a los temporizadores (de manera exclusiva o junto con la forma dinámica) deberás declarar una función callback para obtener memoria para los temporizadores (nada que ver con las callbacks que hemos estado platicando). Se oye complicado, pero no tienes nada de que preocuparte: FreeRTOS te dice cómo debe ser esa función (aquí puedes verlo) y yo ya la puse donde tiene que ir; en otras palabras no tienes que hacer nada, solamente saberlo. (Si estás interesado busca en el directorio src el archivo static_mem_callbacks.c que yo agregué como parte del proyecto Molcajete.)

4to paso. En los archivos de tu proyecto donde utilices a los temporizadores deberás agregar el archivo de encabezado timers.h:

#include <timers.h>

Desarrollo

Creación de los temporizadores

Tanto el daemon que administra los temporizadores como los temporizadores, pueden ser creados con memoria dinámica o con memoria estática (revisa esta lección y esta para que recuerdes la diferencia entre estas dos formas). Aunque para la mayoría los ejemplos que veremos estaré usando la forma dinámica ya que es más fácil de implementar, te recuerdo que la creación estática de objetos es mejor y más segura.

Las funciones para la creación dinámica y estática de los temporizadores son casi idénticas, de no ser por un parámetro extra para la segunda, por eso voy a utilizar a esta última para explicar los diferentes parámetros.

Temporizadores con memoria estática

pcTimerName: Es una cadena de texto que representa un nombre que le quieras dar al temporizador. FreeRTOS no lo utiliza internamente, pero tú lo puedes utilizar cuando estés depurando tus aplicaciones. El nombre lo puedes obtener posteriormente con la función pcTimerGetName().

xTimerPeriod: Es el periodo, en ticks, del temporizador. Puedes usar la macro pdMS_TO_TICKS() para utilizar milisegundos (ms). Por ejemplo, para un periodo de 500 ms, escribirías pdMS_TO_TICKS( 500 ).

uxAutoReload: Este parámetro es el modo. Cuando escribes pdFALSE estableces que el temporizador será de una sola vez (one-shot), mientras que cuando escribes pdTRUE estableces que será continuo (auto-reload). One-shot significa que cuando expire el periodo la callback se llamará y luego el temporizador entrará al estado dormant (durmiente) hasta que reciba un nuevo comando de inicio. Auto-reload significa que la callback se estará llamando de manera repetida cada vez que expire el periodo sin necesidad de enviar el comando de inicio. El comando de inicio lo envías con la función xTimerStart().

pvTimerID: Una misma callback puede ser llamada por varios temporizadores. En este parámetro puedes establecer un identificador que le ayudará a la callback a determinar cuál temporizador la llamó. Dado que este es un parámetro void* también puedes usarlo para pasarle datos a la callback, o para que ésta mantenga un estado (junto con las funciones TimerSetTimerID() y pvTimerGetTimerID()).

pxCallbackFunction: Es la función que se ejecutará cuando el periodo expire.

pxTimerBuffer: (Este parámetro sólo está definido para la función xTimerCreateStatic()). Es una variable de tipo StaticTimer_t (interna a y) que FreeRTOS utilizará para guardar el estado del temporizador. Deberás crearla global o estática (revisa aquí)

Valor devuelto: Esta función devuelve un handler de tipo TimerHandle_t. Este handler lo necesitan todas las funciones de los temporizadores para saber sobre cuál operarán. En caso de que el temporizador no se haya creado, entonces el valor devuelto es NULL.

Ejemplo 1

Vamos a ver un ejemplo simple utilizando temporizadores estáticos:

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

enum { TMR_ONE_SHOT = pdFALSE, TMR_AUTO_RELOAD = pdTRUE };

void callback( TimerHandle_t h )
{
   Serial.println( "hola mundo" );
}

void setup()
{
   static StaticTimer_t tmr1_state;

   TimerHandle_t tmr1_h = xTimerCreateStatic(
         "TMR1",               // nombre
         pdMS_TO_TICKS( 500 ), // periodo inicial
         TMR_AUTO_RELOAD,      // tipo (pdTRUE = auto-reload)
         NULL,                 // ID (se puede usar para pasar información)
         callback,             // función a ejecutar
         &tmr1_state );        // variable para guardar el estado

   configASSERT( tmr1_h != NULL );       

   xTimerStart( tmr1_h, 0 );
   // "arranca" al temporizador que acabamos de crear

   Serial.begin( 115200 );

   vTaskStartScheduler();
}

void loop() 
{
}

En este ejemplo podemos observar varias cosas:

No hay ninguna tarea creada por el usuario (tú). La única actividad del programa se lleva a cabo en la callback, la cual es llamada de manera continua por el daemon del temporizador. Que no haya tareas en este ejemplo no quiere decir que siempre será así; lo menciono porque es interesante que podamos crear aplicaciones completas basadas totalmente en temporizadores.

El handler que devuelve la función lo guardamos en una variable local a la función setup() porque es en esta misma función, y por única vez en este ejemplo, donde lo utilizamos. Lo más común es que sea otra función (una callback o una tarea) quien lo use. En esos casos deberás crear al handler (es decir, la variable tmr1_h) global.

Como el temporizador fue creado de manera estática debíamos crear su variable de estado de tal modo que fuera permanente durante toda la ejecución del programa. Teníamos dos opciones, que fuera global, o hacerla estática a la función. Escogí la segunda por ser más segura:

static StaticTimer_t tmr1_state;

Si utilizas a la función xTimerCreate() en su lugar, entonces este último paso no es necesario.

Habrás notado que en lugar de escribir directamente en el parámetro de modo el valor pdTRUE (modo continuo) agregué una enumeración con símbolos más claros; no es necesario, pero el código se entiende mejor:

enum { TMR_ONE_SHOT = pdFALSE, TMR_AUTO_RELOAD = pdTRUE };

La callback es muy sencilla, y para este ejemplo no guarda ningún estado ni utiliza el parámetro asociado al handler del temporizador que la llamó. Eso lo veremos en un siguiente ejemplo.

La salida es como la esperábamos:

Funciones para iniciar, detener y reiniciar

Antes de ver el siguiente ejemplo detengámonos un momento para ver a algunas de las funciones que controlan la operación de los temporizadores. Comencemos con la función de arranque, xTimerStart():

xTimer: Es el handler del temporizador que quieres iniciar. Éste lo obtuviste cuando lo creaste.

xBlockTime: Es el tiempo de espera, en ticks, para que el comando llegue a la cola de comandos antes de abortar la operación. Ya mencioné que emitir la instrucción no significa que el temporizador la recibirá de forma inmediata, y en caso de que el sistema esté ocupado, ésta se encolará (¿recuerdas la constante configTIMER_QUEUE_LENGTH?). Para convertir milisegundos a ticks puedes usar la macro pdMS_TO_TICKS(). Para un tiempo de espera infinito escribe portMAX_DELAY (asegúrate que la constante INCLUDE_vTaskSuspend, en el archivo FreeRTOSConfig.h, está puesta a 1); para regresar inmediatamente (es decir, sin esperar, puedes escribir 0. Este parámetro es ignorado cuando la función se llama antes de que el planificador inicie; por eso en el ejemplo anterior escribí el valor 0.

Valor devuelto: Si la instrucción no pudo ser encolada antes de que el tiempo establecido por xBlockTime expirara, entonces devolverá pdFAIL. En caso de que el comando sí alcance lugar en la cola, entonces devolverá pdTRUE. La instrucción será efectivamente ejecutada cuando el daemon obtenga tiempo de CPU.

Una función muy importante que quizás llegarás a utilizar con frecuencia es la que detiene al temporizador, xTimerStop():

Los parámetros y el valor devuelto tienen el mismo significado que la función xTimerStart(). Esta función detiene al temporizador indicado por xTimer que previamente hubiera sido iniciado con la función xTimerStart(). La función no espera a que el tiempo expire; en cuanto el daemon recibe el comando el temporizador es detenido. Por supuesto que podrás volver a arrancarlo.

Un temporizador detenido con xTimerStop() se va al estado Dormant. Esto significa que el temporizador sigue siendo parte de sistema, está inactivo (no usa ciclos de CPU), pero, a diferencia del estado Blocked, no está esperando por ningún evento. Como mencioné, para volverlo a la vida deberás reactivarlo con la función xTimerStart() o xTimerReset().

Ejemplo: Arrancando y deteniendo temporizadores

Ahora sí, otro ejemplo. En éste crearemos dos temporizadores. Uno imprimirá texto, T1, mientras que el otro hará parpadear a un LED, T2. T1 detendrá y arrancará a T2 de manera repetida. Por otro lado, los temporizadores serán creados con memoria dinámica:

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

enum { TMR_ONE_SHOT = pdFALSE, TMR_AUTO_RELOAD = pdTRUE };

#define BLINKS 3

TimerHandle_t led_h = NULL;
// el handler del timer asociado al LED debe ser global


void msg_callback( TimerHandle_t h )
{
   static uint8_t blinks = BLINKS;
   // usamos una variable estática para guardar el contador.
   // Este método no debe ser usado si la callback es compartida.

   static bool led_state = false;
   // idem


   Serial.print( blinks );
   Serial.println( ": hola mundo" );

   --blinks;
   if( blinks == 0 ) {
      blinks = BLINKS;

      led_state != false ?  
         xTimerStart( led_h, portMAX_DELAY ) :
         xTimerStop( led_h, portMAX_DELAY );

      led_state = !led_state;
   }
}

void led_callback( TimerHandle_t h )
{
   static bool state = false;
   // usamos una variable estática para guardar el estado de esta callback.
   // Este método no debe ser usado si la callback es compartida.

   digitalWrite( 13, state );

   state = !state;
}

void setup()
{
   TimerHandle_t msg_h = xTimerCreate(
         "MSG",                // nombre
         pdMS_TO_TICKS( 1000 ),// periodo inicial
         TMR_AUTO_RELOAD,      // tipo
         NULL,                 // ID
         msg_callback );       // función a ejecutar

   configASSERT( msg_h != NULL );       

   led_h = xTimerCreate(
         "LED",                // nombre
         pdMS_TO_TICKS( 125 ), // periodo inicial
         TMR_AUTO_RELOAD,      // tipo
         NULL,                 // ID
         led_callback );       // función a ejecutar

   configASSERT( msg_h != NULL );       

   xTimerStart( msg_h, 0 );
   // inicia al timer "maestro"
   
   pinMode( 13, OUTPUT );
   // lo usa el timer LED

   Serial.begin( 115200 );
   // lo usa el timer MSG

   vTaskStartScheduler();
}

void loop() 
{
}

La única información nueva que incluí en el ejemplo es la utilización de la constante simbólica portMAX_DELAY en las funciones xTimerStart() y xTimerStop(). Ésta se utiliza para indicarle a FreeRTOS que deberá esperar de manera indefinida por un evento; en otras palabras, que el tiempo de espera xBlockTime será infinito. Lo he hecho de esta manera para que supieras que puedes usarla en tus programas y por simplicidad. Sin embargo, recuerda que en un programa profesional deberás establecer un tiempo de espera para que el sistema responda correctamente ante posibles bloqueos.

Guardando el estado

En el ejemplo anterior guardamos estados y contadores marcando a las respectivas variables como static. Y funciona. Pero como mencioné en el código, éste método no es el adecuado cuando dos o más temporizadores comparten la misma callback. El siguiente ejemplo te muestra cómo utilizar el parámetro ID, junto con las funciones vTimerSetTimerID() y pvTimerGetTimerID(), para guardar un estado.

Antes del ejemplo revisemos muy rápidamente las firmas de dichas funciones:

El punto en común de estas funciones (junto con las de creación de los temporizadores) es que el parámetro para el identificador ID es del tipo void*. Este tipo es oro molido para nosotros los desarrolladores ya que se presta para realizar muchas cosas más allá de las que los diseñadores originales imaginaron. Es decir, la idea del parámetro (y sus funciones asociadas) es utilizarlo para asignar un ID del tipo que nosotros necesitemos (int o char, por ejemplo); sin embargo, void* es un lienzo en blanco que podemos utilizar, como lo muestra el siguiente ejemplo, para cosas completamente distintas:

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

enum { TMR_ONE_SHOT = pdFALSE, TMR_AUTO_RELOAD = pdTRUE };

void led_callback( TimerHandle_t h )
{
   int state = (int)pvTimerGetTimerID( h );

   digitalWrite( 13, state );

   vTimerSetTimerID( h, state == LOW ? (void*)HIGH : (void*)LOW );
}

void setup()
{
   static StaticTimer_t tmr1_state;

   auto tmr1_h = xTimerCreateStatic(
         "TMR1",               
         pdMS_TO_TICKS( 500 ), 
         TMR_AUTO_RELOAD,      
         NULL,                 // aquí podríamos pasar el estado inicial del LED
         led_callback,         
         &tmr1_state );        

   configASSERT( tmr1_h != NULL );       

   vTimerSetTimerID( tmr1_h, (void*)HIGH );
   // el estado inicial del LED es HIGH

   xTimerStart( tmr1_h, 0 );

   
   Serial.begin( 115200 );

   pinMode( 13, OUTPUT );

   vTaskStartScheduler();
}

void loop() 
{
}

Temporizador one-shot

Los ejemplos vistos hasta el momento han utilizado el modo auto-reload, el cual inicia de forma continua al temporizador. Sin embargo, en ocasiones vamos a querer iniciar un temporizador y que al finalizar su tiempo realice alguna acción, y ya; es decir, hará su tarea una sola vez. De ser necesario el programa volvería a arrancarlo.

Esta característica nos puede servir para establecer un tiempo para una acción y olvidarnos de ella (nosotros como programadores, no así el sistema operativo); esto es, nuestras tareas no ocuparán ciclos de CPU para estar revisando si el tiempo terminó.

Ejemplo

Imagina que estás escribiendo un sistema que necesite emitir un sonido intermitente mientras la temperatura de un proceso se mantenga por encima de un cierto nivel, solamente para llamar la atención (lo cual es diferente de una alarma donde la señal sería contínua hasta que alguien resuelva el problema). Por supuesto que podrías escribir el código que se encarga de esto en una de tus tareas, pero un temporizador en modo one-shot te simplificará la vida como lo muestra el siguiente código:

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

enum { TMR_ONE_SHOT = pdFALSE, TMR_AUTO_RELOAD = pdTRUE };


TimerHandle_t beep_h = NULL;


void read_task( void* pvParameters )
{
   TickType_t last_wake_time = xTaskGetTickCount();

   while( 1 )
   {
      vTaskDelayUntil( &last_wake_time, pdMS_TO_TICKS( pdMS_TO_TICKS( 2000 ) ) );

      float temp = ( analogRead( A0 ) * 5.0 * 100.0 ) / 1024;
      // el sensor es un LM35

      if( temp > 25.0 ){

         // aquí apagaríamos al actuador

         digitalWrite( 13, HIGH );
         // simulamos que activamos la señal audible

         xTimerStart( beep_h, portMAX_DELAY );
         // arrancamos al timer
      }

      Serial.print( "Temp = " );
      Serial.println( temp );
   }
}

void beep_callback( TimerHandle_t h )
{
   // llegaremos aquí cuando el tiempo expire

   digitalWrite( 13, LOW );
   // simulamos que desactivamos la señal audible
}

void setup()
{
   xTaskCreate( read_task, "TEMP", 128, NULL, tskIDLE_PRIORITY, NULL );
   // esta tarea realiza las lecturas. Pudo haber sido un timer también,
   // pero quise cambiarle un poco

   beep_h = xTimerCreate(
         "BEEP",               // nombre
         pdMS_TO_TICKS( 500 ), // duración del beep
         TMR_ONE_SHOT,         // una sola vez
         NULL,                 // no lo usamos
         beep_callback );      // función a ejecutar

   configASSERT( beep_h != NULL );       

   pinMode( 13, OUTPUT );
   // emula a un búzer

   Serial.begin( 115200 );
   // lo usa la tarea principal

   vTaskStartScheduler();
}

void loop() 
{
}

Un diagrama de tiempo de este ejemplo es así:

Reiniciando al temporizador

El último tema que veremos en esta lección es sobre el reinicio (reset) de los temporizadores, a través de la función xTimerReset().

Esto es, puedes hacer que el tiempo se reinicie mientras el temporizador está activo. Una aplicación que se me ocurre es la luz de fondo (backlight) de una pantalla LCD de 16×2. Cuando el usuario presione una tecla la luz del LCD se encenderá por, digamos, 5 segundos. Si el usuario no volviera a presionar ninguna tecla dentro de esa ventana de tiempo, entonces la luz se apagaría; pero si el usuario presionara una tecla dentro de la ventana, entonces el temporizador se reiniciaría a 5 segundos otra vez. Y así sucesivamente mientras el usuario siga presionando teclas. Un ejemplo alternativo, pero similar, es de un sensor de presencia que mientras detecte que hay gente en un pasillo mantendrá las luces encendidas; una vez que deja de detectar personas y la ventana de tiempo se cierra, entonces las luces se apagan.

La firma de esta función es:

Los argumentos y el valor devuelto tienen el mismo significado que los de las funciones xTimerStart() y xTimerStop(), por lo cual no los voy a repetir aquí.

Un punto interesante a destacar, y que facilita la programación, es que si el temporizador está activo, entonces el tiempo se reinicia; pero si el temporizador está inactivo (es decir, en estado Dormant), entonces esta función lo arrancará, como si hubiese sido una llamada a xTimerStart().

El siguiente programa simula el encendido de la luz de fondo de un LCD:

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

enum { TMR_ONE_SHOT = pdFALSE, TMR_AUTO_RELOAD = pdTRUE };


TimerHandle_t button_h = NULL;
TimerHandle_t backl_h = NULL;


void button_callback( TimerHandle_t h )
{
   static uint8_t state = 0;
   
   static uint8_t ticks = 0;

   uint8_t pin = (uint8_t)pvTimerGetTimerID( button_h );
   // recuperamos el pin donde está conectado el push-button

   switch( state ) {

   case 0:
      if( digitalRead( pin ) == LOW ){
         state = 1;

         ticks = 5;
         // debouncing de 50 ms (10ms * 5)

      }
   break;

   case 1:
      --ticks;
      if( ticks == 0 ){

         if( digitalRead( pin ) == LOW ){

            digitalWrite( 13, HIGH );
            // encendemos la luz de fondo del LCD

            xTimerReset( backl_h, portMAX_DELAY );
            // si el timer está activo, lo reinicia;
            // en caso contrario lo arranca (como lo haría xTimerStart)
            
            state = 2;
            // esperamos a que deje de presionar el push-button

         } else{ // fue ruido:
            state = 0;
         }
      }
   break;

   case 2:
      if( digitalRead( pin ) != LOW ) state = 0;
   break;

   default:
      state = 0;
   break;
   }
}

void backl_callback( TimerHandle_t h )
{
   digitalWrite( 13, LOW );
   // apagamos la luz de fondo del LCD
}


#define PUSHBUTTON_PIN 2

void setup()
{
   button_h = xTimerCreate(
         "BTN",
         pdMS_TO_TICKS( 10 ),
         TMR_AUTO_RELOAD,
         
         (void*) PUSHBUTTON_PIN, 
         // usamos al campo ID para pasar el número de pin
         
         button_callback );

   backl_h = xTimerCreate(
         "BCKL",               
         pdMS_TO_TICKS( 5000 ),
         TMR_ONE_SHOT,         
         NULL,                 
         backl_callback );     

   pinMode( 13, OUTPUT );
   // emula a la luz de fondo del LCD

   pinMode( PUSHBUTTON_PIN, INPUT );
   // nuestro push-button

   Serial.begin( 115200 );
   // lo usa la tarea principal


   xTimerStart( button_h, 0 );
   // arrancamos al temporizador "maestro"

   vTaskStartScheduler();
}

void loop() 
{
}

La API de FreeRTOS y las interrupciones

En todos los ejemplos que hemos visto a lo largo de este curso hemos estado llamando a las diferentes funciones de FreeRTOS desde nuestras funciones y tareas; sin embargo, en la vida real vamos a querer obtener la misma funcionalidad desde las interrupciones.

Muchas de las funciones de FreeRTOS que hemos visto (y muchas otras que no hemos visto) no deben ser llamadas desde una interrupción, so pena de un mal-funcionamiento del programa. Para esos casos FreeRTOS provee funciones espejo que pueden ser llamadas con toda seguridad desde dentro de una interrupción. Estas funciones espejo tienen la terminación FromISR():

Funciones de la API de los software-timers de FreeRTOS.

En el caso que nos ocupa en esta lección, de la imagen anterior podrás observar diversas funciones con terminación FromISR(). Esas son las que debes llamar si lo necesitas hacer desde dentro de una interrupción (ISR son las siglas de Interrupt Service Routine).

RECUERDA
Cuando tengas que llamar a alguna función de FreeRTOS desde una interrupción, verifica que estás llamando a la versión correcta.

¿Qué sigue?

Los temporizadores (software-timers) son una adición reciente de FreeRTOS que, como pudiste observar, simplifican nuestras aplicaciones gracias a que la administración de los diferentes temporizadores se delega a una tarea central integrada en el sistema operativo (el daemon). Y seamos honestos, mucho de nuestro tiempo de diseño y desarrollo lo invertimos en actividades temporizadas, y lo queramos o no, terminamos escribiendo temporizadores. Cuando nos veamos haciendo esto deberíamos considerar utilizar los software timers tomando en cuenta que si dichas actividades son complejas, escogeríamos las tareas; pero si no son tan complejas, escogeríamos temporizadores.

En esta lección vimos las operaciones más importantes de los temporizadores, pero como viste en la imagen de la API de los mismos, hay muchas más funciones, las cuales puedes revisar por tu cuenta. Sin embargo, con lo que estudiamos es suficiente para que comiences a integrarlos a tus aplicaciones.

RECUERDA (otra vez): Cuando llames a funciones de FreeRTOS desde interrupciones, verifica que estás llamando a la versión correcta. (Debía insistir en este punto.)

En la siguiente lección veremos semáforos binarios y semáforos contadores utilizando las notificaciones directas a las tareas (direct to task notifications). Éstas ya las vimos, pero ahora lo haremos desde el punto de vista de la sincronización de tareas.

Índice del curso

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


Si encuentras este blog interesante, entonces podrías considerar suscribirte a él y recibir información relevante sobre tecnología y sistemas embebidos, y de vez en cuando, uno que otro regalo.

Notificaciones (II)

En la lección anterior vimos la forma de pasar un dato de hasta 32 bits utilizando las notificaciones directas a las tareas que FreeRTOS provée como un mecanismo incorporado «de fábrica» (todas las tareas incluyen un campo de 32 bits). En la lección de hoy veremos cómo utilizar este mismo mecanismo pero para pasar notificaciones (banderas, avisos) de una tarea a otra.

Tabla de contenidos

¿Qué es y qué necesito?

Este tipo de notificaciones son a nivel de bit; un bit puesto a 1 podría indicar que un sensor se activó, o que una lectura del ADC ya está lista para ser leída, o que un botón se presionó. La tarea productora pondría uno o varios bits a 1 y después la tarea receptora, esperará por el evento, liberando la CPU hasta que le lleguen una o varias notificaciones.

El caso más simple es el de las notificaciones de un sólo bit: la conversión terminó, llegó un byte por el puerto serial (sólo avisa que el byte llegó, no el valor propio de la información), un botón fue presionado (no el valor del botón presionado).

Casos más complejos podrían ser que, además de indicar que el eventó sucedió, pasar la información relacionada a éste. En el ejemplo de una conversión podemos pasar, además, el canal del ADC y el valor de la lectura. En el caso del byte del puerto serial podemos pasar, también, el valor del byte. Y finalmente, para el ejemplo del botón podemos indicar el botón que fue presionado. En todos y cada uno de estos escenarios estaremos utilizando el campo de 32 bits asociado a las notificaciones.

Operaciones a nivel de bit

Máscaras de bits

Antes de entrar en los detalles para usar las notificaciones debemos recordar las máscaras de bits, o simplemente máscaras. Una máscara es una secuencia de bits que indican cuáles bits nos interesan y cuáles no tienen importancia. Para la siguiente explicación usaremos 8 bits, por simplicidad, pero la idea se traslada directamente a los 32 bits disponibles.

Entonces, tenemos 8 bits cuyas posiciones van de la 0 a la 7, contando de derecha a izquierda:

dibujo

Y supongamos que de estos bits nos interesan aquellos en las posiciones 1, 3 y 6. Por lo tanto, debemos descartar los bits en las posiciones 0, 2, 4, 5, 7. Para esto deberemos crear una máscara con 1s en los bits que nos interesan, y con 0s en los bits que no:

dibujo

Este valor lo podemos expresar ya sea en binario o en hexadecimal, siendo esta última la manera más común

#define MASK 0x4A

Una vez que tenemos a la máscara debemos aplicársela al dato del cual queremos discriminar los bits, es decir, quedarnos con los bits que nos interesan. Para ello utilizaremos la operación AND a nivel de bit de C, &. Digamos que nuestro dato tiene el valor 0xFD (en binario, 1111 1101), entonces, de forma gráfica, la aplicación de la máscara es así:

Y en código en C nos queda así:

uint8_t dato = 0xFD;
uint8_t res = dato & MASK; // res <- 0x48

El resultado es 0x48 (en binario, 0100 1000). Esto significa que de nuestro dato el bit 1 vale 0, el bit 3 vale 1, y el bit 6 vale 1. Ya dependerá de nuestra aplicación qué haremos con esta información, lo cual por supuesto estará en los ejemplos que veremos más adelante.

(Si quieres profundizar en las operaciones a nivel de bit, de las cuales hay muchas, puedes ir aquí; y para revisar las operaciones a nivel de bit en C, puedes ir aquí (en inglés)).

Notificaciones de 1 bit

El caso más simple es cuando tienes una sola notificación, la cual puedes codificar con un sólo bit. Si el bit está a 0, el evento no ha sucedido, y si el bit está a 1, entonces el eventó sucedió.

También tenemos el caso en que metemos varios bits independientes en el mismo campo de 32 bits. Esto es útil cuando una misma tarea debe notificar a otra tarea de diversas situaciones, donde cada situación requiere un bit. En el ejemplo a continuación utilizaremos varios bits individuales.

NOTA: Las notificaciones de un sólo bit se parecen más a un mecanismo de sincronización llamado semáforo binario. Éstos no incluyen ningún tipo de información, solamente avisan que un evento sucedió (o que no ha sucedido). Por lo tanto, y como el propio FreeRTOS lo menciona, una forma rápida y barata de implementar semáforos binarios es a través de las notificaciones. Debo señalar, sin embargo, que FreeRTOS incluye semáforos (binarios y contadores) con más características (entre otras, un productor puede notificar a varios receptores), pero que, naturalmente, consumen más recursos.

Ejemplo

En este ejemplo haremos lecturas analógicas para la temperatura ambiente (utilizando al sensor LM35), y avisaremos a la tarea receptora si la temperatura cae en uno de 4 estados: menor a 17 grados centígrados, entre 17 y 24.99 grados, entre 25 y 28.99 grados, y mayor o igual a 29 grados. Cada estado está representado por un bit. Escogí los bits 0-3 del campo de notificación, pero por supuesto tú puedes utilizar la distribución que desees:

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

TaskHandle_t consumidor_h;

void Productor1_task( void* pvParameters )
{
   TickType_t last_wake_time = xTaskGetTickCount();

   while( 1 )
   {
      vTaskDelayUntil( &last_wake_time, pdMS_TO_TICKS( pdMS_TO_TICKS( 1000 ) ) );

      float temp = ( analogRead( A0 ) * 5.0 * 100.0 ) / 1024;

      uint32_t flags = 0;

      if( temp < 17.0 ) flags = 0x00000001;
      else if( temp < 25.0 ) flags = 0x00000002;
      else if( temp < 29.0 ) flags = 0x00000004;
      else flags = 0x00000008;

      xTaskNotify( consumidor_h, flags, eSetValueWithOverwrite );
   }
}

void Consumidor_task( void* pvParameters )
{
   while( 1 )
   {
      uint32_t res;

      if( xTaskNotifyWait( 0xfffffff0, 0xffffffff, &res, pdMS_TO_TICKS( 2000 ) ) != pdFALSE ){

         if( res & 0x01 ){
            Serial.println( "Hace un poco de frío.\n" );
         } else if( res & 0x02 ){
            Serial.println( "El tiempo es agradable." );
         } else if( res & 0x04 ){
            Serial.println( "Hace algo de calor." );
         } else{
            Serial.println( "Hace mucho calor." );
         }

      } else{
         Serial.println( "ERR: No se recibió la notificación." );
      }
   }
}

void setup()
{
   Serial.begin( 115200 );

   xTaskCreate( Productor1_task, "PRD1", 256, NULL, tskIDLE_PRIORITY + 1, NULL );

   xTaskCreate( Consumidor_task, "CONS", 256, NULL, tskIDLE_PRIORITY, &consumidor_h );

   vTaskStartScheduler();
}

void loop() 
{
}

En la tarea consumidora, Consumidor_task(), podrás notar 2 cosas en la llamada a la función xTaskNotifyWait():

  • Utilicé las máscaras de entrada y salida (primero y segundo parámetro, respectivamente) para el campo de notificaciones.
  • Establecí un tiempo de guarda de 2000 ms.

Recordemos la firma de dicha función:

El parámetro ulBitsToClearOnEntry es una máscara que pone a 0 los bits indicados del campo de notificación cuando se entra a la función. Lo podemos utilizar para descartar los bits que no nos interesen. Un bit a 1 significa que el bit en esa posición del campo de notificaciones será puesto a 0. En el ejemplo observarás que descarté los bits 4-31, ya que en la tarea productora solamente utilicé del 0-3.

El parámetro ulBitsToClearOnExit es una máscara que pone a 0 los bits indicados del campo de notificación cuando se sale de la función. Lo podemos utilizar para avisar que ya leímos el campo de notificación. Recuerda que la función xTaskNotify() tiene un parámetro de control, eAction (de tipo eNotifyAction), con el cual la tarea productora podría fallar si pretendieras escribir una nueva notificación sin que la tarea consumida haya leído la anterior (cuando eAction es eSetValueWithoutOverwrite). En el ejemplo establecí que los 32 bits se pusieran a 0 a la salida de la función.

Finalmente, establecí un tiempo de guarda de 2000 ms. La tarea productora realiza una lectura cada 1000 ms, pero si por alguna razón no hiciera la notificación dentro de esa ventana de 2000 ms, entonces la tarea consumidora imprimiría un mensaje de error.

A continuación tienes un fragmento de la salida de este ejemplo:

Ejercicio: Comenta la línea

xTaskNotify( consumidor_h, flags, eSetValueWithOverwrite );

en la tarea productora y observa el resultado.

Codificando bits y datos

Otra cosa interesante que podemos hacer con el campo de notificaciones es codificar en el mismo tanto datos como bits. Al ejemplo anterior podríamos agregarle que, además de indicar con bits el rango de temperatura, notifique la temperatura (para esto deberemos haremos algunos cambios en el código).

Para lograr lo anterior necesitaremos revisar dos cosas antes: poner bits a 1 y desplazamientos.

Poniendo bits a 1

En la sección anterior vimos cómo poner a 0 bits de una máscara usando al operador a nivel de bits de C ‘and’, &. Lo que necesitamos ahora es poner bits a 1; para ello necesitamos al operador ‘or’ a nivel de bits de C, | (una barra). Requeriremos una máscara que indique los bits a poner a 1, y por supuesto el dato (variable) en el cual pondremos a 1 dichos bits.

Por ejemplo, supón que nuestro dato tiene el valor 0x28 y queremos poner sus bits en las posiciones 0, 1, 4, 5 a 1. Entonces creamos una máscara con 1’s en dichas posiciones, y 0’s en el resto: 0x33 (en binario 0011 0011). Después de aplicarle esta máscara a nuestro dato, éste queda con el valor 0x3B:

Los bits que en el dato original ya estaban a 1 y le tocó ponerse a 1, no cambian de valor. Así mismo, los bits que tenían 1 y le aplicas un 0, quedan con 1; es decir, una máscara OR con 0 no afecta el valor del bit.

La operación anterior en C queda como

uint8_t dato = 0x28;
uint8_t res = dato | 0x33; // res <- 0x3B

Desplazando bits

La siguiente operación a nivel de bit es el desplazamiento: mover uno o más bits una o más posiciones a la izquierda o a la derecha. C cuenta con los operadores << y >>, respectivamente. De paso debo comentar que el desplazamiento de un bit a la izquierda equivale a multiplicar al dato por 2; mientras que el desplazamiento de un bit a la derecha equivale a dividir al dato por 2. Dos desplazamientos a la derecha es como multiplicar por 4, y así sucesivamente.

Por ejemplo, si tienes el dato 0x03 (en decimal, 3) y lo desplazas 3 bits a la izquierda, tendrás al final el valor 0x18 (en decimal, 24). Desplazar 3 bits a la izquierda es como multiplicar 2^3=8, y 3*8=24:

La operación anterior en C queda así:

uint8_t dato = 0x03;
uint8_t res = dato << 3; res <- 0x18

Es muy común que el desplazamiento se haga sobre el propio dato; es decir, lo aplicas al dato y lo guardas en el propio dato. Esta operación en C queda así:

uint8_t dato = 0x03; // 0000 0011

dato <<= 3; // dato <- 0x18, es decir, 0001 1000

// la operación "larga" es: dato = dato << 3;

dato >>= 1; // dato <- 0x0C, es decir, 0000 1100, en decimal, 12

Ejemplo

Ahora sí, equipados con las herramientas adecuadas es momento de ver cómo podemos codificar en el campo de notificaciones tanto datos como bits. El ejemplo consta de realizar lecturas en 3 canales del convertidor ADC, una lectura por segundo. La tarea productora notificará a la tarea consumidora el canal del cuál se realizó la lectura (con bits individuales, como el ejemplo pasado) y el valor de la lectura correspondiente (sin procesar). Es decir, un canal del ADC será 0x01, otro canal será 0x02, y otro canal será 0x04. Por otro lado, el dato de 10 bits de la lectura lo insertaremos a partir del bit 16 (este valor no tiene nada de especial, solamente quise abarcar la mayor cantidad posible de bits en el campo de notificación).

NOTA: Mientras trabajaba en el siguiente ejemplo noté un error del compilador de C que Arduino utiliza (avr-gcc): Los desplazamientos a la izquierda NO FUNCIONAN con variables de más de 8 bits. Los desplazamientos a la derecha, sí. En el código notarás que realicé una multiplicación por 65536, lo cual es equivalente a un desplazamiento de 16 bits a la izquierda. Este es un problema de Arduino y su compilador, no de FreeRTOS.

(«No funcionan» significa que sí compilan, pero los resultados son incorrectos, por lo cual no son confiables y no deberías utilizarlos. Una solución es lo que yo hice, multiplicar por 2^n.)

Recuerda que debes activar el uso de las notificaciones en el archivo de configuración FreeRTOSConfig.h. Lo hicimos en el post anterior, pero en caso de que no lo hayas hecho, entra a dicho archivo y escribe la siguiente línea al final del mismo:

#define configUSE_TASK_NOTIFICATIONS 1

Veamos el ejemplo:

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

TaskHandle_t consumidor_h;

void Productor1_task( void* pvParameters )
{
   TickType_t last_wake_time = xTaskGetTickCount();

   uint8_t channel;
   uint16_t read;

   while( 1 )
   {
      for( uint8_t cont = 0; cont < 3; ++cont ){

         vTaskDelayUntil( &last_wake_time, pdMS_TO_TICKS( pdMS_TO_TICKS( 1000 ) ) );

         switch( cont ){
         case 0:
            read = analogRead( A0 );
            channel = 0x01; // 1
         break;

         case 1:
            read = analogRead( A2 );
            channel = 0x02; // 10
         break;

         case 2:
            read = analogRead( A3 );
            channel = 0x04; // 100
         break;

         default:
            while( 1 );
         break;
         }

         uint32_t notif = channel | ( read * 65536 );
         // aquí realizamos la codificación de la lectura y el canal
         //
         // NOTA: El compilador avr-gcc emite código erróneo con los desplazamientos
         // a la izquierda en variables de más de 8 bits. Sin embargo, desplazar a la
         // izquierda n bits equivale a multiplicar el valor por 2^n:
         // 2^16 = 65536

         xTaskNotify( consumidor_h, notif, eSetValueWithOverwrite );

      } // for cont

   } // while 1
}

void Consumidor_task( void* pvParameters )
{
   while( 1 )
   {
      uint32_t res;

      if( xTaskNotifyWait( 0xfc00fff8, 0xffffffff, &res, pdMS_TO_TICKS( 2000 ) ) != pdFALSE ){

         uint8_t channel = res & 0x00000007;
         // aislamos y obtenemos los bits que codifican al canal del ADC

         uint16_t read = ( res & 0x03ff0000 ) >> 16;
         // aislamos y obtenemos los bits que codifican a la lectura


         if( res & 0x01 ){
            Serial.print( "A0: " );
         } else if( res & 0x02 ){
            Serial.print( "A1: " );
         } else if( res & 0x04 ){
            Serial.print( "A2: " );
         } else{
            Serial.println( "Error! " );
         }
         Serial.println( read );

      } else{
         Serial.println( "ERR: No se recibió la notificación." );
      }
   }
}

void setup()
{
   Serial.begin( 115200 );

   xTaskCreate( Productor1_task, "PRD1", 256, NULL, tskIDLE_PRIORITY + 1, NULL );

   xTaskCreate( Consumidor_task, "CONS", 256, NULL, tskIDLE_PRIORITY, &consumidor_h );

   vTaskStartScheduler();
}

void loop() 
{
}

Escogí los canales del ADC A0, A2 y A3 sólo para complicar un poco el código (están «salteados»).

Nota que en primer parámetro de la función xTaskNotifyWait(), en la función consumidora, utilicé la máscara 0xfc00fff8, la cual descarta los bits que no usamos. Te dejo de tarea que lo compruebes (recuerda que el ADC es de 10 bits y que los bits a 1 en la máscara ponen a 0 los bits del campo de notificación).

¿Qué sigue?

Hoy vimos mucha información. Comenzamos con las operaciones a nivel de bit de C, las cuales deben estar en la caja de herramientas de cualquier programador en este lenguaje; sin embargo, hice un pequeño resumen para acordarnos de los temas que utilizaríamos. Vimos también dos usos del campo de notificaciones de las tareas: bits individuales y bits individuales más datos. Mencioné además que descubrí un error (bug) en el compilador que utiliza Arduino. Finalmente, hice mención de un tema que trataremos más adelante, el de los semáforos. Sin embargo, las notificaciones directas se pueden utilizar como semáforos binarios y semáforos contadores «ligeros», es decir, rápidos y que no consumen muchos recursos (a expensas de estar más limitados).

En la siguiente lección exploraremos el tema de los temporizadores por software. Muchas de las tareas que realizan nuestros sistemas están basadas, de una forma u otra, en temporizaciones; y FreeRTOS incluye un mecanismo que nos facilitará la vida.

Índice del curso

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


Si encuentras este blog interesante, entonces podrías considerar suscribirte a él y recibir información relevante sobre tecnología y sistemas embebidos, y de vez en cuando, uno que otro regalo.

Notificaciones (I)

Hasta hoy hemos visto ejemplos de tareas aisladas para demostrar algunos puntos. Sin embargo, las diferentes tareas de las que un sistema embebido está compuesto no trabajan en aislamiento; al contrario, todas ellas, aunque independientes, trabajan en equipo con un mismo objetivo. Para lograrlo las tareas deben pasarse información unas con otras y estar en sincronía. En esta lección veremos una primer forma de pasar información, y en un próxima trataremos el tema de la sincronización.

En FreeRTOS existen diferentes maneras para que una tarea pase información a otra, y cada una varía en su grado de complejidad:

  1. Notificaciones directas (más «fácil», pero más limitada).
  2. Colas.
  3. Flujos y mensajes (menos «fácil» pero más poderosa y versátil).

Las notificaciones directas son la forma más fácil y rápida para pasar información. Hoy vamos a ver cómo pasar un dato de 32 bits, y en la segunda parte veremos cómo pasar y procesar banderas (conocidas también como notificaciones). En lecciones posteriores estudiaremos las restantes.

Tabla de contenidos

¿Qué es y qué necesito?

Hasta antes de la versión 10.4.0 de FreeRTOS cada tarea en FreeRTOS incluía un campo de 32 bits para ser utilizado como canal de comunicaciones o de sincronización. A partir de dicha versión se incluyó un arreglo de campos. En esta lección estudiaremos las notificaciones simples (las anteriores a la versión 10.4.0).

El nombre oficial para los campos de 32 bits es «notificaciones» y pertenecen a un mecanismo de FreeRTOS llamado notificaciones directas a las tareas (en inglés, Direct to task notifications, DtTN). Aunque este nombre evoca más sincronización que comunicaciones lo utilizaré junto con el de campos para evitar futuras confusiones. Además, ten en cuenta que las notificaciones también sirven para notificar eventos a las tareas, lo cual es tema de una siguiente lección.

La idea detrás de las notificaciones es simple: si la tarea Ta debe pasarle un dato a la tarea Tb, entonces Ta deberá escribirlo en el campo de notificación de Tb, y ésta utilizarlo. Así de fácil. El campo de datos del cual estoy hablando es un entero de 32 bits, y en éste puedes pasar valores enteros, booleanos, banderas, e inclusive, apuntadores void (para pasar información más compleja).

Toma en cuenta que este mecanismo, DtTN, sólo funciona para pasar un dato a la vez de una tarea a otra. Si tu aplicación necesita pasar una colección (lista) de resultados a una o más tareas, entonces deberás utilizar colas, flujos o mensajes, los cuales son más complejos, más lentos y consumen más recursos que las notificaciones directas.

Quizás te estés preguntando, ¿porqué no simplemente usamos variables globales ? Bueno, por tres razones:

  1. Las variables globales son malas.
  2. La tarea receptora Tb, del ejemplo anterior, puede bloquearse (es decir, pasar al estado Blocked; en términos coloquiales, irse a «dormir») liberando la CPU hasta que reciba la notificación por parte de la tarea Ta. Cuando el dato esté listo, entonces Tb pasa al estado Ready (lista para ejecutarse).
  3. Si el dato no llega dentro de un cierto tiempo, entonces la tarea receptora Tb puede salir del estado Blocked y tomar las acciones necesarias. Esto es muy importante en sistemas embebidos seguros. Además, bajo ciertas configuraciones, existe la posibilidad de que la tarea productora Ta también tome acciones en caso de que por alguna razón la tarea Tb no pudiera accesar al dato.

Si quisieras utilizar variables globales en lugar de las notificaciones de FreeRTOS tu tarea receptora debería estar todo el tiempo despierta preguntando si ya hay un dato disponible; y como consecuencia estaría consumiendo valiosos ciclos de CPU. Y si eso no es suficiente, tendrías que agregar un temporizador para salir de la espera. Muy complicado, mejor usa las notificaciones.

Cuando la tarea emisora Ta escribe el dato en la tarea receptora Tb, la notificación (así se le llama en FreeRTOS) pasa al estado Pending (en español, pendiente) esperando a que Tb lo lea. Este estado Pending de la notificación es el que hace que Tb pase del estado Blocked a Ready. Luego, cuando Tb lo lee, entonces la notificación pasa al estado Not pending. El hecho de que el dato haya sido escrito en Tb no implica que tenga que ser leído inmediatamente, ya que podría pasar un tiempo entre una y otra cosa. Por ejemplo, supón que Tb es una tarea de baja prioridad, y después de que Ta escribió el dato, una tarea de más alta prioridad obtiene la CPU por lo cual, aunque Tb ya fue «notificada», aún no ha podido leer el dato, sino hasta que le toque su turno.

Por otro lado, en algunas configuraciones (que veremos después) si la notificación tiene el estado Not pending (Tb no la ha leído) y la tarea emisora Ta intenta escribir un nuevo dato, entonces la llamada Ta devolverá un error. Esto sirve porque a veces no querrás sobreescribir un valor nuevo sobre uno anterior si la tarea receptora no lo ha leído.

Para empezar a mandar información entre tareas (en inglés, intertask communication) vamos a usar dos funciones: xTaskNotify() y xTaskNotifyWait(). (Aquí puedes ver el conjunto completo de funciones para notificaciones.)

xTaskNotify()

Esta es la función que la tarea productora debe utilizar para pasar el dato. Su firma es:

xTaskToNotify. En este parámetro le pasamos el handler de la tarea receptora.

¿Recuerdas que en esta lección te mencioné que varios mecanismos de FreeRTOS requieren el handler de las tareas para operar sobre ellas? Pues no hay plazo que no se cumpla. Muchas de las funciones que estudiaremos a partir de esta lección necesitan saber sobre quién van a operar, y la única forma es a través de su handler. En FreeRTOS hay dos maneras de obtenerlo:

  1. Guardándolo (en una variable global) cuando creas tareas con xTaskCreate() y xTaskCreateStatic().
  2. Utilizando a la función xTaskGetHandle(), la cual utiliza a la cadena de texto que representó el nombre de la tarea cuando la creaste (aquí puedes ver su descripción).

En esta lección utilizaremos la primer forma, tanto por simplicidad, como para que veas cómo se guarda (ya que no lo hemos hecho en ejemplos anteriores).

ulValue. Aquí escribes el valor de 32 bits que quieres pasar a la tarea receptora xTaskToNotify.

De ser necesario deberás promocionar el dato que quieres escribir al tipo uint32_t.

eAction. Este parámetro le indica a la función qué hacer con el dato de 32 bits. Para efectos de pasar un dato, el cual es el tema de esta lección, deberás escribir la constante eSetValueWithOverwrite.

En la siguiente lección, Notificaciones (II), te explicaré con detalle el resto de valores que puedes escribir en este parámetro y su significado. Por lo pronto, con lo descrito es suficiente.

Para el caso que nos ocupa, esta función siempre devolverá el valor pdPASS.

xTaskNotifyWait()

La tarea receptora deberá llamar a esta función, cuya firma es:

Como ya lo había mencionado, una vez que la tarea receptora hace la llamada pasa al estado Blocked esperando a que un dato arribe, y mientras la tarea esté en ese estado no usa ciclos de la CPU, lo cual es bueno. Esta función también tiene la ventaja de que si el dato no llega dentro de un cierto tiempo, puede salir y avisarnos para que tomemos las medidas necesarias.

En el bajo mundo de los sistemas operativos cuando una tarea se queda esperando de manera indefinida por un dato o resultado que nunca llegará le llamamos starvation (en español, inanición), y como podrás imaginar, es una muy mala situación. Afortunadamente, muchas funciones de FreeRTOS incorporan una especie de software watchdog (perro guardían) que nos ayudará a salir de dicha situación.

Si deseas utilizar esta característica asegúrate que la tarea receptora tenga una prioridad alta, ya que de no ser así, el código asociado al manejo de la inanición tardará en ejecutarse, o peor aún, nunca hacerlo.

Veamos los parámetros de la función:

ulBitsToClearOnEntry. Este parámetro indica qué bits del dato deben ponerse a cero a la entrada de la función. Es una especie de máscara para eliminar bits que no nos interesan y se utiliza cuando pasas bits en lugar de valores.

En esta lección deberás escribir el valor 0x00. Con esto le estarás diciendo que no ponga ningún bit a cero, ya que eso alteraría nuestro dato. En la segunda parte de esta lección veremos cómo se usa.

ulBitsToClearOnExit. Este parámetro indica qué bits del dato deben ponerse a cero a la salida de la función. Es una especie de máscara para eliminar bits que no nos interesan y se utiliza cuando transmites bits en lugar de valores.

En esta lección deberás escribir el valor 0x00. Con esto le estarás diciendo que no ponga ningún bit a cero a la salida. FreeRTOS hace una copia del dato antes de aplicarle la limpieza de bits, y luego devuelve dicha copia, así que por lo pronto no te preocupes. En la segunda parte de esta lección veremos cómo se usa.

pulNotificationValue. En este parámetro escribirás la dirección de la variable donde quieres guardar el dato. Recuerda que dicha variable debe ser de 32 bits, es decir, del tipo uint32_t.

xTicksToWait. En este parámetro indicas cuánto tiempo (en ticks) quieres esperar por el dato; puedes esperar el tiempo que quieras (especificándolo en milisegundos a través de la macro pdMS_TO_TICKS(), o esperar por siempre especificando el valor portMAX_DELAY.

En un sistema embebido bien hecho JAMÁS deberías especificar portMAX_DELAY.

Atte: Cualquier programador de sistemas embebidos.

Me permito ser repetitivo: para usar esta característica asegúrate que la prioridad de la tarea receptora sea igual o mayor que la productora para que cuando el tiempo expire FreeRTOS la escoja para ser ejecutada. Si tiene prioridad baja, entonces deberá esperar su turno, y quizás éste nunca llegue.

En los ejemplos a continuación veremos ambas formas.

Valor de retorno. Esta función devolverá pdPASS cuando se reciba una notificación, o si la notificación ya estaba lista cuando hiciste la llamada. Si el tiempo programado expiró, entonces devolverá pdFAIL.

Configuración

Antes de ejecutar los ejemplos a continuación asegúrate que las siguientes constantes simbólicas (en el archivo de configuración FreeRTOSConfig.h) tienen el valor mostrado:

#define configUSE_TASK_NOTIFICATIONS            1
#define configTASK_NOTIFICATION_ARRAY_ENTRIES   1 // o cualquier valor igual o mayor

Ejemplo 1

En este primer ejemplo veremos un programa simple donde una tarea produce un dato cada 1000 ms y una tarea receptora los procesa. El dato producido es el valor 1, 2, 3. La tarea receptora parpadeará ese mismo número de veces. El periodo de cada parpadeo es de 200 ms.

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

TaskHandle_t consumidor_h;
// la tarea productora necesita saber quién es el consumidor

void Productor_task( void* pvParameters )
{
    uint32_t cont = 1;
    // el valor que queremos transmitir debe ser uint32_t

    TickType_t last_wake_time = xTaskGetTickCount();

    while( 1 )
    {
        vTaskDelayUntil( &last_wake_time, pdMS_TO_TICKS( 1000 ) );

        Serial.println( "P" );

        xTaskNotify( consumidor_h, cont, eSetValueWithOverwrite );

        ++cont;
        if( cont > 3 ) cont = 1;
    }
}

void Consumidor_task( void* pvParameters )
{
    pinMode( 13, OUTPUT );

    uint32_t blinks;
    // la variable donde guardamos el dato debe ser uint32_t

    while( 1 )
    {
        xTaskNotifyWait( 0x00, 0x00, &blinks, portMAX_DELAY );

        Serial.println( blinks );
        // Depuración: Imprimos el número de parpadeos

        for( uint8_t i = 0; i < blinks; ++i ){

            digitalWrite( 13, HIGH );
            vTaskDelay( pdMS_TO_TICKS( 100 ) );
            digitalWrite( 13, LOW );
            vTaskDelay( pdMS_TO_TICKS( 100 ) );
        }
    }
}

void setup()
{
    Serial.begin( 115200 );

    xTaskCreate( Productor_task, "PROD", 128, NULL, tskIDLE_PRIORITY + 1, NULL );
    // nota que la prioridad del productor es más alta que la del consumidor;
    // casi siempre será así.

    xTaskCreate( Consumidor_task, "CONS", 128, NULL, tskIDLE_PRIORITY, &consumidor_h );
    // en consumidor_h guardamos el handle de la tarea consumidora

    vTaskStartScheduler();
}

void loop() 
{
}

Nota que la tarea productora tiene prioridad más alta que la consumidora. Además, no basta con que la productora escriba el dato, también debe prestarle la CPU a la tarea receptora; esto lo logra llamando a la función vTaskDelayUntil(), aunque por supuesto puedes usar cualquier otra función que haga que la tarea productora se bloquée.

Ejemplo 2

En este ejemplo agregué elementos para que la tarea productora falle (no notifique a la tarea receptora), y para que la receptora no se quede en un ciclo infinito esperando por un dato que nunca va a llegar. Ambas tareas tienen la misma prioridad.

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

TaskHandle_t consumidor_h;
// la tarea productora necesita saber quién es el consumidor

void Productor_task( void* pvParameters )
{
    uint32_t cont = 1;

    uint8_t cont_to_fail = 10;

    TickType_t last_wake_time = xTaskGetTickCount();

    while( 1 )
    {
        vTaskDelayUntil( &last_wake_time, pdMS_TO_TICKS( 1000 ) );

        Serial.println( "P" );

        xTaskNotify( consumidor_h, cont, eSetValueWithOverwrite );

        ++cont;
        if( cont > 3 ) cont = 1;

        // hacemos que falle después de 10 ciclos
        if( --cont_to_fail == 0 ){ 

           while( 1 ) taskYIELD();
           // esta tarea entrega voluntariamente la CPU,
           // aunque la vida real no es tan bonita

        }
    }
}

void Consumidor_task( void* pvParameters )
{
    pinMode( 13, OUTPUT );

    uint32_t blinks;

    while( 1 )
    {
        if( xTaskNotifyWait( 0x00, 0x00, &blinks, pdMS_TO_TICKS( 2000 ) ) == pdPASS ){

           Serial.println( blinks );

           for( uint8_t i = 0; i < blinks; ++i ){

               digitalWrite( 13, HIGH );
               vTaskDelay( pdMS_TO_TICKS( 100 ) );
               digitalWrite( 13, LOW );
               vTaskDelay( pdMS_TO_TICKS( 100 ) );

           }
         } else{ // time-over:

            digitalWrite( 13, HIGH );

            while( 1 ){
               Serial.println( "Error" );
               vTaskDelay( pdMS_TO_TICKS( 1000 ) );
            }
         }
    }
}

void setup()
{
    Serial.begin( 115200 );

    xTaskCreate( Productor_task, "PROD", 128, NULL, tskIDLE_PRIORITY + 0, NULL );

    xTaskCreate( Consumidor_task, "CONS", 128, NULL, tskIDLE_PRIORITY, &consumidor_h );

    vTaskStartScheduler();
}

void loop() 
{
}

En la siguiente imagen podrás ver una ejecución de este programa:

¿Qué sigue?

En la lección de hoy vimos cómo utilizar un mecanismo intrínseco de FreeRTOS para comunicaciones y sincronización: las notificaciones directas a las tareas. Éste es un campo de 32 bits al cual podemos darle diversos usos. Uno de ellos ya lo discutimos, y otro uso, el de las notificaciones, lo veremos en la segunda parte.

También recalqué la idea de que las tareas no deberían quedarse esperando de manera indefinida. Muchas funciones de FreeRTOS incluyen un time-out para que la tarea receptora tome las acciones necesarias en caso de inanición (starvation). Úsalo siempre que te sea posible.

En la segunda parte de esta lección estudiaremos cómo usar este mismo mecanismo pero para enviar notificaciones (banderas, señalizaciones) como bits individuales.

(En esta página encontrarás información de suma importancia sobre los flujos, mensajes y notificaciones, aunque todavía no hayamos visto tales temas. Deberías leerla en cuanto te sea posible.)

Índice del curso

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


Si encuentras este blog interesante, entonces podrías considerar suscribirte a él y recibir información relevante sobre tecnología y sistemas embebidos, y de vez en cuando, uno que otro regalo.

Tareas periódicas

Una actividad muy común en nuestros proyectos es la programación de código que debe ejecutarse de manera periódica. FreeRTOS cuenta con dos funciones que nos permiten tener tareas periódicas: vTaskDelay() y vTaskDelayUntil().

Quizás te estés preguntando por la función delay() de Arduino, ¿no es suficiente con ella? Pues no. Esta función, como otras tantas de Arduino, son funciones bloqueantes. Esto significa que mientras se están ejecutando nadie más en el sistema (a excepción de las interrupciones) puede hacer nada; es como si el sistema se «colgara» mientras transcurre el tiempo asignado a delay(). Esta situación es inaceptable en los sistemas de tiempo real.

¿Y qué hace tan especiales a las funciones vTaskDelay() y vTaskDelayUntil()? Estas funciones, como prácticamente todas las de FreeRTOS, liberan a la CPU mientras el tiempo programado se cumple, lo cual las hace diametralmente opuestas a su contraparte de Arduino, delay(). Cuando el tiempo programado transcurrió, FreeRTOS reactiva a la tarea para que continúe con su trabajo.

Por esta razón, si estás escribiendo un sistema de tiempo real, siempre deberías utilizar a las funciones vTaskDelay() y vTaskDelayUntil() en lugar de delay().

Tabla de contenidos

¿Qué es y qué necesito?

Como mencioné en la introducción, las funciones vTaskDelay() y vTaskDelayUntil() son parte de FreeRTOS y su principal cualidad es que liberan a la CPU mientras el tiempo programado transcurre. Una vez que esto sucede la tarea se reactiva para continuar con su trabajo.

Estamos a punto de comenzar con funciones de FreeRTOS que cambian el estado de una tarea debido a alguna transición. Por ello debemos discutir de manera breve sobre los estados por los que pasa una tarea en FreeRTOS para que le saques el mayor provecho a este sistema operativo. Inmediatamente después abordaremos a las funciones vTaskDelay() y vTaskDelayUntil(), y sus diferencias.

Estados de una tarea en FreeRTOS

Una tarea en FreeRTOS puede estar en uno de cuatro estados. La siguiente imagen los ilustra, así como las transiciones entre estados:

Estados de una tarea y transiciones entre estados.

Ready (listo). En este estado la tarea no se está ejecutando (no utiliza ciclos de CPU), pero es elegible para ejecutarse. Para el caso que nos ocupa, cuando el tiempo transcurrido en las funciones vTaskDelay() o vTaskDelayUntil() ha expirado la tarea pasa a este estado para ser ejecutada en cuanto sea posible.

Running (ejecutándose). En este estado la tarea está siendo ejecutada por el procesador, y por lo tanto, está haciendo un trabajo útil.

Blocked (bloqueada). En este estado la tarea no es elegible para ser ejecutada porque está esperando que un evento (interno o externo) suceda para pasar al estado Ready, y por lo tanto, no utiliza ciclos de CPU. Cuando mandas llamar a las funciones vTaskDelay() o vTaskDelayUntil() (las que estamos viendo en esta lección) la tarea se bloquea hasta que el tiempo programado transcurra. Con otras funciones de FreeRTOS (eventos, semáforos, colas, etc) las tareas se pueden auto-desbloquear luego de transcurrido un tiempo para evitar bloqueos infinitos que afecten al sistema (yo les llamo software-watchdog).

Suspended (suspendida). En este estado la tarea no es elegible para ser ejecutada, pero a diferencia del estado Blocked, no está esperando ningún evento; es como si la tarea no existiera. Sin embargo, es posible que otra tarea puede sacarla de este estado utilizando la primitiva vTaskResume(). Cabe mencionar que una tarea puede enviarse a sí misma a este estado.

Notarás que la transición del estado Running al estado Blocked se produce cuando llamas a una función bloqueante. En el contexto de los sistemas operativos, cuando decimos que una función se bloquea nos referimos a que libera la CPU y queda a la espera de algún evento (tiempo, semáforos, notificaciones, etc) para volver al estado Ready. Por favor no confundas este término con el de la programación secuencial (la de Arduino) donde una función bloqueante significa que dicha función acapara la CPU por todo el tiempo que quiera.

vTaskDelay()

Esta función ya la hemos estado utilizando. Ahora toca analizarla con más detalle. Comencemos viendo su firma:

Esta función manda a la tarea que la invoca al estado Blocked (en términos coloquiales, la «duerme») por el tiempo establecido en su único argumento xTicksToDelay. Como ya he mencionado, mientras la tarea esté en dicho estado no ocupa ciclos de CPU.

Toma en cuenta que esta función trabaja con ticks del sistema, pero nosotros como humanos nos entendemos más en segundos (o fracciones de éstos), por lo que deberías evitar el uso directo de ticks; es mejor convertirlos a milisegundos (ms). (En mi proyecto Molcajete establecí que cada tick equivalga a 1ms, pero tú podrías cambiarlo si lo deseas; de cualquier manera, como práctica de buena programación, siempre deberías realizar la conversión para que tu aplicación sea portable.)

Existen dos formas para convertir ticks a milisegundos: utilizando a la constante portTICK_RATE_MS, o a través de la macro pdMS_TO_TICKS:

vTaskDelay( 100 / portTICK_RATE_MS ); 
// La tarea se 'duerme' durante 100 milisegundos

vTaskDelay( pdMS_TO_TICKS( 100 ) );
// La tarea se 'duerme' durante 100 milisegundos

Ambas formas son funcionalmente equivalentes. La diferencia es que la primera, portTICK_RATE_MS, tiene limitaciones técnicas: no se puede usar con ticks más rápidos de 1ms, por ejemplo, con ticks de 500 microsegundos (us). Aunque es difícil ver al sistema operativo usando ticks a esa velocidad, no es imposible.

Otro escenario donde es conventiente que utilices a la macro pdMS_TO_TICKS() en lugar de ticks directos, es cuando el tick de tu sistema es más lento, por ejemplo 10ms o 100ms, lo cual es muy común. No siempre querrás tener trabajando al sistema operativo a full speed. Mi recomendación es que siempre uses a la macro pdMS_TO_TICKS() aunque el tick sea de 1ms.

Esta función, al igual que vTaskDelayUntil(), deben ser «activadas» en FreeRTOS antes de ser utilizadas. Busca en el archivo de configuración FreeRTOSConfig.h las siguientes líneas y asegúrate que ambas estén a 1:

#define INCLUDE_vTaskDelayUntil 1
#define INCLUDE_vTaskDelay      1

(Si no recuerdas dónde está este archivo de configuración puedes refrescar tu memoria yendo a esta lección.)

El siguiente ejemplo crea dos tareas: una hace parpadear a un LED conectado en el pin 13, y otra envía mensajes a la consola. Ambas usan a la función vTaskDelay() y a la macro pdMS_TO_TICKS():

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

void task_1( void* pvParameters )
{
   pinMode( 13, OUTPUT );

   while( 1 )
   {
      digitalWrite( 13, HIGH );

      vTaskDelay( pdMS_TO_TICKS( 100 ) );

      digitalWrite( 13, LOW );

      vTaskDelay( pdMS_TO_TICKS( 900 ) );
   }
}

void task_2( void* pvParameters )
{
   uint16_t cont = 0;

   while( 1 )
   {
      vTaskDelay( pdMS_TO_TICKS( 1500 ) );

      Serial.print( cont++ );
      Serial.println( ": Hola mundo" );
   }
}

void setup()
{
   xTaskCreate( task_1, "TSK1", 128, NULL, tskIDLE_PRIORITY, NULL );

   xTaskCreate( task_2, "TSK2", 128, NULL, tskIDLE_PRIORITY, NULL );

   Serial.begin( 115200 );

   vTaskStartScheduler();
}

void loop() 
{
}

Antes de que pasemos a ver la siguiente función siento una obsesiva necesidad de recordarte:

Usa a vTaskDelay() o vTaskDelayUntil() en lugar de la función delay() de Arduino en tus sistemas de tiempo real!.

Atte: Programadores de sistemas de tiempo real.

vTaskDelayUntil()

FreeRTOS incluye una segunda función para mandar «dormir» a las tareas (es decir, enviarlas al estado Blocked): vTaskDelayUntil(), ¿y porqué ha incluído dos? Porque vTaskDelay() es relativa: comienza a descontar el tiempo desde el momento en que es llamada, mientras que vTaskDelayUntil() es absoluta: descuenta el tiempo con respecto a un punto de referencia en el pasado.

La mejor forma de entender la diferencia es con un gráfico. Supón que necesitas que una tarea se ejecute con periodo de 10 ms (milisegundos); y que cada tarea requiere (estoy exagerando) 2 ms. La figura superior A muestra el comportamiento con vTaskDelay(), y la figura inferior B muestra el comportamiento con vTaskDelayUntil(). En A puedes ver cómo se va desplazando el momento en que la tarea se ejecuta. Este tiempo es la suma del tiempo que necesita la tarea más el del tiempo de bloqueo (12, 24, 36, …). En B la tarea siempre empieza al tiempo establecido (10, 20, 30, …) sin importar cuánto dura la ejecución de la tarea.

Y como era de esperarse, vTaskDelayUntil() es ligeramente más complicada:

Esta función requiere dos argumentos. El primero, pxPreviousWakeTime, se refiere al punto de referencia en el pasado a partir del cual comenzará la cuenta, mientras que el segundo, xTimeIncrement, es el tiempo que la tarea se bloqueará. La tarea saldrá del estado Blocked cuando un contador interno de ticks de FreeRTOS alcance la cuenta pxPreviousWakeTime + xTimeIncrement. La propia función actualiza al argumento pxPreviousWakeTime con el resultado de la operación anterior para que vuelva a ser usada como punto de referencia.

Veamos un ejemplo:

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

void led_task_1( void* pvParameters )
{
   uint8_t* pin = (uint8_t*) pvParameters;

   pinMode( *pin, OUTPUT );

   const TickType_t period_1 = pdMS_TO_TICKS( 100 );

   const TickType_t period_2 = pdMS_TO_TICKS( 200 );

   TickType_t last_wake_time = xTaskGetTickCount();

   while( 1 )
   {
      digitalWrite( *pin, HIGH );

      vTaskDelayUntil( &last_wake_time, period_1 );

      digitalWrite( *pin, LOW );

      vTaskDelayUntil( &last_wake_time, period_2 );
   }
}

void setup()
{
   static uint8_t led1 = 13;

   xTaskCreate( 
         led_task_1,
         "LD1",
         128,
         (void*) &led1,
         tskIDLE_PRIORITY,
         NULL );

   vTaskStartScheduler();
}

void loop() 
{
}
  • Líneas 10 y 12: Establecemos los periodos ON/OFF para el LED. Observa que las variables tienen el tipo TickType_t, y que estamos usando la macro de conversión pdMS_TO_TICKS().
  • Línea 14: La variable last_wake_time es el punto de referencia para mantener un tiempo absoluto. La función xTaskGetTickCount() devuelve el tiempo actual del sistema en en ticks (es decir, la cantidad de ticks transcurridos desde que el sistema se inicio).
  • Líneas 20 y 24. Usamos la función. La variable last_wake_time es actualizada dentro de vTaskDelayUntil(), por eso debemos mandar su «original» (su dirección) a través del operador referencia, &.

(Si no recuerdas cómo pasar argumentos a las tareas, líneas 6 y 36, quizás quieras visitar esta lección, Pasando argumentos a las tareas. No es requisito usar este mecanismo para utilizar a vTaskDelayUntil()).

¿Qué sigue?

En esta lección hemos visto los estados por los que pasan las tareas. También vimos las dos funciones que FreeRTOS proporciona para realizar tareas periódicas, vTaskDelay() y vTaskDelayUntil(), una relativa y otra absoluta, respectivamente. También platicamos de la importancia de utilizar funciones que liberen a la CPU cuando están esperando un evento en sistemas de tiempo real, y que funciones nativas de Arduino se «cuelgan» mientras están haciendo su trabajo.

En la siguiente lección voy a mostrarte cómo enviar notificaciones simples entre tareas.

Índice del curso

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


Si encuentras este blog interesante, entonces podrías considerar suscribirte a él y recibir información relevante sobre tecnología y sistemas embebidos, y de vez en cuando, uno que otro regalo.

Pasando parámetros a las tareas

En las entradas del blog correspondientes a la creación de tareas vimos que existe la posibilidad de pasar valores a la tarea que está siendo creada. Éstos podrían indicar algún parámetro de configuración, valores para algún cálculo, etc. También podemos usar este mismo mecanismo para reutilizar el código de una tarea (el cual es tema de una siguiente entrada). Y aunque es un sólo parámetro podemos pasar valores simples o compuestos. En esta entrada te voy a mostrar cómo usarlo.

Tabla de contenidos

¿Qué es y qué necesito?

Todas las tareas en FreeRTOS reciben un parámetro, el cual lo puedes usar o no, y cuyo tipo es void*. Y las funciones de creación de tareas, xTaskCreate() y xTaskCreateStatic(), incluyen un parámetro también de tipo void* para pasarle datos a la tarea, es decir, lo que tú especifiques en este parámetro le será pasado a la tarea la primera vez que se ejecute.

Recuerda que aunque las tareas se codifican como funciones, tienen un tratamiento especial por parte del sistema operativo, y la mejor manera de pasarle información (que puede ser usada dentro de la tarea antes de entrar al ciclo infinito) es a través de este parámetro. En la documentación oficial se le llama pvParameters (y si el nombre no te gusta, se lo puedes cambiar, siempre y cuando el tipo void* siga ahí. En lo personal los nombres oficiales de FreeRTOS no me gustan, pero los uso para evitar confusiones):

void una_tarea( void* pvParameters )
{
   // cuerpo de la tarea
}

void otra_tarea( void* params ) // por si no te gusta el nombre pvParameters
{
   // cuerpo de la tarea
}

Vamos a recordar la firma de la función xTaskCreate() (la explicación es idéntica para la función xTaskCreateStatic()):

Es en el 4to parámetro donde le pasas información a la tarea, void* pvParameters. Y aunque es un sólo parámetro, las tareas pueden recibir valores simples o valores compuestos. Un valor simple, por ejemplo, un entero, podría indicar el número de pin en Arduino al que está asociado el LED. Un valor compuesto es un conjunto de dos o más valores, del mismo o diferente tipo. Un par (p,t) podría indicar el pin al cual está asociado un LED, y el tiempo de parpadeo. Para cualquiera de los dos casos, valores simples o compuestos, necesitarás utilizar apuntadores void, mientras que para los valores compuestos necesitas usar estructuras de C.

Pasando valores simples a las tareas

Lo primero que vamos a hacer es pasar valores simples a las tareas, y para esto necesitamos ver lo que son los apuntadores void.

Apuntadores void

Recordemos la firma de una tarea en FreeRTOS:

void tarea( void* pvParameters );

El tipo del parámetro pvParameters es void*, ¿y esto qué significa? La respuesta corta es:

Un apuntador void apunta a cualquier cosa (tipo).

Sin embargo, debemos elaborar una respuesta larga y lo haremos comenzando por lo elemental: los apuntadores son variables que guardan direcciones de otras variables. Un apuntador «normal» sólo puede guardar direcciones de variables de su mismo tipo:

int var_int = 5;       // variable entera
int* p_int = &var_int; // el apuntador p_int sólo puede guardar direcciones de variables enteras
float var_float = 5.0;
p_int = &var_float;    // error: tipos diferentes

La magia comienza cuando declaramos que el tipo del apuntador será void:

void* p_void;

Esto le dice al compilador de C/C++ que el apuntador p_void guardará una dirección que no está asociada a ningún tipo de dato en particular; es decir, solamente está guardando una dirección. ¿Cómo es posible entonces utilizarlo con fines prácticos? Bueno, aquí viene otro concepto: moldeado, o promoción (o casting, en inglés).

Moldeado

El moldeado sirve para promocionar un tipo hacia otro tipo:

int var_int = 3;
float var_float = (float) var_int; // "convierte" el 3 en 3.0

El moldeado o promoción se lleva a cabo cuando el compilador encuentra un tipo de dato encerrado entre paréntesis, en el ejemplo: (float). La variable var_int sigue siendo entera; lo que sucedió es que a su valor asociado, el 3, le agregó el punto decimal (es un poco más complicado que eso, pero quedémonos con esa idea), y luego guardó el 3.0 en la variable var_float.

En general, si nos vemos utilizando castings en uno de nuestros programas quiere decir que algo estamos haciendo mal, pero esto no aplica con los apuntadores void, ya que es eso lo que queremos.

Una vez que vimos lo que es el moldeado, veamos cómo lo podemos aplicar para «quitarle» el tipo a una variable:

int var_int = 3;
void* p_void = var_int;             // en C (el moldeado es implícito)
void* p_void_cpp = (void*) var_int; // en C++ (el moldeado debe ser explícito)

El apuntador p_void ya guarda la dirección de la variable var_int, pero sin saber que se trata de un int. En este punto NO PODEMOS DE-REFERENCIAR al apuntador, es decir, no podemos usarlo ya que el compilador no tiene idea del tipo de dato al que apunta.

Y para que una dirección nos sea útil debe apuntar a una variable en nuestro programa. Para ello utilizaremos al moldeado en sentido contrario, es decir, pasaremos de una dirección void a una dirección del tipo original de la variable:

int var_int = 3;
void* p_void = var_int;

// más código ...

int otra_var_int = p_void;           // en C
int otra_var_int_cpp = (int) p_void; // en C++

A la variable otra_var_int le fue asignada el contenido apuntado en la dirección guardada por p_void, y desde este momento contiene el valor 3.

Ejemplo

Una vez vistos los conceptos de apuntadores void y moldeado, pongamos manos a la obra con un ejemplo simple: Vamos a crear dos tareas idénticas, donde cada una recibirá el número de pin de Arduino asociado a un LED. (En una siguiente entrada veremos cómo reutilizar el código de una tarea para no duplicar código.)

Dentro del cuerpo de cada tarea promocionaremos al parámetro pvParameters (que es del tipo apuntador a void) a un entero uint8_t:

uint8_t pin = (uint8_t) pvParameters;

Y cuando estemos creando las tareas convertiremos el número de pin a apuntador void:

xTaskCreate( 
      led_task_1,
      "LD1",
      128,
      (void*) 13, // convertimos el entero 13 a apuntador void
      tskIDLE_PRIORITY,
      NULL );

Aquí está el ejemplo completo (en sketch):

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

void led_task_1( void* pvParameters )
{
    uint8_t pin = (uint8_t) pvParameters;

    pinMode( pin, OUTPUT );

    while( 1 )
    {
        digitalWrite( pin, HIGH );

        vTaskDelay( pdMS_TO_TICKS( 500 ) );
   
        digitalWrite( pin, LOW );

        vTaskDelay( pdMS_TO_TICKS( 500 ) );
    }
}

void led_task_2( void* pvParameters )
{
    uint8_t pin = (uint8_t) pvParameters;

    pinMode( pin, OUTPUT );

    while( 1 )
    {
        digitalWrite( pin, HIGH );

        vTaskDelay( pdMS_TO_TICKS( 500 ) );
   
        digitalWrite( pin, LOW );

        vTaskDelay( pdMS_TO_TICKS( 500 ) );
    }
}

void setup()
{
   xTaskCreate( 
      led_task_1,
      "LD1",
      128,
      (void*) 13,
      tskIDLE_PRIORITY,
      NULL );

   xTaskCreate( led_task_2, "LD2", 128, (void*) 12, tskIDLE_PRIORITY, NULL );

   vTaskStartScheduler();
}

void loop() 
{
   // put your main code here, to run repeatedly:
}

Hemos usado valores enteros constantes para pasar el número de pin, pero ¿podemos usar variables para lo mismo? Sí, pero es más complicado y merece su propia explicación

Usando variables para pasar valores a las tareas

¿Porqué una sección especial para este tema? ¿No es suficiente con declarar una variable y pasársela a la tarea? No y sí:

  • La respuesta es NO si tu proyecto está en un sketch, porque la cosa se complica un poco.
  • La respuesta es SÍ si estás programando desde la consola, porque las cosas son más sencillas (ya que empiezas desde la función main()).

Pero como la mayoría de personas usa sketches, entonces debo explicar la razón de porqué no es tan simple y las formas que tenemos para hacerlo.

Empezando por el principio: la variable que tiene el valor que le quieres pasar a la tarea debe existir cuando la tarea se esté ejecutando. Esto es, no basta con que la variable exista cuando estás creando a la tarea, debe seguir vigente durante el tiempo que la tarea esté activa.

El problema de los sketches son las funciones setup() y loop(), porque cuando una función alcanza la llave de cierre (ya sea porque el código llegó ahí, o porque utilizaste la instrucción return) las variables declaradas dentro de ellas dejan de existir. En los ejemplos que hemos visto hasta ahora hemos creado a las tareas dentro de setup(), pero cuando el sistema operativo inicia, esta función deja de existir, junto con las variables que hayas declarado en ella:

setup()
{
   uint8_t pin = 13; // pin es una variable local a setup()

   xTaskCreate( 
      led_task_1,
      "LD1",
      128,
      (void*) &pin, // intentamos usar la variable pin. Compila, pero NO FUNCIONA
      tskIDLE_PRIORITY,
      NULL );

   vTaskStartScheduler(); // no se ve, pero la función setup() deja de existir en este punto
}

Aunque parece que le estamos pasando el valor 13 a xTaskCreate() al momento de la creación de la tarea, esto no es así. La dirección de la variable local pin es almacenada para cuando la tarea sea ejecuta posteriormente. Y cuando esto suceda, la función setup() ya no existirá, y por lo tanto, la variable pin tampoco.

¿Qué podemos hacer? Lo fundamental es que la variable siga existiendo cuando la tarea se ejecute y tenemos cuatro formas de asegurarnos de ello:

  1. Usando variables globales, o
  2. Usando variables estáticas, o
  3. Creando variables dinámicas.

1. Usando variables globales

La forma más fácil es usando variables globales, ya que éstas existen durante toda la vida del programa. En lo personal, soy muy reacio a utilizarlas, a menos que no exista de otra:

uint8_t pin = 13; // pin es una variable global

setup()
{
   xTaskCreate( 
      led_task_1,
      "LD1",
      128,
      (void*) &pin, // intentamos usar la variable pin
      tskIDLE_PRIORITY,
      NULL );

   vTaskStartScheduler();
}

2. Usando variables estáticas

Las variables marcadas como static tienen la particularidad de que son locales a la función en la que se declararon, pero existen durante toda la vida del programa; son un híbrido entre variables locales y globales. Tienen la ventaja de que no son visibles fuera de la función donde fueron declaradas, a diferencia de las globales que son visibles desde el punto donde se declararon:

setup()
{
   static uint8_t pin = 13; // la variable pin es marcada como static

   xTaskCreate( 
      led_task_1,
      "LD1",
      128,
      (void*) &pin, // pasamos la dirección de la variable
      tskIDLE_PRIORITY,
      NULL );

   vTaskStartScheduler();
}

Por favor nota que en la línea 9 debes pasar la dirección de la variable, de ahí que esté precedida por el símbolo de referencia, &.

Pero para que esta solución funcione también deberás modificar, en la tarea, el código que lee la variable, ya que ahora es una dirección y no un valor, y por tanto, deberás de-referenciarla:

void led_task_1( void* pvParameters )
{
    uint8_t* pin = (uint8_t*) pvParameters;

    pinMode( *pin, OUTPUT );

    while( 1 )
    {
        digitalWrite( *pin, HIGH );

        vTaskDelay( pdMS_TO_TICKS( 500 ) );
   
        digitalWrite( *pin, LOW );

        vTaskDelay( pdMS_TO_TICKS( 500 ) );
    }
}

¿Porqué funciona? Las variables locales se almacenan en la pila del programa (de cualquier programa en C), y esta zona es dinámica en el sentido de que las variables se crean y se destruyen (pero sin llamadas a malloc()) conforme se entra y se sale de las funciones. Las variables globales se guardan en una zona diferente, y esta zona es estática en el sentido de que una vez que una variable es puesta ahí, ésta existirá mientras el programa exista. El problema es que las variables globales son visibles a todas las funciones del programa. Las variables estáticas se guardan en la misma zona que las globales, pero su visibilidad está limitada a la función, y estrictamente hablando, al bloque, {}, donde fueron declaradas. Las variables dinámicas, que veremos a continuación, se almacenan en otra área de la memoria llamada el heap.

3. Creando variables dinámicas

Para este caso tendremos que crear variables dinámicas utilizando a la función pvPortMalloc(). Su existencia también será, al igual que las globales, durante todo el programa, pero con la ventaja de que no tendrán visibilidad global:

setup()
{
   uint8_t* pin = pvPortMalloc( sizeof( uint8_t ) );
   // pedimos memoria para un entero de 8 bits

   *pin = 13;
   // escribimos en la dirección apuntada por pin

   xTaskCreate( 
      led_task_1,
      "LD1",
      128,
      (void*) pin, // sin &, ya que pin es un apuntador
      tskIDLE_PRIORITY,
      NULL );

   vTaskStartScheduler();
}

Presta especial atención a la línea 13: la variable pin ya es una dirección porque fue declarada como apuntador, por lo cual no deberás escribir al operador referencia, &. ¡No culpes al mensajero! A veces el Lenguaje C es confuso.

Al igual que con las variables estáticas deberás modificar, en la tarea, el código que lee la variable, ya que ahora es una dirección y no un valor (voy a repetir el código para que quede lo más claro posible):

void led_task_1( void* pvParameters )
{
    uint8_t* pin = (uint8_t*) pvParameters;

    pinMode( *pin, OUTPUT );

    while( 1 )
    {
        digitalWrite( *pin, HIGH );

        vTaskDelay( pdMS_TO_TICKS( 500 ) );
   
        digitalWrite( *pin, LOW );

        vTaskDelay( pdMS_TO_TICKS( 500 ) );
    }
}

NOTA: Para que esta solución funcione, el soporte para creación de objetos dinámicos debe estar activo (en el archivo FreeRTOSConfig.h):

#define configSUPPORT_DYNAMIC_ALLOCATION 1

¿Esto aplica a los proyectos en consola?

Sí. Las tres propuestas vistas funcionan para los proyectos que realices en la consola. Pero existe un pequeña ventaja: si declaras tus tareas y las variables para los parámetros dentro de la función main(), entonces no es necesario que uses al modificador static, ya que las variables declaradas dentro de esta función van a existir mientras el programa se esté ejecutando; es decir, mientras no apagues o resetees a tu circuito. Sin embargo, inclusive en este caso, lo mejor es seguir marcando las variables como static.

Pasando valores compuestos a las tareas

Aunque muchas veces es suficiente con pasar un valor simple a la tarea, en muchas otras necesitaremos pasar más de un valor, y quizas de tipos diferentes. Hace rato mencioné que en nuestro ejemplo podíamos pasar, además del pin asociado al LED, el tiempo para el parpadeo. Para pasar dos o más valores utilizarás estructuras de C.

Si quisieras pasar el número de pin, el tiempo de encendido y el tiempo de apagado, la estructura se vería así:

typedef struct
{
   uint8_t pin;
   uint16_t t_on;
   uint16_t t_off;
} Tarea1_Params;

Luego, tendrías que modificar el código de la tarea para que use los campos de la estructura. Nota que debes usar al operador flecha, ->, ya que hay que promocionar pvParameters a un apuntador de tipo Tarea1_Params:

void led_task_1( void* pvParameters )
{
    Tarea1_Params* params = (Tarea1_Params*) pvParameters;

    pinMode( params->pin, OUTPUT );

    while( 1 )
    {
        digitalWrite( params->pin, HIGH );

        vTaskDelay( pdMS_TO_TICKS( params->t_on ) );
   
        digitalWrite( params->pin, LOW );

        vTaskDelay( pdMS_TO_TICKS( params->t_off ) );
    }
}

Finalmente, en la función donde vas a crear las tareas deberás declarar una variable estructura, ya sea que la declares global, o la marques como estática, o que la crees dinámicamente. Aunque las tres funcionan, veamos un ejemplo con una variable estática:

   static Tarea1_Params t1_params = { .pin = 13, .t_on = 100, .t_off = 900 };

   xTaskCreate( 
      led_task_1,
      "LD1",
      128,
      (void*) &t1_params, // pasamos la dirección de t1_params
      tskIDLE_PRIORITY,
      NULL );

Y un ejemplo con una variable dinámica, por si algún día se ofrece:

   Tarea1_Params* t1_params = pvPortMalloc( sizeof( Tarea1_params ) );

   t1_params->pin = 13;
   t1_params->t_on = 100;
   t1_params->t_off = 900;

   xTaskCreate( 
      led_task_1,
      "LD1",
      128,
      (void*) t1_params, // ¡ no lleva & ! t1_params ya es una dirección
      tskIDLE_PRIORITY,
      NULL );

Para terminar, y para que la idea quede clara, aquí está un ejemplo completo (usando la versión de variable estática):

// Pasando valores compuestos a una tarea

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

typedef struct
{
   uint8_t pin;
   uint16_t t_on;
   uint16_t t_off;
} Tarea1_Params;

void led_task_1( void* pvParameters )
{
    Tarea1_Params* params = (Tarea1_Params*) pvParameters;

    pinMode( params->pin, OUTPUT );

    while( 1 )
    {
        digitalWrite( params->pin, HIGH );

        vTaskDelay( pdMS_TO_TICKS( params->t_on ) );
   
        digitalWrite( params->pin, LOW );

        vTaskDelay( pdMS_TO_TICKS( params->t_off ) );
    }
}

void setup()
{
   static Tarea1_Params t1_params = { .pin = 13, .t_on = 100, .t_off = 900 };

   xTaskCreate( 
      led_task_1,
      "LD1",
      128,
      (void*) &t1_params,
      tskIDLE_PRIORITY,
      NULL );

   vTaskStartScheduler();
}

void loop() 
{
   // Nada. Todo se realiza en las tareas
}

¿Qué sigue?

Hoy hemos visto mucha información. Aunque en principio es fácil enviar parámetros a las tareas creí necesario explicar los diversos conceptos y mecanismos que intervienen, desde apuntadores void hasta variables static. Pero, una vez que pongas en práctica lo que vimos, serás capaz de pasar toda la información que tus tareas necesiten.

¿Qué sigue? Reutilizar las tareas. Mencioné que estaba repitiendo código solamente para dejar claro los puntos que estaba explicando, pero en el mundo real esa es una muy mala práctica; lo mejor es escribir el código de la tarea una vez, y después crear cuantas tareas necesites. De hecho, ese era el tema principal de esta entrada, pero descubrí que tenía que explicar muchas cosas antes de ver realmente cómo reutilizar el código, así que decidí partirlo en dos. La buena noticia es que lo que vimos hoy te sirve ya sea que reutilices el código o no, como lo vimos en los diferentes ejemplos.

En la siguiente entrada veremos el tema de cómo reutilizar el código de las tareas. Puedes estar al pendiente, o mejor aún, suscribirte al blog.

Si tienes dudas o preguntas, ¡no dudes en hacérmelas llegar!

Índice del curso


Si encuentras este blog interesante, entonces podrías considerar suscribirte a él y recibir información relevante sobre tecnología y sistemas embebidos, y de vez en cuando, uno que otro regalo.

Tareas estáticas en FreeRTOS para Arduino

Ya en la entrada anterior platicábamos de cúando y porqué utilizar un sistema operativo de tiempo real en nuestras aplicaciones (si no la has leído, te recomiendo hacerlo antes de continuar), y de que FreeRTOS tiene dos maneras de crear tareas. Platicamos largo y tendido de la creación de tareas dinámicas y de un montón de conceptos necesarios.

Hoy te voy a mostrar cómo crear tareas estáticas en el sistema operativo FreeRTOS para tus programas en Arduino UNO (y compatibles), e intentaré explicarte qué son, cómo se crean y porqué debes utilizar tareas estáticas siempre que te sea posible. También te darás cuenta de porqué en la entrada anterior te dije que crear tareas estáticas necesita de más pasos. Pero créeme, vale la pena utilizarlas.

Si ya leíste la entrada anterior verás que estaré repitiendo algo de la teoría ya vista. Lo hago así por aquellos lectores que no lo han hecho, y creo que te servirá como recordatorio.

Tabla de contenidos

¿Qué hay de malo con las tareas dinámicas?

Lo único malo son las llamadas a las funciones malloc() y free() que nos lleva a dos problemas críticos en los sistemas embebidos:

  • Fragmentación de la memoria.
  • No son deterministas.

Fragmentación de la memoria

Cuando un programa llama de forma repetida a las funciones malloc() y free() la memoria RAM comienza a fragmentarse; esto es, los bloques de memoria del heap cada vez se hacen más pequeños y dispersos, llegando al punto en el que la función malloc() ya no puede encontrar un bloque de memoria contiguo del tamaño requerido, y por tanto, falla.

¿Te imaginas que un circuito electrónico de sensado, que utiliza FreeRTOS, y que has instalado en la copa de un árbol en medio de la selva falle debido a que malloc() no pudo atender tu petición de memoria? ¡Yo tampoco!

No son deterministas

El siguiente problema con las llamadas a las funciones malloc() y free() es que el tiempo que necesitan para entregar un resultado es muy variable; esto es, una primer llamada a malloc() puede tardar X ticks, pero una segunda llamada quizás tarde 1.5X ticks, y una más 0.8X ticks, y así sucesivamente. Y ambas funciones son bloqueantes, es decir, nadie puede hacer nada hasta que terminen. Esto podría provocar que tu sistema pierda eventos críticos.

Existe un tercer problema relacionado con free(). Cuando tú mandas llamar a esta función, la memoria no se devuelve de manera inmediata, solamente se marca para ser devuelta. La verdadera devolución se lleva a cabo dentro de una tarea del sistema operativo llamada IDLE task (de la cual te hablaré más adelante) que si no se ejecuta (debido a que tus tareas están activamente ejecutándose), entonces esa memoria no es devuelta.

¿Podemos seguir usando tareas dinámicas?

Claro que sí. Los que saben del tema nos recomiendan crear las tareas (y en general, todos los objetos dinámicos del programa) al inicio de la aplicación, y nunca borrarlos.

¿Qué es y qué necesito?

Al igual que en la entrada anterior vas a necesitar una tarjeta Arduino UNO o compatible junto con el sistema operativo FreeRTOS, y este paso ya lo hice por tí. Descarga el proyecto Molcajete para tu computadora (actualmente sólo está disponible para Linux y 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 este mismo directorio encontrarás las instrucciones para compilar programas en la IDE o en la consola (README.pdf).

Recuerda que 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. No existe ninguna diferencia en la estructura de las tareas ya sea que se creen de forma estática o dinámica; las diferencias las veremos en un momento.

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é.

Registrando las tareas de manera estática

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 estática de tareas, lo cual implica que tú como programador crearás la memoria para la pila y el TCB; pero no te preocupes, es tan fácil como declarar un arreglo y una variable, e involucra más pasos que su contraparte dinámica.

Veamos la firma de la función xTaskCreateStatic() la cual crea tareas de manera estática, y a continuación explicaré cada uno de sus argumentos:

Función para crear tareas estáticas.

pxTaskCode. 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.

ulStackDepth. Es el número de elementos del arreglo puxStackBuffer (el cual veremos a continuación y que hace las veces de pila).

¿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()).

puxStackBuffer. Es un arreglo de tipo StackType_t el cual hará las veces de pila de la tarea. Este arreglo debe ser persistente y deberá ser de tamaño ulStackDepth.

Que sea persistente significa que debe ser declarado de manera global, o dentro de la función main(), o dentro de cualquier función (que no sea tarea y tras ser marcado como static). Lo que no deberías hacer es declararlo dentro de un tarea, ya que estarías utilizando pila de la tarea.

Ten en cuenta que cada elemento de este arreglo es del tipo StackType_t, el cual debe coincidir con el número de bytes utilizado por la pila del procesador elegido. Por ejemplo, en procesadores de 32 bits, como todos los de la familia ARM-Cortex, el tamaño de cada elemento en la pila es de 4 bytes, por lo cual, si estableciste un valor de 128 para ulStackDepth, en realidad estás utilizando 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 ulStackDepth , realmente estás utilizando 128 bytes.

Veamos como ejemplo el arreglo para una pila de 128 elementos (que no bytes) declarado con ámbito global:

const uint16_t TASK_LED_STACK_SIZE = 128;
// este valor se utilizará también para el parámetro ulStackDepth

StackType_t taskLedRAM[ TASK_LED_STACK_SIZE ];

El nombre del arreglo y el de la constante son los que tú quieras, pero como convención les agregamos el nombre de la tarea a la que pertenecen. Y si quisieras crear el arreglo dentro de la función main() te quedaría como (el uso del modificador static no es necesario con esta función, sin embargo, la he marcado así por si quisieras hacer lo mismo en una función diferente):

const uint16_t TASK_LED_STACK_SIZE = 128;
// este valor se utilizará también para el parámetro ulStackDepth

int main()
{
   // ...

   static StackType_t taskLedRAM[ TASK_LED_STACK_SIZE ];

   // ...
}

pxTaskBuffer. Es un apuntador a una variable estructura de tipo StaticTask_t, la cual hace la función de TCB (Task Control Block, o Bloque de Control) y deberá ser persistente.

Al igual que el arreglo puxStackBuffer, esta variable deberá ser declarada en un ámbito donde no deje de existir, es decir, siguiendo los mismos lineamientos dados más arriba para dicho arreglo.

Valor devuelto. Esta función devuelve 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.

Puedes omitir (no guardar) la referencia devuelta si no piensas utilizarla. Pero en caso de que sí la guardes, deberás utilizar una variable global, ya que dicha referencia debe estar disponible para que otras tareas/funciones tengan acceso a ella.

Las tares creadas de manera estática siempre se crean.

Vamos a aplicar todo lo explicado hasta este momento, aunque aún nos faltan varios pasos antes de poder compilar. Registremos la tarea asociada a nuestro ejemplo, led_task():

   xTaskCreateStatic(
      led_task,            // es la función que implementa a la tarea
      "LED",               // es el nombre que le quisimos dar a la tarea
      TASK_LED_STACK_SIZE, // es el número de elementos en la pila
      NULL,                // no vamos a pasarle arguementos a la tarea
      tskIDLE_PRIORITY,    // tskIDLE_PRIORITY es la prioridad más baja: 0
      taskLedRAM,          // es la pila
      &taskLedTCB );       // es la dirección del TCB

      // las tareas estáticas siempre se crean; obtendremos un error en tiempo de compilación
      // en caso de que se termine la memoria.

¿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.

configSUPPORT_STATIC_ALLOCATION. Esta constante debe estar puesta a 1 para que puedas utilizar tareas estáticas (y en general, cualquier otro objeto creado de forma estática).

#define configSUPPORT_STATIC_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 xTaskCreateStatic() 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. 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.

IDLE task

Tienes que saber que FreeRTOS necesita (y de manera interna y privada la implementa) una tarea con prioridad 0 (la más baja) para que se ejecute cuando ninguna otra tarea lo esté haciendo, y aunque no tiene un nombre específico, ni nosotros tenemos acceso directo a ella, vamos a llamarle la IDLE task (tarea en espera). Como cualquier otra la tarea la IDLE task requiere memoria para su pila y su TCB. Cuando el soporte para creación de tareas dinámicas está activado (configSUPPORT_DYNAMIC_ALLOCATION vale 1) entonces FreeRTOS de manera automática se las crea, y tú, afortunadamente, te olvidas de ello; pero en el caso que nos ocupa, tareas estáticas, si el soporte para creación dinámico está desactivado (configSUPPORT_DYNAMIC_ALLOCATION vale 0), entonces deberás proporcionarle memoria como a cualquier otra tarea a través de ĺa función vApplicationGetIdleTaskMemory(). No te preocupes, es tan fácil como cortar y pegar el siguiente código, y más aún, ya lo hice por ti en el proyecto Molcajete. En otras palabras, ¡no tienes que hacer nada!, pero tenías que saberlo. En una entrada posterior hablaré más sobre la IDLE task y cómo utilizarla.

#ifdef __cplusplus
extern "C"
{
#endif
    void vApplicationGetIdleTaskMemory( 
        StaticTask_t **ppxIdleTaskTCBBuffer,
        StackType_t **ppxIdleTaskStackBuffer,
        uint32_t *pulIdleTaskStackSize )
    {
        static StaticTask_t xIdleTaskTCB;
        static StackType_t uxIdleTaskStack[ configMINIMAL_STACK_SIZE ];

        *ppxIdleTaskTCBBuffer = &xIdleTaskTCB;
        *ppxIdleTaskStackBuffer = uxIdleTaskStack;
        *pulIdleTaskStackSize = configMINIMAL_STACK_SIZE;
    }
#ifdef __cplusplus
}
#endif

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>

const uint16_t TASK_LED_STACK_SIZE = 128;
StaticTask_t taskLedTCB;
StackType_t taskLedRAM[ TASK_LED_STACK_SIZE ];

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() 
{
   xTaskCreateStatic(
      led_task,            // es la función que implementa a la tarea
      "LED",               // es el nombre que le quisimos dar a la tarea
      TASK_LED_STACK_SIZE, // es el número de elementos en la pila
      NULL,                // no vamos a pasarle arguementos a la tarea
      tskIDLE_PRIORITY,    // tskIDLE_PRIORITY es la prioridad más baja: 0
      taskLedRAM,          // es la pila
      &taskLedTCB );       // es la dirección del TCB

      // las tareas estáticas siempre se crean; obtendremos un error en tiempo de compilación
      // en caso de que se termine la memoria.

   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 
}

Captura este programa en la IDE de Arduino. Compílalo y súbelo 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.
  • El tamaño de la pila está declarado como una constante TASK_LED_STACK_SIZE. Y además, las variables para la pila y el TCB, taskLedRAM y taskLedTCB, respectivamente, están todas declaradas en el ámbito global.
  • 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.
  • A diferencia del ejemplo de tarea dinámica de la entrada anterior, aquí no es necesario probar que la tarea se haya creado con éxito.
  • 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.
Programa de ejemplo ejecutándose en la tarjeta minimalista UB-1S328, la cual es compatible con Arduino UNO.

¿Pueden las tareas estáticas y dinámicas convivir en el mismo programa?

Absolutamente. Si en tu programa requieres ambas formas, basta con que las constantes respectivas estén puestas a 1:

#define configSUPPORT_STATIC_ALLOCATION 1
#define configSUPPORT_DYNAMIC_ALLOCATION 1

¿Puedo utilizar únicamente tareas estáticas?

Si decides que sólo requieres tareas estáticas en tu aplicación, entonces puedes poner la definición configSUPPORT_DYNAMIC_ALLOCATION a 0. Sin embargo, toma en cuenta lo siguiente: FreeRTOS proporciona cinco versiones para las funciones malloc() y free(). Éstas se encuentran en archivos llamados heap_x.c, donde x va desde 1 hasta 5. En el proyecto Molcajete incluí heap_1.c, la cual supone que nunca vas a devolver la memoria; es decir, la función free() no está implementada.

Pero lo que te quiero decir es que si desactivas por completo el soporte para tareas (y objetos) dinámicos, entonces también deberás eliminar el archivo heap_x.c, porque de no hacerlo el compilador arrojará un error:

/home/fjrg76/arduino-1.8.13/libraries/FreeRTOS/src/heap_1.c:48:6: error: #error This file must not be used if configSUPPORT_DYNAMIC_ALLOCATION is 0
     #error This file must not be used if configSUPPORT_DYNAMIC_ALLOCATION is 0

Es mala idea borrarlo desde el punto de vista que vas a estar trabajando con diferentes proyectos, por lo que te recomiendo renombrarlo en lugar de eliminarlo. Lo que hago es cambiarle el nombre a heap_1.c.txt. Con este cambio el compilador deja de quejarse, y la siguiente vez que lo necesitas vuelves a ponerle su nombre original.

¿Qué sigue?

En la siguiente entrada voy a mostrarte cómo compilar desde la línea de comandos; mientras tanto puedes modificar el programa de ejemplo o escribir nuevos para que pongas en acción lo visto hoy.

Índice del curso

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.


Si encuentras este blog interesante, entonces podrías considerar suscribirte a él y recibir información relevante sobre tecnología y sistemas embebidos, y de vez en cuando, uno que otro regalo.

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 proyecto Molcajete para tu computadora (actualmente sólo está disponible para Linux y 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 este mismo directorio encontrarás las instrucciones para compilar programas en la IDE o en la consola (README.pdf).

IMPORTANTE (03/01/21): Después de que hayas descomprimido el programa deberás entrar a la carpeta /tu/ruta/de/instalación/arduino-1.8.13/libraries/FreeRTOS/src y eliminar el archivo heap_1.c. A continuación, en la misma página de descarga deberás bajar el archivo heap_3.c y copiarlo en la misma ruta. Aparentemente hay un error en heap_1.c que evita que los programas se ejecuten correctamente.

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


Si encuentras este blog interesante, entonces podrías considerar suscribirte a él y recibir información relevante sobre tecnología y sistemas embebidos, y de vez en cuando, uno que otro regalo.