guest@6admin.io:~$

Autopsie d'un firmware Arduino hérité

À l’époque, chez un ancien employeur, le firmware d’origine pilotait des rubans LED fixés devant des salles de réunion, qui indique la disponibilité de la salle. La board poll GET /lights/<mac> toutes les quelques secondes, le serveur répond un unique chiffre ASCII entre <0> et <9>, le ruban affiche une couleur. Produit simple, protocole simple. Je ne l’ai pas écrit, mais je m’en suis servi comme base.

La board n’était que la moitié du système. L’autre moitié côté salles, c’était le serveur d’occupation du bâtiment. Moi, j’ai détourné ce même protocole, pour moi seul, avec un petit serveur Python qui faisait le pont entre mes pipelines de CI et la board: ma veilleuse de CI existait en 2019, bien avant que les “CI status lights” soient une chose. Seul le contrat d’API a survécu, dans la doc (GET /lights/<lightid> répondant <N>, de <0> arc-en-ciel d’erreur à <4> rouge booked-and-busy); le code du serveur lui-même n’a pas survécu. C’est sa propre leçon: un code sans dépôt ne survit pas au poste qui l’a écrit.

Le code est une autre affaire. J’ai gardé une copie du sketch original (lights.c, 475 lignes, identique dans trois dossiers différents sur trois machines différentes, zéro discipline de gestion de version) et comme j’ai l’autorisation de le montrer, le voici disséqué, moins pour juger un prédécesseur que pour en tirer des leçons réutilisables.

Config pour “gens non techniques”, sécurité non incluse

byte serverIP[] = {  198,  51,  100,  23 };  // building server's IP(v4) address
byte forced_mac[] = { 0x00, 0x24, 0x67, 0x3E, 0xFB, 0x8E };

Une IP de serveur public et une adresse MAC, hardcodées dans les sources, livrées dans la section “this part contains what non-technical people can change”. La section déclare aussi use_proxy comme TO BE TESTED et deux fonctionnalités comme TO BE DONE. Chaque board de la flotte compilait sa propre copie des mêmes secrets.

Soyons justes sur le correctif, parce qu’il n’est pas celui qu’on croit. Sortir la config vers du runtime n’était pas réaliste sur une carte aussi contrainte et chez certains clients il fallait de toute façon intégrer sans DHCP ni DNS, en adressage statique. Ce qu’il aurait fallu, ce n’est pas embarquer la conf dans la board mais la sortir des sources: des placeholders dans le sketch et un script ou un rôle Ansible, qui rend le .ino avec les bonnes valeurs avant de compiler, le dépôt gardant le gabarit et non les secrets. Un bootstrap générique qui va chercher sa conf, oui, mais résolu à la compilation plutôt qu’à l’exécution.

La table des couleurs et l’ordre GRB

Avant toute chose, le fichier des constantes:

uint32_t red      = strip.Color(  0, 162,   0);  // red
uint32_t darkblue1 = strip.Color(  0,  0,   32); // red

Douze constantes de couleur, six commentées // red, y compris les bleus. C’est l’ordre des octets GRB de NeoPixel qui fait ses dégâts habituels et personne n’a jamais corrigé les commentaires. truewhite c’est Color(65, 72, 32): une teinte jaunâtre vendue au client comme “33% white”. Et LEDCOUNT 28 porte le commentaire // 35 for epic failure. De la documentation exacte, pour une fois.

Le reset confié au watchdog

void RESET() {
   digitalWrite(7, LOW);      // a wire is soldered between pin 7 and RST, on OLD boards
   wdt_enable(WDTO_4S);
   while(1){}                 // wait for it
}

La fonction de reset commence par court-circuiter le pin 7 sur le pin RST via un fil littéralement soudé sur les vieilles boards, puis arme un watchdog de 4 secondes et tourne en boucle infinie en attendant que le watchdog abatte la board. Un reset dont l’API est “meurs et espère”. Le commentaire // wait for it fait beaucoup de travail.

La récursion en guise de retry

Quand le connect HTTP échoue, GET() se retente récursivement, avec un sommeil croissant:

error_count++;
int patience = error_count * 1000;
delay(patience);
return GET();

Onze niveaux de récursion avec des delays bloquants, sur un AVR qui a 2,5 ko de RAM, chaque niveau gardant une frame de pile plus un String. Ça marche jusqu’à ce que le réseau flanche une minute et là ça déborde soit la pile, soit la chance. Il y a même une excuse en ligne: at some point there was a "while" instead of an "if".

Vingt secondes sans écoute

Chaque chemin de succès finit par delay(show_delay * 1000); avec show_delay = 20. La board poll une fois, affiche une couleur, puis devient complètement sourde pendant 20 secondes. L’occupation de la salle change; la lumière devant la porte s’en moque. Multiplié par chaque case d’un switch à dix branches, plus une boucle de timeout bloquante de 15 secondes dans le lecteur, plus while (!Serial) au boot: la board passe l’essentiel de sa vie à ne pas écouter.

Le retour d’un buffer local

char inString[32];
...
return inString;   // return the char array with what was enclosed between '<' and '>'

readPage() retourne un pointeur vers un buffer local. L’appelant le stocke dans un String assez vite pour que ça marche en général, ce qui est la pire espèce de bug: celui qui marche. Et si le timeout de 15 secondes expire, la fonction tombe hors du bout sans rien retourner: comportement indéfini, offert par une branche de sécurité commentée.

String sur AVR et la fragmentation du tas

Chaque requête HTTP est construite avec request = request + "/lights/"; request = request + macbuffer + .... Sur AVR, String est allouée sur le tas et ce motif fragmente 2,5 ko de RAM en confettis. C’est la maladie Arduino la plus répandue de la décennie et le remède (buffers fixes, snprintf) était connu et ignoré.

Et ce n’était pas théorique: la RAM partait en confettis, la board fuyait et tombait au hasard, un reboot par-ci, un ruban LED figé par-là, jamais deux fois le même symptôme, le genre de panne intermittente qu’on ne reproduit jamais le jour où on la cherche.

Serial, le second écran

Plus de 40 appels Serial.print, dont un qui imprime chaque caractère reçu de la réponse HTTP (Serial.print(c); en plein dans la boucle de lecture), plus un Debug() horodaté après presque chaque étape. Avec while (!Serial) qui bloque le boot tant qu’un terminal n’est pas ouvert. La lumière est un affichage d’état pour les humains; son seul affichage d’état fiable, c’était elle.

La tentative de refactor suivante

La tentative de refactor (sketch_nov04a) reboottait via un pointeur de fonction nul (void (*softReset) (void) = 0; softReset();, le classique du comportement indéfini), parsait le chiffre avec case 48: jusqu’à case 57: (magic numbers ASCII, parce que pourquoi utiliser c - '0') et contenait le bijou case 55: // 7 = blue qui met Color(0,255,0) et le commente // black ! - OFF. La variante de flotte 2019 (sketch_3-0_20190328_.ino) a gardé le GET récursif, le hang du watchdog et le retour pendant, ajouté le support EEPROM MAC et bootait avec Serial.println("No more Lucky Rainbow !");. Même maladie, une décennie de sédiment en plus.

Trois copies du même sketch vivaient sur trois machines sans historique commun. La gestion de version, c’était “le dossier qui est sur la clé USB”. Si vous vous reconnaissez: le remède c’est git init et ça prend une minute.

475 lignes de contre-exemple

  • Les delays bloquants sont des mensonges que vous racontez à votre ordonnanceur. Rien d’autre ne peut se produire pendant; rendez la boucle non-bloquante dès le jour un.
  • Ne retournez jamais un buffer local. String, char[], pointeur, peu importe: l’appelant ne possède rien et vous non plus.
  • La récursion n’est pas une boucle de retry. Sur un microcontrôleur, c’est un débordement de pile avec des étapes en plus.
  • Le watchdog sert aux hangs, pas d’API de reset. Rebooter en attendant la mort exprès fonctionne, mais ça veut dire que votre reset dépend de votre mode de défaillance.
  • Les secrets de flotte codés en dur dans les sources se compilent dans chaque board. Quand la carte est trop contrainte pour une config au runtime, la réponse est un gabarit à placeholders rendu à la compilation par un script ou Ansible, le dépôt gardant le modèle et non les valeurs.
  • Un seul fichier de 475 lignes ne se maintient pas. Le découper en plusieurs .ino par responsabilité est le premier geste, avant même de corriger un bug.
  • Les commentaires mentent quand le protocole embrouille les gens. GRB n’est pas RGB; corrigez la constante, pas le commentaire.

L’histoire de la rédemption, de ce marécage à un firmware non-bloquant et une pipeline CI qui allume les LEDs, est racontée en deux épisodes plus tardifs: la réécriture et la veilleuse CI .