R04.DOC

(105 KB) Pobierz
1









              Jak wykonuje się Twój kod              79



4.

Jak wykonuje się Twój kod

 

Omawiane w poprzednich rozdziałach metody „automatycznego” wykrywania błędów — asercje, testy integralności podsystemów, itp. — stanowią narzędzia niezwykle użyteczne i znaczenie ich naprawdę trudno przecenić, jednakże w niektórych przypadkach okazują się one zupełnie „nieczułe” na błędy występujące w testowanym kodzie. Przyczyna tego stanu rzeczy jest tyleż oczywista, co banalna; wyjaśnijmy ją na (bliskim każdemu z nas) przykładzie zabezpieczenia domu czy mieszkania.

Otóż najbardziej nawet wymyślne zabezpieczenie drzwi i okien okaże się zupełnie nieprzydatne w sytuacji, gdy złodziej dostanie się do domu np. przez klapę w dachu, czy też otworzy sobie drzwi dorobionym kluczem. Podobnie, najwrażliwszy nawet czujnik wstrząsowy zamontowany skrycie w magnetowidzie czy komputerze nie uchroni przez kradzieżą np. drogocennej kolekcji obrazów. W obydwu tych przypadkach zagrożenie pojawia się bowiem poza obszarami, na monitorowanie których zorientowane są urządzenia alarmowe.

Na identycznej zasadzie, najbardziej nawet wymyślne asercje, czy jeszcze bardziej zaawansowane fragmenty kodu testujące występowanie spodziewanych warunków, są coś warte jedynie wtedy, gdy w ogóle zostają wykonane! Brak alarmu ze strony określonej asercji niekoniecznie świadczy o spełnieniu testowanego przez tę asercję warunku, ale może być także wynikiem jej pominięcia; podobnie punkt przerwania spowoduje zatrzymanie wykonywania programu jedynie wtedy, gdy wykonana zostanie instrukcja, na której punkt ten ustawiono.

Wyjaśnia to poniekąd, dlaczego niektóre błędy potrafią skutecznie wymykać się (niczym sprytne szczury) najgęstszej nawet sieci asercji czy punktów przerwań, które tym samym stanowią tylko dodatkowy kłopot dla programisty, a także powodują dodatkową komplikację i tak przeważnie już złożonego kodu.

Uciekając się do małej metafory — skoro nie potrafimy schwytać grubego zwierza w pułapkę, warto podążyć jego śladem; skoro sterowanie w naszym programie omija ustanowione punkty przerwań i asercje, spróbujmy prześledzić jego przebieg. Praca krokowa na poziomie zarówno kodu źródłowego, jak i instrukcji maszynowych jest jedną z podstawowych funkcji każdego debuggera, jest też wbudowana w znakomitą większość współczesnych środowisk projektowych.

Uwiarygodnij swój kod

Opracowywałem kiedyś podprogram wykonujący specyficzną funkcję na potrzeby większego projektu (środowiska programistycznego na Macintoshu). Podczas jego rutynowego testowania znalazłem pewien błąd; jego konsekwencje dla innego fragmentu wspomnianego projektu były tak poważne, iż pozostawało dla mnie zagadką, dlaczego nie został on dotąd wykryty, skoro powinien zamanifestować się w sposób oczywisty.

Spotkałem się więc z autorem wspomnianego fragmentu i pokazałem mu błędny fragment swojego kodu. Gdy także wyraził swe zdziwienie z powodu niewykrycia widocznego jak na dłoni błędu, postanowiliśmy ustawić punkt przerwania w krytycznym miejscu kodu, a po zatrzymaniu — które naszym zdaniem musiało nastąpić — kontynuować wykonywanie w sposób krokowy.

Załadowaliśmy nasz projekt, kliknęliśmy przycisk „Run” i... ku naszemu zdumieniu program wykonał się w całości, bez zatrzymania! Wyjaśniało to skądinąd, dlaczego błąd nie został zauważony, lecz samo w sobie nadal pozostawało rzeczą zagadkową.

