GUIDA Il Linguaggio Macchina del 8086

Pubblicità

DispatchCode

Moderatore
Staff Forum
Utente Èlite
Messaggi
2,332
Reazioni
1,928
Punteggio
134

Il Linguaggio Macchina del 8086 (Parte 1)


Marco (DispatchCode) C.

Cos'è il codice macchina? Codice macchina ed assembly sono la stessa cosa? Come interpreta la istruzioni la CPU?

Credo che a molti di voi sia prima o poi accaduto di pensare a come la CPU interpreta le istruzioni, a cos'è il codice macchina. L'articolo tratterà proprio questi aspetti ad iniziare dalla CPU Intel 8086. La trattazione che ne segue non è divulgativa, pertanto potrebbe risultare di difficile comprensione a chi non ha competenze in materia.

Tutte le informazioni riportate nell'articolo sono frutto di anni di studio personale su materiale "non ufficiale" (autori di libri famosi, ma non appartenenti ad Intel) e materiale "ufficiale" (di Intel). Ho pensato quindi di mettere a disposizione queste conoscenze affinché altri possano trarne vantaggio (per qualsiasi loro scopo personale).

L'articolo è diviso in due parti. La prima tratta, che è la presente, tratta i seguenti argomenti:

  • Panoramica
  • Strumenti e Documentazioni Utili
  • Architettura della CPU
  • Associazioni di default tra Segmento e Offset
  • Puntare ad un indirizzo di memoria
  • Il Linguaggio Assembly
  • Architetture CISC e RISC

potremmo definirla un'introduzione base alla CPU, che permetterà poi di approcciare la seconda parte con più semplicità:

  • Codice Macchina
    • Struttura di un'istruzione
      • Instruction Prefix
      • Segment Override Prefix
      • Instruction Opcode
      • mod_reg_r/m
      • Displacement
      • Immediate
      • Esempi in linguaggio macchina
      • Conclusione

Panoramica


Prima di proporre i materiali utili e gli strumenti da utilizzare, voglio essere certo abbiate compreso a fondo il tipo di viaggio che affronteremo ed il relativo scopo.

Descrivere il viaggio è semplice, per chi lo conosce: inizieremo dando una brevissima panoramica sul linguaggio Assembly, proprio per comprendere cosa vedremo ad un livello più basso; proseguiremo poi con l'architettura CISC/RISC che ci servirà per introdurre proprio il tipo di istruzioni che andremo ad analizzare in seguito; un altro punto che affronteremo prima del codice macchina vero e proprio, sarà l'architettura del processore (brevi cenni su ciò che ci interessa maggiormente, in realtà).
Giunti al codice macchina partiremo proprio alla scoperta della CPU 8086 a 16bit; il viaggio proseguirà poi su CPU a 32bit (in articoli futuri).

L'indice mostrato poco più sopra dovrebbe essere chiaro.
A tratti vi sembrerà sicuramente di leggere un articolo che "esce dal seminato" trattando più concetti relativi ad assembly ed all'architettura, ma è l'unico modo per comprendere poi le istruzioni senza dover spiegare passo passo cosa state vedendo.

Strumenti e Documentazioni Utili


Il viaggio che affronteremo non richiederà strumenti particolari, solo un modesto Hex Editor ed un emulatore per DOS se siamo su un OS a 64bit.Gli strumenti da me utilizzati sono HxD e DOSBox. Se invece possedete un OS a 32bit potrete eseguire gli esempi a 16bit senza problemi con un doppio click. Su OS diversi da Windows è sempre necessario un emulatore (il codice macchina utilizzerà interrupt del DOS).

Un primo file utile e da tenere sempre sotto mano per quanto riguarda le documentazioni è il PDF di intel relativo alla CPU 8086.

- 231455.pdf (Mediafire)

Architettura della CPU


La CPU è composta da svariati circuiti e componenti interni, ciascuno di essi ha compiti ben precisi. Visti gli scopi dell'articolo mi è impossibile trattarli tutti, quindi ne citerò solo alcuni e poi ci concentreremo su ciò che effettivamente un programmatore Assembly utilizza (e di conseguenza, su ciò che è accessibile al programmatore).

La CPU è dotata di circuiti atti alla decodifica, di registri interni, di registri non accessibili al programmatore, di una ALU e di altre parti ancora. Oltre a questi componenti, sul chip è presente anche una cache di primo livello, chiamata L1, che mantiene le istruzioni più utilizzate. Questa cache è molto rapida da consultare per il processore, trattandosi in pratica di un'area interna ad esso. Va inoltre fatto notare che le CPU dei giorni nostri hanno 3 cache, chiamate L1, L2, L3. La cache più vicina alla CPU, accessibile con un costo inferiore in termini di tempo (ma con meno capacità) è la L1; via via seguono le altre,con la L3 che ha costi più alti ma che è grande anche qualche MB.

La cache L1 è come detto interna alla CPU; non è raro trovare anche due cache L1 all'interno della CPU (nelle architetture più moderne). La loro dimensione si aggira attorno ai 16/24KB (ciascuna): prendete con le pinze questo dato, non escludo che CPU recenti abbiano magari cache più grandi.La cache L2 contiene qualche MB di words usate di recente; la L3 è come detto sopra, la più grande. E' difficile stabilire in maniera assoluta dove queste cache risiedano dato che dipende dai progettisti (AMD ed Intel ad esempio hanno differenze nelle progettazioni).

Nella CPU le informazioni transitano sui bus. I bus sono delle "linee di collegamento" e su di esse transitano i bit (1 linea = 1bit).
I bus principali sono 3: il bus di controllo, che abilita e gestisce le operazioni tra i componenti, abilitando ad esempio una cella della memoria; il bus degli indirizzi, su cui transitano gli indirizzi di una cella a cui si deve accedere; ed il bus dati, su cui transitano i dati effettivi da spostare.

L'Address Bus della CPU 8086 è di 20bit, mentre il Data Bus è a 16bit. Prima di continuare, considerate la piedinatura dell'8086:

Immagine tratta da Wikipedia.org
Wyprowadzenie_mikroprocesora_8086.JPG

