Iniziare a sperimentare con un microcontrollore è più facile a dirsi che a farsi, soprattutto per un principiante. E' molto facile trovarsi immediatamente di fronte ad un programma che non fa quello che dovrebbe. Se poi non si ha a disposizione un emulatore in grado di eseguire il programma passo per passo la difficoltà sembra insuperabile. In questo breve articolo illustrerò un metodo semplice per iniziare a scrivere programmi partendo da una base, ampliandolo passo per passo ed utilizzando alcuni accorgimenti e trucchetti per monitorarne in qualche modo l' esecuzione. E' un metodo che ho usato molti anni fa (ma a dire il vero lo uso ancora) quando avere un emulatore che permetteva di eseguire il programma passo per passo direttamente sul sorgente in C era un miraggio o era un qualcosa di irraggiungibile visti i costi. Per illustrare il tutto userò il linguaggio C per sviluppare per il microcontrollore AT90USB1287 (per comodità in quanto si tratta della scheda Pierin che ho già montata e inserita in una breadborad) con Atmel AVR Studio 4 (ma anche studio 5 va benone) ed utilizzando il bootloader USB che la Atmel programma nel micro direttamente in fabbrica unitamente al software FLIP 3.4.3.
Indice |
La partenza
Come partenza uso sempre un main vuoto e scrivo il classico programma "Hello world" alla maniera del microcontrollore: fargli alzare ed abbassare un PIN di uscita in un ciclo infinito. E' il sistema più semplice per fare un minimo di programma che funzioni. Per verificarne il funzionamento uso l' oscilloscopio guardando il PIN utilizzato. Se questo genera la sua bella onda quadra, continua e stabile vuol dire che il micro funziona, il programma funziona e che da qui in poi potrò aggiungergli vari pezzi per realizzare il mio programma definitivo. Quindi chiamo AVR Studio 4, creo un nuovo progetto gcc, seleziono la cartella di destinazione, il micro e visto che non uso emulatori sceglierò l' AVR simulator. Nel main ci scrivo questo semplice programma.
#include <avr/io.h> #include <avr/wdt.h> #include <avr/pgmspace.h> //----------------------------------------------------------------------------- int main(void) { // disabilita watchdog MCUSR &= ~(1 << WDRF); wdt_disable(); // Predispone la porta B con uscita su bit 0 DDRB = 0x01; while(1) { PORTB ^= 0x01; } return(0); // Non serve a niente ma bisogna metterlo }
Come prima cosa disabilito il watchdog perché per adesso non mi serve, anzi mi andrebbe a complicare la vita. Visto che nel Pierin non ho la possibilità di andare a pacioccare i fuses lo faccio usando una funzione di libreria del gcc. La seconda cosa che faccio è decidere arbitrariamente che il PIN 0 della porta B sarà un' uscita, quello che andrò a guardare con l' oscilloscopio, il PIN 10. Poi scrivo un ciclo infinito che mi cambia di stato il bit 0 di PORTB con un' operazione di EXOR (or esclusivo).
- Compilo
- Lancio il programma FLIP
- Inserisco il cavo USB nel connettore del Pierin
- Mentre tengo premuto il tasto BOOT premo il tasto RES (il PC mi fa il suono dell' USB)
- Scelgo sul programma FLIP l' AT90USB1287
- Scelgo come porta l' USB e la apro
- Dal menù file carico il file .hex che si trova nella sottocartella "default"
- Premo il tasto "Run" per programmare il micro. Programmato, OK!
- Premo "Start Application" e controllo con l' oscilloscopio.
Magia delle magie sul pin 10 ci trovo un bella onda quadra. PROGRAMMA FUNZIONANTE!
Ora è tempo d' ingrandirlo un po'.
La seriale
Usare la seriale è quanto di più comodo e utile per poter sviluppare un programma e verificarne il corretto funzionamento di tutta la baracca. Il problema più frequente è quello di visualizzare i vari passaggi ed il valore di variabili mentre il programma è in esecuzione. Avere a disposizione una linea seriale vuol dire avere a disposizione un sistema per visualizzare in qualche modo, su terminale, qualsiasi cosa e per deduzione capire dove il programma incontra un errore. Per prima cosa devo capire bene come funziona la seriale del micro con cui sto lavorando e per fare questo non c'è niente di meglio da fare che aprire il datasheet (7593K–AVR–11/09). Scopro quindi che il micro in questione ha una USART che può funzionare in parecchi modi. Inizialmente questo potrebbe creare confusione ma leggendo a fondo tutta la sezione ed andando a guardare i registri che ne regolano il funzionamento tutto diventa più chiaro. A pagina 181 (Capitolo 18) ho l' elenco delle caratteristiche dell' USART. Nella pagina dopo (182) ho lo schema a blocchi e su questo mi fermo a ragionare.
In alto trovo un blocco chiamato Clock Generator. In effetti per poter far funzionare l' USART ma soprattutto per fare in modo che funzioni alla giusta velocità (quella che imposterò sul terminale) avrò bisogno di intervenire su un registro in particolare: l' UBRR. Vedremo poi come, per adesso vado avanti a leggere il datasheet dove scopro che l' USART può funzionare in diversi modi. Il modo che interessa a me è Normal asyncronous (normale asincrono). A pagina 183 è spiegato nel dettaglio il modulo che genera il clock, il Baud Rate Generator, e a pagina 184 c'è anche una tabella con le formule da utilizzare per trovare il valore che devo scrivere dentro l' UBRR. Come velocità di comunicazione scelgo arbitrariamente 9600 baud e, visto che userò l' USART nel modo più normale che c'è, la formula a cui devo fare riferimento è la prima:
Dove BAUD è la mia velocità di comunicazione e UBRR è il valore che devo scrivere nel registro a 12 bit per avere quella velocità. Visto che il clock di sistema è 8MHz (lo so perché il Pierin su cui sto facendo le prove monta un quarzo da 8MHz.) il valore che otterrò è di 51,083 che approssimerò a 51. Bene, quando sarà il momento dentro a UBRR ci scriverò 51.
Continuando a leggere il datasheet mi imbatto poi, a pagina 187, in un esempio di inizializzazione. Sembra semplice ma voglio verificare che non ci sia qualcosa di particolare da impostare per fare in modo che tutto funzioni come voglio io, e precisamente:
- Velocità 9600 BAUD
- 8 bit di dati
- Nessuna parità
- 1 bit di stop
Più normale di questo non c'è niente. Dicevo che quello che mi rimane da fare è andare a dare uno sguardo ai registri dell' USART che trovo belli in mostra a partire da pagina 198. Scopro così che c'è un solo registro per la trasmissione e la ricezione e che si chiama UDR. Su questo dovrò scrivere il carattere che voglio trasmettere e da questo leggerò il carattere ricevuto. Nella pagina seguente trovo il registro UCSRnA dove n indica l' USART (in questo micro ce n'è una sola perciò n vale sempre 1).
Due bit sono quelli che m' interessano: RXCn (USART Receive Complete) che mi indica l' avvenuta ricezione di un carattere e TXCn (USART Transmit Complete) che mi indica l' avvenuta trasmissione. Gli altri posso anche non prenderli in considerazione visto che quello che voglio fare inizialmente è fare almeno uscire correttamente un carattere dall' USART. A pagina 200 c'è il secondo registro di controllo e in questo registro vedo che i bit che m' interessano sono RXENn (Receiver Enable) che mi abilita il ricevitore e TXENn (Transmitter Enable) che mi abilita il trasmettitore. Questi due bit li dovrò mettere ad 1 per abilitare trasmissione e ricezione. Per ora passo oltre alle varie interrupt perché non m' interessano. L' ultimo registro da guardare è l' UCSRnC e con gioa scopro che per fare quello che mi interessa basta che lo lasci così come è nella condizione di default. Più avanti trovo anche la conferma del calcolo che ho fatto per il valore di UBRR. Nella tabella delle velocità in relazione alla frequenza di clock scopro che il valore da caricare in questo registro è proprio 51. Figo!
A questo punto non mi resta altro da fare che prendere l' esempio di inizializzazione ed inserirlo nel programma che diventerà questo:
#include <avr/io.h> #include <avr/wdt.h> #include <avr/pgmspace.h> //----------------------------------------------------------------------------- int main(void) { // disabilita watchdog MCUSR &= ~(1 << WDRF); wdt_disable(); // Predispone la porta B con uscita su bit 0 DDRB = 0x01; // Inizializza la seriale per 9600 baud 8 bit 1 stop no parità // Come lunghezza della parola, parità e stop vanno bene i // valori di default // Imposta il valore del baud generator // Il valore di UBRR1 = (Fosc/(16 * BAUD)) -1. // Con clock a 8 MHz. per 9600 baud = 51 UBRR1H = 0; UBRR1L = 51; // Accende trasmettitore e ricevitore UCSR1B = (1<<RXEN1)|(1<<TXEN1); while(1) { PORTB ^= 0x01; if(UCSR1A & (1<<UDRE1)) // Guarda se TX register vuoto { UDR1 = 'A'; } } return(0); // Non serve a niente ma bisogna metterlo }
Per poter usare la seriale devo però costruirmi un circuitino che mi trasli i livello RS232 in livelli adatti al micro. Per far questo realizzo un semplice circuito con un ST202 montato su millefori con un connettore a 4 pin (GND, 5V, TX e RX).
Il terminale MPU-TX l' ho collegato al pin 28 (TXD1) ed il terminale MPU-RX al pin 27 (RXD1).
Non resta altro che provare il programma. Teoricamente, una volta fatto partire, dovrei ritrovarmi lo schermo del terminale che si riempie di lettere A e dovrei trovare ancora una specie di onda quadra un po' sgangherata (ogni tanto il micro esegue l' istruzione di caricamento del registro della seriale) ma dovrei trovarla. Lancio quindi il programma Hyper Terminal (qualsiasi programma di terminale va bene), imposto 9600 BAUD, 8 bit, no parità, nessun controllo di handshake e connetto.
Compilazione, caricamento, fuoco alle polveri ... lo schermo si riempie di "A" una di seguito all' altra. PROGRAMMA FUNZIONANTE!
Ed ora gli diamo una sistemata e gli aggiungiamo qualcosa di altro che ci serva per la programmazione.
Qualche utile funzione
Bene! Abbiamo la seriale che funziona, almeno in uscita, ed ora sistemiamo un po' le cose in modo da scrivere qualche funzione da poter inserire nel programma in modo da avere dei punti in cui possiamo visualizzare qualcosa qualora ce ne fosse bisogno. Per prima cosa scriviamo una funzione per inviare un carattere sulla seriale, la chiameremo serialWriteChar e subito dopo scriviamo una funzione per stampare una stringa di caratteri che chiameremo serialWriteString. A questo punto la parte di programma che continua a scrivere la "A" sul terminale non c' interessa più e possiamo eliminarla.
#include <avr/io.h> #include <avr/wdt.h> #include <avr/pgmspace.h> //----------------------------------------------------------------------------- // serialWriteChar(char ch) // Scrive un carattere nella linea seriale. Se il buffer di trasmissione non // è ancora vuoto aspetta fino a quando il flag UDRE1 di USCR1B diventa attivo // e poi scrive il carattere. void serialWriteChar(char ch) { while(!(UCSR1A & (1<<UDRE1))); // Aspetta che il TX register sia vuoto UDR1 = ch; } //----------------------------------------------------------------------------- // serialWriteString(char *s) // Scrive il contenuto di una stringa di caratteri nella linea seriale void serialWriteString(char *s) { while(*s) { serialWriteChar(*s); s++; } } //----------------------------------------------------------------------------- int main(void) { // disabilita watchdog MCUSR &= ~(1 << WDRF); wdt_disable(); // Predispone la porta B con uscita su bit 0 DDRB = 0x01; // Inizializza la seriale per 9600 baud 8 bit 1 stop no parità // Come lunghezza della parola, parità e stop vanno bene i // valori di default // Imposta il valore del baud generator // Il valore di UBRR1 = (Fosc/(16 * BAUD)) -1. // Con clock a 8 MHz. per 9600 baud = 51 UBRR1H = 0; UBRR1L = 51; // Accende trasmettitore e ricevitore UCSR1B = (1<<RXEN1)|(1<<TXEN1); serialWriteChar(0x0c); // Cancella lo schermo del terminale serialWriteString("Prova di scrittura su seriale\n\r"); serialWriteString("Seconda linea\n\r"); while(1) { PORTB ^= 0x01; } return(0); // Non serve a niente ma bisogna metterlo }
Una volta compilato, caricato e fatto partire questo programma produrrà un output sul terminale come questo.
Come si può notare sto andando avanti un passo per volta, scrivendo la funzione e poi provandola immediatamente. Questo sistema può sembrare inefficiente ma permette di avere la sicurezza che le funzioni scritte siano funzionanti perché immediatamente testate. Quello che ci manca ancora è uno straccio di funzione che ci permetta di visualizzare un numero intero in notazione decimale, quindi la scriviamo proprio sopra il main. Già che ci siamo facciamo che metterci come argomento un unsigned long int in modo da poter visualizzare qualsiasi tipo di variabile intera (char, int, long int). Se come argomento gli passiamo, ad esempio, un unsigned char il compilatore effettuerà un' operazione di castin e tutto andrà liscio senza che noi ce ne preoccupiamo.
//----------------------------------------------------------------------------------------------- // void serialWriteUlong(unsigned long int valore) // Stampa su seriale il valore unsigned long int convertito in decimale void serialWriteUlong(unsigned long int valore) { unsigned char fine,i; char cifra; char s[10]; // cancella la stringa for(i=0; i<10; i++) s[i] = 0; fine = 0; i = 9; while(!fine) { cifra = valore % 10; s[i] = cifra + '0'; i--; valore /= 10; if (valore == 0) fine = 1; } // scrive i caratteri for(i=0; i<10; i++) if(s[i]) { serialWriteChar(s[i]); } }
Ed aggiungiamo anche qualche linea sul main dopo le istruzioni di scrittura delle stringhe per provare immediatamente la funzione.
serialWriteString("Valore numerico: "); serialWriteUlong(1234); serialWriteString("\n\r"); unsigned char i; for(i=0;i<10;i++) { serialWriteUlong(i); serialWriteString("\n\r"); }
Solita storia (compilazione, caricamento, fuoco alle polveri) e ci ritroviamo con questo output.
E così possiamo anche visualizzare il valore delle variabili. Un ultimo sforzo e scriviamo anche la funzione per scrivere quelle con segno negativo.
//----------------------------------------------------------------------------------------------- // void serialWriteLong(long int valore) // Stampa su seriale il valore long int convertito in decimale void serialWriteLong(long int valore) { if(valore<0) { serialWriteChar('-'); valore = -valore; } serialWriteUlong(valore); }
Per provarla (provare SEMPRE la funzione appena scritta) mettiamo come commento la linea che stampa il valore numerico 1234 e ne aggiungiamo una per provare la funzione
//serialWriteUlong(1234); serialWriteLong(-1234);
Tutto bene ma manca ancora una cosetta: la possibilità di fermare il programma in un certo punto per aspettare la pressione di un tasto dalla tastiera del terminale. La cosa più semplice da fare è andare a guardare il datasheet (dove si trova tutto) perché a pagina 191 c'è un esempio di lettura da seriale scritto in C. Traendo spunto dall' esempio scriviamo la funzione che andremo a mettere proprio sopra il main.
//----------------------------------------------------------------------------- // char serialReadChar(void) // Aspetta che ci sia un dato pronto nel registro di ricezione e // ritorna tale dato come char char serialReadChar(void) { // Attende che sia presente un dato nel registro di ricezione while(!(UCSR1A & (1<<RXC1))); return(UDR1); }
Proviamola subito inserendo questa linea, ad esempio, prima dell' istruzione che stampa il valore numerico
serialReadChar(); // Il micro si ferma in attesa della pressione di un tasto serialWriteString("Valore numerico: "); //serialWriteUlong(1234); serialWriteLong(-1234); serialWriteString("\n\r");
Lanciamo il programma e verifichiamo che questo si blocca dopo aver scritto le due linee "Prova di scrittura su seriale" e "Seconda linea". Premiamo un tasto qualsiasi sulla tastiera e dopo compare tutto.
Direi che ci siamo! Abbiamo lo scheletro di un programma funzionante con le funzioni utili per il debug che possiamo inserire in qualsiasi punto del programma. Abbiamo quindi l' opportunità di fermare il programma (ad esempio per verificare lo stato di pin di I/O) e di visualizzare messaggi e visualizzare il valore delle variabili per scovare un eventuale errore.
Conclusioni
Per adesso mi fermo qui. Sicuramente questo metodo per scrivere i programmi non è certo quello che viene insegnato nei Sacri Testi ma è efficiente e sicuro e, se vogliamo, anche divertente perché si tratta in fondo di una sperimentazione continua. Bisogna partire da poco per arrivare a molto provando di volta in volta quello che si scrive. E' un metodo empirico ma personalmente lo trovo molto efficace e di sicuro funzionamento. Chiaramente, andando avanti di questo passo, è quasi scontato che sia arrivi ad un punto in cui si avrà la necessità di riscrivere il programma da capo, magari per organizzarlo meglio, ma non è lavoro sprecato. Le funzioni scritte e provate restano, come resta lo scheletro. E' tutto materiale buono che può essere riutilizzato senza timore, anzi con la sicurezza di avere "snippet" di sorgente di sicuro funzionamento.
C' è anche da dire che questo modo di scrivere programmi era la normalità in passato, quando si avevano solo a disposizione schede di valutazione senza emulatori ne debuggers. Quello che si cercava di avere era appunto una seriale, una porta semplice e potente verso un terminale. In mancanza di questa si faceva in altri modi, ma la seriale restava la soluzione migliore. Fortunatamente oggi i micro, per piccoli che siano, hanno sempre a disposizione una seriale che porta via due pin di I/O quindi possiamo dire "Fate attenzione! Ho una seriale, e non ho paura ad usarla!" :)
Qui di seguito metto il sorgente di tutto il programma. Per provarlo basta semplicemente copiarlo nel file main.c del progetto e compilarlo. Funziona davvero perché l' ho scritto mentre scrivevo quest' articolo. La sequenza dei passaggi è proprio quella che ho utilizzato e questo è il risultato.
#include <avr/io.h> #include <avr/wdt.h> #include <avr/pgmspace.h> //----------------------------------------------------------------------------- // serialWriteChar(char ch) // Scrive un carattere nella linea seriale. Se il buffer di trasmissione non // è ancora vuoto aspetta fino a quando il flag UDRE1 di USCR1B diventa attivo // e poi scrive il carattere. void serialWriteChar(char ch) { while(!(UCSR1A & (1<<UDRE1))); // Aspetta che il TX register sia vuoto UDR1 = ch; } //----------------------------------------------------------------------------- // serialWriteString(char *s) // Scrive il contenuto di una stringa di caratteri nella linea seriale void serialWriteString(char *s) { while(*s) { serialWriteChar(*s); s++; } } //----------------------------------------------------------------------------------------------- // void serialWriteUlong(unsigned long int valore) // Stampa su seriale il valore unsigned long int convertito in decimale void serialWriteUlong(unsigned long int valore) { unsigned char fine,i; char cifra; char s[10]; // cancella la stringa for(i=0; i<10; i++) s[i] = 0; fine = 0; i = 9; while(!fine) { cifra = valore % 10; s[i] = cifra + '0'; i--; valore /= 10; if (valore == 0) fine = 1; } // scrive i caratteri for(i=0; i<10; i++) if(s[i]) { serialWriteChar(s[i]); } } //----------------------------------------------------------------------------------------------- // void serialWriteLong(long int valore) // Stampa su seriale il valore long int convertito in decimale void serialWriteLong(long int valore) { if(valore<0) { serialWriteChar('-'); valore = -valore; } serialWriteUlong(valore); } //----------------------------------------------------------------------------- // char serialReadChar(void) // Aspetta che ci sia un dato pronto nel registro di ricezione e // ritorna tale dato come char char serialReadChar(void) { // Attende che sia presente un dato nel registro di ricezione while(!(UCSR1A & (1<<RXC1))); return(UDR1); } //----------------------------------------------------------------------------- int main(void) { // disabilita watchdog MCUSR &= ~(1 << WDRF); wdt_disable(); // Predispone la porta B con uscita su bit 0 DDRB = 0x01; // Inizializza la seriale per 9600 baud 8 bit 1 stop no parità // Come lunghezza della parola, parità e stop vanno bene i // valori di default // Imposta il valore del baud generator // Il valore di UBRR1 = (Fosc/(16 * BAUD)) -1. // Con clock a 8 MHz. per 9600 baud = 51 UBRR1H = 0; UBRR1L = 51; // Accende trasmettitore e ricevitore UCSR1B = (1<<RXEN1)|(1<<TXEN1); serialWriteChar(0x0c); // Cancella lo schermo del terminale serialWriteString("Prova di scrittura su seriale\n\r"); serialWriteString("Seconda linea\n\r"); serialReadChar(); // Il micro si ferma in attesa della pressione di un tasto serialWriteString("Valore numerico: "); //serialWriteUlong(1234); serialWriteLong(-1234); serialWriteString("\n\r"); unsigned char i; for(i=0;i<10;i++) { serialWriteUlong(i); serialWriteString("\n\r"); } while(1) { PORTB ^= 0x01; } return(0); // Non serve a niente ma bisogna metterlo }
Buona sperimentazione a tutti!
Addendum. Alcune funzioni utili
Inserisco qui di seguito alcune funzioni utili per l' uso della seriale.
20 Febbraio 2012. Questa funzione serve per visualizzare valori in esadecimale a 8 bit.
//----------------------------------------------------------------------------------------------- // void serialWriteHexUchar(unsigned char valore) // Stampa il valore di un unsigned char in esadecimale con gli zeri non significativi void serialWriteHexUchar(unsigned char valore) { char cifra,i; char s[2]; cifra = valore >> 4; if (cifra<10) s[0] = cifra + '0'; else s[0] = cifra + 55; cifra = valore & 0x0f; if (cifra<10) s[1] = cifra + '0'; else s[1] = cifra + 55; // scrive i caratteri for(i=0; i<2; i++) { serialWriteChar(s[i]); } }
La stessa funzione di prima ma che visualizza valori a 32 bit. La versione a 16 bit può essere facilmente ottenuta modificando questa
//----------------------------------------------------------------------------------------------- // void serialWriteHexUlong(unsigned long int valore) // Stampa su seriale il valore unsigned long int convertito in esadecimale void serialWriteHexUlong(unsigned long int valore) { char fine,i; char cifra; char s[8]; // cancella la stringa for(i=0; i<8; i++) s[i] = '0'; fine = 0; i = 7; while(!fine) { cifra = valore % 16; if (cifra<10) s[i] = cifra + '0'; else s[i] = cifra + 55; i--; valore /= 16; if (valore == 0) fine = 1; } // scrive i caratteri for(i=0; i<8; i++) { serialWriteChar(s[i]); } }