Torniamo a parlare del fattore “legacy” delle CPU x86, con l’ultimo aspetto che era rimasto in sospeso e che rimane da sviscerare: la segmentazione.
Per inquadrare bene il peso di questa caratteristica è fondamentale analizzare in che modo vengono utilizzati i segmenti nel codice x86 e x64, e l’impatto che possono avere mediamente durante l’esecuzione, sia a livello di specifiche istruzioni o parti di esse che li possono coinvolgere, sia a livello di implementazione nella pipeline d’esecuzione del processore.
Un’introduzione al concetto di segmento si trova in un articolo che è stato scritto in passato, che potete trovare in questa pagina.
L’implementazione dei segmenti fa uso di 4 appositi registi a 16 bit (divenuti 6 a partire dall’80386, a cui sono stati aggiunti altri due segmenti per i dati, FS e GS), che vengono utilizzati dal processore in base al contesto d’esecuzione.
CS viene utilizzato sempre ogni volta che viene eseguito il fetch dei byte dell’istruzione che dev’essere eseguita. SS è impostato di default per tutte le istruzioni di push o che accedono ai dati tramite i registri SP e BP. Per l’accesso a dati che non ricadono nel caso precedente, si fa uso di DS. Infine per le istruzioni di stringa che hanno un operando di destinazione, viene utilizzato ES per accedere al dato.
Qualunque istruzione venga eseguita, almeno CS viene coinvolto per recuperare i byte dell’istruzione tramite l’offset fornito dal registro IP (Instruction Pointer; il PC di altri microprocessori). Inoltre si possono impiegare fino ad altri due segmenti per l’accesso in memoria, a seconda del tipo di istruzione da eseguire. Ciò significa che la segmentazione è sempre operativa.
Nel caso dell’8086, e in generale quando parliamo di esecuzione in modalità reale e virtuale 8086, la segmentazione si riconduce semplicemente all’aggiunta di un valore chiamato base (del segmento) a un offset, col meccanismo che è stato illustrato nel primo link fornito, per superare il limite dei 64KB di memoria altrimenti indirizzabili coi soli 16 bit forniti dai registri.
Con l’80386 i registri sono diventati a 32 bit, per cui anche in queste modalità è possibile indirizzare fino a 4GB di memoria fisica. In ogni caso la segmentazione è e rimane sempre attiva, quindi all‘offset a 32 bit fornito dal registro viene sommato ugualmente l’indirizzo base del segmento associato al tipo di dato a cui si accede.
Si tratta di un costo fisso addizionale rispetto ad altri processori privi del concetto di segmentazione (per lo meno per come l’ha concepito e realizzato Intel), che per accedere ai dati hanno bisogno esclusivamente dell’offset, il quale alla fine coincide con l’indirizzo vero e proprio a cui si vuol accedere.
Quindi qualunque accesso alla memoria vede coinvolta almeno un’ALU per il calcolo del puntatore al primo byte, che può essere molto semplice nel caso del fetch dell’istruzione (si devono sommare soltanto due valori: base del segmento e offset), o molto più complicata per indirizzamenti più complessi verso la memoria (base del segmento + registro base + registro indice moltiplicato per un fattore di scala 1/2/4/8 + offset).
Tolto il primo caso, che rimane una “tassa fissa” da pagare al legacy di x86, il secondo è di carattere più generale e la segmentazione non rappresenta un costo addizionale che pesa, in quanto sommare 3 o 4 valori per un’ALU ha lo stesso costo. In buona sostanza, il costo della somma della base del segmento risulta “assorbito” dall’intrinseca complessità dell’ALU.
Si potrebbe puntare il dito contro la complessità della modalità d’indirizzamento che x86 mette a disposizione (a partire dall’80386, però; in precedenza era assente lo scaling del registro indice), ma anche alcuni processori RISC la mettono a disposizione, perché si tratta di un pattern d’accesso ai dati abbastanza comune (sebbene non sia il più frequente, come vedremo in una nuova serie di futuri articoli); l’esempio più noto è l’accesso a un vettore i cui elementi occupano più di un byte.
Preso atto che la segmentazione pervade l’implementazione dell’architettura x86 (compresa x64, che non l’ha eliminata del tutto dalla modalità d’esecuzione a 64 bit), vediamo anche quale impatto ha sull’ISA.
In termini di registri, come detto, ne servono 4 a 16 bit per i vecchi 8086, 80186, e 80286, e 6 in tutti i successori. Non si tratta, quindi, di una richiesta esagerata, a maggior ragione da quando le microarchitetture hanno cominciato a richiedere centinaia di migliaia di transistor per la loro realizzazione.
Inoltre non è necessario porre particolare attenzione per questa tipologia di registri, in quanto le poche istruzioni preposte alla loro gestione o utilizzo provvedono esclusivamente a leggerne o scriverne il contenuto. Quindi niente operazioni aritmetiche, logiche, o di altro tipo, ma soltanto load o store, dunque molto semplici da gestire (quando tratteremo i selettori il discorso cambierà).
Esistono, quindi, delle specifiche istruzioni di MOV che consentono di leggere un valore a 16 bit dalla memoria e caricarlo nel registro di segmento specificato; viceversa, è possibile scriverne il valore in una locazione di memoria a 16 bit. Inoltre, similmente agli altri registri, si può eseguire il PUSH o il POP di un segmento nello/dallo stack.
Le speciali istruzioni LDS e LES provvedono, invece, a caricare in un colpo solo il segmento DS o ES e un registro general purpose, specificando una locazione di memoria (che in gergo viene chiamata “puntatore far“; “lontano”) dalla quale attingere i rispettivi valori disposti sequenzialmente. L’80386 ha introdotto LFS e LGS per fare lo stesso coi nuovi segmenti FS e GS.
Istruzioni come queste erano molto diffuse coi vecchi processori a 16 bit, in quanto consentivano di superare il limite dei 64KB di memoria indirizzabili facendo uso di soli 16 bit di offset, come già detto. Un qualunque segmento dati mette a disposizione fino a 64KB di memoria, ma potendo usare più segmenti dati si poteva quindi coprire tutto lo spazio d’indirizzamento.
Un accorto uso dei registri di segmento dati consente di ridurre il costo dei continui caricamenti di nuovi segmenti, ma è chiaro che, dovendo operare con molte strutture dati, quest’operazione era, purtroppo, molto frequente e penalizzante in termini prestazionali e di spazio occupato (le istruzioni di caricamento dei segmenti hanno un costo e occupano spazio).
A ciò si aggiunge il fatto che normalmente il processore utilizza il segmento DS per accedere ai dati, per cui se si voleva far uso di un altro segmento (ad esempio ES, ma anche CS nel caso di variabili memorizzate direttamente nel segmento di norma riservato al codice), si doveva ricorrere ai famigerati prefissi di segmento.
Per i programmatori assembly si tratta di etichette (ad esempio “ES:”) da specificare come prefisso dell’operando di memoria, che istruiscono il processore a non utilizzare DS, ma il segmento desiderato, per accedere a quella locazione di memoria.
Tutto ciò si traduce in costi maggiori sia in termini d’esecuzione (non per le microarchitetture più moderne) che di spazio (si tratta di un byte in più che “cambia al volo” il segmento di default). Inoltre l’uso dei prefissi comporta una maggior complessità del decoder delle istruzioni, come abbiamo visto in un paio di articoli (qui e qui).
Le incarnazioni a 32 bit di quest’architettura hanno consentito di eliminare del tutto questo meccanismo, in quanto gli offset per indirizzare la memoria sono passati da 16 a 32 bit, e quindi un singolo segmento dati consente, da solo, di indirizzare ben 4GB.
In linea teorica è sempre possibile continuare a operare coi segmenti esattamente come coi vecchi processori, e quindi indirizzare 4GB di memoria per ognuno di essi, ma in pratica tutti i sistemi operativi moderni mettono a disposizione quello che viene chiamato “modello flat“, quindi tutti i segmenti puntano sostanzialmente alla stessa area di memoria (di 4GB, appunto), e il solo offset discrimina l’accesso a un dato (o istruzione).
Ciò ha permesso di semplificare notevolmente l’implementazione e la gestione dei processi/thread, anche e soprattutto per i programmatori, che non devono gestire puntatori “far” a 48 bit (16 per il segmento, e 32 per l’offset), ma che si “accontentano” di puntatori “near” (vicini) a 32 bit.
Com’è facile intuire, con un modello flat i segmenti vengono caricati una sola volta, alla creazione del processo, e poi “dimenticati” sia dal s.o. che dalle applicazioni. Quindi le uniche e poche istruzioni di manipolazione dei segmenti si trovano all’interno del kernel; per il resto la CPU non ne incontrerà durante il resto dell’esecuzione del codice.
Un discorso simile ai dati valeva anche per il codice. Essendo presente un solo segmento per il codice, CS (quello correntemente in uso), in passato il processore era limitato a indirizzare 64KB di codice.
Per superare questo limite era possibile anche qui far uso dei puntatori “far“, tramite apposite istruzioni di salto (CALL, JMP “far“) e di ritorno (RET “far“) che consentivano di caricare un nuovo segmento di codice assieme a un nuovo offset (dalla memoria o come valore immediato per quelle di salto; dallo stack per quella di ritorno) dal quale cominciare a prelevare i byte della prossima istruzione da eseguire.
Ovviamente il prezzo da pagare era una penalizzazione in esecuzione, dovendo caricare un nuovo segmento, oltre che memorizzare nello stack il vecchio valore, per cui la tendenza era quella accorpare il più possibile il codice in appositi segmenti, in modo da minimizzarne l’impatto.
Anche qui, col modello “flat” tutto ciò non è necessario, in quanto il codice risulta distribuito nei 4GB di memoria indirizzabile, e quindi si fa sempre uso di un solo segmento già caricato e di puntatori “near” ove necessario.
Gli unici casi in cui vengono ancora utilizzati i segmenti anche nel modello “flat” riguardano gli interrupt e le eccezioni. Quindi le istruzioni INT, INTO (non presente in x64) e IRET caricano sempre un segmento (o selettore) e un offset, e lo stesso avviene quando si solleva un’eccezione (che va a pescare i valori da apposite entry nella interrupt table).
Si tratta di scenari non eliminabili (d’altra parte l’esecuzione deve passare al codice del kernel o del device, che si trovano in appositi segmenti), e comunque non frequenti (il processore generalmente non passa il tempo a servire richieste di interruzione o a elaborare eccezioni).
Non abbiamo ancora parlato dei selettori, che riprenderemo col prossimo articolo di questa serie, che hanno rappresentato una notevole e più interessante innovazione per alcune caratteristiche che mettono a disposizione e il diverso comportamento del processore (che “fa altro” rispetto a quanto visto finora), e ci si potrebbe chiedere perché dedicare tanto tempo agli obsoleti segmenti.
Il motivo è che, con x64, il futuro s’è, in parte, ricongiunto col passato. In modalità a 64 bit, infatti, i meccanismi messi in piedi coi selettori sono stati eliminati; il processore opera in una modalità sostanzialmente simile a quella reale di 8086 & compagnia, ignorando persino la base dei segmenti quando indirizza la memoria, e facendo uso del solo offset (che, a questo punto, coincide col puntatore).
In realtà la base del segmento continua a essere aggiunta all’offset, ma il valore rimane sempre zero. Le uniche eccezioni sono rappresentate dai segmenti FS e GS (quando usati con gli appositi prefissi), nel qual caso la rispettiva base viene correttamente considerata dal processore per il calcolo dell’indirizzo, come per i vecchi processori.
Ciò si è reso necessario, nonché molto comodo, per indirizzare le variabili locali di un thread oppure per alcune strutture del kernel, che quindi possono essere velocemente raggiunte tramite FS e GS.
Concludendo, abbiamo visto che l’impatto dei segmenti nell’architettura x86 non è poi così pesante in termini di risorse impiegate e anche durante l’esecuzione (il costo fisso rimane il calcolo dell’indirizzo dell’istruzione, ma è una semplice somma di due valori), specialmente lavorando in modalità a 32 bit “flat” (che è quella usata ovunque ormai) che consente di ignorarne l’esistenza.