2021-12-16 / Bartłomiej Kurek
Od czego zacząć? #6 (budowa programów)

Programy wykonywalne

Wracając do kodu z poprzedniej części - mam taki program:

void main() { }

Kompiluję go:

$ cc main.c -o program

W wyniku otrzymuję plik wykonywalny o nazwie "program".

$ file program
program: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV),
dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2,
BuildID[sha1]=d878d033920ae8c8a09d6a6d52b9c15736e0acf9, for GNU/Linux 3.2.0, not stripped

To jest ELF (Executable and linkable format). Mógłbym ten plik nazwać program.exe, ale to niczego nie zmienia.

$ cp program program.exe
$ file program.exe
program.exe: ELF 64-bit LSB pie executable, ...

*.exe to inny format.

Linkowanie dynamiczne

Widzieliśmy też powyżej, że plik binarny:

  • jest dynamically linked
  • do jego uruchomienia potrzebny jest interpreter /lib64/ld-linux-x86-64.so.2.

Zmienię teraz minimalnie ten kod, żeby omówić powyższe sprawy. Teraz kod wygląda tak:

#include <stdio.h>

void main()
{
    printf("hello world!\n");
}

Kompiluję go i uruchamiam:

$ cc main.c -o program
$ ./program
hello world!

Działa. Sprawdzam co to za format:

$ file program
program: ELF 64-bit LSB pie executable, x86-64, ...version 1 (SYSV),
dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, ...

Czyli jest to taki sam format ELF. Uruchomiłem go, a więc faktycznie jest wykonywalny.

Wróćmy zatem do dynamically linked. Co to znaczy?
W Linux jest taki program ldd (List dynamic dependencies), którym mogę wyświetlić biblioteki wymagane do uruchomienia tego programu.

$ ldd program
    linux-vdso.so.1 (0x00007ffdd3bd0000)
    libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f49280f2000)
    /lib64/ld-linux-x86-64.so.2 (0x00007f49282df000)

Skupmy się na tej środkowej bibliotece: libc.so.
libc to "C library", a konkretnie "standardowa biblioteka C".
so to Shared Object (biblioteka współdzielona). To wytłumaczę za chwilę, najpierw zajmijmy się odpowiedzią na pytanie czym w ogóle są biblioteki i dlaczego mój banalny program wymaga bibioteki libc.

Otóż - w programie użyłem funkcji printf(), ale sam jej nie napisałem. Czyli ona gdzieś musi być, gdzieś musi znajdować się kod, który wykonuje to wypisywanie. Wyświetlę teraz listę nazw/symboli z tego pliku binarnego. Używam programu nm, to skrót od names. Nie zrażaj się poniższym listingiem, zaraz to wytłumaczę.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
$ nm program
00000000000002e8 r __abi_tag
0000000000004030 B __bss_start
0000000000004030 b completed.0
                 w __cxa_finalize@GLIBC_2.2.5
0000000000004020 D __data_start
0000000000004020 W data_start
0000000000001080 t deregister_tm_clones
00000000000010f0 t __do_global_dtors_aux
0000000000003df0 d __do_global_dtors_aux_fini_array_entry
0000000000004028 D __dso_handle
0000000000003df8 d _DYNAMIC
0000000000004030 D _edata
0000000000004038 B _end
00000000000011b4 T _fini
0000000000001130 t frame_dummy
0000000000003de8 d __frame_dummy_init_array_entry
0000000000002154 r __FRAME_END__
0000000000004000 d _GLOBAL_OFFSET_TABLE_
                 w __gmon_start__
0000000000002014 r __GNU_EH_FRAME_HDR
0000000000001000 t _init
0000000000003df0 d __init_array_end
0000000000003de8 d __init_array_start
0000000000002000 R _IO_stdin_used
                 w _ITM_deregisterTMCloneTable
                 w _ITM_registerTMCloneTable
