06. Code generation. Structure of data and objects in memory

Î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”. Rezultatul operatorului sizeof diferă.

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

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

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;

Exerciții

Arhiva laboratorului.

  1. De ce nu există diferențe pentru definițiile lui v între următoarele semnături de funcții?
     void f(int * v); 

    și

     void g(int v[]); 
  2. Intrati in directorul diff_vec_ptr1. Rulati make apoi rulati programul. Care este problema? Incercati sa explicati apoi rezolvati bug-ul. Puteti vedea acelasi comportament si ruland codul din directorul diff_vec_ptr2.
  3. Determinați (inspectând codul asm) cum sunt implementați vectorii automatici de lungime variabilă. Pentru vectori automatici de lungime constantă sizeof(v) reprezintă toată memoria alocată (în bytes) pentru acel vector - o constantă cunoscută la momentul compilării. Cum este implementat operatorul sizeof pentru vectori de lungime variabilă? Folosiți codul din directorul alloca din arhiva laboratorului.
  4. Scrieți un program care să determine ordinea secțiunilor în memorie.
    • Hint: Declarați obiecte în fiecare secțiune și verificați adresa.
  5. Intrati in directorul classes. Fara a modifica functia main, faceti schimbari in fisierul classes.cpp astfel incat sa apara “different”. Scoateti cast-urile catre void *. De ce afiseaza din nou “same”?
  6. Modificați programul următor pentru a afișa structurile sunt identice:
ex8.c
#include <string.h> //memcmp
#include <stdio.h>
 
struct comp_ex {
    char c;
    int i;
    short s;
};
 
int main()
{
     struct comp_ex sa, sb;
 
     // toate câmpurile din a și b sunt inițializate 
     sa.c = sb.c = 1;
     sa.i = sb.i = 2;
     sa.s = sb.s = 3;
 
     if (0 == memcmp(&sa, &sb, sizeof(struct comp_ex)))
         printf("structurile sunt identice\n");
     else
         printf("structurile sunt diferite\n");
 
     return 0;
}

BONUS

Intrati in directorul bonus, compilati si rulati programul. De ce primim Segmentation fault? In ce sectiuni se gasesc “Hello 1”, “Hello 2”, pc1, respectiv pc2?

cpl/labs/06.txt · Last modified: 2016/11/08 00:02 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