Cyfroteka.pl

klikaj i czytaj online

Cyfro
Czytomierz
00579 010359 11031877 na godz. na dobę w sumie
Linux. Programowanie systemowe - książka
Linux. Programowanie systemowe - książka
Autor: Liczba stron: 400
Wydawca: Helion Język publikacji: polski
ISBN: 978-83-246-1497-4 Data wydania:
Lektor:
Kategoria: ebooki >> komputery i informatyka >> systemy operacyjne >> linux
Porównaj ceny (książka, ebook, audiobook).
Wykorzystaj moc Linuksa i twórz funkcjonalne oprogramowanie systemowe!

Dzisiaj systemu Linux nie musimy już nikomu przedstawiać, dzięki swojej funkcjonalności i uniwersalności stał się niezwykle popularny i szeroko wykorzystywany. Działa wszędzie ... poczynając od najmniejszych telefonów komórkowych, a na potężnych superkomputerach kończąc. Z Linuksa korzystają agencje wywiadowcze i wojsko, jego niezawodność doceniły również banki i instytucje finansowe. Oprogramowanie z przestrzeni użytkownika w systemie Linux może być uruchamiane na wszystkich platformach, na których poprawnie działa kod jądra.

Czytając książkę 'Linux. Programowanie systemowe', dowiesz się, jak utworzyć oprogramowanie, które jest niskopoziomowym kodem, komunikującym się bezpośrednio z jądrem oraz głównymi bibliotekami systemowymi. Opisany został tu sposób działania standardowych i zaawansowanych interfejsów zdefiniowanych w Linuksie. Po lekturze napiszesz inteligentniejszy i szybszy kod, który działa we wszystkich dystrybucjach Linuksa oraz na wszystkich rodzajach sprzętu. Nauczysz się budować poprawne oprogramowanie i maksymalnie je wykorzystywać.

Poznaj i ujarzmij potęgę Linuksa!

Znajdź podobne książki Ostatnio czytane w tej kategorii

Darmowy fragment publikacji:

Linux. Programowanie systemowe Autor: Robert Love T‡umaczenie: Jacek Janusz ISBN: 978-83-246-1497-4 Tytu‡ orygina‡u: Linux System Programming: Talking Directly to the Kernel and C Library Format: 168x237, stron: 400 Wykorzystaj moc Linuksa i tw(cid:243)rz funkcjonalne oprogramowanie systemowe! (cid:149) Jak zarz„dza(cid:230) plikowymi operacjami wej(cid:156)cia i wyj(cid:156)cia? (cid:149) Jak zablokowa(cid:230) fragmenty przestrzeni adresowej? (cid:149) Jak sterowa(cid:230) dzia‡aniem interfejsu odpytywania zdarzeæ? Dzisiaj systemu Linux nie musimy ju¿ nikomu przedstawia(cid:230), dziŒki swojej funkcjonalno(cid:156)ci i uniwersalno(cid:156)ci sta‡ siŒ niezwykle popularny i szeroko wykorzystywany. Dzia‡a wszŒdzie ? poczynaj„c od najmniejszych telefon(cid:243)w kom(cid:243)rkowych, a na potŒ¿nych superkomputerach koæcz„c. Z Linuksa korzystaj„ agencje wywiadowcze i wojsko, jego niezawodno(cid:156)(cid:230) doceni‡y r(cid:243)wnie¿ banki i instytucje finansowe. Oprogramowanie z przestrzeni u¿ytkownika w systemie Linux mo¿e by(cid:230) uruchamiane na wszystkich platformach, na kt(cid:243)rych poprawnie dzia‡a kod j„dra. Czytaj„c ksi„¿kŒ (cid:132)Linux. Programowanie systemowe(cid:148), dowiesz siŒ, jak utworzy(cid:230) oprogramowanie, kt(cid:243)re jest niskopoziomowym kodem, komunikuj„cym siŒ bezpo(cid:156)rednio z j„drem oraz g‡(cid:243)wnymi bibliotekami systemowymi. Opisany zosta‡ tu spos(cid:243)b dzia‡ania standardowych i zaawansowanych interfejs(cid:243)w zdefiniowanych w Linuksie. Po lekturze napiszesz inteligentniejszy i szybszy kod, kt(cid:243)ry dzia‡a we wszystkich dystrybucjach Linuksa oraz na wszystkich rodzajach sprzŒtu. Nauczysz siŒ budowa(cid:230) poprawne oprogramowanie i maksymalnie je wykorzystywa(cid:230). (cid:149) Programowanie systemowe (cid:149) Biblioteka jŒzyka C (cid:149) Kompilator jŒzyka C (cid:149) Interfejs odpytywania zdarzeæ (cid:149) Zarz„dzanie procesami i pamiŒci„ (cid:149) U¿ytkownicy i grupy (cid:149) Ograniczenia zasob(cid:243)w systemowych (cid:149) Zarz„dzanie plikami i katalogami (cid:149) Identyfikatory sygna‡(cid:243)w (cid:149) Struktury danych reprezentuj„ce czas (cid:149) Konwersje czasu Poznaj i ujarzmij potŒgŒ Linuksa! Wydawnictwo Helion ul. Ko(cid:156)ciuszki 1c 44-100 Gliwice tel. 032 230 98 63 e-mail: helion@helion.pl Spis treści Przedmowa ...............................................................................................................................7 Wstęp ........................................................................................................................................9 1. Wprowadzenie — podstawowe pojęcia .................................................................... 15 15 18 20 23 36 Programowanie systemowe API i ABI Standardy Pojęcia dotyczące programowania w Linuksie Początek programowania systemowego 2. Plikowe operacje wejścia i wyjścia .............................................................................37 38 43 47 51 55 56 57 59 60 61 72 76 Otwieranie plików Czytanie z pliku przy użyciu funkcji read() Pisanie za pomocą funkcji write() Zsynchronizowane operacje wejścia i wyjścia Bezpośrednie operacje wejścia i wyjścia Zamykanie plików Szukanie za pomocą funkcji lseek() Odczyty i zapisy pozycyjne Obcinanie plików Zwielokrotnione operacje wejścia i wyjścia Organizacja wewnętrzna jądra Zakończenie 3. Buforowane operacje wejścia i wyjścia ...................................................................... 77 77 79 80 Operacje wejścia i wyjścia, buforowane w przestrzeni użytkownika Typowe operacje wejścia i wyjścia Otwieranie plików 3 Otwieranie strumienia poprzez deskryptor pliku Zamykanie strumieni Czytanie ze strumienia Pisanie do strumienia Przykładowy program używający buforowanych operacji wejścia i wyjścia Szukanie w strumieniu Opróżnianie strumienia Błędy i koniec pliku Otrzymywanie skojarzonego deskryptora pliku Parametry buforowania Bezpieczeństwo wątków Krytyczna analiza biblioteki typowych operacji wejścia i wyjścia Zakończenie 81 82 83 86 88 89 91 92 93 93 95 97 98 4. Zaawansowane operacje plikowe wejścia i wyjścia ..................................................99 100 105 110 123 126 129 141 Rozproszone operacje wejścia i wyjścia Interfejs odpytywania zdarzeń Odwzorowywanie plików w pamięci Porady dla standardowych operacji plikowych wejścia i wyjścia Operacje zsynchronizowane, synchroniczne i asynchroniczne Zarządcy operacji wejścia i wyjścia oraz wydajność operacji wejścia i wyjścia Zakończenie 5. Zarządzanie procesami ............................................................................................. 143 143 146 153 156 166 171 176 178 Identyfikator procesu Uruchamianie nowego procesu Zakończenie procesu Oczekiwanie na zakończone procesy potomka Użytkownicy i grupy Grupy sesji i procesów Demony Zakończenie 6. Zaawansowane zarządzanie procesami .................................................................. 179 179 183 186 189 192 206 Szeregowanie procesów Udostępnianie czasu procesora Priorytety procesu Wiązanie procesów do konkretnego procesora Systemy czasu rzeczywistego Ograniczenia zasobów systemowych 4 | Spis treści 7. Zarządzanie plikami i katalogami ............................................................................ 213 213 228 240 245 248 249 251 Pliki i ich metadane Katalogi Dowiązania Kopiowanie i przenoszenie plików Węzły urządzeń Komunikacja poza kolejką Śledzenie zdarzeń związanych z plikami 8. Zarządzanie pamięcią ............................................................................................... 261 261 263 273 274 278 281 282 286 287 291 295 Przestrzeń adresowa procesu Przydzielanie pamięci dynamicznej Zarządzanie segmentem danych Anonimowe odwzorowania w pamięci Zaawansowane operacje przydziału pamięci Uruchamianie programów, używających systemu przydzielania pamięci Przydziały pamięci wykorzystujące stos Wybór mechanizmu przydzielania pamięci Operacje na pamięci Blokowanie pamięci Przydział oportunistyczny 9. Sygnały .......................................................................................................................297 298 304 309 311 314 315 316 324 325 Koncepcja sygnałów Podstawowe zarządzanie sygnałami Wysyłanie sygnału Współużywalność Zbiory sygnałów Blokowanie sygnałów Zaawansowane zarządzanie sygnałami Wysyłanie sygnału z wykorzystaniem pola użytkowego Zakończenie 10. Czas ............................................................................................................................327 329 332 334 337 338 Struktury danych reprezentujące czas Zegary POSIX Pobieranie aktualnego czasu Ustawianie aktualnego czasu Konwersje czasu Spis treści | 5 Dostrajanie zegara systemowego Stan uśpienia i oczekiwania Liczniki 340 343 349 A Rozszerzenia kompilatora GCC dla języka C ............................................................357 B Bibliografia ................................................................................................................369 Skorowidz ..................................................................................................................373 6 | Spis treści ROZDZIAŁ 8. Zarządzanie pamięcią Pamięć należy do najbardziej podstawowych, a jednocześnie najważniejszych zasobów dostęp- nych dla procesu. W rozdziale tym omówione zostaną tematy związane z zarządzaniem nią: przydzielanie, modyfikowanie i w końcu zwalnianie pamięci. Słowo przydzielanie — powszechnie używany termin, określający czynność udostępniania obszaru pamięci — wprowadza w błąd, ponieważ wywołuje obraz wydzielania deficytowego zasobu, dla którego wielkość żądań przewyższa wielkość zapasów. Na pewno wielu użytkowników wolałoby mieć więcej dostępnej pamięci. Dla nowoczesnych systemów problem nie polega jednak na rozdzielaniu zbyt małych zapasów dla zbyt wielu użytkowników, lecz właściwym używaniu i monitorowaniu danego zasobu. W tym rozdziale przeanalizowane zostaną wszystkie metody przydzielania pamięci dla różnych obszarów programu, jednocześnie z ukazaniem ich zalet i wad. Przedstawimy również pewne sposoby, pozwalające na ustawianie i modyfikację zawartości dowolnych obszarów pamięci, a także wyjaśnimy, w jaki sposób należy zablokować dane w pamięci, aby w programach nie trzeba było oczekiwać na operacje jądra, które zajmowałoby się przerzucaniem danych z obszaru wymiany. Przestrzeń adresowa procesu Linux, podobnie jak inne nowoczesne systemy operacyjne, wirtualizuje swój fizyczny zasób pamięci. Procesy nie adresują bezpośrednio pamięci fizycznej. Zamiast tego jądro wiąże każdy proces z unikalną wirtualną przestrzenią adresową. Jest ona liniowa, a jej adresacja rozpoczyna się od zera i wzrasta do pewnej granicznej wartości maksymalnej. Strony i stronicowanie Wirtualna przestrzeń adresowa składa się ze stron. Architektura systemu oraz rodzaj maszyny determinują rozmiar strony, który jest stały: typowymi wartościami są na przykład 4 kB (dla systemów 32-bitowych) oraz 8 kB (dla systemów 64-bitowych)1. Strony są albo prawidłowe, 1 Czasami systemy wspierają rozmiary stron, które mieszczą się w pewnym zakresie. Z tego powodu rozmiar strony nie jest częścią interfejsu binarnego aplikacji (ABI). Aplikacje muszą w sposób programowy uzyskać rozmiar strony w czasie wykonania. Zostało to opisane w rozdziale 4. i będzie jednym z tematów poruszonych w tym rozdziale. 261 albo nieprawidłowe. Strona prawidłowa (ang. valid page) związana jest ze stroną w pamięci fizycz- nej lub jakąś dodatkową pamięcią pomocniczą, np. partycją wymiany lub plikiem na dysku. Strona nieprawidłowa (ang. invalid page) nie jest z niczym związana i reprezentuje nieużywany i nieprzydzielony obszar przestrzeni adresowej. Dostęp do takiej strony spowoduje błąd seg- mentacji. Przestrzeń adresowa nie musi być koniecznie ciągła. Mimo że jest ona adresowana w sposób liniowy, zawiera jednak mnóstwo przerw, nieposiadających adresacji. Program nie może użyć strony, która znajduje się w dodatkowej pamięci pomocniczej zamiast w fizycznej. Będzie to możliwe dopiero wtedy, gdy zostanie ona połączona ze stroną w pamięci fizycznej. Gdy proces próbuje uzyskać dostęp do adresu z takiej strony, układ zarządzania pamięcią (MMU) generuje błąd strony (ang. page fault). Wówczas wkracza do akcji jądro, w spo- sób niewidoczny przerzucając żądaną stronę z pamięci pomocniczej do pamięci fizycznej. Ponie- waż istnieje dużo więcej pamięci wirtualnej niż rzeczywistej (nawet w przypadku systemów z pojedynczą wirtualną przestrzenią adresową!), jądro również przez cały czas wyrzuca strony z pamięci fizycznej do dodatkowej pamięci pomocniczej, aby zrobić miejsce na nowe strony, przerzucane w drugim kierunku. Jądro przystępuje do wyrzucania danych, dla których ist- nieje najmniejsze prawdopodobieństwo, iż będą użyte w najbliższej przyszłości. Dzięki temu następuje poprawa wydajności. Współdzielenie i kopiowanie podczas zapisu Wiele stron w pamięci wirtualnej, a nawet w różnych wirtualnych przestrzeniach adresowych należących do oddzielnych procesów, może być odwzorowanych na pojedynczą stronę fizyczną. Pozwala to różnym wirtualnym przestrzeniom adresowym na współdzielenie danych w pamięci fizycznej. Współdzielone dane mogą posiadać uprawnienia tylko do odczytu lub zarówno do odczytu, jak i zapisu. Gdy proces przeprowadza operację zapisu do współdzielonej strony, posiadającej uprawnienia do wykonania tej czynności, mogą zaistnieć jedna lub dwie sytuacje. Najprostsza wersja polega na tym, że jądro zezwoli na wykonanie zapisu i wówczas wszystkie procesy współdzielące daną stronę, będą mogły „zobaczyć” wyniki tej operacji. Zezwolenie wielu procesom na czy- tanie lub zapis do współdzielonej strony wymaga zazwyczaj zapewnienia pewnego poziomu współpracy i synchronizacji między nimi. Inaczej jest jednak, gdy układ zarządzania pamięcią przechwyci operację zapisu i wygeneruje wyjątek; w odpowiedzi, jądro w sposób niewidoczny stworzy nową kopię strony dla procesu zapisującego i zezwoli dla niej na kontynuowanie zapisu. To rozwiązanie zwane jest kopiowaniem podczas zapisu (ang. copy-on-write, w skrócie COW)2. Proces faktycznie posiada uprawnienia do odczytu dla współdzielonych danych, co przyczynia się do oszczędzania pamięci. Gdy proces chce zapisać do współdzielonej strony, otrzymuje wówczas na bieżąco unikalną jej kopię. Dzięki temu jądro może działać w taki sposób, jak gdyby proces zawsze posiadał swoją własną kopię strony. Ponieważ kopiowanie podczas zapisu jest zaimplementowane dla każdej strony z osobna, dlatego też duży plik może zostać efektywnie udostępniony wielu procesom, którym zostaną przydzielone unikalne strony fizyczne tylko wówczas, gdy będą chciały coś w nich zapisywać. 2 W rozdziale 5. napisano, że funkcja fork() używa metody kopiowania podczas zapisu, aby powielić i udo- stępnić przestrzeń adresową rodzica tworzonemu procesowi potomnemu. 262 | Rozdział 8. Zarządzanie pamięcią Regiony pamięci Jądro rozmieszcza strony w blokach, które posiadają pewne wspólne cechy charakterystyczne, takie jak uprawnienia dostępu. Bloki te zwane są regionami pamięci (ang. memory regions), segmen- tami (ang. segments) lub odwzorowaniami (ang. mappings). Pewne rodzaje regionów pamięci mogą istnieć w każdym procesie: • Segment tekstu zawiera kod programu dla danego procesu, literały łańcuchowe, stałe oraz inne dane tylko do odczytu. W systemie Linux segment ten posiada uprawnienia tylko do odczytu i jest odwzorowany bezpośrednio na plik obiektowy (program wykonywalny lub bibliotekę). • Segment stosu, jak sama nazwa wskazuje, zawiera stos wykonania procesu. Segment stosu rozrasta się i maleje w sposób dynamiczny, zgodnie ze zmianami struktury stosu. Stos wykonania zawiera lokalne zmienne oraz dane zwracane z funkcji. • Segment danych (lub sterta) zawiera dynamiczną pamięć procesu. Do segmentu tego można zapisaywać, a jego rozmiar się zmienia. Sterta jest zwracana przy użyciu funkcji malloc() (omówionej w następnym podrozdziale). • Segment bss3 zawiera niezainicjalizowane zmienne globalne. Zmienne te mają specjalne war- tości (w zasadzie same zera), zgodnie ze standardem języka C. Linux optymalizuje je przy użyciu dwóch metod. Po pierwsze, ponieważ segment bss przeznaczony jest dla prze- chowywania niezainicjalizowanych danych, więc linker (ld) w rzeczywistości nie zapisuje specjalnych wartości do pliku obiektowego. Powoduje to zmniejszenie rozmiaru pliku binar- nego. Po drugie, gdy segment ten zostaje załadowany do pamięci, jądro po prostu odwzo- rowuje go w trybie kopiowania podczas zapisu na stronę zawierającą same zera, co efek- tywnie ustawia domyślne wartości w zmiennych. • Większość przestrzeni adresowej zajmuje grupa plików odwzorowanych, takich jak sam program wykonywalny, różne biblioteki — między innymi dla języka C, a także pliki z danymi. Ścieżka /proc/self/maps lub wynik działania programu pmap są bardzo dobrymi przykładami plików odwzorowanych w procesie. Rozdział ten omawia interfejsy, które są udostępnione przez system Linux, aby otrzymywać i zwalniać obszary pamięci, tworzyć i usuwać nowe odwzorowania oraz wykonywać inne czynności związane z pamięcią. Przydzielanie pamięci dynamicznej Pamięć zawiera także automatyczne i statyczne zmienne, lecz podstawą działania każdego systemu, który nią zarządza, jest przydzielanie, używanie, a w końcu zwalnianie pamięci dyna- micznej. Pamięć dynamiczna przydzielana jest w czasie działania programu, a nie kompilacji, a jej rozmiary mogą być nieznane do momentu rozpoczęcia samego procesu przydzielania. Dla projektanta jest ona użyteczna w momencie, gdy zmienia się ilość pamięci, którą potrzebuje tworzony program lub też zmienny jest czas, w ciągu którego będzie ona używana, a dodat- kowo wielkości te nie są znane przed uruchomieniem aplikacji. Na przykład, można zaimple- mentować przechowywanie w pamięci zawartości jakiegoś pliku lub danych wczytywanych 3 Nazwa to relikt historii — jest to skrót od słów: blok rozpoczęty od symbolu (ang. block started by symbol). Przydzielanie pamięci dynamicznej | 263 z klawiatury. Ponieważ wielkość takiego pliku jest nieznana, a użytkownik może wprowadzić dowolną liczbę znaków z klawiatury, rozmiar bufora musi być zmienny, by programista dyna- micznie go zwiększał, gdy danych zacznie przybywać. Żadne zmienne języka C nie są zapisywane w pamięci dynamicznej. Na przykład, język C nie udostępnia mechanizmu, który pozwala na odczytanie struktury pirate_ship znajdującej się w takiej pamięci. Zamiast tego istnieje metoda pozwalająca na przydzielenie takiej ilości pamięci dynamicznej, która wystarczy, aby przechować w niej strukturę pirate_ship. Programista następnie używa tej pamięci poprzez posługiwanie się wskaźnikiem do niej — w tym przy- padku, stosując wskaźnik struct pirate_ship *. Klasycznym interfejsem języka C, pozwalającym na otrzymanie pamięci dynamicznej, jest funkcja malloc(): #include stdlib.h void * malloc (size_t size); Poprawne jej wywołanie przydziela obszar pamięci, którego wielkość (w bajtach) określona jest w parametrze size. Funkcja zwraca wskaźnik do początku nowo przydzielonego regionu. Zawartość pamięci jest niezdefiniowana i nie należy oczekiwać, że będzie zawierać same zera. W przypadku błędu, funkcja malloc() zwraca NULL oraz ustawia zmienną errno na ENOMEM. Użycie funkcji malloc() jest raczej proste, tak jak w przypadku poniższego przykładu przy- dzielającego określoną liczbę bajtów: char *p; /* przydziel 2 kB! */ p = malloc (2048); if (!p) perror ( malloc ); Nieskomplikowany jest również kolejny przykład, przydzielający pamięć dla struktury: struct treasure_map *map; /* * przydziel wystarczająco dużo pamięci, aby przechować strukturę treasure_map, * a następnie przypisz adres tego obszaru do wskaźnika map */ map = malloc (sizeof (struct treasure_map)); if (!map) perror ( malloc ); Język C automatycznie rzutuje wskaźniki typu void na dowolny typ, występujący podczas operacji przypisania. Dlatego też w przypadku powyższych przykładów, nie jest konieczne rzutowanie typu zwracanej wartości funkcji malloc() na typ l-wartości, używanej podczas operacji przypisania. Język programowania C++ nie wykonuje jednak automatycznego rzu- towania wskaźnika void. Zgodnie z tym użytkownicy języka C++ muszą rzutować wyniki wywołania funkcji malloc(), tak jak pokazano to w poniższym przykładzie: char *name; /* przydziel 512 bajtów */ name = (char *) malloc (512); if (!name) perror ( malloc ); 264 | Rozdział 8. Zarządzanie pamięcią Niektórzy programiści języka C preferują wykonywanie rzutowania wyników dowolnej funkcji, która zwraca wskaźnik void. Dotyczy to również funkcji malloc(). Ten styl programowania jest jednak niepewny z dwóch powodów. Po pierwsze, może on spowodować pominięcie błędu w przypadku, gdy wartość zwracana z funkcji kiedykolwiek ulegnie zmianie i nie będzie równa wskaźnikowi void. Po drugie, takie rzutowanie ukrywa błędy również w przypadku, gdy funkcja jest niewłaściwie zadeklarowana4. Pierwszy z tych powodów nie jest przyczyną powsta- wania problemów podczas użycia funkcji malloc(), natomiast drugi może już ich przysparzać. Ponieważ funkcja malloc() może zwrócić wartość NULL, dlatego też jest szczególnie ważne, aby projektanci oprogramowania zawsze sprawdzali i obsługiwali przypadki błędów. W wielu programach funkcja malloc() nie jest używana bezpośrednio, lecz istnieje dla niej stworzony interfejs programowy (wrapper), który wyprowadza komunikat błędu i przerywa działanie programu, gdy zwraca ona wartość NULL. Zgodnie z konwencja nazewniczą, ten ogólny interfejs programowy zwany jest przez projektantów xmalloc(): /* działa jak malloc(), lecz kończy wykonywanie programu w przypadku niepowodzenia */ void * xmalloc (size_t size) { void *p; p = malloc (size); if (!p) { perror ( xmalloc ); exit (EXIT_FAILURE); } return p; } Przydzielanie pamięci dla tablic Dynamiczne przydzielanie pamięci może być skomplikowane, jeśli rozmiar danych, przeka- zany w parametrze size, jest również zmienny. Jednym z tego typu przykładów jest dynamiczne przydzielanie pamięci dla tablic, których rozmiar jednego elementu może być stały, lecz liczba alokowanych elementów jest zmienna. Aby uprościć wykonywanie tej czynności, język C udostępnia funkcję calloc(): #include stdlib.h void * calloc (size_t nr, size_t size); Poprawne wywołanie funkcji calloc() zwraca wskaźnik do bloku pamięci o wielkości wy- starczającej do przechowania tablicy o liczbie elementów określonej w parametrze nr. Każdy z elementów posiada rozmiar size. Zgodnie z tym ilość pamięci, przydzielona w przypadku użycia zarówno funkcji malloc(), jak i calloc(), jest taka sama (obie te funkcje mogą zwrócić więcej pamięci, niż jest to wymagane, lecz nigdy mniej): int *x, *y; x = malloc (50 * sizeof (int)); 4 Funkcje niezadeklarowane zwracają domyślnie wartości o typie int. Rzutowanie liczby całkowitej na wskaźnik nie jest wykonywane automatycznie i powoduje powstanie ostrzeżenia podczas kompilacji programu. Użycie rzutowania typów nie pozwala na generowanie takiego ostrzeżenia. Przydzielanie pamięci dynamicznej | 265 if (!x) { perror ( malloc ); return -1; } y = calloc (50, sizeof (int)); if (!y) { perror ( calloc ); return -1; } Zachowanie powyższych dwóch funkcji nie jest jednak identyczne. W przeciwieństwie do funkcji malloc(), która nie zapewnia, jaka będzie zawartość przydzielonej pamięci, funkcja calloc() zeruje wszystkie bajty w zwróconym obszarze pamięci. Dlatego też każdy z 50 ele- mentów w tablicy liczb całkowitych y posiada wartość 0, natomiast wartości elementów tablicy x są niezdefiniowane. Dopóki w programie nie ma potrzeby natychmiastowego zainicjalizo- wania wszystkich 50 wartości, programiści powinni używać funkcji calloc(), aby zapewnić, że elementy tablicy nie są wypełnione przypadkowymi danymi. Należy zauważyć, że zero binarne może być różne od zera występującego w liczbie zmiennoprzecinkowej! Użytkownicy często chcą „wyzerować” pamięć dynamiczną, nawet wówczas, gdy nie używają tablic. W dalszej części tego rozdziału poddana analizie zostanie funkcja memset(), która dostar- cza interfejsu pozwalającego na ustawienie wartości dla dowolnego bajta w obszarze pamięci. Funkcja calloc() wykonuje jednak tę operację szybciej, gdyż jądro może od razu udostępnić obszar pamięci, który wypełniony jest już zerami. W przypadku błędu funkcja calloc(), podobnie jak malloc(), zwraca –1 oraz ustawia zmienną errno na wartość ENOMEM. Dlaczego w standardach nie zdefiniowano nigdy funkcji „przydziel i wyzeruj”, różnej od calloc(), pozostaje tajemnicą. Projektanci mogą jednak w prosty sposób zdefiniować swój wła- sny interfejs: /* działa tak samo jak funkcja malloc(), lecz przydzielona pamięć zostaje wypełniona zerami */ void * malloc0 (size_t size) { return calloc (1, size); } Można bez kłopotu połączyć funkcję malloc0() z poprzednio przedstawioną funkcją xmalloc(): /* działa podobnie jak malloc(), lecz wypełnia pamięć zerami i przerywa działanie programu w przypadku błędu */ void * xmalloc0 (size_t size) { void *p; p = calloc (1, size); if (!p) { perror ( xmalloc0 ); exit (EXIT_FAILURE); } return p; } 266 | Rozdział 8. Zarządzanie pamięcią Zmiana wielkości obszaru przydzielonej pamięci Język C dostarcza interfejsu pozwalającego na zmianę wielkości (zmniejszenie lub powiększe- nie) istniejącego obszaru przydzielonej pamięci: #include stdlib.h void * realloc (void *ptr, size_t size); Poprawne wywołanie funkcji realloc() zmienia rozmiar regionu pamięci, wskazywanego przez ptr, na nową wartość, której wielkość podana jest w parametrze size i wyrażona w baj- tach. Funkcja zwraca wskaźnik do obszaru pamięci posiadającego nowy rozmiar. Wskaźnik ten nie musi być równy wartości parametru ptr, który był używany w funkcji podczas wykony- wania operacji powiększenia rozmiaru obszaru. Jeśli funkcja realloc() nie potrafi powięk- szyć istniejącego obszaru pamięci poprzez zmianę rozmiaru dla wcześniej przydzielonego miejsca, wówczas może ona zarezerwować pamięć dla nowego regionu pamięci o rozmiarze size, wyrażonym w bajtach, skopiować zawartość poprzedniego regionu w nowe miejsce, a następnie zwolnić niepotrzebny już obszar źródłowy. W przypadku każdej operacji zacho- wana zostaje zawartość dla takiej wielkości obszaru pamięci, która równa jest mniejszej war- tości z dwóch rozmiarów: poprzedniego i aktualnego. Z powodu ewentualnego istnienia ope- racji kopiowania, wywołanie funkcji realloc(), które wykonuje powiększenie obszaru pamięci, może być stosunkowo kosztowne. Jeśli size wynosi zero, rezultat jest taki sam jak w przypadku wywołania funkcji free() z para- metrem ptr. Jeśli parametr ptr jest równy NULL, wówczas rezultat wykonania operacji jest taki sam jak dla oryginalnej funkcji malloc(). Jeśli wskaźnik ptr jest różny od NULL, powinien zostać zwró- cony przez wcześniejsze wykonanie jednej z funkcji malloc(), calloc() lub realloc(). W przypadku błędu, funkcja realloc() zwraca NULL oraz ustawia zmienną errno na wartość ENOMEM. Stan obszaru pamięci, wskazywanego przez parametr ptr, pozostaje niezmieniony. Rozważmy przykład programu, który zmniejsza obszar pamięci. Najpierw należy użyć funkcji calloc(), która przydzieli wystarczającą ilość pamięci, aby zapamiętać w niej dwuelementową tablicę struktur map: struct map *p; /* przydziel pamięć na dwie struktury map */ p = calloc (2, sizeof (struct map)); if (!p) { perror ( calloc ); return -1; } /* w tym momencie można używać p[0] i p[1]… */ Załóżmy, że jeden ze skarbów został już znaleziony, dlatego też nie ma potrzeby użycia drugiej mapy. Podjęto decyzję, że rozmiar obszaru pamięci zostanie zmieniony, a połowa przydzielo- nego wcześniej regionu zostanie zwrócona do systemu (operacja ta nie byłaby właściwie zbyt potrzebna, chyba że rozmiar struktury map byłby bardzo duży, a program rezerwowałby dla niej pamięć przez dłuższy czas): Przydzielanie pamięci dynamicznej | 267 struct map *r; /* obecnie wymagana jest tylko pamięć dla jednej mapy */ r = realloc (p, sizeof (struct map)); if (!r) { /* należy zauważyć, że p jest wciąż poprawnym wskaźnikiem! */ perror ( realloc ); return -1; } /* tu można już używać wskaźnika r … */ free (r); W powyższym przykładzie, po wywołaniu funkcji realloc() zostaje zachowany element p[0]. Jakiekolwiek dane, które przedtem znajdowały się w tym elemencie, będą obecne również teraz. Jeśli wywołanie funkcji się nie powiedzie, należy zwrócić uwagę na to, że wskaźnik p nie zostanie zmieniony i stąd też będzie wciąż poprawny. Można go ciągle używać i w końcu należy go zwolnić. Jeśli wywołanie funkcji się powiedzie, należy zignorować wskaźnik p i zamiast niego użyć r (który jest przypuszczalnie równy p, gdyż najprawdopodobniej nastąpiła zmiana rozmiaru aktualnie przydzielonego obszaru). Obecnie programista odpowiedzialny będzie za zwolnienie pamięci dla wskaźnika r, gdy tylko przestanie on być potrzebny. Zwalnianie pamięci dynamicznej W przeciwieństwie do obszarów pamięci przydzielonych automatycznie, które same zostają zwolnione, gdy następuje przesunięcie wskaźnika stosu, dynamicznie przydzielone regiony pamięci pozostają trwałą częścią przestrzeni adresowej procesu, dopóki nie zostaną ręcznie zwolnione. Dlatego też programista odpowiedzialny jest za zwolnienie do systemu dynamicz- nie przydzielonej pamięci (oba rodzaje przydzielonej pamięci — statyczna i dynamiczna — zostają zwolnione, gdy cały proces kończy swoje działanie). Pamięć, przydzielona za pomocą funkcji malloc(), calloc() lub realloc(), musi zostać zwol- niona do systemu, jeśli nie jest już więcej używana. W tym celu stosuje się funkcję free(): #include stdlib.h void free (void *ptr); Wywołanie funkcji free() zwalnia pamięć, wskazywaną przez wskaźnik ptr. Parametr ptr powinien być zainicjalizowany przez wartość zwróconą wcześniej przez funkcję malloc(), calloc() lub realloc(). Oznacza to, że nie można użyć funkcji free(), aby zwolnić fragment obszaru pamięci — na przykład połowę — poprzez przekazanie do niej parametru wskazującego na środek wcześniej przydzielonego obszaru. Wskaźnik ptr może być równy NULL, co powoduje, że funkcja free() od razu wraca do procesu wywołującego. Dlatego też niepotrzebne jest sprawdzanie wskaźnika ptr przed wywołaniem funkcji free(). Oto przykład użycia funkcji free(): void print_chars (int n, char c) { int i; for (i = 0; i n; i++) 268 | Rozdział 8. Zarządzanie pamięcią { char *s; int j; /* * Przydziel i wyzeruj tablicę znaków o liczbie elementów równej i+2. * Należy zauważyć, że wywołanie sizeof (char) zwraca zawsze wartość 1. */ s = calloc (i + 2, 1); if (!s) { perror ( calloc ); break; } for (j = 0; j i + 1; j++) s[j] = c; printf ( s , s); /* Wszystko zrobione, obecnie należy zwolnić pamięć. */ free (s); } } Powyższy przykład przydziela pamięć dla n tablic typu char, zawierających coraz większą liczbę elementów, poczynając od dwóch (2 bajty), a kończąc na n + 1 elementach (n + 1 baj- tów). Wówczas dla każdej tablicy następuje w pętli zapisanie znaku c do poszczególnych jej elementów, za wyjątkiem ostatniego (pozostawiając tam bajt o wartości 0, który jednocześnie jest ostatnim w danej tablicy), wyprowadzenie zawartości tablicy w postaci łańcucha znaków, a następnie zwolnienie przydzielonej dynamicznie pamięci. Wywołanie funkcji print_chars() z parametrami n równym 5, a c równym X, wymusi uzy- skanie następującego wyniku: X XX XXX XXXX XXXXX Istnieją oczywiście dużo efektywniejsze metody pozwalające na zaimplementowanie takiej funkcji. Ważne jest jednak, że pamięć można dynamicznie przydzielać i zwalniać, nawet wów- czas, gdy rozmiar i liczba przydzielonych obszarów znana jest tylko w momencie działania programu. Systemy uniksowe, takie jak SunOS i SCO, udostępniają własny wariant funkcji free(), zwany cfree(), który w zależności od systemu działa tak samo jak free() lub posiada trzy parametry i wówczas zachowuje się jak funkcja calloc(). Funkcja free() w sys- temie Linux może obsłużyć pamięć uzyskaną dzięki użyciu dowolnego mechanizmu, służącego do jej przydzielania i już omówionego. Funkcja cfree() nie powinna być używana, za wyjątkiem zapewnienia wstecznej kompatybilności. Wersja tej funkcji dla Linuksa jest identyczna z free(). Należy zauważyć, że gdyby w powyższym przykładzie nie użyto funkcji free(), pojawiłyby się pewne następstwa tego. Program mógłby nigdy nie zwolnić zajętego obszaru do systemu i co gorsze, stracić swoje jedyne odwołanie do pamięci — wskaźnik s — i przez to spowodo- wać, że dostęp do niej stałby się w ogóle niemożliwy. Ten rodzaj błędu programistycznego Przydzielanie pamięci dynamicznej | 269 zwany jest wyciekaniem pamięci (ang. memory leak). Wyciekanie pamięci i tym podobne pomyłki, związane z pamięcią dynamiczną, są najczęstszymi i niestety najbardziej szkodliwymi błędami występującymi podczas programowania w języku C. Ponieważ język C zrzuca całą odpowie- dzialność za zarządzanie pamięcią na programistów, muszą oni zwracać szczególną uwagę na wszystkie przydzielone obszary. Równie często spotykaną pułapką języka C jest używanie zasobów po ich zwolnieniu. Problem ten występuje w momencie, gdy blok pamięci zostaje zwolniony, a następnie ponownie użyty. Gdy tylko funkcja free() zwolni dany obszar pamięci, program nie może już ponownie uży- wać jego zawartości. Programiści powinni zwracać szczególną uwagę na zawieszone wskaź- niki lub wskaźniki różne od NULL, które pomimo tego wskazują na niepoprawne obszary pamięci. Istnieją dwa powszechnie używane narzędzia pomagające w tych sytuacjach; są to Elec- tric Fence i valgrind5. Wyrównanie Wyrównanie danych dotyczy relacji pomiędzy ich adresem oraz obszarami pamięci udostęp- nianymi przez sprzęt. Zmienna posiadająca adres w pamięci, który jest wielokrotnością jej rozmiaru, zwana jest zmienną naturalnie wyrównaną. Na przykład, zmienna 32-bitowa jest natu- ralnie wyrównana, jeśli posiada adres w pamięci, który jest wielokrotnością 4 — oznacza to, że najniższe dwa bity adresu są równe zeru. Dlatego też typ danych, którego rozmiar wynosi 2n bajtów, musi posiadać adres, którego n najmniej znaczących bitów jest ustawionych na zero. Reguły, które dotyczą wyrównania, pochodzą od sprzętu. Niektóre architektury maszynowe posiadają bardzo rygorystyczne wymagania dotyczące wyrównania danych. W przypadku pewnych systemów, załadowanie danych, które nie są wyrównane, powoduje wygenerowanie pułapki procesora. Dla innych systemów dostęp do niewyrównanych danych jest bezpieczny, lecz związany z pogorszeniem sprawności działania. Podczas tworzenia kodu przenośnego należy unikać problemów związanych z wyrównaniem. Także wszystkie używane typy danych powinny być naturalnie wyrównane. Przydzielanie pamięci wyrównanej W większości przypadków kompilator oraz biblioteka języka C w sposób przezroczysty obsłu- gują zagadnienia, związane z wyrównaniem. POSIX definiuje, że obszar pamięci, zwracany w wyniku wykonania funkcji malloc(), calloc() oraz realloc(), musi być prawidłowo wyrównany dla każdego standardowego typu danych języka C. W przypadku Linuksa funkcje te zawsze zwracają obszar pamięci, która wyrównana jest do adresu będącego wielokrotnością ośmiu bajtów w przypadku systemów 32-bitowych oraz do adresu, będącego wielokrotnością szesnastu bajtów dla systemów 64-bitowych. Czasami programiści żądają przydzielenia takiego obszaru pamięci dynamicznej, który wyrów- nany jest do większego rozmiaru, posiadającego na przykład wielkość strony. Mimo istnienia różnych argumentacji, najbardziej podstawowym wymaganiem jest zdefiniowanie prawidłowo wyrównanych buforów, używanych podczas bezpośrednich operacji blokowych wejścia i wyj- ścia lub innej komunikacji między oprogramowaniem a sprzętem. W tym celu POSIX 1003.1d udostępnia funkcję zwaną posix_memalign(): 5 Znajdują się one odpowiednio w następujących miejscach: http://perens.com/FreeSoftware/ElectricFence/ oraz http://valgrind.org. 270 | Rozdział 8. Zarządzanie pamięcią /* należy użyć jednej z dwóch poniższych definicji - każda z nich jest odpowiednia */ #define _XOPEN_SOURCE 600 #define _GNU_SOURCE #include stdlib.h int posix_memalign (void **memptr, size_t alignment, size_t size); Poprawne wywołanie funkcji posix_memalign() przydziela pamięć dynamiczną o rozmiarze przekazanym w parametrze size i wyrażonym w bajtach, zapewniając jednocześnie, że obszar ten zostanie wyrównany do adresu pamięci, będącego wielokrotnością parametru alignment. Parametr alignment musi być potęgą liczby 2 oraz wielokrotnością rozmiaru wskaźnika void. Adres przydzielonej pamięci zostaje umieszczony w parametrze memptr, a funkcja zwraca zero. W przypadku błędu nie następuje przydzielenie pamięci, parametr memptr ma wartość nieokre- śloną, a funkcja zwraca jedną z poniższych wartości kodów błędu: EINVAL Parametr alignment nie jest potęgą liczby 2 lub wielokrotnością rozmiaru wskaźnika void. ENOMEM Nie ma wystarczającej ilości pamięci, aby dokończyć rozpoczętą operację przydzielania pamięci. Należy zauważyć, że zmienna errno nie zostaje ustawiona — funkcja bezpośrednio zwraca kod błędu. Obszar pamięci, uzyskany za pomocą funkcji posix_memalign(), może zostać zwolniony przy użyciu free(). Sposób użycia funkcji jest prosty: char *buf; int ret; /* przydziel 1 kB pamięci wyrównanej do adresu równego wielokrotności 256 bajtów */ ret = posix_memalign ( buf, 256, 1024); if (ret) { fprintf (stderr, posix_memalign: s , strerror (ret)); return -1; } /* tu można używać pamięci, wskazywanej przez buf … */ free (buf); Starsze interfejsy. Zanim w standardzie POSIX została zdefiniowana funkcja posix_memalign(), systemy BSD oraz SunOS udostępniały odpowiednio następujące interfejsy: #include malloc.h void * valloc (size_t size); void * memalign (size_t boundary, size_t size); Funkcja valloc() działa identycznie jak malloc(), za wyjątkiem tego, że przydzielona pamięć jest wyrównana do rozmiaru strony. Jak napisano w rozdziale 4., rozmiar systemowej strony można łatwo uzyskać po wywołaniu funkcji getpagesize(). Funkcja memalign() jest podobna, lecz wyrównuje przydzieloną pamięć do rozmiaru przeka- zanego w parametrze boundary i wyrażonego w bajtach. Rozmiar ten musi być potęgą liczby 2. W poniższym przykładzie obie wspomniane funkcje alokacyjne zwracają blok pamięci o wiel- kości wystarczającej do przechowania struktury ship. Jest on wyrównany do rozmiaru strony: Przydzielanie pamięci dynamicznej | 271 struct ship *pirate, *hms; pirate = valloc (sizeof (struct ship)); if (!pirate) { perror ( valloc ); return -1; } hms = memalign (getpagesize ( ), sizeof (struct ship)); if (!hms) { perror ( memalign ); free (pirate); return -1; } /* tu można używać obszaru pamięci wskazywanego przez pirate i hms … */ free (hms); free (pirate); W przypadku systemu Linux obszar pamięci, otrzymany za pomocą tych dwóch funkcji, może zostać zwolniony po wywołaniu funkcji free(). Nie musi tak być jednak w przypadku innych systemów uniksowych, gdyż niektóre z nich nie dostarczają żadnego mechanizmu pozwala- jącego na bezpieczne zwolnienie pamięci przydzielonej za pomocą wyżej wspomnianych funkcji. Dla programów, które powinny być przenośne, może nie istnieć inny wybór poza niezwalnia- niem pamięci przydzielonej za pomocą tych interfejsów! Programiści Linuksa powinni używać powyższych funkcji tylko wtedy, gdy należy zachować kompatybilność ze starszymi systemami; funkcja posix_memalign() jest lepsza. Użycie trzech wspomnianych funkcji jest niezbędne jedynie wtedy, gdy wymagany jest inny rodzaj wyrów- nania, niż dostarczony razem z funkcją malloc(). Inne zagadnienia związane z wyrównaniem Problemy związane z wyrównaniem obejmują większy obszar zagadnień niż tylko wyrównanie naturalne dla standardowych typów danych oraz dynamiczny przydział pamięci. Na przykład, typy niestandardowe oraz złożone posiadają bardziej skomplikowane wymagania niż typy standardowe. Ponadto, zagadnienia związane z wyrównaniem są szczególnie ważne w przy- padku przypisywania wartości między wskaźnikami różnych typów oraz użycia rzutowania. Typy niestandardowe. Niestandardowe i złożone typy danych posiadają większe wymagania dotyczące wyrównania przydzielonego obszaru pamięci. Zachowanie zwykłego wyrównania naturalnego nie jest wystarczające. W tych przypadkach stosuje się cztery poniższe reguły: • Wyrównanie dla struktury jest równe wyrównaniu dla największego pod względem roz- miaru typu danych, z których zbudowane są jej pola. Na przykład, jeśli największy typ danych w strukturze jest 32-bitową liczbą całkowitą, która jest wyrównana do adresu będą- cego wielokrotnością czterech bajtów, wówczas sama struktura musi być także wyrównana do adresu będącego wielokrotnością co najmniej czterech bajtów. • Użycie struktur wprowadza także konieczność stosowania wypełnienia, które jest wyko- rzystywane w celu zapewnienia, że każdy typ składowy będzie poprawnie wyrównany, zgodnie z jego wymaganiami. Dlatego też, jeśli po polu posiadającym typ char (o wyrówna- niu prawdopodobnie równym jednemu bajtowi) pojawi się pole z typem int (posiadające 272 | Rozdział 8. Zarządzanie pamięcią wyrównanie prawdopodobnie równe czterem bajtom), wówczas kompilator wstawi dodat- kowe trzy bajty wypełnienia pomiędzy tymi dwoma polami o różnych typach danych, aby zapewnić, że int znajdzie się w obszarze wyrównanym do wielokrotności czterech bajtów. Programiści czasami porządkują pola w strukturze — na przykład, według male- jącego rozmiaru typów składowych — aby zminimalizować obszar pamięci „tracony” na wypełnienie. Opcja kompilatora GCC, zwana -Wpadded, może pomóc w tym przypadku, ponieważ generuje ostrzeżenie w momencie, gdy kompilator wstawia domyślne wypełnienia. • Wyrównanie dla unii jest równe wyrównaniu dla największego pod względem rozmiaru typu danych, z których zbudowane są jej pola. • Wyrównanie dla tablicy jest równe wyrównaniu dla jej podstawowego typu danych. Dla- tego też wymagania dla tablic są równe wymaganiu dotyczącemu pojedynczego elementu, z których się składają tablice. Zachowanie to powoduje, że wszystkie elementy tablicy posiadają wyrównanie naturalne. Działania na wskaźnikach. Ponieważ kompilator w sposób przezroczysty obsługuje większość żądań związanych z wyrównaniem, dlatego też, aby doświadczyć ewentualnych problemów, wymagany jest większy wysiłek. Mimo to jest nieprawdą, że nie istnieją komplikacje związane z wyrównaniem, gdy używa się wskaźników i rzutowania. Dostęp do danych poprzez rzutowanie wskaźnika z bloku pamięci o mniejszej wartości wyrów- nania na blok, posiadający większą wartość wyrównania, może spowodować, że dane te nie będą właściwie wyrównane dla typu o większym rozmiarze. Na przykład, przypisanie zmiennej c do badnews w poniższym fragmencie kodu powoduje, że zmienna ta będzie zrzutowana na typ unsigned long: char greeting[] = Ahoj Matey ; char *c = greeting[1]; unsigned long badnews = *(unsigned long *) c; Typ unsigned long jest najprawdopodobniej wyrównany do adresu będącego wielokrotnością ośmiu bajtów; zmienna c prawie na pewno przesunięta jest o 1 bajt poza tę granicę. Odczytanie zmiennej c podczas wykonywania rzutowania spowoduje powstanie błędu wyrównania. W zależności od architektury może być to przyczyną różnych zachowań, poczynając od mniej ważnych, np. pogorszenie sprawności działania, a kończąc na poważnych, jak załamanie pro- gramu. W architekturach maszynowych, które potrafią wykryć, lecz nie mogą poprawnie obsłu- żyć błędów wyrównania, jądro wysyła do takich niepoprawnych procesów sygnał SIGBUS, który przerywa ich działanie. Sygnały zostaną omówione w rozdziale 9. Przykłady podobne do powyższego są częściej spotykane, niż sądzimy. Niepoprawne konstruk- cje programowe, spotykane w świecie realnym, nie będą wyglądać tak bezmyślnie, lecz będą najprawdopodobniej trudniejsze do wykrycia. Zarządzanie segmentem danych Od zawsze system Unix udostępniał interfejsy pozwalające na bezpośrednie zarządzanie seg- mentem danych. Jednak większość programów nie posiada bezpośredniego dostępu do tych interfejsów, ponieważ funkcja malloc() i inne sposoby przydzielania pamięci są łatwiejsze w użyciu, a jednocześnie posiadają większe możliwości. Interfejsy te zostaną jednak omówione, Zarządzanie segmentem danych | 273 aby zaspokoić ciekawość czytelników i udostępnić dociekliwym programistom metodę pozwa- lającą na zaimplementowanie swojego własnego mechanizmu przydzielania pamięci, opartego na stercie: #include unistd.h int brk (void *end); void * sbrk (intptr_t increment); Funkcje te dziedziczą swoje nazwy z dawnych systemów uniksowych, dla których sterta i stos znajdowały się w tym samym segmencie. Przydzielanie obszarów pamięci dynamicznej na stercie powoduje jej narastanie od dolnej części segmentu, w kierunku adresów wyższych; stos rośnie w kierunku przeciwnym — od szczytu segmentu do niższych adresów. Linia gra- niczna pomiędzy tymi dwoma strukturami danych zwana jest podziałem lub punktem podziału (ang. break lub break point). W nowoczesnych systemach operacyjnych, w których segment danych posiada swoje własne odwzorowanie pamięci, końcowy adres tego odwzorowania w dalszym ciągu zwany jest punktem podziału. Wywołanie funkcji brk() ustawia punkt podziału (koniec segmentu danych) na adres przeka- zany w parametrze end. W przypadku sukcesu, funkcja zwraca wartość 0. W przypadku błędu, zwraca –1 oraz ustawia zmienną errno na ENOMEM. Wywołanie funkcji sbrk() zwiększa adres końca segmentu o wartość przekazaną w parame- trze increment, który może być przyrostem dodatnim lub ujemnym. Funkcja sbrk() zwraca uaktualnioną wartość położenia punktu podziału. Dlatego też użycie parametru increment równego zeru powoduje wyprowadzenie aktualnej wartości położenia punktu podziału: printf ( Aktualny punkt podziału posiada adres p , sbrk (0)); Oba standardy — POSIX i C — celowo nie definiują żadnej z powyższych funkcji. Prawie wszystkie systemy uniksowe wspierają jednak jedną lub obie te funkcje. Programy przenośne powinny używać interfejsów zdefiniowanych w standardach. Anonimowe odwzorowania w pamięci W celu wykonania operacji przydzielania pamięci, zaimplementowanej w bibliotece glibc, uży- wany jest segment danych oraz odwzorowania pamięci. Klasyczną metodą, zastosowaną w celu implementacji funkcji malloc(), jest podział segmentu danych na ciąg partycji o roz- miarach potęgi liczby 2 oraz zwracanie tego obszaru, który najlepiej pasuje do żądanej wiel- kości. Zwalnianie pamięci jest prostym oznaczaniem, że dana partycja jest „wolna”. Kiedy graniczące ze sobą partycje są nieużywane, mogą zostać połączone w jeden większy obszar pamięci. Jeśli szczyt sterty jest zupełnie nieprzydzielony, system może użyć funkcji brk(), aby obniżyć adres położenia punktu podziału, a przez to zmniejszyć rozmiar tej struktury danych i zwrócić pamięć do jądra. Algorytm ten zwany jest schematem przydziału wspieranej pamięci (ang. buddy memory allocation scheme). Posiada takie zalety jak prędkość i prostota, ale również wady w postaci dwóch rodza- jów fragmentacji. Fragmentacja wewnętrzna (ang. internal fragmentation) występuje wówczas, gdy więcej pamięci, niż zażądano, zostanie użyte w celu wykonania operacji przydziału. Wynikiem tego jest nieefektywne użycie dostępnej pamięci. Fragmentacja zewnętrzna (ang. external fragmen- tation) występuje wówczas, gdy istnieje wystarczająca ilość pamięci, aby zapewnić wykonanie operacji przydziału, lecz jest ona podzielona na dwa lub więcej niesąsiadujących ze sobą frag- 274 | Rozdział 8. Zarządzanie pamięcią mentów. Fragmentacja ta może powodować nieefektywne użycie pamięci (ponieważ może zo- stać użyty większy, mniej pasujący blok) lub niepoprawne wykonanie operacji jej przydziału (jeśli nie ma innych bloków). Ponadto, schemat ten pozwala, aby pewien przydzielony obszar mógł „unieruchomić” inny, co może spowodować, że biblioteka glibc nie będzie mogła zwrócić zwolnionej pamięci do jądra. Załóżmy, że istnieją dwa przydzielone obszary pamięci: blok A i blok B. Blok A znajduje się dokładnie w punkcie podziału, a blok B zaraz pod nim. Nawet jeśli program zwolni blok B, biblioteka glibc nie będzie mogła uaktualnić położenia punktu podziału, dopóki blok A również nie zostanie zwolniony. W ten sposób aplikacje, których czas życia w systemie jest długi, mogą unieruchomić wszystkie inne przydzielone obszary pamięci. Nie zawsze jest to problemem, gdyż biblioteka glibc nie zwraca w sposób rutynowy pamięci do systemu6. Sterta zazwyczaj nie zostaje zmniejszona po każdej operacji zwolnienia pamięci. Zamiast tego biblioteka glibc zachowuje zwolnioną pamięć, aby użyć jej w następnej operacji przydzielania. Tylko wówczas, gdy rozmiar sterty jest znacząco większy od ilości przydzielonej pamięci, biblioteka glibc faktycznie zmniejsza wielkość segmentu danych. Przydział dużej ilości pamięci może jednak przeszkodzić temu zmniejszeniu. Zgodnie z tym, w przypadku przydziałów dużej ilości pamięci, w bibliotece glibc nie jest uży- wana sterta. Biblioteka glibc tworzy anonimowe odwzorowanie w pamięci, aby zapewnić poprawne wykonanie żądania przydziału. Anonimowe odwzorowania w pamięci są podobne do odwzo- rowań dotyczących plików i omówionych w rozdziale 4., za wyjątkiem tego, że nie są zwią- zane z żadnym plikiem — stąd też przydomek „anonimowy”. Takie anonimowe odwzorowanie jest po prostu dużym blokiem pamięci, wypełnionym zerami i gotowym do użycia. Należy traktować go jako nową stertę używaną wyłącznie w jednej operacji przydzielania pamięci. Ponieważ takie odwzorowania są umieszczane poza stertą, nie przyczyniają się do fragmentacji segmentu danych. Przydzielanie pamięci za pomocą anonimowych odwzorowań ma kilka zalet: • Nie występuje fragmentacja. Gdy program nie potrzebuje już anonimowego odwzorowania w pamięci, jest ono usuwane, a pamięć zostaje natychmiast zwrócona do systemu. • Można zmieniać rozmiar anonimowych odwzorowań w pamięci, posiadają one modyfi- kowane uprawnienia, a także mogą otrzymywać poradę — podobnie, jak ma to miejsce w przypadku zwykłych odwzorowań (szczegóły w rozdziale 4.). • Każdy przydział pamięci realizowany jest w oddzielnym odwzorowaniu. Nie ma potrzeby użycia globalnej sterty. Istnieją również wady używania anonimowych odwzorowań w pamięci, w porównaniu z uży- ciem sterty: • Rozmiar każdego odwzorowania w pamięci jest całkowitą wielokrotnością rozmiaru strony systemowej. Zatem takie operacje przydziałów, dla których rozmiary nie są całkowitą wie- lokrotnością rozmiaru strony, generują powstawanie nieużywanych obszarów „wolnych”. Problem przestrzeni wolnej dotyczy głównie małych obszarów przydziału, dla których pamięć nieużywana jest stosunkowo duża w porównaniu z rozmiarem przydzielonego bloku. 6 W celu przydzielania pamięci, biblioteka glibc używa również dużo bardziej zaawansowanego algorytmu niż zwykłego schematu przydziału wspieranej pamięci. Algorytm ten zwany jest algorytmem areny (ang. arena algorithm). Anonimowe odwzorowania w pamięci | 275 • Tworzenie nowego odwzorowania w pamięci wymaga większego nakładu pracy niż zwra- canie pamięci ze sterty, które może w ogóle nie obciążać jądra. Im obszar przydziału jest mniejszy, tym to zjawisko jest bardziej widoczne. Porównując zalety i wady, można stwierdzić, że funkcja malloc() w bibliotece glibc używa segmentu danych, aby zapewnić poprawne wykonanie operacji przydziału niewielkich obszarów, natomiast anonimowych odwzorowań w pamięci, aby zapewnić przydzielenie dużych obszarów. Próg działania jest konfigurowalny (szczegóły w podrozdziale Zaawan- sowane operacje przydziału pamięci, znajdującym się w dalszej części tego rozdziału) i może być inny dla każdej wersji biblioteki glibc. Obecnie próg wynosi 128 kB: operacje przydziału o obszarach mniejszych lub równych 128 kB używają sterty, natomiast większe przydziały korzystają z anonimowych odwzorowań w pamięci. Tworzenie anonimowych odwzorowań w pamięci Wymuszenie użycia mechanizmu odwzorowania w pamięci zamiast wykorzystania sterty w celu wykonania określonego przydziału, kreowanie własnego systemu zarządzającego przy- działem pamięci, ręczne tworzenie anonimowego odwzorowania w pamięci — te wszystkie operacje są łatwe do zrealizowania w systemie Linux. W rozdziale 4. napisano, że odwzoro- wanie w pamięci może zostać utworzone przez funkcję systemową mmap(), natomiast usunięte przez funkcję systemową munmap(): #include sys/mman.h void * mmap (void *start, size_t length, int prot, int flags, int fd, off_t offset); int munmap (void *start, size_t length); Kreowanie anonimowego odwzorowania w pamięci jest nawet prostsze niż tworzenie odwzo- rowania opartego na pliku, ponieważ nie trzeba tego pliku otwierać i nim zarządzać. Podsta- wową różnicą między tymi dwoma rodzajami odwzorowania jest specjalny znacznik, wska- zujący, że dane odwzorowanie jest anonimowe. Oto przykład: void *p; p = mmap (NULL, /* nieważne, w jakim miejscu pamięci */ 512 * 1024, /* 512 kB */ PROT_READ | PROT_WRITE, /* zapis/odczyt */ MAP_ANONYMOUS | MAP_PRIVATE, /* odwzorowanie anonimowe i prywatne */ -1, /* deskryptor pliku (ignorowany) */ 0); /* przesunięcie (ignorowane) */ if (p == MAP_FAILED) perror ( mmap ); else /* p wskazuje na obszar 512 kB anonimowej pamięci… */ W większości anonimowych odwzorowań parametry funkcji mmap() są takie same jak w powyż- szym przykładzie, oczywiście za wyjątkiem rozmiaru, przekazanego w parametrze length i wyrażonego w bajtach, który jest określany przez programistę. Pozostałe parametry są nastę- pujące: • Pierwszy parametr, start, ustawiony jest na wartość NULL, co oznacza, że anonimowe odwzorowanie może rozpocząć się w dowolnym miejscu w pamięci — decyzja w tym przypadku należy do jądra. Podawanie wartości różnej od NULL jest dopuszczalne, dopóki 276 | Rozdział 8. Zarządzanie pamięcią jest ona wyrównana do wielkości strony, lecz ogranicza to przenośność. Położenie odwzo- rowania jest rzadko wykorzystywane przez programy. • Parametr prot zwykle ustawia oba bity PROT_READ oraz PROT_WRITE, co powoduje, że odwzorowanie posiada uprawienia do odczytu i zapisu. Odwzorowanie bez uprawnień nie ma sensu, gdyż nie można z niego czytać ani do niego zapisywać. Z drugiej strony, zezwo- lenie na wykonywanie kodu z anonimowego odwzorowania jest rzadko potrzebne, a jed- nocześnie tworzy potencjalną lukę bezpieczeństwa. • Parametr flags ustawia bit MAP_ANONYMOUS, który oznacza, że odwzorowanie jest anoni- mowe, oraz bit MAP_PRIVATE, który nadaje odwzorowaniu status prywatności. • Parametry fd i offset są ignorowane, gdy ustawiony jest znacznik MAP_ANONYMOUS. Nie- które starsze systemy oczekują jednak, że w parametrze fd zostanie przekazana wartość –1, dlatego też warto to uczynić, gdy ważnym czynnikiem jest przenośność. Pamięć, otrzymana za pomocą mechanizmu anonimowego odwzorowania, wygląda tak samo jak pamięć ze sterty. Jedną korzyścią z użycia anonimowego odwzorowania jest to, że strony są już wypełnione zerami. Jest to wykonywane bez jakichkolwiek kosztów, ponieważ jądro odwzorowuje anonimowe strony aplikacji na stronę wypełnioną zerami, używając do tego celu mechanizmu kopiowania podczas zapisu. Dlatego też nie jest wymagane użycie funkcji memset() dla zwróconego obszaru pamięci. Faktycznie istnieje jedna korzyść z użycia funkcji calloc() zamiast zestawu malloc()oraz memset(): biblioteka glibc jest poinformowana, że obszar anonimowego odwzorowania jest już wypełniony zerami, a funkcja calloc(), po popraw- nym przydzieleniu pamięci, nie wymaga jawnego jej zerowania. Funkcja systemowa munmap() zwalnia anonimowe odwzorowanie, zwracając przydzieloną pamięć do jądra: int ret; /* wykonano wszystkie działania, związane z użyciem wskaźnika p , dlatego należy zwrócić 512 kB pamięci */ ret = munmap (p, 512 * 1024); if (ret) perror ( munmap ); Szczegóły użycia funkcji mmap(), munmap() oraz ogólny opis mechanizmu odwzorowania znajdują się w rozdziale 4. Odwzorowanie pliku /dev/zero Inne systemy operacyjne, takie jak BSD, nie posiadają znacznika MAP_ANONYMOUS. Zamiast tego zaimplementowane jest dla nich podobne rozwiązanie, przy użyciu odwzorowania specjalnego pliku urządzenia /dev/zero. Ten plik urządzenia dostarcza takiej samej semantyki jak anonimowa pamięć. Odwzorowanie zawiera strony uzyskane za pomocą mechanizmu kopiowania podczas zapisu, wypełnione zerami; dlatego też zachowanie to jest takie samo jak w przypadku anoni- mowej pamięci. Linux zawsze posiadał urządzenie /dev/zero oraz udostępniał możliwość odwzorowania tego pliku i uzyskania obszaru pamięci wypełnionego zerami. Rzeczywiście, zanim wprowadzono znacznik MAP_ANONYMOUS, programiści w Linuksie używali powyższego rozwiązania. Aby Anonimowe odwzorowania w pamięci | 277 zapewnić wsteczną kompatybilność ze starszymi wersjami Linuksa lub przenośność do innych systemów Uniksa, projektanci w dalszym ciągu mogą używać pliku urządzenia /dev/zero, aby stworzyć anonimowe odwzorowanie. Operacja ta nie różni się od tworzenia odwzorowania dla innych plików: void *p; int fd; /* otwórz plik /dev/zero do odczytu i zapisu */ fd = open ( /dev/zero , O_RDWR); if (fd 0) { perror ( open ); return -1; } /* odwzoruj obszar [0, rozmiar strony) dla urządzenia /dev/zero */ p = mmap (NULL, /* nieważne, w jakim miejscu pamięci */ getpagesize ( ), /* odwzoruj jedną stronę */ PROT_READ | PROT_WRITE, /* uprawnienia odczytu i zapisu */ MAP_PRIVATE, /* odwzorowanie prywatne */ fd, /* odwzoruj plik /dev/zero */ 0); /* bez przesunięcia */ if (p == MAP_FAILED) { perror ( mmap ); if (close (fd)) perror ( close ); return -1; } /* zamknij plik /dev/zero, jeśli nie jest już potrzebny */ if (close (fd)) perror ( close ); /* wskaźnik p wskazuje na jedną stronę w pamięci, można go używać… */ Pamięć, otrzymana za pomocą powyżej przedstawionego sposobu, może oczywiście zostać zwolniona przy użyciu funkcji munmap(). Ta metoda generuje dodatkowe obciążenie przez użycie funkcji systemowej, otwierającej i zamy- kającej plik urządzenia. Dlatego też wykorzystanie pamięci anonimowej jest rozwiązaniem szybszym. Zaawansowane operacje przydziału pamięci Wiele operacji przydziału pamięci, omówionych w tym rozdziale, jest ograniczanych i stero- wanych przez parametry jądra, które mogą zostać modyfikowane przez programistę. Aby to wykonać, należy użyć funkcji mallopt(): #include malloc.h int mallopt (int param, int value); Wywołanie funkcji mallopt() ustawia parametr związany z zarządzaniem pamięcią, którego nazwa przekazana jest w argumencie param. Parametr ten zostaje ustawiony na wartość równą argumentowi value. W przypadku sukcesu funkcja zwraca wartość niezerową; w przypadku błędu zwraca 0. Należy zauważyć, że funkcja mallopt() nie ustawia zmiennej errno. Najczęściej 278 | Rozdział 8. Zarządzanie pamięcią jej wywołanie również kończy się sukcesem, dlatego też nie należy optymistycznie podchodzić do zagadnienia uzyskiwania użytecznej informacji z jej kodu powrotu. Linux wspiera obecnie sześć wartości dla parametru param, które zdefiniowane są w pliku nagłówkowym malloc.h : M_CHECK_ACTION Wartość zmiennej środowiskowej MALLOC_CHECK_ (omówiona w następnym podrozdziale). M_MMAP_MAX Maksymalna liczba odwzorowań, które mogą zostać udostępnione przez system, aby poprawnie zrealizować żądania przydzielania pamięci dynamicznej. Gdy to ograniczenie zostanie osiągnięte, wówczas dla kolejnych przydziałów pamięci zostanie użyty segment danych, dopóki jedno z odwzorowań nie zostanie zwolnione. Wartość 0 całkowicie unie- możliwia użycie mechanizmu anonimowych odwzorowań jako podstawy do wykonywania operacji przydziału pamięci dynamicznej. M_MMAP_THRESHOLD Wielkość progu (wyrażona w bajtach), powyżej którego żądanie przydziału pamięci zosta- nie zrealizowane za pomocą anonimowego odwzorowania zamiast udostępnienia seg- mentu danych. Należy zauważyć, że przydziały mniejsze od tego progu mogą również zostać zrealizowane za pomocą anonimowych odwzorowań, ze względu na swobodę postę- powania pozostawioną systemowi. Wartość 0 umożliwia użycie anonimowych odwzoro- wań dla wszystkich operacji przydziału, stąd też w rzeczywistości nie zezwala na wyko- rzystanie dla nich segmentu danych. M_MXFAST Maksymalny rozmiar (wyrażony w bajtach) podajnika szybkiego. Podajniki szybkie (ang. fast bins) są specjalnymi fragmentami pamięci na stercie, które nigdy nie zostają połączone z sąsiednimi obszarami i nie są zwrócone do systemu. Pozwala to na wykonywanie bardzo szybkich operacji przydziału, kosztem zwiększonej fragmentacji. Wartość 0 całkowicie uniemożliwia użycie podajników szybkich. M_TOP_PAD Wartość uzupełnienia (w bajtach) użytego podczas zmiany rozmiaru segmentu danych. Gdy biblioteka glibc wykonuje funkcję brk(), aby zwiększyć rozmiar segmentu danych, może zażyczyć sobie więcej pamięci, niż w rzeczywistości potrzebuje, w nadziei na to, że dzięki temu w najbliższej przyszłości nie będzie konieczne wykonanie kolejnego wywołania tejże funkcji. Podobnie dzieje się w przypadku, gdy biblioteka glibc zmniejsza rozmiar segmentu danych — zachowuje ona dla siebie pewną ilość pamięci, zwracając do systemu mniej, niż mogłaby naprawdę oddać. Ten dodatkowy obszar pamięci jest omawianym uzupełnieniem. Wartość 0 uniemożliwia całkowicie użycie wypełnienia. M_TRIM_THRESHOLD Minimalna ilość wolnej pamięci (w bajtach), która może istnieć na szczycie segmentu danych. Jeśli liczba ta będzie mniejsza od podanego progu, biblioteka glibc wywoła funkcję brk(), aby zwrócić pamięć do jądra. Standard XPG, który w luźny sposób definiuje funkcję mallopt(), określa trzy inne parametry: M_GRAIN, M_KEEP oraz M_NLBLKS. Linux również je definiuje, lecz ustawianie dla nich wartości nie powoduje żadnych zmian. W tabeli 8.1. znajduje się pełny opis wszystkich poprawnych parametrów oraz odpowiednich dla nich domyślnych wartości. Podane są również zakresy akceptowalnych wartości. Zaawansowane operacje przydziału pamięci | 279 Tabela 8.1. Parametry funkcji mallopt() Parametr M_CHECK_ACTION M_GRAIN M_KEEP M_MMAP_MAX Źródło pochodzenia Wartość domyślna Specyficzny dla Linuksa Standard XPG Standard XPG Specyficzny dla Linuksa 0 Brak wsparcia w Linuksie Brak wsparcia w Linuksie 64 * 1024 M_MMAP_THRESHOLD Specyficzny dla Linuksa 128 * 1024 M_MXFAST Standard XPG 64 = 0 = 0 = 0 = 0 0 – 80 Poprawne wartości 0 – 2 Wartości specjalne 0 uniemożliwia użycie mmap() 0 uniemożliwia użycie sterty 0 uniemożliwia użycie podajników szybkich 0 uniemożliwia użycie uzupełnienia M_NLBLKS M_TOP_PAD Standard XPG Specyficzny dla Linuksa Brak wsparcia w Linuksie 0 = 0 = 0 Dowolne wywołanie funkcji mallopt() w programach musi wystąpić przed pierwszym uży- ciem funkcji malloc() lub innych interfejsów, służących do przydzielania pamięci. Użycie jest proste: int ret; /* użyj funkcji mmap( ) dla wszystkich przydziałów pamięci większych od 64 kB */ ret = mallopt (M_MMAP_THRESHOLD, 64 * 1024); if (!ret) fprintf (stderr, Wywołanie funkcji mallopt() nie powiodło się! ); Dokładne dostrajanie przy użyciu funkcji malloc_usable_size() oraz malloc_trim() Linux dostarcza kilku funkcji, które pozwalają na niskopoziomową kontrolę działania systemu przydzielania pamięci dla biblioteki glibc. Pierwsza z tych funkcji pozwala na uzyskanie infor- macji, ile faktycznie dostępnych bajtów zawiera dany obszar przydzielonej pamięci: #include malloc.h size_t malloc_usable_size (void *ptr); Poprawne wywołanie funkcji malloc_usable_size() zwraca rzeczywisty roz
Pobierz darmowy fragment (pdf)

Gdzie kupić całą publikację:

Linux. Programowanie systemowe
Autor:

Opinie na temat publikacji:


Inne popularne pozycje z tej kategorii:


Czytaj również:


Prowadzisz stronę lub blog? Wstaw link do fragmentu tej książki i współpracuj z Cyfroteką: