Ir al contenido

Rust embebido: microcontroladores, no_std y Embassy

Rust embebido (embedded Rust) significa escribir Rust para ordenadores pequeñitos que viven dentro de las cosas: un termostato, un teclado, un dron, un sensor que riega las plantas. Estos ordenadores se llaman microcontroladores. Un solo chip contiene el procesador, unos pocos cientos de kilobytes de memoria y los pines que hablan con LEDs, botones y sensores. Normalmente no hay ningún sistema operativo. Tu programa es lo único que se ejecuta, desde que se enciende hasta que se apaga.

Suena a algo que da miedo, pero también es muy divertido: escribes unas pocas líneas y una luz de verdad parpadea en tu mesa. Esta página te muestra lo que se puede hacer, qué placa y qué crates elegir, y cómo meter tu primer programa en un chip. Por el camino vas a practicar la “forma de pensar embebida” con ejemplos que puedes ejecutar directamente en tu navegador.

Por qué Rust encaja tan bien en sistemas embebidos

Sección titulada «Por qué Rust encaja tan bien en sistemas embebidos»

Durante décadas, el código embebido se ha escrito sobre todo en C. Rust aporta varias cosas que importan mucho en un chip diminuto:

  • Sin recolector de basura. Nada pausa tu programa para limpiar memoria, así que los tiempos son predecibles. Eso es importante cuando tienes que reaccionar a una señal en microsegundos.
  • Seguridad de memoria sin runtime. Las reglas de ownership (propiedad) y borrowing (préstamo) se comprueban al compilar, así que no cuestan nada en el chip. Familias enteras de fallos (punteros colgantes, carreras de datos entre una interrupción y tu bucle principal) se detectan antes de grabar nada en el chip.
  • Abstracciones de coste cero. Una HAL te deja escribir led.set_high() en vez de tocar direcciones de memoria a mano, y se compila exactamente en la misma escritura de registro que escribirías tú.
  • El ownership modela el hardware. Un pin es un valor. Una vez que se mueve a un Output, nadie más puede agarrarlo y usarlo como entrada por error. El compilador hace cumplir lo que antes era un simple comentario en un archivo de C.
  • Herramientas excelentes. cargo, los targets de rustup y probe-rs sustituyen a muchos IDE de fabricantes y makefiles.

Para ser honestos con las partes difíciles: los fabricantes de chips siguen publicando sus SDK y ejemplos primero en C, así que para algunos chips el soporte de Rust es más flojo o lo mantiene solo la comunidad. Sigues necesitando leer hojas de datos (datasheets). Y algunos crates aún no han llegado a la 1.0 y cambian su API entre versiones, así que sigue siempre los ejemplos de la versión que usas.

  • Sensores y aparatos: una estación meteorológica, una alarma de humedad de la tierra, un podómetro.
  • Dispositivos USB: teclados personalizados, mandos de juego, controladores MIDI.
  • Dispositivos conectados (IoT): sensores con Wi-Fi o Bluetooth usando un ESP32, que envían sus lecturas a un servidor.
  • Robots y motores: robots sigue-líneas, control de motores paso a paso y servos, tiras de LED y pantallas.
  • Sistemas serios: Oxide Computer escribió Hubris (en inglés), un pequeño sistema operativo para microcontroladores, entero en Rust. Espressif, la empresa detrás del ESP32, mantiene su propio soporte de Rust y publica The Rust on ESP Book (en inglés).

Cualquiera de estas es una buena primera placa. Si no te decides, elige una Raspberry Pi Pico: es barata, está muy bien documentada y el ejemplo de Embassy de más abajo está escrito para ella.

Placa Chip Target de Rust Por qué está bien Empieza con
Raspberry Pi Pico / Pico W RP2040 (Arm Cortex-M0+) thumbv6m-none-eabi Barata, popular, excelente soporte de Embassy embassy-rp, o rp2040-hal + rp-pico
Raspberry Pi Pico 2 RP2350 (Arm Cortex-M33, o RISC-V) thumbv8m.main-none-eabihf Más nueva y más rápida, con unidad de punto flotante embassy-rp (feature rp235xa), rp235x-hal
Placas ESP32-C3 / ESP32-C6 Chips RISC-V de Espressif riscv32imc-unknown-none-elf / riscv32imac-unknown-none-elf Wi-Fi y Bluetooth integrados, soporte oficial de Rust esp-hal y la herramienta esp-generate
STM32 Nucleo, “Black Pill” STM32 (por ejemplo STM32F4) thumbv7em-none-eabihf Una familia de chips enorme que se usa en la industria; las placas Nucleo traen una sonda de depuración integrada embassy-stm32, stm32f4xx-hal
BBC micro:bit v2 nRF52833 (Arm Cortex-M4F) thumbv7em-none-eabihf Sonda de depuración, LEDs, botones y sensores en la propia placa microbit-v2, más el libro Discovery

