[Advanced] Programmazione Arduino in Bare Metal

Colgo il suggerimento di Guglielmo e apro un apposito post, così ci divertiamo un pò. :D Premetto che non sono un luminare, ma un pò di roba l'ho fatta: Metto a disposizione quello che so e magari qualcun'altro, mette a disposizione il suo. Vediamo cosa esce fuori.

Prologo: L'ambiente di Arduino si appoggia cmq su Avrlib e altre strutture software di Atmel, che sono generalmente utilizzate nel loro ambiente di programmazione principe, quale è AVRStudio.

Grazie a queste caratteristiche è possibile programmare Arduino (o meglio, gli ATmega) in tre maniere diverse:

1) Arduinesca classica (setup, loop) 2) In Bare metal e magari anche con i classici main e while(1) 3) In maniera ibrida, ovvero mescolando le due di cui sopra

In questo Thread ci interessiamo delle ultime due, ovvero in bare metal, e in quella "mescolata" dove possiamo raggiungere una migliore efficienza di codice (risparmio di RAM e di Flash, e ovviamente, aumento della velocità di esecuzione) insieme a librerie che facilitano la stesura (non sempre è ragionevole scrivere in bare metal, quindi ci facciamo andare bene le liBBrerie arduinesche)

Partiamo dal blink:

Blink classico:

/*
  Blink
  Turns on an LED on for one second, then off for one second, repeatedly.

  This example code is in the public domain.
 */

// Pin 13 has an LED connected on most Arduino boards.
// give it a name:
int led = 13;

// the setup routine runs once when you press reset:
void setup() {                
  // initialize the digital pin as an output.
  pinMode(led, OUTPUT);     
}

// the loop routine runs over and over again forever:
void loop() {
  digitalWrite(led, HIGH);   // turn the LED on (HIGH is the voltage level)
  delay(1000);               // wait for a second
  digitalWrite(led, LOW);    // turn the LED off by making the voltage LOW
  delay(1000);               // wait for a second
}

Sketch uses 1.116 bytes (3%) of program storage space. Maximum is 32.256 bytes. Global variables use 11 bytes (0%) of dynamic memory, leaving 2.037 bytes for local variables. Maximum is 2.048 bytes

In bare metal:

void setup() 
{

DDRB = 0b00100000; // PB5=D13  Setto l'uscita PB5 (D13) come uscita

}

void loop() 
{
              PORTB = 0b00100000;
          delay(1000);
              PORTB = 0b00000000;
          delay(1000);

}

Sketch uses 692 bytes (2%) of program storage space. Maximum is 32.256 bytes. Global variables use 9 bytes (0%) of dynamic memory, leaving 2.039 bytes for local variables. Maximum is 2.048 bytes.

Notare che l'impiego di flash è quasi dimezzato e si risparmia anche RAM. Non ci portiamo dietro tutto il dinosauro morto di Arduino, ma solo la struttura del setup(), del loop() e il delay() (che non è fatto malissimo).

Perfetto, un Topic interessante, ma io vado già OT Questo e' lo sketch che avevi proposto in una prima versione

void setup() 
{
 bitSet(DDRB,5); // PB5=D13  Setto l'uscita PB5 (D13) come uscita
}

void loop() 
{
 bitSet (PINB,5);    
 delay(1000) ;
}

Sara' che siamo al fine settimana e quindi poco sveglio, ma non ho capito come faccia a funzionare un bitSet su un registro che dovrebbe essere di sola lettura

In effetti....

Però cè la gabola:

When the AVR design engineers decided to add pin-toggling capabilities to the newer AVR chips, they didn’t have any extra I/O addresses to use, so they cleverly reused an address that had only been used previously for inputs. Writing a 1 to a bit in the PINx register inverts or toggles the output level of the corresponding output pin for that port. Note that this doesn’t work on older AVR devices, such as the ATmega8, ATmega16, and ATmega32. When in doubt, check the datasheets. To toggle the LED, you just need to write a single 1 to the correct bit position in the PINB register. You don’t need to preserve any of the other bits in this register, because they technically don’t exist.

