I programmi scritti per i microcontrollori sono ovviamente diversi dai programmi che vengono scritti per i personal computer. Oltre a differire per il fatto di non avere, ad esempio, un display video o una tastiera la struttura stessa del programma è diversa perché diverso è il campo di applicazione. In questa serie di brevi articoli rivolti a coloro che hanno appena iniziato la sperimentazione con i microcontrollori illustrerò alcuni aspetti legati alla stesura di un programma utilizzando come linguaggio di programmazione il C (e quindi do per scontato che lo si conosca) ma le stesse regole valgono anche per il linguaggio assembly. Non vuole essere una serie di lezioni poiché non sono in grado di darne, semplicemente un piccolo aiuto per iniziare con il piede giusto e quindi qualsiasi commento in grado di fornire ulteriori info o spunti sarà più che apprezzato.
Indice |
Differenze
La differenza sostanziale fra un programma applicativo per un personal computer ed un programma per microcontrollore sta nel fatto che il primo ha un inizio ed una fine (ad esempio lancio un programma di disegno, faccio quello che devo fare e poi chiudo il programma) mentre un' apparecchiatura utilizzante un microcontrollore, nella quasi totalità dei casi, viene accesa ed il programma non ha un vero e proprio termine, semplicemente tutto si ferma quando l' apparecchiatura viene spenta. E' vero che ci sono alcuni programmi che, una volta lanciati, rimangono in esecuzione fino allo spegnimento del personal computer ma è anche vero che, a meno che non manchi la corrente, tutti i programmi eseguono una sezione di chiusura prima che alla macchina venga tolta l' alimentazione.
Un' altra differenza sta nel fatto che un programma per personal computer, ad eccezione del sistema operativo, si ritrova a partire su una macchina che sta già funzionando mentre, nel caso del microcontrollore, questo parte da una condizione di RESET dove tutto è da inizializzare. E' da notare anche che i programmi odierni che girano sui personal computers hanno una struttura event driven cioè vengono eseguite parti di programma in conseguenza alla ricezione di un evento (pressione di un pulsante, movimento del mouse ecc.) ed utilizzano un sistema operativo multitasking, in grado cioè di eseguire più programmi apparentemente in contemporanea. Anche molte applicazioni utilizzanti i microcontrollori si basano su questo tipo di programmazione ed usano anche sistemi operativi multitasking, ma sono utilizzate raramente nelle applicazioni hobbistiche e comunque, anche senza usare per forza dei sistemi operativi real time, si possono fare eseguire contemporaneamente al micro compiti differenti .
La struttura del programma
In linea generale la struttura di un programma per microcontrollore è composta da due sezioni: l' inizializzazione ed il ciclo infinito di funzionamento. Questa flow chart semplificata aiuta a comprendere l' idea.
Scritto in assembly troveremmo alla fine dell' esecuzione del compito un GOTO o JMP o JP o che dir si voglia, un salto insomma all' inzio del compito. In C, per motivi di per se noti, è sconsigliabile l' uso dei GOTO quindi la flow chart della figura scritta in C diventa:
int main(void) { // Inizializzazione while(1) { // Esecuzione compito } return(0); // Non serve a niente ma bisogna metterlo }
Dove il while(1) è semplicemente un espediente per ottenere un ciclo che si ripete all' infinito. La costante 1 è sempre verificata (vera) e quindi il ciclo si ripete all' infinito.
L' inizializzazione
Inizializzare vuol dire fare in modo che il sistema, la macchina, sia correttamente predisposta per fare quello che deve fare, che i pin di ingresso e uscita siano tali e che le periferiche che si utilizzeranno siano pronte per essere utilizzate. Questo in linea generale, analizziamo quindi passo per passo quello che un minimo di inizializzazione deve fare. La flow chart espansa diventa più o meno come questa:
Inizializzazione sistema
Dopo il RESET i microcontrollori si trovano in uno stato iniziale che, molte volte, deve essere adattato all' applicazione. I micro odierni sono abbastanza complessi e non è raro avere bisogno di chiamare funzioni, settare bits per impostare il clock di sistema o il clock delle periferiche o attivare il prefetch delle istruzioni e le interrupt. Si tratta comunque di configurare il cuore della macchina. Alcuni micro (come gli AVR e i PIC) tengono queste configurazioni in appositi registri nella memoria FLASH, i cosiddetti fuses che vengono programmati durante lo scaricamento del programma o con le direttive #pragma (direttive specifiche del compilatore e del micro), altri, come gli ARM i fuses sono pochi e la configurazione deve essere fatta via software. L' inizializzazione del sistema si può anche omettere se basta la condizione presente al RESET. Non lo ha mica detto il dottore che bisogna per forza cambiare qualcosa. Nelle prime prove con un micro è meglio lasciare le cose come sono al RESET e tentare, passo dopo passo, di modificarle in seguito quando si avrà preso dimestichezza con la macchina.
Nei micro di fascia bassa, come ad esempio alcunii PIC16, non è necessario configurare niente se non i fuses il che rende la vita più facile, più si va su micro complessi con complesse architetture più è necessario configurare. Un esempio è la disabilitazione del watchdog nel Pierin che ho utilizzato nel programma che simula l' effetto Supercar dove è l' unica cosa indispensabile da fare per quell' applicazione.
// disabilita watchdog MCUSR &= ~(1 << WDRF); wdt_disable();
Questo pezzo di codice scritto per STM32F100xx è un esempio di inizializzazione del sistema di una certa complessità.
//---------------------------------------------------------------------- // Inizializzazione dell' accesso alla FLASH interna // Abilita il Prefetch Buffer FLASH_PrefetchBufferCmd(FLASH_PrefetchBuffer_Enable); // Imposta la latency per la flash a 0 wait state // Con clock <= 24 MH latency 0 // Con clock 24 - 48 MHz latency 1 // con clock > 48MHz latency 2 FLASH_SetLatency(FLASH_Latency_0); //---------------------------------------------------------------------- // Inizializzazione del clock di sistema a 24MHz // Predispone il PLL per utilizzare il clock interno // Utilizza l' oscillatore interno ad 8 MHz. di default RCC_PLLConfig( RCC_PLLSource_HSI_Div2, RCC_PLLMul_6 ); // Abilita il PLL RCC_PLLCmd( ENABLE ); // Aspetta che il PLL sia pronto while(RCC_GetFlagStatus(RCC_FLAG_PLLRDY) == RESET); // Seleziona come clock di sistema il PLL RCC_SYSCLKConfig(RCC_SYSCLKSource_PLLCLK); // Aspetta che il PLL sia accettato come clock di sistema while(RCC_GetSYSCLKSource() != 0x08);
Questo è invece un esempio di inizializzazione del clock utilizzando un PIC33FJ128GP804 utilizzando un quarzo da 8MHz.
// Set frequenza di clock a 80 MHz. // Di default, con un quarzo da 8 Mhz, la frequenza di clock e' di 50 Mhz // Il VCO del PLL funziona a 200 Mhz, ed il suo clock viene diviso per 4 // quindi bisogna prima abbassare la frequenza di ingresso (di default divisa // per 2) dividendola per 4 ... CLKDIVbits.PLLPRE = 2; // Divide per 4 // ... cambiare il divisore di feedback da 50 (default) a 80 ... PLLFBDbits.PLLDIV = 78; // Divide per 80 // ... e cambiare il post divisore da 4 (default) a 2. CLKDIVbits.PLLPOST = 0; // Divide per 2
Questo è un esempio di configurazione del sistema su un PIC18F13K22
// Disabilita oscillatore primario per liberare anche i pin RA4 e RA5 e poterli // usare come pin di I/O OSCCON2bits.PRI_SD = 0; // Seleziona clock a 16 MHz. Il PLL non e' abilitato. Il micro viaggia veloce // consuma poco ed e' piu' stabile senza PLL OSCCONbits.IRCF0 = 1; OSCCONbits.IRCF1 = 1; OSCCONbits.IRCF2 = 1;
Ed infine un esempio di un' inizializzazione del sistema minima per il PIC32
// KEY CONCEPT - Configure the device for maximum performance, but do not change // the PBDIV clock divisor. // Given the options, this function will change the program Flash wait states, // RAM wait state and enable prefetch cache, but will not change the PBDIV. // The PBDIV value is already set via the pragma FPBDIV option above. peripheral_clock = SYSTEMConfig(SYS_FREQ, SYS_CFG_WAIT_STATES | SYS_CFG_PCACHE); // Configure UART1A (UART 1) Interrupt (porta 1 della RETE) INTEnable(INT_SOURCE_UART_RX(UART1), INT_ENABLED); INTSetVectorPriority(INT_VECTOR_UART(UART1), INT_PRIORITY_LEVEL_7); INTSetVectorSubPriority(INT_VECTOR_UART(UART1), INT_SUB_PRIORITY_LEVEL_0); // Configura l' interrupt ciclica del TIMER1 OpenTimer1(T1_ON | T1_SOURCE_INT | T1_PS_1_256, 3125); // set up the timer interrupt with a priority of 2 ConfigIntTimer1(T1_INT_ON | T1_INT_PRIOR_1); // carica il registo con costante di tempo per 10ms. // configure for multi-vectored mode INTConfigureSystem(INT_SYSTEM_CONFIG_MULT_VECTOR); // enable interrupts INTEnableInterrupts();
Che però necessita di alcune #define per il clock di sistema.
Spero che questi esempi possano far capire che molte volte è necessario configurare il processore per adattarlo al programma affinché lavori nel modo migliore o comunque nel modo desiderato. Tutte le informazioni di configurazione sono contenute nei datasheet ma sopratutto nei Reference Manual delle famiglie dei micro. Sono dei documenti di 500 o più pagine ma, ahimè, sono da studiare. I micro sono oggetti affascinanti ma devono essere studiati, bisogna investire tempo ed impegno.
Inizializzazione variabili
La routine di startup del C provvede all' inizializzazione delle variabili, a dire il vero è la prima cosa che fa. Personalmente preferisco inizializzarle subito dopo il sistema evitando le dichiarazioni che le iizializzano. Trovo che questo sistema renda più leggibile ed ordinato il programma infatti raggruppando le inizializzazioni insieme non è il caso di andare in giro fra i vari sorgenti per sapere lo stato di una certa variabile quando viene inizializzata a meno che non sia coinvolta in un lavoro "dietro le quinte" magari da parte di una funzione. E' una questione di gusto personale.
Inizializzazione ingressi e uscite
I micro non sono oggetti autistici, comunicano in qualche modo con il mondo esterno. Un circuito costruito intorno ad un micro avrà forse degli ingressi, delle uscite, forse è collegato ad un display o forse usa una memoria esterna. Sono tutti dispositivi che sono collegati ai pin di I/O. Solitamente, per non dire sempre salvo qualche eccezione, al RESET i pin di I/O si trovano configurati come ingressi (a volte con dei pull-up) e quindi bisogna configurarli convenientemente.
E qui è necessario ricordare che bisogna tenere a mente, quando si progetta l' hardware, il fatto che alcuni pin di dispositivi esterni (come memorie seriali o drivers vari) hanno un ingresso di chip select o di enable che dovrebbe rimanere a riposo per tenere il dispositivo disattivato fino a quando il micro non deciderà di attivarlo. E' necessario mettere nel circuito resistenze di pull-up o di pull-down per essere sicuri di avere i dispositivi disabilitati all' accensione. Faccio un esempio: driver per LED con segnale di enable attivo alto, memorietta FLASH con segnale di chip select attivo basso e RTC anch' esso con chip select attivo basso tutti collegati ai tre segnali di una SPI. Nel primo dispositivo avremo l' accortezza di metterci una resistenza di pull-down (verso massa) per fare in modo che al RESET il driver non sia abilitato e due resistenze di pull-up (verso la Vdd) sui segnali di chip-select della FLASH e dell' RTC per fare in modo che non siano abilitati anche quando le uscite del micro collegate ad essi sono flottanti, non inizializzate.
Nella fase di inzializzazione degli I/O di solito si programma prima lo stato che dovranno avere a riposo e poi si programmerà il registro che definisce il pin come ingresso o uscita. Questo è un esempio di inizializzazione di un pin di I/O su un PIC
// disabilita ingressi analogici ANSEL = 0; ANSELH = 0; // inizializza l' uscita del LED LATB_bits.LATB2 = 0; TRISB_bits.TRISB2 = 0;
Che configura il bit PB2 come uscita.
Inizializzazione periferiche
Ogni micro ha funzioni di libreria, macro o comunque registri che servono per far funzionare le periferiche. Le periferiche al RESET sono tutte disabilitate e quindi non funzionanti. Occorre configurarle e poi abilitarle. Non solo, ad esempio negli STM32 bisogna prima dare il clock alla periferica (di solito si fa nell' inizializzazione del sistema) altrimenti, anche se abilitate, non funzionano proprio. Dipende dal micro ed i sistemi sono i più disparati. Mai, come in questo caso, è necessario studiarsi a fondo le sezioni dei datasheet per essere in grado di utilizzare le periferiche. Se è vero che molti costruttori o altri mettono a disposizione ottime librerie è anche vero che queste devono essere studiate per poterle utilizzare, quindi non è molto differente dallo studiarsi il datasheet. Le librerie son magari "pesanti" in termini di occupazione di spazio in memoria ma sono funzioni che sicuramente ... funzionano. In ogni caso c'è da passare del tempo sopra la documentazione.
Esecuzione del compito
E siamo arrivati al nocciolo dell' implementazione: quello che il micro deve fare!
Abbiamo detto che il micro esegue di continuo un programma ma qualcuno potrebbe obiettare che, sopratutto nelle prove, è sufficiente far fare al micro alcune cose e poi questo dovrebbe spegnersi, o comunque entrare in una sorta di stato di stop. In effetti succede anche in applicazioni vere dove il micro deve fare una cosa sola e poi fermarsi o andare in no stato di basso consumo chiamato "sleep". Bene, in questo caso si inserisce il codice da eseguire una sola volta prima del ciclo infinito e dopo le inizializzazioni. La flow chart diventerà così:
Ed il corrispondente schema del programma in C diventerà chiaramente:
//------------------------------------------------------------------------------ // void InitSystem(void) // Inizializza il sistema void InitSystem(void) { } //------------------------------------------------------------------------------ // InitIO(void) // Inizializza le linee di ingresso e uscita InitIO(void) { } //------------------------------------------------------------------------------ // void InitModules(void) // Inizializza le periferiche che verranno utilizzate void InitModules(void) { } //------------------------------------------------------------------------------ // MAIN PROGRAM //------------------------------------------------------------------------------ int main(void) { // Inizializzazione sistema InitSystem(); // Inizializzaizone variabili // Inizializzazione I/O InitIO(); // Inizializzazione periferiche InitModules(); // Esecuzione compito // Inserire qui il programma che deve essere eseguito una volta sola // Ferma il micro facendolo girare su se stesso all' infinito while(1) { } return(0); // Non serve a niente ma bisogna metterlo }
Si può notare che il ciclo infinito serve semplicemente a bloccare il micro fino al prossimo RESET ma la struttura del programma non è cambiata.
Piace far notare due cose. La prima è che ho creato tre diverse funzioni di inizializzazione. Non è necessario farlo ma migliora la leggibilità del programma sopratutto quando le inizializzazioni non sono banali. La seconda è che ho messo il main alla fine del programma. Questo per due motivi: il primo è che ho imparato la programmazione strutturata tramite il linguaggio Pascal dove le procedure e funzioni devono essere dichiarate prima di essere chiamate. In questo modo si da al programma una certa gerarchia e si dispongono automaticamente le funzioni al livello più basso in cima al programma ed è questo il secondo motivo: dare una disposizione logica alle funzioni. Il main è in basso e se questi chiama una funzione sono sicuro di trovarla più in alto. E se quest' altra ne chiama un' altra la troverò di sicuro più in alto. Anche questa è una mia scelta personale ma mi trovo molto bene seguendo questo criterio.
Un esempio semplice: un pulsante che accende un LED
Mi sento di dire che questo è, per i micro, il classico programma "hello world!" con la differenza che il suddetto programma inizia, scrive la famosa frase e poi finisce mentre questo continua a funzionare all' infinito. Il circuito del micro è questo. Per semplicità ho preferito collegare il pulsante verso l' alimentazione per avere un 1 logico quando questo è premuto.
Ho utilizzato AVRstudio 5 come ambiente di sviluppo e l' AT90USB1287 perchè ne ho diversi montati sull' adattatore Pierin e quindi solo per comodità. Il sorgente del programma è il seguente:
/* * HelloWorldAT90.c * * Created: 01/10/11 19:18:24 * Author: TardoFreak */ #include <avr/io.h> #include <avr/wdt.h> //------------------------------------------------------------------------------ // void InitSystem(void) // Inizializza il sistema void InitSystem(void) { // disabilita watchdog MCUSR &= ~(1 << WDRF); wdt_disable(); } //------------------------------------------------------------------------------ // InitIO(void) // Inizializza le linee di ingresso e uscita InitIO(void) { // Predispone la porta A come ingresso DDRA = 0x00; // Predispone la porta B come uscita DDRB = 0xff; } //------------------------------------------------------------------------------ // MAIN PROGRAM //------------------------------------------------------------------------------ int main(void) { // Inizializzazione sistema InitSystem(); // Inizializzazione I/O InitIO(); // Ciclo infinito di funzionamento dove legge lo stato del PIN PA0 // Se PA0 è premuto, cioè se vale 1, accende il LED collegato al // PIN PB1 while(1) { if (PINA & 0x01) // Legge lo stato del bit 0 di PINA { // Pulsante premuto, accende il LED PORTB |= 0x02; // PORTB = PORTB | 0x02; } else { // Pulsante non premuto, spegne il LED PORTB &= 0xfd; // PORTB = PORTB & 0xfd; } } return(0); // Non serve a niente ma bisogna metterlo }
Come si può notare non ho fatto altro che prendere lo schema di prima, riempirlo opportunamente con le inizializzazioni (quella dei moduli non c'è perché il programma non ne usa) e mettere all' interno del ciclo infinito il compito che il micro deve svolgere: attivare o disattivare un bit di una porta di I/O a seconda dello stato di un altro pin.
Più compiti eseguiti contemporaneamente
Se si cerca di rispettare una struttura di questo tipo e ci si impone alcune regole da seguire si possono implementare diversi compiti che vengono eseguiti in contemporanea. Per intenderci meglio trasformo il main dell' esempio precedente semplicemente mettendo sotto forma di funzione la gestione del pulsante e del LED in questo modo:
//------------------------------------------------------------------------------ // TASK di gestione del pulsante e del LED // Se PA0 è premuto, cioè se vale 1, accende il LED collegato al // PIN PB1 //------------------------------------------------------------------------------ void Task_1(void) { if (PINA & 0x01) // Legge lo stato del bit 0 di PINA { // Pulsante premuto, accende il LED PORTB |= 0x02; // PORTB = PORTB | 0x02; } else { // Pulsante non premuto, spegne il LED PORTB &= 0xfd; // PORTB = PORTB & 0xfd; } } //------------------------------------------------------------------------------ // MAIN PROGRAM //------------------------------------------------------------------------------ int main(void) { // Inizializzazione sistema InitSystem(); // Inizializzazione I/O InitIO(); // Ciclo infinito di funzionamento while(1) { Task_1(); } return(0); // Non serve a niente ma bisogna metterlo }
Sembra un cambiamento da poco ma è utile per capire come fare eseguire al micro dei processi in contemporanea. Ora, con il programma scritto in questo modo nulla mi vieta di scrivere un' altra funzione per eseguire un altro compito (magari una Task_2 che mi fa la AND fra due ingressi e mi mette il risultato su un' altra uscita) e chiamarla dopo aver chiamato il Task_1 in questo modo:
// Ciclo infinito di funzionamento while(1) { Task_1(); Task_2(); }
Questo è praticamente quello che si fa per realizzare una sorta di multitasking molto semplificato ed è sufficiente per implementare la logica programmata, tipo quella che si usa nei controllori programmabili. Perché tutto funzioni però bisogna:
- Definire un tempo massimo di esecuzione del compito. Il "tempo ciclo", cioè il tempo impiegato dal programma principale sarà la somma di tutti i tempi impiegati da ogni compito.
- Non utilizzare quindi ritardi duri e puri come le funzioni di delay che tanto piacciono a chi comincia a cimentarsi con i micro ma che allungherebbero il tempo ciclo. Meglio utilizzare timers che, quando raggiungono il tempo prestabilito, attivano un bit o qualche diavoleria del genere per far capire che il tempo voluto è trascorso. In tal caso si guarda se il timer è scattato e se si si esegue quello che si deve eseguire, altrimenti si esce dalla funzione.
- Evitare funzioni con loop per, ad esempio, attendere la pressione di un tasto. Una funzione fatta in questo modo blocca inevitabilmente l' esecuzione degli altri compiti. Per fare una cose del genere si utilizzano variabili booleane (vero/falso o zero/non_zero) per sapere da che punto riprendere l' elaborazione o una tecnica chiamata "macchina a stati" che vedremo nella prossima parte.
c'è anche da aggiungere che scrivendo il programma in questo modo e definendo un tempo massimo di tutto il ciclo è facile inserire come ultima istruzione del main il reset del watchdog. In questo caso, nella malaugurata ipotesi che il programma abbia un punto in cui si blocca, il watchdog timer farebbe egregiamente il suo lavoro resettando il micro.
Coming soon
Nelle prossime parti (impegni di lavoro permettendo) vedremo come organizzare l' esecuzione di programmi più complessi prestando attenzione ad alcuni importanti particolari ed implementando una semplice macchina a stati. Esorto chi è interessato alla programmazione dei micro di guardare alcuni esempi di programmazione che vengono forniti insieme ai vari sistemi di sviluppo scegliendo i vari "blink" che sono programmi che fanno lampeggiare dei LED e che eseguono compiti molto semplici. Inizialmente non ha senso cercare di capire, ad esempio, come si implementa un file system per una chiavetta USB collegata ad un micro munito di USB OTG, o di cercare di analizzare un programma che effettui la conversione MP3 al volo utilizzando i dati d' ingresso da SPI sfruttando i canali di DMA (i paroloni mi servono per fare il figo eh eh eh), sarebbe tempo perso. Non abbiate fretta di fare le cose complicate, quelle arriveranno da sole e saranno ... cavoli amari assicurati.