În laboratorul 4 am menționat că din categoria instrucțiunilor de salt fac parte instrucțiunile ce apelează rutine și instrucțiuni care lucrează cu stiva. În continuare vom studia aceste instrucțiuni, dar și alte instrucțiuni și noțiuni necesare atunci când lucrăm cu funcții.
Funcțiile sunt un element important în orice limbaj de programare deoarece oferă suport pentru abstractizarea și refolosirea codului. Pentru a folosi o funcție, avem nevoie de prototipul ei - numele, numărul și tipul argumentelor, respectiv tipul valorii întoarse. Pentru a vedea cum se implementează funcțiile și apelul lor, trebuie întâi să stabilim ce se întâmplă atunci când apelăm o funcție. Vom lua un exemplu simplu: o funcție recursivă scrisă în C care calculează factorialul unui număr:
int fact(int n) { if (n == 0) return 1; else return fact(n - 1) * n; }
Se declară parametrul n, de tip întreg. Această variabilă și oricare altă variabilă pe care am folosi-o trebuie să fie locală funcției și să nu existe conflicte cu alte variabile din alte funcții, chiar dacă acolo se vor folosi aceleași nume ca aici. De fapt, mai mult decât atât, pentru aceste variabile nu trebuie să existe conflicte nici măcar cu alte copii ale lor (exact cum se întâmplă la un apel recursiv).
Pentru a putea implementa așa ceva, pentru fiecare apel al funcției fact, se realizează o copie într-o zonă de memorie diferită pentru variabila n, la fel ca și pentru tot corpul funcției. Vom avea nevoie de o zonă continuă de memorie, separată de restul programului, în care vom realiza aceste operații cu funcții și pe care o vom numi stivă, deoarece implementarea ei este asemănătoare cu a structurii de date cu același nume.
Diferite arhitecturi oferă diferite moduri in care trebuie implementat apelul unei funcții. Mențiuni importante sunt:
Exemple de convenții:
X86:
ARM(32):
Stiva este o zonă rezervată din memorie, continuă, cu ajutorul căreia este implementată ideea de funcție și apel de funcție.
Se alege prima oară o zonă de memorie arbitrară care va fi stivă. Pentru fiecare apel de funcție, va exista o secțiune pe stiva rezervată pentru acea funcție, numită “stack frame”. Să luăm următorul exemplu de cod în C:
int main(void) { int a = 5, b = 7, c; c = foo(a, b); return 0; } int foo(int a, int b) { return a + b; }
La intrarea în main(), stack-ul va conține doar frame-ul funcției main. Apoi, se ajunge la apelul funcției foo(), care ia 2 argumente. Metoda folosită de a trimite argumentele din main în funcția foo este să le încarci pe stivă, lăsând spațiu totodată și pentru valoarea de return a funcției foo(), spațiu din memorie care va fi încărcat odată ce foo() se termină. Stiva arată acum așa:
După cum se vede, după ce am încărcat pe stivă argumentele și valoarea de return pentru funcția pe care o vom apela, “stack frame-ul” pentru main a crescut . Practic ce s-a întâmplat a fost să micșorăm valoarea lui SP (stack-pointer - care de obicei arată ori spre prima zonă de memorie neinițializată/nefolosită din stivă, ori spre ultima zonă inițializată/folosită)
Când ajungem în corpul funcției foo(), aceasta e posibil să aibă nevoie de spațiu pentru variabile locale (în cazul de față nu), caz în care se modifică SP-ul pentru a crește “stack-frame-ul” lui foo() (operație echivalentă cu un PUSH). Stiva arată acum așa:
Se observă că a apărut un nou pointer, FP - frame pointer, care arată spre zona de memorie unde arată SP-ul înainte ca foo() să îl mute pentru a face loc variabilelor sale locale. Acest nou pointer ne va ajuta sa calculam locația în memorie pentru argumentele sau variabilele locale funcției (calculele de adresă pentru diferite variabile se vor face relativ la acest FP). Când funcția foo() s-a terminat, tot ce trebuie făcut este ca SP-ul să fie pus să arate spre locație de memorie indicată de FP (echivalent cu un POP). Astfel, după ce ieșim din foo(), stiva arată la fel cum arata înainte de a apela foo(), doar ca acum zona de memorie care a fost rezervată pentru valoarea de return o conține pe aceasta:
După ce main() salvează valoarea de return, crește SP-ul astfel încât după ce se face POP la aceasta și la argumente, stiva va arăta exact ca la început.
Instrucțiunile ce ne interesează în mod special sunt RCALL și RET.
RCALL este un salt relativ de la adresa curentă la adresa specificată ca parametru. Atunci când se realizează un apel de funcție folosind RCALL, adresa instrucțiunii ce urmează după RCALL este salvată pe stivă. Această adresă se numește return address
. Este important să memorăm această adresă deoarece, la ieșirea din funcție, dorim să continuăm execuția normală a programului. În plus, la realizarea unui apel de funcție, pe stivă se rezervă 2 octeți - (return address
are 16 biți).
Motivul pentru care RCALL realizează un salt relativ și reține adresa de revenire pe stivă este următorul: codul unei funcții/proceduri se poate afla oriunde în memorie. Aceasta poate fi situată la începutul sau la sfârșitul memoriei, într-o zonă în care există deja și o parte din codul unei alte funcții (spre exemplu main()
) sau într-o zonă aleatoare izolată.
Instrucțiunea RET realizează revenirea dintr-o funcție la codul de unde a fost apelată aceasta. Practic, marchează sfârșitul zonei de memorie de unde se citește și execută codul funcției.
RET este o instrucțiune cu 0 operanzi. Operanzii nu sunt necesari deoarece adresa la care trebuie să revină PC-ul pentru a continua execuția normală a programului se află salvată deja pe stivă. În urma apelului RET, se vor elibera 2 octeți de pe stivă (pentru varianta cu program counter pe 16 biți).
În acest laborator vom implementa următoarele instrucțiuni (pe 16 biți):
rcall k
PC ← PC + k + 1
; -2048 < k < 2047SP ← SP - 2
ret
SP ← SP + 2
Scheletul laboratorului conține un checker pentru verificarea Taskului 1 și 2.
rom.v
) este:
0,1,4,5,2,3,6
0:ldi r16, 5 1:rjmp main_function first_function: 2:ldi r17, 15 3:ret main_function: 4:ldi r17, 10 5:rcall first_function 6:ldi r18, 20
Task 0 (2p) In acest laborator vom salva registrul PC pe stiva. Registul PC are 16 biti, iar pe stiva putem pune doar 8 biti per apel de PUSH
. (i) Analizați fișierul state_machine.v
. Cum s-a modificat stagiul MEM al benzii de asamblare pentru instrucțiunile RCALL
și RET
? (ii) Cum putem salva pe stiva PC-ul? Descrieti pe scurt. (Hint: TWO_CYCLE_MEM
) (iii) In control_unit.v
, cum putem folosi cycle_count
pentru a citi de 2 ori din bus_data? Dati un exemplu de cod Verilog care face asta.
Task 1 (2P) Implementați instrucțiunea RCALL. Folosiți varianta (I) a instrucțiunii (pentru dispozitive cu PC pe 16 biți). In control_unit.v
, la ce sunt folosit urmatoarele: data_to_store
, saved_pc
, signals[`CONTROL_MEM_WRITE]
si program_counter
?
saved_pc
- este un buffer de ADDR_WIDTH biti care ajuta la stocarea PC pe stiva si la citirea lui de pe stiva.
bus_data
- folosit pentru a citi datele din memorie.
cycle_count
- folosit pentru a scrie de doua ori in memorie in cadrul aceleasi instructiuni(e.g. avem nevoie sa scrie 16 biti pe stiva), acest semnal ne permite sa ne dam seama daca suntem la prima sau a doua scriere
TWO_CYCLE_MEM
și cycle_count
.TODO 1
din fișierele decode_unit.v
, control_unit.v
și signal_generation_unit.v
.
Task 2 (2P) Implementați instrucțiunea RET. Folosiți varianta (I) a instrucțiunii (pentru dispozitive cu PC pe 16 biți).
TODO 2
din fișierele decode_unit.v
, control_unit.v
și signal_generation_unit.v
.
Task 3 (2P) Creați un program care calculează suma a două numere folosind o funcție sum
ce primește doi parametri. Înainte de apel se vor pune pe stivă cei doi parametri ai funcției.
Funcția sum
va lua parametrii de pe stivă fără a modifica valoarea return_address (salvată tot pe stivă).
Funcția sum
va pune rezultatul în registrul R16.
Exemplu cod C:
int sum (int a, int b) { return a+b; } int main () { int a, b, d; a = 10; b = 2; d = sum (a, b); }
Task 4 (2P) Ce face urmatorul program? Adaugati comentarii sugestive pentru fiecare instructiune.
[BONUS] Task 5 (1p) Refaceți exercițiul anterior intr-un mod recursiv, fara a folosi un loop. Executati programul.