Valid XHTML 1.0 Transitional Valid CSS!

Odpluskwianie


pingwinek

Spis treści

Sekcje opatrzone dopiskiem Bonus wychodzą poza materiał, który trzeba omówić na prezentacji. Zostaną one omówione w przypadku gdy pozostanie wystarczająca ilość czasu na zajęciach i zgromadzona publiczność będzie tego chciała.

Wstęp

W niniejszej prezentacji omawiamy metody i narzędzia debugujące począwszy od tych najprostszych, stosowanych do odpluskwiania zwykłych programów (jak chociażby printf), do bardziej zaawansowanych stosowanych do odpluskwiania systemów operacyjnych, nie tylko uniksopodobnych (printk, UML, kgdb, kd, windbg, strace, valgrind, fenris). Naszą ambicją było zapoznać czytelnika z solidnymi podstawami teoretycznymi procesu odpluskwiania, a także stworzenie swoistego kompendium wiedzy praktycznej o narzędziach do wykrywania błędów i profilowania kodu. Językiem, którego używamy by demonstrować działanie odpluskwiaczy jest C, a kompilatorem, gcc.

Najprostsze sposoby odpluskwiania

Najprostszą, ale i najmniej efektywną metodą usuwania usterek z programów jest dodawanie do kodu źródłowego dodatkowych instrukcji. Mogą one albo wypisywać coś na ekran lub do pliku, albo sprawdzać czy pewne warunki są prawdziwe w określonych miejscach programu.
Funkcje printf() i fprintf() pozwalają wypisywać na standardowe wyjście lub do pliku napisy, wartości zmiennych etc. Każdy programista w swoim życiu na pewno choć raz napisał kod podobny do poniższego:
if (x[i].a == y[n-i].a)
  printf("Niemozliwe: %d %d",x[i].a,y[n-i].a);
Assert
Powyższy sposób sprawdzania jest mało elegancki i czasochłonny - trzeba używać instrukcji if oraz printf, którego składnia jest skomplikowana, a po usunięciu usterki trzeba je wszystkie wykomentować, lub usunąć, co znów zabiera cenny czas programisty. Dlatego stworzono funkcję assert(), o następującej składni:
#include <assert.h>

void assert(scalar wyrazenie);
Ta funkcja sprawdza podany jej warunek logiczny i jeśli jest on fałszywy (równy 0) to kończy działanie programu wypisując na ekran komunikat o błędzie. Na przykład dla programu:
#include <assert.h>

