Salta ai contenuti

Rust embedded: microcontrollori, no_std ed Embassy

Rust embedded significa scrivere Rust per piccoli computer che vivono dentro gli oggetti: un termostato, una tastiera, un drone, un sensore che annaffia le piante. Questi computer si chiamano microcontrollori. Un solo chip contiene il processore, qualche centinaio di kilobyte di memoria e i pin che parlano con LED, pulsanti e sensori. Di solito non c’è nessun sistema operativo. Il tuo programma è l’unica cosa in esecuzione, dall’accensione allo spegnimento.

Sembra spaventoso, ma è anche molto divertente: scrivi poche righe, e una luce vera si mette a lampeggiare sulla tua scrivania. Questa pagina ti mostra cosa si può fare, quale scheda e quali crate scegliere e come caricare il tuo primo programma su un chip. Lungo la strada ti eserciti con il «modo di pensare embedded» in esempi che puoi eseguire direttamente nel browser.

Per decenni il codice embedded è stato scritto soprattutto in C. Rust porta alcune cose che contano molto su un chip minuscolo:

  • Niente garbage collector. Niente mette in pausa il programma per pulire la memoria, quindi i tempi restano prevedibili. È importante quando devi reagire a un segnale entro pochi microsecondi.
  • Sicurezza della memoria senza runtime. Le regole di ownership (possesso) e di prestito (borrowing) vengono controllate durante la compilazione, quindi sul chip non costano niente. Intere categorie di crash (puntatori pendenti, data race tra un interrupt e il ciclo principale) vengono intercettate prima di caricare qualsiasi cosa sul chip.
  • Astrazioni a costo zero. Una HAL ti fa scrivere led.set_high() invece di scrivere direttamente negli indirizzi di memoria, e compila nella stessa singola scrittura di registro che scriveresti a mano.
  • L’ownership modella l’hardware. Un pin è un valore. Una volta spostato in un Output, nessun altro può prenderlo e usarlo per sbaglio come input. Il compilatore impone quello che prima era un commento in un file C.
  • Strumenti ottimi. cargo, i target di rustup e probe-rs sostituiscono tanti IDE dei produttori e tanti makefile.

Onestamente, ci sono anche spigoli: i produttori di chip pubblicano ancora i loro SDK e i loro esempi prima di tutto in C, quindi per alcuni chip il supporto Rust è più scarno o solo della community. Devi comunque leggere i datasheet. E alcuni crate sono prima della 1.0 e cambiano la loro API tra una release e l’altra, quindi segui sempre gli esempi della versione che usi.

  • Sensori e gadget: una stazione meteo, un allarme per l’umidità del terreno, un contapassi.
  • Dispositivi USB: tastiere personalizzate, controller per videogiochi, controller MIDI.
  • Dispositivi connessi (IoT): sensori Wi-Fi o Bluetooth con un ESP32 che inviano le letture a un server.
  • Robot e motori: robot che seguono una linea, controllo di motori passo-passo e servo, strisce LED e display.
  • Sistemi seri: Oxide Computer ha scritto Hubris (in inglese), un piccolo sistema operativo per microcontrollori, interamente in Rust. Espressif, l’azienda dietro l’ESP32, mantiene il suo supporto Rust e pubblica The Rust on ESP Book (in inglese).

Ognuna di queste è una buona prima scheda. Se non sai decidere, prendi un Raspberry Pi Pico: costa poco, è documentato benissimo, e l’esempio Embassy più sotto è scritto per lui.

Scheda Chip Target Rust Perché è bella Inizia con
Raspberry Pi Pico / Pico W RP2040 (Arm Cortex-M0+) thumbv6m-none-eabi Economica, popolare, ottimo supporto Embassy embassy-rp, oppure rp2040-hal + rp-pico
Raspberry Pi Pico 2 RP2350 (Arm Cortex-M33, o RISC-V) thumbv8m.main-none-eabihf Più nuova e più veloce, ha un’unità in virgola mobile embassy-rp (feature rp235xa), rp235x-hal
Schede ESP32-C3 / ESP32-C6 Chip RISC-V di Espressif riscv32imc-unknown-none-elf / riscv32imac-unknown-none-elf Wi-Fi e Bluetooth integrati, supporto Rust ufficiale esp-hal e lo strumento esp-generate
STM32 Nucleo, «Black Pill» STM32 (per esempio STM32F4) thumbv7em-none-eabihf Enorme famiglia di chip usata nell’industria; le schede Nucleo hanno una sonda di debug integrata embassy-stm32, stm32f4xx-hal
BBC micro:bit v2 nRF52833 (Arm Cortex-M4F) thumbv7em-none-eabihf Sonda di debug, LED, pulsanti e sensori sulla scheda microbit-v2, più il libro Discovery

