C e Assembly: due mondi a confronto in ambiente AVR

Il linguaggio assembly è uno strumento tipico per programmare sistemi embedded di piccola fascia. Oggi, con l’efficienza dei compilatori, è fortemente sconsigliato utilizzarlo; infatti, solo un linguaggio strutturato, come il C, rende la nostra architettura più versatile e manutenibile.

Da diverso tempo i sistemi embedded di piccola fascia sono programmati utilizzando l’ormai classico linguaggio C, magari utilizzando qualche prerogativa non propria del linguaggio. A volte può capitare di dover migrare codice scritto in assembly verso un’applicazione C o, ancora meglio, può nascere l’esigenza di sviluppare in Assembly parti di codice per sfruttare la velocità e la versatilità di uno strumento che permette di accedere direttamente alle risorse hardware. In questo articolo vediamo alcune considerazioni da tenere presente per utilizzare il linguaggio  Assembly  all’interno di una nostra applicazione C; per fare questo lavoro ci aiuteremo su un microcontrollore ATMEL AVR a 8 bit. È altrettanto ovvio che molto dipenderà anche dal compilatore utilizzato e del suo grado di efficienza in fase di generazione del codice macchina. In ogni caso, è abbastanza intuitivo rilevare che, grazie al C, si ottimizza l’approccio metodologico e si incrementa la manutenibilità e la portabilità delle nostre applicazioni. Per svolgere questo lavoro ci preoccuperemo di analizzare alcune istruzioni, almeno quelle più comuni e rilevanti, che sono utilizzate da progettista di software embedded; per inciso, ci occuperemo della gestione dei singoli bit, del codice misto e dei meccanismi di looping.

Benefici di un linguaggio di alto livello

Esistono diversi motivi che consigliano di utilizzare un linguaggio di alto livello, per esempio per ottenere una buona portabilità del progetto. Utilizzare un linguaggio strutturato permette di rimuovere specifici richiami alla struttura del microcontrollore; in questo modo il programmatore accede direttamente  alle risorse hardware senza mai preoccuparsi delle sue modalità. Un altro fattore da tenere presente è sicuramente la leggibilità; infatti, un linguaggio strutturato e di alto livello come il C permette di leggere  il codice in maniera più facile e immediata perché il meccanismo di lettura assomiglia di molto al linguaggio parlato. L’altro aspetto positivo è certamente la manutenibilità: grazie a questo, i  programmi sono più facili da gestire. Il vantaggio immediato è identificato in una maggiore modularità, in questo modo è possibile aggiungere e togliere moduli per ottenere nuove funzionalità. La conseguenza immediata di questo è sicuramente il  re-usabilità, in altre parole la possibilità di riusare il codice originario in progetti diversi. Pensiamo alle funzioni ad uso generale, riscrivere costantemente queste porzioni di codici è un’attività dispendiosa, ma utilizzare la modularità può comportare la diminuzione della durata dello sviluppo di un progetto.

Accesso ai singoli bit

Sicuramente un’esigenza tipica di un sistema embedded è la possibilità di accedere direttamente ai singoli bit di un registro di I/O, di una locazione di memoria o di leggere e scrivere una particolare locazione. Per svolgere questa operazione esistono due possibilità: o utilizzare il  bit masking o ricorrendo ai bitfield. Il primo approccio è messo in evidenza nel listato 1.

