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.
Perché Rust è così adatto all’embedded
Sezione intitolata “Perché Rust è così adatto all’embedded”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 dirustupeprobe-rssostituiscono 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.
Cosa puoi costruire
Sezione intitolata “Cosa puoi costruire”- 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).
Schede facili per iniziare
Sezione intitolata “Schede facili per iniziare”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).
no_std e no_main spiegati
Sezione intitolata “no_std e no_main spiegati”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 solocore(in inglese): interi,Option,Result, iteratori, slice, trait, formattazione. NienteVec,String,Box, file o thread. (Con un allocatore puoi riavereVece compagnia tramite il cratealloc, ma a molti progetti non serve mai.)#![no_main]serve perché su un chip non c’è un sistema operativo che chiamimain. Un crate di runtime comecortex-m-rtprepara 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-probeopanic-haltse ne occupano; ne aggiungi uno conuse 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 215stored 218stored 221stored 224buffer full, dropped 227avg=219 (capacity 16)L’ecosistema a strati
Sezione intitolata “L’ecosistema a strati”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.
Installa un target e probe-rs
Sezione intitolata “Installa un target e probe-rs”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:
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, STM32F4Per 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:
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 vedeNon 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 con Embassy su un Raspberry Pi Pico
Sezione intitolata “Blinky con Embassy su un Raspberry Pi Pico”«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 sondause 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è unaasync fn.Timer::after_millis(500).awaitnon gira a vuoto in un ciclo; l’executor mette il chip a dormire finché il timer non scatta. Con più task (avviati tramite loSpawner), 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_25viene spostato (move) inOutput::new. Prova a usarep.PIN_25una seconda volta e il compilatore ti ferma: è l’ownership applicata all’hardware.use defmt_rtt as _;euse 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 chipAltri 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:
cargo run --releasecargo 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.
Log con defmt
Sezione intitolata “Log con defmt”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 defmtpub 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.
Pensare per registri: bit e maschere
Sezione intitolata “Pensare per registri: bit e maschere”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: 0b00000010000000000000000000001000LED acceso? true, pin 2 acceso? falseinvertito: 0b00000000000000000000000000001000pin 3 spento: 0b00000010000000000000000000000000esadecimale: 0x02000000In 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à = 2velocità 5: 0b01010001, velocità = 5velocità 9?: 0b00010001, velocità = 1ancora abilitato: trueNota 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.
Scrivi un driver che puoi testare sul PC
Sezione intitolata “Scrivi un driver che puoi testare sul PC”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 400tutti i controlli superatiI 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.
Calcoli sui sensori senza float
Sezione intitolata “Calcoli sui sensori senza float”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 °CTre 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.
Errori comuni
Sezione intitolata “Errori comuni”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 foundAggiungi 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.
Pratica
Sezione intitolata “Pratica”1. Quali pin sono accesi?
Sezione intitolata “1. Quali pin sono accesi?”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)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.
2. Livello della batteria senza float
Sezione intitolata “2. Livello della batteria senza float”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%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.
Riepilogo
Sezione intitolata “Riepilogo”- 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 solocore;#![no_main]lascia che un crate di runtime chiami la tua funzione di ingresso; devi scegliere un gestore dei panic.heaplessti 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 conespflashsu un ESP32).defmttiene 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
Sezione intitolata “Verifica veloce”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
Per approfondire
Sezione intitolata “Per approfondire”- 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,espflashe 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.