Riflessioni sull’architettura ARM

In questa rubrica abbiamo parlato estensivamente dell’architettura ARM, uno dei microprocessori RISC più diffusi e indiscutibilmente destinato ad avere un ruolo sempre più di primo piano grazie a un ottimo compromesso fra prestazioni, costi e consumi.

Dalla prima (sostanzialmente un prototipo) e seconda versione dell’architettura a 26 bit, che l’ha lanciata, siamo passati all’analisi della terza, che ha introdotto i 32 bit, la quinta che ha fornito delle estensioni DSP, e infine la sesta che ha portato con sé quelle SIMD.

Non abbiamo, però, mai parlato della quarta e ciò appare molto strano, perché una qualche innovazione avrà pure dovuto portarla per giustificare la sua esistenza. Inoltre sembrerebbe che ARM, dopo una prima fase di “assestamento”, sia andata avanti a colpi di “aggiunte” (sostanzialmente nuove istruzioni).

In realtà “sotto la scocca” c’è molto di più di quanto appaia grossolanamente a prima vista…

Le modifiche apportate sono di diverso tipo e possiamo classificarle, per grandi linee, in quattro aree (a volte sovrapponibili):

  • miglioramenti all’ISA (Instruction Set Architecture)
  • aggiunta di nuovi coprocessori
  • introduzione di nuove ISA
  • cambiamenti all’implementazione dell’architettura

I miglioramenti all’ISA riguardano l’aggiunta di nuove istruzioni e nuovi flag nel registro di stato. Rientrano qui, pertanto, le nuove istruzioni DSP e SIMD, l’introduzione rispettivamente del flag Q e dei flag GE, più una manciata di nuove istruzioni (estrazione ed estensione dei dati, shift di valori a 64 bit, ecc., di cui abbiamo già parlato).

Cominciamo col dire che già l’ARMv2 aveva introdotto due nuove istruzioni (SWP e SWPB), che consentivano di scambiare il contenuto di una word (32 bit) o di un byte di un registro con una locazione di memoria, nel classico ciclo di read-modify-write col bus bloccato (locked) per eseguire delle transazioni atomiche con la memoria. Utili per implementare semafori o assicurarsi che altri dispositivi periferici non avessero modificato il dato subito dopo la lettura da parte della CPU.

Queste istruzioni sono state deprecate nella v6 (quindi destinate a sparire in futuro), in quanto ne sono state introdotte delle nuove (LDREX e STREX) che provvedono a marcare la parte di memoria (fisica; non virtuale) interessata con accesso esclusivo soltanto nel caso in cui questa sia classificata come condivisa, migliorando quindi le prestazioni del sistema (le SWP e SWPB bloccano del tutto e “ciecamente” l’accesso alla memoria, comprese le cache, fino alla fine dell’operazione).

Una grossa mancanza dell’ARM fino alla v3 è stata l’impossibilità di poter leggere e scrivere half-word (16 bit), per cui si doveva leggere sempre una word (32 bit), ed eseguire poi un’opportuna istruzione di shift o di and per estrarre i 16 bit interessati. La v4 aggiunge apposite istruzioni allo scopo, e un altro paio che permettono di leggere byte o half-word, estendendoli poi con segno a 32 bit.

Un’altra notevole limitazione è stata rappresentata dal fatto che tutti gli accessi alla memoria dovevano essere allineati rispetto alla quantità di dati da leggere o scrivere (quindi a indirizzi multipli di 4 per dati a 32 bit, e di 2 per quelli a 16 bit), similmente a quanto avviene con diversi altri RISC. Il non rispetto di queste condizioni poteva portare a risultati imprevedibili (generalmente i bit bassi dell’indirizzo venivano troncati, ma questo dipende esclusivamente dall’implementazione).
La versione 6 elimina anche questo limite, introducendo appositi flag per il controllo e il funzionamento dell’accesso alla memoria. Adesso, infatti, è possibile specificare che l’allineamento debba essere rispettato, pena il sollevamento di un’apposita eccezione.
Inoltre si può anche decidere di accedere ugualmente a indirizzi non correttamente allineati, e in tal caso il processore si occuperà di leggere una o due locazioni di memoria contigue, estraendo poi il dato corretto (tramite apposite operazioni di shift e mascheramento) in maniera del tutto trasparente alle istruzioni di load/store (ma con una penalizzazione in termini prestazionali se sono richiesti due accessi; in particolare per la scrittura).
Da notare che rispetto ad architetture come x86 si è proceduto in maniera diametralmente opposta. Infatti queste hanno sempre permesso, fin dall’8086, l’accesso a dati disallineati (che hanno, quindi, richiesto uno o due accessi alla memoria), e soltanto con le ultime incarnazioni (AMD64, se non ricordo male) è stata aggiunta la possibilità di sollevare eccezioni in caso di allineamento non rispettato.

