premik.pl

Jak działa kompilator?

Piszesz kod w Pythonie, Rust czy C++ i wciskasz przycisk uruchomienia. Ułamek sekundy później program działa. Co dokładnie wydarzyło się między tymi dwoma momentami? Odpowiedź kryje się w jednym z najbardziej fascynujących programów, jakie kiedykolwiek napisano – kompilatorze.

Czym jest kompilator i czym różni się od interpretera?

Kompilator to program, który tłumaczy kod źródłowy napisany w języku wysokiego poziomu – zrozumiałym dla człowieka – na kod maszynowy zrozumiały bezpośrednio dla procesora. To tłumaczenie następuje jednorazowo, przed uruchomieniem programu. Efektem jest plik wykonywalny, który można uruchamiać wielokrotnie bez ponownej kompilacji.

Interpreter działa inaczej – zamiast tłumaczyć cały program z góry, wykonuje kod linię po linii, tłumacząc każdą instrukcję w momencie jej napotkania. Python, Ruby i JavaScript w przeglądarce tradycyjnie działały w trybie interpretowanym.

Różnica ma praktyczne konsekwencje. Programy skompilowane są zwykle znacznie szybsze – procesor dostaje instrukcje bezpośrednio, bez pośrednika. Programy interpretowane są wolniejsze, ale łatwiejsze do debugowania i bardziej przenośne między platformami. Współczesne języki często łączą oba podejścia – Java kompiluje do bytecode uruchamianego na wirtualnej maszynie JVM, JavaScript jest kompilowany przez silnik V8 bezpośrednio przed wykonaniem (JIT – Just In Time compilation), a Python 3.13 wprowadza eksperymentalny tryb JIT.

Kompilator to nie jeden program, lecz potok kilku następujących po sobie etapów przetwarzania. Każdy etap przyjmuje wynik poprzedniego, przetwarza go i przekazuje dalej. Warto zrozumieć każdy z nich osobno.

Analiza leksykalna i składniowa – od tekstu do drzewa

Kompilator rozpoczyna od surowego tekstu – ciągu znaków, którym jest twój plik źródłowy. Pierwszym zadaniem jest nadanie temu tekstowi struktury.

Analiza leksykalna (lekser lub tokenizer) dzieli strumień znaków na tokeny – atomowe jednostki znaczenia. Dla fragmentu kodu:

int wynik = a + b * 2;

lekser produkuje sekwencję tokenów: KEYWORD(int), IDENTIFIER(wynik), OPERATOR(=), IDENTIFIER(a), OPERATOR(+), IDENTIFIER(b), OPERATOR(*), NUMBER(2), SEMICOLON. Spacje i komentarze są na tym etapie odrzucane – nie mają znaczenia dla dalszego przetwarzania.

Analiza składniowa (parser) sprawdza, czy sekwencja tokenów jest zgodna z gramatyką języka, i buduje z niej drzewo składniowe (AST – Abstract Syntax Tree). Drzewo to hierarchiczna reprezentacja struktury programu – wyrażenie a + b * 2 nie jest prostą sekwencją, lecz drzewem, w którym * jest głębiej niż +, bo mnożenie ma wyższy priorytet. Parser to właśnie ten etap, który zgłasza błąd „unexpected token” lub „syntax error” gdy zapomnisz zamknąć nawias.

AST jest kluczową strukturą danych całego kompilatora – wszystkie kolejne etapy operują na drzewie lub jego transformacjach, nie na surowym tekście.

Analiza semantyczna – czy kod ma sens?

Poprawna składnia nie gwarantuje poprawnego znaczenia. Zdanie „bezbarwne zielone idee śpią wściekle” jest gramatycznie poprawne po angielsku, ale semantycznie bezsensowne. Podobnie kod może być syntaktycznie poprawny, a semantycznie błędny.

Analiza semantyczna weryfikuje znaczenie programu i jest etapem, na którym kompilator sprawdza między innymi:

Typy danych – czy próbujesz dodać liczbę do napisu? Czy funkcja zwraca wartość zgodną z deklarowanym typem zwracanym? W językach statycznie typowanych jak Rust, C++ czy Java cały ten etap jest szczególnie rozbudowany i to tutaj kompilator Rusta słynie z wyjątkowo pomocnych, precyzyjnych komunikatów o błędach.

Zakres zmiennych – czy zmienna, której używasz, została wcześniej zadeklarowana? Czy jest dostępna w bieżącym zakresie?

Poprawność wywołań funkcji – czy przekazujesz właściwą liczbę argumentów? Czy ich typy się zgadzają?

