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.

Compilando desde la consola

El siguiente procedimiento sólo funciona en Linux.

El proyecto Molcajete nació de mi necesidad de compilar programas para Arduino desde una consola, ya que es la forma en que he compilado mis programas de C/C++ los últimos 25 años de mi vida. Y francamente no soporto ni los sketches ni el modelo de súper-loop de Arduino. Además, me gusta tener el control, ¿te has puesto ha pensar dónde está la función main(), la cual es el punto de entrada de todos los programas, incluyendo los basados en sketches? Pues resulta que también me gusta empezar desde la función main().

Sin embargo, no soy lo suficientemente bueno trabajando con archivos Makefile, pero por suerte hay gente que sí lo es. Y fue que encontré el proyecto Arduino-mk que mi sueño se hizo realidad. Luego, resulta que también son fanático del sistema operativo FreeRTOS y pensé, ¿podré integrar FreeRTOS y al mismo tiempo compilar desde la consola? La respuesta fue afirmativa y eme aquí intentando explicarte cómo se hace, esperando que la siguiente información mueva tu curiosidad y también te sea de utilidad.

Tabla de contenidos

¿Qué es y qué necesito?

Los programas multiarchivo en C/C++, como los escritos para Arduino, utilizan la herramienta make para automatizar el proceso de compilación. Este proceso se configura a partir de archivos Makefile’s. Cuando presionas el botón de Verificar/Compilar (ó Ctrl+r) en la IDE de Arduino, ésta se encarga de generar los Makefile’s correspondientes y llamar al compilador. Después, si todo fue bien, presionas el botón Subir (Ctrl+u) y tu programa se sube a tu tarjeta gracias al programa avrdude. Afortunadamente para muchos, todo este proceso es automático; pero habemos algunos que queremos más.

Como mencioné en la introducción, es posible sacar el proceso descrito hacia una consola, gracias al proyecto Arduino-mk. Éste lo podrías instalar por sí solo si así lo quisieras, pero el proyecto Molcajete ya lo incluye, por lo que nada más necesitas descargarlo para tu computadora (actualmente sólo está disponible para Linux y Windows) y descomprimirlo en el lugar de tu preferencia. En este mismo directorio encontrarás las instrucciones para compilar programas en la IDE o en la consola (README.pdf).

A diferencia del trabajo con sketches, aquí son necesarios unos pasos adicionales para instalar un par de cosas: una versión de Arduino y una versión de Arduino-mk. Hice lo posible porque no tuvieras que instalar nada más aparte del proyecto Molcajete, pero no lo logré. Por esta razón deberás instalar:

  • Arduino-core. En una consola (o con tu administrador de paquetes) escribe: sudo apt install arduino-core.
  • Arduino-mk. En la misma consola escribe: sudo apt install arduino-mk.

Por alguna razón los scripts de Arduino-mk requieren que Arduino-core esté instalado, aunque no se use, y no supe cómo evitarlo, así que hay que vivir con ello.

Proyectos de consola

Cada proyecto de consola que realices está formado de al menos dos archivos. Un main.cpp y un Makefile en el mismo directorio. En el archivo main.cpp escribirás la función main(), y el resto de funciones que necesites, aunque es recomendable que dividas tu proyecto en módulos. En el archivo Makefile indicarás, principalmente, la ruta donde descomprimiste al proyecto Molcajete.

main.cpp

Un archivo main.cpp típico se ve así:

// main.cpp

#include <Arduino.h>

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

void led_task( void* pvParameters )
{
    (void) pvParameters;

    pinMode( 13, OUTPUT );

    while( 1 )
    {
        digitalWrite( 13, HIGH );

        vTaskDelay( 500 / portTICK_PERIOD_MS );

        digitalWrite( 13, LOW );

        vTaskDelay( 500 / portTICK_PERIOD_MS );
    }
}

void taskAnalog( void* pvParameters )
{
    (void) pvParameters;

    Serial.begin( 115200 );

    while( 1 )
    {
        Serial.println( analogRead( A0 ) );

        vTaskDelay( 1000 / portTICK_PERIOD_MS );
    }
}

int main(void)
{
    cli();

    xTaskCreate( taskLed,
        (const portCHAR *)"LED",
        128,
        NULL,
        tskIDLE_PRIORITY,
        NULL );

    xTaskCreate( taskAnalog,
        (const portCHAR *) "ANLG",
        128,
        NULL,
        tskIDLE_PRIORITY,
        NULL );

    init();
    // inicializa al hardware de Arduino

    vTaskStartScheduler();
    // inicia las operaciones del sistema operativo FreeRTOS

    while( 1 )
    {
        // si hubiera algún error, el programa quedaría atrapado aquí
    }
}