Ostatecznie przyczyna całego zamieszania okazała się być prozaiczna: po prostu optymalizujący kompilator wyeliminował z kodu źródłowego instrukcje, które uznał za zbędne; instrukcja, na której ustawiliśmy punkt przerwania miała nieszczęście należeć do tego zestawu. „Wykonanie” kodu źródłowego krok po kroku (czy raczej — próba takiego wykonania) uwidoczniłoby ten fakt w sposób nie budzący wątpliwości.

Jako kierownik projektu, nalegam na programistów, by „krokowe” wykonywanie tworzonego przez nich kodu stanowiło integralny element jego testowania — i, niestety, nazbyt często spotykam się ze stwierdzeniem, że przecież jest to czynność czasochłonna i jako taka spowoduje wydłużenie pracy nad projektem.

To jednak tylko mała część prawdy: po pierwsze — dodatkowy czas przeznaczony na krokowe testowanie kodu jest tylko drobnym ułamkiem czasu przeznaczonego na stworzenie tegoż kodu; po drugie — uruchomienie programu w trybie pracy krokowej nie jest w niczym trudniejsze od „normalnego” uruchomienia, bowiem różnica tkwi zazwyczaj jedynie w... naciśniętych klawiszach; po trzecie (i najważniejsze) — czas spędzony nad testowaniem programu stanowi swego rodzaju inwestycję — w przeciwieństwie do czasu spędzonego na walkę z trudnymi do wykrycia błędami, stanowiącego przykrą konieczność. W jednym z poprzednich rozdziałów, pisząc o testowaniu metodą „czarnej skrzynki”, wyjaśniałem niebagatelną rolę programowania defensywnego w walce z błędami — możliwość obserwacji zachowania się własnego kodu dodatkowo zwiększa przewagę programisty nad testerem obserwującym jedynie przetwarzanie danych przez „czarną skrzynkę”. Śledzenie stworzonego (lub zmienionego) przez programistę kodu powinno zatem stać się nieodłącznym elementem jego pracy i — choć może początkowo uciążliwe — z czasem będzie po prostu pożytecznym nawykiem.

Nie odkładaj testowania krokowego do czasu, gdy pojawią się błędy.

Przetestuj wszystkie rozgałęzienia

Praca krokowa, jak wszelkie inne narzędzia, może wykazywać zróżnicowaną skuteczność w zależności od tego, jak umiejętnie jest stosowana. W szczególności — testowanie kodu zwiększa prawdopodobieństwo uniknięcia błędów tylko wtedy, jeżeli przetestuje się cały kod; niestety, w przypadku pracy krokowej sterowanie podąża ścieżką wyznaczoną przez zachodzące aktualnie warunki — mowa tu oczywiście o instrukcjach warunkowych, instrukcjach wyboru i wszelkiego rodzaju pętlach. Aby więc przetestować wszystkie możliwe rozgałęzienia, należy przeprowadzić testowanie przy np. różnych wartościach warunków instrukcji if, czy selektorów instrukcji switch.

Notabene pierwszymi ofiarami niedostatecznego testowania padają te fragmenty kodu, które wykonywane są bardzo rzadko lub wcale — do tej ostatniej kategorii należą m.in. wszelkiego rodzaju procedury obsługujące błędy. Przyjrzyjmy się poniższemu fragmentowi:

pbBlock = (byte *)malloc(32);

if (pbBlock == NULL)

{

     obsługa błędu

     .

     .

     .

}

xxxxxxxxxxxxxx

