Variables de condición y monitores, I

Esta entrada la quiero presentar a través de dos ejemplos y algunas preguntas.

Imagina un sistema concurrente o multitarea con sistema operativo, como muchos de los que hemos estado escribiendo. Imagina que tienes una estructura de datos Cola (Queue en inglés) y que es compartida por varias tareas, es decir, dos o más tareas tratarán de escribir o leer de ella. Los problemas que se presentan son:

  1. ¿Qué haces si la cola está vacía y una o más tareas necesitan leer de ella?
  2. ¿Qué haces si la cola está llena y una o más tareas necesitan escribir en ella?
  3. ¿Qué haces si mientras una tarea está escribiendo en la cola otra quiere leer de ella «al mismo tiempo»?

Este otro ejemplo es clásico en el tema que vamos a tratar hoy. Imagina un sistema bancario que puede recibir depósitos y puede entregar dinero en efectivo. Varias personas pueden depositar, mientras que una o más pueden retirar.

  1. ¿Qué haces si dos personas quieren depositar al mismo tiempo?
  2. ¿Qué haces si dos personas quieren retirar al mismo tiempo?
  3. ¿Qué haces si mientras una persona está depositando otra quiere retirar, o viceversa?
  4. ¿Qué haces si quieres retirar una cantidad, pero el saldo no es suficiente?

El punto en común de ambos ejemplos es que hay un recurso compartido que por un lado de ninguna manera debe ser accesado al mismo tiempo, so pena de corromperlo, y por otro lado tiene tiempos de inactividad en que el solicitante debe esperar (no hay datos para desencolar, la cola está llena, saldo insuficiente para cubrir el retiro, etc).

Una solución es que si el recurso está siendo utilizado, los solicitantes se agreguen a una lista de espera y se vayan a dormir. En cuanto el recurso es liberado, uno de los solicitantes en la lista de espera es sacado de la misma y despertado para que use al recurso. A esta solución le vamos a llamar Variable de condición (en inglés, Condition variable).

Y por otro lado tenemos a los monitores. Un monitor es una construcción (en forma de tipo abstracto o clase) que encapsula a un recurso compartido, administra el acceso a éste a través de un semáforo mutex, y presenta un conjunto de operaciones al cliente del mismo. El recurso sólo puede ser utilizado a través de las operaciones públicas, es decir, el recurso no puede ser usado de maneras que no hayan sido previstas; además, es el monitor el que se encarga de la administración del mutex, no el usuario del recurso. Un monitor puede usar o no variables de condición.

En esta primera parte del artículo de hoy voy a mostrarte una implementación de variable de condición para un microcontrolador con núcleo ARM Cortex-M3 y el sistema operativo FreeRTOS. Por supuesto que las ideas expuestas, así como el código, los podrás trasladar a otras arquitecturas y sistemas operativos con un mínimo de esfuerzo. En la segunda parte te mostraré cómo implementar un monitores con y sin variables de condición.

Tabla de contenidos

¿Qué es y qué necesito?

Un semáforo mutex (de aquí en adelante solamente ‘mutex’) puede implementar exclusión mutua simple; esto es, nada más una tarea puede accesar a un recurso compartido (o estar activa dentro de una región crítica) en un tiempo dado. Para que otra tarea accese al recurso, la tarea que tenía el mutex lo debe devolver. Esta situación hace imposible que la tarea que tiene al mutex, y por lo tanto al recurso compartido, sea suspendida y despertarla después cuando una condición se cumpla. (En mi curso de Arduino en tiempo real escribí esta entrada sobre los mutex’es.)

En los ejemplos que dí al principio lo podemos observar: una tarea que quiere escribir a la cola ya tiene el mutex, es decir, ya tiene acceso a la cola, pero ésta está llena, y por lo tanto no se puede escribir en ella, ¿qué debe hacer la tarea que tiene el acceso a la cola y quiere escribir? Puede hacer dos cosas:

  1. Devolver el mutex y salir para después tentar a la suerte e intentar adquirir al mutex (y al recurso) cuando se haya hecho algo de espacio, o
  2. Ser insertada en una cola de espera, irse a dormir, y después ser desencolada de manera automática tan pronto se haya hecho algo de espacio en la cola.

El punto 2 es la definición de una variable de condición: una tarea a la espera del mutex es encolada y suspendida, y cuando la condición (en este ejemplo, hay espacio en la cola) se cumple, la tarea es desencolada, despertada, adquiere el mutex y se pone a trabajar. La cola de espera permite que varias tareas estén suspendidas (dormidas) a la espera del mutex, y cada una lo obtendrá tan pronto la condición se cumpla.

Una variable de condición es un tipo abstracto que presenta, al menos, las siguientes operaciones:

  • CWait(). Si el mutex no está disponible, entonces la tarea que hizo la llamada a esta función se encola y se suspende.
  • CNotify(). Avisa que la condición se cumplió, y por lo tanto, la tarea que lleva más tiempo en la cola de espera es despertada y puesta a trabajar. Si no hubiera ninguna tarea esperando, entonces no se hace nada.

(La letra C en los nombres anteriores es de Condition, pero es lo de menos.)

En su forma más básica los atributos de este tipo abstracto es una estructura de datos Cola, es decir, el primer elemento en haber entrado (el más antiguo) será el primero en salir (en inglés, FIFO: First-in, First-out). Aunque el mutex puede ser parte del mismo, la mayoría de implementaciones que he visto lo manejan de forma externa, es decir, son parte de la lista de argumentos de la función CWait(). Quizás más adelante modifique (o mejor aún, tú modifiques) la clase CondVar para que también provea al mutex.

Así mismo, además de las dos funciones básicas mencionadas, también se podrían incluir las siguientes tres:

  • CWaitFor(). Igual que CWait(), pero si no obtiene al mutex antes de que el tiempo (relativo a cuando se hizo la llamada) establecido expire sale y devuelve el valor false.
  • CWaitUntil(). Igual que CWait(), pero si no obtiene al mutex antes de que el tiempo (absoluto con referencia a una marca de tiempo en el pasado) establecido expire sale y devuelve el valor false.
  • CNotifyAll(). Igual que CNotify(), pero despierta a todas las tareas que estuvieran en la cola de espera.

