Artykuł opisuje kolejne kroki kompilacji kodu w GCC. W prosty i przyjemny sposób oprowadzi Ciebie po procesie preprocesingu, kompilacji, optymalizacji oraz konsolidacji. Dzięki prostym przykładom zrozumiesz każdy etap przez jaki przechodzi kod źródłowy nim stanie się programem.

GCC jest podstawowym kompilatorem dla systemów uniksowych (posiada również port pod Windowsa), potrafi kompilować kod na wiele platform sprzętowych, takich jak x86, x86_64, ARM i PowerPC. Sama nazwa GCC jest nieco dwuznaczna: GNU C Compiler lub GNU Compiler Collection. Obecnie prawidłowe jest drugie rozwinięcie, mimo że sam gcc jako program służy do kompilowania kodu w C.

Ale do rzeczy. Może najpierw omówię proces kompilacji, który wielu kojarzy tylko z naciśnięciem przycisku “Build” w IDE. Potocznie na cały ten proces mówi się “kompilacja”, ale kompilacja to tylko jeden z procesów. Nie będę skupiał się na samych kodach źródłowych, ani działaniu bibliotek i ich dołączaniu. Omówię jak aplikacja jest budowana przy pomocy GCC.

Moim plikiem wejściowym będzie prosty algorytm liczenia sinusa w C.

#include <stdio.h>

#ifndef STEPS
	#define STEPS 20
#endif
#ifndef PRECISION
	#define PRECISION 30
#endif

double sin(double x){
	double w=x;
	double x2=x*x;
	double t=w;
	int i;
	for(i=2; i < PRECISION; i+=2){
		t=-t*((x2)/(i*(i+1)));
		w+=t;
	}
	return w;
}

int main(){
	double x=0;
	for(x=-1.570; x<=1.571; x+=3.14/STEPS)
		printf("sin(%lf)=%lf\n", x, sin(x));
	return 0;	
}

Oto kolejne etapy budowania aplikacji napisanej w C.

Preprocesor

Pierwszym krokiem jest przygotowanie kodu do kompilacji, robi to tzw. preprocesor. W tej fazie wykonywane są tzw. dyrektywy preprocesora – instrukcje poprzedzone znakiem “#”, m.in. #inlude, #define, #if itd. oraz wstawiane są definicje i makra. Innymi słowy: jeśli był gdziekolwiek jakiś #include, w te miejsce zostanie dosłownie wklejony podany plik. Jeśli jakiś warunek #if nie będzie spełniony, kod w tym warunku (tzn. do #endif) zostanie usunięty. W miejsca użycia nazw definicji zostaną wklejone dane definicje (lub makra).

Na uwagę zasługują właśnie makra. Trzeba pamiętać że w dane miejsce jego użycia makro zostanie wklejone dosłownie tak, jak było zdefiniowane:

#define SUMA(_v) _v+_v
int v=2*SUMA(2);

Po przejściu przez preprocesor w kodzie pozostanie:

int v=2*2+2;

Bezpieczniej jest takie makra wrzucić do nawiasu. Przykładowy plik wynikowy (na podstawie mojego sinus.c).

W następnym kroku omówię proces kompilacji i optymalizacji.

Kompilacja i optymalizacja

To najciekawszy etap budowania aplikacji w C, jest to proces zamieniający kod w danym języku programowania najpierw na: pośredni język kompilatora, później do Assemblera. W tym procesie kompilator usuwa takie elementy jak pętle zamieniając je na zwykłe warunki i instrukcje goto, a także przygotowuje kod do translacji na assemblera. Staje się też jasne, dlaczego switch działa akurat tak, a nie inaczej.

kod:

int main(){
	volatile int a=4; //volatile sprawia, że nie ma wykonywać optymalizacji względem tej zmiennej
	  //bez tego, kompilator usunąłby całego switch'a, bo byłby zbędny
	switch(a){
	case 1: printf("1");
	case 2: printf("2"); break;
	case 3 ... 4: printf("2..4");
	}
	return 0;
}

kod w trakcie kompilacji

;; Function int main() (main) (executed once)

int main() ()
{
  volatile int a;
  int retval.0;

<bb 2>:
  a ={v} 4;
  retval.0_1 ={v} a;
  switch (retval.0_1) <default: <L3>, case 1: <L0>, case 2: <L1>, case 3 ... 4: <L2>>

<L0>:
  __builtin_putchar (49);

<L1>:
  __builtin_putchar (50);
  goto <bb 6> (<L3>);

<L2>:
  __printf_chk (1, "2..4");

<L3>:
  return 0;

}