La micro:bit es la placa que usa el libro oficial Embedded Rust Discovery (en inglés), un curso gratuito y paso a paso para principiantes. El ESP32 original y el ESP32-S3 usan núcleos Xtensa en vez de RISC-V; para ellos, la herramienta espup de Espressif instala una toolchain de Rust especial. El LED de la Pico W está conectado a su chip de Wi-Fi, no a un pin normal, así que necesita otro blinky (Embassy tiene un ejemplo wifi_blinky para ella).

Un programa normal de Rust usa la biblioteca estándar (std). std espera un sistema operativo: archivos, hilos, un heap, una terminal para println!. Un microcontrolador no tiene nada de eso. Por eso los programas embebidos empiezan con dos líneas:

#![no_std] // no enlaces la biblioteca estándar, solo `core`
#![no_main] // no uses el punto de entrada habitual `fn main()`
  • #![no_std] significa que solo tienes core (en inglés): enteros, Option, Result, iteradores, slices, traits, formateo. Nada de Vec, String, Box, archivos ni hilos. (Con un asignador de memoria puedes recuperar Vec y compañía a través del crate alloc, pero muchos proyectos nunca lo necesitan).
  • #![no_main] hace falta porque en un chip no hay sistema operativo que llame a main. Un crate de runtime como cortex-m-rt prepara la memoria después del reinicio y luego salta a tu función, que marcas con un atributo como #[cortex_m_rt::entry] o, con Embassy, #[embassy_executor::main].
  • También tienes que decir qué pasa cuando hay un panic, porque no hay terminal donde imprimir. Crates como panic-probe o panic-halt se encargan de eso; añades uno con use panic_probe as _;.

Si no hay Vec, ¿cómo guardas una lista? El crate heapless te da Vec, String y colas con una capacidad fija que se elige al compilar. Viven en la pila o en un static, así que no hay heap ni sorpresas por quedarte sin memoria. Si el búfer está lleno, simplemente te devuelve tu valor:

use core::fmt::Write;
use heapless::{String, Vec};
fn main() {
// Espacio para exactamente 4 lecturas. La memoria forma parte de la variable:
// sin heap, sin asignador, y el tamaño se conoce al compilar.
let mut readings: Vec<i32, 4> = Vec::new();
for value in [215, 218, 221, 224, 227] {
match readings.push(value) {
Ok(()) => println!("stored {value}"),
Err(rejected) => println!("buffer full, dropped {rejected}"),
}
}
// Un String con una capacidad fija de 16 bytes
let mut line: String<16> = String::new();
write!(line, "avg={}", readings.iter().sum::<i32>() / readings.len() as i32).unwrap();
println!("{line} (capacity {})", line.capacity());
}

heapless también funciona en un PC, así que puedes probarlo con cargo add [email protected] y cargo run (no está en el Playground). Esta es la salida real; “stored” significa “guardado” y “buffer full, dropped” quiere decir “búfer lleno, descartado”:

stored 215
stored 218
stored 221
stored 224
buffer full, dropped 227
avg=219 (capacity 16)

Los crates de Rust embebido se apilan unos sobre otros. Casi siempre trabajarás en el nivel de la HAL o del framework, pero ayuda saber lo que hay debajo:

Capa Qué es Ejemplos
PAC (Peripheral Access Crate) Se genera a partir del archivo que describe los registros del chip. Una función más o menos segura por cada registro y campo. Muy de bajo nivel. rp2040-pac, stm32f4, esp32c3
HAL (Hardware Abstraction Layer, capa de abstracción del hardware) Tipos cómodos para una familia de chips: Output, Uart, I2c, temporizadores. Se construye sobre un PAC. rp2040-hal, esp-hal, stm32f4xx-hal, embassy-rp, embassy-stm32
Traits de embedded-hal Traits compartidos como OutputPin, I2c, SpiDevice y DelayNs. Las HAL los implementan; los drivers los usan. La versión 1.0 es estable. embedded-hal, embedded-hal-async
BSP (Board Support Package, paquete de soporte de placa) Una HAL más nombres para los pines y piezas de una placa concreta (“el LED”, “el botón A”). rp-pico, microbit-v2
RTIC Un framework para apps guiadas por interrupciones. Las tareas van ligadas a interrupciones y prioridades, y el compilador comprueba los datos compartidos al compilar. rtic
Embassy Un framework async: un ejecutor, temporizadores, red y USB, además de sus propias HAL. Escribes tareas async fn y haces .await sobre eventos del hardware. embassy-executor, embassy-time, embassy-rp

