Zaznacz stronę

Jak przebiega proces kompilacji w C++?

sie 30, 2024 | Arduino, Embedded

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

C++
#pragma once

#define SOME_VAR 1

następnie użyjemy go w pliku main.cpp

C++
#include "header.hpp"

// komentarz
int main()
{
  return SOME_VAR;
}

następnie uruchmimy kompilator z opcją -E aby zobaczyć wynik działania preprocesora. Wpisz zatem w konsoli polecenie g++ -E main.cpp

C++
# 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 -

ASM
    .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. Z pomocą przyjdą tu inne narzędzia, np hexdump lub xdd.

Bash
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.. .......
00000140: 0200 0000 0200 0000 0000 0000 0000 0000  ................
00000150: 002e 7379 6d74 6162 002e 7374 7274 6162  ..symtab..strtab
00000160: 002e 7368 7374 7274 6162 002e 7465 7874  ..shstrtab..text
00000170: 002e 6461 7461 002e 6273 7300 2e74 6578  ..data..bss..tex
00000180: 742e 7374 6172 7475 7000 2e63 6f6d 6d65  t.startup..comme
00000190: 6e74 002e 6e6f 7465 2e47 4e55 2d73 7461  nt..note.GNU-sta
000001a0: 636b 002e 6e6f 7465 2e67 6e75 2e70 726f  ck..note.gnu.pro
000001b0: 7065 7274 7900 2e72 656c 612e 6568 5f66  perty..rela.eh_f
000001c0: 7261 6d65 0000 0000 0000 0000 0000 0000  rame............
000001d0: 0000 0000 0000 0000 0000 0000 0000 0000  ................
000001e0: 0000 0000 0000 0000 0000 0000 0000 0000  ................

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.

Bash
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 wykonywane są 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.

Bash
g++ main.o -o main

polecenie file pozwoli nam dowiedziec sie co to jest za plik

Bash
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że być wykonywane 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 jest podzielony na osobne moduły (pliki źródłowe), co ułatwia jego organizację, zarządzanie i zrozumienie.
  • 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: Ponieważ każdy plik źródłowy jest kompilowany osobno, błędy mogą być łatwiej zlokalizowane i przypisane 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 plik nagłówkowy jest zmieniony, 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 build, takiego jak make lub CMake.
  • Długi czas linkowania: W dużych projektach, mimo optymalizacji na poziomie kompilacji, czas linkowania może być długi, zwłaszcza gdy wymagane są 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.