Rozdział 11. Narzędzia do testowania
Jak już wiemy, informacja śledząca w naszym programie pomaga zlokalizować miejsca występowania błędów. Oznacza to, że robimy wszystko, co w naszej mocy, aby uniknąć zawieszania się programu w rękach nieprzewidywalnego użytkownika. Jak dotąd — szło nam całkiem nieźle, ale czyż możemy wiedzieć z całkowitą pewnością, że program robi to, co powinien? Rozpoczęliśmy od dokładnie określonych wymagań użytkownika. Oczywiście, pamiętamy o nich podczas tworzenia programu, lecz bardzo ważne jest formalne sprawdzanie programu po wykonaniu jakichś prac. Niezależnie od zapisów w umowie wyznaczyliśmy sobie pewne zdefiniowane cele i musimy wiedzieć wszystko o tym, czego nie udało się osiągnąć. Jeśli tak się stało, musimy także wiedzieć, dlaczego. Temu właśnie służy testowanie.
Zbyt często testowanie jest pozostawiane na koniec projektu. Chociaż wiąże się ono z wyszukiwaniem błędów, to dobrym pomysłem jest wcześniejsze zaplanowanie testów. Widzieliśmy już we wcześniejszych rozdziałach, że podczas tworzenia naszej aplikacji można zrobić wiele rzeczy, które mogą się przydać w późniejszych etapach. Jako przykład można podać wprowadzenie takiego stylu pisania programu, który ułatwi wyszukiwanie błędów oraz odpowiednio wczesne utworzenie środowiska testowego. Zanim zbliżymy się do końcowej wersji naszego programu, powinniśmy intensywnie przetestować wszystkie wersje pośrednie.
Testowanie wymagań
Aby mieć pewność, że nasza wersja aplikacji jest gotowa do wdrożenia, powinniśmy sprawdzić, czy zostały spełnione wszystkie wstępnie zdefiniowane wymagania. Dla każdego z nich powinniśmy przeprowadzić test lub zestaw testów, który to pokaże.
Musimy tak działać, aby zostały spełnione różne rodzaje wymagań. Należą do nich:
Wymagania funkcjonalne — funkcje, które musi wykonywać oprogramowanie.
Wymagania wydajności — szybkość, rozmiary danych i przepustowość.
Wymagania pewności — odporność na błędy.
Wymagania utrzymaniowe — łatwość dokonywania zmian i udzielania pomocy.
Wymagania zgodności — możliwość pracy na różnym sprzęcie, czytanie danych we wszystkich potrzebnych formatach, zgodność z obcymi standardami.
Wymagania dotyczące interfejsu — poprawność komunikacji z innymi systemami.
Wymagania użytkowe — łatwość obsługi, stopień złożoności interfejsu.
W tym rozdziale spróbujemy opisać narzędzia i techniki, których można użyć do uporządkowania procesu testowania. Dzięki testom będziemy mogli udostępnić aplikację z pełnym przekonaniem, że spełnia ona potrzeby użytkowników. Zajmiemy się zwłaszcza testowaniem funkcjonalności, pewności i wydajności.
Architektura aplikacji
|
Po podziale aplikacji na trzy warstwy możemy budować osobno każdą z nich, co pomoże nam w testowaniu. Każda warstwa jest niezależna od pozostałych, a ich współdziałanie między nimi opiera się na czytelnie zdefiniowanym interfejsie.
W przypadku naszej wypożyczalni płyt DVD pierwszym etapem było zdefiniowanie API, którego zamierzaliśmy użyć w celu uzyskania odpowiedniej funkcjonalności systemu. Po wykonaniu tego zadania można było tworzyć GUI niezależnie od bazy danych. W rzeczywistości utworzyliśmy więcej niż jeden zestaw danych. Pierwszy zestaw był zestawem wzorcowym wykorzystującym prosty plik tekstowy — był on użyty do sprawdzenia, że zestaw API jest kompletny. Skorzystaliśmy z niego także przy sprawdzaniu działania GUI i na jego podstawie utworzyliśmy początkową wersję aplikacji. Po zastosowaniu pełnej bazy danych można było ją „wsunąć” pod API. Dzięki API uzyskujemy uogólniony dostęp do bazy danych, niezależny od jej rodzaju.
Warstwowa struktura może być także zastosowana przy testowaniu aplikacji. Można utworzyć prosty pomocniczy interfejs do testów użytej bazy danych. Program wykorzystujący wiersz poleceń może być użyty do testów poleceń i sygnalizacji wyników jeszcze przed utworzeniem interfejsu graficznego. Postępując w taki sposób, możemy prowadzić testy równolegle z tworzeniem aplikacji, zwiększając w ten sposób szanse na wczesne wykrycie i usunięcie błędów.
Etapy testowania
Definiowanie API.
Tworzenie bazy wzorcowej (prosty plik tekstowy).
Tworzenie interfejsu z wierszem poleceń dla API.
Wdrożenie bazy wzorcowej i poprawa znalezionych błędów.
Wdrożenie i testowanie GUI na bazie wzorcowej.
Wprowadzenie końcowej bazy danych.
Ponowne przeprowadzenie testów (4) na końcowej bazie danych.
Testowanie GUI na końcowej bazie danych.
Wydanie wersji 1.0.
Poprawa błędów i ponowne wydanie wersji.
W tym rozdziale zapoznamy się z narzędziami służącymi do zarządzania etapami 3, 4 i 6.
Testy ogólne
Jeżeli architektura naszej aplikacji jest zgodna z wzorcem opisanym w poprzednim podrozdziale, można zatem utworzyć zestaw programów testujących kolejno powstające części API. Aplikacja wzorcowa dla obsługi wypożyczalni DVD była testowana właśnie za pomocą takich prostych programów. Jeden z nich o nazwie testtitle zostanie użyty także w tym rozdziale. Wywołuje on kilka funkcji API i wyświetla wynik ich działania. Oto niewielki fragment głównej funkcji testującej o nazwie test_titles, który pokazuje sposób wyszukiwania danych:
int test_titles()
{
dvd_title dvd,
char **genres = NULL;
char **classes = NULL;
int ngenres = 0, nclasses = 0;
int err =+ DVD_SUCCESS;
int count = 0;
int *results;
int i = 1;
show_result("get_genres", dvd_get_genre_list(&genres, &ngenres));
show_result("get_classes", dvd_get_classification_list(&classes, &nclasses));
...
/* Teraz szukamy i wyświetlamy wynik */
show_result("name search", dvd_title_search(NULL, "Jean", &results, &count));
printf("Searched for name \"Jean\": \n");
for(i = 0; i < count; i++) {
dvd_title_get(result[i], &dvd);
print_title(&dvd);
}
free(results);
return DVD_SUCCESS;
}
Program ten jest przydatny na etapie budowy aplikacji, gdy mamy jeszcze niezbyt wiele API. Można go także wykorzystać do okresowego sprawdzania, czy nie występuje poważny błąd w aplikacji. Po uruchomieniu program wyświetla zapis wykonanych operacji i wyniki wywołań API (oczywiście, można to zachować dla późniejszego porównania z wynikami otrzymanymi po jakichś modyfikacjach):
$ ./testtitle
creating dvd title 1
creating dvd title 2
...
created dvd title 25
dvd_open_db: no error
get_genres: no error
name search: no error
Searched for name "Jean":
DVD Title #1: Grand Illusion
Directed by Jean Renoir (1938), Rated: U, Action
Starring: Jean Gabin
ASIN 0780020707, Price 29.99
DVD Title #5: The 400 Blows
Directed by Francois Traffaut (1959), Rated: 12, Education
Starring: Jean-Pierre Leaud
ASIN 1572525320, Price 23.98
...
test_titles: no error
Dane testowe obejmują 25 tytułów filmów, które są używane wyłącznie w tym celu.
Testy regresyjne
Po dokonaniu jakiejkolwiek zmiany w naszej aplikacji zwiększamy ryzyko wystąpienia problemów lub błędów w kodzie, który powinien działać. Jednym ze sposobów sprawdzenia, że wszystko działa należycie, jest ponowne uruchomienie testów, które poprzednio zakończyły się pomyślnie. Takie postępowanie nazywa się testowaniem regresyjnym (ang. regression testing). Może ono być bardzo czasochłonne i nie wymagające dużego wysiłku intelektualnego, a więc trzeba je zautomatyzować. Dzięki automatyzacji możliwe jest wielokrotne wykonywanie tych samych testów.
W systemach UNIX dostępne są darmowe narzędzia do wykonywania testów regresyjnych. Niektóre z nich mogą symulować dość skomplikowane warunki pracy przy dużym obciążeniu.
W naszym przypadku jeden z prostych sposobów automatyzacji testów regresyjnych polega na wykorzystaniu programów, takich jak wspomniany testtitle, oraz na użyciu pliku makefile i programu make do automatycznego ich uruchamiania i sprawdzania wyników.
Mamy zamiar uruchomić kilka programów testowych i przechwycić ich wyniki do pliku. Następnie, po dokonaniu zmian w aplikacji, ponownie chcemy uruchomić te programy i sprawdzić, czy ich wyniki się nie zmienią. Bardzo prosty plik makefile (lub kilka wierszy dodanych do istniejącego pliku) w znacznym stopniu ułatwią ten proces.
A oto przykład:
TPROGS = testmember testtitle
all: $(TPROGS)
flatfile.o: dvd.h
testmember.o: dvd.h
testtitle.o: dvd.h
testmember: testmember.o flatfile.o
testtitle: testtitle.o flatfile.o
expected: $(TPROGS:=.expected)
%.expected : %
$< > $@
check: $(TPROGS:=.out)
%.out : %
$< > $@
diff $@ $(@:.out=.expected)
Wiersz expected uruchamia każdy z programów testowych zdefiniowanych w zmiennej TPROGS i przechwytuje ich wyniki do pliku o nazwie mającej rozszerzenie .expected. Programy te można by uruchamiać na zakończenie każdego testowania, gdy wiemy, że wyniki są już poprawne. Aby przeprowadzić test regresyjny, uruchamiany jest wiersz check, który testuje zmienioną aplikację i przechwytuje wyniki do pliku oznaczonego rozszerzeniem .out. Zawartość plików jest porównywana za pomocą programu diff. Jeżeli występują w nich jakieś różnice, wówczas make przerywa działanie. Oto przykład sesji testowej:
$ make expected
testmember > testmember.expected
testtitle > testtitle.expected
$
W naszym przykładzie nie przechwytujemy standardowych błędów, lecz tylko standardowe wyjście. Przechwycenie obydwu wyników wymaga dokonania niewielkiej poprawki, która polega na skierowaniu obydwóch strumieni do różnych plików i porównywaniu obydwóch wyników z przyszłymi wynikami.
Jeśli wprowadzimy zmiany w aplikacji, skompilujemy ją i ponownie uruchomimy programy testowe, to musimy sprawdzić, czy nowe dane wyjściowe pasują do danych uzyskanych poprzednio:
$ make check
testmember > testmember.out
diff testmember.out testmember.expected
testtitle > testtitle.out
diff testtitle.out testtitle.expected
$
Jeśli coś zaburzy funkcjonalność obsługi klienta (tak, że dane wyjściowe nie będą pasowały do danych uzyskanych we wcześniejszych testach), to zobaczymy wypisane różnice i program make zatrzyma się:
$ make check
testmember > testmember.out
diff testmember.out testmember.expected
7c7
< No. 10002: Mr Ben Matthew
---
> No. 10022: Mr Ben Matthew
make: *** [testmember.out] Error 1
$
Jeżeli mamy zamiar korzystać z takich zautomatyzowanych testów regresyjnych, musimy pamiętać, że należy przechwytywać i porównywać tylko te dane wyjściowe, które nie powinny się zmieniać podczas kolejnych testów. Zapominanie o tym może stwarzać problemy np. z zapisami czasu, dat lub programowo generowanymi numerami seryjnymi, bowiem mogą być one różne podczas kolejnych testów. Proste porównywanie wyników za pomocą programu diff można zastąpić bardziej wymyślnymi porównaniami lub wymyślić samemu bardziej skomplikowane skrypty.
Program testujący
Program testujący, z którym mieliśmy dotychczas do czynienia, był bardzo prosty. Wystarczało to do niektórych zastosowań. Dla większych aplikacji może być jednak konieczne opracowanie programu testowego o bardziej ogólnym charakterze, który będzie mógł obsługiwać testy o większym stopniu komplikacji.
Oto fragment ogólnego programu testującego przeznaczonego do testów API aplikacji obsługującej wypożyczalnię płyt DVD. Program ten jest obsługiwany przez użytkownika interaktywnie. Użytkownik (lub skrypt utworzony dla celów testowych) wypisuje polecenie, a program wywołuje odpowiednią funkcje API i zwraca wynik. Dzięki temu można wykorzystywać go dla różnych danych testowych.
Polecenia programu testującego mają następującą postać:
polecenie pod-polecenie argument,argument,...
Jako przykłady można podać:
title get 6
title search Seven,Kurosawa
Teraz zajmiemy się kodem tego programu.
Nagłówki i deklaracje
Najpierw dołączamy nagłówki biblioteki i deklarujemy funkcje, które będą wywoływane później:
/*
Program testujący API aplikacji dla wypożyczalni DVD
*/
#include <stdlib.h>
#include <stdio.h>
#include <readline/readline.h>
#include <string.h>
#include "dvd.h"
int show_result(char *, int);
int exclude_command(char *);
void initialize_readline(void);
main()
Funkcja main otwiera bazę danych DVD i działa w pętli, odczytując polecenia, uruchamiane następnie za pomocą wywołania execute_command. Użyto tu biblioteki readline, dzięki czemu można modyfikować wiersz poleceń i korzystać z historii poleceń, co ułatwia obsługę programu.
int main()
{
char *command;
printf("DVD Store Application\n");
dvd_open_db();
/* Nie otwierać bazy danych, będą testy */
/* printf("Warning, database is not open\n"); */
/* Inicjacja interfejsu wiersza poleceń */
initialize_readline();
/* Główna pętla, odczyt i uruchomienie poleceń */
while(1) {
command = readline("> ");
if(command == NULL)
break;
if(*command != '0') {
add_history(command);
show_result("!", execute_command(command));
}
free(command);
}
exit(EXIT_SUCCESS);
}
void initialize_redline()
{
/* Wyłączenie uzupełniania nazw plików klawiszem TAB */
rl_blind_key('\t', rl_insert);
}
show_result()
Wyniki wywołań funkcji API są podawane przez funkcję show_result, która dekoduje wszelkie błędy i wyświetla ich opis:
int show_result(char *msg, int err)
{
char *err_msg;
(void) dvd_err_text(err, &err_msg);
printf("%s: %s\n", msg, err_msg);
return err == DVD_SUCCESS;
}
Interfejsy programowe (API)
Interfejsy programowe (API) są podzielone na grupy dotyczące obsługi klientów, tytułów i płyt. W ramach każdej grupy występuje jedna funkcja odpowiedzialna za przetwarzanie wszystkich poleceń podrzędnych. Tablica functions łączy wpisane polecenia z właściwą funkcją obsługującą i dodatkowo zawiera wiesz z pomocniczym opisem, wyświetlany po poleceniu help. Dla zachowania zwartości pokazana tu wersja programu nie obsługuje wszystkich wywołań API. Pełna wersja jest dostępna pod adresem ftp://ftp.helion.pl/przyklady/zaprli.zip.
int help_function(int argc, char *argv[]);
int quit_function(int argc, char **argv[]);
int member_function(int argc, char *argv[]);
int title_function(int argc, char *argv[]);
int disk_function(int argc, char *argv[]);
typedef int Func(int, char **);
struct {
char *name;
Func *func;
char *help;
} functions[] = {
{"help", help_function, "summary of functions"},
{"quit", quit_function, "quit the application"},
{"title", title_function, "create, set, get, search titles"},
{"member", member_function, "create, set, get, search members"},
{"disk", disk_function, "create, set, get, search disks"},
{NULL, NULL, NULL}
};
int help_function(int argc, char *argv[])
{
int f;
printf("These functions are available:\n");
for(f = 0; functions[f].name; f++)
printf("%s \t%s\n", functions[f].name, functions[f].help);
printf("To get more help try <command> help\n");
return DVD_SUCCESS;
}
quit_function()
Jest to najprostsza funkcja, którą można tu zastosować. Służy ona do zatrzymania programu:
int quit_function(int argc, char *argv[])
{
dvd_close_db();
exit(EXIT_SUCCESS);
}
print_title(), title_function()
Podane niżej funkcje obsługują polecenia związane z tytułami płyt DVD. Pierwsza z nich o nazwie print_title wyświetla szczegółowe informacje o płycie. Następna funkcja, title_function, obsługuje wszystkie polecenia związane z tytułem płyty. Pokazujemy tutaj tylko pobieranie szczegółów dotyczących płyty oraz wyszukiwanie tytułu w bazie danych:
void print_title(dvd_title *dvd)
{
printf("DVD Title #%d: %s\n", dvd -> title_id, dvd -> title_text);
printf("Directed by %s (%s), Rated: %s, %s\n", dvd -> director,
dvd -> release_date, dvd -> classification, dvd -> genre);
printf("Starring: %s %s\n", dvd -> actor1, dvd -> actor2);
printf ("ASIN %s, Price %s\n", dvd -> asin, dvd -> rental_cost);
}
int title_function(int argc, char *argv[])
{
if(argc < 2)
return DVD_ERR_NOT_FOUND;
if(argc == 3 && strcmp(argv[1], "get") == 0) {
dvd_title dvd;
if(show_result("title get", dvd_title_get(atoi(argv[2]), &dvd)))
print_title(&dvd);
}
if(argc == 4 && strcmp(argv[1], "search") == 0) {
int count;
int *results;
int i;
show_result("title search",
dvd_title_search(argv[2], argv[3], &results, &count));
for(i = 0; i < couint; i++)
printf("[%d]",results[i]);
printf("\n");
}
else {
return DVD_ERR_NOT_FOUND;
}
return DVD_SUCCESS;
}
member_function(), disk_function()
Następne funkcje do obsługi poleceń są zbudowane w podobny sposób i podajemy tutaj tylko ich szkielety:
int member_function(int argc, char *argv[])
{
return DVD_SUCCESS;
}
int disk_function(int argc, char *argv[])
{
return DVD_SUCCESS;
}
execute_command
Polecenia są uruchamiane za pomocą funkcji execute_command, która dzieli wiersz poleceń na argumenty i wywołuje odpowiednie funkcje obsługujące polecenia:
int execute_command(char *command)
{
/* Podział polecenia na elementy oddzielone przecinkami*/
char *string = command;
char *token;
char *items[20];
int item = 0, i;
char *cmd1, *cmd2;
int f;
/* Polecenia zawierają albo pojedyncze słowo albo dwa słowa,
za którymi następuje lista argumentów oddzielonych przecinkami */
cmd1 = strsep(&string, " ");
items[item++] = cmd1;
cmd2 = strsep(&string, " ");
if(cmd2 == NULL)
items[item] = NULL;
else
items[item++] = cmd2;
if(cmd2) {
/* Podział argumentów */
while(1) {
token = strsep(&string,",");
if(token == NULL) {
/* Ostatni element */
/* items[item++] = string; */
break;
}
else
items[item++] = token;
};
items[item] = NULL;
}
for(i = 0; i < item; i++)
printf)"[%s]", items[i]);
printf("\n");
/* Teraz wywołanie odpowiedniej funkcji dla cmd1 */
for(f = 0; functions[f].name != NULL; f++) {
if(strcmp(cmd1, functions[f].name) == 0) {
(*functions[f].func)(item, items);
break;
}
}
if(functions[f].name == NULL)
return DVD_ERR_NOT_FOUND;
return DVD_SUCCESS;
}
Testowanie programu dvdstore
Program dvdstore wykorzystuje tylko kilka funkcji API, ale pokazuje niektóre kluczowe właściwości użytecznego programu testującego. Po pierwsze — zawiera on informacje pomocnicze, które zawsze warto wstawić do programu, nawet gdy są wykorzystywane tylko przez programistę (bardzo łatwo można zapomnieć, co miał robić jakiś program testujący). Po drugie — wykorzystano w nim bibliotekę GNU readline, dzięki czemu można modyfikować wiersz poleceń i korzystać z historii poleceń, a więc można się cofnąć i wprowadzić takie samo (lub podobne) polecenie, jak wcześniej. Sprawdźmy więc jego działanie:
$ ./dvdstore
DVD Store Application
> title get 6
[title][get][6]
title get: no error
DVD Title #6: Beauty and The Beast
Directed by Jean Cocteau (1946), rated: 18, Thriller
Starring: Jean Marais
ASIN 0780020715, Price 39.95
!: no error
> title search Seven,Kurosawa
[title][search][Seven][Kurosawa]
title search: no error
[2][11]
!: no error
> title get 2
[title][get][2]
title get: no error
DVD Title #2: Seven Samurai
Directed by Akira Kurosawa (1954), Rated: 12, Comedy
Starring: Takashi Shimura Toshiro Mifune
ASIN 0780020685, Price 27.99
!: no error
> quit
[quit]
$
Nie wygląda to może bardzo elegancko, ale spełnia swoje zadanie. Osoba testująca otrzymuje wiele informacji i jest zachęcana do wydania następnego polecenia. Można to wykorzystać przy automatyzacji testowania, odpowiednio reagując na zwracane tytuły i sprawdzając, czy mają one sens. Widzimy tu także klasyfikację filmu oraz jego rodzaj.
Testy w postaci skryptów
Możemy rozpocząć pisanie skryptów dla naszego programu testującego, zapisując polecenia do pliku i skorzystać z funkcji przekierowania wejścia w powłoce. Niech nasz plik zawiera następujące wiersze:
title get 6
title search Seven,Kurosawa
title get 2
quit
Uruchomimy teraz test za pomocą polecenia:
$ ./dvdstore <script
Wszystko to wygląda bardzo ładnie, ale utraciliśmy możliwość reagowania na otrzymane wyniki. Aby można było z tego skorzystać, musimy użyć bardziej wydajnego mechanizmu skryptowego.
expect
Program expect jest programem pomocniczym rozpowszechnianym na licencji GNU. Umożliwia on zautomatyzowanie dialogu użytkownika z programem obsługiwanym interaktywnie, czyli takim jak nasz testowy program dvdstore. Narzędzie to może uruchamiać inne programy, przesyłać do nich polecenia, odczytywać wyniki i działać zgodnie z tym, co odbierze. Wykorzystano w nim język skryptowy ogólnego przeznaczenia o nazwie Tcl i dzięki temu uzyskano doskonałą wydajność. Program expect może być także wywoływany bezpośrednio z kodu w języku C i C++.
Należy sprawdzić, czy w danej dystrybucji Linuksa występuje dokumentacja programu expect lub pobrać ją z sieci: http://expect.nist.gov lub http://www.gnu.org.
Poświęcimy bardzo mało miejsca na opis uruchomienia programu expect, pokazując przykład jego współpracy z naszym programem testującym. W tym celu utworzymy skrypt, który będzie uruchamiał program, wyszukiwał wszystkie płyty DVD i wyświetlał szczegółowe wyniki dla znalezionych tytułów:
#!/user/bin/expect
# Skrypt wyszukujący według danego tytułu i reżysera
if $argc<2 {
send_user "usage: search.exp title name\n"
exit
}
# Ustawienie wzorców wyszukiwania na podstawie argumentów
set title [lindex $argv 0]
set name [lindex $argv 1]
# Pętla obejmująca wyświetlanie zwróconych numerów tytułów
proc print_titles {1} {
send_user "in print: $1"
set list [split $l \[\]]
foreach t $list {
if [llength $t] {
send "title get $t\n"
expect ">"
}
}
}
# Uruchomienie programu, oczekiwanie na pierwszy znak zachęty
spawn ./dvdstore
expect DVD
expect ">"
# Wyszukiwanie
send "title search $title,$name\n"
expect -re \n
expect -re \n
expect -re \n
expect -re \n {
set titles $expect_out(buffer);
expect ">"
print_titles $titles
}
# Wszystko gotowe
send "quit\n"
expect eof
A oto przykładowy wynik uruchomienia skryptu:
$ ./search.exp Seven Kurosawa
spawn ./dvdstore
DVD Store Application
> title search Seven, Kurosawa
[title][search][Seven][Kurosawa]
title search: no error
[2][11]
!: no error
> in print: [2][11]
title get 2
[title][get][2]
title get: no error
DVD Tit;e #2: Seven Samurai
Directed by Akira Kurosawa (1954), Rated: 12, Comedy
Starring: Takashi Shimura Toshiro Mifune
ASIN 0780020685, Price 27.99
!: no error
> title get 11
[title][get][11]
title get: no error
DVD Title #11: The Seven Seal
Directed by Ingmar Bergman (1957), Rated: 12, Science Fiction
Starring: Gunnar Bjornstrand Max Von Sydow
ASIN 6305174083, Price 27.99
!: no error
> quit
[quit]
$
Po uruchomieniu skryptu uruchamia on aplikacje dvdstore, szuka płyt DVD zawierających w tytule słowo Seven lub reżyserowanych przez Kurosawa, a następnie przegląda wszystkie znalezione numery tytułów, wyświetlając szczegóły. Dzięki temu współdziała on dobrze ze zmianami w bazie danych, które mogą powstać w wyniku zmian numeru tytułu DVD. W prosty sposób można także zignorować nieistotne informacje, takie jak np. czas i data.
Wykorzystując expect i ogólny program testujący tak jak pokazano, można zastąpić specyficzne programy testujące (takie np. jak opisany wcześniej test_titles). Skrypty programu expect można także łatwo włączać do plików makefile, dzięki czemu pojawia się możliwość bardzo wydajnego testowania regresyjnego.
Problemy z pamięcią
Programy wykonują operacje na danych. Dane mogą być przechowywane w sposób trwały, np. w plikach na dysku, ale prawie zawsze są przetwarzane w pamięci. Zmienne globalne, zmienne lokalne oraz argumenty funkcji są przetrzymywane w pamięci. Trudno się więc dziwić, że tak wiele błędów w programach wiąże się z błędami użycia tego lub innego rodzaju pamięci.
Każdy program ma dostęp do trzech rodzajów pamięci. Każda z nich jest przeznaczona do innych celów, a także jest przydzielana i zarządzana w odmienny sposób. Mamy więc:
pamięć statyczną,
stos,
pamięć dynamiczną (stertę).
Pamięć statyczna
Po zadeklarowaniu w programie zmiennej globalnej, w momencie jego uruchomienia następuje przydział lokalizacji dla tej zmiennej. Jeżeli odwołujemy się do tej zmiennej, to używana jest właśnie ta lokalizacja. Wszystkie zmienne globalne mają zazwyczaj przydzielane sąsiednie obszary pamięci, które nie zmieniają się podczas pracy programu.
Jest to tzw. pamięć statyczna (ang. static memory). Jeżeli program zaczyna wstawiać dane poza obszar jakiejś zmiennej globalnej, prawdopodobnie zaburzy to wartość innej zmiennej globalnej przechowywanej w sąsiednim obszarze. Błędy tego rodzaju są bardzo trudne do wykrycia.
Stos
Drugim rodzajem pamięci wykorzystywanym przez program jest stos (ang. stack). Jeśli działa program napisany w języku C, to korzysta on intensywnie ze stosu procesora do przechowywania zmiennych lokalnych i adresów powrotnych używanych po zakończeniu działania funkcji lokalnej.
Wszystkie zmienne lokalne w ramach danej funkcji (włączając w to także funkcję main) korzystają z tego samego stosu, a więc błędnie napisany kod programu może zaburzać wartości zmiennych lokalnych używanych przez inne funkcje. Błędy tego rodzaju, nazywane przepełnieniem stosu (ang. stack overwriting), występują najczęściej przy niewłaściwym korzystaniu z tablic lokalnych.
Podany niżej program oraz jego omówienie odnosi się tylko do implementacji Linuksa dla procesorów firmy Intel. Inne systemy operacyjne działające na innych architekturach mogą się zachowywać zupełnie odmiennie.
Strukturę stosu programu napisanego w języku C można obejrzeć, sprawdzając zawartość pamięci za pomocą debuggera (np. GDB). Tę zawartość można także sprawdzić, pisząc program działający poza obszarem funkcji.
#include <stdlib.h>
#include <stdio.h>
/* Program badający zawartość fragmentu stosu */
void showstack()
{
int local = 0xDD111111;
int *ptr = &local;
int i;
for(i = 18; i >= 0; i--)
printf(“%02d: [%08x] %08x\n”, i, ptr+i, ptr[i]);
/* Teraz rozbijamy main:local2 */
ptr[12] = 0xDD222222;
}
int myfunction(int arg1, int arg2)
{
int local1 = 0xFF111111;
int local2 = 0xFF222222;
int array[2] = {0xFFAA1111, 0xFFAA2222};
showstack();
return 0xFF333333;
}
int main(int argc, char *argv[], char **environ)
{
int local1 = 0x11111111;
int local2 = 0x22222222;
int local3 = 0x33333333;
/* Pobieramy argumenty */
printf("main is at %08x, argc: %d, argv: %08x, env: %08x\n",
&main, argc, argv, environ);
printf("myfunction is at %08x\n", &myfunction);
printf("showstack is at %08x\n", &showstack);
/* Wywołanie z argumentami */
local3 = myfunction(0xAA111111, 0xAA222222);
/* Wznowienie w celu sprawdzenia, gdzie trafił wynik */
local3 = myfunction(0xAA111111, 0xAA222222);
exit(EXIT_SUCCESS);
}
Po uruchomieniu programu zmienne lokalne, argumenty funkcji oraz adresy powrotu są przekazywane na stos po wywołaniu main, myfunction i showstack. W większości systemów komputerowych stos powiększa się w kierunku malejących adresów. Oznacza to, że każda nowa pozycja jest umieszczana na stosie pod niższym adresem niż ma pozycja poprzednia. Wierzchołek stosu ma początkowo wysoki adres w pamięci, który jest stopniowo obniżany przy działaniu programu. Rozpoczynając więc od jakiegoś adresu na stosie i przechodząc do wyższego adresu, powinniśmy widzieć dane z wcześniejszych operacji wykonywanych przez program.
Funkcja showstack pobiera adres jednej ze swoich zmiennych lokalnych w celu odczytu adresu stosu:
int local = 0xDD111111;
int *ptr = &local;
Następnie adres ten jest używany jako adres tablicy pozwalającej na dostęp i wyświetlenie dalszych elementów stosu. Pierwszy wynik może mieć np. następującą postać:
$ gcc -o stackframe stackframe.c
$ ./stackframe
main is at 080484d0, argc: 1, argv: bffff8a4, env: bffff8ac
myfunction is at 08048490
showstack is at 08048410
18: [bffff868] bffff8ac ^ [environ]
17: [bffff864] bffff8a4 | [argv]
16: [bffff860] 00000001 | [argc]
15: [bffff85c] 40038313 | [return address, exit program]
14: [bffff858] bffff878 ++ [stack link]
13: [bffff854] 11111111 | [main:local1]
12: [bffff850] 22222222 | [main:local2]
11: [bffff84c] 33333333 | [main:local3]
10: [bffff848] aa222222 | [myfunction:arg2]
09: [bffff844] aa111111 | [myfunction:arg1]
08: [bffff840] 0804853c | [return address:main+0x6c]
07: [bffff83c] bffff858 -++ [stack link]
06: [bffff838] ff111111 | [myfunction:local1]
05: [bffff834] ff222222 | [myfunction:local2]
04: [bffff830] ffaa2222 | [myfunction:array[1]]
03: [bffff82c] ffaa1111 | [myfunction:array[0]]
02: [bffff828] 080484b7 | [return address:myfunction+0x27]
01: [bffff824] bffff83c --+ [stack link]
00: [bffff820] dd111111 [showstack:local]
Można tu zaobserwować mechanizm wywoływania funkcji — przykładowe wywołanie funkcji myfunction z funkcji main przebiega następująco:
zapamiętanie bieżącej wartości wskaźnika stosu,
dopisanie argumentów funkcji do stosu w odwrotnym porządku,
dopisanie do stosu bieżącej wartości licznika programu, służącej jako adres powrotny,
dopisanie do stosu wartości wskaźnika stosu.
Takie połączenie adresu powrotnego, łącza ze stosem, argumentów funkcji i zmiennych lokalnych nazywane jest ramką stosu (ang. stack frame).
Argumenty funkcji w języku C są umieszczane na stosie w odwrotnej kolejności, dzięki czemu funkcja pobiera je w kolejności poprawnej. Rozważając adresy fizyczne, są one umieszczane zgodnie z rosnącymi adresami.
Takie uporządkowanie argumentów pozwala na utworzenie funkcji, która korzysta ze zmiennej liczby argumentów, tak jak printf. W takich wypadkach postępujemy podobnie, jak dla pokazanej tu funkcji showstack, czyli pobieramy adres pierwszego argumentu i przemieszczamy się na stosie, pobierając kolejne argumenty. Dla funkcji printf pierwszym argumentem jest napis formatujący, który informuje o liczbie oczekiwanych argumentów. Jeżeli dostarczymy za mało argumentów, to wynik printf będzie nieczytelny, ponieważ będzie zawierał zawartość stosu wykraczającą poza dostarczone argumenty.
Kompilator korzysta z łącznika stosu do odtwarzania jego pierwotnej zawartości po zakończeniu wywołania.
Można obejrzeć zmienne lokalne jakiejś funkcji, łączniki do stosu (ang. stack links), adresy powrotne, argumenty wywoływanej funkcji i te zmienne lokalne innej funkcji, które sąsiadują z innymi zmiennymi na stosie. Dlatego właśnie nie ma pewności, czy nie zostaną przekroczone te granice. Można np. zobaczyć, że odwołując się do array[2] w myfunction faktycznie będziemy odwoływać się do tej samej komórki pamięci, która jest zajęta przez myfunction:local2. Co więcej, ustawiając wartość array[3] będziemy niszczyli myfunction:local1 itd. Tablice lokalne są więc potencjalnie bardzo niebezpieczne, jeżeli przypadkowo wpiszemy wartość elementu o niepoprawnym indeksie.
W naszym przykładzie program showstack celowo zamazuje jedną ze zmiennych lokalnych w main, co można zaobserwować, wywołując showstack powtórnie:
18: [bffff868] bffff8ac
17: [bffff864] bffff8a4
16: [bffff860] 00000001
15: [bffff85c] 40038313
14: [bffff858] bffff878
13: [bffff854] 11111111
12: [bffff850] dd222222 *** main:local2 zaburzone przez showstack
11: [bffff84c] ff333333 *** main:local3 ma wynik z myfunction
10: [bffff848] aa222222
09: [bffff844] aa111111
08: [bffff840] 08048553
07: [bffff83c] bffff858
06: [bffff838] ff111111
05: [bffff834] ff222222
04: [bffff830] ffaa2222
03: [bffff82c] ffaa1111
02: [bffff828] 080484b7
01: [bffff824] bffff83c
00: [bffff820] dd111111
Przekraczanie granic tablic lokalnych ma daleko sięgające skutki, które trudno wyśledzić, więc należy zwracać na to szczególną uwagę.
Śledzenie zaburzeń zmiennych lokalnych dodatkowo komplikuje się przy kompilacji z włączoną optymalizacją. Jeżeli ponownie skompilujemy program showstack, włączając w kompilatorze optymalizację kodu wynikowego, wtedy otrzymamy zupełnie inne wyniki.
$ gcc -o stackframe -O6 stackframe.c
$ ./stackframe
main is at 080484b0, argc: 1, argv: bffff8a4, env: bffff8ac
myfunction is at 0804860
showstack is at 08048410
18: [bffff89c] 40014090
17: [bffff898] bffff89c
16: [bffff894] 4000ac70
15: [bffff890] 080485a8
14: [bffff88c] 080482bc
13: [bffff888] bffff8a4
12: [bffff884] 00000001
11: [bffff880] 080484b0
10: [bffff87c] 08048371
09: [bffff878] 00000000
08: [bffff874] 08048350
07: [bffff870] 00000001
06: [bffff86c] 40013a44
05: [bffff868] bffff8ac [environ]
04: [bffff864] bffff8a4 [argv]
03: [bffff860] 00000001 [argc]
02: [bffff85c] 40038313 [return address: exit program]
01: [bffff858] bffff878 [stack link]
00: [bffff854] dd111111 [showstack:local]
W tym przypadku kompilator mógł nie skorzystać z pamięci dla zmiennych lokalnych w main i w myfunction (albo wykorzystując zamiast niej rejestry, albo po prostu nie rezerwując pamięci, jeśli nie było takiego zapotrzebowania). Usunął on także żądanie adresu powrotnego dla myfunction, ponieważ showstack wykonuje tę czynność na samym końcu. Optymalizacja powoduje więc znacznie mniejsze wykorzystanie stosu.
Teraz zaburzenia wprowadzane w showstack będą działać w innym obszarze niż poprzednio, powodując nieznane skutki. Oznacza to, że symptomy błędów wykorzystania pamięci zależą od konfiguracji kompilatora i od kodu użytego do śledzenia błędów. Bardzo nieładnie!
Jeżeli pojawiają się zaburzenia zmiennych lokalnych, to można użyć pewnej sztuczki. Polega ona na przydzieleniu pamięci dla dużej tablicy lokalnej, jeżeli problemy w podejrzanej funkcji stwarza pierwsza i ostatnia zmienna lokalna. Jeżeli problemy znikną, oznacza to, że ich źródłem jest prawdopodobnie ta właśnie funkcja.
Istnieje wiele komercyjnych narzędzi pomagających wykrywać opisane tu problemy przy dostępie do zawartości pamięci statycznej. Jednym z takich narzędzi jest program „Purify” firmy Rational Software. Użyto w nim specjalnej techniki nazywanej „wstawianiem kodu obiektowego”, dzięki której powstaje wersja programu zawierająca ten dodatkowy kod sprawdzający zawartość oraz czy wszystkie odwołania do pamięci, które występują podczas pracy, są poprawne. Wiąże się to z obniżeniem wydajności, czego można było się spodziewać, ale czasami jest to ostatnia deska ratunku. Podczas pisania tej książki firma Rational Software nie udostępniła programu „Purify” dla Linuksa, ale wyrażała zainteresowanie takim produktem. Najnowszych informacji na ten temat należy poszukiwać pod adresem http://www.rational.com.
Niektóre wersje kompilatora gcc mogą współpracować z programem pomocniczym z serii GNU o nazwie Checker. Program ten wykonuje te same zadania, co Purify, wykorzystuje jednak inny mechanizm. Aby z niego skorzystać, należy skompilować swój program, używając zmodyfikowanego kompilatora zamiast wstawiania kodu obiektowego do już skompilowanej wersji. Aby wykorzystać wszystkie udogodnienia oferowane przez program Checker, należy także użyć specjalnej wersji bibliotek języka C i bibliotek dodatkowych. Podczas pisania tego tekstu Checker był dostępny dla kompilatora gcc 2.8.1 i można było pobrać go ze strony http://www.gnu.org.
Innym sposobem uniknięcia opisywanych tu problemów jest nieużywanie tablic lokalnych w tworzonej i testowanej aplikacji. Jeśli zamiast tego skorzysta się z dynamicznego przydziału pamięci, wówczas będzie można użyć narzędzi utworzonych specjalnie do rozwiązywania problemów z pamięcią dynamiczną. Są one omówione w następnym podrozdziale.
Pamięć dynamiczna
Pamięć dynamiczna (ang. dynamic memory) jest przydzielana przez system Linux podczas pracy programu, zazwyczaj przy wywołaniu jednej z funkcji obsługujących to przydzielanie, czyli malloc, calloc lub realloc. Pamięć dynamiczną można także przydzielić we własnym zakresie, wywołując inne funkcje biblioteczne tak, aby zwracały dane o zmiennych rozmiarach do wywołującego je programu. Jako przykład mogą posłużyć funkcje API dla naszej aplikacji obsługującej wypożyczalnię. Trzeba wywołać free do zwolnienia pamięci przydzielonej na wyniki wyszukiwania, jeżeli zostały one wykorzystane. W języku C++ pamięć dynamiczna jest używana przez operatory new i delete. Dodatkowo można użyć jakiegoś kodu w destruktorach w celu zwolnienia pamięci po usunięciu obiektu.
Pamięć dynamiczna przydzielana przez malloc i funkcje pokrewne bywa często nazywana „stertą” (ang. heap) i jest oddzielona od obszaru zmiennych lokalnych i globalnych zadeklarowanych w programie, od pamięci statycznej i od stosu. Funkcje przydzielające zarządzają pamięcią na stercie, powiększając w razie potrzeby jej rozmiar za pomocą odwołań do systemu operacyjnego. Pamięć zarządzana przez malloc bywa także nazywana „obszarem działania malloc”. Występują różne wersje tej funkcji, ale zazwyczaj malloc przechowuje rozkład bloków pamięci o różnych rozmiarach, dzieląc je w razie potrzeby i przydzielając do wykorzystania programowi, który tego zażąda.
Jeżeli program kontynuuje żądania przydziału pamięci, lecz nie zwalnia jej, to występuje tzw. wyciek pamięci (ang. memory leak). Rozmiary pamięci zajmowanej przez program będą wówczas nieustannie rosły, przekraczając nawet fizyczne rozmiary pamięci w komputerze — doprowadza to do znacznego spadku wydajności, bowiem system jest zmuszony dodatkowo dostarczyć pamięć, korzystając z dysku (występują tu dodatkowe subtelności związane z zarządzaniem pamięcią wirtualną i systemem operacyjnym, ale ogólnie tak to wszystko wygląda). Ostatecznie Linux jest zmuszony do przerwania pracy programu. Inne działające w tym samym czasie programy także mogą ucierpieć. Unikanie wycieków pamięci to nie tylko dobry zwyczaj, to po prostu także grzeczność.
Jeżeli okaże się, że przydział pamięci dynamicznej jest wymagany tylko w jednej funkcji, można skorzystać z alloca. Funkcja ta przydziela blok pamięci żądanego rozmiaru (w bajtach) na stosie i zwraca wskaźnik void *, podobnie jak malloc. Zwrócony wskaźnik zostaje jednak unieważniony, a przydzielona pamięć uwolniona natychmiast po wyjściu z funkcji, która wywołała alloca. Niestety, funkcja alloca nie występuje we wszystkich systemach i dlatego należy ją traktować jako przestarzałą.
Ważne jest, aby zdawać sobie sprawę z tego, że system operacyjny również rejestruje rozmiary pamięci dynamicznej zużywanej przez program (poprzez żądania zwiększenia sterty). Po zakończeniu pracy programu cała pamięć przydzielona mu przez system operacyjny jest automatycznie zwalniana. Tak dzieje się w systemie Linux i w wielu innych systemach klasy UNIX. Inne systemy operacyjne mogą nie działać tak dokładnie. Kuszące wydaje się więc, że w wypadku programów przydzielających niewielkie obszary pamięci i krótko działających można pozwolić na niestaranne przydzielanie pamięci. Nie wolno dać się złapać w taką pułapkę, ponieważ nigdy nie wiadomo, kto jeszcze będzie korzystał z programu. Pewnego dnia może się również zdarzyć, że program trzeba będzie uruchomić na stałe i wówczas przydzielanie i wycieki pamięci będą nieuchronne. Niezależnie od tego, ostrożne postępowanie z pamięcią dynamiczną należy do dobrego obyczaju.
Warto zauważyć, że większość wersji funkcji free nie zwraca pamięci do systemu operacyjnego, ale utrzymuje ją w gotowości do następnego przydziału za pomocą funkcji malloc. Podczas tworzenia aplikacji może się zatem okazać przydatnym śledzenie całkowitego rozmiaru przydzielonej pamięci np. za pomocą funkcji getrusage lub zewnętrznych programów pomocniczych (np. ps lub top).
Jeżeli pamięć jest przydzielana za pomocą funkcji malloc, to jako wynik otrzymujemy wskaźnik sterty. Jeżeli błędnie go użyjemy, to pojawią się niewypowiedziane kłopoty. Przydzielona pamięć stanowi prawdopodobnie część łańcucha bloków pamięci zlokalizowanych w obszarze dostępnym dla malloc. Jeżeli przypadkowo nastąpi zapis poza granice przydzielonego obszaru, to może dojść do zaburzenia sąsiedniego bloku lub zniszczenia struktury używanej przez malloc do zarządzania stertą. Jeśli coś takiego się zdarzy, to możliwość wykrycia takiej sytuacji wystąpi tylko podczas następnego żądania przydziału pamięci lub przy próbie zwolnienia innego bloku odległego od miejsca powstania błędu.
|
Podczas korzystania z pamięci dynamicznej najczęściej spotykanymi błędami są:
Błąd podczas przydziału pamięci po raz pierwszy (nieprzypisany wskaźnik).
Błąd podczas zwalniania przydzielonej pamięci (wycieki).
Zapis poza koniec (lub przed początkiem) bloku przydzielonej pamięci.
Korzystanie z bloku po jego zwolnieniu.
Wszystkie te błędy można wykryć podczas testów z użyciem odpowiednich narzędzi, włączając w to debuggery malloc. Są to zazwyczaj zastępcze biblioteki, które zwierają specjalne wersje malloc i funkcji pokrewnych. Spośród właściwości tych funkcji można wymienić zapis przydziału pamięci w logu, wykrywanie wycieków i pomoc przy wykrywaniu zaburzeń w najbliższym sąsiedztwie miejsca z błędem. Omówimy tu krótko jeden z takich programów zastępujący funkcje malloc, który nazywa się mpatrol. Jego autorem jest Graeme Roy, a program można znaleźć pod adresem http://www.cbmamiga.demon.co.uk. Alternatywne programy można także znaleźć pod http://www.cs.colorado.edu/~zorn/MallocDebug.html.
Standardowa funkcja biblioteczna malloc w systemie Linux dla libc 2.x może być skonfigurowana tak, aby nie uwzględniała niektórych banalnych błędów, takich jak np. dwukrotne wywołanie free dla tego samego bloku pamięci lub wykroczenie poza granice bloku o jeden bajt. W tym celu funkcja wykorzystuje zmienną środowiskową MALLOC_CHECK. Nadanie jej wartości 0 powoduje ignorowanie tych błędów bez zgłaszania, wartość 1 służy do przesłania wyników diagnostyki na standardowe wyjście komunikatów o błędach, zaś wartość 2 powoduje przerwanie działania programu po wystąpieniu błędu sterty. Więcej szczegółów można znaleźć na odpowiednich stronach podręcznika systemowego.
Instalacja programu mpatrol
Po odszukaniu i pobraniu plików źródłowych programu mpatrol należy go zainstalować. Jest to bardzo proste, mimo że w wersji 1.1.1 nie było jeszcze skryptu automatyzującego instalację. Należy rozpakować pliki źródłowe i zbudować bibliotekę:
$ tar zxvf mpatrol_1.1.1.tar.gz
$ tar zxvf mpatrol_doc.tar.gz
$ cd mpatrol
$ cd build/unix
$ make all
$ cd../..
Powyższe polecenia utworzyły zestaw bibliotek zastępujący malloc. Jest to jedna biblioteka statyczna i dwie biblioteki ładowane dynamicznie (jedna z nich może być przydatna w programach korzystających z wielowątkowości). Pełna dokumentacja jest udostępniana w wielu formatach (łącznie z HTML) i zawiera szczegółowy opis użycia programu mpatrol.
Aby zainstalować mpatrol należy przenieść utworzone biblioteki i pliki pomocnicze do odpowiednich współdzielonych katalogów. Dobrym wyborem jest hierarchia katalogów /usr/local, ale odpowiednia będzie każda inna lokalizacja zastępująca /usr/local.
$ su
# mv build/unix/libmpatrol* /usr /local/lib
# mkdir /usr/local/include
# cp src/mpatrol.h /usr/local/include
# cp build/unix/mpatrol /usr/local/bin
# cp man/man1/mpatrol.1 /usr/local/man/man1
# cp man/man3/mpatrol.3 /usr/local/man/man3
# exit
Zastosowanie programu mpatrol
Przy korzystaniu z programu mpatrol nie ma potrzeby modyfikacji kodu źródłowego własnego programu, wystarczy po prostu podczas kompilacji łącze do biblioteki mpatrol. Oto przykład pokazujący to w programie do testowania naszej aplikacji:
$ gcc -o testtitle testtitle.o flatfile.o -lmpatrol -lbfd -liberty
$ ./testtitle
created dvd title 1
created dvd title 2
...
test_titles: no error
$
Zwróćmy uwagę na to, że użyto tu także biblioteki obsługującej pliki binarne (-lbfd) i biblioteki Liberty (-liberty), ponieważ mpatrol wykorzystuje funkcje zawarte w tych bibliotekach do obsługi swojego logu.
Wydaje się, że nasz program testujący działa normalnie, ale można też stwierdzić, że w jego katalogu pojawił się plik mpatrol.log. Jest to plik używany przez mpatrol do przechowywania wyników swojej pracy. Przy domyślnym poziomie szczegółowości zawiera on po prostu zestawienie wykorzystania pamięci:
$ cat mpatrol.log
@(#) mpatrol 1.1.1 (00/03/09)
Copyright (C) 1997-2000 Graeme S. Roy
This is free software, and you are welcome to redistribute it under certain
conditions; see the GNU Library General Public License for details.
system page size: 4096 bytes
default alignment: 4 bytes
overflow size: 0 bytes
overflow byte: 0xAA
allocation byte: 0xFF
free byte: 0x55
allocation stop: 0
reallocation stop: 0
free stop: 0
unfreed abort: 0
lower chack range: -
upper check range: -
failure frequency: 0
failure seed: 954833145
prologue function: <unset>
epilogue function: <unset>
handler function: <unset>
log file: mpatrol.log
program filename: /proc/1970/exe
symbols read: 3723
allocation count: 57
allocation peak: 431320 bytes
allocation limit: 0 bytes
allocated blocks: 8 (1408 bytes)
freed blocks: 0 (0 bytes)
free blocks: 6 (477824 bytes)
internal blocks: 55 (225280 bytes)
total heap usage: 704512 bytes
total compared: 0 bytes
total located: 0 bytes
total copied: 63401 bytes
total set: 349772 bytes
total warnings: 0
total errors: 0
Poziom szczegółowości można definiować za pomocą zmiennej środowiskowej MPATROL_OPTIONS. Funkcje mpatrol reagują na wartości znaczników stanowiących części tej zmiennej. Aby otrzymać pełny log pokazujący korzystanie z pamięci należy, ustawić poziom na LOGALL:
$ MPATROL_OPTIONS="LOGALL" ./testtitle
created dvd title 1
...
$
Jeśli teraz przejrzymy zawartość mpatrol.log , zobaczymy wpisy dla każdej operacji przydziału i zwalniania obszaru pamięci:
ALLOC: malloc (46, 176 bytes, 4 bytes) [-|-|-]
0x400B385B __new_fopen
0x08049A54 open_db_table
0x08049B39 dvd_open_db
0x08049930 create_db
0x08049468 main
0x40077313 __libc_start_main
0x080493C1 _start
returns 0x0805F0B0
Widzimy tutaj pamięć przydzielaną dynamicznie do strumienia plikowego, który jest używany podczas dostępu do jednego z plików baz danych. Gdy blok pamięci jest zwalniany, mpatrol pokazuje także miejsce, z którego nastąpiło zwolnienie tego bloku.
FREE: free (0x0805F0B0) [-|-|-]
0x400B343B __new_fclose
0x08049C47 dvd_close_db
0x08049A25 create_db
0x08049468 main
0x40077313 __libc_start_main
0x080493C1 _start
0x0805F0B0 (176 bytes) (malloc:46:0} [-|-\-]
0x400B385B __new_fopen
0x08049A54 open_db_table
0x08049B39 dvd_open_db
0x08049930 create_db
0x08049468 main
0x40077313 __libc_start_main
0x080493C1 _start
Jak tego można było oczekiwać, pokazywane są także inne funkcje obsługujące przydzielanie pamięci, takie jak np. wywołanie realloc:
REALLOC: realloc (0x0805F210, 32 bytes, 4 bytes) [-|-|-]
0x0804A5D1 dvd_title_search
0x08049839 test_titles
0x08049495 main
0x40077313 __libc_start_main
0x080493C1 _start
0x0805F210 (16 bytes) {realloc:57:0} [-|-|-]
0x0804A5D1 dvd_title_search
0x08049839 test_titles
0x08049495 main
0x40077313 __libc_start_main
0x080493C1 _start
returns 0x0805F210
Rejestrowane są także inne operacje na pamięci, np. kopiowanie zawartości za pomocą funkcji memcpy, ponieważ często bywają one źródłem trudnych do wykrycia błędów.
Program mpatrol może wykrywać wiele błędów związanych z korzystaniem z pamięci. Może on przydzielać bloki o większych rozmiarach niż potrzeba, zapełniać te „bufory” znanymi wartościami i wykrywać ich zmiany. Tak dzieje się przy wywołaniu funkcji zawartych w mpatrol (malloc, free, memcpy itp.), czyli od momentu powstania błędu do jego wykrycia może upłynąć pewien czas.
Jako alternatywnej metody detekcji błędów można użyć mechanizmów sprzętowych w procesorze, zabezpieczających przed zapisem poza granice dostępnego obszaru pamięci. Program mpatrol może ten mechanizm wykorzystać kosztem powiększenia każdego przydzielonego bloku do rozmiaru strony pamięci używanej przez procesor (w przypadku architektury Intel x86 i Pentium są to 4 kB). W takiej sytuacji testowany program korzystający z przydzielania dużej liczby niewielkich bloków pamięci wymaga znacznie więcej pamięci.
A oto przykładowy program, który pokazuje wykorzystanie opcji mpatrol:
#include <stdio.h>
#include <stdlib.h>
void bad(char *p)
{
p[20] = 'x';
}
main()
{
char *ptr = malloc(16);
bad(ptr)
printf("We've been naughty!\n");
}
W powyższym programie celowo wprowadzono zapis poza obszar przydzielonego dynamicznie bufora. Jest to dosyć często spotykany błąd. Po kompilacji i uruchomieniu bez bibliotek mpatrol program zdaje się działać poprawnie:
$ gcc -o memory.c
$ ./memory
We've been naughty!
$
Jest to jednak potencjalne źródło katastrofy pokazujące, że zastosowanie mpatrol do obserwacji przydziałów pamięci może być bardzo przydatne — bez tej obserwacji można by stwierdzić zaburzenia danych lub odmowę dopiero w ostatecznej, produkcyjnej wersji.
Jeżeli włączymy mpatrol, wtedy okaże się, że działanie naszego programu jest nieoczekiwanie przerywane. Oznacza to, że mpatrol wykrył problem:
$ gcc -o memory memory.c -lmpatrol -lbfd -liberty
$ ./memory
We've been naughty!
Aborted
$
Popatrzmy teraz do logu, aby stwierdzić, co się stało:
ERROR: free memory corruption at 0x0805A0C4
0x0805A0C4 78555555 55555555 55555555 55555555 xUUUUUUUUUUUUUUU
0x0805A0D4 55555555 55555555 55555555 55555555 UUUUUUUUUUUUUUUU
Widać, że mpatrol stwierdził zaburzenie nieprzydzielonej pamięci poza obszarem przydzielonego bloku (zaburzenie w wolnej pamięci) i zarejestrował zawartość zaburzonego bloku. Widać w nim znaki x wpisane przez nasz program. Można także stwierdzić, że wykrycie tego błędu nastąpiło dopiero po jego wystąpieniu, czyli po zakończeniu działania naszego programu.
Można wykorzystywać obszary „buforowe” do wykrywania błędów nie prowadzących do zaburzania zawartości sąsiadujących bloków, włączając w programie mpatrol opcję OFLOWSIZE. Jej wartość powinna być równa rozmiarowi dodatkowej pamięci, którą chcemy dołączyć do bloków w celu detekcji nieprawidłowych operacji dostępu. Jeżeli nastąpi przekroczenie granic, to mpatrol będzie dysponował informacją o tym, którego bloku to dotyczy.
$ MPATROL_OPTIONS="OFLOWSIZE=8" ./memory
We've been naughty!
Aborted
$ cat mpatrol.log
ERROR: allocation 0x0805A0C8 has a corrupted overflow buffer at 0x0805A0DC
0x0805A0D8 AAAAAAAA 78AAAAAA
0x0805A0C8 (16 bytes) {malloc:46:0} [-|-|-]
0x0804925D main
0x40077313 __libc_start_main
0x080491A1 _start
Dodatkowo możemy zabezpieczyć ten bufor, wspomagając się sprzętowym mechanizmem zarządzania pamięcią. Służy do tego opcja PAGEALLOC:
$ MPATROL_OPTIONS="OFLOWSIZE=8 PAGEALLOC=UPPER" ./memory
Aborted
$
W takim przypadku program jest przerywany natychmiast po przekroczeniu granic obszaru, a w logu można znaleźć próbę zapisu tej lokalizacji w programie, choć czasem może być to uniemożliwione przez wywołanie funkcji lub coś podobnego:
$ cat mpatrol.log
ERROR: illegal memory access
call stack
0x0804926E main
0x40077313 __libc_start_main
0x080491A1 _start
Po usunięciu błędów związanych z zapisem poza dozwolony obszar pamięci można także stwierdzić, czy występują błędy związane ze zwalnianiem przydzielonych bloków pamięci. Służy do tego opcja SHOWUNFREED programu mpatrol. Log zawiera wówczas informacje o takich osieroconych blokach:
$ MPATROL_OPTIONS=SHOWUNFREED ./memory-fixed
We've been naughty!
$ cat mpatrol.log
unfreed allocations: 2 (192 bytes)
0x0805A000 (176 bytes) {malloc:1:0} [-|-|-]
0x400B385B __new_fopen
0x0804E5DE __mp_openlogfile
0x0804978D __mp_init
0x08049932 __mp_alloc
0x080492A8 malloc
0x0804925D main
0x40077313 __libc_start_main
0x080491A1 _start
0x0805A0B0 (16 bytes) {malloc:46:0} [-|-|-]
0x0804925D main
0x40077313 __libc_start_main
0x080491A1 _start
Użycie zastępczych funkcji malloc pociąga zwykle za sobą zmniejszenie wydajności. Podczas testowania aplikacji nie stanowi to jednak problemu.
Testy pokrycia
Po opracowaniu i przeprowadzeniu testów mamy zwykle nadzieję, że nasza aplikacja jest wolna od błędów. Jedynym sposobem potwierdzenia poprawności programu jest udowodnienie, że dla każdej możliwej wartości danych wejściowych program zwraca poprawny wynik. Dla wszystkich programów, z wyjątkiem tych najprostszych, nie jest to możliwe do wykonania. Istnieją wprawdzie przybliżone metody dowodzenia poprawności programów, ale ich omówienie wykracza poza zakres tematyczny tej książki. Można by na przykład rozpocząć testy kalkulatora programowego, badając obliczanie pierwiastka kwadratowego każdej wprowadzonej wartości, ale czyż gdziekolwiek na świecie można znaleźć listę poprawnych odpowiedzi?
Musimy znaleźć więc kompromis nie ograniczający naszych testów. Można np. opracować taki zestaw testów dla każdej funkcji wywoływanej przez program. Testy te są uruchamiane dla wybranego zestawu danych, obejmujących dane najczęściej używane, dane ekstremalne i dane nieprawidłowe. Wartości tych spodziewanych danych próbujemy podzielić na zestawy, stosując jako kryterium podziału jednakowe zachowanie się programu dla poszczególnych wartości danych. Następnie uruchamiamy testy, używając po jednej danej z każdego zestawu. Spodziewamy się przy tym, że taki wybór będzie wystarczający dla sprawdzenia działania programu. Czyż można postępować jeszcze bardziej dokładnie? Oczywiście, można — stosując testy pokrycia (ang. test coverage).
U podstaw koncepcji testów pokrycia leży założenie, że korzystamy z fragmentu programu działającego podczas przeprowadzania testu. Jeżeli można stwierdzić, że w czasie testów program pracował w jakimś punkcie, można zatem uzyskać większą pewność co do jego poprawności.
Pokrycie instrukcji
Istnieją trzy rodzaje testów pokrycia, które będziemy brać pod uwagę, a każdy z nich jest bardziej zawężony niż poprzedni. Najpierw rozważa się pokrycie instrukcji, podczas którego próby uruchomienia każdego wiersza kodu w badanym programie odbywają się przynajmniej raz. Taki test powinien dać informację, że sprawdzony został każdy zakamarek kodu.
Pokrycie instrukcji ma wadę polegającą na tym, że nie bierze się w nim wzajemnego oddziaływania części programu. Jako przykład można podać prostą funkcję zawierającą dwie instrukcje warunkowe:
1: int myfunction (int a, int b)
2: {
3: int r = 1;
4: if(a > 0) {
5: r = 0;
6: }
7: if(b > 0) {
8: r = 3/r;
9: }
10: return r;
11:}
Kluczowymi wierszami są tu wiersze o numerach 4, 5, 7 i 8. Jeżeli podczas testu funkcja myfunction zostanie wywołana jako całość, to będzie wykonany kod z wierszy o numerach 4 i 7. Jeżeli wywołamy funkcję z argumentami myfunction(1,0), czyli gdy pierwszy argument będzie dodatni, to wykona się kod z wiersza 5 (zmienna r uzyska wartość 0). Jeżeli wywołanie będzie mieć postać myfunction(0,1), to zadziała druga instrukcja warunkowa, kończąc wykonywanie wszelkich instrukcji w funkcji. Nasz test obejmuje (pokrywa) więc wszystkie instrukcje.
Pokrycie rozgałęzień programu i pokrycie danych
Rozważania na temat ścieżki działań w kodzie programu stanowią istotę testu pokrycia rozgałęzień (ang. branch coverage). Jest to drugi stopień testów pokrycia. Liczba ścieżek we fragmencie kodu narasta nadzwyczaj szybko po wprowadzeniu dodatkowych pętli i instrukcji warunkowych, a więc liczba testów wymaganych do ich pełnego pokrycia również szybko się powiększa.
Trzeci rodzaj testów pokrycia wiąże się niezauważalnie z poprzednimi testami; nazywany jest on testem pokrycia danych (ang. data coverage) i obejmuje testowanie każdej wartości w każdej kombinacji.
Dobrzy programiści piszą programy, mając od początku na uwadze testy pokrycia. Odpowiedni projekt i planowanie może pomóc wydobyć większość informacji z narzędzi omawianych w tym podrozdziale.
Istnieje kilka narzędzi, które pomagają poznać stopień pokrycia kodu badanego programu przez przeprowadzane testy. Jedno z nich krótko tutaj omówimy. Większość narzędzi może pomóc tylko przy testach pokrycia pierwszego stopnia, czyli testach pokrycia instrukcji. Dlatego właśnie trzeba dbać o należyte pisanie kodu programu i dobór odpowiednich danych testowych.
Narzędzia dla testów pokrycia pracują na ogół na zasadzie wzbogacania testowanego programu. Dodają one swój własny dodatkowy kod przy kompilacji programu. Kod ten służy do gromadzenia danych o tym, która instrukcja programu jest w danej chwili wykonywana i jak często odbywa się ten proces. Ponieważ narzędzia te działają na poziomie instrukcji, dobrym pomysłem może być unikanie takich konstrukcji języka C, które pośrednio lub bezpośrednio włączają kilka instrukcji do jednego wiersza kodu. Jako przykład można podać instrukcję warunkową if albo instrukcję pętli zapisane w jednym wierszu. Innym, rzadziej spotykanym przykładem może być dołączanie makropoleceń preprocesora, które zwierają jakiś kod oraz potrójne instrukcje warunkowe. A oto przykład:
/* Nieprawidłowy styl kodowania dla pokrycia instrukcji */
# define SOME_TEST(X) { if(X>0) X--; else X++ }
z = a > b? func(a): func(b);
for(i=0; i<a; i++) func(i);
W takich przypadkach fakt, że dany wiersz kodu wykonuje się, może nie wnosić żadnych informacji o tym, które rozgałęzienie kodu instrukcji warunkowej się wykonało, albo o tym, czy została uruchomiona pętla.
Wybór danych testowych powinien być określony na podstawie warunków granicznych danej aplikacji. Gdy istnieją jakieś dane wejściowe, wówczas należy wybrać przypadki proste i ekstremalne. Załóżmy na przykład, że trzeba obsługiwać hasła o maksymalnej długości 12 znaków. Program powinien być wówczas przetestowany z hasłami o zerowej długości, z hasłami kilkuznakowymi, oraz z hasłami o długości 12 i 13 znaków, a także zawierającymi znaki sterujące i kody zerowe. Trzeba także upewnić się, czy testy badają wszystkie warunki w badanej funkcji.
Kody używanych funkcji powinny być krótkie i proste, z niewielką liczbą odgałęzień, oraz zawierać tylko jedno wejście i jedno wyjście. Tak napisany kod zmniejsza stopień komplikacji ścieżki działań w programie, ułatwiając testowanie pokrycia instrukcji.
Po tym wstępie nadszedł czas na zapoznanie się z działaniem testów pokrycia.
GCOV — narzędzie do testów pokrycia instrukcji
Narzędzie GNU do testowania pokrycia o nazwie gcov bywa zazwyczaj niezauważane w środowisku użytkowników Linuksa. Jego popularyzacji nie sprzyja także fakt, że w typowych dystrybucjach tego systemu operacyjnego brak jest jakiejkolwiek związanej z nim dokumentacji, chociaż jego instalacja jest łatwa. Trochę informacji można znaleźć pod adresem http://gcc.gnu.org/onlinedocs/.
Aby móc korzystać z gcov, należy przygotować specjalną wersję badanej aplikacji, podobnie jak w przypadku zamiaru korzystania z debuggera lub profilowania (wkrótce się z tym zapoznamy). W naszym przypadku musimy używać kompilatora C z serii GNU i specjalnych znaczników. Oto one:
-fprofile-arcs
-ftest-coverage
-fbranch-probabilities
Znacznik -ftest-coverage powoduje, że niezależnie od tworzenia normalnego pliku z kodem obiektowym kompilator tworzy parę specjalnych plików. Nazwy tych plików są tworzone z nazwy pliku źródłowego i końcówek .bb oraz .bbg. Pliki te zawierają zapis struktury rozgałęzień kodu źródłowego i są używane przez gcov do utworzenia mapy działania programu.
Znacznik -fprofile-arcs zmusza kompilator do umieszczenia w testowanym programie dodatkowego kodu, dzięki któremu będzie można rejestrować, która instrukcja jest wykonywana. Ta informacja zostanie zapisana do pliku, którego nazwa jest także utworzona z nazwy pliku źródłowego z końcówką .da, jeżeli program normalnie zakończy swoje działanie (czyli poprzez wywołanie exit lub powrót z funkcji main). Ponieważ ten kod dodatkowy powoduje duże spowolnienie programu, dlatego też trzecia opcja (-fbranch-probabilities) wywołuje optymalizację uzależnioną od sieci działań programu. Jeżeli np. blok kodu nie zawiera instrukcji warunkowych, to zostaje oznaczony jako uruchomiony po wejściu do niego.
Zajmijmy się teraz przykładem.
W naszej aplikacji wzorcowej dvdstore, utworzyliśmy zestaw funkcji w jednym pliku źródłowym o nazwie flatfile.c. Był on używany podczas testów API jeszcze przed utworzeniem aplikacji korzystającej z bazy PostgreSQL równolegle z interfejsem graficznym.
Aby przetestować aplikację wzorcową utworzyliśmy kilka programów testujących, z których każdy badał pewien podzbiór funkcji API. Np. program testmember był stosowany do testowania funkcji związanych z obsługą klienta, zaś za pomocą programu testtitle badane były funkcje związane z dodawaniem tytułu filmu do bazy danych itd.
Chcemy się teraz dowiedzieć, czy po uruchomieniu tych wszystkich programów zbadaliśmy każdą część naszej aplikacji wzorcowej.
Najpierw utworzymy wzbogaconą wersję API:
$ gcc -ftest-coverage -fprofile-arcs -fbranch-probabilities -c flatfile.c
$ ls -lstr
...
16 -rw-r--r-- 1 matthewn matthewn 14924 Mar 31 21:08 flatfile.o
8 -rw-r--r-- 1 matthewn matthewn 7504 Mar 31 21:08 flatfile.bbg
8 -rw-r--r-- 1 matthewn matthewn 4244 Mar 31 21:08 flatfile.bb
Możemy tu zobaczyć zarówno plik obiektowy, jak i pliki służące do analizy rozgałęzień.
Następnie ponownie kompilujemy i uruchamiamy programy testujące:
$ gcc -o testmember testmember.c flatfile.o
$ ./testmember
dvd_open_db: no error
dvd_today: no error
date is: 20000331
Action Education Comedy Thriller Foreign Romance Science Fiction
classes: no error
E U PG 12 15 18
member_create: no error
created member #1
member_create: no error
created member #2
member_get: no error
Member ID #2
No. 10002: Dr Ben Matthew
...
member_delete: no error
member_get: no match found
member_set: no error
member_get_id: no error
member 10002 has id 2
member_get_id: no match found
member_search: no error
1 2
Program testujący działa i wytwarza oczekiwane wyniki. Podczas jego działania dodatkowy kod był zajęty zbieraniem informacji, które zostały zapisane do plików umieszczonych w katalogu z uruchomionym programem. Dla każdego wzbogaconego przez kompilator pliku źródłowego został utworzony odpowiedni plik z rozszerzeniem .da. Pliki te zawierają zapis działania uruchomionych elementów oryginalnego pliku źródłowego.
$ ls -lstr
...
16 -rw-r--r-- 1 matthewn matthewn 14924 Mar 31 21:08 flatfile.o
8 -rw-r--r-- 1 matthewn matthewn 7504 Mar 31 21:08 flatfile.bbg
8 -rw-r--r-- 1 matthewn matthewn 4244 Mar 31 21:08 flatfile.bb
4 -rw-r--r-- 1 matthewn matthewn 60 Mar 31 21:19 reserve.dat
4 -rw-r--r-- 1 matthewn matthewn 876 Mar 31 21:19 member.dat
4 -rw-r--r-- 1 matthewn matthewn 1920 Mar 31 21:19 flatfile.da
$
Pokazane tu pliki .dat są prostymi plikami tekstowymi zawierającymi naszą bazę danych utworzoną przez program testujący.
Teraz możemy użyć gcov do sprawdzenia, jaki zakres kodu z pliku flatfile.c został sprawdzony. Program gcov akceptuje dodatkowo kilka opcjonalnych argumentów, o których wkrótce powiemy, jednak wywołanie domyślne będzie tu wystarczające. Jako argument w wywołaniu gcov podajemy więc tylko nazwę badanego pliku źródłowego.
$ gcov flatfile.c
31.07% of 412 source lines executed in file flatfile.c
Creating flatfile.c.gcov
$
Jak widać, program gcov zasygnalizował, że prawie jedna trzecia kodu z pliku flatfile.c została sprawdzona podczas testów. Nie stanowi to niespodzianki, ponieważ programy testujące badały tylko niektóre właściwości aplikacji. Działanie narzędzia gcov polega na analizie pliku .da utworzonego podczas testowania i wykonaniu pewnych obliczeń podsumowujących pokrycie kodu. Tworzony jest także nowy plik o nazwie flatfile.c.gcov, który zawiera kod źródłowy opatrzony komentarzami informującymi o tym, które wiersze i ile razy były uruchomione.
Plik danych utworzony podczas pracy programu testującego jest powiększany w miarę uruchamiania kolejnych testów. Dotyczy to także innych programów korzystających z kodu, którego pokrycie chcemy badać. Jeżeli więc uruchomimy inny program testujący, to powiększymy w ten sposób zarejestrowane pokrycie kodu. Sprawdzimy to na przykładzie programu testującego obsługę tytułów filmów.
$ gcc -o testtitle testtitle.c flatfile.o
$ ./testtitle
created dvd title 1
created dvd title 2
created dvd title 3
...
created dvd title 24
created dvd title 25
dvd_open_db: no error
get_genres: no error
get_classes: no error
name search: no error
Searched for name "Jean":
DVD Title #1: Grand Illusion
Directed by Jean Renoir (1938), Rated: U, Action
Starring: Jean Gabin
ASIN 0780020707, Price 29.99
DVD Title #5: The 400 Blows
Directed by Francois Traffaut (1959), Rated: 12, Education
Starring: Jean-Pierre Leaud
ASIN 1572525320, price 23.98
DVD Title #6: Beauty and The Beast
Directed by Jean Cocteau (1946), rated: 18, Thriller
Starring: Jean Marais
ASIN 0780020715, price 39.95
DVD Title #25: Alphaville
Directed by Jean-Luc Godard (1965), rated: U, Science Fiction
Starring: Eddie Constantine
ASIN 0780021541, Price 20.99
title search: no error
...
test_titles: no error
$
Program testujący działa normalnie i plik danych jest modyfikowany. Ponowne uruchomienie gcov pokazuje, że została zbadana większa część kodu źródłowego:
$ gcov flatfile.c
44.42% of 412 source lines executed in file flatfile.c
Creating flatfile.c.gcov
$
Przy zmianie testowanej aplikacji należy wyzerować zawartość plików pokrycia (*.da), aby zachować zgodność ze stanem faktycznym. Można to zautomatyzować, dodając do pliku makefile polecenie usuwające te pliki w wypadku zmiany aplikacji.
Zajmijmy się teraz danymi wyjściowymi programu gcov, aby sprawdzić, czy czegoś nie pominięto podczas testowania. Na tym etapie występują funkcje, które zupełnie nie były testowane (np. obsługujące wypożyczanie i zwroty płyt), ale oprócz tego znajdziemy jeszcze kilka niespodziewanych rzeczy. Pokazany niżej plik wyjściowy został skrócony w celu zaoszczędzenia miejsca. Liczby z lewej strony wierszy oznaczają liczbę uruchomień danego wiersza.
$ more flatfile.c.gcov
...
int dvd_member_get_id_from_number(char *member_no, int *member_id)
{
/* Wyszukiwanie klienta na podstawie jego numeru.
Zauważmy, że jest to niezależne od wielkości liter DOKŁADNE
dopasowanie, nawet gdy chcemy używać literowego "numeru" klienta
*/
6 dvd_store_member member;
6 int id = 1;
int err;
6 while(err = dvd_member_get(id, &member),
15 err == DVD_SUCCESS || err == DVD_ERR_NOT_FOUND) {
12 if(err == DVD_SUCCESS &&
strcasecmp(member_no, member.member_no) == 0) {
3 *member_id = id;
3 return DVD_SUCCESS;
9 }
9 id++;
9 }
3 return DVD_ERR_NOT_FOUND;
6 }
Widać tu, że funkcja dvd_member_get_id_from_number była wywoływana wielokrotnie i rzeczywiście każdy wiersz kodu został uruchomiony co najmniej raz. Na podstawie zliczeń uruchomień można stwierdzić, że funkcja była wywoływana sześciokrotnie i trzykrotnie nie udało się znaleźć klienta wypożyczalni. W przypadku funkcji nie do końca sprawdzonej gcov pogrubia niepokryte wiersze aby łatwo się wyróżniały. Oto następny wyciąg z pliku wyjściowego:
int dvd_title_get(int title_id, dvd_title *title_record_to_complete)
{
88 int err = DVD_SUCCESS;
88 if(title_record_to_compete == NULL)
###### return DVD_ERR_NULL_POINTER;
88 err = file_get(title_file,
sizeof(dvd_title) * title_id,
sizeof(dvd_title),
(void *) title_record_to_complete);
/* Jeśli nie możemy otrzymać tytułu w tym miejscu, to błąd może
znajdować się w danych lub tytuł może nie istnieć */
88 if(err != DVD_SUCCESS)
3 return err;
/* Jeśli przetworzony identyfikator tutułu nie wygląda tak, jak
oczekiwaliśmy, to mógł wystąpić błąd lub tytuł mógł zostać usunięty */
85 if(title_record_to_complete -> title_id == 0)
###### return DVD_ERR_NOT_FOUND;
85 if(title_id != title_record_to_compete -> title_id)
###### return DVD_ERR_BAD_MEMBER_TABLE;
Można tutaj zaobserwować, że pomimo przetestowaniu przypadku braku tytułu płyty (3 z 88 wywołań) nie przetestowano przypadku, gdy jako argument podany był pusty wskaźnik. Mamy więc „dziurę” w naszych testach, ponieważ nie ma pewności, czy funkcja radzi sobie ze wszystkimi wartościami argumentów. Co gorsza, nie przetestowano także przypadku, gdy tytuł jest szukany po jego usunięciu, co może powodować problemy, jeśli użyjemy tego programu testującego do zmodyfikowanej aplikacji.
Można zażądać od gcov podawania bardziej szczegółowych danych w pliku z podsumowaniem — służy do tego znacznik -f. Wówczas każda funkcja będzie mieć statystykę pokrycia:
$ gcov -f flatfile.c
87.50% of 16 source lines executed in function open_db_table
60.00% of 15 source lines executed in function dvd_open_db
100.00% of 9 source lines executed in function dvd_close_db
66.67% of 6 source lines executed in function file_set
83.33% of 6 source lines executed in function file_get
75.00% of 4 source lines executed in function dvd_member_set
83.33% of 12 source lines executed in function dvd_member_get
87.50% of 16 source lines executed in function dvd_member_create
100.00% of 4 source lines executed in function dvd_member_delete
100.00% of 12 source lines executed in function dvd_member_get_id_from_number
95.83% of 24 source lines executed in function dvd_member_search
75.00% of 4 source lines executed in function dvd_title_set
75.00% of 12 source lines executed in function dvd_title_get
90.00% of 10 source lines executed in function dvd_title_create
...
$
Informację o pokryciu rozgałęzień można uzyskać, podając znacznik -b w wywołaniu gcov:
$ gcov -b flatfile.c
44.42% of 412 source lines executed in file flatfile.c
43.60% of 250 branches executed in file flatfile.c
32.80% of 250 branches taken at least once in file flatfile.c
52.50% of 120 calls executed in file flatfile.c
Creating flatfile.c.gcov.
$
Jeżeli teraz sprawdzimy zawartość pliku wyjściowego programu gcov, to zobaczymy dodatkową informację o rozgałęzieniach wynikających z instrukcji warunkowych. Dla każdej instrukcji, która ma więcej niż jedno możliwe wyjście, jest teraz podawane procentowe obciążenie tych wyjść. Do instrukcji zawierających rozgałęzienia należą if, case, for, while itp., jak również wywołania funkcji (bowiem z funkcji może nie nastąpić powrót). Kilka przykładów wyjaśni lepiej to zagadnienie:
25 if(file_records == 0) {
branch 0 taken = 84%
/* Właśnie utworzyliśmy plik. Ponieważ jako identyfikatora
użyliśmy zera jako zabezpieczenia, to musimy zarezerwować
pierwszy wpis w pliku, a więc w tym miejscu
dodajemy pusty wpis */
4 file_records = 1;
4 }
Mamy tu prostą instrukcje if. Podany kod określa wartość warunku i następnie rozgałęzia się wokół bloku kodu. Widzimy, że gcov zarejestrował udział tego rozgałęzienia (pomijanie if) równy 84%. Bardziej skomplikowany test mógłby generować dodatkowe rozgałęzienia, które byłyby oddzielnie rejestrowane. Wywołanie funkcji w teście będzie liczone jako dodatkowe rozgałęzienie, tak jak w poniższym przykładzie:
12 if(err == DVD_SUCCESS &&
branch 0 taken = 0%
call 1 returns = 100%
branch 2 taken = 75%
strcasecmp(member_no, member.member_no) == 0) {
3 *member_id = id;
3 return DVD_SUCCESS;
branch 0 taken = 100%
Rozgałęzienia pochodzą tu z testów wartości DVD_SUCCES (czyli niepowodzenia), zwracanych przez wywołania strcasecmp i z testów zwracanych wyników (niepowodzenie). Instrukcja powrotu jest także traktowana jako rozgałęzienie i zawsze jest brana pod uwagę. Instrukcje wielokrotnego wyboru wytwarzają zwykle jedno rozgałęzienie dla każdego przypadku (case), a więc można stwierdzić, które z nich zostało zbadane.
Widać, że interpretacja informacji o rozgałęzieniach podawana przez gcov może być nieco zawiła. W praktyce udaje się rozszyfrować skomplikowane instrukcje i skorzystać w pełni z zalet oferowanych przez narzędzia testujące pokrycie rozgałęzień.
Poniżej podano opcje programu gcov:
gcov [-b] [-v] [-n] [-l] [-f] [-o OBJDIR] file
-b output branch summary
-v print version
-n do not create .gcov file
-l use long file names
-o specify object file directiories
Opcja -o umożliwia określenie lokalizacji plików danych programu gcov (czyli .bb oraz .bbg), jeśli nie znajdują się one w bieżącym katalogu. Znacznik -l jest stosowany wówczas, gdy w pliku include znajduje się kod wykonywalny, a więc faktycznie jest on umieszczany w większej liczbie plików źródłowych. Znaczniki -l nakazują programowi gcov utworzenie plików .gcov oddzielnie dla każdego kodu. A zatem gdy kod umieszczony jest w pliku inc.h, który jest dołączany do plików file1.c i file2.c, wówczas powstaną pliki z informacją o pokryciu o nazwach inc.h.file1.gcov i inc.h.file2.gcov.
Testowanie wydajności
Ważnym aspektem testowania jest wydajność. Nasza aplikacja musi nie tylko wykonywać wszystkie wymagane funkcje, ale także musi być użyteczna. Często użyteczność jest rozumiana jako szybkość odpowiedzi (zwłoka) lub szybkość modyfikacji (przepustowość). W takich wypadkach może się okazać potrzebne znalezienie w programie miejsca, w którym traci się najwięcej czasu. Taką informację można uzyskać, stosując profilowanie aplikacji i uruchamiając testy przeznaczone do oszacowania wydajności.
Podobnie jak przy korzystaniu z debuggera i w testach pokrycia, możemy tu użyć kompilatora i pomocniczego narzędzia wspomagającego analizę profilu uruchomieniowego naszej aplikacji. Budujemy więc specjalną wersję programu, uruchamiamy testy i sprawdzamy dane zebrane podczas pracy programu.
Krótko omówimy tutaj narzędzie do profilowania o nazwie gprof i pokażemy sposób wykrywania miejsc obniżających wydajność aplikacji. Naszym celem jest optymalizacja programu, który działa i jest wolny od błędów. Istnieje tu bardzo płynna granica przy zwiększaniu wydajności metodą niewielkich kroków w mocno obciążonej funkcji lub w funkcji, która jest wystarczająco szybka, jeżeli nastąpi skokowe ulepszenie w innym miejscu. Dokładna analiza informacji o profilu może pomóc w podjęciu decyzji, gdzie należy skierować swoje wysiłki.
Profilowaną wersję naszej aplikacji przygotowujemy, używając opcji -pg w kompilatorze:
$ gcc -pg -o testtitle testtitle.c flatfile.c
W aplikacji dvdstore wykorzystującej prosty plik tekstowy dla celów pokazowych wprowadzono pewne opóźnienia podczas przetwarzania żądań odczytu i zapisu do pliku. Symuluje to pracę w warunkach dostępu do rzeczywistej bazy danych.
Po uruchomieniu naszego programu stwierdzamy, że działa on normalnie. Dodatkowo, po zatrzymaniu program tworzy nowy plik o nazwie gmon.out, który zawiera zapis profilu uruchomieniowego. W rzeczywistości program wykonał pomiary czasu uruchomienia funkcji i pobrania danych, a wyniki tych pomiarów zostały zapisane po zakończeniu jego działania.
Użyjemy teraz programu gprof do analizy zebranych danych i sporządzenia raportu o wydajności naszej aplikacji. Program gprof obsługuje wiele opcji — informacje na ten temat można znaleźć w podręczniku systemowym.
Przy domyślnym ustawieniu gprof tworzy bardzo długi raport zawierający opisy każdej wymienionej statystyki. W naszym przypadku musimy uruchomić jedynie gprof w katalogu zawierającym plik gmon.out (czyli tam, skąd była uruchamiana nasza aplikacja) i przekazać mu nazwę programu, który wyprodukował ten plik. Dzięki temu gprof może odnieść nazwy funkcji do surowych danych w pliku profilowym:
$ ./testtitle
$ ls -ls gmon.out
8 -rw-r--r-- 1 neil users 4809 Apr 4 09:39 gmon.out
$ gprof testtitle
Flat profile:
Each sample counts as 0.01 seconds.
% cumulative self self total
time seconds seconds calls ms/call ms/call name
64.00 0.96 0.96 88 10.91 10.91 file_get
36.00 1.50 0.54 50 10.80 10.80 file_set
0.00 1.50 0.00 88 0.00 10.91 dvd_title_get
0.00 1.50 0.00 50 0.00 10.80 dvd_title_set
0.00 1.50 0.00 25 0.00 10.80 dvd_title_create
0.00 1.50 0.00 10 0.00 0.00 open_db_table
0.00 1.50 0.00 10 0.00 0.00 print_title
0.00 1.50 0.00 6 0.00 0.00 dvd_err_text
0.00 1.50 0.00 6 0.00 0.00 show_result
0.00 1.50 0.00 2 0.00 0.00 dvd_open_db
0.00 1.50 0.00 2 0.00 283.64 dvd_title_search
0.00 1.50 0.00 1 0.00 270.00 create_db
0.00 1.50 0.00 1 0.00 0.00 dvd_close_db
0.00 1.50 0.00 1 0.00 0.00 dvd_get_classification_list
0.00 1.50 0.00 1 0.00 0.00 dvd_get_genre_list
0.00 1.50 0.00 1 0.00 1230.00 test_titles
Widzimy tu, że większość czasu zajmują funkcje obsługujące dostęp do plików, czyli file_get i file_set. Zwróćmy uwagę na to, że ten czas jest sumowany dla funkcji, tzn. całkowity czas trwania jednego wywołania funkcji create_db zawiera w sobie czas poświęcony na wywołania funkcji niższego poziomu, które faktycznie przetwarzają pliki danych. Większość pozostałego czasu jest zużywana w funkcjach wywoływanych przez dvd_title_search. Przyjrzyjmy się teraz temu wszystkiemu dokładniej.
Domyślny raport programu gprof zawiera także wykresy wywołań pokazujące, która funkcja została wywołana przez inne i jak długo trwało to wywołanie. Podane niżej wyniki zostały skrócone i zmodyfikowane ze względu na brak miejsca. Zawierają one analizę wywołań funkcji dvd_title_get, file_get i dvd_title_search.
----------------------------------------------
0.00 0.39 36/88 test_titles [2]
0.00 0.57 52/88 dvd_title_search [5]
[3] 64.0 0.00 0.96 88 dvd_title_get [3]
0.96 0.00 88/88 file_get [4]
----------------------------------------------
0.96 0.00 88/88 dvd_title_get [3]
[4] 64.0 0.96 0.00 88 file_get [4]
----------------------------------------------
0.00 0.57 2/2 test_titles [2]
[5] 37.8 0.00 0.57 2 dvd_title_search [5]
0.00 0.57 52/88 dvd_title_get [3]
----------------------------------------------
Funkcja dvd_title_get (oznaczona tutaj jako [3]) była wywoływana łącznie 88 razy, 33 razy przez test_titles i 52 razy przez dvd_title_search. Mamy także 88 wywołań funkcji file_get, zaś funkcja dvd_title_search byłą wywoływana dwukrotnie.
Szczegółowa analiza profilu wykonawczego może dać ważne informacje o programie. W tym przypadku, gdy program testowy wywołał funkcję dvd_title_search tylko dwa razy, możemy stwierdzić, że wyszukiwanie jest najbardziej czasochłonną operacją w naszej aplikacji. Faktycznie użyliśmy tu przeszukiwania liniowego wpisów dla wszystkich tytułów, co jest bardzo powolne przy większej ich liczbie. Profil pomógł wskazać funkcje wyszukiwania jako cel naszej optymalizacji.
UWAGA! Powyższy przykład jest nieco wydumany, bowiem nigdy nie powinniśmy zakładać, że tak powolny algorytm wyszukiwania będzie użyty w ostatecznej aplikacji. Pełny program obsługujący wypożyczalnię płyt DVD korzysta z bazy danych, tak jak to podano we wcześniejszych rozdziałach. Wybór dobrego algorytmu i projekt stanowią istotne składniki praktyki programowania, które często nie są brane pod uwagę.
Program gprof może gromadzić dane pochodzące 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 gprof.sum.
Ważne jest, aby zdawać sobie sprawę ze statystycznego charakteru informacji dostarczanej przez gprof. Podczas pracy programu jego katalog roboczy jest sprawdzany w regularnych odstępach czasu i tworzony jest pewien obraz jego pracy. Przy niektórych programach można zobaczyć w raporcie informacje odnoszące się do samego gromadzenia danych o profilu. W takich przypadkach najlepiej po prostu pominąć informacje o nieznanych funkcjach.
Podsumowanie
W tym rozdziale przedstawiliśmy niektóre narzędzia i metody przydatne przy testach aplikacji i dostarczające więcej danych na temat działania programu.
Zapoznaliśmy się z programami testującymi, elastycznym wplataniem testów, automatycznymi testami regresyjnymi oraz ze skryptami testującymi połączonymi z programem expect. Omówiliśmy także różne rodzaje pamięci dostępne dla aplikacji i niektóre powstające przy tej okazji problemy. Pokazaliśmy narzędzia do śledzenia błędów w pamięci oraz sposoby pomiaru wydajności aplikacji i jej pokrycia w testach.
Mając do dyspozycji tak wiele narzędzi dostępnych w systemie Linux, nie mamy teraz żadnego usprawiedliwienia dla oprogramowania złej jakości.
31 Część I ♦ Podstawy obsługi systemu WhizBang (Nagłówek strony)
31 C:\chwil\zaawansowane\R-11-07-bledy.doc