Il legacy di x86 & x64 – parte 3 (FPU x87)

L’FPU che Intel realizzò qualche anno dopo la commercializzazione dell’8086 condivide sostanzialmente lo stesso destino del processore, per il marchio d’infamia di essere il suo coprocessore matematico oppure per alcune scelte che furono fatte… 32 anni fa.

Contrariamente a quello che si potrebbe credere, e un mito da smentire subito, la decodifica delle sue istruzioni non risulta “complicata”, per quanto è stato analizzato nei precedenti articoli che hanno trattato questo tema per le istruzioni della CPU.

Infatti tutte le istruzioni x87 (con questo termine si indica l’FPU, che ha subito pochi cambiamenti dal capostipite, l’8087) spaziano in un ben preciso intervallo degli opcode: da D8 a DF esadecimale, pari a 8 opcode riservati allo scopo.

Ciò significa che è necessario analizzare soltanto i 5 bit più significativi dell’opcode a 8 bit, per capire immediatamente che ci si trova davanti a un’istruzione che va smistata al coprocessore. I 5 bit in binario si leggono 11011, che in decimale equivale a 27, il quale nella tabella ASCII corrisponde al famigerato carattere di escape, da cui la nomenclatura “codici di escape” per questi opcode.

Probabilmente questa semplicità e linearità sarà del tutto ignorata dai decoder presenti nelle più moderne implementazioni degli x86 (e x64), ma rimane un aspetto da sottolineare in quanto si distacca parzialmente dalla codifica pseudocasuale della tabella degli opcode delle altre istruzioni.

A seguire il byte dell’opcode si trova sempre quello del cosiddetto ModR/M (a partire dall’80386 a ciò può far seguito un ulteriore byte, detto SIB) che consente di specificare due operandi per l’istruzione; al momento non ci occuperemo di analizzare ModR/M (e SIB), rimandando eventualmente il tutto a un futuro articolo. Il primo è un operando in memoria oppure un registro, e il secondo un registro (fra gli 8 a disposizione) oppure un’estensione dell’opcode (sempre 8 possibili valori).

Senza scendere troppo nei dettagli di basso livello, la logica di ModR/M è un po’ cambiata con x87, rispetto a quanto normalmente si vede con x86. Il secondo operando viene sempre usato come estensione dell’opcode, quindi ogni opcode x87 specifica sempre almeno 8 istruzioni, per un totale di 64 possibili per l’FPU.

Il primo operando, invece, specifica un indirizzo in memoria (quindi segue la logica comunemente usata dalle altre istruzioni x86), o un registro (idem), oppure un’ulteriore estensione dell’opcode (oltre a quella sempre effettuata col secondo operando) con altre 8 combinazioni, che porta a un totale di 512 (64 * 8) possibili istruzioni x87.

Si tratta di possibilità esclusivamente teoriche, perché o si specifica un registro dell’unità in virgola mobile, oppure si fa uso dei 3 bit per aumentare l’insieme degli opcode a disposizione. Quindi l’una esclude l’altra.

Lo schema, come si può notare, è estremamente semplice, e permette di catalogare facilmente tutte le istruzioni in poche macrocategorie:

  • operando in memoria (64 istruzioni)
  • operando in registro (64 istruzioni)
  • nessun operando (512 istruzioni)

ricordando sempre che, per quanto detto prima, le ultime due sono mutuamente esclusive.

Inoltre nessun operando non vuol dire che l’istruzione corrispondente non ne faccia uso (come vedremo dopo, la maggior parte delle istruzioni usa degli operandi implicitamente), ma soltanto che nessuno dei due possibili operandi specificabili dal byte ModR/M viene usato per individuarne uno ben preciso col quale lavorare.

A ciò aggiungiamo anche il fatto che le istruzioni risultano generalmente ben ordinate. Con ciò intendo che, ad esempio, quelle di somma, moltiplicazione, ecc., che fanno uso di valori in virgola mobile utilizzano gli stessi “sub-opcode” (estensioni dell’opcode di cui abbiamo parlato prima) delle medesime, ma che operano con gli interi.

Siamo, insomma, ben lontani dalla codifica pseudocasuale delle altre istruzioni x86, per quanto, come già detto, molto probabilmente i moderni decoder ignoreranno tutto ciò, essendo “abituati” a estrarre istruzioni da flussi di opcode tutt’altro che ordinati.

