PC Laborator 12

De la WikiLabs

Obiective

La sfârșitul acestui laborator studenții vor fi capabili:

  • să definească și să utilizeze tipuri de date pointer;
  • să folosească pointeri pentru a putea modifica variabilele trimise ca argumente unor funcții;
  • să aloce, să folosească și să elibereze memorie HEAP, în mod dinamic;
  • să utilizeze aritmetica pointerilor pentru a itera peste elemente de la adrese consecutive de memorie;
  • să utilizeze valgrind pentru a diagnostica pierderile de memorie.

Tipuri de date pointer

Un pointer reprezintă o variabilă care stochează o adresă în memoria dedicată aplicației. Tipul variabilei de tip pointer specifică tipul datei care poate fi citit de la adresa respectivă.

O variabilă de tip pointer se definește în felul următor:

<tip_data> * <nume_variabila>;

Spre exemplu:

int * pa;

Variabila de tip pointer pa nu memorează un întreg, ci o adresă în memorie, iar de la adresa respectivă se poate citi un întreg. Acest lucru se numește indirectare simplă. În plus, deoarece tipul pointer este în sine un tip de dată, și în definiția unui pointer <tip_data> poate fi un pointer, aceasta permite următoarele construcții:

  • int * pa; - variabilă ce stochează o adresă de unde se poate citi un întreg (indirectare simplă);
  • int ** pa; - variabilă ce stochează o adresă de unde se poate citi o adresă de unde se poate citi un întreg (dublă indirectare);
  • int *** pa; - variabilă ce stochează o adresă de unde se poate citi o adresă de unde se poate citi o adresă de unde se poate citi un întreg (triplă indirectare);
  • etc.
Regulă: Orice variabilă este stocată în memorie și deci are o adresă în memorie.

În C există un tip de dată pointer care poate memora o adresă fără a ști ce date se află la adresa respectivă. Acest tip de pointer este void*.

Dimensiunea tipului de date pointer

Conform regulii de mai sus, și variabilele de tip pointer ocupă loc în memorie, deci au dimensiune, în octeți. Un pointer nu oferă informații legate de dimensiunea ocupată de datele de la adresa respectivă, ci doar adresa de unde începe zona ocupată. Din moment ce un pointer memorează doar o adresă, dimensiunea variabilelor de tip pointer nu depinde decât de dimensiunea spațiului de memorie. Astfel, pentru procesoare și sisteme de operare pe 32 de biți, o variabilă de tip pointer va avea 32 de biți, iar pe procesoare și sisteme de operare pe 64 de biți, o variabilă de tip pointer va avea 64 de biți.

Adresa unei variabile

Operatorul care permite aflarea adresei unde este stocată o variabilă este ampersand (&). Acesta este un operator unar ce se plasează înaintea unei variabile iar rezultatul evaluării sale este adresa unde este stocată variabila respectivă. Această adresă poate fi stocată într-o altă variabilă de tipul corespunzător. Altfel spus, pentru o variabilă de tip tip_data, adresa acesteia se poate stoca într-o variabilă de tip tip_data *:

float floatValue;
float * floatAddress = &floatValue;

int intValue;
int * intAddress = &intValue;
int ** intPointerAddress = &intAddress;

Adresa NULL

Pentru orice aplicație, adresa 0 din spațiul ei de memorie este rezervată. Aceasta nu poate fi nici scrisă și nici citită. Această adresa poartă numele de NULL. Orice variabilă de tip pointer poate lua valoarea NULL, lucru care de obicei specifică faptul că de fapt variabila pointer nu stochează o adresă validă.

Constanta NULL este definită în fișierul header stdlib.h (Standard Library).
#include <stdlib.h>

int main() {
    char * charPointer = NULL;
    return 0;
}

Valoarea de la o adresă

Având o variabilă de tip pointer, valoarea stocată în memorie la adresa respectivă se poate afla folosind caracterul steluță (*), numit și operator de indirectare. Acesta este un operator unar ce se plasează înaintea unei variabile de tip pointer iar rezultatul evaluării sale este valoarea din memorie de la adresa stocată în variabila respectivă. Această valoare poate fi stocată într-o altă variabilă de tipul corespunzător. Altfel spus, pentru o variabilă pointer de tip tip_data *, valoarea de la adresa stocată de pointerul respectiv se poate memora într-o altă variabilă de tip tip_data:

float floatValue;
float * floatAddress = &floatValue;
float anotherFloatValue = *floatAddress;

int intValue;
int * intAddress = &intValue;
int ** intPointerAddress = &intAddress;
int * anotherintAddress = *intPointerAddress;


Atenție: Accesarea valorii de la o adresă se numește dereferențiere. Dereferențierea unei adrese care nu face parte din spațiul de memorie al aplicației sau dereferențierea lui NULL va avea ca efect oprirea imediată a programului printr-un Segmentation fault.
Atenție: Nu confundați steluța utilizată la definirea unui pointer cu operatorul de indirectare/ dereferențiere. Aceștia sunt la fel de diferiți cum este și operatorul de inmulțire față de cel de indirectare.

Utilitatea variabilelor de tip pointer

Pointerii în C au două roluri foarte importante:

  1. Alocarea dinamică de memorie în HEAP
  2. Modificarea variabilelor parametri ale unor funcții astfel încât modificarea să se păstreze în afara funcției

Alocarea dinamică de memorie

Memoria alocată unei aplicații de către sistemul de operare este împărțită în mai multe secțiuni, dintre care importante pentru stocarea de date sunt:

  • Segmentele BSS și Data - reprezintă memoria alocată pentru variabilele statice (globale), care există de la începutul până la încheierea programului, fără posibilitate de eliberare;
  • Stiva (Stack) - zona de memorie în care se alocă contextele funcțiilor apelate în timpul execuției programului și în care se alocă argumentele și variabilele locale are funcțiilor; această zonă este alocată la intrarea în funcție și este eliberată la ieșirea din funcție;
  • HEAP - zonă de memorie în care se pot aloca dinamic, de către programator, blocuri de memorie ce pot fi folosite în program până la eliberarea acestora de către programator.

Alocarea și dezalocarea memoriei în heap se fac folosind următoarele funcții:

  • void * malloc(unsigned size) - alocă size octeți într-o zonă continuă din HEAP și întoarce adresa de memorie unde începe zona respectivă; dacă alocarea eșuează (nu exită suficientă memorie într-o zonă continuă în HEAP), funcția va întoarce NULL; alocarea nu șterge conținutul memoriei respective;
  • void * calloc(unsigned elements, unsigned elementSize) - alocă elements elemente de elementSize octeți fiecare într-o zonă continuă din HEAP și întoarce adresa de memorie unde începe zona respectivă; dacă alocarea eșuează (nu exită suficientă memorie într-o zonă continuă în HEAP), funcția va întoarce NULL; alocarea șterge tot conținutul memoriei respective, scriind 0 la fiecare locație;
  • void free (void *) - dezalocă o zonă de memorie alocată în prealabil cu malloc sau calloc; apelul succesiv de două sau mai multe ori a funcției free pentru aceeași zonă de memorie sau apelul ei pentru o adresă care nu a fost alocată în prealabil va avea ca efect oprirea imediată a programului cu eroare (double free or corruption).

Toate aceste funcții sunt definite în fișierul header stdlib.h.

#include <stdlib.h>
#include <stdio.h>

int main(){
    int * intPointer;
    short * shortPointer;

    intPointer = (int*) malloc(sizeof(int));
    shortPointer = (short*) calloc(1, sizeof(short));

    printf("Valoarea din zona alocata pentru int este: %d\n", *intPointer);
    printf("Valoarea din zona alocata pentru short este: %hd\n", *shortPointer);

    free(intPointer);
    free(shortPointer);

    return 0;
}
Atenție: Deoarece funcțiile malloc și calloc au doar rolul de a aloca memorie, fără să știe care este scopul utilizării acestei memorii, ele întorc un pointer de tip void *. Astfel, pentru a putea stoca adresa într-un alt tip de pointer (de exemplu int *), ea trebuie convertită la tipul de date corect. Aceasta este explicația prezenței operatorului de cast din fața apelului funcțiilor malloc și calloc din codul de mai sus.

Aritmetica pointerilor

Când se alocă memorie in HEAP, rareori se alocă pentru un singur element, de cele mai multe ori se alocă pentru un număr mare de elemente de același fel. Ca exemplu, dacă vrem să stocăm o imagine High Definition, ne trebuie o zonă de memorie care să poată memora informație de culoare pentru 1920 * 1080 de pixeli, fiecare pixel având informație de culoare pentru roșu, verde și albastru (RGB). Fiecare din aceste componente de culoare se stochează pe un octet ca valoare întreagă fără semn (unsigned char). Prin urmare, pentru un frame se vor aloca 1920 * 1080 * 3 octeți = 6220800, aproape 6 MB. Această memorie se alocă întotdeauna într-o zonă continuă de către funcțiile malloc și calloc:

#include <stdlib.h>
int main(){
    unsigned char * frame;
    frame = (unsigned char*) malloc(1920 * 1080 * 3 * sizeof(unsigned char));
    //... use frame

    free (frame);
    return 0;
}

Deoarece pointer-ul nu memorează decât adresa de început a zonei de memorie, există posibilitatea de a modifica adresa pentru a accesa elementele ulterioare. În acest scop, variabilele de tip pointer suportă doar operații aritmetice de adunare sau scădere, nu și de înmulțire sau împărțire.

Atenție: Incrementarea unui pointer nu crește adresa cu 1, ci cu dimensiunea tipului de dată al pointer-ului. Deci pentru un int * p;, linia p++; va incrementa adresa cu 4 (sizeof(int)):
#include <stdio.h>
#include <stdlib.h>

int main(){
    int * pointer;
    pointer = (int*) malloc(10 * sizeof(int));
    printf("The pointer address is: %p\n", pointer);
    printf("The pointer address + 1 is: %p\n", pointer + 1);
    free (pointer);
    return 0;
}
Atenție ca prin operații pe pointeri să nu depășiți zona de memorie alocată.

Pointerii și vectorii

Memoria alocată pentru mai multe elemente de același fel reprezintă de fapt un vector de elemente de acel tip. Astfel aflăm că de fapt vectorii și pointerii, în multe situații se pot folosi interschimbabil.

Când se definește un vector, numele vectorului reprezintă un pointer la adresa de unde începe zona lui de memorie, adică adresa unde este memorat elementul de la indexul 0:
#include <stdio.h>
#include <stdlib.h>

int main(){
    int array[10];
    array[0] = 13;
    printf("The array pointer address is: %p\n", array);
    printf("The value at address %p is %d\n", array, *array);
    return 0;
}
Dereferențierea unui pointer indexat cu o valoare este echivalentă cu folosirea operatorului de acces la vector: *(v + k) == v[k], unde v este un pointer (sau vector) iar k este un întreg.
#include <stdlib.h>
#include <stdio.h>
int main(){
    int arraySize;
    printf("size = ");
    scanf("%d", &arraySize);

    int * heapArray;
    heapArray = (int*) malloc(arraySize * sizeof(int));

    int i;
    for(i = 0; i < arraySize; i++) {
        printf("heapArray[%d] = ", i);
        scanf("%d", &heapArray[i]);
    }

    for(i = 0; i < arraySize; i++) {
        printf("heapArray[%d] = %d\n", i, *(heapArray + i));
    }


    free (heapArray);
    return 0;
}

Pointerii și structurile

Ca orice tip de dată, și variabilele de tip structură ocupă loc în memorie și deci pot exista pointeri de tip structură:

struct Test {
    int intField;
    float floatField;
    char charField;
    char charArrayField[100];
};

int main() {
    struct Test testStructVar;
    struct Test * structPointer = &testStructVar;
    return 0;
}

Având un pointer la o structură, accesul la câmpuri se poate face în două moduri:

  1. Dereferențierea pointerului pentru a obține "valoarea" structurii și apoi utilizarea operatorului "." pentru accesul la membri:
    struct Test {
        int intField;
        float floatField;
        char charField;
        char charArrayField[100];
    };
    
    int main() {
        struct Test testStructVar;
        struct Test * structPointer = &testStructVar;
    
        *structPointer.intField = 10;
        return 0;
    }
    
  2. Accesul la membrii unei structuri accesate printr-un pointer este atât de popular și des întâlnit în C încât există un operator special care permite accesul la câmpuri fără dereferențiere. Acest operator este săgeata "->":
    struct Test {
        int intField;
        float floatField;
        char charField;
        char charArrayField[100];
    };
    
    int main() {
        struct Test testStructVar;
        struct Test * structPointer = &testStructVar;
    
        *structPointer.intField = 10;
        structPointer->floatField = 4.3;
        structPointer->charField = 'B';
        return 0;
    }
    