El ejemplo que te voy a mostrar más adelante incluye las funciones CWait() y CNotify(). Y hablando de ejemplos, antes de ver los pseudo códigos correspondientes creí necesario que supieras que tanto las variables de condición, como los monitores (que veremos en la segunda parte), pueden ser codificados de manera procedimental (en lenguaje C), u orientada a objetos (con clases en lenguaje C++, Java, C#, etc). El ejemplo te lo voy a mostrar en C++ con objetos, pero verás que no tendrás ningún problema es hacerlo en C plano.

Operación CWait

El núcleo de una variable de condición es la operación CWait(), cuyo pseudo código es:

CWait( mutex )
{
    Inserta a la tarea en la cola de espera
    Devuelve el mutex
    La tarea se suspende

    // esta es la instrucción que la tarea ve cuando es despertada:
    Adquiere al mutex
}

Ojalá su implementación fuera tan simple como su pseudo código. Nota que cuando la tarea es despertada lo primero que hace es intentar adquirir al mutex de nuevo (en la línea 4 lo devolvió).

Ahora, entre el momento de que la tarea fue despertada y el momento en que obtuvo al mutex de regreso, muchas cosas pudieron haber sucedido, entre ellas que la condición dejara de cumplirse. Sí, podría pasar. Por ejemplo, si la tarea estaba esperando un espacio para escribir, pero antes de obtener nuevamente el mutex otra tarea llenó la cola, entonces debe volver a esperar. Esta condición se puede manejar así:

Mutex mutex

una_tarea()
{
    Mientras 1 == 1
    {
        Obtiene al mutex

        Mientras la condición no se cumpla
        {
            CWait( mutex )
        }

        // usa al recurso 

        Devuelve al mutex
    }
}

¿Y cómo sabe que la condición se cumplió? ¡Ah! Aquí es donde la función CNotify() entra a escena.

Operación CNotify()

Cuando el código del cliente de la variable de condición detecta que la condición se ha cumplido, entonces manda llamar a CNotify(), cuyo pseudo código es:

CNotify()
{
    Si hay elementos en la cola de espera
    {
        Desencola a la tarea que ha esperado por más tiempo
        La despierta
    }
}

Que una tarea sea despertada no significa que vaya a ser ejecutada inmediatamente; el sistema operativo la pone como candidata a ser ejecutada (es decir, en la lista de tareas Ready) y se ejecutará cuando le corresponda.

¿Cómo se usan ambas funciones, CWait() y CNotify()? Veámoslo en un pseudo código:

una_tarea()
{
    Mientras 1 == 1
    {
        Obtiene al mutex

        Mientras la condición no se cumpla 
        {
            CWait( mutex )
        }

        // usa al recurso 

        Devuelve al mutex
    }
}
    
otra_tarea()
{
    Mientras 1 == 1
    {
        Si la condición se ha cumplido 
        {
            CNotify()
        }
    }
}

La función otra_tarea está revisando el cumplimiento de la condición, y cuando ésta ha sido cumplida, manda llamar a CNotify(), la cual despierta, de existir, a la tarea que más tiempo ha estado esperando. Como la condición ya fue cumplida, el ciclo wait deja de ejecutarse y la tara una_tarea puede accesar al recurso.

Desarrollo

Una vez que ya tenemos una idea de qué son y cómo se usan las variables de condición, es momento de aterrizar los conceptos. Los libros e Internet están llenos de teoría, y me costó mucho trabajo encontrar ejemplos concretos. Por eso, además de realizar la implementación de este tipo de mecanismos, es que quise compartirla contigo por si en algún momento tienes que usarlas.

Función CWait() y compañía

La función CWait() en C/C++, y utilizando al sistema operativo FreeRTOS, queda de la manera siguiente. Como mencioné, si estás utilizando C plano o algún otro sistema operativo me parece que no tendrás problemas en adaptarlo. Al final del artículo te pasaré el repositorio donde podrás descargar el código completo para el microcontrolador LPC1549 de 32 bits, de núcleo Cortex-M3.

   bool CWait( SemaphoreHandle_t& mutex, TickType_t ticks = portMAX_DELAY )
   {
      TaskHandle_t self;

      self = xTaskGetCurrentTaskHandle();
      // obtiene la tarea actual

      taskENTER_CRITICAL();
      {
         // guarda la tarea en la cola de espera:
         configASSERT( this->len < this->max );
         this->queue[ this->tail++ ] = self;
         if( this->tail == this->max ) this->tail = 0;
         ++this->len;

      } taskEXIT_CRITICAL();

      xSemaphoreGive( mutex );
      // devuelve el mutex

      vTaskSuspend( self );
      // la tarea se auto suspende
     
      return xSemaphoreTake( mutex, ticks );
      // intenta reobtener al mutex
   }

Nota que el código que tiene que ver con guardar la tarea (en realidad, su handler o descriptor) en la cola de espera está dentro de una sección crítica. No queremos que sucedan los problemas que mencioné en un principio, es decir, que mientras una tarea está intentando insertarse en la cola, otra, quizás de mayor prioridad, le gane. Usé una sección crítica para que la operación de inserción (y más adelante, la de extracción) fuese atómica, pero también es posible utilizar un mutex dedicado a la variable de condición con el mismo propósito. De hecho muchos libros lo hacen así (en la teoría). En un futuro cambiaré la sección crítica por un mutex dedicado.

CWait() espera de manera indefinida para obtener el mutex. Su argumento ticks indica que espere por siempre (gracias al valor por defecto portMAX_DELAY); esto es como si el argumento no existiera, y así debe ser para esta función. Sin embargo, las funciones CWaitFor() y CWaitUntil() sí que lo usan:

   bool CWaitFor( SemaphoreHandle_t& mutex, TickType_t ticks )
   {
      return CWait( mutex, ticks );
   }

   bool CWaitUntil( SemaphoreHandle_t mutex, TickType_t& last, TickType_t ticks ) 
   {
      TickType_t now = xTaskGetTickCount();

      TickType_t until = last + ticks - now;
      // los overflows se manejan de manera automática

      last = until;

      return CWait( mutex, until );
   }

Si estás familiarizado con la función de FreeRTOS vTaskDelayUntil(), no tendras problema para entender a la función CWaitUntil() que funciona de manera similar: el tiempo establecido es absoluto, es decir, está referido a un punto establecido en el pasado, el cual se actualiza antes de salir de la función. Con esto evitas el desplazamiento de las llamadas en la línea de tiempo. Sin embargo, si tu aplicación no tiene problemas con ello, entonces puedes usar CWaitFor(), la cual establece un tiempo de salida relativo a cuando fue llamada, similar a la función de FreeRTOS vTaskDelay(). En esta lección de mi curso gratuito Arduino en tiempo real explico las diferencias y los usos de ambas funciones, vTaskDelay() y vTaskDelayUntil().

Función CNotify() y compañía

La función CNotify() en C/C++, y utilizando al sistema operativo FreeRTOS, queda de la manera siguiente. Es más fácil que su contraparte CWait(), pero igual de importante, ya que es ésta la que vuelve a la vida a las tareas para que continuen con su trabajo:

   void CNotify()
   {
      TaskHandle_t task = nullptr;

      taskENTER_CRITICAL();
      {
         if( this->len > 0 ){
            task = this->queue[ this->head++ ];
            if( this->head == this->max ) this->head = 0;
            --this->len;
         }
      } taskEXIT_CRITICAL();

      if( task != nullptr ) vTaskResume( task );
   }

RECUERDA: Hablando de la misma variable de condición, una tarea llama a CWait(), mientras que otra debe llamar a CNotify(). En otras palabras, las tareas consumidoras llaman a CWait(), mientras que las tareas productoras llaman a CNotify().

La sección crítica evita que si dos tareas llaman a CNotify() al mismo tiempo interfieran entre sí y corrompan a la cola de espera.

CNotify() despierta a la tarea que ha esperado por más tiempo, de haberla. La función CNotifyAll() despierta a todas:

   void CNotifyAll()
   {
      taskENTER_CRITICAL();
      {
         while( this->len ) CNotify();
      } taskEXIT_CRITICAL();
   }

Ejemplo

Ahora que conocemos qué son, qué hacen y cómo se implementan las variables de condición, ya podemos ver un ejemplo simple. El recurso compartido será una cola de caracteres, y habrá una función put() que escriba en ella y una función get() que lea de ella. Para poder escribir es necesario que haya espacio, y para poder leer es necesario que hayan caracteres disponibles. Para solucionar este problema vamos a usar dos variables de condición, una por cada condición (¡Wow!)

Primero declaramos las dos variables de condición:

ConditionV<4> data_avail;
// Variable de condición que indica que hay caracteres disponibles para leer

ConditionV<4> space_avail;
// Variable de condición que indica que hay espacio disponible en la cola de caracteres

El valor 4 indica el máximo número de tareas que la cola de espera de la variable de condición podrá guardar. En tu aplicación tú decides si son más o son menos.

La función put() queda así:

void put( char c )
{
   xSemaphoreTake( mutex, portMAX_DELAY );
   // adquiere al recurso

   while( len >= max ) space_avail.CWait( mutex );
   // la tarea se bloquea si la cola de caracteres está llena
   // la función get() realiza la notificación

   // escribe un carácter en la cola:
   buf[ tail++ ] = c;
   if( tail >= max ) tail = 0;
   ++len;

   data_avail.CNotify();
   // avisa que hay al menos un carácter listo para ser leído,
   // la usa la función get()

   xSemaphoreGive( mutex );
   // devuelve al recurso
}

La función get() es:

char get()
{
   xSemaphoreTake( mutex, portMAX_DELAY );
   // adquiere al recurso

   while( len == 0 ) data_avail.CWait( mutex );
   // la tarea se bloquea si la cola de caracteres está vacía
   // la función put() realiza la notificación

   // lee un carácter de la cola:
   char c = buf[ head++ ];
   if( head >= max ) head = 0;
   --len;

   space_avail.CNotify();
   // avisa que hay al menos un espacio para escribir,
   // la usa la función put()

   xSemaphoreGive( mutex );
   // devuelve al recurso

   return c;
   // devuelve al carácter
}

Las tareas productoras y consumidoras son las siguientes (en realidad es una de cada una, pero las reutilicé) :

void producer( void* pvParameters )
{
   uint32_t offset = (uint32_t)pvParameters;
   char cont = 0;

   char* task_name = pcTaskGetName( NULL );

   char letter = offset;

   while( 1 )
   {
      put( letter++ );
      // intenta escribir un carácter en la cola

      ++cont;
      if( cont == 10 ){
         cont = 0;
         letter = offset;
      }

      vTaskDelay( pdMS_TO_TICKS( ( rand() % 50 ) + 20 ) );
   }
}

void consumer( void* pvParameters )
{
   char* task_name = pcTaskGetName( NULL );

   while( 1 )
   {
      char c = get();
      // intenta leer un carácter de la cola

      vTaskDelay( pdMS_TO_TICKS( ( rand() % 150 ) + 25 ) );
   }
}

Quizás notaste que los tiempos que ambas tareas establecen para irse dormir (y así liberar la CPU) son aleatorios. Esto no es requisito para las variables de condición. Lo hice así con el fin de probar el código en condiciones cercanas a la realidad.

Para completar el ejemplo veamos la creación de las tareas:

   xTaskCreate( producer, "P0", 128,  (void*) 'A', tskIDLE_PRIORITY + 1, NULL ); // prints out: ABC...J
   xTaskCreate( producer, "P1", 128,  (void*) 'a', tskIDLE_PRIORITY + 1, NULL ); // prints out: abc...j
   xTaskCreate( producer, "P2", 128,  (void*) '0', tskIDLE_PRIORITY + 1, NULL ); // prints out: 012...9

   xTaskCreate( consumer, "C0", 128, NULL, tskIDLE_PRIORITY, NULL );
   xTaskCreate( consumer, "C1", 128, NULL, tskIDLE_PRIORITY, NULL )

Código fuente

El código fuente completo de este artículo lo puedes descargar o clonar desde este commit del repositorio del proyecto; pero si quieres obtener la última versión completa, incluyendo al monitor (tema de la segunda parte), lo puedes hacer desde aquí.

¿Qué sigue?

Hoy vimos qué son y cómo se implementan las variables de condición, herramientas muy útiles en la programación multi tarea.

Utilicé secciones críticas en las funciones CWait() y CNotify(). Una sección crítica detiene a todo el sistema porque desactiva las interrupciones. Una mejora, como lo mencioné, sería agregar un mutex dedicado a la variable de condición en lugar de las secciones críticas. Con esto logramos que el sistema siga funcionando independiente de las variables de condición.

Otra modificación muy interesante de cara a los sistemas de tiempo real es cambiar la cola de espera, que es circular, por una cola de espera doble, o una cola de prioridad, de tal manera que las tareas con prioridad alta sean las primeras en ser despachadas. No es fácil tomar la decisión ya que las tareas de alta prioridad podrían quedarse con la CPU y nunca prestársela a las tareas con baja prioridad que estuvieran esperando por el recurso. De cualquier manera vale la pena realizar el análisis y la modificación de la misma. O en su defecto, tener dos versiones de variables de condición: modo «normal» y modo «tiempo real».

En la segunda parte abordaré el tema de los monitores, los cuales, como también mencioné, pueden o no usar variables de condición.


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.

Cómo notificar a una tarea en FreeRTOS desde dos o más tareas

En esta lección de mi curso gratuito Arduino en tiempo real te mostré cómo utilizar un mecanismo para enviar notificaciones directas de tarea a tarea. En esta nota especial te mencioné que FreeRTOS incluyó un arreglo de notificaciones independientes en cada tarea, lo que significa que varias tareas podrían notificar a una misma (en teoría).

Aunque la documentación oficial y sus ejemplos no muestra cómo lograr esto último, me puse a pensar y llegué a una posible solución. Debo añadir que ni en el foro oficial de FreeRTOS saben para qué sirve tener un arreglo de notificaciones.

Por otro lado, en la nota especial señalé que por alguna razón las notificaciones indexadas (así le llama FreeRTOS a tener un arreglo de notificaciones) no funcionan, por lo que el desarrollo que te mostraré está basado en el microcontrolador de 32 bits LPC1549 y su herramienta de desarrollo gratuita LPCXpresso. Por supuesto que lo que mencionaré aplica para casi cualquier otro microcontrolador, excepto el ATmega328. Por esta razón no he puesto la presente entrada en el curso, ya que al no utilizar al chip ATmega328, no aplica.

Tabla de contenidos

¿Qué es y qué necesito?

Notificaciones directas

Las notificaciones directas a la tarea (direct to task notifications) de FreeRTOS son un mecanismo que permite que una tarea envíe un dato de 32 bits a otra tarea. Así de simple. El valor recibido puede tomar varias interpretaciones, desde un dato puro hasta como semáforos binarios o contadores. Algunas ventajas de este mecanismo es que consume pocos o casi nada de recursos, y además está integrado al sistema operativo, por lo que una tarea puede bloquearse mientras la notificación le llega; de ese modo la tarea bloqueada no consume ciclos de CPU.

Para más detalles de qué son y cómo se configuran por favor visita esta y esta lecciones.

Terminología

Tarea productora. Es una tarea que genera información para enviársela a otra tarea.

Tarea consumidora. Es una tarea que consume la información que otras tareas han generado

Teoría

La idea detrás del desarrollo que estoy a punto a describirte es que cada tarea productora escribirá su información en una entrada específica del arreglo de notificaciones; y además la tarea consumidora tendrá una entrada extra para recibir notificaciones. Esto es, cada vez que una tarea productora escriba en la entrada correspondiente, también deberá avisar que lo hizo señalizando en dicha entrada extra. Por ejemplo, si tu aplicación tiene 3 tareas productoras, entonces utilizarás 4 entradas en el arreglo de notificaciones.

La tarea consumidora será desbloqueada cada vez que una tarea productora se lo notifique. Luego leerá la información que le fue transmitida y volverá al estado de bloqueo.

PARA RECORDAR: FreeRTOS incluye dos mecanismos para transferir flujos y mensajes de información (Streams buffers y Message buffers, respectivamente). Éstos utilizan a la entrada 0 del arreglo de notificaciones, en caso de que actives a cualquiera de los dos. Entonces, si tu aplicación tiene 3 tareas productoras, utilizarás 4 entradas en el arreglo de notificaciones, pero deberías declarar 5:

/* Archivo de configuración FreeRTOSConfig.h */

#define configUSE_TASK_NOTIFICATIONS 1
// avisas que quieres utilizar notificaciones directas

#define configTASK_NOTIFICATION_ARRAY_ENTRIES   5
// avisas que cada tarea tendrá un arreglo de 5 entradas

Derivado de la configuración anterior podrás notar que hay un poco de desperdicio de memoria, ya que si tu aplicación tiene 7 tareas, entonces cada una de ellas tendrá un arreglo de 5 elementos para las notificaciones, en total 35 elementos de 32 bits cada uno, de los cuales ocuparás 5 (para este ejemplo). Tómalo en cuenta para que tengas cuidado cuando establezcas el número de entradas. Si no planeas utilizar los streams ni los messages, podrías, para este ejemplo, indicar 4 entradas.

Desarrollo

Para mostrarte cómo dos o más tareas pueden notificar a una misma, en este ejemplo vamos a tener dos tareas productoras y una consumidora, y no vamos a usar streams ni messages, por lo que nuestra configuración quedaría así:

/* Archivo de configuración FreeRTOSConfig.h */

#define configUSE_TASK_NOTIFICATIONS          1
#define configTASK_NOTIFICATION_ARRAY_ENTRIES 3

Como ya te decía, cada tarea productora deberá notificar la información y una notificación. La siguiente tarea simplemente incrementa en dos un valor y se lo transmite a la tarea consumidora. Por supuesto que tus tareas podrían realizar algo mucho más complejo, incluyendo notificar desde interrupciones:

void Producer1_task( void* pvParameters )
{
   pvParameters = pvParameters;

   uint32_t cont = 1;

   while( 1 )
   {
      vTaskDelay( pdMS_TO_TICKS( ( rand() % 100 ) + 100 ) );

      xTaskNotifyIndexed(
    		  consumer_h,            // handler de la tarea consumidora
			  1,                      // índice en el arreglo de notificaciones de la tarea consumidora para recibir la información
			  (uint32_t) cont,        // información que queremos transmitir
			  eSetValueWithOverwrite  // sobreescribe la información si la anterior no fue leída
	  );

      xTaskNotifyIndexed(
           consumer_h,             // handler de la tarea consumidora
			  0,                      // índice en el arreglo de notificaciones de la tarea consumidora para recibir "notificaciones"
			  ( 1 << 1 ),             // el bit 1 (0x01) corresponde a esta tarea
			  eSetBits                // hace una OR entre el valor 0x01 y lo que ya estuviera en el campo de notificaciones
	  );

      // tu proceso...

      cont += 2;

   }
}

Las tareas productoras necesitan conocer el handler de la tarea consumidora, consumer_h. Este handler lo puedes pasar como argumento a la tarea, o declararlo global. En este ejemplo, como verás más adelante, me fui por el camino fácil y poco recomendado: lo declaré global.

Así mismo, las tareas productoras tienen mayor prioridad que la tarea consumidora. Veamos el código de la otra tarea productora:

void Producer2_task( void* pvParameters )
{
	pvParameters = pvParameters;

   uint32_t cont = 2;

   while( 1 )
   {
      vTaskDelay( pdMS_TO_TICKS( ( rand() % 100 ) + 125 ) );

      xTaskNotifyIndexed(
    		  consumer_h,             // handler de la tarea consumidora
			  2,                      // índice en el arreglo de notificaciones de la tarea consumidora para recibir la información
			  (uint32_t) cont,        // información que queremos transmitir
			  eSetValueWithOverwrite  // sobreescribe la información si la anterior no fue leída
	  );

      xTaskNotifyIndexed(
           consumer_h,             // handler de la tarea consumidora
			  0,                      // índice en el arreglo de notificaciones de la tarea consumidora para recibir "notificaciones"
			  ( 1 << 2 ),             // el bit 2 (0x02) corresponde a esta tarea
			  eSetBits                // hace una OR entre el valor 0x02 y lo que ya estuviera en el campo de notificaciones
	  );

      // tu proceso...

      cont += 2;
   }
}

Ambas tareas son muy parecidas, pero lo hice así para mantener simple al ejemplo y concentrarme en la finalidad del mismo.

Después tenemos a la tarea consumidora, la cual como ya dije, se bloquea a la espera de ser notificada:

void Consumer_task( void* pvParameters )
{
   uint32_t p1_value;
   uint32_t p2_value;

   uint32_t         notified_value;
   const TickType_t max_block_time = pdMS_TO_TICKS( 2000 );

   while( 1 )
   {
      BaseType_t 
      ret_val = xTaskNotifyWaitIndexed(
         0,                       // índice donde recibe las notificaciones
         0,                       // no pone a cero ningún bit a la entrada a la función
         ( 0x01 | 0x02 ),         // pone a cero los bits 1 y 2 a la salida de la función
         &notified_value,         // en esta variable guarda quién lanzó las notificaciones (se usa más adelante)
         max_block_time );        // tiempo de espera antes de indicar error

      if( ret_val != pdFALSE ){   // las notificaciones se entregaron antes de que el tiempo terminara
         
         if( notified_value & 0x01 ){ // la tarea Producer1_task puso una notificación
            xTaskNotifyWaitIndexed( 
                  1,                  // en esta entrada está la información
                  0,                  // no pone a cero ningún bit a la entrada a la función
                  0xffffffff,         // pone el campo de información a 0 a la salida de la función
                  &p1_value,          // en esta variable guarda la información
                  0 );                // no espera ya que sabemos que hay información lista para ser leída

            // tu proceso ...

            DEBUGOUT( "%d\n\r", p1_value ); // imprime el dato
            Board_LED_Toggle( RED );
         }

         if( notified_value & 0x02 ){ // la tarea Producer2_task puso una notificación
            xTaskNotifyWaitIndexed( 
                  2,                  // en esta entrada está la información
                  0,                  // no pone a cero ningún bit a la entrada a la función
                  0xffffffff,         // pone el campo de información a 0 a la salida de la función
                  &p2_value,          // en esta variable guarda la información
                  0 );                // no espera ya que sabemos que hay información lista para ser leída

            // tu proceso ...

            DEBUGOUT( "%d\n\r", p2_value ); // imprime el dato
            Board_LED_Toggle( BLUE );
         }
      }
   }
}

Cuando cualquiera de las tareas productoras «avisa» que hay un dato listo, esta función sale del estado Block, pero como son dos o más tareas las que pueden despertarlo, debe verificar, una a una, la tarea que hizo la notificación revisando su respectivo bit. Una vez que la ha determinado, obtiene de manera inmediata la información. Dado que ya sabemos que hubo un dato, no hay necesidad de establecer un tiempo de espera, por eso el argumento timeout vale 0.

¿Qué sigue?

Hoy hemos visto una forma de utilizar al arreglo de notificaciones para que dos o más tareas notifiquen a una misma. Esta idea la podemos extender a interrupciones (ISRs) para que sea una tarea, y no las propias ISRs, quienes procesen la información (a este procedimiento le llamamos diferred tasks, o en español, tareas diferidas. Escribí un artículo sobre esto, en inglés, aquí en mi blog alternativo).

Ten en cuenta que este procedimiento es útil y ahorra muchísimos recursos, pero si tus tareas deben transferir grandes cantidades de información quizás debas considerar la utilización de otros mecanismos como los streams, los messages, o las colas.

Í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.

Hooks útiles de FreeRTOS y un watchdog por software

Mucho, si no es que casi todo el trabajo que hacemos, lo llevamos a cabo dentro de tareas e interrupciones, en lo que le llamaríamos la capa de usuario. Sin embargo, FreeRTOS nos ofrece la posiblidad de hacer cosas simples como si nuestro código fuese parte de él, ejecutándose en la capa del sistema operativo.

Imagina que debes implementar un LED que parpadée a intervalos regulares para indicarte que el sistema está «vivo» (a esta actividad le llamamos heartbeat, o en español, latido del corazón). Si el LED no parpadea, o dejó de hacerlo después de un tiempo, significa que algo malo pasó dentro del sistema. Para implementarlo puedes hacerlo de dos formas con lo que conocemos hasta el momento:

  1. Una tarea dedicada al heartbeat, o
  2. Un software timer con una callback que lleve a cabo el latido del corazón.

Ambas soluciones tienen el problema del alto consumo de recursos para una tarea tan simple. ¿Qué podríamos hacer? Qué tal que le decimos al sistema operativo que él mismo mande llamar a nuestra función heartbeat. ¡Excelente!

FreeRTOS nos provee dos maneras de hacerlo:

  • Que FreeRTOS llame a nuestra función heartbeat() cada vez que el sistema esté desocupado, o
  • Que FreeRTOS llame a nuestra función heartbeat() en cada tick del sistema.

Cuál escoger depende totalmente de la aplicación que estés escribiendo, y por supuesto que puedes implementar muchas cosas diferentes a un heartbeat.

Con este par de mecanismos no tendrás que crear tareas extra o usar a los software timers para algo tan sencillo. Eso sí, el código de heartbeat(), o cualquier otro que inyectes a FreeRTOS, tiene que ser simple y no debería llamar a ninguna función bloqueante.

Una aplicación muy útil que podemos inyectar al sistema para que se ejecute cada vez que el sistema está desocupado es la de un watchdog por software. Un watchdog se encarga de supervisar al sistema; si una tarea crítica deja de responder, entonces el sistema completo es reiniciado.

En la lección de hoy vamos a recordar qué son las callbacks, cómo usarlas en cada tick del sistema o cada vez que éste tenga tiempo libre, y cómo implementar un watchdog por software básico.

Tabla de contenidos

¿Qué es y qué necesito?

Callbacks

Una función callback es una función tuya que se la pasas a una función de un sistema independiente (o aparte del tuyo) como parte de una lista de argumentos. Esta función del sistema entonces ejecuta tu función:

int fn( int v );
void funcion_del_sistema( int a, int (*pf)(int) )
{
	pf( a );
}


int callback( int v )
{
	return v+1;
}

int main()
{
	funcion_del_sistema( 5, callback );
}

La función funcion_del_sistema() no es tuya, es parte de un sistema más grande, por ejemplo, FreeRTOS o un administrador de ventanas en un sistema gráfico. Pero tú le pasas código tuyo inyectándole la función callback() la cual sí es tuya (le puse ese nombre para que la identificaras). La función funcion_del_sistema() la recibe como parte de sus argumentos y le da el nombre de pf(). Cuando funcion_del_sistema() se encuentra pf( a ) manda llamar a callback( 5 ). Esta es una forma de extender la funcionalidad de un sistema sin modificar su código fuente.

Para que las callbacks funcionen sus firmas deben de corresponder. Por ejemplo, la función funcion_del_sistema() puede recibir cualquier función que devuelva un entero y reciba un sólo argumento y éste sea entero, y sería un error de compilación si le quieres pasar una función que devuelve un float o que recibe dos argumentos, etc.

Hooks

Una función hook (en español, ancla) sirve para lo mismo que una callback, extender la funcionalidad del sistema, pero se implementa diferente. Una función del sistema espera que exista una cierta función y la manda llamar; esto es, no se inyecta:

int hook( int );
void funcion_del_sistema( int a )
{
	hook( a );
}


int hook( int v )
{
	return v+1;
}

int main()
{
	funcion_del_sistema( 5 );
}

Para que este mecanismo funcione debes nombrar a la hook exactamente como el sistema lo espera, y además debe estar visible a la hora de compilar. Del código anterior podrás ver que la función del sistema otra_fun ya no tiene el parámetro para recibir una función, y que dentro manda llamar a una función con un nombre particular, en este ejemplo, hook(). Por lo tanto tú debes escribir una función con la misma firma y el mismo nombre, y además, como mencioné, tu función deberá estar visible al compilador.

Lo que vamos a ver hoy requiere de funciones hook.

Idle task

La idle task (se pronuncia más o menos, [áidel task]) es una función que crea FreeRTOS de manera automática con la prioridad más baja (¿recuerdas la constante tskIDLE_PRIORITY que usamos en la creación de tareas?) y que se ejecuta únicamente cuando no hay tareas de mayor prioridad activas. Esto es, en FreeRTOS siempre habrá una tarea ejecutándose cuando todas las demás estén bloqueadas.

Un uso que FreeRTOS le da a esta tarea, que ya había mencionado en otra lección, es devolver la memoria indicada por una llamada a la función free() en algunos de los esquemas de memoria dinámica (heap_2.c, heap_4.c, y heap_5.c). De manera conveniente FreeRTOS abrió una puerta para que nosotros los programadores tengamos la posibilidad de ejecutar código cada vez que la idle task se ejecuta.

RECUERDA: Tu código debe ser simple y no debe bloquear a la idle task. Así mismo toma en cuenta que esta tarea podría no ejecutarse contínuamente, ya que si la carga de trabajo es alta, entonces ésta, y en consecuencia tu código, se estará ejecutando esporádicamente.

Idle hook

La idle task siempre está en estado Ready (y cuando hay tiempo en el sistema pasa al estado Running), ya sea que se ejecute o no periódicamente. Para que tú inyectes tu código debes realizar cuatro cosas:

1. En el archivo FreeRTOSConfig.h asegúrate que la constante configUSE_IDLE_HOOK está puesta a 1:

#define configUSE_IDLE_HOOK 1

2. (Opcional) La idle task es a final de cuentas una tarea, por lo cual requiere de una pila. El tamaño mínimo de ésta ya viene especificado, pero si tu código necesitase más memoria, entonces debes aumentar su tamaño. En el mismo archivo FreeRTOSConfig.h puedes modificar su valor:

#define configIDLE_STACK_SIZE ( ( UBaseType_t ) 192 )
#define configIDLE_TASK_NAME  "IDLE" // por si quisieras cambiarle el nombre

3. La hook de la idle task se llama vApplicationIdleHook(). Ésta la puedes colocar donde quieras; en el proyecto Molcajete decidí poner todas las hooks en un mismo lugar (aunque puede ser diferente, por supuesto). Abre el archivo hooks.cpp (revisa la lección anterior para más detalles) y ubica a la función:

#if configUSE_IDLE_HOOK == 1
void vApplicationIdleHook()
{

}
#endif

Puedes escribir el código que quieres inyectar a la idle task directamente en esta función, o puedes hacerlo modular (indirecto, por si después necesitas agregar más funcionalidades sin ensuciar al código fuente). Para el ejemplo que veremos más adelante, un heartbeat(), utilizaremos la forma indirecta, es más limpia y el compilador se encarga de la optimización. De esta forma nuestra función vApplicationIdleHook() nos queda como:

#if configUSE_IDLE_HOOK == 1
void vApplicationIdleHook( void )
{
   void heartbeat_idle_hook();
   heartbeat_idle_hook();
}
#endif

4. Cuando activas la inyección de código en la idle task FreeRTOS espera una función vApplicationIdleHook(), la cual acabamos de escribir, pero así como está en este momento no hace nada, falta la función heartbeat_idle_hook() (o como gustes llamarle). Ésta es una función nuestra y la escribiremos en nuestro sketch (o donde nosotros querramos si estamos compilando desde la consola). La implementación completa está más adelante en el ejemplo, pero por el momento veamos su esqueleto para platicar un par de cosas interesantes:

// en un sketch o archivo fuente nuestro:

#ifdef __cplusplus
extern "C"{
#endif
void heartbeat_idle_hook()
{
   taskENTER_CRITICAL();
   {
      // tu código aquí
   }
   taskEXIT_CRITICAL();
}
#ifdef __cplusplus
}
#endif

1. La constante __cplusplus le dice al compilador que las funciones marcadas dentro de extern "C"{ ... } deben ser tratadas como funciones de C, no de C++ (recuerda que los sketches se compilan con C++). El compilador de C++ le cambia el nombre a las funciones para poder implementar cosas muy padres, como la sobrecarga de funciones, pero C no tiene idea de eso. Si la función heartbeat_idle_hook() no estuviera dentro de la sección extern "C"{ ... }, entonces su nombre podría ser algo tan raro como __vjgd74_heartbeat_idle_hook(). C++ sabe de este nombre porque él se lo puso, pero cuando mezclas C++ y C, éste último espera (de hecho es FreeRTOS quien espera) a la función heartbeat_idle_hook(), y aunque pareciera que sí existe, para el compilador de C no es así.

2. Tu código debe estar encerrado en una sección crítica (aplica sólo para micros de 8 bits):

taskENTER_CRITICAL();
{
   // tu código aquí
}
taskEXIT_CRITICAL();

Normalmente esto no es necesario (por «normalmente» hay que entender microcontroladores de 16 o 32 bits), pero de no hacerlo así el programa compilará pero no funcionará correctamente (le pasó al primo de un amigo y casi se vuelve loco). El problema es que en el código del heartbeat() que está más adelante utilizaremos aritmética de 16 bits, pero el ATmega328 es de 8 bits, y la atomicidad de las operaciones no está garantizada en un ambiente multitarea. El problema que tuve es el siguiente: cuando hacía restas (substracciones) sin región crítica obtuve resultados erróneos e inverosímiles, tales como: 1-0=65535 ó 0-0=65535. En un micro de 8 bits las operaciones y asignaciones de 16 y 32 bits ocupan varias instrucciones que deben ser ejecutadas de manera atómica (sin interrupciones), así que para asegurar dicha atomicidad encerré todo el código que involucra aritmética de 16 bits (el tipo TickType_t es de 16 bits) dentro de una región crítica.

RECUERDA: Las regiones críticas se deben ejecutar lo más rápido posible porque has desactivado a las interrupciones.

Tick del sistema

FreeRTOS utiliza un timer del microcontrolador para realizar cambios de contexto (cambiar entre tareas) periódicos. Los detalles de cuál timer y cuál configuración no son importantes en este momento; lo que sí es digno de anotarse es la frecuencia:

// en el archivo FreeRTOSConfig.h:
#define configTICK_RATE_HZ ( ( portTickType ) 1000 )

Esto significa que el planificador de tareas de FreeRTOS es llamado 1000 veces por segundo (la frecuencia), o lo que es lo mismo, una vez cada 1ms (el periodo). Aunque hemos estado utilizando este valor a lo largo del curso, no siempre tiene porqué ser tan rápido; quizás alguna aplicación requiera algo más lento, como 100 ó 10 veces por segundo.

Lo que verdaderamente nos interesa en este instante es que, así como le hicimos con la idle task, también podemos inyectar código en la función de FreeRTOS que es ejecutada en cada tick (con estos valores, cada 1ms).

RECUERDA: Tu código debe ser simple y no debe bloquear al tick. El tick NO es una tarea, es una función del sistema operativo que es llamada cada vez que el tiempo del timer correspondiente expiró, y desde el punto de vista del nivel sistema operativo utiliza la pila verdadera del chip.

Tick hook

Para que inyectes tu código debes realizar tres cosas (aplica la misma teoría de la idle hook, por lo cual no la repetiré a menos que se presenta alguna particularidad):

1. En el archivo FreeRTOSConfig.h asegúrate que la constante configUSE_TICK_HOOK está puesta a 1:

#define configUSE_TICK_HOOK 1

2. La hook del tick hook se llama vApplicationTickHook(). Ésta la puedes colocar donde quieras; en el proyecto Molcajete decidí poner todas las hooks en un mismo lugar (aunque puede ser diferente, por supuesto). Abre el archivo hooks.cpp (revisa la lección anterior para más detalles) y ubica a la función:

#if configUSE_TICK_HOOK == 1
void vApplicationTickHook( void )
{

}
#endif

Puedes escribir el código que quieres inyectar al tick directamente en esta función, o puedes hacerlo modular (indirecto, por si después necesitas agregar más funcionalidades sin ensuciar al código fuente). Para el ejemplo que veremos más adelante, un heartbeat(), utilizaremos la forma indirecta, es más limpia y el compilador se encarga de la optimización. De esta forma nuestra función vApplicationTickHook() nos queda como:

#if configUSE_TICK_HOOK == 1
void vApplicationTickHook( void )
{
   void heartbeat_tick_hook();
   heartbeat_tick_hook();
}
#endif

3. Cuando activas la inyección de código en el tick hook, FreeRTOS espera una función vApplicationTickHook(), la cual acabamos de escribir, pero así como está en este momento no hace nada, falta la función heartbeat_tick_hook() (o como gustes llamarle). Ésta es una función nuestra y la escribiremos en nuestro sketch (o donde nosotros querramos si estamos compilando desde la consola). La implementación completa está más adelante en el ejemplo, pero por el momento veamos su esqueleto:

// en un sketch o archivo fuente nuestro:

#ifdef __cplusplus
extern "C"{
#endif
void heartbeat_tick_hook()
{
   taskENTER_CRITICAL();
   {
      // tu código aquí
   }
   taskEXIT_CRITICAL();
}
#ifdef __cplusplus
}
#endif

Watchdog

En los sistemas embebidos un watchdogsupervisor» en español) es un contador hacia abajo independiente, que cuando llega a cero envía una señal de reset al procesador. Para evitarlo el programa debe estar constantemente alimentándolo (o como la mayoría de textos dice, «pateándolo«, del inglés «kicking the dog«). «Alimentar» significa escribir en unos registros especiales unos valores especiales, y también viene del inglés, feed the dog, otra expresón común con los supervisores. Mientras el programa se ejecuta correctamente, el supervisor es alimentado antes de que la cuenta llegue a cero; pero si el programa se pierde, y puede perderse por muchas razones, entonces ya no lo alimentará dejando que la cuenta llegue a cero, y por lo tanto, reiniciará al sistema.

