This is an old revision of the document!


06. Reprezentarea datelor

În urma compilării și linkării unui program rezultă un fişier binar care, indiferent de formatul lui, este reprezentarea programului în memoria procesorului pentru care a fost compilat. Aceasta reprezentare conține în mod uzual mai multe secțiuni implicite, secțiuni care în funcție de format au diverse denumiri, dar scopul lor este asemănător.

Tipuri de secțiuni

  • secțiunea de cod (.code sau .text) este secțiunea în care se păstrează, codificate binar, instrucțiunile pe care le va executa procesorul.
  • secțiunea de date (.data) este secțiunea în care se aloca variabilele globale.
  • secțiunea de constante (.const sau .rodata) este secțiunea în care se păstrează datele constante dintr-un program.
  • secțiunea de heap (.heap) este secțiunea în care programatorul poate aloca dinamic memorie cu apeluri de tipul malloc sau new.
  • secțiunea de stiva (.stack) este secțiunea folosita pentru pastrarea contextului specific fiecărei funcții (parametrii funcției, variabilele locale și alte date care nu sunt neapărat vizibile la nivelul programatorului).

Dimensiunea secțiunilor, poziționarea lor în memorie, precum și alte aspecte legate de modalitățile de accesare a lor sunt specifice fiecărei arhitecturi. Din motive de securitate, unele procesoare pot restricţiona accesul la scriere in sectiunea de constante, sau pot permite executia doar din secţiunea de cod.

Din punct de vedere a reprezentării datelor, aceste secțiuni găzduiesc diferite tipuri de date din punct de vedere a vizibilității lor în program:

  • variabilele globale se vor regăsi în secțiunea de date
  • constantele se vor regăsi în secțiunea de constante. Majoritatea compilatoarelor încadrează la aceasta secțiune și şirurile de caractere explicite.
  • variabilele locale se vor regăsi fie într-un registru, fie pe stiva în contextul specific funcției de care aparțin.

În figura de mai jos este o posibilă distribuție a acestor secțiuni în memorie:

.stack
 ...
.heap
.data
.const
.code

În afară de aceste secţiuni implicite, programatorul poate defini secţiuni noi, eventual plasate în zone de memorie specificate explicit.

Tipuri de date

Pentru a translata un program scris într-un limbaj de nivel înalt trebuie să oferim mecanisme de reprezentare a structurilor de date ale limbajului, care sunt în general mult mai complexe decât cele suportate nativ de procesorul pentru care se face translatarea.

În continuare vom prezenta pe scurt câteva reprezentări posibile pentru cele mai folosite tipuri de date:

integer

Ne aşteptăm în general ca arhitectura să ofere un suport substanțial pentru lucrul cu numere întregi. Operațiile pe întregi mai lungi sunt implementate prin mai multe load-uri, store-uri și operații de tip addc (add with carry), subb (substract with borrow). Operațiile pe numere întregi mai scurte (byte, short) sunt implementate prin load-uri urmate de operații de extindere a semnului.

char

În general, caracterele se reprezintă pe un octet, de exemplu codificarea ASCII. În C, tipul de date char nu este nici cu semn, nici fără semn (conform standardului). În situația în care programatorul folosește acest tip de date pentru a reprezenta numere pe 8 biți, atunci el va trebui să precizeze explicit dacă tipul de date este signed sau unsigned. Mai nou compilatoarele oferă suport și pentru caractere pe 2 octeți (de ex. Unicode) - aceste reprezentări cuprind și alte stiluri de scriere în afară de cel latin (Katakana, Hiragana, Chinese etc.). Codificările multibyte (de exemplu UTF-8: 8-bit Unicode) permit reprezentarea a mai mult de 256 de caractere păstrând compatibilitatea cu codificarea ASCII.

float

Tipurile floating point au în general două sau trei formate - single, double și (ceva mai puțin frecvent) extended și ocupă până la 80 biți. Hardware-ul suporta tipul float în simplă precizie și de multe ori și dublă precizie. O excepție notabila este arhitectura Intel386 care nu suporta decât tipul extins pe 80 biți.

Pentru majoritatea arhitecturilor, e nevoie de suport software pentru operațiile în virgulă mobilă, de exemplu pentru tratarea cazurilor de overflow, underflow sau operaţii invalide. În unele cazuri - de exemplu pentru majoritatea microcontroller-urilor sau DSP-urilor - operațiile în virgula mobila sunt emulate în întregime de software, fiind implementate ca apeluri de funcții de bibliotecă.