int main(){
  int x = 5;
  assert(x==665);
  return 0;
}
Na ekran wypisane zostanie:
a.out: ble.c:5: main: Assertion `x==665' failed.
Aborted
Oznacza to, że błąd powstał w pliku wykonywalnym a.out, którego źródłem jest ble.c. Nieprawdziwa asercja pojawiła się w linii 5 w funkcji main().

Śledzenie procesów - czyli jak działają debuggery?

Do pisania programów służących odpluskwianiu pomocna może być funkcja ptrace. Pozwala ona jednemu procesowi, zwanemu dalej kontrolującym (lub śledzącym), śledzić wykonanie drugiego procesu (śledzonego).

Po co śledzić procesy?

Proces kontrolujący informuje system, że interesują go zdarzenia związane z procesem śledzonym, mogą do nich należeć: W przypadku zajścia takiego zdarzenia wykonanie programu śledzonego zostanie wstrzymane, proces kontrolujący zostanie o takim zajściu powiadomiony i będzie mógł uzyskać dostęp do przestrzeni danych procesu śledzonego, zmienić jakieś dane a następnie wznowić jego wykonanie.

Interesujące pola struktury task_struct

W strukturze task_struct jest bardzo wiele pól, nas interesować będą jedynie niektóre z nich. Oto jak wyglądają powiązania rodzinne procesu:

rysunek


W jądrze Linuksa zachowane są następujące niezmienniki: Uwaga: Wywołanie funkcji getppid w procesie śledzonym spowoduje zwrócenie numeru ID oryginalnego ojca, a nie procesu śledzącego.

W strukturze task_struct występuje również pole:
struct task_struct {
...
unsigned long ptrace;
...
}
Pole to pełni rolę maski bitowej, w której zapamiętywane są flagi, oto niektóre z nich: Te jak i pozostałe flagi można obejrzeć w pliku include/linux/sched.h.

Funkcja ptrace

Oto jak wygląda deklaracja funkcji ptrace:
#include <sys/ptrace.h>
long  int ptrace(enum __ptrace_request request, pid_t pid, void *addr, void * data)
Znaczenie poszczególnych argumentów:

Rozpoczęcie śledzenia

Możemy to zrobić na 2 sposoby, mianowicie: Po wykonaniu jednej z powyższych czynności proces śledzony będzie kontynuował swoje działanie, jednakże w momencie wywołania funkcji exec (lub otrzymania sygnału) proces śledzony zostanie wstrzymany, proces kontrolujący zostanie o tym powiadomiony i będzie mógł decydować o dalszych losach programu śledzonego. Proces kontrolujący oczekuje na zajścia procesu śledzonego poprzez wywołanie funkcji wait lub waitpid.

Czytanie/pisanie w przestrzeni procesu śledzonego

Do tego celu może nam posłużyć funkcją ptrace z pierwszym argumentem:
Dzięki takim możliwościom jakie daje nam funkcja ptrace proces kontrolujący może (w momencie w którym proces śledzony jest zawieszony):
Oto przykład programu, który ilustruje wykorzystanie funkcji ptrace (program pochodzi z książki: Maurice J. Bach, Budowa systemu operacyjnego):
Proces śledzony (o nazwie trace):
#include <stdio.h>
int dane[5];
int main()
{
 int i;
 for( i = 0; i < 5; i++)
   printf("dane[%d] = %d\n", i, dane[i] );
 printf("Adres sledzonych danych: 0x%x\n", dane );
}
Uruchomienie programu ./trace spowoduje wypisanie na ekran:
[cygan@duch prezentacja]$ ./trace
dane[0] = 0
dane[1] = 0
dane[2] = 0
dane[3] = 0
dane[4] = 0
Adres sledzonych danych: 0x500a00
Proces kontrolujący:
#include <stdio.h>
#include <sys/ptrace.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
int adres;

int main( int argc, char *argv[] )
{
 int i, ident;

 sscanf( argv[1], "%x", &adres );

 if(( ident = fork() ) == 0 ) {
    ptrace( PTRACE_TRACEME, 0, 0, 0 );
    execl("trace","trace", 0 );
    return -1;
 }
 wait((int *)0);
 for( i = 0; i < 5; i++ ) {
    if (ptrace(PTRACE_POKEDATA,ident,(void *)adres,i) == -1)
      return -1;
    adres += sizeof(int);
 }
 ptrace(PTRACE_CONT,ident,1,0);
 return 0;
}
Uruchomienie programu kontrolującego ./ptrace 0x500a00 spowoduje:
[cygan@duch prezentacja]$ ./ptrace 0x500a00
[cygan@duch prezentacja]$ dane[0] = 0
dane[1] = 1
dane[2] = 2
dane[3] = 3
dane[4] = 4
Adres sledzonych danych: 0x500a00

Sygnały

Podczas przyjęcia sygnału przez proces (jeszcze przed wejściem do obsługi przerwania) następuje sprawdzenie czy dany proces jest śledzony, a jeśli tak, to proces śledzony zostaje wstrzymany, a proces kontrolujący zostaje o tym powiadomiony (zazwyczaj za pomocą sygnału SIGCHLD).
UWAGA: Wyjątek stanowi sygnał SIGKILL.
int fastcall do_signal(struct pt_regs *regs, sigset_t *oldset){
...
      unsigned long signr;
      spin_lock_irq(&current->sigmask_lock);
      signr = dequeue_signal(&current->blocked, &info);
      spin_unlock_irq(&current->sigmask_lock);
      if (!signr)
      break;
      if ((current->ptrace & PT_PTRACED) && signr != SIGKILL) {
              /* Let the debugger run.  */
              current->exit_code = signr;
              current->state = TASK_STOPPED;
              notify_parent(current, SIGCHLD);
              schedule();
              ...
      }
      ...
}

Wznawianie procesu śledzonego

Aby wznowić działanie procesu śledzonego, proces kontrolujący może wywołać funkcję ptrace podając jako drugi argument pid procesu śledzonego, natomiast jako pierwszy argumentem :
UWAGA: Proces kontrolujący może zadecydować co się stanie z sygnałem, którego pojawienie się spowodowało wstrzymanie procesu śledzonego. Sygnał ten może zostać anulowany lub nawet zmieniony na inny.

Śledzenie wywołań funkcji systemowych

Podczas wywoływania funkcji systemowych wykonywana jest funkcja syscall_trace. Jeśli proces jest śledzony i flaga PT_TRACESYS jest ustawiona, to zostaje on wstrzymany (stan procesu zostaje zmieniony na TASK_STOPPED) i powiadomiony zostaje proces kontrolujący. Proces śledzony zostanie również wstrzymany przy wyjściu z funkcji systemowej.

Punkty przerwania (ang. breakpoints)

Na komputerach z procesorem i386 punkty przerwania można zaimplementować na dwa sposoby:

Instrukcja int 3.

Instrukcja ta, jak każda instrukcja int X wywołuje przerwanie. Jednakże instrukcja int 3 różni się od pozostałych tym, że ma specjalny, jednobajtowy kod, dzięki czemu łatwiej wstawić ją w kod programu (wystarczy podmienić jeden bajt). Procesor, napotkawszy tę instrukcję generuje przerwanie numer 3, co powoduje wysłanie do procesu sygnału SIGTRAP. Jeśli proces jest śledzony, to sygnał ten zostanie dostarczony do procesu kontrolującego, a proces śledzony zostanie wstrzymany. Proces kontrolujący powinien usunąć instrukcję int 3 (zastępując ją zapamiętanym, oryginalnym bajtem) przed wznawianiem wykonania procesu śledzonego, a następnie zmniejszyć adres powrotu (EIP) o 1 (za pomocą funkcji ptrace(PTRACE_POKEUSR,...)), gdyż przerwanie int 3 powoduje umieszczenie na stosie adresu powrotu wskazującego na kolejną po int 3 instrukcję.

Rejestry uruchamiania.

Procesor i386 posiada specjalne rejestry, zwane rejestrami uruchamiania (ang. debug registers). Pozwalają one na wywoływanie przerwania w przypadku odczytu/zapisu danych pod podanym adresem (można zdefiniować 4 takie adresy). W oczywisty sposób może to być wygodniejsze od użycia int 3 (gdy nie wiemy, która instrukcja powoduje zmianę naszych danych). Procesor sygnalizuje dostęp do danych poprzez przerwanie int 1 (nazywany po angielsku debug exceptions).

Programy śledzące wykonanie programu

Strace

Strace to narzędzie do analizy programów badające ich interakcję z jądrem systemu operacyjnego. Program ten przechwytuje i nagrywa wywołania systemowe, dokonane przez proces, oraz sygnały, które do niego dotarły. Nazwa każdego wywołania, oraz jego argumenty, są wypisywane na wyjście standardowe diagnostyczne, lub do pliku podanego w opcji -o. Do użycia programu strace nie musimy posiadać źródeł danego programu, a także program nie musi być skompilowany z opcją -g. Dla początkującego użytkownika dużą wadą jest rozmiar danych wypisywanych przez strace (nawet dla prostego programu). Bardzo podobnym programem do strace jest program ltrace, który oprócz sygnałów i wywołań funkcji systemowych potrafi śledzić wywołania funkcji z bibliotek dzielonych (ang. shared libraries).

Fenris

rysunek
Fenris to niskopoziomowe narzędzie analizy programów autorstwa Michała Zalewskiego. Fenris próbuje interpretować znaczenie wykonywanego kodu asemblera, jest więc narzędziem pośrednim pomiędzy operującym na poziomie pojedynczej instrukcji procesora debuggerem asemblera, a debuggerem języka wysokiego poziomu. Możliwości tego programu są dużo większe niż programu strace, jednakże projekt ten nie jest obecnie rozwijany (w szczególności nie współpracuje z nowymi wersjami gcc) dlatego też nie będziemy w naszej prezentacji omawiać tego (jednakże ciekawego) projektu.

Kompilowanie programu - wprowadzenie

Aby zrozumieć działanie programów do odpluskwiania i profilowania kodu musimy zagłębić się w proces kompilowania programu. Omówimy go na przykładzie kompilatora gcc.

Format plików obiektowych

Wstęp, czyli po co nam to

Odpluskwiając nasze programy chcielibyśmy zapewne wiedzieć dokładnie co się dzieje w której linii naszego kodu, jakie są wartości poszczególnych zmiennych. Tymczasem maszyna wykonuje kod, który na pierwszy rzut oka nijak ma się do tego co my napisaliśmy w źródłach programu. Powstaje więc pytanie skąd w takim razie debugger ma wziąć tego typu informacje. Otóż właśnie z pliku obiektowego, o ile oczywiście odpowiednio przeprowadzimy proces kompilacji.

Zawartość

Plik obiektowy zawiera pięć podstawowych rodzajów informacji:


Nie wszystkie formaty plików obiektowych zawierają wszystkie te informacje, niektóre mogą zawierać coś więcej.

Rodzaje plików obiektowych

Ze względu na ich przeznaczanie wyróżniamy następujące rodzaje plików obiektowych:

Formaty plików obiektowych

Przykładowe formaty plików obiektowych to:


Format .COM zawiera wyłącznie sekcję object code, natomiast a.out i ELF przechowują bogatszą informację (obecnie ELF zastępuje a.out ze względu na wsparcie dla dynamicznego linkowania i cross-compilation).

Wygląd pliku obiektowego na przykładzie a.out

Plik w formacie a.out podzielony jest na następujące sekcje:


Sekcja text odpowiada kodowi programu, data opisuje jego struktury danych, a sekcje relocation zawierają informacje umożliwiające ustalenie położenia poszczególnych segmentów kodu w przypadku relokacji. Tablica symboli trzyma informacje niezbędne do zlokalizowania i zmiany definicji symbolicznych i referencji w programie. W szczególności zawiera ona definicje wszystkich identyfikatorów zmiennych i procedur. Tablica napisów to po prostu tablica null-terminowanych łańcuchów znaków oznaczających konkretne symbole w kodzie źródłowym.

Informacje potrzebne do odpluskwiania w plikach obiektowych

Abyśmy mogli debugować nasz program w pliku obiektowym muszą znaleźć się zależności pomiędzy kodem maszynowym, a:

Informacja o numerze linii

W pliku obiektowym znajduje się mapa odwzorowująca adresy w programie na numery linii kodu źródłowego. Dzięki tej prostej strukturze podczas odpluskwiania można ustawiać breakpointy, odtwarzać stos wywołań programu, a także podawać dokładne numery linii wystąpienia błędu.
Dla każdej linii z której kompilator wygenerował jakikolwiek kod źródłowy kompilator tworzy nową pozycję w mapie, która paruje numer linii i adres pierwszej instrukcji z danej linii. Jeśli dana instrukcja kodu źródłowego leży pomiędzy dwoma początkami linii (np. jest poprzedzona białym znakiem, lub inną instrukcją) to do mapy trafia oczywiście niższy z dwóch numerów linii. Dodatkowo debugger musi rozróżniać linie kodu źródłowego z różnych plików (gdy includujemy jakiś plik do naszego programu). Ten problem jest najczęściej rozwiązywany na jeden z następujących sposobów:
Ostatnim problemem jest optymalizacja. Jeśli kompilujemy program z jakimiś opcjami optymalizacji musimy się liczyć z tym, że kompilator może zmienić kolejność wykonywania niektórych instrukcji, a niektórych w ogóle nie wykonać. Współczesne formaty plików obiektowych radzą sobie z tym w dwojaki sposób: albo (jak np DWARF) pamiętają dla każdego bajtu numer odpowiadającej mu linii w kodzie źródłowym, (co jest bardzo kosztowne, ale daje poprawny wynik) albo generują w takich sytuacjach jedynie przybliżone numery linii.

Informacja o symbolach

Oczywistym jest, że debugger musi umieć powiązać nazwy zmiennych z ich wartościami i adresami w pamięci. Należy jednak pamiętać, iż dochodzi do tego problem złożonych struktur danych takich jak struct z języka C- debugger musi poprawnie formatować wszystkie pola takiego rekordu.
Informacje o symbolach są zawarte w jawnym, bądź niejawnym drzewie. Na najwyższym poziomie mamy wszystkie typy, zmienne i procedury globalne, w nich z kolei dowiązania do ich podprocedur, zmiennych lokalnych etc. W procedurach występują specjalne znaczniki odpowiadające początkowi i końcowi bloku. Ta dość intuicyjna reprezentacja zapewnia możliwości kontrolowania zasięgów zmiennych.
Najciekawszą (czytaj: najbardziej trickową) częścią informacji o symbolach jest informacja o lokacjach. Lokacja statycznej zmiennej globalnej nie zmienia się w trakcie działania programu, tymczasem zmienna lokalna może być: Na większości architektur dla każdego wywołania zapamiętywane są wskaźniki do stosu i ramki w której było zapamiętane odpowiednie środowisko. Dzięki temu debugger może odtworzyć stos wywołań i odpowiednie zmienne lokalne.

Konkretne formaty plików przygotowanych do odpluskwiania (Bonus)

STABS - symbol tables

W dalszej części prezentacji omówimy dokładniej format stabs, składową formatu plików obiektowych a.out.

Przegląd przepływu informacji do debugowania

Kompilator gcc kompiluje źródło znajdujące się w pliku '.c' do pliku '.s', który zawiera niskopoziomowe instrukcje. Assembler tłumaczy tenże na plik '.o', który z kolei linker łączy z innymi plikami '.o' i produkuje kod wykonywalny. Jeśli kompilując gcc użyjemy opcji '-g' to w pliku s znajdą się dodatkowe informacje potrzebne do debugowania, które poddane zostaną delikatnym modyfikacjom przez assemblera i linkera by w końcu znaleźć się w pliku wykonywalnym. Te informacje opisują: W plikach obiektowych wykorzystujących stabs wszystkie powyższe informacje są zawarte w dyrektywach assemblera zwanych stab (skrót od symbol table). Assembler dodaje informację zawartą w stab do tablicy symboli pliku '.o', który właśnie buduje.

Dyrektywy stab assemblera

Są 3 główne rodzaje dyrektyw assemblera typu stab:
.stabs "string",type,other,desc,value
.stabn type,other,desc,value
.stabd type,other,desc
Ostatnie literki (s,n,d) to skróty odpowiednio od: string, number i dot. Po każdym polu następuje lista informacji, które występują po nim: To co znajduje się w polach desc i value zależy od powyższych pól. Standardowy format pola string to:
name:symbol-descriptor type-information
Przykładowo automatycznie dołączana deklaracja typu int wygląda następująco:
.stabs "int:t1=r1;-2147483648;2147483647;",128,0,0,0
Pole name może być opuszczone, i tak na przykład napis
:t10=*2
oznacza, że typ 10 jest wskaźnikiem do typu 2. Możliwość omijania nazwy jest obsługiwana przez gdb, ale niekoniecznie przez inne debuggery co sprawia, że dane w formacie stabs mogą nie współpracować z i niektórymi debuggerami. Jedną z najistotniejszych informacji dla debuggera jest numer linii kodu źródłowego z której pochodzi dana instrukcja. Żeby ją zapamiętać w pliku umieszczany jest wpis, który w polu string zawiera stałą N_SLINE, w polu value umieszczamy adres kodu wykonywalnego, natomiast w polu desc numer linii w kodzie źródłowym.

Opcje odpluskwiania w gcc

Dla lepszego zrozumienia procesu roli procesu kompilacji w odpluskwianiu omówimy kilka wybranych pozycji z ogromnego wachlarza opcji odpluskwiania w gcc.

Odpluskwianie z użyciem gdb

Czym jest gdb?

GNU Debugger -- debugger będący częścią projektu GNU, napisany w 1988 r. przez Richarda Stallmana. Obecna wersja programu (6.3) obsługuje wiele architektur komputerowych i jest dostępna dla wielu systemów operacyjnych. Zazwyczaj zamiast pełnej nazwy używa się akronimu GDB. GNU Debugger jest dostępny na warunkach Powszechnej Licencji Publicznej GNU.

Jak działa gdb?

Implementacja gdb wykorzystuje funkcję ptrace, dlatego też główne funkcjonalności są realizowane w sposób przedstawiony przy omawianiu możliwości jakie nam daje funkcja ptrace. Dodatkowo, aby zapamiętywać wywołania oraz wywoływane funkcje gdb używa struktury ramki, której deklaracja umieszczona jest poniżej (czytanie zalecany tylko dla najwytrwalszych):
 struct frame_info
{
/* Level of this frame.  The inner-most (youngest) frame is at level
     0.  As you move towards the outer-most (oldest) frame, the level
     increases.  This is a cached value.  It could just as easily be
     computed by counting back from the selected frame to the inner
     most frame.  */
/* NOTE: cagney/2002-04-05: Perhaps a level of ``-1'' should be
     reserved to indicate a bogus frame - one that has been created
     just to keep GDB happy (GDB always needs a frame).  For the
     moment leave this as speculation.  */
int level;

/* The frame's low-level unwinder and corresponding cache.  The
     low-level unwinder is responsible for unwinding register values
     for the previous frame.  The low-level unwind methods are
     selected based on the presence, or otherwise, of register unwind
     information such as CFI.  */
void *prologue_cache;
const struct frame_unwind *unwind;

/* Cached copy of the previous frame's resume address.  */
struct {
  int p;
  CORE_ADDR value;
} prev_pc;

/* Cached copy of the previous frame's function address.  */
struct
{
  CORE_ADDR addr;
  int p;
} prev_func;

/* This frame's ID.  */
struct
{
  int p;
  struct frame_id value;
} this_id;

/* The frame's high-level base methods, and corresponding cache.
     The high level base methods are selected based on the frame's
     debug info.  */
const struct frame_base *base;
void *base_cache;

/* Pointers to the next (down, inner, younger) and previous (up,
     outer, older) frame_info's in the frame cache.  */
struct frame_info *next; /* down, inner, younger */
};

Kompilacja programu

Załóżmy że chcemy odpluskwiać program "costam.c":
#include <stdio.h>

int f(int x){
int y=5;
return 2*x+y;
}

int main(){
int x;
x=15;
printf("%d\n",f(x));
return 0;
}
W tym celu musimy go skompilować z opcją odpluskwiania -g:
gcc -g -o costam costam.c
Powstał plik wykonywalny "costam" przygotowany do odpluskwiania.

Uruchamianie gdb

Aby uruchomić gdb do odpluskwiania naszego programu wykonujemy komendę:
gdb costam
Możemy to również zrobić uruchamiając gdb i wpisując "file costam".

Uruchamianie naszego programu

W tym celu wykonujemy komendę:
run [parametry]
Większość komend gdb posiada jednoliterowe skróty od swoich nazw. I tak na przykład uruchomienie:
r [parametry]
będzie miało równoważny skutek.

Faza odpluskwiania

Oto kilka z najczęściej używanych komend gdb:

Sztuczki

Za pomocą programu gdb możemy zmieniać wartości zmiennych programu. Najprostszym sposobem na zmianę wartości jest komenda:
print zmienna=wartość
która spowoduje przypisanie zmiennej zmienna wartości wartość. Można również użyć komendy set (różnica jest taka, że komenda set nie spowoduje wypisania nowej wartości zmiennej).
Gdb pozwala ponadto na przypisanie wartości pod wskazany adres w pamięci, np:
set {int}0x83040 = 4
Wynikiem tej instrukcji będzie umieszczenie wartości 4 w komórce pamięci o adresie 0x83040.

Przykład użycia

W niniejszym rozdziale zaprezentujemy praktyczne użycie kilku najpowszechniejszych komend gdb, na przykładnie programu costam.c. Po wpisaniu komendy:
gdb costam
Uruchomi się program gdb i przywita nas tekstem:
GNU gdb Red Hat Linux (6.3.0.0-1.21rh)
Copyright 2004 Free Software Foundation, Inc.
GDB is free software, covered by the GNU General Public License, and you are
welcome to change it and/or distribute copies of it under certain conditions.
Type "show copying" to see the conditions.
There is absolutely no warranty for GDB.  Type "show warranty" for details.
This GDB was configured as "i386-redhat-linux-gnu"...Using host libthread_db library "/lib/libthread_db.so.1".

(gdb)
Na początku ustawiamy punkt przerwania (breakpoint) na początku funkcji main:
(gdb) break main
Breakpoint 1 at 0x80483af: file costam.c, line 10.
Następnie uruchamiamy program:
(gdb) run
Starting program: /home/marek/studia/SO/public_html/costam
Reading symbols from shared object read from target memory...done.
Loaded system supplied DSO at 0x93f000

Breakpoint 1, main () at costam.c:10
10        x=15;
(gdb)
Gdb poinformował nas jaką instrukcję program wykona za chwilę i w której linii programu (10) się aktualnie znajduje. Zaczynamy wykonywać program krok po kroku:
(gdb) step
11        printf("%dn",f(x));
(gdb)
Teraz sprawdzamy wartość zmiennej x:
(gdb) print x
$1 = 15
(gdb)
Kontynuujemy wykonanie programu:
(gdb) step
f (x=15) at costam.c:4
4         int y=5;
(gdb)
Gdb poinformował nas, że wywołana została funkcja f z argumentem 15. Możemy sprawdzić jak aktualnie wygląda stos wywołań:
(gdb) backtrace
#0  f (x=15) at costam.c:4
#1  0x080483be in main () at costam.c:11
(gdb)
Teraz zmienimy wartość zmiennej x na 330:
(gdb) print x=330
$2 = 330
(gdb)
Po czym kontynuujemy wykonanie programu (bez zatrzymywania się po każdej instrukcji):
(gdb) continue
Continuing.
665
Program exited normally.
(gdb)
Gdb poinformował nas, że program pomyślnie zakończył swoje działanie.

Uruchamianie gdb na plikach core

Opcji -g możemy również używać w celu wykrycia okoliczności w jakich program zakończył swoje działanie błędem wykonania.

Czym jest plik core?

Gdy program zakończy swoje działanie w sposób niewłaściwy system operacyjny utworzy plik core. Plik ten zawiera stan pamięci programu w chwili wystąpienia błędu (między innymi zawartość stosu użytkownika). W połączeniu z tablicą symboli utworzonej z powodu opcji -g, plik core może posłużyć do znalezienia linii programu, w której jego działanie zostało przerwane, jak również do sprawdzenia wartości zmiennych w tym momencie.

Przykład użycia

Oto przykład programu, który chcemy odpluskwić.
#include <stdlib.h>
int a(int *p)
{
int y = *p;
return y;
}

int main ()
{
int *p = 0;   /* null pointer */
return a(p);
}
Musimy w tym celu skompilować ten program z opcją -g:
gcc -Wall -g -o core core.c
Uwaga: Kompilator nie wygenerował żadnego ostrzeżenia. Teraz uruchamiamy program:
./core
Naruszenie ochrony pamięci (core dumped)
Program ten próbował odczytać wartości pamięci pod adresem 0, co spowodowało jego unicestwienie. Pojawienie się napisu 'core dumped' oznacza, że system operacyjny utworzył plik core w bieżącym katalogu (w naszym przypadku jest to plik core.4157) Plik ten zawiera kopie wszystkich stron pamięci wykorzystywanych przez program w chwili jego zakończenie. Teraz możemy uruchomić gdb na naszym pliku core za pomocą polecenia:
gdb core core.4157
Należy zauważyć, że zarówno plik wykonywalny (skompilowany z opcją -g) jak i plik core.4157 są potrzebne, do odpluskwiania programu. Uruchomienie gdb spowoduje wypisanie informacji diagnostycznych oraz numeru linii, w której program zakończył swoje działanie.
[marek@dhcp2-240 gdb]$ gdb core core.4157
Core was generated by './a.out'.
Program terminated with signal 11, Segmentation fault.
Reading symbols from /lib/libc.so.6...done.
Loaded symbols for /lib/libc.so.6
Reading symbols from /lib/ld-linux.so.2...done.
Loaded symbols for /lib/ld-linux.so.2
#0  0x080483ed in a (p=0x0) at null.c:4
4        int y = *p;
(gdb)
Aby znaleźć przyczynę błędu wykonania wyświetlamy wartość wskaźnika p za pomocą polecenia print:
(gdb) print p
$1 = (int *) 0x0
Dowiadujemy się, że przyczyną naruszenia ochrony pamięci była próba odczytania wartości pod adresem 0x0. Aby dowiedzieć się, jakie funkcje były wywoływane w momencie zakończenia programu możemy użyć polecenia backtrace:
(gdb) backtrace
#0  0x080483ed in a (p=0x0) at core.c:4
#1  0x080483d9 in main () at core.c:11

Dlaczego nie tworzy mi się plik core?

Niektóre systemy operacyjne (większość) domyślnie nie tworzą plików core, gdyż mogą być one duże i znacząco ograniczają wolne miejsce na dysku. W powłoce GNU Bash za pomocą polecenia ulimit -c możemy kontrolować maksymalny rozmiar plików core. Jeśli rozmiar ten jest ustawiony na 0, pliki core nie są tworzone.

Profilowanie kodu

Wstęp

Zadaniem profilera jest zbadanie programu pod kątem częstości wywołań poszczególnych funkcji, jak również czasu spędzanego w każdej z nich. Programista na podstawie takich informacji jest w stanie stwierdzić która funkcja odpowiada za największą część zużytego czasu, co pozwala ukierunkować zabiegi optymalizacyjne właśnie w tym kierunku. Często okazuje się też, że niektóre fragmenty programu wykonują się dużo częściej lub rzadziej (ew. wcale) niż się spodziewano, co może wskazywać na błędy.

Użycie opcji -fprofile-arcs w gcc i program gcov

Umożliwia ona na przykład dokładne wyznaczenie ile razy która linia kodu została wykonana. Jak jednak zaimplementować taką funkcjonalność?? Oczywiście możnaby stworzyć sobie mapę odwzorowującą instrukcje kodu maszynowego na numery linii kodu źródłowego i zliczać po wykonaniu każdej instrukcji maszynowej odwołanie do każdej linii kodu. Jednakże musimy pamiętać, że narzędzia tego chcemy używać do przyspieszania, zbyt wolno naszym zdaniem działającego kodu, a narzut czasowy i pamięciowy takiego rozwiązania jest bardzo duży. Dlatego gcc używa nieco sprytniejszego algorytmu. Kompilator tworzy sobie graf działania programu, którego węzłami są niepodzielne bloki kodu (zbiory instrukcji, które dla każdego ) Formalnie rzecz ujmując ciąg instrukcji maszynowych i1, i2, ... , in tworzy blok wtedy i tylko wtedy, dla każdego możliwego przebiegu programu instrukcja ik zostanie wykonana zawsze po instrukcji ik-1 dla wszystkich k z przedziału [2,n], i nie istnieją instrukcje i0 ani in+1 takie że i0, i1, ... , in lub i1, i2, ... , in+1 tworzą blok. Krawędziami grafu są powiązania między blokami, odpowiadające w kodzie źródłowym instrukcjom warunkowym, powrotu z funkcji, wywołań funkcji itp. Następnie kompilator znajduje w tym grafie drzewo rozpinające. Można sobie wyobrazić, że to drzewo rozpinające reprezentuje zbiór "najprostszych" przebiegów wykonania programu (1 ścieżka od korzenia do liścia to 1, niekoniecznie sensowny, przebieg warunkowy programu). Teraz dopiero budowana jest mapa między węzłami grafu a numerami linii kodu źródłowego. Program zlicza osobno jedynie przejścia po krawędziach niedrzewowych. W trakcie przebiegu programu zliczane są kolejne wykonania kolejnych bloków. Po zakończeniu wykonania programu do każdej linii kodu aktualizowany jest licznik wykonań tejże linii. Na przykład dla kodu (w komentarzu numery bloków):
int main(){                             // 1
  int x,a,b,pa,pb,d;                    // 1
  scanf("%d %d",&n, &m);                // 1
  x = m;                                // 1
  for (int i=1; i<=n; i++){             // 2
    wys[i] = 0;                         // 3
    p[i]   = i;                         // 3
  }
  for (int j=0; j<=m; j++){             // 4 
    scanf("%d %d",&a, &b);              // 5
    pa = find(a);                       // 6-nowy blok wywołanie funkcji find
    pb = find(b);                       // 7-nowy blok wywołanie funkcji find
    if (pa == pb)                       // 8 
      x--;                              // 9 
    else
      unify(pa,pb);                     // 10 
  }
  printf("%dn",x);                     // 11 
  return 0;                             // 12 
}
stworzy się nam graf podobny do poniższego:

rysunek


Na zielono zaznaczone zostały krawędzie drzewa rozpinającego. Oczywiście należy pamiętać, że taki graf jest tworzony nie dla wysokopoziomowych instrukcji języka C, tylko dla kodu maszynowego. W przykładzie chodziło o zademonstrowanie idei konstrukcji tego grafu. Wybranie takiej reprezentacji jest korzystniejsze od wspomnianej na początku mapy instrukcja-linia, gdyż nie musimy przy wykonywaniu każdej instrukcji maszynowej przełączać kontekstu i zliczać wykonań linii kodu źródłowego. Robimy to znacznie rzadziej, ponadto zamiast na pojedynczych liniach operujemy na ich przedziałach, co też zwiększa efektywność.

Przykład użycia

Załóżmy, że chcemy profilować następujący kod:
#include <stdlib.h>

void f(int a)
{
  if (a <= 0)
    return ;
  int* x = malloc(a * sizeof(int));
  x[1] = 665;//to sie nie uda gdy a=1 
  f(a-1);
  return ;
}

int main(void)
{
  f(3);
  return 0;
}
W tym celu kompilujemy go z opcjami optymalizacji, oraz -fprofile-arcs i -ftest-coverage:
gcc -fprofile-arcs -ftest-coverage -O3 przyklad2.c
Powstał plik a.out. Uruchamiamy go:
./a.out
Jeśli uruchomimy program wielokrotnie policzą nam się statystyki wszystkich wykonań. My w tym przykładzie uruchomimy go tylko raz. Program na większości maszyn mimo zawartego w nim błędu zakończy się poprawnie, co nie jest dużym zaskoczeniem - mamy tu do czynienia z profilerem, a nie debuggerem czy menadżerem błędów pamięci. Powstały nam pliki przykład2.da i przykład2.bbg(ten ostatni plik powstał już po kompilacji). Teraz użyjemy programu gcov aby uzyskać potrzebne nam informacje:
gcov przykład2.da
Wynikiem będzie komunikat:
File `przyklad2.c'
Lines executed:100.00% of 10
przyklad2.c:creating `przyklad2.c.gcov'
informujący o utworzeniu pliku przyklad2.c.gcov, który wygląda następująco:
        -:    0:Source:przyklad2.c
        -:    0:Graph:przyklad2.bbg
        -:    0:Data:przyklad2.da
        -:    1:#include <stdlib.h>
        -:    2:
        -:    3:void f(int a)
        4:    4:{
        4:    5:  if (a <= 0)
        2:    6:    return ;
        3:    7:  int* x = malloc(a * sizeof(int));
        3:    8:  x[1] = 665;//to sie nie uda gdy a=1
        3:    9:  f(a-1);
        3:   10:  return ;
        -:   11:}
        -:   12:
        -:   13:int main(void)
        1:   14:{
        1:   15:  f(3);
        1:   16:  return 0;
        -:   17:}
Pobieżna analiza doprowadza nas do przekonania, że numerki po prawej stronie prawie odpowiadają liczbie wykonań poszczególnych linii. Występuje tutaj jedna błędna wartość. Ten błąd nie wynika z niekompetencji autorów tego narzędzia, jest wynikiem faktu, że program skompilowaliśmy z opcjami optymalizacji, które sprawiają problemy wszystkim programom do odpluskwiania i profilowania kodu. Zaletą tego narzędzia jest fakt, że nawet dla skomplikowanych programów obliczone wartości są bardzo bliskie rzeczywistym. Skompilowanie programów z opcją -fprofile-arcs, a bez opcji optymalizacji jest jednak pozbawione sensu (choć możliwe), gdyż chcemy poprawiać efektywność, a więc musimy kompilować program tak, aby działał jak najszybciej. Jako ćwiczenie dla zainteresowanych pozostawiamy sprawdzenie, że bez opcji optymalizacji liczby wykonań poszczególnych linii są poprawne.

Gprof - użycie (Bonus)

Jednym z najpopularniejszym programów służących do tego celu jest GNU gprof dostępny w praktycznie każdej dystrybucji linuksa. By móc skorzystać z jego dobrodziejstw niezbędne jest wykonanie trzech czynności:

Kompilacja

Zasadniczo opcją, którą trzeba zaznaczyć przy kompilacji programu do profilowania jest -pg. Trzeba jej użyć zarówno przy kompilacji jak i linkowaniu. Przykładowo: cc -o mojprogram mojprogram.c funkcje.c -pg Można też wykorzystać bezpośrednio program linkera ld. Tu jednak sprawa się nieco komplikuje. Po pierwsze, by móc korzystać z profilowania, należy użyć biblioteki startowej gcrt0.o, zamiast standardowej crt0.o. Dobrze jest też dołączyć bibliotekę profilującą libc_p.a, która da nam możliwość obserwowania wywołań funkcji systemowych takich jak read lub open. W całości wywołanie linkera wyglądałoby następująco: ld -o myprog /lib/gcrt0.o myprog.o utils.o -lc_p Teoretycznie można próbować analizować program złożony z wielu modułów, z których tylko niektóre były skompilowane z opcją -pg, ale wtedy informacje o pozostałych częściach programu będą bardzo ograniczone. Gprof będzie w stanie wyświetlić tylko łączny czas spędzony w tych modułach. Nie będzie wiadomo ile razy wywołano należące do nich funkcje. Znacznie zmniejszy się przez to użyteczność grafu wywołań. Oprócz ww. opcji istnieje też kilka innych, które pozwolą jeszcze bardziej uszczegółowić otrzymywane informacje. Opcja -g sprawi, że w kod wynikowy wbudowane zostaną informacje niezbędne dla debuggera. Gprof również potrafi z nich skorzystać. Umożliwi to profilowanie programu na szczeblu pojedynczych linijek w funkcjach, może to okazać się przydatne, gdy np. chcemy wiedzieć jak często program wykonuje poszczególne bloki instrukcji if lub iteracji pętli. Dodatkowa opcja -a pozwoli na wygenerowanie wydruku kodu z informacją ile razy została wykonana każda linijka programu.

Uruchamianie

Jeśli chcemy testować program konstantynopolitanczykowianeczka, to musimy go uruchomić tak jak zwykle:
./konstantynopolitanczykowianeczka
Gdy uruchomimy nasz program będzie tylko działał odrobinę wolniej, ze względu na konieczność zbierania informacji dla profilera. Program zapisze swój "profil" w pliku gmon.out tuż przed zamknięciem. Jeśli w katalogu istniał wcześniej taki plik to jego zawartość zostanie nadpisana. Program musi się zakończyć poprawnie - czyli przez wywołanie return z funkcji main() ew. przez wywołanie funkcji exit(). Każde inne zakończenie (np. Segmentation Fault) nie spowoduje zapisania pliku gmon.out. Trzeba też pamiętać, że plik gmon.out zostanie zapisany w aktualnym katalogu roboczym. Nie musi to koniecznie być równoznaczne z katalogiem, z którego program został wywołany. Jeśli program podczas pracy zmienia katalog roboczy (za pomocą funkcji chdir) to gmon.out zostanie zapisany w ostatnim katalogu roboczym. W szczególności, jeśli akurat nie mamy praw zapisu do tego ostatniego katalogu - to zostaniemy przywitani komunikatem o błędzie.

Analiza

Gprof potrafi przełożyć plik a.out na format zrozumiały dla użytkownika. Aby wykorzystać tę możliwość należy wykonać co następuje:
gprof opcje [plik-wykonywalny [pliki-z-danymi-do-profilowania...]] [> plikwyjsciowy]
Jeśli nie wyspecyfikujemy pliku wykonywalnego, który chcemy badać gprof przyjmie domyślne a.out. Domyślną nazwą pliku z profilem jest wspomniane wyżej gmon.out. Oczywiście jeśli któryś z podanych plików nie istnieje, lub wygląda na to że plik profilu "nie pasuje" do pliku programu - dostaniemy komunikat o błędzie.
Można podać więcej plików z profilem - np. odpowiadającym wykonaniom programu z różnymi opcjami wywołania, danymi wejściowymi lub zachowaniami użytkownika. Wtedy statystyki dotyczące wszystkich wywołań zostaną zsumowane.
Wyniki swej pracy gprof prezentuje w jednym z trzech trybów:

Zazwyczaj wyniki generowane przez gprof są na tyle obszerne, że warto je zapisać do pliku, najlepiej przez zwykłe systemowe przekierowanie wejścia.
Opcje sterujące rodzajem wypisywanych danych:

Domyślnym zestawem jest '-p -q'. Do każdej z tych opcji można dodać specyfikację pozwalającą włączyć lub wyłączyć informację dotyczącą konkretnego pliku lub funkcji. Przykładowo -Amain da nam wydruk ograniczający się do wszystkich funkcji o nazwie main występujących w programie. -Qplik.c wyświetli graf wywołań, ale wyłączy z niego funkcje znajdujące się w pliku plik.c. -pfunkcje.c:funkcja da nam płaski profil wywołań funkcji o nazwie funkcja(), znajdującej się w pliku funkcje.c

Inne przydatne opcje:


Płaski profil
Ten tryb prezentacji wyników ma swoje korzenie w zamierzchłej historii profilingu - w programie prof. Typowy wydruk analizy prostego programu wygląda następująco:
Flat profile

Each sample counts as 0.01 seconds.
  %   cumulative   self              self     total           
 time   seconds   seconds    calls  ms/call  ms/call  name    
 33.34      0.02     0.02     7208     0.00     0.00  open
 16.67      0.03     0.01      244     0.04     0.12  offtime
 16.67      0.04     0.01        8     1.25     1.25  memccpy
 16.67      0.05     0.01        7     1.43     1.43  write
 16.67      0.06     0.01                             mcount
  0.00      0.06     0.00      236     0.00     0.00  tzset
  0.00      0.06     0.00      192     0.00     0.00  tolower
  0.00      0.06     0.00       47     0.00     0.00  strlen
  0.00      0.06     0.00       45     0.00     0.00  strchr
  0.00      0.06     0.00        1     0.00    50.00  main
  0.00      0.06     0.00        1     0.00     0.00  memcpy
  0.00      0.06     0.00        1     0.00    10.11  print
  0.00      0.06     0.00        1     0.00     0.00  profil
  0.00      0.06     0.00        1     0.00    50.00  report
Poszczególne kolumny zawierają następujące informacje: (czasy odnoszą się oczywiście do czasu wykorzystania procesora a nie do realnego "kalendarzowego" czasu działania programu)


Warto wspomnieć, że funkcje mcount i profil są częścią mechanizmu zbierania informacji, i umieszczenie ich w płaskim profilu może dać pewne pojęcie o spowolnieniu programu wywołanym przez samo profilowanie.
Graf wywołań
Ten tryb prezentacji wyników daje o wiele więcej informacji na temat działania programu. Pozwala na przykład wykryć funkcje, które same w sobie dużo czasu nie zajmowały, lecz zawierały wywołania innych funkcji - bardziej skomplikowanych i czasochłonnych. Przykładowy wydruk grafu wywołań przedstawia się następująco:
granularity: each sample hit covers 2 byte(s) for 20.00% of 0.05 seconds

index % time    self  children    called     name
                                                 <spontaneous>
[1]    100.0    0.00    0.05                 start [1]
                0.00    0.05       1/1           main [2]
                0.00    0.00       1/2           on_exit [28]
                0.00    0.00       1/1           exit [59]
-----------------------------------------------
                0.00    0.05       1/1           start [1]
[2]    100.0    0.00    0.05       1         main [2]
                0.00    0.05       1/1           report [3]
-----------------------------------------------
                0.00    0.05       1/1           main [2]
[3]    100.0    0.00    0.05       1         report [3]
                0.00    0.03       8/8           timelocal [6]
                0.00    0.01       1/1           print [9]
                0.00    0.01       9/9           fgets [12]
                0.00    0.00      12/34          strncmp <cycle 1> [40]
                0.00    0.00       8/8           lookup [20]
                0.00    0.00       1/1           fopen [21]
                0.00    0.00       8/8           chewtime [24]
                0.00    0.00       8/16          skipspace [44]
-----------------------------------------------
[4]     59.8    0.01        0.02       8+472     <cycle 2 as a whole> [4]
                0.01        0.02     244+260         offtime <cycle 2> [7]
                0.00        0.00     236+1           tzset <cycle 2> [26]
-----------------------------------------------
Linie z samych myślników dzielą tabelę na wpisy odpowiadające pojedynczym funkcjom. Każdy wpis ma jedną lub więcej linijek. Każdy wpis zawiera jedną tzw. "linijkę główną". To ta, która rozpoczyna się numerem porządkowym w nawiasach kwadratowych. Koniec linii głównej mówi jakiej funkcji odpowiada dany wpis. Linie poprzedzające linię główną opisują funkcje wywołujące ("rodziców"), natomiast linie następujące po linii głównej opisują funkcje wywoływane ("potomków"). Wpisy uporządkowane od najbardziej do najmniej czasochłonnego. Bierze się tu pod uwagę czas wykonania samej funkcji, jak i wszystkich jej potomków. Warto wspomnieć, że wewnętrzna funkcja profilująca mcount nigdy nie pojawia się w grafie wywołań.
Liczby występujące w kolejnych kolumnach grafu wywołań mają różne znaczenie w zależności od kontekstu - inne dla linii głównej, inne dla funkcji wywołujących, a inne dla wywoływanych:
Jeśli funkcja a() wywołuje funkcję b() i jednocześnie funkcja b() wywołuje funkcję a() to mówimy, że funkcje a() i b() tworzą cykl rekurencyjny. Cykle rekurencyjne odpowiadają cyklom w skierowanym grafie wywołań. Ich istnienie rodzi problem jak liczyć czas działania potomków funkcji a(), gdy jednym z tych potomków może być sama funkcja a(). Gprof rozwiązując ten problem znajduje takie cykle i nadaje im numery. W miejscach, gdzie funkcja wywołuje lub jest wywoływana przez inną funkcję należącą do tego samego cyklu takie wywołania nie liczą się przy wyliczaniu wartości w polach children i self. Wtedy w grafie wywołań pojawia się dla każdego cyklu osobny wpis. Pokazuje on czas spędzony we wszystkich funkcjach cyklu. "Rodzicami" cyklu są wszystkie funkcje spoza cyklu, które wywołały jakieś funkcje należące do cyklu. "Potomkami" są wszystkie funkcje tworzące cykl i funkcje bezpośrednio przez nie wywoływane. Taki wpis wprowadza kolejne znaczenia dla znanych już kolumn wydruku.
Przykładowy wygląd wpisu:
-----------------------------------------------
[4]     59.8    0.01        0.02       8+472     <cycle 2 as a whole> [4]
                0.01        0.02     244+260         offtime <cycle 2> [7]
                0.00        0.00     236+1           tzset <cycle 2> [26]
-----------------------------------------------
Liczby w kolejnych kolumnach mają następujące znaczenia:
W indywidualnych wpisach dotyczących funkcji cyklicznej - inne funkcje cyklu pojawiają się zarówno jako "rodzice" jak i "potomkowie", ale wtedy pola self i children pozostają puste, ze względu na wspomniane już trudności w wyliczeniu tego czasu.
"Skomentowany" wydruk kodu

Jest to najbardziej szczegółowa informacja, jaką potrafi udzielić gprof. Dzięki niej nasz program przestanie przed nami kryć jakiekolwiek tajemnice. Będziemy mogli na przykład wykryć, że w pojedynczej funkcji pewne linijki kodu nie wykonują się wcale (np. warunek if nigdy nie jest spełniony, choć myślimy, że powinien) podczas gdy inne wykonują się zdecydowanie zbyt często.

Profilowanie na poziomie pojedynczych linii.

Pierwszym etapem w zwiększaniu szczegółowości analizy programu jest opcja -l. (zazwyczaj niezbędne jest też, by przy kompilacji użyto -g - gprof korzysta z informacji debuggera). Dzięki niej wywołania funkcji zostaną przypisane do konkretnych linijek kodu. Będzie to miało wpływ zarówno na widok płaskiego profilu jak i na graf wywołań.

Płaski profil może na przykład wyglądać tak:

Flat profile

Each sample counts as 0.01 seconds.
  %   cumulative   self                    
 time   seconds   seconds    calls  name    
  7.69      0.10     0.01           ct_init (trees.c:349)
  7.69      0.11     0.01           ct_init (trees.c:351)
  7.69      0.12     0.01           ct_init (trees.c:382)
  7.69      0.13     0.01           ct_init (trees.c:385)

Widać, że linijka dotycząca funkcji ct_init została rozbita na cztery, gdyż funkcja ct_init jest wywoływana z czterech miejsc. Możemy więc stwierdzić która linijka kodu odpowiada za najbardziej czasochłonne wykorzystanie funkcji ct_init.

Graf wywołań otrzymuje następującą postać:

  % time    self  children    called     name

            0.00    0.00       1/13496       name_too_long (gzip.c:1440)
            0.00    0.00       1/13496       deflate (deflate.c:763)
            0.00    0.00       1/13496       ct_init (trees.c:396)
            0.00    0.00       2/13496       deflate (deflate.c:727)
            0.00    0.00       4/13496       deflate (deflate.c:686)
            0.00    0.00       5/13496       deflate (deflate.c:675)
            0.00    0.00      12/13496       deflate (deflate.c:679)
            0.00    0.00      16/13496       deflate (deflate.c:730)
            0.00    0.00     128/13496       deflate_fast (deflate.c:654)
            0.00    0.00    3071/13496       ct_init (trees.c:384)
            0.00    0.00    3730/13496       ct_init (trees.c:385)
            0.00    0.00    6525/13496       ct_init (trees.c:387)
[6]  0.0    0.00    0.00   13496         init_block (trees.c:408)

Mamy tutaj fragment wpisu dotyczącego funkcji init_block obejmujący wszystkich jej "rodziców". Można odczytać gdzie dokładnie init_block była wywoływana.

Kod źródłowy z komentarzem

Dzięki opcji -A, mamy możliwość stwierdzić ile razy wykonała się każda pojedyncza linijka kodu (zazwyczaj niezbędne jest wbudowanie tej funkcjonalności w sam program podczas kompilacji, by gprof mógł z niej skorzystać. Służy do tego opcja gcc -a)

Tak poinstruowany gprof potrafi zmienić taki kod:

 1 ulg updcrc(s, n)
 2     uch *s;
 3     unsigned n;
 4 {
 5     register ulg c;
 6
 7     static ulg crc = (ulg)0xffffffffL;
 8
 9     if (s == NULL) {
10         c = 0xffffffffL;
11     } else {
12         c = crc;
13         if (n) do {
14             c = crc_32_tab[...];
15         } while (--n);
16     }
17     crc = c;
18     return c ^ 0xffffffffL;
19 }

... w coś takiego:

                ulg updcrc(s, n)
                    uch *s;
                    unsigned n;
            2 ->{
                    register ulg c;
                
                    static ulg crc = (ulg)0xffffffffL;
                
            2 ->    if (s == NULL) {
            1 ->  c = 0xffffffffL;
            1 ->    } else {
            1 ->  c = crc;
            1 ->        if (n) do {
        26312 ->            c = crc_32_tab[...];
26312,1,26311 ->        } while (--n);
                    }
            2 ->    crc = c;
            2 ->    return c ^ 0xffffffffL;
            2 ->}

Tu już widać jak na dłoni, że funkcja updcrc() wykonała się dwa razy, że s zostało dwukrotnie sprawdzone, czy jest NULL'em. Raz okazało się, że tak i wykonała się linijka c = 0xffffffffL;, a raz okazało się, że nie i wtedy mieliśmy owe 26312 podstawień, bo tyle było trzeba by n sprowadzić do zera.

Przy odrobinie wprawy taka informacja może oddać nieocenione usługi w zrozumieniu działania programu.

Wskazówki

Należy pamiętać, że dobór opcji i danych wejściowych może mieć zasadniczy wpływ na wygenerowany profil. Gprof śledzi jak program działa, a nie jak może działać. Znaczy to tyle, że niewykorzystane fragmenty kodu pozostaną dla profilera niewidoczne. Jeśli zależy nam na pomiarze efektywności jakichś konkretnych elementów programu - to należy tak skonstruować dane wejściowe i opcje wywołania by owe elementy były jak najczęściej wywoływane. Dotyczy to oczywiście również zachowań użytkownika w programach interaktywnych. Przykładowo jeśli pierwszym poleceniem po uruchomieniu programu będzie rozkaz wyjścia, to gprof oprócz informacji na temat inicjalizacji i "sprzątania" nic nam ciekawego nie powie.

Valgrind

rysunek rysunek
Nazwa Valgrind pochodzi z mitologii nordyckiej, gdzie oznacza bramę do Valhalli - raju Wikingów, dokąd udają się po śmierci dzielni wojownicy w warkoczykach i rogatych hełmach. Valgrind jest narzędziem służącym do wykrywania błędów związanych z pamięcią i profilowania kodu w programach linuksowych. Jest to projekt typu Open Source, wciąż rozwijany. W niniejszej prezentacji omówiona zostanie najnowsza wersja 3.1.0, która pracuje zarówno z jądrami 2.4.XX, jak i 2.6.XX. Jest on używany przy pracy kilkudziesięciu dużych projektach (np. Firefox, OpenOffice, StarOffice, AbiWord, Opera, KDE, GNOME, Qt, libstdc++, MySQL, PostgreSQL, Perl, Python, PHP, Samba, RenderMan, Nasa Mars Lander software, SAS, The GIMP, Ogg Vorbis, Unreal Tournament, Medal of Honour, RenderMan ...).

Przegląd narzędzi

Memcheck

Jest to defaultowe narzędzie valgrinda. Potrafi wykrywać następujące usterki (to znaczy wykrywać i tak musi nieszczęsny programista, on co najwyżej podpowiada, zazwyczaj jednak dość dokładnie): Należy pamiętać, że to narzędzie spowolni działanie programu 10 do 30 razy.

Addrcheck

Jest to uproszczona wersja Memchecka. Wykrywa mniej błędów niż jej protoplasta a informacje o nich są mniej dokładne (przez co czasami mogą być bardziej czytelne). Zaletą addrchecka jest jego szybkość- spowalnia program mniej więcej czterokrotnie. Któryś z autorów stwierdził, iż to narzędzie powstało, gdyż ktoś z developerów chciał uruchomić sesję okienek przez valgrinda, a używając standardowego Memchecka by się to nie udało.

Cachegrind

Jest to narzędzie służące do sprawdzania jak sprzęt (i ew. system operacyjny) radzi sobie z cache'owaniem danych dla naszego programu. Celem tego narzędzia jest przetestowanie szybkości działania złożonych programów. Dzięki niemu programista uzyskuje dokładne informacje dotyczące faktycznej liczby odwołań do pamięci. Niestety, Cachegrind znacząco spowalnia uruchamiany program.

Massif

Narzędzie to pomaga programiście stwierdzić ile pamięci stosu systemowego zużywa dany program.

Helgrind

Narzędzie służące do wykrywania sytuacji typu data race w programach współbieżnych. Niestety w obecnej wersji nie działa, ale autorzy obiecują, że już w następnej będzie zaimplementowane.

Defaultowe narzędzie Memcheck - zasada działania

Valgrind jest programem, który działa trochę jak państwo totalitarne, które "opiekuje się" obywatelem "od żłobka do nagrobka". Program ten pozwala wybranemu procesowi robić, to co powinien, ale bacznie obserwuje każdy jego ruch i wkracza przy każdej próbie działania przeciwko systemowi. Konkretniej mówiąc Valgrind implementuje sztuczny komputer, z tym że na każdy bit pamięci przypada dodatkowy bit valid-value(V), określający czy znajdują się tam rzeczywiste dane (czy bit został zainicjalizowany). Ponadto każdy bajt ma przypisany sobie bit valid-adress(A), który pamięta, czy proces może się odwoływać do tego adresu. Dzięki tym wartościom Valgrind może efektywnie kontrolować wszystkie operacje dostępu do pamięci, takie jak: Jeśli zachowanie program użytkownika nie spodoba się Valgrindowi, to odnotuje on to w setkach swoich logów i doniesie o tym odpowiednim służbom (użytkownikowi).

Używanie Valgrinda

Wprowadzenie

Jeśli na przykład chcemy testować program o nazwie "konstantynopolitanczykowianeczka" znajdujący się w bieżącym katalogu to w linii poleceń należy napisać:
valgrind ./konstantynopolitanczykowianeczka
o ile valgrind znajduje się w katalogu zawartym w zmiennej $PATH (jeśli nie to należy wpisać katalog_z_valgrindem/valgrind ./konstantynopolitanczykowianeczka). Należy jednak pamiętać, aby program konstantynopolitanczykowianeczka skompilować z opcjami debugowania -g, na przykład:
gcc -o konstantynopolitanczykowianeczka -g konstantynopolitanczykowianeczka.c
Jeśli tego nie zrobimy Valgrind będzie miał ograniczone pole manewru i zacznie zgadywać pewne rzeczy, które powinien wiedzieć. Ponadto najlepiej jest nie używać optymalizacji na poziomie powyżej drugiego, gdyż może to powodować pojawienie nieoczekiwanych ostrzeżeń i ujemnie wpłynąć na czytelność wypisywanych komunikatów.

Przykłady

Przykład 1 - pisanie po cudzej pamięci

Na początek uruchomimy program, który próbuje zapisać coś do pamięci, która nie należy do niego:
#include <stdlib.h>

void f(void)
{
  int* x = malloc(10 * sizeof(int));
  x[10] = 0;        // problem: piszemy po nie swojej pamieci
  free(x);
}

int main(void)
{
  f();
  return 0;
}
Uzyskamy następujący komunikat:
gcc -g -O0 przyklad1.c
valgrind ./a.out

==2801== Memcheck, a memory error detector.
==2801== Copyright (C) 2002-2005, and GNU GPL'd, by Julian Seward et al.
==2801== Using LibVEX rev 1471, a library for dynamic binary translation.
==2801== Copyright (C) 2004-2005, and GNU GPL'd, by OpenWorks LLP.
==2801== Using valgrind-3.1.0, a dynamic binary instrumentation framework.
==2801== Copyright (C) 2000-2005, and GNU GPL'd, by Julian Seward et al.
==2801== For more details, rerun with: -v
==2801== 
==2801== Invalid write of size 4
==2801==    at 0x80483CF: f (przyklad1.c:6)
==2801==    by 0x80483F6: main (przyklad1.c:12)
==2801==  Address 0x414F050 is 0 bytes after a block of size 40 alloc'd
==2801==    at 0x401B495: malloc (in /usr/lib/valgrind/x86-linux/vgpreload_memcheck.so)
==2801==    by 0x80483C5: f (przyklad1.c:5)
==2801==    by 0x80483F6: main (przyklad1.c:12)
==2801== 
==2801== ERROR SUMMARY: 1 errors from 1 contexts (suppressed: 13 from 1)
==2801== malloc/free: in use at exit: 0 bytes in 0 blocks.
==2801== malloc/free: 1 allocs, 1 frees, 40 bytes allocated.
==2801== For counts of detected errors, rerun with: -v
==2801== No malloc'd blocks -- no leaks are possible.
Pojawiający się na początku każdej linii numer 2801 to pid procesu, który jest dla nas nieistotny. Na początku Valgrind zawsze uprzejmie przywita nas i poinformuje o wszelkich copyrightach. Zaraz potem następują informacje istotne - komunikaty o błędach. W tym przypadku prawidłowo stwierdził, że błąd jest w linii 6 w funkcji f ("at 0x80483CF: f (przyklad1.c:6)"), która została wywołana przez funkcję main w 12 linii programu ("by 0x80483F6: main (przyklad1.c:12)"). Następnie błąd jest dokładnie scharakteryzowany, włącznie z podaniem numerów linii w której wystąpiła alokacja bloku pamięci w który nie trafiliśmy.

Przykład2 - błędy w funkcjach rekurencyjnych

Często zdarza się tak, że pisany przez nas program jest złożony, a błędy mają tendencję do pojawiania się w funkcjach wywoływanych przez jeszcze inne funkcje, które z kolei też są tylko podprogramami. W poniższym kodzie błąd (pisanie po niezaalokowanej pamięci) pojawia się dopiero w trzecim wywołaniu rekurencyjnym:
#include <stdlib.h>

void f(int a)
{
  if (a <= 0)
    return ;
  int* x = malloc(a * sizeof(int));
  x[1] = 665;//to sie nie uda gdy a=1 
  f(a-1);
  return ;
}

int main(void)
{
  f(3);
  return 0;
}
Valgrind z taką złośliwością również sobie radzi i informuje o tym użytkownika w sposób w miarę zrozumiały:
==2665== Memcheck, a memory error detector.
==2665== Copyright (C) 2002-2005, and GNU GPL'd, by Julian Seward et al.
==2665== Using LibVEX rev 1471, a library for dynamic binary translation.
==2665== Copyright (C) 2004-2005, and GNU GPL'd, by OpenWorks LLP.
==2665== Using valgrind-3.1.0, a dynamic binary instrumentation framework.
==2665== Copyright (C) 2000-2005, and GNU GPL'd, by Julian Seward et al.
==2665== For more details, rerun with: -v
==2665== 
==2665== Invalid write of size 4
==2665==    at 0x80483A9: f (przyklad2.c:8)
==2665==    by 0x80483BA: f (przyklad2.c:9)
==2665==    by 0x80483BA: f (przyklad2.c:9)
==2665==    by 0x80483D8: main (przyklad2.c:15)
==2665==  Address 0x414F0A4 is 0 bytes after a block of size 4 alloc'd
==2665==    at 0x401B495: malloc (in /usr/lib/valgrind/x86-linux/vgpreload_memcheck.so)
==2665==    by 0x804839F: f (przyklad2.c:7)
==2665==    by 0x80483BA: f (przyklad2.c:9)
==2665==    by 0x80483BA: f (przyklad2.c:9)
==2665==    by 0x80483D8: main (przyklad2.c:15)
==2665== 
==2665== ERROR SUMMARY: 1 errors from 1 contexts (suppressed: 13 from 1)
==2665== malloc/free: in use at exit: 24 bytes in 3 blocks.
==2665== malloc/free: 3 allocs, 0 frees, 24 bytes allocated.
==2665== For counts of detected errors, rerun with: -v
==2665== searching for pointers to 3 not-freed blocks.
==2665== checked 48,540 bytes.
==2665== 
==2665== LEAK SUMMARY:
==2665==    definitely lost: 24 bytes in 3 blocks.
==2665==      possibly lost: 0 bytes in 0 blocks.
==2665==    still reachable: 0 bytes in 0 blocks.
==2665==         suppressed: 0 bytes in 0 blocks.
==2665== Use --leak-check=full to see details of leaked memory.
Napisał nam co kogo wywołuje i w której linijce. Dodatkowo, całkiem słusznie zauważył, że nie zwolniliśmy pamięci.

Przykład 3 - wycieki pamięci (memory leaks)

Ważną cechą Memchecka jest umiejętność określenia ile pamięci nasz program zgubił - zaalokował i nie oddał. Aby uzyskać szczegółową informację o wycieku uruchomimy valgrinda tak jak nam poradził w poprzednim przykładzie, czyli z opcją --leak-check=full. Poniższy kod zabiera ze sobą "do grobu" 20 bajtów:
#include <stdlib.h>

void f(void)
{
  int* x=malloc(5*sizeof(int));
  x[1] = 0;        // problem: nie zwalniamy pamięci
}

int main(void)
{
  f();
  return 0;
}
Valgrind oczywiście to odnotowuje, pokazując nawet linię w której wystąpił niesparowany malloc:
==2780== Memcheck, a memory error detector.
==2780== Copyright (C) 2002-2005, and GNU GPL'd, by Julian Seward et al.
==2780== Using LibVEX rev 1471, a library for dynamic binary translation.
==2780== Copyright (C) 2004-2005, and GNU GPL'd, by OpenWorks LLP.
==2780== Using valgrind-3.1.0, a dynamic binary instrumentation framework.
==2780== Copyright (C) 2000-2005, and GNU GPL'd, by Julian Seward et al.
==2780== For more details, rerun with: -v
==2780== 
==2780== 
==2780== ERROR SUMMARY: 0 errors from 0 contexts (suppressed: 13 from 1)
==2780== malloc/free: in use at exit: 20 bytes in 1 blocks.
==2780== malloc/free: 1 allocs, 0 frees, 20 bytes allocated.
==2780== For counts of detected errors, rerun with: -v
==2780== searching for pointers to 1 not-freed blocks.
==2780== checked 48,540 bytes.
==2780== 
==2780== LEAK SUMMARY:
==2780==    definitely lost: 20 bytes in 1 blocks.
==2780==      possibly lost: 0 bytes in 0 blocks.
==2780==    still reachable: 0 bytes in 0 blocks.
==2780==         suppressed: 0 bytes in 0 blocks.
==2780== Use --leak-check=full to see details of leaked memory.

Przykład 4 - zwalnianie cudzej pamięci

Poniższy kod zwalnia pamięć, która została już zwolniona, a więc de facto mogła już zostać przydzielona komuś innemu:
#include <stdlib.h>

void f(void)
{
  int* x = malloc(10 * sizeof(int));
  int* y;
  y = x;
  free(x);
  free(y); //zwalniamy drugi raz
}

int main(void)
{
  f();
  return 0;
}
Znów Valgrind pokazuje dokładnie w którym miejscu było nieszczęsne free:
==2817== Memcheck, a memory error detector.
==2817== Copyright (C) 2002-2005, and GNU GPL'd, by Julian Seward et al.
==2817== Using LibVEX rev 1471, a library for dynamic binary translation.
==2817== Copyright (C) 2004-2005, and GNU GPL'd, by OpenWorks LLP.
==2817== Using valgrind-3.1.0, a dynamic binary instrumentation framework.
==2817== Copyright (C) 2000-2005, and GNU GPL'd, by Julian Seward et al.
==2817== For more details, rerun with: -v
==2817== 
==2817== Invalid free() / delete / delete[]
==2817==    at 0x401C1B9: free (in /usr/lib/valgrind/x86-linux/vgpreload_memcheck.so)
==2817==    by 0x80483E4: f (przyklad4.c:9)
==2817==    by 0x80483FB: main (przyklad4.c:14)
==2817==  Address 0x414F028 is 0 bytes inside a block of size 40 free'd
==2817==    at 0x401C1B9: free (in /usr/lib/valgrind/x86-linux/vgpreload_memcheck.so)
==2817==    by 0x80483D9: f (przyklad4.c:8)
==2817==    by 0x80483FB: main (przyklad4.c:14)
==2817== 
==2817== ERROR SUMMARY: 1 errors from 1 contexts (suppressed: 13 from 1)
==2817== malloc/free: in use at exit: 0 bytes in 0 blocks.
==2817== malloc/free: 1 allocs, 2 frees, 40 bytes allocated.
==2817== For counts of detected errors, rerun with: -v
==2817== No malloc'd blocks -- no leaks are possible.

Przykład 5 - próba skorzystania z niezainicjowanej zmiennej

Valgrind kontrolując zachowanie programu potrafi stwierdzić kiedy korzystamy z niezainicjowanej zmiennej, a konkretniej kiedy skok warunkowy zależy od niezainicjowanej wartości:
#include <stdlib.h>

void f(void)
{
  int a,b;
  // problem: korzystamy z wartosci niezainicjalizowanej zmiennej
  if (b != a)
    printf("Karramba!!\n");
  else
    printf("Jest gorzej niz myslisz!!\n");
}

int main(void)
{
  f();
  return 0;
}
Wykonując powyższy kod dostaniemy komunikat:
==2864== Memcheck, a memory error detector.
==2864== Copyright (C) 2002-2005, and GNU GPL'd, by Julian Seward et al.
==2864== Using LibVEX rev 1471, a library for dynamic binary translation.
==2864== Copyright (C) 2004-2005, and GNU GPL'd, by OpenWorks LLP.
==2864== Using valgrind-3.1.0, a dynamic binary instrumentation framework.
==2864== Copyright (C) 2000-2005, and GNU GPL'd, by Julian Seward et al.
==2864== For more details, rerun with: -v
==2864== 
==2864== Conditional jump or move depends on uninitialised value(s)
==2864==    at 0x8048390: f (przyklad5.c:7)
==2864==    by 0x80483C2: main (przyklad5.c:15)
Karramba!!
==2864== 
==2864== ERROR SUMMARY: 1 errors from 1 contexts (suppressed: 13 from 1)
==2864== malloc/free: in use at exit: 0 bytes in 0 blocks.
==2864== malloc/free: 0 allocs, 0 frees, 0 bytes allocated.
==2864== For counts of detected errors, rerun with: -v
==2864== No malloc'd blocks -- no leaks are possible.
Na ekran wypisało się "Karramba", czyli wartości a i b były różne (wbrew naiwnej intuicji, która podpowiadałaby, że oba będą zerami, lub chociaż ich wartości będą równe).

Przykład 6 - przekazywanie niezainicjalizowanych parametrów funkcji systemowej

Teraz spróbujmy wypisać na ekran dane z bufora, który nie jest zainicjalizowany:
#include <stdlib.h>
#include <unistd.h>

int main(void) {
  char* napis= malloc(10);
  write(1,napis,10);
  return 0;
}
Valgrind znów pokonał naszą drobną złośliwość i wskazał nielegalną instrukcję:
==2899== Memcheck, a memory error detector.
==2899== Copyright (C) 2002-2005, and GNU GPL'd, by Julian Seward et al.
==2899== Using LibVEX rev 1471, a library for dynamic binary translation.
==2899== Copyright (C) 2004-2005, and GNU GPL'd, by OpenWorks LLP.
==2899== Using valgrind-3.1.0, a dynamic binary instrumentation framework.
==2899== Copyright (C) 2000-2005, and GNU GPL'd, by Julian Seward et al.
==2899== For more details, rerun with: -v
==2899== 
==2899== Syscall param write(buf) points to uninitialised byte(s)
==2899==    at 0x40E0828: write (in /lib/libc-2.3.5.so)
==2899==    by 0x404C554: __libc_start_main (in /lib/libc-2.3.5.so)
==2899==  Address 0x414F028 is 0 bytes inside a block of size 10 alloc'd
==2899==    at 0x401B495: malloc (in /usr/lib/valgrind/x86-linux/vgpreload_memcheck.so)
==2899==    by 0x80483CF: main (przyklad6.c:5)
==2899== 
==2899== ERROR SUMMARY: 1 errors from 1 contexts (suppressed: 13 from 1)
==2899== malloc/free: in use at exit: 10 bytes in 1 blocks.
==2899== malloc/free: 1 allocs, 0 frees, 10 bytes allocated.
==2899== For counts of detected errors, rerun with: -v
==2899== searching for pointers to 1 not-freed blocks.
==2899== checked 48,540 bytes.
==2899== 
==2899== LEAK SUMMARY:
==2899==    definitely lost: 10 bytes in 1 blocks.
==2899==      possibly lost: 0 bytes in 0 blocks.
==2899==    still reachable: 0 bytes in 0 blocks.
==2899==         suppressed: 0 bytes in 0 blocks.
==2899== Use --leak-check=full to see details of leaked memory.

Cachegrind

Wstęp

Czasami okazuje się, że napisany przez nas program działa zbyt wolno, mimo użycia efektywnych algorytmów odpowiedzialnych za obliczenia oraz komunikację międzyprocesową. Przyczyną takiego stanu rzeczy może być nieefektywne użycie pamięci cache procesora.

Pamięci cache procesora - wprowadzenie

Jak wiadomo dostęp do pamięci RAM jest o klasę wolniejszy od dostępu do rejestrów procesora, oraz jego pamięci podręcznej (cache). Dlatego też dane z pamięci RAM na czas ich używania są ładowane do pamięci podręcznej, a dopiero stamtąd ładowane do rejestrów procesora. Po wykonaniu obliczeń nie są od razu odsyłane do pamięci RAM, gdyż mogą się przydać za chwilę. Zwykle bowiem jest tak, że po to liczymy coś w programie, aby z tego korzystać a nie np. odesłać na dysk na wieki wieków. Dzięki takiemu rozwiązaniu wielokrotny dostęp do tej samej komórki pamięci może być zrealizowany w krótszym czasie. Współczesne procesory mają 2 lub 3 poziomy pamięci podręcznej, zwykle oznaczane L1, L2, L3 (skrót od Level 1, Level 2, Level 3) (Charakterystyka za www.wikipedia.pl):

Problem

Zdarza się jednak i tak, że takie cache'owanie(zapamiętywanie w pamięci podręcznej się nie przydaje- praktycznie każdą daną trzeba ściągnąć z pamięci RAM i tylko marnuje nasz bezcenny czas na przesyłanie wszystkiego między poszczególnymi poziomami. Dodatkowy koszt nieznalezienia danych na poziomie pierwszym to około 10 cykli procesora, a na poziomie drugim - około 200 cykli. Taki problem jest bardzo trudny do wykrycia i tu z pomocą przychodzi nam narzędzie Valgrinda - Cachegrind.

Działanie Cachegrinda

Valgrind konwertuje sobie kod zawierający przerwania x86 do RISCopodobnego formatu UCode. Przed każdą wykonaną instrukcji kodu maszynowego, która w jakikolwiek sposób dotyka pamięci (są tylko 4 takie instrukcje w UCode: LOAD, STORE, FPU_R i FPU_W) Cachegrind przejmuje kontrolę nad procesorem. Cały czas śledząc co jest w pamięciach cache, i podpatrując adresy będące argumentami przerwania jest w stanie określić czy żądane wartości znajdują się w pamięciach podręcznych czy taż nie. Po zakończeniu każdej funkcji i całego programu program podlicza sobie statystyki i wypisuje je na ekran (czy gdziekolwiek użytkownik sobie zażyczy) i do logów.

Jak uruchomić Cachegrinda

Tu sprawa ma się podobnie jak z innym narzędziami. Jeśli na przykład profilujemy program o nazwie "konstantynopolitanczykowianeczka" w bieżącym katalogu to należy napisać
valgrind --tool=cachegrind ./konstantynopolitanczykowianeczka
o ile valgrind znajduje się w odpowiednim katalogu. Należy jednak pamiętać, o włączeniu wszystkich optymalizacji, ponieważ istotne jest zaobserwować jak będzie się zachowywać program w rzeczywistym środowisku.

Przykład

Sprawdzimy jak wygląda cache'owanie dla polecenia la -l. W tym celu w linii komend wpisujemy:
valgrind --tool-cachegrind ls -l
Valgrind wyprodukuje (po dłuższej chwili) coś w rodzaju:

rysunek

Oznaczenia: Na początku są podsumowania dla cache'owanych instrukcji (kod wykonywalny to też dane zajmujące miejsce w pamięci), następnie dla danych, a na końcu statystyki łączone dla pamięci cache L2. Dodatkowo podsumowanie działalności Cachegrinda ląduje w pliku cachegrind.out.pid. Dla powyższego przykładu zajął on 50 KB, a dla większych programów środowiska graficznego może zająć ponad 15 MB.

Electric fence

Electric fence jest to biblioteka dzielona służąca do debugowania dostępu do pamięci w programach bez konieczności ich rekompilacji (jednakże muszą być one skompilowane z opcją -g). Dostępy poza zaalokowany obszar pamięci często powodują dziwne błędy -- program zamiast zakończyć działanie nadpisuje zawartość innej zmiennej, i trudno jest zidentyfikować źródło problemu. Electric fence każdą alokację umieszcza pod koniec (lub na początku) sprzętowej strony pamięci, tak że każde wykroczenie za (odpowiednio przed) zaalokowany obszar spowoduje wygenerowanie wyjątku przez procesor. Ponieważ strony pamięci (przynajmniej na procesorach rodziny x86) muszą mieć rozmiar będący wielokrotnością 4096 bajtów, nie ma możliwości ochrony przed oboma tymi przypadkami jednocześnie, na szczęście jednak po włączeniu electric fence przekroczenia w drugą stronę nadpisywać będą bezużyteczne bajty, nie inne zmienne. Electric fence zajmuje się głównie czytaniem i pisaniem kilka bajtów poza zaalokowanym obszarem, nie zajmuje się innymi rodzajami błędnego dostępu. Weźmy prosty program z błędem off by one:
#include <stdlib.h>
#define SZ 100

int main()
{
      int *foo;
      int i;

      foo = malloc (sizeof(int) * SZ);
      for(i=0; i<=SZ; i++)
              foo[i] = 0;

      return 0;
}
Chociaż program zawiera błąd, prawdopodobnie uruchomi się prawidłowo, ponieważ malloc alokuje zwykle kilka bajtów więcej niż jest to żądane. Sam gdb nic nie znajduje, jednak gdb w połączeniu z electric fence powoduje otrzymanie sygnału i możemy sprawdzić co spowodowało błąd. (komenda gdb efence pojawi się dopiero po skopiowaniu do pliku .gdbinit odpowiedniego fragmentu rozpowszechnianego razem z electric fence).
$ gdb ./foo
GNU gdb 6.1-debian
Copyright 2004 Free Software Foundation, Inc.
GDB is free software, covered by the GNU General Public License, and you are
welcome to change it and/or distribute copies of it under certain conditions.
Type "show copying" to see the conditions.
There is absolutely no warranty for GDB.  Type "show warranty" for details.
This GDB was configured as "i386-linux"...Using host libthread_db library "/lib/tls/libthread_db.so.1".

(gdb) r
Starting program: /home/taw/foo

Program exited normally.
(gdb) efence
Enabled Electric Fence
(gdb) r
Starting program: /home/taw/foo
[Thread debugging using libthread_db enabled]
[New Thread 1073830880 (LWP 3227)]

Electric Fence 2.1 Copyright (C) 1987-1998 Bruce Perens.

Program received signal SIGSEGV, Segmentation fault.
[Switching to Thread 1073830880 (LWP 3227)]
0x0804839f in main () at foo.c:11
11              foo[i] = 0;
(gdb) p i
$1 = 100

Ksymoops

Wstęp

Ksymoops jest programem, który zamienia "magiczne numerki", które zostają wygenerowane przy oops-ie na kod assembler-owy. Ksymoops znajdował się kiedyś w źródłach jądra, lecz został usunięty, ponieważ był wolnostojącą aplikacją, która nie miała za dużo ze źródłami jądra wspólnego. Owe "magiczne numerki" są bezużyteczne dls kogoś, kto nie ma dostępu do odpowiedniej mapy symboli, która odpowiada kernel-owi, który wygenerował oops-a.

Instalacja (Bonus)

Ksymoops jest zwykłym programem. Instalujemy go zależnie od dystrybucji, lub standardowo ze źródeł:
mkdir ~/Ksymoops
cd ~/Ksymoops
wget http://www.kernel.org/pub/linux/utils/kernel/ksymoops/v2.4/ksymoops-2.4.9.tar.gz
tar xzf ksymoops-2.4.9.tar.gz
cd ksymoops-2.4.9
make
make install # jako root

Co może nam wygenerować ksymoops

Oto przykład, co wygeneruje ksymoops:
Unable to handle kernel paging request at virtual address 00003615
c0116a3b
*pde = 00000000
Oops: 0000
CPU:    0
EIP:    0010:[<c0116a3b>]    Not tainted
Using defaults from ksymoops -t elf32-i386 -a i386
EFLAGS: 00010082
eax: c32b332c   ebx: 00003615   ecx: 00000001   edx: 00000001
esi: c32b332c   edi: 00000001   ebp: c2b37e9c   esp: c2b37e80
ds: 0018   es: 0018   ss: 0018
Process ifconfig (pid: 897, stackpage=c2b37000)
Stack: 00000001 00000001 00000001 00000282 c3e39380 c556c560 00001002 00000000
       c01f5295 c10f55d0 c4d00800 c5546bc0 c3e39380 c01f4828 c3e39380 c4a639c0
       c01f5982 c4a639c0 00000000 00000030 00000009 c601d369 c4a639c0 c556c400
Call Trace:    [<c01f5295>] [<c01f4828>] [<c01f5982>] [<c601d369>] [<c01f96f5>]
  [<c01fa879>] [<c01f91df>] [<c022b340>] [<c01f2500>] [<c01f2526>] [<c0146380>]
  [<c010734f>]
Code: 8b 13 0f 0d 02 39 c3 74 16 8b 4b fc 8b 01 85 c7 75 19 8b 02

>>EIP; c0116a3b <__wake_up+1b/70>   <=====
>>eax; c32b332c <_end+2fb1994/5d196e8>
>>esi; c32b332c <_end+2fb1994/5d196e8>
>>ebp; c2b37e9c <_end+2836504/5d196e8>
>>esp; c2b37e80 <_end+28364e8/5d196e8>

Trace; c01f5295 <sock_def_write_space+75/80>
Trace; c01f4828 <sock_wfree+48/50>
Trace; c01f5982 <__kfree_skb+42/150>
Trace; c601d369 <[sis900]sis900_close+99/c0>
Trace; c01f96f5 <dev_close+c5/d0>
Trace; c01fa879 <dev_change_flags+129/140>
Trace; c01f91df <dev_get+f/20>
Trace; c022b340 <devinet_ioctl+290/610>
Trace; c01f2500 <sock_ioctl+0/30>
Trace; c01f2526 <sock_ioctl+26/30>
Trace; c0146380 <sys_ioctl+b0/1b0>
Trace; c010734f <system_call+33/38>

Code;  c0116a3b <__wake_up+1b/70>
00000000 <_EIP>:
Code;  c0116a3b <__wake_up+1b/70>   <=====
   0:   8b 13                     mov    (%ebx),%edx   <=====
Code;  c0116a3d <__wake_up+1d/70>
   2:   0f 0d 02                  prefetch (%edx)
Code;  c0116a40 <__wake_up+20/70>
   5:   39 c3                     cmp    %eax,%ebx
Code;  c0116a42 <__wake_up+22/70>
   7:   74 16                     je     1f <_EIP+0x1f>
Code;  c0116a44 <__wake_up+24/70>
   9:   8b 4b fc                  mov    0xfffffffc(%ebx),%ecx
Code;  c0116a47 <__wake_up+27/70>
   c:   8b 01                     mov    (%ecx),%eax
Code;  c0116a49 <__wake_up+29/70>
   e:   85 c7                     test   %eax,%edi
Code;  c0116a4b <__wake_up+2b/70>
  10:   75 19                     jne    2b <_EIP+0x2b>
Code;  c0116a4d <__wake_up+2d/70>
  12:   8b 02                     mov    (%edx),%eax

</OOPS> 

Co nam to daje

Jeżeli błąd wystąpił w zwykłym, nie modyfikowanym jądrze, w jednej z nowszych wersji, to należy przesłać wynik działania ksymoops-a na LKML (Linux Kernel Mailing List). Jeżeli błąd wystąpił w zwykłym, nie modyfikowanym jądrze, w starszych wersjach, należy zainstalować nowszą. W przypadku, gdy stosujemy dodatkowe moduły spoza kernel-a (i oops pojawia się tylko gdy są one załadowane), należy wysłać wynik działania ksymoops-a do autorów modułu. Jeżeli błąd występuje w jądrze, które było modyfikowane przez użytkownika, to użytkownik ma informacje o tym, gdzie i jaki błąd wystąpił. Należy jednak pamiętać, że oops może być (i w większości wypadkach jest) wynikiem uszkodzenia sprzętu.

User Mode Linux

Wstęp

UML jest programem, który: W przeciwieństwie do normalnego jądra, UML nie komunikuje się bezpośrednio ze sprzętem. Jest on oddzielony warstwą macierzystego systemu. Z punktu widzenia systemu, na którym odpalamy UML-a, jest on zwykłym procesem, który nie posiada uprawnień administratora, więc nie może za dużo w systemie popsuć. Programy uruchamiane w UML-u działają średnio o 20% wolniej niż w zwykłym systemie, co jest bardzo dobrym wynikiem. Z przyczyn czasowych, w tej prezentacji poruszymy tylko temat odpluskwiania jądra. Inne możliwości są opisane w starszych prezentacjach.

uml

Instalacja (Bonus)

UML dostarczany jest jako łatka na jądro.
mkdir ~/UML
mkdir ~/UML/archives
mkdir ~/UML/kernel
mkdir ~/UML/tools
cd ~/UML/archives
wget http://www.kernel.org/pub/linux/kernel/v2.4/linux-2.4.31.tar.bz2
wget http://heanet.dl.sourceforge.net/sourceforge/user-mode-linux/uml-patch-2.4.27-1.bz2
cd ~/UML/kernel
tar xjf ../archives/linux-2.4.31.tar.bz2
cd linux-2.4.31
bzcat2 ~/UML/archives/uml-patch-2.4.27-1.bz2 | patch -p1

make menuconfig ARCH=um # (Bedziemy zadowoleni z default-owej konfiguracji)
make linux ARCH=um
strip linux # (Aby zmniejszyc rozmiar)

# Instalacja UML tools
cd ~UML/tmp
cvs -d:pserver:anonymous@www.user-mode-linux.org:/cvsroot/user-mode-linux -z3 co tools
cd tools
# Warto zmienic w Makefile BIN_DIR na /opt/uml_tools/bin oraz LIB_DIR na /opt/uml_tools/lib
make
make install # Jako root

Konfiguracja

Aby UML został skompilowany z flagą -g należy w konfiguracji uwzględnić:

Sposoby odpluskwiania jądra

Rootfs dla UML-a

Aby UML mógł działać poprawnie, potrzebne jest środowisko w którym ma być uruchomiony. Jedną z możliwości jest "postawienie" pliku, który będzie tak naprawde linuksową partycją. Przykładowe takie pliki można pobrać ze strony projektu.

Jak to działa

UML jest zwykłym programem, skompilowanym z flagą -g, można więc podczepić do niego dowolny debuger (np. gdb).

Kgdb

Wstęp

Kgdb jest łatką na jądro, która daje możliwość jego odpluskwiania. Aby móc korzystać z Kgdb potrzebne są dwa komputery połączone łączem szeregowym (można użyć wirtualnej maszyny np. VMWare). Trwają również prace nad dodaniem możliwości połączenia za pomocą sieci Ethernet.

siec

Instalacja (Bonus)

Na maszynie "development" (tej na której będziemy uruchamiali gdb):
mkdir ~/Kgdb
mkdir ~/Kgdb/archives
mkdir ~/Kgdb/kernel
cd ~/Kgdb/archives
wget http://www.kernel.org/pub/linux/kernel/v2.4/linux-2.4.31.tar.bz2
wget http://kgdb.linsyssoft.com/downloads/linux-2.4.23-kgdb-1.9.patch
cd ~/Kgdb/kernel
tar xjf ../archives/linux-2.4.31.tar.bz2
cd linux-2.4.31
cat ~/gdb/archives/linux-2.4.23-kgdb-1.9.patch | patch -p1

make menuconfig # dokonujemy odpowiedniej konfiguracji
make dep
make bzImage
Kopiujemy następnie nowe jądro na maszynę "test":
rcp System.map testmach:/boot/System.map-2.4.31-kgdb
rcp arch/i386/boot/bzImage testmach:/boot/vmlinuz-2.4.31-kgdb
Na maszynie "test" dodajemy odpowiedni wpis dla lilo:
image=/boot/vmlinuz-2.4.31-kgdb
  label=kgdb
  read-only
  root=/dev/hda1
  append="gdb gdbttyS=1 gdbbaud=115200"
lub dla gruba:
title 2.4.31 kgdb
  root (hd0,0)
  kernel /boot/vmlinuz-2.4.31-kgdb ro root=/dev/hda1 rootfstype=ext3 kgdbwait kgdb8250=1,57600

Konfiguracja

W konfiguracji należy uwzględnić:

Sposoby odpluskwiania jądra

Po uruchomieniu maszyny "test", podczas ładowania jądra z łatką kgdb pojawi się komunikat:
Waiting for connection from remote gdb...
Należy teraz uruchomić gdb na maszynie "development":
gdb vmlinux # w katalogu ze zrodlami
Pojawi się komunikat:
GNU gdb 20000204
Copyright 2000 Free Software Foundation, Inc.
GDB is free software, covered by the GNU General Public License, and you are
welcome to change it and/or distribute copies of it under certain conditions.
Type "show copying" to see the conditions.
There is absolutely no warranty for GDB.  Type "show warranty" for details.
This GDB was configured as "i686-pc-linux-gnu"...
(gdb)
W oknie gdb wpisujemy:
shell echo -e "\003" > /dev/ttyS1
set remotebaud 115200
target remote /dev/ttyS1
Pojawi się komunikat:
Remote debugging using /dev/ttyS1
breakpoint () at gdbstub.c:1153
1153    }
Teraz jesteśmy podłączeni do jądra na maszynie "test" i możemy je odpluskwiać.

Jak to działa

Jądro kompilowane jest z opcją -g. Łatka zmienia plik (init/main.c) dodając tam wpis:
#ifdef CONFIG_KGDB
#include <linux/kgdb.h>
#endif

...

asmlinkage void __init start_kernel(void)
{

...

#ifdef CONFIG_KGDB
        if (gdb_enter) {
                gdb_hook();             /* right at boot time */
        }
#endif

...

}
Co spowoduje wywołanie gdb_hook() podczas ładowania jądra. Inicjalizacja połączenia szeregowego i przekazywanie komunikatów do gdb opisane są w plikach: Oczekiwanie na połączenie realizowane jest za pomocą breakpoint-a (drivers/char/gdbserial.c):
int gdb_hook(void)
{
    ...

    printk("Waiting for connection from remote gdb... ");
    breakpoint();
    gdb_null();

    printk("Connected.\n");

    gdb_initialized = 1;
    return(0);

}

Odpluskwianie w Windows

Wstęp

Do odpluskwiania w systemie Windows służą aplikacje:

Porównanie

Poniższa tabela prezentuje porównanie możliwości poszczególnych odpluskwiaczy.

Możliwość KD NTSD Windbg Visual Studio .NET
Tryb jądra + - + -
Tryb użytkownika - + + +
Zdalne odpluskwianie + + + +
Podpięcie do procesu + + + +
Odpięcie od procesu + + + +
Opdluskwianie SQL - - - +

Dalsza część prezentacji dotyczy windbg.

Dodatkowe możliwości windbg

Gdy Windows "crash-uje" się zapisuje informacje o procesach i zawartości pamięci do odpowiedniego pliku "dump". Windbg wspiera analizę takich plików.

Analiza plików "dump"

Po uruchomieniu windbg pojawi nam się ekran:

win

Wybieramy z menu File opcje Open Crash Dump. Teraz musimy odszukać nasz plik "dump". Standardowo Windows zapisuje je w %systemroot%/minidump. Pliki te posiadają unikalną nazwę np. Mini121404-01.dmp. Jest to pierwszy plik "dump" utworzony 14 grudnia 2004 roku. Otwieramy plik. Przykładowy efekt:
Symbol search path is: SRV*c:\symbols*http://msdl.microsoft.com/download/symbols

Microsoft &reg; Windows Debugger Version 6.3.0017.0
Copyright &copy; Microsoft Corporation. All rights reserved.


Loading Dump File [C:WINDOWSMinidumpMini061904-01.dmp]
Mini Kernel Dump File: Only registers and stack trace are available

Symbol search path is: SRV*c:\symbols*http://msdl.microsoft.com/download/symbols
Executable search path is:
Windows XP Kernel Version 2600 (Service Pack 1) UP Free x86 compatible
Product: WinNt, suite: TerminalServer SingleUserTS
Built by: 2600.xpsp2.030422-1633
Kernel base = 0x804d4000 PsLoadedModuleList = 0x80543530
Debug session time: Sat Jun 19 19:06:57 2004
System Uptime: 0 days 1:03:36.951
Loading Kernel Symbols
....................................................................................................................................
Loading unloaded module list
..........
Loading User Symbols
*******************************************************************************

Bugcheck Analysis

*******************************************************************************

Use !analyze -v to get detailed debugging information.

BugCheck 86427532, {1db, 2, 3, b}

Unable to load image pavdrv51.sys, Win32 error 2
*** WARNING: Unable to verify timestamp for pavdrv51.sys
*** ERROR: Module load completed but symbols could not be loaded for pavdrv51.sys
Probably caused by : pavdrv51.sys

Followup: MachineOwner
Możemy łatwo zauważyć, że kod błędu to 86427532, {1db, 2, 3, b} i został on spowodowany przez pavdrv51.sys. Dzięki temu mamy informację o sterowniku/programie, który prawdopodobnie jest wadliwy i należy dokonać jego aktualizacji. Do usunięcia usterki pomocna również będzie wyszukiwarka Google.

Automatyczne pobieranie symboli

Aby windbg automatycznie pobierał symbole, które są mu niezbędne do odpluskwiania, należy w sekcji File wybrać Symbol File Path i wpisać tam:
SRV*c:\symbols*http://msdl.microsoft.com/download/symbols
wówczas symbole będą ściągane do katalogu c:\symbols. Należy jednak być wtedy cierpliwym, ponieważ niektóre pliki z symbolami zajmuja dużo miejsca (przykładowo 10 MB).

Podstawowe komendy

Oto spis podstawowych koment windbg:

Opis Komenda
Krok w przód p
Kontynuacja programu g
Lista breakpoint-ow Bl
Założenie breakpoint-a bp
Wyłączenie breakpoint-a bd
Włączenie breakpoint-a be
Wyczyszczenie breakpoint-a bc
Komentarz *
Wyświetl zmienną dv

Odpluskwianie notatnika

Odpluskwianie programu omówimy na przykładzie notatnika:

Bibliografia

Autorzy

Marek Cygan;
Aleksander Mielczarek;
Krzysztof Ślusarski;