Aunque cada procesador implementa sus propias formas de inicializar y alimentar al supervisor (sin olvidar que existen supervisores externos), el centro de la discusión no es saber cuándo alimentarlo (obviamente antes de que la cuenta llegue a cero), sino bajo cuáles condiciones debe ser alimentado.

Es decir, no se trata nada más de alimentarlo por alimentarlo, sino hacerlo cuando estemos seguros de que el sistema funciona correctamente. Si detectamos una anomalía, entonces no lo alimentamos, ponemos al sistema en modo seguro, y dejamos que el supervisor reinicie al sistema. Estos pasos son fundamentales en sistemas multitarea, como los que estamos desarrollando: si una tarea crítica falla, de las múltiples que podemos llegar a tener, entonces el sistema debe ser reiniciado.

¿Qué tiene que ver un supervisor con las hooks de FreeRTOS? Te lo responderé de la siguiente manera:

Jamás alimentes al supervisor desde una tarea/interrupción periódica.

Los gurús de los sistemas embebidos

Lo más fácil es alimentar al supervisor desde una interrupción periódica. El problema es que si el programa falla, lo más seguro es que la interrupción continúe, por lo tanto, el sistema no se reiniciará (este tipo de cosas también le han pasado al primo de un amigo).

Nosotros vamos a usar el hook de la idle task para implementar a nuestro supervisor por software. Esta tarea no es ni de cerca periódica, lo cual la convierte en una candidata excelente para esta actividad (y aunque podríamos escribir una tarea de baja prioridad para nuestro supervisor, ya tenemos a la hook, ¡aprovechémosla!). En términos generales, si otorgamos a las tareas críticas una prioridad alta, y alguna de éstas se cuelga, entonces la idle task no se ejecutará, y por lo tanto, tampoco nuestro supervisor por software, y como la alimentación del supervisor por hardware es responsabilidad de ésta, entonces no lo alimentará y el sistema se reiniciará.

