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.

Cómo hacer cortes externos en tus PCBs con KiCAD

En la antigüedad era muy común que las tarjetas PCB fueran rectas, es decir, rectangulares completamente. Una razón para esto es que era carísimo realizar PCBs con cortes, o diseñar PCBs con formas diferentes a los rectángulos. Aunque no recuerdo la cifra, sí que recuerdo que allá por el 2001 pregunté a una casa fabricante por el costo de una tarjeta con cortes externos y me quedé con la boca abierta.

Hoy día ya nadie pregunta por la geometría de la tarjeta; podemos diseñarlas con cortes externos e internos, y la casa fabricante las fabricará. Así de fácil. (Para geometrías muy complicadas podrían cobrarte un pequeño cargo y no por las herramientas, sino por el tiempo que el fabricante usará para el corte complicado. ¡Time is money!)

Un ejemplo de tarjeta con geometría externa «rara» son las tarjetas Arduino UNO, de las cuales realicé un sub-clón que llamé UB-1S328:

Tarjeta UB-1S328, sub-clon de la Arduino UNO.

Y un ejemplo con ranuras internas es mi tarjeta UB-PLR328A:

Tarjeta UB-PLR328A. Los relevadores requieren ranuras internas.

En esta ocasión te voy a mostrar lo fácil que es crear geometrías externas diferentes a las cuadradas en el programa KiCAD. Las ranuras internas las dejaremos para un futuro próximo.

KiCAD

Éste es un programa de diseño de circuitos impresos; es Open source, gratuito, de grado profesional, y se puede utilizar en Linux, Windows y Mac. Si llegaste a este artículo es porque, lo estoy suponiendo, ya lo conoces y lo utilizas con soltura y quieres saber cómo realizar geometrías algo complicadas con este programa.

Voy a utilizar como ejemplo el desarrollo de la propia tarjeta UB-1S328 que te mostré:

KiCAD y su interfaz minimalista.

La siguiente imagen te muestra la tarjeta terminada en el subprograma Pcbnew (el cual es el encargado del diseño del PCB) y una imagen renderizada de cómo quedaría la tarjeta en la vida real:

La vista 3D es una maravilla, y no solamente por como se ve, sino porque te ayuda a planear bien el tamaño y distribución de componentes, para que al final no te lleves desagradables sorpresas cuando recibas el lote de 100 tarjetas.

Corte externo

Cuando diseñé mis primeros PCBs con el programa Tango, allá por los 2000s, me enseñaron a delinear el contorno utilizando pistas, normalmente sobre la cara dominante (casi siempre la inferior). Y aún hoy día puedes seguir haciéndolo de esa manera, pero debes avisarle al fabricante de esta situación.

KiCAD, como cualquier otro software para PCBs profesionales, incluye una capa que sirve para el propósito que nos ocupa, y se llama Edge.Cuts, que en español viene siendo algo así como cortes en el borde:

En la imagen anterior puedes observar tres cosas (de izquierda a derecha):

  1. (Izq) Un segmento de línea que indica el corte externo sobre el PCB.
  2. (Centro) Dos iconos: uno para realizar líneas (que no pistas), y otro para realizar círculos (y una tercera que no se ve para realizar arcos).
  3. (Der) La flecha azul indica que la capa Edge.Cuts ha sido seleccionada.

Líneas (incluyendo arcos y círculos) y pistas son cosas diferentes en KiCAD. Las pistas únicamente pueden ser utilizadas en capas de cobre (superior, inferior e internas), mientras que para dibujar cualquier otra cosa, incluyendo los cortes externos, utilizamos lo que el programa llama Graphic lines.

Procedimiento

El procedimiento para trazar el borde externo abarca 5 pasos:

Paso 1. Selecciona una cuadrícula (Grid) de acuerdo a tu diseño. No es obligatorio, pero ayuda mucho. Este es un menú pop-up que cuando le das click te muestra una lista de diferentes tamaños, inclusive una personalizada:

También, opcional, podrías mostrar el cuadriculado:

En el menú Preferences->Preferences->Pcbnew->Display options puedes seleccionar, opcional, si el cuadriculado es con puntos, líneas, o cruces (Dots, Lines, Small crosses, respectivamente):

Paso 2. Selecciona la capa Edge.Cuts:

Paso 3. Selecciona la herramienta Graphic lines:

Paso 4: Realiza el trazado del contorno de tu tarjeta. En la siguiente imagen podrás ver el del ejemplo (desactivé algunas capas para que solamente estuviera visible dicho contorno):

Para este ejemplo me auxilié de las dimensiones oficiales para la tarjeta UNO. Y no te preocupes por el ancho de la línea. Esta capa la utiliza la máquina de control numérico CNC para realizar el corte.

TIP: Para borrar una línea coloca el cursor sobre ella y presiona la tecla Supr (o Del), o estando sobre ella da click derecho y luego, en el menú emergente, escoge Delete.

TIP: Aunque tu tarjeta sea rectangular (o cuadrada), es buena idea realizar el contorno con esta técnica. Le simplificarás la vida a tu fabricante (de no hacerlo te enviará un email preguntándote por el contorno).

Paso 5: En este paso le vas a avisar a KiCAD que incluya la capa Edge.Cuts en los archivos de fabricación, los conocidos como archivos Gerber. Si no te acuerdas, estos son los archivos que le entregarás a tu fabricante de tarjetas, y aunque este paso lo puedes realizar hasta el último, es importante que no se te vaya a olvidar.

Para ello en el menú ve a File->Plot. Escoge Gerber en el campo Plot format, y en la sección Included layers asegúrate de marcar la capa Edge.Cuts:

TIP: Si tu tarjeta la vas a realizar con técnicas caseras (plancha, enmicadora, película fotosensible, etc), en lugar de escoger la generación de archivos Gerber puedes incluir la capa Edge.Cuts en archivos PDF imprimibles. Ve al menú File->Print y en la sección Tecnical layers, asegúrate de escoger dicha capa. En la sección Pagination escoge que las capas se impriman de manera individual (One page per layer). Dependiendo de la técnica que vayas a utilizar deberás activar/desactivar la opción Print mirrored (impresión en espejo):

(TIP: En el menú Preferences->Set language puedes escoger el idioma español. En lo personal prefiero el inglés ya que, entre otras cosas, tu fabricante se comunicará en inglés contigo y te costará trabajo encontrar en KiCAD los términos a los que pudiera referirse, ya sea en su página o por correos electrónicos.)

Resumen

Hoy vimos cómo realizar contornos externos para geometrías simples o complejas en nuestros PCBs utilizando al súper-híper-mega-programa KiCAD.

En futuros artículos te mostraré algunas otras técnicas y tips para que le saques el mayor provecho a este software, como por ejemplo, insertar tus propios logos.


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

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

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

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

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

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

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

Tabla de contenidos

¿Qué es y qué necesito?

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

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

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

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

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

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

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

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

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

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

#define INCLUDE_xTaskGetCurrentTaskHandle 1

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

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

Creación dinámica

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

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

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

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

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

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

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


#define MAX_CAP 128
#define TRIGGER_POINT 12

void setup()
{
   // código

   g_stream_buffer = xStreamBufferCreate( MAX_CAP, TRIGGER_POINT );

   configASSERT( g_stream_buffer );
   // Error creando al flujo

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

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

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

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

Creación estática

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

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

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

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

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

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

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

#define MAX_CAP 128
#define TRIGGER_POINT 12

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

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

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

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

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

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

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

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

   vTaskStartScheduler();
}

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