Na tym etapie kod jest również optymalizowany krok po kroku, poszczególne kroki można zobaczyć dodając -fdump-tree-all do polecenia kompilującego. Kompilator zna wiele sposobów przyspieszenia kodu, na powyższym przykładzie widać, jak z pozoru ciężkiego printfa zamienia na proste wypisanie pojedynczego znaku, gdzie to jest możliwe. Obserwując kody generowane w trakcje optymalizacji zobaczyć można przekonwertowane liczby rzeczywiste do ich postaci w notacji wykładniczej, a przy tym: utratę dokładności: na tym poziomie staje się jasne dlaczego nie należy porównywać wartości zmiennoprzecinkowych operatorem ==.

Przykładowy plik pośredni, uzyskany podczas optymalizacji kodu przed ostateczną translacją do assemblera: sinus-optimized.i. Jak widać nie ma tu już pętel, są zastąpione zrozumiałymi dla procesora warunkami if i poleceniami goto. Widać również jak wartość -1.570 jest przetłumaczona jako: (<bb 3>, linijka 68):

-1.5700000000000000621724893790087662637233734130859375e+0(2)

Na tym samym poziomie wstawiane są wszystkie stałe wartości, jeśli jakaś funkcja jest const i użyjemy jej gdziekolwiek w kodzie z argumentami również const (znanymi w czasie kompilacji), kompilator może ją obliczyć na poziomie kompilacji i podstawić gotowy wynik. Przykładowo wyrażenie 3.14/20 zostało obliczone na poziomie kompilacji, nie wykonania – to efekt optymalizacji. Widać to w tym miejscu (<bb 5>, linijka 94):

x_7 = x_35 + 1.570000000000000006661338147750939242541790008544921875e-1;

e-1 na końcu świadczy o tym, że mamy do czynienia z wartością 0.157, czyli 3.14/20. Dzięki temu nie wykonuje tego działania na dwóch stałych znanych już w czasie kompilacji podczas wykonywania pętli.

Niektórzy pewnie zadają sobie pytane: a gdzie się podział warunek pętli for i wartość 1.571? Tak właściwie, to ich nie ma! A przynajmniej nie w takiej formie, w jakiej były w kodzie źródłowym. Na drodze optymalizacji kompilator stwierdził, że można go uprościć, bo zarówno wartość początkowa, końcowa, jak i wielkość kroku są stałe, więc pętla może wykonać się określoną ilość razy: 21. Operacje na typach całkowitych są w końcu szybsze. Wartość ta jest trzymana w zmiennej ivtmp.27, widać to w tej linijce (<bb 3>, linijka 69):

# ivtmp.27_25 = PHI <ivtmp.27_46(5), 21(2)>

Jeśli ktoś nie wierzy, może zmienić wartość STEPS i przy takich samych opcjach kompilacji zobaczyć jeszcze raz (dokładne kroki podam później). Obsługa pętli for jak wspomniałem opiera się na poleceniach goto, a tak wygląda końcowa część (instrukcja + warunek, <bb 5>, od linijki 95):

ivtmp.27_46 = ivtmp.27_25 - 1;
  if (ivtmp.27_46 != 0)
    goto &lt;bb 3&gt;;
  else
    goto &lt;bb 6&gt;;

Widać tu też, że kompilator usuwa na tym etapie wyrażenia takie jak x=x-1, zamieniając je na np. x_2=x_1-1, stąd te sufiksy.

W trakcie kompilacji kod jest również dostosowywany do wybranej architektury i środowiska, tzn. czy ma być na maszyny 32, czy 64-bitowe, oraz konkretne technologie procesora. Procesory mają swoje nazwy własne nie nadaremno: poza szybkością zegara, ilością rdzeni i pamięcią cache różnią się jeszcze zaimplementowanymi technologiami, które można przejrzeć zaglądając do pliku cpuinfo (cat /proc/cpuinfo), lub programem CPU-Z na Windowsie.

Podczas zamiany tego kodu pośredniego na assembler, używa dostępnych na danej architekturze instrukcji procesora. Można określić architekturę poprzez przełączniki -march=cpu i -mtune=cpu, gdzie za cpu podstawia się nazwę procesora (listę obsługiwanych można zobaczyć w podręczniku lub w man gcc) lub np. dla tej maszyny: -march=native. Kod wynikowy może się różnić w zależności od wybranej architektury procesora. Wg. podręcznika przełącznik -march wywołuje też -mtune, więc nie trzeba używać ich obu.

Po tym etapie kod jest tłumaczony do Assemblera. Plik wynikowy dla mojego sinus.i:

main:
.LFB1:
	.cfi_startproc
	pushq	%rbx
	.cfi_def_cfa_offset 16
	.cfi_offset 3, -16
	movl	$21, %ebx
	subq	$32, %rsp
	.cfi_def_cfa_offset 48
	movsd	.LC1(%rip), %xmm3
	movsd	.LC0(%rip), %xmm2

