Une introduction pratique à l'écriture de firmware Rust pour la carte ARM BBC micro:bit, de l'installation de la toolchain jusqu'à l'affichage d'un cœur sur la matrice LED
À Ph0wn 2026, une conférence CTF axée sur la sécurité hardware et embarquée, Cyril Marpaud a animé un workshop pratique sur l’écriture de Rust embarqué pour le BBC micro:bit. Les supports du lab sont disponibles publiquement sur son GitLab : gitlab.com/cyril-marpaud/ph0wn.
J’avais utilisé Rust il y a environ trois ans et j’étais assez rouillé depuis. Ce workshop était une bonne occasion de me remettre à niveau avec un objectif concret : afficher un cœur sur la matrice LED du micro:bit, entièrement en Rust, au niveau des registres.
Cet article documente la mise en place et les étapes pour y arriver.

Le micro:bit est un petit ordinateur monocarte conçu au Royaume-Uni, initialement pour enseigner la programmation aux enfants. Il est aujourd’hui disponible au grand public dans de nombreux pays et s’est imposé dans les communautés maker et embarqué.
Malgré ses origines éducatives, le hardware est réellement capable : la carte v2 embarque un ARM Cortex-M4 (Nordic nRF52833) avec Bluetooth, une suite de capteurs intégrés et un debugger USB à bord. Aucune sonde externe n’est nécessaire pour flasher ou déboguer le code.
nRF est la famille de microcontrôleurs de Nordic Semiconductor. Le nRF52833 est la puce spécifique utilisée sur le micro:bit v2.
Le développement embarqué traditionnel se fait en C. Rust offre le même niveau de contrôle sur le hardware avec un avantage clé : le compilateur détecte des catégories entières de bugs à la compilation (déréférencements de pointeurs nuls, dépassements de buffer, data races) sans coût à l’exécution.
Pour les systèmes embarqués, c’est particulièrement important : il n’y a pas d’OS pour intercepter les crashes, pas d’allocateur mémoire pour absorber les erreurs, et les bugs peuvent être silencieux ou destructeurs. Le modèle d’ownership de Rust transforme les accès mémoire non sûrs en erreurs de compilation plutôt qu’en surprises à l’exécution.
L’écosystème Rust embarqué couvre toute la pile. embedded-hal définit des traits d’abstraction hardware (SPI, I2C, UART) qu’implémentent les crates de drivers, rendant les drivers portables entre puces. nrf52833-pac est le PAC (Peripheral Access Crate) pour le nRF52833, généré depuis le fichier SVD de Nordic. Il donne un accès direct aux registres et se situe un niveau en dessous d’une HAL (Hardware Abstraction Layer) complète, qui ajouterait une API de plus haut niveau par-dessus. probe-rs gère le flash et le debug via SWD grâce au DAPLink intégré à la carte.
L’objectif de cette section est d’obtenir un projet Rust minimal qui compile et s’exécute sur le micro:bit. Pas encore de périphériques, juste la preuve que la toolchain fonctionne de bout en bout.
rustup-init -yCette commande installe rustup, rustc et cargo avec les paramètres par défaut. Rechargez votre shell ensuite (source ~/.cargo/env) ou ouvrez un nouveau terminal.
cargo new microbit --bin
cd microbitLe nRF52833 est un Cortex-M4 avec FPU hardware. Rust a besoin de la target de cross-compilation correspondante :
rustup target add thumbv7em-none-eabihfLe fichier .cargo/config.toml définit la target de build par défaut pour le projet, afin que Cargo sache quelle toolchain utiliser sans avoir à la préciser à chaque commande. Il indique aussi à Cargo d’utiliser probe-rs comme runner, ce qui signifie que cargo run compilera, flashera la carte et démarrera l’exécution automatiquement.
.cargo/config.toml :
[build]
target = "thumbv7em-none-eabihf"
[target.thumbv7em-none-eabihf]
runner = "probe-rs run --chip nRF52833_xxAA"Le workshop travaille au niveau PAC (Peripheral Access Crate) plutôt qu’avec une crate de support de carte de plus haut niveau. Le PAC donne un accès direct aux registres hardware, ce qui est plus proche de la façon dont fonctionne le code C embarqué et facilite la compréhension de ce qui se passe réellement sur la puce.
[dependencies]
nrf52833-pac = "0.12"
cortex-m-rt = "0.7"
cortex-m = { version = "0.7", features = ["critical-section-single-core"] }cortex-m-rt fournit le runtime pour les targets Cortex-M (reset handler, table des interruptions). cortex-m est la crate d’accès bas niveau aux périphériques Cortex-M. La feature critical-section-single-core fournit une implémentation de section critique : une région de code qui ne peut pas être interrompue, implémentée ici en désactivant temporairement les interruptions. Elle est requise à l’édition de liens par le PAC et par les crates de logs ajoutées plus tard. Sans elle, le build échoue avec une erreur de symbole manquant.
cortex-m-rt nécessite deux fichiers pour lier le firmware correctement.
memory.x indique au linker où se trouvent la flash et la RAM sur le nRF52833 :
MEMORY
{
FLASH : ORIGIN = 0x00000000, LENGTH = 512K
RAM : ORIGIN = 0x20000000, LENGTH = 128K
}build.rs est un build script Rust que Cargo exécute avant la compilation. Il copie memory.x dans le répertoire de sortie du build et demande à Cargo de passer le linker script link.x (fourni par cortex-m-rt) au linker :
println!("cargo:rustc-link-arg=-Tlink.x");Sans link.x, le linker ne sait pas comment disposer la table des vecteurs d’interruption, la pile et les sections de code pour Cortex-M. Sans memory.x, il ne connaît pas la carte mémoire de la puce.
Les deux fichiers sont disponibles dans le dépôt nrf-hal :
wget https://raw.githubusercontent.com/nrf-rs/nrf-hal/refs/heads/master/nrf52833-hal/build.rs
wget https://raw.githubusercontent.com/nrf-rs/nrf-hal/refs/heads/master/nrf52833-hal/memory.xSi vous ciblez une autre puce nRF, choisissez le dossier correspondant dans nrf-rs/nrf-hal.
La structure du projet est maintenant :
microbit/
├── .cargo/
│ └── config.toml
├── src/
│ └── main.rs
├── build.rs
├── memory.x
└── Cargo.tomlÉcrire un programme Rust bare-metal est différent d’un binaire Rust standard. Sur un PC, l’OS configure la pile, appelle main et gère les panics. Sur un microcontrôleur, rien de tout cela n’existe. Tout doit être déclaré explicitement.
#![no_std] désactive la bibliothèque standard, qui suppose un OS en dessous. Il ne reste que core, le sous-ensemble de la bibliothèque standard Rust indépendant de l’OS. #![no_main] indique au compilateur qu’on ne fournira pas de symbole main conventionnel. À la place, la macro #[entry] de cortex-m-rt définit le reset handler vers lequel le CPU saute au démarrage et enregistre tous les handlers d’interruptions par défaut attendus par le linker (dont DefaultHandler_). Sans elle, le build échoue avec symbol not found: DefaultHandler_. La fonction d’entrée doit retourner ! car il n’y a rien vers quoi retourner.
Sans std, il n’y a pas non plus de comportement de panic par défaut. Le compilateur exige une fonction #[panic_handler]. Pour l’instant, une boucle infinie suffit.
// src/main.rs
#![no_std]
#![no_main]
use cortex_m_rt::entry;
use core::panic::PanicInfo;
#[entry]
fn main() -> ! {
loop {}
}
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
loop {}
}Build et flash avec :
cargo runLa carte démarre et boucle silencieusement. Aucune sortie n’est attendue à ce stade. Si cargo run se termine sans erreur, la toolchain complète fonctionne correctement.
En bare-metal il n’y a pas de stdout ni de terminal. Pour obtenir des sorties de la carte, l’approche standard est RTT (Real-Time Transfer), un protocole qui transmet des données via la connexion de debug SWD déjà utilisée par probe-rs. Aucun câblage supplémentaire n’est nécessaire.
Ajout des crates requises :
cargo add defmt rtt-target
cargo add panic-probe --features print-rttrtt-target gère le transport RTT bas niveau. defmt est un framework de formatage différé : il déplace le travail de formatage des chaînes vers l’hôte plutôt que le microcontrôleur, ce qui garde le firmware compact. panic-probe remplace la boucle #[panic_handler] manuelle : il envoie le message de panic via RTT avant de s’arrêter, si bien qu’au lieu d’un freeze silencieux vous obtenez l’emplacement exact du panic dans le terminal.
Mise à jour de main.rs :
use cortex_m as _;
use cortex_m_rt::entry;
use panic_probe as _;
#[entry]
fn main() -> ! {
rtt_target::rtt_init_print!();
rtt_target::rprintln!("Hello, World!");
loop {}
}use cortex_m as _ charge l’implémentation de critical-section sans importer de noms. use panic_probe as _ enregistre le panic handler RTT. La syntaxe as _ importe uniquement les effets de bord de la crate sans polluer l’espace de noms.
rtt_init_print!() initialise le canal RTT une fois au démarrage. rprintln! fonctionne comme println! mais envoie la sortie via la connexion de debug. Quand vous lancez cargo run, probe-rs lit le flux RTT et l’affiche automatiquement dans le terminal.
L’objectif est d’afficher un cœur sur la matrice LED 5x5. Pour y arriver, il faut d’abord comprendre comment la matrice est câblée, puis comment contrôler les GPIO au niveau des registres, puis comment fonctionne le multiplexage.
Le micro:bit v2 possède une matrice LED 5x5 de 25 LEDs disposées en 5 lignes et 5 colonnes. Les LEDs ne sont pas câblées individuellement sur 25 pins GPIO. La matrice est multiplexée : 5 pins de ligne (ROW) et 5 pins de colonne (COL) contrôlent toute la grille.

