Reti Logiche - Programmare in Assembler
Cerca per parola chiave 0/0

Programmare in Assembler

Controllo di flusso strutturato, sottoprogrammi, ingresso e uscita, istruzioni stringa, efficienza e buone norme

L'Assembler non ha cicli, funzioni con parametri, variabili locali o tipi: ha salti, CALL e RET, registri e byte. Tutto il resto lo costruisce il programmatore, e con la stessa libertà si scrivono programmi chiari o illeggibili. Questo modulo mostra le traduzioni standard dei costrutti ad alto livello, le convenzioni per i sottoprogrammi e le buone norme che rendono un programma Assembler verificabile. Ogni esempio si esegue nel simulatore.

Controllo di flusso strutturato

If … then … else

L'if diventa una CMP seguita da un salto condizionato. Due traduzioni equivalenti di if (AX < variabile) {then} else {else}, con AX naturale:

Si scambiano i rami
Si inverte la condizione

for, do … while e LOOP

for (i = 0; i < var; i++)
do { … } while (AX < var)

LOOP etichetta decrementa ECX senza toccare i flag e salta se ECX ≠ 0: va bene per un for a numero di giri noto, con il contatore usato a scendere. ECX si inizializza prima e non si tocca dentro il ciclo. Le varianti LOOPE/LOOPNE saltano solo se anche ZF è 1 (o 0): per questo LOOP non tocca i flag, deve preservare quelli della CMP precedente. Il salto di LOOP sta su 8 bit: per cicli lunghi si usa una Jcon.

Programmazione strutturata, anche in Assembler. Con JMP si può saltare nel mezzo di un ciclo, e saltare avanti e indietro per risparmiare un'istruzione: è lo spaghetti code, impossibile da verificare. I linguaggi strutturati sono nati alla fine degli anni '60 proprio per impedirlo. In Assembler il vincolo lo deve rispettare il programmatore: si ragiona con if, for e while, si traduce ciascuno nel modo standard, e ogni blocco ha un solo punto di ingresso e uno di uscita.

Tre esempi

Il fattoriale di un numero da 0 a 9. La prima domanda è sempre quella del dimensionamento: 9! = 362880 non sta su 16 bit, e la moltiplicazione più grande ha un operando su 32 bit (9!/2), quindi si lavora a 32 bit. Contando a scendere il risultato resta in EAX; contando a salire l'ultima moltiplicazione sarebbe a 16 bit, ma il risultato starebbe in DX_AX, più scomodo da riportare in memoria.

Una stringa di 16 bit è palindroma se letta al contrario è uguale. Il trucco: far uscire i bit di AH da sinistra con RCL e farli entrare in BL da sinistra con RCR, usando CF come appoggio. Dopo 8 giri BL contiene AH rovesciato, e basta confrontarlo con AL.