Pełen plik wynikowy kompilacji i optymalizacji: sinus.s.

Assemblacja

Dopiero w tym procesie kod jest tłumaczony na kod maszynowy, ale nadal nie nadaje się du uruchomienia: nie posiada potrzebnych nagłówków oraz może posiadać tzw. niezdefiniowane referencje (zwane też w niektórych kompilatorach: unsolved symbols – nierozwiązane symbole). To znaczy, że jeśli np. załączyliśmy plik funkcje.h z samymi predeklaracjami funkcji, assembler nie musi jeszcze wiedzieć gdzie ta funkcja jest, byle ma informację, że taka funkcja gdzieś jest, tak się nazywa i ma daną sygnaturę.

Wynikiem tej fazy jest tzw. obiekt (rozszerzenie .o), fragment programu. Nie ma sensu zamieszczać jego treści.

Konsolidacja, czyli linkowanie

Ostatnia faza budowania aplikacji. Polega na zebraniu wszystkich obiektów do razem i złączeniu ich w wykonywalny program. Konsolidator wymaga już skojarzenia symboli (nazw), musi wiedzieć gdzie znajduje się dana funkcja, dany obiekt zewnętrzny, musi je powiązać (zlinkować). Rozwiązań symboli (czy też: definicji referencji) może także szukać w bibliotekach dzielonych (.so, samego pliku nie dołączy, jedynie utworzy odnośnik) i statycznych (.a, ten plik już zostanie dołączony do aplikacji statycznie). Na tym etapie wśród linkowanych obiektów musi znaleźć się funkcja main.

Na tym etapie wywala błędy typu undefined reference to... – nie potrafi przypisać jakiejś (pre)deklaracji do definicji. Błąd ten jest wyrzucany tylko dla użytych symboli, a nie wszystkich zadeklarowanych (jeśli mamy kilka predeklaracji funkcji, a używamy tylko jedną z nich, błąd wystąpi tylko dla tej używanej). Jeżeli ten błąd występuje, to znaczy że albo pominęliśmy jakąś bibliotekę zewnętrzną, albo jest literówka w którymś nagłówku.

Aby dolinkować bibliotekę, trzeba dodać do argumentów wywołania nazwę biblioteki, która znajduje się w folderze domyślnym bibliotek dla kompilatora (np. -lGL, domyślnym katalogiem w Linuksie jest /usr/lib i /usr/local/lib) i jeśli to konieczne, podać dodatkowe lokalizacje gdzie tych bibliotek ma szukać (-L/sciezka/do/katalogu). Zwykle pliki Readme lub podręczniki bibliotek zawierają informację jakie biblioteki trzeba załączyć do linkera.

Czy gcc robi to wszystko?

Zarówno tak, jak i nie, program gcc jest tylko interfejsem, sterownikiem. GCC (jako pakiet) składa się z wielu programów, gcc jest jednym z nich, w prosty sposób zarządza on całym procesem od początku do końca. Każda faza budowania ma przydzieloną osobną aplikację. Oczywiście wszystkie flagi i przełączniki można mu przekazać, gcc będzie wiedział co z nimi zrobić, gdzie ich użyć. Te programy to m.in.:

  • Preprocesor: cpp (C PreProcessor), tworzy plik gotowy do kompilacji (-E)
  • Kompilator: cc (lub cc1, C Compiler), kompiluje do assemblera (-S)
  • Assembler: as, assembluje kod do kodu maszynowego platformy docelowej (-c)
  • Konsolidator: ld – tworzy końcowy program z utworzonych obiektów

(W nawiasach podane przełączniki, które powodują że na tym etapie budowanie ma się zakończyć pozostawiając pliki powstałe w danym procesie)

Dla C++ są takie same nazwy programów, tyle że trzeba poinformować, że chodzi o język c++: -x c++. Standardowo gcc rozpoznaje pliki źródłowe po rozszerzeniach i powinien wiedzieć co z nimi zrobić (ale skompiluje program jako C ze zlinkowanymi bibliotekami do C, więc kod w C++ może się nie zbudować). Przykładowo .c i .cpp zostaną poddane całemu procesowi. .i (C) i .ii (C++) nie zostaną poddane preprocesorowi, tylko od razu kompilacji. .s zostanie od razu zassemblowany. Produkty .o zostaną poddane konsolidacji. Cały ten proces jest wykonywany przez gcc (i g++ dla C++), nie trzeba wykonywać tych faz ręcznie.

Ręczna kompilacja z pominięciem GCC wygląda mniej więcej tak:

#prekompilacja
cpp sinus.c &gt; sinus.i

#kompilacja
cc sinus.i -S #argument -S, by na tym zakończył, bez tego ruszyłby z procesem dalej assemblując i próbując zlinkować

