Introduzione

Le CPU con architettura ARM sono utilizzate in milioni di dispositivi embedded, come microcontrollori, router, schede wifi, telefoni cellulari, smartphones, palmari, console portatili (GameBoy Advance, Nintendo DS), lettori DVD/DivX, lettori MP3/MP4 e tanti altri…

Per brevità indicherò con ARM tutta la categoria, evitando di specificare ogni volta il tipo di processore, nonostante questa terminologia non sia corretta in tutti i casi. Infatti con il termine ARM si identificano tante cose, tra cui l’azienda che detiene i brevetti, il nome della microarchitettura, il nome del set di istruzioni a 32 bit.

I processori ARM si differenziano dai più noti x86 (nda. i classici processori per PC) per diverse caratteristiche.

Innanzitutto, la grossa distinzione è data dal tipo di architettura interna: i processori ARM sono di tipo RISC, mentre i processori x86 sono di tipo CISC. Rimando a Wikipedia per una descrizione dettagliata delle differenze tra i due tipi di architettura. Per i nostri scopi basta soltanto porre l’accento sul fatto che i processori di tipo RISC tendono a implementare istruzioni molto semplici, ma che vengono eseguite ad altissima velocità (tipicamente 1 solo ciclo di clock), mentre i processori CISC generalmente implementano una vasta gamma di istruzioni complesse che richiedono anche diverse decine di cicli di clock per essere eseguite. Queste istruzioni complesse in genere servono a facilitare  il compito del programmatore di compilatori, a discapito dell’efficienza e della semplicità interna del chip.

Caratteristiche generali

Set di istruzioni ortogonale

I processori ARM dispongono di 31 registri interni, ma in ogni momento sono visibili solo 16 di questi, che vengono selezionati in base alla modalità di funzionamento (modalità Utente, modalità Interrupt, modalità System, ecc…). Ciascuno di questi registri può essere utilizzato indifferentemente come registro sorgente o come registro destinazione di qualunque istruzione. Ad esempio è possibile eseguire una somma del contenuto dei registri 3 e 4 e inserire il risultato nel registro 2 in questo modo:

add r2, r3, r4

Questa flessibilità nell’uso dei registri consente la scrittura di programmi molto compatti. Infatti, a differenza di molte architetture CISC nelle quali ogni calcolo avviene su un singolo registro detto accumulatore, il cuore delle architetture RISC, come quella ARM, risiede proprio nell’ortogonalità del set di istruzioni che, seppur ridotto e semplificato, espone un’ampia flessibilità al programmatore Assembly. 

Esecuzione condizionata

Un’altra caratteristica molto apprezzata dei processori ARM risiede nella cosiddetta esecuzione condizionata. Quasi tutte le istruzioni dei processori ARM possono essere accompagnate da un codice di controllo, chiamato condition code, che modifica l’esecuzione dell’istruzione alla quale viene applicato. L’utilità dell’esecuzione condizionata si nota immediatamente esaminando questo frammento di codice:

// Codice C
if (a < b)
{
        a = a + b;
}
else
{
        a = a - b;
}

che in Assembly diventa:

cmp Ra, Rb          ; confronta le due variabili
                    ; settando dei flags interni
                    ; in base al risultato
addlt Ra, Ra, Rb    ; esegue la somma se LT (Less than)
subge Ra, Ra, Rb    ; esegue la sottrazione se GE (greater or equal)

da notare come in questo pezzo di codice non siano presenti salti, che invece sarebbero necessari per indirizzare il flusso di programma verso i rami di codice corretti, nel caso in cui mancasse la possibilità di effettuare istruzioni condizionate.

In questo esempio  si mostra come in soli 3 cicli di clock l’architettura sia in grado di eseguire un confronto, un’operazione di somma e un’assegnazione… 

Il Barrel Shifter

Supponiamo di dover scrivere un pezzo di codice che esegua questa operazione:

a = b + c / 32;

In questo caso dobbiamo effettuare una divisione per 32 (che corrisponde ad uno shift aritmetico a destra di 5 bit), una somma e un’assegnazione. In teoria sarebbero 3 istruzioni separate, ma grazie alla potenza dell’architettura ARM riusciamo a scrivere tutto questo in una sola istruzione, che viene eseguita in 1 singolo ciclo di clock, vediamo come:

add Ra, Rb, Rc ASR#5    ; ASR sta per Arithmetic Shift Right
                        ; #5 è il numero di bit per lo shift

Le estensioni DSP

Operazioni con saturazione

I processori ARM dalla versione ARMv5e in poi (come l’ARM946E-S presente nel Nintendo DS, in alcuni lettori MP3 Samsung e in moltissimi altri dispositivi) contengono alcune istruzioni votate al signal processing. Sono istruzioni che includono un controllo sulla saturazione del risultato e risultano molto utili nelle applicazioni multimediali come ad esempio mixer digitali, decoder MP3, filtri grafici come alpha blending, ecc…

