W niniejszej prezentacji odpluskwianie (ew. debugowanie) rozumiane jest jako proces systematycznego znajdowania i usuwania błędów w programie komputerowym. Jest to przeciwieństwo programowania - procesu systematycznego wprowadzania błędów. Należy zauważyć, iż ten dokument omawia w zasadzie jedynie metody i narzędzia służące do zbierania informacji pomocnych w ww. procesie w toku (mniej lub bardziej) kontrolowanego uruchomienia programu.
Najprostszą metodą wyszukiwania błędów w programie jest oczywiście przeczytanie jego kodu źródłowego. Niestety zadanie to bywa wysoce nietrywialne, zwłaszcza w przypadku dużego kodu pisanego przez wielu autorów. Umiejętność czytania wbrew pozorom jest trudną sztuką, jednak omowanie tego tematu nie należy do celów prezentacji. Jedyną przyczyną, dla której ten rozdział został tu umieszczony, jest głębokie przeświadczenie jego autora iż użycie zaawansowanych narzędzi omówionych w dalszej części dokumentu nie zawsze jest konieczne do zlokalizowania usterki. Co więcej, bywa także niewskazane. Konieczność użycia narzędzi takich jak debugger bywa niepokojącym symptomem utraty kontroli nad pisanym kodem i może być jedną z oznak uzyskiwania przez ww. kod samoświadomości.
Bardzo często stosowanym sposobem szukania usterek jest modyfikacja kodu tak, by w trakcie działania wypisywane były pewne informacje o stanie programu. Sprowadza się to zwykle do wpisania pewnej liczby instrukcji takich jak printf, printk, itp. Metoda ta, jakkolwiek wielce popularna, ma jednak rozliczne wady. Jej stosowanie wiąże się w szczególności z koniecznością częstych rekompilacji, co nie jest zbyt wygodne. Ponadto stwierdzono metodami doświadczalnymi iż 'tymczasowe' modyfikacje kodu mają silną tendencję do pozostawiania w nim trwałego śladu. Te i inne problemy związane z omawianą metodą spowodowały poddanie jej miażdżącej krytyce w wielu źródłach. Z tej przyczyny dalsze jej opisywanie nie jest celowe. Zauważmy tylko, że w (bardzo) nielicznych przypadkach, gdy narzut czasowy wynikający z podłączania i konfigurowania bardziej zaawansowanych narzędzi jest większy niż przewidywany, łączny czas odpluskwiania, użycie tej metody może być uzasadnione.
Debuggery, które będziemy także nazywać odpluskwiaczami (spotykane jest również określenie 'programy uruchomieniowe'), to narzędzia pozwalające na kontrolowanie i analizowanie programu w czasie jego działania. Szanujący się debugger powinien w szczególności umożliwiać
Programy odpluskwiające możemy podzielić na kilka kategorii ze względu na ich możliwości i zastosowania.
Służą do odpluskwiania kodu działającego w trybie jądra (np.
sterowników urządzeń). Kontrolowanie tego typu procesów jest z
wielu względów zadaniem nietrywialnym.
Przykłady
Do odpluskwiania aplikacji w trybie użytkownika.
Przykłady
Potrafią korzystać z symboli pochodzących z kodu źródłowego,
takich jak nazwy zmiennych i funkcji oraz powiązać skompilowany kod
z odpowiednimi liniami źródeł. Zazwyczaj ułatwiają też analizę i
modyfikację używanych w programie struktur danych - rekordów,
tablic, ...
Przykłady
Operują na poziomie kodu maszynowego, nie pozwalają na
posługiwanie się numerami linii czy nazwami zmiennych ze źródeł.
Czasami potrafią korzystać z prostych informacji takich jak
odwzorowanie nazw funkcji w adresy kodu.
Przykłady
UNIX'owy debugger symboliczny. adb (absolute debugger) jest najstarszym z wymienionych tu odpluskwiaczy. Umożliwia debugowanie programów w C i assemblerze. Dziś w zasadzie nie jest już stosowany, choć wiele nowszych odpluskwiaczy zawiera opcje kompatybilności z adb. Wersja systemowa, kadb, bywa czasem stosowana do debugowania kodu kernel'a na komputerach SPARC.
Przez długi czas był to standardowy i jedynie słuszny debugger UNIX'owy. Wiele współczesnych odpluskwiaczy wywodzi się właśnie z dbx i jest z nim kompatybilne w sensie linii poleceń. dbx powstał w roku 1981 w Berkeley. Początkowo służył tylko do odpluskwiania programów pisanych w interpretowanym języku Berkeley Pascal, został rozszerzony i przystosowany do obsługi kompilowanych języków - C, C++, FORTRAN'u, Pascal'a i Moduli-2. W dzisiejszych czasach dbx wciąż bywa stosowany, np. w systemie Solaris.
gdb (GNU Debugger) został stworzony w latach 1987-89 przez Richarda Stallmana. Obecnie w zasadzie zastąpił dbx w roli standardowego debuggera na systemach UNIX'owych. Podobnie jak jego poprzednik obsługiwany jest z konsoli tekstowej. Istnieją także graficzne nakładki ułatwiające pracę z gdb. Jedną z bardziej popularnych jest ddd (może obsługiwać też wiele innych odpluskwiaczy, w tym dbx).
Jest to w zasadzie połączenie debuggera systemowego z aplikacyjnym, wyposażone dodatkowo w wygodny interfejs graficzny.
Systemowy debugger windowsowy o bardzo dużych możliwościach. Niestety nie należ? do tanich. Często stosowany przy tworzeniu sterowników urządzeń.
Stary debugger firmy Micro$oft, który został zintegrowany z Visual Studio.
Visual Studio nie jest jedynym środowiskiem wyposażonym w zintegrowany odpluskwiacz. W tej dziedzinie nienajgorzej prezentują się np. narzędzia firmy Borland (chwilami zwanej Inprise), takie jak Delphi czy C++ Builder. Wiele środowisk potrafi także korzystać z gdb (np. Dev-Pascal).
Oprócz klasycznych odpluskwiaczy niniejsza prezentacja omawia także inne narzędzia o podobnym zastosowaniu i zasadzie działania:
Mechanizm konstrukcji odpluskwiaczy jest oczyiście zależny od systemu operacyjnego, choć podstawowe koncepcje są w zasadzie wspólne dla wszystkich platform. Poniższy rozdział omawia schemat budowy prostego debugger'a w systemach Windows i Linux. W niektórych miejscach milcząco zakłada, że działamy na procesorze z rodziny x86.
Czasami zdarza się, że człowieka ogarnia przemożna chęć napisania debuggera. Człowiek taki, o ile nie zostanie w porę hospitalizowany, szybko zauważy, iż potrzebna mu jest możliwość zakodowania kilku operacji
Schemat prostego odpluskwiacza w windows wygląda tak
void main ( void ) { CreateProcess (..., DEBUG_ONLY_THIS_PROCESS, ...) ; while ( 1 == WaitForDebugEvent ( ... ) ) { if ( EXIT_PROCESS ) { break; } ContinueDebugEvent ( ... ) ; } }
Żeby proces odpluskwiać, trzeba go najpierw utworzyć (choć można również podłączyć się do już istniejącego procesu za pomocą funkcji DebugActiveProcess()). Przy wywołaniu CreateProcess należy przekazać odpowiednią flagę, aby system Informował o zachodzących w nowym procesie zdarzeniach. Użyta w przykładowym schemacie flaga DEBUG_ONLY_THIS_PROCESS mówi, ze będziemy obsługiwać zdarzenia zachodzące w procesie, ale NIE w jego procesach potomnych. Zamiast niej można użyć DEBUG_PROCESS ? wtedy odpluskwiać będziemy także całe potomstwo pierwotnego procesu.
Ta funkcja służy do oczekiwania na zajście w odpluskwianym programie jakiegoś zdarzenia. Z reguły wołana jest w osobnym wątku, aby nie blokować interfejsu debuggera. Po jej wykonaniu proces odpluskwiany albo się zakończył, albo jest zatrzymany i czeka na wznowienie.
Gdy zajdzie jakieś zdarzenie, proces debugowany zostaje zatrzymany. Aby wznowić jego działanie odpluskwiacz wywołuje funkcję ContinueDebugEvent(). Jej ostatni parametr pozwala kontrolować kwestię dostarczania wyjątku, który spowodował zdarzenie (konkretnie można go dostarczyć albo nie).
WaitForDebugEvent() podaje informację o przyczynie zatrzymania Procesu wypełniając strukturę DEBUG_EVENT:
typedef struct _DEBUG_EVENT { DWORD dwDebugEventCode; DWORD dwProcessId; DWORD dwThreadId; union { EXCEPTION_DEBUG_INFO Exception; CREATE_THREAD_DEBUG_INFO CreateThread; CREATE_PROCESS_DEBUG_INFO CreateProcessInfo; EXIT_THREAD_DEBUG_INFO ExitThread; EXIT_PROCESS_DEBUG_INFO ExitProcess; LOAD_DLL_DEBUG_INFO LoadDll; UNLOAD_DLL_DEBUG_INFO UnloadDll; OUTPUT_DEBUG_STRING_INFO DebugString; RIP_INFO RipInfo; } u; } DEBUG_EVENT
Jak widać, mamy następujące rodzaje zdarzeń
Zdarzenie | Opis |
---|---|
CREATE_PROCESS_DEBUG_EVENT | Generowane w chwili utworzenia nowego procesu LUB podłączenia się do procesu już działającego. |
CREATE_THREAD_DEBUG_EVENT | Generowane w chwili utworzenia nowego wątku LUB podłączenia się do procesu już działającego. |
EXCEPTION_DEBUG_EVENT | Wystąpił wyjątek w procesie odpluskwianym (np. dzielenie przez 0). Dokładniejsze omawianie Windowsowego mechanizmu wyjątków mija się z celem tego dokumentu. |
EXIT_PROCESS_DEBUG_EVENT | Zakończył się ostatni wątek w procesie. |
EXIT_THREAD_DEBUG_EVENT | Zakończył się któryś z wątków w procesie (ale NIE ostatni wątek). |
LOAD_DLL_DEBUG_EVENT | Proces podłączył bibliotekę dynamiczną. Zwykle debugger analizuje wtedy jej tablicę symboli etc. |
UNLOAD_DLL_DEBUG_EVENT | Proces odładował bibliotekę (używając FreeLibrary). Następuje to wtedy, gdy licznik odwołań tej biblioteki spadnie do 0. |
OUTPUT_DEBUG_STRING_EVENT | Proces odpluskwiany wywołał OutputDebugString() |
RIP_INFO | Używane tylko przez niektóre wersje Win98 do oznaczenia błędów takich jak zamykanie niewłaściwych uchwytów. |
Do odczytu pamięci odpluskwianego procesu można użyć funkcji ReadProcessMemory() (proces odpluskwiający ma po temu odpowiednie uprawnienia). Do modyfikowania tejże pamięci służy funkcja WriteProcessMemory(). Niestety czasem strona, którą chcemy zmodyfikować, oznaczona jest jako chroniona przed zapisem (sprawdza się to stosując VirtualQeuryEx()). Ustawienia te poprawia się za pomocą VirtualProtectEx(). Należy jednak pamiętać, aby po wprowadzeniu zmian przywrócić ochronę ? w przeciwnym wypadku doprowadzimy do nieuzasadnionej zmiany w zachowaniu procesu debugowanego.
Rejestry w wątkach odpluskwianego procesu możemy badać i modyfikować funkcjami GetThreadContext() i SetThreadContext(). Ich parametrem jest struktura CONTEXT, której dokładny kształt zależny jest oczywiście od procesora.
Prosty program odpluskwiający może wyglądać np. tak:
int main() { int status; pid_t child = fork(); if (child == 0) { ptrace(PTRACE_TRACEME, 0, NULL, NULL); execl(...); } else { while (1) { wait(&status); if (WIFEXITED(status)) break; ptrace(PTRACE_CONT, child, NULL, NULL); } } return 0; }
ptrace to podstawowe wywołanie systemowe używane przy odpluskwianiu i pokrewnych zadaniach. Powinno być wywołane w procesie, który ma być śledzony (odpluskwiany) z parametrem PTRACE_TRACEME (pierwszy parametr ptrace to komenda). Konsekwencje takiego wywołania są różnorakie, w szczególności proces śledzący (czyli ojciec procesu śledzonego) będzie teraz przechwytywał wszelkie sygnały skierowane do śledzonego (oprócz SIGKILL). Możliwe jest także odpluskwianie już istniejącego procesu - w tym celu śledzący musi wywołać ptrace z parametrem PTRACE_ATTACH. Proces śledzony staje się potomkiem śledzącego, choć nie zawsze daje się to zauważyć (np. getppid() będzie zwracać PID dotychczasowego rodzica).
Gdy proces śledzony zostaje zatrzymany (dzieje się tak np. gdy przyjdzie doń jakiś sygnał), jego działanie powinno zostać wznowione przez proces śledzący za pomocą wywołania ptrace z argumentem PTRACE_CONT. Ostatni argument, jeśli jest różny od zera i od SIGSTOP, jest sygnałem który zostanie dostarczony (zamiast tego, który spowodował zatrzymanie). Zamiast PTRACE_CONT można też użyć parametru PTRACE_SYSCALL. Spowoduje to zatrzymanie (tz. wysłanie sygnału SIGTRAP) po następnym wywołaniu lub powrocie z funkcji systemowej. Należy dodać, iż po wywołaniu exec proces zawsze jest zatrzymywany, nawet , jeśli nie użyto parametru PTRACE_SYSCALL. Istnieje także parametr PTRACE_SINGLESTEP, którego użycie zatrzyma odpluskwiany proces po następnej instrukcji.
Wywołania wait() używane jest zwykle do oczekiwania na zakończenie procesu potomnego. W przypadku procesu odpluskwiającego wywołanie wait() blokuje do chwili, gdy śledzony zakończy działanie lub też otrzyma jakiś sygnał. W tym drugim przypadku sygnał NIE jest dostarczany, a proces śledzony jest zatrzymywany. Przy wznawianiu działania można mu przekazać ww. sygnał lub też jakiś inny (albo i żaden).
Do odczytu pamięci procesu odpluskwianego można użyć funkcji ptrace z argumentem PTRACE_PEEKTEXT lub PTRACE_PEEKDATA (W Linuxie te parametry są równoważne). Takie wywołanie zwróci słowo znajdujące się pod podanym adresem w przestrzeni procesu odpluskwianego. W podobny sposób można rzeczoną pamięć modyfikować - należ? użyć parametrów PTRACE_POKETEXT lub PTRACE_POKEDATA by zapisać słowo w pod wskazany adres. Aby uzyskać dostęp do kontekstu procesu można użyć parametrów PTRACE_PEEKUSER i PTRACE_POKEUSER. System nie pozwoli jednak na niektóre modyfikacje tych danych. Kontekst zawiera w szczególności wartości rejestrów, które można także uzyskać podając ptrace parammetr PTRACE_GETREGS (rejestry ogólnego przeznaczenia) lub PTRACE_GETFPREGS (rejestry zmiennoprzecinkowe). Istnieją także parametry PTRACE_SETREGS i PTRACE_SETFPREGS o oczywistym zastosowaniu.
Proces ustawiania punktów przerwań jest wysoce zależny od procesora. W przypadku rodziny x86 wygląda mniej więcej tak
Podczas odpluskwiania bardzo wygodna jest możliwość operowania na pojęciach pochodzących z kodu źródłowego, takich jak nazwy zmiennych lub funkcji, pola struktur czy numery linii. Jeśli tej możliwości nie mamy, musimy posługiwać się jedynie prostymi adresami - przykładowo, aby podejrzeć zmienną lokalną trzeba odczytać z rejestru adres ramki, wyliczyć przesunięcie i wywietlić określoną liczbę bajtów spod tak zdobytego adresu (potem trzeba jeszcze ten ciąg bajtów jakoś zinterpretować). Znacznie wygodniej byłoby po prostu podać nazwę zmiennej i poprosić debugger, żeby ładnie wyświetlił jej obecną wartość. Podobnie punktu przerwań prościej jest ustawiać według numeru linii niż drogą mozolnego czytania assemblerowego kodu i odnajdywania właściwych adresów. Aby móc w tych kwestiach skorzystać ze wsparcia odpluskwiacza, musimy do skompilowanego pliku dodać jakieś informacje o jego źródłach. Niestety, dołączenie samych źródeł jest z wielu powodów niewystarczające - trzeba jeszcze dodać dane mówiące o powiązaniach między źródłami a kodem skompilowanym. Każdy kompilator generuje przecież nieco inny kod, zwykle zależny także od opcji kompilacji. Jeśli zastosowano optymalizację, kod maszynowy może w nikłym stopniu odpowiadać źródłom (zmienne mogą zostać wyrzucone, instrukcje wyniesione przed pętle, operacje w pętlach zastąpione słabszymi...). Co więcej, sensowny debugger powinien być przystosowany (lub łatwo przystosowywalny) do operowania na programach pisanych w bardzo różnych językach. Informacja o sposobie, w jaki źródła zostały skompilowane w instrukcje maszynowe, musi być jakoś zakodowana i dołączona do pliku wykonywalnego (oczywiście można także trzymać ją w osobnym pliku...).Kodowanie to okazuje się być zadaniem wysoce nietrywialnym. Pojawiające się przy nim problemy omówione zostaną na przykładzie dwóch UNIX'owych formatów - DWARF i stabs. Poniższe rozdziały nie stanowią kompletnego opisu tychże formatów, a jedynie jego nieliczne fragmenty które uznane zostały za ciekawe i/lub pouczające.
Stabs (skrót od symbol tables) stworzony został przez Petera Kesslera na użytek pascalowego debuggera pdx. Został dostosowany do obsługi innych języków (w szczególności C, a nawet C++). Informacje w tym formacie mogą wkodowywane w różne rodzaje plików obiektowych (a.out, COFF, ELF, ...). Przez długi czas był to domyślny format stosowany w Linuxie. Ciekawą cechą jest to, że w generowanym przez kompilator pliku assemblerowym informacje dla odpluskwiaczy mają postać assemblerowych dyrektyw, czyli są (względnie) czytelne. Przykładowo, poniższy program
int main(int argc, char **argv){ int n; n = argc; while (n > 1){ if (n & 1) n = 3 * n + 1; else n >>= 1; } return 0; }
Po skompilowaniu do assemblera przez gcc (które trzeba poprosić o dodanie informacji w formacie stabs) wygląda tak
.file "stabs-demo.c" .stabs "stabs-demo.c",100,0,2,.Ltext0 .text .Ltext0: .stabs "gcc2_compiled.",60,0,0,0 .stabs "int:t(0,1)=r(0,1);-2147483648;2147483647;",128,0,0,0 .stabs "char:t(0,2)=r(0,2);0;127;",128,0,0,0 .stabs "long int:t(0,3)=r(0,3);-2147483648;2147483647;",128,0,0,0 .stabs "unsigned int:t(0,4)=r(0,4);0;4294967295;",128,0,0,0 .stabs "long unsigned int:t(0,5)=r(0,5);0;4294967295;",128,0,0,0 .stabs "long long int:t(0,6)=r(0,6);-0;4294967295;",128,0,0,0 .stabs "long long unsigned int:t(0,7)=r(0,7);0;-1;",128,0,0,0 .stabs "short int:t(0,8)=r(0,8);-32768;32767;",128,0,0,0 .stabs "short unsigned int:t(0,9)=r(0,9);0;65535;",128,0,0,0 .stabs "signed char:t(0,10)=r(0,10);-128;127;",128,0,0,0 .stabs "unsigned char:t(0,11)=r(0,11);0;255;",128,0,0,0 .stabs "float:t(0,12)=r(0,1);4;0;",128,0,0,0 .stabs "double:t(0,13)=r(0,1);8;0;",128,0,0,0 .stabs "long double:t(0,14)=r(0,1);12;0;",128,0,0,0 .stabs "void:t(0,15)=(0,15)",128,0,0,0 .stabs "main:F(0,1)",36,0,0,main .stabs "argc:p(0,1)",160,0,0,8 .stabs "argv:p(0,16)=*(0,17)=*(0,2)",160,0,0,12 .globl main .type main, @function main: .stabn 68,0,1,.LM0-main .LM0: pushl %ebp movl %esp, %ebp subl $24, %esp andl $-16, %esp movl $0, %eax addl $15, %eax addl $15, %eax shrl $4, %eax sall $4, %eax subl %eax, %esp .stabn 68,0,3,.LM1-main .LM1: movl 8(%ebp), %eax movl %eax, -4(%ebp) .stabn 68,0,4,.LM2-main .LM2: jmp .L8 .L3: .stabn 68,0,5,.LM3-main .LM3: movl -4(%ebp), %eax andl $1, %eax testb %al, %al je .L4 .stabn 68,0,6,.LM4-main .LM4: movl -4(%ebp), %edx movl %edx, %eax addl %eax, %eax addl %edx, %eax incl %eax movl %eax, -4(%ebp) jmp .L2 .L4: .stabn 68,0,8,.LM5-main .LM5: leal -4(%ebp), %eax sarl $1, (%eax) .L2: .L8: .stabn 68,0,4,.LM6-main .LM6: cmpl $1, -4(%ebp) jg .L3 .stabn 68,0,10,.LM7-main .LM7: movl $0, %eax .stabn 68,0,11,.LM8-main .LM8: leave ret .size main, .-main .stabs "n:(0,1)",128,0,0,-4 .stabn 192,0,0,main-main .stabn 224,0,0,.Lscope0-main .Lscope0: .stabs "",100,0,0,.Letext0 .Letext0: .ident "GCC: (GNU) 4.0.3 20051201 (prerelease) (Debian 4.0.2-5)" .section .note.GNU-stack,"",@progbits
Jak (po dokładniejszym spojrzeniu) widać, informacje kodowane są
w formie rekordów w dyrektywach .stabs . Dyrektywa taka może mieć
trzy formy
Poniższe sekcje opisują sposoby kodowania niektórych informacji jako dyrektyw .stabs. Ich celem nie jest dokładne opisanie formatu, lecz raczej przedstawienie rodzajów informacji, które format taki musi przechowywać. Zamiast liczbowych stałych widocznych w przykładowym kodzie będą tu używane ich symboliczne nazwy ze specyfikacji.
Nazwa pliku zapisana jest w rekordzie typu N_SO, którego pole string zawiera rzeczoną nazwę, zaś value jest adresem kodu, do którego plik ten został skompilowany. Czasami występuje też drugi rekord N_SO zawierający ścieżkę katalogu, w którym jest dany plik. Pliki include'owane opisane są rekordami typu N_SOL.
.stabs "stabs-demo.c",100,0,2,.Ltext0
Jak nietrudno się domyślić, stała N_SO jest równa 100. Ltext0 jest assemblerową etykietą, która zostanie przetłumaczona na właściwy adres.
Odwzorowanie numerów linii kodu źródłowego w odpowiadające im instrukcje assemblera zapisane jest w postaci rekordów typu N_SLINE. Pole desc takiego rekordu zawiera numer linii, zaś value adres początku kodu przez nią wygenerowanego. Niektóre linie tworzą kod nieciągły - wtedy jest kilka rekordów N_SLINE o tym samym numerze linii.
.stabn 68,0,4,.LM2-main ... .stabn 68,0,4,.LM6-main
Jak widać, instrukcja while w linii 4 została skompilowana do nieciągłego kodu (co nie jest chyba zaskoczeniem) i stąd odpowiadają jej 2 dyrektywy N_SLINE. Zauważmy też, że adresy są względne wobec początku funkcji - co oszczędza nieco kłopotów przy relokacji (temat problemów związanych z relokacją został w prezentacji zasadniczo pominięty).
Każdej funkcji z kodu źródłowego odpowiada rekord typu N_FUN. Zawiera on informację o jej nazwie i typie (w polu string) oraz adres w kodzie (informację tę czasami można wydobyć też z innych źródeł). Numer linii zawierającej deklarację funkcji możemy poznać dzięki rekordom N_SLINE omówionym wcześniej.
.stabs "main:F(0,1)",36,0,0,main .stabs "argc:p(0,1)",160,0,0,8 .stabs "argv:p(0,16)=*(0,17)=*(0,2)",160,0,0,12
Widzimy tu opis funkcji main. Duże 'F' oznacza funkcję globalną (extern). zaś (0,1) to typ wyniku (int). Parametry kodowane są rekordami N_PSYM, których pole value jest przesunięciem pozwalającym odnaleźć wartość na stosie. Istnieje także typ rekordu N_RSYM do opisu parametrów przekazywanych w rejestrze (oraz zmiennych lokalnych tam trzymanych.
Blokowa struktura programu zapisana jest przez rekordy N_LBRAC i N_RBRAC (jak nietrudno się domyślić, odpowiadają lewemu i prawemu nawiasowi (początek/koniec bloku)). Blok z treścią funkcji powinien być opisany zaraz po jej definicji.
.stabn 192,0,0,main-main .stabn 224,0,0,.Lscope0-main
W powyższym przykładzie widzimy kod funkcji main. Jak zwykle adresy zapisane są za pomocą assemblerowych etykiet.
Zmienne globalne opisane są rekordami N_GSYM zawierającymi ich nazwę, typ i adres. Podobnie ma się sprawa ze zmiennymi lokalnymi, których adres jest jednak podawany jako przesunięcie w ramce. Zauważmy, że tak prosty sposób opisu lokacji zmiennej sprawia poważne problemy, gdy ww. lokacja zmienia się w trakcie działania programu (np. przez chwile wartość jest w rejestrze, a potem znowu na stosie, ale już może z innym przesunięciem...). Zmienne lokalne alokowane na stosie opisują rekordy N_LSYM, zaś te trzymane w rejestrach - N_RSYM (tutaj zamiast przesunięcia pamiętamy oczywiście numer rejestru).
.stabs "n:(0,1)",128,0,0,-4
Przykład pokazuje nam opis zmiennej lokalnej n. Nie jest chyba zaskoczeniem, że kompilator umieścił ją w ramce funkcji z przesunięciem -4, co znaczy, że jej wartości należy szukać pod adresem ebp - 4 (po gasowemu mówiąc -4(%ebp)).
W dotychczasowych rozważanaich przewijało się kilkakrotnie pojęcie typu. Nawet niezbyt uważna lektura przykładowego kodu pozwala dostrzec, iż znaczną część zawartych w nim dyrektyw stanowią definicje typów (w tym wypadku standardowych typów z języka C). W formacie stabs typom nadaje się numery. Czasem,tak jak w przykładzie, do indeksowania typu oprócz jego numeru używa się także numeru pliku, w którym został zdefiniowany. Stąd biorą się używane w poprzednich punktach opisy typów, takie jak (0,1) (0 to numer pliku, 1 - typu). Dokładne omówienie formatu, w jakim definiuje się typy dyrektywami .stabs, byłoby bardzo czasochłonne, dlatego omówiony zostanie jedynie prosty przykład
.stabs "short unsigned int:t(0,9)=r(0,9);0;65535;",128,0,0,0
Widzimy tutaj deklarację typu 16 bitowych liczb bez znaku. Typ ten ma numer 9, zaś pojawiająca się literka r oznacza, iż definiujemy go jako typ zakresowy - tu zakresem jest 0..216-1. Liczba 128 to typ rekordu - N_LSYM.
Zostały tu opisane tylko niektóre fragmenty formatu stabs. Jego możliwości są oczywiście znacznie większe niż mogłoby to wynikać z powyższych przykładów. W szczególności jest on dostosowany do opisu pojęć z języka C++, takich jak klasy, metody, etc. Niestety co bardziej zaawansowane konstrukcje kodowane są w sposób raczej nieprzejrzysty co w świetle faktu, że ten dokument pełni rolę prezentacji, a nie formalnej specyfikacji, czyni opisywanie ich tutaj niepożądanym.
Na zakończenie warto nadmienić, że zawarte w pliku obiektowym informacje w formacie stabs można obejrzeć komendą objdump -q
DWARF to skrót od Debugging With Attributed Records Format. Oczywiście fakt, że format ten opracowany był do stosowania w plikach obiektowych ELF jest czystym przypadkiem... Zresztą np. w Linux'ie przez długi czas w plikach ELF informacje dla debuggerów zaszywano w formacie stabs. DWARF powstał na użytek debuggera sdb z Systemu V. W założeniu miał umożliwiać odpluskwianie programów pisanych w różnych językach, na różnych procesorach i z uwzględnieniem obsługi kodu wysoce zoptymalizowanego. Istotnie pod tymi względami format DWARF wypada znacznie lepiej niż stabs. Przystosowany jest do operowania takimi pojęciami jak klasy, szablony czy makra.
Obecną (w chwili powstania tego dokumentu) wersją formatu DWARF jest 3.0 . Jest ona relatywnie nowa (obowiązującą wersją standardu stała się 4 stycznia 2006) i przez to mniej rozpowszechniona niż DWARF2. Szczęśliwie różnice między drugim a trzecim wydaniem mają głównie charakter techniczny i w niewielkim tylko stopniu wpływają na kształt tej prezentacji. Z istotniejszych różnić należy wymienić:
Dla porządku warto jeszcze wspomnieć o formacie DWARF1. Różni się on dość znacząco od DWARF2 i 3, jednak wgłębianie się w te różnice jest tu bezcelowe. Dokładniejsze informacje o tym formacie można pewnie uzyskać na wydziale historycznym UW, adres Krakowskie Przedmieście 26/28, 00-927 Warszawa.
Informacja w formacie DWARF ma zwykle formę wpisów, które składają się z typu i pewnego zbioru atrybutów. Każdy wpis ma co najwyżej jeden atrybut o danej nazwie. Wartość atrybutu należy do jednej z predefiniowanych klas. Klasy te to: address, block, constant, flag, lineptr, loclistptr, rangelistptr, reference i string. Dokładny opis każdej klasy i sposób fizycznego kodowania danych można oczywiście znaleźć w specyfikacji. Wszystkie wpisy trzymane są w sekcji .debug_info pliku obiektowego (począwszy od DWARF2). Co ciekawe, pamiętane są one w postaci drzewa - takie zależności między wpisami w naturalny sposób pozwalają wyrazić blokową strukturę programu czy np. to, że pole opisane jednym wpisem należy do struktury opisanej drugim.
Przy opisywaniu struktury programu w czasie wykonania często wygodne (lub nawet konieczne) jest wyrażenie jakiejś wartości (zwykle adresu) jako wyrażenia zależnego od bieżących wartości rejestrów, pamięci itp. W formacie DWARF wyrażenia takie zapisywane są jako program prostej maszyny stosowej, mogącej m.in wykonywać operacje arytmetyczne oraz badać pamięć i rejestry. Najczęstszym zastosowaniem wyrażeń jest określanie położenia obiektu w pamięci, dlatego w DWARF2 istniały tylko tzw. wyrażenia lokacyjne. W DWARF3 zdefiniowano ogólne pojęcie wyrażenia, a wyrażenia lokacyjne są pewnym jego rozszerzeniem. Konkretniej, jest to jedyny rodzaj wyrażeń zawierający instrukcje wskazujące na rejestr (jako na rzeczoną lokację). Oto kilka przykładów wyrażeń lokacyjnych
DW_OP_REGX 42
DW_OP_BREG11 42
DW_OP_bregx 54 32 DW_OP_deref
Pierwsze z tych wyrażen mówi, że szukany obiekt jest w rejestrze 42. Drugie wyrażenie opisuje lokację położoną 42 bajty od miejsca wskazywanego przez wartość rejestru 11. W trzecim przypadku obiekt jest wskazywany przez słowo położone 32 bajty od adresu z rejestru 54.
Ponieważ niektóre obiekty mogą zmieniać swoje położenie w trakcie wykonania programu, DWARF umożliwia opisywanie adresów za pomocą list lokacji - są to po prostu listy wyrażen lokacyjnych wzbogacone o adresy w kodzie, w których dane wyrażenie obowiązuje. Umieszczane są w sekcji .debug_loc pliku obiektowego.
W sekcji .debug_line opisane jest odwzorowanie instrukcji skompilowanego kodu w numery linii źródeł. Koncepcyjnie ma ono postać macierzy, której rzędy odpowiadają instrukcjom, a kolumny liniom, plikom i innym danym. Oczywiście macierz taka reprezentowana wprost byłaby bardzo duża, dlatego kodowana jest jako program prostej, 10-rejestrowej maszyny.
Debuggery często muszą badać i modyfikować aktywacje procedur położone w głębi stosu wywołań. Niestety, kompilatory generują kod obsługujący ramki w rozmaitych miejscach, formach i stopniach optymalizacji. Dlatego DWARF zawiera dość złożony schemat opisu zawartości i położenia ramek, którego elementem jest zestaw instrukcji służących do wyliczania położenia różnych elementów ramki. Dane te umieszczone są w sekcji .debug_frame.
Omówione wcześniej formaty powstały w środowiskach UNIX'owych i użynane są przez narzędzia z takowych się wywodzące. Jednak od jakiegoś czasu istnieje produkt o nazwie Windows, reklamowany przez producenta jako system operacyjny. Aplikacje windowsowe również wymagają odpluskwiania (i to bardzo), dlatego ozywiście istnieją także formaty informacji dla odpluskwiaczy stosowane w Windows. Najczęściej spotykane z nich zostały tu pokrótce omówione.
Standardowym formatem pliku obiektowego pod Windows jest PE COFF (Portable Executable COFF). Jest to rozszerzona nieco przez micro$oft wersja starego UNIX'owego formatu COFF (który był następcą a.out i został (prawie) wyparty przez ELF). COFF był sporym krokiem naprzód w stosunku do a.out, a jedną z nowinek był właśnie schemat informacji o symbolach (w tym numerach linii). Format ten jest dość stary i w zasadzie nie jest już używany.
Format używany przez debugger CodeView, który został później zintegrowany z Visual Studio. Micro$oftowy kompilator C wciąż można zmusić do generowania informacji w tym formacie, ale jest ogólnie uważany za przestarzały.
Obecnie stosowany przez narzędzia micro$oftu schemat informacji o symbolach. Format, jak format. Można jeszcze w tym miejscu nadmienić, że ww. wyżej informacje zwykle nie są zaszywane w plikach wykonywalnych, lecz umieszczane w osobnych plikach (.PDB). Pliki .DBG mogą zawierać m.in informacje w wymienionych tu formatach (DBG to tylko "pojemnik", a nie osobny schemat danych o symbolach).
Tajny format, używany w micro$ofcie, o którym nikt nic nie wie. Z autorem tego punktu włącznie.
W tym dokumencie znajduje się bardzo wiele odwołań do różnorakich informacji generowanych przez kompilator. Kompilator jednak jest mało domyślnym zwierzęciem i aby stworzył dane, o które nam chodzi, musimy go ładnie poprosić za pośrednictwem odpowiednich opcji linii poleceń. W tym rozdziale opisane zostały opcje kompilatorów MSVC i gcc. Oczywiście istnieją też inne kompilatory, a nawet języki inne niż C, jednak spisywanie tutaj opcji wszystkich popularnie stosowanych kompilatorów byłoby zadaniem tytanicznym i bezsensownym, gdyż informacje te łatwo znaleźć w dokumentacji każdego porządnego narzędzia.
Warto tu zaznaczyć, iż w celu odpluskwiania zwykle kompilujemy program BEZ opcji optymalizacji, takich jak -Ox czy -fomit-frame-pointer. Czytelnik, który prześledził rozdziały poświęcone formatom informacji dla debuggerów, z pewnością świadom jest, iż odpluskwianie silnie optymalizowanego kodu jest trudnym zadaniem (choć nowoczesne formaty, takie jak DWARF3, w pewnym stopniu sobie z tym radzą).
Podstawową opcją, powodującą dołączenie informacji o symbolach, jest -g. Po -g możemy podać nazwę formatu, w jakim te dane mają powstać (np. stabs, dwarf-2, coff, xcoff, vms). Jeśli jako nazwy formatu użyjemy gdb, wybrany zostanie najsilniejszy z obsługiwanych formatów (zwykle dwarf-2, a w braku takowego stabs) oraz zostaną wytworzone dane specyficzne dla debuggera gdb. Generowanie tych danych można w dowolnym formacie (który je obsługuje) włączyć dopisując po jego nazwie znak '+'. Można także dodać informację o poziomie szczegółowości tworzonych informacji, w postaci liczby z przedziału 1..3. Poziom 1 zawiera tylko podstawowe dane, bez numerów linii i zmiennych lokalnych, zaś poziom 3 dodatkowo dodaje definicje makr. Po formacie dwarf-2 nie można podać poziomu - trzeba dopisać dodatkową opcję -gpoziom.
-p, -pg : te opcje powodują, że utworzony program będzie podczas działania generował informacje dla profilerów (odpowiednio) prof i gprof.
-ftest-coverage : Program skompilowany z tą opcją będzie w czasie działania tworzył informacje na użytek gcov (służące do analizy pokrycia kodu).
-fprofile-arcs : jak wiadomo, program można reprezentować jako diagram składający się z bloków i łuków. Jeśli użyjemy przy kompilacji opcji -fprofile-arcs, otrzymamy program, który podczas swego działania zlicza liczbę przejść każdym łukiem. Jest to potrzebne (z opcją -ftest-coverage) do analizy pokrycia. Może też (w połączeniu z opcją -fbranch-probabilities) służyć do optymalizacji.
W tym kompilatorze za informacje dla debuggerów odpowiada głównie opcja /Z. Konkretniej, /Zi tworzy informację w formacie .pdb, /Z7 używa starszego formatu C7 (CodeView), zaś /Zd dodaje jedynie informacje o numerach linii. Warto także rozważyć użycie 'odpluskwiających' wersji standardowych bibliotek (/MDd, /MTd).
MSVC w wersjach professional i enterprise obsługuje także profilowanie, za pomocą opcji /link profile.
John Robbins, Debugging Applications (MS Press)
http://dwarf.freestandards.org/
man ptrace
man wait
man gcc
http://support.microsoft.com/?id=121460
http://www.delorie.com/djgpp/doc/coff/symtab.html
http://www.microsoft.com/msj/0399/hood/hood0399.aspx
http://www.microsoft.com/whdc/system/platform/firmware/PECOFF.mspx
http://en.wikipedia.org/wiki/Debugger
http://msdn.microsoft.com/library/default.asp?url=/library/en-us/debug/
http://sources.redhat.com/gdb/onlinedocs/stabs_toc.html
http://www.ferg.org/papers/ferg--debugger_introduction.html
http://www.informatik.uni-hamburg.de/RZ/software/gnu/gcc/gdbint_5.html
www.google.com