#assemblacja
as sinus.s -o sinus.o

I to jeszcze nie wszystko. Kolejnym krokiem jest konsolidacja, która jest inna na każdej platformie i wersji kompilatora. Znajomość procesu ręcznej kompilacji może się przydać przy dogłębszej zabawie z kompilatorem lub przy niestandardowym jego wykorzystaniu (np. mechanizm konkursowy topcoder.com , gdzie w C++ pisze się jedynie klasę z metodą rozwiązującą zadanie, a nie cały program), jednak końcowe pliki .o najlepiej potraktować gcc:

gcc *.o -o program

C++ wymaga dodatkowo zlinkowania biblioteki stdc++. Znacznie łatwiej jest uruchamiać wszystko za pośrednictwem gcc i g++, one wiedzą co zrobić dla danego języka, żeby nie było problemów.

Jak więc kompilować?

Dobry programista powinien znać swój kompilator, sposób kompilacji oraz umieć obsługiwać go z poziomu konsoli, znam osoby będące święcie przekonane że kompilator C# jest aplikacją okienkową (pomijam już powszechne mylenie kompilatora z IDE).

Przede wszystkim kompilator trzeba mieć zainstalowany. W dystrybucjach Linuksowych (i ogólnie uniksowych) jest on zazwyczaj w repozytoriach pod nazwą właśnie gcc, albo w postaci meta-pakietu build-essential (wersja zalecana). Pod Windowsem trzeba ręcznie ściągnąć pakiet MinGW z GCC i zainstalować go.

Następnie trzeba go mieć całe te środowisko (tzn. gcc i wszystkie podprogramy) w konsoli pod ręką, tzn. w zmiennej środowiskowej PATH. W systemach uniksowych (w tym i Linuksie) po zainstalowaniu gcc przez menedżer pakietów jest on tam domyślnie (/usr/bin), na Windowsie trzeba go sobie ręcznie dodać (poprzez ustawienie zmiennej PATH). Następnie mając uruchomioną konsolę ze ścieżką do aplikacji kompilatora w PATH, przechodzimy do folderu ze źródłem, które chcemy skompilować i wpisujemy:

gcc plik.c

To najprostsze wywołanie kompilatora. Spowoduje to zbudowanie aplikacji z jednego pliku .c do końcowego pliku a.out (lub a.exe, a.out nazwa starego formatu plików wykonywalnych, raczej pozostałość, bo obecnie linuksowe binarki to ELFy). Do sprecyzowania wynikowej nazwy pliku (w tym przypadku pliku wykonywalnego) należy użyć -o nazwa_programu, wtedy zamiast a.out będzie dana nazwa (nazwa wyjściowa musi być zaraz po spacji za -o, inaczej zostanie uznana za nazwę pliku wejściowego). Wynikowy plik od razu ma prawo do wykonywania. Takie wywołanie spowoduje wykonanie wszystkich tych kroków, jakie podałem wyżej: od preprocesora do konsolidacji. Co ciekawe, ta prosta konstrukcja może być użyta do kompilacji wielu plików:

gcc plik.c funkcje.c -o program

Skompiluje to oba pliki do jednego programu. Ale to nie wszystko: obsługiwane są wyrażenia regularne w nazwach! Przykładowo mając pliki: c1.c, c2.c, c3.c. Wywołanie gcc c[1-2].c skompiluje jedynie pliki c1.c i c2.c! Przydaje się konstrukcja gcc *.c by skompilować wszystkie pliki .c w danym folderze, gdy uczy się pracy na wielu plikach źródłowych, a nie chce się robić bałaganu projektami. Funkcja main musi wystąpić tylko raz, jeśli nie będzie, gcc (a raczej ld, bo to jego działka) wypisze undefined reference to `main', a gdy będzie więcej: multiple definition of `main' (podobnie z innymi funkcjami o takich samych sygnaturach).

Kompilacja wielkich projektów może trwać godziny, zatem czy każda zmiana wymaga ponownej kompilacji? Tak, ale nie całego projektu. Menedżery projektów zwykle kompilują pojedyncze pliki .c do plików obiektów .o (nie zlinkowanych). Te zaś, nie są rekompilowane, gdy nie muszą. Załóżmy że mamy pliki p1.c, p2.c i p3.c. Po pierwszym zbudowaniu poza wynikowym programem tworzone są pośrednie pliki obiektów: p1.o, p2.o i p3.o. Jeśli zmienimy cokolwiek w pliku p2.c, przy ponownym budowaniu skompilowany zostanie tylko ten jeden, a końcowy program zostanie zbudowany ze starych p1.o i p3.o i nowego p2.o. W ten sposób oszczędza się czas budowania wielo-plikowych projektów.