+1 Questa non la sapevo proprio E' poco usabile, ma moltooooo interessante. Ora faccio un paio di prove

Sono curioso, con cosa le fai le prove? :roll_eyes:

Interessante...

Però così ti capisce solo chi già queste cose le conosce. Un riassuntino che spiega per i vari modelli (ma per semplicità si può al momento limitarsi al 328) a cosa corrispondono le PORTx ci starebbe bene. Si capirebbe anche cosa succede quando setti PORTB=0b00100000 così, portando un bit ad 1 ma anche contemporaneamente tutti gli altri a 0...

Lo stesso vale per l'altro metodo, quello col bitSet

Il punto è che questo Thread non è per spiegare qualcosa al nabbo di turno, ma una chiaccherata da Bar per utenti avanzati. Nessun’altro scopo se non quello di fare un pò di sana cagnara. :smiley:

Detto questo rispondo alla tua domanda.

Parliamo di livelli di astrazione.

Quasi tutti i uC sono Memory Mapped, ovvero ogni cosa, ogni operazione che agisca al loro interno, è un’operazione che fai da qualche parte in un indirizzo di memoria.

Questo vale, non solo per la Flash, la RAM e la EEPROM, ma anche per le periferiche interne: dalle porte di I/O, alla USART, I2C, SPI, PMP, CAN, RTTC, fino al Crypto Engine per i microcontrollori che ne dispongono.

Queste locazioni di memoria (indirizzabili dal Program Counter) stanno generalmente nella parte più “alta” della memoria, o meglil, degli indirizzi di memoria.

Altresì queste locazioni di memoria sono dei semplici registri (ne casso dell’ATmega328, registri a 8 bit) che possono contenere un byte o 8 bit.
Questi registri vengono chiamati SFR (Special Function Register).

Immagina la periferica come uno “scatolo” che funziona in diverse modalità, a seconda di come posizioni 8 interruttori, 8 microswitch, collegati ad esso.

Nella tabella sopra vedi i due registri che regolano le porte di I/O.

Il registro DDRx è quello che regola la direzione INPUT/OUTPUT del pin

Il registro PORTx è quello che regola se su una uscita ci sarà un UNO od uno ZERO.

Se scrivo DDRB = 0b00000001 significa che ho settato il pin 0 del PORTB come USCITA, mentre tutti gli altri PIN saranno ingressi.

Se scrivo DDRB = 0b00100000 significa che ho settato il pin 5 del PORTB come USCITA, mentre tutti gli altri PIN saranno ingressi.

Se scrivo DDRB = 0b10000001 significa che ho settato il pin 7 ed il pin 0 del PORTB come USCITA, mentre tutti gli altri PIN saranno ingressi.

Se scrivo PORTB = 0b00000001 significa che ho messo a 1 il pin 0 del PORTB, mentre tutte le altre uscite (se sono state settate come uscite), rimarranno a zero.

Se scrivo PORTB = 0b00000010 significa che ho messo a 1 il pin 1 del PORTB, mentre tutte le altre uscite (se sono state settate come uscite), rimarranno a zero.

Se scrivo PORTB = 0b00000100 significa che ho messo a 1 il pin 2 del PORTB, mentre tutte le altre uscite (se sono state settate come uscite), rimarranno a zero.

Se scrivo PORTB = 0b10010000 significa che ho messo a 1 il pin 7 ed il pin 4 del PORTB, mentre tutte le altre uscite (se sono state settate come uscite), rimarranno a zero.

E così via. C’è uno sfalsamento perchè i pin del PORTB (di questi registri) partono da zero e arrivano a sette, per un totale di 8.

Ci sono poi millemila modi per settare un registro, molti dei quali sono chiamati maschere o MASK

