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
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:
| flag | nome | vale 1 quando |
|---|---|---|
CF | carry | c'è riporto (o prestito) uscente: il risultato, come naturale, non sta nei bit degli operandi |
OF | overflow | il risultato, come intero, non sta nei bit degli operandi |
ZF | zero | il risultato è zero |
SF | sign | il bit più significativo del risultato è 1 |
DF | direction | le 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).
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.
| direttiva | esempio | effetto |
|---|---|---|
.BYTE, .WORD, .LONG | v: .WORD 1, 2, 3 | una o più variabili da 1, 2, 4 byte, inizializzate |
.ASCII, .ASCIZ | s: .ASCIZ "ciao" | caratteri; ASCIZ aggiunge in fondo il byte 0 |
.FILL n, dim, val | buf: .FILL 80, 1, 0 | n elementi da dim byte con valore val |
.SET, .EQU | .SET enne, 10 | una 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, %EDXcopia in EDX il contenuto della memoria a 0x2000,MOV $0x2000, %EDXci 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,Ldice 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.
| modo | esempio | operando |
|---|---|---|
| immediato | MOV $5, %AL | la costante 5 |
| di registro | MOV %BX, %AX | il contenuto di BX |
| diretto | MOV dato, %EAX | la memoria all'indirizzo dato |
| indiretto | MOV (%ESI), %AL | la memoria all'indirizzo contenuto in ESI |
| con spiazzamento | MOV 4(%EBX), %EAX | all'indirizzo EBX + 4 |
| vettore | MOV numeri(, %ESI, 2), %AX | l'elemento di indice ESI di un vettore di word |
| completo | MOV tab(%EBX, %ESI, 4), %EAX | all'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.
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.
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.
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:
| operando | MUL: prodotto | DIV: dividendo | quoziente | resto |
|---|---|---|---|---|
| 8 bit | AX = AL · op | AX | AL | AH |
| 16 bit | DX_AX = AX · op | DX_AX | AX | DX |
| 32 bit | EDX_EAX = EAX · op | EDX_EAX | EAX | EDX |
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.
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, dest | naturali | interi | condizione sui flag |
|---|---|---|---|
| dest = src | JE (= JZ) | ZF = 1 | |
| dest ≠ src | JNE (= JNZ) | ZF = 0 | |
| dest > src | JA | JG | CF = 0 e ZF = 0 · ZF = 0 e SF = OF |
| dest ≥ src | JAE | JGE | CF = 0 · SF = OF |
| dest < src | JB | JL | CF = 1 · SF ≠ OF |
| dest ≤ src | JBE | JLE | CF = 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:
./assemble.ps1 programma.sassembla (congcc -m32) e produce l'eseguibileprogrammae il listatoprogramma.lst, utile per vedere che cosa ha capito l'assemblatore;./programmalo esegue;./debug.ps1 ./programmalo apre in gdb, già fermo su_main.
| comando gdb | effetto |
|---|---|
n (next), s (step) | esegue un'istruzione; s entra nei sottoprogrammi, n li esegue per intero |
c (continue) | prosegue fino al prossimo breakpoint |
break etichetta | mette un punto di arresto |
info registers, p $eax, p/x $eax | mostra i registri, o un registro in decimale o esadecimale |
x/4xb &dato, x/s &stringa | mostra la memoria: 4 byte in esadecimale, una stringa |
display $al | ristampa un valore a ogni passo |
rrun, qq | ricomincia, 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
| sottoprogramma | che cosa fa |
|---|---|
inchar | legge un carattere senza eco in AL (Invio arriva come 0x0D) |
outchar | stampa il carattere in AL |
newline | va a capo |
outline | stampa i caratteri a partire dall'indirizzo in EBX fino al ritorno carrello (0x0D), al più 79, e va a capo |
outmess | stampa CX caratteri a partire da EBX |
inline | legge 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, _long | leggono 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, _long | stampano in base 10 il naturale in AL, AX, EAX |
inbyte, inword, inlong | leggono con eco un numero esadecimale (2, 4, 8 cifre) |
outbyte, outword, outlong | stampano in esadecimale AL, AX, EAX |
pause | stampa 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.