Very High Speed Integrated Circuit Hardware Description Language/Multitâche et Système temps réel sur architecture 8 bits

Leçons de niveau 16
Une page de Wikiversité, la communauté pédagogique libre.
Début de la boite de navigation du chapitre
Multitâche et Système temps réel sur architecture 8 bits
Icône de la faculté
Chapitre no 14
Leçon : Very High Speed Integrated Circuit Hardware Description Language
Chap. préc. :Programmer in Situ et déboguer
Chap. suiv. :Le MicroBlaze
fin de la boite de navigation du chapitre
En raison de limitations techniques, la typographie souhaitable du titre, « Very High Speed Integrated Circuit Hardware Description Language : Multitâche et Système temps réel sur architecture 8 bits
Very High Speed Integrated Circuit Hardware Description Language/Multitâche et Système temps réel sur architecture 8 bits
 », n'a pu être restituée correctement ci-dessus.


Dans ce chapitre, nous avons l'intention d'examiner un certain nombre de possibilités sur les systèmes multitâches pour des petites architectures 8 bits. Sur les architectures 8 bits il est possible de faire tourner des systèmes importants mais cela nécessite de la mémoire vive. C'est certainement ce qui nous manquera le plus dans nos FPGA même s'il y en a en petite quantité. La majorité des cartes FPGA proposent des RAM externes mais nous ne voulons pas entrer dans le détail de leur gestion.


Système multitâche non préemptif porté pour ATMega16[modifier | modifier le wikicode]

Nous allons commencer par essayer un système non préemptif et l'adapter à l'ATMega16 (processeur décrit dans un autre chapitre de ce livre).

Notre point de départ pour cette section est un article sur un système multitâche non préemptif écrit par Ron Kreymborg (en 1999 dans Dr.Dobbs). Ce système a été porté pour les AVR mais le code source original de la page Internet précédente est suffisant.

Préliminaires[modifier | modifier le wikicode]

La lecture du code source du système non préemptif nous montre que la gestion du temps se fait à l'aide d'une interruption timer. Cela tombe bien, nous en avons implanté une dans un autre chapitre qui fonctionne à environ 50 Hz. La partie matérielle consiste donc à faire un projet avec l'ATMega16 en utilisant "io_timer.vhd" en lieu et place de "io.vhd". Celui-ci contient un pseudo-timer permettant de réaliser une interruption à une fréquence de 50 Hz pour une horloge à 50 MHz. Il est téléchargeable ICI

Non préemptif ne veut donc pas forcément dire sans interruption timer. Un système préemptif utilise l'interruption timer pour changer de contexte et exécuter ainsi une autre tâche que celle qui était exécutée au moment de l'interruption. Un système non préemptif va tout simplement utiliser l'interruption timer pour gérer le temps (et c’est tout). Le changement de contexte est dans ce cas réalisé par la tâche en cours d'exécution qui le demande explicitement.

Voici le code source C et son commentaire qui montre qu'une interruption timer est nécessaire :

//**********************************************************
// Call from tick timer interrupt routine. Assumes interrupts
// are off. Decrements the delay of the task at the head of
// the delay queue. If it goes to zero takes it off the delay
// queue and puts it on the ready queue. Continues to do this
// until a non-zero delay is found.

void DecrementDelay(void) {

   if (*DelayHead) {
      task[*DelayHead].delay--;                 // decrement head delay
      while (*DelayHead && task[*DelayHead].delay == 0) {
         NewTask = *DelayHead;                  // set NewTask
         *DelayHead = task[*DelayHead].next;    // set head to next
         task[NewTask].status &= ~(BusyBit | DelayBit);
         Tpntr = task[NewTask].pntr;            // set task pointer
         DoQueTask();                           // copy to ready list
         }
      }
}

Nous sommes donc prêt à réaliser quelques exemples.

Quelques exemples[modifier | modifier le wikicode]

La ressource utile pour commencer est donnée maintenant

Prenez ce code et mettez votre interruption et votre "main()" entre les prototypes et déclarations et l'implantation des fonctions. Nous utilisons la version de 1999 du code mais une version plus récente est disponible et est décomposée avec un fichier ".h" pour les prototypes.

