Programmazione in C: cosa fare e cosa non fare

Il linguaggio C è di gran lunga usato nella programmazione dei sistemi embedded ed esistono numerosi volumi che ne illustrano la semantica e la sintassi. Molto più raro è invece trovare una trattazione che illustri le regole principali sull’ottimizzazione degli algoritmi in modo da rendere il codice molto più veloce e compatto. Ecco una breve raccolta di suggerimenti pratici per AVR usando il compilatore AVR-GCC.

Introduzione

Il C è un linguaggio di alto livello il che significa che non è riferito  ad un particolare hardware quindi  il programmatore  non deve accedere alle risorse del microcontrollore   come  i  registri  interni,  lo  stack ecc… Questo, se da un lato facilita molto la stesura del codice, dall’altro impedisce di compiere alcune operazioni che sarebbero sicuramente utili quali:

  • possibilità di controllare il verificarsi di un overflow a seguito di una operazione aritmetica (questo può essere fatto solo leggendo opportuni flag);
  • possibilità di eseguire operazioni in parallelo (per eseguire operazioni multi-thread è necessario salvare il contesto quindi accedere ai registri interni).

Questi problemi  vengono però comunque aggirati grazie ad apposite librerie di sistema (ad esempio la io.h)

Le cose si complicano se la CPU alla quale il codice è destinato non ha una architettura von Neumann bensì una Harvard. Nel primo caso memoria dati e memoria  programma  usano  lo  stesso spazio  di memoria, ma nel secondo caso possono essere usate memorie di vario tipo come ad esempio Flash, EEPROM, RAM le cui caratteristiche differiscono notevolmente da un tipo all’altro ed il linguaggio C tradizionale non prevede il supporto per diverse tipologie  di memoria per cui è necessario utilizzare funzioni diverse. Ad esempio, si supponga di dover scrivere 10 caratteri in una variabile file che risiede su disco, quindi trasferirli in RAM. Il codice C dovrà essere il seguente:

ram char buffer[10]; // Array in RAM
disk char file[10]; // Array su Disco

for (i=0;i<10;i++)
{
file[i]=’0’;
}
// Scrittura in memoria
strncpy(file,buffer,10);

Si noti che le variabili vengono dichiarate allo stesso modo, ma differisce la funzione utilizzata per accedervi: nel caso della variabile su disco viene usata la funzione di assegnazione diretta (file[i]=’0’), mentre per la variabile in memoria viene usata la funzione strncpy.

Meglio strutture o array?

Tutti i manuali di C sottolineano quanto le strutture (intese come tipi  di dati) siano utili  per rendere il codice chiaro e leggibile, ma nel mondo embedded non sempre questa è una scelta ottimale.

Si consideri ad esempio un array di strutture del tipo:

struct SENSOR
{
unsigned char state;
unsigned char value;
unsigned char count;
}
struct SENSOR Sensors[10];

La lettura  del valore value di  un singolo sensore comporta una moltiplicazione ed una addizione e questo non è certamente ottimale in un sistema in cui le risorse sono limitate. In questo caso si possono usare tre array:

unsigned char Sensor_states[10];
unsigned char Sensor_values[10];
unsigned char Sensor_counts[10];

Questa seconda soluzione rende meno leggibile il codice, ma allo stesso tempo ne velocizza l’esecuzione. Come prova di questo, è stato utilizzato un sorgente C per la lettura e la copia di byte dalla struttura e dall’array  e  compilato   con  AVR-GCC utilizzando  le opzioni di ottimizzazione “-O0” (nessuna ottimizzazione) e “-O3” (ottimizzazione sulla velocità). I risultati sono riportati  nella tabella 1: si noti come nella lettura sia molto più performante utilizzare un array, mentre nella copia la struttura dà i risultati migliori.

Tabella 1. Risultati delle prove di prestazioni

Tabella 1. Risultati delle prove di prestazioni

