Reti Logiche - Assembler
Cerca per parola chiave 0/0

Assembler x86

Il processore visto dal programmatore: registri, memoria, sintassi, indirizzamenti, istruzioni e flag

L'Assembler è il linguaggio più vicino alla macchina: ogni riga è un'istruzione del processore, scritta in forma simbolica. Si usa per piccoli pezzi di codice in cui servono controllo ed efficienza, e soprattutto per capire che cosa fa davvero un calcolatore. Il corso usa il processore Intel x86 a 32 bit con la sintassi dell'assemblatore GNU (GAS, detta AT&T): sorgente a sinistra, destinatario a destra.

Tutti i programmi della pagina girano in un simulatore nel browser: si possono eseguire per intero o un'istruzione alla volta, guardando registri, flag, memoria e pila, e si possono modificare. Il simulatore riproduce i sottoprogrammi di utility.s usati all'esame.

Il processore visto dal programmatore

I registri

311570%EAX(metà alta)%AH%AL%AX = AH:AL%EBX(metà alta)%BH%BL%BX = BH:BL%ECX(metà alta)%CH%CL%CX = CH:CL%EDX(metà alta)%DH%DL%DX = DH:DL%ESI%SIindice sorgente (istruzioni stringa)%EDI%DIindice destinatario%EBP%BPpuntatore base%ESP%SPcima della pilaEIPindirizzo della prossima istruzioneEFLAGSOFDFSFZFCFi flag usati dai salti condizionati
I registri generali a 32 bit e i loro «pezzi»: %AX è la metà bassa di %EAX, a sua volta divisa in %AH e %AL. Scrivere in %AL cambia anche %AX e %EAX. Dei flag ci interessano CF, ZF, SF, OF e DF.

I registri generali sono otto, a 32 bit. I primi quattro si possono usare anche a pezzi: %AX (16 bit), %AH e %AL (8 bit). Per gli altri quattro esiste solo la metà a 16 bit. %ESP punta alla cima della pila e lo aggiornano PUSH, POP, CALL e RET; %EIP contiene l'indirizzo della prossima istruzione, e non si scrive direttamente: lo cambiano solo i salti. Del registro dei flag interessano:

flagnomevale 1 quando
CFcarryc'è riporto (o prestito) uscente: il risultato, come naturale, non sta nei bit degli operandi
OFoverflowil risultato, come intero, non sta nei bit degli operandi
ZFzeroil risultato è zero
SFsignil bit più significativo del risultato è 1
DFdirectionle istruzioni stringa vanno all'indietro (lo si imposta con STD e CLD)

Memoria e spazio di I/O

La memoria è uno spazio lineare di 232 byte. Il processore legge e scrive 1, 2 o 4 byte consecutivi (byte, word, long), indicati con l'indirizzo più basso: il byte meno significativo sta all'indirizzo più basso (little endian).

0x3F65B4320x1C0x3F65B4330x390x3F65B4340xA20x3F65B4350xC6memoriaMOVB 0x3F65B432, %AL→ %AL = 0x1CMOVW 0x3F65B432, %AX→ %AX = 0x391CMOVL 0x3F65B432, %EAX→ %EAX = 0xC6A2391CSi usa l'indirizzo più piccolo; il byte più significativosta all'indirizzo più grande (little endian).
Lo spazio di memoria è fatto di byte con indirizzi a 32 bit. Una word occupa due locazioni, una long quattro, e si indicano con l'indirizzo più piccolo: il byte meno significativo viene per primo.

C'è poi lo spazio di I/O, con 216 porte, accessibile con IN e OUT. Ma i programmi utente girano in modalità utente, dove IN, OUT e HLT sono istruzioni privilegiate: il sistema operativo le intercetta. L'ingresso e l'uscita si fanno chiamando sottoprogrammi di servizio, che per l'esame stanno in utility.s.

Scrivere un programma

Codifica macchina, mnemonica, Assembler