Escribiendo y leyendo al flujo

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

La función para escribir en el flujo es:

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

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

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

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

La función para leer desde el flujo es:

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

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

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

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

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

Ejemplo 1

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

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

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

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

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


StreamBufferHandle_t g_stream_buffer = NULL;

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

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

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


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

   uint8_t index = 0;

   uint8_t data = 0;

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

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

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

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

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

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

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

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

      }
   }
}

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

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

   g_stream_buffer = xStreamBufferCreate( STREAM_ELEMS, STREAM_TRIGGER_POINT );

   configASSERT( g_stream_buffer );
   // Error creando al flujo

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

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

   vTaskStartScheduler();
}

void loop() {}

Una corrida de este ejemplo es:

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

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

Ejemplo 2

En este segundo ejemplo veremos algunas cosas interesantes:

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


StreamBufferHandle_t g_stream_buffer = NULL;

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

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

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

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

   uint8_t data = 0;

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

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

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

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

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

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

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

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

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

            Serial.println( in_buffer[ i ] );

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


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

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

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

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


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

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

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

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

   vTaskStartScheduler();
}

void loop() {}

Una corrida de este programa es:

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

Otras funciones de la API

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

Escritura y lectura desde interrupciones

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

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

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

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

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


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

   static uint8_t data = 0;

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

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

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

Funciones auxiliares

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

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

¿Qué sigue?

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

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

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

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

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

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

Índice del curso

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


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

Software timers

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

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

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

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

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

Tabla de contenidos

¿Qué es y qué necesito?

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

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

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

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

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

Funciones callback

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

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

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

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

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

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

Demonios y daemons

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

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

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

Habilitación de los temporizadores

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

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

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

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

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

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

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

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

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

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

#include <timers.h>

Desarrollo

Creación de los temporizadores

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

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

Temporizadores con memoria estática

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

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

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

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

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

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

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

Ejemplo 1

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

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

enum { TMR_ONE_SHOT = pdFALSE, TMR_AUTO_RELOAD = pdTRUE };

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

void setup()
{
   static StaticTimer_t tmr1_state;

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

   configASSERT( tmr1_h != NULL );       

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

   Serial.begin( 115200 );

   vTaskStartScheduler();
}

void loop() 
{
}

En este ejemplo podemos observar varias cosas:

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

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

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

static StaticTimer_t tmr1_state;

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

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

enum { TMR_ONE_SHOT = pdFALSE, TMR_AUTO_RELOAD = pdTRUE };

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

La salida es como la esperábamos:

Funciones para iniciar, detener y reiniciar

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

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

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

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

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

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

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

Ejemplo: Arrancando y deteniendo temporizadores

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

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

enum { TMR_ONE_SHOT = pdFALSE, TMR_AUTO_RELOAD = pdTRUE };

#define BLINKS 3

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


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

   static bool led_state = false;
   // idem


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

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

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

      led_state = !led_state;
   }
}

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

   digitalWrite( 13, state );

   state = !state;
}

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

   configASSERT( msg_h != NULL );       

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

   configASSERT( msg_h != NULL );       

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

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

   vTaskStartScheduler();
}

void loop() 
{
}

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

Guardando el estado

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

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

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

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

enum { TMR_ONE_SHOT = pdFALSE, TMR_AUTO_RELOAD = pdTRUE };

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

   digitalWrite( 13, state );

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

void setup()
{
   static StaticTimer_t tmr1_state;

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

   configASSERT( tmr1_h != NULL );       

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

   xTimerStart( tmr1_h, 0 );

   
   Serial.begin( 115200 );

   pinMode( 13, OUTPUT );

   vTaskStartScheduler();
}

void loop() 
{
}

Temporizador one-shot

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

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

Ejemplo

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

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

enum { TMR_ONE_SHOT = pdFALSE, TMR_AUTO_RELOAD = pdTRUE };


TimerHandle_t beep_h = NULL;


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

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

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

      if( temp > 25.0 ){

         // aquí apagaríamos al actuador

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

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

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

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

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

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

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

   configASSERT( beep_h != NULL );       

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

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

   vTaskStartScheduler();
}

void loop() 
{
}

Un diagrama de tiempo de este ejemplo es así:

Reiniciando al temporizador

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

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

La firma de esta función es:

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

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

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

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

enum { TMR_ONE_SHOT = pdFALSE, TMR_AUTO_RELOAD = pdTRUE };


TimerHandle_t button_h = NULL;
TimerHandle_t backl_h = NULL;


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

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

   switch( state ) {

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

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

      }
   break;

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

         if( digitalRead( pin ) == LOW ){

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

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

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

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

   default:
      state = 0;
   break;
   }
}

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


#define PUSHBUTTON_PIN 2

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

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

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

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

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


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

   vTaskStartScheduler();
}

void loop() 
{
}

La API de FreeRTOS y las interrupciones

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

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

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

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

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

¿Qué sigue?

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

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

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

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

Índice del curso

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


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

¿Sabías que puedes aumentar la precisión (pero no la exactitud) del sensor LM35?

El sensor de temperatura LM35 entrega 10mV por cada grado centígrado. A 25 grados reporta 250 mV, mientras que a 150 grados reporta 1500 mV. ¿Esto es bueno o malo? Es malo si estás considerando utilizar un convertidor analógico-digital (ADC) de 10 bits como el encontrado en muchos microcontroladores (como el ATmega328 del Arduino UNO).

Por ejemplo, uno de estos convertidores entregaría el valor digital 51 a una temperatura de 25 grados, mientras que a 26 grados entregaría el valor digital 53. ¡En un grado centígrado solamente existen dos pasos!

¿Podemos lograr tener más pasos por grado centígrado? Claro que sí, y sigue leyendo para que te enteres cómo lo puedes lograr, sus pros y sus contras.

¿Porqué aumentar la precisión?

¿Qué implica tener dos pasos por grado centígrado? Pues que la resolución máxima del sistema serían 0.5 grados: 25.0, 25.5, 26.0, 26.5, … . Para muchas aplicaciones tener medio grado centígrado de precisión es suficiente, pero para otras no lo es. Si estás construyendo un controlador PID necesitas más precisión, por ejemplo, 10 o más pasos por grado centígrado: 25.0, 25.1, 25.2, … .

¿Qué necesitas para aumentar la precisión del LM35? Nada más dos cosas: un amplificador operacional en modo No Inversor, y (un poco de) álgebra básica.

Precisión y exactitud no son lo mismo. Aunque muchas veces ambos términos son utilizados de manera indistinta, significan cosas diferentes relacionadas al mismo fenómeno. Con lo que te voy a explicar a continuación podrás aumentar la precisión del sensor hasta un cierto límite físico, pero la exactitud del mismo (0.5 grados) es parte inherente de él y no hay mucho que puedas hacer para mejorarla.

Vamos a aclarar algunos conceptos para que estemos en el mismo canal en esta conversación:

Exactitud

Exactitud significa qué tan cerca está el valor medido del valor real. Entre más cerca, más exacto.

La exactitud del LM35 es de más-menos 0.5 grados centígrados. En el peor de los casos el LM35 reportaría un valor 0.5 grados alejado (hacia arriba o hacia abajo) del valor real de temperatura, y no hay nada que puedas hacer para cambiar este comportamiento ya que es inherente al sensor.

