C/Więcej o kompilowaniu

Z Wikibooks, biblioteki wolnych podręczników.
< C
Skocz do: nawigacja, szukaj

Ciekawe opcje kompilatora GCC[edytuj]

  • -E - powoduje wygenerowanie kodu programu ze zmianami, wprowadzonymi przez preprocesor
  • -S - zamiana kodu w języku C na kod asemblera (komenda: gcc -S plik.c spowoduje utworzenie pliku o nazwie plik.s, w którym znajdzie się kod asemblera)
  • -c - kompilacja bez łączenia z bibliotekami
  • -Ikatalog - ustawienie domyślnego katalogu z plikami nagłówkowymi na katalog
  • -lbiblioteka - wymusza łączenie programu z podaną biblioteką (np. -lGL)

Program make[edytuj]

Dość często może się zdarzyć, że nasz program składa się z kilku plików źródłowych. Jeśli tych plików jest mało (np. 3-5) możemy jeszcze próbować ręcznie kompilować każdy z nich. Jednak jeśli tych plików jest dużo, lub chcemy pokazać nasz program innym użytkownikom musimy stworzyć elegancki sposób kompilacji naszego programu. Właśnie po to, aby zautomatyzować proces kompilacji powstał program make. Program make analizuje pliki Makefile i na ich podstawie wykonuje określone czynności.

Budowa pliku Makefile[edytuj]

Uwaga! Uwaga!
Poniżej został omówiony Makefile dla GNU Make. Istnieją inne programy make i mogą używać innej składni. Na Wikibooks został też obszernie opisany program make firmy Borland


Najważniejszym elementem pliku Makefile są zależności oraz reguły przetwarzania. Zależności polegają na tym, że np. jeśli nasz program ma być zbudowany z 4 plików, to najpierw należy skompilować każdy z tych 4 plików, a dopiero później połączyć je w jeden cały program. Zatem zależności określają kolejność wykonywanych czynności. Natomiast reguły określają jak skompilować dany plik. Zależności tworzy się tak:

co: od_czego
  reguły...

Dzięki temu program make zna już kolejność wykonywanych działań oraz czynności, jakie ma wykonać. Aby zbudować "co" należy wykonać polecenie: make co. Pierwsza reguła w pliku Makefile jest regułą domyślną. Jeśli wydamy polecenie make bez parametrów, zostanie zbudowana właśnie reguła domyślna. Tak więc dobrze jest jako pierwszą regułę wstawić regułę budującą końcowy plik wykonywalny; zwyczajowo regułę tą nazywa się all.

Należy pamiętać, by sekcji "co" nie wcinać, natomiast "reguły" wcinać tabulatorem. Część "od_czego" może być pusta.


Plik Makefile umożliwia też definiowanie pewnych zmiennych. Nie trzeba tutaj się już troszczyć o typ zmiennej, wystarczy napisać:

nazwa_zmiennej = wartość

W ten sposób możemy zadeklarować dowolnie dużo zmiennych. Zmienne mogą być różne - nazwa kompilatora, jego parametry i wiele innych. Zmiennej używamy w następujący sposób: $(nazwa_zmiennej).

Komentarze w pliku Makefile tworzymy zaczynając linię od znaku hash (#).

Przykładowy plik Makefile[edytuj]

Dość tej teorii, teraz zajmiemy się działającym przykładem. Załóżmy, że nasz przykładowy program nazywa się test oraz składa się z czterech plików:

pierwszy.c
drugi.c
trzeci.c
czwarty.c

Odpowiedni plik Makefile powinien wyglądać mniej więcej tak:

# Mój plik makefile - wpisz 'make all' aby skompilować cały program
# (właściwie wystarczy wpisać 'make' - all jest domyślny jako pierwszy cel)
CC = gcc  

all: pierwszy.o drugi.o trzeci.o czwarty.o
  $(CC) pierwszy.o drugi.o trzeci.o czwarty.o -o test

pierwszy.o: pierwszy.c
  $(CC) pierwszy.c -c -o pierwszy.o

drugi.o: drugi.c
  $(CC) drugi.c -c -o drugi.o

trzeci.o: trzeci.c
  $(CC) trzeci.c -c -o trzeci.o

czwarty.o: czwarty.c
  $(CC) czwarty.c -c -o czwarty.o

Widzimy, że nasz program zależy od 4 plików z rozszerzeniem .o (pierwszy.o itd.), potem każdy z tych plików zależy od plików .c, które program make skompiluje w pierwszej kolejności, a następnie połączy w jeden program (test). Nazwę kompilatora zapisaliśmy jako zmienną, ponieważ powtarza się i zmienna jest sposobem, by zmienić ją wszędzie za jednym zamachem.

Zatem jak widać używanie pliku Makefile jest bardzo proste. Warto na koniec naszego przykładu dodać regułę, która wyczyści katalog z plików .o:

clean:
  rm -f *.o test

Ta reguła spowoduje usunięcie wszystkich plików .o oraz naszego programu jeśli napiszemy make clean.

Możemy też ukryć wykonywane komendy albo dopisać własny opis czynności:

clean:
  @echo Usuwam gotowe pliki
  @rm -f *.o test

Ten sam plik Makefile mógłby wyglądać inaczej:

CFLAGS = -g -O # tutaj można dodawać inne flagi kompilatora
LIBS = -lm # tutaj można dodawać biblioteki

OBJ =\
    pierwszy.o \
    drugi.o \
    trzeci.o \
    czwarty.o

all: main

clean:
       rm -f *.o test
.c.o:
       $(CC) -c $(INCLUDES) $(CFLAGS) $<

main: $(OBJ)
       $(CC) $(OBJ) $(LIBS) -o test


Tak naprawdę jest to dopiero bardzo podstawowe wprowadzenie do używania programu make, jednak jest ono wystarczające, byś zaczął z niego korzystać. Wyczerpujące omówienie całego programu niestety przekracza zakres tego podręcznika.

Optymalizacje[edytuj]

Kompilator GCC umożliwia generację kodu zoptymalizowanego dla konkretnej architektury. Służą do tego opcje -march= i -mtune=. Stopień optymalizacji ustalamy za pomocą opcji -Ox, gdzie x jest numerem stopnia optymalizacji (od 1 do 3). Możliwe jest też użycie opcji -Os, która powoduje generowanie kodu o jak najmniejszym rozmiarze. Aby skompilować dany plik z optymalizacjami dla procesora Athlon XP, należy napisać tak:

gcc program.c -o program -march=athlon-xp -O3

Z optymalizacjami należy uważać, gdyż często zdarza się, że kod skompilowany bez optymalizacji działa zupełnie inaczej, niż ten, który został skompilowany z optymalizacjami.

Wyrównywanie[edytuj]

Wyrównywanie jest pewnym zjawiskiem, na które w bardzo wielu podręcznikach, mówiących o C w ogóle się nie wspomina. Ten rozdział ma za zadanie wyjaśnienie tego zjawiska oraz uprzedzenie programisty o pewnych faktach, które w późniejszej jego "twórczości" mogą zminimalizować czas na znalezienie pewnych informacji, które mogą wpływać na to, że jego program nie będzie działał poprawnie.

Często zdarza się, że kompilator w ramach optymalizacji "wyrównuje" elementy struktury tak, aby procesor mógł łatwiej odczytać i przetworzyć dane. Przyjrzyjmy się bliżej następującemu fragmentowi kodu:

 typedef struct {
   unsigned char wiek; /* 8 bitów */
   unsigned int dochod; /* 32 bity */
   unsigned char plec; /* 8 bitów */
   unsigned char wzrost; /* 8 bitów */
 } nasza_str;

Aby procesor mógł łatwiej przetworzyć dane kompilator może dodać do tej struktury jedno, ośmiobitowe pole. Wtedy struktura będzie wyglądała tak:

 typedef struct {
   unsigned char wiek; /*8 bitów */
   unsigned char fill[1]; /* 8 bitów */
   unsigned int dochod; /* 32 bity */
   unsigned char plec; /* 8 bitów */
   unsigned char wzrost; /* 8 bitów */
 } nasza_str;

Wtedy rozmiar zmiennych przechowujących wiek, płeć, oraz dochód będzie wynosił 64 bity - będzie zatem potęgą liczby dwa i procesorowi dużo łatwiej będzie tak ułożoną strukturę przechowywać w pamięci cache. Jednak taka sytuacja nie zawsze jest pożądana. Może się okazać, że nasza struktura musi odzwierciedlać np. pojedynczy pakiet danych, przesyłanych przez sieć. Nie może być w niej zatem żadnych innych pól, poza tymi, które są istotne do transmisji. Aby wymusić na kompilatorze wyrównanie 1-bajtowe (co w praktyce wyłącza je) należy przed definicją struktury dodać dwie linijki. Ten kod działa pod Visual C++:

 #pragma pack(push)
 #pragma pack(1)
 
 struct struktura { /*...*/ };
 
 #pragma pack(pop)

W kompilatorze GCC należy po deklaracji struktury dodajemy przed średnikiem kończącym jedną linijkę:

__attribute__ ((packed))

Działa ona dokładnie tak samo, jak makra #pragma, jednak jest ona obecna tylko w kompilatorze GCC.

Dzięki użyciu tego atrybutu, kompilator zostanie "zmuszony" do braku ingerencji w naszą strukturę. Jest jednak jeszcze jeden, być może bardziej elegancki sposób na obejście dopełniania. Zauważyłeś, że dopełnienie, dodane przez kompilator pojawiło się między polem o długości 8 bitów (wiek) oraz polem o długości 32 bitów (dochod). Wyrównywanie polega na tym, że dana zmienna powinna być umieszczona pod adresem będącym wielokrotnością jej rozmiaru. Oznacza to, że jeśli np. mamy w strukturze na początku dwie zmienne, o rozmiarze jednego bajta, a potem jedną zmienną, o rozmiarze 4 bajtów, to pomiędzy polami o rozmiarze 2 bajtów, a polem czterobajtowym pojawi się dwubajtowe dopełnienie. Może Ci się wydawać, że jest to tylko niepotrzebne mącenie w głowie, jednak niektóre architektury (zwłaszcza typu RISC) mogą nie wykonać kodu, który nie został wyrównany. Dlatego, naszą strukturę powinniśmy zapisać mniej więcej tak:

 typedef struct {
   unsigned int dochod; /* 32 bity */
   unsigned char wiek; /* 8 bitów */
   unsigned char plec; /* 8 bitów */
 } nasza_str;

W ten sposób wyrównana struktura nie będzie podlegała modyfikacjom przez kompilator oraz będzie przenośna pomiędzy różnymi kompilatorami.

Wyrównywanie działa także na pojedynczych zmiennych w programie, jednak ten problem nie powoduje tyle zamieszania, co ingerencja kompilatora w układ pól struktury. Wyrównywanie zmiennych polega tylko na tym, że kompilator umieszcza je pod adresami, które są wielokrotnością ich rozmiaru

Kompilacja krzyżowa[edytuj]

Mając w domu dwa komputery, o odmiennych architekturach (np. i386 oraz Sparc) możemy potrzebować stworzyć program dla jednej maszyny, mając do dyspozycji tylko drugi komputer. Nie musimy wtedy latać do znajomego, posiadającego odpowiedni sprzęt. Możemy skorzystać z tzw. kompilacji krzyżowej (ang. cross-compile). Polega ona na tym, że program nie jest kompilowany pod procesor, na którym działa kompilator, lecz na inną, zdefiniowaną wcześniej maszynę. Efekt będzie taki sam, a skompilowany program możemy bez problemu uruchomić na drugim komputerze.

Inne narzędzia[edytuj]

Wśród przydatnych narzędzi, warto wymienić również :


Objdump służy do deasemblacji i analizy skompilowanych programów. Readelf służy do analizy pliku wykonywalnego w formacie ELF (używanego w większości systemów z rodziny Unix). Więcej informacji możesz uzyskać, pisząc (w systemach Unix):

man 1 objdump
man 1 readelf

Zobacz również[edytuj]