00000000000011b0 T __libc_csu_fini
0000000000001150 T __libc_csu_init
                 U __libc_start_main@GLIBC_2.2.5
0000000000001139 T main
                 U puts@GLIBC_2.2.5
00000000000010b0 t register_tm_clones
0000000000001050 T _start
0000000000004030 D __TMC_END__

Interesuje nas linia nr 32.
Brakuje tam liczby w kolumnie z adresami, w drugiej kolumnie jest "U", a dalej puts@GLIBC_2.2.5.
puts() (put string) to funkcja, która wypisuje napis. Ja sam jej nie napisałem, więc w moim programie jest niezdefiniowana (U - undefined), a skoro nie ma jej w tym pliku binarnym, to nie ma adresu (pozycji w pliku).

A zatem - kiedy uruchamiam ten program, system operacyjny musi znaleźć ten kod i go załadować. Cóż w przeciwnym wypadku miałby mój program w tym momencie zrobić? W tym pliku binarnym zapisane jest jakie biblioteki są wymagane do uruchomienia (runtime), a więc podczas uruchomienia programu system ładuje te biblioteki do pamięci i szuka w nich tych konkretnych symboli.
Jeśli w tym libc nie znajdzie symbolu puts, to program nie będzie mógł się wykonać. Ale jeśli ten symbol tam jest, to:

  • z miejsca w moim programie, gdzie wywołuję tę funkcję printf(), nastąpi skok (call - wywołanie)
  • skok nastąpi do miejsca tego puts() załadowanego gdzieś do pamięci wprost z biblioteki libc
  • zacznie się wykonywać kod tej funkcji puts()
  • ta funkcja puts() wypisze ten napis "hello world!\n", a wykonywanie powróci (return) do miejsca w moim programie już po tym wywołaniu printf().

Każde wywołanie funkcji (call) kiedyś wraca (return).
Bibliteka libc jest wymagana w tym programie. Bez niej sam musiałbym napisać funkcję printf(), wiedzieć na jakie urządzenie mam pisać, itd. A to już przecież umie system operacyjny.

System ma takie funkcje jak printf() (tak naprawdę write()) i ma też sterowniki, którym mówi "masz tutaj taki ciąg bajtów" i wypisz je tam na monitor. libc to bilioteka standardowa C, która pozwala mi się odnieść do tych systemowych funkcji. Te systemowe funkcje (syscalls) to jakieś liczby, do których przyporządkowane są funkcje (jakiś kod). Systemy są różne, mogą przyporządkowywać różne numerki tym funkcjom, więc w to już nie muszę wnikać - po prostu biblioteka libc na danym systemie wie jakiego numerka funkcji ma żądać od systemu. Ja wywołuję tylko printf().

Wróćmy teraz do tego "Shared Object".

Dlaczego "object"?

Kiedy kompilujemy kod, to kompilator najpierw tworzy plik "obiektu" (*.o), który zawiera te symbole i kod funkcji, które były zdefiniowane w źródle. Kiedy budujemy program, to możemy wyróżnić etap kompilacji (translację kodu źródłowego na obiekty) oraz budowanie pliku wykonywalnego (etap linkowania obiektów w całość). Plik wykonywalny może powstawać z wielu obiektów i plików obiektów. Te pliki obiektów odpowiadają poszczególnym plikom kodu. Taki plik z kodem w C/C++ który można pojedynczo skompilować, nazywamy właśnie "jednostką translacji". Najpierw jest etap translacji na obiekty, a później etap linkowania w całość (program wykonywalny, lub bibliotekę). Poniżej krótki listing samej kompilacji.

Mam plik z kodem źródłowym, kompiluję go jedynie (-c) i otrzymuję plik obiektu, w którym są tylko te rzeczy, które w moim programie występują.

$ ls
main.c
$ cc -c main.c
$ ls -l
main.c
main.o
$ file main.o 
main.o: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped
$ nm main.o
0000000000000000 T main
                 U puts

