Sommaire
- Un aperçu du langage Clash
- Je crée mon langage de description de matériel
- Hydromel par l'exemple
- Conclusion
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 :
- En C++ : SystemC
- En Python : MyHDL, PyMTL, Migen/FHDL
- En Scala : Chisel, SpinalHDL
- En Haskell : Clash.
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 signals
, je ne peux pas simplement écriref s
. Je dois comprendre que le typeSignal
implémente la classeFunctor
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) :
- Dans la série My first domain-specific language with Racket, 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, 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.
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 :
- L'analyse syntaxique.
- L'analyse sémantique.
- La vérification des types.
- 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 :
- 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
etc_data
sur les sortiesp_valid
etp_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émorisec_data
(write = 1
) dans un registre (que nous appelleronsr_data
) et passe dans l'état Full.
- La FIFO est toujours prête à accepter de nouvelles données (
- 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
etp_ready = 1
), on peut écraser le registrer_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.
- la FIFO signale qu'elle a une donnée disponible (
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 Nicolas Boulay (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 Guillaume Savaton (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 Nicolas Boulay (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 Guillaume Savaton (site web personnel) . Évalué à 2.
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 Nicolas Boulay (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 Axioplase ıɥs∀ (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à…
[^] # Re: Lava
Posté par Guillaume Savaton (site web personnel) . Évalué à 2.
En effet. J'aurais pu le citer avec Clash dans la liste des langages basés sur Haskell.
Lava est également cité dans la documentation officielle de Clash : Clash vs Lava.
Suivre le flux des commentaires
Note : les commentaires appartiennent à celles et ceux qui les ont postés. Nous n’en sommes pas responsables.