01 · Punto de partida
Tres necesidades, cinco herramientas
En el planificador cooperativo del tema 19 las tareas compartían variables sin ningún cuidado, porque nunca se interrumpían entre sí. Con la expropiación del tema 20 eso se acabó: una tarea puede perder el procesador entre las tres instrucciones de un n++, y la otra puede leer un valor a medias. Las variables globales «a pelo» ya no alcanzan. Pero además de proteger datos, un sistema con varias tareas necesita que se coordinen: que una espere el resultado de otra, o el aviso de una interrupción, sin gastar procesador mirando una bandera.
Los problemas se agrupan en tres necesidades, y cada herramienta del RTOS está pensada para una de ellas:
| Necesidad | Pregunta que responde | Herramienta |
|---|---|---|
| Comunicar | ¿Cómo le paso un dato (o una secuencia de datos) a otra tarea? | Cola |
| Sincronizar | ¿Cómo espero a que ocurra algo (otra tarea, una interrupción) sin consumir procesador? | Semáforo, notificación, grupo de eventos |
| Excluir | ¿Cómo evito que dos tareas usen a la vez un recurso que no admite dos usuarios? | Mutex |
Todas comparten una propiedad que las distingue de una variable global: son bloqueantes. Una tarea que pide algo que todavía no está disponible pasa al estado Bloqueada (tema 20), deja de consumir procesador y el sistema la despierta cuando la espera termina o vence un tiempo máximo que ella misma fijó. Es la forma en que un RTOS reemplaza al sondeo (polling) y a las banderas que había que revisar en cada vuelta.
Los ejemplos usan la API de FreeRTOS, que es la más difundida. Los nombres cambian de un sistema a otro (k_msgq en Zephyr, tx_queue en ThreadX), pero las ideas son las mismas.
02 · Comunicar
Colas: pasar datos
Una cola (queue) es un buffer FIFO administrado por el sistema: una tarea (o una interrupción) envía elementos, otra los recibe en el mismo orden. Es el buffer circular del tema 16, pero con tres mejoras decisivas: puede haber varios productores y varios consumidores sin que el programador cuide el orden de las escrituras, los accesos están protegidos por el propio sistema, y el consumidor se bloquea cuando la cola está vacía y se despierta cuando llega un dato, sin sondear.
typedef struct { uint8_t tipo; uint16_t valor; uint32_t instante; } Evento;
static QueueHandle_t cola_eventos;
void app_iniciar(void) {
cola_eventos = xQueueCreate(16, sizeof(Evento)); /* 16 elementos de 8 bytes cada uno */
}
/* productor: una tarea que detecta una pulsación */
void tarea_boton(void *arg) {
for (;;) {
esperar_pulsacion();
Evento e = { EV_BOTON, 1, xTaskGetTickCount() };
if (xQueueSend(cola_eventos, &e, pdMS_TO_TICKS(10)) != pdPASS) { /* espera hasta 10 ms por lugar */
eventos_perdidos++; /* no hubo lugar: se cuenta */
}
}
}
/* consumidor: bloqueado hasta que haya algo */
void tarea_aplicacion(void *arg) {
Evento e;
for (;;) {
if (xQueueReceive(cola_eventos, &e, portMAX_DELAY) == pdPASS) { /* duerme sin consumir procesador */
procesar(&e);
}
}
}
Algunas propiedades que conviene tener presentes:
- Los elementos se copian.
xQueueSendcopia el contenido deedentro de la cola, de modo que el emisor puede reutilizar su variable inmediatamente. Cuesta tiempo proporcional al tamaño del elemento; para datos grandes (un paquete de 256 bytes) se suele enviar un puntero a un buffer de un pool, dejando claro quién es dueño de ese buffer en cada momento (quien lo recibe lo libera). - Hay tres formas de esperar: sin esperar (
0: si no hay lugar o dato, devuelve error en el acto), con un tiempo máximo (pdMS_TO_TICKS(10)), o sin límite (portMAX_DELAY). Esperar para siempre es correcto cuando la tarea no tiene nada que hacer sin ese dato, como el consumidor del ejemplo; para casi todo lo demás conviene un tiempo máximo y una acción cuando vence. - Hay que decidir qué pasa si la cola se llena, porque pasará: descartar el dato nuevo, descartar el más viejo, o bloquear al productor. Cualquiera de las tres es válida según el caso (un sensor que mide una temperatura puede descartar el viejo; una orden de usuario no debe perderse); lo inaceptable es no haber decidido y no contar las pérdidas.
- Si hay varias tareas esperando, se despierta primero la de mayor prioridad; entre iguales, la que lleva más tiempo esperando.
Cuando el productor es una interrupción, la receta cambia en un detalle esencial: una ISR no puede bloquearse, así que usa una variante especial de la función (FromISR) que nunca espera, y debe avisar al sistema si despertó una tarea más prioritaria que la interrumpida, para que el cambio de contexto ocurra al salir de la ISR y no recién en el próximo tick:
void USART1_IRQHandler(void) {
BaseType_t desperto = pdFALSE;
uint8_t b = (uint8_t)USART1_DR; /* leer el dato también borra la bandera (tema 15) */
xQueueSendFromISR(cola_rx, &b, &desperto); /* nunca bloquea: si está llena, devuelve error */
portYIELD_FROM_ISR(desperto); /* si la tarea consumidora es más prioritaria, cambiar ya */
}
Si se omite portYIELD_FROM_ISR el programa funciona, pero la tarea que esperaba el dato se ejecuta recién en el siguiente tick, con una demora de hasta 1 ms (a 1 kHz). Es un defecto de latencia sin ningún síntoma de error, que solo aparece al medir.
El tamaño de la cola es un cálculo, no una intuición: debe cubrir lo que el productor genera durante el mayor intervalo entre dos vaciados de la cola, que es el período de la tarea consumidora más el mayor tiempo que puede quedar sin ejecutarse por culpa de tareas más prioritarias. A 115200 baudios llegan 11 520 bytes por segundo; si la tarea consumidora corre cada 10 ms, se acumulan hasta 115 bytes por vuelta, y si además puede quedar detenida 15 ms por otra tarea más urgente, el intervalo crítico es de 25 ms y se necesitan 288 bytes. El laboratorio de este tema permite verlo.
03 · Sincronizar
Semáforos: avisar y contar
A veces no hay datos para pasar, solo un aviso: «terminó la conversión», «llegó un paquete», «se apretó el botón». Para eso sirve un semáforo, que es un contador con dos operaciones: dar (Give, incrementa) y tomar (Take, decrementa, y si el contador es cero, bloquea a la tarea hasta que alguien dé). Hay dos variantes:
- El semáforo binario vale 0 o 1. Es el equivalente bloqueante de la bandera del tema 16: dice «ocurrió», no «cuántas veces». Si se da dos veces antes de que alguien lo tome, el segundo aviso se pierde.
- El semáforo de conteo llega hasta un máximo. Es el contador de eventos del tema 16, bloqueante: cada
Givedeja un aviso pendiente, y cadaTakeconsume uno. También se usa para administrar un conjunto de recursos iguales (tres buffers: el semáforo empieza en 3, quien toma uno lo gasta y lo devuelve al terminar).
El uso por excelencia es el procesamiento diferido de una interrupción: la ISR hace lo mínimo (limpiar la bandera, anotar) y despierta a una tarea que hace el trabajo largo, con prioridad y tiempo propios. Es la regla del tema 16 («la ISR marca, main procesa») con la tarea reemplazando al bucle principal:
static SemaphoreHandle_t sem_adc; /* binario: empieza "vacío" */
void adc_iniciar_rtos(void) { sem_adc = xSemaphoreCreateBinary(); }
void ADC1_2_IRQHandler(void) {
BaseType_t desperto = pdFALSE;
ADC1_SR &= ~(1u << 1); /* borrar EOC */
xSemaphoreGiveFromISR(sem_adc, &desperto); /* avisar: terminó la conversión */
portYIELD_FROM_ISR(desperto);
}
void tarea_medicion(void *arg) {
for (;;) {
adc_disparar();
if (xSemaphoreTake(sem_adc, pdMS_TO_TICKS(50)) == pdTRUE) { /* esperar hasta 50 ms */
filtrar_y_guardar(ADC1_DR);
} else {
registrar_error(ERR_ADC_TIMEOUT); /* el hardware no respondió: actuar, no colgarse */
}
vTaskDelay(pdMS_TO_TICKS(10));
}
}
Observá el else: la espera con tiempo máximo convierte un hardware mudo en un error que se puede registrar y manejar. Con portMAX_DELAY, la tarea se quedaría dormida para siempre y nadie lo sabría.
04 · Excluir
Mutex: exclusión mutua
Un mutex (mutual exclusion) protege un recurso que no admite dos usuarios simultáneos: un bus I²C compartido por varios sensores, una pantalla, una estructura de varios campos que debe leerse entera. Se usa como un candado: antes de tocar el recurso se toma el mutex, y al terminar se libera. Si otra tarea lo tiene, quien lo pide se bloquea hasta que se libere:
static SemaphoreHandle_t mutex_i2c;
/* mutex_i2c = xSemaphoreCreateMutex(); al iniciar */
bool i2c_leer(uint8_t dir, uint8_t reg, uint8_t *dato) {
if (xSemaphoreTake(mutex_i2c, pdMS_TO_TICKS(20)) != pdTRUE) return false; /* no se consiguió el bus */
bool ok = i2c_transaccion(dir, reg, dato); /* región crítica: una sola tarea a la vez */
xSemaphoreGive(mutex_i2c); /* SIEMPRE se libera, por todos los caminos */
return ok;
}
Con esto, dos tareas que llaman a i2c_leer «a la vez» quedan serializadas: la segunda espera a que termine la primera. Es exactamente lo que hace falta para el ejemplo del tema 16 en su versión de tareas: un n += 1 ejecutado por dos tareas, que sin protección deja un resultado errado en 18 de los 20 entrelazados posibles (lo comprueba el programa de Python de este tema), con un mutex solo admite dos órdenes, y ambos dan el valor correcto.
Un mutex se parece a un semáforo binario, pero no es lo mismo, y confundirlos es un error clásico:
| Aspecto | Mutex | Semáforo binario |
|---|---|---|
| Para qué sirve | Exclusión mutua de un recurso | Avisar un evento |
| Quién da y quién toma | La misma tarea que lo toma debe liberarlo (tiene dueño) | Normalmente una da (o una ISR) y otra toma |
| Herencia de prioridad | Sí: si una tarea de baja prioridad lo tiene y una de alta lo espera, la primera hereda temporalmente la prioridad alta (tema 22) | No |
| Desde una ISR | No se puede usar | Sí, con las funciones FromISR |
Reglas para usar mutex sin lastimarse:
- La región crítica, lo más corta posible. Mientras una tarea tiene el mutex, las demás que lo necesitan esperan; una lectura de I²C de 1 ms es razonable, una escritura de Flash de 100 ms bloquea a media aplicación.
- Se libera siempre. Un
returntemprano o un error en medio de la región que se salte elGivedeja el mutex tomado para siempre, y el sistema se tranca silenciosamente cuando otra tarea lo pida. Una estructura de un solo punto de salida, o una macro que garantice la liberación, evita el defecto. - No tomar un mutex con otro tomado si se puede evitar. Dos tareas que toman dos mutex en órdenes opuestos pueden quedar esperándose mutuamente para siempre (interbloqueo, tema 22).
- No desde una ISR. Una interrupción no puede esperar; si necesita acceder a un dato protegido, se comunica con una tarea mediante una cola o un semáforo.
Para proteger apenas unas instrucciones (copiar dos variables de 32 bits, incrementar un contador) existe una opción más barata que un mutex: la sección crítica del núcleo (taskENTER_CRITICAL() / taskEXIT_CRITICAL()), que enmascara las interrupciones durante ese lapso, como el PRIMASK o BASEPRI del tema 16. Mismas reglas: cortísima y sin llamar a nada que espere. Y es la única que protege también contra una ISR, cosa que un mutex no hace.
05 · Herramientas ligeras
Notificaciones y grupos de eventos
Para el caso más frecuente de sincronización —una interrupción o una tarea avisa a una tarea concreta— el sistema ofrece algo más económico que un semáforo: la notificación de tarea. Es un contador de 32 bits que vive dentro del propio TCB de la tarea destino, de modo que no hay que crear ningún objeto aparte, no consume RAM adicional y es más rápida que un semáforo:
static TaskHandle_t h_uart; /* se obtiene al crear la tarea */
void USART1_IRQHandler(void) {
BaseType_t desperto = pdFALSE;
/* ...leer el byte, guardarlo en el buffer... */
vTaskNotifyGiveFromISR(h_uart, &desperto); /* "tenés trabajo" */
portYIELD_FROM_ISR(desperto);
}
void tarea_uart(void *arg) {
for (;;) {
uint32_t cuantas = ulTaskNotifyTake(pdTRUE, portMAX_DELAY); /* duerme; devuelve cuántos avisos se acumularon */
while (cuantas--) { procesar_un_aviso(); }
}
}
La limitación es que solo puede haber un receptor (la tarea dueña de la notificación) y que el emisor no puede bloquearse. Si hay varios receptores posibles, o hace falta que el emisor espere lugar, se usa un semáforo o una cola.
El otro caso es esperar varias condiciones a la vez. Un grupo de eventos (event group) es un conjunto de bits que distintas tareas activan y que otra puede esperar combinados: «todos» (AND) o «alguno» (OR). El caso típico es la sincronización de arranque, donde la aplicación principal espera a que estén listos el sensor, la red y la configuración:
#define EV_SENSOR_LISTO (1u << 0)
#define EV_RED_LISTA (1u << 1)
#define EV_CONFIG_LEIDA (1u << 2)
static EventGroupHandle_t eventos; /* xEventGroupCreate() al iniciar */
/* cada tarea de inicialización avisa cuando termina su parte */
xEventGroupSetBits(eventos, EV_RED_LISTA);
/* la aplicación espera a LAS TRES, hasta 5 segundos */
EventBits_t b = xEventGroupWaitBits(eventos,
EV_SENSOR_LISTO | EV_RED_LISTA | EV_CONFIG_LEIDA,
pdFALSE, /* no borrar los bits al salir */
pdTRUE, /* esperar a TODOS (AND); pdFALSE = alguno (OR) */
pdMS_TO_TICKS(5000));
if ((b & (EV_SENSOR_LISTO | EV_RED_LISTA | EV_CONFIG_LEIDA)) == (EV_SENSOR_LISTO | EV_RED_LISTA | EV_CONFIG_LEIDA)) {
arrancar_aplicacion();
} else {
modo_degradado(b); /* se agotó el tiempo: ver cuáles faltaron */
}
En un procesador de 32 bits se dispone de hasta 24 bits por grupo. Con un semáforo se esperaría a una sola condición; con un grupo de eventos, la tarea espera a la combinación y, si vence el tiempo, puede saber exactamente cuáles faltaron.
06 · Combinar las piezas
Patrones de diseño
Las herramientas anteriores se combinan en unos pocos patrones que cubren casi todos los diseños. Los cuatro más útiles:
1. ISR → cola → tarea (la tubería)
La interrupción pone el dato crudo en una cola y la tarea lo procesa. Es el patrón del tema 16 con el bloqueo resuelto por el sistema. Se encadena: una tarea de protocolo interpreta los bytes y produce mensajes en otra cola, que consume una tarea de aplicación, que a su vez emite respuestas a una tercera:
2. La tarea guardián (gatekeeper)
Una sola tarea es dueña de un recurso (la UART de salida, la pantalla, la Flash), y las demás le piden servicios por cola en lugar de usar el recurso directamente. No hace falta ningún mutex, la serialización es natural, y se evita la inversión de prioridad y el interbloqueo en ese recurso. Es lo que hace la última tarea del esquema anterior:
static QueueHandle_t cola_log; /* elementos: punteros a cadenas, o mensajes cortos */
void log_escribir(const char *msg) { /* la llama cualquier tarea: no toca la UART */
xQueueSend(cola_log, &msg, 0); /* sin esperar: perder un mensaje es mejor que bloquear al control */
}
static void tarea_guardian_uart(void *arg) { /* la ÚNICA que usa la UART de salida */
const char *msg;
for (;;) {
if (xQueueReceive(cola_log, &msg, portMAX_DELAY) == pdPASS) uart_enviar_bloqueante(msg);
}
}
Observá que el printf bloqueante que el tema 16 prohibía en una ISR sigue siendo una mala idea en una tarea urgente, y la tarea guardián de baja prioridad es la solución: quien necesita informar no se bloquea, y el costo de la UART lo paga una tarea que no tiene plazos.
3. Sincronización de arranque
Un grupo de eventos en que cada tarea de inicialización activa su bit, y la aplicación espera a todos (el ejemplo del apartado 5). Evita las esperas con tiempos «a ojo» después del arranque.
4. Aviso con datos en una cola, y aviso sin datos en una notificación
La regla de decisión más simple: si hay datos que pasar, una cola; si solo hay aviso, una notificación (receptor único) o un semáforo (varios receptores o necesidad de contar).
07 · Criterio
Elegir bien y evitar las trampas
| Situación | Herramienta | Por qué |
|---|---|---|
| Pasar un dato o una secuencia entre tareas, o de una ISR a una tarea | Cola | Conserva el orden y los datos; desacopla los ritmos |
| Avisar un evento a una tarea concreta, sin datos | Notificación de tarea | La más barata y rápida |
| Avisar un evento a una de varias tareas, o contar ocurrencias | Semáforo binario o de conteo | Cualquiera puede tomarlo; el de conteo no pierde avisos |
| Administrar un conjunto de recursos iguales (buffers, canales) | Semáforo de conteo | El contador es la cantidad disponible |
| Usar un recurso en exclusiva por un rato | Mutex (o tarea guardián) | Dueño y herencia de prioridad; o serialización total |
| Esperar varias condiciones a la vez | Grupo de eventos | AND y OR sobre bits; informa cuáles faltaron |
| Proteger una o dos instrucciones, también contra una ISR | Sección crítica | Es lo único que detiene a la interrupción |
Y las trampas más frecuentes, en orden de cuánto daño hacen:
- Llamar a la función común desde una ISR. Las funciones sin
FromISRpueden bloquear o tocar estructuras del sistema de un modo que una interrupción no permite: el resultado es corrupción de memoria o un cuelgue que aparece lejos de la causa. Desde una ISR, siempre la varianteFromISR. - Olvidar el cambio de contexto al salir de la ISR (
portYIELD_FROM_ISR): el sistema funciona pero con hasta un tick de latencia extra. - Esperar sin tiempo máximo donde un fallo del hardware o de otra tarea puede impedir el aviso. La tarea se queda dormida para siempre, sin ruido.
- No liberar un mutex en algún camino. Los
returntempranos y las ramas de error son los culpables habituales. - Colas sin política de desborde. Si el productor es más rápido que el consumidor, la cola se llena. Sin decisión y sin contador de pérdidas, los datos desaparecen sin rastro.
- Colas enormes «por las dudas». Cada elemento ocupa RAM (profundidad × tamaño del elemento, más unas decenas de bytes del objeto). Una cola de 512 elementos de 32 bytes son 16 KB: casi toda la RAM de un STM32F103C8. Se dimensiona con la cuenta del apartado 2.
- Dos mutex tomados en órdenes opuestos, o una tarea de baja prioridad con un mutex que otra de alta prioridad necesita mientras una de prioridad media la expropia: son los problemas del tema 22.
08 · Analogía con programación
Colas y carreras, en Python
El programa tiene tres partes. La primera simula una UART que entrega un byte cada 10 bits a una ISR, que lo guarda en un buffer, y una tarea consumidora que despierta cada cierto tiempo y retira todo; cuenta los bytes perdidos, la ocupación máxima y la espera máxima, con y sin una «parada» de la consumidora causada por otra tarea más prioritaria. La segunda enumera todos los entrelazados posibles de dos tareas que ejecutan n += 1 como tres instrucciones, y cuenta cuáles dan el resultado correcto. La tercera es la cuenta del tamaño de la cola.
Python en tu navegador. Los bloques son editables: cambiá los valores y presioná «Ejecutar Python». La primera vez tarda unos segundos en cargar.
from collections import deque
from itertools import combinations
# --- Parte 1: una ISR llena un buffer y una tarea lo vacía cada cierto tiempo -----------------
def simular(baudios, profundidad, periodo_ms, parada_ms=0, mecanismo="cola", duracion_ms=1000):
"""Un byte cada 10 bits (8N1). La tarea consumidora despierta cada 'periodo_ms' y retira todo lo que haya.
'parada_ms' > 0: cada 100 ms una tarea más prioritaria ocupa el procesador ese tiempo (desde los 50 ms)."""
t_byte = 10e6 / baudios # µs entre bytes
paradas = [(50e3 + k * 100e3, 50e3 + k * 100e3 + parada_ms * 1e3) for k in range(10)] if parada_ms else []
despertares, k = [], 1
while k * periodo_ms * 1e3 <= duracion_ms * 1e3:
t = k * periodo_ms * 1e3
for ini, fin in paradas:
if ini <= t < fin: t = fin # la consumidora llega cuando termina la parada
despertares.append(t); k += 1
cola, total, perdidos, procesados, max_ocup, max_espera = deque(), 0, 0, 0, 0, 0.0
i, w = 1, 0
llegadas = int(duracion_ms * 1e3 / t_byte)
def consumir(t):
nonlocal procesados, max_espera
while cola:
max_espera = max(max_espera, t - cola.popleft()); procesados += 1
for i in range(1, llegadas + 1):
ta = i * t_byte
while w < len(despertares) and despertares[w] <= ta:
consumir(despertares[w]); w += 1
total += 1
if mecanismo == "cola":
if len(cola) < profundidad: cola.append(ta)
else: perdidos += 1
else: # un único dato: el nuevo pisa al anterior
if cola: cola.clear(); perdidos += 1
cola.append(ta)
max_ocup = max(max_ocup, len(cola))
return total, procesados, perdidos, max_ocup, max_espera / 1e3
print("Parte 1: UART -> ISR -> buffer -> tarea (1 s de simulación)")
print(f" {'baudios':>7s} {'mecanismo':>10s} {'prof.':>5s} {'tarea':>6s} {'parada':>7s} | {'bytes':>5s} {'perdidos':>8s} {'ocup. máx':>9s} {'espera máx':>10s}")
for baudios, mec, prof, per, par in ((115200, "dato único", 1, 10, 0), (115200, "cola", 64, 10, 0), (115200, "cola", 128, 10, 0),
(115200, "cola", 128, 10, 15), (115200, "cola", 512, 10, 15), (9600, "cola", 16, 20, 15)):
tot, proc, perd, occ, esp = simular(baudios, prof, per, par, "cola" if mec == "cola" else "unico")
print(f" {baudios:7d} {mec:>10s} {prof:5d} {per:4d}ms {par:5d}ms | {tot:5d} {perd:8d} {occ:9d} {esp:8.1f}ms")
# --- Parte 2: dos tareas incrementan un contador; ¿qué interlineados dan el resultado correcto? --
def ejecutar(orden):
"""Cada tarea hace n = n + 1 como LDR, ADD, STR con su propio registro. 'orden' dice quién ejecuta cada paso."""
n, reg, paso = 0, {0: 0, 1: 0}, {0: 0, 1: 0}
for quien in orden:
p = paso[quien]
if p == 0: reg[quien] = n
elif p == 1: reg[quien] += 1
else: n = reg[quien]
paso[quien] += 1
return n
ordenes = []
for pos in combinations(range(6), 3): # en qué pasos globales ejecuta la tarea 0
ordenes.append([0 if i in pos else 1 for i in range(6)])
buenos = sum(ejecutar(o) == 2 for o in ordenes)
print()
print(f"Parte 2: dos tareas hacen n += 1 sin protección; {len(ordenes)} interlineados posibles")
print(f" resultado correcto (n = 2): {buenos}; incorrecto (n = 1): {len(ordenes) - buenos}")
print(f" con un mutex solo existen 2 órdenes (primero una, después la otra): ambos dan n = {ejecutar([0,0,0,1,1,1])}")
print(f" ejemplo de interlineado que falla: {''.join(map(str, [0,1,0,1,0,1]))} -> n = {ejecutar([0,1,0,1,0,1])}")
# --- Parte 3: cuánto debe medir la cola -------------------------------------------------------
print()
print("Parte 3: tamaño necesario = bytes por segundo × mayor intervalo entre vaciados")
for baudios, per, par in ((115200, 10, 0), (115200, 10, 15), (115200, 5, 15), (9600, 20, 15)):
bps = baudios / 10
hueco = per + par
print(f" {baudios:6d} baudios, tarea cada {per:2d} ms, parada {par:2d} ms: {bps:7.1f} B/s × {hueco} ms = {bps * hueco / 1000:6.1f} bytes")
Resultado, parte 1: con un dato único (una variable compartida, como la bandera del tema 16) de los 11 520 bytes de un segundo se pierden 11 419: solo se recupera uno por cada vuelta de la tarea. Con una cola de 64 posiciones se pierden 5119 (la cola se llena a los pocos milisegundos); con 128 no se pierde ninguno, con una ocupación máxima de 116 (la cuenta daba 115,2) y una espera máxima de 10 ms. Si la tarea consumidora se retrasa 15 ms por una tarea más prioritaria, la cola de 128 pierde 1600 bytes (13,9 %) porque se llena, y hace falta una de 512 (se ocupan 288, tal como daba la cuenta) para no perder nada, con una espera máxima de 25 ms. A 9600 baudios y una cola de 16 posiciones se pierden 179 bytes porque la cuenta pedía 34. Parte 2: de los 20 entrelazados posibles de dos tareas que incrementan sin protección, solo 2 dan el resultado correcto (n = 2): los dos órdenes seriales. Los otros 18 dejan n = 1, con un incremento perdido. Un mutex deja únicamente esos dos órdenes. Parte 3: la fórmula bytes por segundo × mayor intervalo da 115,2 bytes sin parada, 288 con una parada de 15 ms, y 33,6 para 9600 baudios.
La cuenta es una cota segura: en el último caso (9600 baudios, tarea cada 20 ms, parada de 15 ms) la ocupación real llega a 24 bytes y no a 34, porque el peor encuentro entre la parada y el vaciado no siempre ocurre. Probá cambiar la tasa de baudios, el período de la tarea o el largo de la parada, y comprobá cada vez si la cuenta anticipa el resultado.
09 · Exploración interactiva
Dimensionar una cola
El laboratorio ejecuta la simulación de la parte 1: una UART entrega bytes a una interrupción que los pone en un buffer, y una tarea los retira cada cierto tiempo. Elegí la velocidad, si el buffer es una cola o un dato único, su tamaño, el período de la tarea y si otra tarea más prioritaria la detiene 15 ms cada 100 ms. Verás cuántos bytes se pierden, cuánto se ocupa la cola, cuánto espera un dato y un gráfico de la ocupación en los primeros 200 ms.
Una ISR, una cola y una tarea
Ocupación de la cola (primeros 200 ms)
—
Cambiá las opciones para ver cuándo la cola alcanza y cuándo no.
Recorrido guiado
- Con los valores iniciales (115200 baudios, cola de 128, tarea cada 10 ms, sin parada): no se pierde nada. La ocupación máxima es de 116 y la espera máxima de un dato, 10 ms. La cuenta decía 116 bytes.
- Bajá la cola a 64: se pierden 5119 bytes (44,4 %). Cuando la cola se llena, la interrupción descarta lo que llega hasta que la tarea vuelva a vaciarla. Mirá el gráfico: está al tope en todas las ventanas.
- Cambiá a un único dato: se pierde el 99,1 %. Un dato compartido que la ISR pisa solo conserva un byte por vuelta de la tarea, lo mismo que la bandera del tema 16. Para una secuencia de datos, una variable no alcanza.
- Volvé a la cola de 128 y activá la parada de 15 ms: ahora se pierden 1600 bytes (13,9 %), y la espera máxima sube a 25 ms. La cuenta daba 288 bytes y la cola tenía 128. En el gráfico se ven los picos que empiezan a los 50 ms, cuando la tarea más prioritaria le quita el procesador a la consumidora.
- Con la parada, poné la cola en 512: no se pierde nada y la ocupación máxima es 288, justo lo que había calculado. La cola absorbió el atraso. Ese es su trabajo: desacoplar el ritmo del productor del ritmo del consumidor.
- Con 115200 baudios y parada de 15 ms, probá la tarea cada 5 ms (la cuenta pide 231 bytes): con 128 se pierden 1020, con 512 nada. Ahora pasá a 9600 baudios, cola de 16 y tarea cada 20 ms: se pierden 179 bytes; con una cola de 64 no se pierde ninguno. A menor velocidad, bastan colas mucho más chicas.
Desafío: ¿cómo evitar el problema sin agrandar la cola?
Tres caminos, que se pueden combinar. Acortar el intervalo: hacer que la tarea consumidora corra con mayor frecuencia, o mejor, que se despierte por evento (un aviso cada vez que la cola pasa de cierto nivel) y no por tiempo. Subir su prioridad: si la tarea consumidora tiene un plazo ajustado, no debería poder ser detenida 15 ms por otra. Cambiar el hardware: una UART con FIFO de 16 bytes o transferencia por DMA relaja el plazo en el mismo sentido que el FIFO relajaba el de la ISR en el tema 15. Y como último recurso, la política de descarte: si perder datos es inevitable, que sea el más viejo y que haya un contador de pérdidas visible.
10 · Método de trabajo
Método para comunicar tareas
- Hacé el mapa de la información: qué dato o aviso va de qué ISR o tarea a cuál. Dibujalo como el esquema de la tubería del apartado 6. Cada flecha es una cola, una notificación o un semáforo.
- Para cada flecha, clasificá: ¿lleva datos o es solo un aviso?, ¿cuántos emisores y cuántos receptores?, ¿hay que contar o basta con saber que ocurrió?
- Para cada recurso compartido, decidí si lo serializa un mutex o conviene una tarea guardián. Si se puede evitar compartirlo, mejor.
- Dimensioná cada cola con la cuenta: tasa del productor × mayor intervalo entre vaciados del consumidor (su período más la mayor parada que pueda sufrir), más un margen. Anotá la cuenta.
- Decidí la política de desborde de cada cola (descartar el nuevo, el viejo o bloquear) y agregá un contador de pérdidas.
- Fijá un tiempo máximo de espera en cada bloqueo, y escribí qué hace la tarea cuando vence. Reservá
portMAX_DELAYpara los consumidores puros. - Desde las ISR, usá solo las variantes
FromISRy cerrá siempre con el cambio de contexto condicional. Revisá que la prioridad de la interrupción esté dentro de lo que el RTOS admite. - Revisá cada mutex: región corta, liberación por todos los caminos, ninguna espera adentro, nunca desde una ISR, nunca dos tomados en órdenes distintos.
- Medí: la ocupación máxima real de cada cola (la mayoría de los sistemas la registran), el tiempo que las tareas pasan bloqueadas, y la latencia desde la interrupción hasta la primera instrucción de la tarea que despierta.
- Provocá los casos malos: el productor al máximo ritmo, una tarea detenida a propósito, una cola llena, un mutex que se pide cuando está tomado. Comprobá que cada uno termina de la forma prevista.
11 · Comprensión
Confusiones frecuentes
«Una variable global volatile alcanza para pasar datos entre tareas»
Alcanza para un dato atómico, escrito por una sola parte, que se lee de vez en cuando. No sirve para una secuencia (el dato nuevo pisa al anterior, como se vio en el laboratorio), ni para un conjunto de campos que debe leerse junto, ni avisa a la tarea que espera: ésta tendría que sondear. Para eso están las colas y las demás herramientas.
«Un mutex y un semáforo binario son lo mismo»
Se implementan de forma parecida, pero el mutex tiene dueño (quien lo toma, lo libera) y herencia de prioridad, y no se puede usar desde una ISR. El semáforo binario sirve para avisar. Usar un semáforo binario para proteger un recurso funciona hasta que aparece una inversión de prioridad.
«Si la cola se llena, el sistema se encarga»
El sistema no hace nada especial: devuelve un error (o bloquea al emisor si éste pidió esperar). Si el programador no revisa el valor devuelto, el dato se pierde sin que nadie se entere.
«Desde una interrupción puedo llamar a cualquier función del RTOS»
Solo a las que terminan en FromISR, y solo desde interrupciones cuya prioridad esté dentro del rango que el sistema administra. El resto puede bloquear o corromper estructuras internas.
«Cuanto más grande la cola, mejor»
Una cola grande cuesta RAM y solo posterga el problema si el consumidor es estructuralmente más lento que el productor. Se dimensiona con la cuenta: un productor sostenidamente más rápido llena cualquier cola.
«Esperar para siempre es seguro: el dato llegará»
Hasta que el hardware falle o la tarea que debía enviarlo se cuelgue. Entonces la tarea queda dormida sin dar señales. Con un tiempo máximo y una acción de error, el fallo se detecta y se maneja.
«Con mutex en todo ya no hay problemas de concurrencia»
Los mutex resuelven la exclusión, pero introducen los suyos: interbloqueo, inversión de prioridad, bloqueos largos que degradan los plazos. Un diseño con menos recursos compartidos (colas y tareas guardián) es más robusto que uno con muchos candados.
12 · Práctica guiada
Predecí antes de comprobar
1. Dimensionar una cola
Un sensor entrega 4000 muestras por segundo de 2 bytes cada una, a una ISR que las pone en una cola. La tarea consumidora corre cada 20 ms, y puede quedar detenida hasta 30 ms por una tarea más prioritaria. ¿Cuántos elementos como mínimo debe tener la cola, y cuántos bytes ocupan los datos?
Ver solución
El mayor intervalo entre vaciados es 20 + 30 = 50 ms. En ese tiempo llegan 4000 × 0,050 = 200 muestras. Con elementos de 2 bytes, 400 bytes de datos (más unas decenas de bytes del objeto cola). En la práctica se elegiría 256 elementos para dejar margen. Si las muestras se pasaran como bloques, las cuentas serían otras.
2. Binario o de conteo
Una ISR da un semáforo 3 veces en 5 ms, y la tarea que lo toma tarda 10 ms en volver a pedirlo. ¿Cuántos avisos procesa la tarea con un semáforo binario? ¿Y con uno de conteo?
Ver solución
Con el binario solo uno: el semáforo vale 1 después del primer aviso y los otros dos se pierden. Con el de conteo, tres: cada aviso suma uno, y la tarea los consume uno por uno. Si la tarea solo necesita saber «hubo algo», el binario alcanza; si cada aviso corresponde a un trabajo distinto, hace falta el de conteo (o una cola).
3. El mutex que no se libera
Una función toma un mutex, ejecuta una transacción y, si hay un error, hace return false antes de liberarlo. ¿Qué ocurre cuando la transacción falla por primera vez? Reescribí la función para evitarlo.
Ver solución
El mutex queda tomado para siempre: la siguiente tarea que lo pida se bloquea indefinidamente (o hasta su tiempo máximo), y todo el recurso queda inutilizable, sin ningún mensaje de error. La corrección es una única salida: guardar el resultado en una variable, hacer siempre xSemaphoreGive y recién después return ok;. El código del apartado 4 está escrito así.
4. Un defecto raro
El programa de Python muestra que 18 de los 20 entrelazados de dos tareas que hacen n += 1 dan un resultado erróneo. ¿Por qué en un equipo real el defecto aparece rara vez y no 9 de cada 10 veces?
Ver solución
Porque los 20 entrelazados no son igualmente probables: en un sistema real, una tarea ejecuta miles de instrucciones seguidas entre dos cambios de contexto, y el defecto exige que un cambio caiga justo dentro de la ventana de tres instrucciones del n += 1. Es la misma situación del tema 16: la probabilidad por ejecución es pequeña, y por eso el defecto pasa las pruebas, aparece en el campo y se esconde al depurar. Se evita razonando, no probando.
5. Una espera con varias condiciones
Una tarea debe arrancar cuando estén listos el sensor y (la red o el modo local). ¿Cómo lo expresarías con un grupo de eventos?
Ver solución
Un grupo de eventos espera «todos» o «alguno» sobre un mismo conjunto de bits, no una combinación mixta. Se resuelve con un bit derivado: una tarea que espera «red o modo local» (OR) y, cuando se cumple, activa un bit EV_CONECTIVIDAD; la tarea principal espera entonces EV_SENSOR_LISTO | EV_CONECTIVIDAD con AND. Conviene además un tiempo máximo y mirar los bits devueltos para saber qué faltó.
6. El cambio de contexto que no se pidió
Una ISR da un semáforo con xSemaphoreGiveFromISR pero no llama a portYIELD_FROM_ISR. La tarea que lo espera es la de mayor prioridad y tiene un plazo de 200 µs. El tick es de 1 kHz. ¿Qué ocurre?
Ver solución
La tarea pasa a Lista, pero el cambio de contexto no se solicita al salir de la ISR: ocurre recién en el siguiente tick (o cuando la tarea en ejecución se bloquee), con una demora de hasta 1 ms, cinco veces el plazo de 200 µs. El programa funciona y no hay ningún error, pero el plazo se pierde de vez en cuando según el instante en que llegue la interrupción respecto del tick. La corrección es agregar portYIELD_FROM_ISR(desperto) al final de la ISR.
13 · Síntesis
Ideas para recordar
- Con expropiación, las variables globales no alcanzan: hay tres necesidades distintas (comunicar, sincronizar, excluir) y una herramienta para cada una. Todas son bloqueantes: una tarea que espera pasa al estado Bloqueada y no consume procesador.
- Una cola pasa datos en orden, copiándolos, entre varios productores y consumidores. Hay que decidir qué pasa cuando se llena, contar las pérdidas y dimensionarla con una cuenta: tasa del productor por el mayor intervalo entre vaciados.
- Un semáforo avisa: el binario dice «ocurrió» (como una bandera) y el de conteo cuenta avisos o administra recursos iguales. Con una ISR, el procesamiento diferido pone lo mínimo en la interrupción y el resto en una tarea.
- Un mutex serializa el uso de un recurso: tiene dueño, hereda prioridad y no se usa desde una ISR. Región crítica corta, liberación por todos los caminos, nunca dos en órdenes opuestos.
- La notificación de tarea es el aviso más barato para un receptor único; el grupo de eventos espera varias condiciones combinadas y dice cuáles faltaron.
- Desde una ISR se usan solo las variantes
FromISR, y se cierra con el cambio de contexto condicional; si no, se agrega hasta un tick de latencia sin ningún error visible. - Todo bloqueo lleva un tiempo máximo y una acción cuando vence; esperar para siempre se reserva para consumidores puros.
- Los patrones simples ganan: la tubería de tareas con colas y la tarea guardián evitan la mayoría de los mutex y los problemas que traen.
¿Listo para el tema 22?
Antes de avanzar, elegí la herramienta para tres casos: pasar bytes de una UART a una tarea, esperar que terminen tres inicializaciones, y compartir un bus I²C entre dos tareas; escribí la ISR correcta para el primero; y explicá por qué un semáforo binario no es lo mismo que un mutex. Si podés justificar las tres, dominás lo esencial.
En el próximo tema vemos los problemas de concurrencia que persisten aun usando estas herramientas: condiciones de carrera, inversión de prioridad, interbloqueo y cómo diseñar para evitarlos.