Se statisticamente il programma prevede frequenti operazioni di lettura è consigliato dunque l’utilizzo dell’array, mentre se sono previste frequenti operazioni di copia è più indicato l’uso di una struttura.

Salti condizionati

Un altro punto molto importante nella programmazione dei sistemi embedded sono le condizioni di salto. Sicuramente l’uso del costrutto  switch() faciliterà senz’altro la lettura del codice, ma anche qui non sempre risulta essere la scelta migliore. La tabella 2 riporta i risultati della prova effettuata con il codice del listato 1.

char a;

char With_switch()
{
switch(a)
{
case ‘0’: return 0;
case ‘1’: return 1;
case ‘A’: return 2;
case ‘B’: return 3;
default: return 255;
}
}

char With_if()
{
if(a==’0’) return 0;
else if(a==’1’) return 1;
else if(a==’A’) return 2;
else if(a==’B’) return 3;
else return 255;
}
Listato 1
Tabella 2. Risultati delle prove sul listato 1

Tabella 2. Risultati delle prove sul listato 1

Allocazione dinamica della memoria

In un computer l’allocazione dinamica della memoria viene gestita dal sistema operativo, ma nei sistemi embedded privi di un vero e proprio sistema operativo è il compilatore che crea appositi segmenti di memoria. Anche in questo caso dunque è bene valutare prima di scegliere  se fare uso o meno della memoria dinamica. Si consideri ad esempio il seguente codice che non utilizza la memoria dinamica:

char a[100];
void main(void)
{
a[30]=77;
}

Poiché l’indirizzo dell’elemento in cui scrivere è noto, il codice generato è molto  ridotto  (50 words) e la scrittura nell’array avviene in due cicli di clock. La stessa azione può essere effettuata utilizzando l’allocazione dinamica della memoria:

char * a;
void main(void)
{
a=malloc(100);
a[30]=77;
free(a);
}

In  questo  caso però  il  programma  compilato  ha dimensioni molto maggiori (325 words) e la scrittura dell’elemento avviene in sei cicli di clock. Rispetto alla precedente versione il programma è cresciuto di 275 words di cui 157 dovute alla funzione malloc e 104 dovute alla funzione free oltre alle 14 word necessarie per la chiamata di tali funzioni. Inoltre l’array  creato  dinamicamente  viene  inizializzato con tutti  valori nulli il che comporta un ulteriore uso di risorse. Questo esempio dimostra quindi che l’uso dell’allocazione dinamica della memoria è da evitare in tutti quei casi in cui le risorse sono limitate.

Errori da evitare

Nella lettura di una stringa dalla memoria Flash, il compilatore AVR-GCC non è in grado di riconoscere autonomamente se il puntatore alla stringa si riferisce alla memoria dati o alla memoria programma (per default viene considerata la RAM). Per leggere una stringa da memoria Flash è necessario dunque utilizzare la macro contenuta in pgmspace.h:

#include // include files
#include pgmspace.h
prog_char hello_str[]=”Hello AVR!”;
void puts(char * str)
{
while(PRG_RDB(str) != 0)
{
PORTB=PRG_RDB(str++);
}
}
void main(void)
{
puts(Hello_str);
}

Si consideri ora il caso in cui si deve leggere un bit da una porta specifica:

void Wait_for_bit()
{
while( PINB & 0x01 );
}

È necessario  tener conto che se viene abilitata l’opzione di ottimizzazione il compilatore calcola prima il valore di (PINB & 0x01), scrive il risultato in un apposito registro quindi  ne esegue il test. Questo meccanismo non  prevede che il valore del pin  in esame può variare in maniera asincrona, quindi cambiare durante  l’esecuzione del  test.  Per prevenire questo inconveniente è necessario utilizzare la macro contenuta in sfr_gefs.h (che è parte della libreria io.h):

void Wait_for_bit()
{
while ( bit_is_set(PINB,0) );
}