Per quanto riguarda l’endianess, sempre con la v6 è stato introdotto un flag (E) nel registro di stato che, se impostato (tramite l’istruzione SETEND), specifica che tutte le operazioni lettura e scrittura di dati (il caricamento delle istruzioni è escluso da questo processo) saranno effettuate in big-endian anziché in little-endian (questo l’avevo già anticipato nel precedente articolo).
Esistono anche delle apposite istruzioni (REV, REVSH e REV16) per eseguire al volo scambio di byte (i 4 di una word) o di 16 bit (le 2 di una word), cambiandone quindi l’endianess. I programmatori di emulatori avranno già drizzato le orecchie, visto che accedere a dati con un’endianess diversa è un’operazione piuttosto comune. Comunque la gestione dell’endianess è in realtà ben più complicata (e flessibile), ma una trattazione più approfondita esula dallo scopo di questo pezzo.

La v6 ha migliorato anche la gestione delle eccezioni, che adesso possono anche essere vettorizzate (c’è un apposito flag, VE, che controlla questa funzionalità), c’è il supporto a interrupt a bassa latenza (tramite il nuovo flag FI), ed è anche possibile gestire meglio le interruzioni annidate (quando, cioè, si verifica un’eccezione mentre se ne sta già servendo un’altra) tramite le nuove istruzioni CPS, SRS, ed RFE.

In realtà anche la v4 ha introdotto una novità (sebbene non così rilevante) per quanto riguarda le eccezioni, ed è rappresentata dal fatto che è possibile spostare i vettori dal classico indirizzo 0 (fino a 0x1F) alla zona alta della memoria (per la precisione fra 0xFFFF0000 e 0xFFFF001F).
Ciò è utile perché all’indirizzo 0 in un sistema può essere presente indifferentemente ROM o RAM, coi relativi problemi che ciò comporta (sicurezza che nessuno possa alterare i vettori nel primo caso, flessibilità nel poterlo fare nel secondo).

Infine anche la v5 dà un suo contributo all’estensione dell’ISA, aggiungendo un’istruzione (CLZ) per il conteggio del numero di bit a zero che si trovano a sinistra; oppure, se vogliamo leggerla diversamente, quanti bit sono presenti a sinistra prima che si incontri un bit posto a 1. Utile per implementare divisioni, normalizzare i dati o per calcolare la magnetudo di un dato.
A partire dalla v5 è anche presente un’istruzione per generare un’eccezione di tipo breakpoint (BKPT). Utilizzabile, quindi, da debugger (totalmente in software) per interrompere l’esecuzione del codice quando si arriva a un preciso indirizzo.

A meno di qualche dimenticanza, la carrellata sulle modifiche all’ISA finisce qui, e come sempre si dimostra la più appariscente, anche perché i programmatori tendono sempre sbirciare i datasheet a caccia delle nuove e mirabolanti istruzioni che sono state aggiunte, scervellandosi per il possibile impiego nel loro codice.

Non meno importante è stato, invece, il supporto che è stato aggiunto per i coprocessori (fino a 16), che è avvenuto nell’ARM con la seconda versione dell’architettura. Per coprocessore si è storicamente inteso un componente periferico, quindi esterno alla CPU, deputato alla gestione o implementazione di una ben precisa caratteristica.

Il fatto che questa periferica fosse esterna al core aveva due grossi vantaggi. Il primo è che il core non veniva appesantito con funzionalità che potevano essere usate soltanto in alcuni ambiti applicativi (ad esempio le MMU, oppure le FPU). Il secondo è che l’evoluzione del core e delle periferiche poteva andare avanti in maniera indipendente (ad esempio, si potevano realizzare FPU più performanti, ma sempre pienamente compatibili col processore).

