Il legacy di x86 & x64 – parte 5 (i selettori)

Nel precedente articolo è stato analizzato il funzionamento a runtime dei famigerati segmenti della famiglia di CPU x86, e posto in risalto la loro pervasività, essendo utilizzati sempre, persino nella versione a 64 bit, x64, che ne rappresenta il futuro.

I selettori sono la naturale (per x86, s’intende) evoluzione di questo meccanismo, che hanno visto la luce con l’80286, e di cui abbiamo analizzato già il funzionamento in quest’altro articolo (la cui lettura è consigliata prima di proseguire con questa).

Durante l’esecuzione valgono all’incirca le stesse considerazione fatte per i segmenti, a cui se ne aggiungono, però, altre. In comune c’è la necessità di mettere a disposizione l’indirizzo base da sommare poi agli offset per ricavare, ma è il come ci si arriva che è profondamente diverso.

Mentre i segmenti racchiudono già nel loro valore la base (che equivale al valore del segmento moltiplicato per 16), i selettori hanno bisogno di recuperarla dalla memoria sfruttando l’indice in essi contenuto e il tipo di selettore (locale o globale). Quindi è chiaro che l’operazione di caricamento di un selettore risulta decisamente più penalizzante rispetto a quella di caricamento di un segmento.

Valgono, comunque, le stesse considerazioni fatte per i segmenti: con codice a 16 bit il continuo cambio / caricamento di un selettore è un’operazione ben più pesante, mentre a 32 bit e con un modello “flat” ciò avviene esclusivamente all’avvio del processo (e thread, eventualmente), dopodiché il processore non incontrerà più altre istruzioni simili, per cui sostanzialmente rimane ininfluente questo nuovo, più complesso, funzionamento rispetto a quello dei segmenti.

In esecuzione, però, vengono attivati altri meccanismi rispetto all’aggiunta dell’indirizzo base. Similmente alla paginazione, ai selettori sono associati degli attributi che possono condizionarne l’accesso. Ad esempio un selettore di tipo codice può soltanto essere eseguito, ma anche letto se definito opportunamente. Non può, invece, essere scritto, perché altrimenti viene sollevata un’eccezione.

Tutto ciò significa che, oltre agli attributi della pagina di memoria a cui serve accedere, ci sono altri flag (accesso, presente, ecc.) che devono essere controllati per condizioni similari alla paginazione, e ciò avviene sempre perché, come già visto per i segmenti, per lo meno il fetch dei byte dell’istruzione da eseguire deve avvenire ed è soggetto a questi controlli.

Se l’istruzione prevede letture e/o scritture in memoria, ovviamente si aggiungono anche quelli relativi ai segmenti utilizzati allo scopo (ce ne può essere uno per la lettura, e un altro diverso per la scrittura). Quindi nel caso peggiore bisogna controllare gli accessi a tre diversi segmenti (uno per il codice, più due per i dati).

Non si tratta, comunque, di controlli esosi in termini di risorse impiegate e prestazionali. Per fare un esempio, una volta che il processore sa che deve leggere dei byte, il contesto è chiaro e deve semplicemente verificare che il segmento sia presente (c’è un bit apposito che lo segnala nel descrittore di segmento) e leggibile (questo dipende dal tipo di segmento, anch’esso presente nel descrittore). Operazioni, queste, che richiedono poca e banale logica.

Nulla di trascendentale, insomma. Infatti col meccanismo di paginazione della memoria si fanno esattamente le stesse cose, ma al posto del descrittore di segmento si trova il “descrittore” di pagina (che può essere di tipi diversi, a seconda del livello di ramificazione dell’albero delle pagine, ma alla fine contiene sempre informazioni relative all’accesso), che ha anch’esso appositi bit per segnalare quali operazioni si possono fare con quella pagina di dati.

Aggiungo, per completezza, che prima del bit NX non esisteva il concetto di pagina di codice, ed era sufficiente che una pagina fosse leggibile affinché potesse essere anche eseguibile (con tutte le implicazioni che ciò può comportare in termini di sicurezza). Con NX si può finalmente marcare una pagina come “non eseguibile”, e quindi offrire un strumento più solido e sicuro, che fino ad allora era stato di esclusivo dominio della segmentazione (parlando sempre e soltanto dell’architettura x86, ovviamente).