Les anodes des LEDs sont connectées aux lignes, et les cathodes aux colonnes. Une LED conduit le courant uniquement quand son anode est à une tension plus élevée que sa cathode. Appliquer une tension en sens inverse la bloque. Pour allumer une LED spécifique, son pin ROW doit être mis à l’état haut (1) et son pin COL à l’état bas (0). Toute autre combinaison laisse la LED éteinte.
Le schéma (PDF) montre la matrice étiquetée ROWx et COLx. En croisant avec la documentation PAC, on obtient les correspondances GPIO :
| Signal | GPIO |
|---|---|
| ROW1 | P0.21 |
| ROW2 | P0.22 |
| ROW3 | P0.15 |
| ROW4 | P0.24 |
| ROW5 | P0.19 |
| COL1 | P0.28 |
| COL2 | P0.11 |
| COL3 | P0.31 |
| COL4 | P1.05 |
| COL5 | P0.30 |
À noter que COL4 est sur le port P1 (un second port GPIO), tandis que toutes les autres pins sont sur P0.
Le PAC expose le port GPIO via le périphérique P0 (et P1). Le register block contient :
pub struct RegisterBlock {
pub out: OUT, // définit la valeur de sortie de toutes les pins d'un coup
pub outset: OUTSET, // met des pins à l'état haut atomiquement (écrire 1 pour activer)
pub outclr: OUTCLR, // met des pins à l'état bas atomiquement (écrire 1 pour désactiver)
pub in_: IN, // lit les valeurs d'entrée
pub dir: DIR, // définit la direction de toutes les pins d'un coup
pub dirset: DIRSET, // configure des pins en sortie atomiquement
pub dirclr: DIRCLR, // configure des pins en entrée atomiquement
pub pin_cnf: [PIN_CNF; 32], // configuration par pin
// ...
}Avant d’écrire sur une pin, elle doit être configurée en sortie via pin_cnf, sinon l’écriture n’a aucun effet. OUTSET et OUTCLR sont préférés à OUT pour basculer des pins individuelles : ils sont atomiques, donc écrire un 1 sur une position de bit n’affecte que cette pin sans risquer de race condition sur les autres.
Le PAC expose ces registres via une API basée sur des closures :
p.P0.pin_cnf[21].write(|w| w.dir().output()); // configurer la pin 21 en sortie
p.P0.outset.write(|w| unsafe { w.bits(1 << 21) }); // mettre la pin 21 à l'état haut
Le pattern write(|w| ...) est spécifique au code généré par le PAC. La closure reçoit un proxy d’écriture qui n’expose que les champs valides pour ce registre, rendant les écritures invalides impossibles à la compilation.
Avant d’attaquer la matrice complète, on allume une seule LED pour vérifier le câblage. On configure ROW1 (P0.21) et COL1 (P0.28) en sortie, puis on met ROW1 à l’état haut. COL1 reste à l’état bas par défaut, ce qui allume la première LED.
#![no_std]
#![no_main]
use cortex_m_rt::entry;
use cortex_m as _;
use panic_probe as _;
use nrf52833_pac::Peripherals;
#[entry]
fn main() -> ! {
let p = Peripherals::take().unwrap();
// Configure ROW1 and COL1 as outputs
p.P0.pin_cnf[21].write(|w| w.dir().output()); // ROW1
p.P0.pin_cnf[28].write(|w| w.dir().output()); // COL1
// ROW1 high, COL1 stays low -> LED on
p.P0.out.write(|w| w.pin21().high());
loop {}
}Peripherals::take() retourne le singleton qui possède tous les périphériques hardware. Il ne peut être appelé qu’une seule fois ; un second appel retourne None. C’est la façon dont Rust garantit à la compilation qu’aucune partie du code ne peut avoir un accès mutable simultané au même périphérique.
L’objectif est maintenant d’afficher un cœur. Le motif cœur sous forme d’un tableau booléen de 25 éléments (row-major, de haut en bas, de gauche à droite) :
let heart = [
false, true, false, true, false, // . * . * .
true, true, true, true, true, // * * * * *
true, true, true, true, true, // * * * * *
false, true, true, true, false, // . * * * .
false, false, true, false, false, // . . * . .
];Une approche naïve itère les 25 cellules et configure chaque pin de ligne et de colonne en conséquence :
fn set_led_board(p: &Peripherals, tab: [bool; 25]) {
let rows = [21, 22, 15, 24, 19];
let cols = [28, 11, 31, 0, 30];
for row in 0..5 {
for col in 0..5 {
let cell = tab[row * 5 + col];
if cell {
p.P0.outset.write(|w| unsafe { w.bits(1 << rows[row]) });
} else {
p.P0.outclr.write(|w| unsafe { w.bits(1 << rows[row]) });
}
if col == 3 {
if cell { p.P1.outclr.write(|w| unsafe { w.bits(1 << 5) }); }
else { p.P1.outset.write(|w| unsafe { w.bits(1 << 5) }); }
} else {
if cell { p.P0.outclr.write(|w| unsafe { w.bits(1 << cols[col]) }); }
else { p.P0.outset.write(|w| unsafe { w.bits(1 << cols[col]) }); }
}
}
}
}Cela ne fonctionne pas correctement. Le résultat sur la carte n’est pas un cœur :
* * * * *
* * * * *
* * | * *
* * | * *
* * * * *Le problème est que les pins de ligne et de colonne sont partagées sur toute la matrice. Chaque pin se retrouve dans l’état laissé par la dernière cellule qui l’a touchée dans la boucle. Une colonne mise à l’état bas pour une LED est ensuite réécrite à l’état haut quand la cellule suivante sur cette colonne est éteinte. L’état final n’est pas le motif voulu.
Pour afficher une image stable sur une matrice multiplexée, la technique est le row scanning : activer une ligne à la fois, configurer les pins de colonne pour les pixels de cette ligne, maintenir brièvement, puis passer à la ligne suivante. Cycler sur les 5 lignes assez vite (au-dessus de ~50 Hz) fait percevoir à l’œil humain une image complète et stable.
cortex_m::asm::delay(n) exécute n cycles CPU de busy-wait. Sur le nRF52833 à 64 MHz, 10 000 cycles représente environ 156 µs par ligne, soit approximativement 1280 frames complètes par seconde. Bien au-dessus du seuil de persistance de la vision.
#![no_std]
#![no_main]
use cortex_m_rt::entry;
use cortex_m as _;
use panic_probe as _;
use nrf52833_pac::Peripherals;
fn init_leds(p: &Peripherals) {
// row init
p.P0.pin_cnf[21].write(|w| w.dir().output());
p.P0.pin_cnf[22].write(|w| w.dir().output());
p.P0.pin_cnf[15].write(|w| w.dir().output());
p.P0.pin_cnf[24].write(|w| w.dir().output());
p.P0.pin_cnf[19].write(|w| w.dir().output());
// col init
p.P0.pin_cnf[28].write(|w| w.dir().output());
p.P0.pin_cnf[11].write(|w| w.dir().output());
p.P0.pin_cnf[31].write(|w| w.dir().output());
p.P1.pin_cnf[05].write(|w| w.dir().output());
p.P0.pin_cnf[30].write(|w| w.dir().output());
}
fn set_led_board(p: &Peripherals, tab: [bool; 25]) {
let rows = [21u32, 22, 15, 24, 19];
let cols = [28u32, 11, 31, 0, 30]; // index 3 (COL4) is on P1, handled separately below
loop {
for row in 0..5 {
// 1. Turn off all rows
for r in rows {
p.P0.outclr.write(|w| unsafe { w.bits(1 << r) });
}
// 2. Set column pins for this row
for col in 0..5 {
let cell = tab[row * 5 + col];
if col == 3 {
// COL4 is on P1
if cell { p.P1.outclr.write(|w| unsafe { w.bits(1 << 5) }); }
else { p.P1.outset.write(|w| unsafe { w.bits(1 << 5) }); }
} else {
// COL1, COL2, COL3, COL5 are on P0
if cell { p.P0.outclr.write(|w| unsafe { w.bits(1 << cols[col]) }); }
else { p.P0.outset.write(|w| unsafe { w.bits(1 << cols[col]) }); }
}
}
// 3. Turn on this row
p.P0.outset.write(|w| unsafe { w.bits(1 << rows[row]) });
// 4. Hold briefly
cortex_m::asm::delay(10_000);
}
}
}
#[entry]
fn main() -> ! {
let heart = [
false, true, false, true, false, // . * . * .
true, true, true, true, true, // * * * * *
true, true, true, true, true, // * * * * *
false, true, true, true, false, // . * * * .
false, false, true, false, false, // . . * . .
];
let p = Peripherals::take().unwrap();
init_leds(&p);
set_led_board(&p, &heart);
loop {}
}set_led_board ne retourne jamais puisque la boucle de scanning tourne indéfiniment. Pour chaque ligne : éteindre toutes les lignes, configurer les pins de colonne, allumer la ligne courante, attendre 156 µs. Le cœur s’affiche maintenant correctement sur la carte.

Références utiles :
En partant d’un projet vide, nous avons mis en place une toolchain Rust embarquée complète pour le BBC micro:bit, obtenu des sorties via RTT, et travaillé la matrice LED depuis les premiers principes, en comprenant le hardware de multiplexage, l’API des registres GPIO, et la technique de row scanning nécessaire pour afficher une image stable. Le cœur est à l’écran.
L’implémentation actuelle fait tourner la boucle de scanning LED en bare-metal sans concurrence. Une prochaine étape naturelle est d’introduire le threading : faire tourner la boucle d’affichage dans une tâche dédiée et libérer le thread principal pour d’autres traitements comme la lecture des boutons ou des capteurs. Ce sera l’objet d’un prochain article.