Pasando parámetros a las tareas

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


Fco. Javier Rodríguez
Escrito por:

Fco. Javier Rodríguez

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

Ver todas las entradas

1 COMENTARIO