Il micro:bit è la scheda usata dal libro ufficiale Embedded Rust Discovery (in inglese), un corso gratuito passo per passo per principianti. L’ESP32 originale e l’ESP32-S3 usano core Xtensa invece di RISC-V; per questi, lo strumento espup di Espressif installa una toolchain Rust speciale. Il LED del Pico W è collegato al suo chip Wi-Fi, non a un normale pin, quindi serve un blinky diverso (Embassy ha un esempio wifi_blinky apposta).

Un normale programma Rust usa la libreria standard (std). std si aspetta un sistema operativo: file, thread, un heap, un terminale per println!. Un microcontrollore non ha niente di tutto questo. Per questo i programmi embedded iniziano con due righe:

#![no_std] // non collegare la libreria standard, solo `core`
#![no_main] // non usare il solito punto di ingresso `fn main()`
  • #![no_std] significa che hai solo core (in inglese): interi, Option, Result, iteratori, slice, trait, formattazione. Niente Vec, String, Box, file o thread. (Con un allocatore puoi riavere Vec e compagnia tramite il crate alloc, ma a molti progetti non serve mai.)
  • #![no_main] serve perché su un chip non c’è un sistema operativo che chiami main. Un crate di runtime come cortex-m-rt prepara la memoria dopo il reset e poi salta alla tua funzione, che segni con un attributo come #[cortex_m_rt::entry] oppure, con Embassy, #[embassy_executor::main].
  • Devi anche dire cosa succede in caso di panic, perché non c’è un terminale su cui stampare. Crate come panic-probe o panic-halt se ne occupano; ne aggiungi uno con use panic_probe as _;.

Se non c’è Vec, come salvi una lista? Il crate heapless (in inglese) ti dà Vec, String e code con una capacità fissa scelta in fase di compilazione. Vivono sullo stack o in uno static, quindi niente heap e nessuna sorpresa da memoria esaurita. Un buffer pieno ti restituisce semplicemente il valore:

use core::fmt::Write;
use heapless::{String, Vec};
fn main() {
// Spazio per esattamente 4 letture. La memoria fa parte della variabile:
// niente heap, niente allocatore, e la dimensione è nota in compilazione.
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}"),
}
}
// Una String con una capacità fissa di 16 byte
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 funziona anche su PC, quindi puoi provarlo con cargo add [email protected] e cargo run (non è nel Playground). L’output resta in inglese, così come l’abbiamo ottenuto: la quinta lettura viene rifiutata perché il buffer è pieno.

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

I crate di Rust embedded si impilano uno sopra l’altro. Lavorerai soprattutto a livello di HAL o di framework, ma aiuta sapere cosa c’è sotto:

Strato Cos’è Esempi
PAC (Peripheral Access Crate) Generato dal file di descrizione dei registri del chip. Una funzione più o meno sicura per ogni registro e campo. Molto a basso livello. rp2040-pac, stm32f4, esp32c3
HAL (Hardware Abstraction Layer) Tipi comodi per una famiglia di chip: Output, Uart, I2c, timer. Costruita su una PAC. rp2040-hal, esp-hal, stm32f4xx-hal, embassy-rp, embassy-stm32
Trait di embedded-hal Trait condivisi come OutputPin, I2c, SpiDevice e DelayNs. Le HAL li implementano; i driver li usano. La versione 1.0 è stabile. embedded-hal, embedded-hal-async
BSP (Board Support Package) Una HAL più i nomi dei pin e dei componenti di una scheda specifica («il LED», «il pulsante A»). rp-pico, microbit-v2
RTIC Un framework per app guidate dagli interrupt. I task sono legati a interrupt e priorità, e il compilatore controlla i dati condivisi in fase di build. rtic
Embassy Un framework async: un executor, timer, rete e USB, più le sue HAL. Scrivi task async fn e fai .await sugli eventi hardware. embassy-executor, embassy-time, embassy-rp

