Todo el curso ha mirado hacia el mismo sitio: un servidor con corriente permanente, memoria de sobra, disco que se puede ampliar y una red que casi siempre está. Y la lección anterior llevó ese modelo al extremo, donde las máquinas sobran, se crean por API y se tiran cuando estorban.
Ahora vamos a mirar en la dirección contraria, hacia dos familias de sistemas operativos que comparten la misma raíz teórica —procesos, planificación, memoria, IPC, protección— pero trabajan con restricciones opuestas. El teléfono desde el que un usuario consulta la API de Meteora ejecuta un núcleo Linux, pero con una batería que se agota, sin espacio de intercambio, con la red intermitente y con aplicaciones de origen desconocido que no deben poder tocarse entre sí: ese conjunto de restricciones ha obligado a reinventar el IPC, la gestión de memoria y el modelo de permisos de los módulos 3 y 5. Y las estaciones meteorológicas de Meteora —los dispositivos empotrados que en 01-03 decidimos que llevarían un RTOS— tienen 64 KB de RAM, funcionan meses con una pila, no pueden pedir otra unidad cuando les falta memoria, y si el muestreo del sensor llega 10 milisegundos tarde, el dato está mal, no "llega despacio".
En esta lección vas a entender qué añade Android sobre Linux y por qué, cómo se aísla una aplicación móvil y por qué el sistema puede matarla, qué significa de verdad "tiempo real" —que no es velocidad—, cómo se demuestra matemáticamente que un conjunto de tareas cumplirá sus plazos, y por qué una nave en Marte estuvo a punto de perderse por un problema de sincronización que ya conoces. Terminaremos escribiendo el firmware de una estación de Meteora sobre FreeRTOS, con sus tareas, sus prioridades justificadas y su análisis de planificabilidad con números concretos.
Contenido
- Qué añade Android sobre el núcleo Linux
- Binder: por qué no bastaba el IPC de UNIX
- Zygote, ART y el truco del arranque
- Aislamiento por aplicación: un UID por app
- Ciclo de vida y por qué el sistema puede matar una app
- Gestión de energía: wake locks, Doze y big.LITTLE
- iOS comparado
- Qué debe saber quien diseña la API que consume la app
- Tiempo real: la definición correcta
- Duro y blando, con consecuencias
- El modelo de tareas periódicas
- Rate Monotonic y la cota de Liu y Layland
- EDF y el análisis exacto de tiempos de respuesta
- Inversión de prioridades y el Mars Pathfinder
- Latencia de interrupción, jitter y determinismo
- RTOS reales y Linux con
PREEMPT_RT - Particularidades de los sistemas empotrados
- Caso práctico: el firmware de una estación de Meteora
- Cierre del módulo 6
Qué añade Android sobre el núcleo Linux
Android es Linux: el mismo núcleo monolítico modular de 01-05, los mismos procesos de 02-01, los mismos namespaces y cgroups de 06-02. Pero por encima del núcleo casi nada es lo que conoces: no hay glibc (usa Bionic, más pequeña), no hay systemd (usa init propio con ficheros .rc), no hay X11 ni Wayland, y el espacio de usuario está diseñado alrededor de premisas que en un servidor no existen.
| Restricción del móvil | Consecuencia de diseño |
|---|---|
| Batería limitada | Todo el sistema optimiza energía antes que rendimiento sostenido |
| Sin espacio de intercambio | Cuando falta RAM hay que matar procesos, no paginar a disco |
| Aplicaciones de origen desconocido | Aislamiento fuerte por aplicación, no por usuario |
| Interfaz siempre fluida | La tarea de interfaz debe tener prioridad casi de tiempo real |
| Red intermitente y cara | Agrupación de trabajo y tolerancia a la desconexión |
| Hardware muy heterogéneo | Una capa de abstracción (HAL) entre el marco y los controladores |
Los añadidos de Android sobre el núcleo son, en esencia, cinco: Binder, un IPC propio implementado como controlador del núcleo; zygote, el proceso que precarga el entorno de ejecución para arrancar apps al instante; ART, la máquina de ejecución de las aplicaciones; la HAL, capa de abstracción que desacopla el marco de Android de los controladores del fabricante y que desde el Proyecto Treble vive en procesos separados con interfaces versionadas, lo que permite actualizar Android sin que el fabricante rehaga los controladores; y un modelo de aislamiento por aplicación construido sobre los UID de 05-02, pero usado de una manera que en UNIX nadie había previsto.
Binder: por qué no bastaba el IPC de UNIX
En 03-03 vimos el catálogo clásico de IPC: tuberías, FIFO, colas de mensajes, memoria compartida, sockets. Android tiene todo eso disponible y aun así construyó otro mecanismo. La pregunta interesante es por qué.
La respuesta es que en Android casi todo es una llamada entre procesos: pedir la ubicación, consultar la batería, dibujar en pantalla, pedir un permiso. El patrón real no es "enviar un flujo de bytes", sino invocar un método de un objeto que vive en otro proceso y esperar el resultado. Y ese patrón exige cosas que el IPC clásico no da:
| Necesidad | Qué ofrece el IPC clásico | Qué ofrece Binder |
|---|---|---|
| Llamada síncrona con resultado | Hay que montarla a mano sobre dos canales | Nativa: es su modelo |
| Saber quién llama, de forma fiable | El PID se puede reutilizar; el UID hay que pasarlo | El núcleo inyecta UID y PID del llamante |
| Contar referencias a objetos remotos | No existe | Integrado: el objeto muere cuando nadie lo usa |
| Copias de datos | 2 copias (emisor → núcleo → receptor) | 1 copia, con memoria mapeada |
| Límite de recursos | Cada mecanismo el suyo | Pool de hilos por proceso, con control |
| Muerte del interlocutor | Se detecta tarde y mal | Notificación de defunción explícita |
La fila decisiva para la seguridad es la segunda. Cuando meteo-app pide la ubicación, el servicio del sistema necesita saber con certeza quién pregunta para comprobar si tiene el permiso. Si el identificador lo enviara el propio llamante, mentiría. Binder resuelve esto en el núcleo: el controlador añade el UID y el PID reales del emisor a cada transacción, y el receptor los consulta con Binder.getCallingUid(). Es el mismo principio que hacía valioso a journald en 05-04 —los metadatos los pone quien no puede mentir—, aplicado al IPC.
La otra fila importante es la de las copias. Binder mapea un área de memoria del proceso receptor y escribe ahí directamente desde el núcleo, con lo que la transacción cuesta una sola copia en lugar de dos. Con decenas de miles de transacciones por segundo en un teléfono activo, esa mitad importa en energía y en latencia.
El precio es un tamaño de transacción limitado (del orden de 1 MB compartido por proceso), por lo que Binder no sirve para mover datos grandes: para eso se pasa un descriptor de fichero de memoria compartida, que Binder sí sabe transferir entre procesos igual que hacía SCM_RIGHTS sobre sockets UNIX en 03-03.
Zygote, ART y el truco del arranque
Cada aplicación Android se ejecuta en su propio proceso, con su propia instancia del entorno de ejecución. Arrancar ese entorno desde cero —cargar las clases del marco, inicializar los recursos— costaría cientos de milisegundos y varios megabytes por aplicación. Con cuarenta aplicaciones, sería inviable.
La solución es zygote ("cigoto"), y es una aplicación preciosa de algo que ya conoces. Al arrancar el sistema, init lanza un proceso zygote, que precarga las clases del marco y los recursos comunes —miles de clases y decenas de megabytes ya inicializados— y se queda esperando en un socket. Cuando el usuario abre una aplicación, el gestor de actividades le pide a zygote que haga fork(): el hijo hereda todo lo precargado, solo tiene que cargar el código propio de la app, y después llama a setuid() con el UID de la aplicación para soltar privilegios, exactamente el patrón de 05-02.
La clave está en el fork() y en el copy-on-write de 02-01 y 02-04: el hijo no copia los cientos de megabytes del marco, comparte las mismas páginas físicas con zygote y con todos sus hermanos. Solo cuando un proceso escribe en una página se hace una copia privada. Y como las clases precargadas son de solo lectura en la práctica, casi nunca se escriben.
El resultado es doble y espectacular: arranque de una app en decenas de milisegundos en lugar de cientos, y un ahorro de memoria enorme, porque cuarenta aplicaciones comparten físicamente el mismo marco. Fíjate en que es la misma idea que KSM en 06-01 y que las capas de overlayfs en 06-02: compartir lo idéntico y copiar solo al escribir. El truco aparece en los tres mundos.
Sobre ART (Android Runtime), lo relevante para este curso es su evolución, que ilustra una decisión de sistemas: se pasó de interpretar (lento pero sin coste de instalación) a compilar todo al instalar (rápido pero con instalaciones larguísimas y mucho espacio) y finalmente a un esquema híbrido —se interpreta al principio, se perfila lo que de verdad se usa, y se compila en segundo plano cuando el dispositivo está cargando y ocioso—. Esa última condición es puro diseño para móvil: el trabajo caro se hace cuando la energía es gratis.
Aislamiento por aplicación: un UID por app
Aquí Android hace algo que reinterpreta por completo el modelo de 05-02. En un servidor, el UID identifica a una persona, y varios programas de la misma persona comparten UID y pueden tocarse los ficheros entre sí. En un móvil solo hay una persona, así que ese modelo no aísla nada.
Android reutiliza el mecanismo con otro significado: cada aplicación instalada recibe su propio UID, en el rango 10000 en adelante.
adb shell ps -A -o USER,PID,NAME | head
# USER PID NAME
# root 1 init
# system 1842 system_server
# u0_a132 4021 com.meteora.app
# u0_a145 4188 com.otra.app
adb shell ls -la /data/data/com.meteora.app
# drwx------ 4 u0_a132 u0_a132 ... . ← modo 700, del UID de la appQué demuestra. u0_a132 es el usuario 0, aplicación 132: un UID real del sistema (10132). El directorio de datos privado está en modo 700 y pertenece a ese UID. Con eso, los nueve bits de permiso de 04-06 —el mecanismo más viejo del curso— aíslan a las aplicaciones entre sí sin necesidad de nada más. Es una reutilización brillante de un mecanismo existente.
El aislamiento completo, sin embargo, tiene cuatro capas superpuestas, y todas te suenan:
| Capa | Mecanismo | De qué lección |
|---|---|---|
| Identidad | Un UID por aplicación, datos en modo 700 | 04-06, 05-02 |
| MAC | SELinux en modo obligatorio para todo el sistema | 05-01 |
| Confinamiento de syscalls | Filtro seccomp para las aplicaciones | 05-01 |
| Permisos de usuario | Concesión explícita por el usuario en tiempo de ejecución | Sin equivalente en UNIX |
La cuarta capa es la que no tiene precedente. Los permisos en tiempo de ejecución dividen el acceso en dos: los permisos normales (internet, vibración) se conceden al instalar, y los peligrosos (ubicación, cámara, micrófono, contactos) los concede el usuario, en el momento de usarlos, y puede revocarlos. Además pueden concederse de forma acotada: "solo mientras uso la app", "solo esta vez", "ubicación aproximada en lugar de exacta".
Compáralo con el modelo de 05-02: en un servidor, un programa hereda todos los permisos del usuario que lo ejecuta. Si ejecutas un script descargado, ese script puede leer todo lo que tú puedas leer. En Android, una aplicación tiene por defecto cero acceso a todo lo interesante, y el usuario va concediendo capacidades una a una. Es el mínimo privilegio de 05-01 llevado a un modelo donde el software no es de confianza por defecto —y, sinceramente, es un modelo mejor que el del escritorio y el del servidor, donde seguimos ejecutando programas con todos nuestros privilegios—.
Ciclo de vida y por qué el sistema puede matar una app
Esta es la diferencia que más cuesta a quien viene del servidor: en Android, el sistema puede matar tu proceso en cualquier momento y no es un fallo, es el funcionamiento normal.
El motivo es la fila más importante de la tabla del principio: no hay espacio de intercambio. En 02-04 vimos que cuando falta memoria, Linux pagina a disco y solo invoca al OOM killer como último recurso desesperado. En un móvil, con memoria flash de vida limitada y sin partición de swap, esa vía no existe. La única forma de recuperar memoria es liberar procesos enteros.
Así que Android convirtió el último recurso en política ordinaria. El sistema clasifica cada proceso según lo que el usuario percibiría si desapareciera:
| Categoría | Qué es | ¿Se mata? |
|---|---|---|
| Primer plano | Lo que el usuario está usando ahora | Casi nunca |
| Visible | Visible pero no en foco | Rara vez |
| De servicio | Trabajo en segundo plano (descarga, música) | Si hace falta |
| En caché | Ya no hace nada, se mantiene por si vuelve | Constantemente |
| Vacío | Solo el proceso, sin componentes activos | El primero en caer |
Y la diferencia con el OOM killer de 02-04 es cualitativa:
| OOM killer (02-04) | LMKD (Android) | |
|---|---|---|
| Cuándo actúa | Cuando ya no hay memoria | Antes, por presión creciente (PSI) |
| Criterio | Heurística oom_score: sobre todo el tamaño |
Importancia para el usuario |
| Percepción | Un desastre inesperado | Comportamiento normal y esperado |
| Efecto | Se pierde el trabajo | La app debe haber guardado su estado |
LMKD (Low Memory Killer Daemon) vive en espacio de usuario y usa los indicadores de presión de memoria (PSI) del núcleo para actuar antes de que el sistema se degrade: en cuanto la presión sube, empieza a matar procesos en caché, de los menos importantes hacia arriba.
La consecuencia para el desarrollador es una regla dura: el estado que no se ha guardado, se pierde. Por eso el ciclo de vida de una actividad tiene devoluciones de llamada como onPause() y onStop(), con una promesa explícita: cuando se llama a onStop(), el sistema garantiza que has tenido ocasión de guardar; a partir de ahí puede matarte sin más aviso. Una app bien escrita persiste su estado en cada transición y lo restaura al volver, de modo que el usuario no percibe que el proceso murió y volvió a nacer.
Gestión de energía: wake locks, Doze y big.LITTLE
En un servidor, la energía no es un recurso a gestionar: se enchufa y ya está. En un móvil es el recurso escaso principal, y el sistema operativo lo gestiona con la misma seriedad con la que gestiona la CPU.
El principio de partida es que el estado natural del dispositivo es dormido. La CPU, la pantalla, la radio y los sensores están apagados o en bajo consumo, y solo se despiertan por un evento. La pregunta que se hace el sistema no es "cómo reparto la CPU" sino "cómo consigo volver a dormir cuanto antes".
- Wake locks. Un componente que necesita mantener la CPU despierta adquiere un wake lock, y mientras alguno esté activo el sistema no entra en suspensión profunda. Es necesario y es la primera causa de aplicaciones que agotan la batería: uno adquirido y no liberado —por una rama de error que no pasa por el
release()— mantiene el teléfono despierto toda la noche. El patrón correcto es adquirir con tiempo máximo y liberar en un bloquefinally, con la disciplina de un mutex de 03-04. - Doze. Cuando el dispositivo lleva un rato quieto, con la pantalla apagada y sin cargar, el sistema agrupa todo el trabajo diferido: suspende los accesos a red, aplaza alarmas y sincronizaciones e ignora los wake locks, y cada cierto tiempo abre una ventana de mantenimiento donde todo lo pendiente se ejecuta a la vez. Es la misma lógica de amortización que las virtqueues de
virtioen 06-01 y NAPI en 02-07: una interrupción por lote en lugar de una por evento, porque despertar la radio cuesta energía fija y hacerlo una vez para veinte cosas cuesta veinte veces menos. - Restricciones en segundo plano. Una app que no está en primer plano no puede iniciar servicios a voluntad. El trabajo diferido se declara a un planificador del sistema con restricciones —"cuando haya WiFi y el dispositivo esté cargando"—, y el sistema decide cuándo ejecutarlo agrupándolo con el de las demás aplicaciones.
Y en el nivel más bajo, el planificador de 02-02 se vuelve consciente de la energía. Los procesadores móviles son big.LITTLE: núcleos grandes y rápidos que consumen mucho junto a núcleos pequeños y eficientes. El planificador ya no solo decide cuándo ejecuta cada tarea, sino en qué tipo de núcleo, estimando su carga y su exigencia de latencia: la tarea de interfaz va a un núcleo grande porque el usuario percibe cada milisegundo, y una sincronización en segundo plano va a un núcleo pequeño porque nadie la está mirando. Es una dimensión que el CFS de un servidor no tenía, porque allí todos los núcleos son iguales.
iOS comparado
| Aspecto | Android | iOS |
|---|---|---|
| Núcleo | Linux (monolítico modular) | XNU: híbrido, con Mach y BSD (01-05) |
| Modelo | Abierto, muchos fabricantes | Cerrado, hardware y software del mismo fabricante |
| Instalación de apps | Tienda oficial y fuentes externas | Solo tienda oficial (con excepciones regulatorias) |
| Aislamiento | Un UID por app + SELinux + seccomp | Sandbox obligatorio por app, firmada y con entitlements |
| Lenguaje y ejecución | Java/Kotlin sobre ART | Swift/Objective-C compilado a nativo |
| Gestión de memoria | Recolector de basura + LMKD | Recuento de referencias (ARC) + jetsam |
| Segundo plano | Servicios y trabajo con restricciones | Muy restringido: modos concretos y ventanas cortas |
| Actualizaciones | Dependen del fabricante (mejor con Treble) | Directas del fabricante, a todo el parque |
Las dos diferencias con más consecuencias técnicas son la gestión de memoria y el segundo plano. ART usa recolección de basura, cómoda para el programador pero con pausas y consumo de energía, mientras que iOS usa recuento automático de referencias decidido en compilación, que da un uso más ajustado y predecible a cambio de que el programador rompa los ciclos a mano —de ahí que iOS haya funcionado históricamente bien con menos RAM—. Y en segundo plano iOS es mucho más restrictivo: modos concretos, ventanas cortas y muerte sin contemplaciones (jetsam) al pasarse del presupuesto de memoria, lo que da mejor batería y peor flexibilidad, y explica que muchas funciones que en Android hace la app en iOS las tenga que hacer el servidor con notificaciones push.
Qué debe saber quien diseña la API que consume la app
Este apartado es el puente entre las dos mitades del curso, porque quien escribe meteo-api en meteo-01 no ve nada de lo anterior y aun así le afecta todo. Tres restricciones del cliente móvil deben aparecer en el diseño del servidor:
1. Agrupar peticiones, porque la radio es el gasto. Encender la radio móvil para transmitir cuesta energía fija —además, la radio se queda en estado de alto consumo unos segundos después de terminar, "por si acaso"—. Diez peticiones separadas de 2 KB pueden gastar mucho más que una de 20 KB. La API debe permitir pedir varias cosas de una vez:
En lugar de tres peticiones (una por estación) que devuelven todos los campos, una sola petición con selección de campos. La regla de diseño es: una pantalla, una petición. Si la app necesita tres llamadas para pintar una vista, la API está mal diseñada para móvil.
2. Tolerar la desconexión, porque es el estado normal. Un móvil pierde la red en un ascensor, cambia de WiFi a móvil a mitad de una petición y se queda sin cobertura en un túnel. El servidor debe ayudar:
Operaciones idempotentes con una clave de idempotencia, para que reintentar tras un tiempo de espera agotado no duplique nada —sin esto, el reintento del cliente, que es inevitable, crea datos duplicados—; sincronización incremental (?desde_version=8421) en lugar de descargarlo todo otra vez, porque reanudar debe ser barato; etiquetas de entidad y respuestas 304, para que revalidar cueste bytes y no kilobytes; y errores distinguibles, porque el cliente necesita saber si debe reintentar y con qué retardo, y un Retry-After ahorra muchísima batería frente a un bucle de reintentos inmediatos.
3. Cuidar el tamaño de la respuesta, porque se paga tres veces: en datos del usuario, en tiempo de radio (energía) y en memoria de análisis, en un dispositivo que puede estar a punto de que LMKD lo mate. Devolver el fichero diario entero de 17,3 MB a un móvil es un error en tres dimensiones a la vez. Lo correcto es paginar por defecto, comprimir siempre, ofrecer agregados en lugar de datos crudos —el móvil quiere la media horaria, no las 720.000 lecturas— y permitir seleccionar campos.
Y una advertencia que cierra el círculo con la lección anterior: el móvil no puede reintentar indefinidamente, así que la elasticidad del servidor no lo salva todo. Si meteo-api tarda 8 segundos en responder porque está escalando en frío, la app ya ha agotado su tiempo de espera y ha gastado radio para nada.
Tiempo real: la definición correcta
Cambiamos de mundo. Y lo primero es desmontar el malentendido que arrastra todo el mundo al empezar:
Un sistema de tiempo real no es un sistema rápido. Es un sistema previsible.
La propiedad que define el tiempo real es que la respuesta se produce dentro de un plazo garantizado, siempre, incluso en el peor caso. Un sistema que responde en 1 microsegundo el 99,99 % de las veces y en 50 milisegundos el 0,01 % restante no es de tiempo real. Un sistema que responde siempre en 8 milisegundos, ni uno más, con un plazo de 10 milisegundos, sí lo es, aunque sea mil veces más lento que el primero.
De ahí una consecuencia que sorprende: en tiempo real, la corrección de un resultado incluye el instante en que se produce. Un resultado correcto entregado tarde es un resultado incorrecto. Y otra consecuencia práctica: los sistemas de tiempo real sacrifican rendimiento medio a cambio de previsibilidad. Se desactivan cachés y predicciones, se evitan estructuras de datos con coste variable, se prohíbe la asignación dinámica de memoria... todo lo que introduzca variabilidad, aunque en promedio fuera más rápido.
Aplicado a Meteora: si la estación debe muestrear el sensor cada 100 ms para calcular medias correctas, lo que importa no es que el muestreo tarde 2 microsegundos, sino que ocurra siempre dentro de su ventana. Un muestreo que a veces llega a los 130 ms desplaza la media y corrompe el dato, aunque la CPU esté al 3 %.
Duro y blando, con consecuencias
Retomamos y ampliamos la clasificación de 01-03:
| Tiempo real duro | Tiempo real blando | |
|---|---|---|
| Incumplir un plazo | Fallo del sistema | Degradación de la calidad |
| Garantía | Demostrada matemáticamente antes de ejecutar | Estadística: "el 99 % bajo 20 ms" |
| Dimensionado | Para el peor caso absoluto | Para el caso típico con margen |
| Coste | Alto: hardware sobrado, análisis, certificación | Moderado |
| Ejemplos | Airbag, control de vuelo, frenos ABS, marcapasos, control de reactor | Vídeo, audio, videojuegos, telefonía, interfaz de usuario |
La diferencia se ve mejor en las consecuencias del fallo. Un airbag (duro) debe desplegarse entre 15 y 30 ms tras la colisión: a los 50 ms el ocupante ya ha impactado y el despliegue causa lesiones en lugar de evitarlas, así que no hay "un poco tarde", hay correcto o mortal. Un reproductor de vídeo (blando) que pierde un fotograma muestra un tirón, molesto y no catastrófico, y la respuesta correcta es descartarlo y seguir. El frenado automático (duro) tiene un plazo derivado de la velocidad y la distancia, e incumplirlo es un accidente. Y la estación meteorológica es blanda, conviene ser honesto: un muestreo con 30 ms de retraso degrada ligeramente la media horaria y nadie muere, pero si se retrasan sistemáticamente o se pierde el envío por bloqueo, el dato se corrompe y Meteora sirve información falsa. Es blando con requisitos exigentes, la categoría más común en la industria.
Hay una tercera categoría útil, el tiempo real firme: incumplir el plazo no rompe nada, pero el resultado no vale para nada —una predicción de trayectoria que llega después de tener que decidir—. Se descarta el resultado y se sigue.
El modelo de tareas periódicas
Para poder demostrar que un sistema cumplirá sus plazos hace falta un modelo. El clásico describe cada tarea τᵢ con tres números:
| Símbolo | Nombre | Qué es |
|---|---|---|
Tᵢ |
Periodo | Cada cuánto se activa la tarea |
Cᵢ |
Tiempo de cómputo en el peor caso (WCET) | Lo máximo que puede tardar una ejecución |
Dᵢ |
Plazo | Cuándo debe haber terminado desde su activación |
Lo habitual —y lo que asumiremos— es Dᵢ = Tᵢ: la tarea debe terminar antes de volver a activarse.
La utilización de una tarea es Uᵢ = Cᵢ / Tᵢ, y la del sistema U = Σ Cᵢ/Tᵢ. Una tarea que tarda 12 ms y se ejecuta cada 100 ms utiliza el 12 % de la CPU.
El número difícil es el WCET, y merece una advertencia seria. No es "lo que he medido ejecutándolo mil veces", sino la cota superior absoluta, y determinarlo es sorprendentemente difícil porque el hardware moderno conspira contra la previsibilidad: las cachés hacen que la misma función tarde 10 veces más si los datos no están dentro (los 200-300 ciclos de un acceso a RAM de 06-01), un fallo de predicción de saltos cuesta decenas de ciclos, la ejecución fuera de orden y la prebúsqueda hacen que el tiempo dependa del historial reciente, y otros núcleos interfieren en la caché compartida y en el bus de memoria.
Por eso los sistemas de tiempo real duro usan a menudo hardware deliberadamente simple y predecible —microcontroladores sin caché, o con memoria de trabajo dedicada— y por eso hay herramientas de análisis estático que calculan cotas del WCET sobre el binario. En un sistema blando basta con medir el máximo observado y añadir un margen generoso (del 30 % al 100 %), que es lo que haremos con la estación.
Rate Monotonic y la cota de Liu y Layland
Rate Monotonic (RM) es un algoritmo de asignación de prioridades fijas con una regla de una sola línea:
Cuanto menor es el periodo, mayor es la prioridad.
Y su virtud, demostrada por Liu y Layland en 1973, es que es óptimo entre los algoritmos de prioridad fija: si algún esquema de prioridades fijas puede planificar un conjunto de tareas, RM también puede.
Su prueba de planificabilidad es una condición suficiente:
U = Σ (Cᵢ / Tᵢ) ≤ n · (2^(1/n) − 1)
donde n es el número de tareas. La cota vale:
| n | Cota | n | Cota | |
|---|---|---|---|---|
| 1 | 100,0 % | 5 | 74,3 % | |
| 2 | 82,8 % | 10 | 71,8 % | |
| 3 | 78,0 % | ∞ | 69,3 % (ln 2) |
|
| 4 | 75,7 % |
Dos lecturas importantísimas de esa tabla. Si la utilización total está por debajo de la cota, el sistema cumplirá todos sus plazos, garantizado, y no hay que probar nada más. Si está por encima, la prueba no dice nada: puede que se cumplan igualmente, porque es una condición suficiente y no necesaria, y hay que recurrir al análisis exacto.
Y una conclusión de diseño que hay que interiorizar: con muchas tareas, RM solo garantiza el 69,3 % de la CPU. Ese 30 % restante no es desperdicio: es el precio de la garantía. Quien dimensione un sistema de tiempo real duro al 95 % de utilización no ha entendido el problema.
EDF y el análisis exacto de tiempos de respuesta
EDF (Earliest Deadline First) usa prioridades dinámicas: en cada instante ejecuta la tarea cuyo plazo absoluto esté más próximo. Su prueba de planificabilidad es de una simplicidad asombrosa:
U = Σ (Cᵢ / Tᵢ) ≤ 1
Y aquí es condición necesaria y suficiente: si cabe en la CPU, EDF lo planifica. Es óptimo entre todos los algoritmos de un solo procesador.
| Aspecto | Rate Monotonic | EDF |
|---|---|---|
| Prioridades | Fijas, calculadas antes de ejecutar | Dinámicas, recalculadas en ejecución |
| Utilización garantizada | 69,3 % - 100 % según n |
100 % |
| Coste en ejecución | Mínimo | Mayor: hay que ordenar por plazo |
| Previsibilidad ante sobrecarga | Buena: fallan primero las de menor prioridad | Mala: efecto dominó impredecible |
| Implementación | Trivial: prioridades estáticas | Requiere soporte del planificador |
| Uso en la industria | Dominante | Creciente (SCHED_DEADLINE en Linux) |
La fila de la sobrecarga explica por qué RM sigue dominando en sistemas críticos pese a ser peor en utilización. Si el sistema se sobrecarga —una interrupción imprevista, un WCET mal estimado—, con RM sabes exactamente quién falla: las tareas de menor prioridad, es decir, las de periodo más largo, y puedes diseñar para que sean las menos importantes. Con EDF, una tarea que se pasa de su plazo hace que otra se pase del suyo, y esa a otra: el fallo se propaga de forma difícil de predecir. En un sistema crítico, saber quién va a fallar vale más que exprimir el último 25 % de CPU.
El análisis exacto: tiempos de respuesta
Cuando la cota de RM no basta, se usa el análisis de tiempo de respuesta, que calcula el peor tiempo de respuesta real de cada tarea:
Rᵢ = Cᵢ + Σ_{j ∈ hp(i)} ⌈Rᵢ / Tⱼ⌉ · Cⱼ
Se lee así: el peor tiempo de respuesta de una tarea es su propio cómputo más toda la interferencia de las tareas de mayor prioridad (hp(i)), donde cada una interfiere tantas veces como se active durante ese intervalo —de ahí el techo ⌈ ⌉—. Como Rᵢ aparece en los dos lados, se resuelve iterando: se empieza con R = Cᵢ y se recalcula hasta que el valor no cambia. El sistema es planificable si Rᵢ ≤ Dᵢ para todas. Lo aplicaremos con números reales en el caso práctico.
Inversión de prioridades y el Mars Pathfinder
El 4 de julio de 1997 la sonda Mars Pathfinder aterrizó en Marte con éxito. Días después empezó a reiniciarse sola, perdiendo datos científicos en cada reinicio. El fallo estaba en un problema de sincronización que ya conoces del módulo 3, y su desenlace es una de las mejores historias de la ingeniería de sistemas.
La inversión de prioridades ocurre así, con tres tareas:
- Una tarea de baja prioridad (
B) adquiere un mutex sobre un recurso compartido. - Una tarea de alta prioridad (
A) se activa, intenta adquirir el mismo mutex y se bloquea. Hasta aquí es correcto y esperado. - Aparece una tarea de media prioridad (
M), que no usa el mutex. Como tiene más prioridad queB, desaloja aB. - Resultado:
Bno avanza, así que no suelta el mutex, así queAno puede continuar.M, de prioridad media, está bloqueando indirectamente aA, de prioridad alta, durante un tiempo que puede ser arbitrariamente largo.
En el Pathfinder, el reparto era exactamente ese: una tarea de gestión del bus de información (alta prioridad, con plazo), una tarea de datos meteorológicos (baja prioridad, ASI/MET) que compartía con ella un mutex sobre el bus, y una tarea de comunicaciones (prioridad media, de larga duración). Cuando se daba la secuencia, la tarea del bus incumplía su plazo, el watchdog detectaba que el sistema no había hecho su trabajo a tiempo y reiniciaba la nave, que es exactamente lo que un watchdog debe hacer.
Nota la coincidencia, que es demasiado buena para dejarla pasar: la tarea de baja prioridad que provocó todo era la de datos meteorológicos. La estación de Meteora que diseñaremos al final tiene la misma estructura de tareas.
Las dos soluciones clásicas:
| Protocolo | Cómo funciona | Ventaja | Inconveniente |
|---|---|---|---|
| Herencia de prioridad | Cuando A se bloquea en un mutex que tiene B, B hereda temporalmente la prioridad de A hasta soltarlo |
Simple, se activa solo cuando hace falta | No previene interbloqueos; el bloqueo puede encadenarse |
| Techo de prioridad | A cada mutex se le asigna la prioridad más alta de las tareas que lo usan; quien lo adquiere sube a ese techo inmediatamente | Acota el bloqueo a una sola sección crítica y previene interbloqueos | Hay que calcular los techos antes de ejecutar |
Con herencia de prioridad, en el paso 3 B ya está ejecutando con prioridad alta, así que M no la desaloja: B termina, suelta el mutex y A continúa. El bloqueo queda acotado a la duración de la sección crítica de B.
Y lo mejor de la historia: VxWorks ya tenía herencia de prioridad; estaba desactivada en ese mutex por rendimiento. El equipo del JPL reprodujo el fallo en el laboratorio y envió a Marte un cambio de un parámetro para activarla. La nave siguió funcionando.
Tres lecciones que valen para cualquier sistema, no solo para naves: la sincronización de 03-04 no es un detalle de implementación, sino parte del análisis temporal, y un mutex mal usado convierte un sistema demostrado en uno que falla; los mecanismos de seguridad desactivados "por rendimiento" acaban costando caro; y el sistema funcionó como debía, porque el watchdog detectó el incumplimiento y reinició, sin lo cual la nave se habría quedado colgada en Marte sin diagnóstico posible.
Latencia de interrupción, jitter y determinismo
En 02-07 vimos las interrupciones desde la perspectiva del rendimiento. En tiempo real, lo que importa es otra cosa: cuánto se tarda, en el peor caso, desde que ocurre el evento físico hasta que se ejecuta el código que debe responder.
Esa latencia de interrupción se descompone así:
| Componente | Qué es | Cómo se reduce |
|---|---|---|
| Latencia del hardware | Detección y señalización en el controlador | Poco margen |
| Secciones con interrupciones deshabilitadas | El núcleo estaba en una región crítica y no atiende | Núcleo expropiable; secciones críticas cortas |
| Guardado de contexto | Salvar registros y saltar a la rutina | Hardware; a veces optimizado |
| Ejecución de la rutina | La propia atención de la interrupción | ISR cortísima; el trabajo, a una tarea |
| Cambio de contexto a la tarea | Despertar y planificar la tarea que espera | Planificador expropiable, prioridades correctas |
El jitter es la variación de esa latencia entre activaciones. Y aquí está el punto clave para entender el tiempo real: el jitter suele importar más que la latencia media. Un sistema con 500 µs de latencia constante es perfectamente utilizable —basta con contar con esos 500 µs—; un sistema con 50 µs de media pero picos de 5 ms es inservible para control, porque hay que dimensionar para el peor caso y ese peor caso es 5 ms.
Por eso las técnicas del tiempo real van todas en la misma dirección —reducir la variabilidad— y sacrifican alegremente el rendimiento medio: rutinas de interrupción mínimas que solo señalan a una tarea, secciones críticas cortísimas, prohibición de asignar memoria dinámicamente en ejecución (malloc tiene coste variable y puede fragmentar), estructuras de datos de coste acotado, y a veces cachés desactivadas o fijadas para las rutas críticas.
RTOS reales y Linux con PREEMPT_RT
| Sistema | Tipo | Huella | Licencia | Uso típico |
|---|---|---|---|---|
| FreeRTOS | Duro, muy ligero | 6-12 KB | MIT | Microcontroladores, IoT, sensores |
| Zephyr | Duro, modular | 8-100 KB | Apache 2.0 | IoT con conectividad, wearables |
| QNX | Duro, microkernel (01-05) | ~1 MB | Comercial | Automoción, medicina, industrial |
| VxWorks | Duro, certificable | ~1 MB | Comercial | Aeroespacial, defensa, ferrocarril |
Linux + PREEMPT_RT |
Blando/firme, latencias de decenas de µs | ~50 MB+ | GPL | Robótica, audio, control industrial |
Merece la pena detenerse en los dos extremos. FreeRTOS no es un sistema operativo en el sentido de este curso: es una biblioteca de planificación que se enlaza con tu programa, sin procesos, sin protección de memoria y sin sistema de ficheros; hay tareas que comparten el mismo espacio de direcciones, un planificador expropiativo por prioridades fijas, colas, semáforos y mutex con herencia de prioridad. Cabe en 10 KB porque no hace casi nada más, y por eso funciona con 64 KB de RAM. Linux con PREEMPT_RT es el otro extremo: un sistema completo al que se le ha hecho expropiable casi todo el núcleo, convirtiendo los spinlocks en mutex que pueden dormir, moviendo las rutinas de interrupción a hilos del núcleo con prioridad —para que una interrupción no retrase indefinidamente a una tarea más prioritaria— y añadiendo herencia de prioridad en los mutex del núcleo. El parche se fusionó en la rama principal en 2024, tras más de veinte años.
Con PREEMPT_RT, latencias en el peor caso del orden de decenas de microsegundos son alcanzables, frente a los milisegundos de un Linux normal. Combinado con SCHED_DEADLINE —la política que ya nombramos en 02-02, que implementa EDF con presupuesto: se declaran periodo, plazo y tiempo de ejecución, y el núcleo rechaza la tarea si no cabe—, se cubre bien el tiempo real blando y firme.
¿Cuándo basta Linux y cuándo hace falta un RTOS? Basta Linux con PREEMPT_RT cuando los plazos están en cientos de microsegundos o milisegundos, cuando hace falta red, sistema de ficheros o pantalla, y cuando el sistema es blando o firme: robótica, audio profesional, control industrial de gama media. Hace falta un RTOS cuando los plazos son de microsegundos, cuando la memoria se mide en kilobytes, cuando el consumo debe ser mínimo o cuando hay que certificar el sistema (DO-178C en aviónica, ISO 26262 en automoción, IEC 62304 en medicina): certificar un Linux completo es económicamente inviable, certificar 10 KB de FreeRTOS es factible.
Particularidades de los sistemas empotrados
Cuatro realidades que cambian la forma de programar:
- Memoria escasa y estática. 64 KB de RAM y 512 KB de flash es normal, y se prohíbe la asignación dinámica después del arranque: buffers, colas y pilas se reservan estáticamente con tamaños calculados. No es solo por el espacio, es por la previsibilidad, porque
malloctiene coste variable y puede fragmentar hasta fallar tras semanas de funcionamiento, que es el peor momento posible. - Sin MMU en los más pequeños, y arranque directo. Los microcontroladores de gama baja no tienen unidad de gestión de memoria, así que todo lo del módulo 2 sobre memoria virtual no existe: direcciones físicas, sin separación entre tareas, y un puntero corrupto puede escribir en la pila de otra. Muchos llevan una MPU más simple, que define unas pocas regiones con permisos y detecta accesos fuera de zona sin traducir direcciones. Y no hay BIOS ni gestor de arranque: al reiniciar, el procesador lee la dirección de la pila y el vector de reset de una posición fija de la flash y salta al código, ejecutando la aplicación en decenas de milisegundos.
- Watchdog. Un temporizador hardware independiente que reinicia el sistema si el software no lo refresca a tiempo: la última red de seguridad ante un bucle infinito, un bloqueo en un mutex o una corrupción de memoria, y exactamente lo que salvó al Pathfinder de quedarse colgado. Su regla de oro se viola constantemente: debe refrescarse solo si todas las tareas críticas están vivas, nunca desde un temporizador ciego, porque entonces solo protege contra que la CPU se pare del todo, que es el fallo menos probable.
Caso práctico: el firmware de una estación de Meteora
Cerramos el módulo diseñando el firmware de una estación meteorológica. Hardware: microcontrolador Cortex-M4 a 48 MHz, 64 KB de RAM, 512 KB de flash, sensores por I²C, radio NB-IoT por UART, batería de litio. Sistema: FreeRTOS.
Las tareas y sus requisitos
| Tarea | Periodo T |
WCET C |
Plazo D |
Utilización | Por qué ese periodo |
|---|---|---|---|---|---|
tarea_muestreo |
100 ms | 12 ms | 100 ms | 12,0 % | 10 Hz para medias horarias fiables |
tarea_watchdog |
250 ms | 2 ms | 250 ms | 0,8 % | Debe refrescar antes del timeout de 1 s |
tarea_promediado |
1.000 ms | 180 ms | 1.000 ms | 18,0 % | Consolida 10 muestras por segundo |
tarea_envio |
5.000 ms | 2.400 ms | 5.000 ms | 48,0 % | La radio es cara: agrupar 5 s de datos |
U = 78,8 % |
Asignación de prioridades con Rate Monotonic
Menor periodo, mayor prioridad. En FreeRTOS, número mayor = más prioritaria:
| Tarea | T |
Prioridad | Justificación |
|---|---|---|---|
tarea_muestreo |
100 ms | 4 (máxima) | Periodo más corto; un muestreo tardío corrompe el dato |
tarea_watchdog |
250 ms | 3 | Debe ejecutarse aunque el envío se alargue |
tarea_promediado |
1.000 ms | 2 | Cálculo local, sin plazo externo |
tarea_envio |
5.000 ms | 1 (mínima) | La más larga y la más tolerante: si se retrasa, se reintenta |
Y fíjate en la propiedad valiosa de RM que mencionamos: si el sistema se sobrecarga, la primera en fallar es tarea_envio, que es precisamente la que puede reintentar sin perder nada, porque los datos están en el buffer. La tarea cuyo fallo sería irreparable —el muestreo— es la más protegida. La asignación de prioridades por RM coincide aquí con la importancia funcional, y cuando eso ocurre, el diseño es bueno.
Prueba de planificabilidad
Paso 1: cota de Liu y Layland. Con n = 4, la cota es 4 · (2^(1/4) − 1) = 4 · 0,1892 = 0,757, es decir 75,7 %.
Nuestra utilización es 78,8 %, que supera la cota. La prueba es no concluyente: no podemos afirmar que sea planificable, pero tampoco que no lo sea. Hay que hacer el análisis exacto.
Paso 2: análisis de tiempos de respuesta. Aplicamos Rᵢ = Cᵢ + Σ_{j∈hp(i)} ⌈Rᵢ/Tⱼ⌉·Cⱼ de mayor a menor prioridad.
tarea_muestreo(máxima prioridad, sin interferencia):R = 12 ms ≤ 100 ms✓tarea_watchdog: interferida por muestreo.R⁰ = 2→R¹ = 2 + ⌈2/100⌉·12 = 2 + 12 = 14→R² = 2 + ⌈14/100⌉·12 = 14. Converge.R = 14 ms ≤ 250 ms✓tarea_promediado: interferida por muestreo y watchdog.R⁰ = 180→R¹ = 180 + ⌈180/100⌉·12 + ⌈180/250⌉·2 = 180 + 24 + 2 = 206R² = 180 + ⌈206/100⌉·12 + ⌈206/250⌉·2 = 180 + 36 + 2 = 218R³ = 180 + ⌈218/100⌉·12 + ⌈218/250⌉·2 = 218. Converge.R = 218 ms ≤ 1.000 ms✓tarea_envio(mínima prioridad, interferida por las tres):R⁰ = 2.400R¹ = 2.400 + ⌈2400/100⌉·12 + ⌈2400/250⌉·2 + ⌈2400/1000⌉·180 = 2.400 + 288 + 20 + 540 = 3.248R² = 2.400 + ⌈3248/100⌉·12 + ⌈3248/250⌉·2 + ⌈3248/1000⌉·180 = 2.400 + 396 + 26 + 720 = 3.542R³ = 2.400 + ⌈3542/100⌉·12 + ⌈3542/250⌉·2 + ⌈3542/1000⌉·180 = 2.400 + 432 + 30 + 720 = 3.582R⁴ = 2.400 + 432 + 30 + 720 = 3.582. Converge.R = 3.582 ms ≤ 5.000 ms✓
Conclusión: el sistema es planificable con Rate Monotonic, aunque la cota de Liu y Layland no lo garantizara. El margen de la tarea más apretada es de 5.000 − 3.582 = 1.418 ms, un 28 % de holgura sobre su plazo. Es una lección práctica de primer orden: la cota es suficiente pero no necesaria, y descartar un diseño solo porque la supera es un error habitual.
(Con EDF, U = 78,8 % ≤ 100 % habría bastado como prueba, sin más cálculo. El precio, como vimos, es el comportamiento impredecible ante una sobrecarga.)
El código
/* ---------- Recursos compartidos, todos ESTÁTICOS ---------- */
#define N_MUESTRAS 50 /* 5 s a 10 Hz */
typedef struct { /* 24 bytes, como la Lectura de meteo-01 */
uint32_t estacion_id;
uint32_t timestamp;
float temperatura, humedad, presion;
} Lectura;
static Lectura buffer[N_MUESTRAS]; /* reservado en compilación */
static StaticSemaphore_t mem_buffer;
static SemaphoreHandle_t mutex_buffer;
static EventGroupHandle_t vivas; /* señales de "estoy viva" */
#define VIVA_MUESTREO (1<<0)
#define VIVA_ENVIO (1<<1)
/* ---------- Tarea 1: muestreo. Prioridad 4, T=100 ms ---------- */
void tarea_muestreo(void *p) {
TickType_t ultima = xTaskGetTickCount();
for (;;) {
Lectura l;
l.timestamp = rtc_ahora();
l.temperatura = i2c_leer_temp(); /* lecturas acotadas: */
l.humedad = i2c_leer_hum(); /* I2C con timeout, nunca bloqueo */
l.presion = i2c_leer_pres(); /* indefinido */
/* Sección crítica CORTÍSIMA: solo copiar, nunca calcular dentro */
if (xSemaphoreTake(mutex_buffer, pdMS_TO_TICKS(5)) == pdTRUE) {
buffer_insertar(&l);
xSemaphoreGive(mutex_buffer);
} else {
contador_fallos_mutex++; /* se registra, no se bloquea */
}
xEventGroupSetBits(vivas, VIVA_MUESTREO);
vTaskDelayUntil(&ultima, pdMS_TO_TICKS(100)); /* periodo EXACTO */
}
}
/* ---------- Tarea 2: watchdog. Prioridad 3, T=250 ms ---------- */
void tarea_watchdog(void *p) {
TickType_t ultima = xTaskGetTickCount();
for (;;) {
/* Solo refresca si TODAS las tareas críticas han dado señal */
EventBits_t b = xEventGroupClearBits(vivas, VIVA_MUESTREO|VIVA_ENVIO);
if (b & VIVA_MUESTREO) {
iwdg_refrescar(); /* si no, el hardware reinicia */
}
vTaskDelayUntil(&ultima, pdMS_TO_TICKS(250));
}
}
/* ---------- Tarea 3: promediado. Prioridad 2, T=1000 ms ---------- */
void tarea_promediado(void *p) {
TickType_t ultima = xTaskGetTickCount();
for (;;) {
Lectura copia[10];
if (xSemaphoreTake(mutex_buffer, pdMS_TO_TICKS(20)) == pdTRUE) {
buffer_copiar_ultimas(copia, 10); /* copiar dentro */
xSemaphoreGive(mutex_buffer);
calcular_medias(copia, 10); /* calcular FUERA del mutex */
}
vTaskDelayUntil(&ultima, pdMS_TO_TICKS(1000));
}
}
/* ---------- Tarea 4: envío. Prioridad 1, T=5000 ms ---------- */
void tarea_envio(void *p) {
TickType_t ultima = xTaskGetTickCount();
for (;;) {
Lectura lote[N_MUESTRAS];
size_t n = 0;
if (xSemaphoreTake(mutex_buffer, pdMS_TO_TICKS(50)) == pdTRUE) {
n = buffer_extraer_todo(lote);
xSemaphoreGive(mutex_buffer);
}
if (n > 0 && !nbiot_enviar(lote, n * sizeof(Lectura))) {
buffer_devolver(lote, n); /* fallo: se reintenta luego */
}
xEventGroupSetBits(vivas, VIVA_ENVIO);
vTaskDelayUntil(&ultima, pdMS_TO_TICKS(5000));
}
}
/* ---------- Arranque ---------- */
int main(void) {
hw_init();
mutex_buffer = xSemaphoreCreateMutexStatic(&mem_buffer); /* con herencia */
vivas = xEventGroupCreate();
xTaskCreate(tarea_muestreo, "muestreo", 256, NULL, 4, NULL);
xTaskCreate(tarea_watchdog, "watchdog", 128, NULL, 3, NULL);
xTaskCreate(tarea_promediado, "promediado", 384, NULL, 2, NULL);
xTaskCreate(tarea_envio, "envio", 512, NULL, 1, NULL);
iwdg_init(1000); /* watchdog hardware: 1 s */
vTaskStartScheduler(); /* no retorna */
for (;;);
}Las siete decisiones que hacen que esto funcione:
vTaskDelayUntily novTaskDelay.vTaskDelay(100)espera 100 ms desde ahora, así que el periodo real se convierte en 100 ms más lo que tardó la iteración, y el error se acumula: en una hora la estación habría perdido decenas de muestras.vTaskDelayUntildespierta en instantes absolutos y mantiene el periodo exacto, que es justo lo que exige el tiempo real.- Todo estático.
buffer, la memoria del mutex y las pilas se reservan en compilación. Ningúnmallocdespués del arranque: sin fragmentación, sin coste variable, sin fallos a las tres semanas. - Mutex con herencia de prioridad. FreeRTOS la implementa en
xSemaphoreCreateMutex(no en los semáforos binarios). Es exactamente la corrección del Pathfinder: sin ella,tarea_enviocon el mutex tomado podría ser desalojada portarea_promediadoy bloquear al muestreo. - Secciones críticas cortísimas. Dentro del mutex solo se copia; los cálculos y la transmisión —lo caro— ocurren fuera. Esto acota el bloqueo que puede sufrir la tarea de máxima prioridad, que es lo que entra en el análisis temporal.
- Tiempos de espera en todas partes. Cada
xSemaphoreTakey cada operación de I²C tiene un plazo máximo. En tiempo real está prohibido el bloqueo indefinido: es preferible perder una muestra y registrarlo que colgar una tarea. - El watchdog comprueba de verdad. Solo refresca si la tarea de muestreo ha dado señal de vida en el intervalo. Un watchdog que se refresca desde un temporizador ciego solo detecta que la CPU se ha parado, que es el fallo menos probable de todos.
- Las prioridades coinciden con la importancia. Por construcción de RM, la tarea que primero incumpliría es la de envío, que es la única que puede reintentar sin perder datos porque el buffer los conserva.
Un margen que hay que respetar
Con U = 78,8 % queda un 21 % de CPU libre, que no sobra: absorbe las interrupciones (que no están en el modelo y roban tiempo a todas las tareas), los errores de estimación del WCET y el crecimiento futuro. Si mañana se añade un sensor de viento con T = 100 ms y C = 8 ms, la utilización sube al 86,8 % y hay que rehacer el análisis completo, no suponer que cabe. Ese es, en el fondo, el hábito que define la ingeniería de tiempo real: antes de añadir una tarea, se demuestra que sigue cumpliendo.
Errores Comunes y Consejos
Creer que tiempo real significa rápido. Es el error conceptual número uno. Significa previsible: un sistema lento y constante es de tiempo real; uno rapidísimo con picos ocasionales, no.
Dimensionar con el caso medio. En tiempo real solo cuenta el peor caso. Un WCET medido en pruebas benignas y sin margen es una garantía falsa.
Interpretar mal la cota de Liu y Layland. Superarla no significa que el sistema no sea planificable: la prueba es suficiente, no necesaria. Descartar un diseño válido por eso es tan común como hacer el análisis exacto y descubrir, como aquí, que hay un 28 % de holgura.
Usar vTaskDelay en una tarea periódica, o hacer trabajo dentro de la rutina de interrupción. Lo primero desplaza el periodo y acumula error: siempre vTaskDelayUntil o su equivalente. Lo segundo destroza la previsibilidad de todo el sistema, porque toda la latencia depende de que las ISR sean mínimas: señalar a una tarea y salir.
Desactivar la herencia de prioridad "por rendimiento". Es literalmente el fallo del Mars Pathfinder: el ahorro es de unos ciclos y el coste, un sistema que se reinicia solo sin que nadie sepa por qué.
En móvil: suponer que tu proceso seguirá vivo. Sin swap, el sistema mata procesos como funcionamiento normal. El estado no guardado en onStop() está perdido.
En móvil: no liberar un wake lock en la ruta de error. Es la causa más frecuente de aplicaciones que agotan la batería. Adquirir con tiempo máximo y liberar en finally.
Diseñar la API como si el cliente fuera un servidor. Un móvil paga cada petición en batería, datos y memoria. Una pantalla, una petición; agregados en lugar de datos crudos; paginación por defecto; y operaciones idempotentes, porque el reintento es inevitable.
Consejo: mide el jitter, no la media. En cualquier sistema con requisitos temporales, la distribución importa más que el promedio. Un histograma de latencias dice la verdad; una media, casi nunca.
Consejo: escribe el análisis de planificabilidad y guárdalo con el código. Debe actualizarse cada vez que se añade una tarea o cambia un periodo. Un firmware de tiempo real sin ese documento es un firmware que nadie puede modificar con seguridad.
Ejercicios
Ejercicio 1: rediseñar el conjunto de tareas de la estación
Meteora quiere añadir dos funciones a la estación: tarea_viento (anemómetro, T = 50 ms, C = 6 ms) y tarea_diagnostico (autotest y estado de la batería, T = 10.000 ms, C = 400 ms), manteniendo las cuatro tareas existentes.
(a) Asigna las prioridades con Rate Monotonic para las seis tareas y justifica el orden. (b) Calcula la utilización total y compárala con la cota de Liu y Layland para n = 6. (c) Haz el análisis exacto de tiempos de respuesta para tarea_envio y para tarea_diagnostico, y di si el sistema es planificable. (d) Si no lo fuera, propón tres cambios de diseño distintos que lo arreglen sin cambiar de hardware, indicando qué se pierde con cada uno.
Ejercicio 2: diagnosticar una inversión de prioridades
Una estación de Meteora se reinicia de forma aleatoria, entre dos y seis veces al día, sin patrón horario. El registro previo al reinicio muestra siempre que la última operación fue un envío por radio. Se sabe que: tarea_envio (prioridad 1) toma el mutex del buffer antes de transmitir y lo mantiene durante toda la transmisión, que dura hasta 2.400 ms; tarea_promediado (prioridad 2) no usa el mutex y tarda 180 ms; tarea_muestreo (prioridad 4) necesita el mutex para insertar cada lectura, con un tiempo de espera de 5 ms; y el watchdog está a 1 s.
(a) Explica paso a paso la secuencia que provoca el reinicio, identificando el papel de cada tarea. (b) ¿Por qué es aleatorio y no ocurre siempre? (c) Propón tres correcciones independientes, di cuál es la mejor y por qué. (d) ¿Habría bastado la herencia de prioridad para resolverlo por completo? Razónalo con cuidado.
Ejercicio 3: diseñar la API de Meteora para consumo móvil
La app de Meteora muestra una pantalla con: el estado actual de las 3 estaciones favoritas del usuario, la evolución de temperatura de las últimas 24 horas de cada una, y un aviso si alguna estación no reporta desde hace más de una hora. La implementación actual hace 7 peticiones y descarga 2,4 MB.
(a) Rediseña la API para reducirlo, indicando el número de peticiones y una estimación del tamaño, con la justificación de cada decisión. (b) Explica qué mecanismos añadirías para tolerar la desconexión y por qué cada uno. (c) La app también envía correcciones manuales de lecturas erróneas: diseña ese endpoint de forma que un reintento tras un tiempo de espera agotado no duplique la corrección, y explica el mecanismo. (d) Relaciona cada decisión con la restricción del móvil que la motiva (radio, batería, memoria o red intermitente).
Soluciones
Solución 1
(a) Prioridades por RM (menor periodo → mayor prioridad):
| Tarea | T (ms) |
C (ms) |
Prioridad | U |
|---|---|---|---|---|
tarea_viento |
50 | 6 | 6 | 12,0 % |
tarea_muestreo |
100 | 12 | 5 | 12,0 % |
tarea_watchdog |
250 | 2 | 4 | 0,8 % |
tarea_promediado |
1.000 | 180 | 3 | 18,0 % |
tarea_envio |
5.000 | 2.400 | 2 | 48,0 % |
tarea_diagnostico |
10.000 | 400 | 1 | 4,0 % |
El anemómetro pasa a ser la máxima prioridad por tener el periodo más corto. Nota que esto es coherente con la función: medir viento a 20 Hz exige regularidad estricta. Y el diagnóstico queda el último, lo cual también es correcto: es lo único que puede retrasarse sin consecuencias.
(b) Utilización y cota. U = 0,12 + 0,12 + 0,008 + 0,18 + 0,48 + 0,04 = 0,948 → 94,8 %.
Cota para n = 6: 6·(2^(1/6) − 1) = 6·0,1225 = 0,735 → 73,5 %.
U supera ampliamente la cota: no concluyente, y esta vez con muy mala pinta.
(c) Análisis exacto.
tarea_envio (prioridad 2), interferida por viento, muestreo, watchdog y promediado:
R⁰ = 2.400 → R¹ = 2.400 + ⌈2400/50⌉·6 + ⌈2400/100⌉·12 + ⌈2400/250⌉·2 + ⌈2400/1000⌉·180 = 2.400 + 288 + 288 + 20 + 540 = 3.536 → R² = 2.400 + 426 + 432 + 30 + 720 = 4.008 → R³ = 2.400 + 486 + 492 + 34 + 900 = 4.312 → R⁴ = 2.400 + 522 + 528 + 36 + 900 = 4.386 → R⁵ = 2.400 + 528 + 528 + 36 + 900 = 4.392 → R⁶ = 4.392. Converge: R = 4.392 ms ≤ 5.000 ms ✓, con una holgura de solo 608 ms (12 %).
tarea_diagnostico (prioridad mínima), interferida por las cinco:
R⁰ = 400 → R¹ = 400 + 48 + 48 + 4 + 180 + 2.400 = 3.080 → R² = 400 + 372 + 372 + 26 + 720 + 2.400 = 4.290 → R³ = 400 + 516 + 516 + 36 + 900 + 2.400 = 4.768 → R⁴ = 400 + 576 + 576 + 40 + 900 + 2.400 = 4.892 → R⁵ = 400 + 588 + 588 + 40 + 900 + 2.400 = 4.916 → R⁶ = 4.916. Converge: R = 4.916 ms ≤ 10.000 ms ✓
El sistema es planificable, pero con un margen incómodo: tarea_envio tiene solo un 12 % de holgura, y el 5,2 % de CPU libre no basta para absorber interrupciones, errores de WCET ni ampliaciones. Técnicamente cumple; en ingeniería, no es aceptable.
(d) Tres cambios de diseño:
- Alargar el periodo de envío a 10 s (
Ude envío baja del 48 % al 24 %;Utotal, al 70,8 %, por debajo incluso de la cota). Se pierde frescura: los datos llegan al servidor con hasta 10 s de retraso. Para datos meteorológicos es irrelevante, y además ahorra batería al despertar la radio la mitad de veces: es la mejor opción, y es un ejemplo de que el requisito temporal más caro a menudo no está justificado. - Trocear el envío en fragmentos de 300 ms con puntos de cesión entre ellos, de modo que el
Cde la tarea baje aunque la operación total dure lo mismo. Se gana muchísima holgura para las tareas de menor prioridad; se pierde simplicidad y hay que gestionar el estado del envío parcial, con el riesgo de duplicar o perder datos si se interrumpe a mitad. - Bajar el muestreo del anemómetro a 100 ms (
Ude viento del 12 % al 6 %; total 88,8 %). Es el cambio de menor beneficio y con pérdida real de calidad de dato —el viento es la magnitud más variable de las cuatro—, así que solo se justificaría si se comprueba que 10 Hz basta.
Una cuarta opción legítima: mover el diagnóstico a un evento externo en lugar de periódico —cuando lo pida el servidor, o una vez al día en un momento de calma—, lo que elimina 4 puntos de utilización y toda su interferencia sin perder funcionalidad.
Solución 2
(a) La secuencia. Es una inversión de prioridades de manual, con el mismo esquema del Pathfinder:
tarea_envio (prioridad 1) toma el mutex del buffer y empieza a transmitir, manteniéndolo hasta 2.400 ms, que es larguísimo. A los 100 ms, tarea_muestreo (prioridad 4) intenta tomarlo, no puede y agota su espera de 5 ms: pierde la lectura. Entonces tarea_promediado (prioridad 2), que no usa el mutex y tiene más prioridad que envio, la desaloja durante 180 ms; mientras está desalojada, envio no avanza y no suelta el mutex, así que muestreo sigue fallando en cada activación. Como muestreo no completa su trabajo, no pone su bit VIVA_MUESTREO, tarea_watchdog comprueba y correctamente no refresca, y al agotarse el temporizador de 1 s el hardware reinicia la estación.
El papel de cada tarea: envio es la baja que retiene el recurso, promediado es la media que desaloja sin usar el recurso —el ingrediente que convierte un bloqueo normal en inversión—, muestreo es la alta víctima, y el watchdog es el detector que hace visible el fallo.
(b) Por qué es aleatorio. Hacen falta tres coincidencias simultáneas: que la transmisión sea larga (depende de la cobertura NB-IoT, que varía), que tarea_promediado se active dentro de esa ventana, y que la suma de desalojos mantenga a muestreo sin poder marcar su bit durante más de 1 s completo. Con buena cobertura la transmisión dura 400 ms y no da tiempo; con cobertura mala se alarga y coincide. Por eso ocurre entre dos y seis veces al día y sin patrón horario: depende de la propagación radioeléctrica, no de la hora.
(c) Tres correcciones independientes:
- No mantener el mutex durante la transmisión. Tomar el mutex, copiar el lote a un buffer local, soltarlo inmediatamente, y transmitir sin el mutex. La sección crítica pasa de 2.400 ms a menos de 1 ms.
- Activar la herencia de prioridad en el mutex (usar
xSemaphoreCreateMutexy no un semáforo binario). Así, cuandomuestreose bloquea,enviohereda prioridad 4 ypromediadoya no puede desalojarla. - Usar un buffer sin bloqueo —un anillo de productor único y consumidor único con índices atómicos—, eliminando el mutex por completo entre muestreo y envío.
La mejor es la primera, y por una razón de fondo: ataca la causa raíz, que es una sección crítica larguísima. Es el principio de 03-04 llevado a su conclusión: dentro del mutex, solo lo imprescindible. Además es un cambio pequeño, no depende de características del RTOS y hace que el sistema sea correcto aunque la herencia estuviera desactivada. La tercera es elegante y la más rápida, pero es la más fácil de implementar mal.
(d) ¿Habría bastado la herencia de prioridad? Reduce el problema drásticamente, pero no lo resuelve del todo. Con herencia, promediado deja de desalojar a envio, así que desaparece la inversión propiamente dicha y el bloqueo queda acotado a la duración de la sección crítica. Pero esa sección crítica sigue siendo de 2.400 ms, así que tarea_muestreo seguiría sin poder insertar durante hasta 2,4 segundos: seguiría perdiendo ~24 lecturas seguidas y, con el watchdog a 1 s, seguiría reiniciando.
La conclusión es importante y matiza la lección del Pathfinder: la herencia de prioridad acota el bloqueo, no lo elimina. Si la sección crítica es más larga que el plazo de la tarea de alta prioridad, ninguna cantidad de herencia salva el diseño. El arreglo estructural es siempre acortar la sección crítica.
Solución 3
(a) Rediseño. De 7 peticiones y 2,4 MB a 1 petición y unos 15-25 KB comprimidos:
GET /v1/panel?estaciones=12,17,23&historico=24h&resolucion=15m
&campos=temp,hum&formato=agregado
Accept-Encoding: gzip
If-None-Match: "w/panel-12,17,23-8421"Decisiones y justificación:
Un solo endpoint compuesto devuelve estado actual, serie histórica y avisos de las tres estaciones: regla "una pantalla, una petición", que elimina 6 despertares de radio, el gasto energético dominante. Se envían agregados y no datos crudos —24 h a 15 minutos son 96 puntos por estación, 288 en total, frente a 720.000 lecturas; el cliente pinta una curva de 300 píxeles, y enviarle más resolución que píxeles es desperdicio puro—, con selección de campos (ni presión ni metadatos que la pantalla no muestra) y compresión obligatoria, porque el JSON de series numéricas comprime 5:1 o más. Y los avisos se calculan en el servidor: que "no reporta desde hace una hora" lo decida el cliente obligaría a descargar marcas de tiempo de todo.
(b) Tolerancia a la desconexión:
ETag + 304 Not Modified, para que un refresco sin cambios cueste cientos de bytes en lugar de 20 KB; sincronización incremental (?desde_version=8421), para que tras un túnel la app pida solo lo nuevo; Retry-After, para que el servidor indique cuándo reintentar en lugar de dejar a la app en un bucle con la radio encendida; errores distinguibles, 4xx (no reintentar) frente a 5xx y 429 (reintentar con retardo exponencial), porque un cliente que reintenta un 400 gasta energía eternamente para nada; y caché local con marca de frescura, para que la app sea útil sin red mostrando lo anterior con un "actualizado hace X".
(c) Endpoint idempotente para correcciones:
POST /v1/lecturas/correcciones
Idempotency-Key: 7f3c1e9a-2b44-4c8d-9f01-6ab2e5d31c07
Content-Type: application/json
{"estacion_id": 17, "timestamp": 1756598400, "temperatura": 21.4,
"motivo": "sensor descalibrado"}Mecanismo. El cliente genera un UUID por intención de corrección —no por intento— y lo repite en todos los reintentos de esa misma corrección. El servidor mantiene una tabla de claves procesadas con su respuesta y un TTL (24-48 h):
- Llega una petición con clave. Si la clave no existe, se aplica la corrección, se guarda
clave → respuestaen la misma transacción y se devuelve201. - Si la clave ya existe, no se aplica nada y se devuelve la respuesta guardada, con
200. - Si la clave existe pero está en curso, se devuelve
409para que el cliente reintente en breve.
Guardar la clave y aplicar la corrección en la misma transacción es lo que hace correcto el mecanismo: si se hicieran por separado, un fallo entre ambas dejaría la puerta abierta al duplicado. El caso que esto resuelve es exactamente el que se da a diario en un móvil: el servidor procesa la corrección, la respuesta se pierde al entrar en un túnel, y el cliente reintenta creyendo que falló.
(d) Relación decisión ↔ restricción:
| Decisión | Restricción del móvil |
|---|---|
| Una petición en lugar de siete | Radio y batería: cada despertar de radio tiene coste fijo, más segundos de cola en alto consumo |
| Agregados y selección de campos | Memoria (el proceso puede morir por LMKD) y datos del usuario |
| Compresión | Radio (menos tiempo de transmisión) y datos |
ETag / 304 |
Red intermitente y batería: revalidar cuesta casi nada |
| Sincronización incremental | Red intermitente: reanudar debe ser barato |
Retry-After y errores distinguibles |
Batería: evita bucles de reintento con la radio encendida |
| Idempotencia | Red intermitente: el reintento es inevitable, así que debe ser seguro |
| Caché local con frescura | Red intermitente: la app debe ser útil sin cobertura |
Conclusión
Esta lección cierra el módulo 6, que ha tenido un hilo único: el aislamiento, visto en cuatro escalas sucesivas.
En 06-01 vimos el aislamiento más fuerte: duplicar la máquina entera. Los criterios de Popek y Goldberg y su teorema, el fracaso de x86 con sus 17 instrucciones sensibles no privilegiadas y las tres respuestas —traducción binaria, paravirtualización y asistencia por hardware con el modo raíz, la VMCS y el VM exit como unidad de coste—; KVM convirtiendo Linux en hipervisor, donde una VM es un proceso y una vCPU es un hilo; y la virtualización de los tres recursos del módulo 2: vCPU con sobresuscripción y st en top, memoria con EPT/NPT, ballooning y KSM, y E/S con la escalera emulación → virtio → SR-IOV.
En 06-02 vimos el aislamiento más ligero: no duplicar nada y limitar lo que un proceso ve y consume. Los ocho espacios de nombres —con user como la mejora de seguridad decisiva y chroot como el que nunca fue seguridad—, los cgroups v2 cuyos ficheros de texto en /sys/fs/cgroup gobiernan CPU, memoria, E/S y PID, la seguridad imprescindible encima (capabilities, seccomp, MAC), y overlayfs con su copy-up explicando por qué cien contenedores caben donde cabía uno.
En 06-03 vimos qué queda del sistema operativo cuando la máquina deja de ser un objeto: ganado y no mascotas, cloud-init, el servicio de metadatos y su riesgo, la infraestructura inmutable, los sistemas mínimos, el almacenamiento por capas de latencia y coste, y la orquestación como sistema operativo del clúster, donde requests y limits son los cgroups de la lección anterior y el planificador del clúster es el de 02-02 un nivel más arriba.
Y en esta última lección hemos visto los dos mundos donde las restricciones son las contrarias. En móvil, cómo la falta de swap convierte al OOM killer en política ordinaria (LMKD), cómo Binder resolvió lo que el IPC de UNIX no daba —identidad inyectada por el núcleo, una sola copia, recuento de referencias—, cómo zygote aplica fork y copy-on-write para arrancar apps al instante, cómo un UID por aplicación reutiliza el mecanismo más viejo del curso para aislar software no confiable, y cómo la energía se convierte en el recurso que gobierna el diseño, con wake locks, Doze y planificación consciente de big.LITTLE. En tiempo real, que la propiedad no es la velocidad sino la previsibilidad; el modelo de tareas periódicas y el difícil problema del WCET; Rate Monotonic con la cota de Liu y Layland —suficiente pero no necesaria, como demostró nuestra estación con U = 78,8 % sobre una cota del 75,7 % y aun así planificable con 1.418 ms de holgura—; EDF, óptimo en utilización pero impredecible en sobrecarga; la inversión de prioridades del Mars Pathfinder con su tarea de datos meteorológicos, y la lección matizada de que la herencia acota el bloqueo pero solo acortar la sección crítica lo elimina; y el firmware completo de la estación, con sus prioridades justificadas, su análisis numérico y sus siete decisiones de diseño.
Con esto se cierra la mitad conceptual del curso. Sabes qué es un sistema operativo (módulo 1), cómo reparte los recursos (2), cómo coordina la ejecución simultánea (3), cómo organiza la persistencia (4), cómo protege (5) y cómo aísla (6).
Falta la parte que separa a quien entiende un sistema de quien puede arreglarlo a las tres de la madrugada. Porque cuando meteo-01 responde lento y no sabes por qué, ninguna de las abstracciones que has aprendido sirve de nada si no sabes qué comando ejecutar primero, cómo leer su salida y cómo pasar de un síntoma vago —"la web va lenta"— a una causa concreta —"el agregador está haciendo lecturas aleatorias de 4 KB y ha saturado la cola del RAID"—. Eso ya no es teoría: es método, práctica y unas cuantas herramientas que hay que conocer de memoria.
Es el Módulo 7: Administración y Diagnóstico en la Práctica, y empieza bajando a la interfaz por la que pasa todo lo demás: La Línea de Comandos como Interfaz del Sistema.
Fundamentos de Sistemas Operativos
Módulo 1: Introducción a los Sistemas Operativos
- Conceptos Básicos de Sistemas Operativos
- Historia y Evolución de los Sistemas Operativos
- Tipos de Sistemas Operativos
- Funciones Principales de un Sistema Operativo
- Arquitectura del Núcleo: Monolítico, Microkernel e Híbrido
- Modo Usuario, Modo Núcleo y Llamadas al Sistema
Módulo 2: Gestión de Recursos
- Gestión de Procesos
- Planificación de la CPU
- Gestión de Memoria
- Memoria Virtual y Paginación
- Gestión de Almacenamiento
- Gestión de Dispositivos
- Controladores, Interrupciones y Operaciones de E/S
Módulo 3: Concurrencia
- Conceptos de Concurrencia
- Hilos y Procesos
- Comunicación entre Procesos (IPC)
- Sincronización y Exclusión Mutua
- Problemas Clásicos de Concurrencia
- Interbloqueos: Prevención, Detección y Recuperación
Módulo 4: Estructuras de Archivos
- Sistemas de Archivos
- Estructuras de Directorios
- Particiones, Montaje y Sistema de Archivos Virtual
- Gestión de Archivos
- Asignación de Espacio, Journaling e Integridad
- Seguridad y Permisos de Archivos
Módulo 5: Protección y Seguridad del Sistema
- Principios de Protección y Control de Acceso
- Usuarios, Autenticación y Escalada de Privilegios
- Amenazas Comunes y Endurecimiento del Sistema
- Auditoría, Registros y Respuesta a Incidentes
Módulo 6: Virtualización y Contenedores
- Virtualización: Hipervisores y Máquinas Virtuales
- Contenedores: Namespaces y cgroups
- El Sistema Operativo en la Nube
- Sistemas Operativos Móviles y de Tiempo Real
