← Partie 4. Programmation des périphériques et gestion des interruptions
Bibliothèque de générateur de code d'assembleur pour microcontrôleurs AVR
Partie 5. Conception d'applications multithread
Dans les parties précédentes de l'article, nous avons développé les bases de la programmation à l'aide de la bibliothèque. Dans la partie précédente, nous nous sommes familiarisés avec la mise en œuvre des interruptions et les restrictions qui peuvent survenir lors de leur utilisation. Dans cette partie de l'article, nous nous attarderons sur l'une des options possibles pour programmer des processus parallèles en utilisant la classe Parallel . L'utilisation de cette classe permet de simplifier la création d'applications dans lesquelles les données doivent être traitées dans plusieurs flux de programmes indépendants.
Tous les systèmes multitâches pour les systèmes monocœur sont similaires les uns aux autres. Le multithreading est implémenté grâce au travail du répartiteur, qui alloue un intervalle de temps pour chaque thread, et lorsqu'il se termine, il prend le contrôle et donne le contrôle au thread suivant. La différence entre les différentes implémentations n'est que dans les détails, nous allons donc nous attarder plus en détail principalement sur les caractéristiques spécifiques de cette implémentation.
L'unité d'exécution du processus dans le thread est la tâche. Un nombre illimité de tâches peut exister dans le système, mais à un moment donné, seul un certain nombre d'entre elles peuvent être activées, limité par le nombre de flux de travail dans le répartiteur. Dans cette implémentation, le nombre de workflows est spécifié dans le constructeur du gestionnaire et ne peut pas être modifié ultérieurement. Dans le processus, les threads peuvent effectuer des tâches ou rester libres. Contrairement à d'autres solutions, Parallel Manager ne change pas de tâches. Pour que la tâche retourne le contrôle au répartiteur, les commandes appropriées doivent être insérées dans son code. Ainsi, la responsabilité de la durée de l'intervalle de temps dans la tâche incombe au programmeur, qui doit insérer des commandes d'interruption à certains endroits du code si la tâche prend trop de temps, ainsi que déterminer le comportement du thread à la fin de la tâche. L'avantage de cette approche est que le programmeur contrôle les points de commutation entre les tâches, ce qui vous permet d'optimiser considérablement le code de sauvegarde / restauration lors du changement de tâches, ainsi que de vous débarrasser de la plupart des problèmes liés à l'accès aux données thread-safe.
Pour contrôler l'exécution des tâches en cours d'exécution, une classe Signal spéciale est utilisée. Le signal est une variable binaire, dont le réglage est utilisé comme signal d'activation pour démarrer une tâche dans un flux. Les valeurs du signal peuvent être définies manuellement ou par un événement associé à ce signal.
Le signal est réinitialisé lorsque la tâche est activée par le répartiteur ou peut être exécutée par programme.
Les tâches dans le système peuvent être dans les états suivants:
Désactivé - état initial pour toutes les tâches. La tâche ne prend pas le flux et le contrôle d'exécution n'est pas transféré. Le retour à cet état pour les tâches activées se produit à la fin de la commande.
Activé - l'état dans lequel se trouve la tâche après l'activation. Le processus d'activation associe une tâche à un fil d'exécution et à un signal d'activation. Le gestionnaire interroge les threads et démarre la tâche si le signal de tâche est activé.
Bloqué - lorsqu'une tâche est activée, un signal peut déjà lui être affecté en tant que signal, qui est déjà utilisé pour contrôler un autre thread. Dans ce cas, afin d'éviter l'ambiguïté du comportement du programme, la tâche activée passe à l'état verrouillé. Dans cet état, la tâche occupe le thread, mais ne peut pas recevoir de contrôle, même si son signal est activé. À la fin des tâches ou lors de la modification du signal d'activation, le répartiteur vérifie et modifie l'état des tâches dans les threads. Si les threads ont bloqué des tâches pour lesquelles le signal correspond à celui libéré, le premier trouvé est activé. Si nécessaire, le programmeur peut verrouiller et déverrouiller les tâches indépendamment, en fonction de la logique requise du programme.
En attente - l'état de la tâche après l'exécution de la commande Délai . Dans cet état, la tâche ne reçoit le contrôle que lorsque l'intervalle requis s'est écoulé. Dans la classe Parallel , des interruptions WDT de 16 ms sont utilisées pour contrôler le retard, ce qui permet de ne pas occuper les temporisateurs pour les besoins du système. Dans le cas où vous avez besoin de plus de stabilité ou de résolution dans de petits intervalles, au lieu de retard, vous pouvez utiliser l'activation par des signaux de minuterie. Il convient de garder à l'esprit que la précision du retard sera toujours faible et fluctuera dans la plage de «temps de réponse du répartiteur» - «durée maximale de la tranche de temps dans le système + temps de réponse du répartiteur» . Pour les tâches avec des plages de temps exactes, un mode hybride doit être utilisé, dans lequel le temporisateur non utilisé dans la classe Parallèle fonctionne indépendamment du flux de tâches et traite les intervalles en mode d'interruption pure.
Chaque tâche exécutée dans un thread est un processus isolé. Cela nécessite la définition de deux types de données: les données locales d'un flux, qui ne doivent être visibles et modifiées que dans le cadre de ce flux, et les données globales pour l'échange entre les flux et l'accès aux ressources partagées. Dans le cadre de cette implémentation, les données globales sont créées par des commandes précédemment considérées au niveau de l'appareil. Pour créer des variables de tâche locales, elles doivent être créées à l'aide de méthodes de la classe de tâches. Le comportement d'une variable de tâche locale est le suivant: lorsqu'une tâche est interrompue avant de transférer le contrôle au répartiteur, toutes les variables de registre locales sont stockées dans la mémoire du flux. Lorsque le contrôle est renvoyé, les variables de registre locales sont restaurées avant l'exécution de la commande suivante.
La classe avec l'interface IHeap associée à la propriété Heap de la classe Parallel est responsable du stockage des données de flux local. L'implémentation la plus simple de cette classe est StaticHeap , qui implémente l'allocation statique des mêmes blocs de mémoire pour chaque thread. Dans le cas où les tâches ont une large répartition en fonction de l'exigence de la quantité de données locales, vous pouvez utiliser DynamicHeap , qui vous permet de déterminer la taille de la mémoire locale individuellement pour chaque tâche. De toute évidence, la surcharge de travail avec la mémoire de flux dans ce cas sera considérablement plus élevée.
Examinons maintenant de plus près la syntaxe de la classe en utilisant deux flux comme exemple, dont chacun commute indépendamment une sortie de port distincte.
var m = new Mega328 { FCLK = 16000000, CKDIV8 = false }; m.PortB.Direction(0x07); var bit1 = m.PortB[1]; var bit2 = m.PortB[2]; m.PortB.Activate(); var tasks = new Parallel(m, 2); tasks.Heap = new StaticHeap(tasks, 16); var t1 = tasks.CreateTask((tsk) => { var loop = AVRASM.NewLabel(); bit1.Toggle(); tsk.Delay(32); tsk.TaskContinue(loop); },"Task1"); var t2 = tasks.CreateTask((tsk) => { var loop = AVRASM.NewLabel(); bit2.Toggle(); tsk.Delay(48); tsk.TaskContinue(loop); }, "Task2"); var ca = tasks.ContinuousActivate(tasks.AlwaysOn, t1); tasks.ActivateNext(ca, tasks.AlwaysOn, t2); ca.Dispose(); m.EnableInterrupt(); tasks.Loop();
Les premières lignes du programme vous sont déjà familières. Dans ceux-ci, nous déterminons le type de contrôleur et attribuons les premier et deuxième bits du port B comme sortie. Vient ensuite l'initialisation d'une variable de la classe Parallel , où dans le deuxième paramètre nous déterminons le nombre maximum de threads d'exécution. Dans la ligne suivante, nous allouons de la mémoire pour accueillir les flux de variables locales. Nous avons des tâches égales, nous utilisons donc StaticHeap . Le prochain bloc de code est la définition des tâches. Nous y définissons deux tâches presque identiques. La seule différence est le port de contrôle et la quantité de retard. Pour travailler avec des objets de tâche locaux, un pointeur vers la tâche locale tsk est transmis au bloc de code de tâche. Le texte de la tâche lui-même est très simple:
- une étiquette locale est créée pour organiser un cycle de commutation infini
- l'état du port est inversé
- le contrôle est renvoyé au répartiteur et la tâche passe en état d'attente pendant le nombre de millisecondes spécifié
- Le pointeur de retour est défini sur le bloc de départ du bloc et le contrôle est renvoyé au répartiteur.
Evidemment, dans un exemple concret, la dernière commande pourrait être remplacée par une commande normale pour aller au début du bloc et donnée dans l'exemple uniquement à des fins de démonstration. Si vous le souhaitez, l'exemple peut être facilement développé pour contrôler un grand nombre de conclusions, en copiant les tâches et en augmentant le nombre de threads.
Une liste complète des commandes d'abandon de tâche pour transférer le contrôle au répartiteur est la suivante
AWAIT (signal) - le flux enregistre toutes les variables dans la mémoire du flux et transfère le contrôle au répartiteur. La prochaine fois que le flux est activé, les variables sont restaurées et l'exécution se poursuit, en commençant par l'instruction suivante après AWAIT . La commande est conçue pour diviser la tâche en intervalles de temps et pour implémenter la machine d'état selon le schéma Signal → Traitement 1 → Signal → Traitement 2 , etc.
La commande AWAIT peut avoir un signal comme paramètre facultatif. Si le paramètre est vide, le signal d'activation est enregistré. S'il est spécifié dans le paramètre, tous les appels de tâche suivants seront effectués lorsque le signal spécifié est activé et la communication avec le signal précédent est perdue.
TaskContinue (étiquette, signal) - la commande termine le flux et donne le contrôle au répartiteur sans enregistrer les variables. La prochaine fois que le flux est activé, le contrôle est transféré sur l'étiquette de l' étiquette . Le paramètre Signal facultatif vous permet de remplacer le signal d'activation de flux pour le prochain appel. S'il n'est pas spécifié, le signal reste le même. Une commande sans spécifier de signal peut être utilisée pour organiser des cycles au sein d'une seule tâche, où chaque cycle est exécuté dans une tranche de temps distincte. Il peut également être utilisé pour affecter une nouvelle tâche au thread en cours après avoir terminé la précédente. L'avantage de cette approche par rapport au cycle Libérer un fil → Mettre en surbrillance un flux est un programme plus efficace. L'utilisation de TaskContinue élimine la nécessité pour le gestionnaire de rechercher un thread libre dans le pool et garantit les erreurs lors de la tentative d'allocation de threads en l'absence de threads libres.
TaskEnd () - efface le flux une fois la tâche terminée. La tâche se termine, le thread est libéré et peut être utilisé pour affecter une nouvelle tâche avec la commande Activer .
Délai (ms) - le flux, comme dans le cas de l'utilisation d' AWAIT , enregistre toutes les variables dans la mémoire du flux et transfère le contrôle au répartiteur. Dans ce cas, la valeur du retard en millisecondes est enregistrée dans l'en-tête du flux. Dans la boucle du répartiteur, dans le cas d'une valeur non nulle dans le champ de retard, le flux n'est pas activé. La modification des valeurs dans le champ de retard pour tous les flux est effectuée en interrompant le temporisateur WDT toutes les 16 ms. Lorsque la valeur zéro est atteinte, l'interdiction d'exécution est supprimée et le signal d'activation de flux est défini. Seule une valeur à un octet pour le retard est stockée dans l'en-tête, ce qui donne une plage relativement étroite de retards possibles.Par conséquent, pour implémenter des retards plus longs, Delay () crée une boucle interne à l'aide de variables de flux locales.
L'activation des commandes dans l'exemple est effectuée à l'aide des commandes ContinuousActivate et ActivateNext . Il s'agit d'un type spécial d'activation de tâche initiale au démarrage. Lors de la phase d'activation initiale, nous avons la garantie de ne pas avoir un seul thread occupé, de sorte que le processus d'activation ne nécessite pas de recherche préalable d'un thread libre pour une tâche et vous permet d'activer les tâches en séquence. ContinuousActivate active la tâche dans le thread zéro et renvoie un pointeur vers l'en-tête du thread suivant, et la fonction ActivateNext utilise ce pointeur pour activer les tâches suivantes dans les threads séquentiels.
Comme signal d'activation, l'exemple utilise le signal AlwaysOn . C'est l'un des signaux du système. Son objectif signifie que la tâche sera toujours exécutée, car il s'agit du seul signal toujours activé et non réinitialisé par l'utilisation.
L'exemple se termine par un appel en boucle . Cette fonction démarre le cycle du répartiteur, cette commande doit donc être la dernière du code.
Prenons un autre exemple où l'utilisation de la bibliothèque peut simplifier considérablement la structure du code. Que ce soit un dispositif de contrôle conditionnel qui enregistre un signal analogique et l'envoie sous la forme d'un code HEX au terminal.
var m = new Mega328(); m.FCLK = 16000000; m.CKDIV8 = false; var cData = m.DREG(); var outDigit = m.ARRAY(4); var chex = Const.String("0123456789ABCDEF"); m.ADC.Clock = eADCPrescaler.S64; m.ADC.ADCReserved = 0x01; m.ADC.Source = eASource.ADC0; m.Usart.Baudrate = 9600; m.Usart.FrameFormat = eUartFrame.U8N1; var os = new Parallel(m, 4); os.Heap = new StaticHeap(os, 8); var ADS = os.AddSignal(m.ADC.Handler, () => m.ADC.Data(cData)); var trm = os.AddSignal(m.Usart.TXC_Handler); var starts = os.AddLocker(); os.PrepareSignals(); var t0 = os.CreateTask((tsk) => { m.LOOP(m.TempL, (r, l) => m.GO(l), (r, l) => { m.ADC.ConvertAsync(); tsk.Delay(500); }); }, "activate"); var t1 = os.CreateTask((tsk) => { var loop = AVRASM.NewLabel(); var mref = m.ROMPTR(); mref.Load(chex); m.TempL.Load(cData.High); m.TempL >>= 4; mref += m.TempL; mref.MLoad(m.TempL); m.TempL.MStore(outDigit[0]); mref.Load(chex); m.TempL.Load(cData.High); m.TempL &= 0x0F; mref += m.TempL; mref.MLoad(m.TempL); m.TempL.MStore(outDigit[1]); mref.Load(chex); m.TempL.Load(cData.Low); m.TempL >>= 4; mref += m.TempL; mref.MLoad(m.TempL); m.TempL.MStore(outDigit[2]); mref.Load(chex); m.TempL.Load(cData.Low); m.TempL &= 0x0F; mref += m.TempL; mref.MLoad(m.TempL); m.TempL.MStore(outDigit[3]); starts.Set(); tsk.TaskContinue(loop); }); var t2 = os.CreateTask((tsk) => { var loop = AVRASM.NewLabel(); trm.Clear(); m.TempL.Load('0'); m.Usart.Transmit(m.TempL); tsk.AWAIT(trm); m.TempL.Load('x'); m.Usart.Transmit(m.TempL); tsk.AWAIT(); m.TempL.MLoad(outDigit[0]); m.Usart.Transmit(m.TempL); tsk.AWAIT(); m.TempL.MLoad(outDigit[1]); m.Usart.Transmit(m.TempL); tsk.AWAIT(); m.TempL.MLoad(outDigit[2]); m.Usart.Transmit(m.TempL); tsk.AWAIT(); m.TempL.MLoad(outDigit[3]); m.Usart.Transmit(m.TempL); tsk.AWAIT(); m.TempL.Load(13); m.Usart.Transmit(m.TempL); tsk.AWAIT(); m.TempL.Load(10); m.Usart.Transmit(m.TempL); tsk.TaskContinue(loop, starts); }); var p = os.ContinuousActivate(os.AlwaysOn, t0); os.ActivateNext(p, ADS, t1); os.ActivateNext(p, starts, t2); m.ADC.Activate(); m.Usart.Activate(); m.EnableInterrupt(); os.Loop();
Cela ne veut pas dire que nous avons vu beaucoup de nouvelles choses ici, mais vous pouvez voir quelque chose d'intéressant dans ce code.
Dans cet exemple, ADC (convertisseur analogique-numérique) est tout d'abord mentionné. Ce périphérique est conçu pour convertir la tension du signal d'entrée en un code numérique. Le cycle de conversion est démarré par la fonction ConvertAsync , qui démarre uniquement le processus sans attendre le résultat. Une fois la conversion terminée, le CAN génère une interruption qui active le signal adcSig . Faites attention à la définition du signal adcSig . En plus du pointeur d'interruption, il contient également un bloc de code pour stocker les valeurs du registre de données ADC. Tout le code qui est de préférence exécuté immédiatement après une interruption (par exemple, la lecture de données à partir des registres de l'appareil) doit être situé à cet endroit.
La tâche de conversion consiste à convertir un code de tension binaire en une représentation HEX à quatre caractères pour notre terminal conditionnel. Ici, nous pouvons noter l'utilisation de fonctions pour décrire les fragments répétitifs afin de réduire la taille du code source et l'utilisation d'une chaîne constante pour la conversion des données.
Le problème de transmission est intéressant du point de vue de l'implémentation d'une sortie formatée d'une chaîne dans laquelle la sortie de données statiques et dynamiques est combinée. Le mécanisme lui-même ne peut pas être considéré comme idéal; il s'agit plutôt d'une démonstration des possibilités de gestion des gestionnaires. Ici, vous pouvez également faire attention à la redéfinition du signal d'activation pendant l'exécution, qui change le signal d'activation de ConvS en TxS et vice versa.
Pour une meilleure compréhension, nous décrivons en mots l'algorithme du programme.
Dans l'état initial, nous avons lancé trois tâches. Deux d'entre eux ont des signaux inactifs, car le signal pour la tâche de conversion (adcSig) est activé à la fin du cycle de lecture du signal analogique, et ConvS pour la tâche de transmission est activé par un code qui n'a pas encore été exécuté. Par conséquent, la première tâche à lancer après le lancement sera toujours la mesure. Le code de cette tâche démarre le cycle de conversion ADC, après quoi la tâche de 500 ms entre dans le cycle d'attente. À la fin du cycle de conversion, l'indicateur adcSig est activé , ce qui déclenche la tâche de conversion . Dans cette tâche, un cycle de conversion des données reçues en chaîne est implémenté. Avant de quitter la tâche, nous activons le drapeau ConvS , indiquant clairement que nous avons de nouvelles données à envoyer au terminal. La commande exit réinitialise le point de retour au début de la tâche et donne le contrôle au répartiteur. L' ensemble d' indicateurs ConvS permet de transférer le contrôle à la tâche de transmission . Après avoir transmis le premier octet de la séquence, le signal d'activation de la tâche passe à TxS . Par conséquent, une fois le transfert de l'octet terminé, la tâche de transmission sera à nouveau appelée, ce qui conduira au transfert de l'octet suivant. Une fois le dernier octet de la séquence transmis, la tâche renvoie le signal d' activation ConvS et réinitialise le point de retour au début de la tâche. Le cycle est terminé. Le cycle suivant commencera lorsque la tâche de mesure terminera l'attente et activera le cycle de mesure suivant.
Dans presque tous les systèmes multitâches, il existe le concept de files d'attente pour l'interaction entre les threads. Nous avons déjà compris que, comme la commutation entre les tâches dans ce système est un processus complètement contrôlé, l'utilisation de variables globales pour échanger des données entre les tâches est tout à fait possible. Cependant, il existe un certain nombre de tâches où l'utilisation de files d'attente est justifiée. Par conséquent, nous ne laisserons pas de côté ce sujet et verrons comment il est implémenté dans la bibliothèque.
Pour implémenter une file d'attente dans un programme, il est préférable d'utiliser la classe RingBuff . La classe, comme son nom l'indique, implémente un tampon en anneau avec des commandes d'écriture et de récupération. La lecture et l'écriture des données sont effectuées par les commandes de lecture et d' écriture . Les commandes de lecture et d'écriture n'ont pas de paramètres. Le tampon utilise la variable de registre spécifiée dans le constructeur comme source / récepteur de données. L'accès à cette variable se fait via le paramètre classe IOReg . L'état du tampon est déterminé par les deux drapeaux Ovf et Empty , qui aident à déterminer l'état de débordement pendant l'écriture et de débordement pendant la lecture. De plus, la classe a la capacité de déterminer le code qui s'exécute sur les événements de débordement / débordement. RingBuff n'a pas de dépendances sur la classe Parallel et peut être utilisé séparément. La limitation lors de l'utilisation de la classe est la capacité autorisée, qui doit être un multiple de la puissance de deux (8.16.32, etc.) pour des raisons d'optimisation du code.
Un exemple de travail avec la classe est donné ci-dessous.
var m = new Mega328(); var io = m.REG();
Cette partie conclut l'aperçu des fonctionnalités de la bibliothèque. Malheureusement, il restait un certain nombre d'aspects concernant les capacités de la bibliothèque, qui n'étaient même pas mentionnés. À l'avenir, en cas d'intérêt pour le projet, des articles sont prévus pour résoudre des problèmes spécifiques à l'aide de la bibliothèque et une description plus détaillée des problèmes complexes nécessitant une publication séparée.