← Índice del curso

Sistemas Embebidos · Tema 20

Introducción a los sistemas operativos de tiempo real

El tema anterior terminó con un límite: en la planificación cooperativa, una tarea larga bloquea a la urgente y lo único que se puede hacer es partir el trabajo en trozos. Un sistema operativo de tiempo real elimina ese límite de raíz: cuando una tarea más urgente se libera, el sistema le quita el procesador a la que corre, la urgente se ejecuta y después la otra continúa donde estaba. Este tema explica cómo funciona, qué cuesta y qué problemas nuevos trae.

01 · Punto de partida

Qué es un RTOS y qué no es

Un sistema operativo de tiempo real (RTOS, Real-Time Operating System) es, en esencia, una biblioteca pequeña que se enlaza con tu firmware y le agrega tres cosas: un planificador que reparte el procesador entre varias tareas según su prioridad, un conjunto de mecanismos de comunicación y sincronización entre ellas (colas, semáforos, mutex: el tema 21) y servicios de tiempo (esperas, temporizadores). Ocupa del orden de unos pocos kilobytes de memoria de programa y se enlaza en el mismo ejecutable: no hay un sistema aparte, ni archivos, ni usuarios.

Conviene deshacer dos malentendidos de entrada:

  • «Tiempo real» no significa «rápido». Significa predecible: que el tiempo máximo que tarda el sistema en reaccionar a un evento se puede conocer y garantizar. Un RTOS puede ser más lento en promedio que un bucle bien hecho; lo que ofrece es que el peor caso está acotado y que se puede calcular (apartado 6).
  • No es un Linux pequeño. No gestiona memoria virtual ni protege unas tareas de otras (en la mayoría de los casos todas comparten el mismo espacio de direcciones), y su planificador es muchísimo más simple. Lo que tiene en común con un sistema operativo de propósito general es la idea de las tareas concurrentes; lo que lo distingue es el determinismo.

Hay varios núcleos de este tipo, de código abierto y comerciales: FreeRTOS (licencia MIT, el más difundido en microcontroladores), Zephyr (Apache 2.0, con un ecosistema de configuración y controladores muy amplio), Eclipse ThreadX (antes Azure RTOS, hoy abierto), RIOT (orientado a Internet de las cosas), entre otros. Sus nombres de funciones cambian, pero los conceptos de este tema son idénticos en todos. Los ejemplos de código usan la API de FreeRTOS, que es la que vas a encontrar con más frecuencia.

El código sigue siendo para el STM32F103; las ideas valen para cualquier procesador con interrupciones y una pila, que es todo lo que un RTOS necesita para funcionar.

02 · La unidad de trabajo

La tarea: una función con su propia pila

Una tarea de un RTOS es una función de C que contiene un bucle infinito y que el sistema ejecuta como si fuera el único programa en el procesador. Lo que la distingue de las tareas cooperativas del tema anterior es que puede esperar: el código es lineal, con esperas explícitas, y es el sistema —no el programador— quien se ocupa de ceder el procesador a otras tareas mientras ésta aguarda.

#include "FreeRTOS.h"
#include "task.h"

#define PRIO_CONTROL   3        /* en FreeRTOS un número MAYOR es MÁS prioritario (al revés que en el NVIC) */
#define PRIO_SENSOR    2
#define PRIO_PANTALLA  1

static void tarea_control(void *arg) {
    TickType_t t0 = xTaskGetTickCount();
    for (;;) {                                         /* una tarea nunca retorna */
        control_paso();                                /* 0,4 ms de trabajo */
        vTaskDelayUntil(&t0, pdMS_TO_TICKS(10));       /* período exacto de 10 ms: bloquea a ESTA tarea, no al procesador */
    }
}

static void tarea_pantalla(void *arg) {
    TickType_t t0 = xTaskGetTickCount();
    for (;;) {
        pantalla_redibujar();                          /* 30 ms de golpe: ya no hay que trocear */
        vTaskDelayUntil(&t0, pdMS_TO_TICKS(100));
    }
}

int main(void) {
    hardware_iniciar();
    xTaskCreate(tarea_control,  "ctrl", 256, NULL, PRIO_CONTROL,  NULL);   /* 256 palabras = 1 KB de pila propia */
    xTaskCreate(tarea_sensor,   "sens", 256, NULL, PRIO_SENSOR,   NULL);
    xTaskCreate(tarea_pantalla, "pant", 512, NULL, PRIO_PANTALLA, NULL);   /* 2 KB */
    vTaskStartScheduler();                             /* entrega el control al RTOS y no vuelve */
    for (;;) { }
}

Compará con la planificación cooperativa. La secuencia del LED del tema 19, que obligaba a una máquina de estados con un switch, vuelve a escribirse como se piensa:

static void tarea_secuencia(void *arg) {
    for (;;) {
        led_encender();  vTaskDelay(pdMS_TO_TICKS(200));    /* mientras espera, corre otra tarea */
        led_apagar();    vTaskDelay(pdMS_TO_TICKS(800));
    }
}

Es el beneficio más tangible de un RTOS: el código de cada tarea vuelve a ser lineal y bloqueante, y la complejidad de compartir el procesador se traslada al sistema. A cambio, cada tarea necesita algo que en el esquema cooperativo no hacía falta: su propia pila. Una tarea puede quedar suspendida a mitad de una función, con variables locales y direcciones de retorno en pleno uso, y esos datos tienen que sobrevivir mientras corren otras. Por eso cada tarea tiene su pila (de ahí los 256 y 512 del ejemplo, en palabras de 4 bytes) y su bloque de control de tarea (TCB, Task Control Block), una pequeña estructura que el sistema usa para administrarla:

Qué guarda el sistema para cada tarea.
Campo del TCBContenidoPara qué sirve
Puntero de pila guardadoEl valor del SP de la tarea cuando fue suspendidaEs lo único que hace falta para reanudarla: sobre esa pila están todos sus registros
EstadoListo, ejecución, bloqueada o suspendidaDecide en qué lista está la tarea
PrioridadUn número (y, en algunos sistemas, una prioridad base y otra heredada)Ordena la elección de la próxima tarea
Instante de despertarEl tick en que debe volver a estar lista, si está esperando un tiempoPara vTaskDelay y los tiempos de espera de las colas
Nombre y base de la pilaUna cadena y la dirección del extremo de la pilaDepuración y detección de desbordes

El TCB ocupa unas decenas de bytes (del orden de 100 en FreeRTOS); la pila es lo que pesa, y la cantidad de RAM disponible suele ser el límite real de cuántas tareas caben, como se calcula en el apartado 7.

03 · El ciclo de vida

Los estados de una tarea

En todo momento, cada tarea está en exactamente uno de cuatro estados. Es la herramienta mental más útil para entender un RTOS, porque casi cualquier pregunta («¿por qué mi tarea no corre?») se contesta mirando en cuál está y qué la hará cambiar:

Estados de una tarea de un RTOS Cuatro estados. De Lista se pasa a Ejecución cuando el planificador la elige, y de Ejecución a Lista cuando es expropiada por una más prioritaria. De Ejecución se pasa a Bloqueada al esperar un tiempo o un evento, y de Bloqueada a Lista cuando llega ese tiempo o evento. De Ejecución se pasa a Suspendida al suspenderla explícitamente, y de Suspendida a Lista al reanudarla. ListaReady En ejecuciónRunning BloqueadaBlocked SuspendidaSuspended el planificador la elige expropiada por otra espera un tiempo o un evento llegó el tiempo o el evento suspender reanudar
Los estados y las transiciones entre ellos.
EstadoSignificadoCómo se sale
En ejecuciónEs la tarea que usa el procesador ahora. Hay una sola en un procesador de un núcleoPasa a Lista si la expropia otra más prioritaria; a Bloqueada si espera algo; a Suspendida si se la suspende
ListaPuede correr, pero el procesador está ocupado por otra de prioridad igual o mayorPasa a En ejecución cuando el planificador la elige
BloqueadaEspera un evento: que pase un tiempo (vTaskDelay), que llegue un dato a una cola, que se libere un mutex. No consume procesadorPasa a Lista cuando ocurre el evento o vence el tiempo de espera
SuspendidaLa tarea fue puesta fuera de servicio explícitamente (vTaskSuspend); no se la considera hasta que se la reanudePasa a Lista con vTaskResume

Dos de esos estados parecen lo mismo y no lo son. Una tarea Lista quiere correr y no puede; una Bloqueada no quiere correr todavía. Esa diferencia explica la regla que más se incumple al empezar con un RTOS: una tarea que nunca se bloquea no deja de estar Lista ni cuando termina su trabajo, y si es de prioridad alta no deja correr a las de menor prioridad (apartado 7). La forma sana de que una tarea ceda el procesador es esperar algo: un tiempo, un dato, un evento.

Además de las tareas del programador existe siempre una tarea inactiva (idle), la de menor prioridad, que se ejecuta cuando todas las demás están bloqueadas. Es la que garantiza que siempre haya algo para correr. En ella se suele ejecutar wfi para dormir el procesador (como el planificador del tema 19, pero ahora hecho por el sistema) y, en configuraciones de bajo consumo, se apaga incluso el tick (tema 35).

04 · La regla de decisión

Prioridades y planificación expulsiva

El planificador de un RTOS aplica una regla simple, que se repite cada vez que algo cambia (un tick, una tarea que se bloquea, una interrupción que libera una tarea):

La tarea en ejecución es siempre la de MAYOR prioridad de entre todas las que están listas.

Cuando una tarea se libera (despierta de una espera) y su prioridad supera a la de la que corre, la expropia de inmediato: la que corría pasa a Lista, a mitad de lo que estaba haciendo, y la nueva se ejecuta. Cuando la urgente se bloquea o termina, el sistema vuelve a decidir y, si no hay otra más prioritaria, la tarea interrumpida continúa exactamente donde estaba. Del tema 15 reconocés el patrón: es lo que hace el NVIC con las interrupciones, aplicado ahora a tareas.

  • Con prioridades distintas la regla no deja dudas: manda la mayor.
  • Con prioridades iguales hay dos políticas. Una es el reparto de tiempo (round-robin): cada tarea del mismo nivel corre durante un número de ticks y luego cede a la siguiente. La otra es no repartir: una sigue hasta que se bloquea. En FreeRTOS se elige con configUSE_TIME_SLICING.
  • La tarea inactiva tiene la prioridad más baja de todas, y por lo tanto solo corre cuando nadie más puede.
  • El tick (una interrupción periódica, típicamente de 1 ms, del SysTick que ya conocés) es el reloj del sistema: por él avanzan las esperas y se evalúa el reparto de tiempo. Su frecuencia (configTICK_RATE_HZ) fija la resolución de las esperas: con 1000 Hz, la menor espera es 1 ms; con 100 Hz, 10 ms.

Dos advertencias sobre las prioridades que se confunden con frecuencia:

  • Hay dos escalas y van en sentidos opuestos. En el NVIC (tema 15) un número menor es más urgente; en FreeRTOS un número mayor es más prioritario. Una ISR de prioridad 1 es más urgente que una de 5, y una tarea de prioridad 5 es más prioritaria que una de 1. Y además todas las ISR son más urgentes que todas las tareas, sea cual sea el número: una interrupción siempre interrumpe a cualquier tarea.
  • La prioridad no mide importancia sino urgencia. La regla de asignación estándar es la del tema 15: a menor plazo, mayor prioridad. Para tareas periódicas con plazo igual al período se llama monotónica en frecuencia (RMS, Rate Monotonic): la de período más corto, la de mayor prioridad. Si los plazos son más cortos que los períodos, se usa la monotónica en plazos (DMS): el menor plazo manda. En el conjunto de tareas del tema anterior, el lazo (plazo 5 ms) y el botón (plazo 10 ms) tienen el mismo período de 10 ms, y es el plazo el que decide: el lazo primero.

