Sposoby wykrywania i usuwania błędów Tomasz Borzyszkowski
Mylić się jest rzeczą ludzką Typy błędów: błędy specyfikacji: źle określone wymagania błędy projektowe: nieodpowiednie struktury danych i algorytmy błędy kodu: powstają na etapie realizacji projektu przy pomocy wybranego narzędzia programistycznego Etapy debugowania, poprawiania programu: testowanie: znalezienie błędu lokalizacja: zidentyfikowanie błędnego kodu korekta: poprawienie błędu weryfikacja: sprawdzenie czy poprawka działa Program z błędem: W pliku debug1.c znajduje się funkcja sort, która ma sortować struktury typu item metodą bąbelkową. Niestety po uruchomieniu otrzymujemy błąd segmentacji. 2
Sposoby testowania Przeglądanie kodu Podstawowym sposobem wykrywania błędów jest przeglądanie kodu. Najlepiej, gdy nasz kod przejrzy inna osoba. Do wykrywania błędów składniowych możemy użyć kompilatora: gcc -Wall -pedantic -ansi -o debug1 debug1.c Oprzyrządowanie kodu Oprzyrządownie polega na dodawaniu nowego kodu do testowanego programu w celu zebrania jak największej liczby informacji o programie. Wykorzystuje się do tego preprocesor C, pozwalający decydować podczas kompilacji, czy dołączyć kod wykorzystywany do debugowania. Np.: #ifdef DEBUG printf("debug: x=%d\n", x); #endif powyżej printf wykona się, gdy program będzie skompilowany z opcją -DDEBUG. Patrz debug2.c 3
Sposoby testowania cd Oprzyrządowanie kodu bez ponownej kompialcji Wadą poprzedniego rozwiązania była konieczność ponownej kompilacji przed każdym debugowaniem. Alternatywnymi sposobami umożliwiania debugowania kodu przez użytkownika są: Obsługa przez debugowany program opcji -d podawanej z linii komend (trzeba ją oprogramować samemu) Użycie w programie możliwości rejestracji zachowania programu przez funkcję syslog (patrz man 3 syslog) Program gdb gdb jest programem umożliwiającym śledzenie wykonywania innych programów. Śledzony program powinien być skompilowany z opcją -g by do kodu wynikowego dołączyć informacje istotne dla debugera. gcc -g -o debug3 debug3.c gdb debug3 komenda (gdb) help wyświetla dostępne komendy (patrz: man gdb i info gdb). 4
Program gdb gdb uruchamianie Rozpoczynamy wykonanie programu komendą (gdb)run. Argumenty polecenia (gdb)run zostaną przekazane testowanemu programowi. W naszym przypadku pojawi się błąd segmentacji wraz z linią, w której po raz pierwszy ten błąd wystąpił. gdb śledzenie stosu Komenda (gdb)backtrace pozwala śledzić stos wywołań aż do miejsca, w którym jesteśmy. Używane do programów z większą liczbą wywołań podprogramów i funkcji. gdb badanie zmiennych Wypisywania wartości zmiennych: (gdb)print zmienna. Rezultaty są przechowywane w pseudozmiennych $<liczba>, ostatni rezultat to $, a przedostatni $$. Sprawdzamy (gdb)print j, problem sprawiło: a[4]=a[4+1]. Spróbuj: (gdb)print a[$-1].klucz lub print a[3] (gdb)print a[$-1] 5
Program gdb cd gdb listing programu Polecenie (gdb)list wypisuje fragment programu wokół bieżącej pozycji. Widzimy, że zmienna j nie powinna przyjmować wartości 4. Zmieńmy więc linię 23. na następującą: /* 23 */ for( j=0; j<n-1; j++ ) { Po kompilacji i uruchomieniu (patrz debug4.c) program działa ale wynik nie jest niepoprawny. Sprawdźmy co robi w trakcie dziłania. gdb puntky przerwania gdb posiada komendy pozwalającę wstawiać i kontrolować tzw. punkty przerwania. Zobacz (gdb)help breakpoint. My wstawimy punkt przerwania w linii 21.: (gdb)break 21 uruchomimy program (gdb)run. Program zatrzyma się w na linii 21. Wypisujemy stan tablicy: (gdb)print tablica[0]@5 i kontynuujemy do natępnego przerwania (gdb)cont. 6
Program gdb jeszcze Wypisanie stanu tablicy za każdym razem, gdy program się zatrzyma: (gdb)display tablica[0]@5. Używając komendy (gdb) commands. ustalamy, że program ma wykonać cont za każdym razem, gdy dojdzie do bieżącego punktu przerwania. Analiza zachowania programu doprowadza nas do wniosku, że zewnętrzna pętla nie wykonuje się tyle razy ile powinna. Podejrzewamy, że winna temu jest linia 31. Spróbujmy to sprawdzić. gdb łatanie Łatą nazywamy kod, który musimy dodać do programu aby działał poprawnie. Stosując punkty przerwania możemy sprawdzić łatę zanim zmienimy kod źródłowy. Wyłączamy poprzednio ustawiony punkt przerwania i związane z nim wyświetlanie: (gdb)disable break 1 i (gdb)disable display 1. Ustawiamy przerwanie w linii 31. i kojarzymy z nim komendy: set variable n = n+1 i cont. Po uruchomieniu program zadziała poprawnie. 7
Program patch Dystrybucja nowych wersji programów jest kłopotliwa (zwłaszcza, gdy dostarczamy wersje binarne). Oprogramowanie typu Open Source znacznie ułatwia dystrybucję nowych wersji. Zamiast udostępniać nową wersję w pełnej (wielo-mb) postaci, udostępnia się jedynie różnice pomiędzy wersjami. Program diff służy do tworzenia plików zawierających różnice między źródłami wersji. diff plik1.txt plik2.txt > ró?nice.txt Łatanie programu, tj. uaktualnianie źródeł można wykonać tak: patch plik1.txt ró?nice.txt Odwracanie tego procesu: patch -R plik1.txt ró?nice.txt Zadania: 1. Zrobić łatę/łaty dla przykładu z gdb 2. Zrobić łatę do programu składającego się z wielu plików - uwzględnić łatanie w pliku Makefile 8
Testy pokrycia Jedynym sposobem potwierdzenia poprawności programu jest udowodnienie, że dla każdej możliwej wartości danych wejściowych program zwraca poprawny wynik. Dla większości programów, z wyjątkiem najprostszych, jest to zadanie tak skomplikowane, że praktycznie jest niewykonalne. Kompromisem nieograniczającym zakresu testów są tzw. testy pokrycia. Idea testów pokrycia polega na próbie oszacowania, jaka część kodu została wykonana podczas testów. Jeżeli w czasie testów wykonana została każda część programu, ich wyniki uznamy za bardziej godne zaufania. Istnieją trzy rodzaje testów pokrycia: Pokrycie instrukcji Pokrycie rozgałęzień programu Pokrycie danych 9
Pokrycie instrukcji Pokrycie instrukcji polega na sprawdzeniu czy podczas testów została wykonana każda linijka programu. Pokrycie instrukcji ma wadę: nie bierze się w nim pod uwagę wzajemnego oddziaływania części programu. Przykład: 1 :int f(int a,int b){ 2 : int r = 1; 3 : if(a>0){ 4 : r = 0; 5 : }; 6 : if(b>0){ 7 : r = 3/r; 8 : } 10: return r; 11:} Linie 1, 2, 3, 6 i 10 są testowane zawsze. Aby pokryć linię 4 należy przetestować f(1,0), natomiast by pokryć linię 7, f(0,1). Teraz nasz test pokrywa już wszystkie instrukcje programu. Co się jednak stanie gdy sprawdzimy wywołanie f(1,1)? Ze względów formalnych należy także przetestować f(0,0). 10
Pokrycie rozgałęzień i danych Test pokrycia rozgałęzień programu polega na rozważeniu wszystkich możliwych ścieżek działań programu. Liczba możliwych ścieżek programu znacznie wzrasta w miarę dodawania pętli i instrukcji warunkowych. Powoduje to także wzrost liczby testów do ich pełnego pokrycia. Test pokrycia danych obejmuje testowanie każdej możliwej kombinacji użytych danych. Istnieje kilka narzędzi do badania stopnia pokrycia badanego programu za pomocą przeprowadzonych testów. Narzędzia te pracują na zasadzie zwbogacania testowanego programu w trakcie kompilacji. Dodatkowy kod służy do gromadzenia informacji o tym, które instrukcje były wykonywane i jak często. Ponieważ narzędzia te działają na poziomie instrukcji, kod programu powinien być tak napisany by każda linia zawierała najwyżej jedną istrukcję. Złym rozwiązaniem jest umieszczanie instrukcji warunkowej w jednej linii lub stosowanie makr zawierających wyrażenia warukowe. 11
Narzędzie gcov gcov jest narzędziem do testowania pokrycia instrukcji programu. Aby korzystać z gcov, należy przygotować specjalną wersję badanej aplikacji (podobnie jak dla gdb). Do przygotowania kodu musimy użyć kompilatora C w wersji GNU ze specjalnymi opcjami linii poleceń: -fprofile-arcs zmusza kompilator do umieszczania w testowanym programie dodatkowego kodu, pozwalającego rejestrować, która instrukcja jest wykonywana. Informacja taka będzie zapisywana w pliku o nazwie takiej jak plik źródłowy z końcówką.da -ftest-coverage prócz pliku z kodem obiektowym (.o) powstaną pliki o końcówkach.bb i.bbg, zawierające zapis struktury rozgałęzień kodu źródłowego. Używane są przez gcov do tworzenia mapy działania programu. -fbranch-probabilities opcja ta powoduje optymalizację śledzenia rozgałęzień. 12
Narzędzie gcov przykład W katalogu testy znajduje się przykładowa aplikacja obliczająca wyrażenia arytmetyczne zadane w Odwrotnej Notacji Polskiej. Aplikacja składa się z kilku plików zawierających funkcje zewnętrzne, pliku Makefile oraz plików test[0-6] zawierających testowe wyrażenia. Przykładowy przebieg testów: Kompilacja aplikacji: $ make Wykonanie testów: $ make test Wykonanie analiz pokrycia: $ make gcov Wykonanie kasowania liczników: $ make clean_da Analizując testy aplikacji należy szczególną uwagę zwrócić na: Opcje polecenia gcov Wpływ plików *.da na zawartość plików *.c.gcov Analizę rozgałęzień kodu (patrz opcja -b polecenia gcov i instrukcje rozgałęziające: if, case, for, while) 13
Testowanie wydajności Ważnym aspektem testowania jest wydajność. Aplikacja prócztego, że musi działać poprawnie musi być użyteczna, czyli oddawać wyniki w rozsądnym czasie. Stąd ważne jest znajdowanie w programie takich miejsc, w których traci się najwięcej czasu. Narzędziem, które może nam pomóc w testowaniu wydajności jest gprof. Aby przygotować kod programu dla gprof należy go skompilować z opcją -pg, następnie uruchomić program i po nim wywołać gprof z nazwą programu. Przykładowe wywołania w plikach Makefile w katalogach testy i testy2. Po uruchomieniu programu powstaje plik gmon.out, zawierający zapis profilu działania. Program w trakcie działania zapisał w nim wyniki pomiarów czasu spędzonego w każdej z funkcji. Program gprof może gromadzić dane z wielu uruchomień badanego programu. Aby skorzystać z tej możliwości, należy użyć opcji -s w wywołaniu gprof. Informacja o profilu będzie wówczas gromadzona w pliku gmon.sum. 14