Plik jest obiektem "relocatable". Obiekty relocatable mają pseudoadres 0, a na etapie linkowania wszystkiego w całość przypisywane są im konkretne adresy (pozycje w pliku binarnym). Istnieje jeszcze pojęcie "PIC - Position Independent Code" - w tym przypadku mamy kod, któremu adresy mogą zostać przypisane na etapie uruchomienia. Tego używa się, kiedy sami budujemy biblioteki dynamiczne, które będzie można użyć w taki sposób jak omawiany program używa libc.

Dlaczego shared?

Załóżmy, że ja mam ten program, a Ty masz swój program, który też robi printf(). Nie ma sensu ładować dwukrotnie tego samego kodu do pamięci, pamięć trzeba oszczędzać. Zatem jeśli mój program wymagający libc się wykonuje, a Ty uruchomisz swój, to system operacyjny już będzie wiedział, że ma tę bibliotekę libc (w tej wersji) załadowaną do pamięci i kiedy Twoj program zrobi printf(), to wykona skok do tego miejsca pamięci, gdzie ten kod już jest.
Co więcej - jeśli mój program się zakończy, a Twój będzie nadal działał, system nie usunie tej biblioteki z pamięci. Twój program nie mógłby bez niej działać. Dlatego to jest biblioteka "współdzielona". Ta część systemu, która odpowiada za ładowanie tych bibliotek, nazywa się "linker dynamiczny". W skrócie dl (dynamic linker). Jeśli teraz przytoczymy ponownie ten listing, który był trochę wyżej:

$ ldd program
    linux-vdso.so.1 (0x00007ffdd3bd0000)
    libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f49280f2000)
    /lib64/ld-linux-x86-64.so.2 (0x00007f49282df000)

... to na końcu widzimy właśnie, że ten program wymaga linkera dynamicznego (ld-linux). I ten linker ma w /etc/ld.so.conf konfigurację katalogów, w których domyślnie szuka bibliotek o zadanych nazwach.
Jeśli chcesz poczytać trochę o linkerze, najlepiej będzie zacząć od man 8 ld-linux, man dlopen, man dlsym, man dlclose.
Linker jest sprytny, umie śledzić kto aktualnie używa danej biblioteki, a dopóki nie zakończy się ostatni program referujący tę bibliotekę, to linker jej nie usuwa z pamięci. Jest to reference counting. Fragment ze starszej wersji man 3 dlclose link:

dlclose()

The function dlclose() decrements the reference count on the dynamic library handle "handle".
If the reference count drops to zero and no other loaded libraries use symbols in it,
then the dynamic library is unloaded. 

W skrócie: każde otwarcie (dlopen) zwiększa licznik o 1, a każde zamknięcie zmniejsza go o 1. Dopóki wszyscy użytkownicy biblioteki nie zamkną swoich uchwytów (dlclose()), kod biblioteki pozostaje w pamięci. Sam "reference counting" można zobrazować krótkim kodem w Python.

W ten sposób:

>>> import sys
>>> x = []
>>> sys.getrefcount(x)
2
>>> y = x
>>> sys.getrefcount(x)
3
>>> del x
>>> sys.getrefcount(y)
2

Lub w ten sposób:

>>> class MyLib:
...     def __init__(self): print("OPEN")
...     def __del__(self): print("CLOSE")
... 
>>> ja = MyLib()
OPEN
>>> ty = ja
>>> del ja
>>> del ty
CLOSE

Linkowanie statyczne

Programy możemy również linkować statycznie.
W tej chwili mój program (linkowany dynamicznie) ma rozmiar 16040 bajtów (16K). Jest mały, ponieważ większość kodu jest w tych zewnętrznych bibliotekach.

$ stat -c "%n: %s" program 
program: 16040

Ale mógłbym zażądać od kompilatora, aby podczas budowania programu, umieścił kod tego printf() bezpośrednio w pliku wynikowym (ten "ELF").

