Aller au contenu

Rust embarqué : microcontrôleurs, no_std et Embassy

Le Rust embarqué (embedded Rust), c’est écrire du Rust pour les petits ordinateurs cachés à l’intérieur des objets : un thermostat, un clavier, un drone, un capteur d’arrosage pour les plantes. Ces ordinateurs s’appellent des microcontrôleurs. Une seule puce contient le processeur, quelques centaines de kilo-octets de mémoire et les broches (pins) qui parlent aux LED, aux boutons et aux capteurs. Il n’y a généralement aucun système d’exploitation. Votre programme est le seul à tourner, de la mise sous tension jusqu’à l’extinction.

Ça peut faire peur, mais c’est aussi très amusant : vous écrivez quelques lignes, et une vraie lumière clignote sur votre bureau. Cette page vous montre ce qui est possible, quelle carte et quelles crates choisir, et comment mettre votre premier programme sur une puce. En chemin, vous pratiquerez la « façon de penser de l’embarqué » avec des exemples que vous pouvez exécuter directement dans votre navigateur.

Pendant des décennies, le code embarqué a surtout été écrit en C. Rust apporte quelques atouts qui comptent beaucoup sur une toute petite puce :

  • Pas de ramasse-miettes. Rien ne met votre programme en pause pour nettoyer la mémoire, donc les temps de réaction restent prévisibles. C’est important quand vous devez réagir à un signal en quelques microsecondes.
  • La sûreté mémoire sans runtime. Les règles d’ownership (possession) et de borrowing (emprunt) sont vérifiées à la compilation : elles ne coûtent rien sur la puce. Des familles entières de plantages (pointeurs pendants, accès concurrents entre une interruption et votre boucle principale) sont détectées avant même de flasher quoi que ce soit.
  • Des abstractions à coût zéro. Une HAL vous permet d’écrire led.set_high() au lieu de manipuler des adresses mémoire, et le résultat compilé est la même écriture unique dans un registre que celle que vous auriez écrite à la main.
  • L’ownership modélise le matériel. Une broche est une valeur. Une fois déplacée dans un Output, personne d’autre ne peut la récupérer et l’utiliser par erreur comme entrée. Le compilateur impose ce qui n’était qu’un commentaire dans un fichier C.
  • D’excellents outils. cargo, les cibles de rustup et probe-rs remplacent bien des IDE de fabricants et des makefiles.

Pour être honnête sur les points faibles : les fabricants de puces publient encore leurs SDK et leurs exemples d’abord en C, donc pour certaines puces le support de Rust est plus mince, ou seulement communautaire. Vous devrez toujours lire des datasheets. Et certaines crates sont avant la 1.0 et changent d’API entre deux versions : suivez toujours les exemples de la version que vous utilisez.

  • Capteurs et gadgets : une station météo, une alarme d’humidité du sol, un podomètre.
  • Périphériques USB : des claviers sur mesure, des manettes de jeu, des contrôleurs MIDI.
  • Objets connectés (IoT) : des capteurs Wi-Fi ou Bluetooth avec un ESP32, qui envoient leurs mesures à un serveur.
  • Robots et moteurs : suiveurs de ligne, pilotage de moteurs pas à pas et de servos, rubans de LED et écrans.
  • Des systèmes sérieux : Oxide Computer a écrit Hubris (en anglais), un petit système d’exploitation pour microcontrôleurs, entièrement en Rust. Espressif, l’entreprise derrière l’ESP32, maintient son propre support de Rust et publie The Rust on ESP Book (en anglais).

N’importe laquelle de ces cartes est un bon premier choix. Si vous hésitez, prenez une Raspberry Pi Pico : elle est bon marché, très bien documentée, et l’exemple Embassy plus bas est écrit pour elle.

