NEx64T – 6: il legacy di x86/x64

Il legacy è un concetto imprescindibile quando si parla delle architetture x86 e x64, per cui e dopo averlo citato ancora nel precedente articolo, è venuto il momento di “togliersi il dente”, come si suol dire, vedendo in che modo NEx64T sia riuscita a “gestirlo” nonostante si tratti di un’architettura completamente diversa a livello di struttura degli opcode.

Infatti i più grossi problemi che ha dovuto affrontare questa nuova architettura sono stati quelli legati al preciso vincolo di rimanere totalmente compatibile a livello di codice assembly con le due che ci si è prefissati di rimpiazzare. Il che significa farsi carico anche dei loro fardelli, per l’appunto, che sono stati ben illustrati in una precedente, vecchia serie di articoli pubblicati in questo sito.

Via tutto il vecchiume!

E’ importante precisare che NEx64T non implementa proprio tutto ciò che attualmente viene supportato da x86 (x64 è più nuova e ha già tolto un po’ di roba, anche se non molta), e con ciò s’intende in particolare le modalità a 16 bit (reale, protetta, e virtuale 8086).

In linea teorica potrebbe anche farlo e basterebbe generare opportunamente le istruzioni che mappano da x86 / 16 bit a NEx64T (32 o 64 bit, è indifferente). In buona parte dei casi tale mappatura è 1:1, perché a una istruzione 8086/80186/80286 ne corrisponde un’analoga della nuova ISA, ma in altri ne servono di più (ad esempio per “tagliare” gli indirizzi generati da 32/64 ai 20 bit utilizzati in queste modalità. Oppure per ridurre i risultati di certi calcoli da 32/64 a 16 bit. O ancora quando si usano i registri “alti”, di cui parleremo meglio più avanti).

La decisione di non procedere in tal senso è maturata dal fatto che da decenni ormai la quasi totalità dei s.o. e delle applicazioni x86 / x64 girano in modalità protetta a 32 o 64 bit (e con la paginazione della memoria sempre attiva, tra l’altro), per cui non ha oggettivamente senso sprecare tempo e risorse per supportare roba che quasi sicuramente non verrà mai usata.

Il focus di NEx64T è, quindi, rivolto esclusivamente alle modalità protette a 32 e 64 bit, tagliando tutto ciò di x86 e x64 che non rientri in quest’area. Cosa che pure Intel ha deciso di fare (dopo molto più tempo!) quando ha proposto la nuova architettura X86-S (in italiano). Spingendosi anche oltre, visto che non supporta nemmeno s.o. a 32 bit, ma solo a 64 bit: rimangono soltanto le applicazioni a 32 bit ( l’unico codice a 32 bit a girare sul s.o. a 64 bit!).

Addio ai prefissi!

Ne abbiamo già parlato tanto nell’articolo introduttivo della serie, ma l’altro grosso pezzo di legacy che è stato completamente rimosso riguarda proprio i vituperati prefissi, che in x86 e x64 vengono impiegati per alterare il funzionamento delle istruzioni abilitando certe caratteristiche (riportate nel primo articolo).

In particolare non servono più quelli che consentono di cambiare la dimensione dei dati da manipolare (diverse istruzioni x86 / x64 funzionano, di base, soltanto con una certa dimensione), poiché tale informazione risulta già direttamente integrata in un apposito campo dell’istruzione (com’è stato già illustrato nel secondo articolo).

Il prefisso che consente, invece, di troncare un indirizzo da 64 a 32 bit, o da 32 a 16 bit, non è stato implementato. Si tratta di una rarità (anche se è alla base dell’ABI x32. Un buon esperimento che, però, non ha attecchito e non viene più supportato), visto che non è stato riscontrato nel codice finora disassemblato.

La decisione presa è semplicemente quella di emularlo (dovesse presentarsi un’occorrenza), utilizzando un’istruzione per calcolare l’indirizzo, copiandolo poi in un registro di apposita dimensione, e usando infine questo per referenziare la locazione di memoria nell’istruzione che dovesse impiegare questa particolare funzionalità. Si tratta, quindi, di utilizzare un’istruzione in più, ma data l’estrema rarità è un costo che si può benissimo sostenere.

La segmentazione

Non ci si può liberare del tutto dalla segmentazione, invece, per quanto già detto nel precedente articolo, considerato che viene utilizzata per implementare il meccanismo del TLS (Thread-local storage).

L’uso più comune viene gestito in maniera molto più efficiente e veloce, come già illustrato, ma il caso più generale richiede specifici accorgimenti. In particolare ciò che serve è poter specificare il segmento (selettore, in modalità protetta) da utilizzare per poter accedere all’indirizzo base del TLS quando siamo in presenza di una modalità d’indirizzamento diversa da quella assoluta.

In questo caso NEx64T risolve il problema utilizzando opcode più grandi, che sono in grado di estendere il comportamento delle istruzioni in essi contenuti, mettendo a disposizione tutta una serie di funzionalità (esposte in un precedente articolo) da poter abilitare (singolarmente e in maniera indipendente dalle altre), fra cui l’utilizzo del segmento per la TLS, per l’appunto.

Dunque il prezzo da pagare rimane esclusivamente nell’ambito della lunghezza delle istruzioni, che comunque non intacca la densità di codice, essendo questi scenari estremamente rari (inoltre anche le istruzioni x86/x64 possono essere abbastanza lunghe nei casi in cui serva utilizzare la TLS).

Il legacy della segmentazione non si ferma qui, purtroppo, perché questo meccanismo fa utilizzo di diversi registri sia “esterni” (direttamente accessibili con apposite istruzioni) sia “interni” (che conservano informazioni lette dagli appositi descrittori a cui puntano i registri di segmento), e che NEx64T deve per forza supportare (per lo meno a livello di “storage“: memorizzazione di questi dati).

Esiste anche un variegato insieme di istruzioni di esclusiva pertinenza della segmentazione, ma in questo caso la quasi totalità non è implementata, ma viene emulata col meccanismo più generale che sarà descritto più avanti (che viene usato per implementare e supportare tutte le istruzioni legacy che sono state completamente rimosse dall’ISA).

In tal modo i core del processore si mantengono più snelli, dovendo supportare soltanto un minimo insieme di istruzioni molto semplici che vengono utilizzate soltanto per emulare quanto basta per poter far funzionare la segmentazione, a tutto vantaggio del minor consumo di silicio e anche della minor complessità del backend.

LOCK: istruzioni di read-modify-write

Le cosiddette istruzioni RMW (read-modify-write) sono un caposaldo per quanto riguarda la gestione di scenari in cui il processore debba eseguire delle operazioni atomiche in sistemi in cui l’accesso ad alcune risorse sia conteso (con altre periferiche, con altri processori, o anche fra core dello stesso processore).

x86 e x64 sono ben noti nell’utilizzare un apposito prefisso, chiamato di LOCK, il quale segnala l’inizio di un ciclo di operazioni sul bus che ne bloccano l’accesso fino al loro completamento, in modo da garantire che non vi possano essere problemi di race condition per quanto riguarda l’accesso a locazioni di memoria contese fra tutti gli attori.

In questo caso per NEx64T ho optato per l’utilizzo di alcuni, specifici, opcode, che non sono necessariamente più grandi di tutti quelli normali (non viene usato il meccanismo di “estensione delle funzionalità” precedentemente descritto).

L’idea riguarda il raggruppamento di tutte queste istruzioni in appositi opcode, in modo da rendere possibile determinare in maniera estremamente semplice ed efficiente che si tratti di istruzioni RMW (basta esaminare alcuni, pochi bit dell’opcode), senza ricorrere al prefisso né a tabelle che indichino quali istruzioni debbano bloccare l’utilizzo del bus.

Ciò è stato possibile anche perché il numero di istruzioni x86/x64 per le quali sia possibile utilizzare il prefisso di LOCK risulta estremamente ridotto, per cui è stato semplice raggrupparle (a seconda di numero e tipologia degli argomenti) in precisi sottoinsiemi di opcode che sono facilmente controllabili dai decoder.

REP: ripetizione di istruzioni “di stringa”

Le cosiddette istruzioni “di stringa” (LODS, STOS, MOVS, CMPS, SCAS, INS, OUTS) sono state oggetto di dileggio per tantissimo tempo, in quanto ritenute troppo complicate da implementare, molto lente, e aventi la non trascurabile proprietà di bloccare la pipeline del processore durante la loro esecuzione.

Eppure sono state anche molto usate perché consentono di eseguire certe operazioni abbastanza comuni (STOS / riempimento di aree di memoria con un certo valore; MOVS / copia di blocchi di memoria) con una sola istruzione (a vantaggio dell’arcinota densità di codice).

E’ interessante notare che, a partire dalla famiglia Nehalem, Intel ha posto attenzione all’accelerazione di alcune di queste istruzioni (le più comuni e utili ovviamente), valorizzandole enormemente e facendole riscoprire in maniera positiva.

Per questi motivi NEx64T non poteva trascurarle, ma ha tolto completamente di mezzo i relativi prefissi (REP/REPZ e REPNZ), rendendole delle istruzioni compatte (non sono necessari nemmeno i prefissi per cambiare la dimensione degli operandi) e raggruppandole poi tutte in un unico blocco di opcode (così da facilitarne la decodifica).

I quattro registri “alti”

x86 e x64 consentono di utilizzare quattro registri chiamati “alti” (“high”): AH, BH, CH e DH. Si tratta di un retaggio di 8086 che è stato molto utile per poter utilizzare ben 8 registri a 8 bit (assieme ad AL, BL, CL e DL) quando il numero di registri a disposizione era molto basso (parliamo della fine degli anni ’70!).

In realtà non si tratta di registri veri e propri, ma viene data la possibilità di accedere direttamente (quindi senza operazioni di estrazione e/o inserimento) al byte “alto” (bit 15-8) di uno dei quattro registri dati (a 16 bit) dell’8086 (AX, BX, CX, DX).

Supportare questa funzionalità anacronistica avrebbe comportato parecchi contorsionismi e sprecato preziosi bit degli opcode in un’architettura, quella di NEx64T, che permette di utilizzare tranquillamente 16 o ben 32 registri, senza limitazione alcuna.

Per cui a un certo punto ho deciso di tagliare completamente questo cordone ombelicale, cancellando il concetto di registro “alto” da questa questa nuova ISA, e recuperando il relativo spazio negli opcode. Il problema del loro supporto, però, rimaneva, in quanto (e come già detto diverse volte) NEx64T dev’essere compatibile al 100% a livello di codice sorgente con x86 e x64.

La soluzione trovata sta in un paio di semplici istruzioni che consentono di copiare il contenuto del byte “alto” da uno dei suddetti quattro registri a un altro prefissato (R30), e viceversa. In questo modo le normali istruzioni posso operare sul byte estratto (o i byte, nel caso di istruzioni che lavorino entrambe su due registri “alti”; sfruttando il registro R31 come ulteriore “appoggio” per il secondo byte “alto” estratto).

Il prezzo da pagare è ovviamente quello di dover eseguire più istruzioni per estrarre per prima cosa il o i byte “alto/i” e infine per copiare, eventualmente, il risultato nel byte “alto” di destinazione. Si va da una (estrazione del byte “alto” usato sorgente dati) fino a un massimo di quattro (estrazione i due byte “alti” e infine scrittura del risultato in uno di essi) istruzioni che devono essere eseguite.

Si tratta di una scelta un po’ dolorosa (considerato che, sebbene si tratti di funzionalità obsolete, sono ancora presenti e utilizzate in meno dell’1% del codice), ma che non ha impattato molto né sulla densità di codice né sulle prestazioni (numero di istruzioni eseguite), permettendo comunque di togliere definitivamente dai piedi altro vecchiume inutile (considerato il numero di registri normalmente a disposizione).