/* Macros Definition */
#define BIT(x) (1 << (x))           /* Bit Position */
SFR_B (PORTC, 0x15);                /* PORTC Data Register */
__C_task void main (void)
{
         PORTC |= BIT(0);         /* Il bit 0th Bit è acceso ( ‘1’) */
                                  // SBI 0x15, 0x00 ; Set Bit in I/O Register
         PORTC &= ~BIT(0);        /* Il bit 0th è spento (‘0’) */
                                  // CBI 0x15, 0x00 ; Clear Bit in I/O Register
         PORTC ^= BIT(0);         /* Toggle the 0th Bit */
                                  // LDI R16, 1 ; Load Immediate to R16
                                  // IN R17, 0x15 ; Read I/O Register into
                                  // R17
                                  // EOR R17, R16 ; Exclusive OR Registers
                                  // R16 & R17
                                  // OUT 0x15, R17 ; Output R17 to I/O
                                  // Register
         if ( PORTC & BIT(0) )    /* testa il bit? */
                                  // SBIS 0x15, 0x00 ; Skip if Bit Set in
                                  // 0I/O Register
                                  // RJMP ?0001 ; Relative Jump
{
/* In questo posto inserisci le istruzioni */
}
                                  // ?0001:
}
Listato 1 - Accesso a singoli bit attraverso il bit masking
Listato 1 - Accesso a singoli bit attraverso il bit masking
typedef struct
{
unsigned BIT0 : 1,
BIT1 : 1,
BIT2 : 1,
BIT3 : 1,
BIT4 : 1,
BIT5 : 1,
BIT6 : 1,
BIT7 : 1
} IOREG;
#define PORTC (* (IOREG *) 0x35) /* Locate PORTC in I/O Memory */
__C_task void main (void)
{
PORTC.BIT0 = 1;
/* Il bit 0th è accesso */
                         // LDI R30, LOW(53) ; Initialize Z Pointer, Low Byte
                         // LDI R31, (53) >> 8 ; Initialize Z Pointer, High Byte
                         // LD R16, Z ; Load R16 with SRAM Location Z
                         // ORI R16, 0x01 ; OR Immediate with R16
                         // ST Z, R16 ; Store R16 to SRAM Location Z
PORTC.BIT0 = 0;
/* Il bit 0th è spento */
                        // LDI R30, LOW(53) ; Initialize Z Pointer, Low Byte
                        // LDI R31, (53) >> 8 ; Initialize Z Pointer, High Byte
                        // LD R16, Z ; Load R16 with SRAM Location Z
                        // ANDI R16, 0xFE ; AND Immediate with R16
                        // ST Z, R16 ; Store R16 to SRAM Location Z
       if ( PORTC.BIT0 )
/* Test ail bit*/
                       // LDI R30, LOW(53) ; Initialize Z Pointer, Low Byte
                       // LDI R31, (53) >> 8 ; Initialize Z Pointer, High Byte
                       // LD R16, Z ; Load R16 with SRAM Location Z
                       // SBRS R16, 0x00 ; Skip if Bit Set Register Set
                       // RJMP ?0001 ; Relative Jump
{
/* In questo posto inserisci le istruzioni */
}
// ?0001:
}
Listato 2 - Accesso a singoli bit attraverso il bit field

Variabili globali  e localiLe istruzioni SBI e CBI sono utilizzate per “accendere” o “spegnere” un bit. L’approccio mostrato nel listato 1 è una buona scelta in quanto è realizzabile nella maggior parte dei compilatori.  Il bit masking può essere benissimo realizzato ricorrendo alle definizioni del pre-processore C, come mostrato nel listato 3.

#define SETBIT(x,y) (x |= (y)) /* Accendi I bit y nel byte x */
#define CLEARBIT(x,y) (x &= ~(y)) /* Spegni il bit y nel byte x */
#define CHECKBIT(x,y) (x & (y)) /* Controlla il bit y nel byte x */
Listato 3 - Definizioni del pre processore C