¿Porqué no usar la tick hook? Por lo que acabo de mencionar. Al final de cuentas la tick hook no solamente es periódica, sino que funciona por fuera del ámbito de nuestras tareas de usuario, por lo cual, si una tarea se cuelga, el tick siempre seguirá funcionando, dando al traste con la idea principal de los supervisores.

Deadline

Los sistemas embebidos de tiempo real deben realizar su trabajo a tiempo, antes de que sea demasiado tarde: el sistema de frenos de un auto debe activar las bolsas de aire antes de un cierto tiempo (tiempo contabilizado después de haber recibido la señal de impacto), ya que de no hacerlo, el conductor podrá sufrir graves daños. Al tiempo entre que la señal se genera y el sistema responde le llamamos el deadline (o tiempo límite). Existen hard deadlines, como el ejemplo de las bolsas de aire, y también existen soft deadlines, donde pasarse un poco no tiene consecuencias fatales.

Te comento esto porque el ejemplo que veremos no sólo verifica que las tareas no se cuelguen, sino que además reiniciará al sistema si uno o más deadlines no se cumplen.

Implementación del supervisor por software

Hay muchas formas de implementar al supervisor por software (en inglés, «software watchdog«), y la que te mostraré en los ejemplos es una adaptación de una que encontré en:

[LEWIS02] D.W. Lewis. Fundamentals of embedded software. Prentice Hall, USA: 2002.

La idea es la siguiente: tendrás una lista de tareas críticas (no todas las tareas son o deberían ser críticas). Cada tarea tendrá 3 atributos: busy: Bool; failed: Bool; deadline: UINT; last: UINT. busy indica si la tarea está ocupada o no; failed significa si la tarea perdió el deadline (se pasó) o no; deadline guarda el tiempo máximo en el que la tarea debe responder; last indica el tiempo en el que tarea inició su proceso. Al inicio las banderas se ponen a false y estableces el deadline. Cuando un evento se presenta y dispara la ejecución de una tarea, busy se pone a true y guardar la marca de tiempo en last. Cuando la tarea finaliza la bandera failed se pone a true si el deadline se perdió (la tarea se tardó más), e incondicionalmente pone busy a false. El siguiente pseudocódigo muestra la forma general de este método:

// Tarea productora del evento
last[ TASK ] = current_time()
busy[ TASK ] = True
SemaphoreGive(...)


// Tarea consumidora del evento
while( True )
{
	SemaphoreTake(...)

	/* hace lo que tiene que hacer ... */

	if current_time() - last[ TASK ]  > deadline[ TASK ] {
		failed[ TASK ] = True
	}

	busy = False
}

// Software watchdog
Idle_hook()
{
	now = current_time()

	for i = 0; i < MAX_TASKS; ++i {

		if  failed[ i ] == True { break }
		// la tarea perdió su deadline
		
		if busy[ i ] == True && now - last[ i ] > dealine[ i ] { break }
		// ¡ la tarea se perdió por completo !
	}

	if i == MAX_TASKS { feed_the_dog() }
	else { clean_and_reset() }
}

Implementación del watchdog por hardware

El micro ATmega328 incluye un watchdog, pero no lo vamos a usar; en su lugar utilizaremos uno externo. Pero si no tienes uno o no quieres construir uno, entonces puedes usar el interno (aquí está una explicación superficial (en inglés) de cómo usar a este watchdog interno). Ahora que si quieres construir uno, aquí (en mi blog alternativo) podrás encontrar información sobre cómo construir uno con el chip 555. De hecho, la explicación la tuve que sacar de aquí porque la lección se estaba haciendo demasiado larga. Ya sea que visites mi otro blog, o no, aquí te dejo el diagrama del watchdog basado en el 555:

Ejemplos

Ejemplo 1: Heartbeat en la idle hook con tiempo simétrico

Este primer ejemplo, y el más sencillo, muestra cómo implementar un heartbeat() utilizando la hook de la idle task. Como no debemos usar funciones bloqueantes en las hooks (vTaskDelay(), por ejemplo), estaremos calculando diferencias de tiempos.

El tiempo es simétrico porque es el mismo tanto para el LED encendido como apagado. La función xTaskGetTickCount() devuelve el número de ticks transcurridos desde el último reinicio del programa. Nota que el único lugar donde utilizamos la macro de conversión pdMS_TO_TICKS() fue en la constante que establece el intervalo de tiempo; fuera de eso, el resto del código trabaja en término de ticks y no de milisegundos.

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

#define PROGRAM_NAME "hooks_1.ino"

#define TIME_INTERVAL pdMS_TO_TICKS( 500 )
// el LED parpadea cada 500ms

#ifdef __cplusplus
extern "C"{
#endif
void heartbeat_idle_hook()
{
   static bool led_state = false;
   static TickType_t last_time = 0;

   TickType_t current;
   TickType_t elapsed_time;

   taskENTER_CRITICAL();
   {
      current = xTaskGetTickCount(); 
      elapsed_time = current - last_time;

      if( elapsed_time >= TIME_INTERVAL ){

         last_time = current;

         digitalWrite( 13, led_state );
         led_state = !led_state;
      }
   }
   taskEXIT_CRITICAL();
}
#ifdef __cplusplus
}
#endif

