07. Code generation. Function calls and stack frames

Apelul de funcţii

Transmiterea parametrilor

În limbajele de nivel înalt, exista câteva modalități de transmitere a parametrilor și de întoarcere a rezultatelor, dintre care amintim:

  • apelul prin valoare
  • apelul prin referinta
  • apelul prin rezultat
  • apelul prin valoare-rezultat
  • apelul prin nume.

Folosim termenul de argument sau argument actual pentru a ne referi la expresia care este trimisă unei rutine și termenul de parametru sau parametru formal pentru a referi variabila căreia această expresie îi este asociată în rutina apelată

Apelul prin valoare

Fiecare argument este evaluat și valoarea lui este pusă la dispoziție procedurii apelate în parametrul formal corespunzător.

În timpul execuției procedurii, nu există nicio interacțiune cu variabilele apelantului. Excepția apare în cazul în care argumentul este un pointer și procedura apelată ar putea folosi valoarea pointerului pentru a modifica valorile către care indică acesta.

Apelul prin valoare se implementează de obicei prin copierea valorii fiecărui argument în parametrul corespunzător la intrarea în procedura apelată. Aceasta modalitate poate fi foarte eficientă în cazul argumentelor care pot fi ținute în regiștri, dar foarte ineficientă pentru tipuri de date compuse - vectori, structuri, obiecte - deoarece presupune un transfer de date semnificativ în memorie.

Apelul prin valoare este singurul mecanism de pasare a parametrilor în limbajul C. Un parametru poate fi însă și adresa unui obiect; se obține astfel efectul apelului prin referință.

În C++, la transferul unui obiect prin valoare se va crea o copie a acelui obiect, si se va apela constructorul de copiere, daca acesta exista.

class C;
void f(C a);            // parametrul a se transmite prin valoare
void g() { C x; f(x); } // se apeleaza constructorul C::C(const C&) ce copiaza x in a

Apelul prin referință

Realizează o asociere între argumentul actual și parametrul corespunzator. Procedura apelată va avea acces total la argument pe toată perioada execuției sale, putând să schimbe parametrul actual sau să îl transmită mai departe altor proceduri.

Ca implementare tipică, la intrarea în procedura apelată se determină adresa argumentului și aceasta se pune la dispoziția procedurii ca mijloc de accesare a argumentului. Acest mecanism este foarte eficient când vectori sunt transmiși ca parametri, dar poate fi ineficient în cazul parametrilor de mărime mică care ar putea fi transmiși prin regiștri.

Apelul prin referință poate cauza un alias - o situație în care aceeași locație de memorie poate fi accesată folosind două nume simbolice diferite. Un exemplu (C#) :

void f(ref int i, ref int j) { i++; j++; }
void g() { int x = 1; f(x,x); /* x == 3 */ }

Pot apărea probleme în cazul în care o constantă este transmisă prin referință; procedura apelată ar putea modifica acea constantă, ceea ce este ilegal. Soluția uzuală este copierea constantelor în noi locații de memorie și transmiterea acestor adrese când se face apelul prin referință.

Apelul prin referința este implementat implicit în Fortran, sau explicit via o sintaxă specială în C++, C# sau PHP. Același efect se poate obține în C și C++ prin transmiterea adresei ca valoare.

În Java, JavaScript sau Python, parametrii sunt transmiși prin valoare, la fel ca și în C. Acest lucru este valabil atât pentru primitive cât și pentru obiecte.

Afirmația obiectele se transmit prin referință este greșită. Deoarece variabilele din Java conțin referințe către obiecte și nu obiectele în sine, corect este referința obiectelor este transmisă prin valoare.

Codul urmator C++ folosește apelul prin referință pentru a interschimba valorile variabilelor a și b. Codul similar Java, însă, folosește apelul prin valoare și lasă variabilele neschimbate.

C++

void swap(Obj &a, Obj &b) { Obj t=a; a=b; b=t; }
void f() { Obj a,b; swap(a,b); } 

Java

void swap(Obj a, Obj b) { Obj t=a; a=b; b=t; }
void f() { Obj a,b; swap(a,b); } 

Apelul prin rezultat

Este similar celui prin valoare, cu deosebirea că aici se întorc valorile de la apelat la apelant. La intrarea în procedura apelată nu se întamplă nimic, iar la întoarcere valoarea unui parametru apelat prin rezultat este pusă la dispoziția apelantului prin copierea valorii lui în argumentul asociat. Acest tip de apel apare în cazul parametrilor de tip out din Ada sau C#:

void a(out int result)
{ result = 7; /* daca lipseste atribuirea in result, da eroare de compilare */ } 
[...]
// exemplu de folosire:
int rez;
a(out rez); // acum rez a primit valoarea din a.

Apelul prin valoare-rezultat

Este caracterizat de reuniunea proprietăților celor două tipuri de apel: prin valoare și prin rezultat. La intrarea în procedura apelată valoarea argumentului se copiază în parametru, iar la ieșire valoarea parametrului se copiază înapoi în argument. Este implementat în Fortran sau Ada pentru parametri de tip inout, și apare și în cazul apelurilor de procedură pe un alt computer (RPC - remote procedure calls). Diferenta față de apelul prin referință este că nu mai apar situații de alias între argumente.

Apelul prin nume

Este un mecanism de evaluare întârziată a argumentelor unei funcții (lazy evaluation). Este similar cu transmiterea prin referință, însă argumentele actuale sunt evaluate de fiecare dată când parametrul formal este folosit și nu doar o singură dată la intrarea în procedură.

Astfel, dacă argumentul este a[i], și valoarea lui i se schimbă între două folosiri ale argumentului, la cele două folosiri se vor accesa de fapt două elemente diferite ale vectorului.

Mecanismul este rar în limbajele moderne, dar a fost folosit istoric (ALGOL 60). Un exemplu:

begin
  integer array a[1..2]; integer i;
 
  procedure f(x, j);
    integer x, j;
    begin
      integer k;
      k := x;
      j := j+1;
      x := j;
      f := k;
    end;
 
  i := 1;
  a[1] := 5;
  a[2] := 8;	
  outinteger(a[1], f(a[i],i), a[2]);
end

În exemplul de mai sus în care i și a[i] sunt transmiși de către programul principal procedurii f, prima folosire a parametrului x aduce valoarea lui a[1], pe cand cea de-a doua folosire seteaza valoarea a[2]. Apelul outinteger() va avea ca rezultat afișarea valorilor „5 5 2”. Dacă s-ar fi folosit apelul prin referința, atunci s-ar fi afișat „5 5 8”.

O variantă a apelului prin nume este folosită în unele limbaje funcționale, în care evaluarea unui argument se face o singură dată, dar la prima folosire a acestuia, și nu la intrarea în funcție.

Convenții de apel

Apelarea unei proceduri dintr-o altă procedură presupune existența unui 'protocol' prin care se trece controlul de la apelant la apelat, prin care parametrii sunt pasați în aceeași direcție iar valoarea rezultatului este pasata de la apelat la apelant. În cazul unui model simplu de runtime, execuția unei proceduri constă în cinci faze, fiecare dintre acestea având mai multe subfaze:

  • Asamblarea argumentelor ce trebuie transferate procedurii și pasarea controlului.
    • Fiecare argument este evaluat și pus în registrul sau locația de pe stivă corespunzatoare; evaluare poate înseamna calcularea adresei lui (pentru cei pasați prin referință), valoarea lui (pentru cei pasați prin valoare), etc.
    • Se stabilește adresa codului procedurii (pentru cele mai multe limbaje însă, a fost stabilită la compilare sau linkare)
    • Regiștrii care au fost folosiți și vor mai fi și după întoarcerea din procedura apelată, se stochează în memorie dacă protocolul specifică că este datoria apelantului sa facă acest lucru
    • Se salvează adresa de întoarcere și se execută un salt la adresa codului procedurii (de obicei o instrucțiune call face aceste lucruri)
  • Prologul procedurii, executat la întrarea în procedură, stabilește mediul necesar adresării și poate salva regiștrii folosiți de procedură în scopuri proprii
  • Se executa procedura, care la rândul ei poate apela alte proceduri
  • Epilogul procedurii restaurează valorile regiștrilor și mediul de adresare al apelantului, asamblează valoarea pe care trebuie să o întoarcă și îi redă controlul apelantului
    • Regiștrii salvați de procedura apelată sunt restaurați din memorie
    • Valoarea care trebuie întoarsă se pune în locul corespunzator (dacă procedura întoarce o valoare)
    • Se incarcă adresa de revenire și se executa un salt la această adresă (de obicei, o instrucțiune ret face acest lucru)
  • Codul din procedura apelantă care se afla după apel își termină restaurarea mediului sau de execuție și primește valoarea intoarsă
    • Regiștrii salvați de către procedura apelantă sunt restaurați din memorie
    • Se folosește valoarea întoarsă de procedura apelată.

Cadre de stiva (stack frames)

Deși ar fi ideal ca toți operanzii să fie ținuți în regiștri, majoritatea procedurilor necesită spatiu de memorie pentru:

  • variabilele care fie nu au primit regiștri, fie nu pot fi ținute în regiștri pentru că le sunt încărcate adresele (explicit sau implicit, ca in cazul parametrilor transferati prin referință) sau pentru că trebuie să fie indexabile
  • a oferi un loc “standard” pentru a salva valorile din regiștri atunci când se execută un apel de procedură
  • a oferi unui debugger o modalitate de a cunoaște lanțul de proceduri active la un moment dat (call stack)

Deoarece aceste spații de memorie devin necesare la intrarea într-o procedură și devin inutile la ieșirea din aceasta, ele sunt grupate în cadre de stivă(activation records sau stack frames), care la randul lor sunt organizate sub formă de stiva. Un cadru de stivă poate conține:

  • valorile parametrilor trimiși rutinei curente care nu încap în regiștrii destinați acestui scop,
  • toate sau o parte din variabilele locale,
  • o zona de salvare a regiștrilor temporari alocați de compilator,
  • adresa de întoarcere din procedura etc.

Pentru a putea accesa locatiile din cadrul de stivă curent în timpul execuției, acestora li se asociază distante în unitati de memorie relative la un pointer stocat într-un registru. Pointerul poate fi frame pointer-ul fp care indică prima locație a cadrului curent, sau stack pointer-ul sp care indică vârful stivei, adica imediat dupa ultima locație a cadrului curent. Numeroase compilatoare aranjează în memorie cadrele de stivă astfel încat începutul acestora se află la adrese mai mari de memorie decât sfârșitul acestora. Această alegere face ca deplasamentul față de sp-ul curent în cadrul curent să fie pozitiv, așa cum se vede în figura de mai jos.

Unele compilatoare folosesc amândoi pointerii, sp și fp, având variabile relative la amândoi regiștrii.

Caracteristicile limbajului și cele ale hardware-lui determină alegerea unuia dintre acești registri sau chiar folosirea ambilor pentru accesarea locațiilor de pe stivă. Problemele sunt urmatoarele:

  • folosirea a doi regiștri pentru accesul la stivă înseamnă a consuma un registru care ar putea folosi altor scopuri;
  • dacă deplasamentul scurt față de un singur registru oferit de instrucțiunile load (încărcare din memorie) și store (salvare în memorie) este suficient pentru a acoperi spatiul de memorie al majorității înregistrărilor de activare (ceea ce se întâmplă pentru cele mai multe arhitecturi).
  • dacă dimensiunea cadrului de stivă este sau nu cunoscută la momentul compilării, și dacă se modifică pe parcursul execuției procedurii.

Dimensiunea unui cadru de stivă nu este cunoscută, de exemplu, dacă trebuie să fie suportate funcții de alocare a memoriei de tipul alloca() din biblioteca C prin care se alocă spațiu în mod dinamic în cadrul de stivă curent, și se întoarce un pointer la acel spațiu.

Prin urmare, este suficient și de preferat să se folosească doar sp-ul, în cazul în care nu trebuie să se ofere suport pentru funcții de tipul alloca().

Efectul unui apel alloca() este să extindă cadrul de stivă făcând sp-ul să indice la o locație nouă. Prin urmare, deplasamentele relative la sp vor pointa către alte locații. Deoarece în C se poate calcula adresa unei variabile locale și apoi folosi, se impune ca acele cantități accesate relativ la sp să nu poată fi direct adresabile de catre utilizator și se preferă ca acestea să fie valori necesare la apelul unei alte proceduri.

Astfel, în momentul când există fp, adresarea relativa la sp se foloseste pentru:

  • transmiterea argumentelor unei alte proceduri,
  • salvarea regiștrilor peste un apel de procedură
  • întoarcerea rezultatelor dintr-o altă procedură.

Pentru a oferi suport pentru alloca() avem nevoie atât de un registru sp cât și de unul fp. Chiar dacă aceasta necesita înca un registru în plus, exista avantajul execuției rapide în cazul apelurilor de proceduri. Astfel, la intrarea în procedură:

  • se salvează vechiul fp pe stivă, în cadrul noului cadru,
  • se setează valoarea noului fp la valoarea vechiului sp și
  • adaugă lungimea cadrului curent la vechiul sp pentru a obține noua valoare a lui sp.

La întoarcerea din procedură, procesul invers este:

  • se setează valoarea noului sp la valoarea vechiului fp
  • se încarcă noul fp de pe stivă

Exemplu: funcție minimală

int add(int a, int b)
{
      return a + b;
}
gcc - x86 (CISC)
_add:                     ; Identificatorii C sunt prefixati de caracterul _
 
                          ; La intrarea in functie, stiva contine:
                          ;     | b
                          ;     | a
                          ; esp | Adresa de intoarcere
 
  pushl   %ebp            ; Prin conventie, registrul ebp este frame pointer
  movl    %esp, %ebp      ; catre inceputul cadrului de stiva al unei functii.
                          ; Stiva acum:
                          ; ebp + 12 | b
                          ; ebp +  8 | a
                          ; ebp +  4 | adresa de intoarcere
                          ; ebp      | ebp din functia anterioara
 
                          ; Deoarece stiva creste in jos, parametrii se gasesc la 
                          ; deplasamente pozitive (ebp+8) in timp ce variabilele locale 
                          ; se gasesc la deplasamente negative (ebp-8).
 
  movl    12(%ebp), %eax  ; eax = b [ebp + 12]
  movl    8(%ebp), %edx   ; edx = a [ebp + 8 ]
                          ; Registrii eax si edx sunt volatili, deci pot fi folositi de 
                          ; catre functie fara restrictii.
 
  addl    %edx, %eax      ; eax += edx
                          ; Valoarea intoarsa de functie este plasata in registrul eax.
 
  popl    %ebp
  ret
gcc – ARM (RISC)
add:
   add     r0, r0, r1
   bx      lr

Functia nu foloseste stiva. Atat parametrii, cat si adresa de intoarcere sunt transmisi prin registrii procesorului:

R0 a
R1 b
LR Adresa de intoarcere

Valoarea intoarsa de functie trebuie plasata in R0. Codul generat este astfel echivalent cu:

  • R0 = R0 + R1
  • Salt la LR.
JVM (masina virtuala bazata pe stiva)
public static int add(int, int);
   Stack=2, Locals=2, Args_size=2
   0:   iload_0
   1:   iload_1
   2:   iadd
   3:   ireturn	

Masina virtuala Java nu are registri si foloseste stiva pentru toate operatiile.

Functia are 2 argumente, ce sunt copiate in zona de variabile locale, si foloseste 2 locatii de stiva.

Variabilele locale sunt copiate pe stiva:

  push local[0]; --> a
  push local[1]; --> b

Instructiunea de adunare inlocuieste doua valori din varful stivei cu suma lor. Codul echivalent intr-o masina cu registri:

  pop a
  pop b
  c = a + b
  push c

Functia intoarce valoarea ramasa în vârful stivei.

Exemplu: evaluarea unei expresii

int add_mul(int a, int b, int c, int d)
{
      return a * b + c * d;
}
gcc - x86
_add_mul:
  pushl   %ebp
  movl    %esp, %ebp
 
; Stiva, dupa executia codului de initializare:
;    +20  d
;    +16  c
;    +12  b
;    +8   a
;    +4   Adresa de intoarcere
; ebp+0   ebp din functia anterioara
 
; Compilatorul descompune calculul expresiei in instructiuni simple
; Apoi mapeaza variabilele temporare pe registrii hardware ai procesorului
 
  movl    8(%ebp), %edx  ;   T1 = a       ; edx = a
  movl    12(%ebp), %eax ;   T2 = b       ; eax = b
  imull   %edx, %eax     ;   T3 = T1 * T2 ; eax = edx * eax
  movl    16(%ebp), %ecx ;   T4 = c       ; ecx = c
  movl    20(%ebp), %edx ;   T5 = d       ; edx = d
  imull   %ecx, %edx     ;   T6 = T4 * T5 ; edx = ecx * edx
  addl    %edx, %eax     ;   T7 = T3 + T6 ; eax = eax + edx
 
  popl    %ebp
  ret
JVM
public static int add_mul(int, int, int, int); Stiva
Code:
Stack=3, Locals=4, Args_size=4
0: iload_0 a
1: iload_1 a
b
2: imul a * b
3: iload_2 a * b
c
4: iload_3 a * b
c
d
5: imul a * b
c * d
6: iadd a * b + c * d
7: ireturn

Exemplu: apeluri de funcții

int add_mul2(int a, int b, int c, int d)
{
      return mul(a,b) + mul(c,d);
}
gcc - x86
_add_mul2:
        pushl   %ebp
        movl    %esp, %ebp
        pushl   %ebx
        subl    $20, %esp
; Conform conventiei de apel, registrul ebx nu trebuie modificat de catre functii. 
; Pentru ca add_mul2() il foloseste, el este salvat pe stiva la intrarea in functie
; si restaurat la iesire.
;
; Stiva, dupa executia codului de initializare:
;
; ebp+20  d
; ebp+16  c
; ebp+12  b
; ebp+8   a
; ebp+4   Adresa de intoarcere
; ebp+0   ebp din functia anterioara
;         ebx din functia anterioara
;         Spatiu pentru variabilele locale.
; esp+4   Al doilea parametru pentru mul(int,int)
; esp+0   Primul parametru pentru mul(int,int)
 
        movl    12(%ebp), %eax
        movl    %eax, 4(%esp)
        movl    8(%ebp), %eax
        movl    %eax, (%esp)
        call    _mul
; Codul de nivel inalt: ebx = mul ( a, b )
; Parametrii pentru mul ( a, b ) sunt plasati in spatiul prealocat.
 
        movl    %eax, %ebx
; Rezultatul functiei este salvat in ebx. Se foloseste ebx, deoarece, conform
; conventiei, este un registru nonvolatil, prin urmare nu va fi distrus de urmatorul
; apel al functiei mul().
 
        movl    20(%ebp), %eax
        movl    %eax, 4(%esp)
        movl    16(%ebp), %eax
        movl    %eax, (%esp)
        call    _mul
; eax = mul ( c,d )
; De remarcat ca parametrii unei functii apartin cadrului de stiva al functiei 
; anterioare, care o apeleaza. 
; In cazul nostru, stiva la apelul lui mul(c,d) :
;
;                           d
;                           c
;                           b
;                           a
; Cadrul add_mul2() -->     Adresa de intoarcere din add_mul2()
;                           ebp din functia anterioara
;                           ebx din functia anterioara
;                           Spatiu pentru variabilele locale.
;                           d
;                           c
; Cadrul mul()      -->     Adresa de intoarcere din mul()
;                           ...
 
        addl    %eax, %ebx
        movl    %ebx, %eax
; Calculul valorii intoarse de add_mul2():
;  ebx += eax
;  eax = ebx
 
        addl    $20, %esp
        popl    %ebx
        popl    %ebp
        ret

Prototipuri de funcții

Un prototip de funcție reprezintă o declarare a funcției care omite corpul funcției, dar specifică numele, tipul parametrilor și tipul întors. În timp ce definiția unei funcții specifică implementarea unei funcții, prototipul poate fi descris ca fiind interfața funcției. Acest lucru este pus în evidență în IDE-urile care au opțiuni diferite “Go to declaration” și “Go to definition”.

Prototipurile sunt folosite de compilator pentru a determina caracteristicile unei funcții ce urmează a fi apelata, pentru a i se transmite parametrii și a se primi valoarea întoarsă, fără a avea acces la codul sursă al funcției.

Dacă prototipul funcției vizibil în locul în care funcția este apelata diferă de prototipul functiei din locul în care funcția este definita, atunci se pot ajunge la apeluri de funcție eronate, datorate incompatibilității între numărul și tipul parametrilor din locul apelării funcției și din funcția propriu-zisă.

În limbajul C, dacă o funcție nu este declarată, ea va avea automat atribuit prototipul implicit al funcțiilor:

// pentru apeluri de tipul
func(); x = func();
// semnătura implicită este
int func(void);

Pentru funcții care sunt apelate cu argumente semnătura implicită este

func(x, y, z);
//
int func(typeof_x, typeof_y, typeof_z);

Un exemplu de functionare eronată a unui apel de funcție din motive de incompatibilitate între prototipurile unei funcții:

//        fisier1.c
/* func() nedeclarata                   prototip implicit
                                        int func(void) */
int main()
{
    /* ... */
    value += func();
    /* ... */
}
// fisier2.c
/* prototip int func(int, int) */
long long func(int param1, int param2)
{
    return (long long) param1 + param2;
}

În acest caz, în locul de unde se face apelul (din funcția main) se foloseşte prototipul implicit, care împreună cu un apel greşit de funcție, duce la cod eronat:

  • în locul de unde se face apelul, nu se transmite niciun parametru, și se aşteaptă să se întoarcă o valoare de tip int,
  • în funcția apelată, se așteapta 2 parametri care vor fi citiți eronat din locațiile corespunzătoare, iar valoarea întoarsă va fi un long long.

Este important de observat, totuși, ca majoritatea limbajelor moderne (inclusiv C++, versus C) nu acceptă tipuri si prototipuri implicite.

Excepții

Există anumite situații în care se dorește/preferă să se arunce o excepție. Controlul se transferă către blocul de cod desemnat să se execute în momentul în care se produce excepția. Acest cod se numește handler. Când există un handler pentru excepția aruncată, se spune că excepția a fost prinsă. Din punctul de vedere al utilizatorului, mecanismul de tratare a excepțiilor este format din urmatoarele elemente: blocuri de try, blocuri de catch și expresii de throw.

#include <iostream>
using namespace std;
 
int a;
 
int main () {
  try
  {
    if (a != 0)
       throw 10;
  }
  catch (int e)
  {
    cout << "A fost aruncata exceptia numarul " << e << '\n';
  }
  return 0;
}

Când mai multe blocuri de try sunt imbricate și se produce un throw, într-o functie apelată de un bloc try interior, controlul se transferă spre exterior din blocurile try imbricate, până când se gasește primul bloc de catch al cărui argument se potrivește cu argumentul aruncat de excepție.

try
{
      func1();
      try
      {
             func2();
      }
      catch (type1_err) { /* ... */ }
      func3();
}
catch (type2_err) { /* ... */ }
// daca nu se arunca nicio exceptie, executia ajunge in acest punct.

În exemplul de mai sus, dacă type1_err este aruncat din blocul try interior (din func2()), excepția este prinsă de blocul de catch interior, și, presupunand că acest bloc nu transferă controlul, este apelată func3(). Daca type1_err este aruncată după blocul de try interior, de exemplu de către func3(), din cauză că nu există niciun bloc catch care să prindă această excepție, se va apela funcția terminate().

Blocurile de catch conțin rutina de tratare a excepției. Obiectele permise, pe care rutina le poate prinde, sunt declarate în paranteză, după cuvântul cheie catch.

Excepțiile la compile-time

Aruncarea unei excepții este de obicei implementată sub forma unui apel la o funcție _throw_ din biblioteca runtime. Numele difera de la o implementare la alta. Pentru ca _throw_ să funcționeze corect, are nevoie de o tabela ce listeaza toate blocurile catch ale unei funcții. Tabela este generată de catre compilator. Pentru a fi mai usor de urmarit, mai jos este adaugat si codul echivalent, cu etichete.

Cod cu excepții Cod echivalent
processFile(...) {
  try {
    f = openFile(...);
  }
  catch (FileError e) {
    log(e);
  }
}
processFile(...) {
try_start:
  f = openFile(...);
try_end:
  goto try_next;
catch_FileError:
  e=*(FileError*)_exception_;
  log(e);
try_next:
} 
openFile(...) {
  x = open(...);
  if (x < 0)
    throw FileError(x);
}             
openFile(...) {
  x = open(...);
  if (x < 0) {
     FileError tmp(x);
     _exception_ = &tmp;
     _throw_(“FileError”);
  }
}

Intrarea in tabela generata pentru intervalul try din exemplul de mai sus:

Interval de aplicabilitate Început catch Tipul excepției
try_start … try_end catch_FileError FileError

GCC-ul genereaza tabela de exceptii (exception handler framework) in sectiunile .eh_frame / .eh_frame_hdr):

