Tous les textes

~/textes/rp2040-furnace-deadlock

Systèmes embarqués
10 min de lecture

Le contrôleur de fournaise qui se figeait quand le WiFi tombait

Un contrôleur de fournaise sur RP2040 se figeait au hasard, toujours quand le WiFi tombait. Le coupable était une sonde de connectivité qui ouvrait une connexion TCP pour décider si elle pouvait en ouvrir une. Un délai d'expiration l'aurait masquée; retirer la sonde a réglé le problème.

Un contrôleur de fournaise qui se fige, ce n'est pas un bogue qu'on livre. C'est un appareil dont le travail entier consiste à continuer de rouler: tenir une température, surveiller un thermocouple, prêt à déclencher une coupure d'urgence si ça chauffe trop. Quand il s'arrête, il arrête de tout faire ça d'un coup, en silence, et tu l'apprends de la façon la moins souhaitable.

Le symptôme se manifestait de temps en temps: un blocage qui coïncidait avec la chute du WiFi. Je n'aimais pas ça. Intermittent, c'est mauvais. Corrélé au réseau, c'est pire, parce que ça veut dire que la défaillance vit dans la partie du fw qui n'a rien à faire avec le chemin de contrôle au départ.

C'est quoi, la boîte

Le contrôleur est un Pico W: un RP2040 avec la puce WiFi CYW43 intégrée. Il lit la température d'un thermocouple MAX6675 par SPI, pilote deux relais (un pour l'élément chauffant, un pour une coupure d'urgence en cas de surchauffe), et roule une logique de minuterie pour conserver les braises au lieu de laisser le feu mourir. Il y a une petite interface web pour que tu puisses vérifier la température et ajuster les consignes depuis un téléphone. Le WiFi existe pour l'interface. C'est une commodité. Catégoriquement pas une partie de la boucle de sécurité, ou ça n'était pas censé l'être.

Deux cœurs. core0 roulait la logique de contrôle et les relais. core1 roulait la pile réseau et le serveur web. Les séparer voulait dire que le réseau pouvait faire ce qu'il voulait sans jamais bloquer la partie qui décide si l'élément est allumé ou éteint.

C'était la théorie. Le blocage disait le contraire.

Trouver le gel

L'intuition « je pense que c'est le WiFi » était le bon fil à tirer. J'ai ajouté un battement de cœur: core0 basculait un drapeau à chaque cycle de contrôle, et une vérification proche du chien de garde de l'autre côté notait quand le drapeau cessait de bouger. Ensuite j'ai débranché le câble réseau du point d'accès et j'ai attendu.

Il a gelé. Pas juste le serveur web, tout. La boucle de contrôle a cessé de tourner. Sur une fournaise.

J'ai parcouru le code réseau à la recherche de ce qui roule quand le lien disparaît, et j'ai trouvé ceci:

wifi.c
// Called periodically to decide whether we still have a usable link.
static bool wifi_check_connectivity(void) {
    struct tcp_pcb *pcb = tcp_new();
    ip_addr_t probe;
    ipaddr_aton(PROBE_HOST, &probe);
    // tcp_connect with no timeout. If the network is gone, the SYN
    // goes nowhere, nothing ever calls the connected callback, and
    // this path waits. Forever.
    tcp_connect(pcb, &probe, PROBE_PORT, connected_cb);
    return wait_for_connected(pcb);
}

Pour répondre à la question « est-ce que mon réseau est en service », le fw ouvrait une connexion TCP vers un hôte sonde. Quand le réseau était joignable, ça revenait vite et personne ne le remarquait. Quand il avait disparu, le SYN partait dans le vide, connected_cb ne se déclenchait jamais, et wait_for_connected bloquait sans rien pour le réveiller.

La raison pour laquelle ça tuait core0 et pas juste le cœur réseau, c'est que lwIP n'est pas sûr à plusieurs fils sans verrou. L'état partagé entre la pile et le côté contrôle faisait que cette routine était assise sur un chemin derrière lequel la boucle de contrôle pouvait finir par attendre. Un appel bloquant sans borne supérieure, sur un appareil qui ne doit jamais s'arrêter, joignable depuis la boucle de sécurité. Trois péchés dans une seule fonction.

