Firmware, quatre erreurs de conception (part#1)

Vincent OLLIVIER
Everysens
Published in
8 min readApr 28, 2021

--

Four Mistakes

Le firmware c’est quoi ?

La définition du firmware est un peu vague, mais ici on va considérer que c’est du logiciel qui s’exécute directement sur le matériel. C’est-à-dire : “bare-metal”.
La complexité d’un firmware peut grandement varier. De quelques lignes de codes pour faire clignoter des guirlandes de leds à des millions pour gérer un moteur thermique moderne.
Mais tous ont en commun un attribut : la fiabilité. En effet, il est rare que le fonctionnement du firmware ne soit pas focalisé sur la fiabilité et la résilience :

  • Avez-vous envie de voir votre porte de garage s’ouvrir seule en pleine nuit ?
  • Que votre voiture cale au milieu d’une voie ferrée ?
  • Qu’une machine stoppe la cadence de production de votre usine ?

Le “crash” qui est tolérable dans un navigateur web, un traitement de texte ou un jeu mobile, l’est rarement dans le domaine du firmware. Il y a plusieurs raisons à ça :

  • La reprise de contrôle par un humain est forcément longue.
  • Dans quel état va redémarrer le système en cas de reboot ?
  • Des vies humaines peuvent être en jeu.

Pour qu’un certain niveau de fiabilité soit respecté, il faut de la résilience dans la conception, de la simplicité dans la réalisation et du déterminisme (1) à l’exécution.

La manière de “penser firmware” et de concevoir est déterminante dans le résultat final.

Des mauvais choix techniques pris dès le début du projet peuvent s’avérer dévastateurs par la suite, empêchant, avec toute la bonne volonté du monde, une exécution sûre et déterministe. C’est précisément pour faire les bons choix qu’on va analyser quatre erreurs à éviter à tout prix.

Watchdog

Croire que le watchdog vous sauvera systématiquement la vie est une erreur !

Petit rappel technique (simplifié) sur le fonctionnement d’un watchdog :
Un watchdog est un élément hardware d’un microcontrôleur. C’est une “horloge”, qui compte de 0 à une valeur max arbitraire selon un timing réglé. Si le comptage atteint la valeur max, le watchdog réinitialise le microcontrôleur ! Il faut donc régulièrement alimenter (2) le watchdog explicitement, prouvant ainsi que le code n’est pas bloqué.

Tout d’abord il faut considérer le watchdog comme l’ultime sécurité de votre firmware, l’absolue dernière chance. Si le watchdog fait rebooter le firmware en plein fonctionnement, êtes vous certain qu’il saura restaurer son état précédent ou continuer à faire “voler la fusée” comme si de rien n’était ? Ou va-t-il recrasher à nouveau immédiatement ?

De plus, la gestion du watchdog est délicate. Si son utilisation n’est pas parfaitement maîtrisée elle peut rendre caduque son utilité. En effet, dans un firmware bien conçu, seule la boucle principale alimente le watchdog et, jamais, le développement des autres fonctionnalités ne demande de s’en occuper. Si ça n’est pas le cas, et que la tâche de gérer le watchdog est déléguée au développeur dans diverses branches du code, il est évident qu’un manquement apparaîtra tôt ou tard.
Cas 1 : le développeur implémente une tâche logicielle dont l’exécution est longue, par exemple un fix GPS. Il gère lui-même le timeout et le watchdog au sein de la tâche. Son watchdog est régulièrement alimenté mais son timeout est buggé et ne se termine pas. Alors, malgré un watchdog, le code peut rester bloquer en boucle infinie.
Cas 2 : une tâche de communication UART avec le composant GPS s’exécute très rapidement et ne demande pas d’alimentation de watchdog explicite. Seulement un jour, il arrive que le firmware du composant GPS crash et le temps qu’il redémarre la communication s’éternise. Pas de d’alimentation de watchdog, votre firmware reboot futilement.

Pour conclure : l’alimentation du watchdog doit être centralisée et figée, pas de délégation de gestion. Et se souvenir que le watchdog c’est comme un airbag, on sait que ça existe, mais on veut surtout pas tester !

Allocation dynamique de mémoire

Biberonnés au Javascript, Python ou même Java, ça fait un bon moment que les développeurs n’ont plus à s’occuper de la gestion de la mémoire sur les langages de haut niveau. Par conséquent leurs réflexes conceptuels lors de la création de nouveaux algorithmes ne prennent plus en compte ce point critique, le déléguant entièrement au garbage collector. De mon point de vue, c’est d’ailleurs la raison principale à l’explosion de la quantité de RAM nécessaire au fonctionnement de n’importe quel programme ces dernières décennies.

Dans le monde du firmware, l’allocation dynamique de mémoire c’est le Sheitan. L’utiliser, même à petite dose, c’est prendre un immense risque. La réussite de l’allocation n’est jamais garantie, la mémoire se fragmente avec le temps et on ne parlera même pas du défaut de désallocation. De ce fait, aucune exécution de code ne devient prévisible et les tests, même intenses, ne peuvent plus garantir une couverture totale. De plus, vous serez incapable de connaitre l’empreinte mémoire complète de votre firmware. Gênant en cas de décision de portage sur un autre MCU !

Rappelez-vous qu’aucun système (vraiment) critique dans l’industrie n’utilise d’allocation dynamique, l’aviation en tête.

Les questions que vous devez vous poser sont les suivantes :

  • Mon algo/process requiert-il vraiment de l’allocation dynamique ?
  • Comment le programme va réagir à un échec d’allocation ?
  • Suis-je plus malin qu’Airbus ou Boeing ?

Si vous n’avez pas de réponses concrètes à ces questions, tenez vous loin des malloc()!
Forcez-vous à changer votre mindset de développeur et repassez en mode “firmware”, repensez votre façon de concevoir vos algos, utilisez des variables statiques.

Error management

La gestion des erreurs doit être au cœur de la conception de votre firmware. Dans le cas contraire, dès que le nombre de tâches et sous tâches va augmenter le code risque de se transformer en une cascade de else tentant d’intercepter les erreurs de manière erratique. La gestion d’une erreur ne se résume pas à exécuter un print("error"), mais bien à rendre totalement prévisible la suite de l’enchaînement du code. Il va falloir que la logique de gestion d’erreurs soit unifiée, centralisée et cohérente à travers tout le code.
Les erreurs vont devoir remonter aisément des couches basses aux processus parents, afin de laisser le choix à ces derniers de la suite à donner à l’exécution.

Exemple : une erreur de connexion TCP sur un module GSM piloté par le MCU doit remonter au processus appelant afin de stopper la communication, d’arrêter électriquement le module. Enfin, la logique métier va probablement décider de se reconnecter après quelques minutes suite à la notification d’erreur.

Aucune “marge de manœuvre” ne peut être tolérée dans la gestion des erreurs. Un micro-framework et une unique méthodologie doivent être à la disposition du développeur. C’est ainsi la garantie d’un comportement déterministe dans l’échec d’exécution du code. La méthodologie doit être simple, voire simpliste, surtout pas de bloat code, sinon la gestion d’erreur sera elle-même buggée !

La difficulté principale de l’implémentation réside dans la capacité du code à faire transiter, une erreur entre les différents appels aux tâches.

De mon point de vue, l’un des paradigmes de programmation gérant les erreurs de la manière la plus élégante est le “modèle d’acteur” (voir Erlang). Non seulement les notification d’erreurs peuvent voyager à travers des messages d’acteur à acteur, mais en plus ce modèle est fault tolerant. En effet, les erreurs sont par nature imprévisibles et donc aucun code ne peut se targuer de toutes les intercepter et les gérer.

L’idée est que si quelque chose ne se passe pas normalement et qu’un processus se termine de manière incongrue, mieux vaut juste laisser couler que d’essayer de faire une action spécifique à chaque erreur. Ce qui est de toute façon, impossible.
Utiliser se paradigme rendra le code de votre firmware plus clair, déterministe, concentré sur le happy path, et seules les erreurs communes provoqueront un basculement d’état déterminé. Ceci dit, essayez de logger toutes les erreurs dans une zone mémoire “tournante”, pour pouvoir investiguer a posteriori.

Delay and Timeout

Tout comme la gestion d’erreurs, la gestion des délais et des timeouts doit être au cœur de la conception du firmware et être centralisée. Ici aussi il faudra fournir au développeur un framework et une méthodologie simple pour créer des délais synchrones et asynchrones.
Dans la réalisation de tâches proches du matériel, l’utilisation des délais est systématique, voire parfois intense. C’est la raison pour laquelle la stabilité et la simplicité d’utilisation sont primordiales.

De mon point de vue, il existe 3 niveaux de délais distincts :

  1. Les délais inférieurs à la milliseconde. Le micro-delay est rarement utilisé, mais parfois indispensable (SPI, sampling), il peut, sans problème, être synchrone et utiliser la fréquence du MCU comme référence.
  2. Les délais entre la milliseconde et quelques secondes. Les plus courants, ceci doivent utiliser une source de temps à la milliseconde ou au centième de seconde comme un timer, et suffisent à remplir 99% des cas.
  3. Les délais en secondes pleines. Ceux-ci peuvent parfaitement utiliser le Real Time Clockdu MCU.

Le niveau qui va nous intéresser est le deuxième. En effet le premier est un simple appel de fonction synchrone et le troisième un appel a time()pour obtenir un timestamp unix.

Le “délai milli” va nécessiter l’utilisation d’un timer du MCU. Si le code de votre firmware est multitâche, il va falloir que votre “delay framework” autorise le partage de la ressource implicitement (3). Il est donc hors de question d’utiliser un nouveau timer à chaque nouveau délai.

L’astuce est de commencer par créer un ticker. Le ticker est une unique référence de temps disponible tant que le CPU exécute du code (hors deep sleep). Certains MCU comme les STM32 la fournissent nativement, d’autres non, il suffit de lancer un timer exclusivement dédié à cette tâche.
Le ticker génère une interruption à chaque … tick. C’est ici que commence l’implémentation de votre “delay framework”. Chaque tick va incrémenter une variable int64 (4), ainsi vous allez pouvoir construire au dessus un notificateur de délai asynchrone. L’utilisation d’une seule référence de temps partagée va vous éviter d’insurmontables problèmes de synchro ou de disponibilité.

Je considère que si la gestion des délais est maîtrisée, la gestion des timeout se fait naturellement. En effet, les timeouts, sont des sortes de délais, il suffit donc de construire une partie dédiée aux timeouts dans le “delay framework”.

Conclusion

La fiabilité de votre firmware se joue dès les premières lignes de code.
Il faut se construire une boîte à outils (un framework testé et validé) qui facilite au maximum la production d’un code homogène d’un bout à l’autre du firmware.
Et pourtant je comprends qu’au départ on n’ai pas envie de le faire, car ça semble être une perte de temps que de tout codifier. Le problème c’est qu’au fur et à mesure que le firmware va s’étoffer, le code va devenir de moins en moins maintenable, la dette technique va devenir insoutenable et surtout, surtout, la fiabilité dégringoler en flèche.

En cadeau, je vous prépare un 2ème article avec en pagaille : les interruptions inutiles, la logique métier, les communications, etc … 😉

(1) Déterministe ou un comportement répétable : afin que les tests servent vraiment à quelque chose.
(2) Alimenter : feed the dog. L’alimentation (ou clear) se fait via une instruction spécifique à votre MCU cible.
(3) Implicitement : aucune délégation au développeur, le code le gère à 100%.
(4) L’avantage d’utiliser un int64 c’est de ne pas avoir à gérer l’overflow. Même incrémenté chaque milliseconde, il faudra 400.000 ans pour faire un tour !

--

--