Il legacy di x86 & x64 – parte 2 (istruzioni 80186+)

L’8086 viene spesso assurto a sinonimo di peccato originale, che rappresenta quindi il massimo “danno” di cui tutte le future generazioni si dovranno fare carico, pagandone le conseguenze in termini di microarchitettura.

In realtà, e proseguendo nell’analisi delle istruzioni legacy della famiglia x86 (e x64) iniziata nel precedente articolo, i successori dell’8086 hanno introdotto parecchie istruzioni, anche molto più complesse di quelle esaminate e oggetto di lamentazioni.

Ciò non deve far pensare subito male. Aggiungere istruzioni è un processo naturale, perché il produttore del microprocessore non può evitare di analizzare la tendenza del mercato, cercando poi di rispondere in maniera adeguata.

Purtroppo certe scelte possono diventare difficili da sostenere molto più avanti, quando il mercato è nuovamente cambiato, perché in quest’ambito la retrocompatibilità è una caratteristica di primaria importanza (ma non intoccabile). Si tratta in ogni caso di una problematica comune ai produttori di processori.

L’80186 venne presentato nello stesso anno dell’80286, il 1982, ed entrambi introducono diverse istruzioni in user-space (il secondo anche e soprattutto in supervisor/kernel space, che non verranno discusse al momento).

Di queste sono poche a poter essere classificate come legacy: INS, OUTS, PUSHA, POPA, BOUND, ENTER, LEAVE. In compenso alcune risultano particolarmente complesse.

INS e OUTS fanno parte del vecchiume che fa capo alle periferiche mappate come “porte” in un apposito spazio d’indirizzamento, quello delle porte di I/O appunto, a cui è stata fornita la capacità di operare anche come “stringhe“. Permettono, quindi, di leggere blocchi di dati sequenzialmente forniti da una precisa porta, oppure scrivere una serie di dati sempre a una determinata porta.

Campionesse di complessità sono, però, le successive due istruzioni, che servono rispettivamente a memorizzare nello stack e a recuperare tutti i registri del processore (8 in tutto, con x86). Compiti che richiedono necessariamente di passare all’uso di microcodice per la loro esecuzione, bloccando la pipeline, a causa dell’elevato numero di operazioni (“basilari”; lasciatemi passare il termine) da compiere.

Si tratta di istruzioni relativamente comode poiché, utilizzando spesso delle routine che ne richiamano altre a loro volta, si presenta l’esigenza di dover salvare il contenuto dei registri prima della chiamata, per poi recuperarli dopo (giocando un po’ con lo stack nel caso fosse necessario conservare qualche valore di ritorno).

Il termine “relativamente” non è messo a caso, in quanto il più grosso problema, nonché barriera all’uso, di queste istruzioni è rappresentato dal dover operare sull’intero insieme dei registri, anziché su un sottoinsieme limitato esclusivamente a quelli che realmente ci serve che siano salvati e ripristinati.

Il concetto, insomma, era valido, ma la scelta implementativa si è rivelata infelice. Alcuni RISC (come ARM e PowerPC, ad esempio), che mettono a disposizione questa funzionalità, consentono, invece, di selezionare singolarmente i registri oggetto di PUSH e POP o, in generale, di load/store da/verso la memoria di più registri.

Ovviamente anche nel loro caso l’implementazione richiede il blocco della pipeline e l’esecuzione di microcodice per ottemperare al compito, il che dimostra come il concetto di legacy, di “pesantezza”, “complessità” di un’ISA sia abbastanza relativo: va bene se applicato ai “soliti noti”, ma si ignora o nasconde con quelli che vengono ritenuti campioni di virtù…

Basti pensare che i membri della famiglia POWER 4 (e molto probabilmente anche i successori) e PowerPC G5 internamente sono dotati di un RISC “ancora più RISC” per l’esecuzione effettiva delle istruzioni, le quali vengono convertite in una o più istruzioni più semplici, facendo ricorso in alcuni casi, come quello citato ad esempio, al microcodice per la loro esecuzione.

Se lo scenario richiama il lavoro fatto da AMD e Intel in questi anni… non è affatto casuale, perché il concetto è proprio lo stesso. Con buona pace dei puristi e di chi si scaglia contro x86 e compagnia che, alla luce di tutto ciò, avviene ormai per mera questione ideologica.

C’è da dire, per completare il discorso, che ARM ha deciso di togliersi questo fardello con la nuova architettura a 64 bit a cui sta lavorando, ARM64, nota anche come ARMv8. Infatti non esistono più istruzioni di load/store multiple (che operano su più registri; anche tutti), ma ne sono state introdotte un paio che consentono di operare soltanto su una coppia di registri.

Si è trattato, a mio modesto avviso, di una scelta obbligata perché, avendo a disposizione opcode a 32 bit, non sarebbe stato possibile definire un’istruzione che in soli 32 bit permettesse di selezionare qualunque sottoinsieme dei 32 registri dell’ISA (se non ricorrendo ad artifizi). La nuova implementazione, inoltre, non dovrebbe creare problemi alla pipeline per la sua esecuzione.

AMD ha pensato bene di anticipare i tempi con la sua architettura AMD64 / x64, rimuovendo del tutto PUSHA e POPA dal set d’istruzioni. D’altra parte è già difficile trovare applicazioni a 32 bit che ne facciano uso, per cui pensare di eseguire il push e il pop di tutti e 16 i registri del processore ogni volta sarebbe stato assolutamente controproducente per le prestazioni.

Tornando alle istruzioni legacy introdotte da 80186 e 80286, BOUND consente di controllare se un valore (in genere l’indice di un vettore) a 16 o 32 bit risulti compreso in un intervallo (i cui estremi sono recuperati da una locazione di memoria specificata). Un’apposita eccezione viene sollevata nel caso in cui la condizione non venga soddisfatta.

Anche qui, si tratta di un’istruzione introdotta nell’ottica di supportare meglio i paradigmi di programmazione che si stavano diffondendo. Il Pascal, adottato come linguaggio principe per introdurre alla programmazione (lo sarà fino ai primi anni ’90), ne avrebbe tratto giovamento.

L’implementazione, però, non è certo delle migliori a causa della scelta di sollevare un’eccezione. Ciò richiede la scrittura di un apposito handler di basso livello, introducendo problematiche relative al “recupero” dell’eccezione partendo dal punto in cui era stata sollevata.

Inoltre, mentre per un s.o. strettamente monotask / batch è plausibile l’installazione di un handler nel vettore delle eccezioni da parte di una singola applicazione, diventa una scelta insostenibile nell’ottica del multitasking o, in generale, del dover permettere l’accesso a uno spazio delicatissimo qual è la tabella degli interrupt, appunto.

A parte la (relativa, in ogni caso) complessità dell’implementazione, sarebbe stato molto meglio utilizzare qualche flag (ad esempio quello di overflow) per segnalare lo sforamento dei limiti, delegando interamente all’applicazione (tutto in user-space) la gestione di questa condizione.

Un errore simile lo commise, ben prima di Intel, Motorola col suo 68000, salvo poi introdurre col 68020 un’altra istruzione che non solleva eccezioni, ma imposta, per l’appunto, un flag. Rimangono, in ogni caso, istruzioni di una certa complessità che sarebbe meglio evitare di implementare. Alcuni RISC mettono a disposizione un controllo su un singolo limite (superiore; C-like), generando un’eccezione, ma si tratta di casi rari.

Forse anche per questo AMD, ancora una volta con x64, ha deciso di eliminare questa istruzione. Mentre Intel ha pensato bene di riutilizzare l’opcode rimasto libero nell’architettura a 64 bit, sfruttandolo come prefisso per implementare le nuove istruzioni di Larrabee / Knights Corner. Ma questa è un’altra storia…

L’ultima coppia di istruzioni, ENTER e LEAVE, sono anch’esse rivolte a supportare un’esigenza comune all’epoca, quella di sistemare lo stack e, in generale, l’ambiente di lavoro all’inizio dell’esecuzione di una routine (procedura e/o funzione). Di queste, LEAVE serve a rimettere a posto il lavoro fatto dalla prima o da equivalenti istruzioni e, data la (relativa) semplicità, risulta ancora utilizzata.

