Proces kompilacji w języku C++ to wieloetapowy proces, który przekształca kod źródłowy napisany przez programistę na wykonywalny plik binarny, który może być uruchomiony na komputerze lub innym urządzeniu. Zrozumienie tego procesu jest kluczowe dla efektywnego programowania i rozwiązywania problemów, które mogą pojawić się w trakcie tworzenia oprogramowania. W tym artykule omówimy poszczególne etapy kompilacji w C++ i wyjaśnimy, co dzieje się na każdym z nich.
1. Etap preprocesora
Pierwszym krokiem w procesie kompilacji jest faza preprocesora. Preprocesor to narzędzie, które przetwarza kod źródłowy przed jego faktyczną kompilacją. Na tym etapie wykonywane są następujące operacje:
- Rozszerzenie makr: Wszystkie makra zdefiniowane za pomocą dyrektyw
#define
są zastępowane ich wartościami. - Włączenie plików nagłówkowych: Pliki nagłówkowe (
.h
lub.hpp
) dołączane za pomocą dyrektywy#include
są wstawiane w miejsce tych dyrektyw. - Warunkowe kompilacje: Instrukcje takie jak
#ifdef
,#ifndef
czy#if
są analizowane i kod w nich zawarty jest odpowiednio włączany lub pomijany. - Usunięcie komentarzy: Komentarze w kodzie są usuwane, ponieważ nie są potrzebne w dalszych etapach kompilacji.
Wynikiem działania preprocesora jest plik, który zawiera czysty kod źródłowy C++, gotowy do dalszego przetwarzania przez kompilator.
Zróbmy prosty przykład z użyciem kompilatora gcc w Linuksie. Najpierw utworzymy plik header.hpp
#pragma once
#define SOME_VAR 1
następnie użyjemy go w pliku main.cpp
#include "header.hpp"
// komentarz
int main()
{
return SOME_VAR;
}
następnie uruchmimy kompilator z opcją -E
aby zobaczyć wynik działania preprocesora. Wpiszmy zatem w konsoli polecenie g++ -E main.cpp
# 0 "main.cpp"
# 0 "<built-in>"
# 0 "<command-line>"
# 1 "/usr/include/stdc-predef.h" 1 3 4
# 0 "<command-line>" 2
# 1 "main.cpp"
# 1 "header.hpp" 1
# 2 "main.cpp" 2
int main()
{
return 1;
}
jak widzimy, preprocesor usunął zbędne komentarze, a następnie dokonął odpowiednich podstawień.
2. Etap kompilacji (Compilation)
Po zakończeniu pracy preprocesora, rozpoczyna się właściwa kompilacja. Na tym etapie kompilator przetwarza kod źródłowy C++ i tłumaczy go na kod pośredni w postaci tzw. assembly (asembler). Etap ten składa się z kilku podetapów:
- Analiza leksykalna: Kompilator dzieli kod na mniejsze jednostki zwane tokenami, które reprezentują podstawowe elementy języka, takie jak słowa kluczowe, identyfikatory, operatory, liczby itp.
- Analiza składniowa: Kompilator analizuje strukturę tokenów i sprawdza, czy jest ona zgodna z zasadami gramatyki języka C++. W tym kroku generowane są błędy składniowe, jeśli kod nie spełnia wymagań.
- Analiza semantyczna: Kompilator sprawdza, czy program jest semantycznie poprawny. Na przykład, sprawdza, czy zmienne są poprawnie zadeklarowane i używane, a także czy typy danych są zgodne w operacjach.
- Generowanie kodu pośredniego: Kompilator przekształca poprawny kod C++ na kod assembly, który jest blisko poziomu sprzętu, ale nadal zależny od architektury procesora.
Wynikiem tego etapu jest plik z rozszerzeniem .s
, który zawiera kod assembly.
Za pomocą opcji „-S” kompilatora gcc możemy sprawdzić co otrzymamy. Sprawdźmy więc na powyższym przykładzie poleceniem g++ -Os -S main.cpp -o -
.file "main.cpp"
.text
.section .text.startup,"ax",@progbits
.globl main
.type main, @function
main:
.LFB0:
.cfi_startproc
endbr64
movl $1, %eax
ret
.cfi_endproc
.LFE0:
.size main, .-main
.ident "GCC: (Ubuntu 13.2.0-23ubuntu4) 13.2.0"
.section .note.GNU-stack,"",@progbits
.section .note.gnu.property,"a"
.align 8
.long 1f - 0f
.long 4f - 1f
.long 5
0:
.string "GNU"
1:
.align 8
.long 0xc0000002
.long 3f - 2f
2:
.long 0x3
3:
.align 8
4:
Jak widzimy, to nadal jest tekstowy kod źródłowy.
3. Etap asemblacji (Assembly)
Na tym etapie kod assembly jest przekształcany przez asembler na kod maszynowy specyficzny dla danej architektury procesora. Kod maszynowy to niskopoziomowa reprezentacja instrukcji, które procesor może bezpośrednio wykonywać.
Efektem pracy asemblera jest plik obiektowy z rozszerzeniem .o
lub .obj
, zawierający kod maszynowy, ale jeszcze niekompletny – może brakować w nim odniesień do funkcji lub zmiennych zdefiniowanych w innych modułach.
Tutaj przyda nam sie opcja -c
kompilatora. Zobaczmy co wygeneruje. Uruchamiamy zatem polecenie
g++ -Os -c main.cpp -o main.o
Otrzymaliśmy plik main.o
, ale on już ma binarną postać, i żaden edytor tekstowy nam nie pokaże jego zawartości. Do dyspozycji mamy osobne narzędzia, np hexdump lub xdd.
xdd main.o
00000000: 7f45 4c46 0201 0100 0000 0000 0000 0000 .ELF............
00000010: 0100 3e00 0100 0000 0000 0000 0000 0000 ..>.............
00000020: 0000 0000 0000 0000 c801 0000 0000 0000 ................
00000030: 0000 0000 4000 0000 0000 4000 0d00 0c00 ....@.....@.....
00000040: f30f 1efa b801 0000 00c3 0047 4343 3a20 ...........GCC:
00000050: 2855 6275 6e74 7520 3133 2e32 2e30 2d32 (Ubuntu 13.2.0-2
00000060: 3375 6275 6e74 7534 2920 3133 2e32 2e30 3ubuntu4) 13.2.0
00000070: 0000 0000 0000 0000 0400 0000 1000 0000 ................
00000080: 0500 0000 474e 5500 0200 00c0 0400 0000 ....GNU.........
00000090: 0300 0000 0000 0000 1400 0000 0000 0000 ................
000000a0: 017a 5200 0178 1001 1b0c 0708 9001 0000 .zR..x..........
000000b0: 1400 0000 1c00 0000 0000 0000 0a00 0000 ................
000000c0: 0000 0000 0000 0000 0000 0000 0000 0000 ................
000000d0: 0000 0000 0000 0000 0000 0000 0000 0000 ................
000000e0: 0100 0000 0400 f1ff 0000 0000 0000 0000 ................
000000f0: 0000 0000 0000 0000 0000 0000 0300 0400 ................
00000100: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00000110: 0a00 0000 1200 0400 0000 0000 0000 0000 ................
00000120: 0a00 0000 0000 0000 006d 6169 6e2e 6370 .........main.cp
00000130: 7000 6d61 696e 0000 2000 0000 0000 0000 p.main.. .......
Poleceniem objdump
natomiast możemy wylistować kod assemblerowy z pliku obiektowego. Uruchamiamy więc polecenie objdump -d -S main.o
aby zobaczyć, że podświetlony fragment z etapu kompilacji jest identyczny.
main.o: file format elf64-x86-64
Disassembly of section .text.startup:
0000000000000000 <main>:
0: f3 0f 1e fa endbr64
4: b8 01 00 00 00 mov $0x1,%eax
9: c3 ret
4. Etap linkowania (Linking)
Linkowanie to ostatni etap procesu kompilacji. Na tym etapie linker łączy wszystkie pliki obiektowe oraz biblioteki w jedną całość, tworząc plik wykonywalny (np. .exe
na systemach Windows lub bez rozszerzenia na systemach Linux). Podczas linkowania kompilator wykonuje następujące czynności:
- Rozwiązanie symboli: Linker sprawdza, czy wszystkie odwołania do funkcji i zmiennych są poprawnie zdefiniowane i lokalizuje ich definicje. Jeśli znajdzie nierozwiązane symbole (np. odwołanie do funkcji, która nie została zdefiniowana w żadnym z plików obiektowych), wygeneruje błąd linkowania.
- Łączenie kodu: Wszystkie fragmenty kodu maszynowego z różnych plików obiektowych są łączone w jeden ciągły blok kodu.
- Dodanie bibliotek: Jeśli program korzysta z zewnętrznych bibliotek (np. biblioteka standardowa C++), linker dołącza odpowiednie moduły z tych bibliotek.
W wyniku pracy linkera powstaje plik wykonywalny, który można uruchomić na danej platformie.
Spróbujmy dokończyć nasz przykład i wygenerować z niego plik wynikowy. Posłużymy sie poleceniem g++ main.o -o main
. w wyniku otrzymamy plik main, który jest juz binarnym plikiem wykonywalnym, pod określony system operacyjny i architekture procesora.
g++ main.o -o main
polecenie file
pozwoli nam dowiedziec sie co to jest za plik
file main
main: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=ea67949fb43efa168a2313ed6472be6269257127, for GNU/Linux 3.2.0, not stripped
5. Optymalizacje
Na różnych etapach kompilacji, kompilator i linker mogą wykonywać różnorodne optymalizacje, mające na celu poprawę wydajności i zmniejszenie rozmiaru finalnego pliku wykonywalnego. Przykłady optymalizacji to:
- Inline funkcji: Funkcje mogą być wstawiane bezpośrednio w miejsce wywołania, aby zmniejszyć narzut związany z wywołaniem funkcji.
- Usuwanie nieużywanego kodu: Kod, który nigdy nie jest wywoływany, może być usunięty, aby zmniejszyć rozmiar pliku wykonywalnego.
- Optymalizacje pętli: Kompilator może przekształcać pętle w sposób, który zwiększa ich wydajność.
6. Wady i zalety wieloetapowej kompilacji
Zalety
- Inkrementalna kompilacja: W przypadku dużych projektów, gdy zmieniasz tylko jeden plik źródłowy, kompilator musi przetworzyć tylko ten zmodyfikowany plik, a nie cały projekt, dzięki temu czas kompilacji może być znacznie krótszy.
- Równoległa kompilacja: Kompilowanie wielu plików źródłowych można wykonać równolegle na różnych rdzeniach procesora, co znacząco przyspiesza cały proces kompilacji.
- Separacja kodu: Wieloetapowa kompilacja wymusza modularne podejście do projektowania oprogramowania. Kod podzielony na osobne moduły (pliki źródłowe) łatwiej zrozumieć i zarządzać.
- Reużywalność: Możesz kompilować poszczególne moduły (pliki
.o
) osobno i używać ich w różnych projektach, bez konieczności ponownej kompilacji całego kodu. - Izolacja błędów: każdy plik źródłowy jest kompilowany osobno, dzięki temu łatwiej zlokalizować błędy i przypisać do konkretnego modułu. Można debugować poszczególne pliki obiektowe przed ich połączeniem w całość.
- Lokalne optymalizacje: Kompilator może wykonywać optymalizacje na poziomie pojedynczych modułów, zanim zostaną one połączone w końcowy program. Możliwe są również globalne optymalizacje na etapie linkowania, takie jak eliminacja nieużywanego kodu.
Wady
- Nieświeże pliki nagłówkowe: Jeśli w pliku nagłówkowym nastąpiła zmiana, a odpowiednie pliki źródłowe nie są ponownie skompilowane, może to prowadzić do niespójności i trudnych do znalezienia błędów. Wymaga to staranności w zarządzaniu zależnościami.
- Złożoność zarządzania zależnościami: Zarządzanie zależnościami między plikami źródłowymi i nagłówkowymi może być trudne i wymaga dobrze skonfigurowanego systemu budowania, takiego jak
make
lubCMake
. - Długi czas linkowania: W dużych projektach, mimo optymalizacji na poziomie kompilacji, czas linkowania może być długi, zwłaszcza gdy zastosowano zaawansowane optymalizacje na poziomie całego programu (link-time optimization).
- Duża liczba plików obiektowych: Każdy plik źródłowy generuje osobny plik obiektowy, co zwiększa liczbę plików w projekcie i może prowadzić do większego zużycia pamięci i przestrzeni dyskowej.
- Komplikacja procesu kompilacji: Podział kompilacji na wiele etapów wymaga skonfigurowania skryptów budujących, zarządzania zależnościami, opcjami kompilacji i linkowania. Może to zwiększyć złożoność procesu budowania, szczególnie w dużych projektach z wieloma zależnościami.
7. Podsumowanie
Proces kompilacji w C++ jest skomplikowanym i wieloetapowym procesem, który przekształca kod źródłowy na kod wykonywalny, gotowy do uruchomienia. Zrozumienie poszczególnych etapów, od preprocesingu, przez kompilację i asemblację, aż po linkowanie, jest kluczowe dla każdego programisty C++. Pozwala to nie tylko na efektywniejsze programowanie, ale także na lepsze zrozumienie i rozwiązywanie problemów, które mogą wystąpić na różnych etapach tworzenia oprogramowania.