Alocarea memoriei în HEAP pentru structuri se face identic cu alocarea pentru tipurile primitive de date:

#include <stdlib.h>
struct Test {
    int intField;
    float floatField;
    char charField;
    char charArrayField[100];
};

int main() {
    struct Test * structPointer = (struct Test*) malloc(sizeof(struct Test));

    // use structPointer

    free(structPointer);
    return 0;
}

Adrese ca argumente de funcție

Știm deja că în C argumentele funcțiilor sunt pass-by-value, asta înseamnă că funcției i se transmite valoarea dintr-o variabilă, nu variabila în sine. Astfel, dacă o variabilă este folosită ca argument la apelul unei funcții, modificarea argumentului în funcție nu se propagă și spre variabila sursă:

#include <stdio.h>

void divideByTwo(int arg) {
    printf("Argument before division = %d\n", arg);
    arg /= 2;
    printf("Argument after division = %d\n", arg);
}

int main() {
    int var = 10;
    divideByTwo(var);
    printf("Variabile after function call = %d\n", var);
    return 0;
}

Există o soluție pentru a permite propagarea modificării spre variabila sursă, și anume în loc de a trimite ca argument funcției variabila, se transmite adresa variabilei. Asta permite funcției să scrie direct în memoria alocată pentru variabilă și deci să-i modifice valoarea:

#include <stdio.h>

void divideByTwo(int *arg) {
    printf("Argument before division = %d\n", *arg);
    *arg /= 2;
    printf("Argument after division = %d\n", *arg);
}

int main() {
    int var = 10;
    divideByTwo(&var);
    printf("Variabile after function call = %d\n", var);
    return 0;
}

Evitarea pierderilor de memorie - valgrind

Pierderile de memorie (memory leaks) apar atunci când programatorul alocă memorie, dar uită să o elibereze. Aceasta este o problemă foarte delicată pentru că aplicația va continua să aloce până când va depăși limitele permise și sistemul de operare va opri procesul forțat. Chiar și dacă nu se ajunge acolo, ocuparea de cantități mari de memorie va face ca tot sistemul să se miște din ce în ce mai greu (vezi versiunile vechi de Firefox):

1 #include <stdio.h>
2 #include <stdlib.h>
3 
4 int main() {
5     char * buffer = (char*) malloc(100 * sizeof(char));
6     buffer = NULL;
7     printf("Done!\n");
8     return 0;
9 }

În exemplul de mai sus este alocat un buffer de 100 de bytes, iar adresa către zona respectivă se pierde când pointerului buffer i se atribuie valoarea NULL. Astfel, acea memorie nu mai poate fi nici accesată și nici eliberată. Cu toate acestea, programul compilează și rulează fără probleme. Pentru a verifica că nu există pierderi de memorie, se poate folosi programul valgrind:

rhobincu@IronMan ~/work/pc/test $ valgrind --leak-check=full ./test
==6076== Memcheck, a memory error detector
==6076== Copyright (C) 2002-2013, and GNU GPL'd, by Julian Seward et al.
==6076== Using Valgrind-3.10.1 and LibVEX; rerun with -h for copyright info
==6076== Command: ./test
==6076== 
Done!
==6076== 
==6076== HEAP SUMMARY:
==6076==     in use at exit: 100 bytes in 1 blocks
==6076==   total heap usage: 1 allocs, 0 frees, 100 bytes allocated
==6076== 
==6076== 100 bytes in 1 blocks are definitely lost in loss record 1 of 1
==6076==    at 0x4C2AB80: malloc (in /usr/lib/valgrind/vgpreload_memcheck-amd64-linux.so)
==6076==    by 0x40058E: main (test.c:5)
==6076== 
==6076== LEAK SUMMARY:
==6076==    definitely lost: 100 bytes in 1 blocks
==6076==    indirectly lost: 0 bytes in 0 blocks
==6076==      possibly lost: 0 bytes in 0 blocks
==6076==    still reachable: 0 bytes in 0 blocks
==6076==         suppressed: 0 bytes in 0 blocks
==6076== 
==6076== For counts of detected and suppressed errors, rerun with: -v
==6076== ERROR SUMMARY: 1 errors from 1 contexts (suppressed: 0 from 0)
Observație: Ca valgrind să poată afișa numărul liniei și fișierul sursă unde apare eroarea, codul trebuie compilat cu simboluri de debug (-g).

