Ufortyfikuj swoje podsystemy 57
3.
Na stadion zdolny pomieścić 50000 widzów można wejść jedną z (zaledwie) kilku bram, obsługiwanych łącznie przez kilkunastu (kilkudziesięciu) bileterów. Podobne „bramy” skrywa w sobie każdy system operacyjny — za ich pośrednictwem programy użytkowe korzystają z całego bogactwa usług jego podsystemów.
Weźmy jako przykład system plików: większość operacji plikowych sprowadza się do podstawowych czynności — otwierania, zamykania, tworzenia, odczytu, zapisu itp. Te elementarne operacje realizowane są jednak przez kod o znacznym stopniu złożoności, wykonujący skomplikowane zadania w rodzaju gospodarowania pamięcią dyskową, rozstrzygania konfliktów w warunkach wielodostępu czy też obsługi urządzeń zewnętrznych (np. drukarek) w sposób dla nich specyficzny. Złożoność ta nie jest jednak zmartwieniem programisty — jego interakcja z systemem plików ogranicza się do wspomnianych „bramek”, stanowiących „punkty wejścia” do podprogramów realizujących poszczególne usługi.
Nie mniej skomplikowanym podsystemem jest podsystem zarządzania pamięcią. Proste — z punktu widzenia użytkownika — operacje alokowania, zwalniania i zmiany rozmiaru przydzielonych bloków pamięci realizowane są przez procedury o znacznym stopniu złożoności, zwłaszcza w systemach wielozadaniowych. Z punktu widzenia użytkownika wszystko sprowadza się jednak do wywoływania właściwych procedur.
Punkty wejścia do usług systemowych stanowią doskonały przykład „wąskich gardeł” szczególnie nadających się (zgodnie z sugestiami zawartymi w poprzednim rozdziale) do kontrolowania poprawności wykonania, ściślej — do sprawdzania, czy odwołanie się do danej usługi dokonywane jest w sposób prawidłowy. Błędy związane z niewłaściwą obsługą pamięci bywają szczególnie uciążliwe, najczęściej bowiem objawiają się sporadycznie i samo doprowadzenie do ich powtórnego wystąpienia jest już nie lada sztuką. Oto przykład kilku — wziętych z życia — błędnych „zachowań” programu, które w konsekwencji skutkować mogą wspomnianymi błędami:
¨ odwoływanie się do przypadkowej zawartości nowo przydzielonego, nie zainicjowanego jeszcze bloku,
¨ odwoływanie się do zawartości zwolnionego bloku,
¨ wywołanie funkcji realloc dokonującej przemieszczenia bloku, a następnie odwoływanie się do poprzedniej instancji tego bloku,
¨ przydzielenie bloku i utrata dostępu do niego, z powodu niezapamiętania zwróconego wskaźnika,
¨ odczyt lub zapis poza granicami przydzielonego bloku.
Załóżmy teraz, iż ktoś zlecił Ci zadanie napisania funkcji malloc, free i realloc dla standardowej biblioteki C (w końcu ktoś kiedyś napisał te funkcje...); w pierwszym odruchu pomyślisz zapewne o asercjach, które mogłyby zapobiec (przynajmniej częściowo) opisanym patologiom, jednak po krótkim zastanowieniu stanie się oczywiste, iż asercje są w tej sytuacji bezsilne — opisane zachowania są nie do wykrycia z poziomu wspomnianych funkcji! Należy zatem pomyśleć o innych mechanizmach testowych.
Wypadałoby w tym miejscu zademonstrować rzeczywisty kod funkcji malloc, free i realloc wzbogacony wspomnianymi mechanizmami testowymi, nie zrobię tego jednak z dwóch powodów: po pierwsze zaciemniłoby to nieco klarowność treści książki, po drugie — nawet jeżeli producent używanego przez Ciebie kompilatora udostępnia kod źródłowy swej biblioteki, to kod ten może różnić się od tego, który ja posiadam, i który zaprezentowałbym tutaj. Zamiast więc ingerować w implementację funkcji systemowych, osiągniemy żądany efekt w sposób znacznie prostszy — skonstruujemy mianowicie funkcje-otoczki służące identycznym celom i właśnie w ich treści zaimplementujemy stosowne testy.
Rozpocznijmy od równoważnika funkcji malloc:
/* fNewMemory – przydziela blok pamięci */
flag fNewMemory(void **ppv, size_t size)
{
byte **ppb = (byte **)ppv;
*ppb = (byte *)malloc(size)
return (*ppb != NULL); /* Udało się ? */
}
Na pierwszy rzut oka funkcja fNewMemory wygląda na skomplikowaną — głównie za sprawą argumentu typu void ** — jednak jej wywołanie jest znacznie prostsze: zamiast standardowej konstrukcji
if ((pbBlock = (byte *)malloc(32)) != NULL
pbBlock wskazuje na przydzielony blok pamięci
else
blok nie został przydzielony – funkcja zwróciła NULL
można napisać
if (fNewMemory(&pbBlock, 32))
blok nie został przydzielony – pbBlock równy jest NULL
Ponadto wynik funkcji malloc niesie ze sobą dwojakiego rodzaju informacje: o przydzieleniu bądź nieprzydzieleniu bloku oraz adres (ewentualnie) przydzielonego bloku. Funkcja fNewMemory rozdziela te kategorie — jej wynik informuje tylko o statusie operacji („przydzielono — nie przydzielono”), sam zaś adres przydzielonego bloku przekazywany jest pod postacią parametru.
Zastanówmy się teraz, jak można by wzbogacić treść funkcji fNewMemory, by ułatwić wykrycie pierwszego z przytoczonej listy błędów — odwoływania się do przypadkowej zawartości nowo przydzielonego bloku. Wszelka „przypadkowość” jest wrogiem numer jeden skutecznego poszukiwania błędów, zatem funkcja fNewMemory mogłaby inicjować przydzielony blok wartością zerową. Wydaje się to posunięciem rozsądnym, w rzeczywistości jednak ma poważną wadę — mianowicie wykazuje tendencję do ukrywania błędów. Jeżeli (przykładowo) któreś z pól przydzielonej struktury powinno być inicjowane wartością zerową i programista czynność tę zaniedba, pozostanie to niezauważone, gdyż rzeczone pole od początku będzie miało zerową zawartość.
Samo zlikwidowanie „losowości” jest jednak niezłym pomysłem — w rezultacie wspaniałym kompromisem będzie następujące posunięcie: zainicjowanie zawartości przydzielonego bloku, lecz jakąś „neutralną” zawartością, w każdym razie nie zerem.
Moim zdaniem w programach przeznaczonych dla Macintosha wartością taką może być 0´A3. Jeżeli jakikolwiek fragment bloku zostanie zinterpretowany jako wskaźnik 0´A3A3, to przy odwołaniu do danych dwu- i czterobajtowych wygenerowany zostanie wyjątek. Z kolei próba wykonania rozkazu 0´A3A3 wygeneruje wyjątek „niezdefiniowanej pułapki na linii A”. Komputery PC nie wymagają wyrównywania wskaźników — Microsoft stosuje w swoich aplikacjach (na etapie testowania) wypełnianie przydzielanych bloków wartością 0´CC, co w przypadku próby wykonania bloku jako instrukcji spowoduje skierowanie wykonania do debuggera. Oto zmodyfikowana treść funkcji fNewMemory:
#define bGarbage 0xA3
ASSERT(ppv != NULL && size != 0);
*ppb = (byte *)malloc(size);
#ifdef DEBUG
if (*ppb != NULL)
memset(*ppb, bGarbage, size);
#endif
return (*ppb != NULL)
Dodatkowym warunkiem badanym w asercji jest niezerowy rozmiar żądanego przydziału — zgodnie bowiem ze standardami ANSI żądanie „zerowego” przydziału powoduje skutki nieokreślone.
Jeżeli teraz, testując program wykorzystujący funkcję fNewMemory, zobaczysz obszar wypełniony wzorcem 0´A3A3A3A3A3A3, będzie to z dużym prawdopodobieństwem obszar niezainicjowany.
Wyeliminuj losowe zachowania programu; nadaj pojawiającym się błędom cechę powtarzalności.
Zajmijmy się teraz zwalnianiem przydzielonej pamięci:
void FreeMemory(void *pv)
free(pv);
Zgodnie ze standardem ANSI, wywołanie funkcji free ze wskaźnikiem nie reprezentującym przydzielonego obszaru powoduje skutki nieokreślone, warto by więc przed wywołaniem funkcji free zbadać legalność wskaźnika pv.
Tylko jak?! Niestety, podsystem zarządzania pamięcią nie umożliwia tego. Zakładając jednak poprawność wskaźnika, stajemy przed problemem znacznie poważniejszym. Jeżeli mianowicie zwalniany obszar stanowi część większej struktury (np. węzeł drzewa) to po jego zwolnieniu wszystkie wskazujące na niego wskaźniki stają się bezużyteczne; jeżeli o tym zapomnimy i będziemy używać ich nadal, może się zdarzyć, iż w naszym drzewie jeden z węzłów będzie po prostu zwolnionym obszarem — czego konsekwencje objawić się mogą dość nieoczekiwanie.
Procedury zwalniające obszar mogą bowiem nie zmieniać jego zawartości (by nie tracić czasu na zbędne operacje), jeżeli jednak wypełnić ów obszar (przed wywołaniem funkcji free) jakimś charakterystycznym wzorcem, będzie się on po zwolnieniu odróżniał od „normalnego” węzła i próba jego dalszego używania ma dużą szansę na szybkie wykrycie.
Tylko jak?! Nawet jeżeli jakiś wskaźnik reprezentuje przydzielony obszar, nie mamy możliwości stwierdzenia, jaki jest rozmiar tego obszaru. Wygląda to beznadziejnie.
Jednak niezupełnie. Załóżmy tymczasowo istnienie funkcji sizeofBlock, która otrzymawszy wskaźnik do przydzielonego bloku zwraca rozmiar tego ostatniego; przy okazji przeprowadzana jest oczywiście weryfikacja poprawności rzeczonego wskaźnika — jeżeli nie wskazuje on na dynamicznie przydzielony obszar, funkcja sizeofBlock wypisuje stosowny komunikat (za pośrednictwem asercji). Funkcję taką nietrudno skonstruować, jeżeli dysponuje się kodem źródłowym podsystemu zarządzania pamięcią; nawet jednak przy braku źródeł można sobie nieźle poradzić, co niebawem udowodnię.
Zatem bezpośrednio przed zwolnieniem obszaru wypełnijmy go tym samym wzorcem, którego użyliśmy w funkcji fNewMemory:
ASSERT (pv != NULL);
memset(pv, bGarbage, sizeofBlock(pv));
Asercja badająca niezerowość wskaźnika pv nie jest tu konieczna — zgodnie ze standardem ANSI funkcja free wywołana z zerowym wskaźnikiem (jak parametrem) nie wykonuje żadnych czynności.
Osobiście jednak nie darzę zaufaniem przekazywania zerowego wskaźnika w sytuacji, gdy wskaźnikowi temu przypisuje się jakieś znaczenie; niekiedy sytuacja taka może wskazywać na błąd w programowaniu. Nie jest to jednak kwestia szczególnie istotna — jeżeli nie podzielasz mojego zdania, możesz po prostu wspomnianą asercję usunąć.
Przyjrzyjmy się teraz czynności nieco bardziej skomplikowanej, mianowicie zmianie rozmiaru przydzielonego obszaru. Standardowo zadanie to wykonuje funkcja realloc, dla której stworzyliśmy taką otoczkę:
flag fResizeMemory(void **ppv, size_t sizeNew)
byte *pbNew;
pbNew = (byte *)realloc(*ppb, sizeNew);
if (pbNew != NULL)
*ppb = pbNew;
return (pbNew != NULL);
Podobnie jak w przypadku funkcji fNewMemory, wynik funkcji informuje o powodzeniu całej operacji; adres ewentualnie przydzielonego nowego obszaru przekazywany jest jako parametr. W przeciwieństwie jednak do funkcji realloc, która w przypadku niemożności rozszerzenia bloku zwraca NULL, funkcja fResizeMemory zwraca (jako parametr) adres obszaru oryginalnego, jednocześnie informując (poprzez wynik) o niepowodzeniu całej operacji:
if (fResizeMemory(&pbBlock, sizeNew)
udało się, pbBlock wskazuje na nowy blok
nie udało się, pbBlock wskazuje na blok oryginalny
Funkcje realloc i fResizememory są o tyle interesujące, iż skrywają w sobie funkcjonalność obydwu operacji — przydziału i zwalniania pamięci (zależnie od tego, czy żądamy zmniejszenia, czy też rozszerzenia bloku).
Wzbogacimy teraz funkcję fResizeMemory w dwa elementy: w przypadku zmniejszania obszaru wypełnimy charakterystycznym wzorcem jego zwalnianą końcówkę; w przypadku rozszerzania obszaru wypełnimy tym wzorcem jego dodaną końcówkę:
size_t sizeOld;
ASSERT (ppb != NULL && sizeNew != 0);
sizeOld = sizeofBlock(*ppb);
/* jeśli zmniejszanie bloku, wypełnij końcówkę */
if (sizeNew < sizeOld)
memset((*ppb)+sizeNew, bGarbage, sizeOld-sizeNew);
/* jeżeli rozszerzanie bloku, wypełnij wzorcem */
...
grzegorj