Sentiamo spesso ripetere che l’architettura x86 sia un pastrocchio e che si porti dietro una pesante eredità dal primogenito, l’8086 di cui abbiamo già discusso in un vecchio articolo che ha evidenziato alcune bizzarrie.
Legacy è il termine che viene generalmente utilizzato per definire l’insieme di queste caratteristiche vecchie e “contorte”, che nell’immaginario collettivo rappresentano delle catene che bloccano o castrano i processori che implementano quest’architettura, e persino la sua più giovane evoluzione, l’x64 di AMD (nota anche come x86-64, AMD64, o l’EM64T coniato da Intel, infine divenuto Intel 64) a 64 bit.
Parlare genericamente di limitazioni senza comprendere di quali elementi si tratti e delle implicazioni che ne derivano, non giova però a molto, se non ad alimentare le classiche leggende metropolitane o a far pesare un fardello magari molto più del dovuto.
Partendo proprio da quello che, per molti, rappresenta l’origine di tutti i mali, l’8086, possiamo delineare tre grosse categorie in cui inquadrare tutto ciò che classifichiamo come legacy: la mappatura degli opcode delle istruzioni, le istruzioni più complesse e raramente utilizzate (in cui potrebbe rientrarci anche l’FPU x87, di cui discuteremo appositamente), e i famigerati segmenti.
Il primo rappresenta, a mio modesto avviso, il principale responsabile della complessità di quest’architettura e che, dunque, ha creato più problemi a Intel, AMD, e in generale a chi si sia posto il problema di realizzare un’implementazione che risultasse non soltanto veloce, ma anche parca nei consumi e, magari, poco costosa (in termini di silicio impiegato).
In realtà è molto difficile poter conciliare tutti i desiderata, e l’ultimo in particolar modo, poiché per raggiungere elevati livelli prestazionali si è fatto ampio uso di transistor. Ne abbiamo già parlato in passato in due articoli, che trovate qui e qui, in cui vengono analizzate tutte queste problematiche e affrontato, quindi, il primo degli aspetti legacy di x86 e x64.
Le istruzioni più “contorte” alla fine non sono poi molte, e possiamo facilmente elencarle: IN, OUT, CBW, CWD, LAHF, SAHF, AAA, AAD, AAM, AAS, DAA, DAS, XLAT, INT, INT3, INTO, IRET.
Si occupano di gestire l’I/O (ma da parecchio si preferisce mappare le relative “porte” direttamente in memoria), estendere un valore da byte a word (16 bit) o da word a double word (32 bit), leggere e scrivere il byte dei flag, eseguire operazioni con valori BCD (obsoleto è il termine più appropriato), convertire un byte in un altro byte facendo uso di un’apposita tabella, e infine di richiamare (e ritornare da) un interrupt (da tempo sono state introdotte apposite coppie di istruzioni per richiamare velocemente API del sistema operativo).
Non molte, come dicevo, ma che creano problemi a un processore che ha bisogno di eseguire velocemente le istruzioni nella sua pipeline. La decodifica non è complicata, trattandosi di istruzioni di un solo byte (due in pochissimi casi; comunque il secondo byte è costituito sempre da un valore immediato), ma l’esecuzione richiede l’uso di microcodice, anche se non per tutti i casi.
Trattandosi di roba estremamente vecchia, è ormai inutilizzata o usata molto raramente, quindi si può sostanzialmente ignorare la loro esistenza, ma chi progetta un processore x86 o x64 non può certo farlo. Non mi occupo di microarchitetture, per cui non posso valutare l’impatto che possono avere a livello implementativo (anche perché le implementazioni possono essere diverse), ma francamente non credo che possano rappresentare un enorme collo di bottiglia che tarpi le ali alle prestazioni e/o ai consumi del processore.
Avendo sviluppato emulatori in passato, mi sento in dovere di spezzare una (piccola) lancia in favore delle istruzioni LAHF e SAHF, poiché poter accedere velocemente ai flag del processore risulta un’operazione di vitale importanza per le prestazioni generali dell’emulazione.
Purtroppo queste istruzioni presentano due pecche; la prima è che non si tratta di un’istruzione generale, in quanto fa uso del solo registro AH allo scopo, che tra l’altro è uno dei registri high (soltanto con quattro degli 8 registri a 16 bit di 8086 è possibile accedere al loro byte alto) che rappresentano il più grosso elemento di asimmetria / non ortogonalità dell’ISA 8086, dopo l’uso speciale dell’accumulatore.
La seconda, che poi è anche la pecca più grossa, è che non consentono di accedere a un flag particolarmente importante, quello di overflow, che i progettisti dell’8086 hanno pensato bene di inserire nel dodicesimo bit (l’11) del registro dei flag (EFLAGS), tagliandolo completamente fuori dalle istruzioni LAHF e SAHF, che operano esclusivamente col byte basso e, quindi, coi primi 8 bit (i più bassi).
Il risultato di questa scelta (che personalmente trovo assolutamente insensata; in genere i flag “aritmetici” di un processore sono raggruppati / vicini) è che per accedere a tutti i flag utili si deve far ricorso alla classica coppia di istruzioni PUSHF e POP Registro, che consentono di recuperare l’intero contenuto del registro dei flag, per poi mascherare i bit inutili e infine estrarre quelli che servono. No comment…
I più attenti si saranno accorti che all’appello mancano le cosiddette istruzioni di stringa (LODS, STOS, MOVS, CMPS, SCAS), quelle di loop (LOOP, LOOPNE/Z, LOOPE/Z) e l’unica di salto se il registro CX è zero (JCXZ). Si tratta di istruzioni più o meno complesse, ma la cui valutazione è abbastanza controversa.
Posto che le istruzioni di stringa nelle loro incarnazioni con ripetizione (facendo uso dei prefissi REPNE/Z e REPE/Z) complicano il decoder del processore, come analizzato in uno dei due già citati articoli, alcune di queste rimangono oggettivamente utili. Mi riferisco, in particolare, alle istruzioni MOVS e STOS, che permettono rispettivamente di spostare (copiare, in realtà) un blocco di memoria, e di riempirlo con un determinato valore.
Da programmatore assembly di vecchia data le ho usate non poche volte, ma con l’introduzione del Pentium il loro uso è stato deprecato in quanto, trattandosi di istruzioni molto complesse (mi riferisco sempre all’incarnazione dotata di ripetizione), la loro esecuzione creava parecchi problemi alla pipeline, che sostanzialmente rimaneva bloccata eseguendo microcodice.
Infatti al loro posto Intel ha suggerito di convertirle nelle equivalenti istruzioni x86 più semplici che assolvono allo stesso compito, ma che sono molto più facilmente “digeribili” dalla pipeline del processore.
In realtà, e come vedremo in un futuro articolo, queste istruzioni non sono mai scomparse del tutto e, anzi, si ritrovano persino all’interno di codice a 64 bit, quindi compilato per x64, che riserva anche altre sorprese.
Tornando a queste istruzioni, l’interesse nei loro confronti è ritornato negli ultimi tempi, per due motivazioni sostanzialmente. La prima è ovvia: queste istruzioni sono ancora utilizzate e, trattandosi di operazioni con ripetizione, vengono eseguite “internamente” (lasciatemi semplificare il discorso) molte volte.
La seconda è che, pur non essendo molto generali (si deve far ricorso a specifici registri per impostare puntatori, valore, e contatore), in ogni caso eseguono dei compiti molto utili e molto comuni nell’ambito della programmazione. Copiare memoria e riempirla con un certo valore (in particolare lo zero) non credo che abbiano bisogno di presentazioni: sono compiti arcinoti per un programmatore.
Dunque si è posta molta più attenzione nel cercare di velocizzarne il più possibile l’esecuzione, com’è giusto che sia. Alla fine se la pipeline del processore risulta sostanzialmente bloccata, ma in quel preciso momento si sta copiando o riempendo memoria molto più velocemente di qualunque sequenza di istruzioni equivalenti (e magari bloccando quasi completamente le altre unità funzionali e, quindi, risparmiando corrente)… why not? Tra l’altro quest’approccio permette ottimizzazioni impossibili o molto difficilmente ottenibili altrimenti.
Un discorso analogo si può fare per le istruzioni LOOP, che consentono di decrementare un contatore, controllando eventualmente anche il flag Z (di zero) del processore, e saltando se la condizione (o le condizioni, nel caso dell’uso del flag Z) risulta soddisfatta, permettono alcune ottimizzazioni (nessuna modifica del registro dei flag, ad esempio, per l’operazione di decremento) oltre a una maggior compattezza del codice.
L’unica pecca è data dalla loro non generalità, poiché per il contatore fanno uso del solo registro CX, e in questo il giudizio è comune a quello dell’istruzione JCXZ, che opera sempre e solo su CX.
Il tempo, insomma, ha un po’ ribaltato il lapidario giudizio di obsolescenza riguardo ad alcune istruzioni che in passato sono sempre state tacciate di essere un grosso peso che l’architettura x86 si trascina da tempo. Sebbene pecchino di generalità, alcune rimangono ancora utili e utilizzate, come abbiamo visto e come approfondiremo in futuro.
Nel prossimo articolo verrà analizzata l’eredità, in termini di istruzioni legacy, dei successori dell’8086, a cui seguirà un altro pezzo sull’FPU x87, e un altro sui segmenti (che si sono trasformati in selettori nelle evoluzioni più complesse, ma anche più interessanti, di questa famiglia), che dovrebbe idealmente chiudere questo ciclo… per poi aprirne un altro.