La magia sta nella riga di mezzo. Un driver per un sensore di temperatura scritto per il trait I2c di embedded-hal funziona su un Pico, un ESP32 e un STM32 senza modifiche, perché ogni HAL implementa lo stesso trait. Più sotto ne scriverai uno tu.

Il tuo PC ha un processore desktop x86 o Arm; il Pico ha un Cortex-M0+. Quindi fai cross-compilazione: compili su una macchina, per un’altra. rustup scarica la libreria core precompilata per il 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

Per copiare il programma sul chip e vederne i log ti servono probe-rs (in inglese) e una sonda di debug (debug probe): un piccolo dispositivo USB che si collega ai pin di debug del chip. Le schede micro:bit e STM32 Nucleo ne hanno una integrata. Per un Pico usa un Raspberry Pi Debug Probe, oppure un secondo Pico su cui hai caricato il firmware gratuito debugprobe. Installa probe-rs con il suo script di installazione (vedi la pagina di installazione, in inglese, per 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
# oppure, più lento, dai sorgenti:
cargo install probe-rs-tools --locked
probe-rs list # mostra le sonde che vede

Non hai ancora una sonda? Tieni premuto il pulsante BOOTSEL del Pico mentre lo colleghi, e comparirà come chiavetta USB. Strumenti come picotool o elf2uf2-rs possono copiarci sopra il tuo programma in questo modo. Senza sonda, però, non vedrai i log di defmt.

Su un ESP32-C3 o C6 di solito la sonda non serve affatto: il chip ha l’USB integrato, e lo strumento espflash lo programma attraverso il normale cavo USB. Esegui cargo install esp-generate espflash --locked, poi esp-generate, scegli il chip e le opzioni, e cargo run --release compila, carica il programma e mostra i log.

«Blinky» è l’«Hello, world!» dell’embedded: accendi il LED, aspetta, spegnilo, aspetta, per sempre. Questo è l’esempio blinky ufficiale di Embassy per RP2040 (in inglese), con qualche commento in più. Ha bisogno di una scheda vera e non gira nel Playground.

#![no_std]
#![no_main]
use defmt::*;
use defmt_rtt as _; // manda i log di defmt alla sonda
use embassy_executor::Spawner;
use embassy_rp::gpio;
use embassy_time::Timer;
use gpio::{Level, Output};
use panic_probe as _; // in caso di panic: stampa il messaggio tramite la sonda, poi fermati
#[embassy_executor::main(executor = "embassy_rp::executor::Executor", entry = "cortex_m_rt::entry")]
async fn main(_spawner: Spawner) {
// Prendi possesso di tutte le periferiche del chip, una volta sola
let p = embassy_rp::init(Default::default());
// Il LED del Pico è sul pin 25. PIN_25 viene spostato in `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;
}
}

Alcune cose da notare:

  • main è una async fn. Timer::after_millis(500).await non gira a vuoto in un ciclo; l’executor mette il chip a dormire finché il timer non scatta. Con più task (avviati tramite lo Spawner), gli altri girano mentre questo aspetta. Se async/await è una novità per te, l’idea è la stessa di Tokio su un PC, solo senza sistema operativo.
  • p.PIN_25 viene spostato (move) in Output::new. Prova a usare p.PIN_25 una seconda volta e il compilatore ti ferma: è l’ownership applicata all’hardware.
  • use defmt_rtt as _; e use panic_probe as _; importano i crate solo per quello che preparano (il canale dei log e il gestore dei panic), non per un nome.

Il modo più rapido per vederlo lampeggiare è la cartella ufficiale degli esempi: clona il repository di Embassy, poi in examples/rp esegui cargo run --bin blinky --release. Per il tuo progetto, le dipendenze vengono dal Cargo.toml di quell’esempio:

[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 # tieni le info di debug per probe-rs; non vengono caricate sul chip

Altri due file dicono a cargo cosa compilare e come eseguirlo. .cargo/config.toml imposta il target e fa sì che cargo run carichi il programma tramite 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"

Infine copia memory.x (dove si trovano flash e RAM sul chip) e build.rs (che passa gli script del linker) dalla stessa cartella examples/rp. Poi collega la sonda ed esegui:

Terminal window
cargo run --release

cargo compila per thumbv6m-none-eabi, probe-rs carica il programma sul chip, il LED inizia a lampeggiare e i messaggi led on! / led off! scorrono nel tuo terminale con i timestamp.

Un microcontrollore non ha uno schermo, e formattare testo con core::fmt occupa molto spazio in flash. defmt (in inglese; il nome sta per «deferred formatting», formattazione differita) risolve entrambi i problemi. Le stringhe di formato restano sul tuo PC, non sul chip; il chip manda solo un piccolo ID più i valori grezzi attraverso la sonda, e probe-rs li ritrasforma in testo leggibile.

use defmt::{info, warn, Format};
#[derive(Format)] // come #[derive(Debug)], ma per 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` e `=i32` dicono a defmt il tipo esatto, e si risparmia ancora più spazio
warn!("sensor {=u8} is too hot: {=i32} mC", r.sensor_id, r.milli_celsius);
}
}

I livelli sono trace!, debug!, info!, warn! ed error!, proprio come nel crate log. L’impostazione DEFMT_LOG in .cargo/config.toml sceglie quali livelli vengono compilati; tutto quello che sta sotto costa zero byte.

Sotto ogni HAL, controllare l’hardware significa scrivere numeri nei registri: indirizzi di memoria speciali in cui ogni bit accende o spegne qualcosa nel chip. Su un chip tipico, un registro di output GPIO a 32 bit ha un bit per pin. Qui un semplice u32 fa la parte di quel registro, così puoi esercitarti con le quattro mosse che usa ogni sviluppatore embedded:

const LED: u32 = 25; // il LED integrato del Pico è sul pin 25
fn main() {
// Facciamo finta che questo u32 sia il registro di output GPIO del chip.
// Il bit 0 controlla il pin 0, il bit 1 il pin 1, e così via.
let mut gpio_out: u32 = 0b1000; // il pin 3 è già acceso
// Accendere un bit: OR con una maschera
gpio_out |= 1 << LED;
println!("LED acceso: {gpio_out:#034b}");
// Leggere un bit: AND con una maschera, poi confronto con zero
let led_is_on = (gpio_out & (1 << LED)) != 0;
let pin2_is_on = (gpio_out & (1 << 2)) != 0;
println!("LED acceso? {led_is_on}, pin 2 acceso? {pin2_is_on}");
// Invertire un bit: XOR con una maschera
gpio_out ^= 1 << LED;
println!("invertito: {gpio_out:#034b}");
// Spegnere un bit: AND con la maschera negata
gpio_out |= 1 << LED;
gpio_out &= !(1 << 3);
println!("pin 3 spento: {gpio_out:#034b}");
println!("esadecimale: {gpio_out:#010x}");
}

Output:

LED acceso: 0b00000010000000000000000000001000
LED acceso? true, pin 2 acceso? false
invertito: 0b00000000000000000000000000001000
pin 3 spento: 0b00000010000000000000000000000000
esadecimale: 0x02000000
▶ Provalo tu

In Rust, ! su un intero inverte tutti i bit (il C usa ~ per questo). {:#034b} stampa il valore in binario con il prefisso 0b e riempie fino a 32 cifre, così puoi vedere il bit 25 che va e viene. Vedi Operatori per l’elenco completo degli operatori sui bit.

Molti registri impacchettano diversi campi in un solo numero: qualche bit per un’impostazione di velocità, un bit per «abilitato». Per cambiare un campo fai un read-modify-write (leggi, modifica, scrivi): azzeri i bit del campo, poi fai OR con il nuovo valore, lasciando stare tutti gli altri bit:

// Un registro di controllo UART inventato:
// bit 0 = abilitazione
// bit 4..=6 = impostazione di velocità (un numero a 3 bit, da 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 {
// Azzera il vecchio campo, poi metti il nuovo valore.
// Tutti gli altri bit restano esattamente come erano.
(reg & !SPEED_MASK) | ((speed << SPEED_SHIFT) & SPEED_MASK)
}
fn main() {
let mut ctrl: u32 = ENABLE | (2 << SPEED_SHIFT);
println!("inizio: {ctrl:#010b}, velocità = {}", read_speed(ctrl));
ctrl = write_speed(ctrl, 5);
println!("velocità 5: {ctrl:#010b}, velocità = {}", read_speed(ctrl));
// 9 non sta in 3 bit. La maschera lo taglia invece di
// farlo debordare nei bit vicini.
ctrl = write_speed(ctrl, 9);
println!("velocità 9?: {ctrl:#010b}, velocità = {}", read_speed(ctrl));
let still_enabled = (ctrl & ENABLE) != 0;
println!("ancora abilitato: {still_enabled}");
}