Programiści często pytają, jaki jest sens testowania każdej zmiany kodu spowodowanej wzbogaceniem programu w nowe możliwości. Na tak postawione pytanie można odpowiedzieć jedynie innym pytaniem — czy wprowadzone zmiany na pewno, bez żadnych wątpliwości, wolne są od jakichkolwiek błędów? To prawda, iż prześledzenie każdego nowego (lub zmodyfikowanego) fragmentu kodu wymaga trochę czasu, lecz jednocześnie fakt ten staje się nieoczekiwanie przyczyną interesującego sprzężenia zwrotnego — mianowicie programiści przywykli do konsekwentnego śledzenia własnego kodu wykazują tendencję do pisania krótkich i przemyślanych funkcji, bowiem doskonale wiedzą, jak kłopotliwe jest śledzenie funkcji rozwlekłych, pisanych bez zastanowienia.

Nie należy także zapominać o tym, by przy wprowadzaniu zmian do kodu już przetestowanego zmiany te należycie wyróżniać. Wyróżniamy w ten sposób te fragmenty, które istotnie wymagają testowania; w przeciwnym razie każda zmiana kodu może pozbawić istniejący kod wiarygodności uzyskanej drogą czasochłonnego testowania — niczym odrobina żółci zdolnej zepsuć beczkę miodu.

W prawidłowo działającym programie wywołanie funkcji malloc powoduje przydzielenie tu 32-bajtowego bloku pamięci i zwrócenie niezerowego wskaźnika, zatem blok uwarunkowany instrukcją if nie zostaje wykonany. Aby go naprawdę przetestować, należy zasymulować błędną sytuację, czyli zastąpić wartością NULL dopiero co przypisany wskaźnik:

pbBlock = (byte *)malloc(32);

 

pbBlock = NULL;

 

if (pbBlock == NULL)

{

   .

     obsługa błędu

}

Spowoduje to co prawda wyciek pamięci wywołany utratą wskazania na przydzielony blok, jednakże na etapie testowania zazwyczaj można sobie na to pozwolić; w ostateczności można wykonać wyzerowanie wskaźnika zamiast wywoływania funkcji malloc:

/* pbBlock = (byte *)malloc(32); */

 

pbBlock = NULL;

 

if (pbBlock == NULL)

{

   .

     obsługa błędu

}

Na podobnej zasadzie należy przetestować każdą ze ścieżek wyznaczonych przez instrukcje if z frazą else, instrukcje switch, jak również operatory &&, || i ?:.

Pamiętaj o przetestowaniu każdego rozgałęzienia w programie.

Żywotne znaczenie przepływu danych

Pierwotna wersja stworzonej przeze mnie funkcji memset, prezentowanej w rozdziale 2., wyglądała następująco:

void *memset(void *pv, byte b, size_t size)

{

  byte *pb = (byte *)pv;

 

  if (size >= sizeThreshold)

  {

     unsigned long l;

 

     l = (b << 24) | (b << 16) | (b << 8) | b;

    

     pb = (byte *)longfill((long *)pb, l ,size / 4);

     size = size % 4;

  }

 

  while (size-- > 0)

     *pb++ = b;

 

  return (pv);

}

Sprawdziłem jej działanie w tworzonej aplikacji wyzerowując fragmenty pamięci o różnej wielkości, zarówno większej, jak i mniejszej od założonego progu sizeThreshold. Wszystko przebiegało zgodnie z oczekiwaniami; wiedząc jednak o tym, iż zero jest wartością w pewnym sensie wyjątkową, dla nadania testowi większej wiarygodności użyłem w charakterze „wypełniacza” innego wzorca — arbitralnie wybranej wartości 0´4E. Dla bloków mniejszych niż sizeThreshold wszystko było nadal w należytym porządku, jednak dla większych bloków wartość nadana zmiennej l w linii

     l = (b << 24) | (b << 16) | (b << 8) | b;

równa była 0´00004E4E zamiast spodziewanej 0´4E4E4E4E.

