Partie 2. Mise en route →
Bibliothèque de générateur de code d'assembleur pour microcontrôleurs AVR
Partie 1. Première connaissance
Bonjour, cher Khabrovites. Je souhaite attirer votre attention sur le prochain projet (parmi les nombreux disponibles) de programmation des microcontrôleurs populaires de la série AVR.
Il serait possible de dépenser beaucoup de texte pour expliquer pourquoi cela était nécessaire, mais regardez plutôt des exemples de la façon dont cela diffère des autres solutions. Et toutes les explications et comparaisons avec les systèmes de programmation existants seront, si nécessaire, en cours d'analyse des exemples. La bibliothèque est maintenant en cours de finalisation, donc l'implémentation de certaines fonctions peut ne pas sembler optimale. De plus, certaines des tâches assignées au programmeur dans cette version sont censées être optimisées ou automatisées.
Commençons donc. Je tiens à préciser tout de suite que le matériel présenté ne doit en aucun cas être considéré comme une description complète, mais uniquement comme une démonstration de certaines des fonctionnalités de la bibliothèque développée afin d'aider à comprendre à quel point cette approche peut être intéressante pour les lecteurs.
Nous ne nous écarterons pas de la pratique établie et commencerons par un exemple classique, une sorte de «Hello world» pour les microcontrôleurs. À savoir, nous clignotons la LED connectée à l'une des jambes du processeur. Ouvrons VisualStudio de Microsoft (n'importe quelle version fera l'affaire) et créons une application console pour C #. Pour ceux qui ne sont pas au courant, Community Edition, suffisant pour le travail, est absolument gratuit.
En fait, le texte lui-même est le suivant:
Exemple de code source 1using NanoRTOSLib; using System; namespace ConsoleApp { class Program { static void Main(string[] args) { var m = new Mega328(); m.PortB[0].Mode = ePinMode.OUT; m.PortB.Activate(); m.LOOP(m.TempL, (r, l) => m.GO(l), (r) => { m.PortB[0].Toggle();}); Console.WriteLine(AVRASM.Text(m)); } } }
Bien sûr, pour que tout fonctionne et vous avez besoin de la bibliothèque que je représente.
Après avoir compilé et exécuté le programme, dans la sortie de la console, nous verrons le résultat suivant de ce programme.
Résultat de compilation de l'exemple 1 #include “common.inc” RESET: ldi r16, high(RAMEND) out SPH,r16 ldi r16, low(RAMEND) out SPL,r16 outi DDRB,0x1 L0000: in TempL,PORTB ldi TempH,1 eor TempL,TempH out PORTB,TempL xjmp L0000 .DSEG
Si vous copiez le résultat dans un environnement qui sait travailler avec l'assembleur AVR et connectez la bibliothèque de macros Common.inc (la bibliothèque de macros est également l'un des composants du système de programmation présenté et fonctionne en conjonction avec NanoRTOSLib ), alors ce programme peut être compilé et vérifié sur un émulateur ou une puce réelle et assurez-vous que tout fonctionne.
Considérez le code source du programme plus en détail. On attribue tout d'abord à la variable m le type de cristal utilisé. Ensuite, définissez le mode de sortie numérique pour le bit zéro du port B du cristal et activez le port. La ligne suivante semble un peu étrange, mais sa signification est assez simple. Dans ce document, nous disons que nous voulons organiser une boucle infinie, dans le corps de laquelle nous changeons la valeur du bit zéro du port B à l'opposé. La dernière ligne du programme visualise en fait le résultat de tout ce qui a été précédemment écrit sous forme de code assembleur. Tout est extrêmement simple et compact. Et le résultat n'est pratiquement pas différent de ce que l'on pourrait écrire en assembleur. Le code de sortie ne peut poser que deux questions: la première - pourquoi initialiser la pile si nous ne l’utilisons toujours pas, et quel type de xjmp ? La réponse à la première question et en même temps une explication de la raison pour laquelle l'assembleur est affiché, plutôt que le HEX fini, est la suivante: le résultat sous la forme d'assembleur vous permet d'analyser et d'optimiser davantage le programme, permettant au programmeur de sélectionner et de modifier des fragments de code qu'il n'aime pas. Et l'initialisation de la pile a été laissée au moins pour ces raisons que sans utiliser la pile, vous pouvez trouver peu de programmes. Cependant, si vous ne l'aimez pas, n'hésitez pas à le nettoyer. La sortie vers l'assembleur est prévue à cet effet. Quant à xjmp , il s'agit d'un exemple d'utilisation de macros pour augmenter la lisibilité de l'assembleur de sortie. Plus précisément, xjmp remplace jmp et rjmp avec la substitution correcte en fonction de la longueur de la transition.
Si vous remplissez le programme avec une puce, alors bien sûr, nous ne verrons pas le clignotement de la diode, malgré le fait que l'état des broches change. Cela arrive juste trop vite pour le voir à travers les yeux. Par conséquent, nous considérons le programme suivant, dans lequel nous continuons à clignoter avec une diode, mais pour qu'il puisse être vu. Par exemple, un retard de 0,5 seconde est tout à fait approprié: ni trop rapide ni trop lent. Il serait possible de faire de nombreuses boucles imbriquées avec des NOP pour former un retard, mais nous ignorerons cette étape en n'ajoutant rien à la description des capacités de la bibliothèque et profiterons immédiatement de l'opportunité d'utiliser le matériel disponible. Nous modifions notre application comme suit.
Exemple de code source 2 using System; namespace ConsoleApp { class Program { static void Main(string[] args) { var m = new Mega328(); m.PortB[0].Mode = ePinMode.OUT; m.PortB.Activate(); m.WDT.Clock = eWDTClock.WDT500ms; m.WDT.OnTimeout = () => m.PortB[0].Toggle(); m.WDT.Activate(); m.EnableInterrupt(); var loop = AVRASM.newLabel(); m.GO(loop); Console.WriteLine(AVRASM.Text(m)); } } }
De toute évidence, le programme est similaire au précédent, nous ne considérerons donc que ce qui a changé. Tout d'abord, dans cet exemple, nous avons utilisé WDT (watchdog timer). Pour travailler avec des retards importants qui ne nécessitent pas une précision extrême, c'est la meilleure option. Tout ce qui est nécessaire pour l'utiliser est de définir la fréquence requise en définissant le diviseur via la propriété WDT.Clock et de déterminer les actions qui doivent être effectuées au moment où l'événement est déclenché, en définissant le code via la propriété WDT.OnTimeout. Comme nous avons besoin d'interruptions pour fonctionner, elles doivent être activées avec la commande EnableInterrupt. Mais le cycle principal peut être remplacé par un mannequin. Dans ce document, nous ne prévoyons toujours rien faire. Par conséquent, nous allons déclarer et définir une étiquette et y effectuer une transition inconditionnelle pour organiser un cycle vide. Si vous aimez plus LOOP - s'il vous plaît. Le résultat de cela ne changera pas.
Eh bien, au final, regardons le code résultant.
Résultat de compilation de l'exemple 2 #include “common.inc” jmp RESET reti ; IRQ0 Handler nop reti ;IRQ1 Handler nop reti ;PC_INT0 Handler nop reti ;PC_INT1 Handler nop reti ;PC_INT2 Handler nop jmp WDT ;Watchdog Timer Handler RESET: ldi r16, high(RAMEND) out SPH,r16 ldi r16, low(RAMEND) out SPL,r16 outi DDRB,0x1 ldi TempL, (1<<WDCE) | (1<<WDE) sts WDTCSR,TempL ldi TempL, 0x42 sts WDTCSR,TempL sei L0000: xjmp L0000 WDT: push r17 push r16 in r16,SREG push r16 in TempL,PORTB ldi TempH,1 eor TempL,TempH out PORTB,TempL pop r16 out SREG,r16 pop r16 pop r17 reti .DSEG
Ceux qui sont familiers avec ce processeur auront sans aucun doute une question où plusieurs vecteurs d'interruption ont disparu. Ici, nous avons utilisé la logique suivante - si le code n'est pas utilisé - le code n'est pas nécessaire. Par conséquent, la table d'interruption se termine sur le dernier vecteur utilisé.
Malgré le fait que le programme gère parfaitement la tâche, les plus pointilleux peuvent ne pas aimer le fait que l'ensemble des retards possibles soit limité et que l'étape soit trop rude. Par conséquent, nous considérerons une autre manière, et en même temps, nous verrons comment le travail avec les temporisateurs est organisé dans la bibliothèque. Dans le cristal Mega328, qui est pris comme échantillon, il y en a jusqu'à 3. 2 8 bits et un 16 bits. Les architectes ont fait de leur mieux pour investir autant de fonctionnalités que possible dans ces minuteries, leur réglage est donc assez volumineux.
Tout d'abord, nous calculons quel compteur doit être utilisé pour notre retard de 0,5 seconde. Si nous prenons la fréquence d'horloge à cristal de 16 MHz, même avec le diviseur périphérique maximal, il est impossible de rester dans le compteur 8 bits. Par conséquent, nous ne compliquerons pas et n'utiliserons pas le seul compteur Timer1 16 bits à notre disposition.
En conséquence, le programme prend la forme suivante:
Exemple de code source 3 using NanoRTOSLib; using System; namespace ConsoleApp { class Program { static void Main(string[] args) {var m = new Mega328(); m.FCLK = 16000000; m.CKDIV8 = false; var bit1 = m.PortB[0]; bit1.Mode = ePinMode.OUT; m.PortB.Activate(); m.Timer1.Mode = eWaveFormMode.CTC_OCRA; m.Timer1.Clock = eTimerClockSource.CLK256; m.Timer1.OCRA = (ushort)((0.5 * m.FCLK) / 256); m.Timer1.OnCompareA = () => bit1.Toggle(); m.Timer1.Activate(); m.EnableInterrupt(); m.LOOP(m.TempH, (r, l) => m.GO(l), (r) => { }); Console.WriteLine(AVRASM.Text(m)); } } }
Puisque nous utilisons le générateur principal comme source d'horloge pour notre minuterie, pour le calcul correct du retard, vous devez spécifier la fréquence d'horloge du processeur, le réglage du diviseur et le fusible d'horloge périphérique. Le texte principal du programme règle la minuterie sur le mode souhaité. Ici, un délibérateur de 256 et non un maximum est délibérément choisi pour la synchronisation, car lorsque vous sélectionnez un diviseur de 1024 pour la fréquence d'horloge requise de 500 ms, que nous voulons obtenir, un nombre fractionnaire est obtenu.
Le code assembleur résultant de notre programme ressemblera à ceci:
Résultat de compilation de l'exemple 3 #include “common.inc” jmp RESET reti ; IRQ0 Handler nop reti ;IRQ1 Handler nop reti ;PC_INT0 Handler nop reti ;PC_INT1 Handler nop reti ;PC_INT2 Handler nop reti ;Watchdog Timer Handler nop reti ;Timer2 Compare A Handler nop reti ;Timer2 Compare B Handler nop reti ;Timer2 Overflow Handler nop reti ;Timer1 Capture Handler nop jmp TIM1_COMPA ;Timer1 Compare A Handler RESET: ldi r16, high(RAMEND) out SPH,r16 ldi r16, low(RAMEND) out SPL,r16 outi DDRB,0x1 outiw OCR1A,0x7A12 outi TCCR1A,0 outi TCCR1B,0xC outi TCCR1C,0x0 outi TIMSK1,0x2 outi DDRB,0x1 sei L0000: xjmp L0000 TIM1_COMPA: push r17 push r16 in r16,SREG push r16 in TempL,PORTB ldi TempH,1 eor TempL,TempH out PORTB,TempL pop r16 out SREG,r16 pop r16 pop r17 reti .DSEG
Il semble déjà qu'il n'y ait plus rien à commenter. Nous initialisons les appareils, configurons les interruptions et apprécions le programme.
Le travail par interruptions est le moyen le plus simple de créer des programmes pour travailler en temps réel. Malheureusement, il n'est pas toujours possible de basculer entre des tâches parallèles en utilisant uniquement des gestionnaires d'interruption pour effectuer ces tâches. La restriction est l'interdiction de la gestion des interruptions imbriquées, ce qui conduit au fait que jusqu'à la fermeture du processeur, le processeur ne répond pas à toutes les autres interruptions, ce qui peut entraîner une perte d'événements si le processeur fonctionne trop longtemps.
Une solution consiste à séparer le code d'enregistrement de l'événement et son traitement. Le noyau de traitement multithread parallèle de la bibliothèque est organisé de telle manière que lorsqu'un événement se produit, le gestionnaire d'interruption enregistre uniquement l'événement donné et, si nécessaire, effectue les opérations de capture de données minimales nécessaires, et tout le traitement est effectué dans le flux principal. Le noyau vérifie séquentiellement la présence d'indicateurs non traités et, s'il est trouvé, passe à la tâche correspondante.
L'utilisation de cette approche simplifie la conception de systèmes avec plusieurs tâches asynchrones, vous permettant de considérer chacune d'elles isolément, sans vous concentrer sur les problèmes de commutation des ressources entre les tâches. À titre d'exemple, considérons la mise en œuvre de deux tâches indépendantes, dont chacune commute sa sortie avec un certain retard.
Exemple de code source 4 using NanoRTOSLib; using System; namespace ConsoleApp { class Program { static void Main(string[] args) { var m = new Mega328(); m.FCLK = 16000000; m.CKDIV8 = false; m.PortB.Direction(0x07); var bit1 = m.PortB[1]; var bit2 = m.PortB[2]; m.PortB.Activate(); var tasks = new Parallel(m, 4); tasks.Heap = new StaticHeap(tasks, 64); 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(); Console.WriteLine(AVRASM.Text(m)); } } }
Dans cette tâche, nous configurons le zéro et les premières sorties du port B en sortie et modifions la valeur de 0 à 1 et vice versa avec une période de 32 ms pour zéro et 48 ms pour la première sortie. Une tâche distincte est responsable de la gestion de chaque port. La première chose à noter est la définition d'une instance de Parallel. Cette classe est au cœur de la gestion des tâches. Dans son constructeur, nous déterminons le nombre maximal autorisé de threads s'exécutant simultanément. Ce qui suit est une allocation de mémoire pour stocker des flux de données. La classe StaticHeap utilisée dans l'exemple alloue un nombre fixe d'octets pour chaque flux. Pour résoudre notre problème, cela est acceptable, et l'utilisation d'une allocation de mémoire fixe par rapport à dynamique simplifie les algorithmes et rend le code plus compact et plus rapide. Plus loin dans le code, nous décrivons un ensemble de tâches conçues pour s'exécuter sous le contrôle du noyau. Vous devez faire attention à la fonction asynchrone Delay, que nous utilisons pour former un retard. Sa particularité est que lorsque cette fonction est appelée, le délai requis est défini dans les paramètres de flux et le contrôle est transféré au noyau. Une fois l'intervalle défini écoulé, le noyau renvoie le contrôle à la tâche à partir de la commande suivant la commande Délai. Une autre caractéristique de la tâche consiste à programmer le comportement du flux de tâches une fois terminé dans la dernière commande de tâche. Dans notre cas, les deux tâches sont configurées pour être exécutées dans une boucle infinie avec un contrôle revenant au noyau à la fin de chaque cycle. Si nécessaire, l'exécution d'une tâche peut libérer le thread ou le transmettre pour effectuer une autre tâche.
La raison de l'appel de la tâche est d'activer le signal affecté au flux de tâches. Le signal peut être activé à la fois par programme et matériel par des interruptions des périphériques. Un appel de tâche réinitialise le signal. Une exception est le signal prédéfini AlwaysOn, qui est toujours à l'état actif. Cela permet de créer des tâches qui seront contrôlées à chaque cycle d'interrogation. La fonction LOOP est requise pour appeler la boucle d'exécution principale. Malheureusement, la taille du code de sortie lors de l'utilisation de Parallel devient déjà beaucoup plus importante que dans les exemples précédents (environ 600 commandes) et ne peut pas être entièrement citée dans l'article.
Et pour la douceur - quelque chose de plus comme un projet en direct, à savoir un thermomètre numérique. Tout est toujours aussi simple. Un capteur numérique avec une interface SPI, un indicateur à 7 segments à 4 chiffres et plusieurs fils de traitement pour garder les choses au frais. Dans l'un, nous pilotons un cycle d'indication dynamique, dans un autre, les événements par lesquels un cycle de lecture de température est démarré, dans le troisième, nous lisons les valeurs reçues du capteur et les convertissons d'un code binaire en BCD, puis en un code de segment pour un tampon d'indication dynamique.
Le programme lui-même est le suivant.
Exemple de code source 5 using NanoRTOSLib; using System; namespace ConsoleApp { class Program { static void Main(string[] args) { var m = new Mega328(); m.FCLK = 16000000; m.CKDIV8 = false; var led7s = new Led_7(); led7s.SegPort = m.PortC; led7s.Activate(); m.PortD.Direction(0xFF); m.PortD.Activate(); m.PortB[0].Mode = ePinMode.OUT; var tc77 = new TC77(); tc77.CS = m.PortB[0]; tc77.Port = m.SPI; m.Timer0.Clock = eTimerClockSource.CLK64; m.Timer0.Mode = eWaveFormMode.Normal; var reader = m.DREG("Temperature"); var bcdRes = m.DREG("digits"); var tmp = m.BYTE(); var bcd = new BCD(reader, bcdRes); m.subroutines.Add(bcd); var os = new Parallel(m, 4); os.Heap = new StaticHeap(os, 64); var tmrSig = os.AddSignal(m.Timer0.OVF_Handler); var spiSig = os.AddSignal(m.SPI.Handler, () => { m.SPI.Read(m.TempL); m.TempL.MStore(tmp); }); var actuator = os.CreateTask((tsk) => { var loop = AVRASM.NewLabel(); tc77.ReadTemperatureAsync(); tsk.Delay(16); tsk.TaskContinue(loop); }, "actuator"); var treader = os.CreateTask((tsk) => { var loop = AVRASM.NewLabel(); tc77.ReadTemperatureCallback(os, reader, tmp); reader >>= 7; m.CALL(bcd); tsk.TaskContinue(loop); }, "reader"); var display = os.CreateTask((tsk) => { var loop = AVRASM.NewLabel(); m.PortD.Write(0xFE); m.TempQL.Load(bcdRes.Low); m.TempQL &= 0x0F; led7s.Show(m.TempQL); os.AWAIT(); m.PortD.Write(0xFD); m.TempQL.Load(bcdRes.Low); m.TempQL >>= 4; led7s.Show(m.TempQL); os.AWAIT(); m.PortD.Write(0xFB); m.TempQL.Load(bcdRes.High); m.TempQL &= 0x0F; led7s.Show(m.TempQL); os.AWAIT(); m.PortD.Write(0xF7); m.TempQL.Load(bcdRes.High); m.TempQL >>= 4; led7s.Show(m.TempQL); os.AWAIT(); tsk.TaskContinue(loop); }, "display"); var ct = os.ContinuousActivate(os.AlwaysOn, actuator); os.ActivateNext(ct, spiSig, treader); os.ActivateNext(ct, tmrSig, display); tc77.Activate(); m.Timer0.Activate(); m.EnableInterrupt(); os.Loop(); Console.WriteLine(AVRASM.Text(m)); } } }
Il est clair qu'il ne s'agit pas d'une ébauche de travail, mais seulement d'une démonstration technologique conçue pour démontrer les capacités de la bibliothèque NanoRTOS. Mais dans tous les cas, moins de 100 lignes de source et moins de 1 Ko de code de sortie est un assez bon résultat pour une application viable.
Dans les articles suivants, je prévois, en cas d'intérêt pour ce projet, de m'attarder plus en détail sur les principes et caractéristiques de la programmation utilisant cette bibliothèque.