enum

Valorile din enum-uri sunt, în general, reprezentate ca numere întregi naturale consecutive, fără semn; de obicei tipul enum se mapează pe int.

array

În general, array-urile pot avea mai mult de o dimensiune și, în funcție de limbaj, pot avea elemente doar de un tip fundamental (tip predefinit în limbaj) sau pot avea elemente de orice tip (definit de utilizator). În ambele cazuri, pot fi văzute ca blocuri n-dimensionale, cu fiecare dimensiune corespunzând unui indice. Ele sunt liniarizate fiind împărțite în 'felii', pe rânduri (sau în cazul Fortran-ului - pe coloane) și alocând spațiu de stocare pentru fiecare element în funcție de poziția lui în cadrul feliei. De exemplu, un vector declarat în Pascal:

 var a: array[1..10, 0..5] of integer; 

ocupă (10-1+1)*(5-0+1) = 60 de elemente. a[1, 0] va fi elementul nr. 0 a[2, 0] va fi elementul nr. 6 etc. În general, pentru un array Pascal:

 var vect: array[lo1..hi1, lo2..hi2, ..., loN..hiN] of type; 

adresa elementului vect[e1, e2, …., en] este:  base(vect) + size(type) * \sum_{i=1}^N (e_{i} - lo_{i})\prod_{j=1}^i (hi_{j}-lo_{j}+1)

unde base(vect) e adresa primului element și size(type) e numărul de octeți ocupat de fiecare element. Uneori, pentru a spori eficienta, compilatorul rotunjește numărul de octeți ocupat de un element pana la o dimensiune care poate fi încărcată eficient din memorie.

struct

Compilatoarele aleg să reprezinte aceste structuri:

  • împachetat - cu elementele consecutive puse unul după altul în memorie, sau
  • neîmpachetat - adică cu elementele aliniate

O greşeala foarte comuna este să se presupună ca elementele unui struct sunt întotdeauna poziționate unul după altul, de exemplu să recepționeze un șir de octeți dintr-un socket direct într-un struct, aşteptându-se să nu existe spații goale între membrii structurii. De observat că, deși nu se poate “prezice” cu siguranță spațiul care va fi inserat între membrii unei structuri, standardul C nu permite inversarea ordinii membrilor structurii și nici prezență de spații goale înainte de primul membru (vezi secțiunea 6.7.2.1 din rationale).

pointer

Pointerii sunt nişte variabile ce conțin o adresă din memorie. Aspecte care poate nu sunt evidente pentru toată lumea:

  • Dimensiunea unui pointer este dimensiunea unui cuvant din arhitectura pentru care s-a compilat (tipul de date long în C). Din punct de vedere al standardului C, nu este corect sa se presupuna ca un un pointer are aceeași dimensiune cu un număr long.
  • “Tip *a” NU este echivalent cu “Tip a[ ]”. Majoritatea cred că a[5] este echivalent cu *(a+5) și crede că a declara o variabilă ca “pointer” este echivalent cu a o declara “array”. După cum s-a explicat mai sus - array-ul e un șir continuu de elemente de tipul “Tip”, pe când pointer-ul e o adresa la un element de tipul “Tip”. Concret, dacă avem variabilele globale a și b declarate ca “int* a;”, respectiv “int b[];”:
    • pentru a calcula a[5] se află valoarea variabilei a, se adună “5*sizeof(int)” și se întoarce valoarea aflată la această adresă
    • pentru a calcula b[5] se afla adresa variabilei b, se adună “5*sizeof(int)” se întoarce valoarea aflată la această adresă

În general translatoarele fac cast automat între pointer și array și de aceea diferența poate fi uneori greu de conştientizat. Un exemplu care arata diferenta:

// in f1.cpp:
extern int* v;
// in f2.cpp:
int v[10];
 
La compilare:
Error 1 error C2372: 'v' : redefinition; different types of indirection

Un exemplu și pentru reprezentarea și plasarea în memorie a array-urilor și pointerilor, inclusiv din punct de vedere al secțiunilor în care ajung aceste date este următorul:

char st1[6];
char st2[] = “ABCD”;
const char *st3 =1234;

