Esempio RISC-V (1) - Calcolo area di un triangolo

Codice in C:

long moltiplicazione(long a, long b){
	long rst = a;
	for(long i = 1; i < b; i++){
		rst = rst + a;
	}
	return rst;
}

long area(long base, long altezza){
	long rst = moltiplicazione(base, altezza)/2;
	return rst;
}

...
printf("Area = %li\n", area(20, 23));
...

Si supponga di non avere un operatore per la moltiplicazione.

Guardando questo codice, vediamo che vi è un punto del programma (printf()) in cui viene chiamata la funzione area(), tale funzione a sua volta chiama la funzione moltiplicazione() (funzione foglia) che restituisce il risultato della moltiplicazione tra due numeri.

In RISC-V dovremo sicuramente scrivere le due funzioni moltiplicazione e area e poi chiamarle.
Partiamo dal punto di ingresso di RISC-V (_start) dove faremo in modo di chiamare le funzioni e stampare a video il risultato.

Il codice sarà presentato in maniera evolutiva, arricchito delle varie aggiunte opportunamente spiegate.

_start:
	li a0, 20 # salvataggio della base in a0
	li a1, 23 # salvataggio dell'altezza in a1

Per passare i parametri ad una procedura, essi vanno salvati nei registri che vanno da a0-a7.
Vedi tabella dei registri e delle convenzioni sul loro utilizzo.

_start:
	li a0, 20 # salvataggio della base in a0
	li a1, 23 # salvataggio dell'altezza in a1
	
# chiamata della procedura: area(base, altezza)
	jal ra, area 
	add t0, a0, zero

Visto che viene effettuata una chiamata a procedura, viene salvato nel registro ra l'indirizzo dell'istruzione successiva, in modo che l'esecuzione del codice possa poi continuare, alla chiusura della procedura chiamata, dall'istruzione successiva alla chiamata. L'istruzione successiva non è altro che il salvataggio in t0 del valore di ritorno della chiamata che viene salvato in a0 come da convezione (vedi tabella).

_start:
	li a0, 20 # salvataggio della base in a0
	li a1, 23 # salvataggio dell'altezza in a1
	
# chiamata della procedura: area(base, altezza)
	jal ra, area 
	add t0, a0, zero # a0 viene salvato in t0

# stampa del messaggio: Area = 
	la a0, visris # caricamento della stringa dalla memoria in a0
	addi a7, zero, 4 # e stampa di tale stringa
	ecall

# stampa del risultato
	add a0, t0, zero # a0 contiene il valore di t0
	addi a7, zero, 1 # a0 viene stamapato
	ecall

# stampa \n
	la a0, RTN
	addi a7, zero, 4
	ecall

# exit
	addi a7, zero, 10
	ecall

Gli ultimi pezzi di codice aggiunti non sono altro che la stampa a video del risultato, che per gli scopi del nostro corso non ricoprono una grande importanza, ma che per completezza illustreremo lo stesso.
visris è una pseudo-istruzione che consente di mostrare a video dei simboli, in questo caso la stringa Area = (salvata in memoria), infatti viene fatta una operazione di caricamento dalla memoria la, nel registro a0, poi viene settato il registro a7 a 4 (addi a7, zero, 4), nel registro a7 viene specificato il codice della system call che il sistema operativo deve chiamare, in questo caso: 4 indica la system call print string. ecall effettua la chiamata basandosi sui settaggi del registro a7.

Stessa cosa avviene per la stampa del valore di ritorno e di \n e la exit.

Chiamata a procedura: area
...

In questa fase descriviamo la chiamata alla procedura area e i passi che devono essere eseguiti quando si effettua una generica chiamata a procedura.

area:
# creazione del frame di area sullo stack (24 byte = ra + fp + rst)
# lo stack cresce verso il basso
	addi sp, sp, -24 # allocazione call frame nello stack
	sd fp, 0(sp)     # salvataggio del precedente frame pointer
	sd ra, 8(sp)     # salvataggio dell'indirizzo di ritorno
	addi fp, sp, 16  # aggiornamento del frame pointer

