C per sistemi embedded

In fase di sviluppo Pagina in fase di preparazione In fase di sviluppo

Questo non è un  manuale C, tantomeno una serie di lezioni, ma la raccolta di alcune brevi osservazioni spesso considerate bizzarre quando si studia per la prima volta il C, ma spesso utili quando si lavora a stretto contatto con l'hardware.

Rappresentazione degli interi

Gli interi hanno diverse rappresentazione in memoria, a seconda del numero di bit che vengono utilizzati e del fatto che il bit più significativo sia il segno (complemento a due) oppure no.

Il C non definisce la lunghezza di nessuno di questi interi; per esempio un int, a seconda del compilatore (e della sua configurazione...) potrebbe per esempio avere una lunghezza variabile tra 8, 16, 32 o 64 bit. Utile la funzione sizeof().

In genere i compilatori sono distribuiti con un header file stdint.h che specifica alcuni tipi di interi di lunghezza nota e costante, con e senza segno. Qualche esempio, dall'ovvio significato:

Esempio d'uso:

int8_t   simple_byte; // simple_byte potrà contenere valori compresi tra -128 e +127
uint16_t simple_word; // simple_word potrà contenere valori compresi tra 0 e 65535

La base con cui i numeri sono presenti in memoria è sempre, ovviamente, il binario. Nel scrittura del codice è possibile scegliere la rappresentazione "umana" più comoda per un determinato uso:

I quattro esempi mostrati producono tutti esattamente lo stesso identico effetto. In genere la prima si usa per rappresentare numeri nel senso ordinario del termine, gli altri per rappresentare bit.

Operatori bitwise e bit shifting

Gli operatori bitwise e di scorrimento dei bit operano sui singoli bit che formano i byte, indipendentemente dalla loro posizione e quindi dal peso. In pratica sono le stesse operazioni della logica booleana.