Output:

inizio: 0b00100001, velocità = 2
velocità 5: 0b01010001, velocità = 5
velocità 9?: 0b00010001, velocità = 1
ancora abilitato: true
▶ Provalo tu

Nota che 9 è diventato 1 senza dire niente. È proprio il tipo di bug da cui ti proteggono PAC e HAL: danno a ogni campo un suo metodo con un nome (spesso anche con valori con un nome), quindi raramente scrivi le maschere a mano. Sull’hardware vero il registro è un indirizzo di memoria fisso, scritto con core::ptr::write_volatile dentro codice unsafe; quella parte la fa la PAC per te.

La migliore abitudine embedded che puoi imparare: scrivi la logica sui trait, non su un chip specifico. Così lo stesso codice gira sulla scheda con la HAL vera, e sul PC con componenti finti («mock») in un normale test. Qui StatusLed ha bisogno solo di «qualcosa che posso accendere e spegnere» e di «qualcosa che sa aspettare»:

use std::cell::RefCell;
use std::rc::Rc;
// Le due capacità che servono a un driver che fa lampeggiare un LED. Ricalcano
// i trait OutputPin e DelayNs di embedded-hal 1.0, semplificati per girare nel Playground.
trait OutputPin {
fn set_high(&mut self);
fn set_low(&mut self);
}
trait Delay {
fn delay_ms(&mut self, ms: u32);
}
// Il driver non sa niente di nessun chip. Usa solo i trait.
struct StatusLed<P: OutputPin, D: Delay> {
pin: P,
delay: D,
}
impl<P: OutputPin, D: Delay> StatusLed<P, D> {
// Lampeggia `times` volte: acceso per `on_ms`, poi spento per `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);
}
}
}
// --- Finti componenti per testare su un PC ---
// Entrambi scrivono nello stesso log condiviso, così vediamo l'ordine.
type Log = Rc<RefCell<Vec<String>>>;
struct MockPin {
log: Log,
}
impl OutputPin for MockPin {
fn set_high(&mut self) {
self.log.borrow_mut().push("acceso".to_string());
}
fn set_low(&mut self) {
self.log.borrow_mut().push("spento".to_string());
}
}
struct MockDelay {
log: Log,
}
impl Delay for MockDelay {
fn delay_ms(&mut self, ms: u32) {
self.log.borrow_mut().push(format!("attesa {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], "acceso");
assert_eq!(events[1], "attesa 100");
println!("tutti i controlli superati");
}

Output:

acceso, attesa 100, spento, attesa 400, acceso, attesa 100, spento, attesa 400
tutti i controlli superati
▶ Provalo tu

I due mock condividono un solo log tramite Rc<RefCell<...>> (vedi Smart pointer), così puoi controllare l’ordine esatto degli eventi. In un progetto vero le righe assert_eq! vanno in una funzione #[test] e girano con cargo test sul tuo PC, senza bisogno della scheda (vedi Test e Test avanzati).

Sulla scheda scriveresti lo stesso driver sui veri trait di embedded-hal 1.0. Le uniche differenze: i metodi dei pin restituiscono un Result (un pin su un chip di espansione I/O può fallire), quindi passi gli errori avanti con ?, e il codice non ha affatto bisogno di std:

#![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(())
}
}

L’Output di Embassy implementa OutputPin, e embassy_time::Delay implementa DelayNs, quindi StatusLed::new(led, embassy_time::Delay) funziona sul Pico. Per i test sul PC con questi trait veri, il crate embedded-hal-mock (in inglese) fornisce pin, bus I2C e SPI finti già pronti.

Molti chip piccoli, come il Cortex-M0+ dell’RP2040, non hanno un’unità in virgola mobile. I float funzionano lo stesso, ma via software: sono più lenti e rendono il programma più grande. Per questo il codice embedded usa spesso numeri a virgola fissa (fixed-point): interi in un’unità più piccola. Invece di 25.25 gradi, salvi 25250 millesimi di grado.

Un sensore di temperatura in stile TMP102 manda due byte. I 12 bit più alti sono un numero con segno in passi da 1/16 °C:

// I 12 bit più alti sono la temperatura in passi da 1/16 °C (0,0625 °C),
// come numero con segno (complemento a due).
fn to_millicelsius(bytes: [u8; 2]) -> i32 {
// i16 conserva il segno. Anche `>> 4` su un numero con segno lo conserva.
let raw = i16::from_be_bytes(bytes) >> 4;
// 1/16 °C = 62,5 m°C, quindi moltiplica per 625 e dividi per 10.
raw as i32 * 625 / 10
}
// Stampa 25250 come "25.250" senza mai toccare 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 stanza calda
[0x00, 0x10], // il passo più piccolo sopra lo zero
[0xFF, 0x80], // appena sotto zero
[0xE7, 0x00], // un congelatore
];
for bytes in samples {
let m = to_millicelsius(bytes);
println!("{bytes:02X?} -> {m:>6} m°C = {} °C", format_milli(m));
}
}

