3.
Ufortyfikuj swoje podsystemy
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.
Jest błąd, nie ma błędu
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))
pbBlock wskazuje na przydzielony blok pamięci
else
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
flag fNewMemory(void **ppv, size_t size)
{
byte **ppb = (byte **)ppv;
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.
Zutylizuj swoje śmieci
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:
void FreeMemory(void *pv)
{
ASSERT (pv != NULL);
#ifdef DEBUG
{
memset(pv, bGarbage, sizeofBlock(pv));
}
#endif
free(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 **ppb = (byte **)ppv;
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
else
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ę:
flag fResizeMemory(void **ppv, size_t sizeNew)
{
byte **ppb = (byte **)ppv;
byte *pbNew;
#ifdef DEBUG
size_t sizeOld;
#endif
ASSERT (ppb != NULL && sizeNew != 0);
#ifdef DEBUG
{
sizeOld = sizeofBlock(*ppb);
/* jeśli zmniejszanie bloku, wypełnij końcówkę */
if (sizeNew < sizeOld)
memset((*ppb)+sizeNew, bGarbage, sizeOld-sizeNew);
}
#endif
pbNew = (byte *)realloc(*ppb, sizeNew);
if (pbNew != NULL)
{
#ifdef DEBUG
{
/* jeżeli rozszerzanie bloku, wypełnij wzorcem */
/* dodaną końcówkę */
if (sizeNew > sizeOld)
memset(pbNew+sizeOld, bGarbage, sizeNew-sizeOld);
}
#endif
*ppb = pbNew;
}
return (pbNew != NULL);
}
Jeżeli teraz w obszarze, który właśnie miał do czynienia z funkcją fResizeMemory, napotkamy ciąg bajtów 0×A3, z dużym prawdopodobieństwem będzie to (pod)obszar zwolniony lub nowo przydzielony — a więc taki, którego zawartości nie wolno przypisywać żadnego znaczenia.
Posprzątaj swoje śmieci,
aby nie mogły one zostać użyte jako materiał pełnowartościowy.
Jestem już gdzie indziej
Przypuśćmy teraz, iż podlegający rozszerzeniu obszar jest jednym z węzłów drzewa; co stanie się, jeżeli żądający tego rozszerzenia programista nie uwzględni faktu, iż użyta przez niego funkcja realloc (albo fResizeMemory) mogła zmienić adres bloku? Otóż wszystkie wskaźniki uprzednio wskazujące na odnośny węzeł w dalszym ciągu wskazywać będą na jego poprzednią instancję, która być może niczym nie różni się od poprawnych danych i jako taka nie wzbudza żadnych podejrzeń. Może warto wobec tego wypełnić tę instancję charakterystycznym wzorcem, aby się od poprawnych danych wyraźnie odróżniała? Oto przykładowe rozwiązanie:
flag fResizeMemory(void **ppv, size_t sizeNew)
{
.
.
pbNew = (byte *)realloc(*ppb, sizeNew);
if (pbNew != NULL)
{
#ifdef DEBUG
{
/* jeżeli nastąpiła zmiana adresu bloku, */
/* wypełnij wzorcem jego poprzednią instancję */
if (pbNew != *ppb)
memset(*ppb, bGarbage, sizeOld);
/* jeżeli rozszerzono blok, wypełnij wzorcem */
/* jego dodaną końcówkę */
if (sizeNew > sizeOld)
memset(pbNew+sizeOld, bGarbage, sizeNew-sizeOld);
}
#endif
*ppb = pbNew;
}
return (pbNew != NULL);
}
Powyższy kod skrywa jednak dość niebezpieczną pułapkę: zwróć uwagę, iż w przypadku, gdy funkcja realloc zmieni adres bloku, poprzednia jego instancja nie jest już przydzielona do programu — tymczasem my dokonujemy wypełniania jej wzorcem, działając de facto na zwolnionym obszarze pamięci!
Niektóre systemy zarządzania pamięcią nie przywiązują żadnego znaczenia do zawartości zwalnianego bloku i takie postępowanie w niczym im nie zaszkodzi, niektóre jednak kolekcjonują zwolnione bloki (np. grupują je w listę łączoną) umieszczając w ich obszarze informacje związane z owym kolekcjonowaniem (najczęściej jest to rozmiar bloku i wskaźnik do następnego bloku na liście). Beztroskie wpisywanie zawartości do zwolnionego bloku może więc spowodować zamazanie owych informacji i w efekcie załamanie całej gospodarki pamięcią, ergo — powyższą funkcję należy potraktować jedynie w kategoriach przykładu, jak postępować nie należy.
Błędy wynikające z nieuwzględnienia faktu, iż funkcja realloc może zmieniać adres przedmiotowego obszaru, są szczególnie dokuczliwe z jeszcze jednego powodu — mają szansę ujawnić się tylko wówczas, gdy zmiana taka faktycznie nastąpi. Celowo napisałem „mają szansę”, sam bowiem przypominam sobie długie miesiące spędzone na poszukiwaniu błędu spowodowanego (jak się później okazało) taką właśnie przyczyną — oglądając nie wzbudzający podejrzeń obszar, nie zdawałem sobie sprawy, iż oglądam właśnie jego poprzednią, zwolnioną już instancję. Gdyby każde wywołanie funkcji realloc powodowało zmianę adresu bloku, błąd ten mógłby zostać wykryty nie w ciągu kilku miesięcy, lecz być może w ciągu kilku godzin! Zmodyfikujmy więc funkcję fResizeMemory w taki sposób, by (w trybie testowania programu) realokowany blok był zawsze przesuwany:
flag fResizeMemory(void **ppv, size_t sizeNew)
{
byte **ppb = (byte **)ppv;
byte *pbNew;
#ifdef DEBUG
size_t sizeOld;
#endif
ASSERT(ppb != NULL && sizeNew != 0);
#ifdef DEBUG
{
sizeOld = sizeofBlock(*ppb);
/* Jeżeli blok jest zmniejszany, przed zwolnieniem
* wypełnij go wzorcem. Jeżeli blok jest rozszerzany,
* wymuś ponowną alokację (zamiast rozszerzania w miejscu)
* Jeżeli obydwa rozmiary (stary i nowy) są takie same,
* nic nie rób.
*/
if (sizeNew < sizeOld)
memset((*ppb)+sizeNew, bGarbage, sizeOld-sizeNew);
else if (sizeNew > sizeOld)
{
byte *pbForceNew;
if (fNewMemory(&pbForceNew, sizeNew))
{
memcpy(pbForceNew, *ppb, sizeOld);
FreeMemory(*ppb);
*ppb = pbForceNew;
}
}
}
#endif
pbNew = (byte *)realloc(*ppb, sizeNew);
.
.
}
Upiekliśmy więc dwie pieczenie na jednym ogniu — każdorazowa zmiana rozmiaru bloku połączona jest z jego faktyczną realokacją (zmianą adresu), ponadto poprzednia instancja bloku wypełniana jest przed zwolnieniem charakterystycznym wzorcem.
Pewne zdziwienie budzić może fakt, iż przy powiększaniu bloku dwukrotnie próbuje się przeprowadzić realokację: najpierw w wytłuszczonym fragmencie kodu, później w końcowym wywołaniu funkcji realloc; to ostatnie nie powoduje żadnych dodatkowych efektów, bowiem żądany rozmiar bloku jest taki sam jak istniejący. Czyż nie prościej byłoby zakończyć całą sprawę wcześniej, po faktycznym wykonaniu realokacji?
if (fNewMemory(&pbForceNew, sizeNew))
{
memcpy(pbForceNew, *ppb, sizeOld);
FreeMemory(*ppb);
*ppb = pbForceNew;
return(TRUE);
}
Rzeczywiście, można by tak postąpić; stanowiłoby to jednak pogwałcenie reguły mówiącej, iż dodatkowy, testowy kod powinien wykonywać się jako uzupełnienie kodu zasadniczego, nie zaś zamiast niego — nawet wówczas, gdy pewne czynności wykonywane byłyby przez to w sposób redundantny.
Gdy przedstawiałem powyższą koncepcję znajomym programistom, twierdzili oni, iż popadam z jednej skrajności w drugą — konsekwentne, każdorazowe „przesuwanie” bloków w pamięci jest wcale nie lepsze niż jego zupełny brak. To cenne spostrzeżenie wymaga jednak kilku słów komentarza. Otóż permanentne czynienie czegokolwiek jest tak samo złe, jak konsekwentne tego unikanie — pod warunkiem jednakże, iż ma miejsce w obydwu wersjach kodu: zasadniczej (handlowej) i testowej. Tymczasem wersja handlowa charakteryzuje się nikłą dynamiką w zakresie przemieszczania bloków — co stanowi zupełne przeciwieństwo żywiołowego charakteru wersji testowej w tym względzie.
Jeżeli bowiem coś zdarza się rzadko w wersji zasadniczej, powinno być (dla równowagi) wymuszane w wersji testowej.
Jeżeli w programie coś zdarza się bardzo rzadko,
spraw, by zdarzało się regularnie.
Kontroluj wykorzystanie pamięci
Najpoważniejszym problemem związanym z gospodarką pamięcią jest — z punktu widzenia testowania programu — zupełny brak informacji na temat rozmiaru przydzielonego bloku. Rozmiar ten znany jest oczywiście w momencie dokonywania przydziału (tj. wywoływania funkcji malloc), lecz na dalszym etapie wykonania mamy już do czynienia tylko z samym wskaźnikiem. Nie sposób przecenić użyteczności funkcji sizeofBlock, bez której nie mogłyby powstać funkcje FreeMemory i fResizeMemory, i której implementację obiecałem przedstawić jeszcze w tym rozdziale. Nietrudno sobie wyobrazić, o ile ułatwione byłoby testowanie programu, gdyby dostępna była informacja o wszystkich przydzielonych obszarach — ich adresach i rozmiarach. Informacji takiej nie udostępnia rzecz jasna sam system zarządzania pamięcią, skoro jednak dokonaliśmy przechwycenia funkcji malloc, free i realloc, możemy wzbogacić treść funkcji-otoczek w implementację takich właśnie mechanizmów — będziemy mianowicie rejestrować każdy przydział, zwolnienie i realokację bloku. Skonstruowanie wspomnianej funkcji sizeofBlock będzie wówczas zadaniem banalnym.
Jednym z zewnętrznych przejawów mechanizmów kontrolujących będzie funkcja fValidPointer sprawdzająca, czy istnieje blok o adresie określonym przez pierwszy parametr, i czy jego rozmiar nie jest mniejszy od wskazanego przez drugi parametr. Oto prosty przykład jej wykorzystania:
void FillMemory(void *pv, byte b, size_t size)
{
ASSERT(fValidPointer(pv, size));
memset(pv, b, size);
}
Przedstawiona asercja jest znacznie bardziej funkcjonalna niż jedynie badanie niezerowości wskaźnika w ramach funkcji memset (patrz rozdział 2.). Związane z nią wywołanie funkcji fValidPointer pociąga oczywiście za sobą pewne koszty (w zakresie czasu wykonania i zajętości pamięci), jest to jednak cena, jaką płaci się za dodatkowe bezpieczeństwo; można zresztą ograniczyć obowiązywanie przedstawionej definicji funkcji tylko do wersji testowej, a w wersji handlowej utożsamić ją z funkcją memset, na przykład tak:
#define FillMemory(pb,b,size) memset((pb),(b),(size))
Osobiście jednak odradzałbym tego rodzaju praktyki.
Mechanizmy administracyjne, których implementację opisujemy, dają znacznie większe możliwości testowe niż wypełnianie obszarów pamięci charakterystycznym wzorcem; stwierdzenie bowiem, czy dany wskaźnik identyfikuje blok pamięci o dostatecznie dużym rozmiarze jest bardzo łatwe — wystarczy wywołać funkcję fValidPointer.
Przejdźmy do szczegółów — zarządzanie ową „administracyjną” informacją odbywać się będzie w trojaki sposób. Po pierwsze, alokacji nowego bloku musi towarzyszyć alokacja odpowiedniego bloku „administracyjnego”; po drugie, wraz ze zwolnieniem danego bloku należy również zwolnić odpowiadający mu blok administracyjny; po trzecie wreszcie — przy realokacji bloku należy uaktualnić informację zapisaną w stosownym bloku administracyjnym. Czynności te wykonywane będą przez trzy następujące funkcje:
/* utwórz blok administracyjny dla bloku danych */
/* o adresie pbNew i rozmiarze sizeNew */
flag fCreateBlockInfo(byte *pbNew, size_t sizeNew);
/* zwolnij blok administracyjny związany z blokiem danych */
/* o adresie pb */
void FreeBlockInfo(byte *pb);
/* aktualizuj informację związaną z blokiem danych o dotychczasowym */
/* adresie pbOld - nowym adresem bloku jest pbNew, nowym jego */
/* rozmiarem jest sizeNew */
void UpdateBlockInfo(byte *pbOld, byte *pbNew, size_t sizeNew);
Dokładna implementacja tych funkcji nie jest w tej chwili istotna; jeżeli jesteś nią zainteresowany, jeden ze sposobów jej realizacji znajdziesz w dodatku B; zasadniczą sprawą jest natomiast ich „wplecenie” w treść naszych trzech funkcji-otoczek. Rozpocznijmy od zwalniania bloku:
void FreeMemory(void *pv)
{
#ifdef DEBUG
{
memset(pv, bGarbage, sizeofBlock(pv));
FreeBlockInfo(pv);
}
#endif
free(pv);
}
W ramach funkcji fResizeMemory stosowny blok administracyjny powinien być uaktualniony tylko wtedy, gdy funkcja realloc pomyślnie dokona zmiany rozmiaru bloku:
flag fResizeMemory(void **ppv, size_t sizeNew)
{
.
.
.
pbNew = (byte *)realloc(*ppb, sizeNew);
if (pbNew != NULL)
{
#ifdef DEBUG
{
UpdateBlockInfo(*ppb, pbNew, sizeNew);
/* jeżeli rozszerzono blok, wypełnij wzorcem */
/* jego dodaną końcówkę */
if (sizeNew > sizeOld)
memset(pbNew+sizeOld, bGarbage, sizeNew-sizeOld);
}
#endif
*ppb = pbNew;
}
return (pbNew != NULL);
}
Modyfikacja funkcji fNewMemory będzie nieco bardziej skomplikowana. Podczas jej realizacji system musi przydzielić dwa bloki pamięci — żądany blok danych i związany z nim blok administracyjny; jeżeli nie powiedzie się przydział bloku administracyjnego, należy zwolnić przydzielony uprzednio blok zasadniczy i zasygnalizować niepowodzenie, zwracając (jako wynik) wartość FALSE:
flag fNewMemory(void **ppv, size_t size)
{
byte **ppb = (byte *)ppv;
ASSERT (ppv != NULL && size != 0);
*ppb = (byte *)malloc(size);
#ifdef DEBUG
{
if (*ppb != NULL)
{
memset (*ppb, bGarbage, size);
/* jeżeli nie uda się przydzielić bloku administracyjnego */
/* należy zwolnić przydzielony blok zasadniczy */
/* i zasygnalizować niepowodzenie */
if (!fCreateBlockInfo(*ppb, size)
{
free(*ppb);
*ppb = NULL;
}
}
}
#endif
return (*ppb != NULL);
}
To wszystko — w dodatku B znaleźć możesz ponadto implementacje funkcji sizeofBlock i fValidPointer.
Utrzymywanie dodatkowych informacji administracyjnych
w dużym stopniu ułatwia testowanie.
Spójrz na to, czego nie widać
Jednym z powszechnych błędów — zgodnie z przytoczoną na początku rozdziału listą — jest odwołanie do nieistniejącego bloku. Opisane przed chwilą mechanizmy administracyjne pozwalają z łatwością wykryć i zasygnalizować taką sytuację. Załóżmy teraz, iż w strukturze drzewiastej położenie jednego z węzłów uległo zmianie, lecz nie zostały uaktualnione związane z nim wskaźniki. Czy jest to błąd? Zdecydowanie tak. Czy ten błąd jest łatwy do wykrycia? Tak, lecz tylko pod warunkiem, iż błędne wskaźniki w ogóle zostaną użyte; jeżeli bowiem brak będzie odwołań do rzeczonego węzła, dotychczas przedstawione mechanizmy są wobec tego błędu bezsilne.
Sytuacją odwrotną do użycia nieprawidłowych wskaźników jest zagubienie adresu istniejącego bloku. W sekwencji:
*ppb = (byte *)malloc(size);
*ppb = (byte *)malloc(size + 4);
blok alokowany jako pierwszy zostaje stracony, nie istnieje bowiem identyfikujący go wskaźnik. Co prawda błędne konstrukcje występują zazwyczaj w postaci mniej oczywistej niż powyższa — gdyby pomyłkowo „wykomentować” jedną z linijek w funkcji fResizeMemory
if (fNewMemory(&pbForceNew, sizeNew))
{
memcpy(pbForceNew, *ppb, sizeOld);
/* FreeMemory(*ppb); */
*ppb = pbForceNew;
return(TRUE);
}
poprzednia instancja bloku nie ulegnie zwolnieniu i zajęta przez nią pamięć pozostanie stracona aż do zakończenia wykonania programu. Takie zagubione bloki są przypadkiem jeszcze gorszym niż nieprawidłowe wskaźniki, bowiem z jednej strony brak jest do nich odwołań (z konieczności), z drugiej natomiast ich skutki — zwane popularnie „wyciekami pamięci” (ang. memory leaks) — dają o sobie znać dopiero po dłuższym czasie, gdy rozmiar „straconej” pamięci osiąga odczuwalne rozmiary.
Wykrywanie zagubionych bloków pamięci staje się jednak łatwe, jeżeli dysponujemy opisanymi w poprzednim podrozdziale danymi administracyjnymi. Z każdym blokiem administracyjnym należy mianowicie związać flagę odwołania, która będzie ustawiana każdorazowo, gdy specjalna procedura administracyjna zasygnalizuje odwołanie do powiązanego bloku danych. Aby wykryć zagubione bloki, należy najpierw wyzerować flagi odwołania we wszystkich blokach administracyjnych, następnie zasymulować odwołania do wszelkich możliwych bloków, których wskaźnikami dysponujemy w programie; te z bloków administracyjnych, w których flaga odwołania nie będzie ustawiona, reprezentują właśnie zagubione bloki danych.
Ta metoda skuteczna jest również w odniesieniu do nieprawidłowych wskaźników — procedura administracyjna, zamierzająca ustawić flagę odwołania w stosownym bloku administracyjnym, po prostu nie znajdzie go i zasygnalizuje błąd.
Aby zrealizować opisaną „administracyjną kontrolę odwołań”, musimy zdefiniować trzy poniżej wymienione funkcje. Ich dokładna implementacja nie jest istotna (jeden ze sposobów znajdziesz w dodatku B), zaś spełniane przez nie czynności wyjaśnione są w komentarzach:
/* zeruje flagę odwołania dla wszystkich bloków */
void ClearMemoryRefs(void);
/* ustawia flagę odwołania dla bloku danych */
/* wskazywanego przez pv */
void NoteMemoryRef(void *pv);
/* poszukuje bloków, dla których nie są ustawione */
/* flagi odwołania */
void CheckMemoryRefs(void);
Zastosowanie opisanej metody wyjaśnimy na przykładzie drzewa binarnego. Jego każdy węzeł, oprócz innych informacji, zawiera wskaźniki do lewego i prawego poddrzewa oraz wskaźnik do opisującej go nazwy w postaci łańcucha ASCIIZ:
typedef struct SYMBOL
{
struct SYMBOL *psymRight;
struct SYMBOL *psymLeft;
char *strName;
.
.
.
} symbol;
Węzeł taki reprezentować może na przykład jeden z symboli języka programowania w wewnętrznych strukturach kompilatora. Dla każdego z węzłów drzewa zasymulować należy odwołanie do trzech wymienionych wskaźników psymRight, psymLeft i strName.
Spośród trzech najbardziej popularnych metod „przechodzenia” drzewa — INORDER, POSTORDER i PREORDER — ostatnia z wymienionych wydaje się najodpowiedniejsza, bowiem zgodnie z nią „odwiedzenie” danego węzła następuje przed odwiedzeniem jego poddrzew, co w przypadku istnienia wadliwych węzłów wykrywa ten z nich, który znajduje się najbliżej korzenia.
void NoteSymbolRefs(symbol *psym)
{
if (psym != NULL)
{
/* sprawdź węzeł, zanim zejdziesz do jego poddrzew */
NoteMemoryRef(psym);
NoteMemoryRef(psym->strName);
/* sprawdź poddrzewa */
NoteSymbolRefs(psym->psymRigth);
NoteSymbolRefs(psym->psymLeft);
}
}
Aby sprawdzić całe drzewo, należy wpierw wyzerować wszystkie flagi odwołań, a następnie wywołać tę rekurencyjną funkcję dla korzenia drzewa:
void CheckBinaryTreeIntegrity(symbol *root);
{
/* wyzeruj flagi odwołania */
ClearMemoryRefs();
/* przejdź całe drzewo metodą PREORDER */
NoteSymbolRefs(root);
/* zweryfikuj brak zagubionych bloków */
CheckMemoryRefs()
}
Kompletna weryfikacja wszystkich danych rzeczywistego programu będzie mieć postać o wiele bardziej skomplikowaną — oto jeden z przykładów, pochodzący z asemblera dla procesora 68000:
void CheckMemoryIntegrity(void)
{
ClearMemoryRefs();
NoteSymbolRefs(psymRoot);
NoteMacroRefs();
.
.
.
NoteCacherefs();
Note VariableRefs();
CheckMemoryRefs()
}
Pozostaje jeszcze kwestia — w którym momencie dokonywać takiej weryfikacji? Wszak sytuacja na forum gospodarki pamięcią zmieniać się może wiele razy w ciągu ułamka sekundy. Odpowiedź na to pytanie zależna jest od konkretnego programu, na pewno jednak absolutnym minimum jest przeprowadzenie testu po utworzeniu lub zwolnieniu każdego skomplikowanego obiektu; dobrą okazję stanowią ponadto wszelkie pętle oczekiwania, np. na naciśnięcie klawisza przez użytkownika.
Dokonaj ewidencji danych swojego programu.
xxxxxxxx
Prezentowane przykłady charakteryzują się wręcz przytłaczającym rozmiarem kodu testowego — najpierw asercje, potem funkcje-otoczki, następnie ewidencja bloków pamięci i wreszcie kontrola odwołań — nie od rzeczy więc będzie zadać sobie pytanie, czy po takich zabiegach wciąż mamy do czynienia z tym samym programem? Otóż co prawda tekst programu został znacznie rozbudowany, lecz jego funkcje pozostały niezmienione: to fakt, iż testowa wersja funkcji fResizeMemory żongluje blokami pamięci z większą dynamiką niż wersja zasadnicza, jednak ostateczny efekt jej wywołania pozostaje taki sam — rozszerzenie bloku z zachowaniem dotychczasowej jego zawartości, być może połączone ze zmianą adresu. Podobnie funkcja fNewMemory alokuje więcej pamięci w wersji testowej (ze względu na dodatkowy blok administracyjny), lecz i tak użytkownik otrzymuje blok danych o rozmiarze nie mniejszym od żądanego. Różnica w ilości alokowanej pamięci mogłaby być istotna, gdyby użytkownik żądał np. przydzielenia dokładnie 21 bajtów pamięci — takiej funkcji nie potrafi jednak wykonać niemal żaden z istniejących menedżerów pamięci, w tym i funkcja malloc.
Dodatkowe alokacje pamięci na potrzeby testowania programu mogą jednak stanowić problem w sytuacji, gdy pamięci zaczyna brakować — na szczęście są to jednak przypadki skrajne, poza tym nie należy zapominać, iż celem całego procesu testowania jest jak najszybsze znalezienie tkwiących w programie błędów, nie zaś jak najefektywniejsze wykorzystanie pamięci.
Wybieraj rozsądnie
Dr Robert Cialdini w jednej ze swoich książek zwraca uwagę na pewien psychologiczny aspekt podejmowania decyzji. Zaleca on mianowicie sprzedawcom salonów odzieżowych, by swoim klientom prezentowali najpierw kostium za 500 dolarów, a dopiero potem sweter za 80; sweter wyda się wówczas stosunkowo tani i klient na pewno da się na niego namówić. Gdyby rozpocząć prezentację od swetra, klient zapytałby po prostu „dlaczego tak drogo” i poprzestał na zakupie za (powiedzmy) 35 dolarów. Zdaniem Cialdiniego sprawa ta jest oczywista dla każdego, kto zechce pomyśleć o niej chociaż przez 30 sekund, ale — pyta — ilu ludzi myśli dzisiaj w ten właśnie sposób?
Nieważne, czy Dr Cialdini ma rację, w każdym razie kolejność prezentacji towarów jest przykładem pewnego wyboru, a programiści tworzący złożone aplikacje zmuszeni są do dokonywania rozmaitych wyborów nieustannie. Jedną z takich — wydawałoby się oczywistych — decyzji był wybór wzorca wypełniającego blok pamięci, którego zawartości nie należy przypisywać żadnego znaczenia; wykazałem wówczas, dlaczego wartość 0 wypełniającego bajtu jest zdecydowanie złym wyborem i dlaczego 0×A3 będzie (przypuszczalnie) lepsza w tej roli od pozostałych 254 konkurentek. Z kolei przy weryfikowaniu integralności węzłów drzewa binarnego stanęliśmy przed wyborem algorytmu jego przechodzenia; wychodząc z założenia, iż „defekt” w samym węźle jest pilniejszy do zasygnalizowania niż defekt w jego poddrzewach, wybraliśmy metodę PREORDER, zgodnie z którą dopiero po gruntownym przebadaniu węzła przystępuje się do badania jego poddrzew.
Jeżeli więc przyjdzie Ci podjąć decyzję związaną ze szczegółami implementacji jakiegoś algorytmu, pomyśl przez chwilę nad takim wyborem, który ułatwi walkę z ewentualnymi błędami, nie zaś przysporzy dodatkowych kłopotów.
Starannie zaplanuj swoje testy; niczego nie zostawiaj przypadkowi.
Szybki czy bezbłędny
Jest zrozumiałe, iż testowe wersje programów mogą być znacznie wolniejsze i bardziej pamięciożerne od wersji zasadniczych. Nic w tym dziwnego, wszak użytkownicy kupują oprogramowanie w nadziei zaspokojenia swych potrzeb, zaś celem wersji testowej jest „wyłapanie” tkwiących w programie błędów i to bez względu na to, jak dalece pozostaje on w tyle za wersją zasadniczą pod względem szybkości. Należy jednak zachować ostrożność, gdy kieruje się testową wersję do beta-testerów — swego czasu zaprzestali oni pracy nad nową wersją jednego z produktów Microsoftu twierdząc, iż jest ona wspaniała, lecz wolna niczym leniwiec trójpalczasty; w takich wypadkach należy rozważyć ograniczenie lub usprawnienie kodu testowego, bądź opatrzyć interfejs użytkownika stosowną klauzulą wyjaśniającą.
Jeżeli jednak szybkość programu i jego wymagania sprzętowe pozostają — mimo dodania kodu testowego — w granicach akceptowalnych przez użytkowników końcowych, można uczynić wspomniany kod częścią wersji zasadniczej i w takiej postaci udostępnić ją użytkownikom. Jeżeli na skutek dodatkowych testów wykryte zostaną ewentualne błędy, wygrani będą wszyscy — użytkownicy i producent; gdy obawiasz się natomiast zniechęcenia użytkowników wersją niezbyt szybką nie zapominaj, iż wersja zawierająca błędy jest czymś znacznie gorszym.
Nie ma w tym nic nadzwyczajnego — popularny Excel wyposażony jest w rozmaite testy podsystemu zarządzania pamięcią, i to testy bardziej wnikliwe niż prezentowane w tym rozdziale. Nie oznacza to, iż użytkownicy Excela nigdy nie doświadczyli błędów z jego strony, lecz błędy te prawie nigdy nie zdarzyły się w tych częściach kodu, które wspomagane były przez dodatkowe testy.
Teraz lub później
Błędy czające się w programie i tak kiedyś dadzą znać o sobie — teraz lub później. Wykrycie błędu przez zespół programistów i testerów jest niewątpliwie sytuacją jakościowo różną od wystąpienia błędu w czasie normalnej eksploatacji programu. Kwestię „teraz lub później” ograniczyć można również do samego procesu testowania: błąd może być wykryty zawczasu — dzięki umiejętnie skonstruowanym testom — lub później, gdy testerom uda się natrafić na sprzyjające mu warunki. Przypomnę — gdyby stosowne testy znalazły się w tworzonym przeze mnie asemblerze, znalezienie błędu spowodowanego przesuwaniem bloku zajęłoby kilka godzin, nie zaś (niemal) cały rok; nie byłyby przy tym potrzebne żadne szczególne kwalifikacje programistyczne ani też sprzyjające okoliczności; po prostu błąd zostałby wykryty automatycznie. Oto, na czym polega tworzenie bezbłędnych programów.
Podsumowanie
Przyjrzyj się wykorzystywanym przez siebie podsystemom i zastanów się, w jaki sposób programiści mogliby ich użyć niezgodnie z przeznaczeniem. Dodając odpowiednie asercje i testy weryfikacyjne przyczynisz się do łatwego wykrycia błędów, które inaczej długo pozostałyby niewykryte.
Powtarzalność błędów niewątpliwie sprzyja ich wykrywaniu; jeżeli więc w zasadniczej wersji programu coś, co może stać się przyczyną błędu, ma nikłą szansę zaistnienia, spraw, by w wersji testowej zdarzało się regularnie. Jeżeli jakiś obszar ma przypadkową zawartość, wypełnij go charakterystycznym wzorcem, by program nie mógł przypisywać znaczenia nic nie znaczącym śmieciom.
Zaplanuj swoje testy w taki sposób, by ich przydatność nie była uzależniona od jakichś szczególnych kwalifikacji programistycznych; komunikaty towarzyszące wykryciu błędu powinny być użyteczne nawet dla początkujących programistów.
Jeżeli to możliwe, zrealizuj swoje testy w ramach samych podsystemów, nie zaś na poziomie odwołań do nich; wykorzystaj do tego celu funkcje-otoczki, obudowujące wywołania systemowe niezbędnymi testami.
Zastanów się dobrze, nim usuniesz z programu jakiś test tylko dlatego, iż spowalnia on wykonanie lub zwiększa zużycie pamięci; bądź świadom tego, iż efekty te nie wystąpią w wersji zasadniczej. Jeżeli negatywne skutki testu nie są do zaakceptowania nawet na etapie testowania, należy spróbować uczynić ów test efektywniejszym lub mniej pamięciożernym.
Pomyśl o tym
Długi ciąg wartości 0×A3 może świadczyć o tym, iż masz właśnie do czynienia z blokiem niezainicjowanym albo zwolnionym. W jaki sposób zmieniłbyś kod testujący, aby rozróżnić te dwie kategorie?
Częstym błędem programistycznym jest przekraczanie granic przydzielonego bloku pamięci przy wypełnianiu go danymi. W jaki sposób rozszerzyłbyś kod testujący, by wykryć błędy tego rodzaju?
Oto przykład dość subtelnego błędu, który w razie zaistnienia nie zostanie wykryty przez funkcję CheckMemoryIntegrity. Załóżmy, iż zostaje zwolniony blok pamięci (np. węzeł drzewa), pozostawiając „wiszące” wskaźniki, które programista zapomniał wyzerować; chwilę później funkcja fNewMemory przydziela dokładnie ten sam blok (tj. pod tym samym adresem) — rzeczone wskaźniki stają się znowu poprawne, chociaż tak naprawdę wskazują one już zupełnie inny blok, z którym logicznie nie mają żadnego związku; w poprawnym programie wskaźniki te powinny być wyzerowane. Ponieważ w kategoriach funkcji fValidPointer „poprawność” owych wskaźników nie budzi wątpliwości, programista odnosi mylne wrażenie, iż „coś” zniszczyło mu przydzieloną pamięć, wypełniając ją znajomym wzorcem. Tak naprawdę powtórne przydzielenie dopiero co zwolnionego bloku zdarza się znacznie częściej, niż można by przypuszczać — jak więc rozszerzyłbyś swój system testowy, aby tego rodzaju błędy stały się wykrywalne?
Za pomocą funkcji NoteMemoryRef można zweryfikować poprawność każdego wskaźnika (identyfikującego przydzielony blok), jak jednak zweryfikować rozmiar bloku? Załóżmy na przykład, iż pewien wskaźnik wskazuje na 18-znakowy łańcuch, lecz zgodnie z informacją zapisaną w bloku administracyjnym, przydzielony blok ma długość 15 bajtów i vice versa — gdy program korzysta z założenia, iż przydzielony blok jest 15-bajtowy, informacja w bloku administracyjnym wykazuje przydział 18 bajtów. Jak zmodyfikowałbyś testy integralności, by zdolne były weryfikować również długość bloku?
Zgodnie z implementacją zamieszczoną w dodatku B funkcja NoteMemory Ref bezbłędnie ewidencjonuje fakt odwołania się do bloku, nie umożliwia jednak wykrycia pięciokrotnego odwołania się do bloku w sytuacji, gdy zgodnie z logiką programu odwołanie to powinno być co najwyżej jednokrotne. Na przykład, wiązana lista dwukierunkowa utrzymuje dwa wskaźniki dla każdego węzła: jeden w węźle poprzedzającym, drugi w węźle następnym; większość iteracji po elementach listy powinno skutkować jednokrotnymi odwołaniami do każdego z elementów, odwołania wielokrotne mogą być oznaką ewentualnego błędu. Jak zmodyfikowałbyś system kontroli integralności, by sygnalizował wielokrotne odwołania do wybranych bloków, generalnie nie zabraniając jednak wielokrotnych odwołań?
Przedstawione w tym rozdziale techniki i środki służące testowaniu przeznaczone są raczej na użytek programistów. Ponieważ jednak programiści wykazują tendencję do spychania części odpowiedzialności na testerów — jakie środki programistyczne mogłyby Twoim zdaniem ułatwić testerom wykrywanie niektórych błędów, na przykład przekroczenie granic przydzielonej pamięci?
PROJEKT: Przyjrzyj się poszczególnym podsystemom w którejkolwiek stworzonej przez Ciebie aplikacji i zastanów się, jakie rodzaje dodatkowej kontroli zaimplementowałbyś w jej testowej wersji, by łatwiej wykryć ewentualne błędy, popełniane najczęściej w związku ze wspomnianymi podsystemami?
R.Cialdini „How and Why People Agree to Things” (Morrow, 1984).
Tłumacz uważa stanowisko Dr Cialdiniego za wręcz obraźliwe — nie jesteśmy wszak głupkami, którymi można dowolnie manipulować.
58 Niezawodność oprogramowania
Ufortyfikuj swoje podsystemy 57
58 D:\Roboczy\Niezawodność oprogramowania\9 po skladzie 1\r03.doc
D:\Roboczy\Niezawodność oprogramowania\9 po skladzie 1\r03.doc 57