wandres.dev
EMBEBIDO · microcontroladores

La pila embebida: del registro crudo al driver portable

Entre tú y el silicio hay una torre de tres pisos, cada uno una abstracción de coste cero sobre el de abajo. El PAC da acceso tipado a los registros, generado del SVD del fabricante. El HAL levanta una API ergonómica por chip. Y el trait común `embedded-hal` es la cintura estrecha que desacopla miles de drivers de cientos de microcontroladores, haciéndolos portables sin coste.

⏱ 20 min

Escribir a un periférico es, en última instancia, escribir un número en una dirección. Pero entre ese acto crudo y un programa mantenible hay una distancia que en C se salva con macros y máscaras de bits, y en Rust con una torre de tres pisos de abstracciones de coste cero. El PAC te da acceso a los registros con tipos en vez de números mágicos, generado automáticamente de la descripción que publica el fabricante. El HAL levanta sobre él una API ergonómica —configura un reloj, un GPIO, un bus— específica de cada chip. Y por encima, el trait común embedded-hal hace de cintura estrecha: define la forma de un bus I2C o un pin de salida, de modo que un driver escrito contra esos traits corre sin cambios en cualquier microcontrolador. Esta lección recorre la torre de abajo arriba y muestra por qué su cúspide es lo que convierte a Rust embebido en un ecosistema y no en un archipiélago de islas incompatibles.

🎯 Al terminar esta lección sabrás
  • Entender el PAC: acceso tipado a registros generado con svd2rust desde el fichero SVD.
  • Manejar el patrón read / modify / write con cierres y accesores de campo.
  • Ver cómo el HAL convierte ese acceso crudo en una API ergonómica por chip.
  • Comprender por qué los traits de embedded-hal hacen los drivers portables a coste cero.

El PAC: registros con tipos, no con máscaras

Cada fabricante publica un fichero SVD (System View Description): un XML que describe cada periférico, cada registro y cada campo de bits de su chip. La herramienta svd2rust lo devora y emite un PAC (Peripheral Access Crate): una caja que expone todo ese hardware como tipos de Rust. Donde en C escribías RCC->AHB1ENR |= (1 << 2) —un desplazamiento que has de cotejar a mano con la hoja de datos—, el PAC te da accesores con nombre.

let dp = pac::Peripherals::take().unwrap();

// Habilitar el reloj del puerto C (sin esto, el periferico esta muerto)
dp.RCC.ahb1enr.modify(|_, w| w.gpiocen().set_bit());

// PC13 como salida de proposito general
dp.GPIOC.moder.modify(|_, w| w.moder13().output());

// Poner el pin en alto por el registro atomico set/reset
dp.GPIOC.bsrr.write(|w| w.bs13().set_bit());

El patrón de acceso es la clave y merece diseccionarse. Cada registro ofrece tres operaciones: read() devuelve un proxy de lectura; write(|w| ...) parte del valor de reset y construye uno nuevo; y modify(|r, w| ...) hace el ciclo completo leer-modificar-escribir, que es lo que casi siempre quieres para no pisar los otros bits del registro. Dentro del cierre, w.moder13() no es un número: es un método que solo existe para ese campo, y .output() es una de sus variantes válidas. Escribir un valor imposible en un campo de dos bits deja de ser una máscara mal calculada y pasa a ser un método que no existe.

ℹ️
El PAC nace del silicio, no de la mano de nadie

Nadie teclea un PAC: svd2rust lo genera del SVD, así que refleja exactamente el hardware que el fabricante describe, campo por campo. Eso elimina de raíz una fuente crónica de bugs en C —la constante #define copiada mal de la hoja de datos— y garantiza que el nombre del campo, su posición y sus valores legales vengan de la fuente autorizada, no de la memoria del programador. El PAC es tipado, pero deliberadamente austero: no impone reglas de uso, solo impide escribir bits que no existen.

El HAL: del registro a la intención

El PAC es correcto pero tedioso: encadenar modify sobre relojes y modos para encender un LED es mucho ceremonial. El HAL (Hardware Abstraction Layer) es la capa que traduce esa mecánica de registros a la intención. Es específico de cada familia —stm32f4xx-hal, rp2040-hal, nrf-hal, esp-hal— y sobre él reaparece el typestate de la lección anterior: split() reparte un puerto en pines individuales, y configurar uno lo consume devolviendo otro tipo.

let gpioc = dp.GPIOC.split();          // el HAL enciende el reloj por ti
let mut led = gpioc.pc13.into_push_pull_output();
led.set_high();                        // una intencion, no tres registros

Las tres líneas del PAC —reloj, modo, bit— se colapsan en un split() y un into_push_pull_output() que hacen lo mismo pero legibles, y encima con el pin blindado por su tipo. El HAL no añade coste: tras la monomorfización, led.set_high() compila al mismo bsrr.write que habrías escrito a mano. Es azúcar sin calorías, construido sobre el PAC que tiene debajo.

flowchart TD
SVD[Fichero SVD del fabricante] -->|svd2rust| PAC[PAC registros tipados]
PAC --> HAL[HAL API ergonomica por chip]
HAL -->|implementa los traits| EH[embedded-hal la cintura estrecha]
EH -->|de la que dependen| DRV[Driver de sensor portable]
DRV --> APP[Tu aplicacion]
style PAC fill:#fab387,color:#11111b
style HAL fill:#cba6f7,color:#11111b
style EH fill:#89b4fa,color:#11111b
style DRV fill:#a6e3a1,color:#11111b

embedded-hal: la cintura que hace portables los drivers

