ThumbEE: da ARM una virtual machine per tutti i linguaggi

Dopo una serie di articoli sull’Amiga faccio un breve salto indietro agli ARM, poiché avevo tralasciato un argomento per chiudere (temporaneamente, in attesa che la casa inglese tiri fuori qualche altro gioiello dal cilindro) il filone dedicato a queste architetture.

Sappiamo che, oltre all’architettura “base”, cioè all’ISA a 32 bit, che rappresenta la più generale e performante incarnazione, col tempo sono arrivate delle nuove ISA a 16 bit (e non solo) per aumentare la densità del codice. Parlo di Thumb e del suo successore, Thumb-2 (quest’ultimo ha aggiunto una vagonata di istruzioni a 32 bit).

ARM s’è anche cimentata con l’accelerazione Java, aggiungendo una nuova ISA (e modalità di esecuzione), Jazelle, che riprende buona parte degli opcode della Java Virtual Machine (JVM) eseguendoli in hardware.

Se consideriamo che il core di questi microprocessori è estremamente ridotto (circa 35 mila transistor per la versione 6 dell’architettura, e 50 mila per la versione 7 che troviamo nei Cortex-M0), i 12 mila transistor di Jazelle sono un numero decisamente elevato e che complica, tra l’altro, il decoder (che deve eseguire tutt’altro genere di istruzioni). Inoltre serve esclusivamente per la JVM.

Probabilmente è partendo da queste considerazioni, e dall’introduzione di Thumb-2, che ARM ha pensato bene di sviluppare una nuova modalità, chiamata ThumbEE (che è basata su Thumb-2 con qualche modifica e nuova istruzione), in modo da supportare qualunque virtual machine a un costo irrisorio, ed eseguendo al contempo codice “nativo” (codice ThumbEE, al contrario di bytecode Java).

Si tratta, quindi, di un prodotto decisamente diverso, anche come target (Jazelle credo rimarrà saldamente ancorata ai dispositivi mobile che offrono J2ME, grazie anche all’esiguo consumo di memoria), poiché prevede che il bytecode della virtual machine venga traslato “al volo” (tramite un JIT compiler) in codice ThumbEE, per poi essere eseguito, similmente a quanto avviene con la JVM utilizzata sui PC.

Da una parte siamo in presenza di codice “nativo”, ma dall’altra serve innanzitutto una prima fase di compilazione del codice, che richiede necessariamente l’allocazione di memoria addizionale rispetto a quella in cui risiede il solo bytecode della virtual machine, prima dell’effettiva esecuzione.

Una volta compilato, il codice è molto simile a quello eseguito in modalità Thumb-2, dalla quale il nuovo ambiente di esecuzione differisce per pochi, ma particolari e utili (dato l’obiettivo per cui è stata sviluppata), accorgimenti che riguardano il funzionamento di alcune istruzioni, la rimozione e l’aggiunta di altre.

Nel primo caso rientrano tutte le istruzioni denominate di load / store, a cui si aggiungono quelle “implicite” di POP e PUSH (per inserire e rimuovere elementi nel/dallo stack) e quelle di salto “tabellare” TBB e TBH (il salto avviene usando una tabella di byte o half-word, che verrà aggiunto al Program Counter).

La modifica consiste nel fatto che il registro utilizzato come “base” per la formazione dell’indirizzo viene controllato per verificare se risulta essere zero oppure no; nel primo caso l’esecuzione passa a un opportuno handler, mentre nel secondo si procede normalmente col calcolo dell’indirizzo, il load (o lo store) del dato, e il regolare completamento dell’esecuzione dell’istruzione.

Il vantaggio di questa semplice, ma geniale, soluzione è ovvio: in questo modo si intercettano le famigerate NullPointerException tanto care ai programmatori Java, dovute al deferenziamento di un puntatore nullo.

