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.
Ciekawi mnie to "uszkodzenie sterty", można gdzieś poczytać o tym zjawisku?
OdpowiedzUsuń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. ;)
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ńBardzo dobry artykuł. Czekam na więcej :)
OdpowiedzUsuńSzacun dla wiedzy tmf:) Aktualnie czytam Pana ksiązkę i czekam niecierpliwie na zapowiadaną następną.
OdpowiedzUsuń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ę?
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ń