Hooks útiles de FreeRTOS y un watchdog por software

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

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

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

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

FreeRTOS nos provee dos maneras de hacerlo:

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

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

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

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

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

Tabla de contenidos

¿Qué es y qué necesito?

Callbacks

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

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


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

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

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

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

Hooks

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

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


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

int main()
{
	funcion_del_sistema( 5 );
}

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

Lo que vamos a ver hoy requiere de funciones hook.

Idle task

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

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

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

Idle hook

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

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

#define configUSE_IDLE_HOOK 1

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

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

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

#if configUSE_IDLE_HOOK == 1
void vApplicationIdleHook()
{

}
#endif

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

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

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

// en un sketch o archivo fuente nuestro:

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

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

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

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

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

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

Tick del sistema

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

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

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

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

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

Tick hook

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

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

#define configUSE_TICK_HOOK 1

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

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

}
#endif

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

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

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

// en un sketch o archivo fuente nuestro:

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

Watchdog

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

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

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

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

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

Los gurús de los sistemas embebidos

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

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

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

Deadline

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

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

Implementación del supervisor por software

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

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

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

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


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

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

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

	busy = False
}

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

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

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

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

Implementación del watchdog por hardware

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

Ejemplos

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

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

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

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

#define PROGRAM_NAME "hooks_1.ino"

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

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

   TickType_t current;
   TickType_t elapsed_time;

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

      if( elapsed_time >= TIME_INTERVAL ){

         last_time = current;

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

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

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

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

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

   vTaskStartScheduler();

}

void loop() {}

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

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

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

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

#ifdef __cplusplus
extern "C"{
#endif

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

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

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

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

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

#ifdef __cplusplus
}
#endif

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

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

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

#define PROGRAM_NAME "hooks_2.ino"

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

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

   TickType_t current;
   TickType_t elapsed_time;

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

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

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

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

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

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

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

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

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

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

   vTaskStartScheduler();
}

void loop() {}

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

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

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

#define PROGRAM_NAME "hooks_3.ino"

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

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

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

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

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

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

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

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


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

   vTaskStartScheduler();
}

void loop() {}

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

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

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

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

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

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

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

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

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

void watchdog_idle_hook()
{
   TickType_t now;
   uint8_t i;

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

   taskENTER_CRITICAL();
   {
      now = xTaskGetTickCount();

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

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

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

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

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

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

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

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

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

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

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


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


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

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

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

   // inicializa lo relacionado con la tarea...

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


      // --- Empieza el proceso:

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

      // --- Finalizó el proceso


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

      tasks[ TASK ].busy = false;
   }
}

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

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

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

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

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

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

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

#define PROGRAM_NAME "hooks_4.ino"

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


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

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


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


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


#ifdef __cplusplus
extern "C"{
#endif

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

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

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


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


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

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

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

   taskENTER_CRITICAL();
   {
      now = xTaskGetTickCount();

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

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


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

      }

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


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

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

   bool led_state = false;

   uint16_t excess_time = pdMS_TO_TICKS( 50 );

   while( 1 )
   {

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


      // --- Empieza el proceso:

      vTaskDelay( excess_time );

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

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

      // --- Finalizó el proceso


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

      tasks[ 0 ].busy = false;


      excess_time += pdMS_TO_TICKS( 5 );
   }
}

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

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


      // --- Empieza el proceso:

      vTaskDelay( pdMS_TO_TICKS( 93 ) );

      Serial.println( pcTaskGetName( NULL ) );

      // --- Finalizó el proceso


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

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

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


   TickType_t last_wake_time = xTaskGetTickCount();

   uint16_t excess_time = 0;

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

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

      digitalWrite( 13, HIGH );

      xTaskNotifyGive( consumer_handler );
      // avisamos

      Serial.println( pcTaskGetName( NULL ) );

      excess_time += 10;
   }
}

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

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

      ulTaskNotifyTake( pdTRUE, portMAX_DELAY );

      Serial.println( pcTaskGetName( NULL ) );

      digitalWrite( 13, LOW );

      // --- Finalizó el proceso


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

      tasks[ 2 ].busy = false;
   }
}

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

   pinMode( 13, OUTPUT );

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

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

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

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

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


   feed_the_dog();

   vTaskStartScheduler();
}

void loop() {}

¿Qué sigue?

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

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

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

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

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

Índice del curso

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


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

¿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.)