Personalmente, però, terrei sempre abilitata questa caratteristica anche con gli altri linguaggi; ad esempio con C / C++ potrebbe limitare notevolmente i famigerati segmentation fault o, peggio ancora, i bug intermittenti dovuti all’accesso (specialmente in scrittura) a zone di memoria “improprie”.

Per far fronte all’introduzione di altre istruzioni è stato necessario sacrificarne due disponibili in modalità Thumb, LDM e STM, per liberare un intero “slot” da 12 bit utilizzabile liberamente per la codifica dei nuovi arrivi:

In realtà è ancora possibile eseguire il load o lo store di più registri allo stesso tempo, sfruttando le apposite istruzioni ARM (che tra l’altro sono anche più flessibili), e quindi passando alla codifica a 32 bit introdotta con Thumb-2 allo scopo. Si tratta, in buona sostanza, di un raddoppio dello spazio (32 bit anziché 16) per utilizzare la medesima funzionalità, il che rappresenta un buon compromesso considerato l’obiettivo che ARM aveva bisogno di raggiungere.

Sempre sul campo dei “controlli” a runtime, è stata aggiunta una nuova istruzione, CHKA (i fan del Motorola 68000 saranno felici, visto che ricorda molto la CHK), che controlla se un registro supera il valore (intero senza segno) di un altro e, nel caso, invoca anche qui un apposito handler. E’ scontato l’ambito di utilizzo, visto il significato dello mnemonico usato (CHecK Array).

Molte aggiunte all’ISA hanno riguardato, invece, la sezione di load / store, con ben tre istruzioni per il primo tipo e una per il secondo. Lavorando con una virtual machine in genere al bytecode in esecuzione è associata anche una lista di costanti dai cui gli opcode vanno a “pescare” i valori già noti che servono loro.

Una nuova istruzione LDR serve proprio a questo: a prelevare una word (quindi 32 bit) usando R10 come registro base e un offset da 0 a 31 word (5 bit in tutto) per generare l’indirizzo di memoria. R10 risulta, pertanto, hard coded (si può usare soltanto questo registro), e richiama alla mente la specializzazione dei registri dell’ARM quando lavora in modalità Jazelle (per maggiori informazioni vi prego di rileggervi l’articolo di cui ho fornito il link prima).

Molto più strana è un’altra LDR che funziona sempre con un offset immediato, ma a 3 bit (quindi 8 word al massimo indirizzabili), solo che quest’ultimo viene sottratto a uno dei registri da R0 a R7 specificati (quindi l’indirizzo è nella forma Rn + offset, con n = 0..7). Dal manuale sembra che sia utile per operazioni sugli array, ma avendo lavorato con la virtual machine di Python non ne vedo l’utilità; probabilmente la JVM mostra un pattern che calza a pennello e sarà il motivo che ha spinto ARM ad aggiungerla.

A chiudere l’argomento load / store ci sono un paio di istruzioni, una per ogni tipo, che riguardano l’accesso (anche in scrittura, quindi) al cosidetto frame. Si tratta di una struttura (che generalmente si trova nello stack, ma in CPython, ad esempio, vengono allocati appositi blocchi di memoria) che conserva tutti i dati relativi a una chiamata a funzione (o metodo).

Una breve spiegazione si trova qui, mentre di seguito trovate un’immagine dallo stesso link che ne mostra il funzionamento:

Come si può vedere, nello stack frame sono memorizzati l’indirizzo di ritorno (al chiamante), i parametri che sono stati passati, e le variabili locali definite. Le istruzioni LDR e STR studiate allo scopo utilizzano il registro R9 (quindi anche qui troviamo una “specializzazione”) per indirizzare lo stack frame, a cui viene sommato un offset per selezionare fino a 64 word (6 bit di valore immediato).

Le innovazioni di ThumbEE terminano con un gruppo di nuove istruzioni che riguardano gli handler, che abbiamo già incontrato per quanto riguarda il controllo sui puntatori nulli e l’istruzione CHKA, ma che meritano a questo punto una breve spiegazione prima di continuare.

