01 · Qué vamos a construir
Objetivo, materiales y resultado esperado
Un monitor de temperatura con alarma. Es un producto pequeño y real (la lógica de un registrador de cadena de frío, de un termostato o de una vigilancia de tablero eléctrico), con las tres actividades que casi todo dispositivo conectado tiene: medir, decidir y comunicar. Y las tres tienen ritmos y urgencias distintos, que es exactamente la situación para la que sirve un RTOS.
Materiales.
- La placa del tema 12 (Blue Pill, LED en PC13) con FreeRTOS agregado al proyecto, y el entorno de compilación de siempre.
- Un sensor de temperatura digital cuya conversión tarde unos 12 ms. Si no tenés ninguno, el taller incluye una implementación simulada del módulo del sensor (una señal sintética con ruido y picos), de modo que todo lo demás funciona igual.
- La UART1 conectada a la computadora con un adaptador USB-serie, y un programa de terminal a 115200 baudios.
- Opcional, muy recomendable: un analizador lógico para medir el período de muestreo y los tiempos de respuesta.
Resultado esperado. El equipo mide la temperatura 10 veces por segundo, la filtra y envía por la UART, una vez por segundo, una línea como T=23.450 MIN=22.100 MAX=24.000 ALARMA=0. Con el comando UMBRAL 30.0 cambia el umbral de la alarma y responde OK; con GET informa el estado en el acto. Cuando la temperatura filtrada alcanza el umbral, el LED se enciende, y se apaga recién cuando baja medio grado por debajo (histéresis). Un pico aislado del sensor no dispara nada, y una línea mal escrita recibe ERR sin afectar al resto.
Es un taller de diseño más que de periféricos: los módulos de hardware (reloj, UART, sensor) siguen las reglas de la capa HAL del tema 11, y los detalles de registros ya los viste. Acá el foco es cómo se organizan las tareas, qué se dicen y por qué en ese orden de prioridad.
02 · El diseño antes del código
La arquitectura: tareas y flujo de datos
El primer paso no es escribir código sino responder dos preguntas: ¿qué tareas hay? y ¿cómo se hablan? La regla para la primera es separar por responsabilidad y por ritmo: cada tarea hace una sola cosa, a un ritmo propio. Para la segunda, las reglas del tema 22: nada de datos compartidos; cada dato tiene un dueño, y los demás reciben copias por mensajes. El resultado para este producto:
Los cuatro conductos entre tareas se eligen según lo que viaja, con los criterios del tema 21:
| Conducto | Quién escribe | Quién lee | Por qué ese mecanismo |
|---|---|---|---|
cola_rx (128 bytes) | La ISR de la UART | Tarea del parser | Una secuencia de bytes en orden; se cuenta con la cuenta del tema 21 (13 bytes en el peor intervalo) |
cola_proceso (8 mensajes) | Sensor y parser | Tarea de procesamiento | Una sola cola de entrada con mensajes etiquetados: la tarea espera en un solo lugar muestras y comandos |
buzon_estado (1 elemento) | Tarea de procesamiento | Reporte y parser | Solo importa el estado más reciente: se sobrescribe con xQueueOverwrite y se lee sin consumir con xQueuePeek. El escritor nunca se bloquea |
cola_tx (8 líneas de 48 bytes) | Reporte y parser | Tarea guardián | Cada línea se copia: no hay buffers compartidos ni dueños ambiguos |
Dos patrones de diseño merecen atención. El primero es el buzón (mailbox): una cola de un solo elemento que se sobrescribe. Resuelve «quiero compartir la última medida» sin una variable global, sin mutex y sin que el productor tenga que esperar a nadie. El segundo es que los datos del procesamiento (umbral, mínimo, máximo, filtro) tienen un único dueño: la tarea de procesamiento. El parser no toca el umbral; le manda un mensaje. Por eso no hay en todo el proyecto ni un mutex ni una variable compartida entre tareas.
El parser también consulta el buzón (para el comando GET), pero esa flecha de lectura no se dibujó para no cargar el esquema. Las prioridades siguen la regla de los temas 19 y 20, a menor plazo, mayor prioridad (en FreeRTOS, número más alto), no «lo más importante»:
| Tarea | Prio. | C | T | D | Pila | Por qué ese plazo |
|---|---|---|---|---|---|---|
| sensor | 5 | 0,3 ms | 100 ms | 20 ms | 768 B | La lectura debe hacerse en una ventana fija tras disparar la conversión, o el muestreo pierde regularidad |
| proceso | 4 | 0,62 ms | 100 ms | 50 ms | 1 KB | La alarma debe reaccionar en menos de medio período de muestreo |
| parser | 3 | 0,2 ms | 20 ms | 100 ms | 1 KB | Sin esto la cola de recepción se desborda; el usuario percibe 100 ms como inmediato |
| reporte | 2 | 0,4 ms | 1000 ms | 500 ms | 768 B | Se publica cada segundo; media vuelta de margen |
| guardián | 1 | 3,5 ms | 200 ms | 1000 ms | 640 B | Sin plazo exigente: es la que más tarda y la que menos urge |
Observá la lógica de la última fila: la tarea más lenta es la menos prioritaria. Enviar una línea por una UART bloqueante lleva 3,5 ms por línea de 40 caracteres, y sería un desastre para el sensor si tuviera la prioridad alta; como es la última, solo se estira a sí misma.
03 · Paso 1
La tarea del sensor
La interfaz del sensor es la HAL del tema 11: habla de milésimas de grado centígrado (mC) y oculta el bus. Tiene dos operaciones, disparar la conversión y leer el resultado, porque el sensor necesita unos 12 ms entre una y otra:
/* src/hal/hal_sensor.h */
void sensor_iniciar_conversion(void); /* dispara la medición; vuelve enseguida */
bool sensor_leer_mC(int32_t *mc); /* devuelve false si el sensor no responde */
/* src/hal/sim/hal_sensor.c: la versión simulada, para trabajar sin sensor */
static uint32_t semilla = 12345;
static uint32_t cuenta;
void sensor_iniciar_conversion(void) { }
bool sensor_leer_mC(int32_t *mc) {
semilla = semilla * 1103515245u + 12345u;
int32_t ruido = (int32_t)((semilla >> 16) % 601u) - 300; /* ±0,3 °C */
int32_t base = 20000 + (int32_t)(12000u * (cuenta % 300u) / 299u); /* sube de 20 a 32 °C en 30 s */
int32_t pico = (cuenta % 37u == 36u) ? 8000 : 0; /* un pico de +8 °C de vez en cuando */
cuenta++;
*mc = base + ruido + pico;
return true;
}
La tarea que lo usa muestra la ventaja principal de un RTOS: el código se lee como una receta, con una espera en el medio, y esa espera no gasta procesador:
/* src/tareas/tarea_sensor.c */
static void tarea_sensor(void *arg) {
TickType_t t0 = xTaskGetTickCount();
for (;;) {
sensor_iniciar_conversion(); /* ráfaga 1: dispara (decenas de µs) */
vTaskDelay(pdMS_TO_TICKS(15)); /* la conversión tarda ~12 ms: dormir, no esperar */
Mensaje m = { MSG_MUESTRA, 0 };
if (sensor_leer_mC(&m.valor)) { /* ráfaga 2: leer (centenas de µs) */
if (xQueueSend(cola_proceso, &m, 0) != pdPASS) muestras_perdidas++; /* sin esperar; se cuenta */
} else {
errores_sensor++; /* el sensor no respondió: se cuenta y se sigue */
}
vTaskDelayUntil(&t0, pdMS_TO_TICKS(PERIODO_MUESTREO_MS)); /* período exacto: mide desde el inicio de la vuelta */
}
}
Detalles de oficio. El período se mantiene con vTaskDelayUntil, no con vTaskDelay: el trabajo y la espera de 15 ms de cada vuelta no deben sumarse al período (con vTaskDelay(100) el período real sería de unos 115 ms). El envío a la cola es sin espera: si la cola está llena, perder una muestra es mejor que atrasar el siguiente muestreo, y el contador muestras_perdidas lo hace visible. Un sensor que falla no cuelga la tarea ni el sistema: cuenta el error y sigue, y quien reporta decide qué hacer con un contador de errores que crece. Y en el análisis de tiempos, las dos ráfagas se toman juntas como C = 0,3 ms: la dormida de 15 ms no consume procesador, y el plazo de 20 ms deja un margen de unos 5 ms para que la segunda ráfaga se complete después de despertar.
04 · Paso 2
Filtrar, decidir y alarmar
La tarea de procesamiento es el «cerebro»: recibe muestras y comandos, mantiene el estado y decide la alarma. Su lógica numérica va en funciones puras (sin hardware, sin RTOS), en el estilo del módulo control.c del tema 18: se pueden compilar en la PC y probar con datos conocidos. El filtro tiene dos etapas, porque el sensor tiene dos tipos de perturbación distintos que se combaten con herramientas distintas:
- Los picos aislados (una lectura errónea de +8 °C) se eliminan con una mediana de las últimas 5 muestras: un valor atípico queda en un extremo del orden y no puede ser el del medio.
- El ruido continuo (±0,3 °C) se alisa con una media móvil de las últimas 8 medianas.
/* src/filtro.c: enteros puros, milésimas de °C */
#define FILTRO_N 8
typedef struct {
int32_t ult5[5]; uint8_t n5, i5; /* últimas 5 muestras crudas (para la mediana) */
int32_t ventana[FILTRO_N]; uint8_t nv, iv; int32_t suma; /* últimas N medianas (para la media) */
} Filtro;
static int32_t mediana5(const int32_t *v) {
int32_t a[5];
for (int i = 0; i < 5; i++) a[i] = v[i];
for (int i = 1; i < 5; i++) { /* ordenamiento por inserción de 5 elementos */
int32_t x = a[i]; int j = i - 1;
while (j >= 0 && a[j] > x) { a[j + 1] = a[j]; j--; }
a[j + 1] = x;
}
return a[2];
}
int32_t filtro_paso(Filtro *f, int32_t x) {
f->ult5[f->i5] = x; f->i5 = (uint8_t)((f->i5 + 1) % 5); if (f->n5 < 5) f->n5++;
int32_t m = (f->n5 == 5) ? mediana5(f->ult5) : x; /* hasta tener 5 muestras no hay mediana */
if (f->nv == FILTRO_N) f->suma -= f->ventana[f->iv]; else f->nv++;
f->ventana[f->iv] = m; f->suma += m; f->iv = (uint8_t)((f->iv + 1) % FILTRO_N);
return f->suma / f->nv; /* la media: sin números en coma flotante */
}
/* alarma con histéresis: enciende al llegar al umbral, apaga recién medio grado por debajo */
#define HISTERESIS_MC 500
bool alarma_paso(bool activa, int32_t t, int32_t umbral) {
if (!activa && t >= umbral) return true;
if ( activa && t < umbral - HISTERESIS_MC) return false;
return activa;
}
La histéresis resuelve un problema que el filtro, solo, no resuelve. Si la temperatura real se queda justo en el umbral, la medida filtrada oscila alrededor de él por el ruido residual, y una alarma sin histéresis se enciende y apaga una y otra vez («rebota», chattering): un LED que parpadea, un relé que castañetea. Con histéresis, la alarma se enciende al llegar al umbral y no se apaga hasta bajar de umbral − 0,5 °C. El programa de Python de este tema mide los dos efectos por separado.
Y la tarea, que es corta porque la lógica está en las funciones:
/* src/tareas/tarea_proceso.c */
static void tarea_proceso(void *arg) {
Filtro f = {0};
Estado e = { 0, INT32_MAX, INT32_MIN, false };
int32_t umbral = UMBRAL_INICIAL_MC; /* los datos viven acá: nadie más los toca */
Mensaje m;
for (;;) {
xQueueReceive(cola_proceso, &m, portMAX_DELAY); /* duerme hasta que llegue una muestra o un comando */
switch (m.tipo) {
case MSG_MUESTRA:
e.actual = filtro_paso(&f, m.valor);
if (e.actual < e.minimo) e.minimo = e.actual;
if (e.actual > e.maximo) e.maximo = e.actual;
e.alarma = alarma_paso(e.alarma, e.actual, umbral);
led_alarma(e.alarma); /* actuar */
xQueueOverwrite(buzon_estado, &e); /* publicar la última medida: nunca bloquea */
break;
case MSG_UMBRAL:
umbral = m.valor; /* un cambio de configuración, serializado por la cola */
break;
}
}
}
El umbral no está protegido por ningún mutex porque no lo necesita: lo lee y lo escribe una sola tarea. La cola de entrada serializa solos las muestras y los comandos, y se acabaron las carreras posibles por diseño.
05 · Paso 3
Comunicación por UART
La comunicación tiene tres piezas, cada una con una sola función: recibir bytes (la ISR), interpretarlos (el parser) y enviarlos (el guardián), más el reporte periódico. Es la tubería del tema 21:
/* ISR: lo mínimo (tema 15 y 21) */
void USART1_IRQHandler(void) {
BaseType_t desperto = pdFALSE;
uint8_t b = (uint8_t)USART1_DR; /* leer el dato también borra la bandera */
if (xQueueSendFromISR(cola_rx, &b, &desperto) != pdPASS) rx_perdidos++;
portYIELD_FROM_ISR(desperto);
}
/* src/tareas/tarea_parser.c: arma líneas y ejecuta comandos */
static void ejecutar(const char *linea) {
if (strcmp(linea, "GET") == 0) {
reporte_encolar();
} else if (strncmp(linea, "UMBRAL ", 7) == 0) {
int32_t mc;
if (decimal_a_mC(linea + 7, &mc) && mc > -40000 && mc < 125000) {
Mensaje m = { MSG_UMBRAL, mc };
xQueueSend(cola_proceso, &m, pdMS_TO_TICKS(10)); /* el dueño del umbral es la tarea de proceso */
tx_texto("OK\n");
} else tx_texto("ERR\n"); /* valor ilegible o fuera de rango */
} else tx_texto("ERR\n");
}
static void tarea_parser(void *arg) {
char linea[32]; unsigned n = 0; uint8_t b;
for (;;) {
xQueueReceive(cola_rx, &b, portMAX_DELAY);
if (b == '\n' || b == '\r') {
if (n > 0) { linea[n] = '\0'; ejecutar(linea); n = 0; }
} else if (n < sizeof linea - 1) {
linea[n++] = (char)b;
} else {
n = 0; /* línea demasiado larga: descartarla, no desbordar el buffer */
}
}
}
El parser aplica la regla de oro de cualquier código que lee de afuera: nunca confiar en la entrada. Una línea demasiado larga se descarta sin escribir fuera del buffer, un número ilegible o fuera de rango recibe ERR, y un comando desconocido también. Probar con basura es parte de la verificación.
El formateo de números se hace sin printf, a mano, por dos razones: el printf de la biblioteca estándar puede usar de varios cientos de bytes a más de 1 KB de pila (con lo que cada tarea que lo llame necesita una pila mayor) y no es reentrante sin cuidados (tema 22). Las funciones propias trabajan solo con sus parámetros y sus variables locales:
/* src/formato.c: escribe un valor en milésimas como "-12.345"; devuelve el puntero al final */
char *fmt_mC(char *dst, int32_t mc) {
if (mc < 0) { *dst++ = '-'; mc = -mc; }
uint32_t ent = (uint32_t)mc / 1000u, dec = (uint32_t)mc % 1000u;
char tmp[10]; int k = 0;
do { tmp[k++] = (char)('0' + ent % 10u); ent /= 10u; } while (ent);
while (k) *dst++ = tmp[--k];
*dst++ = '.';
*dst++ = (char)('0' + dec / 100u); *dst++ = (char)('0' + dec / 10u % 10u); *dst++ = (char)('0' + dec % 10u);
return dst;
}
typedef struct { char s[48]; } Linea; /* un renglón completo, copiado por la cola */
void reporte_encolar(void) { /* la llaman el reporte periódico y el parser (GET) */
Estado e;
if (xQueuePeek(buzon_estado, &e, 0) != pdPASS) { tx_texto("SIN DATOS\n"); return; }
Linea l; char *p = l.s;
p = copiar(p, "T="); p = fmt_mC(p, e.actual);
p = copiar(p, " MIN="); p = fmt_mC(p, e.minimo);
p = copiar(p, " MAX="); p = fmt_mC(p, e.maximo);
p = copiar(p, " ALARMA="); *p++ = e.alarma ? '1' : '0'; *p++ = '\n'; *p = '\0';
xQueueSend(cola_tx, &l, 0); /* sin esperar: perder un reporte es mejor que bloquear a quien llama */
}
static void tarea_reporte(void *arg) {
TickType_t t0 = xTaskGetTickCount();
for (;;) { reporte_encolar(); vTaskDelayUntil(&t0, pdMS_TO_TICKS(1000)); }
}
reporte_encolar() es llamada por dos tareas distintas (el reporte periódico y el parser), y es segura precisamente por ser reentrante: su buffer es una variable local, que vive en la pila de quien la llama, y la cola copia la línea antes de que la función retorne. Es la aplicación directa de la regla del tema 22.
Por último, la tarea guardián: la única que toca el transmisor de la UART. Como es la menos prioritaria, su ritmo lento (86,8 µs por carácter) no le cuesta nada a las demás:
static void tarea_guardian_uart(void *arg) {
Linea l;
for (;;) {
xQueueReceive(cola_tx, &l, portMAX_DELAY);
for (const char *p = l.s; *p; p++) uart_enviar_byte_bloqueante(*p); /* 40 caracteres ≈ 3,5 ms */
}
}
06 · Paso 4
Arranque, prioridades y protecciones
El main() de un firmware con RTOS sigue un orden estricto, que es el del tema 22: primero se crean todos los objetos, después las tareas, y al final se arranca el planificador. Ninguna tarea debe encontrar una cola que todavía no existe.
/* config/config.h: prioridades en FreeRTOS (número MAYOR = más prioritario) */
#define PRIO_SENSOR 5 /* plazo 20 ms */
#define PRIO_PROCESO 4 /* plazo 50 ms */
#define PRIO_PARSER 3 /* plazo 100 ms */
#define PRIO_REPORTE 2 /* plazo 500 ms */
#define PRIO_GUARDIAN 1 /* plazo 1000 ms: la más lenta, la menos prioritaria */
#define PERIODO_MUESTREO_MS 100
#define UMBRAL_INICIAL_MC 30000
/* src/main.c */
int main(void) {
hardware_iniciar(); /* reloj, pines, UART, sensor (temas 9 y 12) */
cola_rx = xQueueCreate(128, sizeof(uint8_t));
cola_proceso = xQueueCreate(8, sizeof(Mensaje));
buzon_estado = xQueueCreate(1, sizeof(Estado));
cola_tx = xQueueCreate(8, sizeof(Linea));
configASSERT(cola_rx && cola_proceso && buzon_estado && cola_tx); /* si falta memoria, detenerse acá */
xTaskCreate(tarea_sensor, "sensor", 192, NULL, PRIO_SENSOR, NULL); /* pilas en PALABRAS de 4 bytes */
xTaskCreate(tarea_proceso, "proceso", 256, NULL, PRIO_PROCESO, NULL);
xTaskCreate(tarea_parser, "parser", 256, NULL, PRIO_PARSER, NULL);
xTaskCreate(tarea_reporte, "reporte", 192, NULL, PRIO_REPORTE, NULL);
xTaskCreate(tarea_guardian_uart, "guardian", 160, NULL, PRIO_GUARDIAN, NULL);
uart_rx_habilitar(); /* recién ahora puede llegar la interrupción: todos los objetos existen */
vTaskStartScheduler(); /* no vuelve */
for (;;) { }
}
/* Protecciones: los dos "ganchos" que el RTOS llama ante un desastre */
void vApplicationStackOverflowHook(TaskHandle_t t, char *nombre) { (void)t; fallo_fatal(FALLO_PILA, nombre); }
void vApplicationMallocFailedHook(void) { fallo_fatal(FALLO_MEMORIA, 0); }
Tres decisiones para destacar. Primera, las prioridades se escriben en un solo lugar con su motivo, como la tabla de interrupciones del tema 15; y la prioridad de la interrupción de la UART debe respetar el umbral que el RTOS admite para las ISR que llaman a sus funciones (configMAX_SYSCALL_INTERRUPT_PRIORITY). Segunda, las pilas se miden, no se adivinan: se empieza con valores generosos y, tras probar los peores casos, se reduce con uxTaskGetStackHighWaterMark(), que informa cuántas palabras no se usaron jamás. Tercera, los ganchos de desborde de pila y de falta de memoria llevan al equipo a un estado seguro (LED de alarma definido, salidas apagadas) y lo dejan a la espera de que el watchdog del tema 38 lo reinicie: ante un fallo grave, no seguir ejecutando con la memoria corrompida.
07 · Comprobar que funciona
Verificación y diagnóstico
| Prueba | Cómo se hace | Resultado esperado |
|---|---|---|
| Período de muestreo | Pin en alto durante la lectura (ráfaga 2) y analizador lógico durante 10 minutos | 100 ms de período, variación de ±1 ms (un tick), sin deriva |
| Reporte | Terminal a 115200 baudios | Una línea por segundo con el formato indicado |
| Pico aislado | Inyectar +8 °C en una sola muestra con el sensor simulado | La temperatura filtrada casi no se mueve; la alarma no cambia |
| Alarma con histéresis | Subir la temperatura hasta el umbral y mantenerla ruidosa alrededor | Una sola activación; se apaga al bajar medio grado, no antes |
| Comando válido | UMBRAL 25.5 | OK, y la alarma responde al nuevo umbral desde la siguiente muestra |
| Comando inválido | UMBRAL abc, UMBRAL 900, HOLA, una línea de 200 caracteres | ERR en todos los casos, sin afectar el muestreo ni el reporte |
| Ráfaga de entrada | Enviar 1000 bytes seguidos a 115200 baudios | rx_perdidos sigue en 0 |
| Sensor desconectado | Quitarlo en marcha | errores_sensor crece; el reporte sigue con el último estado; nada se cuelga |
| Pilas | uxTaskGetStackHighWaterMark de cada tarea tras todas las pruebas | Al menos un 25 % libre en cada una |
| Carga | Estadísticas de ejecución del RTOS (o un pin en la tarea inactiva) | Tarea inactiva por encima del 90 % del tiempo |
| Estado seguro | Reiniciar con el analizador conectado | LED apagado desde el primer instante, sin destellos |
Y los síntomas más frecuentes de este taller:
| Síntoma | Causa probable | Qué hacer |
|---|---|---|
| El equipo se cuelga al arrancar, antes de la primera línea | La ISR de la UART llama al RTOS con una prioridad más urgente que la admitida, o antes de crear las colas | Revisar la prioridad de la interrupción y el orden de main |
| Reinicios o comportamiento errático al cabo de un rato | Desborde de pila de alguna tarea (típico: printf o buffers locales grandes) | Activar la verificación de desborde y medir la marca de agua de cada pila |
| El período de muestreo es de 115 ms y no de 100 | vTaskDelay en lugar de vTaskDelayUntil | Usar la espera relativa al inicio de la vuelta |
| Líneas de reporte cortadas o mezcladas con las respuestas | Más de una tarea escribe directamente en la UART | Todas por la cola de salida; solo el guardián toca el transmisor |
| La alarma parpadea cuando la temperatura está cerca del umbral | Falta histéresis, o es más chica que el ruido residual | Aumentar la histéresis a por lo menos el semiancho del ruido filtrado |
| La alarma se dispara con picos aislados | Falta la etapa de mediana | Revisar filtro_paso: el pico debe ser eliminado antes del promedio |
| Al conectar una terminal con ráfagas, se pierden comandos | Cola de recepción chica, o parser con prioridad baja | Calcular la cola con la regla del tema 21; contar rx_perdidos |
08 · Analogía con programación
Probar el diseño en Python
Antes de grabar nada se pueden contestar con un programa tres preguntas. La primera: ¿el filtro hace su trabajo, y qué problema resuelve cada etapa? Se implementa el mismo algoritmo de enteros que el de C y se lo prueba con una señal sintética que sube de 20 a 32 °C con ruido y con picos, y con otra que se queda en el umbral. La segunda: ¿el conjunto de cinco tareas cumple sus plazos? Se calcula el tiempo de respuesta en el peor caso con la ecuación del tema 20 y se lo compara con una simulación expulsiva. La tercera: ¿cuánta RAM consume el RTOS?
Python en tu navegador. Los bloques son editables: cambiá los valores y presioná «Ejecutar Python». La primera vez tarda unos segundos en cargar.
from math import ceil
# --- Parte 1: la señal del sensor, el filtro y la alarma (todo en milésimas de °C, como en el firmware) ------------
def señal(n=300):
"""20 °C que suben a 32 °C en 30 s (10 muestras por segundo), con ruido de ±0,3 °C y un pico de +8 °C cada 37 muestras."""
semilla, salida = 12345, []
for i in range(n):
semilla = (semilla * 1103515245 + 12345) & 0x7FFFFFFF
ruido = (semilla >> 16) % 601 - 300 # -300 a +300 milésimas
base = 20000 + 12000 * i // (n - 1)
pico = 8000 if i % 37 == 36 else 0
salida.append(base + ruido + pico)
return salida
def mediana5(v):
return sorted(v)[2]
def filtrar(muestras, n=8):
"""Etapa 1: mediana de las últimas 5 muestras (elimina picos). Etapa 2: media móvil de n medianas (alisa el ruido)."""
ult5, ventana, salida = [], [], []
for x in muestras:
ult5 = (ult5 + [x])[-5:]
m = mediana5(ult5) if len(ult5) == 5 else x
ventana = (ventana + [m])[-n:]
salida.append(sum(ventana) // len(ventana))
return salida
def transiciones(serie, umbral, histeresis=0):
"""Cuántas veces cambia el estado de la alarma (enciende si T >= umbral; apaga si T < umbral - histéresis)."""
activa, cambios = False, 0
for t in serie:
if not activa and t >= umbral: activa, cambios = True, cambios + 1
elif activa and t < umbral - histeresis: activa, cambios = False, cambios + 1
return cambios
crudo = señal()
filtrado = filtrar(crudo)
UMBRAL = 30000
print("Parte 1: umbral de 30,000 °C; la temperatura real cruza el umbral una sola vez, subiendo")
print(f" señal cruda : {transiciones(crudo, UMBRAL):2d} cambios de estado de la alarma")
print(f" filtrada (mediana+media): {transiciones(filtrado, UMBRAL):2d} cambios")
print(f" filtrada + histéresis 0,5 °C: {transiciones(filtrado, UMBRAL, 500):2d} cambios")
print(f" crudo con histéresis 0,5 °C : {transiciones(crudo, UMBRAL, 500):2d} cambios (los picos de +8 °C igual la disparan)")
print(f" máximo de la señal cruda {max(crudo) / 1000:.3f} °C; de la filtrada {max(filtrado) / 1000:.3f} °C")
def estable(n=300):
"""Una temperatura que se queda justo en 30,000 °C, con el mismo ruido de ±0,3 °C (sin picos)."""
semilla, salida = 777, []
for _ in range(n):
semilla = (semilla * 1103515245 + 12345) & 0x7FFFFFFF
salida.append(30000 + (semilla >> 16) % 601 - 300)
return salida
est = estable()
est_f = filtrar(est, 4)
print()
print("Parte 1b: la temperatura real se queda en 30,000 °C (justo en el umbral)")
print(f" señal cruda : {transiciones(est, UMBRAL):3d} cambios de estado de la alarma")
print(f" cruda + histéresis 0,5 °C : {transiciones(est, UMBRAL, 500):3d} cambios")
print(f" filtrada (ventana 4) : {transiciones(est_f, UMBRAL):3d} cambios")
print(f" filtrada + histéresis 0,5 °C : {transiciones(est_f, UMBRAL, 500):3d} cambios")
# --- Parte 2: el conjunto de tareas: cálculo de tiempos de respuesta y comprobación por simulación -------------
def tareas(fs=10, n=8, c_reporte=0.4, baudios=115200, guardian_alto=False):
"""(nombre, C ms, T ms, D ms). El orden es la prioridad, de mayor a menor."""
t_m = 1000 / fs
lista = [("sensor", 0.3, t_m, min(20, t_m)),
("proceso", 0.3 + 0.04 * n, t_m, min(50, t_m)),
("parser", 0.2, 20, 100),
("reporte", c_reporte, 1000, 500),
("guardián", 400000 / baudios, 200, 1000)] # una línea de 40 caracteres: 400 bits
if guardian_alto:
lista.insert(0, lista.pop())
return lista
def respuesta(lista):
salida = {}
for i, (nombre, c, t, d) in enumerate(lista):
r = c
while True:
nuevo = c + sum(ceil(r / x[2] - 1e-9) * x[1] for x in lista[:i])
if abs(nuevo - r) < 1e-9 or nuevo > 50 * d: break
r = nuevo
salida[nombre] = nuevo
return salida
def simular(lista, duracion_ms=10000, U=100):
"""Todas las tareas se liberan juntas en t = 0 (el peor caso) y cada T ms. Expropiativo por prioridad."""
jobs = [dict(prio=p, nombre=n, lib=round(k * t * U), rem=round(c * U), fin=None)
for p, (n, c, t, d) in enumerate(lista) for k in range(int(duracion_ms // t))]
jobs.sort(key=lambda j: j["lib"])
t, i, listos, actual = 0, 0, [], None
while i < len(jobs) or listos:
while i < len(jobs) and jobs[i]["lib"] <= t:
listos.append(jobs[i]); i += 1
if not listos:
t = jobs[i]["lib"]; continue
actual = min(listos, key=lambda j: (j["prio"], j["lib"]))
t_sig = min(t + actual["rem"], jobs[i]["lib"]) if i < len(jobs) else t + actual["rem"]
actual["rem"] -= t_sig - t; t = t_sig
if actual["rem"] == 0:
actual["fin"] = t; listos.remove(actual)
res = {}
for j in jobs:
res[j["nombre"]] = max(res.get(j["nombre"], 0), (j["fin"] - j["lib"]) / U)
return res
def informe(titulo, lista):
r, s = respuesta(lista), simular(lista)
util = sum(c / t for _, c, t, _ in lista) * 100
print(f"{titulo} (utilización {util:.1f} %)")
for nombre, c, t, d in lista:
print(f" {nombre:9s} C={c:6.2f} T={t:6.0f} D={d:5.0f} | R calculado {r[nombre]:7.2f} ms | R simulado {s[nombre]:7.2f} ms | {'cumple' if r[nombre] <= d else 'PIERDE'}")
print()
print()
informe("Parte 2a: diseño del taller (10 Hz, filtro de 8, formateo a mano, 115200, guardián de baja prioridad)", tareas())
informe("Parte 2b: la misma UART a 9600 baudios, con el guardián como tarea de MAYOR prioridad", tareas(baudios=9600, guardian_alto=True))
informe("Parte 2c: 50 Hz, filtro de 32 y printf (2,5 ms) en el reporte", tareas(fs=50, n=32, c_reporte=2.5))
# --- Parte 3: presupuesto de memoria ---------------------------------------------------------------------------
def ram(printf=False):
pilas = {"sensor": 192, "proceso": 256, "parser": 256, "reporte": 512 if printf else 192, "guardián": 160, "idle": 128}
bytes_pilas = 4 * sum(pilas.values())
tcb = 100 * len(pilas)
colas = (8 * 8 + 80) + (128 + 80) + (16 * 1 + 80) + (8 * 48 + 80) # mensajes, rx, buzón de estado, tx (8 líneas de 48 bytes)
return bytes_pilas, tcb, colas, bytes_pilas + tcb + colas
print("Parte 3: presupuesto de RAM del RTOS (STM32F103C8: 20 480 bytes)")
for etiqueta, pf in (("formateo a mano", False), ("con printf ", True)):
p, t, c, total = ram(pf)
print(f" {etiqueta}: pilas {p:5d} + TCB {t:3d} + colas {c:3d} = {total:5d} bytes ({100 * total / 20480:.1f} % de la RAM)")
print(f" cola de recepción: en el peor intervalo de {respuesta(tareas())['parser']:.1f} ms llegan {11520 * respuesta(tareas())['parser'] / 1000:.0f} bytes; la cola tiene 128")
Resultado, parte 1: con una temperatura que sube y cruza el umbral una vez, la señal cruda hace 15 cambios de estado de la alarma (el ruido y los picos de +8 °C la encienden y apagan); con el filtro de mediana y media, 1. El máximo de la señal cruda llega a 39,619 °C por un pico, mientras que el de la filtrada es de 31,787 °C. Poner solo la histéresis a la señal cruda no alcanza (11 cambios): los picos de +8 °C superan cualquier histéresis razonable. En la parte 1b, con la temperatura real clavada en el umbral, la señal cruda hace 162 cambios; filtrada con ventana de 4 todavía hace 33; y filtrada más histéresis de 0,5 °C hace 1. El filtro y la histéresis resuelven problemas distintos (picos y rebote), y el diseño necesita los dos. Parte 2a: el diseño del taller usa el 3,7 % del procesador, y los tiempos de respuesta calculados y simulados coinciden: sensor 0,30 ms, proceso 0,92 ms, parser 1,12 ms, reporte 1,52 ms y guardián 4,99 ms, todos muy por debajo de sus plazos. En la parte 2b se pasa la UART a 9600 baudios y se le da al guardián la prioridad más alta: su línea tarda 41,67 ms y el sensor llega a 41,97 ms contra su plazo de 20: pierde. La misma UART lenta con el guardián en la prioridad más baja no le cuesta nada al sensor. En 2c, con 50 Hz, un filtro de 32 y printf en el reporte, todo sigue cumpliendo (la utilización sube a 12,4 %). Parte 3: el RTOS necesita unos 6248 bytes (30,5 % de la RAM) con formateo a mano y 7528 (36,8 %) con printf; y en el peor intervalo de la tarea del parser (1,1 ms) llegan 13 bytes, contra una cola de 128.
Probá cambiar el umbral o la ventana del filtro, hacer más agresivos los picos o el ruido, o agregar una sexta tarea con sus parámetros. Si algo no cumple, el programa te lo dice antes de que lo haga el equipo.
09 · Exploración interactiva
El presupuesto del diseño
El laboratorio aplica el cálculo de la parte 2 y el presupuesto de la parte 3 al conjunto de cinco tareas. Cambiá la frecuencia de muestreo, la ventana del filtro, la forma de formatear, la velocidad de la UART y la prioridad del guardián, y mirá qué tarea pierde su plazo, cuánto procesador se usa y cuánta RAM consume el RTOS. Los costos de cómputo son los valores supuestos del taller: en tu hardware se miden.
Cinco tareas, una UART y 20 KB de RAM
Cambiá las decisiones de diseño para ver su efecto en los plazos y la memoria.
Recorrido guiado
- Con los valores iniciales: el procesador se usa un 3,7 %, los cinco tiempos de respuesta son los de la tabla del apartado 2 (el guardián, 4,99 ms contra 1000) y la RAM del RTOS es del 30,5 %. Es un diseño con enorme holgura de tiempo: el recurso escaso de este producto es la memoria, no el procesador.
- Pasá el formateo a printf: el reporte se alarga a 3,62 ms de respuesta (nada grave), pero la RAM sube al 36,8 % por la pila más grande que exige. Un cambio «inocente» de una línea de código movió seis puntos de RAM.
- Subí la UART al revés: bajala a 9600 baudios con el guardián en la prioridad baja. Su línea tarda 41,7 ms, el guardián responde en 43,59 ms (cumple su plazo de 1000), y el sensor sigue en 0,30 ms. La tarea lenta solo se perjudica a sí misma.
- Ahora poné esa misma UART lenta con el guardián en la prioridad más alta: el sensor pasa a 41,97 ms y pierde su plazo de 20. El mismo código, las mismas tareas, el mismo procesador al 22,8 %: lo único que cambió es un número en una constante de prioridad. Es la lección de los temas 19 y 20 aplicada: una tarea lenta de prioridad alta degrada a todas las demás.
- Con todo en los valores iniciales, probá 50 Hz con el filtro de 32: el procesador sube al 12,2 % pero todo cumple (el procesamiento tarda 1,88 ms contra un plazo de 20). Aun con ese ritmo, el diseño aguanta; el cuello de botella no es la CPU sino, otra vez, la memoria y la UART.
- Probá combinaciones y buscá cuál es la primera tarea en perder el plazo si hacés cada cambio solo. Es un ejercicio útil para saber dónde está el límite del diseño antes de que lo encuentre un usuario.
Desafío: ¿cómo mandar líneas a 9600 baudios sin que el guardián pierda tiempo?
El guardián bloqueante gasta 41,7 ms de procesador por línea esperando que salga cada carácter, aunque lo haga con baja prioridad (en el cálculo cuenta como 22,8 % de utilización). La solución profesional es hacer que la transmisión sea por interrupción o por DMA: el guardián copia la línea a un buffer circular de transmisión (tema 16) y la interrupción del transmisor la va sacando carácter a carácter, sin ocupar al procesador. El guardián despierta con un semáforo cuando termina. Con eso la utilización de la UART baja a la fracción de microsegundos por carácter de las interrupciones, y la tarea guardián deja de depender de la velocidad de la línea.
10 · Método de trabajo
Método para repartir en tareas
- Listá las actividades del producto y, para cada una, su ritmo (periódica o por evento) y su plazo. «Medir cada 100 ms, terminar en 20 ms» es una especificación; «medir seguido», no.
- Agrupá por responsabilidad y por ritmo. Una tarea por cosa que se hace a su propio ritmo. No una tarea por periférico, ni una tarea gigante que lo haga todo.
- Dibujá el flujo de datos como en el apartado 2: tareas como cajas, conductos como flechas. Cada flecha es una cola, un buzón o una notificación.
- Asigná un dueño a cada dato. Si dos tareas necesitan modificarlo, una de ellas debería pedírselo a la otra por un mensaje.
- Buscá ciclos en el grafo de quién espera a quién (tema 22). Un ciclo es un riesgo de interbloqueo: el flujo debe ir en un solo sentido.
- Asigná prioridades por plazo, la tarea más lenta al final, y escribilas en un solo archivo con el motivo.
- Estimá
C,TyDde cada tarea y calculá el tiempo de respuesta con la ecuación del tema 20, sumando la carga de las interrupciones. Confirmalo con una simulación. - Dimensioná colas y pilas: las colas con la cuenta del tema 21, las pilas generosas al principio. Hacé el presupuesto de RAM antes de que sea un problema.
- Escribí primero las funciones puras (filtro, alarma, formato, análisis de comandos) y probalas en la PC. Después, las tareas, que quedan cortas porque solo conectan cosas.
- Agregá las protecciones: ganchos de desborde y de memoria, contadores de pérdidas y de errores, tiempos máximos en las esperas y, en el próximo bloque, el watchdog.
- Verificá con mediciones, incluyendo los casos malos (basura por la UART, sensor ausente, ráfagas) y la marca de agua de cada pila.
11 · Comprensión
Confusiones frecuentes
«Cuantas más tareas, mejor organizado está el diseño»
Cada tarea cuesta una pila (el 3 al 5 % de la RAM de un chip de 20 KB) y agrega comunicación y cambios de contexto. Se agrega una tarea cuando hay un ritmo o una responsabilidad distintos, no por cada módulo. Cinco tareas bien elegidas ganan a veinte.
«La tarea del sensor es la más importante, así que tiene la prioridad más alta»
Casualmente, en este diseño la tiene, pero por otro motivo: su plazo (20 ms) es el más corto. Si la tarea que lee el sensor tuviera un plazo de 1 s, tendría una prioridad más baja que el procesamiento, por muy importante que sea la temperatura.
«Una variable global compartida es más simple que una cola»
Hasta que aparece una carrera. La variable parece más corta de escribir, pero obliga a pensar en atomicidad, volatile, orden y mutex en cada acceso (temas 16 y 22). La cola y el buzón cuestan unas líneas más y eliminan esa clase de defectos por diseño.
«El printf es inofensivo; es solo formatear un texto»
Usa mucha pila, no es reentrante sin cuidados, puede reservar memoria dinámica y tarda cientos de microsegundos. En una tarea con plazo corto es un riesgo; en un firmware pequeño, un gasto de RAM visible. Formatear a mano es corto y predecible.
«Una cola más grande resuelve cualquier pérdida de datos»
Solo si el problema es una ráfaga que el consumidor puede absorber después. Si el productor es más rápido que el consumidor de forma sostenida, ninguna cola alcanza. Antes de agrandar, se hace la cuenta y se pregunta por qué el consumidor llega tarde.
«Con un RTOS ya no necesito un watchdog»
Al contrario: con varias tareas aparecen más formas de que una parte del sistema quede detenida sin que el resto lo note (tema 22). El watchdog debería comprobar que todas las tareas críticas hacen progreso (tema 38).
«Si pasa las pruebas con el sensor simulado, ya está»
La simulación no tiene los tiempos de bus reales, el ruido real, las desconexiones ni los reinicios del hardware. Sirve para desarrollar y para probar la lógica; la verificación final se hace con el hardware y con los casos malos.
12 · Práctica guiada
Ejercicios y variantes
1. Cambiar la frecuencia de muestreo por comando
Agregá el comando RATE 20 para pasar a 20 muestras por segundo. ¿Quién es el dueño del período y cómo le llega el cambio?
Ver solución
El dueño es la tarea del sensor, que es quien espera. El parser no debe tocar una variable global: le envía un mensaje. Como la tarea del sensor no tiene cola de entrada, hay dos opciones: darle una cola propia de comandos (revisándola con espera cero en cada vuelta) o usar una notificación de tarea con valor (xTaskNotify, modo sobrescribir) que la tarea lee al final de cada vuelta con xTaskNotifyWait sin espera. En ambos casos el período nuevo se aplica en la vuelta siguiente, y se valida el rango (por ejemplo de 1 a 100 Hz) en el parser.
2. El parser más lento
Si el análisis de cada línea tardara C = 1,5 ms en lugar de 0,2 ms, ¿cuánto vale el tiempo de respuesta del parser y el del guardián? ¿Siguen cumpliendo?
Ver solución
Parser: R = 1,5 + 0,3 (sensor) + 0,62 (proceso) = 2,42 ms, contra un plazo de 100. Guardián: R = 3,47 + 0,3 + 0,62 + ⌈R/20⌉·1,5 + ⌈R/1000⌉·0,4; con una iteración da 3,47 + 0,92 + 1,5 + 0,4 = 6,29 ms. Todo sigue cumpliendo con enorme margen. El diseño es robusto porque las tareas que podrían tardar más tienen plazos holgados; ése es el beneficio de repartir por plazos.
3. ¿Hay riesgo de interbloqueo?
Dibujá el grafo de quién envía a quién en el diseño del taller y buscá ciclos. ¿Qué pasaría si el guardián enviara confirmaciones de transmisión de vuelta al parser?
Ver solución
El flujo es: ISR → parser → proceso; sensor → proceso; proceso → buzón → reporte y parser; reporte y parser → guardián. No hay ciclos: todo va «hacia adelante», y el buzón nunca bloquea al escritor. Si el guardián enviara confirmaciones al parser por una cola con espera ilimitada, aparecería el ciclo parser → guardián → parser: con las dos colas llenas, ambas tareas quedarían esperándose para siempre (el caso de colas que se esperan del tema 22). Se evitaría con envíos sin espera en un sentido, o con otro diseño.
4. El presupuesto de RAM
Si la cola de salida pasa a 16 líneas de 48 bytes y el formateo es con printf, ¿cuánta RAM ocupa el RTOS? ¿Qué fracción de 20 KB?
Ver solución
Pilas con printf: 6016 bytes. TCB: 600. Colas: mensajes 144 + recepción 208 + buzón 96 + salida (16 × 48 + 80 = 848) = 1296. Total: 6016 + 600 + 1296 = 7912 bytes, el 38,6 % de los 20 480. Un cambio de dos números en el código movió ocho puntos de RAM respecto del diseño base (30,5 %). Los números por tarea y por cola se mantienen en una hoja del proyecto, y se revisan en cada cambio.
5. La histéresis mínima
La señal tiene un ruido de ±0,3 °C y la temperatura real se queda exactamente en el umbral. ¿Cuál es la histéresis mínima para que la alarma no rebote? ¿Y si el filtro reduce el ruido a ±0,1 °C?
Ver solución
La alarma se apaga cuando la temperatura medida baja de umbral − histéresis. Con la temperatura real justo en el umbral, la medida llega a umbral − 0,3 °C como mínimo, así que alcanza una histéresis de 0,3 °C (el semiancho del ruido) para que nunca se apague por ruido; en el taller se usa 0,5 °C para tener margen. Con el filtro dejando ±0,1 °C bastaría 0,1 °C. La regla: la histéresis debe ser al menos el semiancho máximo del ruido que llega a la comparación; menos, rebota; mucho más, la alarma tarda en apagarse y puede no reflejar el estado real.
6. El período que se estira
Si en la tarea del sensor se reemplaza vTaskDelayUntil(&t0, 100 ms) por vTaskDelay(100 ms), ¿cuál es el período real y cuántas muestras por segundo se obtienen?
Ver solución
Cada vuelta dura la dormida de 15 ms, las ráfagas de trabajo (unos 0,3 ms) y la espera de 100 ms: unos 115,3 ms, es decir, 8,7 muestras por segundo en lugar de 10, y el período además varía con la interferencia de las demás tareas. Con vTaskDelayUntil el período se mide desde el instante teórico de inicio de la vuelta y es de 100 ms exactos en promedio, igual que el proximo += periodo del tema 18.
13 · Síntesis
Ideas para recordar
- Un firmware con varias tareas se diseña antes de programarse: qué tareas hay (por responsabilidad y por ritmo), cómo se hablan (colas, buzón, notificaciones) y quién es dueño de cada dato. Con dueños únicos y mensajes, no hacen falta mutex ni variables compartidas.
- El buzón (cola de un elemento con sobrescritura) comparte «la última medida» sin bloquear al productor; una cola de entrada con mensajes etiquetados permite que una tarea espere en un solo lugar varios orígenes.
- Las prioridades se asignan por plazo y la tarea más lenta va al final: el guardián de una UART lenta con baja prioridad solo se estira a sí mismo; con prioridad alta, hace perder el plazo del sensor aunque todo el resto sea igual.
- Con el filtro, cada etapa tiene su función: la mediana elimina picos y la media alisa el ruido. La histéresis impide el rebote de la alarma cuando la señal está cerca del umbral; resuelven problemas distintos y el diseño necesita ambos.
- El código numérico va en funciones puras de enteros, probables en la PC; las tareas son cortas porque solo conectan; el formateo a mano evita el printf, que gasta pila y no es reentrante.
- El arranque sigue un orden: hardware, objetos, tareas, interrupciones, planificador. Las protecciones (ganchos de desborde y memoria, contadores de pérdidas y errores, tiempos máximos) llevan el equipo a un estado seguro ante un fallo.
- Los plazos se calculan con la ecuación del tiempo de respuesta y se confirman con una simulación; la RAM se presupuesta (pilas, bloques de control, colas) antes de que falte. En este diseño el recurso escaso es la memoria, no el procesador.
- Se verifica con mediciones y con los casos malos: picos, basura por la UART, sensor ausente, ráfagas, marca de agua de las pilas.
¿Listo para el bloque siguiente?
Antes de avanzar, repartí en tareas un producto que conozcas (una cerradura, un cargador, un lavarropas): listá las tareas, dibujá los conductos, asigná prioridades por plazo y calculá una cota de respuesta para la más exigente. Si podés justificar cada decisión, dominás el bloque de tiempo real.
En el próximo bloque ordenamos la lógica: las máquinas de estados finitos (tema 24), la herramienta que modela el comportamiento de un dispositivo en estados, eventos y transiciones, y que vas a necesitar dentro de casi cualquiera de las tareas de este tema.