La magia está en la fila del medio. Un driver para un sensor de temperatura escrito con el trait I2c de embedded-hal funciona sin cambios en una Pico, un ESP32 y un STM32, porque cada HAL implementa el mismo trait. Más abajo vas a escribir tú mismo un driver así.

Tu PC tiene un procesador de escritorio x86 o Arm; la Pico tiene un Cortex-M0+. Así que haces compilación cruzada (cross-compiling): compilas en una máquina para otra distinta. rustup descarga la biblioteca core ya compilada para el chip:

Terminal window
rustup target add thumbv6m-none-eabi # RP2040 (Pico)
rustup target add thumbv8m.main-none-eabihf # RP2350 (Pico 2)
rustup target add thumbv7em-none-eabihf # micro:bit v2, STM32F4

Para copiar el programa al chip y ver sus logs, necesitas probe-rs y una sonda de depuración (debug probe): un pequeño dispositivo USB que se conecta a los pines de depuración del chip. Las placas micro:bit y STM32 Nucleo traen una integrada. Para una Pico, usa una Raspberry Pi Debug Probe, o una segunda Pico con el firmware gratuito debugprobe. Instala probe-rs con su script de instalación (mira la página de instalación, en inglés, para Windows) o con cargo:

Terminal window
curl --proto '=https' --tlsv1.2 -LsSf https://github.com/probe-rs/probe-rs/releases/latest/download/probe-rs-tools-installer.sh | sh
# o, más lento, desde el código fuente:
cargo install probe-rs-tools --locked
probe-rs list # muestra las sondas que detecta

¿Todavía no tienes sonda? Mantén pulsado el botón BOOTSEL de la Pico mientras la conectas, y aparecerá como una unidad USB. Herramientas como picotool o elf2uf2-rs pueden copiar tu programa a la placa de esa forma. Eso sí, sin sonda no verás los logs de defmt.

En un ESP32-C3 o C6 normalmente no necesitas sonda: el chip trae USB integrado, y la herramienta espflash lo graba por el cable USB normal. Ejecuta cargo install esp-generate espflash --locked, luego esp-generate, elige tu chip y las opciones, y cargo run --release compila, graba el chip y muestra los logs.

“Blinky” es el “¡Hola, mundo!” de los sistemas embebidos: enciende el LED, espera, apágalo, espera, y así para siempre. Este es el ejemplo blinky oficial de Embassy para RP2040 (con comentarios añadidos). Necesita una placa real y no funciona en el Playground.

#![no_std]
#![no_main]
use defmt::*;
use defmt_rtt as _; // envía los logs de defmt a la sonda
use embassy_executor::Spawner;
use embassy_rp::gpio;
use embassy_time::Timer;
use gpio::{Level, Output};
use panic_probe as _; // en un panic: imprime el mensaje por la sonda y detente
#[embassy_executor::main(executor = "embassy_rp::executor::Executor", entry = "cortex_m_rt::entry")]
async fn main(_spawner: Spawner) {
// Toma el ownership de todos los periféricos del chip, una sola vez
let p = embassy_rp::init(Default::default());
// El LED de la Pico está en el pin 25. PIN_25 se mueve a `led`.
let mut led = Output::new(p.PIN_25, Level::Low);
loop {
info!("led on!");
led.set_high();
Timer::after_millis(500).await;
info!("led off!");
led.set_low();
Timer::after_millis(500).await;
}
}

Algunas cosas en las que fijarte:

  • main es una async fn. Timer::after_millis(500).await no da vueltas en un bucle ocupado; el ejecutor pone el chip a dormir hasta que salta el temporizador. Si tienes más tareas (lanzadas con el Spawner), las demás se ejecutan mientras esta espera. Si async/await es nuevo para ti, la idea es la misma que con Tokio en un PC, solo que sin sistema operativo.
  • p.PIN_25 se mueve a Output::new. Si intentas usar p.PIN_25 una segunda vez, el compilador te detiene: ownership aplicado al hardware.
  • use defmt_rtt as _; y use panic_probe as _; importan los crates solo por lo que configuran (el canal de logs y el manejador de panic), no por ningún nombre.