În acest caz:

  • st1 este un array neinitializat, care va fi păstrat în secțiunea de date;
  • st2 este un array initializat care va fi păstrat în secțiunea de date, dar valoarea sa de initializare va fi păstrată în secțiunea de constante și va fi copiată în secvența de startup corespunzătoare execuției programului;
  • st3 este un pointer către un șir de caractere, șir de caractere care va fi păstrat în secțiunea de constante.

Operatia *st = 0 este permisă atât pentru st1 cât și pentru st2, având ca rezultat modificarea primei valori din cele 2 array-uri, în schimb ce *st3 = 0 nu este permis pentru ca ar presupune modificări în secțiunea de constante (operație ilegală).

string

Probabil cele mai cunoscute reprezentări: * cea din Pascal - pe primul octet se ține dimensiunea șirului, * cea din C - șirul de caractere este terminat cu caracterul NULL.

set

Șiruri de biți, un bit e 1 dacă elementul este în mulțime și 0 altfel. Dacă știm că un set este rar (adică are mult mai multe elemente posibile decât elemente efective) o reprezentare mai bună e un vector sortat de elemente sau un arbore binar de căutare.

union

O uniune este similara ca declarație cu o structură cu mențiunea că toți membrii unei uniuni sunt toți poziționați de la aceeași adresă în memorie; spațiul alocat în memorie pentru o uniune corespunde cu dimensiunea celui mai mare membru. Principala utilitate a uniunilor este aceea a conservării spațiului, deoarece permite posibilitatea de a stoca mai multe tipuri în același spațiu de memorie – uniunile sunt o formă incipientă de polimorfism. Tocmai datorita faptului ca toți membrii unei uniuni coexistă în același spațiu de memorie, este aproape imposibil pentru un compilator să facă o verificare ca tipul scris într-o uniune este și tipul citit din acea uniune; Verificarea faptul ca sunt folosiți corect membrii dintr-o uniune revine în totalitate programatorului.

bitfield

Un bitfield este în general păstrat într-un cuvânt maşină, iar fiecare din biți este adresat nu prin poziția sa, ci prin numele asociat acestuia (sau acestora), exact ca atunci când se accesează un membru al unei structuri. Deși din punct de vedere a limbajului de programare folosirea de bitfield-uri rezolva probleme legate de manipularea de secvențe de biți și de împachetare a datelor care ocupă mai putin decât dimensiunea unui octet (sau cuvânt), din punct de vedere al codului generat (și implicit a compilatorului), bitfield-urile pot ridica foarte multe probleme: * codul de acces pentru fiecare bit al bitfield-ului poate fi foarte ineficient deoarece în general arhitecturile nu permit accese de memorie pe biți, ci pe cuvinte de memorie; * trebuie avut grijă și la suprapunerile de acces la memorie ce pot aparea în cazul execuției în paralel a acceselor la biții dintr-un bitfield.

ATENȚIE: ordinea biților într-un bitfield nu este garantată, compilatorul putând rearanja biții – de aceea, biții trebuie întotdeauna accesați prin membrul corespunzător din structura și niciodată prin poziția sa!

class

Într-un limbaj orientat obiect, reprezentarea unei clase este legată de cea a unei structuri, în care se adaugă membri suplimentari. Pentru a implementa moştenirea și polimorfismul, reprezentarea unui obiect derivat conține subobiecte corespunzătoare claselor de baza. Din acest motiv, un pointer la obiectul derivat se poate converti către un pointer la obiectul de bază, ca în exemplul următor. De remarcat că, în cazul în care există mai multe clase de bază sau interfețe implementate, obiectul de bază se poate găsi la o altă adresă decât obiectul derivat.

class B { 
  int a, b; 
  virtual void f(void);
};
 
class B1 {
  int x, y;
  virtual void z(void);
};
 
class D: B, B1 { 
  int c, d;
  void f(void);
  void z(void); 
};
 
D objD; B1 * ptrB1;
PtrB1 = &objD;
ptrB1->f();

Reprezentarea unei clase poate conține un membru ascuns, un pointer către tabela funcțiilor virtuale (vtable). Există o tabelă asociată fiecărei clase, iar tabela clasei derivate are sub-tabele corespunzând claselor de bază. Un apel către o funcție virtuală implică două citiri din memorie – prima pentru a afla tabela corectă, a doua pentru a afla adresa funcției. Prin acest mecanism, pornind de la un pointer către o instanță a unei clasă de bază se poate apela o metodă a clasei derivate. De remarcat:

  • in C++, toate metodele sunt automat finale. Urmatorul exemplu arata o functie finala, una virtuala, si, respectiv, una pur virtuala:
  • class foo
    {
    int bar1() { return 0; };
    virtual int bar2() { return 0; };
    virtual int bar3() = 0;
    }
  • in Java, toate metodele sunt automat virtuale. Urmatorul exemplu arata o functie finala, una virtuala, si, respectiv, una pur virtuala:
  • abstract class foo
    {
    final int bar1() { return 0; } 
    int bar2() { return 0; }
    abstract int bar3();
    }

