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:

rc = main(argc, argv, environ);

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:

__attribute__((constructor)) static void initialize() 
{
 // do init stuff
}

__attribute__((destructor)) static void deinitialize() 
{
 // do destroy stuff
}

Oraz przykład:

#include <stdio.h>

__attribute__((constructor))  static  void helloWorld(void)
{
        printf("Hello world before main()\n");
}

__attribute__((destructor))  static  void goodbyeWorld(void)
{
        printf("Goodbye world after main()\n");
}

int main()
{
        printf("Just hanging around in main()\n");
}

Który spowoduje wypisanie:

Hello world before main()
Just hanging around in main()
Goodbye world after main()

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:

typedef void (*SayHelloFunc)(void);

typedef struct _SSayHelloHandler
{
	const char* language;
	SayHelloFunc fn;
} SSayHelloHandler;

void registerHandler(SSayHelloHandler* hn);

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:

#include <stdio.h>
#include "sayHello.h"

static SSayHelloHandler* languages[32];

static void sayHello(const char* language)
{
	SayHelloFunc fn = NULL;
	unsigned ii = 0;
	for (ii = 0; ii < 32; ++ii) {
		if (languages[ii] == NULL)
			break;
		if (strcmp(languages[ii]->language, language) == 0) {
			fn = languages[ii]->fn;
			break;	
		}
	}
	
	if (fn == NULL) 
		printf("Sorry, can't speak %s\n", language);
	else
	   fn();
}

void registerHandler(SSayHelloHandler* hn)
{
	unsigned ii = 0;
	for (ii = 0; ii < 32; ++ii) {
		if (languages[ii] == NULL)
		{
			languages[ii] = hn;
			break;
		}
	}
}

int main()
{
	sayHello("english");
	sayHello("german");
	sayHello("polish");	
}

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.

#include "sayHello.h"
#include "stdio.h"

static const char* lang = "polish";

static void say()
{
	printf("Czesc!\n");
}

static SSayHelloHandler hn;

__attribute__((constructor)) static void registerMyself(void)
{
	hn.language = lang;
	hn.fn = say;
	registerHandler(&hn);	
}
#include "sayHello.h"
#include "stdio.h"

static const char* lang = "english";

static void say()
{
	printf("Hello!\n");
}

static SSayHelloHandler hn;

__attribute__((constructor)) static void registerMyself(void)
{
	hn.language = lang;
	hn.fn = say;
	registerHandler(&hn);	
}
#include "sayHello.h"
#include "stdio.h"

static const char* lang = "german";

static void say()
{
	printf("Tschuss!\n");
}

static SSayHelloHandler hn;

__attribute__((constructor)) static void registerMyself(void)
{
	hn.language = lang;
	hn.fn = say;
	registerHandler(&hn);	
} 

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

$ gcc sayHello.o
$ ./a.out 
Sorry, can't speak english
Sorry, can't speak german
Sorry, can't speak polish
$ gcc sayHello.o german.o
$ ./a.out 
Sorry, can't speak english
Tschuss!
Sorry, can't speak polish
gs@gs-X750JB:~/dev/premain$ gcc sayHello.o german.o polish.o english.o
gs@gs-X750JB:~/dev/premain$ ./a.out 
Hello!
Tschuss!
Czesc!

 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:

#include <cstdio>

class Polite
{
  public:
    Polite() { printf("Good morning\n"); }
    ~Polite() { printf("Good night\n"); }
};

static Polite polite;

static int spanishInquisition(int num)
{
	printf("Nobody expects the Spanish Inquisition!!\n");
	return num + 1;
}

static int unexpectedDynamicInitialization = spanishInquisition(11);

int main()
{
  printf("Hello World\n");
}

I wynik:

$ ./a.out 
Good morning
Nobody expects the Spanish Inquisition!!
Hello World
Good night

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

static int unexpectedDynamicInitialization = spanishInquisition(11);

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:

_GLOBAL__sub_I_<nazwa_pliku.cpp>(void)
__static_initialization_and_destruction_0(int, int)

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++:

#include <cstdio>
#include <map>
#include <string>
#include "sayHello.hpp"

static std::map<std::string, SayHelloFunc>* funcMap = NULL;

static void sayHello(const std::string& lang)
{
	SayHelloFunc fn = NULL;
	
	if (funcMap != NULL) {
		std::map<std::string, SayHelloFunc>::iterator it = funcMap->find(lang);
		if (it != funcMap->end())
			fn = it->second; 
	}
	
	if (fn == NULL)
                printf("Sorry, can't speak %s\n", lang.c_str());
        else
           fn();
}

void registerHandler(const std::string& lang, SayHelloFunc fn)
{
	if (funcMap == NULL)
		funcMap = new std::map<std::string, SayHelloFunc>();
	(*funcMap)[lang] = fn;	
}

int main()
{
	sayHello(std::string("english"));
	sayHello(std::string("german"));
	sayHello(std::string("polish"));	
}

I obsługa języka:

#include "sayHello.hpp"
#include <cstdio>


static void say()
{
	printf("Tschuss!\n");
}

static int doRegister()
{
	registerHandler(std::string("german"), say);
	return 0;
}

static int dummy = doRegister();

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.

 

19 komentarzy do “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.

  4. Ciekawe jest to czemu GPT gdy prosimy go o pokazanie przykładu w sensie „proceduralny” „modulowy” to on pokazuje przy stylu modulowym wyrzucanie funkcji przed main(); a w proceduralnym umieszcza te same funkcje ale w main(); Zadne to modularne podejscie nie jest bo moduł to odzielny plik z którego korzysta program główny jak modul ISP lub UART. Zagadnienia podejscia do pisania kodu gdy wyrzucamy funkcje przed main(); jest łatwiejsze bo wiemy co każda funkcja robi i łatwo odszukać błędy ale te podejscia powinny miec swoje unikalne nazwy. Ktoś spotkał sie już z konkretnymi nawami tych dwóch podejsc do pisania kodu ?!

Dodaj komentarz

Twój adres e-mail nie zostanie opublikowany. Wymagane pola są oznaczone *