Carnet de bord : ce que .NET nanoFramework ne dit pas dans sa doc

Construire MOGWAI NANO m’a appris beaucoup de choses sur .NET nanoFramework — pas depuis la documentation officielle, mais depuis le terrain, à coups de tests d’endurance de plusieurs heures et de traques de bugs qui ne se manifestaient qu’après vingt minutes ou deux heures de fonctionnement. Certains de ces comportements sont documentés quelque part, discrètement. D’autres, je ne les ai trouvés nulle part avant de les vivre moi-même.

Cet article rassemble les enseignements les plus utiles — pour qui construit sur nanoFramework, ou simplement par curiosité de voir ce qui se cache sous le capot d’un CLR taillé pour tourner dans quelques dizaines de kilo-octets de RAM.

Le socle du langage : ce qui manque, et pourquoi ça compte

Pas de generics complets — pour l’instant. Dictionary<TKey,TValue> reste en travaux au moment où j’écris ces lignes. Hashtable et ArrayList, tout droit sortis du .NET 1.x des années 2000, redeviennent les outils du quotidien. Pas de tuples non plus (ValueTuple n’existe pas dans mscorlib) — une petite classe maison fait très bien l’affaire à la place. Une preview publique des generics vient d’être annoncée par l’équipe nanoFramework — mais attention, ce n’est pas une simple option à activer au passage : ça nécessite une extension Visual Studio distincte (preview), et il faut actuellement choisir entre firmware/toolchain sans generics ou avec, pas les deux en simultané sur le même projet. Un point à surveiller pour une future migration, mais qui ne concerne pas MOGWAI NANO aujourd’hui.

float, pas double, si vous visez l’ESP32. Le Xtensa LX6 a un FPU matériel, mais en simple précision seulement. Chaque opération sur un double est émulée en logiciel — nettement plus lent. J’ai fait ce choix pour MOGNumber, et ça se voit : sur des benchmarks flottants comparables, un ESP32 avec FPU est 3 à 10 fois plus rapide qu’un RP2040 (Cortex-M0+), qui lui n’a aucun FPU matériel, même pas en simple précision.

La fragmentation mémoire : le piège le plus contre-intuitif

C’est la découverte qui m’a le plus surpris. Une liste qui grossit progressivement peut déclencher une OutOfMemoryException alors que GC.Run(false) rapporte encore des dizaines de kilo-octets libres.

La raison : ArrayList (comme List<T> sur desktop) repose sur un tableau interne contigu. Quand sa capacité est dépassée, il doit allouer un nouveau tableau, plus grand, et copier l’ancien dedans — avant de libérer l’ancien. Cette nouvelle allocation doit être un bloc contigu. Si la mémoire libre existe mais est fragmentée en petits morceaux disséminés, l’allocation échoue même si le total « libre » suffirait en théorie.

La correction n’a pas été de gérer la fragmentation, mais de la réduire à la source : implémenter le sigil & (référence directe à l’objet plutôt que copie) pour toutes les primitives où ça avait du sens. Résultat mesuré sur un test contrôlé : le même programme qui plantait après 251 éléments dans une liste (avec 27 Ko « libres » au moment du crash) en encaisse 474 avec seulement 10 Ko de « gaspillage » — presque le double de capacité utile, avec une bien meilleure hygiène mémoire.

Retenir : sur un système à ressources aussi comptées, la mémoire libre totale ne dit rien de son utilisabilité réelle. Réduire le nombre d’allocations compte souvent plus que d’optimiser leur taille.

Les exceptions qui ne sont pas catchables

C’est le fil rouge qui traverse toute la partie réseau de ce projet, et probablement la leçon la plus importante de cet article.

Sur trois opérations socket différentes, j’ai rencontré le même symptôme : un défaut natif (CLR_E_FAIL), affiché directement sur la sortie de debug dans un format ++++ Exception ... ++++, qui échappe complètement à n’importe quel try/catch C#, aussi englobant soit-il.

  • Fermer un UdpClient (Close()) depuis un thread pendant qu’un autre thread est bloqué sur Receive()
  • Un Socket.ReceiveTimeout/NetworkStream.ReadTimeout qui se déclenche réellement (pas juste configuré — qui expire vraiment)
  • Une écriture (Write()) sur une connexion TCP dont l’autre bout vient de se fermer

Les trois cas produisent le même symptôme : le thread qui exécute cette ligne meurt, sans qu’aucun catch ne s’exécute. Ce format d’affichage (++++ Exception ... ++++) n’est d’ailleurs pas une notification bénigne de Visual Studio — c’est le dump natif du nanoCLR pour une exception réellement non gérée.

