← Índice del curso

Sistemas Embebidos · Tema 23 · Taller

Taller: un firmware con varias tareas

Cierra el bloque de tiempo real con un proyecto completo: un monitor de temperatura que lee un sensor, filtra las muestras, decide cuándo disparar una alarma y conversa con una computadora por UART. Cada responsabilidad es una tarea, se hablan por colas y un buzón, y los plazos se calculan antes de grabar. Es el lugar donde se juntan los temas 15 a 22.

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:

Arquitectura del monitor de temperatura La tarea del sensor envía muestras a una cola de entrada de la tarea de procesamiento. La interrupción de la UART envía bytes a una cola de recepción que consume la tarea del parser, y el parser envía comandos a la misma cola de entrada del procesamiento. El procesamiento publica el estado en un buzón de un solo lugar. La tarea de reporte y el parser leen el buzón y envían líneas a una cola de salida que consume la tarea guardián de la UART. tarea sensorprio 5 · 10 Hz tarea procesoprio 4 · por mensaje tarea reporteprio 2 · 1 Hz ISRUART RX tarea parserprio 3 · por byte tarea guardiánprio 1 · UART TX LED y UART(hardware) cola_proceso (muestras y comandos) buzónde estado cola_rx cola_tx (líneas)

Los cuatro conductos entre tareas se eligen según lo que viaja, con los criterios del tema 21:

Los conductos de datos del diseño.
ConductoQuién escribeQuién leePor qué ese mecanismo
cola_rx (128 bytes)La ISR de la UARTTarea del parserUna 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 parserTarea de procesamientoUna sola cola de entrada con mensajes etiquetados: la tarea espera en un solo lugar muestras y comandos
buzon_estado (1 elemento)Tarea de procesamientoReporte y parserSolo 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 parserTarea guardiánCada 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»:

Tareas, prioridades y parámetros de tiempo (C = peor caso de CPU; T = período o separación mínima entre activaciones; D = plazo).
TareaPrio.CTDPilaPor qué ese plazo
sensor50,3 ms100 ms20 ms768 BLa lectura debe hacerse en una ventana fija tras disparar la conversión, o el muestreo pierde regularidad
proceso40,62 ms100 ms50 ms1 KBLa alarma debe reaccionar en menos de medio período de muestreo
parser30,2 ms20 ms100 ms1 KBSin esto la cola de recepción se desborda; el usuario percibe 100 ms como inmediato
reporte20,4 ms1000 ms500 ms768 BSe publica cada segundo; media vuelta de margen
guardián13,5 ms200 ms1000 ms640 BSin 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

Pruebas de aceptación del monitor.
PruebaCómo se haceResultado esperado
Período de muestreoPin en alto durante la lectura (ráfaga 2) y analizador lógico durante 10 minutos100 ms de período, variación de ±1 ms (un tick), sin deriva
ReporteTerminal a 115200 baudiosUna línea por segundo con el formato indicado
Pico aisladoInyectar +8 °C en una sola muestra con el sensor simuladoLa temperatura filtrada casi no se mueve; la alarma no cambia
Alarma con histéresisSubir la temperatura hasta el umbral y mantenerla ruidosa alrededorUna sola activación; se apaga al bajar medio grado, no antes
Comando válidoUMBRAL 25.5OK, y la alarma responde al nuevo umbral desde la siguiente muestra
Comando inválidoUMBRAL abc, UMBRAL 900, HOLA, una línea de 200 caracteresERR en todos los casos, sin afectar el muestreo ni el reporte
Ráfaga de entradaEnviar 1000 bytes seguidos a 115200 baudiosrx_perdidos sigue en 0
Sensor desconectadoQuitarlo en marchaerrores_sensor crece; el reporte sigue con el último estado; nada se cuelga
PilasuxTaskGetStackHighWaterMark de cada tarea tras todas las pruebasAl menos un 25 % libre en cada una
CargaEstadísticas de ejecución del RTOS (o un pin en la tarea inactiva)Tarea inactiva por encima del 90 % del tiempo
Estado seguroReiniciar con el analizador conectadoLED apagado desde el primer instante, sin destellos

Y los síntomas más frecuentes de este taller:

Síntomas frecuentes y qué revisar.
SíntomaCausa probableQué hacer
El equipo se cuelga al arrancar, antes de la primera líneaLa ISR de la UART llama al RTOS con una prioridad más urgente que la admitida, o antes de crear las colasRevisar la prioridad de la interrupción y el orden de main
Reinicios o comportamiento errático al cabo de un ratoDesborde 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 100vTaskDelay en lugar de vTaskDelayUntilUsar la espera relativa al inicio de la vuelta
Líneas de reporte cortadas o mezcladas con las respuestasMás de una tarea escribe directamente en la UARTTodas por la cola de salida; solo el guardián toca el transmisor
La alarma parpadea cuando la temperatura está cerca del umbralFalta histéresis, o es más chica que el ruido residualAumentar la histéresis a por lo menos el semiancho del ruido filtrado
La alarma se dispara con picos aisladosFalta la etapa de medianaRevisar filtro_paso: el pico debe ser eliminado antes del promedio
Al conectar una terminal con ráfagas, se pierden comandosCola de recepción chica, o parser con prioridad bajaCalcular 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

sensor—
proceso—
parser—
reporte—
guardián UART—
Procesador—
RAM del RTOS—
Resultado—

Cambiá las decisiones de diseño para ver su efecto en los plazos y la memoria.

Recorrido guiado

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. Asigná prioridades por plazo, la tarea más lenta al final, y escribilas en un solo archivo con el motivo.
  7. Estimá C, T y D de 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.
  8. 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.
  9. 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.
  10. 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.
  11. 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.