Precisión

Precisión significa qué tan cercanos están unos de otros los valores reportados por el instrumento de medición.

Como mencioné, si tomas la lectura directa del LM35 solamente tendrás dos pasos por cada grado centígrado. Pero aquí, a diferencia de la exactitud y dentro de algunos límites, podrás aumentar la precisión del mismo, por ejemplo, tener 10 (o más) pasos por grado centígrado.

En este punto llegamos a algunas combinaciones que volvían locos a mis alumnos de la materia Programación Avanzada y Métodos Numéricos:

  • Puedes tener un instrumento muy exacto, pero poco preciso.
  • Puedes tener un instrumento muy preciso, pero poco exacto.

Precisión del convertidor ADC

El convertidor ADC de tu microcontrolador también tiene sus propios parámetros de exactitud y precisión. El que nos interesa es el segundo, ¿a cuántos volts equivale cada paso (lectura) del convertidor? Esta pregunta se responde a partir del número de bits del convertidor y la tensión de trabajo del mismo. A mayor número de bits mayor será su precisión.

Como ejemplo toma el ATmega328, el cual tiene un convertidor de 10 bits (2^10 = 1024 pasos) y 5 volts de tensión de trabajo. Por lo tanto, cada paso (lectura) del convertidor equivale a 4.88 mV (4.88mV = 5V / 1024).

Aquí viene lo interesante: el LM35 entrega 10mV por grado centígrado, y el ADC entrega un paso cada 4.88mV, ¡solamente hay 2 pasos por grado centígrado!

¿Cómo puedes aumentar el número de pasos por grado centígrado, es decir, cómo puedes mejorar la precisión del sensor? Muy fácil, con un amplificador operacional en modo No Inversor, álgebra y programación.

Álgebra

Supón que ya tienes al amplificador (hablaremos de él más adelante) y lo configuraste para tener una ganacia de 11. Con esto lograrás tener 110mV por grado centígrado (110mV/C = 10mV/C x 11). De esta manera ahora tendrás 22 pasos por grado centígrado (22 = 110mV/C / 4.88mV). ¡Haz logrado pasar de una precisión de 0.5 grados a una de 0.045!

¡Wow! ¿Y porqué limitarte a una ganancia de 11 cuando puedes tener una de 100 o de 1000? ¡Pues porque hay límites físicos! El aumento en la precisión implica que tienes que reducir el rango de medición. Esto es, cada vez que aumentas la precisión el rango de temperatura a medir se reduce. Con la ganancia de 11 la temperatura máxima que se puede medir (sin pasarse de 5V) es de 47.5 grados centígrados. Nada es gratis en la vida.

Y aunque sería grandioso escoger valores de ganancia arbitrarios enormes, no se puede. La tensión de trabajo del ADC es de 5V y cualquier tensión por arriba puede dañar al módulo o al chip. Imagina que tienes una ganancia de 100, lo cual equivale a 204 pasos por grado centígrado (204 = (10mV/C)x(100) / (4.88mV/C)). ¿Cuál es la temperatura máxima que podrás medir? R: 5 grados. Esto es, tu rango de temperatura medible, sin pasarte de 5V, es de sólo 5 grados:

  1. Si G=100, (10mV/C)x(100) = 1000mV/C.
  2. (1000mV/C) / (4.88mV) = 204 pasos por grado centígrado.
  3. (1024 pasos) / (204 pasos/C) = 5 grados centígrados.

Por lo visto no es buena idea escoger una ganancia tan grande y de forma tan arbitraria, ¿cierto? En la siguiente sección calcularás la ganancia máxima para una temperatura máxima dada.

Ejercicio: Haz los cálculos que demuestren que la ganancia máxima que contemple el rango completo del LM35 (150 grados) es 3.33.

Cálculos prácticos

Para determinar la ganancia máxima para una temperatura máxima aplica los siguientes pasos:

  1. Escoge la temperatura máxima que deseas medir. Por ejemplo, T = 50 grados centígrados. Esto significa que cuando el ADC reporte el valor 1023 (el máximo), entonces tendrás 50 grados.
  2. Calcula cuántos pasos habrá por cada grado: p = (1024 pasos) / (50 grados) = 20.48 pasos por grado centígrado.
  3. Calcula a qué tensión corresponde el resultado anterior, v = ( 20.48 pasos/C) x (4.88mV/paso) = 99.94mV por grado centígrado.
  4. Calcula la ganancia máxima para tu valor de temperatura, G = (99.94mV/C) / (10mV/C) = 9.99.

Por lo tanto, la ganancia G máxima para una temperatura máxima de 50 grados centígrados es de 9.99. Cualquier valor de temperatura por arriba de 50 entregará tensiones por encima de 5V, lo cual podría dañar al módulo ADC o al chip.

Amplificador

¿Y cómo implementas la ganancia recién calculada? Pues a través de un circuito amplificador. Éste lo puedes construir con diversas tecnologías, pero lo más simple y efectivo es que utilices un amplificador operacional (opamp) configurado como amplificador no inversor. Para este ejemplo usaremos un LM358, el cual integra dos opamps en un encapsulado de 8 terminales, pero cualquier otro funciona.

A partir de la ecuación del amplificador no inversor, G = 1 + R2/R1, debes escoger una R y calcular la otra. Por ejemplo, si escoges que R2 sea de 100K (100 Kilo Ohmios), entonces tienes que despejar R1 de la ecuación anterior, la cual te queda: R1 = R2 / (G-1), y substituyendo los valores conocidos del ejemplo anterior tenemos que R1 = (100K) / (9.99-1) = 11.12K, cuyo valor comercial más cercano es 11K.

El diagrama eléctrico para una ganancia de 11 es el siguiente:

Diagrama eléctrico correspondiente a la sección Ejemplo del apartado Programación. El seguidor de tensión desacopla al LM35, y es prescindible. Lo usé porque me sobraba un opamp, y podrías conectar la terminal 2 del LM35 de forma directa a la 5 del LM358. Los valores de R1 y R2 del amplificador no inversor son los que resulten de tus necesidades y cálculos.

Ejercicio: Calcula la temperatura máxima que puedes medir a partir del valor de R1 recién encontrado (11 Kilo Ohmios), es decir, ajusta los cálculos a partir de los valores reales.

Programación

El último paso es escribir un programa que lea la temperatura analógica del LM35 y haga algo con ella, por ejemplo, imprimirla (en el puerto serial o en un LCD). Para esto vas a necesitar el valor de la temperatura máxima (teórica o ajustada según los valores de resistencias comerciales). Siguiendo con el ejemplo: T = (50 grados x lectura_del_ADC) / (1024 pasos) y el resultado queda en grados centígrados:

float temperature = ( 50.0 / 1024 ) * analogRead( A0 );

Ejemplo

El siguiente código (para Arduino en formato de sketch) utiliza los siguientes valores: R1=10K, R2=100K, G=11, Tmax=45.45 grados centígrados. Agregué un filtrado de datos, pero eso no te debe distraer del objetivo del ejemplo:

void setup()
{
  pinMode( 13, OUTPUT );
  Serial.begin( 115200 );
}

void loop()
{
   uint16_t rawReading = 0;
   for( uint8_t i = 8; i > 0; --i ){
      rawReading += analogRead( A0 );
   }
   
   float temp_in_centi = ( 45.45 / 1024.0 ) * ( rawReading >> 3 );

   Serial.println( temp_in_centi, 3 );
   delay( 500 );
}

