J'utilise la fonction d'interruption pour remplir un tableau avec des valeurs reçues de digitalRead()
.
void setup() {
Serial.begin(115200);
attachInterrupt(0, test_func, CHANGE);
}
void test_func(){
if(digitalRead(pin)==HIGH){
test_array[x]=1;
} else if(digitalRead(pin)==LOW){
test_array[x]=0;
}
x=x+1;
}
Ce problème est que lorsque j'imprime, test_array
il existe des valeurs telles que: 111
ou 000
.
Si je comprends bien, si j'utilise l' CHANGE
option dans la attachInterrupt()
fonction, la séquence de données doit toujours être 0101010101
sans répétition.
Les données changent assez rapidement car elles proviennent d'un module radio.
arduino-uno
c
isr
user277820
la source
la source
pin
,x
ettest_array
définition, ainsi que laloop()
méthode; cela nous permettrait de voir si cela peut être un problème de concurrence lors de l'accès aux variables modifiées partest_func
.if (digitalRead(pin) == HIGH) ... else ...;
ou, mieux encore, cette ISR unique en ligne:test_array[x++] = digitalRead(pin);
.Réponses:
Comme une sorte de prologue à cette réponse trop longue ...
Cette question m'a profondément captivé par le problème de la latence des interruptions, au point de perdre le sommeil dans les cycles de comptage au lieu des moutons. J'écris cette réponse davantage pour partager mes conclusions que pour simplement répondre à la question: la plupart de ce matériel peut en fait ne pas être à un niveau approprié pour une réponse correcte. J'espère que ce sera utile, cependant, pour les lecteurs qui débarquent ici à la recherche de solutions aux problèmes de latence. Les premières sections devraient être utiles à un large public, y compris l'affiche originale. Ensuite, il devient poilu en cours de route.
Clayton Mills a déjà expliqué dans sa réponse qu'il y avait une certaine latence dans la réponse aux interruptions. Ici, je vais me concentrer sur la quantification de la latence (qui est énorme lors de l'utilisation des bibliothèques Arduino), et sur les moyens de la minimiser. La plupart de ce qui suit est spécifique au matériel de l'Arduino Uno et des cartes similaires.
Minimiser la latence d'interruption sur l'Arduino
(ou comment passer de 99 à 5 cycles)
Je vais utiliser la question d'origine comme exemple de travail et reformuler le problème en termes de latence d'interruption. Nous avons un événement externe qui déclenche une interruption (ici: INT0 lors du changement de broche). Nous devons prendre des mesures lorsque l'interruption est déclenchée (ici: lire une entrée numérique). Le problème est: il y a un certain délai entre le déclenchement de l'interruption et la prise de l'action appropriée. Nous appelons ce délai " latence d'interruption ". Une longue latence est préjudiciable dans de nombreuses situations. Dans cet exemple particulier, le signal d'entrée peut changer pendant le retard, auquel cas nous obtenons une lecture erronée. Nous ne pouvons rien faire pour éviter ce retard: il est intrinsèque au fonctionnement des interruptions. Nous pouvons cependant essayer de le rendre aussi court que possible, ce qui devrait, espérons-le, minimiser les mauvaises conséquences.
La première chose évidente que nous pouvons faire est de prendre l'action critique dans le temps, à l'intérieur du gestionnaire d'interruption, dès que possible. Cela signifie appeler
digitalRead()
une fois (et une seule fois) au tout début du gestionnaire. Voici la version zéro du programme sur lequel nous allons construire:J'ai testé ce programme, et les versions suivantes, en lui envoyant des trains d'impulsions de largeurs variables. Il y a suffisamment d'espacement entre les impulsions pour garantir qu'aucun front ne soit manqué: même si le front descendant est reçu avant que l'interruption précédente ne soit effectuée, la deuxième demande d'interruption sera mise en attente et éventuellement traitée. Si une impulsion est plus courte que la latence d'interruption, le programme lit 0 sur les deux fronts. Le nombre signalé de niveaux ÉLEVÉS est alors le pourcentage d'impulsions correctement lues.
Que se passe-t-il lorsque l'interruption est déclenchée?
Avant d'essayer d'améliorer le code ci-dessus, nous examinerons les événements qui se déroulent juste après le déclenchement de l'interruption. La partie matérielle de l'histoire est racontée par la documentation Atmel. La partie logicielle, en démontant le binaire.
La plupart du temps, l'interruption entrante est réparée immédiatement. Il peut arriver, cependant, que le MCU (signifiant "microcontrôleur") se trouve au milieu d'une tâche critique en termes de temps, où le service d'interruption est désactivé. C'est généralement le cas lorsqu'il est déjà en train de traiter une autre interruption. Lorsque cela se produit, la demande d'interruption entrante est mise en attente et traitée uniquement lorsque cette section critique est terminée. Cette situation est difficile à éviter complètement, car il y a pas mal de ces sections critiques dans la bibliothèque principale Arduino (que j'appellerai " libcore"ci-dessous). Heureusement, ces sections sont courtes et ne s'exécutent que de temps en temps. Ainsi, la plupart du temps, notre demande d'interruption sera traitée immédiatement. Dans ce qui suit, je supposerai que nous ne nous soucions pas de ces quelques des cas où ce n'est pas le cas.
Ensuite, notre demande est traitée immédiatement. Cela implique encore beaucoup de choses qui peuvent prendre un certain temps. Tout d'abord, il existe une séquence câblée. Le MCU finira d'exécuter l'instruction en cours. Heureusement, la plupart des instructions sont à cycle unique, mais certaines peuvent prendre jusqu'à quatre cycles. Ensuite, le MCU efface un indicateur interne qui désactive la poursuite du service des interruptions. Ceci est destiné à empêcher les interruptions imbriquées. Ensuite, le PC est enregistré dans la pile. La pile est une zone de RAM réservée à ce type de stockage temporaire. Le PC (qui signifie " compteur de programmes "") est un registre interne contenant l'adresse de la prochaine instruction que le MCU est sur le point d'exécuter. C'est ce qui permet au MCU de savoir quoi faire ensuite, et l'enregistrer est essentiel car il devra être restauré pour que le principal programme à reprendre d'où il a été interrompu. Le PC est alors chargé avec une adresse câblée spécifique à la demande reçue, et c'est la fin de la séquence câblée, le reste étant contrôlé par logiciel.
Le MCU exécute maintenant l'instruction à partir de cette adresse câblée. Cette instruction est appelée " vecteur d'interruption " et est généralement une instruction de "saut" qui nous amènera à une routine spéciale appelée ISR (" Interrupt Service Routine "). Dans ce cas, l'ISR est appelé "__vector_1", alias "INT0_vect", ce qui est un terme impropre car il s'agit d'un ISR, pas d'un vecteur. Cet ISR particulier vient de libcore. Comme tout ISR, il commence par un prologue qui enregistre un tas de registres CPU internes sur la pile. Cela lui permettra d'utiliser ces registres et, une fois cela fait, de les restaurer à leurs valeurs précédentes afin de ne pas perturber le programme principal. Ensuite, il recherchera le gestionnaire d'interruptions enregistré avec
attachInterrupt()
, et il appellera ce gestionnaire, qui est notreread_pin()
fonction ci-dessus. Notre fonction appellera alorsdigitalRead()
depuis libcore.digitalRead()
examinera certaines tables afin de mapper le numéro de port Arduino au port d'E / S matériel qu'il doit lire et le numéro de bit associé à tester. Il vérifiera également s'il y a un canal PWM sur cette broche qui devrait être désactivé. Il lira alors le port d'E / S ... et nous avons terminé. Eh bien, nous n'avons pas vraiment fini de réparer l'interruption, mais la tâche critique (lire le port d'E / S) est terminée, et c'est tout ce qui compte lorsque nous examinons la latence.Voici un bref résumé de tout ce qui précède, ainsi que les retards associés dans les cycles CPU:
Nous supposerons le meilleur scénario, avec 4 cycles pour la séquence câblée. Cela nous donne une latence totale de 99 cycles, soit environ 6,2 µs avec une horloge de 16 MHz. Dans ce qui suit, j'explorerai quelques astuces qui peuvent être utilisées pour réduire cette latence. Ils viennent à peu près dans un ordre croissant de complexité, mais ils ont tous besoin que nous creusions d'une manière ou d'une autre dans les composants internes du MCU.
Utiliser un accès direct au port
Le premier objectif évident pour raccourcir la latence est
digitalRead()
. Cette fonction fournit une belle abstraction au matériel MCU, mais elle est trop inefficace pour un travail à temps critique. Se débarrasser de celui-ci est en fait trivial: il suffit de le remplacer pardigitalReadFast()
, de la bibliothèque digitalwritefast . Cela réduit la latence de près de moitié au prix d'un petit téléchargement!Eh bien, c'était trop facile pour être amusant, je vais plutôt vous montrer comment le faire à la dure. Le but est de nous lancer dans des choses de bas niveau. La méthode est appelée " accès direct au port " et est bien documentée sur la référence Arduino à la page sur les registres de ports . À ce stade, c'est une bonne idée de télécharger et de consulter la fiche technique ATmega328P . Ce document de 650 pages peut sembler quelque peu intimidant à première vue. Il est cependant bien organisé en sections spécifiques à chacun des périphériques et fonctionnalités du MCU. Et nous avons seulement besoin de vérifier les sections pertinentes à ce que nous faisons. Dans ce cas, il est la section du nom des ports d' E / S . Voici un résumé de ce que nous apprenons de ces lectures:
1 << 2
.Voici donc notre gestionnaire d'interruption modifié:
Maintenant, notre gestionnaire lira le registre d'E / S dès qu'il sera appelé. La latence est de 53 cycles CPU. Cette astuce simple nous a permis d'économiser 46 cycles!
Écrivez votre propre ISR
La prochaine cible pour la réduction de cycle est l'ISR INT0_vect. Cet ISR est nécessaire pour fournir la fonctionnalité de
attachInterrupt()
: nous pouvons changer les gestionnaires d'interruption à tout moment pendant l'exécution du programme. Cependant, bien que agréable à avoir, ce n'est pas vraiment utile pour notre objectif. Ainsi, au lieu d'avoir l'ISR du libcore localiser et appeler notre gestionnaire d'interruption, nous économiserons quelques cycles en remplaçant l'ISR par notre gestionnaire.Ce n'est pas aussi difficile qu'il y paraît. Les ISR peuvent être écrits comme des fonctions normales, il suffit de connaître leurs noms spécifiques et de les définir à l'aide d'une
ISR()
macro spéciale de avr-libc. À ce stade, il serait bon de jeter un œil à la documentation de avr-libc sur les interruptions et à la section de la fiche technique intitulée Interruptions externes . Voici le bref résumé:setup()
.setup()
.ISR(INT0_vect) { ... }
.Voici le code de l'ISR et
setup()
, tout le reste est inchangé:Cela vient avec un bonus gratuit: puisque cet ISR est plus simple que celui qu'il remplace, il a besoin de moins de registres pour faire son travail, alors le prologue d'enregistrement de registre est plus court. Nous en sommes maintenant à une latence de 20 cycles. Pas mal vu que nous avons commencé près de 100!
À ce stade, je dirais que nous avons terminé. Mission accomplie. Ce qui suit est réservé à ceux qui n'ont pas peur de se salir les mains avec un assemblage AVR. Sinon, vous pouvez arrêter de lire ici, et merci d'être allé si loin.
Écrivez un ISR nu
Toujours là? Bien! Pour aller plus loin, il serait utile d'avoir au moins une idée très basique du fonctionnement de l'assemblage et de jeter un œil au livre de recettes de l' assembleur en ligne de la documentation avr-libc. À ce stade, notre séquence d'entrée d'interruption ressemble à ceci:
Si nous voulons faire mieux, nous devons déplacer la lecture du port dans le prologue. L'idée est la suivante: la lecture du registre PIND encombrera un registre CPU, nous devons donc enregistrer au moins un registre avant de le faire, mais les autres registres peuvent attendre. Nous devons ensuite écrire un prologue personnalisé qui lit le port d'E / S juste après avoir enregistré le premier registre. Vous avez déjà vu dans la documentation d'interruption avr-libc (vous l'avez lu, non?) Qu'un ISR peut être rendu nu , auquel cas le compilateur n'émettra aucun prologue ou épilogue, nous permettant d'écrire notre propre version personnalisée.
Le problème avec cette approche est que nous finirons probablement par écrire l'ensemble de l'ISR en assembleur. Ce n'est pas grave, mais je préfère que le compilateur écrive ces ennuyeux prologues et épilogues pour moi. Voici donc la sale astuce: nous allons diviser l'ISR en deux parties:
Notre précédent INT0 ISR est alors remplacé par ceci:
Ici, nous utilisons la macro ISR () pour avoir l'instrument de compilation
INT0_vect_part_2
avec le prologue et l'épilogue requis. Le compilateur se plaindra que "" INT0_vect_part_2 "semble être un gestionnaire de signal mal orthographié", mais l'avertissement peut être ignoré en toute sécurité. Maintenant, l'ISR a une seule instruction de 2 cycles avant la lecture du port réel, et la latence totale n'est que de 10 cycles.Utilisez le registre GPIOR0
Et si nous pouvions avoir un registre réservé pour ce travail spécifique? Ensuite, nous n'aurions pas besoin de sauvegarder quoi que ce soit avant de lire le port. Nous pouvons en fait demander au compilateur de lier une variable globale à un registre . Cela, cependant, nous obligerait à recompiler tout le noyau Arduino et libc afin de nous assurer que le registre est toujours réservé. Pas vraiment pratique. D'un autre côté, l'ATmega328P se trouve avoir trois registres qui ne sont pas utilisés par le compilateur ni aucune bibliothèque, et sont disponibles pour stocker tout ce que nous voulons. Ils sont appelés GPIOR0, GPIOR1 et GPIOR2 ( Registres d'E / S à usage général ). Bien qu'ils soient mappés dans l'espace d'adressage d'E / S de la MCU, ils ne sont en fait pasRegistres d'E / S: ce ne sont que de la mémoire ordinaire, comme trois octets de RAM qui se sont en quelque sorte perdus dans un bus et se sont retrouvés dans le mauvais espace d'adressage. Ceux-ci ne sont pas aussi capables que les registres CPU internes, et nous ne pouvons pas copier PIND dans l'un d'eux avec l'
in
instruction. GPIOR0 est intéressant, cependant, en ce qu'il est adressable par bit , tout comme PIND. Cela nous permettra de transférer les informations sans encombrer aucun registre CPU interne.Voici l'astuce: nous nous assurerons que GPIOR0 est initialement nul (il est en fait effacé par le matériel au démarrage), puis nous utiliserons
sbic
(Ignorer l'instruction suivante si un bit dans un registre d'E / S est effacé ) et lesbi
( Définissez à 1 un bit dans un registre d'E / S) comme suit:De cette façon, GPIOR0 finira par être 0 ou 1 selon le bit que nous voulions lire depuis PIND. L'instruction sbic prend 1 ou 2 cycles à exécuter selon que la condition est fausse ou vraie. De toute évidence, le bit PIND est accessible au premier cycle. Dans cette nouvelle version du code, la variable globale
sampled_pin
n'est plus utile, car elle est fondamentalement remplacée par GPIOR0:Il convient de noter que GPIOR0 doit toujours être réinitialisé dans l'ISR.
Maintenant, l'échantillonnage du registre d'E / S PIND est la toute première chose effectuée à l'intérieur de l'ISR. La latence totale est de 8 cycles. C'est à peu près le mieux que nous puissions faire avant d'être taché de coups terriblement coupables. C'est encore une bonne occasion d'arrêter de lire ...
Mettez le code critique dans le tableau vectoriel
Pour ceux qui sont encore là, voici notre situation actuelle:
Il y a évidemment peu de place pour l'amélioration. La seule façon de raccourcir la latence à ce stade est de remplacer le vecteur d'interruption lui-même par notre code. Soyez averti que cela devrait être extrêmement désagréable pour quiconque apprécie la conception de logiciels propres. Mais c'est possible, et je vais vous montrer comment.
La disposition de la table vectorielle ATmega328P se trouve dans la fiche technique, section Interruptions , sous-section Vecteurs d'interruption dans ATmega328 et ATmega328P . Ou en démontant tout programme pour cette puce. Voici à quoi ça ressemble. J'utilise les conventions de avr-gcc et avr-libc (__init est le vecteur 0, les adresses sont en octets) qui sont différentes de celles d'Atmel.
Chaque vecteur a un créneau de 4 octets, rempli d'une seule
jmp
instruction. Il s'agit d'une instruction 32 bits, contrairement à la plupart des instructions AVR qui sont 16 bits. Mais un slot 32 bits est trop petit pour contenir la première partie de notre ISR: nous pouvons adapter les instructionssbic
etsbi
, mais pas lesrjmp
. Si nous faisons cela, la table vectorielle finit par ressembler à ceci:Lorsque INT0 se déclenche, PIND est lu, le bit correspondant est copié dans GPIOR0, puis l'exécution passe au vecteur suivant. Ensuite, l'ISR pour INT1 sera appelé, au lieu de l'ISR pour INT0. C'est effrayant, mais comme nous n'utilisons pas INT1 de toute façon, nous allons juste "détourner" son vecteur pour desservir INT0.
Il ne nous reste plus qu'à écrire notre propre table vectorielle personnalisée pour remplacer celle par défaut. Il s'avère que ce n'est pas si facile. La table vectorielle par défaut est fournie par la distribution avr-libc, dans un fichier objet appelé crtm328p.o qui est automatiquement lié à tout programme que nous construisons. Contrairement au code de bibliothèque, le code de fichier objet n'est pas censé être remplacé: essayer de faire cela donnera une erreur de l'éditeur de liens sur la table étant définie deux fois. Cela signifie que nous devons remplacer l'intégralité de crtm328p.o par notre version personnalisée. Une option consiste à télécharger le code source complet de avr-libc , à effectuer nos modifications personnalisées dans gcrt1.S , puis à le créer en tant que libc personnalisée.
Ici, je suis allé pour une approche alternative plus légère. J'ai écrit un crt.S personnalisé, qui est une version simplifiée de l'original de avr-libc. Il lui manque quelques fonctionnalités rarement utilisées, comme la possibilité de définir un ISR "catch all", ou de pouvoir terminer le programme (ie geler l'Arduino) en appelant
exit()
. Voici le code. J'ai découpé la partie répétitive de la table vectorielle afin de minimiser le défilement:Il peut être compilé avec la ligne de commande suivante:
L'esquisse est identique à la précédente, sauf qu'il n'y a pas INT0_vect et INT0_vect_part_2 est remplacé par INT1_vect:
Pour compiler l'esquisse, nous avons besoin d'une commande de compilation personnalisée. Si vous avez suivi jusqu'à présent, vous savez probablement comment compiler à partir de la ligne de commande. Vous devez explicitement demander à silly-crt.o d'être lié à votre programme et ajouter l'
-nostartfiles
option pour éviter de lier dans le crtm328p.o d'origine.Maintenant, la lecture du port d'E / S est la toute première instruction exécutée après les déclencheurs d'interruption. J'ai testé cette version en lui envoyant des impulsions courtes à partir d'un autre Arduino, et il peut capter (mais pas de manière fiable) le niveau élevé d'impulsions aussi court que 5 cycles. Nous ne pouvons rien faire de plus pour raccourcir la latence d'interruption sur ce matériel.
la source
L'interruption est définie pour se déclencher lors d'une modification et votre test_func est défini comme la routine de service d'interruption (ISR), appelée pour traiter cette interruption. L'ISR imprime ensuite la valeur de l'entrée.
À première vue, vous vous attendez à ce que la sortie soit comme vous l'avez dit et à un ensemble alterné de hauts bas, car elle n'atteint l'ISR que lors d'un changement.
Mais ce qui nous manque, c'est qu'il faut un certain temps au CPU pour réparer une interruption et se connecter à l'ISR. Pendant ce temps, la tension sur la broche peut avoir changé à nouveau. Surtout si la broche n'est pas stabilisée par un rebond matériel ou similaire. Comme l'interruption est déjà signalée et n'a pas encore été réparée, cette modification supplémentaire (ou la plupart d'entre eux, car un niveau de broches peut changer très rapidement par rapport à la vitesse d'horloge s'il a une faible capacité parasite) sera manquée.
Donc, en substance, sans une certaine forme de rebond, nous n'avons aucune garantie que lorsque l'entrée change, et que l'interruption est marquée pour l'entretien, que l'entrée sera toujours à la même valeur lorsque nous arriverons à lire sa valeur dans l'ISR.
À titre d'exemple générique, la fiche technique ATmega328 utilisée sur l'Arduino Uno détaille les temps d'interruption dans la section 6.7.1 - «Temps de réponse aux interruptions». Il indique pour ce microcontrôleur que le temps minimum de branchement à un ISR pour l'entretien est de 4 cycles d'horloge, mais peut être plus long (supplémentaire si l'exécution d'une instruction multi-cycle à l'interruption ou 8 + temps de veille de veille si le MCU est en veille).
Comme @EdgarBonet l'a mentionné dans les commentaires, le code PIN pourrait également changer de manière similaire pendant l'exécution d'ISR. Parce que l'ISR lit deux fois à partir de la broche, il n'ajouterait rien au test_array s'il rencontrait un LOW sur la première lecture et un HIGH sur la seconde. Mais x augmenterait toujours, laissant cet emplacement dans le tableau inchangé (peut-être sous forme de données non initialisées selon ce qui a été fait au tableau plus tôt).
Son ISR d'une ligne
test_array[x++] = digitalRead(pin);
est une solution parfaite à cela.la source