C’è, però, una differenza abbastanza marcata fra segmentazione e paginazione. Come già detto, al caricamento del selettore il processore provvede a caricare le informazioni presenti nel relativo descrittore, e a mantenerle in memoria in un’area “riservata” (ma comunque accessibile con apposite istruzioni). Fatto ciò, ci si può dimenticare dell’esistenza e del “peso” dei segmenti, perché queste informazioni sono sempre immediatamente disponibili.

Al contrario, la paginazione risulta limitata dalla dimensione delle singole pagine (4KB, 2MB, 4MB). Come sappiamo, lo spazio d’indirizzamento di un processo viene suddiviso in pagine, che possono essere, e generalmente sono, anche numerose. Quando la CPU ha un indirizzo logico, ha bisogno di sapere l’indirizzo della memoria fisica, e gli attributi di cui abbiamo parlato, e per far questo ricorre ad apposite strutture dati che costituiscono l’albero di paginazione del processo.

Poiché le pagine possono essere davvero molte, è chiaro che non può caricare tutte le informazioni relative a quest’albero, ma internamente ha disposizione spazio per un certo numero piccolo e finito di esse (le entry del cosiddetto TLB, Translation Lookaside Buffer) che utilizza per eliminare quelle più vecchie e rimpiazzarle con le nuove.

Quindi, e per chiudere il discorso, durante l’esecuzione il processore si trova a dover impiegare delle risorse (cicli di clock, ma anche banda verso la memoria) per caricare le informazioni delle pagine nel TLB.

Confrontato coi vecchi segmenti o selettori usati in modalità a 16 bit, quest’operazione può essere più o meno onerosa, a seconda della tipologia di codice, per cui non è possibile fornire una stima di quale strumento sia più conveniente (anche perché le implementazioni per entrambi i casi possono essere diverse).

Quest’articolo tratta un altro argomento, perché ci interessa analizzare e valutare l’impatto del “legacy” di x86. Al momento possiamo dire che dividendo lo spazio d’indirizzamento in numerosi segmenti, abbiamo sicuramente a che fare con operazioni di caricamento dei segmenti/selettori che può risultare più o meno onerosa.

Ma, come già visto, in un modello “flat“, che viene utilizzato nei moderni s.o., ciò non si verifica, poiché i selettori vengono caricati una sola volta dal kernel prima dell’esecuzione del processo, e quindi le relative informazioni sono già presenti internamente al microprocessore, che le ha sempre a disposizione e le usa costantemente (perché la segmentazione rimane sempre attiva).

Oltre a indirizzo base e attributi, ai selettori è associata un’altra, molto importante, informazione: il limite del segmento. Si tratta del valore massimo che un offset può raggiungere all’interno di un segmento, oltre il quale la CPU solleva un’apposita eccezione.

Questo limite è presente anche coi segmenti, ma è fissato a 64KB, mentre coi selettori si può selezionare un valore che copre in maniera precisa fino a 1MB (64KB per l’80286), oppure estendere il range a 4GB, ma a “tagli” di 4KB (la granularità non è più di un singolo byte, ma di 4KB alla volta).

Una volta caricato il selettore, il processore si trova, quindi, quest’informazione, che gli serve per controllare, qualunque sia l’accesso che sta effettuando, se sta “sforando” i limiti imposti.

Si tratta, quindi, di un controllo ulteriore (tre nel caso peggiore: codice più due dati), e non così semplice come quello degli attributi di cui abbiamo parlato finora, perché richiede l’uso di un comparatore, che deve produrre un risultato, sulla base del quale poi decidere se proseguire con l’esecuzione oppure bloccarla sollevando un’eccezione.

E’ bene precisare che un controllo simile viene effettuato anche durante la paginazione, per verificare che l’accesso non comporti uno “sforamento” dei limiti della pagina, nel qual caso il processore deve, quindi, cercare (ed eventualmente caricare dall’albero di cui sopra) le informazioni sulla pagina attigua.

Si può arrivare, così, a raddoppiare il numero di controlli da effettuare, mentre con la segmentazione c’è soltanto il limite da verificare, perché il segmento su cui la CPU sta lavorando rimane sempre e soltanto uno (per un ben preciso accesso, s’intende).