Un detalle más sobre las tareas de igual prioridad y la expropiación: un RTOS configurado sin expropiación (configUSE_PREEMPTION = 0) se comporta como el planificador cooperativo del tema 19, con la diferencia de que cada tarea tiene su pila y puede esperar. Es una configuración válida para quien quiere el estilo lineal sin los problemas de concurrencia que traen las interrupciones entre tareas.

05 · Lo que ocurre por debajo

El cambio de contexto

Para que una tarea pueda ser suspendida en cualquier punto y reanudada después, el sistema guarda su contexto: el conjunto de registros del procesador que describen en qué punto está. En un Cortex-M3 son: R0–R12, SP, LR, PC y el registro de estado xPSR. El cambio de contexto (context switch) es la operación de guardar el contexto de la tarea que sale y cargar el de la que entra. Cuesta tiempo y no hace trabajo útil, así que es el precio de la expropiación.

La arquitectura Cortex-M está diseñada para que esto sea barato, usando dos piezas que ya viste. La primera es el apilado automático del tema 15: al entrar a una excepción, el hardware guarda R0–R3, R12, LR, PC y xPSR. La segunda es la existencia de dos punteros de pila: MSP, la pila principal que usan el arranque y las interrupciones, y PSP, la pila de proceso, que usa la tarea en ejecución. Así cada tarea apila en su propia pila, y las interrupciones usan siempre la común. Los pasos de un cambio de tarea son:

Una expropiación, paso a paso, en un Cortex-M3.
PasoQué ocurreQuién lo hace
1Ocurre un evento que libera una tarea más prioritaria (por ejemplo, el tick de un vTaskDelay que vence, o una ISR que envía un dato a una cola)Una interrupción
2El núcleo del RTOS marca la tarea como Lista, ve que su prioridad supera a la actual y solicita un cambio: deja pendiente la excepción PendSVCódigo del RTOS (en la ISR)
3Cuando terminan las demás interrupciones, entra PendSV. El hardware ya apiló R0–R3, R12, LR, PC, xPSR en la pila de la tarea saliente (en PSP)Hardware
4El manejador apila a mano R4–R11 en la misma pila y guarda el PSP resultante en el TCB de la tarea que saleEnsamblador del RTOS
5Se llama al planificador, que elige la tarea de mayor prioridad entre las listasC del RTOS
6Se lee el PSP guardado en el TCB de la tarea entrante y se restauran de su pila R4–R11Ensamblador del RTOS
7Al retornar de la excepción, el hardware restaura R0–R3, R12, LR, PC, xPSR de esa pila: la nueva tarea continúa exactamente donde estabaHardware

El corazón del cambio, simplificado, cabe en una docena de instrucciones:

PendSV_Handler:
    mrs   r0, psp               @ pila de la tarea que sale (el hardware ya apiló R0-R3, R12, LR, PC, xPSR)
    stmdb r0!, {r4-r11}         @ apilar a mano el resto: R4 a R11
    ldr   r1, =tcb_actual
    ldr   r2, [r1]
    str   r0, [r2]              @ guardar el SP de la tarea saliente en su TCB
    push  {r1, lr}
    bl    elegir_siguiente      @ función en C: actualiza tcb_actual con la tarea de mayor prioridad
    pop   {r1, lr}
    ldr   r2, [r1]              @ TCB de la tarea entrante
    ldr   r0, [r2]              @ su SP guardado
    ldmia r0!, {r4-r11}         @ restaurar R4 a R11
    msr   psp, r0
    bx    lr                    @ el hardware restaura el resto y retoma la tarea

Observá tres decisiones de diseño. La primera: el cambio se hace en PendSV y no dentro de la ISR que lo provocó, y PendSV tiene la prioridad más baja de todas (igual que el tick). Así el cambio ocurre recién cuando todas las demás interrupciones terminaron, y nunca interrumpe a una ISR a medias. La segunda: una tarea nueva arranca de un contexto fabricado. Al crearla, el RTOS escribe en su pila un marco falso con el PC apuntando a la función de la tarea y un xPSR válido, como si la tarea hubiera sido interrumpida justo antes de su primera instrucción; así el mismo mecanismo de restauración sirve para empezarla. La tercera: el costo es de unas pocas decenas de instrucciones, que en un Cortex-M3 son del orden de cientos de ciclos con la decisión del planificador incluida; a 72 MHz, unos pocos microsegundos. Es una cifra que conviene medir con el contador de ciclos del tema 18, no suponer.

06 · Predecir los tiempos

Tiempo de respuesta con expropiación

En el modelo cooperativo el tiempo de respuesta de una tarea dependía de la tarea más larga de menor prioridad (el bloqueo). Con expropiación, ese término desaparece: una tarea urgente no espera a ninguna de menor prioridad. Lo único que puede retrasarla son las tareas de mayor prioridad que se liberen mientras ella no termina (la interferencia), más el pequeño costo del cambio de contexto y de las interrupciones. La cota del peor caso para la tarea i es:

R = C(i) + Σ  ⌈ R / T(j) ⌉ · C(j)        suma sobre todas las tareas j de mayor prioridad que i

