URL: https://linuxfr.org/users/llumeao/journaux/hydromel-mon-langage-de-description-de-materiel-ideal Title: Hydromel : mon langage de description de matériel idéal ? Authors: Guillaume Savaton Date: 2022年04月14日T23:02:42+02:00 License: CC By-SA Tags: hdl et racket Score: 25 Sur LinuxFr, on me connaît (ou pas) comme le développeur du logiciel de présentation [Sozi](https://sozi.baierouge.fr/) mais ce n'est pas ma principale activité. Loin du JavaScript et du SVG, mon travail quotidien relève en fait du domaine des systèmes embarqués et des FPGA. Dans ce cadre, je pratique et j'enseigne le langage VHDL. J'anime également des TP d'initiation au langage Verilog, un peu par obligation. VHDL et Verilog appartiennent à la famille des *langages de description de matériel*, ou HDL pour *Hardware Description Languages*. Pourtant, pour des débutants, il est souvent difficile de comprendre le rapport entre le code que l'on écrit et le circuit que l'on devrait obtenir. VHDL, par exemple, ressemble à une sorte de langage de programmation concurrent avec des concepts et une terminologie très éloignés du domaine de l'électronique numérique : entité, architecture, instruction concurrente, instanciation, processus, liste de sensibilité, fonction de résolution, etc. Pour utiliser correctement les outils de synthèse automatique de circuits, il faut comprendre comment ils traduisent les constructions du langage en composants, et pourquoi ce n'est pas toujours possible. On apprend alors à sélectionner un sous-ensemble *synthétisable* du langage et à appliquer des bonnes pratiques de codage pour décrire des circuits combinatoires, des registres, des compteurs, des machines à états. VHDL peut alors paraître inutilement riche et verbeux pour l'utilisation que l'on en fait. Quelles sont les alternatives ? Ces dernières années, sont apparus de nouveaux langages qui prétendent *moderniser* le domaine de la modélisation de circuits ou de systèmes numériques. Beaucoup d'entre eux sont implémentés comme des *extensions* de langages de programmation (on utilise également le terme *Embedded Domain-Specific Language*). De manière peut-être simpliste, je dirais que ce sont des bibliothèques offrant des API pour la modélisation, la simulation et la synthèse de circuits. En voici quelques exemples : * En C++ : [SystemC](https://systemc.org/) * En Python : [MyHDL](https://www.myhdl.org/), [PyMTL](https://pymtl.github.io/), [Migen/FHDL](https://m-labs.hk/gateware/migen/) * En Scala : [Chisel](https://www.chisel-lang.org/), [SpinalHDL](https://spinalhdl.github.io/SpinalDoc-RTD/master/index.html) * En Haskell : [Clash](https://clash-lang.org/). Je ne les pas tous essayés, et je ne saurais pas donner un avis éclairé sur la plupart d'entre eux. Aucun ne m'a totalement convaincu mais j'en ai retiré quelques idées pour imaginer Hydromel, mon langage de description de matériel idéal. Clash a été la principale source d'inspiration, et c'est pourquoi il mérite un petit paragraphe dans ce journal. Un aperçu du langage Clash ========================== Clash est un langage de description de matériel fonctionnel qui s'appuie sur Haskell. Il permet de décrire des circuits combinatoires et des circuits synchrones avec un ou plusieurs domaines d'horloge. De manière très naturelle, un circuit combinatoire peut être représenté par une simple fonction : ```haskell mac a b c = a + b * c ``` Un circuit séquentiel est représenté par une fonction qui transforme une séquence de valeurs en une autre séquence de valeurs. Cette notion de séquence est réalisée par le type `Signal` qui s'apparente à une liste infinie. Dans l'exemple ci-dessous, la fonction prédéfinie `register` est utilisée pour produire un signal `s` dont la valeur initiale est 0 et les valeurs suivantes sont calculés à l'aide de l'expression `s + 1`. Le signal d'horloge est implicite. ```haskell counter = s where s = register 0 (s + 1) ``` Clash propose également des fonctions pour faciliter la création de circuits selon les modèles de Moore et de Mealy. L'exemple ci-dessous implémente le calcul du plus grand diviseur commun de deux nombres par soustractions successives à l'aide de l'algorithme d'Euclide. La fonction `gcdStep` calcule l'état suivant du circuit en fonction de l'état courant et des entrées. La fonction `gcdResult` calcule les sorties en fonction de l'état courant : ```haskell gcdStep (a, b) (a0, b0, start) = if start then (a0, b0) else if a> b then (a - b, b) else if a < b then (a, b - a) else (a, b) gcdResult (a, b) = (a, a == b) gcd' :: HiddenClockResetEnable dom => Signal dom (Int, Int, Bool) -> Signal dom (Int, Bool) gcd' = moore gcdStep gcdResult (0, 0) ``` Dans ces exemples, Clash est clairement plus concis que VHDL, et il semble plus facile de comprendre à quoi ressemble le circuit que l'on décrit. En revanche, en s'appuyant sur Haskell, Clash apporte aussi sa propre complexité : * Comme en VHDL ou Verilog, certaines constructions du langage Haskell, et une partie de sa bibliothèque standard, ne sont pas utilisables pour générer du code synthétisable. On le découvre souvent à ses dépens. * Clash s'appuie sur le système de types d'Haskell mais subit également ses limites. Par exemple, si je veux appliquer une fonction `f` sur les valeurs d'un signal `s`, je ne peux pas simplement écrire `f s`. Je dois comprendre que le type `Signal` implémente la classe `Functor` et écrire : `fmap f s`. * Enfin, même si l'idée de décrire chaque composant par une fonction est séduisante, je trouve la notion d'entité VHDL plus lisible pour décrire leurs interfaces. Je crée mon langage de description de matériel ============================================== Pour implémenter Hydromel, j'ai choisi d'utiliser le langage [Racket](https://racket-lang.org/) et de mettre à l'épreuve ses qualités de *Language-Oriented Programming Language*. J'ai procédé par étapes et j'ai relaté mes premières expérience dans deux séries d'articles de blog (en anglais) : * Dans la série [My first domain-specific language with Racket](http://guillaume.baierouge.fr/2020/11/08/my-first-domain-specific-language-with-racket/index.html), je me pose la question de la méthodologie à suivre pour développer un langage avec Racket. J'y détaille un exemple un peu plus sophistiqué que ceux habituellement traités dans les tutoriels. J'explique quels obstacles j'ai rencontrés et comment je les ai surmontés. * Dans la série [Simulating digital circuits in Racket](http://guillaume.baierouge.fr/2021/03/14/simulating-digital-circuits-in-racket/index.html), je m'intéresse au modèle de calcul du langage Clash et j'en propose une implémentation en Racket. La série culmine avec un exemple complet : [un processeur inspiré de l'architecture RISC-V](http://guillaume.baierouge.fr/2021/04/23/simulating-digital-circuits-in-racket/a-risc-v-core-in-racket/index.html). Pour dissiper tout malentendu, je tiens à préciser deux choses. Contrairement aux langages mentionnés en introduction, Hydromel est un langage autonome ; ce n'est pas un *extension* de Racket. La syntaxe d'Hydromel n'est pas basée sur des S-expressions ; elle contient un nombre raisonnable de parenthèses et ressemble plus à VHDL qu'à Lisp. Où en sommes-nous ? ------------------- À l'heure actuelle, la définition du langage Hydromel est suffisamment aboutie pour répondre aux besoins les plus courants. Pour m'en convaincre, j'ai réécrit mon exemple de processeur RISC-V en Hydromel. Son code source est à votre disposition [dans ce dépôt](https://github.com/aumouvantsillage/Virgule-CPU). À ce jour, l'implémentation de référence réalise les opérations suivantes : 1. L'analyse syntaxique. 2. L'analyse sémantique. 3. La vérification des types. 4. La simulation. L'étape suivante consistera à développer un convertisseur d'Hydromel vers VHDL ou Verilog pour cibler les outils de synthèse. Hydromel par l'exemple ====================== Nous allons utiliser Hydromel pour décrire un circuit *file d'attente* (FIFO) utilisant le [protocole de synchronisation *ready/valid*](http://www.cjdrake.com/readyvalid-protocol-primer.html). Dans un premier temps, nous proposerons plusieurs variantes d'un composant `fifo1` capable de mémoriser une valeur. Ensuite, nous en mettrons plusieurs instances en cascade pour réaliser des files d'attente plus longues. Une FIFO à un élément --------------------- Pour commencer, précisons que les fichiers sources Hydromel devront toujours commencer par la ligne : ``` #lang hydromel ``` Cette ligne est utilisée par Racket pour charger la définition du langage qui servira à traiter le reste du fichier. Commençons par déclarer les ports du composant `fifo1`. En Hydromel, un composant est équivalent à un couple entité-architecture VHDL, ou à un module Verilog. Le composant `fifo1` possède un paramètre `T` qui correspond au type des données que la FIFO transportera. ``` #lang hydromel component fifo1(T : type) # Les ports du côté "consommateur" de la FIFO. port c_valid : in bit port c_ready : out bit port c_data : in T # Les ports du côté "producteur" de la FIFO. port p_valid : out bit port p_ready : in bit port p_data : out T ... end ``` Son comportement respectera ce graphe d'états : ![Graphe d'états d'une FIFO à un élément](http://www.plantuml.com/plantuml/png/SoWkIImgAStDuOhMYbNGrRLJSCqjAAdauW9JO06tqfmS1Ik5vFYon9pC58N4l1I5lFmIXOBuejJ4b4e5_PYyygBCabI83f12Y0ISNm3LQ0ip0aac9R4a4IG1Pd1nWQm2smbL0R88sMAOfGY5c8x3T1XC3LJS5iKKX0EXN8IhYY2kSaZDIm454000) * Dans l'état *Empty* : * La FIFO est toujours prête à accepter de nouvelles données (`c_ready = 1`). * Elle se comporte de manière *transparente*, c'est-à-dire qu'elle copie les entrées `c_valid` et `c_data` sur les sorties `p_valid` et `p_data`. * Si une nouvelle donnée est disponible en entrée (`c_valid = 1`) pendant que le côté producteur est bloqué (`p_ready = 0`), la FIFO mémorise `c_data` (`write = 1`) dans un registre (que nous appellerons `r_data`) et passe dans l'état *Full*. * Dans l'état *Full* : * la FIFO signale qu'elle a une donnée disponible (`p_valid = 1`). * Il s'agit de la dernière donnée qui a été mémorisée (`p_data = r_data`). * La FIFO est prête à accepter une nouvelle donnée à chaque fois que le côté producteur est débloqué (`c_ready = p_ready`). * Si une donnée est disponible en entrée au moment où la donnée de sortie est consommée (`c_valid = 1` et `p_ready = 1`), on peut écraser le registre `r_data` (`write = 1`) et la FIFO reste pleine. * Si la donnée de sortie est consommée (`p_ready = 1`) et si aucun nouvelle donnée n'arrive en entrée (`c_valid = 0`), la FIFO retourne dans l'état *Empty*. Je propose de représenter l'état par un signal `full` sur un bit mémorisé dans une bascule. Sa valeur initiale est 0 et à chaque front d'horloge, il est mis à jour avec le résultat de l'expression `if` ci-dessous : ``` signal full : bit = register(0, if full then c_valid or not p_ready else c_valid and not p_ready) ``` Le plus souvent, le type d'un signal peut être déterminé automatiquement à partir de l'expression qui lui est affectée lorsqu'il n'y a pas de dépendance circulaire. J'ai dû indiquer le type du signal `full` explicitement mais je n'ai pas besoin de le faire pour ces deux autres signaux : ``` signal write = c_valid and (if full then p_ready else not p_ready) signal r_data = register(zero(T), c_data when write) ``` Le signal `r_data` mémorise la valeur de `c_data` lorsque `write` est vrai. Le mot-clé `when` utilisé dans le deuxième argument de `register` correspond à l'entrée `enable` du registre. Comme le type `T` n'est pas connu, on peut utiliser la fonction `zero` pour obtenir une valeur nulle de ce type. Par exemple, si `T` est un type tableau, `zero(T)` retournera un tableau de zéros. Pour finir, voici les affectations des ports de sortie : ``` c_ready = p_ready or not full p_valid = c_valid or full p_data = if full then r_data else c_data ``` Et la description complète du composant `fifo1` : ``` #lang hydromel component fifo1(T : type) port c_valid : in bit port c_ready : out bit port c_data : in T port p_valid : out bit port p_ready : in bit port p_data : out T signal full : bit = register(0, if full then c_valid or not p_ready else c_valid and not p_ready) signal write = c_valid and (if full then p_ready else not p_ready) signal r_data = register(zero(T), c_data when write) c_ready = p_ready or not full p_valid = c_valid or full p_data = if full then r_data else c_data end ``` L'interface `producer` ---------------------- L'interface de `fifo1` est composée de deux groupes de ports qui se ressemblent beaucoup. Pourquoi ne pas les déclarer dans une interface que l'on pourrait réutiliser à volonté ? ``` interface producer(T : type) port valid : out bit port ready : in bit port data : out T end component fifo1(T : type) port c : flip producer(T) port p : producer(T) ... end ``` `c` et `p` sont des ports *composites*. Le mot-clé `flip` permet d'utiliser l'interface `producer` en inversant le sens de ses ports. On évite ainsi de déclarer une interface `consumer`. On modifie également le corps du composant pour accéder aux ports `valid`, `ready` et `data` à partir des ports `c` et `p` : ``` signal full : bit = register(0, if full then c.valid or not p.ready else c.valid and not p.ready) signal write = c.valid and (if full then p.ready else not p.ready) signal r_data = register(zero(T), c.data when write) c.ready = p.ready or not full p.valid = c.valid or full p.data = if full then r_data else c.data ``` L'interface `conducer` ---------------------- Dans la suite de cet article, nous allons créer d'autres composants qui auront un port consommateur et un port producteur. Regroupons `c` et `p` dans une interface `conducer` : ``` interface conducer(T : type) port c : flip producer(T) port p : producer(T) end component fifo1(T : type) port cp : conducer(T) ... end ``` Par contre, ce ne serait pas très joli de devoir écrire `cp.c.valid`, `cp.c.ready`, `cp.c.data`, etc. Ajoutons le mot-clé `splice` dans la déclaration de `cp` pour que ses ports `c` et `p` soient directement accessibles dans `fifo1` comme avant : ``` component fifo1(T : type) port cp : splice conducer(T) signal full : bit = register(0, if full then c.valid or not p.ready else c.valid and not p.ready) signal write = c.valid and (if full then p.ready else not p.ready) signal r_data = register(zero(T), c.data when write) c.ready = p.ready or not full p.valid = c.valid or full p.data = if full then r_data else c.data end ``` Une FIFO à deux éléments ------------------------ Le composant `fifo2` expose l'interface `conducer` et se compose de deux instances de `fifo1` en cascade : ``` import "fifo1.mel" component fifo2(T : type) port cp : splice conducer(T) instance f = fifo1(T) f.c.valid = c.valid f.c.data = c.data c.ready = f.c.ready instance g = fifo1(T) g.c.valid = f.p.valid g.c.data = f.p.data f.p.ready = g.c.ready p.valid = g.p.valid p.data = g.p.data g.p.ready = p.ready end ``` Pour alléger l'écriture, on peut *connecter* deux ports composites qui ont la même interface en une seule instruction : ``` component fifo2(T : type) port cp : splice conducer(T) instance f = fifo1(T) f.c = c instance g = fifo1(T) g.c = f.p p = g.p end ``` Une FIFO à N éléments --------------------- Nous arrivons finalement à la description d'un composant `fifo` de longueur `N` réglable. On peut l'écrire de façon récursive. L'instruction `if` utilisée ci-dessous est analogue à l'instruction `if-generate` de VHDL. ``` component fifo_rec(T : type, N : natural) port cp : splice conducer(T) if N == 0 then p = c else instance f = fifo1(T) f.c = c instance g = fifo_rec(T, N-1) g.c = f.p p = g.p end end ``` On peut également l'écrire de façon itérative, avec une boucle `for` qui correspond à l'instruction `for-generate` de VHDL : ``` component fifo(T : type, N : natural) port cp : splice conducer(T) if N == 0 then p = c else instance f = fifo1(T) f<0>.c = c for n in 1 .. N-1 loop f.c = f.p end p = f.p end end ``` Petit détail de syntaxe : en Hydromel, on distingue les délimiteurs `[...]`, qui sont utilisés pour manipuler des valeurs de type tableau, et les délimiteurs `<...>`, qui permettent de manipuler des tableaux de ports *composites* ou des tableaux d'instances. Conclusion ========== Il y aurait encore beaucoup de choses à écrire mais ce journal est déjà bien long et il faut rester raisonnable. En l'écrivant et en travaillant sur les exemples, j'ai remis en question certains choix. J'ai même découvert et corrigé des bugs passés inaperçus jusque-là. Hydromel n'est pas encore utilisable pour des projets sérieux et je ne sais pas s'il le sera un jour. Il n'y a peut-être même pas de *marché* pour un tel langage aujourd'hui. Évidemment, pour aller plus loin, il faudra encore écrire un convertisseur vers VHDL ou Verilog pour la synthèse. Ce ne sera pas facile. Et pour l'utilisation quotidienne, il faudra améliorer les messages d'erreur, accélérer le simulateur, et documenter tout ça. Si ce journal vous a intéressé, vous pouvez visiter [le projet Hydromel sur GitHub](https://github.com/aumouvantsillage/Hydromel-lang) et [consulter la description d'un processeur, simple mais complet, inspiré de l'architecture RISC-V](https://github.com/aumouvantsillage/Virgule-CPU). Le code source des deux projets est disponible sous les conditions de la licence MPL 2.0.

AltStyle によって変換されたページ (->オリジナル) /