Introduction
Cette page s’appuie sur le guide de programmation de Xillybus pour Linux. Pour une vue plus complète des sujets abordés ci-dessous, il est recommandé de consulter ce guide. Il existe aussi un guide similaire pour Microsoft Windows.
Si vous n’avez pas encore effectué le test « Hello, World », il est recommandé de le faire d’abord.
La communication avec le cœur IP de Xillybus (IP core) s’effectue à l’aide de fichiers de périphérique sur l’hôte. Ces fichiers sont accessibles comme des fichiers ordinaires. Toutefois, contrairement à un fichier normal, un fichier de périphérique ne représente pas des données stockées sur un disque : lire et écrire dans un fichier de périphérique donne lieu à des opérations d’entrées-sorties.
En conséquence, il est possible d’accéder aux fichiers de périphérique de Xillybus avec pratiquement tous les langages de programmation. On peut aussi utiliser les utilitaires en ligne de commande de Linux. On peut donc se demander pourquoi il faut discuter de ce sujet : si vous savez lire et écrire correctement des fichiers, vous savez travailler avec Xillybus.
Certes, il est possible d’écrire des programmes qui accèdent à des fichiers ordinaires sans une connaissance détaillée de l’API. Cela ne suffit toutefois pas pour travailler avec des entrées-sorties : l’interaction avec le matériel crée des situations qui se présentent rarement avec un fichier ordinaire. En tant que programmeur, il est nécessaire de savoir comment gérer ces situations à l’aide de l’API.
La grande majorité de cette page est donc consacrée à des sujets qui valent aussi pour des fichiers ordinaires. La différence avec les fichiers de périphérique est qu’ils pardonnent moins les erreurs d’utilisation de l’API.
Une mauvaise compréhension de l’API peut entraîner deux principaux types de confusion :
- La quantité de données d’une opération d’entrées-sorties peut être différente de celle attendue (en général inférieure).
- La communication avec le FPGA se produit plus tard que prévu.
Langages de programmation et systèmes d’exploitation
Avec Linux, tout outil ou langage de programmation capable d’accéder à un fichier fonctionne avec Xillybus.
Avec Windows, les langages de programmation courants comme C, C++, C#, Python, Perl et tout ce qui vient avec Cygwin ne posent aucun problème. Certains outils (par exemple MATLAB) peuvent toutefois refuser de travailler avec les fichiers de périphérique de Xillybus : ces outils détectent que le fichier de périphérique n’est pas un fichier ordinaire et considèrent cela comme une erreur. Il existe des solutions de contournement pour cette situation, consistant généralement à utiliser des extensions destinées aux entrées-sorties de bas niveau.
La discussion ci-dessous s’appuie sur le langage C, mais les sujets traités valent pour tous les langages de programmation.
Exemple de code
Des exemples de code sont téléchargeables sur le site web de Xillybus. Ils sont écrits en C et montrent comment utiliser correctement l’API de bas niveau.
Pour télécharger les exemples, rendez-vous sur la page web à partir de laquelle vous avez téléchargé le pack de démonstration (demo bundle), c’est-à-dire la page de Xillybus ou la page de XillyUSB.
Si vous utilisez Linux, téléchargez le pilote Linux. Le code d’exemple est inclus dans le même fichier .tar.gz.
Si vous utilisez Windows, téléchargez le pack Xillybus pour Windows.
Dans les deux cas, le code d’exemple se trouve dans le sous-répertoire demoapps/. Notez que, dans ce code, la quantité de données lue ou écrite à chaque opération d’entrées-sorties est faible, car un petit tampon est alloué (128 octets). Cela a simplement pour but de simplifier le code. Dans une application réelle, il est recommandé d’utiliser un tampon plus grand (32 Ko est en général un bon choix).
Les différences entre Linux et Windows sont petites mais importantes. En particulier :
- Les noms des fonctions sont légèrement différents : _open() au lieu de open(), etc.
- Pour Windows : _O_BINARY doit être utilisé lors de l’ouverture du fichier.
Cela dit, les principes derrière le code d’exemple sont exactement les mêmes.
Entrées-sorties sur fichiers avec tampon
Les langages de programmation proposent généralement deux API distinctes pour accéder aux fichiers : une API de haut niveau et une API de bas niveau. L’API de haut niveau est la plus utilisée, car elle est plus facile à employer.
Par exemple, en langage C, l’API de haut niveau se compose de fopen(), fread(), fwrite(), fprintf(), fclose(), etc. L’API de bas niveau se compose de open(), read(), write(), close(), etc. La différence entre ces deux API n’est pas seulement une petite différence dans les noms des fonctions : l’API de haut niveau fournit des tampons RAM en espace utilisateur. Ces tampons sont mis en œuvre par la bibliothèque d’exécution du langage C. Ne les confondez pas avec les mémoires tampon DMA, qui sont contrôlées par le pilote dans le noyau.
La différence la plus importante est le comportement de fwrite() : le résultat d’un appel à cette fonction peut être que les données sont stockées dans un tampon en espace utilisateur. La transmission vers le FPGA peut alors être différée. En réalité, les données peuvent rester indéfiniment dans le tampon de fwrite(), jusqu’à ce que le fichier soit fermé. Cela peut ressembler à un bug avec Xillybus, car les données ont été écrites dans le fichier sans que rien ne se passe.
Il est donc recommandé d’utiliser l’API de bas niveau (non tamponnée). C’est généralement possible, même si la plupart des langages de programmation encouragent l’utilisation de l’API de haut niveau. Si l’outil ou le langage ne propose pas d’API de bas niveau, utilisez l’API disponible à la place. Dans ce cas, il est important de se rappeler que vous n’avez aucun contrôle sur le moment où les entrées-sorties ont lieu. C’est cependant suffisant dans de nombreuses applications, par exemple pour l’acquisition de données (data acquisition).
Encore une fois, il ne faut pas confondre les entrées-sorties tamponnées avec les tampons de Xillybus. Le point le plus important est que les données ne resteront jamais bloquées indéfiniment à cause des tampons de Xillybus. Cela est expliqué plus loin à propos de l’écriture de longueur nulle (zero-length write).
Lecture d’un fichier de périphérique : les bases
Le code d’exemple pour lire à partir d’un fichier de périphérique est streamread.c. Ce programme se trouve dans le répertoire demoapps/. Cependant, le code que je vais montrer ci-dessous provient d’un autre programme : memread.c (du même répertoire). Ce programme est destiné à un autre usage, mais il contient une fonction nommée allread(). Il est plus pratique d’illustrer quelques points avec cette fonction.
Supposons qu’un fichier ait été ouvert avec cette commande :
int fd, len;
char *buf;
fd = open("/dev/xillybus_read_32", O_RDONLY);
Supposons maintenant que nous voulions lire @len octets d’un fichier dans un tampon. Mais aucun résultat partiel ne doit être accepté : nous voulons une fonction qui lit toujours la quantité de données requise.
C’est ce que fait allread() lorsqu’elle est utilisée ainsi :
allread(fd, buf, len);
Cette fonction est définie comme suit :
void allread(int fd, unsigned char *buf, int len) {
int received = 0;
int rc;
while (received < len) {
rc = read(fd, buf + received, len - received);
if ((rc < 0) && (errno == EINTR))
continue;
if (rc < 0) {
perror("allread() failed to read");
exit(1);
}
if (rc == 0) {
fprintf(stderr, "Reached read EOF\n");
exit(1);
}
received += rc;
}
}
Notez que cette fonction lit toujours le nombre d’octets demandé. Si ce n’est pas possible, la fonction provoque la fin du programme. Cela peut paraître excessif dans un scénario d’utilisation normal. allread() doit être considérée comme une simple démonstration d’accès à un fichier avec l’API de bas niveau.
Expliquons cette fonction. La première partie intéressante est celle-ci :
rc = read(fd, buf + received, len - received);
@received est égal à zéro pendant la première itération. Cette ligne est donc équivalente à :
rc = read(fd, buf, len);
read() tente de lire @len octets depuis le descripteur de fichier (@fd) et de stocker ces données dans le tampon (@buf).
En ce qui concerne un fichier de périphérique Xillybus : si la quantité de données demandée (@len octets) n’est pas disponible, read() attend au maximum 10 ms. À l’issue de ce court délai, la fonction retourne avec moins de données que demandé (mais au moins un octet). S’il n’y a aucune donnée du tout, read() attend indéfiniment que des données arrivent du FPGA (il existe des exceptions, voir plus loin). Ce comportement est spécifique au pilote de Xillybus (mais il reste conforme à l’API standard).
Si read() a réussi à lire quelque chose, @rc est égal au nombre d’octets lus. Cela signifie que @rc est un nombre positif et que toutes les conditions if de la boucle sont ignorées. Ce qui se passe ensuite est donc :
received += rc;
Par conséquent, @received contient toujours le nombre total d’octets lus jusqu’à présent. Notez qu’il est parfaitement légal et normal que @rc soit plus petit que @len (le nombre d’octets demandé).
La boucle while continue jusqu’à ce que @received (le nombre total d’octets lus) atteigne @len. C’est ce que reflète l’instruction while :
while (received < len) { ... }
Des données supplémentaires sont donc lues si nécessaire :
rc = read(fd, buf + received, len - received);
Cette fois, le point de départ dans le tampon est décalé de @received. Le nombre d’octets demandés est aussi réduit de la même valeur. Ces ajustements reflètent simplement le fait qu’il s’agit d’une nouvelle tentative de lecture de données.
Quand read() ne lit rien
Jusqu’ici, je me suis concentré sur ce qui se passe quand read() parvient à lire des données. Il existe cependant trois situations où read() ne lit rien. Chacune est traitée par sa propre instruction if.
Signaux POSIX
Lorsque vous appuyez sur CTRL-C pour arrêter un programme, le système d’exploitation envoie un signal POSIX au processus. C’est ce mécanisme qui fait se terminer le programme. La même chose se produit lorsque vous utilisez la commande « kill » dans le même but. Mais il existe aussi beaucoup d’autres types de signaux qui devraient être ignorés dans la plupart des situations.
Que se passe-t-il si le processus reçoit un signal au milieu d’un appel à read() ? Selon les conventions de Linux, read() doit rendre immédiatement la main au programme principal. Si read() avait déjà pu lire des données avant cela, rien d’inhabituel ne se produit : @rc contiendra le nombre d’octets et rien n’indiquera qu’un signal a été reçu.
Mais si aucune nouvelle donnée n’est arrivée, @rc sera un nombre négatif et @errno sera égal à EINTR. La façon standard de gérer cette situation est celle montrée dans le code : se comporter comme si rien ne s’était passé et réessayer.
if ((rc < 0) && (errno == EINTR))
continue;
Cela ne signifie pas que le signal est ignoré : par exemple, si le signal a été provoqué par l’appui sur CTRL-C, le programme se terminera comme d’habitude. Un autre mécanisme s’en charge. Le but de cette instruction if est de gérer les signaux qui ne sont pas destinés à provoquer quoi que ce soit de dramatique. L’instruction continue garantit qu’il ne se produira rien d’étrange si le processus reçoit un signal de ce type.
Par exemple, si le processus est arrêté avec CTRL-Z, cette instruction if est nécessaire pour que le programme reprenne son cours lorsque l’exécution est relancée. En outre, plusieurs autres signaux peuvent arriver sans aucune intervention humaine.
Une véritable erreur
Naturellement, quelque chose peut mal se passer pendant une tentative de lecture. Dans ce cas, @rc sera un nombre négatif et la valeur de @errno sera autre chose que EINTR. Le code d’exemple signale simplement cette erreur et termine le programme :
if (rc < 0) {
perror("allread() failed to read");
exit(1);
}
Fin de fichier (EOF)
Si read() ne peut pas fournir de données parce que la fin du fichier a été atteinte, cette fonction retourne la valeur zéro. Cela vaut bien sûr pour un fichier ordinaire. Xillybus a aussi la capacité de déclarer que le flux de données est terminé. Le comportement est le même.
Le code concerné est celui-ci :
if (rc == 0) {
fprintf(stderr, "Reached read EOF\n");
exit(1);
}
Là encore, ce cas est traité comme une erreur et provoque la terminaison du programme : cela signifie que l’EOF a été atteint avant que la quantité de données demandée (@len octets) ait été lue. Cette instruction if ne peut être atteinte que si @received est inférieur à @len.
Rappelez-vous que l’idée derrière allread() est de toujours lire la quantité de données requise. Si cela n’est pas possible, cette fonction arrête le programme.
Ce qui vaut pour les autres langages de programmation
Le code d’exemple ci-dessus est écrit en C, mais il illustre quelques points importants qui valent quel que soit le langage de programmation.
- read() peut retourner moins de données que demandé. Cela peut aussi arriver avec les entrées-sorties tamponnées (par exemple avec fread() ). Mais avec des entrées-sorties tamponnées, cela ne se produit qu’en cas d’erreur ou si l’EOF a été atteint. En revanche, c’est normal pour read() et cela n’indique pas qu’un événement particulier s’est produit.
- Le programme doit gérer correctement les signaux POSIX.
- read() retourne la valeur zéro lorsque toutes les données ont été lues et que la fin de fichier (EOF) a été atteinte.
Écrire dans un fichier de périphérique
L’API de bas niveau pour écrire dans un fichier est presque identique à celle pour lire un fichier. Pour le démontrer, voici la fonction nommée allwrite(), que l’on trouve dans streamwrite.c :
void allwrite(int fd, unsigned char *buf, int len) {
int sent = 0;
int rc;
while (sent < len) {
rc = write(fd, buf + sent, len - sent);
if ((rc < 0) && (errno == EINTR))
continue;
if (rc < 0) {
perror("allwrite() failed to write");
exit(1);
}
if (rc == 0) {
fprintf(stderr, "Reached write EOF (?!)\n");
exit(1);
}
sent += rc;
}
}
Comparez-la avec allread() présentée plus haut : il n’y a que trois différences :
- L’utilisation de write() au lieu de read(). Mais ces fonctions s’utilisent exactement de la même manière.
- Le nom de la variable @received a été changé en @sent. Mais la seule différence est le nom de la variable. Sa signification et son utilisation sont exactement les mêmes.
- Le texte affiché est adapté : il dit « write » là où il disait « read » auparavant.
En principe, il n’y a donc aucune différence entre écrire et lire.
Cela dit, notez que @rc ne devrait jamais être égal à zéro, car un EOF n’a pas de sens lorsqu’on écrit dans un fichier. Selon la norme POSIX, @rc ne peut être nul que lorsqu’on demande à write() d’écrire zéro octet. Mais cela n’arrive jamais dans cette boucle while.
En résumé, allwrite() écrit toujours le nombre d’octets demandé. La seule alternative est de terminer le processus. Autrement dit, supposons qu’un fichier de périphérique ait été ouvert de la manière suivante :
int fd, len;
char *buf;
fd = open("/dev/xillybus_write_32", O_WRONLY);
L’écriture de @len octets depuis @buf se fait ainsi :
allwrite(fd, buf, len);
Tout ce qui a été dit ci-dessus à propos de allread() s’applique également à allwrite(). Cela vaut aussi pour les autres langages de programmation.
Écriture de longueur nulle (zero-length write)
Il est permis d’appeler write() avec zéro octet. L’API standard ne précise pas ce qui se passera dans ce cas précis. Mais manifestement, cela signifie qu’aucune donnée ne sera écrite.
Un appel de ce type a une signification particulière pour un fichier de périphérique Xillybus : écrire zéro octet revient à demander un vidage des tampons (flush). Pour comprendre ce que cela signifie, voyons d’abord ce qui se passe lorsque des données sont écrites dans un fichier de périphérique.
Supposons que le fichier de périphérique soit un flux asynchrone (asynchronous stream). Ce terme est expliqué brièvement sur une autre page et plus en détail dans la documentation.
Quand des données sont écrites dans un fichier de périphérique (avec write() ), le pilote Xillybus les stocke dans une mémoire tampon en RAM. Il est possible qu’une partie ou la totalité de ces données soit envoyée immédiatement au FPGA. Mais d’une manière générale, une certaine quantité de données peut rester dans le tampon, et le programme qui a effectué l’appel continue néanmoins. Le but de ce mécanisme est d’améliorer les performances, en particulier lorsqu’il y a de nombreux appels à write().
Alors, quand les données dans la mémoire tampon du pilote sont-elles envoyées au FPGA ? Il y a quatre situations possibles :
- Une mémoire tampon en RAM se remplit.
- Le fichier de périphérique est fermé.
- Une période de 10 ms s’est écoulée (vidage automatique).
- Un appel à write() de longueur nulle est effectué.
Les données ne restent donc jamais trop longtemps dans le tampon du pilote. Elles sont toujours envoyées au FPGA dans un délai maximum de 10 ms. Mais dans certaines applications, même ce délai n’est pas acceptable. Dans ce cas, on peut utiliser une écriture de longueur nulle pour demander que toutes les données restantes soient envoyées immédiatement.
Voici comment faire dans le langage C :
write(fd, NULL, 0);
Notez que l’adresse du tampon est NULL. C’est acceptable, car le nombre d’octets à écrire est zéro. Mais cet appel ne garantit pas que la demande aboutisse. Même s’il est très probable qu’elle réussisse, voici la manière correcte de procéder :
while (1) {
rc = write(fd, NULL, 0);
if ((rc < 0) && (errno == EINTR))
continue; // Interrupted. Try again.
if (rc < 0) {
perror("flushing failed");
break;
}
break; // Flush successful
}
Tout cela a été dit pour un flux asynchrone. Si le fichier de périphérique est un flux synchrone (synchronous stream), les données sont toujours envoyées immédiatement au FPGA à la suite d’un appel à write(). En outre, write() attend que les données aient atteint le FPGA avant de retourner (un appel de longueur nulle ne fait pas cela).
Une écriture de longueur nulle n’a donc d’intérêt que pour les flux asynchrones. Il ne faut pas utiliser cette fonctionnalité sauf nécessité, car elle ralentit la communication avec le FPGA.
Résumé
Comme déjà mentionné, presque tout ce qui précède vaut pour l’accès à n’importe quel fichier. Seuls quelques sujets sont spécifiques à Xillybus.
Il est important de suivre ces recommandations pour garantir un comportement cohérent lors de la communication avec le FPGA. Les programmes écrits sans tenir compte de ces sujets risquent d’avoir des défaillances occasionnelles. Ces défaillances semblent souvent provenir d’un problème du FPGA ou du pilote. De bonnes techniques de programmation éviteront donc bien des confusions et des efforts inutiles.