Il millicode per le vecchie istruzioni

Circa un centinaio di istruzioni particolarmente vecchie sono state tolte di mezzo facendo ricorso al cosiddetto millicode, come già anticipato, e non intaccando la dimensione dei core (visto che non esistono e non devono essere implementate) se non in minima parte (implementare solamente il meccanismo alla base del millicode, per l’appunto).

Dunque sono state eliminate le istruzioni per la manipolazione di numeri BCD (AAA, AAD, DAA, …) , quasi tutte quelle relative ai segmenti & descrittori (MOV/PUSH/POP da/per segmenti, LAR/LSL, …), quelle per le chiamate “lontane” (CALL/JMP/RET "FAR"), per gli interrupt (INT, IRET, …), per l’I/O (IN, OUT. Ne sono rimaste soltanto un paio semplici per gestire questi casi), e altre molto complicate (PUSHA, POPA, ENTER, …).

In realtà ci sono ancora (considerato che NEx64T dev’essere totalmente compatibile a livello di codice assembly, come ripetuto già altre volte) e possono essere eseguite, ma la loro esecuzione è demandata ad apposito codice millicode che si occupa di emularne il funzionamento facendo uso delle normali istruzioni.

Ciò ha richiesto una soluzione particolarmente duttile e funzionale (di cui non sono esposti i dettagli in questa sede), in quanto tali istruzioni sono piuttosto variegate in termini di argomenti (possono non averne, avere valori immediati, o referenziare locazioni di memoria).

L’importante, alla fine, è che il risultato sia stato raggiunto. Inoltre tale meccanismo è abbastanza flessibile da consentire di implementare ulteriori istruzioni (c’è ancora parecchio spazio) senza pesare in alcun modo in termini di silicio.

Ovviamente l’esecuzione non può essere veloce come per un’istruzione implementata in hardware, ma considerato che si tratta di roba estremamente vecchia e utilizzata poco o per niente, è un sacrificio che si può certamente fare.

L’FPU x87

Sebbene sia uno dei componenti più vecchi e disprezzati dell’architettura x86 (con x64 è stata deprecata, ma non rimossa), l’FPU x87 è presente anche in NEx64T sempre ed esclusivamente per una questione di totale retrocompatibilità.

Emularne il funzionamento col suddetto meccanismo di millicode sarebbe stata una seria possibilità e ciò non soltanto in linea teorica: “intercettarne” gli opcode / istruzioni e “reindirizzarne” l’esecuzione in appositi sottoprogrammi è a dir poco banale.

Ma non è chiaro ancora quanto software importante sia ancora da essa dipendente e, soprattutto, quanto determinante sia il fattore prestazionale. Infatti e mentre emulare una vecchissima istruzione come AAA, ad esempio, sia abbastanza facile e relativamente veloce, non può dirsi la stessa cosa del codice x87.

Questo perché istruzioni estremamente rare come AAA, per l’appunto, si possono ritrovare in maniera molto occasionale come singole occorrenze sparse in milioni o anche miliardi di altre istruzioni eseguite. Dunque eseguirne una “in versione millicode” è effettivamente del tutto trascurabile sul computo complessivo.

Quelle della vecchia FPU, invece, possono far parte di interi blocchi di programma con parti critiche eseguite all’interno di cicli e, quindi, messe lì a macinare numeri. Avere delle buone prestazioni, in questo caso, è assolutamente fondamentale.

Il che significa che non ci si può affidare interamente al millicode per la loro esecuzione se non si è abbastanza sicuri (benchmark alla mano) di poter far girare tutta quella roba per lo meno decentemente.

Per questi motivi si è preferito supportare integralmente in hardware quasi l’intero insieme di istruzioni x87, fatta eccezione per roba da museo come quelle che leggono o scrivono valori in BCD (FBLD, FBSTP. Emulate col millicode).