A jak to zrobić z poziomu konsoli? Służy do tego przełącznik -c, który kończy proces bez przeprowadzania konsolidacji, wystarczy dodać go do argumentów:

gcc p1.c -c

Nie trzeba podawać nazwy pliku końcowego (ale można), będzie taka sama nazwa jak pliku wejściowego, tyle że z rozszerzeniem .o (przy wielu plikach wejściowych nie można samemu określić nazwy pliku wyjściowego). Gdy mamy już skompilowane pliki źródłowe do obiektów, przystępujemy do konsolidacji:

gcc *.o -o program

Tutaj przydaje się wyrażenie regularne, by nie podawać nazw pojedynczo. W jednym (i tylko jednym, jak wspominałem) obiekcie musi być funkcja main.

Podsumowując, aby zbudować prostą aplikację jedno-plikową, wystarczy prosta konstrukcja:

gcc plik.c -o program

Dla programów wielo-plikowych, można skompilować wszystkie pliki (w folderze) jednocześnie poprzez użycie wyrażenia regularnego lub wypisanie każdego osobno.

Jest jeszcze jedno, o czym nie wspomniałem: GCC jest również cross-compilerem. Pozwala kompilować na różne platformy docelowe, nie tylko x86 i x86_64 i nie tylko na obecną. Mając 32-bitowy system i zainstalowane 32-bitowe biblioteki można skompilować aplikację na 32-bitową platformę używając flagi -m32, gcc dopasuje cały proces do 32-bitowej platformy docelowej. Kompilacja na inne platformy procesorowe (AVR, ARM) to już inny temat.

Flagi optymalizacji

Są to flagi służące do włączenia optymalizacji kodu: kompilator znajduje fragmenty dla których zna szybsze rozwiązanie i zamienia je. Najczęściej wystarczy przełącznik -O2, lub -O3. Przykład użycia:

gcc *.c -O2 -o program

Kolejność argumentów nie ma znaczenia. Jest to optymalizacja drugiego poziomu, umiarkowana, są jeszcze -O0 (domyślnie, brak optymalizacji), -O1 (słaba optymalizacja), -O3 (silna, agresywna, przez wielu uważana za zbyt ryzykowną) oraz -Os (optymalizacja rozmiaru). Flaga -O2 zostanie przekazana dopiero kompilatorowi, bo to on zajmuje się optymalizacją. Na stronie gcc.gnu.org znajduje się opis poszczególnych profili optymalizacji.

Pozostaje jeszcze przełącznik architektury procesora. GCC ma kilka szablonów pod popularne modele, każde z nich włącza dodatkowe opcje kompilacji, które mogą przyspieszyć działanie programu, obserwując różnice kodów assemblera często można zauważyć że czasem kod z -march=native (wykrywa procesor i ustawia flagi dla niego) jest krótszy, często wykonuje się szybciej.

Zachęcam do przeczytania krótkiego manuala GCC, który jest dostępny online na stronie http://linux.die.net/man/1/gcc. Dla skrótu, kilka ciekawych poleceń:

gcc -c -Q -O2 --help=optimizers # wyświetla włączone flagi optymalizacji kodu
gcc -c -Q -O2 --help=target # wyświetla włączone flagi ustawień platformy docelowej

Standardy i ostrzeżenia

-std=

Jak wiemy (a przynajmniej powinno się wiedzieć) języki C i C++ to tylko język, a nie konkretny kompilator, właściwie to brak jakiejkolwiek oficjalnej implementacji, jak to jest w przypadku Javy, czy C#. Są różne implementacje i różne wersje standardów, każdy zapewne słyszał o C++0x (znany też jako C++11). Ale może skupmy się na C.

Dość częsty problem: konstrukcja pętli for. Czy w języku C w pętli for nie można bezpośrednio deklarować zmiennych? Oto przykładowa taka pętla:

for(int i=0; i<10; i++)

W przypadku standardów z 89 roku, czyli c89 i gnu89 (domyślny) taka konstrukcja nie jest dozwolona, otrzymamy komunikat:

for.c:6:2: error: ‘for’ loop initial declarations are only allowed in C99 mode
for.c:6:2: note: use option -std=c99 or -std=gnu99 to compile your code

GCC od razu sugeruje użycie standardu c99 lub gnu99. Jeśli ten sam kod skompiluje się z dopiskiem -std=c99, błąd nie wystąpi. Do dyspozycji wg. podręcznika mamy:

  • c89, gnu89, -ansi
  • c90, gnu90
  • c99, gnu99
  • c11, gnu11 (c1x, gnu1x)
  • c++98, gnu++98, -ansi
  • c++11, gnu++11 (c++0x, gnu++0x)