Per handler (o “gestore”) s’intende una funzione di callback che viene chiamata quando si verifica una particolare condizione, come possono essere le due di cui sopra. All’invocazione la CPU provvede, pertanto, a “congelare” lo stato dell’esecuzione, conservando, eventualmente, nel registro di link (LR in Thumb, R14 nell’ARM) l’indirizzo dell’istruzione “chiamante” e saltando poi all’apposito codice di callback che provvederà a smaltire la richiesta.

Nella modalità ThumbEE esiste una tabella di puntatori che conserva gli indirizzi di tutti gli handler da utilizzare; ovviamente due sono assegnati rispettivamente per l’intercettazione di puntatori nulli, e per il controllo degli indici; gli altri sono, invece, richiamati in maniera programmatica tramite quattro istruzioni che specificano l’indice dell’handler da richiamare.

Queste quattro istruzioni sono suddivise in due gruppi, con due istruzioni per chiamate a handler con (HBP, HBLP) o senza (HB, HBL) parametro. Le versioni con la “L” provvedono a conservare nel registro di link (LR, R14) l’indirizzo dell’istruzione successiva (quindi prevedono il ritorno alla chiamata), mentre quelle senza si limitano a invocare l’handler.

Le istruzioni senza parametro prevedono l’uso di un valore immediato a 8 bit, quindi permettendo di richiamare fino a 256 handler diversi (sempre facendo riferimento alla tabella degli handler del ThumbEE). Non è difficile immaginare, pertanto, che a ogni handler possa corrispondere la gestione di un determinato bytecode.

Infine le due con parametro restringono a 5 bit l’indice dell’handler (riducendo l’accesso ai soli primi 32 della suddetta tabella), ma permettono di specificare un valore immediato a 3 bit che verrà memorizzato nel registro R8 (altra specializzazione simil-Jazelle), che sarà pertanto a disposizione dell’handler.

Personalmente avrei preferito che fossero selezionabili ulteriori 32 handler, anziché rimappare i primi 32, per una maggior flessibilità (al limite si potevano far coincidere i puntatori), ma non ho trovato nessuna informazione a riguardo; assumo in ogni caso che ci sia stata una qualche ragione tecnica dietro a questa scelta.

Con l’introduzione degli handler è possibile immaginare alcuni scenari d’uso. Un primo in cui, ad esempio, a ogni bytecode viene generato un opcode di chiamata a handler (con eventualmente a seguire alcuni parametri), quindi in esecuzione ci sarà rigorosamente una sfilza di handler eseguiti; si tratta di una soluzione estremamente semplice per implementare rapidamente una virtual machine.

Uno scenario più complesso, ma decisamente più efficace, può prevedere, invece, una compilazione in istruzioni native ThumbEE per i bytecode più comuni, relegando agli handler la gestione di quelli meno comuni e più complessi (similmente a Jazelle, che implementa soltanto gli opcode più “gettonati”).

Questa soluzione garantisce prestazioni mediamente superiori, a fronte di una maggior complessità del compilatore JIT e consumo di memoria (a causa del maggior numero di opcode generati per un determinato bytecode).

Per il resto lascio spazio all’immaginazione dei lettori. Personalmente ho trovato interessante questa nuova modalità perché permette di implementare numerose virtual machine non strettamente legate a Java.

Pensando a CPython, avrei comunque notevoli difficoltà a realizzarne un porting per ThumbEE, in quanto il codice del loop principale che esegue i bytecode è decisamente diverso e richiederebbe, di fatto, una VM nuova di zecca.

Ciò non toglie che il lavoro fatto da ARM è ammirevole, specialmente alla luce della semplicità (e, penso, al bassissimo uso di transistor rispetto a Jazelle) con cui è stata realizzata la nuova modalità ThumbEE. Come ho già detto in passato, è un altra, preziosa, freccia che mette a disposizione per questa splendida architettura.

Press ESC to close