Con queste righe di codice viene creato un nuovo frame.
La dimensione di tale frame è di 24 byte:

  • 8 byte per la variabile rst (vedi funzione C in cima)
  • 8 byte per il valore di ritorno
  • 8 byte per il salvataggio del frame pointer
    Con addi sp, sp, -24 viene esteso lo spazio dello stack per allocare il frame.
    Nel registro fp viene salvato l'indirizzo del precedente frame pointer.
    Ecco cosa succede:
    0.png
    Quando viene esteso lo stack pointer, si verifica che sp punta ad una nuova zona di memoria:
    1.png
    Attraverso sd fp, 0(sp), fp viene salvato in 0(sp).
    Mentre l'indirizzo di ritorno viene salvato in 8(sp).
    Mentre la variabile usata dalla funzione C: rst viene salvata in 16(fp).
    Ricordiamo che con 0(sp) ci riferiamo alla 0-esima double word sopra sp.

Ma dove punta fp?
Con l'istruzione addi fp, sp, 16, viene aggiornato il valore del frame pointer che adesso punta a sp + 16 = -8. Ovvero due double word sopra sp.
3.png
Sappiamo che lo stack cresce verso il basso, di fatto, quando abbiamo allocato nuovo spazio per lo stack, non abbiamo fatto altro che sommare allo stack pointer -24.

Lo stack è confusionario. Cresce verso indirizzi di memoria più bassi.

area:
# creazione del frame di area sullo stack (24 byte = ra + fp + rst)
# lo stack cresce verso il basso
	addi sp, sp, -24 # allocazione call frame nello stack
	sd fp, 0(sp)     # salvataggio del precedente frame pointer
	sd ra, 8(sp)     # salvataggio dell'indirizzo di ritorno
	addi fp, sp, 16  # aggiornamento del frame pointer

# calcolo dell'area
	jal ra, moltplicazione
	sd a0, 0(fp) 
	srai a0, a0, 1

Nel calcolo dell'area viene effettuata un'altra chiamata a procedura moltiplicazione. In a0 si troverà il valore ritornato da tale chiamata. Esso viene salvato in 0(fp), ovvero nella variabile rst.
Viene poi implementato un shift aritmetico a destra per effettuare la divisione per due del risultato per calcolare l'area del triangolo (vedi shift aritmetico a destra).

area:
# creazione del frame di area sullo stack (24 byte = ra + fp + rst)
# lo stack cresce verso il basso
	addi sp, sp, -24 # allocazione call frame nello stack
	sd fp, 0(sp)     # salvataggio del precedente frame pointer
	sd ra, 8(sp)     # salvataggio dell'indirizzo di ritorno
	addi fp, sp, 16  # aggiornamento del frame pointer

# calcolo dell'area
	jal ra, moltplicazione
	sd a0, 0(fp) 
	srai a0, a0, 1

# uscita dalla funzione
	ld fp, 0(sp)     # recupero del vecchio frame pointer
	ld ra, 8(sp)     # recupero dell'indirizzo di ritorno
	addi sp, sp, 24  # distruzione del frame di attivazione
	jr ra            # salto all'indirizzo di ritorno (ritorno al chiamante)

Chiamata a procedura: moltiplicazione
...

moltiplicazione:
# creazione del frame di area sullo stack (16 byte = fp + rst)
# lo stack cresce verso il basso
	addi sp, sp, -24 # allocazione call frame nello stack
	sd fp, 0(sp)     # salvataggio del precedente frame pointer
	addi fp, sp, 8   # aggiornamento del frame pointer
	sd a0, 0(fp)     # salvataggio di a0 in memoria (in 0(fp))

Per il frame di questa procedura vengono allocati solo 16 byte, perché trattandosi di una procedura foglia non è necessario tenere traccia dell'indirizzo di ritorno, poiché non verranno chiamate altre procedure che modificheranno tale registro. Bisogna tenere traccia nello stack solo del vecchio frame pointer e della variabile locale rst.
4.png

5.png

moltiplicazione:
# creazione del frame di area sullo stack (16 byte = fp + rst)
# lo stack cresce verso il basso
	addi sp, sp, -24 # allocazione call frame nello stack
	sd fp, 0(sp)     # salvataggio del precedente frame pointer
	addi fp, sp, 8   # aggiornamento del frame pointer
	sd a0, 0(fp)     # salvataggio di a0 in memoria (in 0(fp))

	ld t2, 0(fp)
	li t0, 1         # indice del for scritto in t0

for:
	bge t0, a1, endfor
	add t2, a0, t2
	addi t0, t0, 1
	j for
	
endfor:
	ld fp, 0(sp)     # recupero del vecchio frame pointer
	addi sp, sp, 16  # distruzione del frame di attivazione
	add a0, t2, zero # viene salvato il valore di ritorno in a0
	jr ra            # ritorno al chiamante

Il resto del codice è un normale ciclo for e alla fine viene deallocato il frame di attivazione.