Come notiamo, i listati 1 2, svolgono le stesse operazioni da punti di vista differenti:  il primo listato applica il meccanismo a maschera, mentre il secondo svolge il  lavoro attraverso una struttura appositamente predisposta, chiamata bit-field. Dai listati mostrati vediamo i differenti  approcci utilizzati con i diversi registri coinvolti. Una struttura bitfield può essere utilizzata per realizzare rappresentazioni virtuali di dispositivi di I/O o per accedere a particolare strutture in memoria. Lo standard del C non specifica l’ordine d’allocazione della struttura bit-field. Cioè, l’allocazione dei bit dipendono dal compilatore utilizzato (da sinistra a destra o, viceversa, da destra a sinistra) e può variare da compilatore a compilatore. Questo può essere un problema, quando entra in gioco la portabilità; per questa ragione è fortemente consigliato leggere la documentazione a corredo per determinare l’ordine d’allocazione dei bit per un corretto allineamento in memoria, pensiamo a questo proposito alle periferiche di I/O mappate in memoria. Inoltre, solo i tipi “int” e “unsigned” sono dei validi tipi per il bit-field. Oggi, la maggior parte dei compilatori permettono di estendere la specificazione dei tipi così da includere gli “unsigned char” che permettono di allocare solamente un byte per la struttura sopra menzionata. Al contrario, i compilatori che non prevedono i tipi “unsigned char”, o se non sono utilizzate, allocheranno due byte per realizzare  la stessa struttura aumentando in questo modo l’occupazione di memoria. Nei piccoli sistemi embedded questo può anche essere un serio problema. Oltre a queste considerazioni è opportuno affrontare anche il tema dell’endianness, o l’ordine d’immagazzinamento dei valori più grandi di 8 bit in memoria. A questo proposito esistono due grandi famiglie di microcontrollori, big endian o little endian. I processori che supportano la modalità big endian mettono in memoria  i dati con il  byte più significativo (MSB) prima di quello meno significativo  (LSB); al contrario, con la modalità little endian, l’immagazzinamento delle variabili è esattamente il contrario: prima il byte meno significativo (LSB) e in seguito quello più significativo (MSB). Tutto questo comporta una certa omogeneità tra scrittura e lettura, altrimenti o valori non sono sicuramente congruenti. Lo standard del C non specifica questa modalità, ma occorre verificare l’architettura del microcontrollore che stiamo utilizzando e verificare la congruenza con il compilatore utilizzato. In un linguaggio strutturato esistono differenti tipi di variabili. In questo contesto esistono variabili locali e globali. Una variabile locale è utilizzata solo all’interno di una funzione, la sua dichiarazione è fatta all’interno della funzione stessa. Viceversa, una variabile globale è una variabile che può essere utilizzata da diverse funzioni ed è dichiarata fuori da una funzione. Il listato 4 mostra un esempio.

char global;            /* Variabile globale */
__C_task void main (void)
{
char local;             /* dichiarazione di una variabile locale */
global -= 45;           /* Operazione di sottrazione con una globale */
                        // codice prodotto per accedere a variabili globali:
                        // LDS R16, LWRD(global) ; Carica variabile da SRAM
                        // verso un registro (R16)
                        // SBIW R16, LOW(45) ; Svolge l’operazione
                        // STS LWRD(global), R16 ; Scrive in SRAM il nuovo valore
local -= 34;            /* Operazione su una variabile locale */
                        // SUBI R16, LOW(34) ; In questo caso, l’operazione è
                        // svolta direttamente sulla variabile locale, registro R16
}
Listato 4 - Variabili locali e globali

Le variabili di tipo locali, tipicamente, sono assegnate dal compilatore ai registri del microcontrollore L’assegnamento è valido fino a quanto restiamo nel corpo della funzione, una volta che la funzione esaurisce  il suo ruolo, la variabile non è più referenziata. Viceversa, una variabile globale risiede nella memoria dati del microcontrollore. Ogni microcontrollore dispone di una certa quantità di SRAM utilizzata per le variabili globali e strutture dati. Ogni variabile globale, o una sua struttura, deve essere prima messa in un registro di lavoro per essere modificata o letta. Le istruzioni LDS e STS sono utilizzate per leggere o modificare memoria della SRAM del microcontrollore, occupano due word e due cicli macchina. Operare sulle variabili globali richiede dieci byte di spazio e 5 cicli di clock per l’esecuzione, mentre per le variabili locali lo spazio si riduce a 2 byte con un colpo di clock. Per questa ragione è facilmente intuibile che utilizzare variabili locali è molto più conveniente di quelle globali. Le variabili di tipo locale devono essere dichiarate “static” per ridurre gli impatti nel codice; infatti, una dichiarazione di questo tipo ne deriva che la variabile è caricata in un registro, dalla memoria SRAM, all’inizio della funzione e vi rimane fino alla fine. E’ fortemente consigliato, invece, di ridurre le variabili globali perchè queste impattano pesantemente sulle prestazioni del nostro programma. L’uso di funzioni con passaggio dei parametri (in registro) può ridurne sensibilmente il numero. A volte può essere necessario caricare la variabile globale in un registro e utilizzarlo direttamente invece di utilizzare direttamente la variabile globale. Inoltre, le variabili globali possono essere ottimizzate ricorrendo alle strutture C. In sostanza, con le strutture, il compilatore genera il codice in maniera differente: la struttura, e ogni singola variabile, è referenziata in maniera indiretta mediante un puntatore in luogo ad un accesso diretto in memoria. Si utilizza a questo riguardo il Z-pointer, uno dei tre puntatori disponibili in AVR, che comprende i registri  R31:R30: sono utilizzati le istruzioni LDD e STD. Anche se a prima vista questo approccio è molto più dispendioso in termini di occupazione di memoria, questa soluzione apporta benefici se riusciamo a raggruppare in questa struttura tutte le nostre variabili globali.