$ cc main.c -o program -static
$ ldd program 
    not a dynamic executable

Teraz mój program ma znacznie większy rozmiar: 783712 bajtów (766K).

$ stat -c "%n: %s" program 
program: 783712

Ma większy rozmiar, ponieważ wszystko co mogło być ładowane dopiero podczas uruchomienia (dynamicznie, w runtime), teraz jest wkomponowane w mój plik wykonywalny. W moim programie jest teraz na stałę kod funkcji printf(), a mój program uruchomi się bez względu na to, czy biblioteka libc będzie na dysku, czy nie.

Różnice, konsekwencje

Rozmiar, prędkość, cache

Różnicę już znamy - programy zlinkowane statycznie są niby wygodniejsze (nie mają zależności "runtime"), ale mają większy rozmiar. Jeśli program używa wielu takich funkcji i wszystkie dołączymy do pliku binarnego, to plik binarny będzie duży. Duże pliki wykonywalne mogą być wolniejsze. Spowodowane jest to tym, że kod to instrukcje, w samym pliku binarnym jest jeszcze sekcja danych, a procesor wykonuje większe skoki ("ma dalej"). Procesor ma cache i część tych instrukcji trzyma w tej szybkiej pamięci podręcznej, ale jeśli plik binarny jest duży i funkcje są daleko od siebie (adresy!), to to przeskakiwanie (call/jump) może skoczyć do adresu, którego nie ma w cache. Wtedy mamy tzw. "cache miss", procesor musi doładować instrukcje, do których wykonujemy skok, a później musi jeszcze wrócić (return). Dodatkowo - jeśli mamy wiele rdzeni procesora i współdzielony cache, to następuje wtedy synchronizacja pomiędzy rdzeniami (który z nich i co teraz może wykonywać, itd), a to wymaga wielu cykli procesora.

Aktualizacja bibliotek, kompatybilność

Jeśli program mamy zbudowany statycznie, to na stałę mamy w pliku binarnym te instrukcje, które zostały dołączone do pliku wykonywalnego.

Inaczej jednak jest w przypadku linkowania dynamicznego. Jeśli nasz program jedynie specyfikuje wymagania obecności konkretnych bibliotek (oraz ich wersji i zawartych w nich symboli), to biblioteki mogą się zmieniać (np. ulepszać) poza naszym programem. Dlatego w przypadku linkowania dynamicznego możemy korzystać z usprawnień/aktualizacji. Jeśli w bibliotece libc ktoś dostarczyłby lepszą funkcję printf() (np. szybszą), to mój program automatycznie skorzysta z tych usprawnień. Oczywiście ta nowsza, usprawniona wersja biblioteki musi mieć dla tej funkcji tę samą nazwę (i te same typy) - inaczej linker nie odnajdzie konkretnego symbolu. W symbolach, których program wymaga, mogą być zakodowane nazwa funkcji oraz identyfikatory typów argumentów, które funkcja ewentualnie przyjmuje. To nie są już "typy", a same ich oznaczenia, po których linker dany symbol odnajduje. Dobrze widać to w przypadku C++.
Przykład:

long double my_function(int x)
{
    return x;
}

long double my_function(int x, unsigned long y)
{
    return x + y;
}

long double my_function(int x, unsigned long y, double z)
{
    return x + y + z;
}

W kodzie są trzy funkcje o tej samej nazwie, ale różnych typach argumentów. Kompilujemy kod do pliku obiektów i wyświetlamy nazwy:

$ c++ -c hello.cxx 
$ nm hello.o 
0000000000000000 T _Z11my_functioni
000000000000000e T _Z11my_functionim
0000000000000020 T _Z11my_functionimd

