Jak działają debugery 1

05 października 2012
1 gwiadka2 gwiazdki3 gwiazdki4 gwiazdki5 gwiazdek

Jest to pierwszy z serii artykułów poświęconych tematowi budowy programów diagnostycznych (debugerów). Nie wiem jeszcze z ilu części będzie się składać całość i jakie tematy opiszę w kolejnych częściach, ale w tym artykule omówię podstawy.

W tej części

Przedstawię najważniejszy element budowy debugera w systemie Linux — wywołanie systemowe ptrace. Cały prezentowany kod został napisany w 32-bitowym systemie Ubuntu. Kod ten jest ściśle związany z platformą, dla której został napisany, ale dostosowanie go do potrzeb innych platform nie powinno być trudne.

Motywacja

Aby zrozumieć do czego zmierzamy, wyobraź sobie jak działa typowy debuger. Program taki może uruchomić wybrany proces i go zdiagnozować lub dołączyć się do działającego procesu. Może wykonywać kod krok po kroku, ustawiać punkty wstrzymania i wykonywać kod od jednego takiego punktu do następnego oraz sprawdzać wartości zmiennych i dane na stosie. Wiele debugerów ma zaawansowane funkcje diagnostyczne, takie jak wykonywanie wyrażeń i wywoływanie funkcji w przestrzeni adresowej diagnozowanego procesu, czy zmienianie kodu procesu na bieżąco i obserwowanie efektów tych zmian.

Mimo iż nowoczesne debugery są bardzo skomplikowane [1], zasada ich działania jest zaskakująco prosta. Każdy debuger wykorzystuje tylko kilka podstawowych usług udostępnianych przez system operacyjny i kompilator/konsolidator, a cała reszta to już tylko kwestia odpowiedniego zaprogramowania.

Debugowanie w systemie Linux ? funkcja ptrace

Podstawowym narzędziem używanym do budowy debugerów w systemach Linux jest funkcja systemowa ptrace[2]. To skomplikowane i zarazem bardzo przydatne narzędzie pozwala na śledzenie przez jeden proces wykonywania innych procesów i grzebania w ich wnętrznościach. Na temat funkcji ptrace można by napisać całą książkę, dlatego też w tym artykule skupię się wyłącznie na niektórych jej praktycznych aspektach.

Przejdźmy od razu do praktyki.

Wykonywanie kodu krok po kroku

Pokażę teraz na przykładzie, jak uruchomić wybrany proces w trybie ?śledzonym?, aby móc wykonać jego kod krok po kroku ? mam na myśli kod maszynowy (instrukcje asemblera), który jest wykonywany przez procesor. Przykładowy program przedstawię i szczegółowo opiszę po kawałku. Pełny jego kod znajduje się w pliku C, do którego odnośnik zamieściłem na końcu artykułu. Możesz go skompilować i uruchomić, aby się pobawić.

Ogólny plan jest następujący: chcemy napisać program, który podzieli się na dwa procesy. Jeden proces, zwany potomnym, będzie wykonywał polecenia użytkownika, a drugi, zwany kontrolującym, będzie śledził wykonywanie procesu potomnego. Najpierw przyjrzyjmy się funkcji głównej:

int main(int argc, char** argv)
{
    pid_t child_pid;

    if (argc < 2) {
        fprintf(stderr, "Jako argument należy podać nazwę programun");
        return -1;
    }

    child_pid = fork();
    if (child_pid == 0)
        run_target(argv[1]);
    else if (child_pid > 0)
        run_debugger(child_pid);
    else {
        perror("fork");
        return -1;
    }

    return 0;
}

To proste: za pomocą funkcji fork tworzymy proces potomny [3]. Dalej znajduje się instrukcja warunkowa, której pierwsza gałąź (if) uruchamia proces potomny (tu nazwany target), a druga gałąź (else if) uruchamia proces kontrolujący (tu nazwany debuger).

Oto kod źródłowy procesu, który będziemy śledzić:

void run_target(const char* programname)
{
    procmsg("Proces docelowy został uruchomiony. Będzie działał '%s'n", programname);

    /* Umożliwienie śledzenia procesu */
    if (ptrace(PTRACE_TRACEME, 0, 0, 0) < 0) {
        perror("ptrace");
        return;
    }

    /* Zastąp obraz tego procesu podanym programem */
    execl(programname, programname, 0);
}

Najważniejszy wiersz tego kodu to ten, który zawiera wywołanie funkcji ptrace. Deklaracja tej funkcji (w pliku sys/ptrace.h) jest następująca:

long ptrace(enum __ptrace_request request, pid_t pid,
                 void *addr, void *data);

Pierwszy argument tej funkcji to request, który może być jedną ze standardowych stałych PTRACE_*. Drugi argument określa identyfikator procesu dla żądań. Argumenty trzeci i czwarty zawierają wskaźniki na adres i dane, które są potrzebne do operowania na pamięci. W przedstawionym przykładzie funkcja ptrace wykonuje żądanie PTRACE_TRACEME. Oznacza to, że proces potomny prosi system operacyjny o to, aby zezwolił na śledzenie go przez proces kontrolujący. W dokumentacji man można znaleźć bardzo dobre objaśnienie tego żądania:

Wskazuje, że ten proces ma być śledzony przez proces kontrolujący. Każdy sygnał (z wyjątkiem sygnału SIGKILL) przesłany do tego procesu spowoduje jego zatrzymanie i powiadomienie o tym procesu kontrolującego poprzez funkcję wait().Ponadto wszystkie następne wywołania funkcji exec() wykonywane przez ten proces będą powodować wysłanie do niego sygnału SIGTRAP, co daje procesowi kontrolującemu możliwość przejęcia kontroli zanim nowy program rozpocznie działanie. Proces nie powinien wykonywać tego żądania, jeśli nie jest planowane śledzenie go przez proces kontrolujący (argumenty pid, addr i data są ignorowane).

Fragment, który jest dla nas najważniejszy wyróżniłem pogrubieniem. Zwróć uwagę, że pierwszą czynnością, jaką funkcja run_target wykonuje po funkcji ptrace jest wywołanie za pomocą funkcji execl programu podanego jako argument. To, zgodnie z treścią pogrubionego fragmentu, sprawia, że jądro systemu operacyjnego zatrzymuje proces przed rozpoczęciem wykonywania podanego programu w funkcji execl i wysyła sygnał do procesu kontrolującego.

Czas zobaczyć, co robi program kontrolujący:

void run_debugger(pid_t child_pid)
{
    int wait_status;
    unsigned icounter = 0;
    procmsg("Debuger został uruchomionyn");

    /* Czeka, aż proces potomny zatrzyma się na swojej pierwszej instrukcji. */
    wait(&wait_status);

    while (WIFSTOPPED(wait_status)) {
        icounter++;
        /* Zlecenie wykonania kolejnej instrukcji przez proces potomny */
        if (ptrace(PTRACE_SINGLESTEP, child_pid, 0, 0) < 0) {
            perror("ptrace");
            return;
        }

        /* Czeka, aż proces potomny zatrzyma się na kolejnej instrukcji. */
        wait(&wait_status);
    }

    procmsg("Proces potomny wykonał %u instrukcjin", icounter);
}

Przypomnijmy, że gdy proces potomny rozpocznie wykonywanie funkcji exec, zatrzyma się i otrzyma sygnał SIGTRAP. Proces kontrolujący czeka na to za pomocą swojego pierwszego wywołania funkcji wait. Funkcja ta zwraca wartość, gdy wydarzy się coś interesującego, a proces kontrolujący sprawdza czy zdarzeniem tym jest zatrzymanie procesu potomnego (makro WIFSTOPPED zwraca wartość true, gdy proces zostanie zatrzymany przez otrzymany sygnał).