Excepțiile la runtime

Să presupunem că se executa funcția func_ex, care aruncă o excepție. În vârful stivei se află cadrul de stivă al funcției func_ex. În cazul în care excepția a fost aruncată dintr-un bloc de try, se caută un blocul de catch care sa trateze tipul erorii aruncate. În cazul în care acesta se găsește, se începe procesul de stack-unwinding, dacă nu se găsește, se va apela funcția terminate(). Vom discuta în continuare despre stack unwinding, pornind de la următorul exemplu:

#include <iostream>
#include <string>
 
 void func2()
 { 
  throw std::exception();
 }
 
 void func1()
 {
   std::string str = "Ana are ..."; 
   func2();
 }
 
 int main()
 {
   try
   {
      func1();
   } 
   catch(...) 
   { }
 }

La runtime, în momentul în care func2 aruncă excepția, pe stivă există următoarele cadre: main, func1, func2. Funcția func2 nu prinde aceasta excepție, dar există in program un bloc de catch care sa o prinda, așadar, incepe stack unwinding.

Stack unwinding parcurge lista de cadre de stiva pornind din vârful stivei, și:

  1. apelează destructorii pentru toate obiectele locale funcției al cărei cadrul se află în vârful stivei;
  2. face restore la regiștrii callee-saved
  3. se scoate cadrul de pe stiva
  4. se revine la pasul 1 (acum, în vârful stivei este funcția care a apelat funcția ce tocmai am scos-o de pe stivă);

