wandres.dev
EMBEBIDO · microcontroladores

RTIC: concurrencia por interrupciones con garantías en compilación

En el metal desnudo la concurrencia son interrupciones que comparten estado, y compartir estado entre una interrupción y el resto es una carrera de datos esperando a ocurrir. RTIC convierte las tareas en manejadores de interrupción que el NVIC planifica por prioridad, y superpone un análisis en compilación —el protocolo del techo de prioridad— que hace imposibles por construcción las carreras de datos y los interbloqueos, sin heap y en tiempo real.

⏱ 20 min

En un microcontrolador la concurrencia no llega por hilos: llega por interrupciones. Un temporizador vence, un pin cambia, un byte entra por el bus, y cada evento desvía la CPU hacia un manejador que corre a mitad de otra cosa. En cuanto dos de esos manejadores tocan el mismo dato, tienes una carrera —la misma que en un servidor, pero sobre registros de hardware y sin un Mutex de la biblioteca estándar a mano—. La respuesta tosca es desactivar todas las interrupciones alrededor de cada acceso, matando la latencia que en tiempo real es sagrada. RTICReal-Time Interrupt-driven Concurrency— ofrece la respuesta fina: hace que tus tareas sean manejadores de interrupción, deja que el controlador de interrupciones del chip las planifique por prioridad, y añade un análisis en compilación que demuestra, antes de flashear, que no hay carreras de datos ni interbloqueos posibles. Es la concurrencia sin miedo de Rust descendiendo hasta el mismísimo controlador de interrupciones.

🎯 Al terminar esta lección sabrás
  • Ver por qué compartir estado entre interrupciones es una carrera de datos por defecto.
  • Entender el modelo de RTIC: tareas como manejadores que el NVIC planifica por prioridad.
  • Distinguir recursos #[local] (de una tarea) de #[shared] (protegidos por lock).
  • Comprender por qué el techo de prioridad hace imposibles las carreras y los interbloqueos.

El problema: interrupciones que comparten estado

Imagina un contador que una interrupción de botón incrementa y que el bucle principal lee. En bare-metal ingenuo, ese contador es una variable global static mut, y tocarla desde dos contextos que se interrumpen entre sí es comportamiento indefinido: la lectura puede capturar el valor a medio actualizar. Es la carrera de datos de siempre, ahora entre un manejador y el hilo de fondo.

El remedio clásico de C es la sección crítica: desactivar todas las interrupciones mientras tocas el dato. Funciona, pero es un mazazo. Al silenciar el sistema entero para proteger un contador, retrasas también la interrupción crítica del control de motor que no tenía nada que ver. En tiempo real, donde lo que importa es la latencia del peor caso, apagar todo indiscriminadamente es inaceptable. Necesitas exclusión selectiva: bloquear solo a quien podría tocar ese recurso, y a nadie más.

El modelo de RTIC: el hardware es el planificador

RTIC parte de una idea radical: no escribas un planificador en software: usa el que ya llevas en el chip. Todo Cortex-M incluye un NVIC (Nested Vectored Interrupt Controller) que gestiona interrupciones con prioridades y preempción anidada por hardware. RTIC hace que cada tarea sea un manejador enganchado a un vector, le asigna una prioridad, y deja que el NVIC haga lo que sabe hacer: cuando llega un evento de mayor prioridad, interrumpe al de menor; cuando termina, reanuda. Sin bucle de planificación, sin cambios de contexto en software, sin kernel.

Todo se declara dentro del macro #[app], que en compilación teje el arranque, los vectores y el análisis de recursos:

#![no_std]
#![no_main]
use panic_halt as _;

#[rtic::app(device = stm32f4xx_hal::pac, dispatchers = [SPI1])]
mod app {
    use stm32f4xx_hal::{prelude::*, gpio::{Output, gpioc::PC13}};

    #[shared]
    struct Shared {
        pulsaciones: u32,
    }

    #[local]
    struct Local {
        led: PC13<Output>,
    }