Un'istruzione x86 in codifica macchina è una stringa di 1–14 byte (prefissi, codice operativo, modo, SIB, spiazzamento, immediato). Nessuno la scrive a mano: si usa la codifica mnemonica, come MOVL 0x00000100, %EAX, che l'assemblatore traduce in modo biunivoco. Il linguaggio Assembler aggiunge nomi simbolici per indirizzi e costanti: invece di JE %EIP+$0x07, contando i byte delle istruzioni, si scrive JE fine. L'assemblatore fa due passate, quindi un'etichetta si può usare prima di definirla (serve per i salti in avanti).

La struttura di un programma

Una riga ha la forma nome: KEYWORD operandi # commento, con campi facoltativi. Parole chiave e nomi dei registri non distinguono maiuscole e minuscole, i nomi definiti dal programmatore sì. Una buona abitudine: istruzioni e registri in maiuscolo, il resto in minuscolo.

direttivaesempioeffetto
.BYTE, .WORD, .LONGv: .WORD 1, 2, 3una o più variabili da 1, 2, 4 byte, inizializzate
.ASCII, .ASCIZs: .ASCIZ "ciao"caratteri; ASCIZ aggiunge in fondo il byte 0
.FILL n, dim, valbuf: .FILL 80, 1, 0n elementi da dim byte con valore val
.SET, .EQU.SET enne, 10una costante simbolica (non occupa memoria)
.INCLUDE.include "./files/utility.s"inserisce un altro file

Dollaro, percentuale e suffissi

  • Le costanti (operandi immediati) si scrivono con $; i registri con %. Un numero senza dollaro è un indirizzo: MOV 0x2000, %EDX copia in EDX il contenuto della memoria a 0x2000, MOV $0x2000, %EDX ci mette il numero 0x2000. Sono entrambe lecite, e scambiarle è un errore che l'assemblatore non vede.
  • I numeri sono in base 10, oppure esadecimali con 0x; i caratteri si scrivono tra apici: $'a'.
  • Il suffisso B, W, L dice la lunghezza degli operandi (8, 16, 32 bit). Se c'è un registro si può omettere; se non c'è (MOVB $0x10, (%EDI), CMPB $0, (%ESI)) è obbligatorio. Senza, l'assemblatore a volte segnala l'errore e a volte sceglie da solo, e il programma non funziona.

I modi di indirizzamento

Un operando in memoria si indica, nel caso più generale, come spiazzamento(base, indice, scala): l'indirizzo è spiazzamento + base + indice · scala, con base e indice registri a 32 bit e scala 1, 2, 4 o 8. Nessuna istruzione (salvo quelle stringa) ha due operandi in memoria: per copiare da memoria a memoria si passa da un registro.

modoesempiooperando
immediatoMOV $5, %ALla costante 5
di registroMOV %BX, %AXil contenuto di BX
direttoMOV dato, %EAXla memoria all'indirizzo dato
indirettoMOV (%ESI), %ALla memoria all'indirizzo contenuto in ESI
con spiazzamentoMOV 4(%EBX), %EAXall'indirizzo EBX + 4
vettoreMOV numeri(, %ESI, 2), %AXl'elemento di indice ESI di un vettore di word
completoMOV tab(%EBX, %ESI, 4), %EAXall'indirizzo tab + EBX + 4·ESI

LEA calcola l'indirizzo senza leggere la memoria: LEA stringa, %ESI mette in ESI l'indirizzo di stringa. Il primo esempio completo conta i bit a 1 di una long: esegui un passo alla volta e guarda CF e CL.

Il trucco è ADCB $0x00, %CL: somma a CL zero più il carry, cioè il bit appena uscito da EAX. Nessun salto per decidere se contare. Lo stesso schema, con un puntatore che scorre una stringa terminata da zero:

Che cosa succede se si dimentica la B in CMPB $0x00, (%ESI)? Nessun operando è un registro, quindi la lunghezza è ambigua. L'assemblatore può scegliere 32 bit senza dire niente, e il confronto guarda quattro caratteri invece di uno: il programma sbaglia in silenzio. Il simulatore, più severo, segnala l'errore.