Rzut oka na asemblerową postać wygenerowanego kodu natychmiast ujawnił rzeczywistą przyczynę takiego stanu rzeczy — otóż kompilator, którego używałem, prowadził obliczenia wyrażeń całkowitoliczbowych w arytmetyce 16-bitowej, uwzględniając jedynie 16 najmniej znaczących bitów wyrażeń (b << 16) i (b << 24), czyli po prostu wartość zero. W zmiennej l zapisywała się jedynie bitowa alternatywa wyrażeń b i (b << 8).

A co z czujnością kompilatora ?

No właśnie. Kod prezentowany w niniejszej książce przetestowałem osobiście używając pięciu różnych kompilatorów; żaden z nich, mimo ustawienia diagnostyki na najwyższym możliwym poziomie, nie ostrzegł mnie, iż wspomniane instrukcje przesuwające 16-bitową wartość o 16, czy 24 bity powodują utratę wszystkich znaczących bitów. Co prawda kompilowany kod zgodny był w zupełności ze standardem ANSI C, jednakże wynik wspomnianych konstrukcji niemal zawsze odbiega od oczekiwań programisty — dlaczego więc brak jakichkolwiek ostrzeżeń?

Prezentowany przypadek wykazuje jednoznacznie konieczność nacisku na producentów kompilatorów, by tego rodzaju opcje pojawiały się w przyszłych wersjach ich produktów. Zbyt często my, jako użytkownicy, nie doceniamy siły swej argumentacji w tym względzie...

Ten subtelny błąd zostałby niewątpliwie szybko wykryty przez testerów, chociażby ze względu na widoczne konsekwencje (czyli wypełnianie dużych bloków „deseniem” 004E4E zamiast 4E4E4E4E), jednakże poświęcenie zaledwie kilku minut na prześledzenie kodu pozwoliło wykryć ów błąd już na etapie tworzenia funkcji.

Jak pokazuje powyższy przykład, krokowe wykonywanie kodu źródłowego może nie tylko wskazać przepływ sterowania, lecz także uwidocznić inny, niesamowicie ważny czynnik, mianowicie zmianę wartości poszczególnych zmiennych programu w rezultacie wykonywania poszczególnych instrukcji — co nazywane bywa skrótowo przepływem danych. Możliwość spojrzenia na stworzony kod pod kątem przepływu danych stanowi dodatkowy oręż dla programisty — zastanów się, które z poniższych błędów mogą zostać dzięki temu wykryte i nie są możliwe do wykrycia w inny sposób:

¨      nadmiar lub niedomiar;

¨      błąd konwersji danych;

¨      błąd „pomyłki o jedynkę” (patrz rozdział 1.);

¨      adresowanie za pomocą zerowych wskaźników;

¨      odwołanie do nieprzydzielonych lub zwolnionych obszarów pamięci („błąd A3”, patrz rozdział 3.);

¨      pomyłkowe użycie operatora „=” zamiast „==”;

¨      błąd pierwszeństwa operatorów;

¨      błędy logiczne.

Przywołajmy raz jeszcze błędną instrukcję z rozdziału 1.:

if (ch = '\t')

  ExpandTab();

pomyłkowe użycie operatora = może być tu łatwo przeoczone, jednak zaobserwowana zmiana wartości zmiennej ch wskutek wykonania instrukcji błąd ten natychmiast demaskuje.

Podczas pracy krokowej programu
zwracaj szczególną uwagę na przepływ danych.

Czy czegoś nie przeoczyłeś

Obserwując przepływ danych podczas pracy krokowej, nie jesteś jednak w stanie wykryć wszystkich błędów. Przyjrzyjmy się poniższemu fragmentowi:

/* jeżeli istnieje węzeł reprezentujący symbol

* i utworzony został łańcuch zawierający jego nazwę,

* zwolnij ten łańcuch

*/

 

if (psym != NULL & psym->strName != NULL)

{

  FreeMemory(psym->strName);

  psym->strName = NULL;

...

Zgłoś jeśli naruszono regulamin