L’autentico mostro di complessità risulta, invece, la prima, ENTER, che si porta dietro anche due valori immediati, a 16 e 8 bit rispettivamente. Sinteticamente, conserva nello stack il valore del registro BP (concettualmente in letteratura viene denominato frame pointer), vi copia il registro dello stack (per il nuovo ambiente / frame d’esecuzione), esegue poi il push nello stack di un certo numero di frame pointer quanti sono i livelli di annidamento della routine (specificati nel valore a 8 bit), e infine sottrae il valore a 16 bit dallo stack pointer per fare spazio alle variabili locali.

Un lavoro immane, come si può ben capire, che si traduce in quella che si può tranquillamente considerare la più complessa istruzione (per lo meno per x86, e in user-space), introdotta per accelerare in hardware l’esecuzione di subroutine per i linguaggi che supportano il concetto di nested procedure (quindi non C & derivati).

Evidentemente alla Intel c’erano degli ingegneri che amavano i linguaggi di Wirth… Il che non dispiace, se non per il fatto che questa istruzione estremamente rara (mai vista finora) dev’essere implementata in tutti i processori x86. Stranamente AMD non ha calato la mannaia con x64 che, quindi, la supporta ancora anche nella modalità a 64 bit.

L’80386 ha introdotto ben poche istruzioni che possano essere classificate come legacy: CWDE, CDQ, che si occupano di estendere un valore da 16 a 32 bit, e da 32 a 64 bit.

In realtà sono state inserite parecchie altre istruzioni anche un po’ complesse, come quelle di caricamento “one-shot” di segmenti/selettori e offset, manipolazione dei singoli bit, ricerca del primo bit a uno, e di shift con inserimento di campi di bit.

In particolare queste ultime (SHLD e SHRD) svolgono un lavoro complesso, ma negli ultimi anni anche nei processori RISC sono state aggiunte parecchie istruzioni di questo tipo, perché poter gestire campi di bit è un’esigenza molto comune e particolarmente importante quando si ha a che fare con operazioni di codifica e decodifica dei dati (ZIP, RAR, GIF, PNG, JPEG, MP3, MPEG, H264, ecc., sono gli esempi più lo eloquenti).

L’80486 introduce poche altre istruzioni, come la BSWAP usata per scambiare i byte di un valore a 32 bit, in modo da favorire la manipolazione di dati in formato big-endian, quindi usata molto raramente (stranamente non nell’eseguibile del MAME; in altri… sì).

Altre istruzioni vengono messe a disposizione per un’implementazione efficiente di meccanismi di lock, semafori, mutex, ecc., da parte dei s.o.. Lo stesso avvenne col Pentium, e in generale con tutti gli altri successori.

Anche qui, si tratta di esigenze comuni che hanno visto pure i RISC arricchirsi di funzionalità simili. Al solito, ciò che poteva essere visto male, come il capestro messo sui soliti noti, in base alle necessità diviene una ghirlanda da usare per decorare il “collo” dei virtuosi…

Perché questa, alla fine, è la realtà che emerge dopo un’attenta analisi delle istruzioni legacy che per anni sono state additate come il male assoluto di cui l’architettura x86 avrebbe dovuto vergognarsi fino alla fine dei tempi.

Non v’è dubbio che alla luce del contesto storico e delle scelte poi effettuate anche dalla concorrenza, processori RISC in primis, il caso delle istruzioni legacy debba necessariamente essere ridimensionato.

Sì, non si può certo nascondere che il “marcio” sia presente, come abbiamo avuto modo di vedere, ma non ha l’enorme portata che le leggende metropolitane hanno consegnato alla storia.

Inoltre la strada intrapresa da x64, che ha fatto fuori parecchie di queste istruzioni, dimostra che la retrocompatibilità a tutti i costi non rappresenta un tabù intoccabile…

Press ESC to close