Ecco una roadmap di quello che vedremo oggi:

  • Registri specializzati e non;
  • Syscall;
  • Direttive all'assemblatore;
  • Formati delle istruzioni;
  • Salti condizionati e non;
  • Cicli;
  • Vettori e matrici.

Programmare in assembly è un'esperienza totalmente diversa rispetto a programmare con un qualunque linguaggio ad alto livello. Abbiamo diretto accesso ai registri, e possiamo ottimizzare (o peggiorare) il codice con un alto livello di precisione.

Nella serie di articoli parallela sulla teoria vedremo meglio questo aspetto di ottimizzazione, ma ti anticipo al volo che riguarda principalmente l'ordine delle istruzioni.

In questa serie di articoli utilizzeremo MARS: una IDE MIPS che integra anche un simulatore.

Senza perderci ancora in chiacchiere, cominciamo.

Registri

Come anticipato prima, in assembly abbiamo accesso diretto ai registri. In particolare abbiamo 31 registri (più quelli con la robaccia in virgola mobile), che convenzionalmente servono a fare cose distinte. In realtà possiamo usarli tutti a nostro piacimento, ma renderemmo il nostro codice non in linea con le convenzioni, e rischieremo di confonderci o complicarci la vita.

Ecco i registri standard MIPS:

# Nome Descrizione o uso convenzionale
$0 $zero Contiene solo il valore 0
$1 $at Riservato per le pseudo-istruzioni
$2-$3 $v0-$v1 Valori di ritorno delle funzioni
$4-$7 $a0-$a3 Argomenti delle funzioni, non preservati tra funzioni
$8-$15 $t0-$t7 Dati temporanei, non preservati tra funzioni
$16-$23 $s0-$s7 Dati salvati, preservati tra funzioni
$24-$25 $t8-$t9 Dati temporanei, non preservati tra funzioni
$26-$27 $k0-$k1 Riservato per il kernel
$28 $gp Global Area Pointer (base del segmento dati)
$29 $sp Stack Pointer
$30 $fp Frame Pointer
$31 $ra Indirizzo di ritorno
$f0-$f3 - Valori di ritorno in virgola mobile
$f4-$f10 - Registri temporanei in virgola mobile, non preservati tra funzioni
$f12-$f14 - Primi due argomenti in virgola mobile, non preservati tra funzioni
$f16-$f18 - Dati temporanei in virgola mobile, non preservati tra funzioni
$f20-$f31 - Dati salvati in virgola mobile, preservati tra funzioni

Un paio di note:

  • Nel codice possiamo usare sia il numero di registro (es. $0) che il nome (es. $zero);
  • La differenza tra dati preservati e non preservati tra funzioni è solo convenzionale, e non riguarda un comportamento che avviene in automatico. Per fare un esempio, significa che per convenzione non dovresti usare i registri temporanei per salvare o passare dati tra funzioni;
  • Il registro con la costante $zero esiste perché è molto utile.

Esempi

Prima di andare avanti con i formati, vediamo al volo qualche esempio di istruzioni in ASM MIPS che coinvolgono registri:

# $t0 = $t0 + $t1
add $t0, $t0, $t1

# $t0 = $t0 + 1
addi $t0, $t0, 1

# $s0 = $t2
move $s0, $t2

# $t1 = read_mem($sp + 4)
lw $t1, 4($sp)

Due parole su questi esempi:

  • Abbiamo scoperto che i commenti iniziano con #;
  • Abbiamo scoperto che la sintassi è abbastanza semplice: può o meno sempre nello stile cod_istruz op1, op2 [, op3];
  • Per usare valori costanti c'è una i alla fine delle istruzioni (vedremo perché);
  • L'ultima istruzione (load word) legge dalla memoria all'indirizzo dato da $sp + 4 byte (offset 4), e lo salva in $t1; possiamo anche vedere la situazione come un accesso ad un array che inizia all'indirizzo contenuto in $sp, e il nostro vettore è di word da 32 bit (4 byte), accedere al byte 4 significa accedere logicamente all'indice 1.

Carino eh?

Syscall

Le syscall sono letteralmente chiamate al sistema operativo, che servono principalmente per operazioni di input e output. Un esempio può essere la lettura di caratteri da tastiera, e la scrittura di qualcosa sullo schermo.

Esistono diversi tipi di syscall, identificate da un numero, e funzionano in questo modo:

  1. Carichiamo in un apposito registro il codice della syscall;
  2. Carichiamo gli eventuali valori in appositi registri per gli argomenti;
  3. Chiamiamo la syscall;
  4. Recuperiamo gli eventuali valori di ritorno dagli appositi registri di risultato.

Un esempio tranquillissimo per stampare un numero:

addi $a0, $0, 42    # alternativa a move $a0, 42
li $v0, 1           # carica il servizio "print integer"
syscall             # chiama la syscall 1
# risultato: stampa 42

Allora, capisco che in questo istante la situazione può essere un po' confusa, ma è voluto. Ora rimetteremo insieme i pezzi con un bell'elenco di syscall che MARS mette a disposizione, insieme alle rispettive descrizioni e registri coinvolti.

Servizio Codice Argomenti Risultato
print integer 1 $a0 = intero da stampare
print float 2 $f12 = float da stampare
print double 3 $f12 = double da stampare
print string 4 $a0 = indirizzo della stringa (terminata da NULL) da stampare
read integer 5 $v0 ← intero letto
read float 6 $f0 ← float letto
read double 7 $f0 ← double letto
read string 8 $a0 = indirizzo input buffer
$a1 = num max caratteri da leggere
exit 10
print character 11 $a0 = carattere da stampare
read character 12

Questo elenco è incompleto, perché comprende solo le syscall che useremo con più frequenza in questa serie di articoli. Puoi leggere la lista completa nella documentazione di MARS (premi F1), oppure nella documentazione online.

Esempi di altre syscall sono quelle per generare numeri random, gestire file, chiedere la data, far comparire finestre e fare incantesimi.

Esempio

Prima di provare questo esempio, disabilita le finestre popup per l'input da tastiera; trovi l'opzione sotto Settings.

Facciamo finta di voler prendere due numeri da tastiera, sommarli, e sputare fuori il risultato. Nel farlo vogliamo anche agevolare l'utente con dei messaggi di guida, tipo "Inserisci numero:" e "Il risultato è:".

Potremmo fare una cosa del genere (disponibile anche su GitHub):

.globl main

.data
  prompt1: .asciiz "Intero 1: "
  prompt2: .asciiz "Intero 2: "
  resultDescr: .asciiz "Il risultato è "

.text

main:
  # chiedi numero 1
  la $a0, prompt1
  li $v0, 4
  syscall

  # leggi numero 1
  li $v0, 5
  syscall
	
  # spostalo in $t0
  move $t0, $v0
	
  # chiedi numero 2
  la $a0, prompt2
  li $v0, 4
  syscall
	
  # leggi numero 2
  li $v0, 5
  syscall

  # somma i due numeri
  addu $a1, $v0, $t0
	
  # salva testo del risultato
  la $a0, resultDescr
  li $v0, 4
  syscall
	
  # carica il risultato dove serve
  move $a0, $a1
	
  # stampa intero
  li $v0, 1
  syscall
	
  # termina il programma
  li $v0, 10
  syscall

In questo esempio abbiamo anche introdotto delle cosette nuove. Vediamo.

  • Direttive:
    • .globl main indica all'assemblatore che il simbolo main può essere acceduto anche da un altro file;
    • .data delimita l'inizio del segmento data, ovvero il contenitore dei dati statici nel file oggetto:
      • .asciiz indica che la stringa tra virgolette è ASCII e terminata da NULL (byte 0).
    • .text delimita l'inizio del segmento text, ovvero il contenitore delle istruzioni nel file oggetto.
  • Simboli:
    • main è in questo caso il nostro punto di ingresso.

Quando eseguito, il programma fa questo:

  1. Con la load address (la) carichiamo in $a0 l'indirizzo della stringa del primo prompt;
  2. Carichiamo il servizio print string (4) con la load immediate (li);
  3. Eseguiamo la syscall per far comparire la stringa;
  4. Carichiamo il servizio read integer (4) e facciamo partire la syscall;
  5. Ora il numero letto è disponibile in $v0, ma per comodità lo copiamo in $t0;
  6. Ripetiamo i passi 1-4 per il secondo prompt e numero;
  7. Sommiamo i numeri con una addu (add unsigned) per non dover gestire un eventuale overflow. In caso lo volessimo, possiamo usare una semplice add;

Il resto è abbastanza capibile avendo analizzato quello che succede prima. Terminiamo l'esecuzione con la syscall 10.