Metodo 1) PORTB = 0b00000011; (binario)
Metodo 2) PORTB = 0x03; (EXA)
Metodo 3) PORTB = 3; (decimale)
Metodo 4) PORTB |= 0b00000011; (questo metodo setta a 1 dove ci sono gli “uni” lasciando inalterati gli altri bit

Poi altre operazioni, chiamate di bitwise che non ho voglia di starti a spiegare, tipo robe strane così:
PORTB ~(&=0b11111100);
PINB & (1 << PINB0);

bitSet invece è una funzione, molto rapida, che fa la stessa cosa DIRETTAMENTE.

bitSet(nomeRegistro, nomeBit)

bitSet(PORTB, DDB0) = Setta a 1 il bit DDB0 del registro PORTB

bitClear(PORTB, DDB7) = Mette a zero il bit DDB7 del registro PORTB.

Ecc. ecc. ecc.

E’ più chiaro ora? :smiley:

Ho dimenticato di spiegare bene i livelli di astrazione....

Tu puoi modificare un registro accedendo direttamente ad esso nella maniera più diretta possibile, ovvero scrivendo _SFR (indirizzo) = ecc.

Siccome è una palla ricordarsi a memoria gli indirizzi di tutte le periferiche, da qualche parte esiste un file di definizioni dove c'è qualcosa del genere:

define _SFR0x84 PORTB

Si capisce che è molto più immediato ricordarsi cosa significa PORTB piuttosto che rammentarsi il numero 0x84!

Questo è un primo livello di astrazione.

Esistono poi livelli più elevati di astrazione, fino all'ultimo, quale può essere quello di una funzione di Libreria, come digitalWrite() o analogRead(), denbtro le quali c'è qualcosa che alla fine è sempre un indirizzo. L'indirizzo di un registro SFR.

Minimo livello di astrazione:

PORTB = 0b00010000; // porto a 1 il PORTB5 = D13 di Arduino

Massimo livello di astrazione:

digitalWrite(13, HIGH); // porto a 1 il D13 di Arduino

Il primo non si capisce un tubo (più vicino alla macchina)

Il secondo più direttamente comprensibile (più vicino all'umano).

BaBBuino: Quasi tutti i uC sono Memory Mapped, ovvero ogni cosa, ogni operazione che operi su di loro, è un'operazione che fai da qualche parte in un indirizzo di memoria.

Questo vale solo per le MCU con architettura Harvard, nei micro, ma anche mcu, con Architettura von Neumann le periferiche sono una cosa separata dal micro/mcu, anche se risiedono nello stesso chip, e sono locate sul control bus, non condividono la ram per i relativi registri.

Non ci credo l'hardwarista BaBBuino sta subendo una mutazione, qualcosa di software ha invaso il suo DNA. :D

MauroTec: Non ci credo l'hardwarista BaBBuino sta subendo una mutazione, qualcosa di software ha invaso il suo DNA. :D

Fermo li... questo per me è ancora Hardware, anche se il confine è sfumato.

Mi immagino di settare le periferiche con degli interruttori. :grin:

Mica roba pallosa come classi, oggetti, istanze, tipi enumerati, ecc. :)

BaBBuino: Mica roba pallosa come classi, oggetti, istanze, tipi enumerati, ecc. :)

Già. Mica roba per "spelafili" :grin: Battuta che serve anche per iscriversi a questo thread. 8)

BaBBuino: ... 1) Arduinesca classica (setup, loop) 2) In Bare metal e magari anche con i classici main e while(1) 3) In maniera ibrida, ovvero mescolando le due di cui sopra

In questo Thread ci interessiamo delle ultime due, ...

Ciao, sto usando con estrema soddisfazione Arudino-Makefile https://github.com/sudar/Arduino-Makefile che consente un buon controllo della tool-chain avr ed è pure retro-compatibile con gli sketch arduino se serve. Provatelo!

Bene, grazie per l'approfondimento. Restano sempre argomenti "avanzati", ma quando si affronta una discussione mi piace avere un quadro preciso della situazione, sarà una mia deformazione - conformazione mentale :grin: (Lo so che sono notizie che si trovano, ma è meglio avere tutto in un unico blocco) Avevo già usato in passato i comandi PORTx ma mai i bitSet. Interessante, senza dubbio alcuno.

Allora... pensavo intervenisse un sacco di gente, ed invece un tubo!

Ripartiamo dal post che ha "scatenato" la nascita di questo.

La lettura di questo è necessario per comprendere il prosieguo, dove proveremo a fare un sistema RTOS "de noantri", ovvero dei poracci! :D