Es una ecuación con R en ambos lados, que se resuelve iterando: se empieza con R = C(i), se calcula el lado derecho, se vuelve a calcular con ese nuevo valor y se repite hasta que no cambie. Para la tarea más prioritaria no hay suma y R = C: responde en lo que tarda en ejecutarse. Con las cuatro tareas del tema 19 (lazo, botón, sensor y pantalla en este orden), la pantalla de 30 ms, la menos prioritaria, tiene R = 30 + ⌈R/10⌉·0,4 + ⌈R/10⌉·0,05 + ⌈R/50⌉·1,5; empezando en 30 se obtiene 32,85, luego 33,3, y ahí se estabiliza: R = 33,3 ms, bien por debajo de su plazo de 100 ms. El simulador de este tema da exactamente ese valor.

Dos reglas prácticas salen de la ecuación:

  • La tarea larga ya no importa para las urgentes, siempre que tenga menor prioridad. En el tema 19 la pantalla de 30 ms obligaba a trocearla; con expropiación se la deja entera y de baja prioridad, y el único efecto es que ella misma se estira, de 30 a 33,3 ms. El costo de las tareas urgentes lo pagan las menos urgentes.
  • Hay un umbral de utilización garantizado. Liu y Layland demostraron en 1973 que, con prioridades monotónicas en frecuencia y plazos iguales a los períodos, n tareas cumplen siempre sus plazos si U ≤ n·(21/n − 1). Vale 1,0 para una tarea, 0,828 para dos, 0,780 para tres, 0,757 para cuatro y tiende a 0,693 (ln 2) para muchas. Es una condición suficiente pero no necesaria: pasarse del umbral no condena al conjunto, solo obliga a usar el cálculo exacto de arriba. Y notá la diferencia con el modelo cooperativo: allí ninguna utilización, ni siquiera el 10 %, garantizaba nada.

El cálculo ideal debe ampliarse con lo que en la práctica suma retraso: el cambio de contexto (unos microsegundos por cada expropiación), el tiempo que el núcleo del RTOS mantiene las interrupciones deshabilitadas en sus secciones críticas internas (que aparece como un bloqueo pequeño y acotado, medible), y las ISR de usuario, cuya carga estira todas las duraciones como se vio en el tema 19. Con un margen del 20 o 30 % en los plazos justos, se cubre sin problemas.

07 · El otro lado de la moneda

Costos y trampas

Los beneficios de un RTOS no son gratuitos. Estos son los costos y las trampas que más se subestiman:

  • Memoria de pila. Cada tarea reserva su pila en RAM, aunque casi nunca la use entera, y hay que dimensionarla para el peor caso (incluyendo las interrupciones que se anidan sobre ella, en la pila de la tarea o, según la configuración, en la principal). Con 5 tareas de 1 a 2 KB más sus TCB, el RTOS consume unos 6 KB: el 30 % de los 20 KB de un STM32F103C8. Una pila corta produce un desborde que corrompe silenciosamente a la tarea vecina; los RTOS ofrecen verificaciones (rellenar la pila con un patrón y comprobarlo en cada cambio, configCHECK_FOR_STACK_OVERFLOW), que conviene activar siempre durante el desarrollo.
  • Inanición (starvation). Una tarea de alta prioridad que nunca se bloquea acapara el procesador y las de menor prioridad no corren jamás, ni siquiera la inactiva (que alimenta el watchdog, tema 38). La causa típica es un for (;;) { leer(); procesar(); } sin ninguna espera: la tarea debe esperar algo en cada vuelta (un tiempo, un dato).
  • Sobrecarga del cambio de contexto. Con 270 cambios por segundo, como en el sistema de este tema, y 5 µs cada uno, el costo es el 0,14 % del procesador: despreciable. Pero un sistema con muchas tareas que se despiertan en cada tick, o un tick de 10 kHz, puede llegar a un porcentaje alto. Se calcula, no se supone.
  • Resolución del tiempo. Las esperas se miden en ticks. vTaskDelay(1) puede esperar menos de un tick completo (se cuenta desde el momento de la llamada hasta el siguiente tick), y vTaskDelay en general garantiza al menos casi lo pedido, sin la exactitud de período de vTaskDelayUntil. Para un período regular se usa siempre la segunda.
  • Las interrupciones y el RTOS. Una ISR no es una tarea y no puede bloquearse: usa variantes especiales de las funciones (en FreeRTOS, las que terminan en FromISR). Y las ISR cuya prioridad es más urgente que cierto umbral (configMAX_SYSCALL_INTERRUPT_PRIORITY) no pueden llamar al RTOS; son para lo extremadamente urgente, y el sistema no las retrasa nunca.
  • Aparece la concurrencia verdadera. Con expropiación, una tarea puede ser interrumpida entre dos instrucciones cualesquiera por otra tarea, igual que una ISR interrumpe a main. Todos los problemas del tema 16 (n++ no atómico, lecturas de 64 bits partidas) ahora ocurren entre tareas, no solo entre una tarea y una ISR. En el esquema cooperativo era imposible; aquí es el pan de cada día. Los mecanismos para resolverlo (mutex, semáforos, colas) son el tema 21, y los defectos que no resuelven (inversión de prioridad, interbloqueo) son el 22.

Y un costo menos técnico: un sistema con RTOS es más difícil de razonar y de depurar. La secuencia de ejecución ya no es la de un programa sino la de varios entrelazados, y los errores dependen de cuándo ocurren las expropiaciones. Requiere herramientas más finas (depuradores que entienden las tareas, trazas de eventos) y más disciplina.

08 · Decidir

Cooperativa o RTOS: cómo decidir

No hay una respuesta universal, pero sí un criterio razonable. La pregunta de fondo es la del tema 19: ¿existe alguna tarea con plazo corto frente a la duración de las que no se pueden partir? La tabla recoge los indicios:

Indicios para elegir el esquema de planificación.
SituaciónConvienePor qué
Pocas tareas, todas breves, plazos holgados frente a la tarea más largaCooperativaCumple con el mínimo de memoria y complejidad; el análisis es una cuenta
Una tarea urgente y otra larga que no se puede partir (cálculo, criptografía, biblioteca de terceros bloqueante)RTOSLa expropiación evita que la larga bloquee a la urgente
Muchas actividades independientes con esperas (red, almacenamiento, pantalla, sensores)RTOSCódigo lineal por tarea; las máquinas de estados cooperativas se vuelven inmanejables
Pila de comunicaciones o biblioteca que supone un RTOS (TCP/IP, Bluetooth, USB)RTOSSuelen exigir tareas y colas; no tiene sentido pelear contra ellas
Microcontrolador con muy poca RAM (unos pocos KB)CooperativaLas pilas por tarea no entran
Máxima verificabilidad, certificación de seguridadCooperativa o RTOS certificadoCuanto menos código y menos entrelazado, más fácil de demostrar

Un consejo práctico: no migres a un RTOS por moda sino por un plazo que no cumplís. Los productos de complejidad media suelen funcionar mejor, y de forma más económica, con un planificador cooperativo bien dimensionado, hasta que aparece una necesidad concreta que lo supera. Y el camino de salida es suave: una vez que las tareas están escritas como funciones sin esperas, con las técnicas del tema 19, pasarlas a tareas de un RTOS es mecánico.

09 · Analogía con programación

Cooperativa contra expulsiva, en Python

El programa simula el mismo conjunto de cuatro tareas del tema 19 con los dos planificadores, con una resolución de 0,01 ms. En el modo cooperativo, una tarea en marcha termina siempre; en el expulsivo, en cada instante corre la tarea lista de mayor prioridad, y cada cambio de tarea puede costar tiempo de procesador. Se comparan los tiempos de respuesta, los plazos perdidos y la cota exacta de la ecuación del apartado 6, y se calcula el umbral de Liu y Layland y el costo de los cambios de contexto.

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 = 100                                   # ticks por ms: la simulación trabaja con 0,01 ms de resolución

def tareas(pantalla_c=30.0, pantalla_t=100, pantalla_fase=0, sensor_c=1.5, sensor_fase=2, pantalla_primero=False):
    """(nombre, C en ms, T en ms, D en ms, fase en ms). El orden de la lista es la prioridad (el primero manda)."""
    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())
    return lista

def simular(lista, expropiativo, cambio_ms=0.0, duracion_ms=1000):
    """Simula por eventos. Cooperativa: una tarea en marcha termina siempre. Expropiativa: en cada instante corre
    la lista de mayor prioridad, y cada cambio de tarea cuesta 'cambio_ms' de procesador."""
    jobs = []
    for prio, (nombre, c, t, d, fase) in enumerate(lista):
        k = 0
        while fase + k * t < duracion_ms:
            jobs.append(dict(prio=prio, nombre=nombre, lib=round((fase + k * t) * U), rem=round(c * U),
                             d=round(d * U), fin=None))
            k += 1
    jobs.sort(key=lambda j: j["lib"])
    costo = round(cambio_ms * U)
    t, i, listos, actual, cambios = 0, 0, [], None, 0
    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"]; actual = None; continue
        mejor = min(listos, key=lambda j: (j["prio"], j["lib"]))
        if actual is not None and not expropiativo:
            mejor = actual                                   # la tarea en marcha no se interrumpe
        if mejor is not actual:
            cambios += 1
            if expropiativo:
                mejor["rem"] += costo                        # guardar un contexto y restaurar el otro
        actual = mejor
        t_fin = t + actual["rem"]
        t_sig = min(t_fin, jobs[i]["lib"]) if (expropiativo and i < len(jobs)) else t_fin
        actual["rem"] -= t_sig - t
        t = t_sig
        if actual["rem"] == 0:
            actual["fin"] = t; listos.remove(actual); actual = None
    res = {}
    for j in jobs:
        r = res.setdefault(j["nombre"], dict(maxresp=0.0, perdidos=0, n=0))
        r["n"] += 1
        r["maxresp"] = max(r["maxresp"], (j["fin"] - j["lib"]) / U)
        r["perdidos"] += j["fin"] - j["lib"] > j["d"]
    return res, cambios

def respuesta_exacta(lista):
    """Tiempo de respuesta en el peor caso con expropiación: R = C + suma de ceil(R/Tj)*Cj de las más prioritarias."""
    salida = {}
    for i, (nombre, c, t, d, fase) 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 > 20 * d: break
            r = nuevo
        salida[nombre] = nuevo
    return salida

def comparar(titulo, lista):
    print(titulo)
    coop, _ = simular(lista, False)
    expr, cambios = simular(lista, True)
    exacta = respuesta_exacta(lista)
    for nombre, c, t, d, fase in lista:
        a, b = coop[nombre], expr[nombre]
        print(f"  {nombre:9s} plazo {d:3d} ms | cooperativa: {a['maxresp']:6.2f} ms, pierde {a['perdidos']:3d}"
              f" | expropiativa: {b['maxresp']:6.2f} ms, pierde {b['perdidos']:3d} (cota {exacta[nombre]:6.2f})")
    print()
    return cambios

comparar("1. Pantalla entera de 30 ms, la menos prioritaria", tareas())
comparar("2. Sensor bloqueante de 12 ms, pantalla en trozos de 3 ms", tareas(3.0, 10, 0, 12.0, 8))
comparar("3. Pantalla entera, pero con la prioridad más alta", tareas(pantalla_primero=True))
comparar("4. Sensor bloqueante de 12 ms, pantalla entera (plazo de 100 ms)", tareas(30.0, 100, 0, 12.0, 8))

