Ciao a tutti! Avete letto la prima lezione? L'avete studiata bene? La sapete ripetere a memoria??? Allora sarete pronti per un'altra puntata: oggi faremo un passo indietro, e parleremo dell'architettura dei processori Intel 80x86; poi, magari, vedremo qualche altra istruzione, oltre a quelle già viste la scorsa volta.
Ogni nuovo modello dei processori Intel ha qualcosa di diverso rispetto ai suoi predecessori: più velocità, nuove caratteristiche, magari nuove istruzioni che vengono aggiunte. Nonostante questo, ogni nuovo processore Intel è compatibile con i modelli precedenti: ciò vuol dire che se un programma gira su un Pentium, allora, logicamente, può essere eseguito senza problemi anche da un Pentium3 (non è sempre vero il contrario…). In particolare, c'è un modello col quale tutti i processori 80x86 sono compatibili, ovvero il primo: l'8086. I programmi scritti per un 8086, infatti, possono girare anche sui più moderni processori Intel.
L'architettura dell'8086 è la più semplice: ci conviene, quindi, iniziare a vedere questa.
Innanzitutto, l'8086 è un processore a 16 bit: questo vuol dire che ogni suo registro può contenere, al massimo, un numero di 16 bit. Ricordo che il bit è la più piccola informazione trattata dal computer, e può assumere solo due valori: 0 e 1. Un numero composto da un solo bit può assumere solo 2 valori; un numero composto da 2 bit può assumere 4 valori (00, 01, 10, 11). In generale, un numero composto da "n" bit può assumere 2^n valori. Quindi, un numero da 16 bit può assumere 2^16=65536 valori; siccome la numerazione dei registri parte dallo 0 (e non dall'1), il rangedi valori di un registro a 16 bit va dallo 0 a 65535 (cioè (2^16)-1).
I registri dell'8086 si dividono a seconda del loro uso: alcuni di essi servono per gestire numeri, altri servono per lavorare con la memoria del PC.
I REGISTRI PER LA GESTIONE DEI NUMERI
I numeri vengono gestiti dai registri AX, BX, CX, DX. Sono tutti e quattro registri a 16 bit; ognuno di essi, però, può essere "diviso" in due registri da 8 bit (1 byte) ciascuno. Chiarisco questo concetto con un'immagine:
Ogni registro terminante con la "X" può essere considerato come la somma di due registri, uno terminante con la "L" (Lower byte) e un altro con la "H" (Higher byte).
Se io, ad esempio, cambio il valore di "AL" o "AH", cambio anche il valore di "AX":
mov al,01h
mov ah,02h
corrisponde a
mov ax,0201h
I REGISTRI "PUNTATORI"
Se già conoscete un linguaggio di programmazione, allora probabilmente avete familiarità con il concetto di "puntatore". Noi avremo modo di vedere in modo dettagliato il significato di questa parola: per ora, vi dico che un puntatore serve a "puntare" un dato in memoria. Pensiamo alla memoria del PC come ad una grande tabella, divisa in diverse "celle": ecco, un puntatore identifica una singola cella all'interno di questa tabella.
I registri "puntatori" sono quattro: SI, DI, SP, BP. Sono tutti a 16 bit, ma, diversamente dai registri per la gestione dei numeri, questi quattro non possono essere divisi in due "parti".
"SP" (Stack Pointer) e "BP" (Base Pointer) vengono generalmente usati nelle operazioni di gestione dello stack (un'altra cosa che vedremo dopo…spero solo di non perdere il conto di tutte le cose che dovremo vedere dopo!); "SI" (Source Index) e "DI" (Destination Index) vengono solitamente usati per puntare ad aree di memoria destinate a contenere i dati di un programma.
I REGISTRI DI SEGMENTO
L'8086 prevede quattro registri di segmento a 16 bit: CS (Code Segment), DS (Data Segment), ES (Extra Segment) e SS (Stack Segment). Ehm, a questo punto voi vi starete chiedendo: cos'è un segmento? Vi spiegherò tutto nel prossimo paragrafo.
L'INDIRIZZAMENTO A MEMORIA
Dunque, riprendete l'esempio di prima, in cui parlavo della memoria come di una tabella: ora, diciamo che ogni cella è un byte di memoria. Ma come facciamo, in un programma in assembly, a riferirci ad un singolo byte, e a scrivere o leggere quel byte? Mmh, se usassimo i soli registri puntatori, potremmo riferirci, al massimo, a 65536 bytes…cioè potremmo avere un range di soli 64 kb di memoria! Pensateci un attimo: se io volessi riferirmi al byte 0, dovrei fare, ad esempio:
mov si,0000
mov al,[si] ;trasferisce in AL il byte puntato da SI
Per riferirmi al byte 100, dovrei scrivere:
mov si,100
mov al,[si]
E se io volessi leggere il contenuto del byte 100000? Come faccio, dal momento che SI può contenere al massimo 65535?
Allora, gli sviluppatori dell'8086 hanno "aggirato" il problema in questo modo: per riferirsi ad un byte in memoria non si usa un solo registro, ma una coppia di registri. Il primo è un registro di segmento, il secondo è un registro puntatore. Un segmento non è altro che un'area di memoria di 65536 bytes (64 kilobytes): è all'interno di questa area che andrà a "spaziare" il nostro registro SI o DI (o SP e BP in caso di stack). Il valore contenuto nel registro puntatore è invece definito offset.
L'offset, in pratica, è la posizione del dato voluto all'interno del segmento.
In generale, quindi, un qualsiasi byte in memoria è individuato dalla combinazione dei due valori segmento:offset.
INDIRIZZO LOGICO vs. INDIRIZZO FISICO
Una cosa che, spesso, crea confusione, è la corrispondenza tra indirizzo logico e indirizzo fisico. L'indirizzo logico individua un byte con la coppia segmento:offset. L'indirizzo fisico, invece, è il numero che si riferisce ad un byte in modo lineare; nell'8086 l'indirizzo fisico è un numero a 20 bit (2^20= 1048576 = 1 MegaByte di memoria).
All'inizio si tende a pensare che, per ricavare l'indirizzo l'indirizzo fisico, basti "unire" segmento e offset in un unico numero…sbagliato! In questo modo, infatti, si ottiene un numero a 32 bit (16 bit del segmento + 16 bit dell'offset), che NON corrisponde all'indirizzo fisico effettivo.
Per ottenere l'indirizzo fisico di una locazione in memoria, bisogna innanzitutto considerare il valore del segmento, e moltiplicarlo per 16 (se il segmento è espresso in esadecimale, basta aggiungere uno zero davanti al numero stesso); poi sommare il valore dell'offset…e si ottiene così l'indirizzo fisico.
Ad esempio, se abbiamo l'indirizzo logico 0040h:0017h, possiamo ricavare l'indirizzo fisico in questo modo:
0040h * 10h = 00400h (sto esprimendo i numeri in esadecimale, quindi 10h = 16)
00400h + 00017h = 00417h (questo è l'indirizzo fisico).
Proviamo, ora, a ricavare l'indirizzo fisico di 0000:0417h. Ovviamente, sarà 00417h…uguale a quello di prima! Come vedete, non c'è una corrispondenza biunivoca tra indirizzi logici ed indirizzi fisici…cioè, ad un solo indirizzo fisico corrispondono tanti indirizzi logici. Tenete conto di questo, nei vostri programmi…potreste sovrascrivere parti di memoria importanti senza volerlo!
Nei nostri programmi per 8086 noi non andremo mai a specificare l'indirizzo fisico di un dato; tratteremo sempre indirizzi logici. Sarà compito del processore, poi, calcolare l'indirizzo fisico.
I FLAGS
"Flag" in inglese vuol dire bandiera…e, anche nell'ambito dei computers, i flags sono qualcosa per segnalare. Nell'architettura di un processore, un flag è un bit che indica delle "situazioni", degli stati, in cui la CPU si viene a trovare. Questi bit sono contenuti in un registro (che nell'8086 è a 16 bit), organizzato in questo modo:
15
14
13
12
11
10
09
08
07
06
05
04
03
02
01
00
/
/
/
/
OF
DF
IF
TF
SF
ZF
/
AF
/
PF
/
CF
CF: Carry Flag, viene settato quando c'è un riporto in un'operazione matematica. A volte viene anche settato alla fine delle routine, in caso di errore.
PF: Parity Flag, viene usato per scoprire eventuali errori di trasmissione.
AF: Auxiliary Flag, viene usato con i numeri BCD (…non è importante).
ZF: Zero Flag, viene settato se il risultato dell'operazione è zero.
SF: Sign Flag, viene settato se il risultato dell'operazione è negativo, mentre assume il valore 0 se il risultato è positivo.
TF: se questo flag vale 1, allora il processore si interrompe dopo ogni istruzione (serve ai debuggers).
DF: Direction Flag, serve nelle istruzioni in cui SI e/o DI vengono aggiornati automaticamente (se DF=1, SI/DI decrementano; se DF=0, SI/DI incrementano).
OF: Overflow Flag, indica se viene generato un "overflow".
Sei di questi flags sono detti "di stato": è il processore che si occupa di cambiare il loro valore, automaticamente, dopo un'operazione. I flags di stato sono CF, PF, AF, ZF, SF, OF. Il loro valore è importante per controllare il flusso del programma (cioè per prendere decisioni condizionali).
HELLO WORLD!
Spero di non avervi annoiato troppo, con i paragrafi precedenti (fossi in voi io sarei annoiato…). Ora che siamo espertissimi di registri & c., è giunto il momento del "primo programma" per eccellenza, il famoso "hello world"!
Ecco qui il codice (sempre per A86); io l'ho nominato "hello.asm":
mov dx, offset Messaggio
mov ah,09h
int 21h
mov ax,4c00h
int 21h
Messaggio db "Hello world!$"
All'inizio chiamo l'int 21h, funzione 09h (AH=09h); se consultiamo la lista di Ralph Brown, vediamo che la funzione 09h serve per scrivere una stringa, e che richiede, in DS:DX, l'indirizzo della stringa stessa. E' per questa ragione che, all'inizio del nostro programma, mettiamo "mov
dx,offset Messaggio".
Nell'ultima riga, "Messaggio db "Hello world!$" ", troviamo la direttiva "db". "DB" significa "define byte" e viene usata per inserire, all'interno del listato del programma, dei dati della grandezza di un byte. Qui, in sostanza, noi stiamo dicendo all'assemblatore di inserire, nel file .com che verrà generato, la stringa "Hello world!$"…e gli stiamo anche dicendo di ricordarsi la posizione della stringa in memoria, attraverso l'etichetta "Messaggio". L'etichetta "Messaggio" non è altro che un numero (come vedremo dopo, sarà 010Ch), calcolato dall'assemblatore, che corrisponde all'offset della stringa all'interno del segmento.
Per capire meglio il significato di tutto questo, proviamo ad aprire il file "hello.com" con il debug del dos. Allora, lanciate il prompt del dos, portatevi nella cartella in cui c'è "hello.com", e digitate "debug hello.com". Al prompt del debug (segnalato da un trattino, "-"), digitate "u" (ovvero disassembla). Vi apparirà qualcosa di questo tipo:
C:\PROGRA~2\A86>debug hello.com
-u
0CC3:0100 BA0C01 MOV DX,010C
0CC3:0103 B409 MOV AH,09
0CC3:0105 CD21 INT 21
0CC3:0107 B8004C MOV AX,4C00
0CC3:010A CD21 INT 21
0CC3:010C 48 DEC AX
0CC3:010D 65 DB 65
0CC3:010E 6C DB 6C
0CC3:010F 6C DB 6C
0CC3:0110 6F DB 6F
0CC3:0111 20776F AND [BX+6F],DH
0CC3:0114 726C JB 0182
0CC3:0116 64 DB 64
0CC3:0117 2124 AND [SI],SP
[…]
-q
C:\PROGRA~2\A86>
(Il segmento, che da me è 0CC3, da voi sarà sicuramente diverso…ma questo non è importante).
"u" è il comando da impartire al debug per disassemblare il nostro programma; e, in effetti, come vedete, all'inizio possiamo riconoscere le istruzioni di "hello.asm": vediamo un "mov ah,09", "int 21", ecc. (il debug non mette la "h" alla fine dei numeri, perché per lui sono tutti numeri esadecimali).
Notate che "mov dx, offset Messaggio" è stata tradotta in "mov dx,010Ch". Come potete vedere, all'indirizzo 010ch c'è il valore 48h, che corrisponde proprio alla lettera "H"; poi c'è 65h, che corrisponde a "e", 6ch che corrisponde a "l", eccetera, fino ad arrivare a 24h che corrisponde a "$" (è necessario specificare quest'ultimo carattere, alla fine della stringa, per far capire all'interrupt 21h che la stringa è terminata).
Vi consiglio di usare spesso un debugger: potete vedere la dimensione delle opcodes, e anche il loro corrispondente esadecimale. Inoltre potete osservare come l'assemblatore traduca il codice dei vostri programmi (cioè, potete vedere come viene tradotto, ad esempio, "mov dx,offset Messaggio")…e inoltre, last but not least…anzi, direi molto importante, se ci sono degli errori potete vedere in cosa consistono.
E così si conclude anche questa seconda puntata; abbiamo visto poco codice, lo so…ma ci rifaremo la prossima volta, vedrete!
- Albe