La richiesta era per far lampeggiare 3 LED contemporaneamente.

Ti suggerisco una soluzione "figa", con un pò di "Bare Metal"

Ora sono al lavoro, quindi non posso testarla, ma dovrebbe funzionare:

#define LED_1 11
#define LED_2 12
#define LED_3 13  // definisco i pin dei 3 LED

#define TEMPO_LED_1 10   // tempo di lampeggio del LED_1, circa 1 sec
#define TEMPO_LED_2 20   // LED_2 circa 2 sec
#define TEMPO_LED_3 30   // LED_3 circa 3 sec

volatile unsigned int conteggio_1;  // variabili di conteggio incrementate ad ogni INTERRUP del Timer
volatile unsigned int conteggio_2;
volatile unsigned int conteggio_3;

void setup()
{
    pinMode(LED_1, OUTPUT);
    pinMode(LED_2, OUTPUT);
    pinMode(LED_3, OUTPUT);

    TCCR1A = 0b00000000;  // Registro di configurazione del timer come normali operazioni Timer
    TCCR1B = 0b00000010; // Registro Timer del Prescaler (divisore del clock)
    bitSet(TIMSK1, TOIE1); // abilito lo scattare dell'INTERRUPT all'Overflow del timer

}

void loop()
{

  // Non faccio GNIENTE

}

ISR(TIMER1_OVF_vect)  // Routine dell'INTERRUPT che si attiva all'overflow del Timer1
{
    conteggio_1++;   // Incremento i contatori ad ogni Overflow del Timer (che avviene a 16 bit = 65536)
    conteggio_2++;
    conteggio_3++;

    if (conteggio_1 == TEMPO_LED_1){   // se...
        //bitSet(PINB, 3); // toggle LED_1
        digitalWrite(LED_1, !digitalRead(LED_1));  // inverto la condizione del LED ad ogni "giro"
        conteggio_1 = 0;  // azzero il conteggio che riparte da capo
    }

    if (conteggio_2 == TEMPO_LED_2){
        digitalWrite(LED_2, !digitalRead(LED_2));
        conteggio_2 = 0;
    }

    if (conteggio_3 == TEMPO_LED_3){
        digitalWrite(LED_3, !digitalRead(LED_3));
        conteggio_3 = 0;
    }

E' ben commentata ma vediamo cmq i punti salienti

1) Definisco i 3 LED e li associo ai 3 pin di Arduino 11, 12 e 13 (quindi uno è il LED onboard. 2) Definisco i tempi di lampeggio per ciascun LED, 3 valori diversi. 3) Dichiaro 3 variabili che userò come contatori. Sono dichiarate come volatile perchè sono modificate dentro una routine di INTERRUPT, quindi fuori dal flusso principale del programma e con "volatile" diciamo al compilatore di non ottimizzarle per non fare casini.

4) Dichiaro i pin 11, 12 e 13 come USCITE

5) Usiamo direttamente il Timer1 dell'ATmega328 che mi è più simpatico. E' un timer a 16 bit, quindi può contare da 0 fino a 65536, poi va in Overflow e riparte da zero

Ci sono due registri principali ad 8 bit che gestiscono questo registro.

Il primo è di semplice configurazione. La nostra funzione è di semplice timer, quindi tutti gli 8 bit a zero:

TCCR1A = 0b00000000; // Registro di configurazione del timer come normali operazioni Timer

Il secondo registro è il divisore di frequenza, alias PRESCALER.

Come da tabella lo imposto per dividere la frequenza principale (che come sappiamo è 16 MHz) nella frequenza che utilizzerà il Timer per contare

|500x282

TCCR1B = 0b00000010; // Registro Timer del Prescaler (divisore del clock = /8)

Quindi significa che al Timer gli arrivano 2 milioni di clock al secondo (16.000.000 / 8 = 2.000.000). Ora sappiamo che il Timer va "a tappo", in overflow, ogni 65535 conteggi, quindi 2.000.000 / 65535, andrà in Overflow circa 30 volte al secondo. Ora usiamo questi 30 Overflow al secondo come clock "umano" per contare qualcosa...

