Laboratorium 6: Dynamiczny przydział pamięci dr inż. Arkadiusz Chrobot dr inż. Grzegorz Łukawski 15 maja 2015
1. Wprowadzenie Instrukcja poświęcona jest dynamicznemu przydziałowi i zwalnianiu pamięci w języku c. Opisano w niej również podstawy tworzenia dynamicznych struktur danych. W rozdziale pierwszym zawarte są informacje na temat funkcji umożliwiających alokację i zwalnianie pamięci w trakcie wykonania programu. Rozdział drugi zawiera opis dwóch funkcji, które przydatne są gdy trzeba wykonać pewne proste operacje na zawartości określonych obszarów pamięci operacyjnej. W rozdziale trzecim przedstawiony jest sposób definiowania typów bazowych najczęściej używanych dynamicznych struktur danych. Rozdział czwarty zawiera opis mechanizmu wykrywania wycieków pamięci (ang. memory leaks debugger), który oferowany jest przez kompilator gcc. Ostatni rozdział zawiera przykładowy program używający dynamicznego przydziału pamięci. 2. Funkcje przydziału i zwalniania pamięci Język c oferuje trzy standardowe funkcję do dynamicznego przydziału pamięci i jedną funkcję do jej zwalniania. Aby z nich skorzystać należy w kodzie źródłowym programu załączyć plik nagłówkowy stdlib.h. 2.1. Przydział pamięci Najprostszym sposobem dynamicznego przydziału obszaru pamięci operacyjnej jest użycie funkcji malloc(). Funkcja ta przyjmuje jeden argument, którym jest wyrażony w bajtach rozmiar przydzielanego obszaru pamięci. Argument ten może być podany jako zwykła liczba, ale zazwyczaj do określenia potrzebnej liczby bajtów używamy operatora sizeof(). Funkcja malloc() zwraca wartość typu void *, którą jest adres początku przydzielonego obszaru. Jeśli nie uda się alokacja pamięci o podanym rozmiarze, to funkcja ta zwróci wartość null. Po każdym wywołaniu malloc() powinniśmy sprawdzać, czy nie wystąpił taki przypadek. Zwrócony adres możemy zapisać do zmiennej wskaźnikowej dowolnego typu, ale zaleca się wykonać przy tym rzutowanie na na typ tej zmiennej. Zostanie to pokazane w przykładowym programie. Obszar pamięci przydzielony przez tę funkcję nie jest zerowany i może zawierać przypadkowe wartości. Funkcja calloc() działa podobnie jak malloc(), została jednak stworzona w celu ułatwienia przydziału pamięci na tablice. Pobiera zatem dwa argumenty. Pierwszym jest liczba elementów w tworzonej tablicy, a drugim jest rozmiar pojedynczego elementu wyrażony w bajtach. Przydzielony przez nią obszar pamięci jest wyzerowany. Pod innymi względami nie różni się od malloc() i wszystkie zamieszczone wyżej uwagi do tej funkcji stosują się również do niej. Funkcja realloc() służy do zmiany (zmniejszenia lub zwiększenia) rozmiaru przydzielonego obszaru pamięci. Zawartość pamięci otrzymanej po wywołaniu tej funkcji nie jest inicjowana, co jest szczególnie ważne, jeśli chcemy powiększyć przydzielony obszar bez utraty jego zawartości. Funkcja realloc() jako argumenty przyjmuje wskaźnik na obszar, którego wielkość trzeba zmienić oraz nowy rozmiar obszaru wyrażony w bajtach. Jeśli pierwszy z argumentów będzie miał wartość null, to realloc() zadziała tak samo jak malloc(). Jeśli drugi z argumentów będzie miał wartość zero, to funkcja zwolni przydzielony blok pamięci. Typ wartości zwracanej przez realloc() jest taki sam jak dla wcześniej opisanych funkcji przydziału pamięci. Jeśli zmiana wielkości obszaru się nie powiedzie, a może mieć to miejsce tylko w przypadku jego powiększania, to zwracana jest wartość null, ale sam obszar zostaje nienaruszony i wskaźnik przekazany funkcji jako pierwszy argument będzie nadal wskaźnikiem poprawnym. Wartość null jest zwracana przez funkcję również wtedy, gdy jej drugi argument miał wartość zero. 2.2. Zwalnianie pamięci Do zwalnienia pamięci przydzielonej dowolną z wyżej opisanych funkcji służy funkcja free(). Funkcja ta nie zwraca żadnej wartości, ale przyjmuje jeden argument będący wskaźnikiem na obszar pamięci do zwolnienia. Jeśli ten wskaźnik będzie miał przed wywołaniem free() wartość null, to nie zostanie wykonana żadna operacja. Jeśli jednak będzie on wskazywał na już zwolniony obszar pamięci, to sposób zachowania się funkcji fee() w takim wypadku nie jest określony. Zaleca się zatem, aby zaraz po wywołaniu tej funkcji wskaźnikowi, który był jej argumentem nadać wartość null. Zapobiega to również przypadkowemu użyciu wskaźnika na zwolniony obszar pamięci w innym miejscu programu. 1
3. Manipulowanie obszarami pamięci Po włączeniu w programie pliku nagłówkowego string.h możemy korzystać nie tylko z opisywanych w jednej ze wcześniejszych instrukcji funkcji do manipulacji ciągami znaków, ale również do dwóch funkcji, których działanie jest bardziej ogólne. Obie działają na dowolnych obszarach pamięci o zadanej wielkości. Pierwszą z nich jest funkcja memset(). Służy ona do wypełniania obszaru pamięci zadaną wartością. Jako pierwszy argument przyjmuje wskaźnik na obszar pamięci, który ma zostać wypełniony. Może to być równie dobrze obszar przydzielony dynamicznie, jak i statycznie. Drugim argumentem jest wartość, którą ten obszar trzeba wypełnić, może to być np. znak, a trzecim jest rozmiar wypełnianego obszaru podany w bajtach. Wartością zwracaną jest wskaźnik na początek tego obszaru, ale ta wartość najczęściej jest ignorowana. Funkcja memset() jest często stosowana do zerowania obszarów pamięci, np. tablic. Drugą funkcją jest memcpy(), która służy do kopiowania zawartości obszarów pamięci. Najczęściej za jej pomocą kopiuje się tablice, za wyjątkiem tablic znakowych, w których przypadku używa się opisanych wcześniej strcpy() lub strncpy(). Funkcja memcpy() przyjmuje trzy argumenty. Pierwszym jest wskaźnik na obszar pamięci do którego mają być kopiowane dane. Drugim jest wskaźnik na obszar pamięci, z którego mają być kopiowane dane. Obszary te nie powinny na siebie nachodzić, a więc nie można za pomocą tej funkcji np. kopiować elementów tablicy do niej samej. Trzecim argumentem jest liczba bajtów, które mają być przekopiowane. Funkcja zwraca wskaźnik na obszar docelowy, ale ta wartość jest najczęściej ignorowana. W pliku nagłówkowym string.h zadeklarowano również inne funkcje o działaniu podobnym do memcpy() lub memset(). Są to np. funkcje memmove() i memccpy(). Ich działanie nie będzie jednak opisywane w tej instrukcji. 4. Definiowanie typów bazowych dynamicznych struktur danych Bazowe typy danych takich struktur jak listy, kolejki stosy i drzewa są definiowane z użyciem struktur ze wskaźnikami. Listing 1 zawiera definicję przykładowego bazowego typu danych, który może być użyty do budowy np. jednokierunkowej listy liniowej. Zawarto w nim również deklarację zmiennej wskaźnikowej tego typu. struct list_node { int a; struct list_node *next; }; struct list_node *first; Listing 1: Typ bazowy listy jednokierunkowej i zmienna wskaźnikowa Taki sposób definiowania typu bazowego struktury danych ma tę zaletę, że w każdym miejscu jego użycia jawnie jest zapisane, iż jest to typ bazujący na strukturze. Jego główną wadą jest rozciągłość zapisu. Przy deklarowaniu zmiennych globalnych i lokalnych oraz parametrów i typów wartości zwracanych przez funkcje należy zawsze powtarzać słowo kluczowe struct. Jeśli więc funkcja zwraca wskaźnik na element kolejki i przyjmuje jako argumenty dwa wskaźniki, odpowiednio na początek i koniec tej kolejki, to prototyp takiej funkcji jest bardzo długi, co zmniejsza jego czytelność. Aby poradzić sobie z tym problemem można skrócić zapis typu bazowego używając słowa kluczowego typedef. Służy ono do tworzenia nowych nazw dla wbudowanych lub zdefiniowanych przez programistę typów danych. Sposób jego użycia jest następujący: typedef typ_danych nowa_nazwa; Kod z listingu 1 może zatem zostać zapisany krócej przy użyciu słowa kluczowego typedef (listing 2). 2
typedef struct list_node { int a; struct list_node *next; } node; node *first; Listing 2: Użycie typedef dla typu bazowego dynamicznej struktury danych Dzięki temu rozwiązaniu zapis nazwy typu bazowego znacznie się skraca, jednakże z tego zapisu nie możemy wywnioskować jaki tak na prawdę jest to typ. Informacja o tym, że jego definicja oparta jest na strukturze zostaje zatracona. To rozwiązanie można więc swobodnie stosować tylko w programach o niewielkich rozmiarach. W innych przypadkach należy stosować je rozważnie, a korzystać głownie z pierwszego z przedstawionych sposobów definiowania typów bazowych dla dynamicznych struktur danych. 5. Wykrywanie wycieków pamięci Jednym z najczęściej popełnianych przez programistów błędów podczas korzystania z dynamicznego przydziału pamięci jest tracenie adresów do przydzielonych obszarów pamięci. W ten sposób powstają tzw. wycieki pamięci, czyli obszary pamięci, do których nie można się dostać i nie można ich zwolnić. W przeciwieństwie do takich błędów jak użycie niewłaściwego wskaźnika, przyczyny wycieków pamięci są trudne do wykrycia, bowiem ich skutki ujawniają się zawsze z pewnym opóźnieniem. Najczęściej wtedy, gdy nie ma już wolnej pamięci dla nowych przydziałów i nie można zwolnić przydzielonych obszarów, bo albo są w użyciu, albo uległy wyciekowi. Kompilator gcc zapewnia mechanizm, który ułatwia lokalizację przyczyn wycieków pamięci. Ten mechanizm składa się z dwóch funkcji języka c oraz jednego polecenia powłoki. Aby go użyć najpierw do kodu źródłowego musimy dołączyć plik nagłówkowy mcheck.h. Następnie w kodzie źródłowym, przed wszystkimi przydziałami pamięci umieszczamy wywołanie funkcji mtrace(). Wywołanie drugiej funkcji, jaką jest muntrace() umieszczamy za wszystkimi zwolnieniami obszarów przydzielonej pamięci. Program powinniśmy skompilować z opcją dodającą informacje dla debuggera, czyli -g. Przed uruchomieniem skompilowanego programu należy w powłoce systemowej utworzyć zmienną środowiskową o nazwie malloc_trace, której wartością będzie ścieżka dostępu do pliku tekstowego zawierającego informacje o ewentualnych wyciekach pamięci. W przypadku powłoki bash możemy taką zmienną stworzyć następująco: export malloc_trace=./plik.txt Proszę zwrócić uwagę na brak spacji po obu stronach znaku =. Kropka w ścieżce dostępu oznacza katalog bieżący. Jeśli plik memory.txt nie istnieje, to zostanie automatycznie stworzony po wydaniu tego polecenia. Po uruchomieniu programu informacje o ewentualnych wyciekach będą zapisane w tym pliku. Jego zawartość nie jest łatwa do odszyfrowania, dlatego stworzono osobny program - polecenie powłoki, który przedstawia zawarte w nim informacje w sposób czytelny. To polecenie nazywa się również mtrace i używamy go następująco: mtrace plik_wykonywalny plik.txt, gdzie plik_wykonywalny jest plikiem ze skompilowanym programem, a plik.txt zawiera dane o ewentualnych wyciekach pamięci. Jeśli do nich nie doszło, to na ekranie zobaczymy zdanie: No memory leaks. W przeciwnym przypadku zostanie wydrukowana tabela zawierająca informację o adresach obszarów pamięci, które nie zostały zwolnione, ich rozmiarze oraz miejscu w kodzie, gdzie zostały one utworzone. Liczby będą wypisane w kodzie szesnastkowym. 3
6. Przykładowy program - użycie stosu 1 #include<stdio.h> 2 #include<stdlib.h> 3 #include<string.h> 4 #include<mcheck.h> 5 6 typedef struct stack_node 7 { 8 char arg[100]; 9 struct stack_node *next; 10 } node; 11 12 node *push(node *top, char *str) 13 { 14 node *new_node = NULL; 15 new_node = (node*)malloc(sizeof(node)); 16 if(new_node==null) { 17 fprintf(stderr,"błąd przydziału pamięci\n"); 18 return top; 19 } 20 strncpy(new_node->arg,str,100); 21 new_node->next=top; 22 return new_node; 23 } 24 25 node *pop(node **top) 26 { 27 node *next = NULL, *old_top = NULL; 28 if(*top!=null) { 29 next=(*top)->next; 30 (*top)->next = NULL; 31 old_top = *top; 32 *top = next; 33 } 34 return old_top; 35 } 36 37 int main(int argc, char **argv) 38 { 39 node *top = NULL; 40 int i; 41 mtrace(); 42 for(i=0;i<argc;i++) 43 top = push(top,argv[i]); 44 while(top) { 45 node *tmp = pop(&top); 46 printf("%s\n",tmp->arg); 47 free(tmp); 48 tmp=null; 49 } 50 muntrace(); 51 return 0; 52 } Listing 3: Program, który odkłada na stos swoje argumenty 4
Listing 3 zawiera kod programu odkładającego swoje argumenty wywołania na stos. Jeśli ten program zostanie wywołany bez argumentów, to na stos zostanie odłożona, a następnie zdjęta i wydrukowana na ekranie nazwa pod jaką został wywołany. Jeśli zostaną mu przekazane argumenty wywołania w dowolnej liczbie, to również zostaną one odłożone na stosie, a potem zdjęte i wydrukowane w osobnych wierszach na ekranie wraz z nazwą. Ze względu na specyfikę działania stosu te informacje zostaną wypisane w odwrotnej kolejności. W wierszach 6-9 zdefiniowano przy użyciu typedef typ bazowy stosu 1. Funkcja push() (wiersze 11-22) zapisuje poszczególne argumenty wywołania programu na stosie. Odpowiedzialna jest ona za tworzenie i dodawanie nowych elementów stosu. Ta funkcja przyjmuje dwa argumenty: wskaźnik na szczyt stosu (może mieć wartość null) i wskaźnik na ciąg znaków, który ma być zapisany w nowym elemencie stosu. Zwraca ona wskaźnik na nowy szczyt stosu lub stary szczyt stosu, jeśli nie udało się utworzyć nowego elementu. Do wypisywania informacji o niepowodzeniu realizacji przydziału pamięci (wiersz 16) wykorzystywana jest funkcja fprintf(). Jako pierwszy argument został jej podany wskaźnik do standardowego strumienia diagnostycznego (stderr), który związany jest z ekranem monitora. Z pewnych względów, które nie będą tu opisane, jest to rozwiązanie lepsze niż stosowanie funkcji printf() do wyświetlania opisu błędu. Do kopiowania argumentu, który zapisany jest w postaci łańcucha znaków (wiersz 19) użyta została funkcja strncpy(), gdyż pozwala ona ograniczyć liczbę kopiowanych znaków do wielkości, która mieści się w polu arg pojedynczego elementu stosu. Funkcja pop() zdejmuje pojedynczy element ze szczytu stosu. Jej implementacja jest bardziej skomplikowana niż funkcji push(). Ponieważ musi być zmodyfikowany wskaźnik na szczyt stosu, to do tej funkcji, jako argument, przekazywany jest jego adres (wiersz 44), stąd parametrem funkcji jest podwójny wskaźnik typ node (wiersz 24). Jego użycie komplikuje trochę odwołania do poszczególnych pól elementu szczytowego stosu (wiersze 28 i 29), ale jest niezbędne do prawidłowego działania programu. Funkcja pop() nie zwalnia pamięci przydzielonej na szczytowy element, a jedynie odłącza go od reszty stosu i zraca jego adres. Zwalnianie dokonywane jest po jej wywołaniu, w wierszu 46. Ponieważ argument wywołania programu zapisywany jest w tablicy znaków będącej integralną częścią pojedynczego elementu stosu, to rozwiązanie polegające na zwróceniu wskaźnika na łańcuch znaków i zwolnienie elementu w funkcji pop() jest błędne i nie zostało tu zastosowane. Przed odwołaniem do szczytowego elementu stosu funkcja sprawdza, czy on istnieje (wiersz 27). Jeśli nie, to zwracana jest przez nią wartość null. Obie opisane funkcje są wywoływane w funkcji main(). Proszę zwrócić uwagę na inicjowanie we wszystkich funkcjach zmiennych lokalnych, które są wskaźnikami, a także na nadawanie wskaźnikowi tmp wartości null, tuż po wywołaniu funkcji free(). Sposób wywołania funkcji pop() i push() pokazany jest w wierszach 44 i 42. Jeśli po skompilowaniu i wykonaniu wszystkich niezbędnych czynności opisanych w poprzednim rozdziale, uruchomimy program z np. trzema argumentami, to polecenie mtrace wypisze na ekranie informację, że nie ma żadnych wycieków pamięci. Jeśli jednak umieścimy w komentarzu wiersz 46, to po wykonaniu polecenia mtrace zobaczymy na ekranie informację podobną do tej: Memory not freed: ----------------- Address Size Caller 0x00000000018b1460 0x70 at /home/arek/programming/c/mtrace/stack.c:14 0x00000000018b14e0 0x70 at /home/arek/programming/c/mtrace/stack.c:14 0x00000000018b1560 0x70 at /home/arek/programming/c/mtrace/stack.c:14 0x00000000018b15e0 0x70 at /home/arek/programming/c/mtrace/stack.c:14 1 Numery wierszy nie stanowią części kodu, zostały dodane wyłącznie w celu ułatwienia jego opisu. 5