Materiał do tych i kolejnych ćwiczeń zakłada, że umiesz swobodnie pisać programy w języku ANSI C. Jeśli nie czujesz się w tym zbyt pewnie, powtórz sobie materiał z zajęć z Podstaw programowania lub poczytaj literaturę we własnym zakresie.
Proces kompilacji programu w języku C
Proces kompilacji programu w języku C składa się zwykle z czterech etapów:[1]
- Prekompilacja (preprocessing). W czasie tego etapu przetwarzane są instrukcje preprocesora związane z dołączaniem plików, kompilacją warunkową czy makrami.
- Kompilacja (compilation). W czasie tego etapu kod przetworzony przez preprocesor jest przekształcany na kod źródłowy w języku assemblera.
- Assemblacja (assembly). W czasie tego etapu tworzone są pliki obiektów, zbudowane z przetworzonego na język maszynowy kodu assemblera.
- Linkowanie/konsolidacja (linking). W czasie tego etapu pliki obiektów i bibliotek łączone są tak, że powstaje jeden wykonywalny plik, w którym odwołania do elementów zdefiniowanych w innych plikach obiektów mogą być realizowane.
W praktyce, wiele funkcji jest wspólnych dla różnych programów (rozważmy chociażby funkcję printf z biblioteki standardowej). Na szczęście współczesne systemy dobrze radzą sobie z tzw. dynamicznym linkowaniem, którego idea polega na tym, że niektóre obiekty nie są dołączane do programu w procesie konsolidacji (statyczne linkowanie), lecz dopiero w czasie jego uruchamiania.
[1] Zwięzłe, ale szczegółowe wyjaśnienie zagadnień związanych z kompilacją można znaleźć choćby na stronie http://www.tenouk.com/ModuleW.html.
W przeszłości do kompilowania programów w języku C wykorzystywano uniksowy kompilator języka C o nazwie cc. Dziś jednak o wiele większą popularność zdobywa pakiet gcc (ang. GNU Compiler Collection), umożliwiający kompilację programów w różnych językach. Z niego będziemy korzystać w czasie tych i kolejnych ćwiczeń. Okazuje się, że w większości instalacji systemu GNU/Linux polecenia cc i gcc odwołują się do tego samego programu. Warto jednak zdawać sobie sprawę z historii polecenia cc.
Utwórz plik źródłowy program.c o treści:
#include <stdio.h>
main(){
printf("Hello World\n");
}
Skompiluj program, wykonując polecenie
$ gcc program.c
Następnie uruchom go, wykonując polecenie
$ ./a.out
Dlaczego ostatnie z poleceń rozpoczyna się od ciągu ./?
Sprawdź co się stanie, jeśli nie dołączysz do pliku źródłowego deklaracji zawartych w nagłówku stdio.h. Czy wówczas program się skompiluje?
Korzystając z opcji -o programu gcc, zapisz efekt działania kompilatora do pliku o nazwie aplikacja.
W przypadku języka C przyjęło się, że:
- kod źródłowy programu przechowuje się w pliku o rozszerzeniu
.c, - przetworzony przez preprocesor kod przechowuje się w pliku o rozszerzeniu
.i, - kod assemblera przechowuje się w pliku o rozszerzeniu
.s, - plik obiektu ma rozszerzenie
.o.
Sprawdź efekt działania kompilatora gcc w poszczególnych etapach kompilacji. W tym celu użyj:
- opcji
-c, aby zakończyć działanie kompilatora na procesie assemblacji, - opcji
-S, aby zakończyć działanie kompilatora na procesie kompilacji, - opcji
-E, aby zakończyć działanie kompilatora na procesie prekompilacji.
Jakie są domyślne nazwy plików, do których kompilator zapisuje efekt swojej pracy w poszczególnych przypadkach? Sprawdź, czy da się wpłynąć na te nazwy korzystając z opcji -o. Czy zawsze kompilator zapisuje dane do pliku? Jeśli nie, to jak sobie z tym poradzić?
W przypadku opcji -E kompilator wypisuje dane na wyjściu. Można je przekierować do odpowiedniego pliku standardowym przekierowaniem strumienia danych. W przypadku skorzystania z opcji --save-temps efekty poszczególnych etapów kompilacji są zachowywane.
Usuń wszystkie pliki związane z kompilacją pliku program.c, poza nim samym. Następnie skompiluj plik program.c, wywołując program gcc z opcją --save-temps. Skomentuj efekty.
Utwórz pliki hw.c, gb.c oraz app.c o treściach, kolejno:
#include <stdio.h>
int helloworld(){
printf("Hello World\n");
return 0;
}
#include <stdio.h>
int goodbye(){
printf("Good bye\n");
return 0;
}
int helloworld();
int goodbye();
int main(){
helloworld();
goodbye();
return 0;
}
Następnie wykonaj polecenia:
$ gcc app.c
$ gcc -c app.c
$ gcc -c hw.c
$ gcc hw.o gb.c app.o
Które z poleceń nie zadziałały? Dlaczego? Zastanów się nad znaczeniem pozostałych.
Pierwsze polecenie zakończyło się niepoprawnie. Kompilator nie potrafił znaleźć definicji funkcji helloworld oraz goodbye.
Dołączanie bibliotek
W czasie pisania programu możemy korzystać z funkcji pochodzących z pewnych bibliotek, takich jak biblioteka standardowa (libc) czy biblioteka funkcji matematycznych (libm). Wymaga to dołączenia odpowiednich plików nagłówkowych (zwykle z rozszerzeniem .h), zawierających między innymi deklaracje funkcji bibliotecznych.[1]
Aby proces konsolidacji zakończył się powodzeniem, konieczne jest także poinformowanie kompilatora o tym, jakie biblioteki mają brać w nim udział. Kompilator gcc, o ile nie zdecydowano inaczej, zawsze dołącza w procesie konsolidacji bibliotekę libc, zawierającą definicje funkcji zadeklarowanych w wielu plikach nagłówkowych, np. stdlib.h oraz stdio.h.
Same biblioteki dzielimy jednak w ogólności na statyczne i dynamiczne. W przypadku tych pierwszych, wykorzystywane funkcje są dołączane na stałe do pliku wynikowego. Biblioteki dynamiczne umożliwiają współdzielenie swoich zasobów pomiędzy różnymi procesami, a tym samym zapewniają lepsze zarządzanie pamięcią. System operacyjny dołącza wówczas wymagane definicje do programu w momencie jego uruchamiania. Przyjęło się, że biblioteki statyczne są plikami o rozszerzeniu .a, a dynamiczne .so.
[1] Należy ostrożnie obchodzić się z pojęciami deklaracji i definicji zmiennych i funkcji. Pliki nagłówkowe zawierają deklaracje funkcji (a czasem też stałych), zaś biblioteki ich definicje.
Sprójrz na Ćwiczenie 6.1. Odwoływaliśmy się w nim do funkcji printf, a jednak nigdzie jej nie definiowaliśmy. Gdzie została zadeklarowana? Gdzie była zdefiniowana? Dlaczego udało się skompilować program bez dołączania dodatkowych bibliotek?
Aby dołączyć bibliotekę do programu, należy skorzystać z opcji -l programu gcc. Opcję tę można wywołać wielokrotnie, wskazując każdorazowo nazwę jednej biblioteki (z pominięciem przedrostka lib). Przyjęło się, że jeśli nazwa biblioteki jest jednoznakowa, to umieszcza się ją za opcją -l bez dodatkowego znaku odstępu, tworząc np. zbitkę -lm (dla biblioteki libm). Program gcc poprawnie interpretuje takie wywołania.
Utwórz plik źródłowy sin.c o następującej treści:
#include <stdio.h>
#include <math.h> // plik nagłówkowy math.h deklaruje stałe oraz
// funkcje zdefiniowane w bibliotece
// matematycznej (libm)
main(){
int x = 5;
printf("sin(%d)=%f\n", x, sin(x));
}
Spróbuj go skompilować, wykonując polecenia:
$ gcc -o sin sin.c
$ gcc -l m -o sin sin.c
$ gcc -o sin sin.c -l m
$ gcc -o sin sin.c -lm
Skomentuj efekty.
Korzystając z programu ldd sprawdź, z jakich bibliotek dynamicznych korzysta skompilowany przed chwilą program.
Wypełnij plik hellobye.c treścią:
#include <stdio.h>
int helloworld(){
printf("Hello World\n");
return 0;
}
int goodbye(){
printf("Good bye\n");
return 0;
}
Aby utworzyć z niego bibliotekę dynamiczną, wykonaj następujące polecenia:
$ gcc -c -fPIC hellobye.c
$ gcc -shared -o libhellobye.so hellobye.o
Utwórz plik hello.c o treści:
int helloworld();
int goodbye();
int main(){
helloworld();
goodbye();
return 0;
}
i skompiluj go z wykorzystaniem utworzonej biblioteki dynamicznej libhellobye.so, wykonując polecenie:
$ gcc -L . hello.c -l hellobye
Za co odpowiada opcja -L? Dlaczego program nie uruchomi się? Zmodyfikuj wartość zmiennej systemowej LD_LIBRARY_PATH w taki sposób, aby możliwe było uruchomienie programu. Usuń plik biblioteki i sprawdź, czy program dalej będzie działać.
Program nie uruchomi się, bo poszukuje bibliotek dynamicznych w określonych katalogach. Katalog bieżący nie należy do tego zbioru. Aby możliwe było przeszukiwanie lokalnego katalogu, należy ustawić wartość zmiennej LD_LIBRARY_PATH na .. Zmienna ta pozwala ustalić, oddzielone przecinkami, ścieżki, które powinny być przeszukiwane przed standardowymi lokalizacjami. Opcja fPIC (ang. Position Independent Code) programu gcc wprowadza odpowiednie zarządzanie pamięcią.