Le truc avec les appels bloquants dans du code de sécurité

Un appel bloquant sans délai d'expiration, c'est un blocage que tu n'as juste pas encore déclenché. Sur un poste de travail, c'est un curseur qui tourne. Sur un contrôleur de fournaise, c'est l'élément coincé dans l'état où il était quand l'appel s'est suspendu, sans rien qui roule encore pour le faire changer d'idée. Le danger, ce n'est pas le WiFi qui tombe. Le danger, c'est un micrologiciel qui traite un réseau absent comme une raison d'attendre.

Le correctif qui a remis le blocage en place

Le geste évident, c'est de borner l'appel. Ajouter un délai d'expiration: si la connexion n'aboutit pas en, disons, deux secondes, on abandonne et on passe à autre chose. Le blocage devient un hoquet de deux secondes. Sur cet appareil, survivable.

Alors je suis allé l'ajouter. La version de lwIP contre laquelle je compilais n'exposait pas le délai d'expiration de la connexion de la façon que j'attendais. L'option que j'ai cherchée n'était pas là sous la forme que je voulais, et le code que j'ai écrit pour l'utiliser ne compilait pas. J'ai essayé l'orthographe évidente suivante. Pas plus. Plus je tâtonnais sur la surface spécifique à la version de l'API TCP, plus c'était clair que je me chicanais avec la bibliothèque au lieu de régler quoi que ce soit.

Ensuite c'est devenu pire que de ne pas compiler. La version intermédiaire que j'ai réussi à bâtir, pendant que je câblais ma propre comptabilité de délai autour de la connexion, avait encore un chemin où l'ordre des rappels laissait le PCB dans un état dont ma boucle d'attente ne sortait pas proprement. Ajouter de la machinerie à un appel bloquant m'a donné un contrôleur qui pouvait encore se coincer, avec plus de code maintenant. Retour à la case départ, sauf que la case départ avait moins de lignes.

Ç'a été l'échec utile. J'étais tellement déterminé à faire bien se comporter l'appel bloquant que je ne me suis jamais demandé s'il devait exister.

Ne sonde pas. Vérifie.

Un contrôleur de fournaise n'a pas besoin de composer vers l'extérieur pour savoir si son lien est en service. C'était l'hypothèse cachée sous tout le reste. Ouvrir une connexion TCP vers un hôte sonde mélange deux questions différentes: « est-ce que ma radio est associée et est-ce que j'ai une IP » et « est-ce que je peux joindre l'internet public ». Le contrôleur n'avait jamais besoin que de la première, et la première n'exige pas d'envoyer un seul paquet. La puce le sait déjà.

Le pilote CYW43 rapporte l'état du lien directement, et le netif de lwIP porte l'état de l'interface. Lis les deux. Pas de socket, pas de SYN, pas de rappel, pas d'attente sur un hôte distant qui peut être là ou pas.

wifi.c
// No TCP. No probe host. Ask the parts that already know.
static bool wifi_link_up(void) {
    int link = cyw43_wifi_link_status(&cyw43_state, CYW43_ITF_STA);
    if (link != CYW43_LINK_UP)
        return false;
 
    // Associated is not the same as configured. Confirm the netif
    // is up and actually holds an address before calling it usable.
    struct netif *nif = &cyw43_state.netif[CYW43_ITF_STA];
    return netif_is_up(nif) && !ip_addr_isany(netif_ip4_addr(nif));
}

Quelques lectures de registres et de structures. Ça ne peut pas bloquer, parce qu'il n'y a rien à attendre: la réponse existe déjà en mémoire, maintenue par le pilote à mesure que l'état d'association change. Quand le réseau tombe, wifi_link_up retourne false immédiatement, le serveur web se retire, et la boucle de contrôle ne sait jamais que quoi que ce soit s'est passé.

Le bon correctif n'a pas borné la défaillance. Il a retiré la classe de défaillance au complet. Pas de délai d'expiration à régler parce qu'il n'y a aucun appel qui peut se suspendre. Vérifie l'état, ne sonde pas.

L'autre danger que le gel cachait