Direttive all'assemblatore

Ne abbiamo viste un paio poco fa, ed ora le spieghiamo in generale. Con le direttive diciamo all'assemblatore:

  • Come gestire certe cose (es. allineamento dei valori al byte);
  • Come preparare il file oggetto (es. inizio dei segmenti);
  • Cosa metterci (es. dati statici);
  • Se usare dei nomi più umani per i registri;
  • Se raggruppare una sequenza di istruzioni in macro.

Le direttive iniziano con il punto, e quelle che useremo sono:

Direttiva Descrizione
.text Inizio del bocco istruzioni
.globl x Indica che la label x è accessibile da un altro file
.data Inizio del blocco dei dati statici
.eqv $nome, $reg Permette di usare $nome per riferirci a $reg
.macro e .end_macro Definisce una macro

All'interno del blocco .data possiamo definire dati statici in questi modi:

.data
label: .tipo val1, val2, ..., valn  # n valori separati da virgola
label: .tipo val:n                  # n valori ripetuti 

Dove .direttiva può essere una di queste:

Direttiva Utilizzo Esempio
.align k Allinea i prossimi dati alla potenza k^2 .align 2
.space Riserva n byte s: .space 255
.word Alloca spazio per delle word n: .word 1,2,3
.half Alloca spazio per della half-word h: .half 0:10
.asciiz Alloca un testo ASCII terminato da 0 txt: .asciiz "Salve"
.ascii Alloca un testo ASCII txt: ascii "Salve"

Dobbiamo spendere due parole sulla direttiva .align.
Se la usiamo, l'assemblatore farà in modo che tutti i dati siano allineati di un certo numero di byte, dato da 2^k. In altre parole, le posizioni di inizio dei dati saranno allineate con una precisione pari a 2^k.

Di solito si utilizza .align 2 per allinare dati a multipli di 4. Il motivo è semplice: dato che l'hardware è ottimizzato per trasferire word (4 byte), facciamo in modo che il loro recupero sia più facile (la posizione di inizio dei dati è facilmente calcolabile, perché ha precisione di una word).

Il lato negativo di questa direttiva è che crea inevitabilmente dei buchi in memoria.

Le macro ci permettono di definire blocchi di codice come se fossero delle funzioni. Durante l'assemblamento, vengono rimpiazzate dal codice che contengono.

Un esempio di macro può essere:

.macro takeInt (%regDst)
	li $v0, 5
	syscall
	move %regDst, $v0
.end_macro

Questa macro generalizza il comportamento della presa in input di un intero, come abbiamo visto prima. Le macro si chiamano nel codice come se fossero funzioni ad alto livello, quindi:

# ...
takeInt ($s0)
# ...

Nice.

Fermiamoci un attimo

Prima di passare ai salti (che sono carinissimi), dobbiamo trattare una cosa noiosa e brutta, che però ci serve per capire perché in MIPS non possiamo fare cose virtuose come lw $t0($t1), ed anche per capire cosa significa quella i in istruzioni come addi, subi, muli, etc.

Per questo direi di fare una pausa, in cui ti fai un po' di esercizi per consolidare queste poche conoscienze che abbiamo visto. Alcune idee:

  • Crea una variante a piacere dell'esempio visto prima;
  • Prendi in input due interi ed un carattere separatore, e stampa queste cose separate dal carattere dato:
    • Numero 1;
    • Numero 2;
    • Il loro prodotto;
    • La loro somma + 42.

Formati delle Istruzioni

Rieccoci qui.

Questa parte va a braccetto con lo schema della CPU MIPS ad un colpo di clock, quindi se non l'hai ancora visto ti consiglio di farlo più o meno ora.

Dobbiamo sapere che le istruzioni che utilizziamo in ASM MIPS sono un'astrazione che ci maschera la scrittura di sequenze di bit; a basso livello, infatti, un'istruzione è una sequenza di bit che viene data in pasto alla CPU.

In opportune fasi di esecuzione, altrettanto opportune unità funzionali della CPU prendono i pezzi del'istruzione che servono (in base ai segnali di controllo attivi).

Qui ho parlato in un modo abbastanza offuscato, perché sono cose che vediamo meglio nell'articolo di teoria che ho linkato qualche riga fa.