void a_task( void* pvParameters )
{
    while( 1 )
    {
        vTaskDelay( pdMS_TO_TICKS( 497 ) );
        Serial.println( "Ok" );
    }
}

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

   pinMode( 13, OUTPUT );
   // lo vamos a usar en la hook de la idle task

   xTaskCreate( a_task, "TSK1", 128, NULL, tskIDLE_PRIORITY + 1, NULL );
   // establecemos que esta tarea tenga mayor prioridad que la idle task

   vTaskStartScheduler();

}

void loop() {}

La función xTaskGetTickCount() devuelve un valor de 16 bits para el ATmega328; esto implica que el contador interno de ticks de FreeRTOS pasa de 65535 a 0 (le decimos rollover) cada 65.535 segundos (cada minuto, aprox). Haber utilizado diferencias de tiempo como en la expresión: elapsed_time = current - last_time nos permite obtener siempre resultados correctos que permiten que el programa funcione de manera continua a pesar de los constantes rollovers.

Las variables led_state y last_time son ser estáticas porque deben mantener su valor entre llamadas.

El archivo hooks.cpp, incluyendo nada más el código de la idle hook queda así:

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

#ifdef __cplusplus
extern "C"{
#endif

#if configUSE_MALLOC_FAILED_HOOK == 1
void vApplicationMallocFailedHook()
{
}
#endif

#if configCHECK_FOR_STACK_OVERFLOW > 0
void vApplicationStackOverflowHook( TaskHandle_t xTask, char *pcTaskName )
{
}
#endif

#if configUSE_IDLE_HOOK == 1
void vApplicationIdleHook()
{
   void heartbeat_idle_hook();
   heartbeat_idle_hook();
}
#endif

#if configUSE_TICK_HOOK == 1
void vApplicationTickHook( void )
{
}
#endif

#if configUSE_DAEMON_TASK_STARTUP_HOOK == 1
void vApplicationDaemonTaskStartupHook( void )
{
}
#endif

#ifdef __cplusplus
}
#endif

Ejemplo 2: Heartbeat en la idle hook con tiempo asimétrico

Este ejemplo es un poquito más complejo que el anterior y lo puedes utilizar si no quieres un tiempo simétrico, o si tu aplicación necesita intervalos asimétricos. La diferencia principal está en que utiliza la variable led_state como parte de una pequeña máquina de estados. El resto del código es idéntico al ejemplo anterior.

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

#define PROGRAM_NAME "hooks_2.ino"

#define TIME_ON  pdMS_TO_TICKS( 250 )
#define TIME_OFF pdMS_TO_TICKS( 750 )

#ifdef __cplusplus
extern "C"{
#endif
void heartbeat_idle_hook()
{
   static bool led_state = false;
   static TickType_t last_time = 0;

   TickType_t current;
   TickType_t elapsed_time;

   taskENTER_CRITICAL();
   {
      current = xTaskGetTickCount(); 
      elapsed_time = current - last_time;

      if( led_state == false && elapsed_time >= TIME_OFF ){

         digitalWrite( 13, HIGH );
         led_state = true;
         last_time = current;

      } else if( led_state == true && elapsed_time >= TIME_ON ){

         digitalWrite( 13, LOW );
         led_state = false;
         last_time = current;

      } else{  // siempre debemos completar el if-else if-else:
         ; 
      }
   }
   taskEXIT_CRITICAL();
}
#ifdef __cplusplus
}
#endif

void a_task( void* pvParameters )
{
    while( 1 )
    {
        vTaskDelay( pdMS_TO_TICKS( 497 ) );
        Serial.println( "Ok" );
    }
}

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

   pinMode( 13, OUTPUT );
   // lo vamos a usar en la hook de la idle task

   xTaskCreate( a_task, "TSK1", 128, NULL, tskIDLE_PRIORITY + 1, NULL );
   // establecemos que esta tarea tenga mayor prioridad que la idle task

   vTaskStartScheduler();
}

void loop() {}

Ejemplo 3: Heartbeat en la tick hook con tiempo asimétrico

En este tercer ejemplo vamos a implementar el heartbeat() con tiempos asimétricos utilizando la tick hook. A diferencia de lo que hicimos en la idle hook, donde teníamos que utilizar diferencias de tiempos porque no sabíamos cada cuándo se activaría la tarea, en este caso solamente vamos a «contar» ticks, ya que esta hook se llama 1 vez en cada tick:

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

#define PROGRAM_NAME "hooks_3.ino"

#define TIME_ON  pdMS_TO_TICKS( 100 )
#define TIME_OFF pdMS_TO_TICKS( 900 )

#ifdef __cplusplus
extern "C"{
#endif
void heartbeat_tick_hook()
{
   static bool led_state = false;
   static uint16_t ticks = TIME_OFF;

   taskENTER_CRITICAL();
   {
      --ticks;
      if( ticks == 0 ){

         digitalWrite( 13, led_state );
         led_state = !led_state;

         ticks = led_state == false ? TIME_ON : TIME_OFF;
      }
   }
   taskEXIT_CRITICAL();
}
#ifdef __cplusplus
}
#endif

void a_task( void* pvParameters )
{
    while( 1 )
    {
        vTaskDelay( pdMS_TO_TICKS( 497 ) );
        Serial.println( "Ok" );
    }
}

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

   pinMode( 13, OUTPUT );
   // lo vamos a usar en la hook del tick system


   xTaskCreate( a_task, "TSK1", 128, NULL, tskIDLE_PRIORITY, NULL );
   // no importa la prioridad

   vTaskStartScheduler();
}

void loop() {}

Antes de ejecutar este programa asegúrate que en el archivo hooks.cpp tienes las siguientes líneas de código (no pongo el contenido completo de este archivo para no alargar la lección):

#if configUSE_TICK_HOOK == 1
void vApplicationTickHook( void )
{
   void heartbeat_tick_hook();
   heartbeat_tick_hook();
}
#endif

Ejemplo 4: Supervisor por software basado en la idle hook y el chip 555

Este ejemplo es una implementación del supervisor por software que mencioné anteriormente. Tiene 4 tareas, 3 de ellas críticas (la task_3() no es crítica).

Agrupé en una structura los parámetros necesarios para el supervisor, en lugar de mantenerlos sueltos, y declaré un arreglo para 3 tareas críticas:

struct Watchdog
{
   bool       busy{ false };
   bool       failed{ false };
   TickType_t deadline{ 0 };
   TickType_t last_time{ 0 };

   // podríamos agregar un ID que nos ayude a identificar la tarea que falló
};

// No todas las tareas deben ser supervisadas:
#define MAX_TASKS 3
Watchdog tasks[ MAX_TASKS ];

El código del supervisor es el siguiente. Un ciclo recorre la lista de tareas críticas y en cuanto una de ellas se cuelgue o el deadline no se haya cumplido, sale y ejecuta la función clean_and_reset(); en caso de no encontrar problemas, llama a la función feed_the_dog():

void watchdog_idle_hook()
{
   TickType_t now;
   uint8_t i;

   // las secciones críticas se necesitan cuando un micro de 8 bits debe hacer
   // operaciones o asignaciones de 16 o 32 bits en un ambiente multitarea

   taskENTER_CRITICAL();
   {
      now = xTaskGetTickCount();

      for( i = 0; i < MAX_TASKS; ++i ){

         if( tasks[ i ].failed == true ) break;
         // la tarea perdió el deadline (se tardó más de lo debido)

         TickType_t diff = now - tasks[ i ].last_time;
         if( tasks[ i ].busy == true && diff > tasks[ i ].deadline ) break;
         // la tarea se perdió
      }

      if( i == MAX_TASKS ) feed_the_dog();
      // ninguna tarea se colgó y todos los deadlines se cumplieron

      else clean_and_reset();
      // una o más tareas se colgaron, o uno o más deadlines no se cumplieron
   }
   taskEXIT_CRITICAL();
}

La función para alimentar al supervisor externo es feed_the_dog(), la cual envía un pulso negativo (ALTO-BAJO-ALTO) de 100 microsegundos antes de que el tiempo expire. Escogí el pin D4 de Arduino:

void feed_the_dog()
{
   // alimentamos al perro guardián:

   digitalWrite( 4, LOW );
   delayMicroseconds( 100 );
   digitalWrite( 4, HIGH );
}

Sin mayor problema podrías adaptarla para que alimentes al watchdog interno, si es que decidiste utilizarlo.

La función clean_and_reset() es llamada cuando el sistema falló. Ésta forza un reinicio, pero no sin antes poner al sistema a un estado seguro, y avisar de alguna forma, que algo salió mal:

void clean_and_reset()
{
   // pone el sistema a un estado seguro


   Serial.println( "***" );
   // avisamos con un texto, un LED, un timestamp, etc.


   // obligamos a un watchdog reset:
   vTaskSuspendAll();
   taskDISABLE_INTERRUPTS();
   while( 1 )
      ;
}

El esqueleto de una tarea crítica en este programa es:

void critical_task( void* pvParameters )
{
   tasks[ 0 ].deadline = TIME_LIMIT;
   // el deadline es estático

   // inicializa lo relacionado con la tarea...

   while( 1 )
   {
      tasks[ TASK ].busy = true;
      tasks[ TASK ].last_time = xTaskGetTickCount();


      // --- Empieza el proceso:

      // aquí va el proceso crítico...

      // --- Finalizó el proceso


      TickType_t diff = xTaskGetTickCount() - tasks[ TASK ].last_time;
      if( diff > tasks[ TASK ].deadline ) tasks[ TASK ].failed = true;

      tasks[ TASK ].busy = false;
   }
}

Finalmente, actualiza el código del archivo hooks.cpp para que la idle hook mande llamar al supervisor:

#if configUSE_IDLE_HOOK == 1
void vApplicationIdleHook()
{
   void watchdog_idle_hook();
   watchdog_idle_hook();
}
#endif

(No olvides poner la constante configUSE_IDLE_HOOK a 1 en el archivo FreeRTOSConfig.h)

Ya tenemos todos los elementos para poder ver al ejemplo completo:

  • La task_1 hace parpadear a un LED a una razón que se va incrementendo en cada paso lo que hace que a larga el programa falle.
  • La task_2 imprime su nombre en la consola serial. No falla.
  • Las tareas task_3 y task_4 trabajan de manera coordinada; la primera hace las veces de generadora de eventos, mientras que la segunda los consume. Este par de tareas podrían hacer que el sistema falle.

Por supuesto las tareas las diseñé así para que el programa falle bajo condiciones controladas.

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

#define PROGRAM_NAME "hooks_4.ino"

#define TIME_LIMIT_1 pdMS_TO_TICKS( 500 )
#define TIME_LIMIT_2 pdMS_TO_TICKS( 500 )
#define TIME_LIMIT_3 pdMS_TO_TICKS( 500 )


struct Watchdog
{
   bool       busy{ false };
   bool       failed{ false };
   TickType_t deadline{ 0 };
   TickType_t last_time{ 0 };

   // podríamos agregar un ID que nos ayude a identificar la tarea que falló
};


// No todas las tareas deben ser supervisadas:
#define MAX_TASKS 3
Watchdog tasks[ MAX_TASKS ];


TaskHandle_t consumer_handler = NULL;
// lo necesita una de las tareas (la productora)


#ifdef __cplusplus
extern "C"{
#endif

void feed_the_dog()
{
   // alimentamos al perro guardián:

   digitalWrite( 4, LOW );
   delayMicroseconds( 100 );
   digitalWrite( 4, HIGH );
}

void clean_and_reset()
{
   // pone el sistema a un estado seguro


   Serial.println( "***" );
   // avisamos con un texto, un LED, un timestamp, etc.


   // obligamos a un watchdog reset:
   vTaskSuspendAll();
   taskDISABLE_INTERRUPTS();
   while( 1 )
      ;
}

//----------------------------------------------------------------------
//                     watchdog_idle_hook()
//----------------------------------------------------------------------
void watchdog_idle_hook()
{
   TickType_t now;
   uint8_t i;

   // las secciones críticas se necesitan cuando un micro de 8 bits debe hacer
   // operaciones o asignaciones de 16 o 32 bits en un ambiente multitarea

   taskENTER_CRITICAL();
   {
      now = xTaskGetTickCount();

      for( i = 0; i < MAX_TASKS; ++i ){

         if( tasks[ i ].failed == true ) break;
         // la tarea perdió el deadline (se tardó más de lo debido)


         TickType_t diff = xTaskGetTickCount() - tasks[ i ].last_time;
         if( tasks[ i ].busy == true && diff > tasks[ i ].deadline ) break;
         // la tarea se perdió

      }

      if( i == MAX_TASKS ) feed_the_dog();
      else clean_and_reset();
   }
   taskEXIT_CRITICAL();
}
#ifdef __cplusplus
}
#endif