Deux cœurs qui partagent de l'état, c'est sa propre façon de se faire mal, et le bogue de connectivité m'avait distrait de ça. Si core1 écrit la dernière température ou une consigne pendant que core0 la lit, une lecture déchirée est une possibilité réelle, et sur cet appareil une consigne déchirée, c'est une décision de relais prise sur de la cochonnerie.

Là où l'état partagé était un simple drapeau, j'ai utilisé atomic_bool plutôt que de prendre un mutex. Un drapeau atomique n'a pas de verrou à tenir ni de verrou qu'on oublie de relâcher, et oublier de relâcher un verrou, c'est le même blocage que je venais de passer un après-midi à retirer, avec un autre chapeau. Là où un mutex était vraiment nécessaire, pour l'état à plusieurs champs qui doit rester cohérent comme un tout, j'ai gardé la section critique aussi courte que possible: copie en entrée, copie en sortie, lâche le verrou. Plus tu tiens un mutex sur une boucle de contrôle, plus ça commence à ressembler à un appel bloquant qui attend de se produire.

state.c
// Cross-core flags that don't need a lock at all.
static atomic_bool g_overheat_latched = false;
static atomic_bool g_element_enabled  = false;
 
// Read from either core, no lock, no torn read, nothing to hold.
bool element_is_enabled(void) {
    return atomic_load_explicit(&g_element_enabled, memory_order_acquire);
}

Et ensuite la partie sans laquelle je ne livrerai pas un contrôleur de fournaise. Un chien de garde matériel. Le RP2040 en a un, et l'entente est simple: le fw doit le flatter à une cadence régulière, et s'il arrête un jour, la puce se réinitialise elle-même. J'ai câblé la flatterie dans le battement de cœur de la boucle de contrôle, le même drapeau que j'avais utilisé pour attraper le gel original, donc le chien de garde n'est nourri que lorsque core0 tourne réellement. Si core0 se coince pour une raison que je n'ai pas prévue, le chien de garde se déclenche, la puce redémarre, les relais reviennent dans leur valeur par défaut sûre, et le contrôleur repart de zéro.

main.c
void core0_control_loop(void) {
    watchdog_enable(WATCHDOG_TIMEOUT_MS, true);
    for (;;) {
        run_control_cycle();   // read thermocouple, decide relays
        watchdog_update();     // only reached if the cycle completed
        sleep_ms(CONTROL_PERIOD_MS);
    }
}

Le chien de garde n'est pas une excuse pour du code bâclé. C'est un aveu que je ne peux pas prouver l'absence de chaque coincement. Le fw auquel tu fais confiance, c'est celui que tu n'as pas encore attrapé en train de se suspendre.

Appels bloquants sur le chemin de contrôle
10
la sonde a disparu, elle n'est pas bornée
Pire gel possible
foreverreset
le chien de garde attrape ce que j'ai manqué

Ce qui s'est transféré

Ce contrôleur a existé sous trois formes: la version C sur le SDK Pico que je cite depuis le début, une réécriture en MicroPython, et une en TinyGo. Je continue de le porter en partie pour voir ce que chaque chaîne d'outils rend facile et en partie parce que j'aime ça. Le blocage se foutait du langage dans lequel c'était écrit. Une sonde de connectivité qui ouvre une connexion pour décider si elle peut ouvrir une connexion, c'est une erreur de logique, et une erreur de logique survit à la traduction. La version C est là où je me suis fait piquer. Je suis allé vérifier les deux autres quand même, et le port MicroPython avait la même forme de sonde assise là à attendre.

Deux règles en sont sorties. Premièrement, sur un appareil critique pour la sécurité, le correctif n'est pas de faire expirer l'appel bloquant. C'est de ne pas faire l'appel bloquant. Si tu peux lire l'état directement, lis-le; ne va pas sonder le monde pour redécouvrir quelque chose que le matériel t'a déjà dit. Deuxièmement, aie un chien de garde, et nourris-le à partir du travail, pas à partir d'une minuterie qui continue de battre que le travail se fasse ou non. Un chien de garde flatté par une minuterie en roue libre va joyeusement garder une fournaise coincée en vie.