Cicli iterativi

Un programma per un’applicazione embedded di solito è composto da una funzione main con un ciclo infinito, e, nella maggior parte dei casi, non è chiamata nessuna funzione in maniera diretta, ma solo su un evento asincrono. In ogni caso, ogni programma usa, com’è ragionevole, una serie di cicli iterativi. Il ruolo di un ciclo iterativo è quello di far ripetere, per un numero prefissato di volte, una certa sequenza di operazioni fino a quando è rilevata una condizione di termine, che può essere valutata prima dell’inizio del ciclo o al termine di almeno una sequenza di operazioni. I cicli hanno differenti usi, per esempio esistono cicli infiniti, senza nessuna condizione di uscita, utilizzati semplicemente per aspettare un evento. Questi particolari cicli sono più efficienti se sono costruiti utilizzando il  costrutto for( ; ; ) { }, come mostra il listato 5.

for( ; ;)
{
/* This is an eternal loop*/
}
// ?0001:RJMP ?0001 ; Jump to label
Listato 5 - Ciclo infinito

Lo standard del C prevede tre differenti cicli iterativi,  il listato 6 pone in evidenza i tre differenti cicli sulla stessa espressione.

__C_task void main (void)
{
char counter8 = 0;      /* Initialize counter8 */
                        // LDI R16, 0 ; Initialize R16
                        /*** Esempio: ciclo while ***/
        while ( counter8++ < 5 )
                        // ?0000:
                        // MOV R17, R16 ; Copy Contents of R16 to R17
                        // INC R16 ; Increment R16
                        // CPI R17, 5 ; Compare R16 with Immediate
                        // BRNE ?0000 ; Branch if Not Equal to Zero
                 counter8 = 5;
                        // LDI R16, 5 ; Initialize R16
                        /*** Example: do...while Loop ***/
         do
{
} while ( —counter );
                        // ?0001
                        // DEC R16 ; Decrement R16
                        // BRNE ?0001 ; Branch if Not Equal to Zero
                        /*** Esempio: ciclo for ***/
for ( counter8 = 0; counter8 < 5; counter++) { }
                        // ?0002:
                        // MOV R17, R16 ; Copy Contents of R16 to R17
                        // INC R16 ; Increment R16
                        // CPI R17, 5 ; Compare R16 with Immediate
                        // BRCC ?0003 ; Branch if Cary Flag Cleared
                        // INC R16 ; Increment R16
                        // RJMP ?0002 : Relative Jump
                        // ?0003:
}
Listato 6 - Ciclo in C

Il primo compito del ciclo while è di valutare prima l’espressione true o false (zero o non-zero). Se l’espressione è vera, l’istruzione statements è eseguito e immediatamente dopo il  program counter si posiziona alla testa del ciclo per rivalutare l’espressione. A questo punto la sequenza delle operazioni è ri-eseguta fino a quando non è valutato, positivamente, l’espressione di fine ciclo. Un ciclo di do…while è leggermente differente di quello while. In questo caso, la condizione è valutata al termine del ciclo: il ciclo è eseguito almeno una volta al contrario del ciclo while dove la sequenza delle istruzioni all’inter no del ciclo potrebbero mai essere eseguite. L’ultimo ciclo è l’espressione for.. Questo ciclo è probabilmente quello più utilizzato. E’ bene ricordare che con il ciclo di tipo
do{ }while(expression)
in genere si genera codice più efficiente rispetto ai cicli
while{ } e for{expr1; expr2; expr3).
Il listato 7 mostra codice generato con
do{ }while(expression).
Dal listato 6 si evidenzia che ogni ciclo viene eseguito cinque volte. Dal listato assembly equivalente è possibile notare che il  ciclo più efficiente è, come abbiamo scritto,
il do{ }while(expression)
con pre-decremento. Come regola generale è possibile, senz’altro, affermare che i contatori utilizzati in un ciclo sono più efficienti quando sono impiegati variabili di tipo pre-decremento o post-incremento perché l’istruzione di salto dipende dal flag di stato associato.