Per completare l’analisi della decodifica delle istruzioni dell’FPU è necessario discutere brevemente del prefisso detto di WAIT o FWAIT. Si tratta di un byte (9B in esadecimale) che x86 usa per indicare esplicitamente che un’istruzione debba controllare se sono state generate delle eccezioni (dall’esecuzione di una precedente), e quindi gestirle (richiamando l’apposito handler), prima di procedere con la sua operazione.

In realtà quasi tutte le istruzioni x87 eseguono sempre questo controllo (e gestione), tranne un piccolissimo insieme (FNINIT, FNSTENV, FNSAVE, FNSTSW, FNSTCW, FNCLEX) che rientra nella categoria di quelle di “controllo” di alcuni aspetti dell’FPU. Quindi la norma è che il prefisso di FWAIT non venga usato, mentre in rari casi è necessario che preceda l’opcode della particolare istruzione di controllo.

Questo, però, vale soltanto se il processore esegue delle istruzioni x87, ma ciò non è sempre vero. Infatti nel flusso delle istruzioni da eseguire il processore può incontrare sia istruzioni x86 che x87, le quali possono anche essere eseguite “parallelamente” o comunque far riferimento a medesime locazioni di memoria.

In questi casi potrebbe succedere che se un’istruzione x87 fa riferimento a una locazione di memoria e genera un’eccezione, mentre quella seguente è un’istruzione x86 che può modificare la stessa locazione, e successivamente viene eseguita un’altra istruzione x87, quest’ultima, accorgendosi dell’eccezione presente (generata dalla prima), invoca l’apposito handler, ma questi non è più in grado di analizzare lo stato del sistema e provvedere, a causa delle modifiche apportate dall’istruzione x86 alla locazione di memoria usata anche dalla prima istruzione x87.

Per evitare questi scenari risulta, quindi, necessario sincronizzare opportunamente l’esecuzione delle istruzioni x87 e x86, inserendo nel flusso un prefisso WAIT/FWAIT nel punto in cui si vuole essere sicuri che le eccezioni eventualmente generate dall’FPU vengano processate correttamente prima di continuare.

Tolto il caso del prefisso WAIT/FWAIT (che si potrebbe benissimo considerare un’istruzione più che un prefisso; infatti come tale risulta classificata nel volume 2B “Instruction Set Reference” di Intel), finora non s’è visto nulla che si potesse classificare come “legacy” per l’FPU progettata da Intel per la sua famosa famiglia di microprocessori.

Bisogna, quindi, passare ad alcune scelte progettuali per tirare in ballo quest’appellativo. Scelte che, come già anticipato, risultano figlie del tempo in cui fu realizzato questo coprocessore (all’epoca era fisicamente separato dalla CPU), come pure in parte anche della famiglia di appartenenza (x86, appunto; o, meglio ancora, 8086).

La più importante è sicuramente quella di fornire l’accesso ai registri dell’FPU come stack anziché come modello “flat” / diretto (cioè selezionare un determinato registro), come avviene invece nella maggior parte delle altre FPU.

Questo significa che tutte le istruzioni fanno quasi sempre implicito riferimento al “top” dello stack (indicato come ST(0)) come primo operando, ed eventualmente col seguente (ST(1)) per il secondo operando, mentre il risultato finisce (quasi) sempre per diventare il nuovo top dello stack (con un sistema automatico di “rotazione” dei registri fisici dell’FPU).

Fortunatamente non è obbligatorio utilizzare esclusivamente i due valori / registri in cima allo stack, ma come secondo operando è spesso possibile specificare un qualunque altro registro oppure una locazione di memoria. Ci sono anche casi, come ad esempio per le sottrazioni, in cui il ruolo di top dello stack e dell’altro registro sono invertiti.

Non siamo, quindi, in presenza di una macchina a stack “pura” (che opera esclusivamente con la cima), ma di una sorta di ibrido che consente di migliorare (non di poco) l’efficienza dell’FPU sia come densità di codice che come velocità d’esecuzione.

Certamente siamo molto lontani dalle altre implementazioni, che consentono di specificare un qualunque registro per gli operandi sorgente e per quello di destinazione, molto familiari nel mondo RISC.

Si potrebbe pensare che, comunque, x87 presenti almeno il vantaggio della densità di codice rispetto ai RISC, potendo pure contare sull’indirizzamento a una locazione di memoria per il secondo operando. Questo è sicuramente vero, e non potrebbe essere altrimenti visto che parliamo di uno dei punti di forza della macrofamiglia CISC.

Però quest’efficienza si scontra con l’enorme inefficienza dovuta al non poter specificare liberamente su quali registri operare. In una macchina a stack il punto di riferimento rimane comunque la sua cima (e il successivo elemento), per cui eseguire operazioni su registri diversi richiede l’esecuzione di una speciale istruzione, FXCH, che consente di scambiare il top dello stack con un qualunque altro registro.