Faire clignoter une LED à 0,5 Hz environ[modifier | modifier le wikicode]

Puisque le timer fait un dépassement de capacité (overflow) au rythme de 50 Hz, un appel avec un temps de 25 fera environ 0,5 s. Voici donc le code :

#include <avr/io.h>
#include <avr/interrupt.h>
/********************************************************
  DISPATCH - A Task Dispatcher.
 A program to manage multiple tasks via a ready queue
 and multiple delays via a delay queue. Runs one task at
 a time. Tasks coming off the delay queue are put on the
 ready queue. Delay queue is ticked from timer interrupt.
 Ron Kreymborg
 11-Apr-99 
********************************************************/

#define TOTALTASKS 10 // set for your application

void InitMulti(void);
int QueTask(void (*pt)());
int QueDelay(void (*pt)(), int delay);
void DecrementDelay(void);
void Dispatch(void);
void ReRunMe(int delay);

#define BusyBit 0x01 // task is busy
#define ReadyBit 0x02 // task is ready
#define DelayBit 0x04 // task is delayed

static void DoQueTask(void);
static void DoQueDelay(void);
static void RunTask(void);
static void GetNewTask(void);
static void DoDelay(int delay);

static struct {
   int     status;        // task status
   int      delay;         // delay in ticks
   void     (*pntr)();     // pointer to task
   int     next;          // linked list
   } task[TOTALTASKS+1];

static int RunningTask;
static int TaskHead, TailTask;
static int Status, NewTask;
static int *DelayHead;
static int NewDelay;
static void (*Tpntr)();

/************* NOTRE CODE COMMENCE ICI **********************/
// débordement du timer (fréquence environ 100 Hz) :
ISR(TIMER0_OVF_vect) // ISR(_VECTOR(9))
{
    DecrementDelay(); // environ 50 Hz
    TCNT0 = 0; 
}

void TurnLedOff();

void TurnLedOn(){ // tâche qui allume la LED de poids faible
  PORTC=0x01;
  QueDelay(TurnLedOff, 25);
}

void TurnLedOff(){ // tâche qui éteint la LED de poids faible
  PORTC=0x00;
  QueDelay(TurnLedOn, 25);
}
 
int main(int argc, char * argv[]) {
// gestion des interruptions
    TCNT0 = 0;
    TIMSK = 0x01; // TOVIE0
    sei(); // autorise interruption générale
// appels au noyau non préemptif
    InitMulti();
    QueTask(TurnLedOn);
    for (;;)
      Dispatch();
}
// AJOUTER LE RESTE DU NOYAU ICI ....

L'ajout de tâches supplémentaires se ferait avant le for(;;) et nous avons omis l’ensemble des implémentations des fonctions du noyau que nous avons donné comme ressource un peu plus haut.

Ce code vous montre la présence de deux queues : une pour les tâches prêtes utilisée avec QueTask() et une avec les tâches retardées utilisée avec QueDelay(). C'est l'interruption qui réalise la gestion des tâches retardées avec DecrementDelay().

Faire clignoter deux LEDs[modifier | modifier le wikicode]

Nous allons faire maintenant clignoter deux LEDs, chacune avec leur tâche associée. Ce problème peu sembler pas très différent du précédent mais il va nous montrer un point qu’il n'était pas nécessaire d'évoquer dans l'exemple précédent.

L'écriture ou l'effacement d'un bit particulier peut se faire avec une instruction du genre "PORTC = PORTC | 0x01;". Mais si vous utilisez cette technique avec notre cœur ATMega16, cela ne fonctionnera pas. Nous avons déjà évoqué ce problème plusieurs fois. Il est lié au fait qu'écrire PORTC à droite d'une affectation veut dire qu'on lit le PORTC en question. Cela fonctionne sur une architecture du commerce mais en ce qui nous concerne nous n'avons pas implanté les PORTs comme cela. Pour passer outre ce problème il suffit d’utiliser une variable interne sur laquelle on travaille systématiquement avant de l'affecter au PORT. Regardez dans le code ci-dessous, vous voyez la déclaration de la variable vportc... qui ne sert qu’à cela.

Il est possible de séparer la mise à jour du PORT (ici PORTC) du calcul de la valeur des deux bits comme ceci :

