← Índice del curso

Sistemas Embebidos · Tema 19

Planificación cooperativa

En el taller anterior escribiste, casi sin darte cuenta, un planificador: una tabla de funciones con su período y un bucle que las ejecuta cuando vencen. Este tema lo convierte en una herramienta con reglas: qué datos describen una tarea, cómo se decide el orden, cuánto puede durar cada una sin arruinar a las demás y cómo se calcula, antes de probar, si un conjunto de tareas va a cumplir sus plazos.

01 · Punto de partida

Cooperar, no expropiar

Un planificador (scheduler) es la parte del programa que decide qué tarea usa el procesador en cada momento. Hay dos grandes familias, y la diferencia entre ellas es una sola pregunta: ¿puede el planificador quitarle el procesador a una tarea que está a mitad de su trabajo?

  • En la planificación expropiativa (preemptive), sí: si aparece una tarea más urgente, el sistema suspende a la que corre, guarda su contexto y pasa a la nueva. Es la que implementa un sistema operativo de tiempo real (tema 20).
  • En la planificación cooperativa, no: una tarea en marcha sigue hasta que ella misma devuelve el control, normalmente terminando su función. El planificador solo decide cuál sigue, nunca interrumpe.

La palabra «cooperativa» describe el pacto: cada tarea promete ser breve y no quedarse esperando, y a cambio el sistema es simple, usa una sola pila, no necesita bloqueos entre tareas y es fácil de entender y de depurar. Pero el pacto tiene un precio que se paga con exactitud matemática: una tarea corta y urgente puede quedar esperando detrás de una tarea larga y poco importante, y no hay nada que el planificador pueda hacer al respecto. Todo este tema es sobre cómo vivir con esa limitación y cuándo deja de ser tolerable.

Seguimos en el mismo proyecto del tema 18 y con el mismo STM32F103 como referencia; el planificador en sí es C estándar y funciona en cualquier microcontrolador.

02 · Hablar el mismo idioma

El modelo de una tarea

Para razonar sobre tiempos hay que describir cada tarea con unos pocos números. Son los mismos que usa la teoría de planificación de tiempo real, y conviene adoptar su vocabulario porque es el que vas a encontrar en cualquier libro o documentación:

Los parámetros de una tarea periódica.
SímboloNombreSignificadoEjemplo: el lazo de control
CDuración (tiempo de cómputo)Cuánto tarda cada ejecución en el peor caso (WCET)0,4 ms
TPeríodoCada cuánto se libera una nueva ejecución10 ms
DPlazo (deadline)Cuánto después de la liberación debe haber terminado5 ms
φFase (offset)Desplazamiento del primer instante de liberación respecto del arranque0 ms
RTiempo de respuestaDesde la liberación hasta el fin de la ejecución; es lo que hay que comparar con Dlo que calcularemos
jitterVariaciónCuánto cambia el instante real de inicio entre ejecucionesdepende del conjunto

Dos observaciones. Primero, el plazo no tiene por qué coincidir con el período: un lazo de control puede ejecutarse cada 10 ms pero requerir que cada ejecución termine dentro de los 5 ms de su liberación, porque su salida se usa a la mitad del ciclo. Si no se dice otra cosa, se toma D = T. Segundo, C es siempre el peor caso, no el promedio: un sistema se dimensiona con el caso malo, y el promedio solo sirve para estimar el consumo de energía.

Con estos números se define la utilización del procesador:

U = C₁/T₁ + C₂/T₂ + … + Cₙ/Tₙ        (la fracción del tiempo que las tareas necesitan)

Si U > 1 el conjunto es imposible con cualquier planificador: no hay procesador que alcance. Si U < 1, podría ser posible, pero no está garantizado: la utilización dice si hay suficiente tiempo en total, no si cada tarea llega a tiempo. Esa diferencia es el centro de este tema y se verá en el laboratorio: un conjunto con 37,5 % de utilización puede perder plazos en forma sistemática.

Además de las tareas periódicas hay otros dos tipos. Las esporádicas o por eventos se ejecutan cuando ocurre algo (un byte recibido, un botón) y no cada cierto tiempo; se modelan con un período mínimo entre ocurrencias. Y las de fondo (background) hacen trabajo sin plazo, como un diagnóstico o una compresión, y se ejecutan cuando no hay nada más urgente. Todas se tratan con el mismo planificador.

03 · La herramienta

Un planificador por tiempo

El de este apartado es el del tema 18 pero con las decisiones de diseño explícitas. Tiene tres características que lo definen, y las tres son elecciones que podrían haber sido otras:

  1. Es disparado por tiempo (time-triggered): lo único que hace que una tarea se libere es que el reloj llegue a su instante. No hay tareas que se activen unas a otras de forma impredecible, lo que hace al sistema determinista.
  2. La prioridad es el orden de la tabla. Cuando hay varias tareas listas, corre la que está primero. Es el mecanismo más simple posible y alcanza para casi todo; solo hay que poner arriba las de plazo más corto.
  3. Después de cada tarea se vuelve a decidir. En lugar de recorrer la tabla ejecutando todo lo vencido, el planificador ejecuta una tarea y vuelve a empezar a buscar desde arriba. Así, si mientras corría una tarea larga vencieron varias, la más prioritaria pasa primero.
/* src/tareas.h */
typedef enum { POLITICA_RAFAGA, POLITICA_SALTAR } Politica;

typedef struct {
    void     (*funcion)(void);
    uint32_t periodo_ms;
    uint32_t fase_ms;          /* desplazamiento del primer instante, para repartir las tareas en el tiempo */
    Politica politica;         /* qué hacer si la tarea llegó tarde por más de un período */
    uint32_t proximo;          /* instante teórico de la próxima liberación (lo maneja el planificador) */
    bool     habilitada;
    uint32_t ejecuciones, perdidas, duracion_max, atraso_max;   /* estadísticas (tema 18) */
} Tarea;