Output:

[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
▶ Provalo tu

Tre dettagli fanno funzionare tutto. i16::from_be_bytes legge i due byte come un unico numero con segno, con il byte più significativo per primo («big-endian»), che è il modo in cui la maggior parte dei sensori manda i dati. >> 4 su un intero con segno conserva il segno, quindi le temperature negative escono giuste. E format_milli gestisce il segno a parte: -500 / 1000 fa 0 nell’aritmetica intera, quindi senza quel passaggio -0,5 °C verrebbe stampato come 0.500. (Il programma usa il punto come separatore decimale, come la maggior parte del software.)

Anche la divisione intera tronca: 62,5 m°C è diventato 62. Mezzo millesimo di grado non conta per un sensore da stanza, ma chiediti sempre quanta precisione ti dà un’unità. E occhio all’intervallo: moltiplicare una lettura grande per un fattore grande può far andare in overflow un i32 (vedi Overflow degli interi). Su un PC String va benissimo; sul chip stamperesti con defmt o scriveresti in una heapless::String.

Usare cose di std in un programma no_std. println!, Vec e String vengono da std, quindi semplicemente non ci sono. Il compilatore ti dice che non trova la macro println in questo scope:

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

Usa defmt::info! per i log, e heapless (o il crate alloc con un allocatore) per le collezioni.

Dimenticare il gestore dei panic. Un binario no_std deve dire cosa fa un panic. Senza un crate come panic-probe, il compilatore si lamenta che manca una funzione #[panic_handler]:

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

Aggiungi panic-probe (o panic-halt) a Cargo.toml e use panic_probe as _; a main.rs.

Sovrascrivere un registro intero. reg = 1 << 3 accende il pin 3, e spegne tutti gli altri. Per cambiare un solo bit usa reg |= 1 << 3. Per i campi, fai il read-modify-write visto sopra.

Seguire un tutorial vecchio. I crate embedded evolvono in fretta, e gli articoli di qualche anno fa usano spesso embedded-hal 0.2 o vecchie API di Embassy. Parti dalla cartella degli esempi della versione del crate da cui dipendi davvero.

Scrivi toggle(reg: u32, pin: u32) -> u32, che inverte un bit, e pins_on(reg: u32) -> Vec<u32>, che elenca tutti i pin il cui bit vale 1. Parti con i pin 0, 5 e 25 accesi, stampa la lista e quanti pin sono accesi, poi inverti i pin 5 e 7 e stampa di nuovo. Suggerimento: controlla ogni pin da 0 a 31 con una maschera, e u32 ha un metodo count_ones().

Mostra la soluzione
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!("accesi: {:?} ({} pin)", pins_on(reg), reg.count_ones());
reg = toggle(reg, 5);
reg = toggle(reg, 7);
println!("accesi: {:?} ({} pin)", pins_on(reg), reg.count_ones());
}

