Introduzione
Quando si progettano schede con microcontrollori spesso si rimane piacevolmente sorpresi da come queste risultino semplici dal punto di vista elettronico, constatando come aspetti, quali lo sviluppo delle piste del pcb, la disposizione dei componenti, l'accessibilità dei connettori, risultano molto agevolati. Questo è ovviamente dovuto al fatto che molta della logica funzionale dell'intero sistema è demandata e gestita dal uControllore, o meglio dal firmware contenuto al suo interno. Non è raro però notare come, in alcune occasioni, ad una semplificazione nella realizzazione hardware non si affianchi un' altrettanta semplicità nell'organizzazione e nell'implementazione del software. Avere infatti a disposizione un gran numero di linee di I/O, UART, convertitori ADC, timers, e molte altre periferiche rende difficile la loro gestione all'interno del programma principale. Una soluzione elegante è offerta dal meccanismo delle interruzioni hardware, che consente alle varie periferiche di interrompere autonomamente il flusso di elaborazione a fronte di particolari eventi, permettendo così alla CPU di eseguire tutte le istruzioni necessarie alla loro gestione, in maniera totalmente asincrona. Non sempre, però, è possibile applicare questo meccanismo: basti pensare a quando si ha la necessità di eseguire porzioni di codice simultaneamente al fine di valutarne complessivamente gli effetti nel main-program. Con l'avverbio "simultaneamente" si intende che le istruzioni costituenti queste porzioni di codice (TASK - Processi) vengono comunque eseguite sequenzialmente una alla volta, ma la CPU distribuisce il suo tempo totale di elaborazione tra tutti i processi che viene chiamata ad eseguire, saltando continuamente da un task ad un altro (Context Switch). Se la frequenza di clock che alimenta il uC è abbastanza elevata e la complessità dei task limitata, si avrà l'impressione che tali processi siano eseguiti appunto simultaneamente. Questo tipo di architettura è nota come MultiTasking. Obiettivo di queste note non è certo quello di approfondire un argomento sul quale molto è stato scritto, ma di capire come implementare questa tecnica nell' ambito dei microcontrollori, in modo da poter strutturare e semplificare la programmazione, un po' come avviene per gli aspetti hardware.
A questo punto è utile un esempio: si pensi alla programmazione di un centralino d'allarme di un sistema anti-intrusione domestico che deve testare lo stato degli ingressi (porte, finestre, ecc.), gestire una tastiera remota, interagire con una interfaccia telefonica (combinatore e decoder DTMF) ed infine comandare la sirena dall'arme. Sarebbe ben poco sicuro se durante la lettura del codice battuto sulla tastiera il programma smettesse di testare gli ingressi o non spegnesse la sirena eventualmente attiva. E' quindi necessario che tutti i processi vengano eseguiti sempre e comunque, assegnando maggior tempo di elaborazione a quelli che si reputano più importanti e minore a quelli ritenuti meno critici, introducendo cioè una priorità nell'esecuzione dei task.
Quando si progettano schede con microcontrollori spesso si rimane piacevolmente sorpresi da come queste risultino semplici dal punto di vista elettronico, constatando come aspetti, quali lo sviluppo delle piste del pcb, la disposizione dei componenti, l'accessibilità dei connettori, risultano molto agevolati. Questo è ovviamente dovuto al fatto che molta della logica funzionale dell'intero sistema è demandata e gestita dal uControllore, o meglio dal firmware contenuto al suo interno. Non è raro però notare come, in alcune occasioni, ad una semplificazione nella realizzazione hardware non si affianchi un' altrettanta semplicità nell'organizzazione e nell'implementazione del software. Avere infatti a disposizione un gran numero di linee di I/O, UART, convertitori ADC, timers, e molte altre periferiche rende difficile la loro gestione all'interno del programma principale. Una soluzione elegante è offerta dal meccanismo delle interruzioni hardware, che consente alle varie periferiche di interrompere autonomamente il flusso di elaborazione a fronte di particolari eventi, permettendo così alla CPU di eseguire tutte le istruzioni necessarie alla loro gestione, in maniera totalmente asincrona. Non sempre, però, è possibile applicare questo meccanismo: basti pensare a quando si ha la necessità di eseguire porzioni di codice simultaneamente al fine di valutarne complessivamente gli effetti nel main-program. Con l'avverbio "simultaneamente" si intende che le istruzioni costituenti queste porzioni di codice (TASK - Processi) vengono comunque eseguite sequenzialmente una alla volta, ma la CPU distribuisce il suo tempo totale di elaborazione tra tutti i processi che viene chiamata ad eseguire, saltando continuamente da un task ad un altro (Context Switch). Se la frequenza di clock che alimenta il uC è abbastanza elevata e la complessità dei task limitata, si avrà l'impressione che tali processi siano eseguiti appunto simultaneamente. Questo tipo di architettura è nota come MultiTasking. Obiettivo di queste note non è certo quello di approfondire un argomento sul quale molto è stato scritto, ma di capire come implementare questa tecnica nell' ambito dei microcontrollori, in modo da poter strutturare e semplificare la programmazione, un po' come avviene per gli aspetti hardware.
A questo punto è utile un esempio: si pensi alla programmazione di un centralino d'allarme di un sistema anti-intrusione domestico che deve testare lo stato degli ingressi (porte, finestre, ecc.), gestire una tastiera remota, interagire con una interfaccia telefonica (combinatore e decoder DTMF) ed infine comandare la sirena dall'arme. Sarebbe ben poco sicuro se durante la lettura del codice battuto sulla tastiera il programma smettesse di testare gli ingressi o non spegnesse la sirena eventualmente attiva. E' quindi necessario che tutti i processi vengano eseguiti sempre e comunque, assegnando maggior tempo di elaborazione a quelli che si reputano più importanti e minore a quelli ritenuti meno critici, introducendo cioè una priorità nell'esecuzione dei task.
Implementazione
Per implementare un framework MultiTasking, per quanto elementare lo si possa concepire, si devono prevedere i seguenti elementi fondamentali:
- Il codice dei singoli Task
- Uno Scheduler
- Un Dispatcher
Ogni Task è costituito dalla sequenza di istruzioni che ne identificano il compito come ad esempio testare lo stato di ingressi digitali, acquisire livelli analogici, leggere lo stato di sensori, ecc.
Lo Scheduler (Schedulatore) è la parte di codice che supervisiona l'esecuzione dei task da parte della CPU e decide quale task deve essere attivo in un determinato istante. E' inoltre in questo modulo che è possibile impostare la priorità dei vari processi, definendo quanto tempo CPU assegnare ad ogni task. A seconda di quale strategia di servizio (algoritmo di scheduling) venga seguita, lo scheduler controlla la ripartizione del tempo di CPU tra tutti i processi attivi e lo stato di questi ultimi, i quali possono trovarsi attivi, fermi o in attesa.
Il Dispatcher è la porzione di codice che passa effettivamente il controllo della CPU ai processi scelti dallo scheduler realizzando lo switch tra i vari task attivi.
Come è intuibile, il componente più importante di questa struttura è lo scheduler, che costituisce la "base dei tempi" di tutto il sistema. Infatti, la prima cosa che bisogna introdurre per realizzare uno scheduler è proprio un meccanismo che tenga conto dell'evolvere del tempo e che sia legato al tempo di elaborazione della CPU. Questa informazione non deve essere necessariamente il tempo di clock o di ciclo macchina della CPU, ma è sufficiente un segnale temporale ottenuto da uno dei timer presenti all'interno del uControllore, è lo si può pensare intorno ai millisecondi. Questo intervallo di tempo sarà il quanto temporale con il quale verranno misurati i tempi di esecuzione di ogni task. E' bene ricordare che il processo di generazione di questi tempi avviene tramite una interruzione hardware sollevata dall' overflow di un timer e quindi è completamente asincrona alla esecuzione delle istruzioni del main-program. Una volta abilitate le interruzioni hw e avviato il timer, la CPU sarà costretta ad interrompere il flusso del programma principale per saltare alla routine di servizio dell'interrupt dell' overflow del timer (ISR) che sarà costituita dal codice per la gestione dei task. In questa routine si potrà cambiare lo stato dei vari task, regolarne la priorità e soprattutto decidere la loro sequenza di esecuzione. La traccia dello scorrere dei quanti temporali è mantenuta semplicemente da una variabile intera che viene incrementata ogni qualvolta viene eseguito il codice dello schedulatore. Il valore contenuto in questa variabile sarà poi utilizzato per decidere qual è il task da porre in esecuzione e quali invece da sospendere. Tali decisioni vengono attuate impostando degli appositi flags che verranno successivamente gestiti dal Dispatcher, il quale, in una versione molto primitiva, può essere realizzato con una sequenza di blocchi decisionali con lo scopo di saltare verso le label di inizio del task da porre (o mantenere) in esecuzione.
Le differenze tra gli istanti (espressa in quanti temporali di eleborazione) in corrispondenza dei quali si attivano i task riflettono la priorità di esecuzione di un processo rispetto ad un altro.
Per implementare un framework MultiTasking, per quanto elementare lo si possa concepire, si devono prevedere i seguenti elementi fondamentali:
- Il codice dei singoli Task
- Uno Scheduler
- Un Dispatcher
Ogni Task è costituito dalla sequenza di istruzioni che ne identificano il compito come ad esempio testare lo stato di ingressi digitali, acquisire livelli analogici, leggere lo stato di sensori, ecc.
Lo Scheduler (Schedulatore) è la parte di codice che supervisiona l'esecuzione dei task da parte della CPU e decide quale task deve essere attivo in un determinato istante. E' inoltre in questo modulo che è possibile impostare la priorità dei vari processi, definendo quanto tempo CPU assegnare ad ogni task. A seconda di quale strategia di servizio (algoritmo di scheduling) venga seguita, lo scheduler controlla la ripartizione del tempo di CPU tra tutti i processi attivi e lo stato di questi ultimi, i quali possono trovarsi attivi, fermi o in attesa.
Il Dispatcher è la porzione di codice che passa effettivamente il controllo della CPU ai processi scelti dallo scheduler realizzando lo switch tra i vari task attivi.
Come è intuibile, il componente più importante di questa struttura è lo scheduler, che costituisce la "base dei tempi" di tutto il sistema. Infatti, la prima cosa che bisogna introdurre per realizzare uno scheduler è proprio un meccanismo che tenga conto dell'evolvere del tempo e che sia legato al tempo di elaborazione della CPU. Questa informazione non deve essere necessariamente il tempo di clock o di ciclo macchina della CPU, ma è sufficiente un segnale temporale ottenuto da uno dei timer presenti all'interno del uControllore, è lo si può pensare intorno ai millisecondi. Questo intervallo di tempo sarà il quanto temporale con il quale verranno misurati i tempi di esecuzione di ogni task. E' bene ricordare che il processo di generazione di questi tempi avviene tramite una interruzione hardware sollevata dall' overflow di un timer e quindi è completamente asincrona alla esecuzione delle istruzioni del main-program. Una volta abilitate le interruzioni hw e avviato il timer, la CPU sarà costretta ad interrompere il flusso del programma principale per saltare alla routine di servizio dell'interrupt dell' overflow del timer (ISR) che sarà costituita dal codice per la gestione dei task. In questa routine si potrà cambiare lo stato dei vari task, regolarne la priorità e soprattutto decidere la loro sequenza di esecuzione. La traccia dello scorrere dei quanti temporali è mantenuta semplicemente da una variabile intera che viene incrementata ogni qualvolta viene eseguito il codice dello schedulatore. Il valore contenuto in questa variabile sarà poi utilizzato per decidere qual è il task da porre in esecuzione e quali invece da sospendere. Tali decisioni vengono attuate impostando degli appositi flags che verranno successivamente gestiti dal Dispatcher, il quale, in una versione molto primitiva, può essere realizzato con una sequenza di blocchi decisionali con lo scopo di saltare verso le label di inizio del task da porre (o mantenere) in esecuzione.
Le differenze tra gli istanti (espressa in quanti temporali di eleborazione) in corrispondenza dei quali si attivano i task riflettono la priorità di esecuzione di un processo rispetto ad un altro.
Multitasking su uC Atmel AVR
Tutte le considerazioni fatte finora possono concretizzarsi in un semplice progettino per rendersi conto di come sia possibile implementare il multitasking anche su dei microcontrollori che non dispongono di particolari risorse. L'esempio che segue è stato scritto e provato su un uC Atmel AVR AT90S8515 e compilato con BASCOM-AVR, ma può essere facilmente esteso a tutti i uC e tradotto in tutti i linguaggi più diffusi. E' composto da 3 task costituiti da semplici istruzioni: incremento di una variabile intera e successiva stampa tramite UART. Il quanto temporale è di circa 2 ms, con un quarzo da 10MHz. Lo schedulatore incrementa ad ogni interruzione del Timer0 la variabile intera T e testa quando è il momento di far partire l'esecuzione dei task, aggiornando 3 flags. Alla fine del codice di ogni task si trova un gruppo di 3 costrutti if..then che consentono di deviare il flusso di esecuzione da un task ad un altro (context switch). In questo esempio il task 1 ha una durata di 15-10=5 quanti, il task 2 dura 45-15=30 quanti, mentre il task 3 occupa la CPU per 10-0=10 quanti. I rapporti dei tempi di esecuzione sono quindi:
- Task2/Task1 = 30/5 = 6
- Task2/Task3 = 30/10 = 3
- Task3/Task1 = 10/5 = 2
La struttura riportata è molto semplice, ma la si può estendere a piacimento replicando quanto fatto per ognuno dei 3 task qui considerati. In una versione ancora più evoluta è inoltre possibile gestire anche lo stato dei task, eseguendo solo quelli attivi e saltando quelli posti in stato di stop.
Tutte le considerazioni fatte finora possono concretizzarsi in un semplice progettino per rendersi conto di come sia possibile implementare il multitasking anche su dei microcontrollori che non dispongono di particolari risorse. L'esempio che segue è stato scritto e provato su un uC Atmel AVR AT90S8515 e compilato con BASCOM-AVR, ma può essere facilmente esteso a tutti i uC e tradotto in tutti i linguaggi più diffusi. E' composto da 3 task costituiti da semplici istruzioni: incremento di una variabile intera e successiva stampa tramite UART. Il quanto temporale è di circa 2 ms, con un quarzo da 10MHz. Lo schedulatore incrementa ad ogni interruzione del Timer0 la variabile intera T e testa quando è il momento di far partire l'esecuzione dei task, aggiornando 3 flags. Alla fine del codice di ogni task si trova un gruppo di 3 costrutti if..then che consentono di deviare il flusso di esecuzione da un task ad un altro (context switch). In questo esempio il task 1 ha una durata di 15-10=5 quanti, il task 2 dura 45-15=30 quanti, mentre il task 3 occupa la CPU per 10-0=10 quanti. I rapporti dei tempi di esecuzione sono quindi:
- Task2/Task1 = 30/5 = 6
- Task2/Task3 = 30/10 = 3
- Task3/Task1 = 10/5 = 2
La struttura riportata è molto semplice, ma la si può estendere a piacimento replicando quanto fatto per ognuno dei 3 task qui considerati. In una versione ancora più evoluta è inoltre possibile gestire anche lo stato dei task, eseguendo solo quelli attivi e saltando quelli posti in stato di stop.
Risultati ottenuti
Una volta compilato e mandato in esecuzione il programma su una qualsiasi scheda munita di interfaccia RS232, sono stati ottenuti i seguenti risultati:
Una volta compilato e mandato in esecuzione il programma su una qualsiasi scheda munita di interfaccia RS232, sono stati ottenuti i seguenti risultati:
Come si può vedere dalla schermata del terminale, i valori delle variabili interne ai task (a,b,c) riflettono la durata di esecuzione dei singoli processi, e risultano essere coerenti con i valori teorici attesi, e cioè:
- Task2/Task1 = 54/10 = 5,4 [6 teorico]
- Task2/Task3 = 54/18 = 3 [3 teorico]
- Task3/Task1 = 18/10 = 1,8 [2 teorico]
Agendo sui tempi dello schedulatore e sulle priorità dei vari processi sarà possibile pianificare un programma in grado di gestire in maniera modulare e strutturata quelle routines di gestione di tutti gli eventi che non sono direttamente "agganciabili" a interruzioni hardware, consentendo di concentrarsi su ogni singolo blocco funzionale, un po' come avviene nella programmazione ad oggetti ed eventi nei sistemi operativi più evoluti.
- Task2/Task1 = 54/10 = 5,4 [6 teorico]
- Task2/Task3 = 54/18 = 3 [3 teorico]
- Task3/Task1 = 18/10 = 1,8 [2 teorico]
Agendo sui tempi dello schedulatore e sulle priorità dei vari processi sarà possibile pianificare un programma in grado di gestire in maniera modulare e strutturata quelle routines di gestione di tutti gli eventi che non sono direttamente "agganciabili" a interruzioni hardware, consentendo di concentrarsi su ogni singolo blocco funzionale, un po' come avviene nella programmazione ad oggetti ed eventi nei sistemi operativi più evoluti.
Conclusioni
Queste note sono state pubblicate al solo scopo di introdurre brevemente le problematiche e i vantaggi dell'uso della tecnica multi-processo e time-sharing applicabili al mondo dei uControllers. In rete sono ovviamente disponibili moltissime versioni di sistemi multitasking per microcontrollori, alcuni anche free, che consentono di sfruttare a pieno le potenzialità di questa architettura, fornendo addirittura dei piccoli sistemi operativi (micro-kernel) che offrono un vastissimo panorama di utili funzioni e servizi.
Queste note sono state pubblicate al solo scopo di introdurre brevemente le problematiche e i vantaggi dell'uso della tecnica multi-processo e time-sharing applicabili al mondo dei uControllers. In rete sono ovviamente disponibili moltissime versioni di sistemi multitasking per microcontrollori, alcuni anche free, che consentono di sfruttare a pieno le potenzialità di questa architettura, fornendo addirittura dei piccoli sistemi operativi (micro-kernel) che offrono un vastissimo panorama di utili funzioni e servizi.
1 commento:
Gli atmel non li uso, ma il codice per iniziare uno studio di kernel basilare mi sembra proprio buono!
Posta un commento