Najprostszy przykład formatu plików obiektowych to .COM z MSDOS. Pliki w takim formacie miały zazwyczaj rozszerzenia .bin czy .com i zawierały praktycznie tylko kod maszynowy programu. Ładując taki plik do pamięci, wystarczy go przeczytać i przekazać sterowanie do pierwszego adresu z tego pliku. Bardziej skomplikowane formaty to, np. a.out używany w systemie BSD UNIX, ELF z Systemu V, rozszerzony format COFF (ECOFF) do linkowania i wykonywalny PE stosowane przez 32-bitowe Windows'y czy OMF używany przez Windows'a zanim powstał COFF.
Plik obiektowy powinien zawierać przynajmniej część z następujących informacji:
/etc/magic
)
objdump
(patrz man objdump
).
Linkery obsługują różne rodzaje symboli, które określają np. powiązania między modułami (każdy moduł zawiera własną tablicę symboli). Rozróżniamy następujące rodzaje symboli:
Dzięki informacjom zgromadzonym w fazie kompilacji i linkowania programu, programista może odpluskwiać plik obiektowy, odwołując się do kodu źródłowego funkcji i nazw zmiennych, ustawiać punkty kontrolne (breakpoints) i wykonywać program instrukcja po instrukcji. Kompilatory wspomagają ten proces, mapując numery linii z kodu źródłowego w odpowiednie adresy w kodzie maszynowym (podobnie dla opisów funkcji, zmiennych, typów i struktur użytych w programie).
Kompilatory UNIXowe posługują się dwoma różnymi formatami wspomagającymi debugowanie: stab (skrót od `symbol table') używany w a.out, COFF i ELF (poza Systemem V) i DWARF zdefiniowany dla plików ELF Systemu V. Microsoft również stworzył swoje własne formaty dla debugera Codeview, najnowszy z nich to CV4.
Zamapowanie numerów linii z kodu źródłowego do pliku obiektowego pozwala odpluskwiaczowi umieścić punkt kontrolny (breakpoint) w odpowiednim miejscu w kodzie oraz umożliwia śledzenie stosu wywołań i raportowanie błędów z odniesieniami do tego kodu. Oczywiście trzeba brać pod uwagę to, czy kod wykonywalny nie jest przypadkiem zoptymalizowany przez kompilator, bo wówczas odpowiedniość między kodem i plikiem obiektowym nie zawsze jest zachowana i można się natknąć na przykrą niespodziankę (częste są np. problemy ze zmiennymi lokalnymi i ich wartościami).
Dla każdej linii w kodzie źródłowym, dla której powstał jakiś kod maszynowy, kompilator produkuje dane składające się z numeru linii i
adresu początku kodu maszynowego, który jej odpowiada (tzw. line number entry).
Jeśli adres znajduje się między dwoma takimi wpisami, to odpluskwiacz przypisuje ten adres niższemu numerowi linii. Numery linii obowiązują
tylko w zakresie pliku źródłowego o danej nazwie, ewentualnie dodatkowo nazwie pliku dołączonego np. dyrektywą
#include
. Niektóre formaty radzą sobie z tym w ten sposób, że tworzą listę plików i dodają do każdej pojedynczej danej
o numerze linii dodatkowo indeks pliku. Inne dodają znaczniki "begin include"
i "end include"
do listy
numerów linii i w ten sposób jawnie utrzymują stos numerów linii.
Kiedy optymalizacja wykonana przez kompilator powoduje, że kod wygenerowany z jednej linii staje się nieciągły w pliku obiektowym, niektóre formaty (np. DWARF) pozwalają zamapować każdy bajt wygenerowanego kodu maszynowego z powrotem w jedną linię źródłową (w ten sposób rozmiar pliku znacznie rośnie). Inne formaty (np. stabs) podają w takich przypadkach tylko przybliżone położenie.
Kompilator musi zachować nazwy, typy i położenie każdej zmiennej w programie. Informacja do debugowania w tym przypadku jest dość skomplikowana, bo musimy zakodować nie tylko typ, ale także często strukturę tego typu, żeby odpluskwiacz mógł poprawnie formatować poszczególne pola w tej strukturze.
Informacja o takich symbolach jest przechowywana w formie drzewa. Na górnym poziomie dla każdego pliku mamy listę typów,
zmiennych i funkcji zdefiniowanych w nim globalnie. W poddrzewach znajdują się pola struktur, zmienne lokalne dla funkcji, itd.
Dla funkcji mamy też dodatkowo znaczniki "begin block"
i "end block"
odnoszące się do numerów linii,
żeby odpluskwiacz mógł określić, które zmienne są widoczne w danym miejscu w programie.
Najtrudniejszą cześcią jest przechowywanie danych o położeniu. Położenie zmiennej statycznej się nie zmienia, ale zmienna lokalna może znajdować się na stosie, w rejestrze, w zoptymalizowanym kodzie i jest przemieszczana z jednego miejsca funkcji w inne. Dodatkowo w większości architektur standardowa metoda wywoływania funkcji utrzymuje ciąg zachowanych wskaźników do wierzchołka stosu i wskaźników do rekordów aktywacji (tzw. wskaźnik ramki albo frame pointer) odpowiadających kolejnym zagnieżdżonym wywołaniom funkcji. Funkcje, które są na końcu takiego łańcucha wywołań i nie alokują żadnych lokalnych zmiennych na stosie, często są optymalizowane w ten sposób, że nie tworzy się dla nich wskaźnika ramki. Odpluskwiacz musi o tym wiedzieć, żeby poprawnie zinterpretować stos wywołań i znaleźć wszystkie zmienne lokalne.
Linkery często wykrywają i usuwają nadmiarowe kopie tej samej informacji do debugowania. W C i C++ programy zazwyczaj używają zestawu plików nagłówkowych, które definiują typy i deklarują funkcje i jeśli kilka plików źródłowych korzysta z tych samych nagłówków, w wynikowym pliku obiektowym powstałym po zlinkowaniu ta informacja pojawia się w wielu kopiach.
Kompilatory zachowują informacje do debugowania dla każdego pliku nagłówkowego dołączonego do każdego pliku źródłowego. Oznacza to, że jeśli pewien plik nagłówkowy jest dołączony do 20 plików źródłowych, które są w jednym projekcie, linker dostanie 20 identycznych kopii informacji do debugowania dla tego pliku. Odpluskwiacze dobrze radzą sobie z tymi nadmiarowymi danymi, ale rozmiar takiego pliku może być bardzo duży. Linker może usunąć nadmiar danych, żeby przyspieszyć działanie debugera i oszczędzać miejsce. Czasami kompilator umieszcza informacje dla odpluskwiacza w oddzielnych plikach przeznaczonych specjalnie dla niego tak, że linker w ogóle nie musi się nimi zajmować.
Kiedy dane do debugowania są umieszczane w pliku obiektowym, czasami są pomieszane z innymi symbolami w jednej dużej tablicy symboli, a czasem są trzymane oddzielnie. Systemy UNIXowe dodawały stopniowo dane do debugowania do plików, więc wszystko znajduje się w jednej dużej tablicy symboli (tak jest w formacie stabs). Inne formaty, jak np. ECOFF Microsoft oddzielają jedne symbole od drugich.
Jak już pisałam wcześniej, czasami informacje do debugowania trafiają do oddzielnego pliku, a czasami są trzymane razem
z resztą pliku obiektowego. Zaletą drugiego podejścia jest uproszczenie procesu budowania programu, bo mamy wszystko
dostępne od razu w jednym miejscu. Wadą jest to, że plik wykonywalny może być bardzo duży. Poza tym, jeśli oddzielimy
informacje do debugowania, łatwo będzie opublikować końcową wersję programu bez tych informacji. Dzięki temu plik
wykonywalny jest rozsądnych rozmiarów, zniechęcamy inne firmy do podglądania naszych rozwiązań ;), a my mamy dalej
pliki potrzebne do znajdowania błędów w opublikowanym kodzie. W UNIXie mamy polecenie "strip"
, które usuwa dane do debugowania
z pliku obiektowego, nie zmieniając reszty kodu. Dzięki temu możemy opublikować okrojoną (stripped), a zachować pełną wersję tego samego
pliku. Pomimo że te dwa pliki będą się od siebie różniły, będą korzystały z tych samych symboli i zrzut pamięci wykonany
okrojoną wersją programu będzie mógł być analizowany przez odpluskwiacz podłączony do pełnej wersji.
Informacja do debugowania w tym formacie znajduje się w dyrektywach asemblera, które nazywamy dyrektywani stab i które
są przemieszane z generowanym kodem. Stabs jest natywnym formatem do debugowania dla plików a.out i XCOFF, ale można z niego
korzystać też w plikach ELF, w których niezbędne informacje są przechowywane w sekcjach .stab
i .stabstr
.
Są 3 podstawowe rodzaje dyrektyw stab: .stabs (string)
, .stabn (number)
, .stabd (dot)
.
Ogólny format tych dyrektyw to:
.stabs "string",type,other,desc,value .stabn type,other,desc,value .stabd type,other,desc
Rozważmy bardzo prosty przykład (test0.c)
void main() { printf("Hello world"); }
Kompilując go z opcją -g i oglądając powstały plik .s (kod maszynowy wygenerowany przez kompilator) dostaniemy coś takiego:
1 gcc2_compiled.: 2 .stabs "/home/heathcliff/stabs/",100,0,0,Ltext0 3 .stabs "test0.c",100,0,0,Ltext0 4 .text 5 Ltext0: 6 .stabs "int:t1=r1;-2147483648;2147483647;",128,0,0,0 //przykładowa dyrektywa .stabs (opisje się nią np. zmienne) 7 .stabs "char:t2=r2;0;127;",128,0,0,0 8 .stabs "long int:t3=r1;-2147483648;2147483647;",128,0,0,0 9 .stabs "unsigned int:t4=r1;0;-1;",128,0,0,0 10 .stabs "long unsigned int:t5=r1;0;-1;",128,0,0,0 11 .stabs "short int:t6=r1;-32768;32767;",128,0,0,0 12 .stabs "long long int:t7=r1;0;-1;",128,0,0,0 13 .stabs "short unsigned int:t8=r1;0;65535;",128,0,0,0 14 .stabs "long long unsigned int:t9=r1;0;-1;",128,0,0,0 15 .stabs "signed char:t10=r1;-128;127;",128,0,0,0 16 .stabs "unsigned char:t11=r1;0;255;",128,0,0,0 17 .stabs "float:t12=r1;4;0;",128,0,0,0 18 .stabs "double:t13=r1;8;0;",128,0,0,0 19 .stabs "long double:t14=r1;8;0;",128,0,0,0 20 .stabs "void:t15=15",128,0,0,0 21 .align 4 22 LC0: 23 .ascii "Hello, world!\12\0" 24 .align 4 25 .global _main 26 .proc 1 27 _main: 28 .stabn 68,0,4,LM1 29 LM1: 30 !#PROLOGUE# 0 31 save %sp,-136,%sp 32 !#PROLOGUE# 1 33 call ___main,0 34 nop 35 .stabn 68,0,5,LM2 //przykładowa dyrektywa .stabn (opisuje się nią m.in. etykiety) 36 LM2: 37 LBB2: 38 sethi %hi(LC0),%o1 39 or %o1,%lo(LC0),%o0 40 call _printf,0 41 nop 42 .stabn 68,0,6,LM3 43 LM3: 44 LBE2: 45 .stabn 68,0,6,LM4 46 LM4: 47 L1: 48 ret 49 restore 50 .stabs "main:F1",36,0,0,_main 51 .stabn 192,0,0,LBB2 52 .stabn 224,0,0,LBE
Dodatkowe infornacje można znaleźćć na stronie: http://www.informatik.uni-frankfurt.de/doc/texi/stabs_toc.html
DWARF jest nowszy niż stabs i związany przede wszystkim z formatem ELF, choć był używany też z innymi formatami plików obiektowych.
Informacje potrzebne do debugowania w tym formacie są umieszczane w sekcji .debug
pliku obiektowego (przynajmniej w wersji
1.1) i składają się ze specjalnych rekordów, które reprezentują kod źródłowy programu. Każda taka dana jest opisywana przez jednoznaczny
identyfikator (tag) i zawiera zbiór atrybutów. Identyfikator mówi, do jakiej klasy konkretne dane należą,
a atrybuty zapisują pozostałe informacje.
Autorzy formatu twierdzą, że DWARF dzięki blokowej strukturze jest łatwo rozszerzalny, bo można dodawać opisy nowych obiektów lub rozszerzać istniejące. Stabs jest pod tym względem bardziej restrykcyjny, ponieważ opiera się na predefiniowanych dyrektywach. DWARF może też opisywać bardziej skomplikowane struktury niż stabs, np. nieciągłe zakresy widoczności zmiennych czy strukturę stosu.
Dodatkowe informacje można znaleźć na stronach:
Szczegółowe informacje o tym formacie znajdują się na stronie: http://www16.boulder.ibm.com/pseries/en_US/files/aixfiles/XCOFF.htm
gcc oferuje ogromną kolekcję przydatnych opcji. Tutaj wspomnę tylko o najważniejszych z nich, które rzeczywiście często się przydają i znacznie ułatwiają debugowanie. Spis wszystkich opcji można znaleźć na stronach: http://gcc.gnu.org/onlinedocs/gcc/Debugging-Options.html i http://developer.apple.com/documentation/DeveloperTools/gcc-4.0.1/gcc/Debugging-Options.html
`-g'
Produkuje informacje do debugowania w formacie właściwym dla danego systemu operacyjnego (stabs, COFF, XCOFF, DWARF). Informacje te mogą być potem wykorzystywane przez gdb.
Opcja ta generuje dużo informacji, które mogą być przydatne tylko dla gdb, a przeszkadzać innym odpluskwiaczom, więc często lepiej jest stosować jedną z opcji podanych poniżej, żeby mieć większą kontrolę nad powstającym plikiem obiektowym.
Inaczej niż w innych kompilatorach gcc pozwala na używanie opcji `-g
' razem z `-O'
, która służy
do włączania optymalizacji kodu. Czasami prowadzi to do dziwnych rezultatów, bo np. zadeklarowana zmienna może wcale nie istnieć,
przepływ sterowania może być zupełnie inny niż myślimy, niektóre instrukcje mogą nie być wykonywane, bo znamy już ich rezultat
albo instrukcje mogą wykonywać się w innych miejscach, bo zostały przesunięte na zewnątrz pętli.
Mimo tych problemów, debugowanie zoptymalizowanych programów jest możliwe i wspierane przez niektóre formaty (np. DWARF).
`-ggdb'
To samo co `-g'
tyle, że z wszystkimi rozszerzeniami gdb.
`-gstabs'
, `-gstabs+'
Informacje w formacie stabs z lub bez rozszerzeń gdb.
`-gcoff'
Format COFF.
`-gxcoff'
, `-gxcoff+'
Format XCOFF.
`-gdwarf'
, `-gdwarf+'
Format DWARF.
`-gLEVEL'
`-ggdbLEVEL'
`-gstabsLEVEL'
`-gcoffLEVEL'
`-gxcoffLEVEL'
`-gdwarfLEVEL'
Dodanie do pliku informacji dla odpluskwiacza z określeniem, jak dużo informacji chcemy zapisać. Domyślny poziom to 2.
Poziom 1 generuje minimalną ilość danych, m.in. opisy funkcji i zewnętrznych zmiennych, ale bez uwzględnienia lokalnych zmiennych i numerów linii.
Poziom 3 zawiera dodatkowe dane takie, jak np. wszystkie makrodefinicje występujące w programie.
`-pg'
Opcja używana, jeśli chcemy korzystać z narzędzia do profilowania kodu gprof opisanego dalej. Ta opcja musi być użyta w czasie kompilacji i linkowania.
`-p'
Opcja potrzebna jeśli chcemy korzystać z programu prof do profilowania kodu. Musi być użyta w czasie kompilacji i linkowania.
© Magdalena Dukielska |