void planificador_iniciar(Tarea *tabla, unsigned cantidad);
bool planificador_paso(void);  /* ejecuta como máximo UNA tarea; devuelve false si no había ninguna lista */

/* src/tareas.c */
bool planificador_paso(void) {
    uint32_t ahora = tick_ms();
    for (unsigned i = 0; i < n; i++) {                       /* el orden de la tabla es la prioridad */
        Tarea *t = &tabla[i];
        if (!t->habilitada) continue;
        int32_t atraso = (int32_t)(ahora - t->proximo);
        if (atraso < 0) continue;                             /* todavía no le toca */

        if (atraso >= (int32_t)t->periodo_ms) {               /* llegó tarde por más de un período entero */
            t->perdidas++;
            if (t->politica == POLITICA_SALTAR) t->proximo = ahora;   /* resincronizar: descartar lo atrasado */
        }
        t->proximo += t->periodo_ms;                          /* por el instante teórico, no por 'ahora' */
        if ((uint32_t)atraso > t->atraso_max) t->atraso_max = (uint32_t)atraso;

        uint32_t c0 = DWT_CYCCNT;
        t->funcion();
        uint32_t dur = DWT_CYCCNT - c0;
        if (dur > t->duracion_max) t->duracion_max = dur;
        t->ejecuciones++;
        return true;                                         /* volver a decidir desde la tabla */
    }
    return false;
}

/* src/main.c */
int main(void) {
    /* ... inicialización ... */
    while (1) {
        if (!planificador_paso()) { __asm volatile ("wfi"); }   /* sin trabajo: dormir hasta la próxima interrupción */
    }
}

Lo que cambia respecto del tema 18, y por qué:

  • La política ante el atraso es una decisión por tarea. Una tarea que se retrasó más de un período puede recuperarse en ráfaga (ejecutarse varias veces seguidas hasta alcanzar el reloj; conviene para un contador de eventos periódicos) o saltar las ejecuciones perdidas (conviene para un lazo de control, donde una salida atrasada no tiene valor). Y en ambos casos se cuenta en perdidas: un atraso no debe pasar inadvertido.
  • La fase permite repartir las tareas en el tiempo para que no coincidan todas en el mismo milisegundo. Dos tareas de 10 ms con fase 0 compiten cada vez; con fases de 0 y 5 ms, casi nunca. Se verá que la fase puede reducir el bloqueo, pero no lo elimina.
  • Si no hay ninguna tarea lista, el procesador duerme. La instrucción wfi («esperar una interrupción») detiene el núcleo hasta el próximo evento; el tick de 1 ms lo despierta a lo sumo un milisegundo después. Con la mayoría del tiempo libre (la utilización del taller era de menos del 1 %), el procesador pasa dormido casi todo el tiempo, y ése es el primer paso hacia el bajo consumo del tema 34.

Una propiedad que ya se ve en el código y que simplifica mucho la vida: las tareas nunca se interrumpen entre sí. Dos tareas pueden compartir una variable sin volatile, sin sección crítica y sin ningún mecanismo: nunca hay una a mitad de camino mientras la otra lee. Los problemas del tema 16 aparecen solo en la frontera con las ISR.

04 · La disciplina

Tareas que nunca se bloquean

La cooperación se rompe con una sola línea: cualquier esperar_ms(), cualquier bucle while (!listo) {}, cualquier lectura que quede girando hasta que el periférico responda. Mientras la tarea espera, todas las demás esperan, y sus plazos corren. La regla es absoluta: una tarea hace lo que puede hacer ahora, y si debe esperar algo, anota que está esperando y devuelve el control. La próxima vez que el planificador la llame, vuelve a mirar.

Las formas de bloqueo más comunes y su reemplazo cooperativo.
Lo que bloqueaPor quéCómo se evita
Esperar un tiempo (esperar_ms(200))El procesador no hace nada útil durante 200 msGuardar el instante y salir; volver a mirar con tick_vencido()
Esperar a un periférico (conversión del sensor, bit de la UART)El tiempo de espera lo fija el periféricoPedir la operación, salir, y volver más tarde a recoger el resultado (o que lo avise una interrupción)
Enviar una cadena larga por una UART lenta64 bytes a 115200 baudios son 5,6 msCopiar al buffer circular de transmisión y salir; la interrupción envía
Un cálculo largo (un filtro, un redibujo de pantalla)Su duración no se puede interrumpirTrocear: un pedazo por llamada
Escribir en Flash o EEPROMCada escritura tarda milisegundosUna máquina de estados: iniciar, consultar «¿terminó?» en cada llamada, seguir
Reintentar una operación hasta que salgaSi nunca sale, el bucle no terminaUn reintento por llamada, con un contador de intentos

La técnica común a todos los casos es una máquina de estados (tema 24) dentro de la tarea: una variable static recuerda en qué punto del trabajo quedó, y cada llamada avanza lo que puede. Tres ejemplos, cada uno reemplazando una espera:

/* 1) Una secuencia con esperas: MAL (bloquea 1 s) */
void secuencia_mal(void) { led_encender(); esperar_ms(200); led_apagar(); esperar_ms(800); }

/* 1) BIEN: una tarea de 10 ms que recuerda desde cuándo está en cada estado */
void tarea_secuencia(void) {
    static enum { APAGADO, ENCENDIDO } estado = APAGADO;
    static uint32_t desde;
    switch (estado) {
    case APAGADO:   if (tick_vencido(desde, 800)) { led_encender(); desde = tick_ms(); estado = ENCENDIDO; } break;
    case ENCENDIDO: if (tick_vencido(desde, 200)) { led_apagar();   desde = tick_ms(); estado = APAGADO;   } break;
    }
}