Vom vorbi mai mult despre generarea de cod pentru clase in laboratorul urmator.

Inferența de tipuri

Inferența de tipuri reprezintă posibilitatea de deducere automată, parțială sau integrală, a tipului valorii derivate dintr-o eventuală evaluare a unei expresii. Din moment ce acest proces are loc în timpul compilării, compilatorul este de obicei capabil sa „infere” tipul unei variabile sau semnătura de tip a unei funcții, fără adnotări explicite de tip. În multe cazuri este posibilă omiterea completă a adnotărilor de tipuri dintr-un program dacă sistemul de inferență de tipuri este suficient de robust, sau dacă programul sau limbajul de programare este suficient de simplu. De exemplu, pentru o functie simplă în C

int increment(int x)
{
    return x + 1;
}

Într-un limbaj fără declarația explicită a tipurilor, precum Python, sau Visual Basic, ea se poate scrie

increment(x)
{
    return x + 1;
}

Din faptul ca 1 este număr întreg și din faptul că adunarea este permisă doar între numere de același fel și că rezultatul adunării este tot număr de același fel cu ceilalți operanzi, se poate „infera” ca funcția de incrementare întoarce un număr întreg și, de asemenea, că primește o valoare întreagă.

Și in C# se pot defini variabile fără a specifica tipul, si acestea trebuie inițializate la definire. Exemplu:

 var lista = new List<Int64>(); 

Pentru a obține informații corecte pentru inferența tipului unei expresii care nu are o adnotare de tip explicită, compilatorul poate fie să adune informații de tip din adnotările specifice sub-expresiilor ce formează expresia neadnotată, fie prin înțelegerea implicită a valorilor diferiților atomi ce formează expresia.

De cele mai multe ori compilatoarele care fac inferență de tipuri folosesc o combinație complexă a acestor două metode.

Este posibil să existe cazuri care nu pot fi complet rezolvate de inferența de tipuri, de exemplu în cazul polimorfismului. Oricât de performantă ar fi inferența de tipuri, de multe ori, pentru dezambiguizări (atât pentru programator cât și pentru compilator), este bine să se folosească adnotări de tipuri.

Deşi inferența de tipuri este foarte des întâlnită în limbajele funcționale, exista și limbaje de programare clasice care prezintă anumite forme de inferență de tipuri. În cele ce urmează se vor face referiri la forme de inferență în limbajul C.

În limbajul C, inferența de tipuri este necesară în momentul în care într-o expresie, operanzii au tipuri diferite, iar operațiile asociate expresiei sunt valide pentru mai multe din tipurile implicate în expresie. În astfel de cazuri, se aplică intern conversii între tipurile operanzilor la alte tipuri care să permită corectitudinea operațiilor, pe operanzi omogeni (din punct de vedere al tipurilor). Astfel de conversii se numesc conversii implicite, iar omogenitatea tipurilor operanzilor este dictată de standardele limbajului de programare (de exemplu pentru C, standardul ANSI).

Un caz foarte interesant de inferență de tip este cel din COOL, și în limbajele funcționale, în care o expresie returnează mereu o valoare, chiar și in cazul instrucțiunilor de bază – if, while, case. În mod evident, o astfel de expresie, cu mai multe ramuri, va returna tipul de date din care sunt derivate toate tipurile rezultatelor ramurilor. În COOL, de exemplu, a fost introdus SELF_TYPE, care este mereu evaluat la tipul clasei în care se află. Dacă o metodă virtuală a clasei de bază întoarce SELF_TYPE, ea va intoarce: * când este apelată printr-o instanță a clasei de bază → tipul clasei de bază * când este apelată printr-o instanță a unei clase derivate → tipul clasei derivate.

Conversiile de tipuri

Conversiile de tipuri se pot clasifica după mai multe criterii; cele mai reprezentative sunt: * implicit sau explicit * numerice sau de pointeri