    #[init]
    fn init(cx: init::Context) -> (Shared, Local) {
        // configurar relojes, GPIO y habilitar la interrupcion EXTI0 del boton
        // ... (omitido)
        (Shared { pulsaciones: 0 }, Local { led })
    }
}

#[init] corre una sola vez, con las interrupciones desactivadas, y devuelve el estado inicial repartido en dos structs. Esa partición es el primer acto de seguridad: #[local] son recursos que pertenecen a una única tarea —nadie más los ve, así que no necesitan protección— y #[shared] son los que varias tareas se disputan y que, por tanto, exigen un candado.

Recursos, lock y el techo de prioridad

Añade dos tareas. Una tarea hardware, atada con binds a la interrupción del botón, se dispara sola cuando el pin cambia; una #[idle] de fondo, la de menor prioridad, lee el contador cuando no hay nada más que hacer:

    // Tarea hardware: la dispara la interrupcion EXTI0. Prioridad 2.
    #[task(binds = EXTI0, local = [led], shared = [pulsaciones], priority = 2)]
    fn boton(mut cx: boton::Context) {
        cx.local.led.toggle();                    // recurso local: sin candado
        cx.shared.pulsaciones.lock(|n| *n += 1);  // recurso compartido: bajo candado
    }

    // Fondo de baja prioridad: lee el contador compartido.
    #[idle(shared = [pulsaciones])]
    fn idle(mut cx: idle::Context) -> ! {
        loop {
            let actual = cx.shared.pulsaciones.lock(|n| *n);
            let _ = actual;
        }
    }

Fíjate en la asimetría. led es #[local] de boton: se accede directo, cx.local.led, sin ceremonia, porque el sistema de tipos ya garantiza que ninguna otra tarea lo posee. pulsaciones es #[shared]: solo se toca dentro de lock(|n| ...), y fuera de ese cierre es inaccesible. El compilador te obliga a bloquear antes de tocar lo compartido; olvidarlo no es un bug latente, es un error de tipos.

Y aquí está la joya. Ese lock no es un mutex que pueda bloquear ni dormir. Implementa el protocolo del techo de prioridad (SRP, Stack Resource Policy): al bloquear un recurso, RTIC eleva momentáneamente la prioridad del núcleo hasta el techo de ese recurso —la mayor prioridad de todas las tareas que lo usan—. Por enmascaramiento del hardware, eso impide que cualquier tarea capaz de tocar ese dato preempte durante el acceso, mientras deja correr libremente a las que no lo comparten. La exclusión es selectiva, no global. Y el coste es ridículo: escribir un registro de prioridad (BASEPRI), no adquirir un candado con espera.

flowchart TD
IRQ[Llega una interrupcion] --> NVIC[El NVIC ordena por prioridad]
NVIC --> PRE[La de mayor prioridad preempta a la menor]
PRE --> TASK[Corre la tarea vinculada al vector]
TASK --> LOCK[lock sobre un recurso compartido]
LOCK --> CEIL[Sube la prioridad al techo del recurso]
CEIL --> EXCL[Nadie que comparta el recurso puede preemptar]
EXCL --> DONE[Al salir del lock baja la prioridad]
style CEIL fill:#89b4fa,color:#11111b
style EXCL fill:#a6e3a1,color:#11111b
ℹ️
Tareas software y async en RTIC 2

Además de las tareas hardware atadas a un vector, RTIC define tareas software que despachas con task::spawn() y que corren sobre los vectores libres declarados en dispatchers. En RTIC 2 esas tareas pueden ser async fn: se suspenden con .await esperando un temporizador de rtic-monotonics o un evento, y el ejecutor las reanuda cuando llega. Async y prioridades conviven en el mismo modelo, todo sin heap y todo estático.

⚠️
El techo se calcula de tus prioridades: no lo adivines