Adesso setto il Timer1 per lanciare un segnale di Interrupt ogni volta che fa "un giro", ovvero ogni volta che va in overflow, contando fino a 65535 e poi ripartire da 0.

bitSet(TIMSK1, TOIE1); // abilito lo scattare dell'INTERRUPT all'Overflow del timer

6) Loop. Non c'è niente quindi non faccio niente...

7) Routine dell'INTERRUPT (il nome ISR(TIMER1_OVF_vect) è obbligatorio e si riferisce al vettore diINTERRUPT relativo all'Overflow del Timer1).

In questa routine si incrementano 3 contatori ogni volta che va in Overflow il Timer1.

conteggio_1++; // Incremento i contatori ad ogni Overflow del Timer (che avviene a 16 bit = 65536) conteggio_2++; conteggio_3++;

I contatori vengono poi utilizzati per dividere il numero degli Overflow e "togglare" i 3 LED

if (conteggio_1 == TEMPO_LED_1){ // se... //bitSet(PINB, 3); // toggle LED_1 digitalWrite(LED_1, !digitalRead(LED_1)); // inverto la condizione del LED ad ogni "giro" conteggio_1 = 0; // azzero il conteggio che riparte da capo }

L'ho compilato con Visual Studio, occupa anche poca memoria.

|500x281

Dovrebbe essere molto chiaro, in caso di dubbi, chiedi...


BaBBuino: Allora... pensavo intervenisse un sacco di gente, ed invece un tubo!

Non è vero ... ... la mancanza di interventi NON necessariamente significa la mancanza di interesse ;)

Guglielmo

Dopo quanto sopra, proviamo a fare questo RTOS che chiameremo NOANTROS (Sistema Operativo Real Time De Noantri) :D

Dunque... definiamo brevemente e BaBBuinicamente RTOS...

RTOS, al dilà di quello che è l'immaginario collettivo (sarà un potentissimo sistema che fa robe pazzesche alla velocità della luce? ::) ) è semplicemente un sistema che garantisce che un task (tradotto da noantri in compito) sia eseguito in un tempo deterministico, ovvero i cui confini temporali sono stabiliti a priori.

Il compito può essere eseguito un pò prima o un pò dopo ma MAI oltre un certo lasso di tempo chiamato Dead Time Line.