/* 2) Un redibujo de pantalla de 30 ms, partido en diez franjas de 3 ms */
void tarea_pantalla(void) {                    /* período de 10 ms */
    static uint8_t franja;
    pantalla_dibujar_franja(franja);           /* 1/10 del trabajo: unos 3 ms */
    franja = (uint8_t)((franja + 1) % 10);
}

/* 3) Un sensor cuya conversión tarda 12 ms: pedir y recoger en dos tiempos */
void tarea_sensor(void) {                      /* período de 5 ms */
    static enum { PEDIR, ESPERAR } estado = PEDIR;
    static uint32_t pedido;
    if (estado == PEDIR) {
        sensor_iniciar_conversion();           /* no espera: solo la dispara */
        pedido = tick_ms();
        estado = ESPERAR;
    } else if (tick_vencido(pedido, 15)) {     /* ya pasaron los 12 ms de conversión */
        medida = sensor_leer();
        estado = PEDIR;
    }                                          /* si todavía no pasó, no hace nada y devuelve el control */
}

En los tres casos la tarea tarda microsegundos o unos pocos milisegundos en cada llamada, y el trabajo total sigue siendo el mismo; lo que cambia es que se reparte en el tiempo. Hay un costo: el código se vuelve menos lineal (una secuencia que se leía de arriba abajo pasa a ser un switch), y es el motivo por el cual existen los sistemas operativos: dan a cada tarea la ilusión de un flujo lineal y bloqueante. Hasta ahí, es una cuestión de elegir cuándo la complicación vale la pena.

Los trozos deben ser elegidos con criterio. El tamaño del trozo más grande de todo el sistema fija el bloqueo máximo para todas las tareas más urgentes, como se calcula en el apartado siguiente. Cortar una tarea de 30 ms en dos de 15 no sirve de nada para un plazo de 5 ms.

05 · Antes de probar, calcular

Cuánto puede durar una tarea

Probar que un conjunto de tareas cumple sus plazos ejecutándolo tiene un problema ya visto en el tema 16: lo que se observa es un camino entre muchos posibles, y el peor puede no aparecer jamás en el escritorio. Con planificación cooperativa se puede razonar sobre el peor caso con un argumento sencillo. Tomá una tarea i que se libera en un instante cualquiera. Para terminar necesita, en el peor caso, esperar tres cosas:

  1. El bloqueo (B): si justo antes de su liberación empezó a ejecutarse una tarea de menor prioridad, no se la puede interrumpir, y hay que esperarla entera. En el peor caso, es la más larga de todas las de menor prioridad: B = máx Cj para las j debajo de i en la tabla.
  2. La interferencia (I): las tareas de mayor prioridad que se liberen mientras tanto pasan antes. Si i tarda w en empezar, cada tarea superior j pudo liberarse ⌊w/Tj⌋ + 1 veces y cada una cuesta Cj.
  3. Su propia duración (Ci).

El tiempo de espera hasta empezar se calcula iterando w = B + Σ (⌊w/Tj⌋ + 1) · Cj hasta que deja de cambiar, y la cota es Ri = w + Ci. La condición de que la tarea cumple siempre es Ri ≤ Di. Para la tarea más prioritaria no hay interferencia, y la cuenta se reduce a una desigualdad que se recuerda fácilmente:

C(tarea más urgente) + C(tarea más larga de las demás) ≤ D(tarea más urgente)

El conjunto del laboratorio de este tema lo ilustra. Cuatro tareas: el lazo de control (C = 0,4 ms, T = 10, D = 5), el botón (0,05 / 10 / 10), un sensor (1,5 / 50 / 50) y la pantalla. Con la pantalla entera (30 ms cada 100 ms), la cota del lazo de control es 30 + 0,4 = 30,4 ms contra un plazo de 5: no cumple, y la simulación confirma que pierde 20 de cada 100 ejecuciones. Con la pantalla en 10 trozos de 3 ms, la cota baja a 3 + 0,4 = 3,4 ms y cumple. Con 5 trozos de 6 ms, la cota es 6,4 ms: no alcanza, aunque la utilización sea la misma (37,5 %) en los tres casos.

Una tarea larga bloquea a la tarea urgente Línea de tiempo de 0 a 40 milisegundos. En la fila superior, una tarea de pantalla de 30 milisegundos ocupa el procesador de 2 a 32 milisegundos y las liberaciones del lazo de control en 10, 20 y 30 milisegundos esperan hasta que termine. En la fila inferior, la pantalla troceada en bloques de 3 milisegundos deja huecos en los que el lazo corre apenas se libera. PANTALLA 30 ms: nada más puede correr 3 ms3 ms3 ms3 ms 010203040 ms CTRLCTRLCTRL CTRLCTRLCTRL EnteraEn trozos