W przypadku standardów gdzie są nawiasy, w nawiasach podane przestarzałe nazwy używane w wersjach do 4.7, w 4.7 i nowszych obowiązują te bez nawiasów. Te które mają dopisek -ansi, oznaczają że są to domyślne standardy, które można uzyskać poprzez użycie flagi -ansi (będą wtedy użyte w wariancie bez gnu, czyli c89/c++98, w zależności od języka).

Kompilatory oferują również często własne możliwości, aby odróżnić się od innych. Są one opcjonalne i często znacznie rozszerzają możliwości języka. Warianty standardów z GNU w nazwie włączają właśnie takie rozszerzenia. Dostępne w GCC rozszerzenia można znaleźć na tej stronie: http://gcc.gnu.org/onlinedocs/gcc/C-Extensions.html.

Przykładowe rozszerzenia:

  • zmienna przy deklarowaniu tablic
  • zakres case (case 1 … 5:)
  • typy _Decimal*, które redukują problemy dokładności (używa systemu dziesiętnego przy zapisie ułamków, nie posiadają obsługi wejścia/wyjścia)
  • 128-bitowy integer i float (nie posiadają obsługi wejścia/wyjścia)
  • funkcje osadzone
  • liczby zespolone z zapisem w postaci 1+1j

-pedantic, -Wall

GCC posiada swoje rozszerzenia i udogodnienia, ale nie jest egoistyczny. Może ostrzec programistę, że używa specyficznej dla tego kompilatora i dialektu konstrukcji. Przykładem jest użycie zmiennej do określania wielkości tablicy:

int n;
scanf("%d", &n);
int tab[n];

Taka konstrukcja nie jest dopuszczona w standardzie C (ani C++), aczkolwiek w GCC przejdzie i będzie działać prawidłowo – na etapie optymalizacji kompilator będzie wiedział co z tym fantem zrobić. Przeniesienie takiego kodu do np. Visual C++ spowoduje nieco nieporozumień. O ile normalna kompilacja nie wyrzuci żadnego błędu, to dodanie flagi -pedantic, który sprawdza zgodność kodu z ISO C90/C99 już zarzuci:

plik.c:7:2: warning: ISO C90 forbids variable length array ‘tab’ [-Wvla]
plik.c:7:2: warning: ISO C90 forbids mixed declarations and code [-pedantic]

To są tylko ostrzeżenia, że dany kod jest niezgodny ze standardem ISO C90 i jest tym samym nieprzenośny na inne kompilatory. Najpierw jest podana nazwa pliku, następnie numer lini (w tym przypadku: 7).

Drugą przydatną flagą jest -Wall (nie, nie ściana, tylko Warning: all): włącza większość ostrzeżeń (można je włączyć ręcznie, dodając poszczególne flagi). Przykładowo informuje nas o zadeklarowanych, ale nieużywanych zmiennych:

warning: unused variable ‘zmienna’ [-Wunused-variable]

Innym ważnym ostrzeżeniem, jakie włącza -Wall, jest przypisywanie w miejscach, gdzie spodziewane są warunki, np. if(n=4), poinformuje:

plik.c:8:2: warning: suggest parentheses around assignment used as truth value [-Wparentheses]

Jak widać, włączenie tej flagi może ułatwić szukanie tak prostych błędów. Co prawda ten komunikat informuje o tym, że wyrażenie powinno być w nawiasach dla czytelności, ale często się sprawdza. Czasem w takich miejscach stawia się podstawianie (np. w pętli while, gdzie coś się robi dopóki jakaś funkcja coś zwraca), ale trzeba to robić świadomie (poprzez np. umieszczenie w nawiasach).

Dla ciekawostki dopowiem, że kompilator clang dodaje notkę, że to mogło być niedopatrzenie i powinien być w tym miejscu znak porównania ==, a nawiasy by te ostrzeżenie wyciszyły.

Definicje dla prekompilatora

Niektórzy mogli sobie zadać pytanie: po co w pliku sinus.c definicje są wsadzone do warunku. gdyby nie były, nie byłoby możliwości (właściwie sensu, bo wartość będzie nadpisana) takiego wywołania:

gcc *.c -O3 -o sinus -D STEPS=40

W ten sposób na start przekazuje się prekompilatorowi zdefiniowaną wartość. Przydatne, gdy w wielu miejscach używa się definicji i chce się je modyfikować w trakcie kompilacji, np. w celu dopasowania optymalnej wartości (w tym przypadku mógłbym dopasowywać definicję PRECISION dopasowując ilość wyrazów ciągu taylora).

Podsumowanie