Din combinația celor 2 criterii se pot obține 4 categorii de conversii destul de uzuale:

conversii numerice explicite

 int x = (int) 3.3; 

O astfel de conversie, presupune extragerea părții întregi a unui număr în virgulă mobilă; deși din punct de vedere al programatorului o astfel de operațiune pare trivială, din punct de vedere al compilatorului nu este atât de simplu și presupune calcule destul de complexe (în funcție și de suportul de virgulă mobilă specific arhitecturii pentru care se compilează); de foarte multe ori, astfel de conversii presupun apeluri de funcții de bibliotecă care emulează funcționarea procesorului în virgulă mobilă.

conversii numerice implicite

Aceste conversii presupun promovări ale anumitor operanzi dintr-o expresie la tipuri superioare (mai cuprinzătoare) tipului lor, acest lucru permițând efectuarea de operații pe tipuri care la prima vedere sunt neomogene ordinea promovării tipurilor numerice este următoarea:

                      char -> unsigned char ->
                      short int -> unsigned short int ->
                      int -> unsigned int ->
                      long -> unsigned long ->
                      long long -> unsigned long long ->
                      float -> double -> long double

NOTĂ Operațiile în virgulă mobilă se fac întotdeauna la precizie maximă (double sau long double, în funcție de arhitectură), iar rezultatul este apoi convertit la tipul și precizia corespunzatoare

int a = 7, b = 2;
double x;
x = a * 1. / b;
x = a / b * 1.;
x = 1. * a + a / b;

O prima expresie: „a * 1. / b”. Toate operațiile se fac pe double, datorita promovării treptate a fiecărui operand.

  • „a * 1.” înmulțire între întreg și float – fiind operație floating point se va face cu dublă precizie –
  • a va fi promovat la double, la fel ca și constanta „1.”
  • rezultatul double al expresiei anterioare se va impărți la valoarea întreagă b, care va fi convertită și ea la double.
  • rezultatul obținut este un double – variabila căreia îi este atribuit rezultatul este tot double, deci nu mai este nevoie și de alta conversie – dacă variabila era de alt tip, o alta conversie ar fi fost necesară.

a doua expresie este „a / b * 1.”

  • „a / b” este o operație între numere întregi. rezultatul împărțirii va fi un număr întreg (adica 3).
  • întregul obținut la operația anterioară se va inmulți cu „1.”, rezultând o înmulțire de numere double, precedată de conversia lui 3 în virgulă mobilă.

a treia expresie este „1. * a + a / b”

  • „1. * a” este o operație în virgulă mobilă datorita precedentei operatorilor,
  • „a / b” este o impărțire de întregi, cu rezultat întreg
  • cea de-a treia operație este +, efectuata pe numere în virgulă mobilă (precedată de conversiile implicite de rigoare)

conversii de pointeri explicite

Conversiile explicite de pointeri, spre deosebire de conversiile numerice, nu produc schimbări în date, ci doar în modalitatea de folosire a adresei la care se referă pointerul

int x = 12345;
float *p;
p = (float *) &x;

În acest caz, *p va fi o valoare în virgulă mobilă care are ca reprezentare binară valoarea din x.

conversii de pointeri implicite

Conversiile implicite de pointeri se fac doar către pointeri de tip void * - orice altă conversie de pointeri, atât între diferite tipuri de pointeri, cât și de la tipul void * la orice alt tip, trebuie făcute explicit:

void *p;
int x;
int *q;
p = &x;
q = (int *) p;

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: NOTĂ - Folosim termenul de argument sau argument actual pentru a ne referi la valoarea sau variabila care se pasează unei rutine și termenul de parametru sau parametru formal pentru a referi variabila căreia îi este asociată în rutina apelată

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

Apelul prin valoare

Trimite un argument punând la dispoziția procedurii apelate valoarea lui care se asociază parametrului formal corespunzător. În timpul execuției procedurii apelate, nu există nici o interacțiune cu variabilele apelantului. Excepția apare desigur în cazul în care argumentul este un pointer și procedura apelata ar putea folosi valoarea pointerului pentru a modifica valorile către care indică. 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 vectori deoarece presupune schimb de date intens cu memoria.