uint8_t vportc;
ISR(TIMER0_OVF_vect) // ISR(_VECTOR(9))
{
    DecrementDelay(); // environ 50 Hz
    TCNT0 = 0; 
}

void TurnLedOff();

void TurnLedOn(){
    vportc |=0x01;
    QueDelay(TurnLedOff, 50);
}

void TurnLedOff(){
    vportc &= 0xFE;
    QueDelay(TurnLedOn, 50);
}

void TurnLed2Off();

void TurnLed2On(){
    vportc |= 0x02;
    QueDelay(TurnLed2Off, 100);
}

void TurnLed2Off(){

    vportc &= 0xFD;
    QueDelay(TurnLed2On, 100);
}

void updatePORTC() {
    PORTC = vportc;
    ReRunMe(1);
}
 
int main(int argc, char * argv[])
{
    TCNT0 = 0;
    TIMSK = 0x01; // TOVIE0
    vportc = 0;
    sei(); // autorise interruption générale
    InitMulti();
    QueTask(TurnLedOn);
    QueTask(TurnLed2On);
    QueTask(updatePORTC);
    for (;;)
      Dispatch();
}

Vous voyez apparaître une tâche supplémentaire "updatePORTC()" qui s'exécute pratiquement sans arrêt (Ne pas mettre "ReRunMe(0)" dans cette tâche de fond, cela bloque les autres tâches).


Il est grand temps de passer maintenant aux systèmes multitâche préemptif.

Système multitâches préemptif[modifier | modifier le wikicode]

Les systèmes Multitâches préemptifs ont leur article dans Wikipédia : Multitâche préemptif. La différence essentielle avec la section précédente est que maintenant ce n'est plus la tâche en cours d'exécution qui décide de donner la main à l'ordonnanceur mais une interruption matérielle.

Il est assez facile de trouver un petit système multitâche pour AVR. AtomThreads est un exemple et FemtoOs en est un autre. L'adapter à un cœur VHDL nécessite cependant une bonne connaissance du matériel et du logiciel. La première chose qu’il faut aller voir dans les sources est le timer utilisé.

FemtoOs[modifier | modifier le wikicode]

Dans le cas de FemtoOs, c’est le timer 0 qui est utilisé. Comme nous avons déjà utilisé un pseudo timer0 dans ce chapitre, on peut avoir l'impression qu’il suffit simplement de l’utiliser. En fait ce sera plus compliqué que cela.

  • D'abord notre timer 0 ne déclenche que l'interruption de débordement... et il faut donc vérifier que ce n’est pas l'interruption de comparaison qui est utilisée (avec un mode CTC du timer0). Si c’est le cas, il faut changer le matériel... ou changer le code
  • Notre matériel possède une différence assez subtile avec un AVR du commerce... et comme d'habitude pour changer cela il nous faut soit nous pencher sur le matériel soit sur le logiciel. Pour ce qui est du matériel nous ne voyons pas du tout comment faire ! Bien après ce suspens insoutenable, dévoilons cette subtile différence : le flag de déclenchement de l'interruption est automatiquement repassé à 0 pendant l'interruption. Nous avons de bonne raison de penser que cela se passe pendant le retour d'interruption. Pour notre pseudo timer, la remise à0 du flag se fait soit par une écriture dans le timer, soit par écriture d'un 1 dans le bit TOV0.


Nous avons du mal à comprendre comment est architecturé FemtoOs en lisant son code. Nous allons donc essayer avec un autre.

Atomthread[modifier | modifier le wikicode]

Les problèmes de portage sont identiques, mais il est plus simple de trouver l'interruption de commutation des tâches. Nous trouvons le code :

ISR (TIMER1_COMPA_vect)
{
    /* Call the interrupt entry routine */
    atomIntEnter();

    /* Call the OS system tick handler */
    atomTimerTick();

    /* Call the interrupt exit routine */
    atomIntExit(TRUE);
}

dans "atomport.c" qui nous montre que c’est l'interruption de comparaison du timer1 qui est utilisée. Régler le problème évoqué du flag peut se faire simplement en ajoutant une instruction après "atomIntExit(TRUE);" et avant l'accolade fermante. Mais, rappelons-le, ceci n'est plus nécessaire avec une version du processeur postérieure à Juillet 2015.

Réaliser une commutation des tâches matérielle[modifier | modifier le wikicode]

Dans cette section, nous allons nous intéresser à une réalisation matérielle de la commutation des tâches. Il nous faut donc détailler ce qui est réalisé par le logiciel et l'implanter en VHDL. L'objectif est naturellement d'accélérer cette fameuse commutation.

Système multi processeurs[modifier | modifier le wikicode]

Une autre possibilité de faire des tâches en parallèles et des les affecter chacune à un processeur. Il y a alors un certain nombre de problèmes à résoudre, en particulier sur la communication et synchronisation des processeurs entre eux.

Le problème du logiciel[modifier | modifier le wikicode]

Le développement logiciel et matériel dans le cas de multi processeurs est un problème peu courant pour les petits microcontrôleurs : vous allez transformer les tâches en programmes et compiler chacun des programmes. Les programmeurs sont habitués à compiler des tâches ("thread" ou autres) au sein d'un seul programme. La mise au point est certes délicate, mais connue. Mais la mise au point de plusieurs programmes est certainement plus délicate. En ce qui nous concerne, nous développerons un exemple suffisamment simple pour ne pas être trop confronté à ce type de problème.

Choix du processeur[modifier | modifier le wikicode]

Une contrainte forte pour nous est de programmer en langage C.

Le choix des processeurs n’est pas non plus indifférent. Si l’on se cantonne aux architectures 8 bits, il nous faut choisir une architecture relativement petite, sauf à prendre un FPGA très gros dont nous ne disposons pas pour nos essais. Il nous faudra donc abandonner notre architecture préférée des AVR ATMega trop gourmande en ressource FPGA. En restant dans le domaine des processeurs ATMEL, nous avons commencé à utiliser un ATTiny861 et aussi commencé à rédiger un chapitre de TP dans ce livre sur le sujet : cela fait de l'ATTiny861 un excellent candidat.

Les temps changent bien vite depuis l'écriture des quelques lignes ci-dessus, il y a deux ans. Aujourd’hui (Decempbre 2014) la Basys3 de Digilent est disponible pour le même prix que la Basys2 mais avec 15 fois plus de ressources FPGA. Faire du multiprocesseur même avec notre ATMega8 peut se réaliser sans problème. Nous ne l'avons pas encore réalisé pour le moment et sommes donc restés sur le choix de l'ATTiny861.

Pour débroussailler[modifier | modifier le wikicode]

La réalisation de multiprocesseur nécessite la réalisation d'une interface de synchronisation entre les processeurs qu’il nous faut commencer par explorer. Le processeur que nous utilisons a implanté l'instruction IRET mais aucun mécanisme de déclenchement de l'interruption. Il nous est donc impossible d’utiliser celle-ci pour communiquer.

Il est facile d'imaginer la situation où l'un des deux processeurs (disons processeur A) demande à l'autre (processeur B) un travail quelconque et doit attendre que celui-ci soit effectué. Ce type de synchronisation peut être fait avec un bit particulier que le processeur A passe à 1 et attend que celui-ci passe à 0. C'est naturellement le processeur B qui le passera à 0.

Pour mettre cela en œuvre, nous allons commencer par un ensemble hyper simple, tellement simple qu’il n'y a en aucun cas lieu d'y mettre deux processeurs !

Processeur 1 pour compter et processeur2 pour afficher[modifier | modifier le wikicode]

Voici la partie matérielle sans commentaire :

library IEEE;
use IEEE.STD_LOGIC_1164.ALL;
use IEEE.STD_LOGIC_ARITH.ALL;
use IEEE.STD_LOGIC_UNSIGNED.ALL;

entity twoProcessors is
    Port ( clk : in  STD_LOGIC;
           Rst : in  STD_LOGIC;
			 sw : in STD_LOGIC_VECTOR (7 downto 0);
			 In_PINB : in STD_LOGIC_VECTOR (7 downto 0);
-- Led : out STD_LOGIC_VECTOR (7 downto 0);
			 Aff7segs : out STD_LOGIC_VECTOR (7 downto 0));