char counter = 100; /* Declare loop counter variable*/
// LDI R16,100 ; Init variable
do
{
} while(—counter); /* Decrement counter and test for zero*/
?0004:DEC R16 ; Decrement
// BRNE ?0004 ; Branch if not equal
Listato 7 - Ciclo do{ }white

Codice misto

La maggior parte dei compilatori permettono di inserire codice assembler all’interno di un programma C. L’utilità di codice di questo tipo è abbastanza intuitivo; infatti, in questo modo è possibile sfruttare le prerogative di moduli assembler (quali la velocità o la dimensione) per svolgere un lavoro specifico come per esempio la gestione efficiente di un’ISR o la gestione di dispositivi hardware. Chiaramente applicazioni di questo tipo dipendono molto dalla singola applicazione e devono essere adeguatamente progettate per sfruttare pienamente le prerogative di un microcontrollore quali  l’uso dei registri o delle zone dati: questo per non creare problemi alla restante parte del programma scritto in C. Per questa ragione è indispensabile leggere, per prima cosa, la documentazione associata del compilatore. I  listati 8 e 9 pongono in evidenza le scelte effettuate: la parte scritta in assembler risiede in una differente zona rispetto al programma scritto in C e, nel nostro caso, in una differente unità di compilazione per meglio intervenire sul codice e garantire  il suo necessario supporto.

#define F_CPU 16000000UL //16Mhz clock
#include <avr/io.h>
#include <util/delay.h>
//declare the assembly language spi function routine
extern void sw_spi(uint8_t data);
int main(void)
{
DDRB = 0x07; //set port B bit 1,2,3 to all outputs
while(1){
sw_spi(0xA5); //alternating pattern of lights to spi port
sw_spi(0x5A);
} //while
} //main
Listato 8 - Chiamata di funzione SPI in linguaggio assembly
#NAME SW_SPI
#include <avr/io.h>
.text
.global sw_spi
//define the pins and ports, using PB0,1,2
.equ spi_port , 0x18            ;PORTB
.equ mosi , 0                   ;PB2 pin
.equ sck , 1                    ;PB0 pin
.equ cs_n , 2                   ;PB1 pin
//r18 counts to eight, r24 holds data byte passed in
sw_spi: ldi r18,0x08            ;setup counter for eight clock pulses
cbi spi_port, cs_n              ;set chip select low
loop: rol r24                   ;shift byte left (MSB first); carry set
;if bit7 is one
brcc bit_low                    ;if carry not true, bit was zero, not one
sbi spi_port, mosi              ;set port data bit to one
rjmp clock                      ;ready for clock pulse
bit_low: cbi spi_port, mosi     ;set port data bit to zero
clock: sbi spi_port, sck ;sck -> one
cbi spi_port, sck ;sck -> zero
dec r18                         ;decrement the bit counter
brne loop                       ;loop if not done
sbi spi_port, cs_n              ;dessert chip select to high
ret ;
.END
Listato 9 - Modulo SPi

Le relazioni tra i  due mondi, C e Assembly, sono risolte al momento della compilazione. È bene ricordare che molto dipende dall’ambiente di compilazione utilizzato. Esiste un’altra possibilità, cioè quella di affidarsi alla modalità che è definita come “inline assembly”. Questa permette di inserire, direttamente nel codice, istruzioni assembler. In questo modo è possibile inserire istruzioni e registri come mnemonici, costanti e variabili: insomma l’utilizzo è simile all’uso diretto di un assemblatore senza particolari controlli in fase di compilazione. L’aspetto negativo di maggior rilievo è sicuramente la manutenibilità del codice e un approccio del genere ha degli impatti non indifferenti sulla ottimizzazione del codice. Utilizzare la modalità inline deve essere fortemente giustificata perché si sacrifica l’ottimizzazione del codice, il listato 10 mostra un piccolo esempio.

asm(“in %0, %1” : “=r” (value) : “I” (_SFR_IO_ADDR(PORTD)) );
Listato 10 - inline assembly

Una risposta

  1. Andrea Salvatori IU6FZL Andrea Salvatori IU6FZL 27 agosto 2018

Scrivi un commento

EOS-Academy

Ricevi GRATIS le pillole di Elettronica

Ricevi via EMAIL 10 articoli tecnici di approfondimento sulle ultime tecnologie