Kompilator c++ wykonuje tzw. "name mangling" - dodaje takie ślaczki/maczki, dzięki którym wiadomo która funkcja jest którą:

  • pierwsza przyjmuje tylko int, zatem pierwszy obiekt ma na końcu w nazwie literkę "i": _Z11my_functioni
  • druga przyjmuje int oraz unsigned long, kompilator dodaje jeszcze "m": _Z11my_functionim
  • trzecia funkcja przyjmuje int, unsigned long, oraz double, zatem kompilator dodaje "d": _Z11my_functionimd

Tę możliwość definiowania funkcji o tych samych nazwach lecz z różnymi argumentami, nazywamy w C++ "przeciążaniem funkcji" (function overloading). W języku C tego mechanizmu nie ma, C nie realizuje "name mangling". Za to "name mangling" ma swoje konsekwencje - różne kompilatory mogą realizować to po swojemu (różnice w ABI /Application Binary Interface/). Oczywistym wnioskiem jest zatem to, iż linker może nie znaleźć symboli w bibliotece, która została skompilowana innym kompilatorem (a czasem kompilatorem tej samej rodziny, lecz w innej wersji), pomimo iż biblioteka w odpowiedniej wersji jest dostępna w systemie.

Dystrybucja oprogramowania

Stąd - wracając do systemów operacyjnych - wychodzą kwestie związania platformy z danym kompilatorem. Systemy Apple, niektóre BSD, oraz inne - przeszły w całości na kompilator Clang lub wspierają różne kompilacje (np. zarówno kompilatorem Clang, jak i GCC), Linux jest związany z GCC. Same dystrybucje Linux są kompilowane w całości kompilatorem w konkretnej wersji. W ramach wersji/gałęzi dystrybucji unika się zmian wersji kompilatora, czy głównych bibliotek standardowych (libc, libstdc++).

Stąd też podział w niektórych dystrybucjach na gałęzie stable/testing/unstable. "Stable" od czasu wydania ("release") do czasu zakończenia wsparcia ("end of life") ma ten sam kompilator bazowy i te same libc/libstdc++. Ot, "base system". Istnieją też inne implementacje biblioteki libc. W Linux znajdziemy głównie GNU Libc (glibc). Istnieją jednak alternatywy, które ze względu na architektury, specyficzne zastosowania, ograniczenia platformy, czy też prostszą/mniejszą implementację preferowane są np. w systemach wbudowanych i dystrybucjach celujących w mały rozmiar. Przykładem może być dystrybucja Alpine Linux, która opiera się na musl. Jest to chyba najpowszechniej wykorzystywana dystrybucja w świecie kontenerów Linux. Innym przykładem może być OpenWrt (dystrybucja na routery/urządzenia sieciowe), która wcześniej była oparta o uclibc, a obecnie przeszła również na musl. Programy skompilowane dynamicznie pod bibliotekę glibc nie będą działać z biblioteką musl-libc, czy inną implementacją libc.

Stąd można wyciągnąć kolejne wnioski. W dzisiejszym świecie popularność zdobywa np. język "go" (golang). Programy w nim tworzone są przeważnie budowane statycznie, co - kosztem rozmiaru - ułatwia ich dystrybucję. Programy są jednoplikowe, łatwe do wdrożenia. Golang jest popularny w dziedzinie "DevOps", gdyż programy w nim tworzone łatwo wrzucić w kontenery. Zawiera też dużo gotowych modułów wysokiego poziomu. Poza tym - napisano w nim część przydatnych narzędzi (np. Gitea). Napisano w nim również wiele narzędzi, które wydają się cierpieć na tzw. "endless feature creep" (np. Docker) - być może ze względu na stosunkową prostotę i łatwość użycia języka. Temat ten jednak tutaj urywam, a zainteresowanych odsyłam np. w to ciekawe miejsce.

Mini demo - statyczne vs dynamiczne linkowanie i kontenery

