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ą.