FreeRTOS Avanzado: Sincronización de Tareas, Colas y Mecanismos de Herencia de Prioridad
Introducción a los Sistemas Operativos en Tiempo Real (RTOS)
En el diseño de sistemas mecatrónicos complejos—como robótica móvil, controladores de servomotores o unidades de control de vuelo—los microcontroladores tradicionales con bucles while(1) (arquitectura Bare-Metal) se vuelven insuficientes. Cuando requerimos determinar con precisión determinista el tiempo de respuesta ante eventos externos, la implementación de un Sistema Operativo en Tiempo Real (RTOS) como FreeRTOS resulta imprescindible.
A diferencia de los sistemas operativos multipropósito (como Linux o Windows), donde el objetivo central es maximizar el rendimiento promedio (throughput), un RTOS prioriza la predecibilidad temporal. Para lograr esto, el scheduler (planificador) gestiona múltiples tareas concurrentes mediante un esquema de planificación preempitica basado en prioridades.
Sin embargo, la concurrencia introduce desafíos críticos: el acceso desordenado a recursos compartidos y el paso ineficiente de datos entre tareas. En este artículo profundizaremos en el uso de Colas (Queues), Semáforos, Mutexes y analizaremos el fenómeno crítico de la Inversión de Prioridad y su mitigación mediante la Herencia de Prioridad.
Intercambio de Datos Seguros mediante Colas (Queues)
En FreeRTOS, una cola es una estructura FIFO (First-In, First-Out) asignada en memoria dinámica o estática que permite transferir datos entre tareas o entre Rutinas de Servicio de Interrupción (ISR) y tareas de forma segura frente a condiciones de carrera.
Mecánica Interna
- Copia por Valor: Por defecto, FreeRTOS copia el valor completo del dato al interior de la cola. Esto evita que la tarea emisora sobrescriba el dato antes de que la receptora lo procese.
- Copia por Referencia: Cuando el tamaño de los datos es elevado (por ejemplo, tramas grandes de sensores), se transfiere un puntero a la estructura de datos para optimizar el tiempo de CPU y la RAM.
- Bloqueo Determinista: Si una tarea intenta leer de una cola vacía o escribir en una cola llena, el scheduler la pasa al estado Blocked, liberando la CPU para tareas de menor prioridad hasta que se cumpla una condición o expire un tiempo de espera (timeout).
Ejemplo de Código: Paso de Datos con Colas
#include "FreeRTOS.h"
#include "task.h"
#include "queue.h"
typedef struct {
uint16_t sensor_id;
float value;
} SensorData_t;
QueueHandle_t xSensorQueue;
void vTaskDataProducer(void *pvParameters) {
SensorData_t xData = { .sensor_id = 1, .value = 0.0f };
while(1) {
xData.value += 0.5f; // Simulación de lectura de sensor
// Envía el dato a la cola con un timeout de 100 ms
if (xQueueSend(xSensorQueue, (void *)&xData, pdMS_TO_TICKS(100)) == pdPASS) {
// Dato enviado exitosamente
}
vTaskDelay(pdMS_TO_TICKS(500)); // Periodo de muestreo
}
}
void vTaskDataConsumer(void *pvParameters) {
SensorData_t xReceivedData;
while(1) {
// Espera indefinidamente hasta recibir un dato
if (xQueueReceive(xSensorQueue, &(xReceivedData), portMAX_DELAY) == pdTRUE) {
// Procesar xReceivedData.value
}
}
}
int main(void) {
// Crear una cola capaz de albergar 5 elementos de tipo SensorData_t
xSensorQueue = xQueueCreate(5, sizeof(SensorData_t));
if (xSensorQueue != NULL) {
xTaskCreate(vTaskDataProducer, "Producer", 128, NULL, 2, NULL);
xTaskCreate(vTaskDataConsumer, "Consumer", 128, NULL, 1, NULL);
vTaskStartScheduler();
}
while(1);
}
Primitivas de Sincronización: Semáforos y Mutexes
Aunque suelen confundirse, los semáforos binarios, contadores y mutexes poseen arquitecturas internas y propósitos conceptuales distintos en FreeRTOS.
1. Semáforo Binario (Binary Semaphore)
Utilizado para la sincronización de eventos. Un evento (como una interrupción por HW) incrementa el semáforo (Give) y la tarea encargada de procesarlo decrementa el semáforo (Take). No posee el concepto de propietario (ownership).
2. Semáforo Contador (Counting Semaphore)
Utilizado para la gestión de recursos limitados (por ejemplo, un buffer circular con N ranuras libres) o el conteo de eventos acumulados.
3. Mutex (Mutual Exclusion Semaphore)
Diseñado específicamente para la exclusión mutua en el acceso a recursos compartidos (como un periférico SPI, I2C o una variable global).
- A diferencia del semáforo binario, el Mutex implementa la noción de Propietario: solo la tarea que toma el Mutex (
xSemaphoreTake) está autorizada para liberarlo (xSemaphoreGive). - Incluye un mecanismo automático de Herencia de Prioridad (Priority Inheritance).
El Problema de la Inversión de Prioridad
La inversión de prioridad es una anomalía en el comportamiento del scheduler donde una tarea de alta prioridad (H) se ve diferida indefinidamente por una tarea de baja prioridad (L) debido a la interferencia de una o varias tareas de prioridad media (M).
Escenario de Falla (Inversión Desprotegida)
- Tiempo T0: La Tarea L (Baja) toma un semáforo binario para acceder a una variable compartida.
- Tiempo T1: La Tarea H (Alta) despierta. Al ser de mayor prioridad, expulsa a L e intenta tomar el mismo semáforo. Al estar ocupado, H entra en estado Blocked esperando que L libere el recurso.
- Tiempo T2: La Tarea M (Media), que no requiere el recurso compartido, se activa. Dado que la prioridad de M es mayor que la de L, el planificador expulsa a L y ejecuta M.
- Resultado: H queda bloqueada esperando a L, pero L no puede ejecutarse porque M monopoliza la CPU. En la práctica, una tarea de prioridad media (M) está bloqueando indirectamente a la tarea crítica de alta prioridad (H).
Este problema causó la famosa falla del rover Mars Pathfinder en 1997 en la superficie de Marte, donde las tareas del sistema colapsaban periódicamente debidos al reinicio del temporizador watchdog.
Prioridad
^
H | (Bloqueada) ------------->|=================> (Ejecución)
| |
M | |==============| (Preempta a L)
L |===>|=======> (Prempted) -------->| Liberación Mutex
+----+-------+----+--------------+-+---------------> Tiempo
Mutex H despierta Mutex
Tomado y se bloquea Liberado
Solución: Mecanismo de Herencia de Prioridad
FreeRTOS resuelve este problema a través de los Mutexes con Herencia de Prioridad.
¿Cómo Funciona la Herencia de Prioridad?
Cuando la Tarea H intenta tomar un Mutex que actualmente pertenece a la Tarea L:
- El kernel eleva temporalmente la prioridad de la Tarea L al mismo nivel que la Tarea H.
- Con esta prioridad elevada, la Tarea M ya no puede expulsar (preempt) a la Tarea L.
- La Tarea L se ejecuta sin interrupciones de tareas medias hasta que libera el Mutex mediante
xSemaphoreGive(). - Al liberar el Mutex, la prioridad de L se restaura instantáneamente a su valor original de baja prioridad.
- La Tarea H adquiere el Mutex y se ejecuta de inmediato.
Implementación en C usando Mutex en FreeRTOS
#include "FreeRTOS.h"
#include "task.h"
#include "semphr.h"
SemaphoreHandle_t xMutex;
void vHighPriorityTask(void *pvParameters) {
while(1) {
vTaskDelay(pdMS_TO_TICKS(100)); // Esperar evento
// Intentar tomar el Mutex
if (xSemaphoreTake(xMutex, portMAX_DELAY) == pdTRUE) {
// Acceso exclusivo a recurso de alta prioridad
xSemaphoreGive(xMutex);
}
}
}
void vLowPriorityTask(void *pvParameters) {
while(1) {
if (xSemaphoreTake(xMutex, portMAX_DELAY) == pdTRUE) {
// Si HighPriorityTask solicita el mutex mientras la ejecutamos,
// esta tarea heredará temporalmente la prioridad de HighPriorityTask.
// Realizar trabajo crítico...
for (volatile int i = 0; i < 10000; i++);
xSemaphoreGive(xMutex);
// Aquí se restaura la prioridad original de esta tarea.
}
vTaskDelay(pdMS_TO_TICKS(10));
}
}
Nota Crítica de Diseño: Los semáforos binarios NO implementan herencia de prioridad en FreeRTOS. Para proteger recursos críticos compartidos de acceso concurrente se debe utilizar siempre
xSemaphoreCreateMutex(), nunca un semáforo binario.
Resumen y Reglas de Diseño para Mecatrónicos
- Usa Colas para transferencia de datos: Evita usar variables globales compartidas con flags de lectura/escritura manuales.
- Usa Mutex para Exclusión Mutua: Evitan la inversión de prioridad descontrolada gracias a la herencia implícita de prioridad.
- Usa Semáforos Binarios para Sincronización ISR-Tarea: Son ligeros e ideales para diferir el procesamiento pesado de una interrupción hacia una tarea en segundo plano.
- Minimiza la Sección Crítica: Mantén el código ejecutado entre
xSemaphoreTake()yxSemaphoreGive()tan breve como sea posible para evitar cuellos de botella en la latencia del sistema.