L'analyseur logique fait partie des outils indispensables lorsqu'il s'agit de mettre au point des projets impliquant des communications série ou parallèle. Comme nous l'avons vu dans de précédents articles ([1] et [2]), accompagnés des bons logiciels, ils permettent très simplement d'espionner des bus SPI, i2c, série, LCD, etc. Mais il est possible d'aller bien plus loin et de développer sa propre solution pour traiter des signaux bien plus exotiques...
Le matériel concerné ici sera la myriade de clones des premiers analyseurs logiques développés et vendus par Saleae. Aujourd'hui, leurs produits (Logic, Logic Pro, etc.) sont bien différents, mais la première génération développée utilisait les puces Cypress EZ-USB FX2LP, faisant fonctionner un firwmare stocké en RAM et fourni par l'application du constructeur. Le FX2 est un microcontrôleur bien particulier, capable de transmettre directement l'état des GPIO en USB avec un nombre minimal de composants complémentaires. Ce mode de fonctionnement a permis à certains constructeurs peu scrupuleux de développer des produits similaires, parfaitement compatibles avec l'application originale, à un prix défiant toute concurrence, allant même jusqu'à réutiliser la marque déposée (là, il ne s'agit plus de clones, mais de contrefaçons). Aujourd'hui, le constructeur a fait évoluer ses produits et il n'est plus possible, pour les « cloneurs », de faire fonctionner leur produit avec l'applicatif de Saleae.
En parallèle, un projet open source appelé Sigrok a vu le jour, proposant non seulement une application alternative à la solution propriétaire de Saleae, mais également un firmware entièrement réécrit permettant ainsi d'utiliser les clones de façon parfaitement légale (et morale). Aujourd'hui, on trouve toujours ces clones sur les sites habituels (eBay, Amazon, etc.), pour une dizaine d'euros (voire moins pour un devkit CY7C68013A) et ils sont toujours parfaitement utilisables, même si légèrement dépassés pour les applications hautes fréquences (24 MHz maximum).
Sigrok, accompagné de PulseView, une application graphique Qt de visualisation et de décodage de protocoles, permet donc, avec un budget réduit, de disposer d'un outil capable d'analyser toutes sortes de signaux digitaux. Mais la modularité de Sigrok pourra également être exploitée via la libsigrok, fournissant un accès abstrait à l'ensemble des matériels supportés, ainsi que quelques fonctions utilitaires. La libsigrok repose massivement sur la GLib [3] et propose, en plus de l'utilisation native en C, des bindings pour C++, Python, Java et Ruby.
« Pourquoi utiliser la libsigrok alors que PulseView nous fournit tout le nécessaire ? », me demanderez-vous. Tout simplement pour pouvoir traiter des signaux non standardisés et non normalisés, et surtout ne pas s'en tenir au simple décodage et à l'affichage des données capturées. Avec libsigrok, vous pouvez intégrer les fonctionnalités d'analyse logique dans vos propres programmes, sans avoir à gérer la partie bas niveau du pilotage du FX2. L'ensemble de la configuration du périphérique est prise en charge par la libsigrok, du nombre d'échantillons à collecter à la gestion des triggers, en passant par le choix de la fréquence d'échantillonnage.
On pourrait voir le fait d'utiliser la libsigrok comme une tentative d'écriture d'un clone de PulseView, mais bien que ceci soit parfaitement possible, il s'agit plutôt de créer une suite complète du traitement de l'information, à commencer par la détection des signaux, leur interprétation, mais également leur utilisation. À titre d'exemple, et même si cela ne s'est pas avéré être l'idée la plus éclairée aux vues des capacités du FX2, je me suis penché sur la libsigrok dans le but de lire et de décoder un signal vidéo. Il ne s'agit que d'un PoC (Proof of Concept ou preuve de concept), mais l'idée était de lire les signaux RGBi d'un Commodore 128 (mode 80 colonnes), avec pour objectif de décoder l'information, la stocker dans un buffer et finalement produire une image RGB 24-bits. Notez que la sortie RGBi en question n'est pas analogique (contrairement au VGA), mais, comme c'est le cas pour CGA, utilise uniquement des signaux pouvant être à l'état bas ou haut (VSync, HSync, rouge, vert, bleu et intensité).
La fréquence d'échantillonnage de 24 MHz n'est pas suffisante pour obtenir un résultat parfait, mais cela fonctionne et constitue tout de même un bel exercice. Le point notable ici est le fait qu'il s'agit d'un traitement qu'il n'est pas possible d'effectuer avec un applicatif comme PulseView, qui se contente de décoder des protocoles, mais ne fait finalement rien des données (si ce n'est les présenter à l'utilisateur). La libsigrok nous donne l'opportunité de compléter n'importe quelle acquisition avec un traitement immédiat. Il pourra s'agir de désassembler à la volée des instructions sur un bus, recomposer une image, capturer un firmware, etc.
Bien entendu, PulseView repose lui-même sur la libsigrok et si votre distribution GNU/Linux propose cet applicatif, vous aurez donc également accès à cette bibliothèque, et très probablement au paquet de développement correspondant (libsigrok-dev dans le cas de Debian et ses dérivés comme Ubuntu, Raspbian, Raspberry Pi OS, etc.).
1. Premier code juste pour goûter
Dans sa plus simple expression, un code utilisant la libsigrok se résume à peu de choses, mais comme avec toutes les bibliothèques, ceci ne servira que de point de départ, tout en permettant de vérifier que l'ensemble fonctionne. Notre code source débutera donc avec quelque chose comme :
Rien de bien complexe ici, nous retrouvons le mécanisme assez classique d'initialisation reposant sur l'utilisation d'une structure (ctx) regroupant les informations propres à la bibliothèque sous la forme d'un « contexte ». Notez cependant que sr_context n'est pas un type (typedef), mais une structure, ce qui est sensiblement peu commun et qu'on retrouve dans toute la libsigrok. Attention donc aux réflexes et habitudes, car le compilateur ne manquera pas de vous rappeler qu'il y a des struct qui manquent...
Pour accompagner ce code, nous rédigerons un Makefile assez simple :
Comme dit précédemment, la libsigrok utilise intensivement la GLib et celle-ci sera automatiquement mentionnée dans les sorties de pkg-config, utilisées en argument du compilateur et de l'éditeur de liens.
2. Trouver le périphérique
Le projet Sigrok ne concerne pas uniquement les périphériques purement digitaux basés sur le microcontrôleur Cypress EZ-USB FX2LP. En réalité, c'est toute une gamme de produits qui sont supportés [4], et ce, via différentes interfaces (USB, HID, Bluetooth, série, etc.). Bien entendu, certains matériels se ressemblent et sont pris en charge de façon identique, avec un pilote unique, comme c'est le cas pour les clones Saleae sur base FX2. L'approche à adopter dans votre code consiste donc à tout d'abord choisir un pilote, puis sur cette base, détecter et utiliser le périphérique découvert.
Commençons donc par la sélection du pilote :
La liste des pilotes présents est obtenue via sr_driver_list() nous retournant une liste de sr_dev_driver se terminant par NULL. Nous parcourons la liste à la recherche d'un pilote dont le nom est "fx2lafw", puis utilisons le pointeur obtenu avec sr_driver_init() pour procéder à l'initialisation. Dès lors, nous pouvons scanner les périphériques présents, et ici, utiliser le premier qui sera détecté :
sr_driver_scan() nous retournera la liste des périphériques sous la forme d'une GSList, un type fourni par la GLib permettant de créer des listes chaînées. Notez qu'il est à la charge du développeur de libérer la mémoire avec g_slist_free() et que ce genre de différences entre des opérations qui par ailleurs sont très similaires (lister les pilotes vs lister les périphériques) sont légion dans l'API de la libsigrok. La documentation de l'API [5] spécifie heureusement ce genre de choses, mais la vérification systématique avec Valgrind (option --leak-check=yes) sera indispensable pour éviter les fuites de mémoire.
À ce stade, nous avons dans sdi un descripteur de périphérique que nous pouvons utiliser :
3. Configuration et GVariant
Nous avons accès au périphérique et pouvons donc désormais l'utiliser. Il nous faut cependant tout d'abord ajuster sa configuration à nos besoins et en particulier, la fréquence d'échantillonnage ainsi que la quantité d'échantillons à capturer. La libsigrok étant en mesure de prendre en charge nombre de périphériques (et pas seulement des analyseurs logiques), la configuration peut sembler relativement « touffue ». En effet, obtenir ou définir une valeur de configuration passe par l'utilisation des fonctions sr_config_get() et sr_config_set(), prenant en argument, entre autres choses, une clé de configuration issue de l'énumération sr_configkey déclarée dans libsigrok.h. Notez que ces fonctions doivent être utilisées après sr_dev_open().
L'énumération est assez importante puisqu'elle couvre l'ensemble des périphériques supportés. Fort heureusement, nous ne développons pas un outil générique, mais un programme entièrement dédié à une unique tâche et à un unique matériel. Les deux clés de configuration qui nous intéressent sont SR_CONF_SAMPLERATE pour la fréquence d'échantillonnage, et SR_CONF_LIMIT_SAMPLES pour la quantité de données à collecter. Notez que dans le code qui va suivre, nous ne vérifierons pas les valeurs de retour des fonctions afin de garder l'exemple (et l'article) à une taille raisonnable. Il faudra cependant toujours vous assurer que les fonctions de la libsigrok retournent effectivement SR_OK.
sr_config_set() prend en argument le périphérique concerné (sdi ici), un éventuel groupe de canaux ou NULL, la clé de configuration et une valeur à attribuer. Oublions les groupes de canaux qui ne sont pas pertinents pour un périphérique FX2 (il n'y a qu'un groupe avec les 8 canaux disponibles) et concentrons-nous sur la valeur à passer. Il s'agit ici d'un argument à fournir sous la forme d'un pointeur vers un GVariant, un type fourni par GLib (struct GVariant) constitué d'une valeur et d'une information concernant le type de cette valeur. De plus, la GLib s'occupe de l'allocation et de la libération de la mémoire associée aux GVariant via un mécanisme de comptage de référence.
Pour utiliser sr_config_set(), nous devrons donc convertir un entier (typiquement un uint64_t) en GVariant. Mais la libsigrok offre une fonction utilitaire supplémentaire, prenant en argument une chaîne de caractères représentant une valeur « naturelle » et produisant un uint64_t. Cette fonction, sr_parse_sizestring(), reconnaît les expressions comme "4 M" (4000000), "25khz" (25000) ou encore "2g" (2000000000) et placera la valeur entière correspondant dans la variable dont le pointeur est passé en argument.
Configurer la fréquence d'échantillonnage peut donc être fait ainsi :
Et le nombre d'échantillons de manière similaire :
Pour appliquer effectivement cette configuration, il ne faudra pas oublier d'appeler :
Encore une fois, il est important de vérifier les valeurs de retour de chaque fonction. Le firmware pour le microcontrôleur FX2, par exemple, n'est pas en mesure d'utiliser une fréquence arbitrairement choisie. Les fréquences utilisables sont 25 kHz, 50 kHz, 100 kHz, 200 kHz, 250 kHz, 500 kHz, 1 MHz, 2 MHz, 3 MHz, 4 MHz, 6 MHz, 8 MHz, 12 MHz, 16 MHz et 24 MHz. Toute autre valeur provoquera une erreur qu'il faudra prendre en compte, en particulier si la fréquence est obtenue de l'utilisateur via une option en ligne de commandes, par exemple.
Notez également qu'une fonction inverse de sr_parse_sizestring() existe, c'est sr_si_string_u64() permettant d'obtenir une valeur « naturelle » à partir d'un uint64_t. Un autre élément important, en particulier si votre code prend du volume, est le fait que sr_parse_sizestring() « consomme » (sink) le GVariant en décomptant une référence et donc en demandant implicitement à la GLib de libérer ici la mémoire. Après l'appel à sr_parse_sizestring(), newsr et newsl ne sont donc plus utilisables, ce qui peut être relativement perturbant, car pour d'autres fonctions, la libération est laissée à la discrétion du développeur.
4. Session et fonction callback
Nous sommes presque prêts à capturer nos premiers signaux, mais ceci ne peut être fait qu'en exécutant une session. Une session est une phase d'acquisition déclenchée par un appel à sr_session_start() prenant en argument un pointeur de pointeur vers une session (struct sr_session**) préalablement configurée. La notion de session véhicule à la fois le ou les périphériques à utiliser, la ou les fonctions callback de traitement des données et le ou les triggers déclenchant l'acquisition (cf. plus loin).
Pour procéder à une acquisition, nous devons donc créer une session et y ajouter le périphérique concerné :
La libsigrok est très souple à ce niveau. Rien ne nous empêche, en effet, d'utiliser plusieurs périphériques dans une seule session pour, par exemple, utiliser plusieurs clones Saleae Logic pour capturer des signaux sur un bus de 16, 24 ou 32 bits. Il est même possible de combiner des périphériques très différents et ainsi collecter des données digitales avec un matériel et analogiques avec un autre (comme un multimètre).
Pour traiter la masse de données résultante de cette capture, un mécanisme de callback est utilisé. Nous devons donc créer une fonction qui sera appelée à plusieurs reprises, à chaque mise à disposition d'un lot de données. Pour l'exemple, notre fonction sera relativement simple :
Plusieurs types de paquets sont susceptibles d'être envoyés (voir enum sr_packettype dans libsigrok.h), en fonction de la nature des données qu'ils contiennent (le payload). Avec notre analyseur logique à base de FX2, nous n'avons besoin de traiter que trois types de paquets :
- SR_DF_HEADER indiquant que le payload est un entête de capture de type struct sr_datafeed_header ;
- SR_DF_END marquant la fin de la capture (pas de payload) ;
- SR_DF_LOGIC signifiant que nous avons affaire à des données d'acquisition sous la forme d'une struct sr_datafeed_logic.
Cette dernière structure prend la forme suivante :
Nous retrouvons ici la taille totale des données du paquet (length), celle d'un élément unitaire en octet (unitsize) et un pointeur vers les données elles-mêmes (*data). Dans le cas de notre analyseur, un unique octet est suffisant pour encoder l'état des huit entrées et unitsize sera égale à 1. length en revanche sera dépendant de la fréquence d'échantillonnage configurée. Plus celle-ci est importante, plus la taille d'un paquet augmente et donc moins les appels à la fonction de callback seront fréquents. Traiter les données reviendra à simplement parcourir le contenu pointé par data lors de chaque appel du callback, et ce, en fonction de la taille de unitsize (ici, octet par octet, où chaque bit correspond à l'état d'une entrée du périphérique).
Cette fonction pourra être ajoutée dans notre session en utilisant :
Et il ne restera plus, ensuite, qu'à débuter la session avec :
sr_session_run() est une simple fonction utilitaire déclenchant une mainloop GLib pour rendre le code bloquant. Il est également possible de travailler de façon non bloquante et de définir un autre callback avec sr_session_stopped_callback_set(), permettant de déclencher une action en fin de session. Cette alternative permet d'intégrer plus facilement la capture à un code reposant déjà sur une boucle, comme une application graphique, par exemple.
Nous disposons à présent d'un code pleinement fonctionnel, configuré pour procéder à l'acquisition de 100000 échantillons à une fréquence de 2 MHz. L'exécution de cet exemple après compilation nous affichera :
Nous avons obtenu cinq paquets de données totalisant 100000 octets dont le traitement pourra se faire à votre convenance. Dans le cadre de mes expérimentations autour du signal RGBi de la machine Commodore, il s'agissait de composer un buffer RGB dépendant de l'état des lignes de la sortie, puis lors de l'arrivée du paquet SR_DF_END, d'enregistrer le tout dans un fichier pour analyse et conversion dans un format graphique. L'objectif étant, à la prochaine itération sur le code, de rafraîchir régulièrement ce buffer et d'avoir en parallèle un thread chargé d'utiliser son contenu pour afficher l'image (sans doute avec SDL) en temps réel.
5. Triggers
Notre code de démonstration fait le travail en capturant les données, mais il le fait dès le lancement du programme, ce qui n'est pas nécessairement une solution adaptée. En effet, l'objectif d'une capture n'est généralement pas d'obtenir un lot de bits à un moment aléatoire, mais une séquence entière à partir d'un événement précis. Cet événement est souvent un signal récurrent marquant le début d'une transmission, comme un signal /CS asservissant un composant (flash, RAM, ROM, périphérique, etc.). Dans le cas de mon outil de capture vidéo, le rafraîchissement de l'écran est lié au signal VSync (50 ou 60 Hz selon le modèle) ramenant le faisceau d'électrons du tube cathodique à sa position initiale en haut à gauche de l'écran. La capture d'une image complète débute donc par un changement d'état sur la ligne où est connecté ce signal, qu'il est possible de détecter en configurant un déclencheur ou trigger en anglais.
Cette fonctionnalité indispensable à tout analyseur logique est, bien entendu, supportée par le périphérique FX2 et par la libsigrok. Il est cependant décomposé en plusieurs éléments, un trigger pouvant comporter plusieurs étapes ou stages, eux-mêmes comportant un ou plusieurs matchs correspondants à un état ou changement d'état survenant sur une entrée, constituant un événement.
Ces événements, listés dans l'énumération sr_trigger_matches sont, pour les entrées digitales :
- SR_TRIGGER_ZERO : déclenche lorsque l'entrée est à l'état bas ;
- SR_TRIGGER_ONE : idem à l'état haut ;
- SR_TRIGGER_RISING : déclenche sur le front montant (bas à haut) ;
- SR_TRIGGER_FALLING : sur le front descendant (haut à bas) ;
- SR_TRIGGER_EDGE : déclenche sur un changement d'état quelconque.
Et pour les entrées analogiques :
- SR_TRIGGER_RISING : augmentation de la valeur mesurée ;
- SR_TRIGGER_FALLING : réduction de la valeur ;
- SR_TRIGGER_OVER : dépassement d'une valeur définie ;
- SR_TRIGGER_UNDER : passage sous une valeur définie.
Dans un cas d'usage courant comme celui qui nous occupe dans notre démonstration ou dans mon petit projet de capture vidéo, les matchs seront le plus souvent SR_TRIGGER_RISING ou SR_TRIGGER_FALLING, pour capturer et agir lors d'un changement d'état, comme un signal /CS. Nous allons donc considérer cet exemple, avec l'entrée 0 du clone Saleae hypothétiquement reliée à un signal passant de l'état bas (masse) à l'état haut (Vcc) lors d'une « transaction ». Pour ce faire, nous commençons par décrire un canal (channel) correspondant à l'entrée en question :
sdi est notre descripteur de périphérique, index est le numéro de l'entrée concernée, type est le type d'entrée (ici, SR_CHANNEL_LOGIC pour une entrée digitale), enable détermine l'état actif ou non et name est une simple chaîne descriptive. Notez qu'il n'est pas forcément nécessaire de procéder de la sorte. Il est également possible de récupérer directement une liste (GSList) de canaux fournie par le périphérique avec sr_dev_inst_channels_get() et d'utiliser les struct sr_channel y figurant dans les fonctions que nous allons voir dans un instant. L'approche optimale n'est pas détaillée dans la documentation de l'API et la libsigrok ne fournit pas d'exemples d’utilisation (mais uniquement des fichiers de tests des fonctionnalités [6]).
Nous créons ensuite un nouveau trigger :
Puis ajoutons un stage à ce dernier :
Et ajoutons le match dans le stage :
C'est ici que la magie opère, puisque notre match correspond à un front montant (SR_TRIGGER_RISING) sur le canal chan_vsync. Notez le float en guise de dernier argument, qui ici n'a aucune importance, mais pourra être utilisé avec un périphérique analogique et un déclencheur de type SR_TRIGGER_OVER, par exemple.
Enfin, nous ajoutons le trigger à la session (avant sr_session_start(), bien entendu) :
Comme précédemment, chaque opération impliquant des fonctions de la libsigrok sous-entend une vérification de la valeur de retour, qui doit être SR_OK en cas de succès. Remarquez également que la libsigrok ne semble pas intégrer de fonctionnalité de repeat triggering et que si vous souhaitez disposer de ce type de mécanique, vous devrez l'implémenter dans votre code afin de réarmer le déclencheur une fois une capture terminée. Ceci est confirmé par ailleurs dans PulseView, où ce type de fonctionnalités n'existe que dans un fork distinct du projet [7]. De plus, il ne faudra pas oublier de libérer la mémoire après la phase de capture avec sr_trigger_free(mytrig) pour éviter toute fuite.
Une fois tout ceci compilé, l'exécution de notre code restera en attente du signal déclencheur et la capture ne débutera que lorsque celui-ci apparaîtra sur l'entrée désignée. Nous n'avons utilisé ici qu'un seul déclencheur, mais là encore, la libsigrok permet de créer des choses beaucoup plus complexes, combinant plusieurs périphériques de types différents et plusieurs matchs et stages pour cerner avec précision un événement précis à surveiller.
Conclusion
La libsigrok est fort pratique, mais comme nous venons de le voir, son utilisation est rendue délicate par la quantité de périphériques supportés. Réunir de façon cohérente un ensemble de fonctionnalités couvrant à la fois les analyseurs logiques, les oscilloscopes connectés, les multimètres et même les balances, hygromètres et thermomètres numériques n'est pas une mince affaire. Si on ajoute à cela l'omniprésence de la GLib dans l'API, des différences importantes de logiques d'implémentation d'une fonctionnalité à l'autre ou encore le fait que la documentation soit relativement peu explicite, force est de constater que la libsigrok n'est pas très facile à prendre en main. Elle offre cependant une couche d'abstraction dont il serait difficile de se passer, et l'expérience aidant, permet de réaliser de petits outils « sur mesure », qu'il serait par ailleurs très difficile de créer en attaquant le périphérique au niveau USB (mais pas impossible, voir [8]).
Bien entendu, les réalisations dépendront très fortement des capacités effectives du matériel mis en œuvre et il faudra garder à l'esprit qu'il n'y a pas de solution miracle. Dans le cadre de mon petit projet de traitement de signal vidéo par exemple, l'approche idéale consisterait à utiliser un FPGA/CPLD pour procéder à un premier traitement, puis à passer les données résultantes à un système embarqué pour affichage en HDMI. C'est précisément ce que fait le RGBtoHDMI de David Banks [9] en couplant un CPLD Xilinx XC9572XL à une Raspberry Pi Zero. Remplacer une telle solution par un analyseur logique « discount » fonctionne, mais montre rapidement ses limites. Il est toutefois très satisfaisant de voir s'afficher une image en lieu et place de simples signaux dans PulseView et on imagine sans peine l'étendue des possibilités, car cela va bien au-delà du simple décodage de bus.
Références
[3] https://gitlab.gnome.org/GNOME/glib
[4] http://sigrok.org/wiki/Supported_hardware
[5] https://sigrok.org/api/libsigrok/0.5.2/index.html
[6] https://github.com/sigrokproject/libsigrok/tree/master/tests
[7] https://github.com/Cenkron/pulseview
[8] https://github.com/hoglet67/6502Decoder