La instrucción

rawReading >> 3

toma el acumulado rawReading y lo divide entre 8 (el número de lecturas). Una expresión equivalente es: rawReading / 8.

Resultados

El programa anterior arroja los siguientes resultados, tanto en formato numérico como en gráfica.

La curva es muy plana, o dicho de otra forma, los valores son muy estables debido a 3 cosas: la temperatura ambiente fue constante, la precisión que hemos añadido con la técnica explicada, y el filtro promediador de 8 muestras. En la tabla puedes observar dos valores separados por un paso: 22.681 y 22.636. Si calculas la diferencia entre ellos obtendrás 0.045 grados centígrados por paso, lo cual es consistente con los cálculos para este ejemplo: 0.045=1 / (22.52 pasos por grado centígrado).

Resumen

En este artículo viste la manera de aumentar la precisión del sensor de temperatura LM35 amplificando su señal, las diferencias entre exactitud y precisión, y que nada en la vida es gratis: si aumentas la precisión disminuyes la temperatura máxima a medir.

¿Qué sigue?

¿Y qué si deseamos reducir el rango, por ejemplo de 30 a 40 grados centígrados? La lectura 0 del ADC correspondería a 30, mientras que la lectura 1023 correspondería a 40.

Así mismo, en este artículo consideré la versión LM35DZ, la cual tiene un rango de 0 a 150 grados centígrados. Las otras versiones del mismo manejan rangos diferentes. Un trabajo futuro consistiría en modificar el circuito eléctrico para que incluya un desplazador de tensión. Del ejemplo del párrafo anterior deberíamos de alguna manera restarle 300 mV a la señal del LM35 para que con ello comencemos a contar a partir de 30 grados centígrados. Pero esto es tema de un siguiente artículo.

¡Suscríbete si esta entrada te gustó o si aprendiste algo nuevo!

¡Construyamos un PLC de grado industrial basado en Arduino! 2da parte

Un controlador lógico programable (PLC por sus siglas en inglés) es dispositivo electrónico inteligente que se utiliza mucho en la automatización de procesos industriales. En esta oportunidad vamos a platicar al diseño al que llegué.

Marcas y modelos comerciales de PLCs se cuentan por decenas, pero todos tienen dos desventajas:

  • Son caros.
  • Su programación también es muy cara.

Es cierto que son robustos y están muy bien diseñados, pero a los costos de producción también hay que agregar los gastos de mantenimiento de grandes corporaciones. Por otro lado, en muchos casos el software de programación debe ser adquirido aparte, y por si eso no fuera poco, el cable de programación puede ser tan costoso como el PLC mismo.

Y qué decir del costo de programación. Los PLCs se pueden programar hasta en 5 lenguajes, siendo todos ellos especializados, y el conocido como «lenguaje de escalera (ladder)» el más común. Aunque la programación de PLCs es relativamente fácil, los programadores cobran mucho ya que es un mercado pequeño (a comparación del de programación de aplicaciones para computadora o teléfonos inteligentes). Ahora tome en cuenta que además de pagar los (altos) honorarios del programador, también en algunas ocasiones, deberá cubrir gastos de transporte y viáticos, porque al ser pocos, tendría Ud mucha suerte de encontrar uno en (o cerca de) su comunidad.

¿Qué oportunidades tienen los micro y pequeños empresarios que no pueden cubrir los altos costos que implica utilizar PLCs comerciales por las razones antes mencionadas?

¿Existirá una opción económica, de código abierto, que no dependa de los caprichos de una empresa ni de un puñado de programadores altamente especializados?


¿Porqué desarrollar un PLC de grado industrial en Arduino?

Porque podemos, porque es divertido, porque es una alternativa real a los productos comerciales, porque es más económico, y porque se puede programar en el lenguaje C/C++ con software de código abierto, como el de Arduino. Además, el número de programadores en esta plataforma se cuenta por miles.

Hay una cantidad de aplicaciones que no justifican los costos de un PLC comercial (y otra cantidad de aplicaciones que sí lo hacen). A continuación le presento una lista demostrativa, más no limitativa, de aplicaciones donde PLCs comerciales quedan sobrados:

  • Arrancadores e inversores de giro de motores eléctricos.
  • Refrigeradores industriales.
  • Lavadoras industriales.
  • Sistemas de riego agrícola.
  • Pequeños invernaderos.
  • Puertas de garage.
  • Plumas de estacionamientos.
  • Semáforos viales.
  • Taladros.
  • Iluminación residencial.
  • Mezcladoras.
  • Sistemas Cisterna-Tinaco.
  • Y un enorme et cétera.

El hardware de un PLC comercial, del tipo brick (de módulo único, o de los llamados micro-PLCs), no está alejado del hardware que diseñamos en nuestro trabajo cotidiano. Si Ud tuviera oportunidad de ver uno por dentro encontraría muchas similitudes con el hardware que diseñamos en el día a día.

¡Pongamos manos a la obra y empecemos con nuestro controlador industrial!

Diseño

Un controlador industrial consiste de 5 partes, principalmente:

  1. Entradas.
  2. Salidas.
  3. Cerebro.
  4. Fuente de alimentación
  5. Sistema operativo.

Entradas

Nuestro controlador tiene 6 entradas digitales ópticamente aisladas, con alimentación independiente, y 2 entradas analógicas. En ambos casos podemos jugar con los valores de algunos componentes para que un mismo circuito impreso nos sirva para

  • Entradas digitales de 5VDC, o 12 VDC, o 24VDC.
  • Entradas analógicas de 5VDC o 10VDC.

El optoacoplador PC817 requiere de una corriente mínima de 1.5 mA para funcionar en su zona segura. Eso nos permite calcular las resistencias en serie de manera que cambiando su valor podamos conseguir que funcione para uno u otro voltaje de entrada. Con el valor de 2K2 las entradas funcionan perfectamente para 5VDC y 12VDC. Para 24VDC su valor debería ser doblado a 3K9 o 4K7. El diodo zener es de 5V1 y está ahí para no estresar al foto-diodo.

A diferencia de otros diseños, los LEDs testigos los coloqué del lado de 5VDC. Con esto logramos que no se le exija más corriente de la necesaria a los sensores digitales y aumentamos su vida útil. El capacitor de 10nF funciona como un minifiltro y evitamos escribir rutinas anti-ruido por software.

Las entradas analógicas no están aisladas. Utilicé un divisor de tensión para reducir los 10VDC de los sensores analógicos comerciales a los 5VDC máximos que maneja el microcontrolador. Si el sensor entrega 5VDC, entonces podemos colocar una resistencia pequeña, de unos 33R (en R4 o R5), y dejamos el otro lado del divisor sin resistencia (abierto). Así mismo, en esta misma configuración, podríamos hacer que las entradas sirvan para un lazo de corriente de 4-20mA calculando solamente el valor adecuado para R4 o R5 (o ambos).

¡Ser dueños del diseño nos da un mundo de posibilidades!

¿Porqué entonces algunos fabricantes han conectado las entradas a través de resistencias en lugar de opto-acopladores? Existe una razón muy importante para hacerlo así. Si Ud lo sabe, póngalo en los comentarios; si no lo sabe y quiere saberlo, escriba su duda en los comentarios y cuando el número de éstos llegue a 25 dentro de los próximos 10 años, entonces les daré la respuesta =)

(Esta promoción termina en julio de 2030.)