Przyjrzyjmy się zatem kwestii przytoczonej wyżej konteneryzacji. Nie będę tutaj używał Dockera, ani nawet cgroups, zdam się po prostu na najstarsze rozwiązanie sprzed epoki kontenerów - chroot.

Chroot: static

Tworzę nowy katalog i umieszczam w nim jedynie mój statycznie zbudowany program.

$ cc main.c -o program -static
$ ldd program 
    not a dynamic executable
$ mkdir my-chroot
$ cp -pv program my-chroot/
'program' -> 'my-chroot/program'
$ tree my-chroot/
my-chroot/
└── program

0 directories, 1 file
$ sudo chroot my-chroot /program
hello world!

Program się uruchomił, pomimo że w nowym drzewie katalogów, do którego ograniczony jest "chroot", nic nie ma oprócz pliku tego programu.
Jego uruchomienie nie wymaga zatem żadnych bibliotek ani plików urządzeń.

Chroot: dynamic

Sprawdzimy teraz to samo z wersją linkowaną dynamicznie.

$ cc main.c -o program
$ cp -pv program my-chroot/
'program' -> 'my-chroot/program'
$ sudo chroot my-chroot /program
chroot: failed to run command ‘/program’: No such file or directory

Nie udało się uruchomić programu. Spróbujmy dodać do katalogu "my-chroot" wymagane przez program biblioteki.

$ ldd program 
    linux-vdso.so.1 (0x00007fff709e9000)
    libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007fdefd3b6000)
    /lib64/ld-linux-x86-64.so.2 (0x00007fdefd5a3000)

$ mkdir -p my-chroot/lib64
$ mkdir -p my-chroot/lib/x86_64-linux-gnu/ 
$ cp -p /lib/x86_64-linux-gnu/libc.so.6 my-chroot/lib/x86_64-linux-gnu/
$ cp -p /lib64/ld-linux-x86-64.so.2 my-chroot/lib64

Uruchamiamy:

$ sudo chroot my-chroot /program
hello world!

Teraz działa, ponieważ wymagane biblioteki są we właściwych lokalizacjach.

$ tree my-chroot/
my-chroot/
├── lib
│   └── x86_64-linux-gnu
│       └── libc.so.6
├── lib64
│   └── ld-linux-x86-64.so.2
└── program

3 directories, 3 files
$ tar zcvf hello-world-container.tar.gz my-chroot
my-chroot/
my-chroot/lib/
my-chroot/lib/x86_64-linux-gnu/
my-chroot/lib/x86_64-linux-gnu/libc.so.6
my-chroot/program
my-chroot/lib64/
my-chroot/lib64/ld-linux-x86-64.so.2

Et voilà!

Debug

Programy budujemy w tzw. wersjach Release oraz Debug. Wersje release to te, które wydajemy, dostarczamy użytkownikom. Wersje debug budujemy wtedy, gdy wciąż pracujemy nad kodem i potrzebujemy metainformacji w celu użycia narzędzi śledzących/profilujących wykonanie programu.
Przykład: regularna kompilacja vs kompilacja debug (gdb), porównanie rozmiaru, różnica (metadane):

Rozmiar:

$ cc main.c -o program
$ stat -c "%n: %s" program
program: 16040

$ cc main.c -ggdb -o program.debug
$ stat -c "%n: %s" program.debug 
program.debug: 17000

Używam strings do wydobycia ciągów znakowych. Można użyć objdump, dla prostoty wskazuję jednak rożnice w krótkim listingu.

$ strings program > program.txt
$ strings program.debug > program.debug.txt

Porównuję:

$ diff program.txt program.debug.txt 
14a15,23
> long unsigned int
> short unsigned int
> short int
> unsigned char
> long int
> GNU C17 11.2.0 -mtune=generic -march=x86-64 -ggdb -fasynchronous-unwind-tables
> main
> main.c
> /empty
72a82,87
> .debug_aranges
> .debug_info
> .debug_abbrev
> .debug_line
> .debug_str
> .debug_line_str