end twoProcessors;

architecture arch of twoProcessors is
component microcontroleur is
    Port ( clk : in  STD_LOGIC;
           Rst : in  STD_LOGIC;
			 sw : in STD_LOGIC_VECTOR (7 downto 0);
			 In_PINB : in STD_LOGIC_VECTOR (7 downto 0);
           Led : out  STD_LOGIC_VECTOR (7 downto 0);
			 Aff7segs : out STD_LOGIC_VECTOR (7 downto 0));
end component microcontroleur;
signal s_data2Vers1,s_data1Vers2 : STD_LOGIC_VECTOR (7 downto 0); 
-- comme on le voit maintenant les deux processeurs ont exactement la même interface extérieure. 
begin
 uc1:microcontroleur port map (
   clk => clk,
	Rst => rst,
	sw => sw, 
	in_PINB => s_data2Vers1,
	Led => s_data1Vers2,
	Aff7segs => open
	);
	uc2:microcontroleur port map (
   clk => clk,
	Rst => rst,
	sw => s_data1Vers2, 
	in_PINB => "00000000",
	Led => s_data2Vers1,
	Aff7segs => Aff7segs
	);
end arch;

Aller ici pour trouver le reste du code.

Voici les deux fichiers BMM nécessaire (un par processeur) :

  • attiny_1_S6.bmm
ADDRESS_SPACE prgmem RAMB16 [0x00000000:0x00001FFF]
    BUS_BLOCK
        uc1/prgmem/pe_1 RAMB16 [7:4] [0:4095] PLACED = X0Y2;
        uc1/prgmem/pe_0 RAMB16 [3:0] [0:4095] PLACED = X0Y0;      
        uc1/prgmem/pe_3 RAMB16 [15:12] [0:4095] PLACED = X0Y6;
        uc1/prgmem/pe_2 RAMB16 [11:8] [0:4095] PLACED = X0Y4;
    END_BUS_BLOCK;
END_ADDRESS_SPACE;

ainsi que sa partie UCF associée :

# microcontroleur 1l
INST uc1/prgmem/pe_0 LOC = RAMB16_X0Y0;
INST uc1/prgmem/pe_1 LOC = RAMB16_X0Y2;
INST uc1/prgmem/pe_2 LOC = RAMB16_X0Y4;
INST uc1/prgmem/pe_3 LOC = RAMB16_X0Y6;
  • attiny_2_S6.bmm
ADDRESS_SPACE prgmem RAMB16 [0x00000000:0x00001FFF]
    BUS_BLOCK
        uc2/prgmem/pe_1 RAMB16 [7:4] [0:4095] PLACED = X1Y2;
        uc2/prgmem/pe_0 RAMB16 [3:0] [0:4095] PLACED = X1Y0;      
        uc2/prgmem/pe_3 RAMB16 [15:12] [0:4095] PLACED = X1Y6;
        uc2/prgmem/pe_2 RAMB16 [11:8] [0:4095] PLACED = X1Y4;
    END_BUS_BLOCK;
END_ADDRESS_SPACE;
  • et le scipt pour compiler et charger.
#!/bin/bash
#pour spartan6
#export PATH=$PATH:/usr/local/avr/bin:/opt/Xilinx/11.1/ISE/bin/lin/
export PATH=$PATH:/opt/Xilinx/14.5/ISE_DS/ISE/bin/lin64
# pong_2_rs232_VGA.c a retrouver avec $1
avr-gcc -g -mmcu=attiny861 -Wall -Os -c CmptPassage.c
#avr-gcc -g -mmcu=attiny861 -Wall -Os -c crc.c 
#if [$? -lt 0]; then exit ; fi
avr-gcc -g -mmcu=attiny861 -o CmptPassage.elf -Wl,-Map,CmptPassage.map CmptPassage.o
avr-gcc -g -mmcu=attiny861 -Wall -Os -c Affichage.c
avr-gcc -g -mmcu=attiny861 -o Affichage.elf -Wl,-Map,Affichage.map Affichage.o
#avr-objdump -h -S InterruptSPM.elf > InterruptSPM.lss
#avr-objcopy -O binary -R .eeprom cordic.elf cordic.bin
#avr-objcopy -R .eeprom -O ihex hello.elf hello.hex
#./make_mem ATmegaBOOT.hex prog_mem_content.vhd
cp ../twoProcessors.bit .
data2mem -bm attiny_1_S6.bmm -bd CmptPassage.elf -bt twoProcessors.bit -o uh twoProcessors
data2mem -bm attiny_2_S6.bmm -bd Affichage.elf -bt twoProcessors_rp.bit -o uh twoProcessors_rp
djtgcfg prog -d Nexys3 --index 0 --file twoProcessors_rp_rp.bit