Carte Puce Cible Rust Pourquoi elle est sympa Pour commencer
Raspberry Pi Pico / Pico W RP2040 (Arm Cortex-M0+) thumbv6m-none-eabi Bon marché, populaire, excellent support d’Embassy embassy-rp, ou rp2040-hal + rp-pico
Raspberry Pi Pico 2 RP2350 (Arm Cortex-M33, ou RISC-V) thumbv8m.main-none-eabihf Plus récente et plus rapide, avec une unité de calcul flottant embassy-rp (feature rp235xa), rp235x-hal
Cartes ESP32-C3 / ESP32-C6 Puces RISC-V d’Espressif riscv32imc-unknown-none-elf / riscv32imac-unknown-none-elf Wi-Fi et Bluetooth intégrés, support officiel de Rust esp-hal et l’outil esp-generate
STM32 Nucleo, « Black Pill » STM32 (par exemple STM32F4) thumbv7em-none-eabihf Immense famille de puces utilisée dans l’industrie ; les cartes Nucleo ont une sonde de débogage intégrée embassy-stm32, stm32f4xx-hal
BBC micro:bit v2 nRF52833 (Arm Cortex-M4F) thumbv7em-none-eabihf Sonde de débogage, LED, boutons et capteurs sur la carte microbit-v2, plus le livre Discovery

La micro:bit est la carte utilisée par le livre officiel Embedded Rust Discovery (en anglais), un cours gratuit et progressif pour débutants. L’ESP32 d’origine et l’ESP32-S3 utilisent des cœurs Xtensa au lieu de RISC-V ; pour ceux-là, l’outil espup d’Espressif installe une toolchain Rust spéciale. La LED de la Pico W est reliée à sa puce Wi-Fi, pas à une broche normale : elle a donc besoin d’un autre clignotant (Embassy a un exemple wifi_blinky pour elle).

Un programme Rust normal utilise la bibliothèque standard (std). std suppose un système d’exploitation : des fichiers, des threads, un tas (heap), un terminal pour println!. Un microcontrôleur n’a rien de tout ça. Les programmes embarqués commencent donc par deux lignes :

#![no_std] // ne pas lier la bibliothèque standard, seulement `core`
#![no_main] // ne pas utiliser le point d'entrée habituel `fn main()`
  • #![no_std] veut dire que vous n’avez que core (en anglais) : les entiers, Option, Result, les itérateurs, les slices, les traits, le formatage. Pas de Vec, de String, de Box, de fichiers ni de threads. (Avec un allocateur, vous pouvez retrouver Vec et compagnie grâce à la crate alloc, mais beaucoup de projets n’en ont jamais besoin.)
  • #![no_main] est nécessaire parce que sur une puce, il n’y a pas de système d’exploitation pour appeler main. Une crate de runtime comme cortex-m-rt prépare la mémoire après le reset, puis saute vers votre fonction, que vous marquez avec un attribut comme #[cortex_m_rt::entry] ou, avec Embassy, #[embassy_executor::main].
  • Vous devez aussi dire ce qui se passe en cas de panic, car il n’y a pas de terminal où afficher le message. Des crates comme panic-probe ou panic-halt s’en chargent ; vous en ajoutez une avec use panic_probe as _;.

Sans Vec, comment stocker une liste ? La crate heapless (en anglais) vous donne Vec, String et des files avec une capacité fixe choisie à la compilation. Ils vivent sur la pile ou dans une static : pas de tas, et pas de mauvaise surprise de mémoire épuisée. Un tampon plein vous rend simplement votre valeur :

use core::fmt::Write;
use heapless::{String, Vec};
fn main() {
// De la place pour exactement 4 mesures. La mémoire fait partie de la variable :
// pas de tas, pas d'allocateur, et la taille est connue à la compilation.
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}"),
}
}
// Une String d'une capacité fixe de 16 octets
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 marche aussi sur un PC : vous pouvez essayer avec cargo add [email protected] et cargo run (la crate n’est pas disponible dans le Playground) :

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

Les crates de Rust embarqué s’empilent les unes sur les autres. Vous travaillerez surtout au niveau de la HAL ou du framework, mais il est utile de savoir ce qu’il y a en dessous :

Couche Ce que c’est Exemples
PAC (Peripheral Access Crate) Générée à partir du fichier de description des registres de la puce. Une fonction plutôt sûre par registre et par champ. Très bas niveau. rp2040-pac, stm32f4, esp32c3
HAL (Hardware Abstraction Layer) Des types pratiques pour une famille de puces : Output, Uart, I2c, des timers. Construite sur une PAC. rp2040-hal, esp-hal, stm32f4xx-hal, embassy-rp, embassy-stm32
Les traits embedded-hal Des traits partagés comme OutputPin, I2c, SpiDevice et DelayNs. Les HAL les implémentent ; les drivers les utilisent. La version 1.0 est stable. embedded-hal, embedded-hal-async
BSP (Board Support Package) Une HAL, plus des noms pour les broches et composants d’une carte précise (« la LED », « le bouton A »). rp-pico, microbit-v2
RTIC Un framework pour les applications pilotées par interruptions. Les tâches sont liées à des interruptions et à des priorités, et le compilateur vérifie les données partagées à la compilation. rtic
Embassy Un framework async : un exécuteur, des timers, le réseau et l’USB, plus ses propres HAL. Vous écrivez des tâches async fn et vous faites .await sur des événements matériels. embassy-executor, embassy-time, embassy-rp