Aquí está la pieza que convierte piezas sueltas en ecosistema. embedded-hal no habla con ningún chip: define traits que describen la forma de las capacidades genéricas de un microcontrolador —un pin de salida, un bus SPI, un bus I2C, un retardo—. Su versión 1.0, estable desde finales de 2023, fijó esa interfaz para toda la industria. Cada HAL de fabricante implementa esos traits para su hardware; y un driver, en vez de depender de un chip concreto, depende del trait.

use embedded_hal::i2c::I2c;

pub struct Termometro<I2C> {
    i2c: I2C,
    dir: u8,
}

impl<I2C: I2c> Termometro<I2C> {
    pub fn new(i2c: I2C, dir: u8) -> Self {
        Self { i2c, dir }
    }

    pub fn leer_celsius(&mut self) -> Result<i16, I2C::Error> {
        let mut buf = [0u8; 2];
        self.i2c.write_read(self.dir, &[0x00], &mut buf)?; // registro 0x00
        Ok(i16::from_be_bytes(buf) >> 4)
    }
}

Lee la firma con atención: Termometro no sabe si el bus I2C lo provee un STM32, un RP2040 o un nRF. Solo exige I2C: I2c. El mismo driver, sin recompilar su lógica, funciona sobre cualquier HAL que implemente el trait —y el tipo de error viaja tipado a través de I2C::Error, sin borrar información—. Escrito una vez, corre en todas partes. Ese es el motivo de que el registro de crates rebose de drivers para acelerómetros, pantallas y sensores que ningún autor probó jamás en tu placa concreta y aun así funcionan en ella.

🔌

PAC · el registro

Generado del SVD con svd2rust. Acceso tipado a cada bit vía read / modify / write. Austero y fiel al silicio, sin reglas de uso.

⚙️

HAL · la intención

Específico de cada chip. Convierte la mecánica de registros en operaciones legibles con typestate. Coste cero sobre el PAC.

🔗

embedded-hal · el contrato

Traits que definen la forma de un bus o un pin. Los HAL los implementan; los drivers dependen de ellos. La portabilidad nace aquí.

💡
Y su hermano async: embedded-hal-async

Junto a los traits bloqueantes existe embedded-hal-async, que define las mismas capacidades como async fn: un I2c que se espera con .await mientras el DMA trabaja, en vez de girar en un bucle ocupado. Es el puente por el que el mundo de los drivers portables entra en Embassy, el framework asíncrono que verás al final del nivel. Un mismo driver puede ofrecer ambas variantes y servir a los dos mundos.

embedded-hal es una cintura estrecha, y por eso el ecosistema existe

Merece la pena nombrar la forma exacta de lo que acabas de ver, porque es uno de los patrones más potentes del diseño de sistemas. Una torre de abstracciones puede degenerar en un producto cartesiano: si cada driver hablara directamente con cada chip, tendrías que escribir drivers por chips piezas, y cada microcontrolador nuevo obligaría a reescribir todo el catálogo. embedded-hal rompe esa multiplicación interponiendo una cintura estrecha —un reloj de arena— entre las dos poblaciones. Por debajo, cada HAL implementa un puñado de traits una sola vez. Por encima, cada driver depende de esos mismos traits una sola vez. En el cuello del reloj no hay chips ni sensores: solo la forma abstracta de un bus, un pin, un retardo. Y como esa forma es un trait genérico, la conexión entre un driver y un chip concreto se resuelve por monomorfización en compilación, produciendo exactamente el código que habrías escrito a mano para ese par: la portabilidad no cuesta ni un byte ni un ciclo. Es el mismo principio que ya viste hacer portable a Iterator o desacoplar a Read y Write, ahora gobernando un ecosistema físico de miles de dispositivos. La lección profunda es que la interoperabilidad a gran escala no se logra con más código de pegamento, sino con menos: encontrando el contrato mínimo que ambos lados aceptan y dejando que el sistema de tipos haga el resto. El PAC te da el silicio, el HAL te da la comodidad, pero es embedded-hal —la capa que no habla con ningún chip— la que hace que el trabajo de cada autor sirva a todos los demás.

📝
Lo esencial de la pila embebida

La pila tiene tres pisos de coste cero. El PAC, generado con svd2rust del SVD del fabricante, da acceso tipado a los registros mediante read / modify / write con accesores de campo en vez de máscaras. El HAL, específico de cada chip, convierte esa mecánica en una API ergonómica con typestate. Y embedded-hal define traits —I2c, SpiBus, OutputPin, DelayNs— que actúan de cintura estrecha: los HAL los implementan y los drivers dependen de ellos, de modo que un driver genérico corre en cualquier microcontrolador sin recompilar su lógica y sin coste en ejecución. embedded-hal-async ofrece las mismas capacidades como async fn.

⚔️ Recorre la torre de arriba abajo
  1. Descarga o localiza el SVD de un chip y observa cómo un registro concreto —por ejemplo GPIOC.MODER— se traduce en un accesor tipado del PAC. Contrasta un campo con su equivalente #define en C.
  2. Enciende un LED usando solo el PAC: reloj, modo y bit con tres modify / write. Anota cuántas líneas te cuesta.
  3. Repite lo mismo con el HAL (split() e into_push_pull_output()) y compara la legibilidad. Explica por qué no hay diferencia en el código máquina generado.
  4. Escribe la firma de un driver genérico struct Pantalla<SPI> acotado por embedded_hal::spi::SpiDevice y razona por qué no menciona ningún chip.
  5. Justifica por qué embedded-hal es una “cintura estrecha”: cuántas implementaciones evita frente a un mundo donde cada driver hablara con cada chip.