Journal Hydromel : mon langage de description de matériel idéal ?

Posté par  (site web personnel) . Licence CC By‑SA.
Étiquettes :
25
14
avr.
2022

Sommaire

Sur LinuxFr, on me connaît (ou pas) comme le développeur du logiciel de présentation Sozi 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 :

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 :

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.

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 :

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 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) :

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.

À 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. 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

  • 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<N> = fifo1(T)
        f<0>.c = c

        for n in 1 .. N-1 loop
            f<n>.c = f<n-1>.p
        end

        p = f<N-1>.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 et consulter la description d'un processeur, simple mais complet, inspiré de l'architecture RISC-V. Le code source des deux projets est disponible sous les conditions de la licence MPL 2.0.

  • # autre insipiration

    Posté par  (site web personnel) . Évalué à 4.

    Est-ce que tu connais le langage Esterel de Gérard Berry ? C'était un langage purement synchrone très efficace. Une version V7 était en cours de standardisation. Puis la boite a coulé.

    "La première sécurité est la liberté"

    • [^] # Re: autre insipiration

      Posté par  (site web personnel) . Évalué à 2.

      On en parlait beaucoup quand j'étais en thèse il y a 20 ans. Il était souvent mentionné avec les autres langages synchrones Signal et Lustre. Je ne les ai jamais utilisés personnellement.

      Je vois qu'il y a eu des travaux pour synthétiser du matériel à partir d'Esterel, mais je ne sais pas à quel point le langage est adapté à cet usage. Dans tous les cas, il semble assez orthogonal à l'approche que j'ai présentée dans ce journal : Esterel semble mettre l'accent sur la partie contrôle d'un système (émission et attente d'événements, processus qui peuvent être interrompus) alors que Clash et Hydromel s'appuient sur des fonctions pures qui manipulent des séquences de valeurs.

      • [^] # Re: autre insipiration

        Posté par  (site web personnel) . Évalué à 6.

        Esterel a été développer par Esterel technologie EDA qui a fait faillite en 2009. Le produit a été transféré à une filial de Xillinx qui faisait un produit concurrent.

        Texas Instrument a utilisé Esterel pour plusieurs blocks avec un gros succès. C'était quelques jours avant la crise de 2008. Le langage se synthétise très bien. Il y a eu des POC de dsp fait chez TI. Il était très naturelle de faire de mini FSM qui dialoguait, plutôt qu'une grosse FSM. Le dialogue de FSM en vhdl est un enfer. Ainsi, des spécifications sois-disant pour simplifier le code le complexifiait (pas de hit under miss pour un cache)

        Il y avait aussi un outil de preuve formel associé. Il était possible d'avoir des tests qui revenait à dire que telle sortie était toujours vrai. Le système trouvait le contre-exemple en quelques secondes.

        Lustre a été renommer en SCADE et existe toujours chez Esterel Technologies. SCADE 6 a eu les FSM de Esterel en plus. Il est plus orienté logiciel temps réel dur (code aéronautique, …). Je ne crois pas qu'il génère du VHDL ou autre. Je crois avoir entendu parler de générateur de code pour des puces "matrice de processeurs".

        Esterel Tech a été vendu à Ansys (le gros concurrent de Dassaut System) vers 2015.

        "La première sécurité est la liberté"

        • [^] # Re: autre insipiration

          Posté par  (site web personnel) . Évalué à 2.

          Le dialogue de FSM en vhdl est un enfer.

          Tout à fait d'accord. Et c'est sans doute vrai pour tous les langages lorsqu'on travaille à ce niveau d'abstraction.

          Ça fait partie des sujets que j'aimerais explorer : à partir d'un langage comme Hydromel, est-il possible de construire des abstractions de plus haut niveau, pour aller vers du Transaction-Level Modeling par exemple ?

          • [^] # Re: autre insipiration

            Posté par  (site web personnel) . Évalué à 4.

            Esterel propose un système d'event présent/absent plus facile à gérer qu'un booléen, les operateurs pre ou post pour gérer les registres.

            En 2000, il était question de behavioral compiler. L intérêt était surtout le code avec des wait, qui infére les FSM.

            "La première sécurité est la liberté"

  • # Lava

    Posté par  (site web personnel) . Évalué à 3.

    Ça me fait penser à Lava un langage de description matériel en Haskell. Enfin, ça date d'il y a plus de 20 ans déjà…

Suivre le flux des commentaires

Note : les commentaires appartiennent à celles et ceux qui les ont postés. Nous n’en sommes pas responsables.