La forma más rápida de verlo parpadear es usar la propia carpeta de ejemplos oficial: clona el repositorio de Embassy y, dentro de examples/rp, ejecuta cargo run --bin blinky --release. Para tu propio proyecto, las dependencias salen del Cargo.toml de ese ejemplo:

[package]
name = "blinky"
version = "0.1.0"
edition = "2024"
[dependencies]
embassy-executor = { version = "0.10.0", features = ["defmt"] }
embassy-time = { version = "0.5.1", features = ["defmt", "defmt-timestamp-uptime"] }
embassy-rp = { version = "0.10.0", features = ["defmt", "unstable-pac", "time-driver", "critical-section-impl", "rp2040", "executor-thread"] }
defmt = "1.0.1"
defmt-rtt = "1.0.0"
panic-probe = { version = "1.0.0", features = ["print-defmt"] }
cortex-m = { version = "0.7.6", features = ["inline-asm"] }
cortex-m-rt = "0.7.0"
[profile.release]
debug = true # conserva la información de depuración para probe-rs; no se graba en el chip

Dos archivos más le dicen a cargo qué compilar y cómo ejecutarlo. .cargo/config.toml fija el target y hace que cargo run grabe el chip con probe-rs:

[target.'cfg(all(target_arch = "arm", target_os = "none"))']
runner = "probe-rs run --chip RP2040"
[build]
target = "thumbv6m-none-eabi"
[env]
DEFMT_LOG = "debug"

Por último, copia memory.x (dónde están la memoria flash y la RAM en el chip) y build.rs (que pasa los scripts del enlazador) desde la misma carpeta examples/rp. Luego conecta tu sonda y ejecuta:

Terminal window
cargo run --release

cargo compila para thumbv6m-none-eabi, probe-rs graba el chip, el LED empieza a parpadear y los mensajes led on! / led off! (“LED encendido” / “LED apagado”) van apareciendo en tu terminal con marcas de tiempo.

Un microcontrolador no tiene pantalla, y formatear texto con core::fmt ocupa mucho espacio en la flash. defmt (en inglés; “deferred formatting”, formateo diferido) resuelve las dos cosas. Las cadenas de formato se guardan en tu PC, no en el chip; el chip solo envía por la sonda un ID diminuto y los valores en bruto, y probe-rs los vuelve a convertir en texto legible.

use defmt::{info, warn, Format};
#[derive(Format)] // como #[derive(Debug)], pero para defmt
pub struct Reading {
pub sensor_id: u8,
pub milli_celsius: i32,
}
pub fn report(r: &Reading) {
info!("new reading: {}", r);
if r.milli_celsius > 60_000 {
// `=u8` y `=i32` le dicen a defmt el tipo exacto, lo que ahorra todavía más espacio
warn!("sensor {=u8} is too hot: {=i32} mC", r.sensor_id, r.milli_celsius);
}
}

Los niveles son trace!, debug!, info!, warn! y error!, igual que en el crate log. El ajuste DEFMT_LOG de .cargo/config.toml elige qué niveles se compilan; todo lo que esté por debajo ocupa cero bytes.

Debajo de cualquier HAL, controlar el hardware significa escribir números en registros: direcciones de memoria especiales donde cada bit enciende o apaga algo del chip. En un chip típico, un registro de salida GPIO de 32 bits tiene un bit por pin. Aquí un simple u32 hace el papel de ese registro, para que practiques los cuatro movimientos que usa todo desarrollador de sistemas embebidos. Los textos que se imprimen se quedan en inglés, porque sus espacios alinean los números en columnas (“LED on” es “LED encendido”, “toggled” es “invertido” y “as hex” es “en hexadecimal”):