To, co proces robi dalej stanowi najważniejszą część tego artykułu. Proces ten wywołuje funkcję ptrace przekazując jej jako argumenty żądanie PTRACE_SINGLESTEP i identyfikator procesu potomnego. Innymi słowy ?zwraca się? do systemu operacyjnego w następujący sposób: wznów wykonywanie procesu potomnego, ale zatrzymaj go znowu, gdy wykona kolejną instrukcję. Proces kontrolujący ponownie czeka na zatrzymanie procesu potomnego i koło się zamyka. Zakończenie wykonywania pętli nastąpi w momencie, gdy funkcja wait zwróci sygnał nie oznaczający zatrzymania procesu potomnego. W normalnej sesji działania programu śledzącego zwrócony zostanie sygnał informujący proces kontrolujący o tym, że proces potomny został zakończony (makro WIFEXITED zwróciłoby wartość true w takim przypadku).

Zwróć uwagę, że w zmiennej icounter zapisywana jest liczba instrukcji wykonanych przez śledzony proces. A zatem ten nasz prosty przykład robi coś pożytecznego ? pobiera nazwę programu z wiersza poleceń, wykonuje ten program i zwraca liczbę instrukcji procesora, jakie zostały wykonane w czasie działania tego programu. Zobaczmy jak to działa w praktyce.

Uruchomienie testowe

Skompilowałem poniższy program i uruchomiłem go pod kontrolą programu śledzącego:

#include
<stdio.h>
int main()
{
    printf("Witaj, świecie!n");
    return 0;
}

Ku memu zaskoczeniu program śledzący działał bardzo długo, a gdy skończył, poinformował mnie, że wykonanych zostało aż 100000 instrukcji. Dla prostego wywołania funkcji printf? Co jest? Odpowiedź na to pytanie jest bardzo ciekawa[4]. Domyślnie kompilator gcc w Linuksie łączy programy z bibliotekami wykonawczymi C dynamicznie. Oznacza to, że na początku wykonywania każdego programu zostaje uruchomiony program dynamicznie wczytujący biblioteki, który wyszukuje potrzebne wspólne biblioteki. Do wykonania jest bardzo dużo kodu ? a przypomnę, że nasz prosty program śledzący bierze pod uwagę wszystkie instrukcje, nie tylko te, które znajdują się w funkcji main, lecz instrukcje całego procesu.

Gdy do konsolidacji programu użyłem flagi -static (i sprawdziłem, czy plik wykonywalny powiększył się o około 500 KB, co jest normalne w przypadku statycznego dołączania bibliotek wykonawczych C), program śledzący zgłosił wykonanie tylko około 7000. To nadal dużo, ale było to do przewidzenia, jeśli weźmie się pod uwagę fakt, że przed funkcją main musi zostać dokonana inicjacja biblioteki libc, a po wykonaniu funkcji main muszą zostać wykonane czynności porządkowe. Poza tym funkcja printf też jest skomplikowana.

Osiągnięty wynik nie był dla mnie satysfakcjonujący, ponieważ moim celem było coś testowalnego, tzn. chciałem, aby każda zliczona instrukcja była pod moją kontrolą. Można to oczywiście zrobić przy użyciu kodu asemblera. Postanowiłem zatem zapisać program Witaj, świecie w asemblerze:

section    .text
    ; Symbol _start musi być zadeklarowany dla konsolidatora (ld)
    global _start

_start:

    ; Przygotowanie argumentów dla wywołania systemowego sys_write:
    ;   - eax: liczba wywołań systemowych (sys_write)
    ;   - ebx: deskryptor pliku (stdout)
    ;   - ecx: wskaźnik na łańcuch
    ;   - edx: string length
    mov    edx, len
    mov    ecx, msg
    mov    ebx, 1
    mov    eax, 4

    ; Wykonanie wywołania systemowego sys_write
    int    0x80

    ; Wykonanie funkcji sys_exit
    mov    eax, 1
    int    0x80

section   .data
msg db    'Witaj, świecie!', 0xa
len equ    $ - msg

Faktycznie, teraz program śledzący poinformował o wykonaniu 7 instrukcji, co mogę z łatwością zweryfikować.

W głąb strumienia instrukcji