Un exemplu de limbaj în care se folosește apelul prin valoare este C, în care de fapt, acesta este singurul mecanism de pasare a parametrilor. Dar un parametru poate fi și adresa unui obiect și se obține efectul apelului prin referință. În limbaje precum Java, argumentele de tipuri primare sunt trimise astfel. De asemenea, pe obiecte de tip Integer, Float, etc, se realizează unboxing înainte de trimitere, deci efectul va fi același.

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. Exemplu in 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 Ada pentru parametri de tip inout.

Apelul prin referinta

Realizează o asociere între argumentul actual și parametrul corespunzator. La intrarea în procedura apelată se determină adresa argumentului și aceasta se pune la dispoziția procedurii ca mijloc de accesare a argumentului. Procedura apelată va avea acces total la argument pe toată perioada execuției sale, putând să schimbe parametrul actual și să îl transmită altor rutine pe care le apelează. Acest mecanism este foarte eficient când vectori sunt tramsmiși ca parametri, dar poate fi ineficient în cazul parametrilor de mărime mică care ar putea fi transmiși prin regiștri. Pot apărea probleme în cazul în care o constantă este transmisă prin acest mecanism.

Dacă compilatorul implementează o constantă ca pe o locație partajată de memorie în care se stochează valoarea constantei și care se accesează la fiecare folosire a constantei într-o procedură și dacă constanta se transmite altei proceduri prin referință, procedura apelata ar putea modifica conținutul locației alterând astfel valoarea constantei, necesară continuării execuției procedurii apelante după revenirea din cealaltă procedură. Soluția uzuală este copierea constantelor în noi locații de memorie și transmiterea acestor adrese când se face apelul prin referință. Aceasta modalitate de apel prin referința este implementată în Fortran, dar după cum am spus, acest efect se poate realiza și în C și C++ prin transmiterea adresei ca valoare.

În Java, 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ă. Obiectele sunt transmise tot prin valoare, însă acestea reprezintă o referință către zona de memorie în care sunt stocate datele. Corect este referința obiectelor este transmisă prin valoare.

Apelul prin nume

Este cel mai complex mecanism de pasare a argumentelor, atât din punct de vedere conceptual, cât și în ceea ce privește implementarea și îl amintim doar din motive istorice deoarece ALGOL 60 este singurul limbaj care oferă acest tip de mecanism. Se poate spune că este similar cu apelul prin referință deoarece permite accesul procedurii apelate la argumentul transmis. Diferența vine din faptul că adresa argumentului este calculată la fiecare acces la acesta ș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.

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”.

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ă.

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ă ce face o funcție, prototipul funcției 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 foarte importante, pentru că ele sunt folosite de compilator pentru a determina caracteristicile unei funcții ce urmează a fi apelata pentru a i se putea transmite parametrii sau a se putea primi valoarea întoarsă de funcție. De asemenea, prototipul funcției este folosit și în funcția apelată pentru primirea parametrilor de la funcția apelantă, precum și pentru transmiterea valorii întoarse de funcție.

Dacă prototipul funcției vizibil în locul sau locurile î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ă.

Din punct de vedere al compilatorului, 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);

ATENȚIE: dacă tipurile argumentelor sunt mai mici decât un int, compilatorul de C va face upcast parametrului la int și apoi va chema funcția. Dacă implementarea funcției se aștepta să îi fi fost trimiși mai puțini bytes se poate corupe memoria. Exemplu:

char c = 'a';
short s = 1;
f(c, s);
 
// semnătură implicită generată: 
int f(int, int);

Presupunând că parametrii sunt trimiși pe o stivă adresabilă la nivel de octet și că sizeof(char) = 1, sizeof(short) = 2 și sizeof(int) = 4 avem următoarea diferență între ce așteptă funcția f să primească ca parametri și ce argumente au fost pasate pe stivă.

parametri trimiși la apel c0 c1 c2 c3 s0 s1 s2 s3
parametri așteptați de implementare c1 s0 s1

în general compilatoarele fac verificări de compatibilitate între prototipurile unei aceleiași funcții, dar există cazuri cand aceste verificări nu sunt posibile (de exemplu când se folosesc funcții din biblioteci).

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.

Cea mai frecventă eroare din punct de vedere al incompatibilității între prototipuri este aceea a nedeclarării prototipurilor în cazul unor funcții de bibliotecă, fapt ce duce la folosirea prototipului implicit și la imposibilitatea verificării compatibilității între prototipuri.

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

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ă

cpl/labs/06.1447104806.txt.gz · Last modified: 2015/11/09 23:33 by laura.vasilescu
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