Pentru un cod corect, ieșirea lui valgrind este:

 1 #include <stdio.h>
 2 #include <stdlib.h>
 3 
 4 int main() {
 5     char * buffer = (char*) malloc(100 * sizeof(char));
 6     free(buffer);
 7     buffer = NULL;
 8     printf("Done!\n");
 9     return 0;
10 }
rhobincu@IronMan ~/work/pc/test $ valgrind --leak-check=full ./test
==6244== Memcheck, a memory error detector
==6244== Copyright (C) 2002-2013, and GNU GPL'd, by Julian Seward et al.
==6244== Using Valgrind-3.10.1 and LibVEX; rerun with -h for copyright info
==6244== Command: ./test
==6244== 
Done!
==6244== 
==6244== HEAP SUMMARY:
==6244==     in use at exit: 0 bytes in 0 blocks
==6244==   total heap usage: 1 allocs, 1 frees, 100 bytes allocated
==6244== 
==6244== All heap blocks were freed -- no leaks are possible
==6244== 
==6244== For counts of detected and suppressed errors, rerun with: -v
==6244== ERROR SUMMARY: 0 errors from 0 contexts (suppressed: 0 from 0)

Exerciții

  1. Realizați o funcție char * allocateString(unsigned size) care să aloce în HEAP o zonă de memorie care să poată stoca un sir de caractere de dimensiunea size și să întoarcă adresa de unde începe zona respectivă. HINT: nu uitați de caracterul pentru final de șir.
  2. Citiți de la tastatură o lungime de șir length apoi folosind funcția de mai sus, alocați memorie pentru stocarea unui șir de acea dimensiune. Citiți apoi de la tastatură și memorați un șir de caractere pe care să-l afișați ulterior în consolă folosind funcția puts.
  3. Pentru programul de mai sus, realizați o funcție void printString(char * string) care să afișeze pe ecran șirul de caractere, folosind exclusiv funcția putchar și fără a folosi operatorul de indexare a vectorilor ([]). Verificați că această funcție afișează același lucru ca funcția puts. HINT: Utilizați operatorul de dereferențiere și aritmetica pointerilor.
  4. Realizați o funcție compute de tip void care să calculeze suma și produsul a două valori de tip float astfel încât funcția apelantă să le poată utiliza. Testați funcția într-un program.
  5. Găsiți și eliminați pierderile de memorie din codul de mai jos:
    #include <stdio.h>
    #include <stdlib.h>
    #include <string.h>
    
    #define BUFFER_SIZE 100
    
    struct Buffer {
        unsigned size;
        char * string;
    };
    
    struct Buffer * createBuffer(unsigned size) {
        struct Buffer * newBuffer = (struct Buffer*) malloc (sizeof(struct Buffer));
        newBuffer->size = size;
        newBuffer->string = (char*) malloc(size * sizeof(char));
        return newBuffer;
    }
    
    struct Buffer * reverseWord(struct Buffer * buffer) {
        struct Buffer * newBuffer = createBuffer(buffer->size);
        int i;
        for(i = 0; i < strlen(buffer->string); i++) {
            *(newBuffer->string + i) = buffer->string[strlen(buffer->string) - 1 - i];
        }
        newBuffer->string[strlen(buffer->string)] = '\0';
        return newBuffer;
    }
    
    int main() {
        unsigned wordCount;
        printf("How many words? ");
        scanf("%u", &wordCount);
    
        unsigned i;
        for(i = 0; i < wordCount; i++) {
            struct Buffer * buffer = createBuffer(BUFFER_SIZE);
            scanf("%s", buffer->string);
            buffer = reverseWord(buffer);
            printf("Reversed is: %s\n", buffer->string);
            free(buffer);
        }
        return 0;
    }