# --- cuánto vale la utilización: la cota de Liu y Layland ------------------------------------------
print("Cota de utilización garantizada (prioridad según el período, el más corto primero):")
for n in (1, 2, 3, 4, 8):
    print(f"  {n} tareas: U <= {n * (2 ** (1 / n) - 1):.3f}")
print("  muchas tareas: tiende a 0,693")

# --- el costo de cambiar de contexto --------------------------------------------------------------
_, cambios = simular(tareas(), True, 0.0)
CAMBIO_US = 5.0                           # costo de un cambio de contexto en un Cortex-M3 a 72 MHz (a medir)
por_s = cambios / 1.0
print()
print(f"Conjunto 1: {cambios} cambios de contexto en 1 s; a {CAMBIO_US} µs cada uno consumen {por_s * CAMBIO_US / 1e4:.2f} % del procesador")

Resultado: en el escenario 1 (pantalla de 30 ms, la menos prioritaria) la expropiación resuelve el problema del tema anterior sin trocear nada: el lazo de control pasa de 20,85 ms (perdiendo 20 de 100 ejecuciones) a 0,40 ms, exactamente su duración, y el botón de 21,70 a 0,45 ms. El precio lo paga la pantalla, que se estira de 30 a 33,30 ms (la cota exacta da el mismo valor) y cumple su plazo de 100. En el 2, con un sensor bloqueante de 12 ms, el lazo sigue en 0,40 ms, pero la pantalla troceada en pedazos de 3 ms, con plazo de 10 ms y la menor prioridad, llega a 13,90 ms y pierde 20 de 100: la expropiación protege a los urgentes y deja sin protección a los de abajo. El 4 muestra la salida natural: con la pantalla sin trocear y plazo de 100 ms, todo cumple, con 44,25 ms para la pantalla. En el 3, con la pantalla como tarea de mayor prioridad, el resultado es idéntico al cooperativo (el lazo en 30,40 ms, perdiendo 30 de 100): la expropiación no ayuda si la tarea larga es la más prioritaria. Los umbrales de Liu y Layland son 0,780 para tres tareas, 0,757 para cuatro y 0,724 para ocho. Y con 270 cambios de contexto por segundo a 5 µs cada uno, el costo es el 0,14 % del procesador.

Probá poner un costo de cambio de contexto más alto en la simulación (por ejemplo 0,1 ms en la llamada a simular) y observá cómo se estira la respuesta de todas las tareas. O invertí el orden de prioridades de otra manera. Cada vez que cambies algo, preguntate primero qué esperás que pase.

10 · Exploración interactiva

El mismo sistema con dos planificadores

El laboratorio ejecuta el simulador del apartado anterior. Elegí si el planificador es cooperativo o expulsivo, cómo se ejecuta la pantalla, cuánto bloquea el sensor, el costo de un cambio de contexto y el orden de prioridades. Verás los tiempos de respuesta, los plazos perdidos, el número de cambios de contexto y la línea de tiempo de los primeros 50 ms, con las tareas expropiadas marcadas.

Cuatro tareas con dos planificadores

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

Línea de tiempo (primeros 50 ms)

—

Cambiá las opciones para comparar los dos planificadores.

Recorrido guiado

  1. Con los valores iniciales (expulsivo, pantalla entera): el lazo de control responde en 0,40 ms, exactamente su duración, y todo cumple. La pantalla de 30 ms tarda 33,30 ms en completarse porque la interrumpen. En la línea de tiempo, mirá cómo la pantalla queda marcada como «expropiada» cada vez que se libera el lazo.
  2. Pasá a Cooperativo sin tocar nada más: el lazo salta a 20,85 ms y pierde 20 de 100 ejecuciones; el botón, también. El mismo sistema, con las mismas tareas y la misma utilización, y un resultado opuesto. Es la diferencia entre los temas 19 y 20.
  3. Volvé a expulsivo y poné el sensor en 12 ms (bloqueante) con la pantalla en trozos de 3 ms: el lazo y el botón siguen impecables (0,40 y 0,45 ms), pero ahora pierde la pantalla (13,90 ms contra un plazo de 10): el sensor, de mayor prioridad, la interrumpe. La expropiación protege a los de arriba a costa de los de abajo.
  4. Con ese mismo sensor, volvé la pantalla a entera: ahora todo cumple, con 44,25 ms para la pantalla. Con un RTOS no hay que trocear nada; hay que darle a cada tarea el plazo que le corresponde.
  5. Con la pantalla entera, poné la pantalla como la más prioritaria: el lazo vuelve a 30,40 ms y pierde 30 de 100, igual que en el modo cooperativo. Nadie puede interrumpir a la tarea de mayor prioridad. La expropiación no arregla las prioridades mal asignadas.
  6. Volvé a las prioridades por plazo, con la pantalla entera, y subí el costo del cambio de contexto a 100 µs (exagerado a propósito): el lazo pasa a 0,50 ms, la pantalla a 34,70 ms y el costo total es del 2,7 % del procesador con 270 cambios por segundo. Con los 5 µs reales sería 20 veces menos, pero no es cero: cada expropiación se paga.
Desafío: ¿se puede perder un plazo en el modo expulsivo con las prioridades correctas?

Sí, y es lo que sucede con la pantalla troceada del tercer paso: la tarea de menor prioridad paga la interferencia de todas las demás. El cálculo del apartado 6 lo predice (R = 15,9 ms en el peor caso contra su plazo de 10). También ocurriría con el lazo si el conjunto de tareas más prioritarias que él tuviera tanta carga que R superara 5 ms. La expropiación no crea tiempo: reparte el que hay, y la tarea que queda al final de la fila es la que se queda sin él cuando falta. Por eso el análisis de utilización y de tiempo de respuesta sigue siendo imprescindible en un RTOS; lo que cambia es la forma de la cuenta.

11 · Método de trabajo

Método para pasar a un RTOS

  1. Confirmá que lo necesitás. Escribí la tarea que no cumple su plazo en el esquema cooperativo y la causa (qué tarea la bloquea y cuánto). Si se resuelve troceando o con una ISR, probablemente no hace falta.
  2. Hacé la tabla de tareas con la duración C, el período T y el plazo D de cada una, medidas con el contador de ciclos.
  3. Asigná prioridades por plazo (monotónicas en frecuencia o en plazos): la de plazo más corto, la mayor. Dejá la más baja para el trabajo sin plazo. Escribilas en un solo archivo de configuración, con el sentido de la escala bien explicado.
  4. Calculá la cota de respuesta de cada tarea con la ecuación del apartado 6, sumando el costo del cambio de contexto y la carga de las ISR. Comprobá que R ≤ D con un 20 a 30 % de margen.
  5. Dimensioná las pilas de cada tarea con el peor caso, empezando generoso y midiendo el uso real (la marca de agua de la pila) para ajustar después. Activá la detección de desborde.
  6. Convertí cada tarea en una función con un bucle infinito en el que siempre hay una espera: vTaskDelayUntil para las periódicas, o la espera de un evento para las demás. Verificá con la lista de la sección 7 que ninguna acapare el procesador.
  7. Identificá todos los datos compartidos entre tareas (y entre tareas e ISR) y protegelos con los mecanismos del tema 21. Lo que en el esquema cooperativo era seguro, ahora no lo es.
  8. Configurá las prioridades de las interrupciones respecto del umbral del RTOS, y usá las funciones FromISR desde las ISR.
  9. Medí: el tiempo de respuesta real de cada tarea (un pin por tarea y un analizador lógico, como en el tema 19), el uso de la pila, la carga del procesador y el número de cambios de contexto. Compará con el cálculo.
  10. Probá a propósito los casos peores: todas las tareas liberadas a la vez, la ISR más frecuente a su ritmo máximo, la memoria al límite.

12 · Comprensión

Confusiones frecuentes

«Un RTOS hace que mi código vaya más rápido»

No ejecuta más instrucciones por segundo; agrega un costo por cada cambio de contexto y por el propio núcleo. Lo que ofrece es previsibilidad y la posibilidad de que la tarea urgente pase por delante de la larga. Un sistema con RTOS, en promedio, trabaja un poco más que sin él.

«Un RTOS es como Linux, en pequeño»

No tiene memoria virtual, ni procesos aislados, ni sistema de archivos, ni usuarios. Es un planificador con servicios de comunicación, dentro del mismo ejecutable y con acceso a todo. Una tarea con un puntero errado puede destruir a otra.

«Más prioridad significa que la tarea es más importante»

Significa más urgente, es decir, de plazo más corto. Una tarea importante pero lenta (guardar datos, mostrar una pantalla) va con baja prioridad. Las ISR siempre mandan sobre las tareas, sea cual sea el número.

«En FreeRTOS, la prioridad 0 es la más alta, como en el NVIC»

Es al revés: en FreeRTOS un número mayor es más prioritario, y la prioridad 0 es la de la tarea inactiva. Mezclar las dos escalas es una fuente constante de errores. Conviene definir constantes con nombre y comentar el sentido en el mismo archivo.

«La tarea de alta prioridad se ocupa de todo mientras tanto»

Una tarea de alta prioridad que nunca se bloquea deja sin procesador a todas las demás. La prioridad alta se justifica por un plazo corto y se acompaña de una tarea breve que se bloquea esperando el próximo evento.

«vTaskDelay(10) da un período de 10 ms»

Espera 10 ticks desde que se llama; el período real es 10 ticks más el tiempo que tardó la tarea en trabajar, y puede variar con las interrupciones. Para un período exacto se usa vTaskDelayUntil, que mide desde el instante teórico anterior, como el proximo += periodo del tema 18.

«Con un RTOS ya no hace falta pensar en los tiempos»

Hace falta pensar igual, con otra fórmula. Las tareas de menor prioridad se estiran, los cambios de contexto cuestan, y un error de prioridades puede reproducir exactamente el problema cooperativo. El RTOS ofrece las herramientas; los plazos los sigue garantizando el análisis.

13 · Práctica guiada

Predecí antes de comprobar

1. ¿Quién corre?

Tres tareas: A (prioridad 3), B (prioridad 2), C (prioridad 1), y la inactiva. A está bloqueada esperando una cola, B está lista y C está lista. ¿Quién corre? Si a los 5 ms llega un dato a la cola que espera A, ¿qué ocurre?

Ver solución

Corre B: es la de mayor prioridad entre las listas (A está bloqueada, y no cuenta). C queda lista. Cuando llega el dato, A pasa a Lista, y como su prioridad (3) supera a la de B (2), expropia a B de inmediato: B pasa a Lista a mitad de su trabajo y corre A. Cuando A vuelva a bloquearse, el sistema elige de nuevo: B, no C, y B retoma donde estaba.

2. Tiempo de respuesta

Tres tareas periódicas con plazo igual al período: C (C = 1, T = 5), A (C = 2, T = 10) y B (C = 5, T = 50), con prioridades monotónicas en frecuencia (C, A, B). Calculá el tiempo de respuesta de cada una y compará con el caso cooperativo del tema 19.

Ver solución

C: es la más prioritaria, R = 1 ms. A: R = 2 + ⌈R/5⌉·1; empezando en 2 resulta 3, y ahí se estabiliza: R = 3 ms. B: R = 5 + ⌈R/5⌉·1 + ⌈R/10⌉·2; empezando en 5: 8, luego 9, y se estabiliza: R = 9 ms. Todos cumplen sus plazos (5, 10 y 50) con holgura. En el esquema cooperativo del tema 19, el mismo conjunto daba para C una cota de 6 ms contra un plazo de 5 ms: no cumplía, porque B (5 ms) la bloqueaba. La expropiación lo resuelve sin tocar ninguna tarea.