Dall'immagine mostrata precedentemente possiamo osservare alcune cose: innanzi tutto, possiamo riconoscere l'address bus ed il data bus.L'address bus è indicato dalle linee nominate AD0, sino ad AD19 (notare che sono infatti 20). Il data bus sfrutta esattamente le stesse linee del bus indirizzi una volta che l'address bus ha eseguito la sua operazione (ovvero una volta trasmesso l'indirizzo a cui si dovrà accedere). Guardando bene la figura infatti noterete che le linee da AD0 ad AD15, recano tutte una 'D', mentre le successive hanno solo la lettera 'A'. Quelle linee sono dedicate al data bus.

Il bus indirizzi è quindi a 20bit, e questo significa che è in grado di indirizzare 1MB di memoria (RAM); il data bus è a soli 16bit. I registri interni - che tratteremo a breve - sono quindi a 16bit. Per considerazioni relative al modo in cui viene indirizzata la memoria, vi lascio alla parte sotto spoiler.


(chiedo venia, ma trattandosi di una risposta data in un post, troverete magari riferimenti ad un singolo)

Sotto 8086 la memoria è divisa in segmenti: ciascun segmento è grande 65536byte. Per spostarsi all'interno di un segmento si usa un Offset (spiazzamento, sarebbe il termine corretto). Ecco che nasce quindi la coppia Segmento:Offset.
In pratica, il segmento 0 ha 65536byte, il segmento 1 ha 65536byte... il segmento 65536 ha 65536byte. Questa rappresentazione era necessaria in quanto a quell'epoca si utilizzavano appunto i segmenti (infatti era un disastro quando un programma cresceva di dimensioni); ogni segmento è di 64KB, appunto.
Con 65536 segmenti e 65536 spiazzamenti, puoi indirizzare 2^32 byte di memoria (questo perchè sia i segmenti sia gli offset/spiazzamenti sono a 16bit, quindi 2^16 * 2^16=2^32). Detto così sembra poco, ma in realtà si tratta di 4294967296byte, ovvero 4GB.

Ora la situazione si fa contorta: come diceva anche Madda, l'indirizzo fisico è espresso a 20bit, ma quello logico soltanto a 16. Questo perchè l'architettura dell'8086 era a 16bit; il problema è che il bus degli indirizzi (l'address bus) è a 20bit. 20bit significa 2^20, ovvero 1MB di memoria. In pratica poteva indirizzare 1MB di memoria (ma grazie all'idea dei progettisti spiegata li sopra, divennero ben 4GB).

La coppia logica è formatta come dicevo prima da Segmento:Offset, ciascuno di 16bit. Ciascun segmento di memoria parte inoltre da un indirizzo fisico che è un multiplo di 16, chiamato paragrafo.
Abbiamo alcune "costanti" numeriche da ricordare. 65536 in esadecimale si scrive FFFFh, e sono appunto 16bit. Se utilizziamo 20bit per rappresentarlo, scriveremo 0FFFFh. Il fatto che ciascun paragrafo si trovi ad un multiplo di 16 permette una conversione abbastanza rapida e semplice da una coppia logica (Segmento:Offset) in una fisica.

Vogliamo sapere l'indirizzo fisico (quindi nella RAM) di questa coppia: 1234h:0005h. Qui abbiamo quindi come segmento 1234h, e come Offset 0005h. La conversione in indirizzo fisico avviene come descritto da Madda (16 in esadecimale corrisponde a 10h):

Codice:
(segmento * 10h) + Offset

(1234h * 10h) + 0005h = 12340h + 0005h = 12345h

L'indirizzo fisico corrispondente è quindi 12345h (che come noti è a 20bit). Al posto di moltiplicare per 16, si può utilizzare lo shift a sinistra. Ogni spostamento a sinistra equivale ad una moltiplicazione per 2 del numero. Spostare 4bit a sinistra, significa quindi moltiplicare per 16. Si usa lo shift in quanto è più veloce di una moltiplicazione (in realtà il chip includeva un circuito speciale dedicato a questo tipo di conversione).

La conversione da fisico a logico risulta altrettanto semplice. Si può prendere come esempio l'indirizzo ottenuto nell'esempio qui sopra, 12345h. Per ottenere Segmento:Offset si considerano i bit di livello più alto, sino a contarne 16; gli altri rimanenti rappresentano l'offset. Per farla semplice: se hai l'indirizzo espresso in esadecimale, i primi 4 bit a sinistra rappresentano l'indirizzo logico, quindi:
Codice:
12345h -> 1234h:0005h


Vi sono alcune osservazioni molto importanti da fare ora: se vi è stata spiegato l'indirizzamento lineare, saprai che esiste una corrispondenza biunivoca tra l'indirizzo lineare e l'indirizzo fisico, ovvero: un indirizzo lineare corrisponde ad un indirizzo fisico. Purtroppo con la coppia logica la situazione non è più la stessa in quanto i segmenti sono parzialmente sovrapposti.
Cosa significa in pratica? Per capire, osserva sempre la coppia logica dell'esempio precedente. L'indirizzo fisico risultante è: 12345h. Ma esistono altri modi per ottenerlo:

Codice:
1234h:0005h
1233h:0015h
1232h:0025h
1231h:0035h
1230h:0045h
122Fh:0055h
122Eh:0065h
122Dh:0075h
.................
.................
0235h:FFF5h

Te ne ho mostrati solo alcuni (e l'ultimo). Qualche anno fa scrissi due righe in Java per convertire indirizzi logici in indirizzi fisici e viceversa. Se lo trovo lo posto, o scrivo due righe in C/C++ o altro e posto l'exe, che magari è più utile.

Come noti comunque, tutte quelle coppie logiche daranno come risultato lo stesso indirizzo fisico.

L'altro aspetto da considerare è che gli ultimi segmenti hanno meno di 65536byte. Qual è l'ultima coppia logica che genera l'ultimo indirizzo fisico? Questa:
Codice:
FFFFh:000Fh

(FFFFh * 10h) + 000Fh = FFFFFh

Come noti è l'ultimo. Questo significa che dell'ultimo segmento sono usati solo 16byte. Il segmento prima invece avrà solo 32byte... e così via a ritroso.

Oltre ai registri non accessibili al programmatore (sono registri temporanei utilizzati dalla CPU), vi sono una serie di registri a cui il programmatore ha accesso. Questi registri prendono il nome di registri generali, registri di segmento e registri speciali. Un registro è una piccola area di memoria - ovviamente interna alla CPU - che consente di salvare una piccola quantità di dati al fine di eseguire poi operazioni su essi. La "piccola quantità di memoria" dipende dalla CPU, dalla sua progettazione, ma in generale si tratta dell'architettura. Abbiamo infatti visto che un data bus di 16bit (quindi di 16linee) permette di spostare 16bit alla volta. Ne consegue che una CPU a 32bit è in grado di leggere 32bit "in un colpo solo", sfruttando tutte le sue linee sul data bus.

I registri che il programmatore può utilizzare sono i seguenti, e sono a 16bit, per quanto riguarda il processore 8086:

1.webp

La prima colonna elenca i registri di segmento: questi 4 registri memorizzano la parte "Segmento", e ciascuno di essi si occupa di un segmento in particolare. Il paragrafo che segue tratterà meglio questi aspetti, ma si possono trovare dettagli alla pagina linkata precedentemente. La memoria sotto 8086 è suddivisa in segmenti, e ciascun segmento non può essere più grande di 64KB (65536byte, rappresentabili in 16bit). I segmenti citati nella tabella consentono di memorizzare le parti principali di un programma; infatti un programma è composto solitamente da almeno 3 segmenti (questo anche sotto CPU superiori alla 8086, come vedremo in seguito) i cui nomi sono: CS (Code Segment), DS (Data Segment), e SS (Stack Segment). I registri segmento come vedremo tra poco vengono associati ad uno degli altri registri della CPU, ed insieme formano una coppia logica o indirizzo logico. L'indirizzo è quindi rappresentato dalla coppia Segmento:Offset (verrà ripreso tra poco questo concetto).

Il registro CS contiene il blocco codice del programma (le istruzioni eseguite dalla CPU, quelle del nostro programma);
il segmento DS contiene invece il segmento Dati del nostro programma, ovvero tutti i dati inizializzati e talvolta no, ma con una dimensione conosciuta (che non cambia nel tempo);
il segmento SS è lo stack del nostro programma. Lo Stack è un'area della memoria RAM usata dalla CPU quando deve ad esempio chiamare delle procedure. Lo Stack contiene i dati che vengono quindi creati e distrutti (come i parametri di una funzione/procedura ad esempio).
Il registro ES è un secondo registro di segmento dedicato ad un blocco dati; la E sta per Extra.

Ricordo che vi è una limitazione molto fastidiosa: 64KB massimi per un segmento, quindi un aumento dei registri permetteva ai programmatori di scrivere programmi più complessi e non dover impazzire per gestire più segmenti.

La seconda tabella contiene invece i registri generali AX, BX, CX, DX: questi segmenti sono solo 4, ma spesso anche i registri speciali SI e DI vengono utilizzati come "generali". I segmenti generali vengono utilizzati per operazioni di assegnamento (come vedremo tra non molto, l'istruzione MOV) e per operazioni matematiche.
Il registro AX è il registro accumulatore, ed è il registro di default per molte operazioni matematiche, oltre che essere il registro di default per i valori restituiti dalle funzioni (di solito restituisce uno stato per sapere se si sono verificati errori o un valore di una funzione); il discorso è valido come vedremo in seguito anche per processori superiori all'8086, dove sarà evidente l'uso di questo registro con le funzioni/procedure.
Il registro BX è il registro base, e viene utilizzato allo stesso modo di AX.
Il registro CX è il registro contatore, ed è l'operando di default utilizzato dalla CPU nel caso di cicli (while).
Il registro DX è il registro dati, e svolte le stesse operazioni di BX; tuttavia nel caso di alcune istruzioni viene utilizzatoper l'input o per l'output (ad esempio, nel caso di una divisione conterrà il resto).

I registri generali sono scomponibili in altri registri più piccoli, di 8 bit ciascuno. I registri AH e AL formano rispettivamente la parte più alta e la parte più bassa del registro AX; così come CH e CL sono la parte più alta e più bassa di CX; BH e BL quelle di BX e DH e DL quelle di DX.

La terza tabella evidenzia i registri speciali. I registri SI (Source Index) e DI (Destination Index) vengono utilizzati come se fossero registri generali, quindi per operazioni matematiche comuni e per spostamenti di dati; un altro utilizzo è destinato all'indirizzamento dei dati.
Il registro SP (Stack Pointer) di norma non è manipolato direttamente dal programmatore, e contiene il puntatore allo stack.
Il registro BP (Base Pointer) si riferisce sempre allo stack, ma è di norma manipolato dal programmatore per accedere allo stack.
Il registro IP (Instruction Pointer) si rifrisce al segmento del codice, e non è utilizzato dal programmatore. La CPU utilizza questo registri per tener traccia dell'istruzione da eseguire, e viene incrementato ad ogni istruzione (o quando serve, come nel caso di salti condizionati/incondizionato, viene impostato al nuovo indirizzo).

La quarta ed ultima tabella mostra il registro dei flags. Questo speciale registro è a 16bit. Alcuni bit sono riservati, altri indicano lo stato del processore (e non sono accessibili); altri ancora invece vengono settati per indicare lo stato di alcune operazioni, precisamente, quelle matematiche. E' raro che il programmatore debba settare dei bits in questo registro. Di norma il programmatore si occupa della lettura, anche se non in maniera diretta. Esistono alcune istruzioni, come CMP, che usa due operandi per effettuare una comparazione: ciò che fa è una sottrazione senza però memorizzare il risultato. Le istruzioni usate solitamente in seguito a questa istruzione, sono quelle di salto condizionato, ovvero JE, JG, JL e molte altre ancora. Queste istruzioni leggono il bit di loro interesse e se è settato effettuano un salto.

Associazioni di Default tra Registri e Segmenti


Utilizziamo questo breve paragrafo per fare il punto della situazione: i registri della CPU accessibli dal programmatore sono 15. Tra questi vi sono i registri speciali, il registro dei flags, i registri generali e quelli di segmento.
Le istruzioni hanno tutte almeno 1 operando. Quando ne hanno due la sintassi è sempre:

istruzione dest, sorgente

Come ad esempio:

MOV AX, BX

che causa un'assegnazione del valore di BX nel registro AX (corrisponde in alto livello ad un assegnamento tra due variabili, come a = b).

Come si avrà avuto modo di osservare, i registri di Segmento contengono la parte Segmento del programma. I segmenti sempre presenti sono CS (Codice), DS (Dati), e SS (Stack, dati dinamici). Il valore che contengono è quindi l'indirizzo di tale segmento. Per indirizzare un dato al suo interno (quindi per accedere ad una posizione particolare di quel segmento), è necessario utilizzare un puntatore, un altro registro.

La CPU crea le seguenti associazioni di default:

SS è il registro di segmento a cui vengono associati i registri SP e BP.
DS è il registro di segmento a cui vengono associati i registri AX, BX, CX, DX, SI e DI,
CS è il registro di segmento a cui viene associato il regostro IP.

Le associazioni avvengono utilizzando la seguente sintassi: [DS:BX]. In questo caso l'associazione è inutile ed anche ridondante, poichè come abbiamo visto il registro BX è già associato al registro DS. Con alcuni assemblatori l'associazione è anche controproducente visto che l'associazione viene comunque considerata ed elaborata dal compilatore, che - come si vedrà in seguito - genererà un codice macchina più lungo.Supponiamo di avere dei dati nel segmento ES e di utilizzare il registro DI per accedere ad essi e trasferire il contenuto nel registro AX. L'istruzione sarà quindi MOV AX, [ES:DI]

Puntare ad un Indirizzo di Memoria


Sin dagli inizi dell'articolo ho parlato di "memoria indirizzabile" oppure ho utilizzato termini come "indirizzamento" o ancora "la memoria indirizzabile da 8086 è 1MB". Ora è il momento di affrontare questo concetto importante, introducendo l'operatore di deriferimento o di indirezione.

Se avete delle basi di C/C++ o di un linguaggio che permette l'utilizzo di puntatore, avrete anche sentito probabilmente questi termini, o sicuramente avrete sentito parlare dei puntatori. In pratica l'operatore di riferimento è il puntatore, in C indicato come *, che consente di far riferimento ad un indirizzo di memoria per leggerne/scriverne il contenuto.

La particolarità di un puntatore è che viene manipolato il dato, attraverso il suo indirizzo, e non il solo valore. Questo causa il cambio del valore di quel dato a quel particolare indirizzo.Per essere più chiari, ipotizziamo che il registro DX punti ad un indirizzo di memoria (l'offset, in pratica) 1234h. All'interno di questa cella di memoria è presente il valore 10h.

In assembly l'operatore di deriferimento è la parentesi quadrata; quando racchiudiamo dei registri o dei valori tra parentesi quadrate, stiamo accedendo alla memoria puntata da quel valore. Il valore è quindi 10h nel nostro caso, e l'indirizzo di memoria in cui risiede il dato è 1234h.

Codice:
DX 1234h
[DX] 10h

Linguaggio Assembly


Codice macchina ed assembly sono la stessa cosa?
No, o almeno non esattamente. Il linguaggio Assembly è di basso livello, si interfaccia bene con la macchina, e le sue istruzioni sono degli mnemonici; l'assembly ha corrispondenze dirette con il linguaggio macchina.
Come vedremo in seguito, un'istruzione come B4 09 corrisponde in Assembly ad una MOV che coinvolge il registro AH ed il valore immediato 09h (con "immediato" si fa riferimento a valori hard-coded, a delle costanti numeriche). L'istruzione precisamente è la seguente mov ah, 09h

Assembly ed Assembler sono sinonimi?
Anche in questo caso la risposta è No.
Assembly è il linguaggio; Assembler (assemblatore) è invece il software utilizzato per trasformare il codice sorgente Assembly in codice oggetto. Quindi si può dire che l'assembler è un compilatore per il linguaggio assembly, solo meno sofisticato di un compilatore per C, C++ e gli altri. La maggior semplicità deriva dalla vicinanza che ha assembly con le istruzioni in codice macchina.

Assembly dispone di alcuni mnemonici. Qui farò sempre riferimento alla sintassi di Intel, che è la più utilizzata. In tutti i casi in cui sono coinvolte due istruzioni, il primo operando prende il nome di "destinazione" ed il secondo di "sorgente". Alcune istruzioni richiedono invece solo 1 operando.Alcuni degli mnemonici sono: MOV, ADD, DIV, SUB, INC, DEC,PUSH, POP, JMP, Jxx

L'istruzione MOV opera spostamenti tra registro/registro, tra registro/memoria, registro/immediato, memoria/immediato. Come vedremo in seguito dispone di codici differenti per ciascuna di queste operazioni, e di altri "speciali" (più brevi) utilizzati con il registro accumulatore (AX).

L'istruzione ADD svolge l'addizione tra due numero, e salva il risultato nel primo operando (destinazione).

L'istruzione DIV opera la divisione tra due numeri. Ha un solo operando, poichè l'altro è preso di default. L'operando di default è il registro AX, mentre il secondo deve essere specificato. Prima del suo utilizzo è necessario azzerare il registro DX, che conterrà poi il resto della divisione.

L'istruzione SUB opera una sottrazione tra due numeri; il primo operando è sempre quello di destinazione (dove verrà salvato il risultato).

Le istruzioni INC e DEC operano rispettivamente l'addizione e la sottrazione di 1 solo unità, ed usano solo un operando inc ax causa l'incremento del valore di AX.

Le istruzioni PUSH/POP sono molto importanti (anche su 32bit). Queste istruzioni operano sulla memoria (sullo Stack). Entrambe utilizzano solo 1 operando. Utilizzando PUSH si inserisce il valore passato sullo stack; questo causa l'incremento di un registro puntatore (SP). L'istruzione POP estrae dallo stack il valore PUSHato per ultimo. Vale la pena far notare che lo Stack è di fatto una struttura dati LIFO, vale a dire che l'ultimo elemento inserito sullo stack è il primo ad essere estratto.Le istruzioni JMP e quelle con forma Jxx le incontreremo più spesso nella parte dedicata al 32bit, ma svolgono di fatto le stesse operazioni a 16 ed a 32bit.JMP prende il nome di salto incondizionato, in quanto opera un salto senza tenere in considerazione i flags del registro FLAGS.

Le istruzioni con forma Jxx (come JE, JNE, JL, JNL, JG,...) provocano invece quello che viene definito salto condizionato, in quanto l'effettivo salto dipende dai flags del registro FLAGS. Se il bit coinvolti sono settati (o non settati, dipende dal tipo di salto), l'istruzione provoca un salto ad un altro indirizzo.Prima di utilizzare una di queste istruzioni condizionato, si utilizza di norma l'istruzione CMP o l'istruzione TEST.Dati gli scopi dell'articolo, la trattazione di Assembly termina qui; è inutile essere più specifici.

CISC e RISC


I primi elaboratori erano nettamente meno complessi dei processori della nostra epoca, ed erano meno complessi anche dell'8086. Questi elaboratori funzionavano per mezzo di schede programmate; il programma sulla scheda veniva scritto in base alle necessità. I processori moderni invece eseguono le più svariate operazioni.
Possiamo distinguere inoltre due tipi di architetture: l'architettura RISC, e l'architettura CISC.

Le istruzioni RISC (Reduced Instruction Set Computer) sono più rapide da eseguire, e sono anche più semplici rispetto alle CISC. Gli opcodes di macchine con architettura RISC (in breve ogni opcodes è una differente operazione, come somma, addizione, spostamento di dati, etc) sono di norma un numero molto inferiore. MIPS e SPARC sono due esempi di RISC. Il numero di opcodes non raggiunge il centinaio. L'architettura ARM, ad esempio, è RISC.

Le istruzioni CISC (Complex Instruction Set Computer) sono più lente da eseguire, e sono complesse da interpretare. Gran parte dei processori attuali utilizza questa progettazione, ma esistono anche altri set di istruzioni. Il set di x86 utilizza CISC, ed è proprio il set su cui focalizzeremo la nostra attenzione. La CPU 8086 dispone di un numero di opcodes molto elevato, circa 400. La nascita di CISC la si deve al bisogno di istruzioni in qualche modo vicine ai costrutti utilizzati ad alto livello da un punto di vista semantico; inoltre, il peso del codice risultante è ridotto.
La tecnica utilizzata per la loro codifica permette di ottenere un codice macchina molto compatto. Ogni istruzione è convertita in un codice che occupa un determinato numero di byte (nel caso di 8086 al massimo 2); la CPU sa cosa eseguire decodificando ogni byte (il prossimo paragrafo tratterà il codice macchina, e sarà chiara questa strutura).
Attualmente l'approccio utilizzato lo si potrebbe definire ibrido: le istruzioni sono CISC, ma le più semplici vengono tradotte in micro-codice e di fatto interpretate come RISC (essendo più veloce la sua esecuzione); cito da un mio precedente articolo (presente su un altro forum, al momento):

I processori IA-32 infatti usano il microcodice per implementare le istruzioni più complesse. Il microcode è salvato all'interno di una ROM; ogni istruzione ha quindi il corrispondente microcode. Dato che l'accesso continuo alla ROM produrrebbe dei rallentamenti, vi è una cache apposita che memorizza le istruzioni a cui la CPU accede più spesso.

Non è scopo di questo articolo occuparsi del µ-ops (Micro-Ops), quindi procederemo oltre senza ulteriori puntualizzazioni.

Conclusione Prima Parte


La prima parte termina qui.
La seconda è in realtà già scritta, ma aspetterò un pò a postarla. Nella seconda, come da indice, ci occuperemo della struttura delle istruzioni vere e propri, e per concludere ci saranno alcuni esempi in linguaggio macchina.

Spero sia stato di vostro gradimento, alla prossima!
 
Ultima modifica da un moderatore:

Il Linguaggio Macchina del 8086 (Parte 2)


Marco (DispatchCode) C.

Introduzione alla seconda parte


Prima di riprendere il viaggio alla scoperta del codice macchina dell'8086, voglio fare una piccola panoramica sui contenuti del presente articolo, e del precedente. Il precedente articolo forniva basi per comprendere la parte che andremo a scoprire a breve; per questo motivo, consiglio a chi non ha letto la parte precedente di leggerla prima di proseguire nella lettura.
Consiglio a tutti la lettura del paragrafo relativo agli strumenti utili.

Di seguito gli argomenti trattati nell'articolo:
  • Codice Macchina
    • Struttura di un'istruzione
      • Instruction Prefix
      • Segment Override Prefix
      • Instruction Opcode
      • mod_reg_r/m
      • Displacement
      • Immediate
      • Esempi in linguaggio macchina
      • Conclusione

Codice Macchina


Dopo un cammino discretamente lungo siamo giunti al codice macchina. I successivi paragrafi avranno un livello di complessità maggiore e richiederanno pertanto una maggiore attenzione. Cercherò di rendere il tutto scorrevole, nei limiti del possibile.
Se non lo avete ancora fatto, è il momento di munirsi del manuale 231455.pdf linkato nel paragrafo dedicato alle Documentazioni Utili.

Struttura di un'istruzione in Codice Macchina


Come abbiamo detto pocanzi, il set di istruzioni CISC, è non poco tortuoso. Una tale istruzione è composta da diversi campi (che analizzeremo a breve) che possono essere obbligatori, che possono non esserlo e che possono avere 1 o 2 byte. Possiamo dividere un'istruzione in 6 differenti parti, ciascuna di esse con un nome ed una dimensione. Vediamo di seguito quindi i differenti campi:

2.jpg
Ad un primo sguardo si potrà osservare che l'unico campo effettivamente sempre presente è chiamato Opcode; questo campo gode di una grande importanza in quanto identifica effettivamente l'istruzione che stiamo eseguendo (Opcode significa infatti Operation Code).
Le istruzioni che non richiedono altri operandi, come ad esempio CPUID, POPCNT, PUSHA/PUSHD (notare che molte di esse verranno aggiunte in set successivi, e quindi equipaggiano altre CPU), necessitano del solo byte chiamato Opcode. In tutti gli altri casi è necessario almeno 1 byte.

L'immagine ritrae un'istruzione nel suo insieme, è un pò come guardarla al microscopio. Affronteremo tutti i singoli campi, e quasi tutti verranno trattati approfonditamente; i primi due campi sono i prefissi, e li affronteremo nei prossimi due paragrafi.

Instruction Prefix


3.jpg

LOCK quando presente garantisce che l'istruzione avrà accesso esclusivo a tutta la memoria condivisa.
REPNE/REPNZ sono istruzioni che si riferiscono alla manipolazione di stringhe, così come
REP, REPE/REPZ. REP usa un contatore per le iterazioni; per contare utilizza il Couter Register (CX).Le altre invece vengono "influenzate" da uno dei bit del registro FLAGS (ZF, Zero Flag).

Segment Override Prefix


4.png

Questo Prefisso è molto "speciale". Qualche paragrafo sopra si è detto che la coppia logica è formata da un Segmento e da un Offset; è stato aggiunto inoltre che la CPU effettua delle associazioni di default tra i registri di segmento ed i registri generali/speciali/puntatori. Quando uno di questi byte è presente nell'istruzione stiamo facendo un override, stiamo cioè bypassando l'associazione automatica della CPU tra i vari registri.

E' importante evitare di utilizzare l'override dove non necessario. Quindi, se dobbiamo utilizzare [BX], è bene evitare di specificare il segmento relativo, [DS:BX], in quanto questo causa l'aggiunta di 1 byte alla nostra istruzione. Non è così per tutti gli assemblatori, alcuni riconoscono l'associazione e riparano alla disattenzione del programmatore.

Opcode


5.png

Questo campo è molto importante ed identifica il codice dell'istruzione che si vuole eseguire e che verrà inviato alla CPU. Ai più attenti non sarà sfuggito il particolare dei bit: l'opcode è un campo composto da 8bit, ma 2 di questi bit (i bit meno importanti del byte) vengono identificati dalle lettere d e w.

La lettera d sta per direction bit, e identifica un'operazione da o verso registro. In particolare un'operazione come mov ax, [bx] è detta to register in quanto il valore viene assegnato al registro; l'operando di destinazione (ax) è l'operando di riferimento. Se l'operazione è ad esempio mov [bx], ax l'operando di riferimento è il registro. Nel caso di un'operazione come la seguente mov ax, bx l'operando di destinazione è il registro destinazione, ax. L'operando di riferimento è sempre il registro; se l'operazione è tra registri, allora l'operando di riferimento è il registro destinazione.
Possiamo quindi dire che: se d=0, l'operando di riferimento svolge il ruolo di registro sorgente (from register); se d=1 l'operando di riferimento svolge il ruolo di destinazione (to register).La lettera w è il word bit, e identifica semplicemente il tipo di operando coinvolto, se a 8bit o a 16bit. Se w=0 gli operandi sono a 8bit; se w=1 gli operandi sono a 16bit.

Questi due ultimi bit li vedremo in situazioni specifiche combinati con il campo reg del campo mod_reg_r/m.

Quando applicheremo questi concetti vi si chiariranno molti punti; l'importante al momento è che comprendiate i campi ed i significati, in seguito, quando sarà il momento, metteremo tutto assieme.

mod_reg_r/m


I campi sino ad ora analizzati forniscono informazioni importanti ma non sufficienti a garantire l'esecuzione di un'istruzione. Infatti mancano molte informazioni, come ad esempio il secondo operando. Cosa sappiamo del secondo operando? Praticamente nulla. Le uniche informazioni che abbiamo sino a questo punto sono: instruction prefix che è presente nei casi particolari citati sopra; segment override prefix, che è presente in caso si voglia bypassare l'associazione di default dei registri; ed il campo opcode, che fornisce informazioni sul tipo di istruzione (MOV, ADD, PUSH, ...), sul tipo di dati (8 o 16bit tramite il bit w) e sull'operando di riferimento, se è un'operazione da o verso registro (o se il registro non è coinvolto, come nel caso di memoria e valore immediato).

Un esempio di Opcode, è il seguente 001010dw, dove i due campi dw li determineremo ora. Quell'opcode si riferisce ad un'operazione SUB (sottrazione). Che l'operazione coinvolga operandi di tipo Registro o di tipo Memoria, fa poca differenza; l'opcode è il medesimo. Nel caso di un'operazione che coinvolge il registro AX (come registro destinazione) avremmo i campi d e w settati ad 1. Quindi l'opcode risultaten sarà: 00101011.

Ora la domanda: come viene determinata la destinazione? Ed in che modo si capisce se l'operazione è da o verso memoria, o da o verso registro?

6.png

Il campo mostrato nella tabella codifica quindi l'operando sorgente; ma non solo, ci dice anche se si tratta di un registro o di una locazione di memoria. Questo campo è tanto importante quanto complicato.

Il sottocampo reg è il più semplice da descrivere e da ricordare; indica semplicemente il codice dell'operando a cui fa riferimento il direction bit visto in precedenza. Può assumere 23=8 valori, che come potete notare coincidono in numero agli 8 registri (AX, BX, CX, DX, SP, BP, DI, SI). Più avanti verranno mostrati i relativi codici.

Facendo un passo indietro, troveremo il campo mod. Questo sottocampo codifica il tipo di operando e ci dice se si tratta quindi di un Reg o di una Mem. Il campo può assumere svariati valori, in base alla combinazione dei bit; trattandosi di due soli bit, le combinazioni possibili sono: 00b, , 01b, 10b, 11b (ovvero 22). Quando mod=11b, anche il secondo operando è un registro; se è diverso da 11b, il suo valore viene usato assieme a r/m, ed in questo caso significa che il secondo operando è una locazione di memoria (più sotto verranno mostrati gli indirizzamenti possibili alla memoria).
Prima di procedere con le altre descrizioni relative al campo mod, voglio mostrare il codice macchina che identifica i registri:

Codifica Registri Generali e Speciali

7.png

Le precedenti associazioni sono le stesse che riguardano il campo reg citato pocanzi.
Per comprendere la tabella dobbiamo considerare che un'istruzione deve avere una dimensione ben precisa, che nel caso della 8086 si riduce a: operandi a 8bit, oppure operandi a 16bit. Grazie all'associazione utilizzando il word bit la CPU è in grado di identificare correttamente la dimensione del registro (ed ovviamente il registro).

Il secondo prefisso citato (Segment Override Prefix) riportava anche i registri di segmento (ES, CS, SS, DS). La tabella mostrava indirettamente la codifica precisa di questi registri, che tuttavia non è direttamente deducibile senza conoscerne la struttura.

Codifica Registri di Segmento

8.png

Osservando ad esempio il Segment Override Prefix, e precisamente la codifica del registro ES, vedremo questo codice macchina 00100110b. I 2bit centrali, in questo caso 00b, corrispondono ai bit mostrati nella tabella qui sopra. Quindi si può dire che la forma dei Segment Register (abbreviati in SegReg) è 001SegReg110, dove SegReg identifica uno dei registri di segmento.

Quando mod è diverso da 11b l'istruzione può identificare altri tipi di operando, quali Memoria (Mem) e valori Immediati (Imm, in pratica una costante numerica). Nel caso di un operando Imm, ci viene incontro l'Opcode. In questi casi infatti - PDF alla mano a pagina 26 - l'opcode non ha il sottocampo direction bit, ma solo il word bit; il perchè è scontato: ad una costante numerica possiamo assegnare una variabile o una locazione di memoria? Direi sia inutile risponderci in questo caso. Analizziamo piuttosto l'esempio di un'istruzione MOV che coinvolge un operando Reg (o Mem) ed un valore Imm. L'opcode è 1100011w (Immediate to Register/Memory).

Quando l'operando è Mem, non è purtroppo così semplice. Ad inizio articolo si è fatto utilizzo di un termine, indirizzamento, che è poi stato ripreso in un successivo paragrafo ("Puntare ad un indirizzo di memoria"). In sostanza dicevamo come avviene l'accesso ad una locazione di memoria utilizzando uno dei registri. Se inseriamo un indirizzo valido, utilizzando l'operatore di deriferimento (che in asembly sono le parentesi quadre, [ ]), è possibile puntare a quell'indirizzo; vale a dire che accediamo a quell'indirizzo di memoria e modifichiamo il valore contenuto a quella locazione (rileggere la sopra citata sezione se qualcosa non fosse chiaro).

La tabella mostrata di seguito evidenzia tutte le possibili modalità di indirizzamento. Queste modalità le si ricava dal valore del sottocampo mod e dal sottocampo r/m che viene combinato ad esso. Le possibili combinazioni di mod sono 4, ma a queste dobbiamo sottrarne 1, ovvero mod=11b (che codifica la presenza di un registro); quindi ne abbiamo in totale 3. Le combinazioni fornite da r/m essendo di 3bit sono 8 (23). Quindi abbiamo 8*3=24 modalità di indirizzamento!
Gli indirizzamenti possibili sono: [DS:DI] (e le altre analoghe, utilizzando anche gli altri registri di segmento, come [SS:BP]), [DI], [Variabile], ed un'altra, che combina un offset numerico ad un registro (ovviamente, non tutti, la validità è relativa solo a quelli riportati nella tabella di seguito).

Modalità di Indirizzamento

9.png

I registri di segmento sono mostrati per far capire a quale segmento di default viene associato un effective address. In tutti gli effective address mostrati possiamo distinguere 3 componenti: il primo registro prende il nome di base, il secondo registro prende il nome di indice, mentre l'ultima parte (quando presente) indica lo spiazzamento (displacement). Il Displacement è semplicemente una costante numerica che viene sommata ai due registri per calcolare - appunto - l'indirizzo effettivo a cui è necessario far riferimento.
Nel caso ad esempio di mod=01b con r/m=000b abbiamo l'effective address [BX+SI+Disp8], dove BX prende il nome di base, SI di indice e Disp8 di spiazzamento. Come già detto precedentemente, se il programmatore ha necessità di utilizzare un altro registro di segmento, dovrà porre all'inizio il nome del segmento, seguito dai "due punti"; in questo caso quindi, un'ipotetica istruzione potrebbe essere mov ax, [SS:BX+SI+000Ah] dove l'associazione di default viene bypassata dall'override (infatti il registro di segmento di default sarebbe DS).

Displacement


Questo campo è presente solo nel caso di un indirizzamento che prevede una componente Disp; quando un tale indirizzamento è utilizzato, la componente chiamata Disp viene inserita in questo campo (gli esempi chiariranno questo punto).

Immediate


Questo campo è presente nel caso di un trasferimento di un valore immediato all'interno di un registro o di una locazione di memoria. Vedremo con gli esempi che questo è il caso più semplice per comporre un'istruzione.

Esempi Pratici in Codice Macchina 8086 (DOS OS)


A questo punto siamo pronti per iniziare a creare alcuni esempi in codice macchina. Inizieremo da istruzioni semplici, sino a comporre il primo classico codice: Hello World!.

Personalmente non utilizzo strumenti particolar, oltre all'emulatore già citato ad inizio articolo.I piccoli codici di esempio che andremo a comporre gireranno sotto DOS appunto, ma non saranno in formato EXE (è troppo complesso da riprodurre); useremo quindi un formato ormai in disuso, il *.COM. Per la scrittura del codice - come vedrete dagli screen - utilizzerò semplicemente il Blocco Note di Windows; per il salvataggio del .COM mi trovo comodo con HxD, ma potete utilizzare qualsiasi altro hex editor.

Intel e little-endian
Prima di proseguire vorrei porre l'accento sul formato dei byte utilizzato da Intel, noto come little-endian. Questo formato dice che un numero composto da più di 1 byte debba essere rappresentato in memoria al contrario, a partire cioè dal byte meno significativo (quello che normalmente sta a destra).Quindi nel caso di un numero come 0111b, dovremmo scrivere 1101b.

Assegnamento di Imm ad un Reg


Ipotizziamo lo spostamento del valore 09h all'interno del registro AH, mov ah, 09h. Possiamo già escludere i primi due prefissi che compongono l'istruzione, non ci servono. Il successivo byte è sicuramente da utilizzare in quanto si tratta dell'Opcode. Il PDF ci dice che esiste un'operazione Immediate to Register, che è proprio ciò di cui necessitiamo.
Il relativo opcode è 1011_w_reg. Il word bit, che abbiamo già nominato un sacco di volte, deve essere posto a w=0, in quanto il registro AH è a 8bit (è la parte più alta del registro AX). Quindi l'istruzione ora è diventata 10110_reg. In ultimo ci serve sapere la componente reg, come richiesto dall'opcode. La componente la otteniamo dalla tabella relativa ai registri; in corrispondenza di w=0 ed AH troviamo infatti 100b, che è il codice del registro. L'opcode completo sarà quindi 10110100b. A questo punto, come richiesto dall'ultimo campo dell'istruzione, dobbiamo specificare il valore immediato, che è composto da 1byte (nel nostro caso almeno) ovvero 1001b (09h). Il risultato sarà quindi 10110100 00001001 che possiamo anche scrivere in esadecimale per maggior chiarezza, B4 09.

Addizione tra due Reg


Ipotizziamo un'addizione che vede il registro BX nelle vesti dell'operando destinazione, ed il registro AX in quelle di sorgente, add bx, ax.Per prima cosa, saltiamo già i prefissi, sappiamo che non ci servono non essendo coinvolte istruzioni speciali, stringhe o segment override. Il primo opcode ADD è quello che serve a noi e reca la descrizione Reg./Memory with Register to Either. Il valore del direction bit è 0 ed il valore del word bit è 1. Quindi avremo:

00000001b

Abbiamo ora bisogno di indicare che si tratta di un'operazione che coinvolge solo registri ed indicare di quali operandi si tratta. Tutto ciò è indicato dal campo mod_reg_r/m. Questo campo avrà la componente mod settata a 11b, in quanto si tratta di registri. La componente reg al centro contiene il codice dell'operando sorgente, che in questo caso è AX, quindi 000b, mentre la component r/m il codice della destinazione, quindi 011b. Quindi il campo mod_reg_r/m si presenterà:

11000011b

L'intera istruzione add bx, ax in codice macchina corrisponderà quindi a 00000001 11000011b.

Assegnamento tra Mem e Reg


Passiamo ora ad un altro caso più complesso, l'assegnamento con destinazione Mem e sorgente Reg. Supponiamo che l'indirizzo sia 012Dh e che il registro sia AL. Procediamo quindi con la codifica dell'opcode. L'opcode che si riferisce ad un trasferimento tra memoria e registro è 100010dw. Nel nostro caso il registro è a 8bit, ed il trasferimento è from register, quindi dobbiamo porre d=0. L'opcode risultante sarà quindi 10001000. Ora dobbiamo specificare il registro e l'indirizzamento, oltre che al Disp16 (012Dh sono infatti 16bit). La configurazione che fa per noi è quella che utilizza solo Disp16; quindi dobbiamo porre mod=00b con r/m=110b. Il registro che utilizziamo è AL, e quindi la codifica la troviamo sempre nell'altra tabellina (000b). Quindi la codifica di questo campo è 00000110. Come detto in precedenza quando nell'istruzione è presente un Displacement è necessario utilizzare anche l'altro campo, chiamato appunto Displacement, dell'istruzione. Trattandosi di 2byte (16bit), dobbiamo riportare i byte al contrario, quindi 2D 01.L'istruzione in codice macchina risultante è quindi, espressa in binario, 10001000 00000110 00101101 00000001; in formato hex possiamo scrivere 88 06 2D 01. In Assembly l'istruzione è mov [012Dh], al.

Assegnamento tra Mem e Reg con Segment Override


Un altro caso interessante è quello che vede un trasferimento dati da un registro ad una locazione di memoria che utilizza però un Segment Override. Per i nostri scopi possiamo utilizzare il solito indirizzo di prima, ovvero 012D. Come registro utilizziamo questa volta BX, e come segment register utilizziamo ES.

L'istruzione in Assembly che utilizziamo sarà quindi mov [ES:012Dh], bx.
Questa volta è presente un Segment Override Prefix, quindi il primo byte è proprio quello che corrisponde al registro ES, ovvero 26h, 00100110.
Il byte successivo è l'opcode. Questa volta non sarà identico al precedente in quanto stiamo utilizzando un dato a 16bit, e di conseguenza l'opcode 100010dw diventerà 10001001 (w=1, dati a 16bit).
Il campo mod_reg_r/m subirà piccole modifiche in quanto l'unica differenza sono i 3bit centrali che codificano il registro BX, ovvero 011b. Quindi il campo avrà il valore 00011110.
In ultimo, il campo Displacement, che sarà esattamente quello del caso precedente. L'istruzione completa sarà quindi 00100110 10001001 00011110 00101101 00000001.

Vorrei focalizzare la vostra attenzione sul segment override. Ho scelto il registro di segmento ES per rendere l'esempio più "reale", ma se avessi utilizzato il segmento DS sarebbe stata la stessa cosa. Ora, perchè dico ciò? Perchè se avessi scritto mov [DS:012Dh], bx l'assembler avrebbe aggiunto il Segment Override Prefix relativo al segmento DS, anche se di fatto sarebbe stato totalmente inutile considerando che DS è il registro di segmento di default per quel tipo di indirizzamento!

E' bene osservare che l'assembler può fare la differenza. Ci sono assembler che riparano a questi errori (distrazioni) del programmatore, evitando di aggiungere il segment override qualora non dovesse servire; altri assembler invece fanno esattamente ciò che il programmatore scrive!

Assegnamento di Imm a SegReg


Ho deciso di includere anche questo tipo di trasferimento poichè lo si vede quasi sempre all'inizio di un programma a 16bit. Questo tipo di assegnamento permette di inizializzare il registro DS con il valore del segmento dati. Vediamone quindi il relativo codice macchina.

Anche in questo caso l'assembler può fare la differenza. In NASM, ad esempio, è necessario inizializzarlo esplicitamente.

L'opcode da utilizzare in questo caso è semplicemente 10001110. Il campo mod_reg_r/m assume una forma differente questa volta, ovvero mod_0_reg_r/m. Il registro DS è a 16bit, quindi non è consentito utilizzare valori più piccoli; ecco perchè non esiste nemmeno l'opcode che permette di utilizzare un registro differente da AX, BX, CX, DX come operando sorgente. Noterete infatti che i due bit di livello più basso, chiamati rispettivamente d e w hanno questa volta i valori 1 e 0 in quanto il trasferimento è per forza a 16bit e l'operazione è verso registro (to register).
L'istruzione completa è quindi 10001110 00111010, che in Assembly corrisponde a mov ds, dx.

Hello World!


Sino ad ora abbiamo analizzato singole istruzioni, e non parti nel loro insieme. A questo punto vi presento del codice macchina un pochino più completo, si tratta del solito e classico primo codice, l'Hello World!.


Vettori di Interruzione
Sentirete parlare ora di interrupt e di vettore delle interruzioni. Il BIOS così come il sistema operativo DOS dispongono di routine a basso livello per la richiesta di servizi. Questi servizi vengono forniti dai vettori delle interruzioni. Ogni qual volta dobbiamo leggere un dato da tastiera, stamparlo a video, o utilizzare altre operazioni, le dobbiamo richiedere al vettore designato. Il vettore più utilizzato sotto DOS è sicuramente l'interrupt 21h. Una lista consultabile è disponibile a questo indirizzo. Come risorsa utile c'è sempre la famosa Ralf Brown's Interrupt List


Se conosciamo i registri e conosciamo gli interrupt del DOS, non dovremmo avere grandi problemi nel sapere cosa fare; diverso è il come. Ciò che dovremmo utilizzare saranno sicuramente i seguenti elementi:
1) servizio del DOS 21h;
2) dovremmo utilizzare la configurazione ah=09h
3) dobbiamo anche creare una stringa con un carattere terminatore ($).

Il vettore di interruzione ci serve per richiamare proprio il servizio desiderato; 09h è la stampa di una stringa che abbia il carattere terminatore.

Io abitualmente scrivo il codice in questo formato nel Blocco Note:

0100: xx xx
0102: xx xx xx
0105: xx xx


In questo modo so sempre esattamente a quale indirizzo mi trovo. Ricordo che il formato COM vede come entry point l'indirizzo 0100.

Per comodità, non ci occuperemo di segmenti e di altre operazioni; tuttavia il codice sarà comunque direttamente eseguibile dalla macchina. Per prima cosa dobbiamo caricare l'indirizzo della stringa nel registro DX, così da poterla stampare a video con l'interrupt del DOS citato in precedenza. Le stringhe le inserisco di solito al termine del codice per questioni di comodità (se le aggiungessimo all'inizio e ci accorgiamo di aver dimenticato un carattere o di volerne aggiungere anche solo 1, dovremmo ricalcolare tutti gli offset delle istruzioni che seguono!).
Non avendo l'indirizzo della stringa, metteremo una sorta di "segnaposto" al momento. A parte questo, possiamo procedere con la codifica del registro DX e dell'opcode.

L'opcode che ci serve è uno di quelli già visti nelle sezioni precedenti di esempio, e si tratta della MOV da Imm a Reg. La MOV avverrà tra Imm a Regg perchè l'indirizzo che inseriamo nel registro è a tutti gli efetti un numero, non stiamo accedendo a quella posizione di memoria (se avessimo dovuto leggere 1 carattere, allora saremmo stati costretti ad utilizzare Mem). L'opcode ormai lo conosciamo, 1011wreg. Trattandosi di operandi a 16bit abbiamo w=1 e come reg il valore del registro DX, ovvero 010b. Successivamente compileremo il campo Immediate, lasciando l'indirizzo con "xx xx", un etichetta come un'altra, BA xxxx. Lo spostamento successivo è quello che vede il valore Immediato 09h inserito nel registro AH, così da poter chiamare int 21h e stampare la stringa.Questo trasferimento è identico al precedente, ma al posto della codifica di DX ci sarà quella di AH, 100b.

Codice:
0100: BA xxxx
0103: B4 09

A questo punto è necessario invocare la int 21h. Il codice macchina di INT è CD. A questo punto, dato che il programma terminerebbe subito, è bene leggere un carattere dalla tastiera (come il getch() di C). Per farlo, si utilizza l'interrupt 01h del vettore 21h dei servizi del DOS. La codifica è B4 01.Non resta ora che invocare ancora int 21h, ma questa volta con parametri differenti: dobbiamo dire al DOS che il programma è terminato. La chiamata avviene impostando ah=4C e lasciando al=00. Possiamo codificarli separatamente; la codifica è ancora una volta identica alle precedenti, trattandosi di valori Immediati. Cambia solo la codifica del registro; B4 4C e B0 00. Il codice - quasi - completo è:

Codice:
0100: BA xx xx
0103: B4 09
0105: CD 21
0107: B4 01
0109: CD 21
010B: B4 4C
010D: B0 00
010F: CD 21
0111: stringa_da_stampare

Questa è la codifica per intero. Possiamo sostituire prima di tutto le "xxxx" con l'indirizzo della stringa che è 0111, ma che va inserito a partire dal byte meno significativo (little-endian); quindi 11 01. La stringa deve essere inserita in binario (utilizziamo l'hex comodamente però) ed è 48 65 6C 6C 6F 20 57 6F 72 6C 64 21 24. Il '24' è il carattere terminatore della stringa.
Il codice completo che possiamo utilizzare è il seguente BA 11 01 B4 09 CD 21 B4 01 CD 21 B4 4C B0 00 CD 21 48 65 6C 6C 6F 20 57 6F 72 6C 64 21 24.
Per darne anche una rappresentazione in uno pseudo linguaggio assembly:

Codice:
mov dx, stringa
mov ah, 09h
int21h
mov ah, 01h
int    21h
mov ah, 4Ch
mov al, 00h
int21h
stringa db "Hello World!",$

10.jpg

Per provare l'esempio è necessario ora un editor esadecimale. Apriamo HxD > File > Nuovo.... Incolliamo semplicemente il codice e diamo l'Ok al messaggio della modifica della dimensione del file. A questo punto andate su Salva e scegliete una locazione. Se vi trovate su un 32bit è sufficiente un doppio click per l'esecuzione; in caso invece siate - come me - su un 64bit (o su Linux) dovrete utilizzare un emulatore. Per eseguire su emulatore è sufficiente trascinare il file COM sull'icona del programma. Il programma mostra il messaggio, e poi si mette in attesa di input (grazie a 01h del vettore 21h).

Lettura e stampa di due caratteri andando a capo


Ad un livello così basso la manipolazione diretta della memoria risulta anche relativamente semplice. Come in Assembly, è sufficiente memorizzare un indirizzo in un registro, e poi puntare ad esso indirizzando così la memoria ed accedendo di fatto a quella locazione. L'esempio di questa piccola sezione tratterà proprio ciò; per semplicità, utilizzeremo solo due caratteri come input. Non ripeterò il modo in cui si formano gli opcode di ogni singola istruzione, mi concentrerò sulle poche nuove che introdurremo.

In primis, il listato completo:

Codice:
B4 01
CD 21
88 06 2D 01
B4 01
CD 21
88 06 2E 01
B0 24
88 06 2F 01
BA 2A 01
B4 09
CD 21
BA 2D 01
B4 09
CD 21
B4 4C
B0 00
CD 21
0D 0A 24

Per comodità nella copia, lo metto anche inline B4 01 CD 21 88 06 2D 01 B4 01 CD 21 88 06 2E 01 B0 24 88 06 2F 01 BA 2A 01 B4 09 CD 21 BA 2D 01 B4 09 CD 21 B4 4C B0 00 CD 21 0D 0A 24.

11.jpg

Le prime due istruzioni ci sono già ben note, si tratta di mov ah, 01h seguita da int 21h.
La terza è più complessa alla vista, ed in effetti si tratta di un accesso alla memoria, 88 06 2D 01. Il primo byte è l'opcode, ed indica una MOV che coinvolge registri/memoria. Ancora non sappiamo di preciso gli operandi in realtà - anche se i byte successivi ad occhio ci fanno capire molto. L'opcode è quindi 1 0 0 0 1 0 d w. Sono stati settati a 0 anche i bit d e w, quindi sappiamo che gli operandi sono a 8bit e che l'operazione è from register, cioè, sappiamo che il registro non si trova nella posizione di destinazione.
Il campo successivo è mod_reg_r/m, e ci permette di capire subito il tipo degli operandi coinvolti; i singoli bit nei campi sono 00_000_110. Grazie alle tabelle già d'aiuto in precedenza, ricaviamo che il campo reg=000b indica che il registro coinvolto è AX oppure AL; in questo caso il registro è AL in quanto abbiamo w=0. Combinando mod e r/m otteniamo l'indirizzamento che vede come operando Disp16. Si tratta quindi di un'istruzione che vede l'operando di riferimento (il registro AL) nella posizione di sorgente (d=0) e la locazione puntata da Disp16 nella posizione di destinazione.
Proseguendo, abbiamo 2byte. Sappiamo avendo un Disp16 che quel valore è il campo Displacement e che dirà quindi l'indirizzo da puntare. In assembly quindi quell'istruzione la si può scrivere in questo modo mov [012Dh], al.
Le altre istruzioni con stessi opcode e mod_reg_r/m indicano chiaramente un accesso alla memoria identico a questo; l'unica differenza è la locazione puntata.

Riporto di seguito la corrispondenza tra il codice macchina e l'assembly:

Codice:
0100: B4 01          ; mov  ah, 01h
0102: CD 21          ; int  21h
0104: 88 06 2D 01    ; mov  [012D], al
0108: B4 01          ; mov  ah, 01h
010A: CD 21          ; int  21h
010C: 88 06 2E 01    ; mov  [012E], al
0110: B0 24          ; mov  al, 24h
0112: 88 06 2F 01    ; mov  [012F], al
0116: BA 2A 01       ; mov  dx, 012Ah
0119: B4 09          ; mov  ah, 09h
011B: CD 21          ; int  21h
011D: BA 2D 01       ; mov  dx, 012Dh
0120: B4 09          ; mov  ah, 09h
0122: CD 21          ; int  21h
0124: B4 4C          ; mov  ah, 4Ch
0126: B0 00          ; mov  al, 00h
0128: CD 21          ; int  21h
012A: 0D 0A 24       ; 13,10,$

Stampare una stringa di input di lunghezza non prefissata


Questo esempio credo sia il più interessante tra quelli proposti sino ad ora. E' per certi aspetti simile al precedente, ma fa utilizzo di due istruzioni che non abbiamo ancora mai affrontato: JE e JMP.

Come nel caso precedente mostro il codice macchina prima della spiegazione:

Codice:
    BE 2F 01
    B4 01
    cd 21
    3C 0D
    74 06
    88 04 46
    E9 F2 FF
    C7 04 24 00
    BA 21 01
    B4 09
    cd 21
    B8 00 4C
    cd 21
    48 61 69 20 69 6e 73 65 72 69 74 6f 3a

BE 2F 01 B4 01 cd 21 3C 0D 74 06 88 04 46 E9 F2 FF C7 04 24 00 BA 21 01 B4 09 cd 21 B8 00 4C cd 21 48 61 69 20 69 6e 73 65 72 69 74 6f 3a

Lo scopo del programma è svolgere le seguenti operazioni:

- inizializzazione del registro SI con il valore 012F (è l'indirizzo della prima locazione disponibile, vuota);
- legge un carattere da tastiera con mov ah, 01h; questa istruzione è la prima del ciclo While;
- se il carattere è uguale a 0Dh (Invio) esce dal ciclo, altrimenti continua;
- se continua, salva il carattere nella locazione puntata da [SI];
- se continua, incrementa il registro SI e poi esegue un JMP all'istruzione che legge da tastiera;
- Se esce dal ciclo, inserisce il carattere terminatore (24h) a termine della stringa letta in input;
- Inseriesce poi nel registro DX il valore della stringa presente nel codice

Quella stringa non ha un carattere terminatore, in quanto verrà stampata con la stringa immessa in input. L'immagine di output è la seguente:

12.jpg

Una considerazione particolare è da riservare all'istruzione C7 04 24 00. Questa istruzione è un trasferimento di dati da una componente Immediata ad una di Memoria. L'opcode è infatti 1 1 0 0 0 1 1 w. Il direction bit non serve come si diceva in un paragrafo precedente. Il campo mod_reg_r/m ha un'altra forma, ma il principio è sempre lo stesso. Infatti mod ed r/m sono da ricercarsi nella solita tabella che riporta le modalità di indirizzamento disponibili.

L'aspetto più interessante è che l'esempio da me redatto - con tanto di bug poi risolto, lo ammetto - ci permette di vedere come funzionano le istruzioni di salto a basso livello. Iniziamo dalla prima che compare nel listato, ovvero l'opcode 74. Questo opcode corrisponde a JE.PDF alla mano, possiamo notare un'altra cosa interessante: utilizzare JE o JZ è praticamente la stessa cosa. In entrambi i casi l'opcode è 01110100. Notiamo che tutte le istruzioni di salto presentano come byte successivo quella componente disp. Questa componente indica il numero di byte da sommare o sottrarre per effettuare il salto. Con un esempio sarà tutto più chiaro. Mostro l'intero listato con tanto di assembly affiancato:

Codice:
    0100: BE 2F 01                    ; mov    si, 012Fh
    0103: B4 01                       ; ciclo: mov ah, 01h
    0105: cd 21                       ; int    21h
    0107: 3C 0D                       ; cmp    al, 0Dh
    0109: 74 06                       ; je     _exit
    010B: 88 04                       ; mov    [si], al
    010D: 46                          ; inc    si
    010E: E9 F2 FF                    ; jmp    ciclo
    0111: C7 04 24 00                 ;  _exit:  mov    [si], 24h
    0115: BA 21 01                    ; mov    dx, 0121h
    0118: B4 09                       ; mov    ah, 09h
    011A: cd 21                       ; int    21h
    011C: B8 00 4C                    ; mov    ax, 4C00h
    011F: cd 21                       ; int    21h
    0121: 48 61 69 20 69 6e 73 65 72 69 74 6f 3a
    012F:

Considerate l'istruzione JE _exit; il salto avviene se il bit ZF di FLAGS è settato a 1. Il modo in cui avviene è abbastanza semplice: è necessario porre come codice macchina la differenza tra l'indirizzo di destinazione e quello di partenza. Così, nel caso sopra esposto, si avrà 111h - 109h = 8h; a questo valore va tolto il numero di byte dell'istruzione su cui ci troviamo, ovvero 2. Quindi il totale è 6h. Il numero da inserire in disp è proprio questo.

Scendendo troviamo il JMP E9 F2 FF. Il JMP è disponibile in diverse forme in base al tipo di salto che vogliamo effettuare (near (vicino), far (lontano); il salto può essere all'interno dello stesso segmento, nel segmento e vicino, oppure può essere in un altro segmento. In questo caso ho utilizzato il primo Opcode che lo identifica, 11101001. Per quanto riguarda il campo disp il discorso è analogo al caso del salto con JE, solo che avremmo un numero composto da 2byte (una parte alta ed una più bassa, che come ci viene ricordato dal PDF vanno disposte al contrario).
Ricordate: il calcolo avviene sempre sottraendo all'indirizzo di destinazione, quello di partenza. Quindi in questo caso: 103h - 10Eh = FFF5h - 3h = FF2h. Qui 3h sono i byte dell'istruzione che compngono la JMP.

FFF2h, perchè un numero così grande?
In realtà no. Il numero FFF2h è piccolo. Ricordo che l'MSB bit (il più di livello più alto, quello a sinistra) determina il segno del numero. Ciò che abbiamo fatto noi è stato sottrarre ad un numero1 un numero2 che è più grande; il risultato non può che essere negativo. Infatti in binario il numero FFF2h corrisponde a 1111111111110101b

Conclusione


La scoperta del Codice Macchina della CPU Intel 8086 è giunto al termine. In questa prima parte dedicata al 16bit abbiamo evitato tutto ciò che è inerente alla sua architettura ed alle componenti interni, esclusi solo i registri e poco più. Abbiamo però scoperto in quale modo vengono codificate le istruzioni - non le componenti relative alla decodifica purtroppo, ma saremmo usciti troppo dal seminato - ed in che modo la CPU capisce la dimensione degli operandi ed il loro tipo.

Ora penso che tutti abbiate compreso a fondo l'importanza che ha avuto il linguaggio Assembly; può sembrare di poco conto, può sembrare solo un'interfaccia sulla macchina, ma in realtà è un'astrazione sulla complessità delle istruzioni. Provate a codificare qualche semplice esempio, e vi renderete conto di quanto tempo sia necessario e quanto sia semplice anche commettere errori.

In un prossimo articolo che avrei in programma vedremo invece le novità introdotte su una CPU che ha fatto la storia ed ha dato le basi per le moderne CPU a 32bit: la CPU 80386. Le CPU hanno subito notevoli cambiamenti, non solo nella circuiteria, ma anche nei nuovi opcode introdotti poi in altri set di istruzioni. La CPU 80386 introdurrà - come vedremo - cambiamenti nel formato delle istruzioni (neanche a dirlo, saranno più complesse).


Al momento ci fermiamo qui, e prendiamo una pausa.


Spero che la lettura sia stata di vostro gradimento, alla prossima!

DispatchCode
 
Ultima modifica da un moderatore:
Re: Il Linguaggio Macchina dell'8086 (Parte 2)

Salve @bullmario,
esattamente quali sono gli aspetti "fuori dalla tua portata" ? :look:
Prima di affrontare lo studio del linguaggio Assembler è fondamentale la conoscenza dell'architettura dei micro-processori, cioè del modo in cui le varie parti dei chip sono state "assemblate" per poter lavorare insieme :sisilui:
Conosci già la struttura di un micro-processore e le sue caratteristiche ? :ciaociao:

Salve, ne ho un infarinatura ma non ho mai approfondito
 
Re: Il Linguaggio Macchina dell'8086 (Parte 2)

Salve. Articolo veramente utile e istruttivo, a mio parere. Avrei però una domanda:

Qui si è trattato di analizzare nello specifico l'architettura del processore Intel 8086. Quindi, se non erro, molte informazioni a riguardo dell'architettura di base del processore sono appartenenti solo a questa specifica linea di processori. Ma per quanto riguarda la programmazione, è possibile utilizzare lo stesso linguaggio o cambia a seconda della CPU che hai di fronte?
 
Re: Il Linguaggio Macchina dell'8086 (Parte 2)

Salve. Articolo veramente utile e istruttivo, a mio parere. Avrei però una domanda:

Qui si è trattato di analizzare nello specifico l'architettura del processore Intel 8086. Quindi, se non erro, molte informazioni a riguardo dell'architettura di base del processore sono appartenenti solo a questa specifica linea di processori. Ma per quanto riguarda la programmazione, è possibile utilizzare lo stesso linguaggio o cambia a seconda della CPU che hai di fronte?

Il set di istruzioni dipende dall'architettura del processore, processori con architettura diversa hanno set di istruzioni diversi ma processori appartenenti alla stessa "famiglia" condividono lo stesso set di base :sisilui:
I nuovi processori semplicemente aggiungono nuove istruzioni a quelle già disponibili :sisilui:
 
Re: Il Linguaggio Macchina dell'8086 (Parte 2)

Salve. Articolo veramente utile e istruttivo, a mio parere. Avrei però una domanda:

Qui si è trattato di analizzare nello specifico l'architettura del processore Intel 8086. Quindi, se non erro, molte informazioni a riguardo dell'architettura di base del processore sono appartenenti solo a questa specifica linea di processori. Ma per quanto riguarda la programmazione, è possibile utilizzare lo stesso linguaggio o cambia a seconda della CPU che hai di fronte?

L' assembly per la CPU 8086 è compatibile con tutte le cpu X86 o X86-64 uscite nel corso degli anni. Ovviamente devi usare o un sistema operativo a 32 bit oppure devi andare sulla Virtualizzazione.

Ogni famiglia di Cpu il codice è cambiato e sono state aggiunte nuove cose ogni volta. Infatti es. la prima Cpu a 32 bit fu l' 80386 o 386 è l' assembly diciamo che è cambiato ma è compatibile con l' assembly per le cpu uscite prima di lui. L' assembly sul 386 non è compatibile non le cpu uscite prima di lui.

L' assembly a 16 bit è più facile rispetto a quello a 32 bit e quest' ultimo è più facile rispetto all' Assembly a 64 bit.
 
Re: Il Linguaggio Macchina dell'8086 (Parte 2)

Salve. Articolo veramente utile e istruttivo, a mio parere. Avrei però una domanda:

Qui si è trattato di analizzare nello specifico l'architettura del processore Intel 8086. Quindi, se non erro, molte informazioni a riguardo dell'architettura di base del processore sono appartenenti solo a questa specifica linea di processori. Ma per quanto riguarda la programmazione, è possibile utilizzare lo stesso linguaggio o cambia a seconda della CPU che hai di fronte?

Ciao,

Hai già ricevuto risposte esaustive, ma cerco di rendermi utile pure io, aggiungendo magari altri dettagli a quanto già detto dagli altri sopra di me.

Riguardo all'architettura è aumentata molto la complessità.
Sono aumentate le cache, e tante altre cose. Quella che definisci linea di processori è in realtà la "base" della famiglia x86.
Con le CPU successive certe cose non sono più possibili; l'astrazione è tutta a carico del sistema operativo, e la CPU è in modalità protetta (non più reale, questa già con 80286).
Da un punto di vista hardware sono poi stati introdotti gli APIC ed i SAPIC, così come gli APIC locali (8086 ha un PIC). Per farla breve: si tratta del controller che gestisce gli interrupt hardware. Il Master APIC demanda la gestione dell'interrupt al singolo local APIC (interno al core del processore) anche in base al carico di lavoro che sta gestendo (Windows in realtà ha delle sue priorità e di fatto riprogramma questo algoritmo).

Per quanto riguarda le istruzioni, anche se può sembrare sorprendente, il codice macchina è compatibile (eccezion fatta per i 64bit che non possono eseguire codice a 16bit) e quindi su una macchina a 32bit moderna puoi eseguire programmi a 16bit ed avviare ancora i vecchi *.COM. Su 64bit serve un emulatore.

Il linguaggio in sè cambia in base alla CPU, se per CPU intendi l'architettura. Intel x86 ha un codice macchina; altre architetture, come ARM, ne hanno un altro. L'assembly che vedi qui è eseguibile solo sotto DOS (o appunto un emulatore); sui 32bit moderni l'assembly è abbastanza differente in quanto di qualsiasi cosa necessiti devi passare dal sistema operativo (invocando le librerie della WinAPI, se sei sotto Windows).
Rispetto all'8086 però i vantaggi sono molti... :D Già 80386 porta vantaggi su 8086 (sparisce ad esempio la memoria suddivisa in segmenti da 64K).
 
Re: Il Linguaggio Macchina dell'8086 (Parte 2)

Permettimi di fare alcune note

Per quanto riguarda le istruzioni, anche se può sembrare sorprendente, il codice macchina è compatibile (eccezion fatta per i 64bit che non possono eseguire codice a 16bit) e quindi su una macchina a 32bit moderna puoi eseguire programmi a 16bit ed avviare ancora i vecchi *.COM. Su 64bit serve un emulatore.
Le limitazioni di cui parli non hanno molto a che fare con le CPU di per sé, bensì è qualcosa che il sistema operativo ti impone: infatti, parli di COM, che è appunto qualcosa di specifico a Windows - le CPU non eseguono COM, non saprebbero come interpretarlo. Parlando di x86, le CPU comprendono nativamente il "codice a 16 bit" (per quello che possa significare). Basti pensare che talune vengono inizializzate dal BIOS proprio in modalità reale e dunque a 16 bit.

In ogni modo, complimenti per l'articolo comprensivo!
 
Grazie per la precisazione e per l'apprezzamento!


Temo di essermi espresso male se ciò che è passato è una sorta di similitudine tra i COM e l'impossibilità della CPU di eseguirli in quanto tali.
 
Pubblicità
Pubblicità
Indietro
Top