GCC jest bardzo prosty w obsłudze, mimo ogromnego manuala. Jedyne co trzeba wiedzieć, że jeśli chce się skompilować jakiś plik, wystarczy podać jego nazwę. Kolejność argumentów jest dowolna. Proces budowania można zakończyć w dowolnym momencie:

  • -c – nie konsoliduje (tworzy pliki .o)
  • -S – nie assembluje (pozostawia pliki .s)
  • -E – wyświetla (lub zapisuje do pliku, jeśli został podany plik wyjściowy, np. -o doKompilatora.i) kod po przejściu przez preprocesor

Flaga -fdump-tree-all pozostawia pliki z poszczególnymi krokami kompilacji i optymalizacji.

Najprościej jest użyć flagi -O2 do optymalizacji (jak wspomniałem: -O3 bywa ryzykowne). Kompilować można wiele plików jednocześnie. Nazwy plików wejściowych wpisuje się nie poprzedzając żadnym argumentem, każda nazwa, której gcc nie będzie umiał dopasować do któregoś argumentu (np. -o ), będzie uznana za nazwę pliku wejściowego. Kolejność argumentów nie ma znaczenia. Przy np. rozwiązywaniu zadań konkursowych najprościej wywołać kompilację poleceniem:

gcc z1.c -O2 -o z1

Jeśli nie podamy nazwy pliku wyjściowego (-o nazwa), użyta zostanie domyślna nazwa a.out (a.exe na Windowsie). Można też dodać -march=native, które dostosuje kod do danego procesora, co może (ale nie musi, co pokaże benchmark) przyspieszyć wykonywanie kodu wynikowego na danej platformie.

Dzięki temu, że gcc/g++ zwracają numery błędów, można ich użyć do pewnej automatyzacji, np. od razu po kompilacji uruchomić, jeśli się powiedzie:

gcc z1.c -O2 -o z1 && time ./z1&lt;z1.in

Jeśli kompilacja się powiedzie, skompilowany program zostanie uruchomiony z wejściem z pliku z1.in i zmierzony zostanie czas wykonywania. Jeśli kompilacja się nie powiedzie, program nie będzie uruchomiony.

Jeśli program składa się z kilku plików, można je razem skompilować:

gcc z1a.c z1b.c -O2 -o z1

Lub też za pomocą wyrażenia regularnego:

gcc *.c -O2 -o z1 #wszystkie w tym folderze
gcc z1*.c -O2 -o z1 #zaczynające się na z1

Aby zobaczyć pliki przejściowe optymalizacji:

gcc z1.c -fdump-tree-all -o z1

Aby gcc nie usuwał tymczasowych plików (poza tymi z optymalizacjami, zostawia pliki .i, .ii, .s i .o):

gcc z1.c -save-temps -o z1

Jeśli chcemy zakończyć proces budowania na np. preprocesorze (np. w celu zbadania poprawności kodu po wstawieniu makr), dodajemy -E i wyrzucamy do jakiegoś pliku:

gcc z1.c -E -o z1.i

Pliki z rozszerzeniem .i (lub .ii w przypadku C++) w rozszerzeniu są plikami które już przeszły przez preprocesor. Rozszerzenie .s oznacza że jest to plik dla assemblera. Pliki .o to obiekty tymczasowe, skompilowane, po assemblerze, ale nie zlinkowane. Dopiero konsolidator (linker) tworzy wykonywalny program.

Jeśli mając system 64-bitowy chcemy skompilować program dla systemu 32-bitowego, musimy mieć pobrane wszystkie biblioteki dla 32-bitowego systemu i dodać do flag kompilacji -m32:

gcc z1.c -m32 -o z1.i

Kolejność argumentów wywołania (w przypadku gcc i g++) jest dowolna, jednak są opcje dwuargumentowe, takie jak właśnie nazwa pliku wyjściowego: -o nazwa_pliku.

Jednak jeśli kompiluje się już większe projekty z użyciem zewnętrznych bibliotek, lepiej zaopatrzyć się w jakieś IDE. Ręczne wpisywanie poleceń kompilacji nie jest zbyt wygodne na dłuższą metę, zwłaszcza gdy używa się kilku bibliotek. Jednak używanie kompilatora z poziomu konsoli w wielu przypadkach o wiele lepiej się sprawdza niż używanie IDE, np. konkursy, zabawa z kompilatorem, pisanie prostych algorytmów i małych programów.

Znajomość całego procesu krok po kroku może się przydać przy robieniu bardziej zaawansowanych lub niskopoziomowych rzeczy takich jak tworzenie własnego menedżera projektów, mechanizm konkursowy, czy też debugowanie kodu na poziomie kodu przejściowego i assemblera. Znajomość flag optymalizacji pozwala lepiej je dopasować do konkretnych algorytmów.

Podobne artykuły

GNU - logo