Ad esempio il seguente frammento di codice in C:

a = b + c;
if (a > MAXINT)
{
    a = MAXINT;
}

viene reso in Assembly dalla singola istruzione:

qadd Ra, Rb, Rc

Istruzioni SIMD

Processori più recenti, dalla versione ARMv6 in poi, inoltre hanno delle istruzioni ancora più potenti che risultano estremamente utili quando si lavora con immagini e suoni. Supponiamo ad esempio di dover implementare un filtro grafico che calcoli la somma dei valori RGBA tra 2 pixel (un esempio simile si potrebbe fare nel caso di un filtro audio digitale che debba mixare diverse sorgenti audio su un sistema multitraccia).
I dati di colore ci vengono presentati sotto forma di numeri interi a 32 bit, strutturati in questo modo:

bit 0-7: canale R
bit 8-15: canale G
bit 16-23: canale B
bit 24-31: canale Alpha

In C scriveremmo qualcosa del genere (per chi non avesse padronanza con gli operatori binari, si rimanda al relativo articolo):

int R,G,B,A;   // risultato
int destinazione;
int R1, R2, G1, G2, B1, B2, A1, A2; // operandi
int colore1, colore2;

// estraggo i canali dal primo pixel
R1 = (colore1 & 0x000000FF) >> 0;
G1 = (colore1 & 0x0000FF00) >> 8;
B1 = (colore1 & 0x00FF0000) >> 16;
A1 = (colore1 & 0xFF000000) >> 24;

// estraggo i canali dal secondo pixel
R2 = (colore2 & 0x000000FF) >> 0;
G2 = (colore2 & 0x0000FF00) >> 8;
B2 = (colore2 & 0x00FF0000) >> 16;
A2 = (colore2 & 0xFF000000) >> 24;

// calcolo il risultato
R = R1 + R2;
if (R > 255) R = 255; // il massimo valore consentito è 255 per canale
G = G1 + G2;
if (G > 255) G = 255;
B = B1 + B2;
if (B > 255) B = 255;
A = A1 + A2;
if (A > 255) A = 255;

// impacchetto nuovamente i canali nel pixel di destinazione
destinazione = (A << 24) | (B << 16) | (G << 8 ) | (R << 0);

Queste 16 righe di codice (che in realtà corrispondono a circa una quarantina di operazioni elementari), possono essere rese incredibilmente compatte in ARM:

uqadd8 Rrisultato, Rcolore1, Rcolore2

questo calcolo estremamente complesso viene eseguito dal processore in un singolo ciclo di clock… sembra incredibile? 🙂

L’istruzione UQADD8 sostanzialmente effettua 4 somme da 8 bit in parallelo, una somma per ogni byte dei registri in ingresso, saturando i risultati al limite massimo per gli interi senza segno a 8 bit (cioè da 0 a +255). Esiste anche la variante QADD8 che effettua la saturazione con segno (quindi da -128 a +127)

Multiply Accumulate

Un altro esempio di istruzione DSP-like è l’istruzione MLA (Multiply and Accumulate). Questa istruzione viene pesantemente utilizzata nei motori di rendering 3D e in numerose altre applicazioni che richiedono il calcolo di espressioni polinomiali della forma Y = A1 * X1 + A2 * X2 + … + An * Xn. Queste operazioni sono estremamente comuni quando si trattano vettori e matrici (un esempio classico è il calcolo di una matrice inversa o il calcolo di un prodotto scalare tra 2 vettori).

Ad esempio questo frammento di codice in C:

y = a1 * x1 + a2 * x2 + a3 * x3;

viene reso in Assembly da queste poche istruzioni

mul R0, R1, R4
mla R7, R2, R5, R0
mla R0, R3, R6, R7

L’istruzione MUL è una normale istruzione di moltiplicazione, ciascuna delle istruzioni MLA successive eseguono il prodotto di due registri e sommano il risultato al contenuto del registro specificato nel primo parametro. Da notare come sia possibile specificare uno qualsiasi dei registri da R1 a R15 come parametri, i registri R13, R14 ed R15 sono utilizzati come registri speciali, rispettivamente come Stack Pointer, Link Register e Program Counter, quindi il loro utilizzo deve essere regolato in modo molto attento.

Obiettivo: 1 istruzione / ciclo macchina

Spulciando i reference documents di ARM ci si accorge di un dettaglio importante: molte delle istruzioni più complesse (come le istruzioni Multiply-Accumulate) impiegano un certo numero di cicli di clock per essere eseguite (nel caso specifico l’istruzione MLA impiega 3 cicli macchina sul core ARM9E-S, può variare in altre implementazioni) ma, grazie a particolari tecniche, è possibile avvicinarsi al valore medio di 1 istruzione per ogni ciclo macchina.
Com’è possibile?