Una cosa analoga avviene nel caso in cui una funzione è in attesa di un interrupt visto che il flag può cambiare in qualsiasi momento:

unsigned char flag;
void Wait_for_interrupt()
{
while(flag==0);
flag=0;
}
SIGNAL(SIG_OVERFLOW0)
{
flag=1;
}

In questo caso la soluzione è utilizzare una variabile volatile:

volatile unsigned char flag;
void Wait_for_interrupt()
{
while(flag==0);
flag=0;
}
SIGNAL(SIG_OVERFLOW0)
{
flag=1;
}

Altro tipico errore è quello di utilizzare un ciclo vuoto per la generazione dei ritardi:

void Big_Delay()
{
long i;
for(i=0;i<1000000;i++);
}

Una funzione di questo tipo non altera le variabili locali e globali e non ritorna alcun valore, per cui attivando l’opzione di ottimizzazione del compilatore, questa funzione potrebbe  essere completamente  ignorata.

Per  evitare questo basta utilizzare la funzione nop dell’assembler nel seguente modo:

define nop() {asm(“nop”);}
void Big_Delay()
{
long i;
for(i=0;i<1000000;i++) nop();
}

Altri suggerimenti utili

Per quanto visto in precedenza nella programmazione di sistemi embedded con linguaggi di alto livello, si deve sempre giungere ad un compromesso tra dimensioni del codice e velocità di esecuzione. Prima di iniziare la stesura di un programma è bene dunque valutare quale dei due fattori sia il più rilevante. In generale è bene tener conto che:

  • Utilizzare funzioni in-line permette di evitare chiamate a funzioni remote velocizzando l’esecuzione del programma, ma ciascuna funzione viene copiata nell’opportuna parte di programma, provocando un aumento delle dimensioni del codice talvolta anche significativo. Si consiglia di utilizzare funzioni in-line solo quando queste vengono usate frequentemente e sono costituite da poche linee di codice.
  • Utilizzando salti condizionati con il comando switch, è bene mettere nelle prime posizioni i casi più frequenti in modo da arrivare al matching delle condizioni nel più breve tempo possibile. In questo modo ne gioverà il tempo di esecuzione del programma.
  • Utilizzare istruzioni in-line in assembler per le parti di codice che risulterebbero poco ottimizzate se scritte in C.
  • Se  nel  programma  vi  sono delle  variabili  usate molto frequentemente, è utile dichiararle con la parola chiave Register in modo che queste vengano assegnate ad un registro generale velocizzando i tempi di accesso.
  • Utilizzare il più possibile variabili globali in modo da ridurre le operazioni di push/pop nello stack in fase di chiamata e ritorno da una funzione.
  • Evitare il più possibile aritmetica floating-point  in micro privi di FPU. Se il micro non è datato di FPU, il  compilatore  esegue una  serie di  funzioni  per emulare il comportamento  della FPU con conseguente incremento delle dimensioni del codice. Spesso è possibile effettuare divisioni e moltiplicazioni semplicemente scorrendo a destra o a sinistra il contenuto di una variabile.
  • Utilizzare tipi  di dati strettamente necessari: se si necessita di un intero su 8 bit, evitare l’uso del tipo int (che normalmente viene espresso su più di 8 bit – tipicamente 16 o 32) preferendo il tipo unit.
  • Utilizzare il comando goto in alternativa a complesse strutture condizionali: questo permette di ridurre i tempi di esecuzione evitando le operazioni di comparazione.

 

 

6 Commenti

  1. Davide Di Gesualdo 30 marzo 2016
    • Emanuele Bonanni Emanuele 30 marzo 2016
  2. Giovanni Caturano 30 marzo 2016
  3. Maurizio Di Paolo Emilio Maurizio 30 marzo 2016
  4. f.amantea 1 aprile 2016
  5. Valter Valter 5 aprile 2016

Scrivi un commento

Send this to a friend