Odpluskwianie

Tadeusz Sznuk

Valid XHTML 1.0 Strict


Wstęp, mechanizmy działania debuggerów, symbole i opcje kompilacji

Spis treści



Odpluskwianie


Wstęp

Definicje

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.

Metody odpluskwiania

Czytanie kodu

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.

Komunikaty kontrolne

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

Wstęp

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ć

Rodzaje debuggerów

Programy odpluskwiające możemy podzielić na kilka kategorii ze względu na ich możliwości i zastosowania.

Debuggery systemowe

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

"Zwykłe" debuggery

Do odpluskwiania aplikacji w trybie użytkownika.
Przykłady

Debuggery symboliczne

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

Debuggery maszynowe

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

Popularne debuggery
(k)adb

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.

dbx

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.

(k)gdb (+ ddd)

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).

WinDBG

Jest to w zasadzie połączenie debuggera systemowego z aplikacyjnym, wyposażone dodatkowo w wygodny interfejs graficzny.

SoftICE

Systemowy debugger windowsowy o bardzo dużych możliwościach. Niestety nie należ? do tanich. Często stosowany przy tworzeniu sterowników urządzeń.

CodeView (czyt. Visual Studio

Stary debugger firmy Micro$oft, który został zintegrowany z Visual Studio.

Inne zintegrowane z IDE (Delphi, ...)

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).

Narzędzia debuggeropodobne

Oprócz klasycznych odpluskwiaczy niniejsza prezentacja omawia także inne narzędzia o podobnym zastosowaniu i zasadzie działania:


Działanie odpluskwiaczy


Wstęp

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.

Prosty debugger

Wymagania

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

Windows

Schemat

Schemat prostego odpluskwiacza w windows wygląda tak

void main ( void ) {

        CreateProcess (..., DEBUG_ONLY_THIS_PROCESS, ...) ;
        while ( 1 == WaitForDebugEvent ( ... ) ) {

                if ( EXIT_PROCESS ) { break; }
                ContinueDebugEvent ( ... ) ;
        }

}
        

CreateProcess()

Ż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.

WaitForDebugEvent()

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.

ContinueDebugEvent()

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).

Zdarzenia

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.

Pamięć i rejestry

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.

Linux

Schemat

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()

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.

wait()

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).

Pamięć i rejestry

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.

Punkty przerwań (breakpoints)

Proces ustawiania punktów przerwań jest wysoce zależny od procesora. W przypadku rodziny x86 wygląda mniej więcej tak

  1. Odpluskwiacz wstawia w kod debugowanego procesu instrukcję INT3. Ma ona wygodny, krótki kod (0xCC). Przy tego typu modyfikacjach trzeba jednak pamiętać o tym, że kod programu mógł zostać w jakimś cache'u, zatem konieczne jest wyczyszczenie takowych (FlushInstructionCache pod Windows, ptrace robi to automatycznie).
  2. Gdy program wykona przerwanie, system operacyjny przejmie sterowanie, zatrzyma proces odpluskwiany i poinformuje debugger
  3. Przed wznowieniem działania programu trzeba przywrócić instrukcję nadpisaną przez INT3 oraz cofnąć licznik instrukcji. Niestety proces ten spowoduje usunięcie breakpoint'a, co często jest niepożądane. Problem ten można łatwo rozwiązać, jeśli procesor pozwala na wykonanie krokowe (single step execution), czyli zatrzymanie programu po następnej instrukcji. Szczęśliwie procesory x86 mają taką możliwość. Efekt ten można uzyskać np. wywołaniem ptrace z PTRACE_SINGLESTEP.

Symbole


Wprowadzenie

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.

Formaty

stabs

Podstawy

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.

Struktura programu

Nazwa pliku

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.

Numery linii

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).

Funkcje

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.

Struktura blokowa

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

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)).

Typy

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.

Inne uwagi

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

Wstęp

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.

Wersje

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.

Format informacji

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.

Wyrażenia

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.

Listy lokacji

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.

Numery linii

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.

Rozwijanie wywołań

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.

Formaty używane w Windows

Wstęp

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.

COFF

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.

C7

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.

PDB

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).

OMAP

Tajny format, używany w micro$ofcie, o którym nikt nic nie wie. Z autorem tego punktu włącznie.


Opcje kompilacji


Wstęp

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ą).

gcc

-g

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.

Inne

-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.

MSVC

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.

Źródła

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