const LED: u32 = 25; // el LED de la placa Pico está en el pin 25
fn main() {
// Imagina que este u32 es el registro de salida GPIO del chip.
// El bit 0 controla el pin 0, el bit 1 el pin 1, y así sucesivamente.
let mut gpio_out: u32 = 0b1000; // el pin 3 ya está encendido
// Activar un bit: OR con una máscara
gpio_out |= 1 << LED;
println!("LED on: {gpio_out:#034b}");
// Leer un bit: AND con una máscara, y luego comparar con cero
let led_is_on = (gpio_out & (1 << LED)) != 0;
let pin2_is_on = (gpio_out & (1 << 2)) != 0;
println!("LED on? {led_is_on}, pin 2 on? {pin2_is_on}");
// Invertir un bit: XOR con una máscara
gpio_out ^= 1 << LED;
println!("toggled: {gpio_out:#034b}");
// Borrar un bit: AND con la máscara invertida
gpio_out |= 1 << LED;
gpio_out &= !(1 << 3);
println!("pin 3 off: {gpio_out:#034b}");
println!("as hex: {gpio_out:#010x}");
}

Salida:

LED on: 0b00000010000000000000000000001000
LED on? true, pin 2 on? false
toggled: 0b00000000000000000000000000001000
pin 3 off: 0b00000010000000000000000000000000
as hex: 0x02000000
▶ Pruébalo tú mismo