En esta entrada podrás encontrar información detallada de qué son las tareas dinámicas, la función de creación xTaskCreate(), sus parámetros, cómo se inicia al sistema operativo, y cómo se configura tu proyecto en el archivo FreeRTOSConfig.h.

Las diferencias entre un programa basado en sketch y uno de consola son las siguientes (en órden de aparición, es decir de arriba-abajo, del programa anterior):

  • Debes incluir al archivo de encabezado Arduino.h con: #include <Arduino.h>.
  • Debes escribir la función main(), que como sabes es el punto de entrada de cualquier programa escrito en C/C++.
  • La primer instrucción, cli(), deshabilita las interrupciones para que puedas crear tus tareas y arrancar de forma correcta a FreeRTOS. No queremos que interrupciones pendientes
  • Luego puedes crear tus tareas, ya sean estáticas o dinámicas; este procedimiento no tiene nada que ver con su naturaleza.
  • Después debes llamar a la función de Arduino init(). Esta función inicializa el hardware que un programa para Arduino UNO y compatibles necesita. Si olvidas hacerlo tus programas no funcionarán.
  • Lo siguiente es iniciar al sistema operativo con la función de FreeRTOS vTaskStartScheduler().
  • Si el sistema operativo inició correctamente nunca deberías regresar a la función main(). Sin embargo, si algo saliera mal, entonces el programa quedaría atrapado dentro del ciclo infinito para que no se salga de control y que además puedas buscar la fuente de error.

Makefile

Como ya te decía, en este archivo debes indicarle al compilador dónde está la carpeta /arduino-1.8.13 (el nombre puede variar según la versión instalada):

ARDUINO_DIR = /tu/ruta/de/instalación/arduino-1.8.13

Algo que tal vez necesites cambiar es el puerto serial. En Linux, el cual es el sistema operativo que yo uso, nombra (casi siempre) a los adaptadores seriales como /dev/ttyUSB* (y veces los nombra como /dev/tty/ACM*):

ARDUINO_PORT = /dev/ttyUSB*

También puedes cambiar el baudrate del puerto serial, si así lo deseas. Yo uso 115200 BPS (bits por segundo), pero he visto que mucha gente usa 9600 BPS:

MONITOR_BAUDRATE = 115200

Y esos son todos los cambios que deberís hacerle a este archivo.

Compilando y subiendo el proyecto

Es momento de compilar. Pero antes de que lo hagas revisa que tu archivo de configuración FreeRTOSConfig.h esté correcto y actualizado. Visita esta entrada si aún no estás familiarizado con él.

Abre una consola en el directorio donde están tus archivo main.cpp y Makefile, y ejecuta la orden make:

$ make

(El símbolo $ indica el prompt del sistema y no debes escribirlo). Si todo fue correcto, obtendrás una salida parecida a la siguiente con el ejemplo que estamos trabajando:

Extracto del resultado de la compilación del proyecto.

Si obtuviste una salida parecida, entonces toca subir el programa a la tarjeta. Ejecuta la orden make upload:

$ make upload

(Si estás usando alguna de las tarjetas UB-1S328, UB-C328A, o UB-PLR328, entonces deberás presionar y soltar el botón de reset al mismo tiempo que le das ENTER a la instrucción anterior.)

Si todo fue correcto, obtendrás una salida parecida a la siguiente y tu tarjeta ya debería estar ejecutando el programa:

Extracto del resultado de subir el programa a una tarjeta UB-1S328, la cual es una versión minimalista pero compatible con Arduino UNO.

Si tu programa incluye instrucciones del tipo Serial.println(), entonces querrás ver la salida. Para ello tienes dos opciones:

  • Abre la IDE de Arduino e inicia el monitor serial que viene incluído (Ctrl-Shift-m), o
  • En la misma consola abre un monitor ejecutando la instrucción make monitor. Para salir presiona Ctrl-a, k y luego y.
Monitor mostrando lecturas del ADC.

¿Que sigue?

¡Felicidades! Ya compilaste tu primer proyecto de Arduino en la consola utilizando un sistema operativo de tiempo real. Pero si la consola no es lo tuyo, no te preocupes, podrás seguir utilizando los sketches. Utilizar a FreeRTOS, en consola o en sketch, evitará que uses variables globales cuando no son necesarias, y también dejarás de usar el súper-loop de Arduino (función loop()).

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.