La vraie conséquence architecturale : aucun thread dont la mort serait critique ne doit jamais toucher directement au réseau. J’ai dû restructurer mon dispatcher d’exécution RPN pour qu’il délègue chaque exécution à un thread jetable plutôt que de tourner lui-même le code utilisateur — un défaut natif sur une primitive réseau ne tue alors que ce thread jetable, jamais le dispatcher qui continue d’accepter de nouvelles commandes. Même chose côté envoi : un SenderLoop dédié, alimenté par une file d’attente, isole le risque loin du thread d’exécution principal.

Retenir : face à ce genre de défaut, n’essayez pas de trouver « le bon try/catch« . Contournez le problème structurellement — isolez le risque sur un thread dont la mort est sans conséquence, plutôt que de compter sur une gestion d’exception qui ne se déclenchera jamais.

L’ordre d’initialisation statique n’est pas garanti comme sur desktop

Celui-là a été le plus déroutant à diagnostiquer. Un champ statique readonly, initialisé une seule ligne plus bas dans une classe sans aucune dépendance externe complexe (public readonly static EvalResult NoError = new EvalResult();), se retrouvait null au moment où une autre classe y accédait pour la première fois — alors que rien dans cette classe ne devrait jamais pouvoir provoquer ça sur desktop .NET.

Deux remèdes classiques et documentés côté desktop n’ont rien changé :

  • Ajouter un constructeur statique explicite (censé retirer le flag beforefieldinit et forcer un ordre d’initialisation strict) — sans effet
  • Déplacer l’assignation dans un constructeur d’instance plutôt qu’un initializer de champ — sans effet

La seule solution qui a fonctionné : abandonner complètement les champs statiques readonly initialisés en ligne, au profit de propriétés à initialisation paresseuse explicite, avec un flag _initialized vérifié à chaque accès :

private static bool _initialized = false;
private static EvalResult _noError;
private static void EnsureInitialized()
{
if (_initialized) return;
_initialized = true;
_noError = new EvalResult(/* ... */);
// ... reste des champs
}
public static EvalResult NoError { get { EnsureInitialized(); return _noError; } }

Verbeux, mais imparable : l’initialisation devient du code normal qui s’exécute au moment de l’appel, sans dépendre d’aucune garantie implicite du runtime sur les constructeurs statiques.

Retenir : la garantie CLI classique (« accéder à un membre statique déclenche l’exécution du cctor du type avant tout accès ») ne semble pas fiable à 100 % sur le nanoCLR dans certains enchaînements d’initialisation entre classes. Si vous avez des champs statiques readonly interdépendants, testez sérieusement leur état au tout premier accès de l’application.

Le WiFi a ses propres surprises

Une reconfiguration réseau via Device Explorer nécessite un vrai reboot matériel. Modifier le SSID/mot de passe dans la boîte de dialogue de Visual Studio écrit bien la configuration en flash — mais tant que le device n’a pas été physiquement redémarré (pas juste redéployé), la pile réseau active continue d’utiliser l’ancienne configuration chargée au premier boot. Sans le savoir, j’ai fini par coder les identifiants en dur dans le code source pour contourner ce qui semblait être un échec de la reconfiguration — un vrai risque de sécurité, découvert et corrigé juste à temps avant l’ouverture publique du dépôt.

Le mode économie d’énergie WiFi de l’ESP32 (modem sleep) pourrait provoquer des coupures TCP intermittentes sur de longues sessions, même avec la connexion affichée comme active — c’était mon hypothèse de départ. Le délai avant qu’une session se coupe s’est révélé extrêmement variable dans mes tests — de 11 minutes à plus de 2 heures — sans corrélation avec la mémoire disponible (parfaitement stable dans tous les cas). Le facteur le plus déterminant s’est avéré être la qualité du module ESP32 lui-même : un module certifié (AZ-Delivery) a tenu plus d’1h30 sans le moindre souci, tandis que deux clones bas de gamme différents ont systématiquement décroché en une douzaine de minutes.

J’ai posé la question à Laurent Ellerbach, contributeur actif du projet nanoFramework — il n’a jamais rencontré ce comportement de son côté. Ce retour renforce plutôt l’hypothèse matérielle : plutôt qu’un vrai piège du framework ou de l’ESP-IDF sous-jacent, ça ressemble de plus en plus à une conséquence directe de la qualité (ou plutôt du manque de qualité) des clones ESP32 les moins chers du marché — composants RF de moindre qualité, calibration approximative, voire puces contrefaites. Une bonne illustration que, sur du matériel embarqué, le prix d’achat d’un module n’est jamais un détail anodin.

Un déploiement qui « réussit » sans jamais fonctionner