Possiamo anche osservare che le istruzioni MIPS possono essere divise in 4 gruppi, in base al loro funzionamento. Infatti abbiamo istruzioni che:

  • Operano solo su registri;
  • Operano su registri, e costanti;
  • Fanno dei salti:
    • In base a certe condizioni;
    • In ogni caso.

Da qui deriva l'esistenza di 3 codifiche di istruzioni MIPS.
I vero: i numeri non quadrano; il motivo è che possiamo riassumere quei 4 comportamenti in 3 sole codifiche.

Codifica R (Register)

Queste istruzioni operano solo su registri (es. add, move), ed il loro formato è questo:

oc rs rt rd shamt
bit 6 5 5 5 5

Alcune istruzioni di tipo R sono add, sub, and, xor, sra, jal.

Codifica I (Immediate)

Ecco finalmente il significato di quella i. In MIPS le istruzioni che hanno uno spazio per una costante numerica si chiamano immediate.

oc rs rt imm
bit 6 5 5 16

In questa categoria ricadono anche le istruzioni che eseguono salti condizionati, ovvero quelle della famiglia branch (le vedremo tra poco).

Alcuni esempi di istruzioni di tipo I sono addi, andi, slt, bne, lui, lw, sw, lb.

Codifica J (Jump)

Qui abbiamo i salti incondizionati, cioè quelli che vengono eseguiti sempre, senza necessità di soddisfare una condizione.

oc dest
bit 6 26

Abbiamo solo due istruzioni di questo tipo: j e jal.

Bene, ci siamo tolti questa parte.

Salti

Li abbiamo appena introdotti, ed ora ne parliamo meglio.

Come accennato poco fa, abbiamo due tipi di salti:

  • Condizionati: branch;
  • Non condizionati: jump.

Salti non condizionati

Questo tipo di salto è semplicissimo. In pratica ogni volta che si incontra un'istruzione di questo tipo, saltiamo alla destinazione indicata.

La sintassi è:

tipo_jump label

Dove label è l'etichetta che denota l'istruzione a cui saltare. Vediamo:

Tipo jump A voce Uso
j Jump j label
jal Jump and Link jal label

La jal salva il contenuto del program counter (PC) in $ra, e serve per chiamare funzioni salvando l'indirizzo da cui riprendere l'esecuzione una volta terminate. Vedremo meglio nel prossimo articolo.

Branch

Branch (o diramazioni) sono quelli che in un linguaggio ad alto livello corrispondono agli if.

In MIPS non abbiamo la stessa flessibilità di quel tipo di costrutti, ma è molto più rudimentale. La sintassi qui è:

tipo_branch $reg1, $reg2, label

In cui i due registri vengono confrontati in base al tipo di operazione di branch, ed in caso positivo l'esecuzione salta all'istruzione denotata da label.

Esistono branch in cui si compara con 0, ed hanno una sintassi più compatta.

Tipo branch A voce Uso Salta se op1 ? op2
beq Branch if equal beq $t0, $t1, label ==
bne Branch if not equal bne $t0, $t1, label !=
blt Branch if less than blt $t0, $t1, label <
ble Branch if less or equal ble $t0, $t1, label <=
bgt Branch if greater than bgt $t0, $t1, label >
bge Branch if greater or equal bge $t0, $t1, label >=
beqz Branch if equal to zero beqz $t0, label
bnez Branch if not equal to zero bne $t0, label

Ti viene in mente un modo per usare un branch per saltare sempre?
Il primo che risponde bene nei commenti vince un panino.

PRO TIP: Salvati la MIPS Reference Card.

Cicli

In MIPS non abbiamo istruzioni che ci permettono puramente di creare dei loop, ma possiamo farli combinando banch e jump.

A seconda del tipo di ciclo che vogliamo, abbiamo dei pattern.

Alcune note:

  • Uso branch come placeholder per beq, bne, etc.;
  • Uso etichette dummy (come do, while, for) per far vedere le analogie, ma puoi (leggi "devi") usare etichette univoche e adatte al contesto.

Do-While

Se ad alto livello vogliamo:

do {
  // roba
} while (cond)

In MIPS abbiamo:

do:
  # roba
  branch $a, $b, do

While

Alto livello:

while (cond) {
  // roba
}

MIPS:

while:
  branch (not_cond), endWhile
  # roba
  j while
endWhile:
# resto del programma

Da notare che dobbiamo negare la condizione.

For

Alto livello:

for (i; i<n, i++) {
  // roba
}