//----------------------------------------------------------------------
//                          task_1()
//----------------------------------------------------------------------
void task_1( void* pvParameters )
{
   tasks[ 0 ].deadline = TIME_LIMIT_1;
   // el deadline es estático

   pinMode( 2, OUTPUT );
   // un led en D2

   bool led_state = false;

   uint16_t excess_time = pdMS_TO_TICKS( 50 );

   while( 1 )
   {

      tasks[ 0 ].busy = true;
      tasks[ 0 ].last_time = xTaskGetTickCount();


      // --- Empieza el proceso:

      vTaskDelay( excess_time );

      digitalWrite( 2, led_state );
      led_state = !led_state;

      Serial.println( pcTaskGetName( NULL ) );
      // hacemos tiempo calculando el nombre en lugar de usar texto estático

      // --- Finalizó el proceso


      TickType_t diff = xTaskGetTickCount() - tasks[ 0 ].last_time;
      if( diff > tasks[ 0 ].deadline ) tasks[ 0 ].failed = true;

      tasks[ 0 ].busy = false;


      excess_time += pdMS_TO_TICKS( 5 );
   }
}

//----------------------------------------------------------------------
//                          task_2()
//----------------------------------------------------------------------
void task_2( void* pvParameters )
{
   tasks[ 1 ].busy = false;
   tasks[ 1 ].failed = false;
   tasks[ 1 ].deadline = TIME_LIMIT_2;

   while( 1 )
   {
      tasks[ 1 ].busy = true;
      tasks[ 1 ].last_time = xTaskGetTickCount();


      // --- Empieza el proceso:

      vTaskDelay( pdMS_TO_TICKS( 93 ) );

      Serial.println( pcTaskGetName( NULL ) );

      // --- Finalizó el proceso


      TickType_t diff = xTaskGetTickCount() - tasks[ 1 ].last_time;
      if( diff > tasks[ 1 ].deadline ) tasks[ 1 ].failed = true;
      tasks[ 1 ].busy = false;
   }
}

//----------------------------------------------------------------------
//                          task_3()
//----------------------------------------------------------------------
void task_3( void* pvParameters )
{
   configASSERT( consumer_handler );

   // las inicializamos aquí porque la tarea productora tiene mayor prioridad que la consumidora:
   tasks[ 2 ].busy = false;
   tasks[ 2 ].failed = false;
   tasks[ 2 ].deadline = TIME_LIMIT_3;


   TickType_t last_wake_time = xTaskGetTickCount();

   uint16_t excess_time = 0;

   while( 1 )
   {
      vTaskDelayUntil( &last_wake_time, pdMS_TO_TICKS( 111 + excess_time  ) );

      // actualizamos los atributos ANTES de dar el aviso:
      tasks[ 2 ].busy = true;
      tasks[ 2 ].last_time = xTaskGetTickCount();

      digitalWrite( 13, HIGH );

      xTaskNotifyGive( consumer_handler );
      // avisamos

      Serial.println( pcTaskGetName( NULL ) );

      excess_time += 10;
   }
}

//----------------------------------------------------------------------
//                          task_4()
//----------------------------------------------------------------------
void task_4( void* pvParameters )
{
   while( 1 )
   {
      // --- Empieza el proceso:

      // los atributos fueron establecidos en el productor antes de dar el aviso

      ulTaskNotifyTake( pdTRUE, portMAX_DELAY );

      Serial.println( pcTaskGetName( NULL ) );

      digitalWrite( 13, LOW );

      // --- Finalizó el proceso


      TickType_t diff = xTaskGetTickCount() - tasks[ 2 ].last_time;
      if( diff > tasks[ 2 ].deadline ) tasks[ 2 ].failed = true;

      tasks[ 2 ].busy = false;
   }
}

//----------------------------------------------------------------------
//                         setup()
//----------------------------------------------------------------------
void setup()
{
   Serial.begin( 115200 );
   Serial.println( PROGRAM_NAME );

   pinMode( 13, OUTPUT );

   pinMode( 4, OUTPUT );
   digitalWrite( 4, HIGH );

   xTaskCreate( task_1, "TSK1", 128, NULL, tskIDLE_PRIORITY + 0, NULL );
   // requiere supervisión, podría fallar

   xTaskCreate( task_2, "TSK2", 128, NULL, tskIDLE_PRIORITY + 1, NULL );
   // requiere supervisión, pero nunca falla

   xTaskCreate( task_3, "TSK3", 128, NULL, tskIDLE_PRIORITY + 2, NULL );
   // productor (no requiere supervisión)

   xTaskCreate( task_4, "TSK4", 128, NULL, tskIDLE_PRIORITY + 1, &consumer_handler );
   // consumidor (requiere supervisión)


   feed_the_dog();

   vTaskStartScheduler();
}

void loop() {}

¿Qué sigue?

Hoy vimos 3 temas importantes: 2 sobre cómo inyectar código nuestro al sistema operativo, y otro sobre cómo implementar y utilizar un supervisor por software utilizando un watchdog externo.

Tú podrías utilizar ya sea un watchdog externo propiamente dicho, o el interno al ATmega328.

RECUERDA: No utilices funciones bloqueantes dentro de las hooks de FreeRTOS.

RECUERDA: Nunca alimentes al watchdog desde una tarea o interrupción periódica.

RECUERDA: Revisa que el sistema está en perfectas condiciones antes de alimentar al supervisor.

Í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.

Administración de la memoria

No podíamos terminar este curso sin mencionar brevemente la forma en que FreeRTOS administra la memoria. De ésta tenemos dos tipos, la memoria dinámica y la memoria estática, siendo la primera la que más problemas da. La memoria dinámica es memoria RAM que le pedimos al sistema operativo cuando el programa ya está ejecutándose. En programas de computadora esta es una actividad que realizamos todo el tiempo, sin apenas darnos cuenta. Aunque lenguajes como C/C++ nos obligan a devolver manualmente la memoria que hayamos pedido, hay otros más «pesados» (en términos de recursos y tiempo de procesamiento) que devuelven la memoria por nosotros, y además pareciera, en cualquier caso, que la memoria RAM nunca se termina. Y somos felices.

Sin embargo, los sistemas embebidos son arena de otro costal. La memoria RAM está severamente limitada y raras veces podemos darnos el lujo de pedir y devolver memoria en tiempo de ejecución. Y aunque tuviéramos la posibilidad de hacerlo debemos preguntarnos si eso es realmente lo que queremos.

La memoria dinámica tiene varios problemas que se amplifican en nuestros sistemas con recursos limitados:

  • Las llamadas a la función malloc() no son deterministas; esto es, mientras que una llamada puede tomar 300 microsegundos, otra podría tomar 5 milisegundos, y luego otra 1 milisegundo.
  • La función malloc() no es re-entrante. Esto es, la función no está diseñada para ser accesada por dos o más tareas al mismo tiempo en un programa concurrente, con lo cual el sistema podría corromperse. Este problema se podría resolver desactivando las interrupciones o al sistema operativo mientras malloc() se está ejecutando. El problema es que en algunos casos la búsqueda de un bloque de memoria contiguo puede tardar mucho.
  • La memoria se fragmenta. Cuando tu programa inicia toda la memoria es contigua, pero cada vez que la pides y la devuelves se van creando huecos, y podría llegar a ser que tengas, digamos, 100 bloques no contiguos (es decir, con huecos entre uno y otro) de 16 bytes (por dar un número) cada uno. En total tienes 1600 bytes NO contiguos. ¿Qué sucedería si haces una petición de 20 bytes? La llamada a malloc() va a fallar porque en el escenario propuesto no hay ningún bloque contiguo de al menos 20 bytes. «Oye, pero tengo 1600 bytes, ¿cómo es eso posible?». Bueno, la memoria se fragmentó.
  • Fugas de memoria. Las funciones que piden memoria, deben devolverla (en los casos más clásicos. Cuando veamos los diferentes esquemas de FreeRTOS retomaré este punto), ¿y qué pasa si la devuelves? Ese bloque de memoria queda marcado como «en uso», por lo que aunque ya no la estés utilizando, a los ojos del sistema operativo sí lo estás haciendo. La consecuencia es que nadie más, durante la vida del programa, podrá usar esa memoria.
  • FreeRTOS no devuelve la memoria inmediatamente. Uno pensaría que cuando llamas a la función free() la memoria se devuelve al momento y queda lista para volver a ser utilizada. Bueno, esto no es del todo cierto. FreeRTOS devuelve la memoria cuando tiene un poco de tiempo para hacerlo. Esto significa que si la carga de trabajo de tu aplicación es alta y FreeRTOS no se hace de tiempo, entonces esa memoria que devolviste… realmente no ha sido devuelta. Y lo peor de todo es que el escenario no es determinista (otra vez) por lo que repetir el incidente para investigarlo será casi imposible.
  • ¿Cuánta memoria es suficiente?. La memoria dinámica se toma de una región llamada heap, y tú estableces el tamaño (en bytes) de esa región. En los ejemplos que hemos estado viendo el heap está establecido como 1500 bytes, de los 2048 que tiene el ATmega328. ¿1500 es mucho o poco? ¿Porqué no los 2048? ¿Porqué no 1024? ¿Qué sucede con los bytes que no usemos del heap? FreeRTOS proporciona herramientas para determinar si 1500 bytes es mucho o poco, lo cual es tema de esta lección.

Durante mucho tiempo la creación dinámica de objetos fue la única disponible en FreeRTOS, y no fue sino hasta la versión 9.0.0 que se introdujo la creación estática de objetos. Y antes de ella siempre me había preguntado porqué no estaba «de fábrica», ya que yo conocía de los problemas de la memoria dinámica. De hecho, los gurús de los sistemas embebidos advierten sobre su utilización y aconsejan no usarla. Afortunadamente me leyeron la mente e introdujeron el concepto a FreeRTOS.

Ahora que si a pesar de los problemas descritos, quieres seguir utilizando la creación dinámica de objetos, o estás trabajando sobre un proyecto que ya la usa (ya me pasó), o tu aplicación a mano los necesita, sí o sí, entonces lo mejor es que conozcas cómo se comporta y se administra la memoria para que no te lleves (desagradables) sorpresas.

En esta lección vamos a platicar sobre algunos conceptos importantes relacionados con la memoria, y de herramientas de FreeRTOS que nos permitirán determinar los tamaños óptimos para el heap y las pilas de las tareas, para intentar disminuir los problemas que se nos presenten.

Tabla de contenidos

¿Qué es y qué necesito?

Vamos a platicar sobre dos problemas que se nos presentan en el desarrollo de programas con FreeRTOS (y en general, con cualquier sistema operativo):

  • Desboardamiento del heap: un heap demasiado grande equivale a desperdicio de memoria, y uno demasiado pequeño equivale a problemas.
  • Desbordamiento de la pila. Igual que el anterior, pero con la pila de cada tarea.

Y también sobre algunas herramientas que FreeRTOS ofrece para afinar sus respectivos tamaños.

Heap

¿De dónde sale la memoria para la creación dinámica de objetos? La memoria es tomada de un área conocida como el heap (se pronuncia más o menos [jép]. En español es montón). En FreeRTOS es un arreglo cuyo tamaño tú defines en el archivo FreeRTOSConfig.h:

#define configTOTAL_HEAP_SIZE (( UBaseType_t )( 1500 ))

(Para el ATmega328 el tipo UBaseType_t está difinido como unsigned char. Para más detalles revisa el archivo portmacro.h.)

Aunque el ATmega328 tiene 2048 bytes de RAM, no es recomendable usar toda para el heap, ya que cuando declaras variables globales, o usas funciones antes de iniciar al sistema operativo, o usas interrupciones, necesitarás memoria que no es parte del heap.

Como mencioné en la introducción, ¿1500 bytes es mucho o poco? ¿Y si tengo dos tareas nada más, cada una con una pila de 256 bytes, qué sucede con la memoria no utilizada del heap? Hacer a ésta área de memoria muy grande o muy chica tiene sus consecuencias. Si la haces muy grande y no la utilizas toda, entonces estarás desperdiciando valiosa memoria (la RAM no se da en los árboles); pero si la haces muy chica, entonces se terminará antes de lo esperado y no podrás crear más objetos dinámicos mientras el programa se está ejecutando (tiempo de ejecución, le decimos). Los objetos estáticos no tienen esta disyuntiva: si eventualmente se termina la memoria lo sabrás cuando estés compilando el programa (tiempo de compilación) y sabrás qué hacer antes de instalar tu aplicación en la copa de un árbol; en el caso de que se agote la memoria en tiempo de ejecución, entonces tendrás que subir a la copa del árbol a presionar el botón de reset.