Reutilizando el código de las tareas

Hola, ¿sabías que puedes reutilizar el código de tus tareas? Si te ves escribiendo dos tareas muy similares, que quizás sólo cambian en el actuador o en la temporización, entonces quizás querrías escribir el código una sola vez y utilizarlo muchas veces. En esta entrada te voy a mostrar la forma en qué puedes lograrlo.

Tabla de contenidos

¿Qué es y qué necesito?

Repetir el código (quizás haciendo un copy and paste) y adaptarlo a su nueva función es una solución que no escala muy bien. Si encuentras un error, o quieres hacer una modificación importante en la tarea original, debes llevar esos cambios a todos los lugares donde hiciste la copia.

En FreeRTOS puedes escribir una tarea que se adapte a una serie de requerimientos. Por ejemplo, tienes 3 LEDs que deben parpadear a ritmos muy diferentes, t0, t1, t2, respectivamente. La solución inmediata es crear tres tareas, pero como ya dije, esta propuesta presenta problemas si posteriormente quieres hacer cambios, ya que éstos tienes que aplicarlos a las mismas 3 tareas. Entonces, ¿qué podemos hacer? La clave está en que las tareas reciben un parámetro, void* pvParameters, y cuando creas la, o las tareas, con las funciones xTaskCreate() y xTaskCreateStatic() tienes un parámetro para pasarle valores a la tarea que estás creando. Y es en este parámetro donde indicarías la salida asociada al LED y la temporización de cada uno. En caso de que tengas que hacer cambios, éstos los harás en un sólo lado y una sola vez, ahorrándote muchos dolores de cabeza.

Visita esta entrada en donde explico de manera extensa cómo se pasan argumentos a las funciones. Si no la has leído te recomiendo que lo hagas antes de continuar, ya que estaré utilizando todo lo visto allá. De hecho, esta entrada es como la segunda parte de la anterior.

Pasando un argumento simple constante

Vamos a comenzar definiendo la tarea de la cual estaremos reutilizando su código. El propósito de la tares es que un LED parpadée a intervalos constantes (más adelante cada LED parpadeará a su propio ritmo):

void led_task( void* pvParameters )
{
    uint8_t pin = (uint8_t) pvParameters;

    pinMode( pin, OUTPUT );

    while( 1 )
    {
        digitalWrite( pin, HIGH );

        vTaskDelay( pdMS_TO_TICKS( 497 ) );
   
        digitalWrite( pin, LOW );

        vTaskDelay( pdMS_TO_TICKS( 497 ) );
    }
}

En la línea 3 definimos la variable pin que almacenará el número de pin de Arduino asociado al LED. Cada tarea tendrá su propia copia de esta variable, por lo cual no importa cuantas tareas crees, todas serán independientes.

Luego debemos crear las tareas que reutilizarán este código:

   xTaskCreate( 
      led_task,   // es el mismo código ...
      "LD1",
      128,
      (void*) 13, // pero con datos diferentes
      tskIDLE_PRIORITY,
      NULL );

   xTaskCreate( led_task, "LD2", 128, (void*) 12, tskIDLE_PRIORITY, NULL );
   xTaskCreate( led_task, "LD3", 128, (void*) 11, tskIDLE_PRIORITY, NULL );
   xTaskCreate( led_task, "LD4", 128, (void*) 10, tskIDLE_PRIORITY, NULL );

Tenemos cuatro tareas idénticas cuya única, y fundamental diferencia, es el pin asociado. En este ejemplo estoy utilizando números constantes para el número de pin, pero también es posible utilizar variables (como veremos más adelante).

Aquí está el ejemplo completo con cuatro LEDs para que lo intentes:

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

void led_task( void* pvParameters )
{
    uint8_t pin = (uint8_t) pvParameters;

    pinMode( pin, OUTPUT );

    while( 1 )
    {
        digitalWrite( pin, HIGH );

        vTaskDelay( pdMS_TO_TICKS( 497 ) );
   
        digitalWrite( pin, LOW );

        vTaskDelay( pdMS_TO_TICKS( 497 ) );
    }
}

void setup()
{
   xTaskCreate( 
      led_task,
      "LD1",
      128,
      (void*) 13,
      tskIDLE_PRIORITY,
      NULL );

   xTaskCreate( led_task, "LD2", 128, (void*) 12, tskIDLE_PRIORITY, NULL );

   xTaskCreate( led_task, "LD3", 128, (void*) 11, tskIDLE_PRIORITY, NULL );

   xTaskCreate( led_task, "LD4", 128, (void*) 10, tskIDLE_PRIORITY, NULL );

   vTaskStartScheduler();
}