La magie est dans la rangée du milieu. Un driver de capteur de température écrit pour le trait I2c d’embedded-hal fonctionne sur une Pico, un ESP32 et un STM32 sans modification, parce que chaque HAL implémente le même trait. Vous écrirez vous-même un tel driver plus bas.

Votre PC a un processeur de bureau x86 ou Arm ; la Pico a un Cortex-M0+. Vous faites donc de la compilation croisée (cross-compiling) : compiler sur une machine, pour une autre. rustup télécharge la bibliothèque core précompilée pour la puce :

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

Pour copier le programme sur la puce et voir ses logs, il vous faut probe-rs (en anglais) et une sonde de débogage (debug probe) : un petit appareil USB qui se branche sur les broches de débogage de la puce. Les cartes micro:bit et STM32 Nucleo en ont une intégrée. Pour une Pico, utilisez une Raspberry Pi Debug Probe, ou une deuxième Pico flashée avec le firmware gratuit debugprobe. Installez probe-rs avec son script d’installation (voir la page d’installation, en anglais, pour Windows) ou avec 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
# ou, plus lent, depuis les sources :
cargo install probe-rs-tools --locked
probe-rs list # affiche les sondes qu'il détecte

Pas encore de sonde ? Maintenez le bouton BOOTSEL de la Pico pendant que vous la branchez : elle apparaît comme une clé USB. Des outils comme picotool ou elf2uf2-rs peuvent y copier votre programme de cette façon. Par contre, vous ne verrez pas les logs defmt sans sonde.

Sur un ESP32-C3 ou C6, vous n’avez généralement pas besoin de sonde du tout : la puce a l’USB intégré, et l’outil espflash la flashe par le câble USB normal. Lancez cargo install esp-generate espflash --locked, puis esp-generate, choisissez votre puce et vos options, et cargo run --release compile, flashe et affiche les logs.

Faire clignoter une LED avec Embassy sur une Raspberry Pi Pico

Section intitulée « Faire clignoter une LED avec Embassy sur une Raspberry Pi Pico »

Le « blinky » (le clignotant) est le « Hello, world! » de l’embarqué : allumer la LED, attendre, l’éteindre, attendre, pour toujours. Voici l’exemple blinky officiel d’Embassy pour le RP2040 (avec des commentaires ajoutés). Il a besoin d’une vraie carte et ne tourne pas dans le Playground.

#![no_std]
#![no_main]
use defmt::*;
use defmt_rtt as _; // envoyer les logs defmt à la sonde
use embassy_executor::Spawner;
use embassy_rp::gpio;
use embassy_time::Timer;
use gpio::{Level, Output};
use panic_probe as _; // en cas de panic : afficher le message via la sonde, puis s'arrêter
#[embassy_executor::main(executor = "embassy_rp::executor::Executor", entry = "cortex_m_rt::entry")]
async fn main(_spawner: Spawner) {
// Prendre possession de tous les périphériques de la puce, une seule fois
let p = embassy_rp::init(Default::default());
// La LED de la Pico est sur la broche 25. PIN_25 est déplacée dans `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;
}
}

Quelques points à remarquer :

  • main est une async fn. Timer::after_millis(500).await ne tourne pas dans une boucle d’attente active ; l’exécuteur endort la puce jusqu’à ce que le timer se déclenche. Avec plus de tâches (lancées via le Spawner), les autres s’exécutent pendant que celle-ci attend. Si async/await est nouveau pour vous, l’idée est la même qu’avec Tokio sur un PC, simplement sans système d’exploitation.
  • p.PIN_25 est déplacée (moved) dans Output::new. Essayez d’utiliser p.PIN_25 une deuxième fois, et le compilateur vous arrête : c’est l’ownership appliqué au matériel.
  • use defmt_rtt as _; et use panic_probe as _; importent des crates uniquement pour ce qu’elles mettent en place (le canal de logs et le gestionnaire de panic), pas pour un nom.