In realtà il controllo del limite richiede anche l’uso di un apposito sommatore. Infatti abbiamo visto coi segmenti che per il calcolo dell’indirizzo virtuale si fa uso di un sommatore a cui vengono passati l’indirizzo base del segmento, e gli eventuali registro base, registro indice (scalato), e displacement (intero, massimo a 32 bit). Questo valore serve poi al meccanismo di paginazione per recuperare le informazioni sulla pagina a cui si sta facendo accesso.

Coi selettori, però, il controllo del limite viene applicato soltanto agli ultimi tre elementi, e quindi non tiene conto dell’indirizzo base del segmento, il quale è necessario esclusivamente per ricavare l’indirizzo virtuale finale.

Questo significa che l’offset che dev’essere controllato dal meccanismo dei selettori dev’essere elaborato indipendentemente rispetto all’indirizzo virtuale, richiedendo, quindi, un apposito sommatore, anch’esso utilizzato sempre (per lo meno per il codice, come già detto).

Ricapitolando, la segmentazione, sempre presente, aggiunge dei “costi fissi” all’architettura x86: caricamento di un segmento, controllo dei suoi attributi, e controllo del limite. Il costo dell’implementazione non mi sembra eccessivo, tant’è che i selettori sono stati introdotti con l’80286, che aveva soltanto 134000 transistor in totale: un numero ridicolo se visto con gli occhi di oggi (ma “pesante” all’epoca).

C’è da dire che per migliorare le prestazioni sarà sicuramente aumentato con le successive implementazioni. Non mi occupo di microarchitetture, ma la mia idea è che il costo sia rimasto molto basso, poiché la logica che vi sta dietro è molto semplice e “a buon mercato” (poche porte logiche, sommatori, comparatori, e pochi registri per contenere i segmenti e le informazioni del descrittore caricato).

Da tutto questo discorso ho eliminato la trattazione delle istruzioni privilegiate che sono state introdotte con la modalità protetta, perché ho preferito focalizzare l’attenzione sul concetto di selettore, e l’impatto che ha durante l’esecuzione (visto che è sempre utilizzato), che poi è anche il prezzo che deve pagare l’architettura x86 nei confronti delle altre architetture in termini di consumo ed eventuale latenza delle operazioni (con relativa penalizzazione prestazionale).

Per quest’ultimo caso è bene spendere qualche parola prima di concludere. E’ chiaro che questi controlli devono essere effettuati, e il processore deve poi prendere delle decisioni. Ciò significa che potrebbe essere necessario aggiungere qualche stadio alla pipeline del processore, allungando latenza e peggiorando i tempi d’esecuzione.

Premesso che dipende tutto da com’è stata implementata una particolare microarchitettura, credo sia possibile mascherare l’esecuzione di questi controlli assieme alle altre operazioni che vengono svolte dai singoli stati della pipeline.

Ciò significa che, ad esempio, mentre sta eseguendo il fetch dalla cache dei byte dell’istruzione che deve eseguire, può verificare se l’ultimo byte ha sforato il limite del segmento di codice. Nel caso della normale esecuzione, non sarà presente alcuna latenza addizionale per questo controllo, perché l’esecuzione dell’istruzione va a buon fine.

Ma, se ci pensiamo bene, anche il caso peggiore, che è rappresentato ovviamente dallo “sconfinamento”, non comporta particolari “sanzioni”. Infatti anche il meccanismo di paginazione prevede il controllo dei limiti della pagina. Con la segmentazione la verifica del limite potrebbe benissimo essere effettuata nello stesso momento in cui si fa quella della pagina, per poi prendere provvedimenti tenendo conto di entrambi i risultati.

Rispetto alle altre architetture che fanno uso della paginazione, non v’è, quindi, sostanziale differenza in termini puramente prestazionali. Anche il consumo non credo sia eccessivo, ma qui qualche esperto di microarchitetture può sempre correggermi.

Conclusa l’analisi della segmentazione, il prossimo articolo della serie sarà dedicato alle istruzioni privilegiate che, come anticipato, non sono state trattate per il momento.

Press ESC to close