Retour d'expérience ADNT : TDD/BDD sur Raspberry Pi Pico assisté par IA
Par ADNT Sàrl & Florian Mahon (florian.mahon@adnt.io) (ingénierie firmware / systèmes embarqués — RP2040)
Depuis plusieurs mois, j'ai développé un bootloader en Rust pour la plateforme RP2040 et, surtout, j'ai cherché à intégrer des agents IA dans mon flux de développement sans tomber dans les écueils classiques : verbosité, sur‑production de code, implémentations "hors‑sujet", et refactoring permanent.
J'ai expérimenté plusieurs approches (dont des workflows de type "tickets/stories" très détaillés, et des méthodes plus "spécification exhaustive"). À chaque tentative, je constatais la même dérive : accumulation de tickets, croissance rapide du code, difficulté à conserver un cap clair… puis une dette technique difficile à résorber. Au final, je passais plus de temps à "piloter l'agent" et à rechercher les zones à refactorer qu'à livrer des fonctionnalités utiles.
Pour ce projet, j'ai volontairement changé de posture : j'ai procédé comme si je développais avec une petite équipe (deux personnes), en appliquant une méthode simple et disciplinée (AGILE):
petits pas, objectifs testables, tests d'abord, branche courte, revue, intégration.
L'objectif de cet article est de partager cette démarche, ses résultats, et des conseils pratiques pour intégrer l'IA dans du développement embarqué sans perdre en rigueur. Le projet est public, téléchargeable, et testable sur une Raspberry Pi Pico.
Le but n'était pas de "faire un bootloader" pour le plaisir. Le bootloader est une brique technologique au service d'un objectif plus large : construire une plateforme permettant du TDD/BDD firmware sur matériel réel, avec la même agilité qu'en développement logiciel classique mais validé sur hardware.
La première brique est Crispy-bootloader, un bootloader A/B pour RP2040 qui permet d'itérer sur du firmware comme on itère sur du logiciel, avec la vérité du hardware. Il ouvre la voie à des scénarios BDD sur carte, des non‑régressions sur les séquences de boot, des tests d'intégration validant des comportements réels, et à terme, une ferme de tests automatisée.
1. Roadmap minimale : trois exigences
Avant même de coder, j'ai rédigé une roadmap minimale, guidée par trois exigences :
- Double banque A/B pour permettre le rollback.
- Exécution du firmware en RAM pour conserver un jitter faible et un comportement plus déterministe.
- Respect de la séquence de boot imposée par le RP2040 (ROM → boot2 → application).
Sur RP2040, un second stage bootloader (boot2) est exécuté avant l'application pour initialiser l'accès à la flash QSPI (XIP) [4][5]. Crispy s'y insère ensuite, choisit une banque, copie le firmware en RAM et transfère l'exécution.
2. Du blink LED à l'instrumentation
J'ai démarré sur une base Rust issue d'un projet initial (généré par l'outillage VSCode pico sdk), compilé une première version et validé un comportement simple (blink LED). C'était utile pour valider l'environnement, mais insuffisant : ce workflow exigeait ma présence permanente dans la boucle.
J'ai donc basculé rapidement vers une boucle plus automatisable :
- logs via defmt (et RTT),
- scripts pour piloter l'exécution,
- et, dès que possible, des tests d'intégration.
Dès ce stade, quand je sollicitais l'IA, je lui fournissais un contexte exploitable et surtout un accès à une chaîne de debug (sonde + probe‑rs/GDB) pour observer l'état réel du système et confirmer/infirmer des hypothèses [3].
La progression s'est faite en itérations courtes :
- Implémenter un boot minimal et un firmware minimal pour valider le boot.
- Mettre en place des tests BDD pour valider le comportement.
- Implémenter un outil d'upload et un protocole minimal de communication.
- Étendre le bootloader au strict nécessaire pour supporter ces tests.
- Consolider / nettoyer / refactorer, puis itérer.
3. Chaîne minimale : bootloader + firmware
Mes tentatives précédentes m'avaient appris une chose : laisser l'IA "partir trop loin" conduit vite à une base de code volumineuse, difficile à maintenir. J'ai donc volontairement découpé en petites étapes complètes, couvrant les couches nécessaires, mais testables à chaque incrément (unitaires + intégration/BDD).
La première étape consistait à obtenir :
- un bootloader minimal,
- un firmware Rust minimal,
- des retours observables (defmt/RTT),
- et des scripts permettant de valider le comportement.
Ce choix a eu deux effets immédiats :
- je disposais rapidement d'un système testable depuis un poste de dev ;
- l'IA cessait d'être un "générateur de code" et devenait un partenaire de discussion d'architecture, contraint par des retours mesurables.
À partir de là, les prompts n'avaient plus besoin d'être longs : la boucle de test fournissait les informations, et l'IA pouvait proposer des modifications que je validais immédiatement sur cible.
4. Outillage et consolidation
Une fois la chaîne minimale stable, j'ai développé l'outillage nécessaire pour rendre la boucle build → upload → boot → validation reproductible :
Outil CLI (crispy-upload) pour :
- uploader des binaires dans les banques,
- configurer le bootloader,
- et automatiser le flux de test.
Consolidation du protocole :
- implémentation d'un mécanisme de vérification (CRC) pour valider l'intégrité des firmwares,
- formalisation du protocole minimal,
- création d'une librairie équivalente en Python pour faciliter certains scénarios/outillages.
À ce stade, le chemin était réellement minimal, stable et testable : bootloader → copie en RAM → exécution en RAM → comportement observable. Le firmware Rust jouait le rôle de firmware étalon : petit, déterministe, et idéal pour stabiliser la chaîne sans ajouter les hypothèses d'un SDK complet.
5. Refactoring et debug avec probe-rs
Une fois l'outillage (Rust + Python) et la base de tests en place, j'ai refactoré :
- mutualisation d'informations et de structures,
- création de bibliothèques communes entre firmware et bootloader,
- nettoyage du code pour améliorer la lisibilité.
En parallèle, j'ai structuré l'environnement de développement VSCode pour avoir une boucle de debug fiable avec probe‑rs.
Effet de bord inattendu : contribuer à probe-rs grâce à une bonne instrumentation
Cette phase m'a confronté à un défi inattendu : le firmware fonctionnait (les tests le prouvaient), mais le debug restait instable. Situation frustrante où la cible est saine, mais l'outillage ne suit pas.
Premier obstacle : En combinant tests et instrumentation, nous avons isolé un problème côté probe‑rs lui-même. En travaillant avec l'IA sur un repro minimal, l'investigation a débouché sur un correctif upstream (PR probe‑rs [6]). Cette contribution a permis de stabiliser le workflow.
Second obstacle : Même après cette correction, le firmware exécuté en RAM restait difficile à déboguer. Les breakpoints matériels ne s'appliquaient pas correctement (limites connues du Cortex-M0+/FPB avec code relocalisé). Avec l'aide de l'IA, nous avons implémenté des breakpoints software dans une version modifiée de probe‑rs [2][7]. Cette modification, bien que non proposée upstream (implémentation légère et pragmatique), a débloqué le workflow et accéléré les itérations.
Enseignement : même les outils de développement peuvent nécessiter une investigation. La méthodologie rigoureuse (tests + repro + instrumentation) s'applique aussi pour identifier les problèmes hors du projet lui-même.
6. Du Rust au C++ : validation croisée
Fort de cette base stable et debuggable, l'étape suivante consistait à valider la compatibilité avec l'écosystème C++ du Pico SDK en ajoutant un firmware minimal basé sur le Pico SDK. La contrainte était non négociable :
le SDK doit rester standard, sans patch, afin de conserver un projet maintenable (et compatible avec une exécution "no‑flash"/préchargée en RAM).
La réutilisation des tests existants a été déterminante : elle a rendu visible, très rapidement, un problème d'initialisation matérielle (PLL/timers/clocks) lié au chevauchement entre l'état laissé par le bootloader et les hypothèses d'initialisation du firmware Pico SDK.
Là encore, la méthode a été la même :
- tests → repro,
- instrumentation → observation,
- IA → hypothèses et expériences minimales,
- fix → non‑régression.
Cette phase a permis, en moins d'une journée, de stabiliser un firmware minimal C++ fonctionnant avec le SDK standard.
7. Architecture en services
Une refonte importante du bootloader a ensuite consisté à structurer le code en "services". La motivation principale n'était pas esthétique : elle était fonctionnelle.
- Déterminisme : rendre la boucle principale explicite, prévisible et stable (ordre des actions maîtrisé).
- Lisibilité : isoler les responsabilités dans des modules/services dédiés.
let services = [
ServiceType::UsbTransport(UsbTransportService::new()),
ServiceType::Trigger(TriggerCheckService::new()),
ServiceType::Update(UpdateService::new()),
ServiceType::Led(LedBlinkService::new()),
];
Cette organisation a aussi mis en évidence la nécessité de passer par une zone de staging en RAM lors de l'upload avant persistance en flash — ce qui s'aligne avec les contraintes de la flash (XIP, interruptions, multicore) documentées côté Pico SDK [4].
8. Rendre l'IA utile sans perdre la maîtrise
La leçon la plus importante de ce projet n'est pas "l'IA écrit du code". C'est :
l'IA devient utile quand elle est contrainte par des tests et alimentée par des observables réels.
Structurer les prompts avec des observables réels
Au fil du projet, j'ai convergé vers une approche qui s'est révélée systématiquement efficace : nourrir l'IA avec des faits mesurables plutôt qu'avec des descriptions longues.
Concrètement, au lieu de prompts verbeux, je fournis :
- Objectif : comportement attendu (court, précis)
- Repro : 4 à 6 étapes maximum, reproductibles sur cible
- Observé : symptômes + logs + ce qui manque ou diffère
- Instrumentation : données probe-rs / GDB (PC, SP, registres clés, dump mémoire)
- Demande : 3 hypothèses ordonnées, avec pour chacune un test minimal + observation attendue
Cette structure n'était pas formalisée dès le départ, mais elle s'est imposée naturellement. Avec ce type de contexte, la collaboration devient méthodique : hypothèses → expériences → validation sur cible → non‑régression.
Conclusion
Crispy n'est pas "un bootloader A/B". C'est une brique structurante pour permettre du TDD/BDD firmware sur matériel réel, avec une boucle d'itération rapide, instrumentée et fiable — comparable à ce qu'on obtient en développement logiciel classique, mais validé sur hardware.
L'enseignement principal de ce projet n'est pas lié à une implémentation particulière, mais à la méthode qui permet de rester efficace — y compris avec un agent IA.
Cinq recommandations pour intégrer l'IA en embarqué sans perdre la maîtrise
-
Construire une couche testable de bout en bout dès le départ Avant d'élargir le périmètre fonctionnel, établissez une chaîne minimale qui couvre le flux complet : build → upload → boot → validation sur cible. Cette "colonne vertébrale" rend chaque itération mesurable et réduit drastiquement le risque de dérive.
-
Donner à l'IA les moyens de tester et d'expérimenter, pas uniquement de générer du code Une IA devient réellement utile lorsqu'elle peut travailler à partir de faits : sorties de tests, logs, mesures, états observables (debug). Plus le contexte est empirique, plus les hypothèses proposées sont pertinentes.
-
Enrichir les prompts avec des données réelles issues de la cible Plutôt que d'écrire des prompts longs, alimentez l'IA avec des observables concrets : scénario qui échoue, étapes de reproduction, logs, et résultats d'instrumentation. Cela réduit la verbosité, évite les raisonnements spéculatifs, et accélère la convergence.
-
Conserver un flux de développement cadré (type Gitflow) pour sécuriser les réintégrations Branches courtes, revues systématiques, intégration fréquente : l'IA accélère la production, mais la discipline de réintégration évite que la vitesse ne se transforme en dette technique. Le flux doit rester un garde-fou.
-
Traiter la qualité des tests comme un livrable à part entière Les tests sont la "source de vérité" : s'ils sont instables, incomplets ou non déterministes, tout le reste devient fragile (y compris l'apport de l'IA). Investissez dans des tests fiables, reproductibles, et orientés comportement sur cible.
Bilan et perspectives
Ce projet m'a permis de tester une démarche agile adaptée à l'embarqué, d'intégrer l'IA dans un workflow réellement utile (plutôt que verbeux), et de consolider une chaîne complète : bootloader, firmware, outils, tests, debug.
En appliquant ces principes, on obtient une base saine : une boucle de développement sereine, capable d'absorber la complexité (nouveaux firmwares, nouvelles briques, intégration continue sur hardware), tout en bénéficiant de l'IA comme accélérateur — sans perdre le contrôle du système.
La suite logique est désormais de durcir et industrialiser cette approche (runners, tests d'intégration élargis, et extension aux briques de communication/bus rapides).
Références
-
Repo Crispy (README, structure, Quick Start, tests, philosophie IA) https://github.com/ADNTIO/crispy-bootloader-rp2040-rs
-
Documentation crispy-upload (note sur probe‑rs modifié + software breakpoints, install via
make install-probe-rs) https://docs.rs/crate/crispy-upload/0.2.0 -
probe‑rs (toolchain de debug — CLI/DAP/GDB server, etc.) https://github.com/probe-rs/probe-rs https://probe.rs/docs/
-
Pico SDK docs — hardware_flash (contraintes XIP/IRQ/cores) + mention PICO_NO_FLASH https://www.raspberrypi.com/documentation/pico-sdk/hardware.html
-
rp2040-boot2 (second stage bootloader — boot2 et placement dans l'image) https://github.com/rp-rs/rp2040-boot2
-
PR probe‑rs (correctif lié au workflow PicoProbe / debug) https://github.com/probe-rs/probe-rs/pull/3810
-
Fork probe‑rs (version modifiée avec breakpoints software pour debug firmware en RAM) https://github.com/fmahon/probe-rs