In soldoni, per simulare quanto fanno altre FPU coi loro registri, è necessario eseguire alcune istruzioni FXCH per caricare opportunamente la cima dello stack, eseguire l’operazione richiesta, e infine rimettere a posto lo stack con altre istruzioni FXCH.

A conti fatti il codice diventa molto più lungo (sono due byte per ogni istruzione FXCH), e anche molto più lento a causa dell’esecuzione di queste istruzioni addizionali.

Fortunatamente Intel e AMD hanno almeno risolto l’impatto sulle prestazioni, rendendo sostanzialmente “gratis” (senza alcun costo, grazie a operazioni di “register renaming” interne) le istruzioni FXCH, a partire dal Pentium (salvo Intel, che col P4 rimosse/limitò questa preziosissima caratteristica, poi ripristina di corsa coi successori, ma nuovamente del tutto rimossa dagli Atom). Rimane lo scotto dell’aumento dello spazio per il codice x87, che ovviamente non si può eliminare in alcun modo.

Una soluzione per migliorare questa situazione sarebbe stata quella di riservare uno dei codici di escape per specificare un ulteriore byte di opcode, similmente con quanto è poi stato fatto con 80286 (col famoso opcode 0F, in esadecimale, che ha permesso di aggiungere altri 256 opcode all’ISA).

In questo modo sarebbe stato possibile riservare 3 bit per un registro (ad esempio per il secondo operando sorgente), altri 3 per un ulteriore registro (ad esempio la destinazione), e i rimanenti due potevano essere utilizzati per aumentare ancora il numero di opcode a disposizione.

Il tutto conservando la possibilità di specificare una locazione di memoria o un registro per l’altro operando (ad esempio il primo operando sorgente), con una flessibilità maggiore dei RISC grazie al possibile indirizzamento diretto della memoria per uno degli operandi.

Col senno di poi, però, è facile pontificare e bacchettare. In realtà, e come dicevo, quella effettuata è una scelta figlia del tempo in cui è stato realizzato l’8086 (quindi prima ancora che si pensasse di dargli il coprocessore 8087): gli 8 opcode di escape furono implementati in modo da rendere semplice la gestione tanto dal processore quanto dal futuro coprocessore.

Inoltre la possibilità di usare un opcode per estendere ulteriormente la loro tabella, tramite il successivo byte, molto probabilmente era un concetto del tutto alieno per quell’epoca, e sarebbe arrivato parecchi anni più tardi, quando i “buchi” a disposizione da utilizzare per inserire nuove istruzioni erano ormai diventati così pochi da richiedere una soluzione tanto radicale (rispetto alla semplicità di design dell’ISA 8086).

Tornando al “legacy“, ma potremmo chiamarlo anche più propriamente “limite”, avere a disposizione soltanto 8 registri per l’FPU si è rivelato abbastanza penalizzante. La possibilità di accedere direttamente alla memoria ha mitigato in parte l’esigenza di avere più registri, al costo di codice più o meno denso e di un maggior consumo di banda verso la memoria.

In ogni caso il disagio permane, in quanto il codice che fa uso di virgola mobile in genere trae molto beneficio dalla presenza di più registri a disposizione (o della possibilità di poter manipolare più valori allo stesso tempo, con lo stesso registro).

Purtroppo il limite degli 8 registri è anche frutto della scelta che Intel fece con 8086 e il già citato byte ModR/M, che riserva soltanto 3 bit per ognuno dei registri che può specificare. E non potrebbe essere altrimenti: tolti i 2 bit per specificare il tipo dell’operando in memoria, in un byte rimangono  solamente altri 6 bit…

Anche qui, e col senno di poi (per chi aveva a disposizione delle sfere di cristallo), è possibile pensare a estensioni et similia per incrementare questo numero, ma al costo di stravolgere un progetto che in quel periodo ha goduto di una relativa semplicità di definizione dell’ISA come pure della sua implementazione.

Un’altra caratteristica che si può, almeno questa volta senza dubbio alcuno, classificare come legacy è quella di poter operare col tipo di dato packed BCD (di 10 byte / 80 bit). x87 consente, infatti, di lavorarci, ma esclusivamente caricando e memorizzando valori di questo tipo. Per essere più chiari, non esistono operazioni specifiche per i BCD, che possono soltanto essere letti dalla memoria, e in essa successivamente scritti.