Dzięki programowi napisanemu w asemblerze mogę przedstawić inne przydatne zastosowanie funkcji ptrace ? możliwość szczegółowego badania stanu śledzonego procesu. Oto nowa wersja funkcji run_debugger:

void run_debugger(pid_t child_pid)
{
    int wait_status;
    unsigned icounter = 0;
    procmsg("Debuger został uruchomionyn");

    /* Czeka, aż proces potomny zatrzyma się na swojej pierwszej instrukcji. */
    wait(&wait_status);

    while (WIFSTOPPED(wait_status)) {
        icounter++;
        struct user_regs_struct regs;
        ptrace(PTRACE_GETREGS, child_pid, 0, &regs);
        unsigned instr = ptrace(PTRACE_PEEKTEXT, child_pid, regs.eip, 0);

        procmsg("icounter = %u.  EIP = 0x%08x.  instr = 0x%08xn",
                    icounter, regs.eip, instr);

        /* Zlecenie wykonania kolejnej instrukcji przez proces potomny */
        if (ptrace(PTRACE_SINGLESTEP, child_pid, 0, 0) < 0) {
            perror("ptrace");
            return;
        }

        /* Czeka, aż proces potomny zatrzyma się na kolejnej instrukcji. */
        wait(&wait_status);
    }

    procmsg("Proces potomny wykonał %u instrukcjin", icounter);
}

Ta wersja funkcji różni się od poprzedniej tylko kilkoma pierwszymi wierszami kodu w pętli while. Są tam dwa nowe wywołania funkcji ptrace. Pierwsze wczytuje wartość rejestrów procesu do struktury. Definicja struktury user_regs_struct znajduje się w pliku sys/user.h. Teraz będzie najlepsze. W początkowej części tego pliku nagłówkowego znajduje się następujący komentarz:

/* Plik ten jest przeznaczony tylko dla GDB.
   Nie czytaj za dużo jego treści. Nie używaj go do niczego innego, niż
   GDB, chyba że dokładnie wiesz, co robisz.  */

Nie wiem, jak Ty, ale ja czuję, że jesteśmy na właściwym tropie . Ale wracajmy do przykładu. Mając wszystkie rejestry w strukturze regs, możemy przyjrzeć się bieżącej instrukcji procesu za pomocą wywołania funkcji ptrace z żądaniem PTRACE_PEEKTEXT i przekazując jej jako adres regs.eip (rozszerzony wskaźnik na instrukcje w procesorach x86). W zwrocie otrzymujemy instrukcję[5]. Zobaczmy jak ta nowa wersja programu diagnostycznego będzie działać na naszym programie napisanym w asemblerze:

$ simple_tracer traced_helloworld
[5700] Debuger rozpoczął działanie
[5701] Proces docelowy został uruchomiony. Będzie działał pod nazwą 'traced_helloworld'
[5700] icounter = 1.  EIP = 0x08048080.  instr = 0x00000eba
[5700] icounter = 2.  EIP = 0x08048085.  instr = 0x0490a0b9
[5700] icounter = 3.  EIP = 0x0804808a.  instr = 0x000001bb
[5700] icounter = 4.  EIP = 0x0804808f.  instr = 0x000004b8
[5700] icounter = 5.  EIP = 0x08048094.  instr = 0x01b880cd
Witaj, świecie!
[5700] icounter = 6.  EIP = 0x08048096.  instr = 0x000001b8
[5700] icounter = 7.  EIP = 0x0804809b.  instr = 0x000080cd
[5700] Proces potomny wykonał 7 instrukcji

Zatem teraz oprócz zmiennej icounter widzimy także wskaźnik na instrukcje i instrukcje, które on wskazuje w każdym kroku. Jak sprawdzić, czy to jest poprawne? Zrobimy to przepuszczając plik wykonywalny przez narzędzie objdump -d:

$ objdump -d traced_helloworld

traced_helloworld:     file format elf32-i386

Disassembly of section .text:

08048080 <.text>:
 8048080:     ba 0e 00 00 00          mov    $0xe,%edx
 8048085:     b9 a0 90 04 08          mov    $0x80490a0,%ecx
 804808a:     bb 01 00 00 00          mov    $0x1,%ebx
 804808f:     b8 04 00 00 00          mov    $0x4,%eax
 8048094:     cd 80                   int    $0x80
 8048096:     b8 01 00 00 00          mov    $0x1,%eax
 804809b:     cd 80                   int    $0x80

Bez trudu można zauważyć, że ten wynik pokrywa się z uzyskanym za pomocą naszego programu śledzącego.

Dołączanie się do działającego procesu

Jak wiemy, debugery mogą też ?podłączać się? pod już działające procesy. Pewnie nie zdziwi Cię wiadomość, że do tego również używa się funkcji ptrace, z żądaniem PTRACE_ATTACH. Nie będę tu pokazywał przykładowego kodu, jak to zrobić, ponieważ na bazie tego, co zostało pokazane wcześniej można to z łatwością zrobić samodzielnie. Po względem edukacyjnym wygodniejsze jest pierwsze podejście, ponieważ badany proces możemy zatrzymać już na samym początku jego działania.

Kod

Kompletny kod źródłowy w języku C opisanego w tym artykule programu śledzącego (jest to rozbudowana wersja, która dodatkowo drukuje instrukcje) znajduje się tutaj. Można go skompilować za pomocą polecenia -Wall -pedantic –std=c99 przy użyciu kompilatora gcc w wersji 4.4.

Podsumowanie i plany

Na razie jeszcze niewiele dowiedziałeś się na temat budowy debugerów ? przykładowy program daleki jest jeszcze od tego, co nazwalibyśmy prawdziwym debugerem. Mam jednak nadzieję, że udało mi się przynajmniej częściowo odkryć tajemnice tych narzędzi programistycznych. Funkcja ptrace to bardzo wszechstronne narzędzie systemowe, które dopiero zaczęliśmy poznawać.

Wykonywanie kodu krok po kroku jest przydatne, ale tylko w pewnym zakresie. Weźmy na przykład przedstawiony w tym artykule program Witaj, świecie. Aby dojść do funkcji main, trzeba wykonać kilka tysięcy instrukcji kodu inicjującego system wykonawczy języka C. Nie jest to zbyt dogodne podejście. Lepiej by było, gdybyśmy bezpośrednio przed funkcją main ustawili punkt wstrzymania i wykonywanie krok po kroku rozpoczynali od tego miejsca. W następnym artykule z tej serii pokażę właśnie, jak implementuje się punkty wstrzymania.

Źródła

Przygotowując ten artykuł korzystałem z następujących źródeł:

Przypisy

[1] Nie sprawdzałem tego, ale jestem pewien, że liczba LOC gdb mieści się przynajmniej w zakresie sześciocyfrowym.

[2] Wykonaj polecenie man 2 ptrace, jeśli potrzebujesz więcej informacji.

[3] Autor tego artykułu zakłada, że czytelnik zna podstawy programowania w systemie Unix/Linux. Ponadto powinieneś znać (przynajmniej teoretycznie) rodziny funkcji fork i exec oraz sygnały Uniksa.

[4] Przynajmniej jeśli masz taką samą obsesję na punkcie niskopoziomowych szczegółów, jak ja.

[5] Ostrzeżenie: znaczna część tego artykułu dotyczy konkretnej platformy. Dokonuję tu pewnych uproszczeń ? na przykład instrukcje w architekturze x86 nie muszą mieścić się w 4 bajtach (rozmiar typu unsigned w moim komputerze z 32-bitowym systemem Ubuntu). W istocie w wielu przypadkach tak nie jest. Do przeglądania instrukcji potrzebny jest kompletny dezasembler. My takiego nie mamy, ale w prawdziwych debugerach takie coś jest.

Autor: Eli Bendersky

Źródło: http://eli.thegreenplace.net/2011/01/23/how-debuggers-work-part-1/

Tłumaczenie: Łukasz Piwko

Dyskusja

Twój adres email nie zostanie opublikowany. Pola, których wypełnienie jest wymagane, są oznaczone symbolem *