MIPS:

for:
  branch not_cond endFor
  # roba
  addi $i, $i, 1
  j for
endFor:
# resto del programma

Anche qui dal punto di vista concettuale dobbiamo negare la condizione iniziale.

Facciamo (ancora) una pausa

Prima di ucciderci con i vettori, conviene fermarsi a fare qualche esercizio.
Anche qui, ecco qualche idea:

  • Calcolatrice: dati in input due interi ed un'operazione codificata come intero, stampa il risultato del calcolo. Poi confronta con questo esempio;
  • Somma cumulativa:
    • Prendi in input un numero n;
    • Prendi in input n interi, e man mano sommali al registro $s0;
    • Stampa il risultato.
  • strlen: stampa la lunghezza di una stringa presa in input;
  • str2int:
    • Prendi una stringa e iteraci sopra;
    • Per ogni carattere, stampa il suo valore intero (hint: tabella ASCII);
    • Se il carattere è maiuscolo (stesso hint), aggiungi il suo valore intero ad una somma comulativa;
    • Al termine stampa la somma comulativa.

Quando risolvi gli esercizi (anche a piacere), mettili su GitHub e linkali nei commenti ;)

Vettori e Matrici

Un vettore è una struttura dati in cui sono presenti dati contigui della stessa lunghezza. Sono facilmente immaginabili in memoria come un segmento spezzettato.

Quello che bisogna sapere è che l'accesso ad un vettore è diretto: significa che dato un indirizzo di partenza (base) ed uno scostamento (offset), accediamo direttamente alla posizione che ci interessa, senza passare per quelle precedenti (cosa che avviene, ad esempio, nelle liste).

Ad esempio, ragionando in simil-C:

type vettore[2] <=> vettore + 2*sizeof(type)

In assembly MIPS, quindi, ci basta replicare il ragionamento appena visto: per accedere ad un elemento dobbiamo:

  1. Prendere l'indice attuale i;
  2. Moltiplicarlo per la dimensione del tipo;
  3. Sommarlo all'indirizzo di partenza del vettore.

Per fare un esempio, supponiamo di voler accedere alla posizione 2 di un vettore di interi V:

.eqv $i, $t0		# indice
.eqv $offs, $t1		# offset
.eqv $data, $t2		# dato letto

addi $i, $0, 2		# i=2
sll $offs, $i, 2	# offset=i*4
lw $data, V($offs)	# data=read_mem(V+offset)

Da notare la riga sll: facciamo lo shift left logical di 2 posizioni perché equivale a moltiplicare l'indice per 4, cioè la dimensione in byte di un intero (word).

Se invece operiamo su un vettore di byte (una stringa, ad esempio), non serve moltiplicare.

La situazione è un po' più complicata per le matrici, perché dobbiamo scordarci del comodissimo accesso con righe e colonne, perché non esistono. Dobbiamo immaginare di avere una riga lunghissima che contiene tutte le "righe" della matrice.

Ci accorgiamo che per accedere ad un elemento dobbiamo:

  1. Prendere la base;
  2. Calcolare lo scostamento in verticale (offset riga): riga*dimRiga;
  3. Calcolare lo scostamento in orizzontale (offset colonna): colonna*sizeof(tipo);
  4. Sommare i due offset alla base.

Quindi abbiamo che:

int M[X][Y];

M[x][y] <=> M + y*Y + x*sizeof(int)

Hai a disposizione qualche esempio sia per vettori che matrici.

Conclusioni

Carino MIPS eh?

Con questo articolo abbiamo imparato le basi della programmazione in assembly MIPS, ed abbiamo tutto quello che ci serve per passare allo stadio successivo, in cui vedremo roba un po' più virtuosa: giocheremo con lo stack e la ricorsione.

Prima di passare al prossimo articolo (quando uscirà), ti consiglio di:

  • Prendere familiarità con MARS, specialmente con il debugger;
  • Aver ben chiari i pattern per creare cicli;
  • Aver ben chiaro come operare su vettori.

Un extra ben accetto è sperimentare con le macro.

Se vuoi fare il figo, cimentati nella riduzione del numero di branch: ristruttura il codice in modo da evitare i branch non necessari.

Come sempre, se hai domande usa i commenti. Se hai risolto o vuoi proporre qualche esercizio/esempio, mettili in una repo su GitHub e segnalalo nei commenti.