Et maintenant le carburant pour les deux processeurs :

  • programme pour processeur 1
//*********** Compteur/Decompteur : CmptPassage.c
#include <avr/io.h>
#undef F_CPU
#define F_CPU 100000000UL
#include "util/delay.h"

void incrementBCD(unsigned char *cnt) ;
void decrementBCD(unsigned char *cnt);

int main(void) {
  unsigned char cmpt=0;
  while(1) {
      if (PINA & 0x01) incrementBCD(&cmpt); else decrementBCD(&cmpt);
      PORTA = cmpt; // transmission vers processeur 2
      _delay_ms(400);
  }
  return 0;
}

void incrementBCD(unsigned char *cnt) {
  (*cnt)++;    
  if ((*cnt & 0x0F) > 0x09) *cnt += 6;
  if ((*cnt & 0xF0) > 0x90) *cnt = 0;
}

void decrementBCD(unsigned char *cnt) {
  (*cnt)--;    
  if ((*cnt & 0x0F) == 0x0F) *cnt -= 6;
  if ((*cnt & 0xF0) == 0xF0) *cnt = 0x99;
}
  • Programme pour processeur 2
//*********** Affichage.c pour processeur 2
#include <avr/io.h>
//#include <avr/interrupt.h>
#undef F_CPU
#define F_CPU 100000000UL
#include "util/delay.h"

//*********** xabcdefg : 1=eteint
const unsigned char digit7segs[16]={0x81,0xCF,0x92,0x86,0xCC,0xA4,0xA0,0x8F,0x80,0x84,0x88,0xE0,0xB1,0xC2,0xB0,0xB8};

int main() {
  unsigned char a;
  while(1) {
    a=PINA ; // lecture de ce qui arrive du processeur 1
    PORTB = digit7segs[a & 0x0F] ; // Affichage :on laisse tomber les poids forts
  }
  return 0;
}

On voit dans ce code qu’il n'y a aucune synchronisation : le processeur 2 ne fait que lire sans arrêt ce que le processeur 1 lui fourni et l'affiche.

Malgré le côté rudimentaire de ce que l’on vient de faire, on peut noter deux choses :

  • il y a deux programmes C à écrire et donc à mettre au point. Quand ils sont simples comme ici, ce n’est pas un gros problème mais cela peut en devenir un sérieux pour une mise au point complexe.
  • le côté identique des deux processeurs (on parle ici de leurs entrées/sorties) est plutôt embêtant si l’on veut gérer des activités complètement différentes. Ici on a laissé tombé l’affichage des dizaines par exemple (on aurait pu utiliser la sortie Led pour cela et ne pas la relier au processeur 1).

S'il l’on veut regrouper les deux programmes C en un seul, c’est certainement possible avec une mémoire programme sur deux ports, ce qui existe dans les FPGA depuis les Spartan 3. Mais il faut alors inventer un mécanisme pour faire démarrer le deuxième seulement sur un sous-programme ! La gestion correcte de la pile peut rendre ce mécanisme comme un vrai casse-tête ! De toute façon pour trois processeurs et plus, cette solution est inenvisageable.

Projet futur[modifier | modifier le wikicode]

Nous envisageons de refaire le pacman avec :

  • un tiny861 comme maître responsable de la gestion du fond du pacman : pac-gomme et scores.
  • un tiny861 responsable du sprite pacman
  • un tiny861 responsable du sprite fantôme 1
  • un tiny861 responsable du sprite fantôme 2

Les trois esclaves pourront être rétrogradé en tiny461 si cela ne pose pas de problème du point de vue de la taille de code.

Voir aussi[modifier | modifier le wikicode]