Semáforos monitores y el ADC de Arduino
(Read this article in english here.)
Diseñé esta tarjeta, la UB-C328 basada en el ATMEGA328. De los 6 canales del convertidor analógico-digital expone 5, y el que no está expuesto lo usé para implementar un teclado basado en una escalera resistiva. Hasta ahí todo bien.
El problema surgió cuando comencé a escribir aplicaciones concurrentes para la UB-C328, es decir, aplicaciones multi-tarea a través del framework KleOS, el cual incluye a Arduino y al sistema operativo FreeRTOS. El teclado, o más bien dicho, el canal del ADC asignado al teclado, se lee de manera continua cada 1ms; mientras que el resto de canales puede ser leído en cualquier momento. Y ese es el problema: en los sistemas concurrentes hay que tener mucho cuidado de quién, cómo y cuándo se accesa a un recurso compartido (el ADC en este caso).
En una aplicación donde utilicé la UB-C328 para leer la temperatura externa (a través de, por supuesto, un canal del ADC) empecé a tener lecturas erróneas, sin sentido; siendo que el mismo código funcionaba muy bien en otro programa (no concurrente). Después de hacer pruebas y analizar el programa concurrente entendí que el problema era que mientras que en la tarea donde el ADC realizaba la conversión en el sensor de temperatura, la tarea encargada del teclado interrumpía la conversión, echándola a perder. El datasheet del ATMEGA328 lo indica muy claro: la conversión no se debe interrumpir.
Este problema, condición de carrera (o en inglés, race condition), es típico de los programas concurrentes. Para resolverlo utilicé un mecanismo llamado monitor.
Monitores
Un monitor es un tipo abstracto (o en términos más coloquiales, una clase en C++) que protege a un recurso compartido encapsulando a un semáforo tipo mutex y exponiendo las únicas operaciones que se podrán realizar sobre el recurso.
Recurso compartido
Un recurso compartido puede ser ya sea un área de memoria o un periférico, que pueden ser accesados potencialmente por muchas tareas al mismo tiempo con resultados catastróficos (como los que te platiqué en la introducción). En mi caso el recurso compartido es el ADC, pero también podría ser una variable global, un arreglo, la UART, la I2C (o TWI, para los usuarios de Atmel), etc.
Semáforo mutex
Un semáforo tipo mutex (de aquí en adelante simplemente mutex) es un semáforo binario con dos características sobresalientes:
- El semáforo se obtiene y se devuelve.
- La prioridad de la tarea que obtiene al mutex se puede elevar temporalmente para que dicha tarea no sea interrumpida mientras lo tiene y así termine su trabajo tan pronto como pueda. Una vez que la tarea ha terminado su prioridad vuelve a su valor normal. Cuando una tarea obtiene a un recurso compartido, pero no lo devuelve, y por lo tanto ninguna otra tarea puede utilizar al recurso, entonces se tiene el problema de inanición (harvesting, en inglés). La elevación de la prioridad intenta evitar este problema.
Aunque usar un mutex ya nos ayuda a controlar el acceso al recurso compartido, aún no terminamos. El monitor debe exponer un conjunto mínimo de operaciones sobre el recurso; esto es, el monitor limita el uso (y mal uso) del mismo.
En esta lección de mi curso gratuito de Arduino en tiempo real explico los semáforos mutex en FreeRTOS y también muestro los resultados de accesar fuera de control al recurso compartido impresión serial (es decir, Serial.println()
).
NOTA: Para mostrarte el uso de los monitores en este artículo voy a simplificar el programa para que nos concentremos en los detalles que nos interesan y no desviarnos con cosas de la tarjeta UB-C328 que no vienen al caso.
- Utilizaré dos tareas con periodos relativos. Cada tarea leerá del ADC un canal diferente (aquí no es importante el teclado basado en escalera resistiva).
- Utilizaré tareas construídas de manera dinámica. Lo mejor es utilizar la creación estática de tareas (puedes dar click aquí para saber más), pero complicaría un poco el código; y una vez más, lo importante en este momento son los monitores.
- Utilizaré a la plataforma Arduino a través del framework KleOS.
Monitor en C++
Como mencioné, el monitor se puede implementar a través de una clase de C++:
#include <FreeRTOS.h> #include <task.h> #include <semphr.h> class AdcMonitor { private: SemaphoreHandle_t mutex{ NULL }; // 1 public: AdcMonitor() { this->mutex = xSemaphoreCreateMutex(); // 2 } int16_t read( uint8_t channel, uint16_t timeout ) { int16_t ret_val = -1; if( xSemaphoreTake( this->mutex, timeout ) != pdFALSE ) // 3 { ret_val = analogRead( channel ); // 4 xSemaphoreGive( this->mutex ); //5 } return ret_val; } };
- La clase encapsula a un semáforo mutex.
- El constructor de la clase crea al mutex (también puede ser creado de manera estática, pero no es el tema de este artículo).
- Aquí se intenta obtener al mutex. De no lograrlo dentro del tiempo indicado por
timeout
, entonces la función termina indicando que no pudo ser obtenido (valor centinela-1
). - Aquí se usa al recurso compartido. La tarea que lo usará no debería bloquearse o utilizar tiempo de más.
- El mutex se devuelve para que otras tareas puedan accesar al recurso.
NOTA: Los mutex’es deben estar habilitados en el archivo de configuración FreeRTOSConfig.h: #define configUSE_MUTEXES 1
.
NOTA: Usé la propiedad del timeout (tiempo de espera) de los semáforos; pero también existe la posibilidad de esperar por siempre (poco recomendado). Si quisieras utilizar esta característica, entonces establece como timeout el valor portMAX_DELAY
y en el archivo de configuración FreeRTOSConfig.h asegúrate que: #define INCLUDE_vTaskSuspend 1
.
Declarando instancias del monitor
El siguiente paso es que las tareas accesen al recurso compartido a través del monitor, ¿cómo es que cada tarea lo accesará? Tenemos dos opciones:
- El monitor se crea de manera global. Fácil, pero lo pensaría dos veces (las variables globales son malas). Si hacemos esto cualquier tarea no relacionada con el ADC podría accesar al mismo (por la naturaleza global del monitor). Y por si eso fuera poco, una tarea no relacionada podría obtenerlo y nunca regresarlo.
- El monitor se crea de manera local y se le pasa a cada tarea que lo necesite. De esta manera solamente las tareas que tengan relación con el recurso compartido tendrán acceso al monitor. La técnica no es difícil, pero tampoco es muy clara si no estás familiarizado con los apuntadores void, (
void*
) y el paso de parámetros a las tareas de FreeRTOS; sin embargo, en este ejemplo de mi curso gratuito te lo muestro si estás intersado.
Una vez más, para concentrarnos en lo importante utilizaremos la forma 1; pero en código de producción deberías utilizar la forma 2.
AdcMonitor adc_monitor; // instancia global del monitor
Tareas
Como ya lo mencioné, tendremos dos tareas compitiendo por el ADC:
- Una de ellas tendrá prioridad más alta que la otra (para obligar a que sea ejecutada y evitar que el ADC sea accesado de manera secuencial).
- Ambas tendrán el mismo periodo para obligarlas, una vez más, a competir por el ADC.
- No es relevante para el ejemplo, pero una tarea leerá la temperatura de un sensor LM35, mientras que la otra hará lo mismo pero con el sensor MCP9700. Lo importante es que ambas utilizan al mismo recurso compartido.
xTaskCreate( task_lm35, "LM35", 128, NULL, tskIDLE_PRIORITY + 1, NULL ); xTaskCreate( task_mcp9700, "9700", 128, NULL, tskIDLE_PRIORITY, NULL );
NOTA: Por simplicidad, y para seguir enfocados en el uso e implementación del monitor, creé tanto a las tareas como al mutex de forma dinámica; sin embargo, en código de producción deberías preferir la forma estática, xTaskCreateStatic()
y xSemaphoreCreateMutexStatic()
, respectivamente.
task_lm35
Esta tarea lee un canal del ADC a través del monitor y convierte la lectura a grados centígrados:
#define LM35_ADC_CHANNEL 0 #define LM35_TIMEOUT 50 #define LM35_PERIOD 500 void task_lm35( void* pvParameters ) { (void) pvParameters; TickType_t last_wake_time = xTaskGetTickCount(); while( 1 ) { vTaskDelayUntil( &last_wake_time, pdMS_TO_TICKS( LM35_PERIOD ) ); int16_t ret_val = adc_monitor.read( LM35_ADC_CHANNEL, pdMS_TO_TICKS( LM35_TIMEOUT ) ); // 1 Serial.print( "LM35: " ); if( ret_val >= 0 ) // 2 { float temp_lm35 = ( ret_val * 5.0 * 100.0 ) / 1024.0; Serial.println( temp_lm35 ); } else // 3 { Serial.println( "TIMEOUT" ); } } }
- Observa la forma de realizar la lectura del ADC; la hacemos a través de la instancia
adc_monitor
de la claseAdcMonitor
en lugar de hacerlo directo con la función de ArduinoanalogRead()
, la cual de hecho, está encapsulada dentro del monitor. Así de fácil (y seguro) es. - Si el ADC pudo ser obtenido antes de que venciera el tiempo de espera (timeout), entonces usamos la lectura. En este caso la convertimos a grados centígrados.
- En caso contrario, avisamos que no obtuvimos al recurso antes de que el timeout expirara y tomamos las medidas pertinentes. Utilizar al timeout permite que el sistema no se cuelgue. La rutina de recuperación deberá ser escrita de acuerdo a las necesidades de la aplicación.
La otra tarea, task_mcp9700
es casi idéntica, así que no la pondré aquí, pero la podrás ver más adelante en el ejemplo completo. Aparte de que usa otro canal del ADC, la fórmula de conversión a grados centígrados también es diferente, pero eso no es relevante para el ejemplo.
Si quieres saber más sobre la fórmula de conversión dale click aquí, que te llevará a mi blog alternativo; y si quieres saber cómo puedes aumentar la precisión de este sensor, entonces dale click aquí.
Notarás que en ambas tareas utilicé a la función vTaskDelayUntil()
en lugar de vTaskDelay()
. Hice esto con la finalidad de que ambas tareas pidieran al ADC (casi) al mismo tiempo. vTaskDelay()
introduce corrimientos en el tiempo que evitarían que ambas tareas compitieran por el ADC (lo cual sería lo ideal, pero no para este ejemplo).
Si quieres saber más sobre las tareas periódicas en FreeRTOS da click aquí y aquí.
Ejemplo
A continuación está el ejemplo completo. Nota que agregué una tarea heart_beat
que hace parpadear a un LED con el fin de saber que el sistema está corriendo. No es relevante, pero sí es muy útil.
#include <stdint.h> #include <FreeRTOS.h> #include <task.h> #include <semphr.h> class AdcMonitor { private: SemaphoreHandle_t mutex{ NULL }; public: AdcMonitor() { this->mutex = xSemaphoreCreateMutex(); } int16_t read( uint8_t channel, uint16_t timeout ) { int16_t ret_val = -1; if( xSemaphoreTake( this->mutex, timeout ) != pdFALSE ) { ret_val = analogRead( channel ); xSemaphoreGive( this->mutex ); } return ret_val; } }; //---------------------------------------------------------------------- // Monitor instance: //---------------------------------------------------------------------- AdcMonitor adc_monitor; #define LM35_ADC_CHANNEL 0 #define LM35_TIMEOUT 50 #define LM35_PERIOD 500 void task_lm35( void* pvParameters ) { (void) pvParameters; TickType_t last_wake_time = xTaskGetTickCount(); while( 1 ) { vTaskDelayUntil( &last_wake_time, pdMS_TO_TICKS( LM35_PERIOD ) ); int16_t ret_val = adc_monitor.read( LM35_ADC_CHANNEL, pdMS_TO_TICKS( LM35_TIMEOUT ) ); Serial.print( "LM35: " ); if( ret_val >= 0 ) { float temp_lm35 = ( ret_val * 5.0 * 100.0 ) / 1024.0; Serial.println( temp_lm35 ); } else { Serial.println( "TIMEOUT" ); } } } #define MCP9700_ADC_CHANNEL 1 #define MCP9700_TIMEOUT 50 #define MCP9700_PERIOD 500 void task_mcp9700( void* pvParameters ) { (void) pvParameters; TickType_t last_wake_time = xTaskGetTickCount(); while( 1 ) { vTaskDelayUntil( &last_wake_time, pdMS_TO_TICKS( MCP9700_PERIOD ) ); int16_t ret_val = adc_monitor.read( MCP9700_ADC_CHANNEL, pdMS_TO_TICKS( MCP9700_TIMEOUT ) ); Serial.print( "9700: " ); if( ret_val >= 0 ) { float temp_9700 = ( 500.0*ret_val - 51200.0 ) / 1024.0; Serial.println( temp_9700 ); } else { Serial.println( "TIMEOUT" ); } } } #define PERIOD_HIGH 20 #define PERIOD_LOW 980 void heart_beat( void* pvParameters ) { (void) pvParameters; pinMode( 13, OUTPUT ); bool led = false; while( 1 ) { vTaskDelay( pdMS_TO_TICKS( !led ? PERIOD_LOW : PERIOD_HIGH ) ); digitalWrite( 13, ( led = !led ) ); } } void setup() { xTaskCreate( task_lm35, "LM35", 128, NULL, tskIDLE_PRIORITY + 1, NULL ); xTaskCreate( task_mcp9700, "9700", 128, NULL, tskIDLE_PRIORITY, NULL ); xTaskCreate( heart_beat, "HBT", 128, NULL, tskIDLE_PRIORITY, NULL ); Serial.begin( 115200 ); Serial.println( "\nmonitor_ADC.ino\n" ); vTaskStartScheduler(); } void loop() { // nothing in here when using a kernel }
Una salida de este programa es así:
Más allá de las diferencias en temperatura, que no son relevantes para este artículo, nota que no hay ningún resultado incorrecto ni con cifras extrañas.
En esta lección, a la cual ya te había referido, las diferencias entre usar y no usar al monitor son notables; te dejo aquí una salida del programa de prueba que usé allá sin monitor:
cuando la salida correcta (o esperada) es así (aquí ya usé al monitor):
NOTA CURIOSA: Quizás ya observaste que utilicé al canal serial Serial.print()
en ambas tareas. Este módulo también es un recurso compartido, y para evitar el problema de la imagen de la salida sin monitor, mantuve los mensajes lo más cortos posible, ya que el tema de hoy era con el ADC.
Otros recursos compartidos
Aquí te he presentado dos recursos compartidos para un mismo sistema:
- El ADC.
- El puerto de comunicaciones seriales.
Sin embargo, en el mismo sistema también existen otros recursos compartidos, que en programas concurrentes más complejos, también deberían utilizar a los monitores:
La interfaz I2C. En este otro proyecto, UB-PLR328 (un controlador lógico programable (PLC) basado en Arduino y el ATMEGA328), utilizo dicha interfaz para accesar a 3 elementos diferentes:
- El reloj de tiempo real MCP79410.
- Dos expansores PCF8574: uno para escribir a una pantalla LCD de 16×2, y otro para leer de un teclado.
Como podrás adivinar, los 3 elementos no tienen relación temporal, y de no usar un sistema de control, los resultados serán desastrozos. Aquí puedes ver un video donde los 3 elementos están trabajando al mismo tiempo.
La interfaz SPI. Presenta el mismo problema que la I2C: muchos dispositivos pueden estar conectados al mismo bus; por lo tanto, un monitor debería controlar el acceso.
En general, cualquier interfaz a la cual se le puedan «colgar» diferentes dispositivos a su bus (RS-422/485, CANbus, LIN, etc) necesitarán de un sistema de control de acceso si el programa está realizado de manera concurrente.
Para terminar, quizás también quieras ver un tema relacionado con los monitores, el de las variables de condición (aquí).
¿Quieres comenzar a escribir aplicaciones de tiempo real en Arduino UNO? ¡Descárgate mi framework KleOS!
Aquí te explico cómo descargarlo en Windows. Por supuesto que también funciona para Linux.
Aquí te muestro cómo realizar tu primer sketch con Arduino y KleOS.
Si el artículo te gustó, quizás quieras suscribirte al blog.
- Esta clase poco conocida de Arduino le dará vida a tus displays de texto y te olvidarás de tener que escribir funciones de conversión - septiembre 20, 2024
- Printable: The class you didn’t know existed in Arduino and that you won’t be able to stop using - septiembre 1, 2024
- Printable: La clase que no sabías que existía en Arduino y que no podrás dejar de usar - agosto 3, 2024
1 COMENTARIO