Salidas

Nuestro controlador tiene 4 salidas a relevador y 2 salidas a transistor. Las salidas a relevador están galvánicamente aisladas y sirven para controlar directamente cargas de hasta 10A, tanto en corriente directa como alterna. Las salidas a transistor, que no están aisladas, se utilizan para controlar cargas ligeras (como contactores o LEDs), o cargas que requieran tiempos de conmutación muy rápidos, tal como PWM (modulación por ancho de pulso), o para llevar a cabo control de temperatura a través de un relevador de estado sólido (SSR).

La tensión de bobina de los relevadores puede ser de 5VDC, 12VDC, o 24VDC. Agregué un puente para que la tensión que llega a las bobinas sea la misma de la alimentación de la tarjeta (12VDC o 24VDC), o se tome de la salida de 5VDC del regulador 7805. Con esto último, y una correcta selección de las resistencias para las entradas digitales, ¡logramos un controlador que funcione a 12VDC o 24VDC!

La potencia para las bobinas de los relevadores, así como las salidas a transistor, se toman de un ULN2003. Los LEDs testigos de las salidas están conectados a la entrada de este circuito integrado, y no a su salida. Poner los LEDs del lado de los relevadores los estresaría mucho, tanto por la tensión de alimentación (por arriba de 5VDC) como por la tensión inversa creada al quitar la alimentación de las bobinas (aunque utilicemos el diodo free wheeling).

Además, los relevadores, cuya tensión y corriente nominales son 127VAC y 10A, deben estar aislados de la baja tensión y sus pistas deben muy anchas, y en la medida de lo posible, exponiendo el estaño. Es por eso que cada salida a relevador de nuestro controlador incluye una ranura de seguridad y tiene pistas gruesas y con el estaño expuesto:

Para terminar esta sección quiero comentar que Ud encontrará diseños en Internet que tienen opto-acopladores entre la salida del ULN2003 y los relevadores. Es mi obligación aclarar que si no utiliza una fuente separada para las salidas, entronces tanto aislamiento no sirve para nada. El doble aislamiento sirve como mercadotecnia, a menos que incluya una fuente de alimentación separada.

La única forma para que los opto-acopladores a la salida sirvan a su propósito es que las terminales comunes (COMx) sean diferentes. Cuando todas las salidas del controlador son a transistor, entonces el opto-acoplador es obligatorio; no así cuando se trata de relevadores.

Cerebro

Aquí no hay mucho que adivinar. El cerebro de nuestro controlador es el microcontrolador de 8 bits ATmega328 de Atmel (adquirida por Microchip). Tiene 32KB de memoria de programa y 2 KB de memoria para variables. Para un controlador de 8 entradas y 8 salidas programado en C/C++, ¡32KB es muchísima memoria de programa!

Este procesador es el mismo utilizado en las tarjetas Arduino UNO, se consigue con mucha facilidad, y su precio es decente. A mi parecer es el microcontrolador con más soporte en todo el mundo gracias a la plataforma Arduino.

Tarjeta minimalista (sistema mínimo) tipo Arduino UNO. Se utiliza para instalaciones definitivas ya que el usuario puede usar los conectores que mejor se adapten a su proyecto.

8 bits parecen poco para un controlador industrial, ¿cierto? FALSO. El procesador del PLC Zelio, cuya foto le mostré hace un momento, es el ATmega128, de la misma familia (mismo núcleo) que el ATmega328, pero con más memoria y más terminales. Así que por el momento no se preocupe por utilizar 8 bits.

Sin embargo, si sus procesos requiriesen una gran cantidad de cálculos numéricos, o necesita una velocidad de procesamiento más alta, entonces lo mejor sería migrar a un procesador de 32 bits con núcleo ARM Cortex, como el LPC1227, pero para los procesos industriales mencionados hace un momento, el ATmega328 es más que suficiente.

Fuente de alimentación

Finalmente llegamos al cuarto elemento de nuestro controlador, la fuente de alimentación.

Para diseñarla tuve dos opciones: una fuente lineal, o una fuente conmutada. La segunda es más eficiente, pero más complicada. La idea de nuestro controlador es que podamos construirlo con componentes thru-hole y que además sean de fácil consecución. Las fuentes conmutadas requieren de inductancias que no son fáciles de conseguir si no es a través de distribuidores internacionales como mouser.com. Yo compro seguido con ellos, y lo más probable es que ustedes no lo hagan. Esta fue la principal razón por la cual me decidí por una fuente lineal basada en el circuito integrado 7805.

La protección contra inversiones de polaridad en la alimentación es más complicado que un simple diodo. Éste nos daría muchos problemas si decidimos que los relevadores sean de 5VDC alimentados por el 7805.

Y hablando del 7805, en específico de la disipación de calor, Ud notará que no existe un disipador de calor. Más o menos. Cuando el 7805 sólo se dedica a alimentar la parte del cerebro y los relevadores son alimentados por la tensión de entrada (12VDC o 24 VDC), entonces el disipador no es necesario. Sin embargo, si los relevadores son alimentados por el 7805, ¡sí que necesitaremos un disipador!

Entre el cuerpo del 7805 y el borde de la tarjeta dejé una distancia de 2mm lo cual nos permitirá colocar una lámina de aluminio de ese grosor en caso de que el chip se sobre-caliente (cosa que sí sucederá en el escenario descrito).

Reloj de tiempo real

Existe un quinto elemento del cual vale la pena hablar debido al grado de flexibilidad que introduce en el diseño: el reloj de tiempo real (RTC por sus siglas en inglés).

Un RTC es un dispositivo que maneja y mantiene la hora/calendario. Los más simples sólo llevan la hora, por ejemplo el MCP7940, la cual se pierde si se le quita la alimentación; mientras que otros más sofisticados, como el MCP79410, incluyen soporte para batería de respaldo (más otras monerías: RAM, EEPROM, alarmas, pin multi función), con lo cual la hora/calendario se mantiene aún con cortes largos de la alimentación.

Nuestro controlador incluye un RTC: el que nosotros deseemos instalarle. Así es, afortunadamente el tamaño del chip y la distribución de terminales es estándar entre fabricantes y grado de sofisticación, por lo que podemos soldar un MCP7940, o un MCP79410, o un M41T11. Todos estos RTCs se comunican vía el protocolo serial IIC (ó I2C, ó Wire, como también se le conoce).

En la imagen anterior podemos observar varias cosas acerca del RTC:

  • Utilicé al MCP79410 porque tiene soporte de batería, unos cuantos bytes de RAM, y dos alarmas.
  • Usa un cristal de 32768 Hz. Aquí se puede notar una cama de estaño que conecta el encapsulado metálico con el plano de tierra de la tarjeta.
  • Los capacitores C5 y C6 no están instalados porque al momento de construir el prototipo no los tenía a la mano (15/julio/2020, en México estamos en medio de la pandemia del Covid-19 y no podemos salir de nuestras casas, así que no puedo ir a comprarlos), pero por fortuna, el reloj funciona y permite realizar su programación.
  • El puente MFP-DIR conecta una terminal del ATmega328 hacia la salida MFP (Multi-function pin) del RTC o hacia la salida RS485-DIR. Dado que ya no tenía pines disponibles en el chip principal, tuve que agregar el puente y que el usuario decidiera si quiere las alarmas del RTC o utilizar el canal de comunicación RS485.

Ventajas del RTC

