Last modified: September 18, 2024

This article is written in: 🇵🇱

Proces kompilacji

Proces kompilacji to złożony ciąg etapów, który przekształca kod źródłowy napisany w języku wysokiego poziomu na kod maszynowy zrozumiały dla procesora. Kompilacja zapewnia, że kod jest poprawny pod względem składniowym i semantycznym, a także optymalizuje go pod kątem wydajności. Poniżej szczegółowo omówione są poszczególne etapy kompilacji.

1. Etap preprocesowania

Preprocesor jest pierwszym narzędziem, które działa na kodzie źródłowym przed właściwą kompilacją. Jego główne zadania to:

I. Włączanie zawartości plików nagłówkowych

Dyrektywy preprocesora takie jak #include <nazwa_pliku.h> lub #include "nazwa_pliku.h" służą do włączenia zawartości innych plików do bieżącego kodu. Dzięki temu możliwe jest korzystanie z deklaracji funkcji, klas czy zmiennych zdefiniowanych w innych plikach, co promuje modularność i reużywalność kodu.

Przykład:

#include <stdio.h> // Załącza standardową bibliotekę wejścia/wyjścia
#include "moje_funkcje.h" // Załącza plik nagłówkowy zdefiniowany przez użytkownika

II. Przetwarzanie makr

Makra umożliwiają definiowanie stałych, funkcji makro oraz zastępowanie fragmentów kodu. Są one przetwarzane przez preprocesor i nie istnieją w skompilowanym kodzie.

Przykład stałej:

#define PI 3.14159

Przykład funkcji makro:

#define MAX(a,b) ((a) > (b) ? (a) : (b))

III. Warunkowe kompilowanie

Dyrektywy takie jak #ifdef, #ifndef, #if, #else, #elif, #endif pozwalają na kompilowanie fragmentów kodu tylko wtedy, gdy spełnione są określone warunki. Jest to użyteczne przy kompilacji kodu dla różnych platform lub konfiguracji.

Przykład:

#ifdef DEBUG
printf("Wartość zmiennej x: %d\n", x);
#endif

IV. Usuwanie komentarzy

Preprocesor usuwa wszystkie komentarze (// oraz /* */), ponieważ nie są one potrzebne w dalszych etapach kompilacji i nie wpływają na działanie programu.

2. Analiza kodu źródłowego

Po preprocesowaniu kod trafia do właściwego kompilatora, który przeprowadza analizę w trzech głównych krokach:

I. Analiza leksykalna (tokenizacja)

Kod źródłowy jest dzielony na podstawowe jednostki zwane tokenami. Tokeny mogą być słowami kluczowymi (if, while), identyfikatorami (nazwy zmiennych i funkcji), literałami (np. liczby, łańcuchy znaków), operatorami (+, -, *, /) oraz znakami interpunkcyjnymi.

Przykład:

Kod:

int suma = a + b;

Tokeny:

II. Analiza składniowa (parsing)

Na tym etapie kompilator sprawdza, czy sekwencja tokenów tworzy poprawne konstrukcje zgodne z gramatyką języka. Tworzone jest drzewo składniowe (AST - Abstract Syntax Tree), które reprezentuje hierarchiczną strukturę programu.

Przykład drzewa składniowego dla int suma = a + b;:

Deklaracja zmiennej
|
+-- Typ: int
+-- Nazwa: suma
+-- Inicjalizacja
    |
    +-- Wyrażenie arytmetyczne
        |
        +-- Operator: +
        +-- Operand lewy: a
        +-- Operand prawy: b

III. Analiza semantyczna:

Sprawdzana jest poprawność semantyczna kodu, czyli czy wyrażenia mają sens w kontekście języka. Obejmuje to:

Przykład błędu semantycznego:

int x = "tekst"; // Przypisanie łańcucha znaków do zmiennej typu int

3. Generacja plików obiektowych

Po pomyślnym przejściu analiz, kompilator przystępuje do generowania kodu pośredniego:

I. Generacja kodu pośredniego (Intermediate Representation - IR)

II. Tworzenie plików obiektowych

Wynikowy kod maszynowy wraz z informacjami o symbolach i sekcjach jest zapisywany w plikach obiektowych (.o, .obj). Pliki te zawierają również informacje potrzebne do linkowania, takie jak tablice symboli i informacje o relokacji.

Struktura pliku obiektowego:

4. Linkowanie

Linker jest narzędziem, które łączy wiele plików obiektowych i bibliotek w jeden plik wykonywalny:

I. Łączenie symboli

Linker przegląda tablice symboli wszystkich plików obiektowych, aby zmapować wywołania funkcji i odwołania do zmiennych na ich rzeczywiste definicje.

Przykład:

Jeśli funkcja void funkcja() jest zadeklarowana w plik1.o, a wywoływana w plik2.o, linker połączy te referencje.

II. Rozwiązanie referencji do bibliotek:

III. Relokacja

Linker dostosowuje adresy pamięci w kodzie, aby odzwierciedlić rzeczywiste rozmieszczenie kodu i danych w pamięci.

IV. Tworzenie pliku wykonywalnego

Po zakończeniu wszystkich powyższych kroków, linker generuje finalny plik wykonywalny, który zawiera skompilowany kod gotowy do uruchomienia przez system operacyjny.

Dodatkowe aspekty linkowania:

Dlaczego nie piszemy bezpośrednio w assemblerze?

Choć assembler daje pełną kontrolę nad sprzętem i pozwala na pisanie bardzo wydajnego kodu, programowanie w nim jest niepraktyczne dla większości zastosowań z kilku kluczowych powodów:

Przykład:

Dodanie dwóch liczb w C:

int a = 5;
int b = 10;
int c = a + b;

Dodanie dwóch liczb w Asemblerze (dla architektury x86):

section .data
    a dd 5        ; zmienna a = 5
    b dd 10       ; zmienna b = 10
    c dd 0        ; zmienna c = 0, tutaj zostanie zapisany wynik

section .text
    global _start

_start:
    mov eax, [a]   ; załaduj wartość zmiennej a do rejestru eax
    add eax, [b]   ; dodaj wartość zmiennej b do eax
    mov [c], eax   ; zapisz wynik do zmiennej c

    ; zakończenie programu (system call exit)
    mov eax, 1     ; kod systemowy dla exit
    xor ebx, ebx   ; kod powrotu 0
    int 0x80       ; wywołanie systemu

Zastosowania assemblera:

Kompilacja z wiersza poleceń

Kompilacja z wiersza poleceń daje programiście pełną kontrolę nad procesem kompilacji i pozwala na dostosowanie opcji kompilatora do specyficznych potrzeb projektu.

Kompilacja kodu w C przy użyciu gcc

gcc jest potężnym narzędziem, które oferuje wiele opcji:

Podstawowa kompilacja:

gcc main.c -o program

Opcje kompilatora:

I. Standard języka (-std): Określa, który standard języka C ma być użyty (np. c89, c99, c11).

gcc -std=c11 main.c -o program

II. Ostrzeżenia (-Wall, -Wextra, -Werror):

Przykład:

gcc -Wall -Wextra -Werror main.c -o program

III. Optymalizacje (-O, -O1, -O2, -O3, -Os):

Przykład:

gcc -O2 main.c -o program

IV. Debugowanie (-g): Dodaje informacje debugowania, które są niezbędne podczas używania debugerów takich jak gdb.

Przykład:

gcc -g main.c -o program

V. Definiowanie makr (-D): Pozwala na definiowanie makr z poziomu kompilacji.

Przykład:

gcc -DDEBUG main.c -o program

VI. Ścieżki do plików nagłówkowych (-I): Dodaje dodatkowe ścieżki, w których kompilator szuka plików nagłówkowych.

Przykład:

gcc -I./include main.c -o program

VII. Linkowanie z bibliotekami (-l, -L):

Przykład:

gcc main.c -o program -L./lib -lmojabiblioteka

Kompilacja wielu plików:

Jeśli projekt składa się z wielu plików źródłowych:

gcc plik1.c plik2.c plik3.c -o program

Kompilacja etapowa:

I. Kompilacja do plików obiektowych:

gcc -c plik1.c -o plik1.o
gcc -c plik2.c -o plik2.o

II. Linkowanie plików obiektowych:

gcc plik1.o plik2.o -o program

Kompilacja programu w C++ przy użyciu g++

g++ działa podobnie do gcc, ale jest przeznaczony dla języka C++.

Podstawowa kompilacja:

g++ main.cpp -o program

Opcje kompilatora:

I. Standard języka (-std): Dostępne standardy to m.in. c++98, c++03, c++11, c++14, c++17, c++20.

g++ -std=c++17 main.cpp -o program

II. Ostrzeżenia i rygorystyczność (-Wall, -Wextra, -pedantic):

Przykład:

g++ -Wall -Wextra -pedantic main.cpp -o program

III. Optymalizacje, debugowanie, definiowanie makr, ścieżki do plików nagłówkowych i bibliotek: Działają analogicznie jak w gcc.

Przykład z wieloma opcjami:

g++ -std=c++20 -O2 -Wall -Wextra -g -I./include -L./lib -lmojabiblioteka main.cpp -o program

Formatowanie kodu przy użyciu clang-format

clang-format to narzędzie do automatycznego formatowania kodu zgodnie z określonym stylem. Dzięki temu kod jest spójny i czytelny dla wszystkich członków zespołu.

Konfiguracja stylu:

Przykład pliku .clang-format:

BasedOnStyle: LLVM
IndentWidth: 4
ColumnLimit: 100

Formatowanie plików:

I. Formatowanie pojedynczego pliku:

clang-format -i plik.cpp

II. Automatyczne formatowanie wszystkich plików w projekcie:

find . -regex '.*\.\(cpp\|hpp\|c\|h\)' -exec clang-format -style=file -i {} \;

Dostosowywanie opcji kompilacji

I. Profilowanie wydajności (-pg):

Dodaje informacje potrzebne do profilowania programu narzędziami takimi jak gprof.

gcc -pg main.c -o program

II. Analiza statyczna:

Narzędzia takie jak cppcheck pozwalają na statyczną analizę kodu w celu wykrycia potencjalnych błędów.

Przykład:

cppcheck --enable=all --inconclusive --std=c11 main.c

III. Tworzenie bibliotek:

Biblioteki statyczne (.a):

  1. Kompilacja plików obiektowych:

gcc -c plik1.c -o plik1.o
gcc -c plik2.c -o plik2.o

  1. Utworzenie biblioteki:

ar rcs libmojabiblioteka.a plik1.o plik2.o

  1. Użycie biblioteki podczas kompilacji programu:

gcc main.c -L. -lmojabiblioteka -o program

Biblioteki dynamiczne (.so):

  1. Kompilacja z opcją tworzenia kodu współdzielonego:

gcc -fPIC -c plik1.c -o plik1.o
gcc -fPIC -c plik2.c -o plik2.o

  1. Utworzenie biblioteki:

gcc -shared -o libmojabiblioteka.so plik1.o plik2.o

  1. Użycie biblioteki podczas kompilacji programu:

gcc main.c -L. -lmojabiblioteka -o program

  1. Ustawienie zmiennej środowiskowej LD_LIBRARY_PATH:

export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:.

Korzystanie z make i Makefile:

Automatyzacja procesu kompilacji przy użyciu narzędzia make.

Przykład prostego Makefile:

CC=gcc
CFLAGS=-Wall -Wextra -std=c11 -O2
LDFLAGS=

SOURCES=main.c plik1.c plik2.c
OBJECTS=$(SOURCES:.c=.o)
TARGET=program

all: $(TARGET)

$(TARGET): $(OBJECTS)
  $(CC) $(LDFLAGS) -o $@ $^

%.o: %.c
  $(CC) $(CFLAGS) -c -o $@ $<

clean:
  rm -f $(OBJECTS) $(TARGET)

Uruchomienie kompilacji:

make

Usunięcie plików obiektowych i wykonywalnych:

make clean

Spis Treści

  1. Proces kompilacji
    1. 1. Etap preprocesowania
    2. 2. Analiza kodu źródłowego
    3. 3. Generacja plików obiektowych
    4. 4. Linkowanie
  2. Dlaczego nie piszemy bezpośrednio w assemblerze?
  3. Kompilacja z wiersza poleceń
    1. Kompilacja kodu w C przy użyciu gcc
    2. Kompilacja programu w C++ przy użyciu g++
    3. Formatowanie kodu przy użyciu clang-format
    4. Dostosowywanie opcji kompilacji