Celui-là s’est produit à quelques jours de l’ouverture du dépôt, au pire moment possible pour le découvrir. Un flash complet avec nanoff --update --deploy --image se déroulait sans la moindre erreur — hash vérifié, Flash write complete, tout au vert. Et pourtant, l’application ne semblait jamais démarrer : en interrogeant le device, la section Assemblies: restait désespérément vide.

J’ai d’abord suspecté un mismatch de version entre le firmware fraîchement mis à jour et les packages NuGet utilisés pour compiler l’application — une piste plausible, mais fausse. La vraie cause, après plusieurs heures de comparaisons de logs : nanoff calcule automatiquement l’adresse flash où écrire la partition de déploiement, et ce calcul s’est révélé peu fiable pour la cible ESP32_REV3, écrivant parfois l’application quelques dizaines de kilo-octets avant la vraie zone de déploiement rapportée par le device lui-même. Le flash « réussissait » au sens où les octets étaient bien écrits et leur hash vérifié — juste pas au bon endroit, donc jamais reconnus comme une application valide.

Le correctif : préciser l’adresse de déploiement explicitement plutôt que de laisser nanoff la déduire :

nanoff --target ESP32_REV3 --serialport COMx --deploy --image MogwaiNano.bin --address 0x1E0000

(l’adresse exacte se lit dans la carte des partitions du device, via nanoff --devicedetails ou le Device Explorer de Visual Studio, section Flash Sector Map, ligne Deployment)

Il restait un troisième piège, découvert en testant la procédure complète sur un tout nouveau device : --address est silencieusement ignoré si on le combine avec --update dans la même commande. nanoff --update --deploy --image ... --address 0x1E0000 en une seule ligne retombe sur le calcul automatique erroné, malgré l’adresse explicitement fournie. Il faut impérativement flasher le firmware (--update) et déployer l’application (--deploy --address) en deux commandes séparées pour que l’adresse soit vraiment respectée — confirmé de façon reproductible sur deux devices ESP32 différents.

Un deuxième piège s’est glissé dans le diagnostic lui-même : le champ Deployment Map de cette même sortie affiche systématiquement Empty, y compris sur un déploiement parfaitement réussi — il semble lié à une fonctionnalité de mise à jour à distance (In-Field Update) que cette cible ne supporte simplement pas, sans rapport avec la présence réelle d’une application déployée. Le seul indicateur fiable est la section Assemblies:, qui doit lister le nom et la version de l’application.

Retenir : si un déploiement nanoff semble réussir (hash vérifié, aucune erreur) mais que l’application ne démarre jamais, vérifiez d’abord si vous combinez --update et --address dans la même commande — séparez-les. Vérifiez ensuite l’adresse de déploiement affichée dans Assemblies: avant de suspecter autre chose, et méfiez-vous des champs de diagnostic qui peuvent rester vides pour des raisons sans rapport avec votre problème réel.

Ce qui fonctionne étonnamment bien

Tout n’est pas que pièges. Le même fichier .bin compilé tourne, sans la moindre modification, sur un ESP32 (Xtensa LX6) et un Raspberry Pi Pico W (Cortex-M0+) — deux architectures CPU complètement différentes. C’est la preuve la plus concrète que l’abstraction du CLR fait vraiment son travail, malgré toutes les aspérités qu’on vient de lister.

Une checklist pour qui se lance sur nanoFramework

  • Préférez ArrayList/Hashtable par réflexe, pas par contrainte — et surveillez la fragmentation sur toute structure qui grossit dans la durée
  • N’espérez jamais catcher un défaut de socket qui touche à un Close()/timeout/écriture croisée entre threads — concevez pour que ça n’ait pas d’importance si ça arrive
  • Testez vos champs statiques readonly interdépendants au tout premier accès réel de l’application, pas seulement en théorie
  • Un test de 5 minutes ne révèle rien sur la stabilité longue durée — beaucoup de ces comportements ne se manifestent qu’après plusieurs dizaines de minutes, voire plusieurs heures
  • La qualité du matériel (modules ESP32 en particulier) a un impact réel et mesurable sur la stabilité, indépendamment de tout ce que dit votre code
  • Un déploiement nanoff « réussi » (hash vérifié, aucune erreur) ne garantit pas que l’application démarre — vérifiez toujours la section Assemblies: du device, pas juste le statut de la commande de flash

MOGWAI NANO est open source sur GitHub. Si vous construisez aussi sur nanoFramework et croisez l’un de ces comportements, n’hésitez pas à me contacter — ça m’intéresse toujours de comparer les notes.

Laisser un commentaire

En savoir plus sur CODING 4 PHONE

Abonnez-vous pour poursuivre la lecture et avoir accès à l’ensemble des archives.

Poursuivre la lecture

En savoir plus sur CODING 4 PHONE

Abonnez-vous pour poursuivre la lecture et avoir accès à l’ensemble des archives.

Poursuivre la lecture