Revenind la exemplul nostru, func2() nu prinde exceptia, nu are nicio variabilă locală, deci singurul pas va fi scoaterea cadrului de pe stivă. Funția func1() are acum cadrul în vârful stivei, dar nici func1() nu contine blocul de catch, deci, se va apela destructorul lui str, apoi cadrul funcției func1() este scos de pe stiva. Se ajunge astfel la funcția main() care prinde excepția.

În procesul de unwinding, destructorii sunt apelați în ordine inversă constructorilor.

Stack unwinding este folosit și de debugger pentru a afișa stiva de apeluri și pentru a putea reveni cu execuția dintr-un punct anterior.

Puteți să vă gandiți și la altă situație în care s-ar putea folosi acelați mecanism?

Exerciții

Arhiva laboratorului.

  1. Scrieți un program C care să determine sensul stivei (este mărită sau micșorată valoarea adresei vârfului stivei la operații de push?).
  2. În directorul return_struct din arhiva laboratorului aveți implementate două funcții: una care întoarce o structură și alta care primește o structură ca parametru. Rulați make asm pentru a genera un fișier ”.s” și explicați:
    • modul în care se trimite un argument de tip structură,
    • modul în care se întoarce o structură.
  3. Modificați doar funcția ask din programul ovr pentru a afișa nota corectă.
  4. Scrieți o funcție fast_multiply_3 în asm care să înmulțească trei numere întregi primite ca argumente. Declarați funcția ca fiind de tip fastcall (Pe arhitecturi Intel x86, folosirea conveției de apel fastcall, presupune că:
    • primul argument se află în %ecx
    • al doilea argument se află în %edx
    • toți ceilalți parametri se află pe stivă ca la convenția de apel
    • Următoarea linie declară o funcție specificându-i convenție de apel de tip fastcall
    •  __attribute__((fastcall)) int fast_multiply_3(int a, int b, int c); 
    • Apelați funcția definită la pasul anterior dintr-un fișier C. Observați și explicați ce se întâmplă dacă în cadrul fișierului C nu apare declarația funcției fast_multiply_3 sau dacă acestei declarații îi lipsește decoratorul fastcall.
    • Pentru operația de înmulțire folosiți instrucțiunea
    •  imull %eax, %ecx # echivalent cu %ecx := %ecx * %eax 
  5. Comentați performanța codului perf_exceptii.cpp, din directorul exceptii.
  6. Executati exemplul exceptii.cpp din directorul exceptii, explicati ce se intampla la runtime si corectati-l.
cpl/labs/07.txt · Last modified: 2016/11/15 02:35 by bogdan.nitulescu
CC Attribution-Share Alike 3.0 Unported
www.chimeric.de Valid CSS Driven by DokuWiki do yourself a favour and use a real browser - get firefox!! Recent changes RSS feed Valid XHTML 1.0