En FreeRTOS es posible que una tarea cree a otra tarea (u objetos dinámicos), pero lo más recomendable, en la medida de los posible, es crear TODOS los objetos dinámicos al inicio del programa, y probar que realmente se hayan creado, y no sólo suponerlo. Además, tampoco deberías destruirlos (es decir, devolver su memoria). Este escenario nos hace pensar en utilizar, mejor, objetos estáticos.

En algunas aplicaciones podría ser prohibitivo o inconveniente, desde muchos ángulos, probar el valor valor devuelto por la función xTaskCreate() de todas las tareas que hayas creado. Para estos casos puedes indicarle a FreeRTOS que te avise cuando una llamada a malloc() falle, es decir, cuando malloc() no haya encontrado la memoria contigua requerida.

Primero deberás decirle a FreeRTOS que quieres el aviso. Para ello debes poner a 1 la constante configUSE_MALLOC_FAILED_HOOK en el archivo FreeRTOSConfig.h:

#define configUSE_MALLOC_FAILED_HOOK 1

Luego debes escribir una función callback (también conocidas como hooks) que será llamada por FreeRTOS. Una función callback o hook es una función que tú escribes, pero que el programa (en este caso FreeRTOS) utilizará como si fuera parte de él, sin ser parte de él. También le llamamos inyección de dependencias. Anteriormente en el curso ya hemos usado varias callbacks, así que el término no te debería ser desconocido. La función callback que deberás escribir es la siguiente:

Dentro de ella tú estableces qué hacer en caso de que la memoria del heap se haya agotado, y todo dependerá de la aplicación que estés escribiendo. Las acciones podrían ir desde activar un LED de error, o escribir un mensaje en la consola, o algo más drástico como resetear al sistema. Ten en cuenta que esta función la utilizarías mientras el programa está en desarrollo o pruebas y es para que te des cuenta si el tamaño de heap debe ser incrementado.

En el Ejemplo 1 que veremos más adelante está una posible implementación. Para que este mecanismo funcione asegúrate de crear todos los objetos dinámicos, directa o indirectamente, que tu programa va a necesitar.

Desbordamiento de la pila

Cuando creas tareas debes especificar la cantidad de memoria para su pila. La pila es una estructura de datos dinámica (en el sentido de que su contenido está cambiando continuamente) que almacena las variables declaradas en la tarea, así como los argumentos que envías a funciones y los valores que éstas pudieran devolver. Si tu tarea, o las funciones a las que llama, declaran muchas variables (o unas pocas pero del tipo arreglo), o las llamadas a las funciones son muy profundas, corres el riesgo de que la pila se desborde por tener un tamaño insuficiente. El desbordamiento de la pila es cuando se rebasa el tamaño máximo de la misma, por las razones que mencioné. ¿Cuál es el tamaño ideal para una pila? Es difícil decirlo; esta es una tarea que involucra la experiencia y el sentido común. FreeRTOS propone un tamaño mínimo para la pila para cada procesador. En el caso del ATmega328 es:

#define configMINIMAL_STACK_SIZE ( ( UBaseType_t ) 85 )

Y de ahí para arriba. Pero ¿cuánto es suficiente? Afortunadamente FreeRTOS incluye herramientas que nos permitirán verificar si la pila se ha desbordado, y en caso afirmativo, afinar su tamaño. Debes activar esta herramienta antes de usarla, estableciendo un valor 1 ó 2 en el archivo FreeRTOSConfig.h:

/*
 * 0: Deshabilitada
 * 1: Precisa
 * 2: Más precisa
 */
#define configCHECK_FOR_STACK_OVERFLOW 1

Cuando este mecanismo detecte un desbordamiento de la pila mandará llamar una función hook llamada vApplicationStackOverflowHook():

Los argumentos xTask y pcTaskName contienen cada uno, de manera respectiva, el handler y el nombre de la tarea que hizo que llegaras ahí.

(Nota: Por algún desfase entre el código y la documentación, el tipo del parámetro pcTaskName se muesra como signed char*, sin embargo el compilador lo rechazará; en su lugar usa char* únicamente, como lo he hecho en los ejemplos más adelante.)

En caso de un problema con la pila FreeRTOS buscará la función vApplicationStackOverflowHook() en tu proyecto y llevará a cabo lo que ahí indiques.

¿Qué hay de los valores 1 y 2 de la constante configCHECK_FOR_STACK_OVERFLOW? En realidad son dos métodos para la detección de los desbordamientos.

Método 1

configCHECK_FOR_STACK_OVERFLOW es puesta a 1. FreeRTOS observará, en cada cambio de contexto, si la pila no ha sido desbordada. En caso de desbordamiento llamará a la función hook vApplicationStackOverflowHook().

Método 2

configCHECK_FOR_STACK_OVERFLOW es puesta a 2. FreeRTOS llenará la pila con un valor conocido cuando la tarea sea creada. Posteriormente, en cada cambio de contexto observará los últimos 16 bytes de la pila para ver si cambiaron con respecto al valor inicial. Este método es más lento, pero te avisará antes de un posible desbordamiento. El aviso viene en forma de una llamada a la función hook vApplicationStackOverflowHook(). Esta funcionalidad se presta para ser utilizada con un depurador por hardware.

Ambos métodos introducen más carga de trabajo al sistema, por lo que deberías utilizarlas solamente mientras desarrollas la aplicación, o en la etapa de pruebas. Además, no tiene sentido continuar un programa si su pila se ha desbordado, so pena de muchos problemas.

FreeRTOS indica que no existe una garantía de capturar todos los desbordamientos, y es lógico. Pero tú puedes ayudarlo haciendo que tu programa recorra todos los caminos posibles en la lógica de tu aplicación. Imagina que uno de estos caminos manda llamar a funciones que ocupan mucha memoria, pero asumes que «en la vida real» la condición para tomar ese camino «nunca» se dará. Resulta que como la vida real es caprichosa («Si puede suceder, entonces sucederá«) un día se le ocurrirá tomar ese camino y la pila se desbordará (ya cuando tu aplicación ha sido distribuída y el dispositivo ha sido instalado en la copa de un árbol en medio de la selva). Moraleja:

Si programas un camino crítico en tu programa, entonces haz pruebas que lo recorran.

Antes de terminar esta sección sobre conceptos veamos las diferentes opciones que FreeRTOS ofrece para las funciones malloc()/free().

Implementaciones de malloc

FreeRTOS incluye 5 implementaciones de malloc(), cada una con características y potencias diferentes, dentro de archivos con el nombre heap_x.c (donde la x va desde 1 hasta 5):

  1. heap_1.c. Es la implementación más simple y no permite devolver la memoria (la función free() no está definida). Su uso no es recomendado debido a que la creación estática de objetos la vino a substituir.
  2. heap_2.c. Igual que heap_1.c, pero permite devolver la memoria, sin juntar los fragmentos, cuyo resultado es memoria fragmentada.
  3. heap_3.c. Utiliza las funciones malloc() y free() de tu compilador/plataforma. Por ejemplo, en el caso del ATmega328 esta implementación simplemente manda llamar a dichas funciones que ya vienen incluídas en el compilador avr-gcc. NOTA: El projecto Molcajete utiliza este esquema; pero si tú quisieras usar algún otro, simplemente descárgalo y cópialo en el directorio /src de FreeRTOS. (Por favor lee la nota NOTA IMPORTANTE al final del primer ejemplo.)
  4. heap_4.c. Igual que heap_2.c, pero junta los fragmentos. Por supuesto es más lenta, pero la memoria no se fragmenta. FreeRTOS recomienda utilizar este esquema en lugar de heap_2.c para programas nuevos.
  5. heap_5.c. Igual que heap_4.c, pero permite distribuir el heap entre diferentes regiones de RAM. Algunos microcontroladores modernos y potentes incluyen no una región de RAM, sino varias no contiguas. Este esquema aprovecha todas las regiones, y obviamente es el más complicado de todos.

Recuerda: Solamente un esquema de los 5 mencionados debe estar activo a un mismo tiempo, de lo contrario el programa no compilará (por duplicidad de símbolos).

Función uxTaskGetStackHighWaterMark()

Si quieres saber cuánta memoria queda en la pila de una tarea debes utilizar la función uxTaskGetStackHighWaterMark():

Esta función devuelve el número de elementos aún no utilizados (words o bytes, depende de la definición de UBaseType_t) desde que la tarea indicada por el argumento xTask inició. Puedes usar el valor NULL para referirte a la propia tarea que hizo la llamada. En los ejemplos más adelante veremos su uso.

Configuración

Antes de ver los ejemplos de esta sección debes de saber que la forma de construir (compilar) sketches es un tanto especial cuando integras código de terceros, tal como FreeRTOS, y las cosas no siempre funcionan como uno esperaría. Y este es el caso de las funciones hook que te he platicado.

Idealmente deberías escribir el código de la hook en el mismo sketch de tu aplicación, pero no funciona. Por esta razón creé originalmente un archivo especial hooks.c en el directorio /src de FreeRTOS del proyecto Molcajete. Pero este tiene un problema, ¡es un archivo de lenguaje C, no de C++!. Esto significa que en dicho archivo no podrás usar objetos de C++, como por ejemplo Serial.println(), ya que en C no existen objetos. Pero si utilizaras funciones como digitalWrite() no tendrías inconvenientes. Sin embargo, para efectos de depuración de los programas es mejor contar con una salida de texto.

Por ello deberás realizar los siguientes 3 simples pasos antes de probar los ejemplos de la siguiene sección:

  1. Ubica (pero no lo abras, no es necesario) al archivo hooks.c, el cual está en /tu/ruta/de/instalación/de/molcajete/arduino-1.8.13/libraries/FreeRTOS/src.
  2. Cámbiale el nombre a hooks.c.txt. Este paso es para que no lo borres y para que el compilador no lo considere en el proceso de compilación. Por supuesto que también puedes borrarlo con toda seguridad.
  3. En ese mismo directorio crea un nuevo archivo hooks.cpp con el siguiente contenido (copia y pega para evitar errores de dedo):
#include <Arduino.h>
#include <FreeRTOS.h>
#include <task.h>

#ifdef __cplusplus
extern "C"{
#endif


#if configUSE_MALLOC_FAILED_HOOK == 1
void vApplicationMallocFailedHook()
{
}
#endif

#if configCHECK_FOR_STACK_OVERFLOW > 0
void vApplicationStackOverflowHook( TaskHandle_t xTask, char *pcTaskName )
{
}
#endif

#if configUSE_IDLE_HOOK == 1
void vApplicationIdleHook()
{
}
#endif

#if configUSE_TICK_HOOK == 1
void vApplicationTickHook( void )
{
}
#endif

#ifdef __cplusplus
}
#endif

En este archivo he concentrado todas las hooks que FreeRTOS pudiera llegar a necesitar; para la lección de hoy solamente utilizaremos las primeras dos.

Ejemplos

Los ejemplos a continuación los vas a estar trabajando con tres archivos abiertos: hooks.cpp (para que escribas el código de tus hooks), FreeRTOSConfig.h (para jugar con las constantes), y los archivos de tus sketches.

Ejemplo 1: Terminándonos el heap

(* Nota importante al final del ejemplo.)

En este ejemplo vamos a pedir más memoria de la que establecimos para el heap y veremos si la función vApplicationMallocFailedHook() se manda llamar. Recuerda que estas herramientas son para cuando estás desarrollando o probando tus aplicaciones, no son para código de producción (es decir, el código que ejecuta tu aplicación ya en campo).

Abre el archivo hooks.cpp y modifica la función vApplicationMallocFailedHook() con el siguiente contenido:

void vApplicationMallocFailedHook()
{
   Serial.println( "ERR: Se agotó el heap. El programa se detiene." );

   while( 1 )
      ;
}

El ciclo infinito while( 1 ); es para que el programa no continúe. Si esta función se llega a llamar es porque debes aumentar el heap o afinar el tamaño de la pila de cada tarea. Que el programa continue no tendría sentido.

Es importante que en este tipo de funciones no utilices funciones bloqueantes (xTaskDelay(), temporizadores, xQueueReceive(), etc) dado que tu programa está en una situación crítica; sin embargo sí que podrás utilizar algunas funciones de FreeRTOS que devuelven valores sin bloquearse (es decir, que regresan inmediatamente), o funciones de Arduino que no sean bloqueantes, como digitalWrite(), por ejemplo.

Luego escribe un sketch con el siguiente contenido:

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

// No olvides poner configUSE_MALLOC_FAILED_HOOK a 1 en el archivo FreeRTOSConfig.h