Seis consecuencias prácticas que salen de esta cuenta:

  • La tarea más larga del sistema es la que manda. Su duración se suma al tiempo de respuesta de todas las que tengan menor plazo. Un sensor lento o una pantalla pesada degradan a un lazo que ni los conoce.
  • La utilización es una condición necesaria, no suficiente. Un 37,5 % de uso puede ser inviable; un 90 % puede ser viable si todas las tareas son cortas. Es la duración de cada tarea, comparada con los plazos, lo que decide.
  • Trocear tiene un mínimo. Para el lazo de plazo 5 ms hace falta un bloqueo de a lo sumo 5 − 0,4 = 4,6 ms, así que la pantalla de 30 ms necesita al menos ⌈30 / 4,6⌉ = 7 trozos. Diez de 3 ms dejan margen.
  • Una prueba que sale bien no demuestra nada. Con fase 0 el lazo de control siempre se libera cuando la pantalla todavía no empezó, y parece cumplir con trozos de 6 ms; con la pantalla desfasada 9 ms, el trozo ya está corriendo cuando el lazo se libera y se pierde un plazo cada 20 ms. La cota lo anticipa; la prueba en el banco, no necesariamente.
  • Las interrupciones se suman. La cota supone que las tareas corren sin interrupciones; en la práctica cada ISR las estira. Si las ISR consumen un 5 % del procesador, las duraciones efectivas son un 5 % mayores o más, y los plazos ajustados al límite dejan de cumplirse. Se suele dejar un margen del 30 % o más.
  • La cota es conservadora. En el caso de la pantalla entera el simulador midió 20,85 ms y la cota es 30,4: asume la coincidencia más desfavorable, que puede no ocurrir nunca. Cuando la cota dice que cumple, se puede dormir tranquilo; cuando dice que no, hay que mirar con más detalle o reducir la tarea larga.

06 · Verificación en el equipo

Medir y proteger

Los números C del cálculo anterior no se adivinan: se miden. Una tabla de tareas bien instrumentada es la mejor herramienta de depuración que puede tener un firmware:

  • Duración máxima de cada tarea. El contador de ciclos DWT del tema 18, guardando el máximo (duracion_max) tras someter al sistema a sus peores casos (todas las entradas activas, los datos más grandes). Es el C real.
  • Atraso máximo de cada tarea (atraso_max): la diferencia entre el instante teórico y el real. Para una tarea de plazo D, atraso_max + duracion_max debe quedar por debajo de D.
  • Contador de plazos perdidos (perdidas): una tarea que se atrasó más de un período no debe pasar silenciosa. Un contador visible por el puerto de depuración convierte un problema invisible en un dato.
  • Un pin por tarea. Poner un pin en alto al entrar a la función y en bajo al salir, y mirar todos con un analizador lógico, muestra la línea de tiempo real del sistema, con sus huecos y sus bloqueos. Es una imagen de lo que el laboratorio de este tema simula, pero medida.
  • El watchdog (tema 38). Un temporizador de hardware que reinicia el equipo si el programa no lo «alimenta» a tiempo. Se reinicia en un solo lugar (por ejemplo, en el planificador cuando las tareas críticas cumplieron), de modo que una tarea colgada, que es el peor fallo de un esquema cooperativo, termine en un reinicio controlado y no en un equipo congelado.

Esta última observación es importante: en un sistema cooperativo, una sola tarea con un bucle infinito deja al sistema entero sin respuesta. No hay quien la saque. Los sistemas expropiativos limitan ese daño; en uno cooperativo, la disciplina del código y el watchdog son la defensa.

07 · Primer plano y segundo plano

Cooperar con las interrupciones

El esquema completo de un firmware con esta arquitectura tiene dos niveles, que en la jerga se llaman foreground y background:

  • En el primer plano están las ISR (tema 15): lo urgente, microsegundos, que preempta a todo lo demás. Atienden al hardware, guardan el dato y avisan.
  • En el segundo plano está el planificador: las tareas, de milisegundos, que no se interrumpen entre sí pero sí son interrumpidas por las ISR.

El puente entre ambos niveles son los patrones del tema 16: la ISR levanta una bandera, incrementa un contador o llena un buffer; una tarea los consume. Para las tareas por eventos hay dos maneras de organizarlo. La primera es una tarea periódica que mira la bandera en cada llamada: sencilla, pero la reacción puede demorar hasta un período. La segunda es una tarea que se consulta en cada paso del planificador y se ejecuta solo si su bandera está levantada; así la demora de reacción es solo el bloqueo de lo que esté corriendo, no un período entero:

/* Una tarea por evento: la ISR levanta la bandera, el planificador la atiende ni bien se libera el procesador */
static volatile bool paquete_listo;

void USART1_IRQHandler(void) { /* ...guardar el byte en el buffer circular... */ if (fin_de_paquete) paquete_listo = true; }

static void tarea_paquete(void) {              /* aparece en la tabla con período 0 y 'es_evento' */
    paquete_listo = false;                     /* borrar ANTES de procesar (tema 16) */
    interpretar_paquete();
}

/* en planificador_paso(), antes del recorrido por tiempo, o integrado en él: */
if (paquete_listo) { tarea_paquete(); return true; }

Dos advertencias. La primera: una tarea por eventos tiene un plazo igual que una periódica, y hay que sumarla al análisis del apartado 5 con su período mínimo entre ocurrencias (el intervalo más corto con que puede llegar el evento). La segunda: si el plazo del evento es más corto que el bloqueo que el resto de las tareas puede causar, no hay manera de cumplirlo con una tarea. La respuesta es ejecutar el trabajo urgente dentro de la ISR (si es corto), o ir a un esquema expropiativo.

08 · Cuándo no alcanza

Los límites del modelo

La planificación cooperativa es la elección correcta con más frecuencia de lo que se cree. Muchos productos exitosos, desde electrodomésticos hasta instrumentos de medición, corren con un planificador de pocas decenas de líneas. Pero tiene un límite estructural que conviene conocer de antemano para no encontrarlo tarde:

Planificación cooperativa frente a expropiativa.
AspectoCooperativaExpropiativa (RTOS, tema 20)
Quién cede el procesadorLa propia tarea, al terminarEl sistema, cuando hay otra más urgente
Respuesta de la tarea más urgentePeor caso: la tarea más larga de las demásCasi inmediata (el costo de un cambio de contexto)
MemoriaUna pila para todoUna pila por tarea
Datos compartidos entre tareasSin riesgo: no se interrumpen entre síRequieren exclusión mutua (tema 22)
Estilo de códigoMáquinas de estados: hay que trocearCódigo lineal con esperas bloqueantes
Complejidad y verificaciónBaja; el análisis es una cuentaMayor; aparecen la inversión de prioridad y los interbloqueos
Costo en código y RAMMínimoUnos kilobytes más una pila por tarea

Los indicios de que ya no alcanza son concretos. Hay una tarea con un plazo corto y otra que no se puede partir (un algoritmo de compresión, una transformada, una operación criptográfica, una biblioteca de terceros bloqueante) cuya duración excede ese plazo. O el número de tareas crece tanto que las máquinas de estados son más difíciles de mantener que el sistema que reemplazarían. Cuando ocurre, hay tres salidas, de menos a más drástica: mover el trabajo urgente a una ISR, partir lo que se pueda partir, o adoptar un sistema operativo de tiempo real. El próximo tema describe exactamente esa última opción.

09 · Analogía con programación

Un simulador de planificación en Python

El programa implementa las reglas del apartado anterior con una resolución de 0,05 ms: liberaciones periódicas con su fase, no expropiación, prioridad por orden de tabla y, a igual tarea, la liberación más antigua primero. Mide el tiempo de respuesta máximo y los plazos perdidos durante 1000 ms de simulación y los compara con la cota teórica. Los cinco escenarios son el del laboratorio.

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

U = 20                                    # ticks por ms: la simulación avanza de a 0,05 ms

def tareas(pantalla_c=30.0, pantalla_t=100, pantalla_fase=0, sensor_c=1.5, sensor_fase=2, pantalla_primero=False):
    """Cada tarea: (nombre, C en ms, T en ms, D en ms, fase en ms). El orden de la lista ES la prioridad."""
    lista = [("CTRL",     0.4,        10,         5,          0),
             ("BTN",      0.05,       10,         10,         0),
             ("SENSOR",   sensor_c,   50,         50,         sensor_fase),
             ("PANTALLA", pantalla_c, pantalla_t, pantalla_t, pantalla_fase)]
    if pantalla_primero:
        lista.insert(0, lista.pop())      # la pantalla pasa a ser la más prioritaria (mal)
    return lista

def simular(lista, duracion_ms=1000):
    """Planificador cooperativo por tiempo: no se interrumpe una tarea en marcha; al quedar libre
    corre la ejecutable de mayor prioridad (menor índice) y, a igual tarea, la más antigua."""
    trabajos = []
    for prio, (nombre, c, t, d, fase) in enumerate(lista):
        k = 0
        while fase + k * t < duracion_ms:
            trabajos.append(dict(prio=prio, nombre=nombre, lib=round((fase + k * t) * U),
                                 c=round(c * U), d=round(d * U), ini=None, fin=None))
            k += 1
    ocupado_hasta, t_actual = 0, 0
    pendientes = sorted(trabajos, key=lambda j: j["lib"])
    hechos = []
    while pendientes:
        listos = [j for j in pendientes if j["lib"] <= t_actual]
        if not listos:
            t_actual = min(j["lib"] for j in pendientes)
            continue
        j = min(listos, key=lambda x: (x["prio"], x["lib"]))
        j["ini"], j["fin"] = t_actual, t_actual + j["c"]
        t_actual = j["fin"]
        pendientes.remove(j)
        hechos.append(j)
    res = {}
    for j in hechos:
        r = res.setdefault(j["nombre"], dict(maxresp=0, perdidos=0, maxlat=0, n=0))
        r["n"] += 1
        r["maxresp"] = max(r["maxresp"], (j["fin"] - j["lib"]) / U)
        r["maxlat"] = max(r["maxlat"], (j["ini"] - j["lib"]) / U)
        if j["fin"] - j["lib"] > j["d"]:
            r["perdidos"] += 1
    return res