Operano su tutte le variabili riconducibili agli interi (int, char, long int, signed int, unsigned int, int32_t..., per comodità umane spesso rappresentate in esadecimale. Sono quattro operazioni logiche di base e due operatori di shift (scorrimento):

AND &
OR |
XOR ^
NOT ~ (AltGr + ì sulla tastiera italiana)
Scorrimento verso destra >>
Scorrimento verso sinistra <<

Esempi:

unsigned char a=0xFA, b=0xA0, y;  // a = 1111 1010    b = 1010 0000

y = a & b;    // y = 0xA0 = 1010 0000
y = a | b;    // Y = 0xFA = 1111 1010
y = a ^ b;    // y = 0x5A = 0101 1010
y = ~a;       // y = 0x05 = 0000 0101
y = a >> 1;   // y = 0x7D = 0111 1101
y = a << 4;   // y = 0xA0 = 1010 0000

Quanto vale il terzo bit da destra del byte myBYTE? Scriviamo l'if corrispondente:

if (myBYTE & 0b00000100)
    // myBYTE, bit 2 vale 1
else
    // myBYTE, bit 2 vale 0

Voglio che il secondo bit da sinistra (bit 6) del byte myBYTE diventi 1:

myBYTE = myBYTE | 0b01000000; // Non standard... meglio myBYTE = myBYTE | 0x40

Voglio che il secondo bit da sinistra del byte myBYTE diventi 0:

myBYTE = myBYTE & 0b10111111;

Possono anche operare secondo le usuali regole degli operatori. Ad esempio:

a <<= 1; coincide con a = a << 1

Un caso particolare si ha nello scorrimento verso destra dei numeri il cui bit più significativo vale 1:

Esempio:

int8_t X2 = 0x80; // un uno (segno), seguito da sette zeri. Interpretato come -128 in complemento a due
uint8_t X = 0x80; // un uno, seguito da sette zeri. Interpretato come +128 in binario naturale

X2 = X2 >> 1; // Risultato: -64 -> 0b1100 0000
X = X >> 1;   // Risultato: +64 -> 0b0100 0000

Approfondimento ed altri esempi al termine della pagina 44 della guida C. (operazioni logiche tra variabili)

Allineamento dei dati

In genere i compilatori assegnano l'indirizzo alle variabili per ottimizzare i tempi di accesso, anche a scapito della memoria occupata. Per esempio, un tipico compilatore C a 32 bit assegna la memoria allineando tutte le variabili al "confine dei 4 byte, lasciando libere alcune celle che non sono multiplo di 4:

int8_t  variabile1 = 0x12;
int32_t variabile2 = 0x2356789A;
int16_t variabile3 = 0xBCDE;

In memoria avremo un'occupazione simile alla seguente, dove -- indica una cella dal contenuto non definito; per semplicità espositiva si è scelto un ordine dei byte big endian

12 -- -- -- 23 56 78 9A BC DE -- --

Di conseguenza lo spazio occupato è 12 byte e non 7.

Per un allineamento senza perdita di spazio occorre specificarlo, attraverso un'apposita direttiva non standard; per esempio gcc utilizza #pragma pack(1), #pragma pack(2) per un allineamento al byte o alla word.

Lo stesso problema emerge nel caso di struct. Per esempio la seguente struttura:

struct frame {
  int8_t  variabile1byte;
  int32_t variabile4byte;
  int16_t variabile2byte;
};

Occupa:

Nei compilatori a 8 bit (per esempio XC8, per i PIC16/18) questo problema in genere non si pone.

In alternativa all'uso dell'allineamento automatico, valido per tutte le variabili e quindi con impatti negativi sulle prestazioni, è possibile manualmente ordinare le variabili per rispettare l'allineamento oppure inserire variabili di riempimento (padding).

Bit field

Questo tipo particolare di struct permette di creare variabili di lunghezza arbitraria, compresa tra un bit e la lunghezza di un intero. Tipicamente serve per identificare il significato di determinati bit o gruppi di bit all'interno di un registro hardware. Esempio:

typedef struct {
    unsigned a :3;
    unsigned b :1;
    unsigned c :4;
} esempio_t;

esempio_t simple_esempio;

La variabile simple_esempio è quindi costituita da 3 parti di lunghezza rispettivamente 3, 1 e 4 bit. L'uso è quello solito delle struct:

simple_esempio.a = 5;  // Anche simple_esempio.a = 0b101;
simple_esempio.b = 0;
simple_esempio.c = 20; // Errore! 20 non è rappresentabile con soli 4 bit

Questo tipo di struct è ampiamente utilizzato dal compilatore XC8, associando a ciascun SFR fisico due variabili (union):

La modifica di una delle due "variabili" si riflette immediatamente anche sull'altra, oltre che sulla periferica fisica.

Variabile volatile

La parola chiave volatile usata per definire una variabile significa che il suo valore può cambiare indipendentemente dal programma in esecuzione. Nei sistemi embedded due sono i casi significativi:

In pratica il compilatore disattiva per queste variabili una serie di ottimizzazioni.

Esempio: PORTC è il contenuto di un registro hardware:.

volatile unsigned char PORTC:

while (PORTC == 10) {
  ... //Codice che non modifica PORTC
  }

Consideriamo PORTC come una variabile normale diversa da 10; questo codice corrisponde ad un loop infinito, il compilatore lo sa e quindi evita che verificare ad ogni giro se PORTC è uguale a 10, risultando più veloce, ma, nel nostro caso, errato. Definire PORTC volatile, obbliga invece il compilatore ad eseguire il confronto ad ogni "giro".

Altro esempio:

datoPronto = false;
... // Codice qualunque che non modifica datoPronto
if (datoPronto) {
  ... // Codice qualunque
}

Il compilatore, trovandosi di fronte il codice appena riportato fa una cosa assolutamente logica: non compila l'if perché la condizione è sempre falsa; in genere lo segnala con un warning in quanto il codice è apparentemente privo di senso.

Ovviamente la cosa cambia profondamente se la variabile datoPronto viene modificata per esempio all'interno di un interrupt... In questo caso la variabile potrebbe cambiare stato in modo imprevedibile e quindi il test deve essere eseguito.

Variabile locale statica

Una variabile locale statica (quindi definita all'interno di una funzione) è una variabile che mantiene il suo valore tra le varie chiamate alla funzione. Quindi si comporta, da questo punto di vista, come una variabile globale pur mantenendo invariata la sua visibilità (scope). Queste variabili vengono inizializzate dal compilatore come le variabili statiche (la variabile My_a dell'esempio, viene inizializzata a zero mentre una normale variabile locale assumerebbe ad ogni chiamata della funzione un valore casuale)

Esempio:

int my_function(void) {
static int My_a;
  ...
}

Utile per esempio per memorizzare lo stato di una ISR senza dove creare una variabile globale.

Diverso il caso di variabili globali statiche: la variabile è infatti globale ma il suo scope è limitato al file in cui è definita.

Approfondimenti: guidaC.pdf

Endianness

Con questo termine si indicano le diverse modalità con cui un numero a 16 o più bit è memorizzato nella memoria, in genere costituita da un insieme di byte. Le due tecniche principali sono conosciute come little endian e big endian, il primo usato per esempio dai processori Intel/AMD e ARM, il secondo dai processori AVR32 e ARM (ARM è bi-endian...):

Quale delle due scelte è migliore? il primo che ha suggerito la risposta corretta è stato Jonathan Swift (non è un link errato...). Certo non aspettatevi una risposta.

"Conversione" tra signed ed unsigned

A volte una stessa sequenza generica di bit deve poter essere interpretata come numero con segno in complemento a due oppure senza segno. Esempi classici sono lo scorrimento verso destra oppure l'uso di una variabile di ritorno di una funzione. La seguente union fornisce una soluzione senza l'uso di puntatori o cast esplicito.

typedef union {
 uint16_t u;
 int16_t i;
} dual_t;

dual_t Dual;

Il contenuto di Dual.u e Dual.i è ovviamente identico, ma:


Data di creazione di questa pagina: giugno 2017
Ultima modifica: 5 marzo 2022


Licenza "Creative Commons" - Attribuzione-Condividi allo stesso modo 3.0 Unported


Pagina principaleAccessibilitàNote legaliPosta elettronicaXHTML 1.0 StrictCSS 3

Vai in cima