Cet article montre comment tout concepteur de logiciels temps-réel enfouis pour microcontrôleurs (MCU) ou processeurs de traitement du signal (DSP), insatisfait comme moi des lourdeurs et coûts de leurs environnements intégrés de développement commerciaux, peut créer à peu de frais ses propres environnements hôtes de développement incrémental, qui lui permettront une testabilité aisée et donc des cycles de mise au point rapides grâce à une interactivité exceptionnelle avec son matériel cible et son logiciel temps-réel embarqué.
Depuis les débuts de l'informatique, deux conceptions différentes du cycle de développement se sont cotoyées :
Cette comparaison, de mon point de vue, montre que la différence entre langages dits compilés et langages dits interprétés se situe moins au niveau des techniques de traduction des langages sources, rédigés par un humain, en code machine (traduction faite une seule fois pour plusieurs exécutions du code machine dans le cas des langages compilés, ou faite à chaque exécution dans le cas des langages interprétés), qu'au niveau de l'interactivité procurée par les langages interprétés, alors que les langages compilés offrent des quantités d'options de paramétrage des outils de leur chaîne de compilation.
Les langages dits interprétés sont réputés moins rapides que les compilés, moins à cause du potentiel d'optimisations plus globales (rarement exploité, d'après mes constatations) des langages compilés, qu'à cause du surcoût d'interprétation soit directement du code source, soit du code intermédiaire que certains langages dits interprétés génèrent, autant pour des raisons de portabilité que de compacité (comme c'est aussi le cas par exemple du "P-code" Pascal ou de la "machine virtuelle" Java). Cette réputation est controversée (tout comme les programmes de test choisis pour comparer les performances), en particulier depuis l'introduction des techniques de compilation "à la volée" qui génèrent du code machine proche de celui des langages dits compilés, à partir du code source (cf par exemple FreeForth du même auteur) ou intermédiaire (cf compilateurs "Just-In-Time" de Smalltalk et Java), lors de son chargement depuis la mémoire lente de stockage non volatile (disque ou ROM ou flash) vers la memoire rapide d'exécution (RAM).
Compilateurs comme interpréteurs ont pour principale tâche de traduire les noms/symboles choisis par le développeur en adresses mémoire fixes (dans le cas des variables globales ou des destinations d'instructions de saut) ou relatives (à un pointeur de pile, dans le cas de variables locales, ou à une adresse de base, dans le cas de champs de structures de données).
Les langages compilés accomplissent cette tâche en plusieurs "passes" : analyse syntaxique et grammaticale du source traduit en un arbre, simplifié par réduction des expressions constantes et factorisation des sous-expressions communes, allocation des registres, assemblage de sections séquentielles de code machine et de données, résolution des adresses des instructions de saut et des variables, et génération finale d'un fichier chargeable en mémoire vive pour exécution.
Les langages interprétés accomplissent cette tâche en une seule passe,
au fur et à mesure de "l'interprétation" du source :
l'adresse d'une variable ou de de la destination d'une instruction de saut
est définie lors de l'analyse du source qui la déclare, car l'allocation
mémoire se fait au fur et à mesure de l'analyse du source,
souvent avec des pointeurs d'allocation code et données séparés,
surtout dans le cas où ces deux mémoires sont distinctes
(par exemple flash et ram, ou dans le cas de machines de type "Harvard").
Les références arrières, aux addresses de variables ou de saut
déjà déclarées, sont résolues sans problème puisque déjà connues.
Les références avant ne sont généralement pas admises pour les
variables et pour les points d'entrée de fonctions/sous-programmes
(qu'il est en général facile de déclarer avant utilisation) ;
pour les structures de contrôle, l'addresse de compilation d'une instruction
de saut avant est mémorisée, puis retrouvée lors de l'analyse ultérieure
du symbole déclarant l'addresse destination du saut, ce qui permet
alors de résoudre l'argument de l'instruction de saut avant.
Pour supporter l'imbrication des structures de contrôle, les addresses
des instructions de saut avant à résoudre sont mémorisées sur un pile
de compilation LIFO ("Last-In, First-Out" = dernière empilée, première dépilée).
Cette technique de compilation "à la volée" des structures de contrôle
est particulièrement rapide, et aussi simple à comprendre qu'à implémenter.
Chaque langage a sa propre syntaxe, nécessitant un analyseur lexical plus ou moins complexe. Celle des dialectes Forth est particulièrement simple : les unités lexicales, appelées "mots" en Forth, sont séparées par des blancs (espace, saut de ligne, etc.) et sont composées de tous caractères exceptés les blancs ; chaque mot prend les arguments dont il a besoin sur une pile LIFO "de données", où il retourne aussi ses résultats.
Chaque langage a sa propre grammaire, nécessitant un analyseur grammatical plus ou moins complexe. Celle du Forth est particulièrement simple : chaque mot est défini, et associé dans un dictionnaire, par une phrase appelée "définition" (ou "sous-programme" ou "fonction" pour les autres langages) composée d'une séquence de mots, dont l'exécution séquentielle peut être interrompue par des sauts, soit à l'intérieur d'une même définition (par des mots de saut avant ou arrière, conditionnel ou non), soit entre définitions (pour l'appel et le retour de sous-programme).
Chaque langage a son propre compilateur, plus ou moins complexe en fonction des optimisations qu'il tente d'effectuer. Celui du Forth est constitué simplement d'une boucle principale isolant les mots du source et les compilant l'un après l'autre, en fonction du résultat de la recherche dans le dictionnaire :
Le développement d'un programme consiste en général à étendre le langage de programmation en ajoutant à son dictionnaire de nouveaux mots, dont la définition est composée de mots déjà présents dans le dictionnaire (c'est-à-dire soit précédemment définis, soit "déclarés" auparavant pour permettre au développeur de définir les mots dans l'ordre qui lui convient).
Le test d'un programme est plus aisé quand ses composants ont été préalablement testés. Bien tester un programme, ou un de ses composants, nécessite sa mise en interaction avec tous ses contextes d'exécution possibles.
Le test est malcommode et coûteux avec un langage compilé et son débogueur. Pour bien faire il faut souvent écrire pour le test plus de code (qu'il faut tester aussi) que pour le composant testé, et comme tout débogueur nécessite l'arrêt d'un programme pour en observer l'état, on ne peut l'utiliser pour les tests en temps réel.
Le test est plus aisé avec un environnement de développement incrémental, qui permet l'édition/exécution interactive (ou en fichier de traitement par lot pour les tests automatisés) de petites définitions préparant chacune un contexte d'exécution (souvent en ne modifiant qu'une petite partie du contexte d'exécution courant), puis exécutant en temps réel le mot à tester dans ce contexte, puis affichant le résultat du test (ou le vérifiant et affichant le cas échéant un message d'erreur, pour les tests automatisés).
Ces petites définitions de test ne sont jamais utilisées comme composants du code à tester, et elles ne sont normalement exécutées qu'une seule fois (sauf si le test échoue et nécessite une modification du mot testé, et donc une nouvelle exécution du test), donc chacune peut être exécutée aussitôt compilée, et son code compilé peut être oublié/effacé aussitôt exécuté, sans qu'il soit nécessaire de lui associer un nom dans le dictionnaire : c'est pourquoi FreeForth nomme ces petites définitions temporaires "définitions anonymes", qui lui donnent toute son interactivité.
Note : les autres dialectes Forth procurent leur interactivité non pas au moyen de définitions anonymes, mais au moyen d'un "mode interprétation" qui exécute chaque mot aussitôt trouvé dans le dictionnaire, ce qui exclut l'utilisation des mots immédiats réservés au "mode compilation" (dont ceux des structures de contrôle, sauf ceux dits "STATE smart" plus complexes qui se comportent différement en fonction du mode, contenu dans la variable "STATE"), et ce qui fait que l'exécution d'une phrase en mode interprétation prend plus de temps que l'exécution de la même phrase compilée, ce qui biaise les tests en temps réel.
Il est rare de nos jours qu'un MCU ou un DSP soit embarqué avec des ressources matérielles suffisantes pour héberger son propre système de développement, qui est plus confortablement hébergé par un PC "hôte", communicant par un lien "ombilical" avec le MCU ou DSP "cible", qui exécute un "moniteur" permettant principalement de charger, dans la mémoire du processeur cible, du code compilé par le PC hôte, et de lancer son exécution lorsqu'il s'agit de code de test (dont l'exécution se terminera par un retour au moniteur).
Le code exécutable du moniteur ne peut être ni chargé ni testé au moyen du moniteur lui-même : il doit donc être le plus simple possible pour que sa conception nécessite le minimum de mise au point, et doit être chargé en mémoire cible par les moyens conçus par le constructeur du processeur cible (programmation d'une EPROM externe, ou utilisation d'une liaison JTAG ou d'un programme "bootloader" en ROM interne du processeur cible).
Le moniteur le plus simple que j'aie conçu comprend :
Le moniteur que j'ai réalisé dans tous mes environnements de développement incrémental ombilical comprend un seul programme exécuté au démarrage :
À suivre... CL20100626