void loop() 
{
   // nada que hacer aquí, todo se realiza en las tareas
}

Pasando argumentos simples en variables

Tal vez quisieras pasar el número de LED en una variable, y piensas que solamente tienes que declarar las variables, ¿cierto?. Pues no, es un poco más difícil, y para los detalles debo remitirte a la entrada anterior de este curso. Sin embargo, haré un resumen: la variable debe existir cuando la tarea ya se esté ejecutando. Si tu declaras variables en la función setup() para usarlas como parámetros, éstas ya no existirán cuando las tareas se ejecuten. Esta situación se resuelve utilizando variables globales (poco recomendable), variables estáticas, o variables dinámicas.

Para ejemplificar este punto vamos a utilizar variables estáticas para pasar el número de pin a las tareas. Deberemos entonces crear cuatro variables estáticas y modificar el cuerpo de la tarea porque ahora recibirá direcciones de variables en lugar de constantes. Aquí está el ejemplo completo:

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

void led_task( void* pvParameters )
{
    uint8_t* pin = (uint8_t*) pvParameters;

    pinMode( *pin, OUTPUT );

    while( 1 )
    {
        digitalWrite( *pin, HIGH );

        vTaskDelay( pdMS_TO_TICKS( 497 ) );
   
        digitalWrite( *pin, LOW );

        vTaskDelay( pdMS_TO_TICKS( 497 ) );
    }
}

void setup()
{
   static uint8_t pin_led_1 = 13;
   xTaskCreate( 
      led_task,
      "LD1",
      128,
      (void*) &pin_led_1,
      tskIDLE_PRIORITY,
      NULL );

   static uint8_t pin_led_2 = 12;
   xTaskCreate( led_task, "LD2", 128, (void*) &pin_led_2, tskIDLE_PRIORITY, NULL );

   static uint8_t pin_led_3 = 11;
   xTaskCreate( led_task, "LD3", 128, (void*) &pin_led_3, tskIDLE_PRIORITY, NULL );

   static uint8_t pin_led_4 = 10;
   xTaskCreate( led_task, "LD4", 128, (void*) &pin_led_4, tskIDLE_PRIORITY, NULL );

   vTaskStartScheduler();
}

void loop() 
{
   // no se hace nada, todo el trabajo se lleva a cabo en las tareas
}

Observa que:

  • Línea 6: La variable pin ahora es un apuntador que guardará la dirección de la variable estática que reciba.
  • Líneas 8, 12, 16: Dado que la variable pin es un apuntador tienes que dereferenciarla para obtener el valor del pin asociado (* va a la dirección guardada en pin y devuelve el valor ahí almacenado. Apuntadores 101).
  • Líneas 24, 33, 36, 39: Las variables que se declaran para pasarlas como parámetros son marcadas como static. Aunque utilicé valores constantes en su declaración, estos valores podrían venir, posteriormente, de un cálculo o del usuario. Lo importante es que sean estáticas.
  • Líneas 29, 34, 37, 40: A diferencia del ejemplo anterior, aquí tienes que pasar la dirección de la variable estática. Para ello utilizas al operador referencia, &.

Notas:

1.- Si estás pensando en utilizar una misma variable para para las cuatro tareas, debo advertirte que eso no funciona. Cuando cada tarea se esté ejecutando necesita su propia variable, y si tú la compartes, entonces todas las tareas tomarán el mismo valor:

void setup()
{
   static uint8_t pin_led_1 = 13;
   xTaskCreate( 
      led_task,
      "LD1",
      128,
      (void*) &pin_led_1,
      tskIDLE_PRIORITY,
      NULL );

   pin_led_1 = 12;
   xTaskCreate( led_task, "LD2", 128, (void*) &pin_led_1, tskIDLE_PRIORITY, NULL );

   pin_led_1 = 11;
   xTaskCreate( led_task, "LD3", 128, (void*) &pin_led_1, tskIDLE_PRIORITY, NULL );

   pin_led_1 = 10;
   xTaskCreate( led_task, "LD4", 128, (void*) &pin_led_1, tskIDLE_PRIORITY, NULL );

   // Compila, pero no funciona como esperarías. TODAS las tareas, cuando comiencen a
   // ejecutarse, tomarán como valor el 10, el cual fue el último que se asignó a pin_led_1.

   vTaskStartScheduler();
}