Le moyen le plus rapide de la voir clignoter est d’utiliser le dossier d’exemples officiel lui-même : clonez le dépôt Embassy, puis dans examples/rp, lancez cargo run --bin blinky --release. Pour votre propre projet, les dépendances viennent du Cargo.toml de cet exemple :

[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 # garder les infos de débogage pour probe-rs ; elles ne sont pas flashées sur la puce

Deux autres fichiers disent à cargo quoi compiler et comment l’exécuter. .cargo/config.toml fixe la cible et fait flasher cargo run via 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"

Enfin, copiez memory.x (où se trouvent la flash et la RAM sur la puce) et build.rs (qui transmet les scripts de l’éditeur de liens) depuis le même dossier examples/rp. Branchez ensuite votre sonde et lancez :

Terminal window
cargo run --release

cargo compile pour thumbv6m-none-eabi, probe-rs flashe la puce, la LED se met à clignoter, et les messages led on! / led off! défilent dans votre terminal avec leur horodatage.

Un microcontrôleur n’a pas d’écran, et formater du texte avec core::fmt coûte beaucoup de place en flash. defmt (en anglais), pour « deferred formatting » (formatage différé), résout les deux problèmes. Les chaînes de format sont stockées sur votre PC, pas sur la puce ; la puce n’envoie qu’un petit identifiant et les valeurs brutes via la sonde, et probe-rs les retransforme en texte lisible.

use defmt::{info, warn, Format};
#[derive(Format)] // comme #[derive(Debug)], mais pour 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` et `=i32` donnent à defmt le type exact, ce qui économise encore plus de place
warn!("sensor {=u8} is too hot: {=i32} mC", r.sensor_id, r.milli_celsius);
}
}

Les niveaux sont trace!, debug!, info!, warn! et error!, comme avec la crate log. Le réglage DEFMT_LOG dans .cargo/config.toml choisit quels niveaux sont compilés ; tout ce qui est en dessous ne coûte aucun octet.

Sous chaque HAL, piloter le matériel veut dire écrire des nombres dans des registres : des adresses mémoire spéciales où chaque bit active quelque chose sur la puce. Sur une puce typique, un registre de sortie GPIO de 32 bits a un bit par broche. Ici, un simple u32 joue le rôle de ce registre, pour que vous puissiez pratiquer les quatre gestes qu’utilise tout développeur embarqué. Les libellés affichés restent en anglais, car ils sont alignés à la main avec des espaces :

const LED: u32 = 25; // la LED intégrée de la Pico est sur la broche 25
fn main() {
// Imaginez que ce u32 est le registre de sortie GPIO de la puce.
// Le bit 0 contrôle la broche 0, le bit 1 la broche 1, et ainsi de suite.
let mut gpio_out: u32 = 0b1000; // la broche 3 est déjà allumée
// Mettre un bit à 1 : OU avec un masque
gpio_out |= 1 << LED;
println!("LED on: {gpio_out:#034b}");
// Lire un bit : ET avec un masque, puis comparer à zéro
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}");
// Inverser un bit : OU exclusif (XOR) avec un masque
gpio_out ^= 1 << LED;
println!("toggled: {gpio_out:#034b}");
// Mettre un bit à 0 : ET avec le masque inversé
gpio_out |= 1 << LED;
gpio_out &= !(1 << 3);
println!("pin 3 off: {gpio_out:#034b}");
println!("as hex: {gpio_out:#010x}");
}

Sortie :

LED on: 0b00000010000000000000000000001000
LED on? true, pin 2 on? false
toggled: 0b00000000000000000000000000001000
pin 3 off: 0b00000010000000000000000000000000
as hex: 0x02000000
▶ Essayez par vous-même