Wynikiem analizy semantycznej jest wzbogacone drzewo AST – uzupełnione o informacje typów, powiązania między deklaracjami a użyciami i inne metadane potrzebne w kolejnych etapach. Jeśli analiza semantyczna wykryje błąd, kompilacja zostaje przerwana z komunikatem – to etap odpowiedzialny za większość błędów, które widzisz podczas próby skompilowania kodu z błędem logicznym.

Optymalizacja – kompilator mądrzejszy od programisty

Tu dzieje się prawdziwa magia. Po zbudowaniu poprawnego drzewa semantycznego kompilator przystępuje do optymalizacji – transformacji kodu, które zachowują jego znaczenie, ale poprawiają wydajność wynikowego programu.

Stałe składania (constant folding) – wyrażenie int x = 2 * 3 * 10 kompilator zamienia bezpośrednio na int x = 60, bo wynik jest znany w czasie kompilacji. Żadnego mnożenia w czasie wykonania.

Eliminacja martwego kodu – kod, który nigdy nie może zostać wykonany (np. instrukcje po return lub warunki, które zawsze są fałszywe), jest usuwany z wyjściowego programu.

Inlining funkcji – zamiast wywoływać małą funkcję (co wiąże się z kosztem skoku do innego adresu, zapisania rejestrów i powrotu), kompilator wstawia jej ciało bezpośrednio w miejsce wywołania. Programista pisze czytelny kod z małymi funkcjami, a kompilator zamienia to w efektywny kod bez narzutu wywołań.

Wektoryzacja SIMD – nowoczesne procesory mają instrukcje, które wykonują tę samą operację na wielu danych jednocześnie. Kompilator potrafi automatycznie przekształcić zwykłą pętlę sumującą elementy tablicy w instrukcje SIMD przetwarzające 4, 8 lub 16 elementów naraz.

Optymalizacja pętli – kompilator może zmienić kolejność operacji w pętli, wyprowadzić niezmienne obliczenia poza pętlę, rozwinąć pętlę (loop unrolling) zamieniając 100 iteracji w 25 bloków po 4 operacje, czy połączyć dwie sąsiednie pętle w jedną.

Flagi optymalizacji w GCC i Clang (O0, O1, O2, O3) kontrolują agresywność tego etapu. Kod skompilowany z flagą O3 potrafi być kilkakrotnie szybszy niż ten sam kod skompilowany bez optymalizacji.

Generowanie kodu – od drzewa do instrukcji procesora

Ostatni etap to generowanie kodu maszynowego – tłumaczenie zoptymalizowanego drzewa na rzeczywiste instrukcje procesora lub pośrednią reprezentację, z której docelowy kod jest generowany.

Nowoczesne kompilatory często pracują z pośrednią reprezentacją (IR – Intermediate Representation). LLVM, infrastruktura kompilatorów używana przez Clang (C/C++), Rust, Swift i dziesiątki innych języków, definiuje własne IR – niskopoziomowy, niezależny od architektury kod, który następnie jest tłumaczony na instrukcje konkretnego procesora: x86-64, ARM, RISC-V, WebAssembly.

To podejście ma elegancką konsekwencję. Jeśli napiszesz nowy język programowania i zbudujesz frontend kompilatora generujący LLVM IR, automatycznie zyskujesz wszystkie optymalizacje LLVM i obsługę wszystkich architektur procesorów przez LLVM. Rust, Swift i Julia działają właśnie w ten sposób.

Alokacja rejestrów to jeden z najtrudniejszych problemów na tym etapie – procesor ma ograniczoną liczbę rejestrów (szybkiej pamięci wewnętrznej), a zmiennych w programie może być dużo więcej. Kompilator musi zdecydować, które zmienne trzymać w rejestrach, a które „wylewać” do wolniejszej pamięci RAM – i jest to problem NP-trudny rozwiązywany przez heurystyki.

Finalnym wynikiem jest plik obiektowy, który linker łączy z innymi plikami obiektowymi i bibliotekami w gotowy plik wykonywalny. Dlatego duże projekty kompilują każdy plik źródłowy osobno (co pozwala na równoległą kompilację) i dopiero na końcu łączą wszystko w jeden program.

Kompilatory to jeden z tych obszarów informatyki, gdzie teoria (formalne gramatyki, teoria typów, teoria grafów) spotyka się z inżynierią w najbardziej satysfakcjonujący sposób. Jeśli chcesz zgłębić temat – kurs „Crafting Interpreters” Roberta Nyströma dostępny za darmo na craftinginterpreters.com to jedno z najlepszych wprowadzeń w tej dziedzinie. Masz pytania dotyczące konkretnych kompilatorów lub chcesz porozmawiać o implementacji własnego języka? Napisz przez formularz kontaktowy.

Zobacz powiązane wpisy