2.- Puedes asignar nombres diferentes a cada tarea, como lo muestra el ejemplo, y también puedes asignar prioridades diferentes, si así lo requiere tu diseño (aunque el ejemplo no lo muestra).

3.- En el ejemplo utilicé variables estáticas, pero podrías usar variables globales (no lo recomiendo), o variables dinámicas (si el soporte dinámico está activado en FreeRTOS).

Pasando argumentos compuestos

Los ejemplos que hemos visto han utilizado tiempos constantes para el parpadeo de cada LED. Vamos a dar el siguiente paso: además de pasar el número de pin también pasaremos el tiempo de encendido y el tiempo de apagado de cada uno (por supuesto puedes extender esta idea según tus necesidades). Una vez más, para los detalles de esta solución puedes visitar esta entrada.

Para pasar argumentos compuestos necesitas usar estructuras de C y apuntadores a estructuras. Para nuestro ejemplo la estructura podría ser:

typedef struct
{
   uint8_t pin;    // pin
   uint16_t t_on;  // tiempo de encencido
   uint16_t t_off; // tiempo de apagado
} Led_Task_Params;

Vamos a ver el ejemplo completo y luego platicamos sobre él:

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

typedef struct
{
   uint8_t pin;
   uint16_t t_on;
   uint16_t t_off;
} Led_Task_Params;

void led_task( void* pvParameters )
{
    Led_Task_Params* params = (Led_Task_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 Led_Task_Params led1_params = { .pin = 13, .t_on = 100, .t_off = 900 };
   xTaskCreate( 
      led_task,
      "LD1",
      128,
      (void*) &led1_params,
      tskIDLE_PRIORITY,
      NULL );

   static Led_Task_Params led2_params = { .pin = 12, .t_on = 1401, .t_off = 1100 };
   xTaskCreate( led_task, "LD2", 128, (void*) &led2_params, tskIDLE_PRIORITY, NULL );

   static Led_Task_Params led3_params = { .pin = 11, .t_on = 899, .t_off = 199 };
   xTaskCreate( led_task, "LD3", 128, (void*) &led3_params, tskIDLE_PRIORITY, NULL );

   static Led_Task_Params led4_params;
   led4_params.pin = 10;
   led4_params.t_on = 497;
   led4_params.t_off = 497;
   xTaskCreate( led_task, "LD4", 128, (void*) &led4_params, tskIDLE_PRIORITY, NULL );

   vTaskStartScheduler();
}

void loop() 
{
   // no se hace nada, todo el trabajo se lleva a cabo en las tareas
}
  • Línea 13: Declaramos una variable params que guardará la dirección de la variable estructura que usamos para pasar datos a la tarea. Recuerda que hay una de estas variables params por cada tarea y que son independientes. Y por supuesto, también puedes ver la promoción desde void* hacia Led_Task_Params*.
  • Líneas 15, 19, 21, 23, 25: Accesamos a los campos de la estructura a través del operador flecha, ->, ya que params es un apuntador a estructura.
  • Líneas 31, 40, 43: Declaramos e inicializamos variables estructura estáticas con los valores que le queremos pasar a las tareas.
  • Línea 46: Similiar al punto anterior, pero quise que vieras una manera alternativa en caso de que los valores de los diferentes campos de led4_params no se puedan inicializar cuando declaras la variable. ¿Porqué usamos aquí al operador punto, ., y en el primer punto al operador flecha, ->? Porque dentro de la función setup() las 4 variables son «reales»; es decir, son variables normales con sus tres campos. En el código de la tarea lo que tienes es la dirección de una variable estructura.
  • Líneas 36, 41, 44, 50: Dado que las variables ledX_params son «reales» debemos pasarle su dirección a la tarea (habiendo convertido la dirección a apuntador void, por supuesto).

¿Qué sigue?

Hemos visto cómo reutilizar el código de una tarea para evitar la duplicación de código, de tres formas diferentes: con constantes, con variables simples, y con variables compuestas.

Como mencioné, lo que platicamos hoy era el post original, sin embargo, me encontré en la necesidad de realizarlo en dos partes debido a que es muchísima información, y lo que hoy leíste fue la segunda parte. Si tienes mucha prisa y sólo quieres la implementación, entonces lo que estudiamos hoy basta y sobra. Pero si tienes un poco de tiempo y quieres ahondar en los detalles también, entonces podrías visitar la entrada anterior.

En la siguiente entrada veremos qué son las funciones vTaskDelay() y vTaskDelayUntil(), sus diferencias y sus aplicaciones.

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.