Per gestire segmenti e selettori a livello applicativo abbiamo visto che sono necessarie poche istruzioni (MOV, PUSH/POP, LDS/LES/LSS/LFS/LGS, CALL/RET, JMP, INT/INTO/IRET), peraltro inutilizzate in un modello di memoria “flat” (fatta eccezione per quelle usate per chiamare il sistema operativo).
Il s.o., invece, fa sempre uso di una parte di queste quanto meno per impostare i selettori (ormai i segmenti 8086 sono, di fatto, inutilizzati) in maniera che l’applicazione che gira nel processo/thread abbia l’ambiente configurato per poter girare, ma tutto ciò non basta per gestire l’intero sistema, per cui già a partire dall’80286 sono state introdotte istruzioni allo scopo.
Questo perché, se è vero che ormai il legacy di x86 nella normale esecuzione non ha praticamente peso, come abbiamo appurato dai precedenti articoli, è anche vero che in ogni caso rimane sempre attivo e che, quindi, i selettori debbano in qualche modo essere caricati almeno una volta, seppur con valori “di comodo” (per abilitare lo spazio d’indirizzamento unico, che copre tutto lo spazio virtuale).
Caricare un selettore significa eseguire un’istruzione MOV o una POP per i dati, oppure una CALL/RET, JMP, o INT/INTO/IRET per quanto riguarda il codice. Operazione che non è possibile se non è stata impostata quanto meno la tabella dei descrittori globali (per maggiori informazioni consiglio sempre di rispolverare il vecchio articolo sull’80286).
Infatti mentre per un segmento 8086 caricare un segmento equivaleva ad avere già immediatamente a disposizione l’indirizzo base da utilizzare per i successivi accessi, fare la stessa operazione con un selettore vuol dire semplicemente piazzare un dato in quel registro, che corrisponde a un indice (e un livello di privilegio) in una delle due tabelle di descrittori (globale o locale).
Per ottenere finalmente l’indirizzo base è, pertanto, necessario accedere a una di queste due tabelle, caricando l’apposito descrittore, cioè una struttura dati che consente di specificare non soltanto tale indirizzo, ma anche l’ampiezza dell’area indirizzabile (il cosiddetto limite; per gli offset, ovviamente) e vari altri flag che ne regolano l’accesso.
Risulta fondamentale, allo scopo, avere già sistemato la tabella dei descrittori globali, tramite la nuova istruzione LGDT, la quale, per quanto detto finora, non può che essere privilegiata, in quanto eseguibile esclusivamente al livello di privilegio più elevato (lo 0, dei 4 messi a disposizione dal meccanismo dei selettori; il 3 è usato per le applicazioni).
Lo scopo è semplice: caricare l’indirizzo base (24, 32, o 64 bit) a partire dal quale si estende la tabella, e un valore a 16 bit che ne rappresenta il limite (in modo da intercettare eventuali accessi a descrittori inesistenti o riservati). La duale è SGDT, che memorizza i valori attuali in memoria.
Un’esigenza simile c’è anche per interrupt ed eccezioni. Prima dell’introduzione della modalità protetta la CPU utilizzava il primo KB di memoria dello spazio d’indirizzamento per ottenere il puntatore al codice relativo a una ben precisa entry di questa tabella (di 256 elementi).
Con la nuova modalità sono sorte due esigenze. La prima è quella di avere un controllo molto più fine, solido, e sicuro sul codice eseguito per queste importantissime nonché delicatissime operazioni, per cui era perfettamente naturale applicare anche qui il concetto di selettori al posto dei segmenti.
La seconda, comune a tanti altri processori, era quella di poter spostare in qualunque punto in memoria tale tabella, ed eventualmente limitare il numero di entry accessibili. L’istruzione LIDT nasce appositamente allo scopo, e funziona esattamente come la LGDT, consentendo, quindi, di caricare l’indirizzo base e il limite dalla memoria. Similmente, la duale è rappresentata dalla SIDT.
Assomiglia a queste anche la LLDT, che consente di caricare la tabella dei descrittori locali, ma la differenza è sostanziale. Non consente, infatti, di specificare direttamente indirizzo base e limite, ma l’istruzione carica un selettore nell’apposito registro (LDTR), e se questi risulta valido (e del tipo giusto) sarà utilizzato per prelevare base e limite dalla tabella dei descrittori globali.
Si tratta, in sostanza, di una doppia lettura dalla memoria: prima il selettore, e successivamente il corrispondente descrittore per recuperare le informazioni che servono. La tabella dei descrittori globali serve, quindi, per conservare un certo numero di tabelle di descrittori locali che il s.o. utilizzerà di volta in volta per impostare gli specifici dati dei vari processi/thread.
Si tratta di un’istruzione un po’ più complicata rispetto alle precedenti, che richiedevano soltanto una lettura (o scrittura, per le store) dalla memoria, ma la cui implementazione non richiede certamente esagerate risorse. Ovviamente la sua duale, la SLDT, risulta molto più semplice, in quanto deve memorizzare soltanto i 16 bit del selettore correntemente caricato.
Molto simile a alla LLDT è l’istruzione LTR, che serve a caricare un selettore nel Task Register (la STR lo memorizza, invece). Anche qui, trattandosi di un selettore, se è valido ed è del giusto tipo, vengono prelevati base e limite dalla tabella dei descrittori globali; questi valori vengono utilizzati per individuare la zona di memoria dove sono memorizzate tutte le informazioni relative a un ben preciso processo/thread.
Com’è stato evidenziato nell’articolo sull’80286, con la modalità protetta è stato introdotto il concetto di task, che consente di implementare lo switch di processi/thread in maniera molto semplice ed efficiente (sebbene vengano richiesti parecchi cicli di clock). Infatti lo stato del task correntemente eseguito viene memorizzato nella zona di memoria individuata dal Task Register (TR), subito dopo viene prelevato il selettore del nuovo task, caricati indirizzi base e limite, e infine caricato lo stato completo per poi cedergli l’esecuzione.
Purtroppo questa interessantissima novità non è stata sfruttata da chi ha scritto i s.o., in quanto pochissimi microprocessori offrono funzionalità equivalenti, per cui i programmatori hanno preferito progettare kernel generici, in grado di essere facilmente adattati al più gran numero di architetture, piuttosto che realizzarne versioni apposite per la modalità protetta degli x86.
In effetti forse è anche un bene che tale funzionalità non sia stata utilizzata e ulteriormente sviluppata, poiché è opinabile cosa s’intenda per “stato” di un processore, tant’è che non vengono memorizzati, ad esempio, i registri dell’FPU né dell’unità SIMD (ove presente) … né altro che potrebbe essere utile per un processo/thread.
Difatti si rende necessario l’uso di un bit presente nel registro CR0 (ex MSW, Machine Status Word, introdotto nell’80286) per indicare se si è verificato un task switch, in modo che il kernel possa valutare l’opportunità di conservare lo stato di tutti i registri che non sono stati conservati nello switch. Allo scopo è utile anche l’istruzione CLTS, che serve ad azzerare questo flag, una volta portata a termine l’operazione.
Sarà, dunque, per questi motivi che in modalità a 64 bit (x64) non è presente alcun supporto ai task (sebbene debba essere impostato un selettore nel Task Register; infatti non è supportato il meccanismo di context-switch automatico, ma il concetto di task permane), similmente alla tabella dei descrittori locali che in questa modalità è completamente inutile (i selettori sono, di fatto, disabilitati).
Rimane l’esigenza di poter gestire la tabella dei descrittori globali, perché i registri di segmento contengono comunque dei selettori, e in ogni caso FS e GS continuano a essere validi quanto meno per specificare l’indirizzo base. Inoltre anche per interrupt ed eccezioni è ancora necessario poter accedere all’apposita tabella dei descrittori.
Riprendendo il citato registro MSW, sono presenti due istruzioni, LMSW e SMSW, che consentono di caricarvi un valore o salvarlo in memoria. La prima, in particolare, veniva usata per passare dalla modalità reale a quella protetta, imposta alcuni bit in esso presenti. Questa coppia di istruzioni privilegiate va senz’altro contemplata nel legacy, in quanto dall’80386 è presente il più ampio (32 bit) registro CR0, oltre a una coppia di istruzioni MOV che consentono di leggere e scrivere valori presenti in uno qualunque dei nuovi registri di controllo definiti con questa ISA.
Ricapitolando, abbiamo visto che non sono molte le istruzioni privilegiate introdotte per poter gestire la modalità protetta e i selettori, con le sole istruzioni LMSW, SMSW, LLDT e SLDT che si possono configurare come legacy, in quanto non più utilizzate per la presenza di istruzioni più generiche oppure a causa del modello flat che da parecchi anni viene utilizzato dai s.o..
Per concludere, il “peso” di tutte le istruzioni citate (quindi non soltanto di queste ultime) non è elevato. Introdurne una manciata in un’architettura che ne contempla centinaia e centinaia è un’operazione di poco conto. La loro implementazione, poi, è abbastanza semplice, con pochi casi in cui è necessario ricorrere a una doppia indirezione (due letture consecutive e dipendenti).
A runtime, eseguendo il codice di tutti i giorni, il loro costo è quasi nullo, in quanto nei modelli flat l’ambiente di lavoro del processo/thread è già impostato, salvo per il s.o. che deve eseguirne il setup prima dell’avvio.
Non è trascurabile, però, la gestione degli interrupt, che fanno uso dei cosiddetti interrupt gate, accennati nell’articolo sull’80286. Fin dai tempi dell’MS-DOS questo meccanismo veniva utilizzato per richiamare API del s.o., per cui se ci sono ancora s.o. che lo impiegano, s’incappa nel costo del context-switch (non di tutto lo stato del processore, fortunatamente) e nel controllo dei limiti della tabella degli interrupt (poca roba, comunque: giusto un confronto).
Da parecchio, però, i s.o. mettono a disposizione altri meccanismi e istruzioni allo scopo, nettamente più pratici e veloci. Anche di questo parleremo nel prossimo articolo, in cui analizzeremo brevemente le poche istruzioni non privilegiate rimaste che attengono all’argomento legacy finora trattato.