C’è, però, una concreta differenza: NEx64T non supporta il prefisso WAIT (o FWAIT. 9B in esadecimale), che con x87 viene, invece, utilizzato per controllare se la precedente istruzione abbia generato o meno un’eccezione (prima di procedere con l’esecuzione di quella successiva).

In generale quasi tutte le istruzioni dell’FPU effettuano già questa verifica, tranne alcune (poche) istruzioni “di controllo” (FNINIT, FNSTENV, ecc.). In questo caso è bastato duplicarle e fornirne una versione con e senza funzionalità WAIT/FWAIT incorporata, eliminando del tutto il prefisso.

Estensioni MMX/SSE

Sebbene sia stata introdotta “soltanto” nel 1997, anche l’estensione MMX (che riutilizza i registri della suddetta FPU x87) fa parte del bagaglio legacy di x86 / x64, poiché è stata rimpiazzata nel giro di appena due anni dalla successiva SSE (la quale è alla base di tutte le successive estensioni SIMD di Intel).

Nel frattempo, però, parecchio codice era stato scritto (nel pieno boom dell’elaborazione SIMD), di cui ancora oggi si trovano tracce a volte anche consistenti (ad esempio è circa il 10% rispetto alle istruzioni SSE di quelle disassemblate nella versione a 32 bit di WinUAE — Il più famoso emulatore Amiga).

Situazione analoga per le SSE, le quali sono di gran lunga più usate, ma per giunta alla base di x64 (sono un requisito nonché una raccomandazione per quest’ISA, nella versione SSE2), nonostante da parecchi anni siano arrivate e si siano diffuse le ben più avanzate nonché performanti AVX (e successive).

In questo caso la decisione presa è stata quella di mantenerne il supporto, trasformandole opportunamente, rivitalizzandole, e raggruppandole in un insieme di istruzioni ortogonali e molto bene organizzato (soprattutto banale da decodificare, poiché non esistono prefissi di alcun tipo!), come vedremo nel prossimo articolo.

Modalità d’indirizzamento a 16-bit

Abbiamo già visto nel secondo articolo quanto complicata sia la decodifica delle istruzioni di x86/x64 e di come lo sia anche la parte (un byte per il cosiddetto ModR/M, un eventuale altro per il SIB, e a seguire altri byte per un possibile offset) che riguarda l’eventuale presenza di una modalità d’indirizzamento per un argomento in memoria.

In NEx64T non esiste nulla di così arzigogolato: le istruzioni in grado di referenziare uno o più operandi in memoria sono ben definite / raggruppate, ed esiste soltanto una word (2 byte; 16 bit) di estensione che elenca in maniera semplice e precisa quale modalità d’indirizzamento utilizzare fra quelle a disposizione.

Poiché, e per quanto già detto all’inizio, non sono supportate le modalità d’indirizzamento presenti con l’esecuzione a 16 bit, non sono state implementate nemmeno le relative modalità d’indirizzamento (ad esempio [BX + SI]), le quali erano un po’ diverse da quelle che sono state poi introdotte con la modalità a 32 bit.

Nel caso dovesse servire si potrebbero, comunque, facilmente emulare al costo dell’esecuzione di un’istruzione aggiuntiva da utilizzare per calcolare l’indirizzo a 16 bit, per poi utilizzarne il risultato nella successiva istruzione.

Conclusioni

Come si può vedere, quasi tutto il legacy di x86 / x64 è stato rimosso dall’architettura, lasciando soltanto quello di cui non era assolutamente possibile fare a meno (in particolare alcune funzionalità della segmentazione).

Il risultato è che i core di NEx64T occupano meno spazio perché il costo implementativo è ridotto ai minimi termini, semplificando non soltanto il decoder, ma anche il backend che si occupa di eseguire le istruzioni.

Come anticipato, il prossimo articolo riprenderà le innovazioni che porta la nuova architettura, focalizzandosi questa volta sull’unità SIMD / vettoriale (che ne rappresenta un altro punto di forza, come si vedrà anche prendendo un esempio noto, mostrandone l’implementazione e confrontandola con tutte le ISA più note e blasonate).

Press ESC to close