def cota(lista):
    """Tiempo de respuesta en el peor caso, planificación sin expropiación y por prioridades."""
    cotas = []
    for i, (nombre, c, t, d, fase) in enumerate(lista):
        bloqueo = max([x[1] for x in lista[i + 1:]], default=0.0)    # la tarea más larga de menor prioridad
        w = bloqueo
        while True:
            nuevo = bloqueo + sum((int(w // x[2]) + 1) * x[1] for x in lista[:i])
            if abs(nuevo - w) < 1e-9: break
            w = nuevo
            if w > 10 * d: break
        cotas.append((nombre, w + c))
    return cotas

def informe(titulo, lista):
    util = sum(c / t for _, c, t, _, _ in lista) * 100
    res = simular(lista)
    cotas = dict(cota(lista))
    print(f"{titulo}   (utilización {util:.1f} %)")
    for nombre, c, t, d, fase in lista:
        r = res[nombre]
        marca = "cumple" if r["perdidos"] == 0 else f"PIERDE {r['perdidos']} de {r['n']}"
        print(f"  {nombre:9s} C={c:5.2f} T={t:3d} D={d:3d}  respuesta máx {r['maxresp']:6.2f} ms (cota {cotas[nombre]:6.2f})  plazo {d:3d}: {marca}")
    print()

informe("A. Pantalla entera: 30 ms cada 100 ms", tareas())
informe("B. Pantalla en 10 trozos de 3 ms, cada 10 ms", tareas(3.0, 10))
informe("C. Igual que B pero con la pantalla desfasada 8 ms", tareas(3.0, 10, 8))
informe("D. Pantalla en 5 trozos de 6 ms cada 20 ms, desfasada 9 ms", tareas(6.0, 20, 9))
informe("E. Trozos de 3 ms, pero el sensor bloquea 12 ms (lectura por I2C bloqueante)", tareas(3.0, 10, 0, 12.0, 8))

# --- cuántos trozos hacen falta ---------------------------------------------------------------
c_ctrl, d_ctrl, c_total = 0.4, 5.0, 30.0
max_trozo = d_ctrl - c_ctrl
print(f"Para que CTRL cumpla su plazo de {d_ctrl} ms, el bloqueo (la tarea de menor prioridad más larga) debe ser <= {max_trozo:.1f} ms")
for n in (1, 2, 5, 7, 10):
    trozo = c_total / n
    print(f"  {n:2d} trozos de {trozo:5.2f} ms: {'sirve' if trozo <= max_trozo + 1e-9 else 'NO sirve'}")
print(f"  mínimo de trozos: {ceil(c_total / max_trozo)}")

Resultado: la utilización es de 37,5 % en los escenarios A a D (en E sube a 58,5 % por el sensor de 12 ms). En A (pantalla entera) el lazo de control tiene un tiempo de respuesta máximo medido de 20,85 ms y una cota de 30,40, contra un plazo de 5: pierde 20 de 100 ejecuciones, y el botón también (21,70 ms contra 10). En B (diez trozos de 3 ms) todo cumple y el lazo responde en 0,40 ms con una cota de 3,40. En C (la pantalla desfasada 8 ms) el lazo llega a 1,40 ms: la pantalla ya estaba corriendo y lo hizo esperar 1 ms. En D (cinco trozos de 6 ms, desfasados 9 ms) el lazo llega a 5,40 ms y pierde 50 de 100: la cota de 6,40 ms ya lo anticipaba. En E, un sensor que bloquea 12 ms hace que el lazo llegue a 10,40 ms, y pierden plazos el lazo, el botón y la pantalla. Y el cálculo final dice que el bloqueo máximo tolerable es de 4,6 ms: hacen falta al menos 7 trozos; con 5 trozos de 6 ms no alcanza y con 10 de 3 ms sobra.

Probá poner la fase de la pantalla en 0 en el escenario D: el lazo aparentemente cumple (0,40 ms), porque nunca coincide con el trozo largo. La cota de 6,40 ms sigue diciendo que el diseño es inseguro. Cambiá también las prioridades (poné la pantalla primero) o los plazos y mirá cómo se mueven los resultados. Es exactamente el tipo de razonamiento que se hace antes de grabar el equipo, no después.

10 · Exploración interactiva

Quién bloquea a quién

El laboratorio ejecuta el simulador del apartado anterior sobre el conjunto de cuatro tareas. Elegí cómo se ejecuta la pantalla (entera o en trozos), la fase de la pantalla, cuánto bloquea el sensor, su fase y el orden de prioridades. Verás el tiempo de respuesta máximo y la cota de cada tarea, cuántos plazos se pierden y la línea de tiempo de los primeros 100 ms.

Cuatro tareas, un procesador y ninguna expropiación

CTRL · lazo de control—
BTN · botón—
SENSOR—
PANTALLA—
Utilización—
Resultado—

Línea de tiempo (primeros 100 ms)

—

Cambiá las opciones para ver quién bloquea a quién.

Recorrido guiado

  1. Con los valores iniciales (la pantalla entera): el procesador está ocupado solo el 37,5 % del tiempo, pero el lazo de control tiene un tiempo de respuesta de 20,85 ms contra un plazo de 5 y pierde 20 de 100 ejecuciones. Mirá la línea de tiempo: a los 2 ms arranca la pantalla y hasta los 32 ms no corre nada más. Poco uso del procesador, y aun así un lazo que no funciona.
  2. Elegí 10 trozos de 3 ms: todo cumple. El lazo responde en 0,40 ms y su cota del peor caso es 3,40 ms. La utilización es la misma; lo que cambió es que ningún bloque dura más de 3 ms.
  3. Con los trozos de 3 ms, poné la fase de la pantalla en 8 ms: la pantalla ya está corriendo cuando el lazo se libera a los 10 ms, y el lazo pasa a responder en 1,40 ms. Sigue cumpliendo, pero ahora se nota el bloqueo. Esa es la fase actuando.
  4. Elegí 5 trozos de 6 ms con fase 0: parece cumplir (0,40 ms), pero el aviso del laboratorio te dice que la cota (6,40 ms) supera el plazo. Ahora poné la fase en 9 ms: el lazo responde en 5,40 ms y pierde 50 de 100. El peor caso existía todo el tiempo; la fase solo decidió si se mostraba.
  5. Volvé a los trozos de 3 ms con fase 0 y poné el sensor en 12 ms (bloqueante) con fase 8 ms: el sensor ocupa de 8 a 20 ms, y el lazo (plazo 5) llega a 10,40 ms. Pierden plazos el lazo, el botón y hasta la pantalla. Aunque la pantalla esté bien troceada, el sensor es ahora la tarea que manda.
  6. Con la pantalla entera, poné la pantalla como la más prioritaria: el resultado es peor todavía, porque ahora la pantalla se ejecuta primero y las demás se acumulan detrás (pierde 30 de 100 en el lazo). La prioridad ordena, pero no repara un bloque demasiado largo.
Desafío: ¿se puede arreglar el sensor de 12 ms sin cambiarlo?

Hay tres vías. La primera, convertirlo en dos pasos como en el ejemplo del apartado 4: disparar la conversión y volver a recoger el resultado, de modo que la tarea no ocupe 12 ms. La segunda, hacerlo con interrupciones o DMA: el hardware transfiere los datos sin el procesador y avisa al terminar. La tercera, si la biblioteca del sensor es bloqueante y no se puede modificar, aislarla en una tarea de baja prioridad con un plazo largo, de modo que su bloqueo se descuente de las más urgentes: pero entonces ese bloqueo de 12 ms es inevitable, y solo es aceptable si todos los plazos más cortos son mayores que 12 ms. Con un lazo de 5 ms, la única salida real es una de las dos primeras, o un sistema expropiativo.

11 · Método de trabajo

Método para diseñar el conjunto de tareas

  1. Hacé la lista de lo que el equipo debe hacer y, para cada cosa, escribí su período o su evento y su plazo. «Cada 10 ms» y «terminar dentro de 5 ms» son datos distintos.
  2. Medí o estimá la duración de cada tarea en el peor caso (C), con el contador de ciclos en el hardware real tan pronto sea posible.
  3. Calculá la utilización. Si supera el 60 o 70 %, no sigas: hay que reducir trabajo o aumentar la capacidad.
  4. Ordená la tabla por plazo: la tarea de plazo más corto, primero. Las de fondo, al final.
  5. Calculá la cota R de cada tarea con el bloqueo de las de menor prioridad y la interferencia de las de mayor, y compará con el plazo. Anotá el resultado.
  6. Si alguna no cumple, identificá la tarea que más bloquea (la más larga de menor prioridad) y reducila: trocéala, usá interrupciones o DMA, o cambiale el diseño. Después de cada cambio, volvé a calcular.
  7. Asigná fases para repartir las liberaciones en el tiempo y reducir la coincidencia. Es una mejora de comportamiento, no una garantía: el análisis sigue siendo el del peor caso.
  8. Programá cada tarea sin esperas, como una máquina de estados donde haga falta, y revisá con la lista del apartado 4 que ninguna se quede esperando.
  9. Instrumentá: duración máxima, atraso máximo, plazos perdidos y un pin por tarea. Compará lo medido con lo calculado, y ante una diferencia importante, desconfiá del cálculo hasta entender la causa.
  10. Agregá el watchdog y probá a propósito que una tarea colgada produce un reinicio y no un equipo congelado.

Un hábito que ahorra problemas: escribir el análisis en el repositorio, junto con la tabla de prioridades y la de interrupciones del tema 15. Una hoja con las tareas, sus C, T, D y R, es lo primero que se actualiza cuando el producto crece y lo primero que se consulta cuando una nueva función rompe los plazos.

12 · Comprensión

Confusiones frecuentes

«Mi procesador está al 30 %, así que seguro cumplo los plazos»

La utilización dice cuánto tiempo hay en total, no si cada tarea llega a tiempo. Una sola tarea de 30 ms hace imposible un plazo de 5 ms, aunque el resto del tiempo el procesador esté ocioso. Hay que mirar la duración de la tarea más larga, no el promedio.

«Si la prueba pasó, el diseño es correcto»

La prueba recorre un camino; el peor caso exige que varias liberaciones coincidan de cierta manera, y puede no aparecer jamás en el escritorio (y aparecer en el campo, al mes). El cálculo de la cota se hace precisamente para no depender de la suerte.

«Con prioridades altas arreglo las tareas lentas»

La prioridad ordena las tareas que están listas, pero no puede interrumpir a la que ya corre. Si la tarea larga ya empezó, la urgente espera, sea cual sea su prioridad. Lo único que sirve es acortar la tarea larga.

«Cortar la tarea en dos mitades ya la hace cooperativa»

Hace falta que el trozo sea lo bastante corto para el plazo más exigente del sistema. Partir 30 ms en dos de 15 sigue bloqueando 15 ms. El tamaño del trozo se calcula a partir del plazo, no se elige al azar.

«Con tareas cooperativas necesito semáforos y mutex para compartir datos»

No para compartir entre tareas, que nunca se interrumpen entre sí. Sí hace falta cuidado con los datos compartidos con una ISR (tema 16), porque esa sí interrumpe a cualquier tarea.

«Un bucle infinito en una tarea es un problema local»

En un sistema cooperativo deja a todo el firmware sin respuesta. No hay nada que le quite el procesador. Por eso las esperas se escriben con tiempos de espera y reintentos limitados, y por eso se agrega el watchdog.

«Un RTOS siempre es mejor»

Es mejor cuando hay plazos cortos que las tareas largas no permiten, o cuando el número de tareas hace inmanejables las máquinas de estados. Pero cuesta memoria, complejidad y los problemas de concurrencia del tema 22. Un firmware pequeño y bien dimensionado con planificación cooperativa suele ser más simple, más pequeño y más fácil de verificar.

13 · Práctica guiada

Predecí antes de comprobar

1. La utilización

Tres tareas: A (C = 2 ms, T = 10), B (C = 5 ms, T = 50) y C (C = 1 ms, T = 5). ¿Cuál es la utilización? ¿Es viable el conjunto?

Ver solución

U = 2/10 + 5/50 + 1/5 = 0,2 + 0,1 + 0,2 = 0,5 (50 %). Es menor que 1, de modo que el conjunto puede ser viable, pero la utilización no basta para afirmarlo: hay que mirar los plazos y el bloqueo (ejercicio siguiente).

2. La cota del peor caso

Con las tres tareas anteriores y plazos iguales a los períodos, en el orden C, A, B (C la más prioritaria): ¿cuánto vale la cota de respuesta de C? ¿Cumple?

Ver solución

C es la más prioritaria: sin interferencia. El bloqueo es la tarea más larga de las de abajo: máx(2, 5) = 5 ms. R = 5 + 1 = 6 ms. El plazo de C es 5 ms: no cumple, aunque el procesador esté al 50 %. La causa es B: tarda 5 ms y bloquea a la más urgente. Partirla en dos trozos de 2,5 ms (o más) da un bloqueo de 2,5 y R = 3,5 ms ≤ 5.

3. Cuántos trozos

Un algoritmo tarda 40 ms por vez. La tarea más urgente del sistema tiene C = 1 ms y D = 8 ms. ¿En cuántos trozos como mínimo hay que partirlo?

Ver solución

El bloqueo tolerable es D − C = 7 ms. 40 / 7 = 5,71, así que hacen falta al menos 6 trozos (de unos 6,7 ms). En la práctica se usan 8 o 10 para dejar margen para las interrupciones y las demás tareas.

4. Espera bloqueante a máquina de estados

Reescribí en forma cooperativa: «encender un relé, esperar 50 ms, comprobar la entrada de realimentación; si no respondió, esperar 100 ms y reintentar hasta tres veces; si sigue sin responder, declarar fallo».

Ver solución

Estados: REPOSO, ESPERA_50, ESPERA_REINTENTO, FALLO. Una variable desde con el instante del último cambio y un contador intentos. En REPOSO, al recibir la orden: encender el relé, desde = tick_ms(), intentos = 0, pasar a ESPERA_50. En ESPERA_50, si tick_vencido(desde, 50): si la realimentación está activa, volver a REPOSO (éxito); si no, intentos++; si intentos >= 3 pasar a FALLO, y si no, pasar a ESPERA_REINTENTO con desde = tick_ms(). En ESPERA_REINTENTO, al cumplirse 100 ms, reintentar y volver a ESPERA_50. La tarea tarda microsegundos por llamada y nunca espera.

5. La política ante el atraso

Una tarea de 10 ms se retrasó 35 ms porque otra tarea larga ocupó el procesador. ¿Cuántas veces se ejecuta de inmediato con la política de ráfaga? ¿Y con la política de saltar? ¿Cuál convendría para un lazo de control?

Ver solución

Con ráfaga, el instante teórico queda 35 ms atrás, de modo que se ejecuta una vez por cada liberación pendiente: 4 veces seguidas (las liberaciones teóricas de hace 35, 25, 15 y 5 ms) hasta alcanzar al reloj. Con saltar, se ejecuta una sola vez y se resincroniza, descartando las demás. Para un lazo de control conviene saltar: ejecutar tres veces seguidas con datos viejos y un integrador pensado para 10 ms entre pasos distorsiona la acción integral; es mejor perder ciclos que ejecutar ciclos falsos. Además, en ambos casos perdidas se incrementa.

6. Las interrupciones y los plazos

Una tarea tiene C = 3 ms medidos sin interrupciones y un plazo de 4 ms. Las ISR del sistema consumen el 15 % del procesador. ¿Cuánto tarda la tarea en el peor caso, aproximadamente? ¿Cumple?

Ver solución

Con el 15 % del tiempo ocupado por las ISR, a la tarea le queda el 85 %: su duración efectiva se estira a 3 / 0,85 ≈ 3,53 ms. Cumple si no se le suma ningún bloqueo; pero si hay otra tarea que bloquee 1 ms, ya son 4,53 ms y no cumple. Es la razón de sumar la carga de las interrupciones (tema 15) al análisis, y de dejar un margen del 30 % en los plazos que están al límite.

14 · Síntesis

Ideas para recordar

  • En la planificación cooperativa el planificador decide qué tarea sigue, pero nunca interrumpe a la que corre: cada tarea devuelve el control por sí misma. A cambio es simple, usa una sola pila y las tareas pueden compartir datos sin protegerlos entre sí.
  • Cada tarea se describe con su duración C (siempre en el peor caso), período T, plazo D y fase; la utilización ΣC/T debe ser menor que 1, pero es solo una condición necesaria.
  • El planificador por tiempo usa el orden de la tabla como prioridad, avanza por el instante teórico, decide de nuevo después de cada tarea, cuenta los plazos perdidos y duerme si no hay nada para hacer.
  • Una tarea nunca espera: guarda en qué punto quedó (máquina de estados), devuelve el control y vuelve a mirar en la siguiente llamada. Las esperas por tiempo se resuelven con tick_vencido(); las de periféricos, pidiendo y recogiendo, o con interrupciones.
  • El tiempo de respuesta en el peor caso de una tarea es su duración, más el bloqueo de la tarea más larga de menor prioridad, más la interferencia de las de mayor prioridad: R ≤ D es la condición de que cumple.
  • La tarea más larga del sistema es la que manda: su duración se suma al tiempo de respuesta de todas las tareas con plazo más corto. Trocear una tarea solo ayuda si el trozo es menor que D − C de la tarea más exigente.
  • Una prueba que sale bien no demuestra el cumplimiento de los plazos; la fase puede esconder el peor caso. Se calcula la cota y se instrumenta el sistema: duración máxima, atraso máximo, plazos perdidos y un pin por tarea.
  • La planificación cooperativa llega hasta donde llega el plazo más corto frente a la tarea indivisible más larga; más allá, se mueve el trabajo urgente a una ISR o se pasa a un sistema expropiativo. El watchdog es la defensa contra una tarea colgada.

¿Listo para el tema 20?

Antes de avanzar, calculá la cota de respuesta de la tarea más urgente en un conjunto que te inventes; explicá por qué un procesador al 30 % puede perder plazos; y reescribí una espera bloqueante como máquina de estados. Si podés justificar las tres, entendés el modelo cooperativo y sus límites.

En el próximo tema damos el paso hacia los sistemas operativos de tiempo real: tareas con su propia pila, estados, prioridades y el cambio de contexto que permite que una tarea urgente expropie a otra en marcha, resolviendo exactamente el problema del bloqueo que acá solo pudimos acotar.