Comment créer une FIFO AXI dans un bloc de RAM à l'aide de la poignée de main prête/valide
J'étais un peu agacé par les particularités de l'interface AXI la première fois que j'ai dû créer une logique pour interfacer un module AXI. Au lieu des signaux de contrôle habituels occupé/valide, plein/valide ou vide/valide, l'interface AXI utilise deux signaux de contrôle nommés « prêt » et « valide ». Ma frustration s'est rapidement transformée en admiration.
L'interface AXI dispose d'un contrôle de flux intégré sans utiliser de signaux de contrôle supplémentaires. Les règles sont assez faciles à comprendre, mais il y a quelques pièges dont il faut tenir compte lors de la mise en œuvre de l'interface AXI sur un FPGA. Cet article vous montre comment créer une FIFO AXI en VHDL.
AXI résout le problème du retard d'un cycle
La prévention de la surlecture et de l'écrasement est un problème courant lors de la création d'interfaces de flux de données. Le problème est que lorsque deux modules logiques cadencés communiquent, chaque module ne pourra lire les sorties de son homologue qu'avec un retard de cycle d'horloge.
L'image ci-dessus montre le chronogramme d'un module séquentiel écrivant dans un FIFO qui utilise le write enable/full schéma de signalisation. Un module d'interfaçage écrit des données dans le FIFO en affirmant le wr_en
signal. Le FIFO affirmera le full
signaler lorsqu'il n'y a pas de place pour un autre élément de données, invitant la source de données à arrêter l'écriture.
Malheureusement, le module d'interfaçage n'a aucun moyen de s'arrêter dans le temps tant qu'il n'utilise que la logique cadencée. Le FIFO lève le full
drapeau exactement au front montant de l'horloge. Simultanément, le module d'interfaçage tente d'écrire la donnée suivante. Il ne peut pas échantillonner et réagir au full
signaler avant qu'il ne soit trop tard.
Une solution consiste à inclure un almost_empty
supplémentaire signal, nous l'avons fait dans le didacticiel Comment créer un tampon FIFO en anneau en VHDL. Le signal supplémentaire précède le empty
signal, laissant au module d'interfaçage le temps de réagir.
La poignée de main prêt/valide
Le protocole AXI implémente le contrôle de flux en utilisant seulement deux signaux de contrôle dans chaque direction, un appelé ready
et l'autre valid
. Le ready
signal est contrôlé par le récepteur, un '1'
logique valeur sur ce signal signifie que le récepteur est prêt à accepter un nouvel élément de données. Le valid
signal, d'autre part, est contrôlé par l'expéditeur. L'expéditeur doit définir valid
à '1'
lorsque les données présentées sur le bus de données sont valides pour l'échantillonnage.
Voici la partie importante : le transfert de données ne se produit que lorsque ready
et valid
sont '1'
au même cycle d'horloge. Le récepteur informe lorsqu'il est prêt à accepter des données, et l'expéditeur met simplement les données là-bas lorsqu'il a quelque chose à transmettre. Le transfert se produit lorsque les deux sont d'accord, lorsque l'expéditeur est prêt à envoyer et que le destinataire est prêt à recevoir.
La forme d'onde ci-dessus montre un exemple de transaction d'un élément de données. L'échantillonnage se produit sur le front montant de l'horloge, comme c'est généralement le cas avec la logique cadencée.
Mise en œuvre
Il existe de nombreuses façons d'implémenter une FIFO AXI en VHDL. Il pourrait s'agir d'un registre à décalage, mais nous utiliserons une structure de tampon en anneau car c'est le moyen le plus simple de créer un FIFO dans la RAM de bloc. Vous pouvez tout créer dans un processus géant à l'aide de variables et de signaux, ou vous pouvez diviser la fonctionnalité en plusieurs processus.
Cette implémentation utilise des processus séparés pour la plupart des signaux qui doivent être mis à jour. Seuls les processus devant être synchrones sont sensibles à l'horloge, les autres utilisent la logique combinatoire.
L'entité
La déclaration d'entité comprend un port générique qui est utilisé pour définir la largeur des mots d'entrée et de sortie, ainsi que le nombre d'emplacements pour lesquels réserver de l'espace dans la RAM. La capacité du FIFO est égale à la profondeur de la RAM moins un. Un emplacement est toujours laissé vide pour faire la distinction entre une FIFO pleine et une FIFO vide.
entity axi_fifo is generic ( ram_width : natural; ram_depth : natural ); port ( clk : in std_logic; rst : in std_logic; -- AXI input interface in_ready : out std_logic; in_valid : in std_logic; in_data : in std_logic_vector(ram_width - 1 downto 0); -- AXI output interface out_ready : in std_logic; out_valid : out std_logic; out_data : out std_logic_vector(ram_width - 1 downto 0) ); end axi_fifo;
Les deux premiers signaux de la déclaration de port sont les entrées d'horloge et de réinitialisation. Cette implémentation utilise une réinitialisation synchrone et est sensible au front montant de l'horloge.
Il existe une interface d'entrée de style AXI utilisant les signaux de contrôle prêts/valides et un signal de données d'entrée de largeur générique. Vient enfin l'interface de sortie AXI avec des signaux similaires à ceux de l'entrée, mais avec des directions inversées. Les signaux appartenant à l'interface d'entrée et de sortie sont préfixés par in_
ou out_
.
La sortie d'un FIFO AXI peut être connectée directement à l'entrée d'un autre, les interfaces s'emboîtent parfaitement. Cependant, une meilleure solution que de les empiler serait d'augmenter le ram_depth
générique si vous voulez un FIFO plus grand.
Déclarations de signal
Les deux premières instructions de la région déclarative du fichier VHDL déclarent le type de RAM et son signal. La RAM est dimensionnée dynamiquement à partir des entrées génériques.
-- The FIFO is full when the RAM contains ram_depth - 1 elements type ram_type is array (0 to ram_depth - 1) of std_logic_vector(in_data'range); signal ram : ram_type;
Le deuxième bloc de code déclare un nouveau sous-type entier et quatre signaux à partir de celui-ci. Le index_type
est dimensionné pour représenter exactement la profondeur de la RAM. Le head
Le signal indique toujours l'emplacement RAM qui sera utilisé lors de la prochaine opération d'écriture. Le tail
le signal pointe vers l'emplacement auquel on accédera lors de la prochaine opération de lecture. La valeur du count
signal est toujours égal au nombre d'éléments actuellement stockés dans la FIFO, et count_p1
est une copie du même signal retardée d'un cycle d'horloge.
-- Newest element at head, oldest element at tail subtype index_type is natural range ram_type'range; signal head : index_type; signal tail : index_type; signal count : index_type; signal count_p1 : index_type;
Viennent ensuite deux signaux nommés in_ready_i
et out_valid_i
. Ce ne sont que des copies des sorties d'entité in_ready
et out_valid
. Le _i
suffixe signifie simplement interne , cela fait partie de mon style de codage.
-- Internal versions of entity signals with mode "out" signal in_ready_i : std_logic; signal out_valid_i : std_logic;
Enfin, nous déclarons un signal qui sera utilisé pour indiquer une lecture et une écriture simultanées. J'expliquerai son but plus loin dans cet article.
-- True the clock cycle after a simultaneous read and write signal read_while_write_p1 : std_logic;
Sous-programmes
Après les signaux, nous déclarons une fonction pour incrémenter notre index_type
personnalisé . Le next_index
la fonction regarde le read
et valid
paramètres pour déterminer s'il y a une transaction de lecture ou de lecture/écriture en cours. Si tel est le cas, l'index sera incrémenté ou enveloppé. Sinon, la valeur d'index inchangée est renvoyée.
function next_index( index : index_type; ready : std_logic; valid : std_logic) return index_type is begin if ready = '1' and valid = '1' then if index = index_type'high then return index_type'low; else return index + 1; end if; end if; return index; end function;
Pour nous éviter des saisies répétitives, nous créons la logique de mise à jour du head
et tail
signaux dans une procédure, au lieu de deux processus identiques. Le update_index
la procédure prend les signaux d'horloge et de réinitialisation, un signal de index_type
, un ready
signal, et un valid
signal comme entrées.
procedure index_proc( signal clk : in std_logic; signal rst : in std_logic; signal index : inout index_type; signal ready : in std_logic; signal valid : in std_logic) is begin if rising_edge(clk) then if rst = '1' then index <= index_type'low; else index <= next_index(index, ready, valid); end if; end if; end procedure;
Ce processus entièrement synchrone utilise le next_index
fonction pour mettre à jour le index
Signal lorsque le module n'est plus réinitialisé. Lors de la réinitialisation, le index
signal sera défini sur la valeur la plus basse qu'il peut représenter, qui est toujours 0 en raison de la façon dont index_type
et ram_type
est déclaré. Nous aurions pu utiliser 0 comme valeur de réinitialisation, mais j'essaie autant que possible d'éviter le codage en dur.
Copier les signaux internes vers la sortie
Ces deux instructions simultanées copient les versions internes des signaux de sortie vers les sorties réelles. Nous devons opérer sur des copies internes car VHDL ne nous permet pas de lire les signaux d'entité avec le mode out
à l'intérieur du module. Une alternative aurait été de déclarer in_ready
et out_valid
avec mode inout
, mais la plupart des normes de codage des entreprises restreignent l'utilisation de inout
signaux d'entité.
in_ready <= in_ready_i; out_valid <= out_valid_i;
Mettez à jour l'en-tête et la queue
Nous avons déjà discuté du index_proc
procédure qui permet de mettre à jour le head
et tail
signaux. En mappant les signaux appropriés aux paramètres de ce sous-programme, nous obtenons l'équivalent de deux processus identiques, l'un pour contrôler l'entrée FIFO et l'autre pour la sortie.
-- Update head index on write PROC_HEAD : index_proc(clk, rst, head, in_ready_i, in_valid); -- Update tail index on read PROC_TAIL : index_proc(clk, rst, tail, out_ready, out_valid_i);
Étant donné que le head
et le tail
sont mis à la même valeur par la logique de réinitialisation, le FIFO sera initialement vide. C'est ainsi que fonctionne ce tampon en anneau, lorsque les deux pointent vers le même index, cela signifie que le FIFO est vide.
Déduire la RAM du bloc
Dans la plupart des architectures FPGA, les primitives de bloc RAM sont des composants entièrement synchrones. Cela signifie que si nous voulons que l'outil de synthèse déduit la RAM de bloc de notre code VHDL, nous devons placer les ports de lecture et d'écriture à l'intérieur d'un processus cadencé. De plus, il ne peut y avoir aucune valeur de réinitialisation associée au bloc RAM.
PROC_RAM : process(clk) begin if rising_edge(clk) then ram(head) <= in_data; out_data <= ram(next_index(tail, out_ready, out_valid_i)); end if; end process;
Il n'y a pas d'autorisation de lecture ou autorisation d'écriture ici, ce serait trop lent pour AXI. Au lieu de cela, nous écrivons en permanence dans l'emplacement de RAM pointé par le head
indice. Ensuite, lorsque nous déterminons qu'une transaction d'écriture s'est produite, nous avançons simplement le head
pour verrouiller la valeur écrite.
De même, out_data
est mis à jour à chaque cycle d'horloge. Le tail
le pointeur se déplace simplement vers l'emplacement suivant lorsqu'une lecture se produit. Notez que le next_index
La fonction est utilisée pour calculer l'adresse du port de lecture. Nous devons faire cela pour nous assurer que la RAM réagit assez rapidement après une lecture et commence à sortir la valeur suivante.
Compter le nombre d'éléments dans le FIFO
Compter le nombre d'éléments dans la RAM consiste simplement à soustraire le head
du tail
. Si le head
s'est terminé, nous devons le compenser par le nombre total d'emplacements dans la RAM. Nous avons accès à ces informations via le ram_depth
constante de l'entrée générique.
PROC_COUNT : process(head, tail) begin if head < tail then count <= head - tail + ram_depth; else count <= head - tail; end if; end process;
Nous devons également garder une trace de la valeur précédente du count
signal. Le processus ci-dessous en crée une version retardée d'un cycle d'horloge. Le _p1
postfix est une convention de nommage pour l'indiquer.
PROC_COUNT_P1 : process(clk) begin if rising_edge(clk) then if rst = '1' then count_p1 <= 0; else count_p1 <= count; end if; end if; end process;
Mettre à jour le prêt sortie
Le in_ready
le signal doit être '1'
lorsque ce module est prêt à accepter une autre donnée. Cela devrait être le cas tant que le FIFO n'est pas plein, et c'est exactement ce que dit la logique de ce processus.
PROC_IN_READY : process(count) begin if count < ram_depth - 1 then in_ready_i <= '1'; else in_ready_i <= '0'; end if; end process;
Détecter la lecture et l'écriture simultanées
En raison d'un cas particulier que j'expliquerai dans la section suivante, nous devons être en mesure d'identifier les opérations de lecture et d'écriture simultanées. Chaque fois qu'il y a des transactions de lecture et d'écriture valides au cours du même cycle d'horloge, ce processus définira le read_while_write_p1
signaler à '1'
sur le cycle d'horloge suivant.
PROC_READ_WHILE_WRITE_P1: process(clk) begin if rising_edge(clk) then if rst = '1' then read_while_write_p1 <= '0'; else read_while_write_p1 <= '0'; if in_ready_i = '1' and in_valid = '1' and out_ready = '1' and out_valid_i = '1' then read_while_write_p1 <= '1'; end if; end if; end if; end process;
Mettre à jour le valide sortie
Le out_valid
signal indique aux modules en aval que les données présentées sur out_data
est valide et peut être échantillonné à tout moment. Le out_data
le signal provient directement de la sortie RAM. Implémenter le out_valid
le signal est un peu délicat en raison du délai de cycle d'horloge supplémentaire entre l'entrée et la sortie de la RAM du bloc.
La logique est implémentée dans un processus combinatoire afin qu'elle puisse réagir sans délai au changement du signal d'entrée. La première ligne du processus est une valeur par défaut qui définit le out_valid
signaler au '1'
. Ce sera la valeur dominante si aucune des deux instructions If suivantes n'est déclenchée.
PROC_OUT_VALID : process(count, count_p1, read_while_write_p1) begin out_valid_i <= '1'; -- If the RAM is empty or was empty in the prev cycle if count = 0 or count_p1 = 0 then out_valid_i <= '0'; end if; -- If simultaneous read and write when almost empty if count = 1 and read_while_write_p1 = '1' then out_valid_i <= '0'; end if; end process;
La première instruction If vérifie si le FIFO est vide ou était vide dans le cycle d'horloge précédent. Évidemment, le FIFO est vide lorsqu'il contient 0 éléments, mais nous devons également examiner le niveau de remplissage du FIFO dans le cycle d'horloge précédent.
Considérez la forme d'onde ci-dessous. Initialement, le FIFO est vide, comme indiqué par le count
le signal étant 0
. Ensuite, une écriture se produit au troisième cycle d'horloge. L'emplacement de RAM 0 est mis à jour au cycle d'horloge suivant, mais il faut un cycle supplémentaire avant que les données n'apparaissent sur le out_data
production. Le but du or count_p1 = 0
est de s'assurer que out_valid
reste '0'
(entouré en rouge) pendant que la valeur se propage dans la RAM.
La dernière instruction If protège contre un autre cas particulier. Nous venons de parler de la façon de gérer le cas particulier de l'écriture sur vide en vérifiant les niveaux de remplissage FIFO actuels et précédents. Mais que se passe-t-il si et nous effectuons une lecture et une écriture simultanées lorsque count
est déjà 1
?
La forme d'onde ci-dessous montre une telle situation. Initialement, il y a une donnée D0 présente dans la FIFO. Il est là depuis un certain temps, donc count
et count_p1
sont 0
. Ensuite, une lecture et une écriture simultanées surviennent dans le troisième cycle d'horloge. Un élément quitte le FIFO et un nouveau y entre, rendant les compteurs inchangés.
Au moment de la lecture et de l'écriture, il n'y a pas de valeur suivante dans la RAM prête à être sortie, comme il y en aurait eu si le niveau de remplissage était supérieur à un. Nous devons attendre deux cycles d'horloge avant que la valeur d'entrée n'apparaisse sur la sortie. Sans aucune information supplémentaire, il serait impossible de détecter ce cas d'angle, et la valeur de out_valid
au cycle d'horloge suivant (marqué en rouge continu) serait réglé par erreur sur '1'
.
C'est pourquoi nous avons besoin du read_while_write_p1
signal. Il détecte qu'il y a eu une lecture et une écriture simultanées, et nous pouvons en tenir compte en définissant out_valid
à '0'
dans ce cycle d'horloge.
Synthétiser dans Vivado
Pour implémenter la conception en tant que module autonome dans Xilinx Vivado, nous devons d'abord donner des valeurs aux entrées génériques. Ceci peut être réalisé dans Vivado en utilisant les Paramètres → Général → Génériques/Paramètres menu, comme indiqué dans l'image ci-dessous.
Les valeurs génériques ont été choisies pour correspondre à la primitive RAMB36E1 dans l'architecture Xilinx Zynq qui est le périphérique cible. L'utilisation des ressources après la mise en œuvre est illustrée dans l'image ci-dessous. Le FIFO AXI utilise un bloc de RAM et un petit nombre de LUT et de bascules.
AXI est plus que prêt/valide
AXI signifie Advanced eXtensible Interface, il fait partie de la norme AMBA (Advanced Microcontroller Bus Architecture) d'ARM. La norme AXI est bien plus que la poignée de main lecture/valide. Si vous voulez en savoir plus sur AXI, je vous recommande ces ressources pour une lecture plus approfondie :
- Wikipédia :AXI
- Présentation d'ARM AXI
- Présentation de Xilinx AXI
- Spécification AXI4
VHDL
- Le cloud et comment il change le monde informatique
- Comment tirer le meilleur parti de vos données
- Comment initialiser la RAM à partir d'un fichier à l'aide de TEXTIO
- Comment vous préparer pour l'IA à l'aide de l'IoT
- Comment l'Internet industriel change la gestion des actifs
- Meilleures pratiques en matière de suivi des actifs :comment tirer le meilleur parti de vos données d'actifs durement gagnées
- Comment obtenir une meilleure image de l'IoT ?
- Comment tirer le meilleur parti de l'IoT dans la restauration
- Comment les données permettent la chaîne d'approvisionnement du futur