Non si tratta di un limite o un problema, poiché alla lettura il dato viene convertito in virgola mobile a precisione estesa, che consente di conservare tutte le informazioni senza alcuna perdita di dati / precisione. Alla scrittura si può presentare il problema di un overflow, in quanto il dato a precisione estesa può risultare… troppo esteso per poter essere conservato in un packed BCD, ma non è un problema rilevante per l’FPU (dipende, piuttosto, dal programmatore, che non ha tenuto conto dei limiti di questo tipo).

Sempre in materia di legacy è d’obbligo citare il contorto meccanismo messo in piedi per poter accedere ai flag impostati dalle istruzioni di confronto, sulla base dei quali poi eseguire delle operazioni (ad esempio col classico salto su condizione).

E’ presente una sola istruzione, FSTSW, che consente di copiare nel registro AX il contenuto del registro di stato (a 16 bit) dell’FPU. Da qui in poi è possibile manipolare il contenuto di AX come si vuole per estrarre le informazioni che servono, ed eventualmente prendere delle decisioni.

Un “pattern” di utilizzo comune è rappresentato dalla successiva esecuzione dell’istruzione SAHF (altra istruzione legacy che abbiamo incontrato nel primo articolo di questa serie) per caricare negli 8 bit bassi del registro dei flag del processore (EFLAGS) il contenuto del registro AH (che conserva gli 8 bit alti della status word  appena letta dall’FPU).

A questo punto è possibile finalmente eseguire delle istruzioni di salto condizionato sulla base dei flag appena caricati, oppure eseguire altre istruzioni che fanno sempre uso dei flag (MOV o SET condizionali).

Un’apposita istruzione di salto condizionato specifica per l’FPU avrebbe fatto sicuramente molto più comodo e sarebbe stata di gran lunga più efficiente di questa diavoleria che è stata messa in piedi malamente. Dopo “appena” 30 anni qualcosa di simile è arrivato con Knights Corner (ex Larrabee) per la sua nuova unità SIMD, ma anche questa è un’altra storia…

Infine un ultimo appunto va dato alla mancanza di istruzioni specifiche per consentire la conversione a intero specificando direttamente il tipo di arrotondamento da eseguire. Per far ciò è necessario procedere a una trafila di operazioni che richiedono la modifica di un apposito registro in cui è conservato il tipo di arrotondamento da utilizzare, ed eventualmente il ripristino del precedente valore dopo l’operazione. Ma qui, più che di legacy, possiamo parlare di una mancanza da parte di Intel.

Mentre un grosso vantaggio che x87 conserva tuttora, nei confronti delle altre FPU (tranne quelle rare che supportano il formato a 128 bit), è la possibilità di eseguire operazioni nella già citata precisione estesa (16 bit per l’esponente e ben 64 per la mantissa), che consente di ottenere, come si può ovviamente immaginare, una precisione più elevata nei risultati dei calcoli.

Caratteristica decisamente utile, e a volte tutt’altro che scontata (per chi da x87 passa ad altro e si ritrova coi conti che non tornano), che ancora oggi può giustificare ampiamente l’uso di x87 anziché delle ben più veloci unità SIMD. Forse è anche per questo che l’FPU non è mai scomparsa dalla famiglia x86 da quando è stata integrata nel core, ed è presente persino in uno degli ultimi nati, il già citato Knights Corner.

D’altra parte, e come penso sia ormai chiaro, la sua implementazione non richiede grosse risorse (ad esempio sommatori, moltiplicatori, ecc., si potrebbero riciclare / condividere con le unità SIMD; ma qui, non essendo un esperto di microarchitetture, si ferma il mio pensiero).

La decodifica risulta relativamente semplice. L’esecuzione pure, a parte per alcune istruzioni complesse, come quelle trascendentali (su cui si potrebbe discutere: se ci sono si bacchetta il processore perché si devono implementare con microcodice; se non ci sono… lo si potrebbe bacchettare ugualmente… perché mancano). Infine il supporto ai BCD è circoscritto a due sole istruzioni, e il numero di registri da implementare è scarso (oltre agli 8 per i dati, sono presenti 3 registri di stato/controllo, 2 utili per l’handler delle eccezioni, e uno interno che memorizza l’opcode).

Dunque x87 lo si annovera sempre come legacy per x86 (tranne quando fa comodo, per la precisione estesa, come già detto), ma, da quanto analizzato, ritengo che si tratti comunque di ben poca roba, che alla fine non incide in maniera significativa nell’implementazione di un microprocessore di questa famiglia.

Press ESC to close