Le istruzioni principali

Trasferimento

MOV copia il sorgente nel destinatario, XCHG li scambia, LEA calcola un indirizzo, PUSH e POP usano la pila (operandi da 16 o 32 bit). Le estensioni: MOVZBL, MOVZWL estendono con zeri (naturali), MOVSBL, MOVSWL ripetendo il segno (interi), e CBW, CWDE, CWD, CDQ estendono AL, AX, EAX come interi. Nessuna istruzione di trasferimento tocca i flag.

Somma, sottrazione, confronto

ADD, ADC (somma anche CF), SUB, SBB (sottrae anche CF), INC, DEC, NEG. Il circuito che somma è uno solo per naturali e interi (lo si dimostra nel modulo di aritmetica): l'istruzione aggiorna sia CF sia OF, e il programmatore, l'unico che sa come interpretare i bit, guarda quello giusto.

naturali → CF

101 + 011 su 3 bit dà 000 con CF = 1: 5 + 3 = 8 non sta in 3 bit. Nella sottrazione CF = 1 indica il prestito: il risultato non è un naturale.

interi → OF

Gli stessi bit sono −3 + 3 = 0: il risultato è giusto e OF = 0, anche se CF = 1. C'è overflow solo se due operandi concordi danno un risultato di segno opposto.

CMP

Calcola destinatario − sorgente, aggiorna i flag come SUB e butta via il risultato. Dopo una CMP c'è sempre un salto condizionato.

Moltiplicazione e divisione

Qui naturali e interi richiedono algoritmi diversi: ci sono MUL/IMUL e DIV/IDIV. Il prodotto di due numeri su n bit sta su 2n bit, quindi uno dei fattori e il destinatario sono impliciti, scelti dalla lunghezza dell'operando:

operandoMUL: prodottoDIV: dividendoquozienteresto
8 bitAX = AL · opAXALAH
16 bitDX_AX = AX · opDX_AXAXDX
32 bitEDX_EAX = EAX · opEDX_EAXEAXEDX

L'operando non può essere immediato. Il problema vero è la divisione: il quoziente deve stare nel registro destinato a lui, altrimenti il processore genera un'eccezione (la stessa della divisione per zero) e il programma si ferma. Non lo si scopre dai flag dopo: va evitato prima, scegliendo la versione giusta. 15000 / 3 = 5000 non sta in AL:

Due errori tipici: dimenticare di azzerare DX (o EDX) prima di una divisione a 16 o 32 bit, e dimenticare che MUL e DIV a 16 o 32 bit modificano DX (o EDX) anche se non compare nell'istruzione. Per portare in EAX un risultato che sta in DX_AX: PUSH %DX, PUSH %AX, POP %EAX. Nella divisione tra interi il resto ha il segno del dividendo: −7 diviso 3 dà −2 con resto −1.

Traslazioni, rotazioni, logiche

SHL/SAL, SHR, SAR traslano; ROL, ROR, RCL, RCR ruotano. Il formato è OP conteggio, dest oppure OP dest (una posizione); il conteggio è un immediato o CL, e conta solo modulo 32. L'ultimo bit uscito finisce in CF.

ROLCFRORCFRCLCFRCRCFROL/ROR: il bit che esce rientra dall'altra parte (e una copia va in CF).RCL/RCR: la rotazione passa da CF, come se il registro avesse un bit in più.
Le rotazioni. Con RCL e RCR il flag CF fa da nono (diciassettesimo, trentatreesimo) bit: è il trucco usato per rovesciare l'ordine dei bit nel test di palindromia.

Attenzione: SAR divide un intero per una potenza di 2 approssimando verso meno infinito, IDIV verso zero. Con −5 / 2 si ottiene −3 nel primo caso e −2 (resto −1) nel secondo:

Le logiche AND, OR, XOR, NOT lavorano bit per bit; TEST fa una AND senza scrivere il risultato, solo per i flag. Usi classici: AND $0x01, %AL isola un bit, AND $0xDF, %AL rende maiuscola una lettera minuscola, XOR %EAX, %EAX azzera un registro (1 byte invece dei 5 di MOV $0, %EAX).

Salti e salti condizionati

JMP etichetta salta sempre. I salti condizionati guardano i flag, e vanno scelti in base all'interpretazione degli operandi:

dopo CMP src, destnaturaliintericondizione sui flag
dest = srcJE (= JZ)ZF = 1
dest ≠ srcJNE (= JNZ)ZF = 0
dest > srcJAJGCF = 0 e ZF = 0 · ZF = 0 e SF = OF
dest ≥ srcJAEJGECF = 0 · SF = OF
dest < srcJBJLCF = 1 · SF ≠ OF
dest ≤ srcJBEJLECF = 1 o ZF = 1 · ZF = 1 o SF ≠ OF

Ci sono poi i salti su un singolo flag, utili dopo qualunque istruzione che lo tocca: JC/JNC, JO/JNO, JS/JNS, JZ/JNZ. Due cose da ricordare: le condizioni A, B, G, L si riferiscono al destinatario della CMP (CMP %BX, %AX seguita da JA salta se AX > BX); e scegliere A/B invece di G/L è un errore che cambia il programma, come mostra questo esempio con il byte 0xFF:

L'ambiente d'esame

Assemblare, eseguire, fare il debug

Nell'ambiente del corso il sorgente .s sta nella cartella assembler, accanto agli script; da un terminale PowerShell in quella cartella:

  1. ./assemble.ps1 programma.s assembla (con gcc -m32) e produce l'eseguibile programma e il listato programma.lst, utile per vedere che cosa ha capito l'assemblatore;
  2. ./programma lo esegue;
  3. ./debug.ps1 ./programma lo apre in gdb, già fermo su _main.
comando gdbeffetto
n (next), s (step)esegue un'istruzione; s entra nei sottoprogrammi, n li esegue per intero
c (continue)prosegue fino al prossimo breakpoint
break etichettamette un punto di arresto
info registers, p $eax, p/x $eaxmostra i registri, o un registro in decimale o esadecimale
x/4xb &dato, x/s &stringamostra la memoria: 4 byte in esadecimale, una stringa
display $alristampa un valore a ogni passo
rrun, qqricomincia, esce (comandi definiti dall'ambiente)

Il simulatore di questa pagina fa le stesse cose senza installare niente: Passo è s, Passo oltre la call è n, e registri, flag, variabili e pila sono sempre visibili.

I sottoprogrammi di utility.s

sottoprogrammache cosa fa
incharlegge un carattere senza eco in AL (Invio arriva come 0x0D)
outcharstampa il carattere in AL
newlineva a capo
outlinestampa i caratteri a partire dall'indirizzo in EBX fino al ritorno carrello (0x0D), al più 79, e va a capo
outmessstampa CX caratteri a partire da EBX
inlinelegge con eco una riga (al più CX − 2 caratteri, con backspace) e la mette a partire da EBX, terminata da 0x0D 0x0A
indecimal_tiny, _short, _longleggono con eco un naturale in base 10 (al più 3, 5, 10 cifre) in AL, AX, EAX; finiscono con Invio o quando le cifre sono esaurite; scartano i caratteri non numerici
outdecimal_tiny, _short, _longstampano in base 10 il naturale in AL, AX, EAX
inbyte, inword, inlongleggono con eco un numero esadecimale (2, 4, 8 cifre)
outbyte, outword, outlongstampano in esadecimale AL, AX, EAX
pausestampa un messaggio e aspetta un tasto

Tutti preservano i registri che non usano per restituire valori. Un dettaglio che fa perdere tempo: indecimal_short su 5 cifre termina da solo alla quinta, senza aspettare Invio.

Verifica