Laboratorium 2: „Biblioteki statyczne w języku C”

Transkrypt

Laboratorium 2: „Biblioteki statyczne w języku C”
Laboratorium 2:
„Biblioteki statyczne w języku C”
mgr inż. Arkadiusz Chrobot
16 listopada 2009
1
Wprowadzenie
Pisząc programy w języku C, szczególnie te duże, nie musimy umieszczać całości
kodu źródłowego w jednym pliku. Podobnie jak w innych językach programowania
możemy podzielić go na niezależne części, które mogą być kompilowane osobno
i w razie konieczności włączane do innych programów. Takie pliki nazywamy bibliotekami. W języku C z bibliotekami związanie są pliki nagłówkowe, które ułatwiają
korzystanie z nich. Ta instrukcja wyjaśnia w jaki sposób stworzyć własną bibliotekę
statyczną lub dynamiczną i jak jej użyć w innym programie.
2
Definicje i deklaracje zmiennych oraz funkcji
Aby móc użyć w programie zmiennej zdefiniowanej w innym pliku musimy posłużyć się jej deklaracją. Deklaracja zmiennej informuje kompilator, że zmienna
o podanej nazwie i typie została już wcześniej w kodzie zdefiniowana. Definicja
zmiennej informuje kompilator, że w miejscu jej umieszczenia trzeba przydzielić pamięć na tę zmienną i ją zainicjalizować. Każdy z plików z kodem źródłowym może
zawierać osobną deklarację jednej i tej samej zmiennej — nawet ten, który zawiera
jej definicję (przydaje się, jeśli chcemy użyć zmiennej przed jej zdefiniowaniem). Definicja zmiennej może występować tylko w jednym pliku. Aby zadeklarować zmienną
należy przed jej typem i nazwą umieścić słowo kluczowe extern. Definicja polega na
podaniu typu i nazwy zmiennej. Jeśli w żadnym z plików nie zadeklarujemy zmiennej, to jej definicja stanie się równocześnie jej deklaracją. Dotychczas w programach
używaliśmy właśnie takiej łącznej deklaracji i definicji. Odwrotna sytuacja, czyli
podanie deklaracji zmiennej bez definicji powoduje błąd czasu kompilacji. Poniżej
znajduje się przykładowy kod źródłowy, który został podzielony na dwa pliki.
external.c
int foo ;
prog1.c
#i n c l u d e <s t d i o . h>
extern int foo ;
i n t main ( v o i d )
{
p r i n t f ( ”%d\n” , f o o ) ;
return 0;
}
Czytając kod można zauważyć, że w pliku o nazwie „prog1.c” została użyta zmienna „foo”, która jest w tym pliku jest jedynie zadeklarowana. Jej definicja znajduje się w pliku „external.c”. Wartość początkowa tej zmiennej wynosi „0”, bo jest
to zmienna globalna. Jeśli chcielibyśmy w pliku „external.c” umieścić dodatkową
zmienną, widoczną tylko w tym pliku to przed jej definicją powinniśmy umieścić
słowo kluczowe static.
Podobnie jak ze zmiennymi możemy postąpić z funkcjami. Aby użyć funkcji
zdefiniowanej w innym pliku wystarczy umieścić jej nagłówek przed miejscem jej
1
wywołania, poprzedzając go słowem kluczowym extern. W nagłówku tym można
pominąć nazwy argumentów, zostawiając wyłącznie typy. Oto przykład:
external2.c
#i n c l u d e <s t d i o . h>
void print ( i n t v a r i a b l e )
{
p r i n t f ( ”%d\n” , v a r i a b l e ) ;
}
prog2.c
extern int foo ;
extern void print ( i n t ) ;
i n t main ( v o i d )
{
print ( foo ) ;
return 0;
}
Program zapisany w pliku „prog2.c” korzysta również ze zmiennej „foo” z pliku
„external.c” Proszę zwrócić uwagę, że tylko w pliku „external2.c” konieczne było
włączenie pliku nagłówkowego „stdio.h”.
Kompilacja tak napisanych programów składa się z kilku etapów. Najpierw musimy skompilować pliki, w których zdefinowane są funkcje i/lub zmienne. Tę kompilację należy wykonać tak jak pokazuje przykład 1. Opcja -c powoduje, że kompilator
Przykład 1
gcc -Wall -c library.c
utworzy plik obiektowy 1 , o takiej samej nazwie jak plik z kodem źródłowym, ale
o rozszerzeniu „.o”, który będzie można połączyć (ang. link ) z innym plikiem. Po
wykonaniu kompilacji wszystkich bibliotek możemy przystąpić do skompilowania
programu głównego. Kompilację tą wykonuje się podobnie jak w kolejnym zamieszczonym przykładzie 2.
Przykład 2
gcc -Wall -o program program.c library.o library2.o
3
Pliki nagłówkowe
Dzięki plikom nagłówkowym możemy zebrać w jednym miejscu deklaracje wszystkich zmiennych i funkcji, których definicję są rozproszone po wielu plikach. Włącza1 Ta
nazwa nie ma nic wspólnego z programowaniem zorientowanym obiektowo.
2
jąc odpowiednio przygotowany plik nagłówkowy do innych plików z kodem źródłowym unikamy konieczności wielokrotnego przepisywania deklaracji wspomnianych
elementów programu. Spróbujmy zastosować plik nagłówkowy w ostatnim przykładowym programie z poprzedniego rozdziału.
header.h
#i f n d e f HEADER H
#d e f i n e HEADER H
extern int foo ;
extern void print ( i n t ) ;
#e n d i f
external.c
#i n c l u d e ” h e a d e r . h”
int foo ;
external2.c
#i n c l u d e <s t d i o . h>
#i n c l u d e ” h e a d e r . h”
void print ( i n t v a r i a b l e )
{
p r i n t f ( ”%d\n” , v a r i a b l e ) ;
}
prog2.c
#i n c l u d e ” h e a d e r . h”
i n t main ( v o i d )
{
print ( foo ) ;
return 0;
}
Jak widać na załączonych listingach deklaracje zmiennej „foo” oraz funkcji „print”
zostały umieszczone w pliku nagłówkowym. Można również zauważyć, że te deklaracje otoczone są instrukcjami poprzedzonymi znakiem „#”. Są to instrukcje
preprocesora, które zapobiegają wielokrotnemu włączeniu wspomnianych deklaracji w wypadku gdybyśmy mieli do czynienia z kilkoma plikami nagłówkowymi, które
wzajemnie się włączają. Zasada działania tego tych instrukcji zwanych wartownikami pliku nagłówkowego zostanie wyjaśniona w następnej instrukcji. Zaważmy, że
stworzony przez nas plik nagłówkowy został włączony do wszystkich plików z rozszerzeniem „.c” za pomocą instrukcji include, ale nazwa tego pliku nie jest ujęta
w nawiasy trójkątne, tylko w cudzysłów. Informuje to kompilator, że powinien poszukać naszego pliku nagłówkowego w katalogu bieżącym. Moglibyśmy umieścić
3
nazwę naszego pliku w nawiasach trójkątnych, ale wówczas musielibyśmy dołączyć
katalog z naszym plikiem nagłówkowym do listy katalogów, które będą przeszukiwane przez kompilator (opcja -I ). Włączenie pliku nagłówkowego do pliku z kodem
źródłowym, gdzie definiowana jest zmienna lub funkcja w opisywanym przykładzie
jest opcjonalne, ale pozwala kompilatorowi na etapie kompilacji sprawdzić, czy nie
ma rozbieżności między definicją i deklaracją funkcji i zmiennej. W sytuacji gdy plik
nagłówkowy zawiera definicje typów lub makr preprocesora takie włączenie jest konieczne. Reasumując - zawsze opłaca się włączyć plik nagłówkowy do wszystkich
plików źródłowych z nim związanych. W pliku nagłówkowym powinniśmy unikać
umieszczania definicji zmiennych i funkcji, poprzestając tylko na deklaracjach. Słowo kluczowe extern przed nagłówkiem funkcji jest opcjonalne.
4
Program make
Kiedy mamy kod źródłowy programu podzielony na kilka plików (jednostek kompilacji) konieczne staje się kilkukrotne wywołanie kompilatora z linii poleceń. Taka
metoda kompilacji jest w przypadku dużych projektów zbyt czasochłonna. Rozwiązaniem tego problemu jest użycie narzędzia do kompilacji rozłącznej, jakim jest
program make. Wielokrotna kompilacja przy pomocy tego programu sprawdza się
wyłącznie do wypisania jego nazwy w linii poleceń i naciśnięcia klawisza „Enter”.
Zanim jednak tak się stanie należy przygotować specjalny plik o nazwie „Makefile”,
który będzie zawierał odpowiednie reguły dla takiej kompilacji. Oto plik „Makefile”
dla przykładu, który był omawiany w poprzednim rozdziale.
Makefile
CC = g c c
OFLAGS = −Wall −o
CFLAGS = −Wall −c
prog2 : prog2 . c e x t e r n a l 2 . o e x t e r n a l . o
$ (CC) $ (OFLAGS) prog2 prog2 . c e x t e r n a l . o e x t e r n a l 2 . o
external2 . o : external2 . c
$ (CC) $ (CFLAGS) e x t e r n a l 2 . c
external . o : external . c
$ (CC) $ (CFLAGS) e x t e r n a l . c
clean :
rm −f ∗˜
rm −f ∗ . o
rm −f ∗ . a
c l e a n a l l : clean
rm −f prog2
Na początku tego pliku znajdują się deklaracje zmiennych, które posłużą do zdefiniowania odpowiednich reguł. Zmiene te definowane są poprzez podanie ich nazwy
i nadanie im wartości za pomocą operatora „=”. W powyższym przykładzie tymi
4
wartościami są nazwa kompilatora oraz flagi kompilacji. Pierwsza reguła która zostanie zdefiniowana w pliku „Makefile” jest wykonywana domyślnie. W przykładowym
pliku ta reguła nazywa się prog2 i powoduje skompilowanie pliku zawierającego
funkcję main. Regułę definiuje się podając jej nazwę, która może (lecz nie musi)
być taka sama jak nazwa pliku powstającego w wyniku wykonania tej reguły. Po
nazwie stawiamy dwukropek. Po nim należy podać nazwy reguł, które muszą być
wykonane przed definiowaną regułą. Opcjonalnie można również podać nazwy plików z kodem źródłowym niezbędnym do wykonania reguły. Działania wykonywane
w ramach reguły definiowne są w następnych wierszach. W omawianym przykładzie jest to po prostu takie samo wywołanie kompilatora jakie do tej pory ręcznie
wpisywaliśmy w powłoce systemowej. Jedyna różnica polega na zastąpieniu nazwy
kompilatora i flag wartościami odpowiednich zmiennych. Wartości te uzyskujemy
umieszczając nazwę zmiennej w nawiasach okrągłych, a przed całością stawiając
znak dolara. Definicję poszczególnych reguł oddzielamy pustym wierszem. Po zdefiniowaniu pliku „Makefile” w katalogu z kodem źródłowym programu wystarczy
wydać w powłoce systemowej polecenie „make” żeby wykonać kompilację programu. Tak jak napisano to wcześniej zostanie wykonana reguła prog2 oraz wszystkie
inne od których ona zależy. Nie zostaną wykonane natomiast reguły clean i cleanall.
Pierwsza z tych reguł jest odpowiedzialna za usunięcie zbędnych plików powstałych
podczas kompilacji i edycji plików, a ostania za usunięcie wszystkich plików powstałych podczas kompilacji. Aby je wykonać należy w powłoce systemowej po słowie
„make” wpisać nazwę reguły, która ma być wykonana. Tak możemy postąpić z dowolną regułą zdefiniowaną w pliku „Makefile”. Możemy również stworzyć regułę,
która będzie kompilowała więcej niż jeden program. Wystarczy dla każdego z tych
programów stworzyć osobną regułę kompilacji, a następnie jako pierwszą w pliku
umieścić regułę która na liście zależności będzie miała wymienione te reguły. Reguła
ta może być pusta, tzn. po pierwszym wierszu będą dwa wiersze puste. Program
„make” porównuje czasy powstania plików wynikowych z czasami modyfikacji plików źródłowych. Jeśli plik wynikowy jest „starszy” od źródłowego, to nie wykonuje
związanej z nim reguły kompilacji. Jeśli jest odwrotnie lub jeśli plik wynikowy nie
istnieje to reguła jest wykonywana. Jeśli z jakiś powodów nie możemy użyć nazwy
„Makefile” to możemy użyć nazwy „makefile” lub dowolnej innej. W tym ostatnim
przypadku należy programowi „make” wskazać nazwę pliku z regułami, dodając do
jego wywołania przełącznik -f i podając nazwę tego pliku. Program „make” jest
dosyć dobrze zintegrowany z edytorem Vim. Możemy wywołać program „make”
z poziomu tego edytora. Wystarczy w trybie poleceń wydać polecenie :cmake. Jeśli podczas kompilacji pojawią się błędy to możemy przestawiać kursor do wiersza
w którym występują za pomocą poleceń :cnext i :cprev. Programu „make” możemy
używać również dla programów napisanych w innych językach programowania.
5
Kompresja bibliotek statycznych
Biblioteki statyczne są w całości włączane do kodu wynikowego kompilowanych z nimi programów, zwiększając tym samym objętość plików wynikowych.
Istnieją również biblioteki dynamiczne, które nie mają tej wady. Proces ich tworzenia jest opisany w następnym rozdziale. Biblioteki statyczne można archiwizować programem ar. Pliki z rozszerzeniem „.o” są pakowane do plików z rozszerzeniem „.a” i zastępują je podczas kompilacji. Oto przykład użycia programu „ar”:
ar rs archiwum.a external.o external2.o. Program używające skompresowanych bi-
5
bliotek można skompilować na dwa sposoby. Prostszy polega na umieszczeniu nazwy archiwum w wywołaniu kompilatora (przykład 3). Bardziej skomplikowany
Przykład 3
gcc -Wall -o program program.c archiwum.a
sposób wymaga odpowiedniej nazwy archiwum. Nie może być ona dowolna, musi
rozpoczynać się przedrostkiem lib. W przypadku naszego przykładowego archiwum
zamiast nazwy archiwum.a w wywołaniu programu ar należałoby więc umieścić
nazwę libarchiwum.a. Taką bibliotekę możemy włączyć do programu za pomocą
wywołania kompilatora przedstawionego w przykładzie 4: Uwaga: po opcjach -l, ani
Przykład 4
gcc -Wall -o program program.c -L. -larchiwum
-L nie występuje spacja! Opcja -l nakazuje włączenie konsolidatorowi do programu wynikowego bibliotek z archiwum o podanej nazwie. Konsolidator (ang. linker )
jest programem, który odpowiedzialny jest za łączenie plików obiektowych i tworzenie pliku wynikowego. Jest uruchamiany automatycznie podczas kompilacji, ale
może być wywołany również osobno. W polskiej literaturze często używa się jego
angielskiej nazwy. Jeśli archiwum znajduje się w domyślnym katalogu lub katalogach, które przeszukuje kompilator podczas pracy, to wystarczy użyć tylko opcji -l.
W przeciwnym przypadku należy określić także, który katalog dodatkowo kompilator powinien przeszukać, używając opcji -L. W przykładzie, po opcji -L występuje
kropka oznaczająca katalog bieżący. Drugi sposób kompilacji z zarchiwizowanymi
bibliotekami jest wygodniejszy, jeśli mamy do czynienia z wieloma bibliotekami,
które nie znajdują się w bieżącym katalogu.
6
Biblioteki dynamiczne
Biblioteki dynamiczne nazywane są w środowiskach uniksowych bibliotekami
współdzielonymi lub obiektami współdzielonymi i zapisywane są w plikach wykonywalnych posiadających rozszerzenie „.so”. Aby stworzyć taką bibliotekę należy najpierw skompilować pliki obiektowe wchodzące w skład tej biblioteki z opcją -fPIC
(przykład 5). W wyniku kompilacji powstają pliki obiektowe (z rozszerzeniem „.o”),
Przykład 5
gcc -Wall -c -fPIC external.c
które możemy połączyć w jedną bibliotekę odpowiednim wywołaniem kompilatora
(przykład 6). Tak powstałą bibliotekę można włączyć do programu na trzy sposoby.
Przykład 6
gcc -Wall -shared -fPIC -o libexternal.so external.o external2.o
Pierwszy polega na wskazaniu konsolidatorowi, w czasie jego pracy, gdzie znajduje
6
wymagana biblioteka. Możemy to uczynić za pomocą opcji -Wl,-rpath,katalog kompilatora gcc, gdzie katalog oznacza ścieżkę do katalogu z plikiem biblioteki współdzielonej (przykład 7). Drugi sposób polega na zdefiniowaniu zmiennej środowi-
Przykład 7
gcc -Wall -o prog2 prog2.c -L. -lexternal -Wl,-rpath,.
skowej LD LIBRARY PATH lub na zmianie jej wartości, tak aby uwzględniała
katalog, w którym znajduje się biblioteka współdzielona. Przykład 8 pokazuje jak to
zrobić używając powłoki Bash. Jeśli odpowiednio ustawimy wartość tej zmiennej, to
Przykład 8
export LD LIBRARY PATH=$LD LIBRARY PATH:.
w wywołaniu kompilatora z przykładu 7 możemy pominąć opcję -Wl,rpath. Trzeci
sposób polega na dodaniu odpowiedniego wpisu w pliku /etc/ld.so.conf i uruchomieniu programu ldconfig, ale ta metoda wymaga posiadania specjalnych uprawnień,
więc nie każdy użytkownik może z niej skorzystać. Jeśli chcemy dowiedzieć się z jakich bibliotek współdzielonych korzysta program, to możemy użyć programu ldd,
który może być wywołany na przykład tak: ldd program. Zaletą bibliotek współdzielonych jest to, że są ładowane do pamięci dopiero wtedy, gdy odwoła się do nich choć
jeden proces oraz to, że z jednego egzemplarza biblioteki znajdującego się w RAM
komputera może korzystać wiele procesów równocześnie. Biblioteki współdzielone
są również dostępne w systemach rodziny Windows, ale pod inną postacią.
7
Dynamiczne ładowanie bibliotek współdzielonych
Biblioteki dynamiczne nie muszą być łączone z programem podczas kompilacji.
Program może sam je załadować podczas wykonania. Takie rozwiązanie umożliwia
tworzenie wtyczek (ang. plug-ins). Funkcje, które są niezbędne do skorzystania z tej
możliwości nazywają się dlopen(), dlsym(), dlerror () i dlclose() i wymagają włączenia do kodu źródłowego programu pliku nagłółkowego dlfcn.h. Poniżej znajduje się
przykład programu, który korzysta z tej możliwości.
prog3.c
#i n c l u d e <d l f c n . h>
#i n c l u d e ” h e a d e r . h”
i n t main ( v o i d )
{
v o i d ∗ h a n d l e = d l o p e n ( ” l i b e x t e r n a l . s o ” ,RTLD LAZY ) ;
v o i d ( ∗ p r i n t ) ( i n t ) = dlsym ( handle , ” p r i n t ” ) ;
i n t ∗ f o o = dlsym ( handle , ” f o o ” ) ;
(∗ p r i n t )(∗ foo ) ;
d l c l o s e ( handle ) ;
return 0;
}
7
Funkcja dlopen() ładuje bibliotekę o podanej przez argument nazwie do pamięci
i zwraca wskaźnik na nią. Funkcja dlsym() zwraca wskaźnik adres symbolu z biblioteki, którego nazwę jej przekazujemy. Jeśli tym symbolem jest funkcja, to zwrócony
adres należy zapisać we wskaźniku na funkcję i za jego pomocą tę funkcję wywoływać. Funkcja dlclose() usuwa z pamięci załadowaną bibliotekę. Bezpośrednio po
każdym z wywołań tych funkcji należy (czego nie uczyniono w przykładzie) wywołać
funkcję dlerror (). Jeśli wystąpi wyjątek podczas wykonywania wcześniej opisanych
funkcji, to dlerror () zwróci jego opis, jesli nie, to natychmiast zakończy swoje działanie. Programy używające wyżej wymienionych funkcji należy kompilować z flagą
-ldl.
8