En Rust, ! sur un entier inverse tous les bits (le C utilise ~ pour ça). {:#034b} affiche la valeur en binaire avec le préfixe 0b et des zéros jusqu’à 32 chiffres : vous voyez ainsi le bit 25 apparaître et disparaître. Voir Les opérateurs pour la liste complète des opérateurs bit à bit.

Beaucoup de registres regroupent plusieurs champs dans un seul nombre : quelques bits pour un réglage de vitesse, un bit pour « activé ». Pour changer un champ, vous faites une lecture-modification-écriture (read-modify-write) : effacer les bits du champ, puis y mettre la nouvelle valeur avec un OU, sans toucher aux autres bits :

// Un registre de contrôle UART inventé :
// bit 0 = activation
// bits 4..=6 = réglage de vitesse (un nombre sur 3 bits, de 0 à 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 {
// Effacer l'ancien champ, puis y placer la nouvelle valeur.
// Tous les autres bits restent exactement comme avant.
(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 ne tient pas sur 3 bits. Le masque le tronque au lieu
// de déborder sur les bits voisins.
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}");
}

Sortie :

start: 0b00100001, speed = 2
speed 5: 0b01010001, speed = 5
speed 9?: 0b00010001, speed = 1
still enabled: true
▶ Essayez par vous-même

Remarquez que 9 est devenu 1 sans prévenir. C’est exactement le genre de bug dont les PAC et les HAL vous protègent : elles donnent à chaque champ sa propre méthode nommée (souvent avec des valeurs nommées aussi), donc vous écrivez rarement des masques à la main. Sur du vrai matériel, le registre est une adresse mémoire fixe, écrite avec core::ptr::write_volatile dans du code unsafe ; la PAC s’occupe de cette partie pour vous.

La meilleure habitude à prendre en embarqué : écrivez votre logique pour des traits, pas pour une puce précise. Le même code tourne alors sur la carte avec la vraie HAL, et sur votre PC avec de faux composants (mocks) dans un test normal. Ici, StatusLed a seulement besoin de « quelque chose que je peux allumer et éteindre » et de « quelque chose qui sait attendre » :

use std::cell::RefCell;
use std::rc::Rc;
// Les deux capacités dont a besoin un driver de clignotement. Elles reprennent les traits
// OutputPin et DelayNs d'embedded-hal 1.0, simplifiés pour tourner dans le Playground.
trait OutputPin {
fn set_high(&mut self);
fn set_low(&mut self);
}
trait Delay {
fn delay_ms(&mut self, ms: u32);
}
// Le driver ne sait rien d'aucune puce. Il n'utilise que les traits.
struct StatusLed<P: OutputPin, D: Delay> {
pin: P,
delay: D,
}
impl<P: OutputPin, D: Delay> StatusLed<P, D> {
// Clignoter `times` fois : allumée pendant `on_ms`, puis éteinte pendant `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);
}
}
}
// --- Des faux pour tester sur un PC ---
// Les deux faux écrivent dans le même journal partagé, pour qu'on voie l'ordre.
type Log = Rc<RefCell<Vec<String>>>;
struct MockPin {
log: Log,
}
impl OutputPin for MockPin {
fn set_high(&mut self) {
self.log.borrow_mut().push("allumée".to_string());
}
fn set_low(&mut self) {
self.log.borrow_mut().push("éteinte".to_string());
}
}
struct MockDelay {
log: Log,
}
impl Delay for MockDelay {
fn delay_ms(&mut self, ms: u32) {
self.log.borrow_mut().push(format!("attente {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], "allumée");
assert_eq!(events[1], "attente 100");
println!("toutes les vérifications ont réussi");
}

Sortie :

allumée, attente 100, éteinte, attente 400, allumée, attente 100, éteinte, attente 400
toutes les vérifications ont réussi
▶ Essayez par vous-même

Les deux mocks partagent un même journal grâce à Rc<RefCell<...>> (voir Pointeurs intelligents), ce qui permet de vérifier l’ordre exact des événements. Dans un vrai projet, les lignes assert_eq! vont dans une fonction #[test] et s’exécutent avec cargo test sur votre PC, sans carte (voir Les tests et Tests avancés).

Sur la carte, vous écririez le même driver avec les vrais traits d’embedded-hal 1.0. Les seuls changements : les méthodes des broches renvoient un Result (une broche sur une puce d’extension d’E/S peut échouer), donc vous propagez les erreurs avec ?, et le code n’a pas du tout besoin de 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 d’Embassy implémente OutputPin, et embassy_time::Delay implémente DelayNs : StatusLed::new(led, embassy_time::Delay) fonctionne donc sur la Pico. Pour tester sur PC avec ces vrais traits, la crate embedded-hal-mock (en anglais) fournit des fausses broches et des faux bus I2C et SPI prêts à l’emploi.

Beaucoup de petites puces, comme le Cortex-M0+ du RP2040, n’ont pas d’unité de calcul flottant. Les flottants marchent quand même, mais en logiciel : plus lentement, et ils grossissent votre programme. Le code embarqué utilise donc souvent des nombres en virgule fixe (fixed-point) : des entiers dans une unité plus petite. Au lieu de 25.25 degrés, vous stockez 25250 millidegrés.

Un capteur de température du type TMP102 envoie deux octets. Les 12 bits du haut sont un nombre signé, par pas de 1/16 °C :

// Les 12 bits du haut sont la température par pas de 1/16 °C (0,0625 °C),
// sous forme de nombre signé (complément à deux).
fn to_millicelsius(bytes: [u8; 2]) -> i32 {
// i16 garde le signe. `>> 4` sur un nombre signé le garde aussi.
let raw = i16::from_be_bytes(bytes) >> 4;
// 1/16 °C = 62,5 m°C, donc on multiplie par 625 et on divise par 10.
raw as i32 * 625 / 10
}
// Afficher 25250 sous la forme "25.250" sans jamais toucher à un flottant.
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], // une pièce chaude
[0x00, 0x10], // le plus petit pas au-dessus de zéro
[0xFF, 0x80], // juste en dessous de zéro
[0xE7, 0x00], // un congélateur
];
for bytes in samples {
let m = to_millicelsius(bytes);
println!("{bytes:02X?} -> {m:>6} m°C = {} °C", format_milli(m));
}
}

