This is an old revision of the document!
Î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.
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:
Î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.
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:
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.
Î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.
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ă.
Valorile din enum-uri sunt, în general, reprezentate ca numere întregi naturale consecutive, fără semn; de obicei tipul enum se mapează pe int.
Î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:
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.
Compilatoarele aleg să reprezinte aceste structuri:
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).
Pointerii sunt nişte variabile ce conțin o adresă din memorie. Aspecte care poate nu sunt evidente pentru toată lumea:
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
.
Î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:
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ă).
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.
Ș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.
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.
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!
Î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:
class foo { int bar1() { return 0; }; virtual int bar2() { return 0; }; virtual int bar3() = 0; }
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 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 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:
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ă.
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 doua expresie este „a / b * 1.”
a treia expresie este „1. * a + a / b”
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.
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;
Î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ă
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.
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.
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.
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.
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.
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”.
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:
call
face aceste lucruri)ret
face acest lucru)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:
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.
Deși ar fi ideal ca toți operanzii să fie ținuți în regiștri, majoritatea procedurilor necesită spatiu de memorie pentru:
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:
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:
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:
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ă:
La întoarcerea din procedură, procesul invers este: