Przed i po main()

 
“Mr. Death, is there an after-life?” – Monty Python’s The Meaning of Life
Funkcja main() jest umownym punktem wejścia dla programów w języku C i C++. Jednak wykonanie programu wcale nie zaczyna się w pierwszej linijce maina i wcale nie kończy się za ostatnią linijką. Wiedza o tym, co dzieje się przed i po mainie w sumie nie jest programiście niezbędna jednak czasem można ją pomysłowo wykorzystać.

ELFy i CRT

Linuxowy loader systemowy to program, który w dużym uproszczeniu ma za zadanie otworzyć plik wykonywalny w formacie ELF, wyczytać pozycje części z kodem i części z danymi, rozlokować je w przestrzeni adresowej procesu, a następnie wskoczyć do programu, pod adres w pamięci określony w pliku ELF jako “entry point” (celowo pomijam proces ładowania bibliotek dynamicznych). I tym entry pointem bynajmniej nie jest main(). Tutaj z grubsza nie istnieje ścisła konwencja ale tak się jakoś składa, że pod adresem “entry point” znajduje się funkcja o nazwie “start”.
Cała przedmainowa inicjalizacja i pomainowe sprzątanie jest częścią C runtime library. Jest to kod domyślnie załączany przez linker do budowanego programu. Tenże linker, do naszego HelloWorld.o doklei jeszcze pliki obiektowe o charakterystycznych nazwach zaczynających się od “crt”, czyli prawdopodobnie crt0.o, crtbegin.o i crtend.o .
To właśnie w crt0 znajdziemy funkcję występującą w pliku ELF jako “entry point”, umówmy się, że będzie to start(). Także w crt0 znajdzie się charakterystyczna linijka kodu zawierająca charakterystyczne wywołanie:

crtbegin.o i crtend.o zawierają z kolei konstruktory i destruktory programu. Termin konstruktor i destruktor kojarzy się zwykle z językami obiektowymi jednak tutaj odnosi się akurat do języka C. W crtbegin.o znajduje się sekcja .init, a w niej funkcja (najczęściej) init(), która woła “konstruktory”. Znajduje się tam też funkcja .fini, a w niej funkcja (najczęściej) fini() wołająca “destruktory”.

Język C

W typowym “Hello world” napisanym w C nie mamy potrzeby użycia tego mechanizmu. Ba, z reguły użycia tego mechanizmu nie znajdziemy nawet w potężnych aplikacjach napisanych w C. Stosuje się go głównie do zainicjowania bibliotek ładowanych dynamicznie. Otóż linuxowa biblioteka dynamiczna nie ma swojego “maina”, nie ma więc gdzie przeprowadzić inicjalizacji w momencie załądowania biblioteki. Programiści w Microsofcie postanowili ułatwić innym programistom życie wprowadzając do dll-ek funkcję DllMain, jednak w domenie unixowej takiego wynalazku nie ma. Zamiast tego kompilatory udostępniają mechanizmy wstrzykiwania funkcji do tablic konstruktorów i destruktorów – w przypadku gcc wygląda to następująco:

Oraz przykład:

Który spowoduje wypisanie:

Mój gcc widząc atrybut constructor/destructor umieszcza adresy tych funkcji odpowiednio w sekcjach .init_array i .fini_array dzięki czemu linker, w procesie relokacji, sklejając ze sobą sekcje utworzy pełne tablice .init_array i .fini_array z fragmentów sekcji pochodzących z poszczególnych plików obiektowych. Funkcje w obu tablicach przyjmują i zwracają void z oczywistych względów. Po tych tablicach pląsają funkcje (wwywołując kon- i destruktory)  init() i odpowiednio fini() wołane gdzieś pomiędzy “entry pointem” ( _start() ) programu a wywołaniem maina oraz odpowiednio pomiędzy wyjściem z maina i wywołaniem _exit().

Do czego można te mechanizmy wykorzystać?

Oprócz wspomnianej wcześniej szeroko stosowanej inicjalizacji przy ładowaniu dynamicznych bibliotek ja używam tego mechanizmu aby zmniejszyć zależności między modułami aplikacji w przypadku, kiedy o zestawie funkcjonalności danej aplikacji chcę decydować na etapie budowania, a chcę jednocześnie uniknąć wielu wariantów kompilacji i piekiełka związanego z  nadmierną liczbą #ifdef’ów.

A poniżej przykład aplikacji, która wita się w kilku językach. Każde powitanie można dowolnie dokompilować do aplikacji, można też nie włączać żadnego. Zmiana funkcjonalności aplikacji odbywa się wyłącznie poprzez wybór plików które zostaną przekazane do linkera. Taka architektura “pluginowa”. Najpierw nagłówek:

SayHelloFunc to funkcja wypisująca ‘Hello’ w danym języku, SSayHelloHandler to struktura, mapująca nazwę języka na odpowiednią funkcję, typowa asocjacja.
Funkcja registerHandler pozwala zarejestrować dla danej nazwy języka funkcję wypisującą powitanie. Teraz kod:

languages jest tablicą zarejestrowanych wskaźników do handlerów. Ponieważ jest globalna, więc jest gwarancja, że będzie ona zainicjalizowana zerami.
Funkcja sayHello ma za zadanie wypisać powitanie w danym języku, przekazanym jako argument, więc iteruje po tablicy handlerów w poszukiwaniu takiego, który obsługuje dany język. Jeśli znajdzie, wówczas wołana jest przypisana do tego handlera funkcja. Jeśli nie, program uprzejmie przeprasza 😉

Ale najważniejszą z funkcji jest registerHandler, bo to ona umożliwia dołączanie poszczególnych handlerów. I oto, gdyby nie możliwość wywołania funkcji poza mainem, żeby obsługiwać w programie różne zestawy języków musiałoby istnieć kilka różnych implementacji funkcji main(), każda z nich inicjalizowałaby inny zestaw. Można by też załatwić sprawę mechanizmem warunkowij kompilacji przez #ifdef ale w miarę wzrostu liczby funkcjonalności (w tym przypadku języków) kod byłby coraz trudniejszy do opanowania.

Teraz przyszła kolej na wstrzyknięcie kilku funkcji do  .init_array, czyli rejestrację funkcji do obsługi poszczególnych języków.

I teraz, po skompilowaniu wszystkich plików możemy dowolnie zdecydować, jaki zestaw funkcji powinien obsługiwać nasz program:

 Język C++

W c++ nie ma aż tyle zachodu, przede wszystkim dlatego, że inicjalizacja statyczna obiektów jest jedną z podstawowych funkcjonalności tego języka. Tutaj, niezależnie od kompilatora na porządku dziennym są takie oto sztuczki:

I wynik:

Warto wspomnieć, że niewinnie wyglądające wywołanie

jest błędne w języku C.

Tutaj kompilator ma nieco więcej pracy. Funkcje wołane podczas inicjalizacji i deinicjalizacji w tym konkretnym przypadku to konstruktor klasy polite, który mógłby mieć jakieś argumenty (w tym wypadku wprawdzie ich nie ma, ale warto pamiętać o niejawnym argumencie – this), destruktor tejże klasy (który też przyjmuje this), a także funkcja przyjmująca i zwracająca int. Nie można tego przypadku załatwić wrzucając pointery do funkcji do tablicy .init_array . Dlatego kompilator dla każdego pliku źrodłowego, w pliku obiektu umieści specjalną funkcję zawierającą pełen, czasem dość obszerny, kod inicjalizacji. W przypadku gcc jest to para funkcji:

Przy czym pierwsza woła drugą. W __static_initialization_and_destruction_0 znajduje się kod wołający konstruktor obiektu polite klasy Polite, wołana jest funkcja __cxa_atexit(destruktor, this) rejestrująca destruktor klasy Polite z this’em statycznego obiektu polite, która zostanie wywołana w momencie wywołania exit(), jest tu jeszcze kod wywołujący spanishInquisition(11) i przypisujący jego wartość do zmiennej. Pełna automatyzacja.
Dopiero funkcja _GLOBAL__sub_I_<nazwa_pliku.cpp> wstrzykiwana jest do “tablicy konstruktorów” .init_array  znanej z poprzedniego przykładu.

Przykład w języku C można teraz dość łatwo przepisać na C++:

I obsługa języka:

Pewnie Was zastanowi dlaczego funcMap jest pointerem a nie statyczną mapą. Jest to związane z problemem znanym jako “static initialization order fiasco”. O tym, a także o wykorzystaniu narzędzi objdump, nm i gdb do podglądania tego co dzieje się poza mainem opowiem w innym poście. Na koniec jeszcze jedna porada – jeśli wasz program w C++ działa ogólnie bez zarzutu ale crashuje się w momencie wyjścia i jest to jedyny jego problem to prawdopodobnie crash występuje właśnie w którymś ze statycznych destruktorów wołanych w ramach mechanizmu atexit.

 

18 przemyśleń nt. „Przed i po main()

  1. Coś mi niektóre konstrukcje tutaj przypominają 🙂 Ale fajna, doglębna dość analiza. Pomogło mi to też w innej sprawie… a co będzie jak zawołamy exit() (wychodząc z main(), albo po prostu, “z palca”) w środowisku wielowątkowym, jak inne wątki wciąż działają i korzystają z globalnych struktur danych? Podpowiem: BUUUMMM!

      1. Eee tam… To pikuś. Najwyżej będzie deadlock w innym wątku, jak ubijany wątek miał akurat zamknięty mutex. A przy exicie (kocham te polskie oboczności) możesz jeszcze jakiegoś SIGSEGVa zaliczyć albo co 🙂

        Chociaż, po zastanaowieniu, to nie wiem co gorsze 🙂

  2. Fajny artykuł. Szkoda że nie doczekałem się:

    “o wykorzystaniu narzędzi objdump, nm i gdb do podglądania tego co dzieje się poza mainem opowiem w innym poście.”

      1. 🙂

        Trafiłem tutaj bo walczę z dziwnym problemem gdzie przy przekroczeniu pewnej wielkości binarki leci mi SEGFAULT przed main’em. Sprawa dzieje się na starym systemie embedded z linuxem 2.4. Podejrzewam kolejność statycznej inicjalizacji ale może to być również coś innego, gdb przekłamuje i nie mogę dociec na czym program leci.

        1. Mnie się zdarzyły zarówno problemy przy kolejności inicjalizacji, jak też bardziej kuriozalne, gdy w dwóch plikach była globalna zmienna o tej samej nazwie, lecz innym typie, bez statica – czyli extern. Objawem był również segfault przed mainem.
          Może dałoby się zerknąć za pomocą Valgrinda? O ile są do tego zasoby.

          1. a jest na to jakieś ograniczenie?

            Napisałem mały program który statycznie alokuje duży obszar pamięci i otrzymałem dużą binarkę (ponad 10mb). Program startuje.

            W binarka programu z którym walczę przy rozmiarze ok. 9.8mb przestaje się uruchamiać – SEGFAULT. Wystarczy że optymalizację zmienię na Os i rozmiar zejdzie do ok. 9mb i binarka działa – podobny efekt gdy wywalę część kodu aby zejść poniżej tej magicznej granicy.

            Próbowałem gdbem ale nic sensownego nie pokazuje – tj. pokazuje zwalony stos lub miejsca które nie mogą powodować segfaulta. Valgrinda póki co nie udało mi się skompilować dla tej platformy ale nawet jak to zrobię będzie ciężko bo to platforma z małą ilością ramu (128mb).

          2. A możesz spróbować z alokowaniem dynamicznym pamięci ze sterty? To co opisujesz z SEGV to częsty problem w potyczkach algorytmicznych gdy rozmiar stosu jest ograniczony i trzeba uciekać w obcinanie statycznych danych.

          3. > A możesz spróbować z alokowaniem dynamicznym pamięci ze sterty?

            Nie do końca rozumiem. Myślisz że malloc dużego obszaru pamięci może wywołać segfaulta? To dlaczego zmiana optymalizacji (przy tym samym kodzie) ratuje sytuację.

            > potyczkach algorytmicznych gdy rozmiar stosu jest ograniczony i trzeba uciekać w obcinanie statycznych danych

            Brzmi sensownie ale… myślę że najpierw powinienem zreprodukować problem na jakimś małym programie. Napisać program celowo tak aby doprowadzić do tej sytuacji – dopiero potem szukać obejścia/rozwiązania. Problemem jest tylko stworzenie takiego programu – jak on powinien wyglądać?

          4. #include

            int hugebuffer [/*bardzo dużo..*/ 2 << 32 /*albo i więcej */];

            int main () {
            // coś zrobię żeby nie zoptymalizowało
            return hugebuffer[rand ()]; }

            powinno załatwić sprawę – ale to na czuja bo jestem z daleka od kompa teraz. Jeśli masz możliwość to napisz jak się zachowuje.

          5. Mnie się kiedyś udało z Valgrindem w podobnych warunkach tyle, że zamontowałem swapa na pendrivie usb.
            To jest rzeczywiście ciekawy przypadek. Jeśli GDB w trace pokazuje miejsca które nie mogą powodować segfaulta to często jest to wynikiem pląsania po stercie (memory corruption), bo pokazuje nie tego co zepsuł, a tego, komu się zapsuło.
            Czy program jest wielowątkowy?

          6. saddam: Tak właśnie zrobiłem i nie powoduje to segfaulta na starcie.

            GS: Też kombinowałem ze swapem aby w ogóle gdb z symbolami debugowymi puścić. Jest wielowątkowy ale nie ma to znaczenia bo wątki tworzone są w kodzie a program nawet do main’a nie dociera (ani wg gdb ani wg printfa w main’ie).

  3. Bardzo ciekawe rozważania nad traktowaną najbardziej po macoszemu w programowaniu funkcją. PS. Może warto pokusić się o rozwój bloga o część dla koderów? Pozdrawiam

    1. Dzięki,
      Jak widać na razie mam problem z regularnym postowaniem na bloga. Może niedługo znajdę wystarczająco dużo czasu żeby to zmienić. Wcześniej nie wiedziałem, że wytworzenie jednego wpisu na bloga może zajmować nawet kilka godzin.

Dodaj komentarz

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