Sortie :

[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
▶ Essayez par vous-même

Trois détails font que ça marche. i16::from_be_bytes lit les deux octets comme un seul nombre signé, octet de poids fort en premier (« big-endian »), ce qui est la façon dont la plupart des capteurs envoient leurs données. >> 4 sur un entier signé garde le signe, donc les températures négatives sortent correctement. Et format_milli gère le signe à part : -500 / 1000 vaut 0 en calcul entier, donc sans cette étape, -0.5 °C s’afficherait 0.500. (Le programme utilise un point décimal, à l’anglaise.)

La division entière tronque aussi : 62,5 m°C est devenu 62. Un demi-millidegré n’a aucune importance pour un capteur d’ambiance, mais demandez-vous toujours quelle précision une unité vous donne. Et surveillez la plage de valeurs : multiplier une grande mesure par un grand facteur peut faire déborder un i32 (voir Le dépassement d’entier). Sur un PC, String convient très bien ; sur la puce, vous afficheriez avec defmt ou vous écririez dans une heapless::String.

Utiliser des éléments de std dans un programme no_std. println!, Vec et String viennent de std, donc ils ne sont tout simplement pas là. Le compilateur vous dit qu’il ne trouve pas la macro println :

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

Utilisez defmt::info! pour les logs, et heapless (ou la crate alloc avec un allocateur) pour les collections.

Oublier le gestionnaire de panic. Un binaire no_std doit dire ce que fait un panic. Sans une crate comme panic-probe, vous obtenez cette erreur (une fonction #[panic_handler] est obligatoire, mais introuvable) :

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

Ajoutez panic-probe (ou panic-halt) à Cargo.toml et use panic_probe as _; à main.rs.

Écraser tout un registre. reg = 1 << 3 allume la broche 3, et éteint toutes les autres. Pour changer un seul bit, utilisez reg |= 1 << 3. Pour les champs, faites la lecture-modification-écriture vue plus haut.

Suivre un vieux tutoriel. Les crates embarquées évoluent vite, et les articles de blog d’il y a quelques années utilisent souvent embedded-hal 0.2 ou d’anciennes API d’Embassy. Partez du dossier d’exemples de la version de la crate dont vous dépendez réellement.

Écrivez toggle(reg: u32, pin: u32) -> u32, qui inverse un bit, et pins_on(reg: u32) -> Vec<u32>, qui liste toutes les broches dont le bit vaut 1. Commencez avec les broches 0, 5 et 25 allumées, affichez la liste et le nombre de broches allumées, puis inversez les broches 5 et 7 et affichez à nouveau. Indice : testez chaque broche de 0 à 31 avec un masque, et u32 a une méthode count_ones().

Voir la solution
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!("allumées : {:?} ({} broches)", pins_on(reg), reg.count_ones());
reg = toggle(reg, 5);
reg = toggle(reg, 7);
println!("allumées : {:?} ({} broches)", pins_on(reg), reg.count_ones());
}

Sortie :

allumées : [0, 5, 25] (3 broches)
allumées : [0, 7, 25] (3 broches)
▶ Essayez par vous-même

Le XOR inverse un bit quelle que soit son ancienne valeur : inverser la broche 5 l’a donc éteinte, et inverser la broche 7 l’a allumée. count_ones() compte les bits à 1 pour vous, donc pas besoin de boucle pour cette partie.

Un ADC (convertisseur analogique-numérique) de 12 bits donne des mesures de 0 à 4095 pour 0 à 3300 mV. La batterie est branchée via un pont diviseur qui divise sa tension par deux : la vraie tension est donc le double de celle mesurée. Écrivez battery_mv(raw: u16) -> i32 et battery_percent(mv: i32) -> i32, où 3000 mV correspond à 0 % et 4200 mV à 100 %, jamais en dessous de 0 ni au-dessus de 100. Essayez les mesures brutes 2606, 2482, 2234 et 1861. Indice : multipliez avant de diviser, pour ne pas perdre en précision, et regardez du côté de clamp.

Voir la solution
const EMPTY_MV: i32 = 3000;
const FULL_MV: i32 = 4200;
fn battery_mv(raw: u16) -> i32 {
// ADC 12 bits (0..=4095) qui mesure 0..=3300 mV, derrière un pont diviseur qui divise la tension par deux
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!("brut {raw} -> {mv} mV -> {} %", battery_percent(mv));
}
}