Widzimy, że wersja debug posiada metadane o zmiennych, nazwach plików, liniach kodu, itp.

GDB: no debug

Uruchamiam w debuggerze (gdb) wersję zwykłą (na obrazku lepiej widać):

GDB informuje na wstępie, że ten program nie ma metadanych: No debugging symbols found in program.
Uruchomiłem program i dowiedzieliśmy się, że nie ma żadnych informacji o konkretnych liniach w kodzie:
Single stepping until exit from function main, which has no line number information.

GDB: debug

Uruchamiam wersję debug. Ustawiam taki sam breakpoint jak poprzednio, startuję program. Wykonanie zatrzymuje się w funkcji main() w pliku main.c na linii nr 5, gdzie mamy nasz printf("hello world!\n").

Wersja debug zawiera metadane na temat samej budowy programu, łącznie z kodem źródłowym. Obecność tych metadanych zazwyczaj znacznie zwiększa rozmiar pliku, a sam program jest wolniejszy.

Użytkownikom nie dostarczamy (zazwyczaj) wersji debug, gdyż metadane zdradzałyby różne rozwiązania technologiczne lub ewentualne dane poufne. Programy opensource, które nie mają niczego do ukrycia, i które znajdujemy w dystrybucjach Linux, posiadają odpowiednie pakiety *-dbg lub *-dbgsym w repozytoriach dystrybucji, z których możemy skorzystać po ich instalacji, bez potrzeby przebudowywania programów we własnym zakresie.

Strip

Wróćmy jeszcze do programu nm (names, który wyświetlał nam symbole zawarte w pliku wykonywalnym).
W przypadku naszego programu w wersji podstawowej (bez "debug info"), program nm listuje nam 34 symbole, a sam program ma rozmiar 16040 bajtów:

$ nm program | wc -l
34
$ stat -c "%n: %s" program
program: 16040

Spójrzmy teraz ponownie na wyjście programu file:

$ file program
program: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), ... not stripped

Ten fragment na końcu - not stripped - informuje nas, że same nazwy symboli są zapisane w pliku programu.
Jeśli ich nie potrzebujemy, możemy się ich pozbyć. Zmniejszymy tym samym rozmiar samego pliku wykonywalnego.

$ strip program
$ stat -c "%n: %s" program
program: 14408
$ nm program
nm: program: no symbols

Jak widzimy - program potraktowany komendą "strip" traci informacje o symbolach, jego rozmiar wynosi teraz niespełna 15K, a program "nm" informuje nas, że w pliku nie ma już żadnych symboli.

Jeśli popatrzymy na informacje o programie zbudowanym w trybie "debug", to zauważymy, że również zawiera symbole, a przy okazji zawiera debug_info.

$ file program.debug
program.debug: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), ..., with debug_info, not stripped

Jeśli go "zestripujemy", to pozbędziemy się tych wszystkich informacji.

$ strip program.debug
$ stat -c "%n: %s" program.debug
program.debug: 14408
$ nm program.debug
nm: program.debug: no symbols

Wygląda teraz prawie jak wersja "release". Rozmiar ma ten sam, nie posiada symboli, debugger żadnych symboli nie znajdzie.
Nie są to jednak identyczne pliki. Policzmy ich hashe, dla prostoty - md5:

$ md5sum program*
b0a80ea20bd8590fcd37660d3f1a7cb0  program
b0f306dcb639c385549f119e00ac6ec6  program.debug

Program strings również nie wskaże żadnych różnic w zawartych w programie danych. Jaka zatem może być różnica?
Prawdopodobnie w adresach poszczególnych instrukcji. Wersja debug zaweriała metadane, a zatem fizycznie plik miał inną organizacje wewnętrzną.
Tego już tutaj nie badam, podam jedynie, że ewentualne informacje można wydobyć programem objdump, który potrafi wyświetlać szczegółowo poszczególne sekcje plików wykonywalnych.
"Na upartego" możemy zrzucić wszystkie sekcje i sprawdzić różnice:

$ objdump -s program > A.txt
$ objdump -s program.debug > B.txt
$ diff A.txt B.txt
2c2
< program:     file format elf64-x86-64
---
> program.debug:     file format elf64-x86-64
9,10c9,10
<  02d4 0999712a 803ec1a6 3b6dd369 37dea5db  ..q*.>..;m.i7...
<  02e4 a5e76ff3                             ..o.
---
>  02d4 0b4ace18 c1e14d6c 779f6788 ed8daa7e  .J....Mlw.g....~
>  02e4 ba518ac7                             .Q..

Pomijając 2 pierwsze różnice - nazwy plików, które pochodzą z outputu programu objdump - widzimy, że faktycznie jest jakaś fizyczna różnica pomiędzy tymi plikami wynikowymi.

Debug runtime, runtime debug

Debugowanie i profilowanie programów to bardzo złożony temat. Jedyne co chciałbym na koniec dodać w tym temacie to to, że niektóre środowiska programistyczne zerują pamięć w trybie debug, przez co możemy nie być w stanie odtworzyć błędu, który zdarza się jedynie w realnych warunkach. Najpewniej powodem jest wtedy błędny odczyt pamięci. W trybie debug z błędnych obszarów program może czytać zera, a w trybie release "śmieci".

Podsumowanie

Być może ten konkretny artykuł w formie i treści nieco wykracza poza tytułowe pytanie "od czego zacząć?", ale kto wie... Każdy z nas ma inne cele i zainteresowania, a ostatecznie wszyscy używamy programów. Starałem się przedstawić zagadnienia prostym językiem, na minimalnych przykładach. Myślę zatem, że - jeśli ktoś odnalazł tutaj nowe informacje - wiedza przyda się zarówno w programowaniu, jak i w systemach operacyjnych na co dzień. W poprzedniej części napisałem, że rzeczywistym problemem ludzi, którzy się uczą, jest brak takich właśnie informacji. Zdarza mi się pomagać różnym osobom, które uczą się poprawnie, wszystko w kodzie robią dobrze, a jednak "stają przed ścianą", gdyż nie mają wystarczających informacji do wydedukowania na czym polega dany błąd.
W poprzednim artykule był fragment kodu:

#include <openssl/md5.h>
#include <string.h>

void main()
{
    const unsigned char *data = "hello world!\n";
    unsigned char result[MD5_DIGEST_LENGTH];
    MD5(data, strlen(data), result);
}

Podany tam błąd kompilacji "undefined reference" można załatwić jedną opcją: -lcrypto.
-lcrypto wskazuje linkerowi, aby przy budowaniu programu dołączył bibliotekę dynamiczną libcrypt.so.
Linker jest uruchamiany automatycznie przez kompilator, zatem opcję -lcrypto podajemy bezpośrednio kompilatorowi.

Nie zawsze jest to jednak tak proste. Zdarza się, że problem jest w narzędziach. Niedawno pomagałem komuś rozwiązać problem z prostym kodem sieciowym używającym API systemu Microsoft Windows. Narzędzie Visual Studio było w tym przypadku - z nieznanych mi przyczyn - w jakiś sposób uciążliwe, zatem słusznym pomysłem obejścia problemu była próba skompilowania kodu przy użyciu g++. Niestety, rzeczone api windows kod sieciowy umieszczony ma w bibliotece *.dll, a g++ na Windows (mingw) najwyraźniej nie potrafi linkować do tychże bibliotek *.dll. Tak blisko, a tak daleko... Undefined reference.

Pamiętaj również: nie możesz złączyć w jeden program rożnych plików obiektów jeśli więcej niż jeden z nich zawiera funkcję main(). Funkcja main() może być tylko jedna i musi być dostępna. Dlatego w języku Java widzimy: public static void main() {}.