El techo de un recurso no lo fijas tú a mano: RTIC lo deduce en compilación como el máximo de las prioridades de las tareas que lo comparten. Por eso importa asignar prioridades con criterio: si una tarea muy rápida comparte un recurso con una lenta, elevar al techo puede retrasar a otras. El análisis es correcto siempre, pero un mal reparto de prioridades castiga la latencia. Diseñar bien es repartir prioridades, no pelear con candados.

RTIC convierte dos pesadillas del tiempo real en imposibilidades demostradas antes de arrancar

Las carreras de datos y los interbloqueos son los dos fantasmas que han atormentado el software de tiempo real desde que existe. El primero corrompe estado de forma intermitente e irreproducible; el segundo congela el sistema entero cuando dos tareas se esperan en círculo. Un RTOS clásico te da hilos y mutexes y te desea suerte: ambos peligros siguen ahí, latentes, dependiendo de que ningún programador olvide un candado o los adquiera en mal orden. RTIC hace algo categóricamente distinto, y para verlo hay que juntar sus dos mitades. La primera es que el hardware es el planificador: en vez de simular concurrencia con hilos y un kernel que cambia contextos, RTIC usa directamente el controlador de interrupciones del chip, su mecanismo de prioridades y su preempción anidada, que ya existían y son deterministas. La segunda es que sobre ese sustrato superpone el protocolo del techo de prioridad, un resultado de la teoría de tiempo real de los años ochenta, y lo aplica en compilación. De ahí salen dos teoremas, no dos buenas prácticas. No hay carrera de datos posible, porque un recurso compartido solo se toca bajo un lock que eleva el techo, y ese techo enmascara por hardware a toda tarea capaz de tocarlo: dos accesos concurrentes al mismo dato son, literalmente, inexpresables. Y no hay interbloqueo posible, porque el lock no es un candado que espera —no puede bloquear, solo enmascara— y el protocolo garantiza que los bloqueos se anidan en pila sin espera circular: la condición necesaria del interbloqueo nunca se forma. El macro #[app] es, en el fondo, un pequeño demostrador de teoremas: analiza qué tarea toca qué recurso, calcula los techos, genera el enmascaramiento exacto y rechaza lo que no sabe probar seguro. Lo que en otros sistemas es disciplina y plegaria, en RTIC es un hecho verificado antes de que un solo byte llegue a la flash. Es la concurrencia sin miedo de Rust llevada a su expresión más pura: no un runtime que vigila en ejecución, sino una prueba que desaparece del binario dejando solo escrituras a BASEPRI.

📝
Lo esencial de RTIC

En el metal, la concurrencia son interrupciones que comparten estado, y compartirlo sin protección es una carrera de datos; la sección crítica que apaga todas las interrupciones arruina la latencia. RTIC hace que las tareas sean manejadores que el NVIC planifica por prioridad, sin planificador en software. Los recursos se declaran #[local] (de una tarea, sin candado) o #[shared] (bajo lock). El lock no es un mutex: aplica el techo de prioridad, elevando la prioridad al máximo de las tareas que comparten el recurso para excluir selectivamente por hardware, a coste de escribir BASEPRI. De ahí dos garantías demostradas en compilación por el macro #[app]: sin carreras de datos y sin interbloqueos. RTIC 2 suma tareas async fn, sin heap y en tiempo real.

⚔️ Demuestra la seguridad, no la esperes
  1. Explica por qué compartir un static mut entre una interrupción y el bucle principal es una carrera de datos, y por qué la sección crítica global que lo arregla castiga la latencia.
  2. Reparte un estado inicial en #[shared] y #[local] y justifica, recurso por recurso, cuál necesita candado y cuál no.
  3. Escribe una tarea hardware con binds = EXTI0 que incremente un contador #[shared] bajo lock, e intenta tocarlo fuera del cierre. Lee el error del compilador.
  4. Explica el protocolo del techo de prioridad: qué prioridad se eleva al bloquear, cómo se calcula el techo y por qué eso excluye solo a quien comparte el recurso.
  5. Argumenta por qué RTIC no puede sufrir interbloqueos, apoyándote en que el lock enmascara en vez de esperar y en que los bloqueos se anidan sin espera circular.