Sortie :

brut 2606 -> 4200 mV -> 100 %
brut 2482 -> 4000 mV -> 83 %
brut 2234 -> 3600 mV -> 50 %
brut 1861 -> 2999 mV -> 0 %
▶ Essayez par vous-même

Diviser par 4095 en dernier préserve la précision : raw / 4095 * 6600 donnerait 0 pour toute mesure inférieure à 4095. Le plus grand produit, 4095 * 6600, vaut environ 27 millions, ce qui tient largement dans un i32.

  • Le Rust embarqué tourne sur des microcontrôleurs sans système d’exploitation. L’absence de ramasse-miettes, la sûreté vérifiée à la compilation et les abstractions à coût zéro en font un excellent choix.
  • #![no_std] ne vous laisse que core ; #![no_main] permet à une crate de runtime d’appeler votre fonction d’entrée ; vous devez choisir un gestionnaire de panic. heapless vous donne des collections à capacité fixe.
  • Les couches : PAC (les registres), HAL (une famille de puces), traits embedded-hal (partagés), BSP (une carte), et par-dessus des frameworks comme RTIC et Embassy.
  • Ajoutez la cible de la puce avec rustup target add, puis flashez et lisez les logs avec probe-rs et une sonde de débogage (ou espflash sur un ESP32). defmt garde des logs tout petits.
  • Entraînez-vous sur votre PC : masques de bits pour les registres, drivers écrits pour des traits avec de faux composants, et entiers en virgule fixe au lieu des flottants.

Petit quiz

1. Que fait #![no_std] ?

2. Quelle couche permet à un même driver de capteur de fonctionner sans modification sur une Pico, un ESP32 et un STM32 ?

3. Comment mettre à 1 le bit 3 de reg sans changer aucun autre bit ?

4. Pourquoi le code embarqué stocke-t-il souvent 25,25 °C sous la forme de 25250 millidegrés ?

Score : 0 / 4

  • The Embedded Rust Book (en anglais) : l’introduction officielle à no_std, aux périphériques, aux registres et à l’écosystème embarqué.
  • Discovery (micro:bit v2) (en anglais) : un cours gratuit pour débutants qui vous emmène pas à pas d’une LED qui clignote jusqu’aux capteurs, sur une BBC micro:bit.
  • The Embassy Book (en anglais) : bien démarrer avec Embassy, comment fonctionnent son exécuteur et ses HAL, et comment lancer les exemples officiels.
  • The Rust on ESP Book (en anglais) : le guide d’Espressif sur esp-hal, esp-generate, espflash et les toolchains ESP32.
  • Installation de probe-rs (en anglais) : comment installer probe-rs sur chaque système d’exploitation et configurer votre sonde de débogage.