3. El umbral de utilización

Para el conjunto del ejercicio anterior, ¿cuál es la utilización y qué dice el umbral de Liu y Layland?

Ver solución

U = 1/5 + 2/10 + 5/50 = 0,2 + 0,2 + 0,1 = 0,5. El umbral para tres tareas es 3·(21/3 − 1) = 0,780. Como 0,5 ≤ 0,78, el conjunto cumple sus plazos con prioridades monotónicas en frecuencia, sin necesidad de calcular los tiempos de respuesta uno por uno. Es un atajo: si hubiera dado 0,85, no se concluiría nada y habría que usar la ecuación exacta.

4. La inanición

Una tarea de prioridad 4 contiene for (;;) { leer_sensor(); procesar(); } sin ninguna espera, y hay otras de prioridades 1 a 3. ¿Qué se observa en el sistema y cómo se corrige?

Ver solución

La tarea nunca se bloquea: está siempre Lista o en ejecución, y como es la de mayor prioridad, las demás tareas, incluida la inactiva, no corren nunca. El sistema parece congelado salvo por esa tarea; si hay watchdog alimentado en la tarea inactiva, el equipo se reinicia. La solución es hacer que la tarea espere en cada vuelta: vTaskDelayUntil a la frecuencia a la que se necesita el dato, o mejor, bloquearla esperando que el sensor avise (una interrupción y un semáforo, tema 21).

5. La memoria

Un STM32F103C8 tiene 20 KB de RAM. El sistema usa 3 tareas con 1 KB de pila, 1 con 2 KB, la inactiva con 0,5 KB y 5 TCB de unos 100 bytes. ¿Qué fracción de la RAM consume el RTOS? ¿Qué pasa si se agregan otras 6 tareas de 1 KB?

Ver solución

3 × 1024 + 2048 + 512 + 5 × 100 = 6132 bytes, es decir, el 30 % de los 20 480 bytes. Con 6 tareas más de 1 KB (y sus TCB), se suman unos 6,7 KB: 12,9 KB en total, el 63 %, y quedan 7,5 KB para las variables globales, el heap y la pila de las interrupciones. En una familia con poca RAM, el límite del número de tareas es la memoria, no el procesador. Es un argumento para agrupar actividades afines en una misma tarea.

6. El costo de la expropiación

Un sistema produce 2000 cambios de contexto por segundo, de 6 µs cada uno. ¿Qué porcentaje del procesador se va en cambios? ¿Y si se sube el tick de 1 kHz a 10 kHz y los cambios se multiplican por 5?

Ver solución

2000 × 6 µs = 12 000 µs por segundo: 1,2 %. Con 5 veces más cambios (10 000 por segundo): 60 000 µs, el 6 %, sin contar el costo de la interrupción del tick, que también es 10 veces más frecuente. No es prohibitivo, pero ya no es despreciable, y sale de un parámetro (la frecuencia del tick) que a veces se sube sin pensar. Se calcula antes de decidir.

14 · Síntesis

Ideas para recordar

  • Un RTOS es una biblioteca pequeña que agrega un planificador, comunicación entre tareas y servicios de tiempo. «Tiempo real» significa predecible, no rápido; y no es un Linux pequeño: no protege ni aísla.
  • Una tarea es una función con un bucle infinito y su propia pila, administrada con un TCB. Puede esperar, de modo que el código vuelve a ser lineal.
  • Cada tarea está en uno de cuatro estados: en ejecución, lista, bloqueada o suspendida. Una bloqueada no consume procesador; una tarea que nunca se bloquea acapara el procesador y deja sin correr a las de menor prioridad.
  • La regla del planificador expulsivo es: corre siempre la tarea lista de mayor prioridad. Las prioridades se asignan por plazo (monotónicas en frecuencia o en plazos), y no miden importancia. En FreeRTOS un número mayor es más prioritario, al revés que en el NVIC; y toda ISR es más urgente que toda tarea.
  • El cambio de contexto guarda los registros de la tarea saliente en su pila y restaura los de la entrante. En un Cortex-M lo hace PendSV (de prioridad mínima) con la ayuda del apilado automático y de los dos punteros de pila; cuesta unos pocos microsegundos.
  • Con expropiación una tarea no espera a las de menor prioridad: R = C + Σ ⌈R/T⌉·C de las de mayor prioridad. Las urgentes quedan protegidas, y las menos urgentes pagan el costo estirándose. La expropiación no ayuda si la tarea larga es la más prioritaria.
  • Los costos son memoria (una pila por tarea), tiempo de cambio de contexto, la resolución del tick, y sobre todo la concurrencia verdadera: los problemas del tema 16 ahora ocurren entre tareas y requieren los mecanismos del tema 21.
  • Un RTOS se adopta por un plazo que no se cumple de otra forma, no por moda: si el planificador cooperativo cumple, es más simple y barato.

¿Listo para el tema 21?

Antes de avanzar, dibujá el diagrama de estados y explicá qué provoca cada transición; calculá el tiempo de respuesta de una tarea de un conjunto que te inventes; y describí qué ocurre, paso a paso, desde que una interrupción libera una tarea de alta prioridad hasta que ésta ejecuta su primera instrucción. Si podés justificar las tres, entendés cómo funciona un RTOS.

En el próximo tema vemos cómo se comunican y se sincronizan las tareas: colas, semáforos, mutex, notificaciones y eventos, las herramientas que resuelven los problemas de datos compartidos que la expropiación hace aparecer.