Ovviamente lo svantaggio era che, essendo esterni, il protocollo di comunicazione fra core e coprocessore comportava una penalizzazione a livello prestazionale. Col passare del tempo, e l’integrazione di sempre più transistor, i coprocessori sono stati via via “assimilati” dal core, con indubbi vantaggi su questo fronte.

Per ARM sono stati aggiunti alcuni coprocessori a cui è stata demandata la gestione della memoria (MMU), del sistema e il calcolo di valori in virgola mobile (FPU). Recentemente, con la versione 7 AKA Cortex-A8 (e successori) è stato introdotto un coprocessore (chiamato NEON) appositamente dedicato per le operazioni SIMD; questo è soltanto un accenno, perché al momento mi vorrei fermare alla versione 6 dell’architettura.

Il coprocessore 15 (System Control Coprocessor) è il primo che è stato aggiunto, e si occupa di implementare i meccanismi tipici dell’MMU. Quindi traslazione dell’indirizzo virtuale in quello fisico, protezione della memoria e restrizione dell’accesso a determinate aree. Con l’introduzione di memorie cache nel core, è stato delegato a esso anche il loro controllo (in particolare per mantenere coerente l’accesso alle zone di memoria mappate come I/O, e il DMA).
Infine è stato esteso per supportare fino a 128 processi e gestire il loro context switch. Visto che c’era ancora spazio, sono stati aggiunti anche dei registri per l’identificazione della CPU e di alcune sue caratteristiche.

Il Vector Floating-Point (VFP) è, invece, il coprocessore deputato al calcolo dei valori in virgola mobile. In realtà dal nome si evince che la sua capacità non è limitata a singoli valori, ma anche a vettori che possono includere fino a 8 elementi per valori a singola precisione, e fino a 4 per quelli in doppia precisione (per le versioni che la implementano).
Si tratta, quindi, di una sorta di unità SIMD abbastanza flessibile, che mette a disposizione 32 registri in singola precisione, oppure 16 a doppia precisione (utilizzando due registri contigui). Poiché ogni coprocessore negli ARM può avere fino a 16 registri, la VFP ne utilizza ben due (il 10 e l’11).

Infine la v6 ha introdotto un’altra interessante caratteristica, chiamata Debug Architecture (coprocessore numero 14), con la quale ha implementato delle funzionalità di debug chiamate EmbeddedICE, utilizzate per il debug e il monitoraggio dell’applicazione, con supporto hardware a breakpoint e watchpoint, e il controllo di appositi eventi di debug (fra i quali i precedenti).

Passando alle ISA (sì, proprio al plurale), a quella “normale” / di default che prevede istruzioni con opcode di lunghezza fissa a 32 bit, ne sono state ovviamente introdotte altre (altrimenti non staremmo qui a parlarne) due, denominate rispettivamente Thumb e Jazelle.

Gestire nuove ISA significa che al processore sono state aggiunte nuove modalità di esecuzione, per le quali sono previste apposite sezioni di decodifica delle istruzioni nel loro formato (che è chiaramente diverso; e anche molto, per la verità). Se la cosa vi fa storcere un po’ il naso o vi turba, non vi preoccupate: si tratta di roba già vista con gli x86, che fra modalità reale, virtuale, e protetta a 16, 32 e 64 bit, non è seconda a nessuno in questo campo.

Senza addentrarci nei dettagli (per i quali magari scriverò appositi articoli di approfondimento in futuro), spendo qualche parola per descriverne il funzionamento e le finalità per cui ARM Ltd ha optato per questa strada, che a prima vista sembra un po’ controcorrente.

La modalità Thumb è stata introdotta con la v4 per ridurre la dimensione degli opcode a 16 bit di lunghezza (sempre fissa, tranne per la sua evoluzione, la Thumb-2) permettendo quindi di risparmiare spazio poiché, a parità di algoritmo da implementare, in genere il codice è molto più compatto rispetto all’equivalente ARM con opcode tradizionali a 32 bit.
Questo senza dimenticare che meno RAM per il codice significa anche minor consumo di banda verso la memoria e/o le cache, quindi guadagnando qualcosa a livello prestazionale da questo punto di vista, anche se ARM continua a consigliare l’uso di codice ARM e non Thumb per ottenere il massimo risultato prestazionale (perché per ottenere lo stesso lavoro, tante volte sono richieste più istruzioni Thumb).