In realtà esistono funzioni Hard Real Time e Soft Real Time, dove nelle prime il vincolo temporale è imprescindibile (es. causa morte o gravi danni all'operatore) mentre le seconde sono meno stringenti, e se la funzione "sfora" un pò il tempo previsto, non muore nessuno (nel vero senso della parola! :D)

Naturalmente le cose sono più complicate, però per i nostri scopi, è sufficiente sapere questo.

L'elemento principale di un RTOS è lo Scheduler.

Questo (o questa) è una funzione che si occupa di smistare i vari compiti del programma.

Esistono diversi metodi con cui uno Scheduler può gestire i vari Task di un programma. Noi ci occuperemo del più elementare, ovvero del tipo Round Robin.

In questa modalità lo scheduler assegna delle quantità di tempo (time slices), uguali per ogni task. Terminata una quantità di tempo il task viene interrotto e messo a riposo, e le risorse di calcolo vengono assegnate al task successivo.

Una modalità più complessa del Round-Robin consiste nel designare a task particolari una maggiore o minore importanza, per cui si assegneranno di conseguenza più o meno time-slices (fette di tempo).

Nel prossimo post procediamo con il codice... (me lo devo ancora inventare! :D)

La base di uno Scheduler è il TickSystem.

Cos'è sta roba? :(

E' semplicemente un clock, un "battito" al cui ritmo vengono eseguiti i Task. Non è il clock della MCU, che è troppo veloce, ma qualcosa derivato da questo.

E' talmente importante questo elemento che i uC di ARM (i Cortex Mx) hanno integrato nel loro core, non un normale timer, ma proprio un timer di sistema, intimamente connesso con l'unità di calcolo: il SysTick, un timer dedicato per sistemi RTOS

|500x344

Dobbiamo quindi munirci di questo clock di sistema. Per farlo dobbiamo partire dalla frequenza di clock del micro, nel nostro caso 16 MHz.

Il timer è il solito Timer1 a 16 bit, quindi capace di contare da 0 a 65535, dopodichè va in overflow e riparte da 0.

Dobbiamo inventarci un qualche sistema per dividere i 16 MHz e ridurli ad un sottomultiplo del secondo.

In realtà non è così importante che sia un sottomultiplio del sec, può essere un numero qualsiasi, ma con un Tic derivato dal secondo ci semplifica la vita in termini umani.

Naturalmente 16.000.000 di Hz e 65535 non sono compatibili come fattore di divisione (uscirebbe fuori un numero float) però cè un trucco... ...noi possiamo caricare un numero all'interno del contatore del timer (il solito registro) e fare in modo che il contatore conti un numero diverso da 65535.

Mi spiego: se noi carichiamo dentro il contatore del timer il numero 65530, e facendo in modo che ad ogni overflow (che avviene a 65535) il contatore invece che resettarsi a zero riparta da 65530, noi avremo ottenuto un contatore che conta fino a 6!

..(1).......(2).......(3).......(4).......(5).......(6) 65530...65531...65532...65533...65534...65535.......6530 e così via!

Se allora carichiamo dentro il contatore il numero 49535 avremo un contatore che conta 16.000 impulsi (da 49535 fino a 65535). In questo modo abbiamo ottenuto un contatore multiplo dei 16MHz, con cui potremo dividere il clock di 16.000.000 e recuperare un sottomultiplo del secondo (e conteggi più umaneschi).

Quindi bisogna recuperare il Datasheet dell'ATmega328 e vedere come fare.

Si prende il liBBrone di 500 pagine e si apre a pagina 115:

Leggiamo che:

To do a 16-bit write, the high byte must be written before the low byte. For a 16-bit read, the low byte must be read before the high byte.

Per scrivere o leggere i registri a 16 bit (ed il contatore del Timer1 lo è!) bisogna prendere delle precauzioni, perchè il bus interno è sempre a 8 bit, quindi le operazioni di lettura e scrittura avvengono un "pezzo" (8bit) per volta. I registro è uno, ma è diviso in parte alta TCNT1H e parte bassa TCNT1L.

A pag 116 troviamo cmq già pronto un paio di istruzioni in C che permettono di eseguie la scrittura senza prendere gli accorgimenti che andrebbero presi in Assembly:

unsigned int i; ... /* Set TCNT1 to 0x01FF / TCNT1 = 0x1FF; / Read TCNT1 into i */ i = TCNT1; ...

Abbiamo già pronto il codice per caricare un numero nel contatore del time. Dobbiamo solo caricare dentro il numero decimale 49.535. Due click sulla calcolatrice di Windows e vediamo che 49.535 equivale a 0xC17F in notazione Esadecimale.

In realtà possiamo anche scrivere direttamente 49535, ma 0xC17F è molto più cool! :D

Quindi con una roba del genere:

void setup(){

TCCR1A = 0b00000000; // Registro di configurazione del timer come normali operazioni Timer TCCR1B = 0b00000001; // Registro Timer del Prescaler (divisore del clock a 0, nessuna divisione) TCNT1 = 0xC17F; // Carico dentro il Timer il numero decimale 49535 bitSet(TIMSK1, TOIE1); // abilito lo scattare dell'INTERRUPT all'Overflow del timer

}

Otteniamo un overflow ogni 16.000.000 / 16.000 = 1000 conteggi, ovvero 1000Hz

Quindi, avendo abilitato l'INTERRUPT per gli overflow del Timer1, avremo una ISR che viene chiamata 1000 volte al secondo, un sottomultiplo del secondo!

Abemus SysTic! :D

Continua...

L’alcolista non ammette di avere un problema, quando lo fa c’è ancora speranza di smettere, BaBBuino e un softwarista e lo nega, quindi…
Spetta a noi riportarlo sulla retta via della risposta in frequenza.
Ti ricordi quanto è sinuosa una rete di zobel, con la sua bella bobbinona, ti ricordi la guerra intrapresa nei confronti della terza armonica, non vorrai mica dargliela vinta.
:smiley: