Przejdź do zawartości

C/Więcej o kompilowaniu

Z Wikibooks, biblioteki wolnych podręczników.
< C

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]


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

lub innymi słowami:

 co: z_czego
   jak

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

oraz plików nagłówkowych

pierwszy.h
drugi.h
trzeci.h
czwarty.h

przyjmijmy, iż plik drugi.c w treści ma wpisane dołączenie plików:

  1. include "trzeci.h"
  2. include "czwarty.h"

Odpowiedni plik Makefile z użyciem kompilacji 2 etapowej 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 pierwszy.h
   $(CC) pierwszy.c -c -o pierwszy.o
 
 drugi.o: drugi.c drugi.h trzeci.h czwarty.h
   $(CC) drugi.c -c -o drugi.o
 
 trzeci.o: trzeci.c trzeci.h
   $(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. dodanie jako zależności plików z rozszerzeniem .h zapewnia rekompilację plików w których są używane zdefiniowane w nich wartości. Brak tych wpisów jest najczęstszą przyczyną braku zmian działania programu po zmianie ustawień w plikach nagłówkowych.

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.


Wzorcowy plik Makefile

[edytuj]

Zaletą tego pliku makefile jest to, że automatycznie określa on zależności. Wszystko, co musisz zrobić, to umieścić pliki C/C++ w folderze src/.[1]


# Thanks to Job Vranish (https://spin.atomicobject.com/2016/08/26/makefile-c-projects/)
TARGET_EXEC := final_program

BUILD_DIR := ./build
SRC_DIRS := ./src

# Find all the C and C++ files we want to compile
# Note the single quotes around the * expressions. The shell will incorrectly expand these otherwise, but we want to send the * directly to the find command.
SRCS := $(shell find $(SRC_DIRS) -name '*.cpp' -or -name '*.c' -or -name '*.s')

# Prepends BUILD_DIR and appends .o to every src file
# As an example, ./your_dir/hello.cpp turns into ./build/./your_dir/hello.cpp.o
OBJS := $(SRCS:%=$(BUILD_DIR)/%.o)

# String substitution (suffix version without %).
# As an example, ./build/hello.cpp.o turns into ./build/hello.cpp.d
DEPS := $(OBJS:.o=.d)

# Every folder in ./src will need to be passed to GCC so that it can find header files
INC_DIRS := $(shell find $(SRC_DIRS) -type d)
# Add a prefix to INC_DIRS. So moduleA would become -ImoduleA. GCC understands this -I flag
INC_FLAGS := $(addprefix -I,$(INC_DIRS))

# The -MMD and -MP flags together generate Makefiles for us!
# These files will have .d instead of .o as the output.
CPPFLAGS := $(INC_FLAGS) -MMD -MP

# The final build step.
$(BUILD_DIR)/$(TARGET_EXEC): $(OBJS)
	$(CXX) $(OBJS) -o $@ $(LDFLAGS)

# Build step for C source
$(BUILD_DIR)/%.c.o: %.c
	mkdir -p $(dir $@)
	$(CC) $(CPPFLAGS) $(CFLAGS) -c $< -o $@

# Build step for C++ source
$(BUILD_DIR)/%.cpp.o: %.cpp
	mkdir -p $(dir $@)
	$(CXX) $(CPPFLAGS) $(CXXFLAGS) -c $< -o $@


.PHONY: clean
clean:
	rm -r $(BUILD_DIR)

# Include the .d makefiles. The - at the front suppresses the errors of missing
# Makefiles. Initially, all the .d files will be missing, and we don't want those
# errors to show up.
-include $(DEPS)

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, wzrost, 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 dodać po deklaracji struktury 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]


  1. make file tutorial by Chase Lambert and contrib.