En Rust, ! sobre un entero invierte todos los bits (C usa ~ para esto). {:#034b} imprime el valor en binario con el prefijo 0b y rellenado hasta 32 dígitos, así que puedes ver cómo el bit 25 aparece y desaparece. Mira Operadores en Rust para la lista completa de operadores de bits.

Muchos registros meten varios campos en un solo número: unos cuantos bits para un ajuste de velocidad, un bit para “activado”. Para cambiar un campo haces un leer-modificar-escribir (read-modify-write): borras los bits del campo y luego haces OR con el valor nuevo, sin tocar ningún otro bit:

// Un registro de control UART inventado:
// bit 0 = activado
// bits 4..=6 = ajuste de velocidad (un número de 3 bits, de 0 a 7)
const ENABLE: u32 = 1 << 0;
const SPEED_SHIFT: u32 = 4;
const SPEED_MASK: u32 = 0b111 << SPEED_SHIFT;
fn read_speed(reg: u32) -> u32 {
(reg & SPEED_MASK) >> SPEED_SHIFT
}
fn write_speed(reg: u32, speed: u32) -> u32 {
// Borra el campo viejo y luego mete el valor nuevo.
// Todos los demás bits se quedan exactamente como estaban.
(reg & !SPEED_MASK) | ((speed << SPEED_SHIFT) & SPEED_MASK)
}
fn main() {
let mut ctrl: u32 = ENABLE | (2 << SPEED_SHIFT);
println!("start: {ctrl:#010b}, speed = {}", read_speed(ctrl));
ctrl = write_speed(ctrl, 5);
println!("speed 5: {ctrl:#010b}, speed = {}", read_speed(ctrl));
// 9 no cabe en 3 bits. La máscara lo recorta en vez de
// desbordarse hacia los bits vecinos.
ctrl = write_speed(ctrl, 9);
println!("speed 9?: {ctrl:#010b}, speed = {}", read_speed(ctrl));
let still_enabled = (ctrl & ENABLE) != 0;
println!("still enabled: {still_enabled}");
}

Salida:

start: 0b00100001, speed = 2
speed 5: 0b01010001, speed = 5
speed 9?: 0b00010001, speed = 1
still enabled: true
▶ Pruébalo tú mismo

Fíjate en que el 9 se convirtió en 1 sin avisar. Ese es justo el tipo de error del que te protegen los PAC y las HAL: le dan a cada campo su propio método con nombre (a menudo también con valores con nombre), así que casi nunca escribes máscaras a mano. En el hardware real, el registro es una dirección de memoria fija, que se escribe con core::ptr::write_volatile dentro de código unsafe; el PAC hace esa parte por ti.

Escribe un driver que puedas probar en tu PC

Sección titulada «Escribe un driver que puedas probar en tu PC»

El mejor hábito embebido que puedes aprender: escribe tu lógica contra traits, no contra un chip concreto. Así el mismo código funciona en la placa con la HAL real, y en tu PC con piezas falsas (mocks) en una prueba normal. Aquí, StatusLed solo necesita “algo que pueda encender y apagar” y “algo que pueda esperar”:

use std::cell::RefCell;
use std::rc::Rc;
// Las dos capacidades que necesita un driver de parpadeo. Imitan los traits
// OutputPin y DelayNs de embedded-hal 1.0, simplificados para que funcionen en el Playground.
trait OutputPin {
fn set_high(&mut self);
fn set_low(&mut self);
}
trait Delay {
fn delay_ms(&mut self, ms: u32);
}
// El driver no sabe nada de ningún chip. Solo usa los traits.
struct StatusLed<P: OutputPin, D: Delay> {
pin: P,
delay: D,
}
impl<P: OutputPin, D: Delay> StatusLed<P, D> {
// Parpadea `times` veces: encendido durante `on_ms` y apagado durante `off_ms`.
fn blink(&mut self, times: u32, on_ms: u32, off_ms: u32) {
for _ in 0..times {
self.pin.set_high();
self.delay.delay_ms(on_ms);
self.pin.set_low();
self.delay.delay_ms(off_ms);
}
}
}
// --- Piezas falsas para probar en un PC ---
// Las dos escriben en el mismo registro compartido, así vemos el orden.
type Log = Rc<RefCell<Vec<String>>>;
struct MockPin {
log: Log,
}
impl OutputPin for MockPin {
fn set_high(&mut self) {
self.log.borrow_mut().push("encender".to_string());
}
fn set_low(&mut self) {
self.log.borrow_mut().push("apagar".to_string());
}
}
struct MockDelay {
log: Log,
}
impl Delay for MockDelay {
fn delay_ms(&mut self, ms: u32) {
self.log.borrow_mut().push(format!("esperar {ms}"));
}
}
fn main() {
let log: Log = Rc::new(RefCell::new(Vec::new()));
let pin = MockPin { log: Rc::clone(&log) };
let delay = MockDelay { log: Rc::clone(&log) };
let mut led = StatusLed { pin, delay };
led.blink(2, 100, 400);
let events = log.borrow();
println!("{}", events.join(", "));
assert_eq!(events.len(), 8);
assert_eq!(events[0], "encender");
assert_eq!(events[1], "esperar 100");
println!("todas las comprobaciones pasaron");
}

Salida:

encender, esperar 100, apagar, esperar 400, encender, esperar 100, apagar, esperar 400
todas las comprobaciones pasaron
▶ Pruébalo tú mismo

Los dos mocks comparten un mismo registro de eventos a través de Rc<RefCell<...>> (mira Punteros inteligentes), así que puedes comprobar el orden exacto de los eventos. En un proyecto real, las líneas assert_eq! van en una función #[test] y se ejecutan con cargo test en tu PC, sin necesidad de placa (mira Pruebas en Rust y Pruebas avanzadas).

En la placa, escribirías el mismo driver con los traits reales de embedded-hal 1.0. Los únicos cambios: los métodos del pin devuelven un Result (un pin en un chip expansor de E/S puede fallar), así que pasas los errores hacia arriba con ?, y el código no necesita std para nada:

#![no_std]
use embedded_hal::delay::DelayNs;
use embedded_hal::digital::OutputPin;
pub struct StatusLed<P, D> {
pin: P,
delay: D,
}
impl<P: OutputPin, D: DelayNs> StatusLed<P, D> {
pub fn new(pin: P, delay: D) -> Self {
StatusLed { pin, delay }
}
pub fn blink(&mut self, times: u32, on_ms: u32, off_ms: u32) -> Result<(), P::Error> {
for _ in 0..times {
self.pin.set_high()?;
self.delay.delay_ms(on_ms);
self.pin.set_low()?;
self.delay.delay_ms(off_ms);
}
Ok(())
}
}

El Output de Embassy implementa OutputPin, y embassy_time::Delay implementa DelayNs, así que StatusLed::new(led, embassy_time::Delay) funciona en la Pico. Para probar en el PC con estos traits reales, el crate embedded-hal-mock trae pines y buses I2C y SPI falsos ya hechos.

Muchos chips pequeños, como el Cortex-M0+ del RP2040, no tienen unidad de punto flotante. Los floats siguen funcionando, pero por software: más lentos, y hacen que tu programa ocupe más. Por eso el código embebido usa a menudo números de punto fijo: enteros en una unidad más pequeña. En vez de 25.25 grados, guardas 25250 miligrados.

Un sensor de temperatura del estilo del TMP102 envía dos bytes. Los 12 bits de arriba son un número con signo en pasos de 1/16 °C:

// Los 12 bits de arriba son la temperatura en pasos de 1/16 °C (0,0625 °C),
// como número con signo (en complemento a dos).
fn to_millicelsius(bytes: [u8; 2]) -> i32 {
// i16 conserva el signo. `>> 4` sobre un número con signo también lo conserva.
let raw = i16::from_be_bytes(bytes) >> 4;
// 1/16 °C = 62,5 m°C, así que multiplicamos por 625 y dividimos entre 10.
raw as i32 * 625 / 10
}
// Imprime 25250 como "25.250" sin tocar nunca un float.
fn format_milli(m: i32) -> String {
let sign = if m < 0 { "-" } else { "" };
let m = m.abs();
format!("{sign}{}.{:03}", m / 1000, m % 1000)
}
fn main() {
let samples = [
[0x19, 0x40], // una habitación templada
[0x00, 0x10], // el paso más pequeño por encima de cero
[0xFF, 0x80], // justo por debajo del punto de congelación
[0xE7, 0x00], // un congelador
];
for bytes in samples {
let m = to_millicelsius(bytes);
println!("{bytes:02X?} -> {m:>6} m°C = {} °C", format_milli(m));
}
}

Salida:

[19, 40] -> 25250 m°C = 25.250 °C
[00, 10] -> 62 m°C = 0.062 °C
[FF, 80] -> -500 m°C = -0.500 °C
[E7, 00] -> -25000 m°C = -25.000 °C
▶ Pruébalo tú mismo

Tres detalles hacen que esto funcione. i16::from_be_bytes lee los dos bytes como un solo número con signo, con el byte más significativo primero (“big-endian”), que es como envían los datos la mayoría de los sensores. >> 4 sobre un entero con signo conserva el signo, así que las temperaturas negativas salen bien. Y format_milli trata el signo por separado: -500 / 1000 da 0 con aritmética de enteros, así que sin ese paso -0.5 °C se imprimiría como 0.500. (El programa usa punto como separador decimal, como hace Rust al imprimir números).

La división entera además trunca: 62,5 m°C se quedó en 62. Medio miligrado no importa para un sensor de habitación, pero pregúntate siempre cuánta precisión te da una unidad. Y vigila el rango: multiplicar una lectura grande por un factor grande puede desbordar un i32 (mira Desbordamiento de enteros). En un PC, String está bien; en el chip imprimirías con defmt o escribirías en un heapless::String.

Usar cosas de std en un programa no_std. println!, Vec y String vienen de std, así que simplemente no están. El compilador dice que no encuentra la macro println en este ámbito:

error: cannot find macro `println` in this scope
--> src/main.rs:4:5
|
4 | println!("hello");
| ^^^^^^^

Usa defmt::info! para los logs, y heapless (o el crate alloc con un asignador) para las colecciones.

Olvidar el manejador de panic. Un binario no_std tiene que decir qué hace un panic. Sin un crate como panic-probe, el compilador te dice que hace falta una función #[panic_handler] y no la encuentra:

error: `#[panic_handler]` function required, but not found

Añade panic-probe (o panic-halt) a Cargo.toml y use panic_probe as _; a main.rs.

Sobrescribir un registro entero. reg = 1 << 3 enciende el pin 3, y apaga todos los demás. Para cambiar un solo bit, usa reg |= 1 << 3. Para los campos, haz el leer-modificar-escribir de antes.

Seguir un tutorial antiguo. Los crates embebidos avanzan rápido, y los artículos de hace unos años a menudo usan embedded-hal 0.2 o APIs viejas de Embassy. Parte de la carpeta de ejemplos de la versión del crate de la que dependes de verdad.

Escribe toggle(reg: u32, pin: u32) -> u32, que invierte un bit, y pins_on(reg: u32) -> Vec<u32>, que lista todos los pines cuyo bit vale 1. Empieza con los pines 0, 5 y 25 encendidos, imprime la lista y cuántos pines están encendidos, luego invierte los pines 5 y 7 y vuelve a imprimir. Pista: comprueba cada pin del 0 al 31 con una máscara; además, u32 tiene un método count_ones().

Ver solución
fn toggle(reg: u32, pin: u32) -> u32 {
reg ^ (1 << pin)
}
fn pins_on(reg: u32) -> Vec<u32> {
(0..32).filter(|pin| reg & (1 << pin) != 0).collect()
}
fn main() {
let mut reg: u32 = (1 << 0) | (1 << 5) | (1 << 25);
println!("encendidos: {:?} ({} pines)", pins_on(reg), reg.count_ones());
reg = toggle(reg, 5);
reg = toggle(reg, 7);
println!("encendidos: {:?} ({} pines)", pins_on(reg), reg.count_ones());
}

Salida:

encendidos: [0, 5, 25] (3 pines)
encendidos: [0, 7, 25] (3 pines)
▶ Pruébalo tú mismo

XOR invierte un bit sea cual sea su valor anterior, así que invertir el pin 5 lo apagó e invertir el pin 7 lo encendió. count_ones() cuenta los bits a 1 por ti, así que para esa parte no necesitas un bucle.

Un ADC (conversor analógico-digital) de 12 bits da lecturas de 0 a 4095 para 0 a 3300 mV. La batería está conectada a través de un divisor de tensión que reduce su voltaje a la mitad, así que el voltaje real es el doble del medido. Escribe battery_mv(raw: u16) -> i32 y battery_percent(mv: i32) -> i32, donde 3000 mV es el 0 % y 4200 mV es el 100 %, nunca por debajo de 0 ni por encima de 100. Prueba con las lecturas 2606, 2482, 2234 y 1861. Pista: multiplica antes de dividir, para no perder precisión, y échale un vistazo a clamp.

Ver solución
const EMPTY_MV: i32 = 3000;
const FULL_MV: i32 = 4200;
fn battery_mv(raw: u16) -> i32 {
// ADC de 12 bits (0..=4095) que mide 0..=3300 mV, detrás de un divisor que reduce el voltaje a la mitad
raw as i32 * 3300 * 2 / 4095
}
fn battery_percent(mv: i32) -> i32 {
let percent = (mv - EMPTY_MV) * 100 / (FULL_MV - EMPTY_MV);
percent.clamp(0, 100)
}
fn main() {
for raw in [2606, 2482, 2234, 1861] {
let mv = battery_mv(raw);
println!("lectura {raw} -> {mv} mV -> {}%", battery_percent(mv));
}
}

Salida:

lectura 2606 -> 4200 mV -> 100%
lectura 2482 -> 4000 mV -> 83%
lectura 2234 -> 3600 mV -> 50%
lectura 1861 -> 2999 mV -> 0%
▶ Pruébalo tú mismo

Dividir entre 4095 al final conserva la precisión: raw / 4095 * 6600 daría 0 para cualquier lectura por debajo de 4095. El producto más grande, 4095 * 6600, es de unos 27 millones, que cabe de sobra en un i32.

  • Rust embebido se ejecuta en microcontroladores sin sistema operativo. No tener recolector de basura, la seguridad comprobada al compilar y las abstracciones de coste cero hacen que Rust encaje muy bien.
  • #![no_std] te deja solo con core; #![no_main] permite que un crate de runtime llame a tu función de entrada; tienes que elegir un manejador de panic. heapless te da colecciones de capacidad fija.
  • Las capas: PAC (registros), HAL (una familia de chips), traits de embedded-hal (compartidos), BSP (una placa), y frameworks como RTIC y Embassy por encima.
  • Añade el target del chip con rustup target add, y luego graba el chip y mira los logs con probe-rs y una sonda de depuración (o espflash en un ESP32). defmt hace que los logs ocupen muy poco.
  • Practica en tu PC: máscaras de bits para los registros, drivers escritos contra traits con piezas falsas, y enteros de punto fijo en vez de floats.

Comprueba lo aprendido

1. ¿Qué hace #![no_std]?

2. ¿Qué capa permite que un mismo driver de sensor funcione sin cambios en una Pico, un ESP32 y un STM32?

3. ¿Cómo activas el bit 3 de reg sin cambiar ningún otro bit?

4. ¿Por qué el código embebido guarda a menudo 25,25 °C como 25250 miligrados?

Puntuación: 0 / 4

  • The Embedded Rust Book (en inglés): la introducción oficial a no_std, los periféricos, los registros y el ecosistema embebido.
  • Discovery (micro:bit v2) (en inglés): un curso gratuito para principiantes que te lleva paso a paso desde un LED que parpadea hasta los sensores, con una BBC micro:bit.
  • The Embassy Book (en inglés): cómo empezar con Embassy, cómo funcionan su ejecutor y sus HAL, y cómo ejecutar los ejemplos oficiales.
  • The Rust on ESP Book (en inglés): la guía de la propia Espressif sobre esp-hal, esp-generate, espflash y las toolchains del ESP32.
  • Instalación de probe-rs (en inglés): cómo instalar probe-rs en cada sistema operativo y configurar tu sonda de depuración.