Il test di primalità di un numero a 16 bit prova a dividerlo per 2 (guardando solo il bit meno significativo), poi per i dispari da 3 in su. Basta arrivare alla radice quadrata: se un numero su 16 bit non è primo ha un divisore su 8 bit, quindi quando il divisore non sta più in BL (l'ADD dà riporto) si può smettere. La DIV sporca AX: lo si salva in pila e lo si ripristina.

Sottoprogrammi

Parametri e documentazione

CALL e RET non passano parametri: chiamante e chiamato si accordano su una convenzione. I modi sono due, anche insieme: registri o variabili in memoria condivise (in Assembler tutte le variabili sono globali: non esistono variabili locali né regole di visibilità). Il terzo modo, la pila, è quello dei compilatori e si vedrà a Calcolatori Elettronici. Chi scrive un sottoprogramma deve dichiarare in un commento dove vuole i parametri di ingresso, dove mette quelli di uscita e che cosa significano.

Salvare i registri e bilanciare la pila

Chi chiama si aspetta che, dopo la CALL, i registri che non contengono risultati siano gli stessi di prima (i flag invece possono cambiare). Il sottoprogramma salva in pila i registri che usa e li ripristina in ordine inverso. Attenzione a quelli che non si vedono: una MUL o una DIV a 16 o 32 bit modificano anche DX o EDX.

prima della CALL………dati del mainESPdopo CALL conta……ind. di ritornodati del mainESPdopo PUSH %AX…vecchio AXind. di ritornodati del mainESPdopo POP e RET…(sporco)(sporco)dati del mainESPLa pila cresce verso indirizzi più bassi. RET preleva dalla cima l'indirizzo di ritorno:se un PUSH non ha il suo POP, RET salta a un indirizzo a caso.
La pila durante CALL conta: la CALL mette in cima l'indirizzo di ritorno, il sottoprogramma salva i registri che usa, e prima della RET deve togliere esattamente ciò che ha messo.

Per ogni PUSH ci vuole una POP, su ogni percorso che porta alla RET: altrimenti la RET preleva un indirizzo di ritorno sbagliato e il programma salta chissà dove (il simulatore lo segnala). Un trucco per non dimenticarsene: scrivere subito

così il programma non si assembla finché non si è deciso che cosa salvare. Anche _main è un sottoprogramma: per convenzione restituisce in EAX 0 se tutto è andato bene, da cui il XOR %EAX, %EAX prima della RET finale. Se il programma usa molto la pila la si può dichiarare e inizializzare a mano (nell'ambiente del corso non serve):

Due esempi

Bit a 1 in un vettore di word, con un sottoprogramma che li conta in una word. Si noti l'indirizzamento numeri(, %ESI, 2): ESI è l'indice dell'elemento, la scala 2 tiene conto che ogni elemento occupa due byte.

Il coefficiente binomiale A!/(B!(A − B)!) con A, B < 10. Il dimensionamento guida tutto: 9! sta su 32 bit, il prodotto dei due fattoriali al denominatore anche (al più 9! · 0!), la divisione si fa a 32 bit con EDX azzerato, e il risultato (al più 126) sta su una word.

Programmi interattivi

Gli esercizi d'esame chiedono quasi sempre di leggere da tastiera «facendo gli opportuni controlli» e stampare qualcosa. Lo schema: si legge con inchar (senza eco), si scartano i caratteri non ammessi tornando a leggere, e si fa l'eco con outchar solo di quelli accettati. Nel simulatore si scrive nel riquadro sotto il terminale; Invio manda il ritorno carrello.

Per le minuscole basta azzerare il bit 5 (0x20): AND $0xDF, %AL (la dispensa usa 0x5F, che azzera anche il bit 7: per le lettere è lo stesso). L'algoritmo di Euclide: finché X ≠ 0, si sostituisce (X, Y) con (max mod min, min), stampando i passaggi. Il massimo e il minimo si ottengono con un confronto e uno XCHG.

Stampare il nome di un numero da 0 a 19 richiede una struttura dati che l'Assembler non ha: un vettore di puntatori a stringhe. Ogni stringa termina con il ritorno carrello (così la stampa outline), e il vettore contiene i loro indirizzi su 4 byte; l'elemento i si legge con vett_nomi(, %ESI, 4).

Ultimo esempio d'esame: un rettangolo pieno 2N × N di vocali, con N cifra in base dodici. I due sottoprogrammi di lettura incapsulano i controlli; il programma principale è un doppio ciclo.

Le istruzioni stringa

«Stringa» qui vuol dire vettore, non sequenza di caratteri. Le istruzioni stringa usano ESI come puntatore al sorgente ed EDI al destinatario, e dopo ogni elemento li spostano di 1, 2 o 4 byte (il suffisso è obbligatorio): in avanti se DF = 0, all'indietro se DF = 1. Un errore classico è non impostare DF con CLD o STD: il programma funziona a volte sì e a volte no.

istruzioneeffettoprefissi
MOVSxcopia (ESI) in (EDI): l'unica copia da memoria a memoriaREP
LODSxcarica (ESI) in AL/AX/EAX–
STOSxscrive AL/AX/EAX in (EDI): riempire un bufferREP
CMPSxconfronta (ESI) e (EDI)REPE, REPNE
SCASxconfronta AL/AX/EAX con (EDI): cercare un valoreREPE, REPNE

Il prefisso REP ripete l'istruzione ECX volte (come una LOOP, ma con un solo fetch); REPE/REPNE si fermano anche appena la condizione su ZF diventa falsa. Copiare 1000 long diventa:

Due sottigliezze. CMPS e SCAS confrontano al contrario rispetto alla CMP: dopo CMPSB, JA salta se (ESI) > (EDI). E all'uscita di REPE CMPSW ESI ed EDI puntano all'elemento dopo quello diverso. Infine, per copiare buffer parzialmente sovrapposti la direzione conta: se la destinazione sta più avanti della sorgente si copia all'indietro.

Efficienza

L'Assembler serve a scrivere piccole porzioni di codice molto efficienti, e l'assemblatore non ottimizza niente: traduce quello che si scrive. Misurare il tempo di un programma non ha senso (si misura un processo, con certi dati, su un processore con cache, pipeline, frequenza variabile ed esecuzione fuori ordine), ma alcune regole valgono sempre.

istruzioni corte

Gli operandi immediati e in memoria allungano l'istruzione e il fetch: XOR %EAX, %EAX (1 byte) invece di MOV $0, %EAX (5 byte), INC invece di ADD $1.

operandi allineati

Una long a un indirizzo non multiplo di 4 richiede due accessi. .BALIGN 4 allinea la variabile successiva; il codice conviene allinearlo solo dopo un JMP o una RET.

niente MUL e DIV

Costano decine di clock. Per potenze di 2 si usano gli shift; LEA calcola base + indice · scala + spiazzamento in un clock: moltiplicare per 3, 5, 9 è una LEA.

pochi salti

Un salto preso svuota la coda di prefetch e mette in crisi la pipeline. Meglio istruzioni che usano i flag: ADC, SBB, SETcon, CMOVcon.

Calcolare z = 16x + 3y − 13500 senza moltiplicazioni:

SETcon reg8 scrive 1 o 0 a seconda della condizione (lo si è visto contando gli elementi diversi); CMOVcon sorgente, registro esegue la MOV solo se la condizione è vera (16 o 32 bit). Il massimo di due naturali, prima con i salti e poi senza:

Buone norme ed errori tipici

Organizzazione.

  • Prima tutte le dichiarazioni (costanti e variabili, nell'ordine in cui le accetterebbe un compilatore C++), poi il codice. Un'etichetta non ancora definita si usa solo per i salti in avanti.
  • Istruzioni e registri in maiuscolo, nomi in minuscolo e parlanti; commenti che dicono perché, non che cosa.
  • Ogni sottoprogramma ha un'intestazione con ingressi, uscite e registri modificati; salva e ripristina tutti gli altri.
  • Si ragiona per costrutti strutturati e si traducono nel modo standard: niente salti nel mezzo dei cicli.

Correttezza.

  • Prima di scrivere un'istruzione aritmetica: naturali o interi? Da lì discendono i flag (CF o OF) e i salti (A/B o G/L).
  • Prima di una moltiplicazione o divisione: su quanti bit stanno operandi e risultato? DX o EDX azzerati prima della DIV, salvati se servono dopo.
  • Suffisso esplicito quando nessun operando è un registro; $ davanti alle costanti.
  • CLD o STD prima di ogni istruzione stringa; ECX non si tocca dentro un LOOP.
  • Ogni PUSH ha la sua POP su ogni percorso verso la RET.
  • Si prova il programma con ingressi scelti apposta: zero, il massimo rappresentabile, i casi limite delle condizioni.
La pila sbilanciata
Un solo punto di uscita
Divisione a 16 bit con DX sporco
DX azzerato (e salvato se serve)

Verifica