L'architettura x86
Struttura generale

Data

by "AndreaGeddon"

 

26/09/2002

UIC's Home Page

Published by Quequero


bau

bau

(((: lairotut lus otnemmoc elautneve oim ehclauQ

Ok stavolta non mi riapproprio del mio spazio solo perche' hai fatto un bel tutorial, complimenti per la pazienza e la dettagliatezza, bella andreeeee :PPP

micio

micio

....

Home page:http://www.andreageddon.8m.com
E-mail: [email protected]
irc.azzurra.org   #crack-it
 

....

Difficoltà

(x)NewBies ( )Intermedio ( )Avanzato ( )Master

 

Un tutorial un pò più approfondito sull'architettura generale x86. E' un tutorial che ho scritto per la e-zine Security System, la trovate all'url http://securitysystem.flashcom.it/ (pubblicità occulta!)


L'architettura x86
Struttura generale
Written by AndreaGeddon

Introduzione

Questo è solo un testo orientativo, vi consiglio caldamente i manuali Intel per approfondire gli argomenti!

Tools usati

Acrobat reader (per leggere i man intel!)

URL o FTP del programma

quale programma?

Notizie sul programma

Aridaje... nessun programma!

Essay

 

Stiamo per affrontare lo studio dell'architettura e programmazione di un elaboratore, come esempio ci riferiremo all'architettura Intel x86 visto che è tra le più diffuse e anche tra le più potenti. Questo testo cercherà di essere il più esaustivo possibile, tuttavia per approfondire le conoscenze è fondamentale studiare i datasheet degli elaboratori che intendete utilizzare. In fondo al testo troverete links e testi consigliati sull'argomento.

Come ogni cosa l'architettura di un elaboratore può essere vista a livelli: ai più bassi siamo molto a contatto con l'elaboratore, ai più alti siamo ad un livello più astratto e possiamo utilizzare costrutti evoluti che permettono di semplificare l'uso dell'elaboratore. Chiaramente i livelli più alti sono costruiti su quelli più bassi. La prima domanda è: qual'è il livello più basso? E qual'è quello più alto? Teoricamente non ci sono. Dal lato più basso possiamo considerare il livello del codice macchina come più basso, ma se vogliamo scendere possiamo ancora considerare la circuiteria elettronica che compone l'elaboratore, e più in basso ancora il livello dell'elettromagnetismo, e così anche in alto, si può sempre costruire un livello più astratto a partire da quelli precedenti. Noi però non siamo qui per un trattato di filosofia per cui ci limiteremo a studiare solo alcuni livelli che si trovano abbastanza in basso.

Prima di iniziare diamo un piccolo cenno sulle grandezze numeriche e sistemi di numerazione usati per l'elaboratore. L'unità fondamentale come tutti sanno è il BIT, cioè è una grandezza che può contenere solo due stati, 1 o 0. Mettendo insieme più bit si possono ottenere grandezze che contengono un numero più ampio di stati, cioè se abbiamo una grandezza di n bit il numero di stati diversi ottenibili sarà pari a 2^n, il che vuol dire anche che siamo in grado di rappresentare un numero compreso tra 0 e 2^n - 1. La potenza di un calcolatore si esprime sul numero di bit su cui è basata la sua architettura, ad esempio l'architettura x86 è basata su 32 bit (anche se come vedremo può arrivare a manipolare quantità fino a 128 bit). I bit ovviamente non sono usati alla rinfusa, ma sono organizzati in gruppi definiti:

 

4 bit = 1 nibble

8 bit = 1 byte

16 bit = 2 bytes = 1 word

32 bit = 4 bytes = 2 words = 1 dword

64 bit = 8 bytes = 4 words = 2 dwords = 1 quadroword

 

in effetti la grandezza fondamentale dal livello assemblativo è il byte, infatti la cpu ci permette di usare istruzioni sempre orientate all'uso dei byte. Ciò non toglie che possiamo comunque modificare bit per bit tutto quello che vogliamo, infatti al livello assemblativo abbiamo una libertà di azione altissima! Ma questo porta a un discorso più ampio sulla valutazione della programmazione basso/alto livello a cui torneremo in seguito. Un byte contiene 256 configurazioni diverse (2^8), con tale grandezza possiamo rappresentare il set di caratteri ASCII, cioè tutto l'alfabeto maiuscolo, minuscolo, numeri, caratteri speciali e altro; possiamo dunque dire in maniera semplicistica che 1 byte equivale ad un carattere (in realtà le cose stanno un pò diversamente, vedi set unicode). Un'ultima cosa su cui focalizzare l'attenzione è il formato dei dati quando si trovano in memoria. L'x86 infatti adotta la convenzione little endian, cioè quando si ha a che fare con dati più grandi di un byte, in memoria vengono immagazzinati ponendo prima i byte meno significativi. Ad esempio se avete il numero a 32 bit (e quindi 4 bytes) pari a 0x1122AABB, in memoria questo verrà memorizzato come 4 bytes consecutivi ma invertiti: 0xBB 0xAA 0x22 0x11. E così anche per le word, quadroword, etc. etc. Altre architetture invece (ad esempio mips) usano il big endian (per la precisione mips può usare entrambi), non c'è un particolare motivo per usare big o little endian, è solo una questione di convenzioni.

Ora entriamo nel processore ed iniziamo a vedere come è fatto l'x86; la cpu si interfaccia all'esterno con la memoria ram, rom, periferiche etc con dei bus di comunicazione, dalla ram preleva ed esegue le istruzioni e i dati necessari alla computazione, internamente avvengono i calcoli veri e propri. Per svolgere le computazioni il processore è dotato di registri, ovvero dei contenitori temporanei dove scrivere e leggere dati, come se fossero delle comuni variabili in un linguaggio di alto livello. L'architettura P4 ha diversi set di registri, ognuno corrispondente a un set di istruzioni sviluppato a seguito dell'evoluzione dell'elaboratore. I vari set sono:

set standard

set floating point (FPU)

set MMX

set SSE

set SSE2

registri speciali (hanno funzioni particolari, li vedremo alla fine)

 

SET STANDARD

Il più importante è il set standard, composto da quelli che sono definiti i "general purpose registers" e che più o meno sono comuni a tutti gli elaboratori. Vediamo in dettaglio questi registri:

EAX = accumulator

EBX = base

ECX = counter

EDX = data

ESI = source index

EDI = destination index

ESP = stack pointer

EBP = base pointer

EFLAGS = flag register

EIP = instruction pointer

sono tutti registri a 32 bit, però qui occorre fare un pò di storia: in precedenza i registri non erano a 32bit ma erano più piccoli, a 16 bit (o ancora prima anche a 8 bit). I registri eax, ebx, ecx, edx risultano quindi così composti:

 

+-----------------------------------+

|11110000 11110000 11110000 11110000|

+-----------------------------------+

                  |---AH---|---AL---|  8  bit (due registri)

                  |--------AX-------|  16 bit

|----------------EAX----------------|  32 bit

 

gli altri registri invece sono solo passati da 16 a 32 bit (SI -> ESI, BP -> EBP etc etc). Oltre questi registri generali abbiamo anche i registri segmento, che sono

CS = code segment

DS = data segment

SS = stack segment

ES = extra segment

FS = extra segment

GS = extra segment

e questi sono tutti a 16 bit, come vedremo infatti questi segmenti non sono usati nelle computazioni ma solo in questioni di indirizzamento della memoria. Ma ripartiamo dai registri generali: il loro nome fa pensare che ognuno di questi abbia uno scopo preciso, ed infatti ora li vedremo in dettaglio:

EAX: ovvero l'accumulatore. Di default tutto passa attraverso questo registro: per molte istruzioni eax è sottinteso essere il registro da usare come operando, ad esempio se abbiamo una istruzione

 imul  ecx

come operando compare solo ecx ma è sottinteso che il processore moltiplica il registro per eax e salva il risultato sempre in eax (usando anche edx per la parte alta del risultato). Il discorso vale anche in alto livello, ad esempio quando abbiamo a che fare con una funzione e con il suo return value la chiamata a funzione viene tradotta in una istruzione CALL e il return value viene posto in eax in uscita dalla call.

EBX: nessuna funzione precisa, di solito usato come base per indicizzare gli array o le strutture.

ECX: registro contatore. E' usato nelle istruzioni cicliche, come LOOP, REPE, REPNE etc. Queste funzioni di default assumono ecx come counter, cioè ripetono le iterazioni per il numero di volte specificato da ecx, ad esempio

 loop address

salta all'address per un numero ecx di volte, quando ecx raggiunge lo zero il ciclo finisce.

EDX: è usato come data storage generale, ad esempio tornando alla istruzione IMUL, se moltiplichiamo due numeri a 32 bit il risultato potrà essere grande fino a 64 bit, per cui occorrono due registri per manipolare il risultato: il processore comporrà i registri EDX:EAX in modo da tenere i 32 bit alti del risultato in edx e i 32 bit bassi in eax. Per idiv invece in edx viene messo il resto della divisione.

ESI: nelle istruzioni che operano sulle stringhe esi rappresenta il buffer sorgente, ad esempio l'istruzione CMPSB legge la stringa da esi etc etc

EDI: simile ad esi ma rappresenta il buffer di destinazione, ad esempio l'istruzione MOVSD prende una dword dall'indirizzo puntato da esi e la copia all'indirizzo puntato da edi.

EBP: punta sempre alla base dello stack. A runtime lo stack è sempre definito da un puntatore alla sua base e da un puntatore al suo top. In alto livello i linguaggi si basano su ebp e esp per gestire la creazione di stackframes privati di ogni funzione, quindi gestirne i relativi parametri e variabili locali (che appunto risiedono sullo stack).

ESP: il puntatore al top dello stack, viene aggiornato ogni volta che avviene una CALL, PUSH, POP etc. Ricordate che lo stack è come una pila di piatti vista sottosopra: se mettete dei dati nello stack questo crescerà di grandezza, cioè esp viene decrementato, quando invece togliete i dati dallo stack esp viene incrementato. La base quindi si trova ad un indirizzo più grande e il top ad uno più piccolo.

EIP: è il puntatore alla prossima istruzione da eseguire. Non potete modificare direttamente questo registro, cioè non facendo ad esempio un

MOV  eip,  eax

ma lo modificate ogni volta che usate una istruzione di salto (jump) o di chiamata (call). Eventualmente potete modificarlo anche chiamando apposite funzioni del sistema operativo, ma questo è un altro discorso.

EFL: registro eflags. Questo registro non ha un significato come registro a 32 bit, ma ogni suo bit ha una funzione specifica. Anche questo registro non lo si può modificare direttamente, ma lo potete modificare o bit per bit con le apposite istruzioni (ad esempio CLC) oppure con POPF che copia in EFL il dato attualmente puntato dallo stack. Vediamo questi flag in dettaglio:

 

 31 30 29 ... 21 20  19  18 17 16 15 14 13 12 

+---------------------------------------------- - -

| riservati  |ID|VIP|VIF|AC|VM|RF|  |NT| IOPL | ... 

+---------------------------------------------- - -

 

      11 10 09 08 07 06 05 04 03 02 01 00

 - - ------------------------------------+

 ... |OF|DF|IF|TF|SF|ZF|  |AF|  |PF|  |CF|

 - - ------------------------------------+

 

vi menziono per ora i più importanti, per una spiegazione dettagliata potete consultare il manuale intel numero uno capitolo 3.4.3 EFLAGS REGISTER.

CF - carry flag: viene usato per segnalare gli overflow nelle operazioni aritmetiche, ad esempio se abbiamo il numero 0xFFFFFFFF nel registro eax, e lo incrementiamo, otterremo come risultato 0x00000000 e verrà settato il flag carry per salvare il bit in overflow (l'addizione di due numeri a 32 bit può generare un risultato grande fino a 33 bit).

PF - parity flag: viene settato a 1 se, dopo una operazione, nel byte meno significativo del risultato c'è un numero pari di 1.

ZF - zero flag: viene acceso se il risultato di una operazione è zero. E' particolarmente usato con le istruzioni TEST e CMP: l'istruzione TEST esegue un AND logico degli operandi, per cui è molto frequente trovare TEST reg, reg, nel caso reg sia zero l'and restituisce zero e il ZF viene impostato a uno. L'istruzione CMP invece sottrae un operando all'altro, per cui se i due operandi sono uguali il risultato della sottrazione sarà zero e il ZF verrà di conseguenza acceso.

SF - sign flag: prende lo stesso valore del bit più significativo del risultato, ne indica quindi il segno (0 positivo - 1 negativo).

OF - overflow flag: è impostato quando dopo una operazione aritmetica il risultato risulta essere più grande dell'operando che lo deve contenere.

DF - direction flag: nelle istruzioni relative alle stringhe accendere il DF equivale ad usare l'operazione di decremento mentre avere il DF a zero implica una operazione di incremento sui buffer-stringa.

TF - trap flag: quando è settato viene impostato il modo single-step: ogni volta che con un debugger tracciamo il codice il TF viene settato per notificare lo step di una istruzione al debugger stesso tramite una debug_exception (le eccezioni saranno discusse in seguito).

RF - resume flag: usato per controllare la risposta del processore alle debug_exceptions.

VM - virtual mode flag: viene impostato per andare nella modalità V86 ed eseguire in protected mode del codice originariamente scritto per real mode (i modi di operazione saranno discussi in seguito). Viene posto a zero per tornare al protected mode.

Poi ci sono i registri segmento, ma per capirli in pieno dovremmo spiegare i vari modi di operazione della CPU perchè l'uso dei segmenti cambia da modo a modo. Per ora ci limiteremo a dire che in protected mode (la modalità a 32 bit del processore in uso dai sistemi operativi) i segmenti sono dei descrittori, cioè indicano le proprietà della memoria che si usa sotto quel segmento:

CS - code segment: usato insieme al registro EIP, in effetti l'indirizzo globale di una istruzione è determinato da CS:EIP.

DS - data segment: quando ci si riferisce alla memoria è sottinteso che la si sta considerando sotto il segmento DS, l'uso di altri segmenti deve essere specificato direttamente.

SS - stack segment: le operazioni sullo stack fanno riferimento a questo segmento

FS, GS, ES: segmenti extra, il loro utilizzo varia a seconda del sistema operativo.

per ora è un pò scarno come discorso, torneremo sull'uso dei segmenti quando spiegheremo i modi di funzionamento del processore.

 

IL SET FLOATING POINT

La fpu (o coprocessore matematico, anche denotato con la sigla 8087) è una unità che si occupa dei calcoli su numeri grandi fino a 80 bit, può lavorare sui numeri interi così come sui numeri reali in virgola mobile. Il termine "mobile" si riferisce al fatto che il numero di cifre decimali dopo la virgola non è prefissato, infatti alcune fpu meno evolute sono legate ad un numero fisso di cifre dopo la virgola. La CPU usa l'istruzione WAIT per sincronizzarsi con la FPU: prima di iniziare ad usare la fpu infatti il processore deve sincronizzarsi per attendere di volta in volta il completamento dell'operazione da parte della fpu al fine di evitare l'uso di zone di memoria che potrebbero essere già in uso dalla fpu. Questo non vuol dire che la cpu deve usare l'istruzione wait prima di ogni istruzione della fpu, ma solo una volta prima di iniziare ad usare la fpu. Le istruzioni della fpu cominciano tutte per "f" (fimul, fiadd, etc), i registri della fpu sono 8, denominati da ST(0) a ST(7) e sono organizzati a stack: non abbiamo istruzioni di MOV per mettere i valori nei registri, ma li dobbiamo pushare o poppare (con l'istruzione FLD/FST), quindi non possiamo accedere direttamente al registro ST(8), ma di volta in volta solo al registro al top dello stack, ad esempio se abbiamo caricato tre numeri il registro top sarà ST(2). Oltre a questi ci sono anche altri registri di controllo nella fpu: status register, control register, tag register, last instruction pointer, last data operand pointer. Lo status register contiene i flags per la specifica dello stato della fpu, come ad esempio la precisione, l'overflow, lo zero divide, il busy della fpu etc. Il control register invece contiene alcuni flag simili, e altri riguardanti il controllo di precisione, arrotondamento e infinito. Il tag register contiene informazioni su tutti i registri ST(0/7): è un registro a 16 bit, organizzato in 8 gruppi da 2 bit che rappresentano ognuno un registro ST, e per ogni registro il relativo gruppo da 2 bit può assumere le seguenti informazioni:

 

00 - numero valido

01 - zero

10 - non valido: NaN, non supportato, infinito o denormale

11 - vuoto

 

I registri ST hanno una grandezza di 80 bit, i numeri contenuti sono rappresentati con la notazione segno-esponente-mantissa:

bit 79: segno

bit 78 - 64: esponente

bit 63 -  0: mantissa

per convenzione il numero intero usato è 1, questo vuol dire che i numeri saranno del tipo

(segno1.mantissa)^esponente, ad esempio  (-1.3)^12

Questo sistema chiaramente offre potenzialità di calcolo nettamente superiori rispetto al set standard. Ciò non vuol dire che senza fpu non potete fare calcoli in virgola mobile, semplicemente dovreste scrivere delle routine abbastanza complesse per la cpu.

 

IL SET MMX

Questo set è stato ideato per operazioni parallele su grandi quantità di numeri. Le istruzioni di questo set infatti sono denominate SIMD (single instruction multiple data). L'ambiente mmx introduce otto nuovi registri a 64 bit (MM0 ... MM7), anche se "nuovi" non è una parola tecnicamente corretta. Questi registri, infatti, non sono standalone ma sono degli alias ai registri della FPU. I registri della fpu come abbiamo visto infatti sono grandi 80 bit, e la parte relativa all'intervallo bit 0..63 era quella relativa alla mantissa. I registri mmx sono in realtà un alias a questa zona della mantissa, cioè sono mappati nei primi 64 bit degli otto registri della fpu. Questo vuol dire che ogni modifica allo stato della fpu è anche una modifica allo stato dell'ambiente mmx e viceversa (registro tag, control, status), di conseguenza non potete usare i due set di istruzioni contemporaneamente, ma li potete usare solo in maniera sequenziale (una volta finito un blocco di calcolo fpu iniziate un blocco di calcolo mmx e viceversa). Come per la fpu anche per l'mmx il formato dei dati manipolati è diverso: i formati introdotti infatti sono tre:

64 bit packed byte integers

64 bit packed word integers

64 bit packed dword integers

rispettivamente sono strutturati nel seguente modo:

 

0                       64

+-----------------------+

|  |  |  |  |  |  |  |  |    packed byte integers

+-----------------------+

 

0                       64

+-----------------------+

|     |     |     |     |    packed word integers

+-----------------------+

 

0                       64

+-----------------------+

|           |           |    packed dword integers

+-----------------------+

 

ogni registro contiene più dati, vedete che nel caso dei packed byte un registro mmx può contenere otto bytes! In questo modo potete effettuare operazioni con una singola istruzione per operare su tutti e otto i bytes: questo metodo di calcolo in parallelo è stato ideato appunto per operazioni su un gran numero di bytes/words/dwords. Ad esempio:

 

0                       64

+-----------------------+

|  A  |  B  |  C  |  D  |

+-----------------------+

            

            +

0                       64

+-----------------------+

|  X  |  Y  |  Z  |  T  |

+-----------------------+

 

            =

0                       64

+-----------------------+

| A+X | B+Y | C+Z | D+T |

+-----------------------+

 

il set mmx ha due modi di interfaccia: a 32 bit e a 64 bit; il modo a 64 bit è usato nei trasferimenti di dati mmx-mmx o mmx-memoria, il modo a 32 bit è usato nei trasferimenti di dati da mmx a general purpose registers (che appunto sono a 32 bit). In memoria i dati dei registri mmx sono immagazzinati rispettando la notazione little endian. Anche per l'mmx abbiamo problemi di overflow, per cui sono previsti tre metodi di controllo di queste situazioni:

Wraparound

Signed saturation

Unsigned saturation

nel primo metodo il risultato viene semplicemente troncato ed ignorato, nel secondo e nel terzo l'overflow semplicemente viene trattato saturando l'operando con il suo valore estremo nel range rappresentabile. Ad esempio se stiamo considerando byte signed il valore estremo superiore è 0x7F, quello inferiore è 0x80, se invece consideriamo gli unsigned byte allora il valore superiore è 0xFF e l'inferiore è 0x00. Questo significa che un overflow positivo saturerà un signed byte impostandolo a 0x7F e così via. Tutte e tre le tecniche hanno i loro vantaggi e svantaggi, ad esempio se trattiamo le immagini la tecnica di saturazione sarebbe più utile: pensate ad esempio di voler implementare un algoritmo per scurire i pixel, il valore del pixel scende sempre di più fino a raggiungere la saturazione in negativo del byte, per cui se si tentasse di scurire il pixel ulteriormente non si rischierebbe di scendere sotto lo zero (ripartire da 0xFF) e quindi avere il pixel che di improvviso diventa bianco! La tecnica di troncamento invece è utile ad esempio in algoritmi in cui è controllato a priori il valore degli operandi etc etc.

 

IL SET SSE

La sigla sta per "streaming SIMD extensions", è un set introdotto al fine di potenziare il calcolo  riguardante grafica 2D, 3D, audio, video e telefonia, sono una estensione al set MMX. All'inizio questo fece spargere la voce (assolutamente infondata) che il P3 fosse il processore per internet! Il set SSE introduce otto nuovi registri a 128 bit (XMM0..XMM7) ed anche il registro MXCSR, che è un registro di controllo contenente vari flags per la descrizione dello stato xmm. Stavolta i registri sono standalone, cioè non sono alias di altri registri, per cui possono essere usati senza problemi in qualunque momento. I tipi di dato utilizzati da questo set sono come quelli dell'mmx, cioè packed byte/word/dword integers (avendo registri a 128 bit questo set può eseguire quindi calcoli paralleli su un numero doppio di dati alla volta, se prima poteva operare su 8 bytes contemporaneamente adesso può gestirne 16); in più il set sse è capace di usare non solo integers, ma anche numeri in virgola mobile a singola precisione, cioè può effettuare operazioni su quattro numeri single precision floating point (grandi 32 bit) contemporaneamente. Il set fornisce anche delle istruzioni di conversione da single precisione floating point a packed dword integers, da xmm a mmx, da xmm a general purpose registers. I flag dello stato (in MXCSR) contengono tutte le informazioni per l'environment sse, in particolare i bit da 0 a 5 contengono informazioni sulle eccezioni provocate nello stato dell'sse (invalid operation, denormal, divide by zero, overflow, underflow, precision), e i bit da 7 a 12 sono i corrispondenti bit per mascherare le eccezioni (le eccezioni saranno spiegate in seguito). Rimangono solo due flag, il flag flush to zero e il flag denormals are zero, entrambi servono per controllare il comportamento del calcolatore in caso di eccezioni mascherate.

 

IL SET SSE2

Il set SSE2 è una ulteriore estensione al set SSE, infatti utilizza gli stessi registri. In particolare nel set sse2 è stato introdotto il nuovo data type 128 packed double precision floating point, cioè con l'sse2 è possibile usare calcoli in parallelo anche su numeri floating point a doppia precisione (grandi 64 bit), quindi ogni registro mmx potrà contenere due numeri floating point a doppia precisione. In compenso può anche contenere due numeri interi di tipo quadroword, cioè può operare su numeri interi a 64 bit (due in contemporanea). Per il resto il funzionamento è del tutto analogo al set MMX e SSE. 

 

SET DI ISTRUZIONI

Ci siamo dilungati a spiegare i vari aspetti dei vari set, ma non abbiamo ancora parlato di istruzioni. Le istruzioni sono tante, e per conoscerle in modo approfondito vi rimando al manuale intel numero 2, comunque ora vediamo alcune istruzioni generiche del set standard:

-- spostamenti di dati --

MOV dest, src

copia un dato dall'operando sorgente all'operando destinazione. Il size degli operandi deve essere coincidente. I possibili operandi sono registri, memoria, segmenti e valori immediati, tuttavia non li potete comporre a piacimento, ad esempio non potete effettuare un movimento direttamente da memoria a memoria (e questo vale sempre, non solo per MOV!).

LEA  dest, src

sta per "load effective address", src è un operando di memoria e dest è un registro. Carica l'indirizzo del dato puntato dall'operando src, usato di solito in modo indiretto, ad esempio

  LEA  eax, [ebx+8]

in pratica vi permette di ottenere ad esempio l'indirizzo dell'ennesimo byte di un array, o l'ennesimo membro di una struttura.

PUSH src

salva un valore nella memoria puntata dallo stack pointer (esp), e decrementa lo stack pointer per puntare alla prossima locazione da usare, l'operando src può essere un registro, memoria, immediato o segmento.

POP dest

copia dallo stack il dato nell'operando destinazione, che può essere memoria, registro o segmento. Lo stack viene aggiornato (incrementato). In seguito daremo una descrizione dettagliata dello stack.

-- istruzioni aritmetiche --

ADD dest, src

aggiunge l'operando sorgente a quello destinazione, aggiorna i flag Overflow e Carry.

SUB dest, src

sottrae l'operando sorgente a quello destinazione, aggiorna i flag overflow, carry e sign.

IMUL src

IMUL dest, src

IMUL dest, src, immediate

la differenza tra mul e imul è che la prima esegue la moltiplicazione su interi senza segno e la seconda su interi con segno. Nella forma con un solo operando il registro eax viene moltiplicato per l'operando sorgente e il risultato viene messo nel registro composto edx:eax che rappresenta il risultato a doppia precisione (edx è la parte alta, eax la parte bassa). La forma con due operandi funziona come quella con uno, solo che l'operando destinazione non è implicitamente eax ma è specificato nel primo operando, cioè destinazione viene moltiplicato per l'operando sorgente e il risultato viene posto nell'operando destinazione. Nella forma con tre operandi invece la moltiplicazione avviene tra l'operando sorgente e il valore immediato e il risultato viene posto nell'operando destinazione.

DIV/IDIV src

anche qui div sta per la divisione senza segno e idiv per quella con segno. Divide il numero contenuto in edx:eax per il valore dell'operando sorgente, il risultato intero viene posto in eax mentre il resto viene posto in edx.

-- istruzioni logiche --

SHL/SHR dest, count

fa uno shift logico a sinistra/destra di un valore pari a count sull'operando destinazione. Lo shift è un semplice spostamento di bits, il flag carry rappresenta l'ultimo bit uscito (i bit uscenti vanno persi), in entrata invece ci sono sempre bit settati a zero. Lo shift non è altro che una moltiplicazione/divisione per una potenza di due, cioè un numero shiftato a sinistra di 3 equivale a moltiplicare il numero per 2^3, analogamente lo shift a destra di 3 posizioni per un numero equivale a dividere il numero stesso per 2^3.

AND dest, src

esegue l'operazione di and tra l'operando destinazione e quello sorgente e salva il risultato nell'operando destinazione. L'operazione di and è definita come segue:

 0 and 0 = 0

 0 and 1 = 0

 1 and 0 = 0

 1 and 1 = 1

OR dest, src

esegue l'operazione di or tra l'operando sorgente e quello destinazione e salva il risultato nell'operando destinazione. L'operazione di or è definita come segue:

 0 or 0 = 0

 0 or 1 = 1

 1 or 0 = 1

 1 or 1 = 1

NOT dest

inverte i bit dell'operando destinazione.

XOR dest, src

esegue l'operazione di or tra l'operando sorgente e quello destinazione e salva il risultato nell'operando destinazione. L'operazione di or è definita come segue:

 0 xor 0 = 0

 0 xor 1 = 1

 1 xor 0 = 1

 1 xor 1 = 0

in genere l'operazione di xor è utilizzata specificando lo stesso registro sia in destinazione che in sorgente, questo serve semplicemente ad azzerare il registro. E' un semplice trick che consente di risparmiare cicli di clock rispetto a un mov eax, 0.

ROL/ROR dest, count

rotazione di bit a sinistra/destra. E' come lo shift, i bit vengono spostati di un numero di posizioni specificate da count, con la differenza che i bit che escono da un lato rientrano dall'altro. Ad esempio, se voglio ruotare il valore di 10010101 a destra di tre posizioni, il risultato sarà 10101100.

-- istruzioni di controllo --

TEST dest, src

fa un and logico dell'operando sorgente per l'operando destinazione (ma non aggiorna nessuno dei due!) ed aggiorna i flag in EFLAGS per indicare il risultato. Anche questo spesso è usato specificando lo stesso registro sia per destinazione che per sorgente, serve semplicemente a controllare se il registro è zero o meno. Anche questo trick consuma fa risparmiare cicli di clock rispetto a un CMP reg, 0.

CMP dest, src

cmp sta per compare, sottrae l'operando sorgente all'operando destinazione (ma non aggiorna nessuno dei due!) ed aggiorna i flags in EFLAGS per indicare il risultato.

-- controllo del flusso di esecuzione --

CALL dest

serve per chiamare una procedura. Quando usate la call il processore salva sullo stack l'indirizzo di ritorno (cioè quello della riga di codice immediatamente successiva alla call) e poi mette in eip l'indirizzo corrispondente alla procedura. Per tornare alla riga successiva alla call potete usare l'istruzione RET.

RET (n)

prende dallo stack l'indirizzo di ritorno salvato al momento della call e quindi mette in eip l'indirizzo dell'istruzione successiva alla call. Notate che ret non sa dove sia davvero il return address della call, semplicemente prende il valore che si trova nel top dello stack, per cui all'interno della procedura dovete stare attenti a bilanciare correttamente lo stack. In caso di scompenso potete specificare un operando per l'istruzione ret, cioè potete specificare di quanti bytes avete sbilanciato lo stack: in tal modo l'operando n verrà sommato allo stack pointer ed è lì che verrà pescato il return address. C'è anche iret, che sta per interrupt return, ma lo discuteremo in seguito quando parleremo degli interrupt.

JMP/J.. dest

serve per saltare alla destinazione. La forma più semplice è il jump incondizionato (JMP), cioè salta sempre alla destinazione. In genere però sono più utili i jump condizionati, cioè i jump che fanno saltare l'esecuzione solo se la condizione specificata è rispettata. Le condizioni sono rappresentate dallo stato dei flag in EFLAGS; i jump condizionati sono:

 

JA     Jump short if above (CF=0 and ZF=0)

JAE    Jump short if above or equal (CF=0)

JB     Jump short if below (CF=1)

JBE    Jump short if below or equal (CF=1 or ZF=1)

JC     Jump short if carry (CF=1)

JCXZ   Jump short if CX register is 0

JECXZ  Jump short if ECX register is 0

JE     Jump short if equal (ZF=1)

JG     Jump short if greater (ZF=0 and SF=OF)

JGE    Jump short if greater or equal (SF=OF)

JL     Jump short if less (SF<>OF)

JLE    Jump short if less or equal (ZF=1 or SF<>OF)

JNA    Jump short if not above (CF=1 or ZF=1)

JNAE   Jump short if not above or equal (CF=1)

JNB    Jump short if not below (CF=0)

JNBE   Jump short if not below or equal (CF=0 and ZF=0)

JNC    Jump short if not carry (CF=0)

JNE    Jump short if not equal (ZF=0)

JNG    Jump short if not greater (ZF=1 or SF<>OF)

JNGE   Jump short if not greater or equal (SF<>OF)

JNL    Jump short if not less (SF=OF)

JNLE   Jump short if not less or equal (ZF=0 and SF=OF)

JNO    Jump short if not overflow (OF=0)

JNP    Jump short if not parity (PF=0)

JNS    Jump short if not sign (SF=0)

JNZ    Jump short if not zero (ZF=0)

JO     Jump short if overflow (OF=1)

JP     Jump short if parity (PF=1)

JPE    Jump short if parity even (PF=1)

JPO    Jump short if parity odd (PF=0)

JS     Jump short if sign (SF=1)

JZ     Jump short if zero (ZF = 1)

 

LOOP dest

salta alla riga destinazione per un numero di volte specificato da ecx. Ad ogni iterazione il valore di ecx viene decrementato, quando ecx diventa zero smette di iterare e continua l'esecuzione alla riga successiva.

Queste sono le istruzioni base che potete ritrovare più o meno in tutte le architetture.

 

LO STACK

Ne abbiamo parlato parecchio, vediamo ora di spiegare cosa è lo stack e come viene usato. Come sapete lo stack è fondamentale in alcune tecniche di vulnerabilità (buffer overflows), infatti come abbiamo visto le call piazzano nello stack gli indirizzi di ritorno delle procedure: se possiamo in qualche modo controllare quei dati possiamo allora controllare anche il flusso del programma, ma non siamo qui per parlare di buffer overflows! Continuiamo con il nostro stack. L'area dello stack per ogni programma viene assegnata dal sistema operativo, lo stack è una struttura LIFO, cioè last in first out, potete immaginarlo come una pila di piatti (che poi nel nostro caso è messa sottosopra!). Vediamo un esempio:

 

    - - -----------------+

                 | x | x |

    - - -----------------+

   low          esp     high

 

consideriamo che questo sia lo stack, adesso per esempio scriviamo due push:

 push  eax

 push  ebx

e vediamo cosa accade nello stack:

 

    - - --------------------+

            |ebx|eax| x | x |

    - - --------------------+

   low    esp              high

 

ecco dove sono stati messi i valori di eax ed ebx. Ora però se volessimo ripristinarli nei corrispondenti registri non possiamo fare un 

 pop  eax

 pop  ebx

perchè il primo pop pescherebbe il valore sul top dello stack, ma tale valore è quello che prima era ebx! Per cui dovremmo fare

 pop  ebx

 pop  eax

per ripristinarli correttamente, e lo stack tornerebbe allo stato della prima figura.

Ora è interessante capire come lo stack ritorni utile nei linguaggi di alto livello: vediamo cosa succede con le funzioni. In un linguaggio di alto livello (high level language - hll da ora in poi) possiamo usare le funzioni, ad esempio in C possiamo chiamare una funzione nel modo seguente:

 

void funzione(long parametro1, long parametro2)

{

  long a;    //variabili locali

  long b;

 ... }

...

funzione(1, 2);

 

sappiamo che in hll le funzioni hanno il loro ambito di variabili, i loro parametri etc etc. La chiamata alla funzione verrebbe tradotta in questo modo:

 

 push 2

 push 1

 call funzione

 

e all'interno della funzione funzione avremmo:

 

 push ebp

 mov  ebp, esp     crea lo stackframe privato della procedura

 sub  esp, 8       crea lo spazio per le variabili locali

 ...

 

immaginiamo che prima della call lo stack era come nella prima figura dell'esempio precedente, una volta entrati nella call e arrivati al sub esp, 8 lo stack sarebbe nel seguente stato:

 

    - - --------------------------------------+

              | b | a |ebp|ret| 1 | 2 | x | x |   (ogni cella 4 bytes)

    - - --------------------------------------+

   low       esp     ebp       +8  +C        high

           (sub esp, 8)

                  (mov esp, ebp)

 

lo stackframe privato della funzione va da ebp a esp. Prima vengono messi nello stack i parametri della funzione (in ordine inverso a quelli che vediamo nel codice in C), poi la call salva nello stack il return address. A questo punto spostando ebp e esp viene creato il nuovo stack della funzione, e ad esp viene sottratto lo spazio necessario alle variabili locali della funzione stessa. I parametri della funzione (2 e 1) vengono quindi riferiti tramite:

 primo parametro:  [ebp+08]

 secondo parametro: [ebp+0Ch]

mentre le variabili locali vengono riferite con:

 variabile x:  [ebp-4]

 variabile y:  [ebp-8]

in uscita dalla funzione poi lo stack viene ripristinato allo stato precedente alla chiamata. Questa è una possibile implementazione di una chiamata a funzione, poi i compilatori hanno diverse strategie per risolvere il codice C! Come vedete lo stack è importantissimo, ovviamente in hll tutta la gestione dello stack avviene in maniera trasparente al programmatore. Ora capite anche perchè le procedure ricorsive consumano molto velocemente lo stack!

 

GLI INTERRUPT

Le interruzioni sono un'altra feature importante per una cpu. Gli interrupt non sono altro che delle routine associate ad una qualche interruzione hardware o software. Le interruzioni sono chiamate così perchè in genere rappresentano eventi che non possono essere messi in attesa, per cui quando si verificano la cpu deve smettere quello che sta facendo ed eseguire la routine per la gestione dell'evento verificatosi. Ad esempio gli irq hanno tutti una linea di interrupt a cui sono associati (è il sistema operativo che decide su che interrupt mappare un irq, lo si può fare tramite il PIC, programmable interrupt controller, anche noto come 8259a). Gli interrupt possono anche essere dei semplici servizi software che possono essere usati richiamando l'interrupt stesso. Prendiamo ad esempio l'interrupt del timer di sistema (interrupt hw). Una volta stabilita la routine di irq associata al timer, ogni volta che il timer raggiunge la sua frequenza di aggiornamento (che può essere programmata a piacimento) genera il relativo interrupt, e la cpu esegue la routine associata, non importa quale programma stesse eseguendo. Ovviamente questi interrupt non interferiscono con le applicazioni che girano nel sistema, tuttavia in caso di necessità è possibile inibire le interruzioni con l'istruzione CLI. STI invece serve per riabilitarle. Non tutti gli interrupt possono essere inibiti, ci sono alcuni interrupt (chiamati NMI, non maskable interrupt) che non vengono inibiti dal CLI, per poterli inibire bisogna scrivere su una determinata porta hardware, ma questo esula da questo testo, sappiate solo che ci sono interrupt maskable e altri non maskable. La manipolazione degli interrupt è importante, ne riparleremo fra poco quando spiegheremo i modi di operazione della cpu.

 

MEMORIA E PROTEZIONE

Una cpu a 32 bit ci permette ovviamente di indirizzare 2^32 locazioni di memoria. E' vero che l'x86 ha registri grandi fino a 128 bit, ma questi non possono essere utilizzati per indirizzare la memoria. La memoria può anche essere indirizzata con registri a 16 bit (in real mode), vediamo il tutto in dettaglio. I modi di operazione della cpu sono 4, partiamo dal primo:

-- real address mode --

quando avviate il pc il processore parte in modalità reale, il codice del bootsector è eseguito in real mode, poi è il sistema operativo che si occupa di fare un switch in protected mode. Il real mode è la modalità usata dal DOS, ed era anche l'unica modalità disponibile al tempo dell'8086 (l'80286 supportava un rudimentale protected mode, ma l'introduzione del pmode così come la conosciamo oggi avvenne con l'80386). In modalità reale la cpu utilizza un indirizzamento di memoria 1 16 bit, cioè può gestire 64k di memoria. Essendo questo un limite molto basso si dovette ricorrere ad alcuni strategemmi per poter riuscire ad indirizzare una quantità maggiore di memoria. La memoria veniva infatti indirizzata combinando un segmento e un registro (entrambi a 16 bit) per ottenere un valore finale di 20 bit. Questo permetteva di indirizzare 2^20 = 1048576 locazioni di memoria, cioè poco più di un mega (1024k). Vediamo un attimo come funzionava questo indirizzamento:

 

  11110000111100000000     selettore (segmento shiftato a sinistra di 4 bit)

      1111000011110000     offset    (resgistro)

 --------------------------------------------------------------------

  11111111111111110000     real address che andrà sull'address bus

 

in questo modo ogni segmento inizia 16 bit dopo il precedente (lo shift left di 4 bit non fa altro che creare un gap di 16 bytes in 16 bytes per ogni segmento). Ciò non vuol dire che la memoria verrà sovrascritta: ad esempio partiamo dal caso 0000:0000. In questo caso il segmento vale 0 e l'offset anche. Quindi prendiamo il prossimo valore del segmento, cioè 0001. Ora la composizione avviene in questo modo:

 

 0x0001 shl 4 = 0x0010

 real address = 0x0010 + 0000 = 0x00010

 

come vedete con il segmento pari a 0x0001 indirizziamo le locazioni a partire dal sedicesimo byte, solo che il sedicesimo byte era già accessibile dal precedente segmento: infatti sia 0000:0010 che 0001:0000 si riferiscono allo stesso byte in memoria (il sedicesimo), cioè allo stesso real address. Ovviamente i segmenti sono usati in base alla necessità espressa dal programma. Il primo segmento può infatti indirizzare 64k di memoria, ma se il programma ne richiede di meno non ha senso sprecare tutto il blocco! Ecco quindi che la memoria ancora disponibile nel segmento sarà usata tramite l'uso di un segmento successivo, e ciò causerebbe uno spreco di al massimo 15 bytes. Vediamo un esempio per rendere le cose più semplici: abbiamo un programma con la sezione di codice di 10k e la sezione dati di 10k. Per la sezione di codice dovremmo usare lo stesso segmento, e così per i dati. Partiamo ad esempio dall'indirizzo 0000:0000. Per la sezione di codice ci servono 10240 bytes, il che ci porta ad usare il range 0000:0000 -> 0000:2800; abbiamo usato quindi solo una porzione del segmento indirizzabile col selettore 0000. Per mettere ora i dati nella memoria immediatamente successiva non dobbiamo fare altro che cercare un selettore il cui valore sia corrispondente al real address successivo a quello puntato da 0000:2800. Ci basta fare 10240 / 16 = 640, ed ecco quindi che abbiamo il nostro indirizzo per la sezione data: 0280:0000. Infatti se facciamo i calcoli:

 0x0280 lsh 4 = 0x2800 + 0000 = 0x02800 real address

che è lo stesso real address coincidente con la fine della sezione di codice, cioè dell'address 0000:2800. Il nostro programma avrà quindi un selettore 0000 per la sezione di codice e un selettore 0280 per la sezione di dati. Di nuovo si pone il limite di memoria, un mega indirizzabile è ancora troppo poco, per cui sono stati introdotti i dos extenders (ricordate il famoso dos4gw ?) che avevano il compito appunto di riuscire a gestire memoria indirizzata oltre il primo mega. Abbiamo detto che in real mode siamo in modalità 16 bit, tuttavia era cmq possibile utilizzare i registri a 32 bit e i registri della fpu! Semplicemente non potevano essere utilizzati per indirizzare memoria. Questo modello di operazione era abbastanza limitato, per cui fu implementato il protected mode.

-- protected mode --

il modo protetto è alla base di tutti gli odierni sistemi operativi (windows, linux, bsd etc), è la modalità nativa dell'intel x86 (dal pentium in poi) e lavora a 32 bit. Il termine "protetto" si riferisce al fatto che tutto deve sottostare agli anelli (ring) di privilegio: ci sono quattro anelli, ring0 ... ring3, il ring3 è quello con il privilegio più basso, il ring0 è quello con il privilegio più alto. L'uso di registri a 32 bit permettono di indirizzare 2^32 = 4294967296 = 4 gigabytes di memoria. Vediamo ora in dettaglio come funziona la memoria in modalità protetta: qui dobbiamo fare un richiamo ai registri speciali di cui abbiamo parlato all'inizio, vediamoli rapidamente

 

 LDTR = registro che contiene il size e l'indirizzo della local descritpor table

 GDTR = registro che contiene il size e l'indirizzo della global descriptor table

 IDTR = registro che contiene il size e l'indirizzo della interrupt descriptor table

 TR = task register che contiene le informazioni sul tss del task in corso

 CR0 = control register che contiene flags per il controllo dello stato e del modo di operazione della cpu

 CR1 = riservato

 CR2 = contiene il linear address dell'istruzione che ha causato un page fault

 CR3 = page directory base register, contiene l'indirizzo della page directory

 CR4 = flags che controllano varie estensioni dell'architettura

 

e qui tornano nel discorso anche i registri segmento di cui avevamo parlato prima. Nella modalità 32 bit l'indirizzo infatti è formato sempre nel modo

selettore:offset

dove selettore è un registro segmento a 16 bit e l'offset è un registro a 32 bit. Il significato del selettore stavolta è diverso: il segment infatti è solo un indice nella relativa descriptor table (locale o globale). La descriptor table non è altro che un array di descrittori, ogni descrittore indica le proprietà (indirizzo base, grandezza e privilegio) del segmento stesso. Ad esempio quando eseguiamo una istruzione l'indirizzo globale è formato da

CS:EIP

dove il numero di CS è un indice nella descriptor table che ci indicherà il descrittore in uso. Quel descrittore ci fornirà le informazioni sul segmento che andiamo ad usare, e questo assicura la protezione: se infatti il nostro segmento ci dà un privilegio 3 non potremo usare istruzioni che vadano a modificare dati in un segmento a privilegio 0, oppure se tentiamo di spostare l'esecuzione al di fuori dei limiti prefissati dal descrittore del segmento verrà generata una eccezione, e così via. In modalità 32 bit viene implementato il multitasking, cioè l'esecuzione di più processi contemporaneamente: il descrittore locale è specifico per il processo in corso mentre quello globale è valido per tutti i processi. In ogni momento quindi le azioni della cpu sono governate dagli anelli di esecuzione, è chiaro che il codice di sistema risiederà nel ring0 mentre quello applicativo risiederà nel ring3, questo impedisce che per errore una applicazione vada ad interferire con il cuore del sistema operativo. E' chiaro che una cosa importante è il passaggio da ring3 a ring0, questo passaggio può avvenire in vari modi, ad esempio tramite gli interrupt. Abbiamo già visto cosa sono gli interrupt, abbiamo appena visto anche il registro IDTR. Esattamente come per LDT e GDT, anche gli interrupt hanno un descrittore. Il numero di interrupt è l'indice all'interno della descriptor table, e punta ad un descrittore dell'interrupt che indica la locazione della routine associata e i suoi privilegi. In effetti le chiamate alle funzioni del kernel avvengono tramite interrupt nei sistemi operativi, e sempre tramite gli interrupt vengono segnalate le eccezioni. Gli interrupt costituiscono dei gates per passare da un ring all'altro, infatti quando chiamate un interrupt da ring3 e questo ha un privilegio di ring0 non generate eccezioni, semplicemente eseguite la routine associata all'interrput. Per motivi di sicurezza in teoria non è possibile modificare la IDT/GDT/LDT da una applicazione (andare a ring0 significa avere il controllo su tutto, il che sarebbe poco indicato!). Ovviamente quando si sfrutta un int gate bisogna stare attenti: se si usa un int handler con privilegio diverso dal codice chiamante dovete fare attenzione ad usare i segmenti (soprattutto dati e stack), perchè in caso di incoerenza degli stessi è probabile che da un ring non possiate usare dati di un segmento di un altro ring. Per risolvere questo problema bisogna fare un switch del segmento (data o stack switching) per poter usare memoria allocata ed utilizzabile dall'interrupt routine.

Per completare il discorso diamo uno sguardo alla paginazione e segmentazione. La segmentazione è la suddivisione in segmenti del codice, come abbiamo visto si usano i registri segmento per descrivere le proprietà delle aree di memoria usata dal codice, dati, stack etc. La paginazione invece è una suddivisione di memoria in blocchi, ognuno dei quali ha le sue proprietà locali. Ad esempio il segmento di codice può specificare una area di memoria grande un giga, poi questa area sarà suddivisa in pagine, ogni pagina ha un suo size (in genere 4k, ma possono essere usate anche pagine di 2 o 4 mega), ogni pagina può avere attributi tipo: esecuzione, lettura, scrittura, coerenza, etc. Le pagine sono gestite dal sistema operativo, questo sistema può essere implementato in vari modi, il più evoluto è la paginazione su richiesta, ma questo non riguarda questo testo, potete trovare più informazioni a riguardo sul libro "MMURTL v1.0" di Richard Burgess e "Sistemi Operativi" di Avi Silberschatz. Ma come funziona l'indirizzamento e la gestione della memoria? Partiamo dal livello più alto. Come abbiamo detto avendo registri a 32 bit possiamo indirizzare una area di memoria di 2^32 bytes, questa area di memoria a livello fisico è vista come un unico spazio contiguo. Entrano in gioco i segmenti, abbiamo visto il ruolo che hanno, senza paginazione i segmenti descrivono aree anch'esse contigue di memoria. Se usiamo la paginazione la memoria verrà suddivisa in pagine, ogni pagina ha le sue proprietà locali e questo offre notevoli vantaggi per una gestione avanzata della memoria. Tuttavia c'è anche tutto un meccanismo dietro a livello di processore per l'uso delle pagine e la gestione degli indirizzi. Gli indirizzi di memoria possono passare attraverso tre fasi: indirizzo logico, lineare e fisico. Per passare da una fase all'altra il processore effettua una traduzione. Si parte dall'indirizzo logico: è formato da un selettore:offset, l'offset specifica solo la posizione del byte all'interno del segmento. Per formare un indirizzo lineare il processore usa il selettore per trovare il segment descriptor nella relativa table, effettua controlli di accesso (si assicura che l'offset ricada dentro i limiti del segmento e controlla i privilegi di accesso), e poi somma l'offset al base address indicato dal segment descriptor. A questo punto è stato ottenuto un indirizzo lineare, cioè un valore a 32 bit unsigned. Se la paginazione è disattivata allora la traduzione finisce qui, nel senso che l'indirizzo lineare = indirizzo fisico, per cui viene inviato direttamente sul bus. Se la paginazione è attivata, allora è necessaria una ulteriore traduzione dall'indirizzo lineare a quello fisico. L'indirizzo fisico è quello che effettivamente indica nella ram in che posizione si trova il dato a cui ci stiamo riferendo. Per tradurre l'indirizzo lineare in fisico servono delle strutture che descrivono le pagine (page tables e page directories). Il registro CR3 come abbiamo visto contiene l'indirizzo della page directory (PDBR). Ogni entry della page directory (PDE) punta ad una page table, la quale a sua volta contiene delle entry (PTE) che puntano alle pagine fisiche nella ram. Quindi attraverso le pte e pde il processore dall'indirizzo lineare arriva a quello fisico, ma questo vale in caso di pagine da 4k. se si usano pagine di 4mega il procedimento è analogo, solo che non viene usata la page table. Nel caso di pagine a 2mega, infine, la traduzione avviene sempre in modo analogo ma viene usata una tabella che contiene puntatori alle page directory, e poi si procede come per la traduzione per le pagine di 4 mega. Ecco quindi come in protected mode abbiamo un complesso sistema di gestione della memoria che permette di implementare features come il memory swapping, segmentazione paginata e controllo di privilegio costante. Sebbene i ring di privilegio siano 4, attualmente i sistemi operativi ne usano solo 2 (ring0 e ring3), sia perchè a livello di protezione due ring sono sufficienti (sistema - utente), sia per motivi di compatibilità (altre architetture sono basate su 2 livelli di privilegio). La cpu inizia il suo lavoro in real mode, poi sta al sistema operativo switchare in protected mode. Notate che una volta arrivati in protected mode non potete più tornare indietro in real mode, o per lo meno potete fare un switch in real mode ma questo comporterebbe il reset totale di tutto quello che avevate fatto in protected mode (non a caso il back switch in real mode è implementato dai sistemi operativi per effettuare il reboot). In pratica non è che non sia possibile, la cpu può fare il switch quante volte volete, solo che dovete stare molto attenti a tenere quello che vi serve sotto un mega di memoria, altrimenti lo perdete. In effetti il passaggio da protected mode a real mode veniva sfruttato per quello che si chiamava "unreal mode", cioè una volta sotto pmode si settano i selettori in modo che abbiano una grandezza di 4 giga, poi tornando in realmode usando questi selettori si possono riuscire ad indirizzare 4 giga anche se si è in rm! E' un rimedio "a mano" ma comunque valido. Ma se in generale un sistema operativo che gira in pmode non può tornare in rmode, come fa allora il windows (che gira in protected mode) ad eseguire anche vecchi programmi dos che richiedevano l'ambiente real mode? Adesso lo vediamo!

-- virtual-8086 mode --

i manuali intel lo definiscono come un quasi-operating mode, in effetti è una emulazione del real mode che gira sotto protected mode. Lo si può abilitare tramite il bit 17 del registro eflags, in questo modo anche sotto un sistema operatvio multi tasking in protected mode può essere eseguito codice originariamente scritto per il real mode, anche se la compatibilità non è perfetta al 100%. In real address gli interrupt sono modificabili, ovviamente l'environment del modo virtual 8086 è isolato, in modo da non rischiare interferenze con il sistema operativo in protected mode. Il problema della backward compatibility riguarda soprattutto microsoft, infatti Windows ha provveduto ad assicurare un supporto sia per applicazioni DOS che per applicazioni win16 (l'attuale stato è il win32). Per le applicazioni dos viene usato il modo virtual 8086 in cui viene ricostruita una dos box in cui è possibile eseguire software originariamente scritto per real mode. Per il win16 è diverso, l'emulazione avviene completamente in protected mode sfruttando la memoria condivisa, ma questo non è un problema da discutere qui, semplicemente sappiate che per emulare il win16 non è necessario ricorrere al virtual 8086 mode.

-- system management mode --

è un modo messo a disposizione per gestire agevolmente funzioni di power management e OEM. Questo modo viene attivato dal relativo system management interrupt (SMI), che a sua volta è generato dal segnale del relativo system interrupt pin. Una volta in ssm si può tornare al modo operativo di partenza tramite l'istruzione RSM.

 

SERVE ANCORA MEMORIA? PAE!

Fino a qualche anno fa il gigabyte era fantascienza, ora 4 giga di memoria cominciano ad essere pochi! I sistemi operativi in genere suddividono a metà la memoria, riservando due giga ad un processo e 2 giga al sistema. Ovviamente in un sistema multitasking potete avere più processi il che vuol dire che ogni processo avrà i suoi 2 giga di spazio privato, ed avendo a disposizione la segmentazione paginata non è un problema gestire più memoria di quanta se ne abbia fisicamente. Tuttavia 2 giga potrebbero risultare pochi, ad esempio per processi che hanno bisogno di aprire numerosi file di grandi dimensioni, oppure un programma che debba gestire dei database enormi potrebbe non avere spazio a sufficienza per mappare in memoria tutti i dati su cui deve operare. Windows 2000 implementa un sistema per estendere il private process space da 2 a 3 giga, riservando 1 solo giga system wide, ma di nuovo potrebbe non bastare, per cui l'architettura intel ci mette a disposizione 4 bit in più per estendere l'indirizzabilità da 32 a 36 bit, il che significa poter indirizzare 2^36 = 64 giga di memoria. Questo meccanisimo si chiama physical address extension (PAE), per poterlo usare bisogna attivare la paginazione (bit 31 in CR0) e poi abilitare il flag del pae (bit 5 in CR4). L'uso di un indirizzamento a 36 bit ovviamente comporta alcune modifiche nell'iter di gestione della memoria: viene aggiunta una nuova struttura, la page directory pointer table (che di volta in volta punta alla page directory da usare), nell'address translation la page directory pointer table si trova prima della page directory ed è formata da 4 entry da 64 bit, cioè ci permette di usare 4 page directory diverse. Le entry delle page table sono incrementate da 32 a 64 bit e nel CR3 dai 20 bit meno significativi si passa a 27 per specificare non più il pointer alla page directory ma adesso alla page directory pointer (PDPTR). Nella traduzione abbiamo un passaggio in più; si parte dal CR3: da questo otteniamo l'address della page directory pointer table. I bit 30 e 31 ci specificano quale entry usare nella PDPT (30-31 = 2 bit = 4 valori possibili, le 4 entry della pdpt), una volta individuata la pagedirectory abbiamo i bit dal 21 al 29 che ci specificano quale entry usare in questa table, quindi arriviamo ad una page table. I bit dal 12 al 20 indicano quale entry usare nella pagetable, e finalmente arriviamo alla pagina che ch serve. A questo punto non restano altro che i bit da 0 a 11 (2^12 = 4096) che ci indicano l'offset all'interno della pagina. Questo in caso di pagine da 4kb, per pagine da 2 mega il discorso è analogo, solo che dalla page directory si passa direttamente alla pagina (sono saltate le page tables). In questo modo possiamo usare 4 giga alla volta, per usare vari "pezzi" da 4 giga possiamo cambiare o il registro CR3 per puntare ad un'altra pdpt oppure cambiare le entry nella pdpt per puntare ad altre page directories. Con il PSE (page size extension) invece possiamo usare pagine da 4 mega, stavolta però non usiamo page directory pointers, semplicemente con il registro CR3 abbiamo sempre il pointer alla page directory, da qui ogni entry della page directory punta direttamente ad una pagina da 4 mega.

E' da tenere chiaro che usando il PAE (o il PSE) noi usiamo comunque indirizzi a 32 bit, quello che cambia è che sono mappati in un physical address space di 36 bit. Cioè, a livello di indirizzo logico noi abbiamo sempre indirizzi a 32 bit, nel momento della traduzione con il pae possiamo utilizzare le strutture descritte prima per accedere effettivamente ad una area fisica indirizzata a 36 bit, per cui anche se siamo pur sempre legati all'uso di 4 giga possiamo usare più slot da 4 giga (16 in tutto), in modo da usare effettivamente una area fisica di memoria di 64 giga.

 

REGISTRI SPECIALI: DEBUG REGISTERS

Per continuare la rassegna dei registri speciali dobbiamo parlare anche dei debug registers (DR0 .. DR7). L'architettura x86 infatti fornisce ampio supporto per il debugging tramite l'uso di otto registri:

 

 DR0 = Breakpoint linear address 1

 DR1 = Breakpoint linear address 2

 DR2 = Breakpoint linear address 3

 DR3 = Breakpoint linear address 4

 DR4 = riservato

 DR5 = riservato

 DR6 = debug status

 DR7 = debug control

 

i primi quattro specificano l'indirizzo lineare di un memory breakpoint. I memory breakpoint hanno delle condizioni parametrizzabili che sono specificate nel registro DR7. Il DR6 mantiene lo status di debugging, cioè indica se le breakpoint condition sono state rilevate, e poi contiene tre flag che segnalano il rilevamento di un accesso a un debug register, di un single step o di un task switch. Il DR7 invece serve per abilitare, disabilitare ed impostare le break conditions relative al break specificato dal relativo DR0/3. Si possono controllare le impostazioni in modo locale (valide solo per il task in corso) o globali (valide per tutti i task). Sono anche usati due interrupt associati al debugger, il secondo e il quarto vettore (int 1 e int3). Il primo è il responsabile del trapping delle eccezioni di tipo single step (cioè esecuzione passo passo, debug exception), il secondo è il responsabile delle eccezioni di tipo breakpoint (breakpoint exceptions, eccezione su una specifica linea di codice).

Infine rimangono i TEST REGISTERS. Fino al processore 80486 c'erano a disposizione otto test registers (TR0 .. TR7) usati per il testing della cache e altre cose simili, dalla famiglia pentium in poi sono stati introdotti gli MSR (model specific registers). L'istruzione MOV con operando test register su un processore successivo all'80486 genererebbe una eccezione di tipo undefined opcode, per poter accedere agli MSR ci sono a disposizione le istruzioni RDMSR e WRMSR. Gli MSR servono a controllare varie feature hardware e software, come ad esempio il peformance monitoring, le debug extensions e il MTRR (memory type range registers). Questi ultimi furono introdotti dal pentium pro e sono coinvolti nel controllo del caching della memoria e altre funzioni di ottimizzazione per accesso alla memoria.

 

LE ECCEZIONI

In ogni momento la cpu esegue dei controlli sulle istruzioni che esegue per assicurarsi della validità delle stesse. Ad esempio se facciamo una divisione per zero la cpu se ne accorge e segnalerà l'errore con una eccezione di tipo zero divide. Cosa sono le eccezioni? Sono appunto degli errori (ma non solo) che la cpu rileva e segnala. Possono essere di vario tipo, ogni istruzione è potenzialmente in grado di generare eccezioni di tipo relativo al suo impiego (un MOV probabilmente genererà eccezioni di tipo page fault mentre un DIV probabilmente genererà eccezioni di tipo zero divide). Le eccezioni possono essere di tre tipi:

 faults: in genere dovuti ad errori software, a volte non determinano il blocco del software e possono essere riparati

 traps: in genere dovuti ad interrupts, di solito non comportano il blocco del programma nè la perdita della continuità di esecuzione

 aborts: errori molto gravi in genere dovuti all'hardware o a componenti di sistema (descriptor tables), non è possibile ripararli e non forniscono indicazioni precise sull'eccezione avvenuta

in primis le eccezioni sono notificate tramite appositi interrupt (questo però dipende dall'architettura, nell'intel x86 le notifiche di eccezioni avvengono tramite interrupt, nel mips ad esempio c'è un registro che contiene la causa dell'eccezione), poi questi interrupt si occupano di smistare le eccezioni verso gli specifici handlers (chiamati exception handlers), handler questi gestiti completamente dal sistema operativo. L'ultimo destinatario delle notifiche di eccezioni è il processo, se non sono installati exception handlers adeguati ci pensano quelli di sistema a segnalare l'errore ed eventualmente terminare il processo causante l'eccezione. La cpu ovviamente fornisce informazioni sulla eccezione avvenuta in caso di traps o faults, e questo aiuta a ricostruire l'environment che ha causato l'errore e quindi può permettere in alcuni casi di riparare l'errore e continuare. Per le aborts invece c'è poco da fare, il crash di sistema è inevitabile visto che si tratta di errori in componenti di sistema. Le eccezioni sono di tipi diversi

 

0 #DE Divide Error Fault

1 #DB Debug Fault/ Trap

2 NMI Interrupt Interrupt

3 #BP Breakpoint Trap

4 #OF Overflow Trap

5 #BR BOUND Range Exceeded Fault

6 #UD Invalid Opcode (Undefined Opcode) Fault

7 #NM Device Not Available (No Math Coprocessor) Fault

8 #DF Double Fault Abort

9 Coprocessor Segment Overrun (reserved) Fault

10 #TS Invalid TSS Fault

11 #NP Segment Not Present Fault

12 #SS Stack-Segment Fault Fault

13 #GP General Protection Fault

14 #PF Page Fault Fault

15 (Intel reserved. Do not use.)

16 #MF x87 FPU Floating-Point Error (Math Fault) Fault

17 #AC Alignment Check Fault

18 #MC Machine Check Abort

19 #XF SIMD Floating-Point Exception Fault

20-31 Intel reserved. Do not use.

32- 255 User Defined (Non- reserved) Interrupts Interrupt External interrupt or INT n instruction.

 

gli interrupt fino al 31 sono già assegnati e non possono essere usati (il che vuol dire che non potete pensare di rimappare un irq sul vettore 1!). Il resto degli interrupt possono essere usati a piacimento.

 

TASK SWITCHING

Il protected mode è l'ambiente ideale per lo sviluppo di sistemi operativi multitasking, cioè capaci di eseguire più processi in parallelo. L'algoritmo che si occupa della gestione dei task si chiama "scheduler", e si occupa appunto delle operazioni di sequenzializzazione dei task. Sebbene sia un problema considerato di tipo prevalentemente software, la cpu mette a disposizione un meccanismo hardware per la gestione dei processi, per poter effettuare lo scambio di task in esecuzione (task switching). Per task intendiamo una singola unità di esecuzione (un thread tanto per intenderci), ad ogni task è associato il relativo task state segment (TSS). Per effettuare un task switching infatti, bisogna salvare lo stato della cpu nel momento dell'interruzione del task e ripristinare lo stato della cpu del task che si sta per eseguire, e il tss è appunto la struttura che contiene lo status della cpu di un task interrotto (in high level il concetto di environment di un task viene chiamato context). Tale tss è una struttura predefinita che contiene l'immagine dei registri general purpose, i registri segmento, il PDBR, un selettore della ldt, selettori per lo stack, un link al task precedente e una I/O Map Base address, cioè l'indirizzo di una bitmap che specifica le zone di memoria a cui è permesso l'accesso al task. Il tss cambia a seconda se ci si trova ad eseguire un task switching a 16 o 32 bit. Un tss è riferito dal relativo descrittore (installato nella GDT) che poi è usato fisicamente per eseguire il task switch. Il task switching infatti può avvenire in 5 modi:

 - con una call

 - con un jump

 - con un interrupt

 - con una call ad un exception handler

 - con un iret con il flag NT settato in EFL

i primi due metodi sono diretti, nel senso che potete chiamare direttamente il tss descriptor nella gdt, mentre gli altri tre metodi devono passare per forza attraverso un task gate. Il task gate è un ulteriore descrittore che fa riferimento al tss descriptor del task e si trova nella ldt o idt. Effettuando una call o un jump direttamente ad un tss descriptor il processore in automatico salva lo stato della cpu nel tss del task uscente, carica il tss del task in ingresso e ripristina lo stato della cpu, quindi riprende l'esecuzione. Quando invece si usano gli altri tre metodi la idt (in caso di interrupt e eccezione) o la ldt (in caso di iret) devono contenere un task gate, cioè il gate che fa riferimento ad un tss descriptor nella gdt, il quale a sua volta si riferisce al tss del task. Il task switching per task a 16 bit è del tutto identico, cambia solo la struttura del tss che stavolta conterrà valori a 16 bit invece che a 32. Contrariamente a quanto si possa pensare, di solito è preferibile non usare uno scheduler hardware based. Ed infatti gli odierni sistemi operativi (windows, linux, etc) utilizzano tutti uno scheduler completamente software: il task switching hw infatti oltre che essere un pò limitato (il tss infatti non contiene una immagine completa dello status del processore) risulta essere anche più lento.

Qui si conclude questo testo, per maggiori informazioni o per approfondimenti vi consiglio di studiare i libri citati, ricordate: nothing better than datasheet!

 

 Testi utili:

--------------

o IA-32 Intel Architecture Software Developer’s Manual Volume 1: Basic Architecture

o IA-32 Intel Architecture Software Developer’s Manual Volume 2: Instruction Set Reference

o IA-32 Intel Architecture Software Developer’s Manual Volume 3: System Programming Guide

o P6 Family Of Processors - Hardware Developer's Manual

o 8259A Programmable Interrupt Ccontroller (8259A/8259A-2)

o AMD Athlon Processor - x86 Code Optimization Guide

o The Art Of Assembly Language Programming - Randall L. Hyde

o Ralph Brown's Interrupt List - Ralph Brown

o Windows Assembly Language & Systems Programming - Barry Kauler

o 8086-8088 - Programmazione - James Coffron

o Introduzione alla organizzazione e progettazione di un calcolatore elettronico - Franco Preparata

o Architettura del computer - Un approccio strutturale - Andrew S. Tanenbaum

o Peter Norton's Assembly language book for the IBM PC - Peter Norton

 

written by:   AndreaGeddon

email:        [email protected]

sito:         www.andreageddon.8m.com

sito UIC:     www.quequero.cjb.net

thanks:       Ironspark e Albe per aver revisionato questo testo ed aver dato suggerimenti per arricchire alcuni argomenti. Un saluto a tutta la UIC e a tutto #crack-it.

Note finali

Un saluto agli amici di Security System, che sono tutti seri ed austeri. (hahahahahaaaaaaaaaaaaaaaaa)

Disclaimer

Noi reversiamo perchè tanto se non lo facessimo noi lo farebbe qualcun altro!

Capitoooooooo????? Bhè credo di si ;))))