Jazelle, invece, con la J presente nel nome serve a richiamare Java alla mente, e quindi si tratta di una modalità (introdotta con la v5) che nasce appositamente per accelerare l’esecuzione dei bytecode generati dai compilatori per questo popolarissimo linguaggio e, quindi, implementare Java Virtual Machine (JVM) molto più performanti.
In questa modalità gli opcode hanno dimensione variabile in multipli di byte (non si chiamerebbero bytecode, altrimenti), col primo byte che indica il tipo di opcode, e gli eventuali seguenti che fanno parte di parametri addizionali.

Arriviamo infine ai cambiamenti all’implementazione dell’architettura, di cui spesso non si parla perché meno tangibili per i programmatori. Più precisamente si tratta di caratteristiche non o difficilmente controllabili a livello applicativo, ma delle quali si sente sicuramente l’influenza.

Mi riferisco in particolare all’introduzione delle cache di dati L1 e/o L2, delle cache TLB (per l’MMU), delle unità di predizione dei salti statiche e/o dinamiche (sì, sembra assurdo, ma ci sono anche negli ARM più moderni), della lunghezza della pipeline, del numero di core.
Tutta roba che “conta”, perché le prestazioni sono fortemente influenzate da esse (e non sono le sole caratteristiche; pensiamo, per fare un altro esempio, al tipo e alla velocità della memoria presente nel sistema).

Mi soffermo un attimo sulla lunghezza della pipeline, che è stata da sempre il pezzo forte di ARM, che coi soli 3 stadi delle prime versioni dell’architettura ha contribuito a garantire delle ottime prestazioni a fronte di una semplicità implementativa. Ebbene, con le ultime incarnazioni della v6 siamo arrivati a ben 9 stadi: il triplo!
Se da una parte questo ha contribuito a innalzare notevolmente le frequenze massime di lavoro delle CPU, dall’altra ha messo via via in crisi l’efficienza della pipeline, e ciò ha comportato l’introduzione di unità di predizione dei salti prima statiche, poi dinamiche, oppure miste, per cercare di mitigare gli effetti dello svuotamento della stessa.

Perché tanta enfasi sulla pipeline? Altri microprocessori sono passati da pipeline corte ad altre molto più lunghe, e a parte casi come il P4 (architettura NetBurst) col Prescott, nessuno s’è stracciato le vesti. Bisogna però ricordare che ARM col suo gioiello ha introdotto l’esecuzione condizionale delle istruzioni proprio per evitare lo svuotamento della pipeline, sfruttando anche il fatto che fosse corta.
Quindi in parte questo modello è stato vanificato sull’altare delle maggiori frequenze che, in ogni caso, garantiscono prestazioni mediamente migliori rispetto alle soluzioni con pipeline più corte.

A chiusura dell’articolo è d’obbligo tornare sul titolo, che sembra sia stato trascurato. Ho parlato di riflessioni, ma finora ne ho portate poche sparse fra le righe. In realtà prima di fare delle riflessioni bisogna avere dei dati su cui lavorare, ed è quello che ho fatto, riportandoli.

Avrete sicuramente notato che, nel corso degli anni, tanta carne è stata messa sul fuoco da ARM Ltd. Questo ha portato a un’architettura (quasi) sempre più efficiente e molto flessibile, ma anche… più complicata. Già questo termine farà storcere il naso ad alcuni, visto che richiama alla mente i famigerati CISC!

Non ho difficoltà ad ammettere che lo scopo era anche quello, e i riferimenti ad architetture come x86 non sono stati affatto casuali. Anzi, quest’articolo serve a focalizzare alcune cose ben precise su un argomento che in futuro (remoto, perché c’è ancora parecchio altro da dire) verrà trattato: il confronto fra RISC e CISC, per il quale ARM (assieme, in parte, ai PowerPC & POWER di IBM) avrà un ruolo centrale!

Press ESC to close