En cualquier caso no sólo se trata de controlar la hora/calendario, sino tomar ventaja de ello. Con el RTC podremos programar eventos diarios o semanales basados en la hora del día, o el día de la semana. ¡Con un RTC se nos abre otro mundo de posibilidades!

¿No podríamos llevar la hora/calendario por software y ahorrarnos el RTC? Llevar la hora del día por software es muy fácil; llevar el calendario es una pesadilla.

Controlador industrial UB-PLR328A

Pues la hora de la verdad ha llegado y les presento a nuestro controlador industrial, casi terminado de ensamblar:

Controlador industrial UB-PLR328A.

Sus dimensiones son de 10x10cm, doble cara, componentes trhu-hole en su mayoría.

Si Ud quiere ver el proceso en el tiempo del desarrollo de este proyecto, lo invito a ver algunos videos que creé con ese propósito:

¿Qué sigue?

Me gustaría hablarles del sistema operativo del PLC (desde el punto de vista de Ingeniería de Software), de su programación (desde el punto de vista de programación de PLCs), y de la versión Open source de una variante de este diseño para que ustedes manden fabricar sus propios circuitos impresos. Pero cada uno de estos sub-temas requieren su propia entrada del blog (y eventualmente, un video). Si les interesa que escriba o haga videos sobre esos temas, háganmelo saber en los comentarios. ¡Ah! y no olviden suscribirse a este blog.


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.

¡Construyamos un PLC de grado industrial basado en Arduino!

¿En serio, un PLC basado en Arduino? ¿Te volviste loco?
¿Debemos ser mentalmente inestables para considerar la idea de utilizar la plataforma Arduino para realizar un PLC de grado industrial? ¿Es imposible? ¿Alguien nos lo prohíbe?
¡Podemos hacerlo mejor que los chinos!

Hola, soy Fco. Javier (fjrg76), Ingeniero en Electrónica con 20+ años de experiencia en electrónica y sistemas embebidos, y en esta serie de artículos vamos a descubrir si podemos realizar un PLC basado en el chip ATmega328. Nuestro producto final será un PLC diseñado y ensamblado por nosotros que programaremos en C/C++, en sketches o en la línea de comandos.

¿Qué tenemos que hacer?

  • Lo primero es analizar a los PLCs comunes y corrientes, tener claro qué son y cómo están fabricados. Esto lo veremos en este artículo.
  • En un próximo artículo estableceremos las características de nuestro PLC.

Antes de responder a todas las preguntas que planteamos hace un momento veamos (o recordemos) brevemente lo que es un PLC (Programable Logic Controller, Controlador Lógico Programable).

¿Qué es un PLC?

Un PLC es una computadora de propósito específico utilizada para la automatización industrial que debe soportar los ambientes extremos de las industrias.

Un PLC es una computadora de propósito específico

Si es una computadora de propósito específico, entonces en un sistema embebido, como los que nos gustan hacer. Esto es, un PLC no es una computadora de propósito general, como las computadoras a las que estamos acostumbrados. Muchos PLCs ni siquiera tienen una pantalla.

Micro PLC.

Ambientes y condiciones extremos

Un PLC debe soportar condiciones de temperatura y humedad extremas que se generan en las industrias. Así mismo, debe soportar las variaciones de tensión y ruido electromagnético producido por motores eléctricos (recuerda que los motores eléctricos mueven al mundo).

Compactos y expandibles

Es muy deseable que los PLCs sean compactos, consuman poca energía y se puedan expandir. Esto último significa que deben incrementar su funcionalidad (más entradas, más salidas) de la manera más simple y barata posible. Esto se consigue con módulos esclavo conectados al módulo central a través de protocolos seriales: RS-232, RS-485, Ethernet, así como con protocolos propietarios.

Fáciles de programar

Existen diferentes lenguajes de programación para los PLCs, siendo el lenguaje de escalera (ladder diagram) el más común entre los programadores. Cada fabricante tiene su propio software de programación.

La programación de nuestro PLC será en C/C++, desde un sketch o desde la línea de comandos (mi preferida).

¿De qué está hecho un PLC?

Cerebro

El cerebro de un micro PLC moderno es un microcontrolador. El tamaño de éste dependerá del conjunto de funcionalidades que tenga el PLC. Algunos de estos microcontroladores son comerciales (de fabricantes como Atmel (hoy Microchip), ST, NXP, Hitachi), mientras que otros son propietarios. Sus rangos de velocidades van de los 72 MHz hasta los 180 MHz.

Microcontrolador de 32 bits LPC1227 Cortex-M0 de grado industrial. Foto tomada de una tarjeta de desarrollo que yo diseñé.

Algunos PLCs son simples y sólo se encargan de leer las entradas, procesar el programa, escribir a las salidas, y realizar comunicaciones seriales básicas; mientras que otros son mucho más complejos: en un mismo chip integran controladores de pantallas LCD, Ethernet, CAN, convertidores digitales-analógicos (DACs), el reloj RTC, memoria externa FLASH/RAM, etc.

Desde hace un poco más de una decada la empresa ARM introdujo al mercado (de la mano de muchos fabricantes) procesadores RISC de 32 bits muy económicos. Esta línea de chips es conocida como Cortex-Mx (donde la x puede ser 0, 0+, 3, 4, …). Los PLCs chinos utilizan como base al procesador STM32F1xx por las razones mencionadas y por la alta integración de periféricos en un mismo chip. (Como dato interesante, un procesador Cortex-M0 puede costar menos de la mitad que un ATmega328.)

Es conveniente saber que algunos PLCs que requieren velocidades de ejecución extremadamente rápidas optan por usar FPGAs, que son una especie de dispositivos cuya programación es sobre compuertas lógicas y bloques lógicos pre-construídos sobre las compuertas lógicas.

Entradas

Las entradas de un PLC son de dos tipos: digitales y analógicas. Y no podemos conectar un dispositivo directamente a las entradas ya que en el ambiente industrial tales dispositivos suelen funcionar con 24V de corriente directa, o con tensiones alternas de 120/240 V, mientras que la tensión máxima que soporta un microcontrolador es de máximo 5V (ya sea que éste trabaje a 5V o 3.3V).

Si a una terminal del microcontrolador se le aplica una tensión por arriba de la máxima soportada, en el mejor de los casos ese pin quedará inservible; en el peor, el chip completo estará frito.

La etapa de entrada que baja la tensión (de digamos 24V) a 5V o 3.3V también proteje las terminales en casos de cortos-circuitos, inversiones de polaridad, o de picos de tensión. Usualmente están construídas en base a optoacopladores, aunque existen modelos de PLCs que en lugar de éstos tienen nada más un divisor resistivo a la entrada. PLCs más sofisticados utilizan transformadores que aislan galvánicamente las terminales del chip de los dispositivos externos.

¿Tiene alguna ventaja la entrada basada en resistencias?

Sí. La misma entrada puede ser utilizada de manera digital o analógica. Es por eso que PLCs similares al mostrado incluyen muchas entradas analógicas (compartidas con las digitales), ya que es relativamente fácil configurarlas como digitales o analógicas.

Un segundo tipo de entrada digital es para tensión alterna, la cual puede ir desde 24 VAC hasta 240 VAC.

