Mikrokontrolery - Jak zacząć?

... czyli zbiór praktycznej wiedzy dot. mikrokontrolerów.

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.

Oceń artykuł.
Wasze opinie są dla nas ważne, gdyż pozwalają dopracować poszczególne artykuły.
Pozdrawiamy, Autorzy
Ten artykuł oceniam na:

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ń

Działy
Działy dodatkowe
Inne
O blogu




Dzisiaj
--> za darmo!!! <--
1. USBasp
2. microBOARD M8


Napisz artykuł
--> i wygraj nagrodę. <--


Co nowego na blogu?
Śledź naszego Facebook-a



Co nowego na blogu?
Śledź nas na Google+

/* 20140911 Wyłączona prawa kolumna */
  • 00

    dni

  • 00

    godzin

  • :
  • 00

    minut

  • :
  • 00

    sekund

Nie czekaj do ostatniego dnia!
Jakość opisu projektu także jest istotna (pkt 9.2 regulaminu).

Sponsorzy:

Zapamiętaj ten artykuł w moim prywatnym spisie treści.