Questo traguardo viene raggiunto tramite l’organizzazione interna dell’architettura, che si suddivide in subunità funzionali costituendo una cosiddetta pipeline di esecuzione. Esistono diverse possibili implementazioni della pipeline, ne mostriamo una soltanto a titolo esemplificativo:

 

5 stage pipeline

5 stage pipeline

 

 

Le 5 fasi (stage) della pipeline sono: Fetch, Decode, Execute, Memory, Write.

  • Lo stadio di Fetch del processore si occupa di recuperare dalla memoria la prossima istruzione da eseguire
  • lo stadio di Decode serve a predisporre i circuiti interni per l’esecuzione dell’istruzione precedentemente caricata (durante la precedente fase di fetch)
  • l’istruzione così decodificata passa allo stadio di Execute, dove vengono eseguiti effettivamente tutti i calcoli, il barrel shifting e il calcolo degli offset per le istruzioni di branch
  • successivamente l’istruzione passa allo stadio di Memory, che consiste nell’accesso ai dati (operandi) dalla memoria nel caso di istruzioni che operano in memoria (come istruzioni di load, o istruzioni con operandi fuori dai registri), oppure semplicemente lascia transitare l’esecuzione alla fase successiva, se l’istruzione corrente non prevede accesso in memoria (come le istruzioni che operano sui registri), oppure ancora serve per completare la fase di esecuzione per le istruzioni di moltiplicazione o di Multiply Accumulate (le istruzioni di moltiplicazione richiedono 2 fasi per essere completate, durante la prima fase vengono calcolati i prodotti parziali e durante la seconda fase vengono sommati dando il prodotto finale)
  • lo stadio finale, quello di Write, serve per riporre i risultati dell’operazione all’interno del registro di destinazione.

Internamente, il processore è a sua volta suddiviso in 5 stadi, ciascuno dei quali opera in maniera semi-indipendentemente dagli altri. Pertanto, mentre lo stadio di Execute sta effettuando dei calcoli, lo stadio di decode sta già preparando i circuiti per l’istruzione successiva, e lo stadio di Fetch ha già recuperato le prossime 3 istruzioni (quella in Execute, quella in Decode e quella corrente). Questa organizzazione a catena di montaggio consente l’esecuzione contemporanea di più istruzioni all’interno di un singolo ciclo macchina. Più precisamente, ciascuna istruzione può essere spezzata in 5 sottofasi, ciascuna delle quali, a sua volta, viene eseguita da uno dei 5 stadi del processore. In questo modo si massimizza l’efficienza di esecuzione sfruttando appieno tutte le parti del processore, ciascuna impegnata ad eseguire il proprio compito e successivamente a passare il risultato allo stadio successivo.

Grazie a questa strategia il processore è in grado di iniziare una nuova istruzione (in condizioni ottimali, ma ciò non è sempre possibile) ad ogni ciclo macchina, mentre in output fornisce il risultato dell’istruzione iniziata 5 cicli macchina prima. Superati quindi (nel caso ideale) i primi 4 cicli macchina, dal quinto ciclo in poi il processore è in grado di processare praticamente 1 istruzione per ogni ciclo.

La struttura a pipeline ha comunque degli svantaggi, ma per approfondire l’argomento vi consiglio di dare un’occhiata ad altre fonti più prettamente tecniche, seguendo i link che troverete a fine articolo.

Set di istruzioni multipli

I processori ARM si distinguono per la loro capacità di eseguire codice con set di istruzioni misti. Tipicamente un processore ARM moderno supporta fino a 4 set di istruzioni diversi (il numero e il tipo cambiano in base al modello): il set ARM a 32 bit, il set Thumb a 16 bit, il Thumb-2 misto a 16 e 32 bit e il set Java ByteCode (Jazelle) a 8bit.

Le caratteristiche principali dei vari set:

  • ARM: Consente la scrittura di codice molto denso, indicato per le sezioni di codice dedicate al calcolo intensivo (applicazioni multimediali in primis)
  • Thumb e Thumb-2: Sono set ridotti a 16 bit (il secondo include anche istruzioni a 32 bit) che consentono la scrittura di codice che occupa poco spazio, adatto per quei casi in cui il bus di sistema sia a 16 bit (come nel Nintendo DS), per ottimizzare i tempi di caricamento delle istruzioni dalla RAM (1 ciclo di sistema per caricare ogni istruzione a 16 bit, 2 cicli di sistema per le istruzioni a 32 bit)
  • Jazelle: Set di istruzioni Java, consente l’esecuzione rapida di bytecode Java, utile per l’implementazione di macchine virtuali Java particolarmente ottimizzate. Contiene un meccanismo intelligente di software fallback, per consentire al programma di mixare istruzioni Java eseguite in hardware con istruzioni Java interpretate via software in modo classico

In ciascun momento il processore può passare da una modalità all’altra tramite delle semplici istruzioni di salto (BX e BXJ).

Conclusioni

Spero di aver soddisfatto la curiosità di molti appassionati di architetture, ma soprattutto di aver stuzzicato il palato di quei programmatori un po’ smaliziati che volessero avvicinarsi al mondo della programmazione a basso livello su macchine RISC. Per ulteriori approfondimenti potete iniziare a spulciare questi link, dai quali io stesso ho imparato parecchio e che costituiscono una fonte molto vasta di informazioni, tecniche e riferimenti di vario genere:

http://en.wikipedia.org/wiki/ARM_architecture (informazioni generali sull’architettura ARM, in inglese)
http://en.wikipedia.org/wiki/Classic_RISC_pipeline (alcune informazioni sulla pipeline dell’ARM9)
http://infocenter.arm.com/help/topic/com.arm.doc.qrc0001m/QRC0001_UAL.pdf (set di istruzioni a 32 bit ARM e Thumb-2)
http://infocenter.arm.com/help/topic/com.arm.doc.qrc0006e/QRC0006_UAL16.pdf (set di istruzioni a 16 bit Thumb e Thumb-2)
http://infocenter.arm.com/help/index.jsp (tutto quello che c’è da sapere sui processori ARM parte da qui!!!)

Buona lettura!

11 commenti
  1. Antonio Barba
    Antonio Barba dice:

    @Marco:
    Risposta breve:
    Dipende dal cellulare…

    Risposta lunga:
    Se hai uno smartphone basato su Windows Mobile o su Symbian, allora basta semplicemente scaricare il devkit di prova dal rispettivo sito. Io ad esempio uso uno smartphone Nokia con sistema Symbian. Mi è bastato scaricare dal sito della nokia il sistema di sviluppo open source Carbide.c++ (basato su Eclipse), che è già preconfigurato per creare applicativi Symbian.
    Opzionalmente è possibile acquistare il devkit di categoria superiore, che include il software per collegare il cellulare tramite USB ed effettuare il debugging direttamente sull’hardware.

    In alternativa esiste anche una versione top di gamma, che include una piastra hardware che replica le funzioni di uno smartphone e consente il debugging e il tracing a basso livello, utile per chi sviluppa componenti hardware e sistemi operativi.

    Esistono piattaforme analoghe anche per Windows Mobile e Apple iPhone OS. Diverso è il discorso per i dispositivi Android, perchè il devkit prevede soltanto lo sviluppo in Java, quindi non si ha nessun accesso a livello di codice macchina.

    Se invece vuoi sviluppare per un dispositivo cellulare (non smartphone) la cosa si complica. Bene che vada, puoi programmarci solo in Java 2 Mobile Edition (J2ME). L’accesso al SO (e alle sue API) è mediato da opportune classi Java e in generale è impossibile inserire segmenti di codice in altri linguaggi (incluso ASM o linguaggio macchina compilato). L’accesso di basso livello, su questi dispositivi, è consentito soltanto tramite opportune devboards che possono essere acquistate soltanto da grosse compagnie che abbiano stipulato una partnership diretta con i produttori hardware.

    Link utile per Symbian: http://www.forum.nokia.com/

    Rispondi
  2. Vittorio Cionini
    Vittorio Cionini dice:

    Bravo! Ben fatto e interessante. Ho iniziato a sviluppare software (non si chiamava così) nel 1963 e ci sono dentro ancora oggi. Sono convinto che un ritorno alle fondamenta coincida con il generale desiderio di ridurre sprechi, consumi e fuffa. Vittorio

    Rispondi
      • Vittorio Cionini
        Vittorio Cionini dice:

        Si Alfonso nel 1963 mi sono laureato in ingegneria elettronica ramo calcolatori. Si programmava in linguaggio macchina. I linguaggi simbolici sono arrivati alcuni anni più tardi. Comunque ho iniziato su macchine “moderne” basate su circuiti a transistor. I miei docenti avevano costruito un calcolatore con diecimila valvole che ho visto funzionante all’Università di Pisa. Faceva un po’ caldo nella stanza e non si poteva parlare per il rumore ma ci lavoravano molte persone. Ciao Vittorio

        Rispondi
  3. Mirko Usai
    Mirko Usai dice:

    Ci sono arrivato casualmente cercando materiale per preparare l’esame di calcolatori…
    Complimenti per l’articolo, interessante e ben fatto! 😉

    Rispondi

Lascia un Commento

Vuoi partecipare alla discussione?
Sentitevi liberi di contribuire!

Lascia un commento

Il tuo indirizzo email non sarà pubblicato. I campi obbligatori sono contrassegnati *