En cuanto a las entradas analógicas se presenta el mismo problema que las entradas digitales, sólo que en este caso no se pueden utilizar optoacopladores como interfaz. Aquí existen varias opciones:

  1. Conectar el dispositivo analógico externo (que normalmente opera en el rango 0-10V) al microcontrolador a través de un divisor de tensión que reduzca el rango a 0-5V ó 0-3.3V.
  2. Utilizar un convertidor analógico-digital (ADC) externo que soporte tensiones mayores y que transmita el resultado al microcontrolador en forma serial o paralela.

Salidas

Así como las entradas sólo pueden trabajar con baja tensión, las salidas tienen, además, restricciones de potencia, esto es, solamente pueden entregar una cantidad muy limitada de corriente, en el mejor de los casos, 20 mA, siendo el caso general del órden de los 3 – 5 mA, apenas lo suficiente para activar un LED. Pero los PLCs deben manejar cargas de muchas órdenes de magnitud superior a eso, tanto en tensión como en corriente.

Por eso a la salida de los PLCs encontramos, generalmente, relés o transistores, actuando ambos como «amplificadores» (en el sentido más amplio del término) de corriente. La salida a relé provee un aislamiento galvánico entre la terminal del microcontrolador y la carga. La salida a transistor provee potencia, pero no aislamiento, a menos que se incluya una pre-etapa basada en optoacopladores.

Los fabricantes de PLCs incluyen, en la misma línea de producto, versiones a relé y versiones a transistor. Algunos también agregan al catálogo salidas a TRIAC o SCR.

Tenía una duda personal sobre cuál salida es la mejor. La respuesta a la que llegué es: ambas y ninguna. Es decir, cada tipo de salida obedece a diferentes tipos de aplicaciones, y el diseñador del sistema debe escoger la más adecuada. Veamos brevemente las ventajas y desventajas de cada una.

Salida a relé

Ventajas:

  • Provee aislamiento galvánico.
  • Puede manejar tensiones directas y alternas.
  • Pueden manejar tensiones y corrientes muy altas.

Desventajas:

  • Se desgastan con el tiempo. Entre más se usen, más se gastan.
  • El cierre de los contactos puede producir interferencia EMI (Electro-Magnetic-Interference) que podría afectar no sólo a equipo cercano, sino al mismo PLC.
  • Son lentos. No se pueden usar en dispositivos de alta velocidad, o de control por PWM (Pulse-Width Modulation).

Salida a transistor

Ventajas:

  • Son muy rápidos, con pulsos en el orden de los KHz. Se pueden usar para dispositivos de alta velocidad, o de control por PWM, por ejemplo para controlar un SSRs (Solid-State Relay) o un motores servo (o servo-motores).
  • Si se utilizan dentro de sus parámetros seguros de tensión y corriente su duración es de muchos años antes de que fallen.
  • Es más fácil controlar la EMI.
  • Son ligeros y compactos.

Desventajas:

  • Por sí mismas no proveen aislamiento galvánico entre las terminales del microcontrolador y el mundo externo por lo que se debe agregar una etapa optoacoplada entre el procesados y los transistores.
  • La tensión y corriente que soportan son mucho menores que la de un relé.
  • Funcionan sólo para tensión directa. Si se quiere controlar cargas de corriente alterna, entonces se tiene que utilizar un SSR. Como mencioné, algunos modelos de PLC cuentan con salida a TRIAC o SCR, siendo estos el componente principal de los SSRs.

Fuente de alimentación

Por último, pero no menos importante, está la fuente de alimentación que provee de energía tanto al PLC como a los sensores/actuadores conectados a él.

Los PLCs llamados compactos casi siempre incluyen la fuente de alimentación dentro de su propio gabinete y convierten ya sean 120VAC/220VAC o 24VDC a la tensión del procesador, que como ya dije, puede ser de 3.3V o 5V. Regularmente la fuente de alimentación es del tipo SMPS (Switch-Mode Power Supply) por su alta eficiencia en la conversión.

Cuando las entradas del PLC son para tensión alterna, entonces se alimenta al mismo directamente con la tensión de línea, ya sea de 120VAC o 220VAC. Cuando las entradas del PLC son de 24VDC, entonces se le alimenta con una tensión de 24VDC proveniente de una fuente externa.

Últimamente es común encontrarse con PLCs que trabajan a 12VDC, y algunos que trabajan a 5VDC. Sin embargo, 24VDC es un estándar de la industria.

Entonces, ¿podemos construir un PLC basado en la plataforma Arduino, sí o no?

La respuesta es un rotundo SÍ acompañado de un rotundo SIEMPRE Y CUANDO LO DISEÑEMOS BIEN.

Más arriba mencioné que algunos PLCs conocidos utilizan microcontroladores comerciales (de esos que podemos comprar en una tienda de electrónica o pedir a mouser.com o digi-key.com, aunque algunos podrían estar descontinuados). Vamos a ver una pequeña lista:

PLCChipTecnología del chipFabricante del chip
Leganza 88DDT8P89C6688051Philips (NXP)
Mitsubishi FX-20MR-ESHD64332588 bitsHitachi
WeconSTM32F10332 bitsST
Scheneider ZelioATmega1288 bitsAtmel (Microchip)

Scheneider Zelio

Pongamos atención al último de la lista porque es muy interesante. Y para tener un contexto de la pequeña lista de características que voy a mencionar, quizás querrías echarle una mirada a este siguiente enlace, donde se muestra al PLC desensamblado.

Esta imagen representa el interior de un PLC marca Zelio, y podemos observar, además del procesador, al reloj de tiempo real, los relevadores y la batería de mantenimiento del calendario (la que parece rebanada amarilla).
  • Usa al chip ATmega128, una versión «ampliada» del ATmega328 (corazón de la Arduino UNO).
  • Las entradas no están optoaisladas.
  • Usa un RTC (Real-Time Clock) comercial M41T56 de ST acompañado de una pila.
  • Usa transistores discretos para energizar a las bobinas de los relés.
  • Parece que la fuente de alimentación se reduce a un simple 7805 o un LM317.
  • Tiene pistas en ángulo recto (los expertos no lo recomiendan).
  • Muchos PLCs tienen una cubierta (coated paint ) que lo protege contra la humedad y la corrosión. A simple vista éste no la tiene.

(Para conocer más acerca de este PLC puedes dar click aquí que te llevará a su página oficial en español.)

DISCLAIMER: Hagamos lo que hagamos, en ningún momento y bajo ninguna circunstancia será una copia o clon del PLC mostrado. Lo usé como ejemplo de que es posible construir equipo profesional a partir de microcontroladores comerciales.

¿Qué sigue?

Hoy hemos visto de forma muy general lo que son los PLCs, las partes de las que están hechos, y muy importante, el cerebro del mismo. También vimos que no hay nada que nos impida construir nuestro PLC de grado profesional, solamente hay que diseñarlo bien.

Y precisamente eso será lo que veamos en el siguiente artículo: el diseño de un PLC basado en Arduino, y siendo más específicos, un PLC basado en el microcontrolador ATmega328 y programado en el lenguaje más hermoso del mundo, C++.

Microcontrolador de 8 bits ATmega328 montado en una tarjeta tipo Arduino UNO. Este chip será el cerebro de nuestro PLC.

Nos vemos en el siguiente artículo (click aquí) donde tomaremos decisiones (algunas muy difíciles) de diseño para construir nuestro PLC.

Si consideras que hoy has aprendido algo nuevo, y que en el futuro podrás seguir aprendiendo cosas nuevas o útiles para tu trabajo o escuela, entonces podrías suscribirte a mi blog.