#define PROGRAM_NAME "mem_mang_1.ino"


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()
{
   Serial.begin( 115200 );
   Serial.println( PROGRAM_NAME );


   xTaskCreate( led_task, "LD1", 256, (void*) 13, tskIDLE_PRIORITY, NULL );
   Serial.println( "La tarea 1 se creó ..." );

   xTaskCreate( led_task, "LD2", 256, (void*) 12, tskIDLE_PRIORITY, NULL );
   Serial.println( "La tarea 2 se creó ..." );

   xTaskCreate( led_task, "LD3", 256, (void*) 11, tskIDLE_PRIORITY, NULL );
   Serial.println( "La tarea 3 se creó ..." );

   xTaskCreate( led_task, "LD4", 256, (void*) 10, tskIDLE_PRIORITY, NULL );
   Serial.println( "La tarea 4 se creó ..." );


   vTaskStartScheduler();
}

void loop() {}

Este programa intentará crear 4 tareas (reutilizando el código de una misma, aunque eso es lo de menos en el ejemplo), cada una pidiendo 256 words, es decir, 512 bytes para cada pila. Este ejemplo lo voy a ejecutar utilizando la implementación heap_3.c, y para ésta el valor de la constante configTOTAL_HEAP_SIZE no es tomado en cuenta, por lo que su valor es irrelevante (el porqué usé el esquema heap_3.c lo explico en la nota al final del ejemplo).

#define configTOTAL_HEAP_SIZE (( UBaseType_t )( 1500 )) // Irrelevante para heap_3.c

La salida de este programa es:

En este ejemplo nosotros sabemos dónde y porqué se agotó el heap y porqué el programa se detuvo, dado que ese es el objetivo del ejemplo; sin embargo, en un escenario real donde crees objetos dinámicos de forma dispersa será difícil determinar, con el método Serial.println(), dónde fue el punto exacto donde el programa falló. Para determinarlo deberás utilizar un depurador por hardware, tal como el AVR Dragon (para el chip ATmega328). Microcontroladores más modernos usan depuradores más potentes, o las tarjetas donde vienen montados (las conocidas como tarjetas de desarrollo o de evaluación) integran el soporte para la depuración por hardware.

Parte de mi colección de tarjetas de desarrollo. Puedes ver que algunas integran al depurador por hardware en la misma tarjeta, mientras que en otras conecté depuradores externos (ambos del tipo JTAG). Desafortunadamente es muy complicado depurar por hardware al ATmega328 en Linux.

*NOTA IMPORTANTE: En esta lección hablé sobre creación dinámica de tareas, y en la del día de hoy hablé de las diferentes implementaciones para las funciones malloc() y free(), y durante todo el curso he estado utilizando la versión heap_3.c, la cual como ya dije, utiliza las implementaciones de dichas funciones que vienen con el compilador avr-gcc. ¿Y qué sucede con las otras? NO FUNCIONAN con el micro/compilador ATmega328/avr-gcc. Así de simple, y por lo tanto, si no tienes una razón de peso para no utilizar el esquema heap_3.c, entonces quédate con él, o mejor aún, descarta el uso de objetos dinámicos y usa nada más objetos estáticos.

Lo anterior te lo he comentado por dos cosas:

  1. Para que no te lleves sorpresas desagradables si utilizas un esquema diferente a heap_3.c.
  2. Porque FreeRTOS incluye una función, xPortGetFreeHeapSize(), que te devuelve el número de palabras (que no de bytes) que quedan libres en el heap. Sin embargo, el esquema heap_3.c no es parte de FreeRTOS y por eso la función no es implementada. No obstante, ahí queda la información para cuando uses un microcontrolador y compilador diferente. Dicha función la puedes utilizar para afinar el tamaño del heap en una aplicación dada.

Ejemplo 2: Desbordando la pila

Para este y el siguiente ejemplo fue complicado encontrar ejemplos simples que desbordaran a la pila; lo mejor que encontré fue utilizar funciones recursivas (una función recursiva es aquella que se llama a sí misma de manera controlada). Y aún así no me fue posible ver la llamada a la función hook vApplicationStackOverflowHook() (de alguna manera el código generado esta súperoptimizado). Sin embargo, ya tienes los elementos para supervisar los problemas con la pila.

La función hook, en el archivo hooks.cpp, quedó así:

#if configCHECK_FOR_STACK_OVERFLOW > 0
void vApplicationStackOverflowHook( TaskHandle_t xTask, char *pcTaskName )
{
   Serial.print( "ERR: Desbordamiento de pila para la tarea: " );
   Serial.println( pcTaskName );

   vTaskSuspendAll();
   taskDISABLE_INTERRUPTS();

   while( 1 )
      ;
}
#endif

La función vTaskSuspendAll() suspende al planificador, esto es, ya no habrá cambios de contexto; para restaurar al planificador usa xTaskResumeAll(). Ambas funciones devuelven void y no reciben argumentos. La función vTaskSuspenAll() no detiene las interrupciones, para ello es necesario que llames a taskDISABLE_INTERRUPTS(); para reactivarlas usa taskENABLE_INTERRUPTS(). La documentación oficial menciona que este par de funciones deberían ser substituídas por taskENTER_CRITICAL() y taskEXIT_CRITICAL() (ambas funciones también devuelven void y no reciben argumentos), pero el contexto en que estoy usando me da para usar las funciones que te mencioné (y además internamente son diferentes). Puedes usar vTaskSuspendAll() y taskDISABLE_INTERRUPTS() en el Ejemplo 1.

El programa de prueba es:

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

// No olvides poner configCHECK_FOR_STACK_OVERFLOW a 1 o 2 en el archivo FreeRTOSConfig.h

#define PROGRAM_NAME "mem_mang_2.ino"


void led_task( void* pvParameters )
{
    uint8_t pin = (uint8_t) pvParameters;
    pinMode( pin, OUTPUT );

    while( 1 )
    {
        digitalWrite( pin, HIGH );
        vTaskDelay( pdMS_TO_TICKS( 200 ) );
        digitalWrite( pin, LOW );
        vTaskDelay( pdMS_TO_TICKS( 200 ) );
    }
}

void recursive( int val )
{
   if( val > 0 ){
      Serial.print( "Val = " );
      Serial.println( val );
      recursive( val - 1 );
   } else{
      return;
   }
}

void other_task( void* pvParameters )
{
   Serial.println( "Antes del desbordamiento ..." );

   while( 1 )
   {
      vTaskDelay( pdMS_TO_TICKS( 497 ) );
      recursive( 5000 );
   }
}

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

   xTaskCreate( led_task, "LED", 128, (void*) 13, tskIDLE_PRIORITY, NULL );
   xTaskCreate( other_task, "OTHR", 128, NULL, tskIDLE_PRIORITY, NULL );

   vTaskStartScheduler();
}

void loop() {}

Al igual que en el ejemplo anterior, no olvides que lo que estamos haciendo es para descubrir problemas con la pila ANTES de que instales tu aplicación en la copa de un árbol muy alto en una selva. Una vez que has determinado que no hay problemas con las pilas, entonces deberías desactivar tanto su verificación como la del heap.

En una situación crítica donde la pila se desbordó quizás sea mejor idea mantener el código de vApplicationStackOverflowHook() lo más simple posible. Serial.print() está lejos de ser simple; en su lugar puedes activar un LED (si tu hardware y aplicación lo permiten):

#if configCHECK_FOR_STACK_OVERFLOW > 0
void vApplicationStackOverflowHook( TaskHandle_t xTask, char *pcTaskName )
{
   digitalWrite( 2, HIGH );

   vTaskSuspendAll();
   taskDISABLE_INTERRUPTS();

   while( 1 )
      ;
}
#endif

Para que funcione debes configurarlo en la función setup():

void setup()
{
   pinMode( 2, OUTPUT );
   digitalWrite( 2, LOW );

   // más código
}

Este ejemplo no tiene imagen de la salida de una ejecución porque fue difícil obtener algo

Ejemplo 3: Verificando la memoria disponible en la pila con uxTaskGetStackHighWaterMark()

Mencioné que la función uxTaskGetStackHighWaterMark() devuelve el número de elementos restantes de la pila que no han sido usados. En este ejemplo imprimiremos dichos valores, que para el ATmega328 el valor devuelto estará en bytes.

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

// No olvides poner configCHECK_FOR_STACK_OVERFLOW a 1 o 2 en el archivo FreeRTOSConfig.h

#define PROGRAM_NAME "mem_mang_3.ino"

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

    pinMode( pin, OUTPUT );

    while( 1 )
    {
        digitalWrite( pin, HIGH );
        vTaskDelay( pdMS_TO_TICKS( 200 ) );
        digitalWrite( pin, LOW );
        vTaskDelay( pdMS_TO_TICKS( 200 ) );
    }
}

void recursive( int val )
{
   uint8_t arr[ val ];

   UBaseType_t high_watermark = uxTaskGetStackHighWaterMark( NULL );
   Serial.print( "Memoria restante: " );
   Serial.println( high_watermark );

   if( val > 0 ){
      Serial.print( "Val = " );
      Serial.println( val );

      vTaskDelay( pdMS_TO_TICKS( 50 ) );

      recursive( val - 1 );
   } else{
      return;
   }
}

void other_task( void* pvParameters )
{

   UBaseType_t high_watermark = uxTaskGetStackHighWaterMark( NULL );
   Serial.println( "Memoria antes del desbordamiento ..." );
   Serial.println( high_watermark );

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

      recursive( 30 );

      Serial.print( "MEMORIA RESTANTE: " );
      high_watermark = uxTaskGetStackHighWaterMark( NULL );
      Serial.println( high_watermark );
   }
}

void setup()
{
   // para stackOverflow:
   pinMode( 2, OUTPUT );
   digitalWrite( 2, LOW );

   Serial.begin( 115200 );
   Serial.println( PROGRAM_NAME );

   xTaskCreate( led_task, "LED", 128, (void*) 13, tskIDLE_PRIORITY, NULL );
   xTaskCreate( other_task, "OTHR", 128, NULL, tskIDLE_PRIORITY, NULL );

   vTaskStartScheduler();
}

void loop() {}

La salida de este programa cuando configCHECK_FOR_STACK_OVERFLOW es 1 es la siguiente:

Observa que a pesar de que la cantidad de memoria restante es cero, al menos entró dos veces más a la función recursiva. Y aunque la impresión serial ya no continuó, el programa hizo cosas raras, y muy importante, no llamó a la función vApplicationStackOverflowHook(); el programa pareció haberse perdido. Es natural, ya que el desbordamiento de una pila tiene consecuencias desastrosas.

Veamos la salida de este programa cuando configCHECK_FOR_STACK_OVERFLOW es 2:

Aquí puedes ver que el programa se detuvo antes de que la memoria de la pila llegara 0, y aunque no se ve, sí entró a la función vApplicationStackOverflowHook(). Ya te decía que la función Serial.print() no es exactamente simplona, por lo cual no aparece el mensaje de error; sin embargo, en su lugar sí que el LED asociado a D2 se activó, y a diferencia de la ejecución anterior, el programa efectivamente se detuvo y dejó de hacer cosas raras. La hook que utilicé para este ejemplo es:

#if configCHECK_FOR_STACK_OVERFLOW > 0
void vApplicationStackOverflowHook( TaskHandle_t xTask, char *pcTaskName )
{
   Serial.print( "ERR: Desbordamiento de pila para la tarea: " );
   Serial.println( pcTaskName );

   digitalWrite( 2, HIGH );

   vTaskSuspendAll();
   taskDISABLE_INTERRUPTS();

   while( 1 )
      ;
}
#endif

¿Qué sigue?

Hoy vimos un tema algo delicado, importante y muy técnico relacionado con los sistemas operativos: la administración de la memoria. Vimos conceptos y herramientas que nos permiten identificar mientras desarrollamos o probamos la aplicación posibles desbordamientos del heap o de las pilas. También vimos que de las 5 implementaciones de malloc() que FreeRTOS incluye, debemos usar heap_3.c para el ATmega328.

RECUERDA: Siempre que te sea posible evita el uso de memoria dinámica y prefiere en su lugar la memoria estática.

RECUERDA: Si te vez utilizando la implementación heap_1.c, entonces puedes considerar usar memoria estática.

RECUERDA: Si estás desarrollando para el ATmega328 utiliza la implementación heap_3.c.

RECUERDA: Prefiere la implementación heap_4.c en lugar de la heap_2.c (en plataformas que no tengan problemas con ellas.

RECUERDA: FreeRTOS no devuelve inmediatamente la memoria cuando llamas a free(). FreeRTOS espera a que haya tiempo libre en el sistema para devolverla (a través de la tarea interna Idle_task).

RECUERDA: Cuando utilices configCHECK_FOR_STACK_OVERFLOW para atrapar los desbordamientos de las pilas prefiere el Método 2 (valor 2); ya vimos que es más seguro.

RECUERDA: Las herramientas para atrapar los desbordamientos del heap y de las pilas deberías utilizarlos únicamente mientras estás desarrollando tu aplicación. Puedes auxiliarte con la compilación condicional para eliminarla en código de producción.

Í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.