wtorek, 29 marca 2011

O wyciekach pamięci i sprzątaniu po sobie w GCC

Autor: tmf
Redakcja: Dondu


Kompilator GCC ma zaimplementowanych wiele rozszerzeń w stosunku do standardu języka C, z których możemy skorzystać poprzez określenie tzw. atrybutów (słowo kluczowe __attribute__).

Atrybuty można definiować zarówno dla zmiennych, jak i dla funkcji. Pośrednio z takimi rozszerzeniami każdy programujący mikrokontrolery AVR się zetknął – znane makro PROGMEM, to nic innego jak atrybut (rozszerzenie) dodawany do zmiennej, informujący kompilator o sposowie alokacji dla niej pamięci.

Poniżej przedstawię kolejny użyteczny atrybut, oddający nieocenione usługi programistom korzystającym z dobrodziejstw (i przekleństw) dynamicznej alokacji pamięci.


Uwaga!!! 
Artykuł ten zawiera informacje specyficzne dla kompilatora GCC (GNU Compiller Collection), dlatego najprawdopodobniej podane przykłady i rozwiązania nie będą działać w innych kompilatorach. 

Artykuł ten jest rozwinięciem treści zawartych w rozdziale 6 „Dynamiczna alokacja pamięci” mojej książki.


Wycieki pamięci – jak im zapobiegać?

Co prawda wiele osób programujących mikrokontrolery wzdraga się na samą myśl o użyciu pamięci alokowanej dynamicznie, to ja jako C++ guy właściwie nie mogę przed tym uciec. Nie tyle nie mogę, co nie chcę – a dlaczego – to szczegółowo opisałem w swojej książce.

Niestety zmienne dynamiczne nieuchronnie wiążą się z różnymi mniej lub bardziej wyimaginowanymi problemami. Tymi urojonymi zajmować się nie będę, ale niewątpliwie realnym problemem jest zagrożenie wyciekami pamięci (ang. memory leaks) w sytuacji, w której zapomnimy zwolnić pamięć przydzieloną wskaźnikowi. Zobaczmy jak to wygląda na prostym przykładzie:

void func()
{
   int *x=malloc(100);
   x[1]=1;
}

Uwaga!!! 
Wykorzystane w poniższych przykładach funkcje malloc() i free() wymagają załączenia nagłówka stdlib.h, w którym to są zdefiniowane prototypy tych funkcji.

Powyższa funkcja alokuje 100 bajtów na stercie, po czym bajtom 2 i 3 nadaje wartość (int)1 i na tym kończy swoje działanie. Tu mamy oczywisty problem – zaalokowana pamięć nie jest nigdzie zwalniana, ponieważ po powrocie z funkcji func() tracimy referencję do przydzielonego bloku pamięci, jego zwolnienie nie jest już możliwe. Poprawnie powyższa funkcja powinna wyglądać tak (no w miarę poprawnie, bo nie sprawdzamy, czy przydział pamięci zakończył się powodzeniem):

void func()
{
   int *x=malloc(100);
   x[1]=1;
   free(x);
}

Oczywiście jeśli zawsze pamiętamy o zwolnieniu wcześniej przydzielonej pamięci to nie ma problemu – ale nie zawsze pamiętamy. Poza tym, że nasza pamięć nie jest doskonała, zdarzają się sytuacjie, w których dana funkcja ma kilka punktów wyjścia, np.:

void func(int i)
{
   int *x=malloc(100);
   x[1]=1;
   if(i==0) return;
   free(x);
}

No i mamy problem, jeśli funkcja wykona się „cała” to wykona się także instrukcja free(x), lecz jeśli i będzie równe 0 to funkcja zakończy się wcześniej i mamy wyciek pamięci. Rozwiązanie oczywiste wygląda tak:

void func(int i)
{
   int *x=malloc(100);
   x[1]=1;
   if(i==0) 
   {
      free(x);
           return;
   }
   free(x);
}

A co jeśl mamy kilka punktów wyjścia?
Tutaj puryści językowi powiedzą, że tworzenie funkcji posiadających kilka punktów wyjścia jest przykładem złego stylu programowania. Może i jest, ale moim skromnym zdaniem czasami dopuszczanie do takich sytuacji prowadzi mimo wszystko do bardziej przejrzystego kodu. Chyba, że... mamy zaalokowanych pełno zmiennych dynamicznych i w efekcie kilka razy powtarzamy sekwencje free() dla kolejnych zmiennych.

Na szczęście twórcy gcc postanowili nam ułatwić nieco życie i wprowadzili specjalny atrybut cleanup – jego składnia wygląda następująco:

__attribute__((cleanup(int_free)))

Atrybut cleanup przyjmuje jako parameter nazwę funkcji, która będzie wywoływana za każdym razem, kiedy zmienna zadeklarowana z atrybutem cleanup wyjdzie z zasięgu (będzie out of scope).

Co ważne taka funkcja musi posiadać dokładnie jeden argument, będący wskaźnikiem na typ zgodny z typem zmiennej zdefiniowanej z atrybutem cleanup. Np. jeśli mamy wskaźnik na int:

int *x;

to funkcja „czyszcząca” powinna posiadać argument o typie wskaźnika na wskaźnik na int (uff, zawile, ale wskaźniki nie są dla początkujących;P):

void cleanup(int **ptr);

Atrybut ten może być określony wyłącznie dla zmiennych lokalnych, nie ma sensu go używać w połączeniu ze zmiennymi globalnymi oraz statycznymi. Jest tak dlatego, że zmienne globalne lub statyczne nigdy nie kończą swojego „życia”, stąd też użycie tego atrybutu nie miałoby sensu. Próba użycia cleanup z taką zmienną generuje ostrzeżenie:

warning: 'cleanup' attribute ignored


A więc atrybut nie ma żadnego efektu. Zobaczmy jak wygląda użycie tego atrybutu na prostym przykładzie. Najpierw zdefiniujmy prostą funkcję odpowiedzialną za zwolnienie pamięci przydzielonej wskaźnikowi na typ int:

void intptrfree(int **ptr)
{
   free(*ptr);
}

Dla wygody zdefiniujemy sobie symbol automatycznie definiujący „bezpieczny” wskaźnik na typ int, który będzie automatycznie zwalniany po wyjściu z danego bloku programu:

#define safe_int_ptr int * __attribute__((cleanup(intptrfree)))

Funkcja z poprzedniego przykładu może więc wyglądać tak:

void func(int i)
{
   safe_int_ptr x=malloc(100);
   x[1]=1;
   if(i==0)  return;
}

Jak widzimy, w tym przypadku w ogóle nie używamy free() – kiedy x będzie poza zasięgiem nastąpi automatyczne wywołanie funkcji intptrfree i zwolnienie przydzielonej x pamięci. W tej sytuacji dodanie do programu free(x) byłoby błędem – nawet jeśli je umieścimy, to po wyjściu z funkcji zostanie wywołana funkcja intptrfree, w efekcie dojdzie do uszkodzenia sterty (za drugim razem nastąpi próba zwolnienia nie przydzielonej wskaźnikowi pamięci). Aby temu zapobiec, jeśli koniecznie chcemy „ręcznie” zwolnić pamięć przydzieloną x musimy zrobić coś takiego:

void func(int i)
{
   safe_int_ptr x=malloc(100);
   x[1]=1;
   if(i==0)  return;
   free(x);
   x=NULL;
}

A dlaczego tak?
Ano dlatego, że free nie nadaje zmiennej x automatycznie wartości NULL, a tylko zwalnia przydzieloną jej pamięć. W kolejnej instrukcji jawnie nadajemy zmiennej x wartość NULL, w efekcie przy wyjściu z funkcji wywołana zostanie funkcja intptrfree(&x) – w efekcie wywołamy free(x), dla x równego NULL, a jak wiemy wywołanie free(NULL) jest bezpieczne (nic nie robi).


Podsumowanie
Jak widać atrybut cleanup nie jest z pewnością zachętą do niedbalstwa, ale w wielu przypadkach jego użycie może prowadzić do powstania bardziej przejrzystego kodu. Jest on użyteczny szczególnie w sytuacji, w której mamy wielokrotnie zagnieżdżoną instrukcję warunkową i wielokrotne powroty z funkcji. Oczywiście cleanup można stosować nie tylko dla wskaźników – w ten sposób możemy z każdą zmienną skojarzyć funkcję, która będzie wywołana, kiedy zmienna skończy swój żywot.

5 komentarzy:

  1. Ciekawi mnie to "uszkodzenie sterty", można gdzieś poczytać o tym zjawisku?

    Z drobniejszych uwag tradycyjnie przyczepię się, że nie ma potrzeby informowanie, że coś nie jest dla początkujących (lub jest trudne, czy cokolwiek w tym rodzaju), zostawmy to ocenie zainteresowanych. ;)

    OdpowiedzUsuń
  2. O ile nie jesteś developerem AVR-libc to o budowie sterty nic nie powinieneś wiedzieć. Z punktu widzenia programisty C jej struktura jest bez znaczenia. Natomiast zgodnie ze standardem języka C nie wolno zwalniać pamięci na którą wskazuje nieprawidłowy wskaźnik (chyba, że ma wartość NULL). A po wykonaniu funkcji free wskaźnik nie wskazuje już na prawidłowy obszar pamięci - pomimo tego, że jego wartość się nie zmienia.

    OdpowiedzUsuń
  3. Bardzo dobry artykuł. Czekam na więcej :)

    OdpowiedzUsuń
  4. Szacun dla wiedzy tmf:) Aktualnie czytam Pana ksiązkę i czekam niecierpliwie na zapowiadaną następną.

    Artykuł bardzo fajny i pouczający mimo że nie korzystam z gcc. Mam jedną wątpliwość. Na początku napisane jest "Powyższa funkcja alokuje 100 bajtów na stercie, po czym bajtom 2 i 3 nadaje wartość (int)1.
    Wg mnie to tylko bajtowi 2-giemu nadaje 1. Czy się mylę?

    OdpowiedzUsuń
  5. Jest napisane bajtom 2, 3 nadaje wartość (int)1. Kluczem jest to int - dla architektury little-endian będzie to odpowiednio 1 i 0. Proszę zauważyć, że nigdzie nie sugeruję, że oba bajty będą miały wartość 1 :)

    OdpowiedzUsuń