Por favor déjame tus comentarios, qué piensas de este proyecto, si ya has hecho PLCs en el pasado y qué micro usaste.

¿Sabías que puedes tener un system tick en Arduino con una base de tiempo de 1ms?

Sigue leyendo para que veas cómo lo puedes implementar tú mismo.

Tabla de contenidos

¿Qué es un system tick?

Un system tick es una interrupción que el procesador genera a intervalos de tiempo regulares.

¿Para qué queremos un system tick?

Un system tick nos sirve para infinidad de situaciones:

  • Llevar la hora para un reloj.
  • Leer y decodificar un teclado.
  • Hacer que el led backlight de nuestro LCD prenda y apague a intervalos de tiempo regulares.
  • Hacer que el búzer suene por un intervalo de tiempo predeterminado y se apague al finalizar éste.
  • Tener un heart beat. Éste es un led que parpadea en intervalos de 0.5 segundos y me hace saber que el sistema está funcionando. Recuerda que depurar programas en Arduino es muy complicado.

¿Qué beneficios obtenemos con un system tick?

  • Simplifica los procesos asíncronos.
  • Tenemos una base de tiempo estable.
  • Nos permite tener código más profesional.

¿Arduino incluye un soporte nativo para el system tick?

No. Nosotros debemos modificar el código fuente de Arduino para tener un system tick (lo cual, debo admitir, es increíblemente divertido).

¿La función yield() serviría para implementar un system tick?

Sí…​ pero no. Esta función de biblioteca es llamada por delay(), y surgen muchos problemas:

  • Debemos llamar a delay() incluso cuando no hay necesidad de hacerlo.
  • La función yield() se invoca cada 7 microsegundos. Raro pero cierto.
  • El tiempo se va recorriendo y, por lo tanto, no podemos obtener una señal regular.

Implementación de un system tick con una base de tiempo de 1ms en Arduino

En primer lugar vamos a ver cómo podemos implementar este mecanismo en Arduino, y después veremos un pequeño ejemplo.

Implementación

Debemos modificar 3 archivos de Arduino. Pero no se preocupen, son unas cuantas líneas y no tenemos que recompilar la plataforma.

Arduino genera una interrupción por reloj cada milisegundo, pero no está a la vista del cliente (nosotros). Lo que vamos a hacer incluir código que llame a una función callback nuestra en cada interrupción.

Callback
Una callback es una función nuestra, no de Arduino, que le vamos a inyectar al sistema.

Las referencias a los archivos son con respecto a la instalación de Arduino en Linux, y para esta entrada del blog, utilicé la versión 1.8.5. No deberían existir problemas con versiones más recientes.

/ruta/de/instalación/arduino-1.8.5/hardware/arduino/avr/cores/arduino

hooks.c

Al final de este archivo debemos escribir un place holder para nuestra callback. La podemos nombrar como queramos, pero usaré el nombre usrTickHook():

/**
 * User tick hook.
 *
 * This function is called every system tick directly from the ISR0 interrupt.
 * The user can through this function implement any strictly timed code.
 */
static void __empty2() { 
	// Empty 
}
void usrTickHook( void ) __attribute__ (( weak, alias( "__empty2" )));

Arduino.h

En este archivo debemos declarar a nuestra callback usrTickHook() casi al principio del mismo.

void yield(void);

void usrTickHook();

#define HIGH 0x1
#define LOW  0x0

#define INPUT 0x0

Debemos insertar la declaración de nuestra función usrTickHook() después de la declaración de la función yield() y antes de la declaración de las macros HIGH y LOW. El lugar no es crítico, de hecho puede ser en cualquier lugar válido de este archivo, pero vamos a hacerlo así.

wiring.c

Para que nuestra callback usrTickHook() sea llamada cada milisegundo debemos insertarla en la interrupción del timer de Arduino (TIM0_OVF ó TIMER0_OVF), al final de la ISR. Ésta nos debe quedar así:

#if defined(__AVR_ATtiny24__) || defined(__AVR_ATtiny44__) || defined(__AVR_ATtiny84__)
ISR(TIM0_OVF_vect)
#else
ISR(TIMER0_OVF_vect)
#endif
{
	// copy these to local variables so they can be stored in registers
	// (volatile variables must be read from memory on every access)
	unsigned long m = timer0_millis;
	unsigned char f = timer0_fract;

	m += MILLIS_INC;
	f += FRACT_INC;
	if (f >= FRACT_MAX) {
		f -= FRACT_MAX;
		m += 1;
	}

	timer0_fract = f;
	timer0_millis = m;
	timer0_overflow_count++;

	usrTickHook();
}

Si son curiosos en este archivo podrán ver cómo se implementan las funciones delay(), micros(), delayMicroseconds() e init(); todas cruciales para la plataforma Arduino.

Prueba

Eso es todo en cuanto a la plataforma Arduino. Toca probar que hicimos las cosas bien. Abrimos Arduino, preparamos y subimos el siguiente sketch:

// esta es la función que se llama cada 1 ms. Hagamos que el led on-board
// parpadee cada 500 ms
void usrTickHook()
{
	static uint16_t ticks = 500;
	static bool state = false;

	--ticks;
	if( ticks == 0 ){
		ticks = 500;
		state = state == false ? true : false;
		digitalWrite( 13, state );
	}
}

void setup()
{
	pinMode( 13, OUTPUT );
}

void loop()
{
	// nada!
}

Si todo fue bien el led on-board debería parpadear cada medio segundo:

IMPORTANTE: En la esquina superior izquierda del osciloscopio podrán observar que el periodo de nuestro tick (multiplicado por dos) es 976.58 ms, un poquitín alejado de 1000 ms. Esto no es un problema de software, sino de hardware, ¿porqué? Muy fácil, Arduino NO puede generar una señal exacta de 1000 ms debido al cristal y a los prescalers internos. Aún así, esta señal constante nos puede servir para multitud de aplicaciones, y como ya lo comenté, simplifica la programación como no tienen una idea. ¡Ah!, y muy importante, nuestra señal no se recorre como lo hace la función de biblioteca delay().

Ejemplo

Este ejemplo muestra un búzer que se activa por el tiempo que determine la llamada y, sin intervención del usuario, se apaga.

volatile uint16_t g_buzzer_ticks;
// :debe ser global porque es una variable compartida.
// :deber ser volatile porque será modificada dentro de una ISR.


// esta es la función que se llama cada 1 ms.
void usrTickHook()
{
   if( g_buzzer_ticks > 0 ){
      --g_buzzer_ticks;
      if( g_buzzer_ticks == 0 ){
         digitalWrite( 2, LOW );
      }
   }
}

/**
 * @brief Activa el búzer por un determinado tiempo.
  * @param ticks El tiempo en milisegundos que estará activo el búzer.
  * @post El búzer se desactivará "por sí solo" una vez que el tiempo termine.
 */
void buzzer( uint16_t ticks )
{
	digitalWrite( 2, HIGH );
	g_buzzer_ticks = ticks;
}

void setup()
{
	pinMode( 2, OUTPUT );
	// conectamos un búzer a D2
}

void loop()
{
	buzzer( 100 );
	delay ( 500 );
}

Palabras finales

Es todo por esta vez. Si tienen preguntas o comentarios, por favor háganmelos llegar. Y si quieren ver algún otro tema avanzado de Arduino no dejen de avisarme.

(La versión original y en inglés de esta entrada la pueden consultar en mi blog alternativo.)