- Avril 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 :
- 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
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 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.
Commentaires :
voir le flux Atom
ouvrir dans le navigateur