Output:

accesi: [0, 5, 25] (3 pin)
accesi: [0, 7, 25] (3 pin)
▶ Provalo tu

XOR inverte un bit qualunque fosse il suo valore precedente, quindi invertire il pin 5 lo ha spento e invertire il pin 7 lo ha acceso. count_ones() conta i bit a 1 per te, quindi per quella parte non ti serve un ciclo.

Un ADC a 12 bit dà letture da 0 a 4095 per 0–3300 mV. La batteria è collegata tramite un partitore di tensione che dimezza la sua tensione, quindi la tensione reale è il doppio di quella misurata. Scrivi battery_mv(raw: u16) -> i32 e battery_percent(mv: i32) -> i32, dove 3000 mV è lo 0% e 4200 mV è il 100%, mai sotto 0 o sopra 100. Prova le letture grezze 2606, 2482, 2234 e 1861. Suggerimento: moltiplica prima di dividere, così non perdi precisione, e dai un’occhiata a clamp.

Mostra la soluzione
const EMPTY_MV: i32 = 3000;
const FULL_MV: i32 = 4200;
fn battery_mv(raw: u16) -> i32 {
// ADC a 12 bit (0..=4095) che misura 0..=3300 mV, dietro un partitore che dimezza la tensione
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!("lettura {raw} -> {mv} mV -> {}%", battery_percent(mv));
}
}

Output:

lettura 2606 -> 4200 mV -> 100%
lettura 2482 -> 4000 mV -> 83%
lettura 2234 -> 3600 mV -> 50%
lettura 1861 -> 2999 mV -> 0%
▶ Provalo tu

Dividere per 4095 alla fine conserva la precisione: raw / 4095 * 6600 darebbe 0 per ogni lettura sotto 4095. Il prodotto più grande, 4095 * 6600, è circa 27 milioni, che sta comodamente in un i32.

  • Rust embedded gira su microcontrollori senza sistema operativo. Niente garbage collector, sicurezza in compilazione e astrazioni a costo zero rendono Rust una scelta ottima.
  • #![no_std] ti lascia solo core; #![no_main] lascia che un crate di runtime chiami la tua funzione di ingresso; devi scegliere un gestore dei panic. heapless ti dà collezioni a capacità fissa.
  • Gli strati: PAC (registri), HAL (una famiglia di chip), trait di embedded-hal (condivisi), BSP (una scheda), e sopra framework come RTIC ed Embassy.
  • Aggiungi il target del chip con rustup target add, poi carica il programma e guarda i log con probe-rs e una sonda di debug (o con espflash su un ESP32). defmt tiene i log piccolissimi.
  • Esercitati sul PC: maschere di bit per i registri, driver scritti sui trait con componenti finti, e interi a virgola fissa al posto dei float.

Verifica veloce

1. Cosa fa #![no_std]?

2. Quale strato permette a un driver di sensore di funzionare su un Pico, un ESP32 e un STM32 senza modifiche?

3. Come accendi il bit 3 di reg senza cambiare nessun altro bit?

4. Perché il codice embedded spesso salva 25,25 °C come 25250 millesimi di grado?

Punteggio: 0 / 4

  • The Embedded Rust Book (in inglese): l’introduzione ufficiale a no_std, periferiche, registri e all’ecosistema embedded.
  • Discovery (micro:bit v2) (in inglese): un corso gratuito per principianti che ti porta passo dopo passo da un LED che lampeggia ai sensori, su un BBC micro:bit.
  • The Embassy Book (in inglese): come iniziare con Embassy, come funzionano il suo executor e le sue HAL, e come eseguire gli esempi ufficiali.
  • The Rust on ESP Book (in inglese): la guida di Espressif a esp-hal, esp-generate, espflash e alle toolchain per ESP32.
  • Installazione di probe-rs (in inglese): come installare probe-rs su ogni sistema operativo e configurare la tua sonda di debug.