przez -
1 588
  • Greg

    WOW! No ten artykuł zasługuje na malinę :) Jeżeli napisałeś tego więcej to szacun :)

    Siadam do czytania bo zapowiada się ciekawie :D

  • Pingback: Wyniki III malinowego konkursu | OSWorld.pl()

  • Adi

    Małe wideo na ten temat:
    Gynvael's Code: Proces Kompilacji C/C++ https://www.youtube.com/watch?v=wDKeJ79TBsg

  • Dobry artykuł. Mam pytanie do autora. Długo programujesz?

    • Razi

      Jakieś 5 lat, jeśli chodzi o C/C++.

  • Tomasz

    Świetny artykuł. Dziękuje :-) Więcej takich!

  • cojack

    nie łatwiej cmake + cmakefile? i nie musisz się niczym martwić?

    • Razi

      łatwiej – na poziomie całego projektu, chciałoby ci się pisać/generować pliki cmake dla programu z 3 plików, albo jednego? Jeśli chodzi o niskopoziomową analizę, to taka wiedza jest konieczna. Poza tym artykuł nie był o budowaniu całych projektów, tylko o samym procesie tzw. kompilacji, czyli jak to działa i jak tym zarządzać.

    • Nie programuję, ale możecie mi wytłumaczyć co robi cmake? Bo make to znam.

    • Razi

      CMake to program używany do stworzenia i przygotowania projektu na daną platformę (OS, czy też kompilator), np. na Linuksie stworzy Makefile, a na Windowsie projekt VS (albo Makefile, jak sobie user zażyczy). Oczywiście cały projekt musi być oskryptowany CMake'iem.

    • y0g1

      po kilku latach używania cmake przerobiłem wszystkie projekty na zwykłe skrypty basha. Zaleta – szybkość przebudowania wszystkich projektów i peła kontrola nad doborem opcji kompilacji. Dodatkowa zaleta – brak wymogu instalacji cmake, autotools na hoście (wystarczy gcc + wymagane biblioteki).
      Sprawniej przebiega testowanie na innych systemach z rodziny bsd. Dotyczy to projektów komercyjnych. Dla otwartych – dedykowanych szerokiemu gronu odbiorców lepsze jest dopasowanie projektu do standardowych narzędzi (np. cmake) co może wymagać większej dbałości o same pliki projektowe cmake i właściwe warunkowe załączanie poszczególnych opcji w zależności od wykrytej wersji cmake. Przy własnych skryptach pilnujemy tylko opcji gcc w zależności od dostępnej wersji gcc na hoście.

    • Michał Walenciak

      Jak sobie radzisz z budowaniem pod visual studio?

  • androidowy

    Bardzo prosto , klarownie – miło czytać i dowiedzieć się czegośc nowego. Pozdrawiam

  • Pingback: Benchmark kompilatorów i konsekwencje optymalizacji | OSWorld.pl()

  • Pingback: Czy Java faktycznie jest taka wolna? | OSWorld.pl()

  • Pingback: Kompilacja jądra pod Raspberry Pi | OSWorld.pl()

  • o_O

    > znam osoby będące święcie przekonane że kompilator C# jest aplikacją okienkową
    > (pomijam już powszechne mylenie kompilatora z IDE).

    Windows odmóżdża. Programiści tego ekosystemu nie umieją sami rozwiązywać problemów ani próbować go zrozumieć, a tylko szukają ikonki, która zrobi to za nich.

    To klikacze i małpy, więc trudno się dziwić niskiej jakości oprogramowania na windows.

    Nie dziwi też mała ilość softu na Linuksa, bo większość tych programistów, z braku kompetencji, nie jest w stanie przestawić się na prawdziwe programowanie. Widać to dokładnie po ostatniej modzie na portowanie gier na Linuksa: bardzo dużo silników i gierek poległo, bo jakość kodu była tak żenująca, że trzeba by je przepisać od zera, aby były przenośne.

    Na szczęście era tych pseudo-programistów się kończy. Dziś brak znajomości Linuksa skreśla programistę w każdej liczącej się firmie.

  • y0g1

    Warto dodać kilka dobrych praktyk podczas programowania:
    – zapoznanie się z podręcznikiem stosowanych narzędzi (man gcc / info gcc)
    – określić standard języka (wersję) do którego chcemy dostosować projekt
    – wybrać najbardziej rygorostyczne opcje kompilatora, które pozwolą wychwycić tyle odstęp od standardu na ile jest to możliwe (-std=… -pedantic-errors, -Wextra).
    – traktować ostrzeżenia jako błędy (-Wfatal-errors, -Werror)

  • Bardzo dobry artykuł. Widać, że piszący wie o czym pisze. Programuje w C, jakby to określić, “okazjonalnie”. Dla mnie materiał był bardzo pomocny, polecam.