Hej, hej... Programisto, to kolejny artykuł dla Ciebie! Druga część artykułu na temat wzorców projektowych. Poznaj Adapter oraz Memento.
W tym artykule opowiemy Wam trochę o tym czym jest kompilacja, a czym interpretacja kodu. Jakie są różnice, a jakie podobieństwa obu podejść? Jeśli masz ochotę poszerzyć swoje horyzonty, a dzięki temu być lepszym programistą, to zapraszamy do lektury.
Podstawowe pojęcia
Ze względu na duże zamieszanie w pojęciach w ramach tego tematu proponujemy na początku zapoznać się z definicjami, których treść zaczerpnęliśmy z Internetu. Dzięki temu będziemy mieli pewność, że pod tymi samymi pojęciami rozumiemy te same zjawiska.
- Kod źródłowy (ang. source code) – szczegółowe instrukcje programu komputerowego przy pomocy określonego języka programowania, opisujące operacje, jakie powinien wykonać komputer na zgromadzonych lub otrzymanych danych. Kod źródłowy jest wynikiem pracy programisty i pozwala wyrazić w czytelnej dla człowieka formie strukturę oraz działanie programu komputerowego. Jest on zwykle zapisywany w pliku tekstowym.
- Kompilator (ang. compiler) – program służący do automatycznego tłumaczenia kodu napisanego w jednym języku (języku źródłowym) na równoważny kod w innym języku (języku wynikowym). Proces ten nazywany jest kompilacją. W informatyce kompilatorem nazywa się najczęściej program do tłumaczenia kodu źródłowego w języku programowania na kod maszynowy. Niektóre z nich tłumaczą najpierw do języka asemblera, a ten na kod maszynowy jest tłumaczony przez tzw. asembler np. kompilator GCC używa asemblera GAS (GNU Assembler)
- Asembler (ang. assembler) – termin informatyczny związany z programowaniem i tworzeniem kodu maszynowego dla procesorów. Oznacza on program tworzący kod maszynowy na podstawie kodu źródłowego (tzw. asemblacja) wykonanego w niskopoziomowym języku programowania. Taki język bazujący na podstawowych operacjach procesora nazywa się językiem asemblera, a popularnie mówi się również asembler. Czyli niskopoziomowy język programowania powinno się nazywać językiem asemblera, a program tłumaczący – asemblerem, choć często potocznie na język assemblera mówimy assembler.
- Plik binarny (ang. binary) – plik o dowolnej zawartości, oznaczający wszystkie pliki poza plikami tekstowymi (executable, muzyka, filmy). Pliki binarne są zwykle traktowane jako ciąg bajtów, co oznacza, że bity są pogrupowane w ósemki (bajty). Te bajty mają być interpretowane jako coś innego niż znaki tekstowe. Plików binarnych nie da się edytować przy pomocy programów do edycji tekstu, programy te zakładają, że plik zawiera tekst i interpretują dane będące kodami sterującymi. Pliki binarne można edytować za pomocą edytora heksadecymalnego (xxd, ghex, IDA).
Nie każdy plik wykonywalny jest binarny (np. skrypt pythonie jest wykonywalny, ale nie jest binarny). Nie każdy plik binarny jest wykonywalny (np. skompilowana biblioteka .lib lub .dll jest plikiem binarnym, ale nie jest wykonywalna). Plik z rozszerzeniem .exe w Windowsie jest zarówno wykonywalny i binarny. - Plik obiektowy – plik binarny generowany przez kompilator lub asembler podczas kompilacji pliku z kodem źródłowym lub podczas łączenia plików obiektowych przez konsolidator (linker) np. w C++ jeden plik obiektowy zwykle powstaje z jednego pliku cpp.
- Konsolidacja, pop. „linkowanie” (od ang. link, „łączyć”) – proces polegający na połączeniu skompilowanych modułów (plików zawierających kod obiektowy lub plików bibliotek statycznych) i utworzeniu pliku wykonywalnego lub – rzadziej – innego pliku obiektowego. Dodatkowo podczas konsolidacji do pliku wynikowego mogą być dołączone odpowiednie nagłówki i informacje charakterystyczne dla konkretnego formatu pliku wykonywalnego. Narzędziem które służy do konsolidacji jest konsolidator (pop. „linker”).
- Plik wykonywalny, plik uruchamialny (ang. executable) – plik, który może być uruchomiony bezpośrednio w środowisku systemu operacyjnego. Zawiera instrukcje w postaci pozwalającej na jej zrealizowanie przez komputer. W Windowsie to mogą być pliki z rozszerzeniem .exe. Inna definicja mówi, że pliki executable to pliki, które możesz uruchomić w systemie jako proces (ale nie jest to do końca prawda).
- Język maszynowy, kod maszynowy – zestaw rozkazów procesora, w którym zapis programu wyrażony jest w postaci liczb binarnych stanowiących rozkazy oraz ich argumenty.
Kod maszynowy może być generowany w procesie kompilacji (w przypadku języków wysokiego poziomu) lub asemblacji (w przypadku języków niskiego poziomu). W trakcie procesu generowania kodu maszynowego często tworzony jest przenośny kod pośredni zapisywany w pliku obiektowym. Następnie kod ten pobrany z pliku obiektowego poddawany jest konsolidacji (linkowaniu) z kodem w innych plikach, w celu utworzenia ostatecznej postaci kodu maszynowego, który będzie zapisany w pliku wykonywalnym. - Biblioteka – plik dostarczający funkcjonalności, dane oraz typy danych które mogą zostać wykorzystane z poziomu kodu źródłowego programu. Użycie bibliotek to sposób na ponowne wykorzystanie tego samego kodu.
Ze względu na moment dołączania biblioteki do programu wyróżniamy biblioteki statyczne (np. lib) dołączane w czasie konsolidacji oraz biblioteki dynamiczne (np. .dll) dołączane w czasie uruchamiania programu.
Kompilacja
Najprościej mówiąc kompilacja jest procesem tłumaczenia języka programowania na inny język bądź kod maszynowy. Komputer nie potrafi zrozumieć samego języka programowania, który rozumie człowiek. Kompilator pełni więc rolę “tłumacza”, który jest w stanie wyprodukować plik w kodzie maszynowym, który jest rozumiany przez dany procesor wykonany w danej architekturze.
Rysunek 1 – Wysokopoziomowy obraz kompilacji (Źródło: EngMicroLectures).
Inaczej można powiedzieć, że są dwie wersje Twojego programu – ta którą rozumiesz Ty i nie rozumie jej komputer (source file np. “test.c”) oraz wersja w kodzie maszynowym, której nie rozumiesz Ty, ale rozumie ją komputer. Kompilator to więc “magiczny program”, który sprawia, że możemy tłumaczyć “human readable code” na “computer readable code”. Po uruchomieniu program ładowany jest z pamięci dyskowej do pamięci operacyjnej (RAM), a następnie wykonywany przez procesor.
Rysunek 2 – Wykonanie programu na CPU, odczytanie instrukcji z pamięci komputera.
Czy na pewno jest to tak proste?
Oczywiście powyższe modele są znacznym uproszczeniem tego skomplikowanego tematu. Po wgłębieniu się w proces okazuje się, że ma on wiele kroków i jest bardziej skomplikowany, co widać na poniższym rysunku.
Rysunek 3 – bardziej szczegółowe przedstawienie kompilacji na podstawie języka C++, Źródło: Stackoverflow.
Preprocesor jest to program wchodzący w skład sterownika kompilacji, przetwarzający kod źródłowy według określonych reguł nazywanych dyrektywami, dając w rezultacie kod źródłowy gotowy do kompilacji. Standard C++ w pełni opisuje poprawną pracę preprocesora.
Mamy także kompilator, czyli program, który zamienia nam język wysokiego poziomu na kod assemblera dopasowany pod daną architekturę. Następnie wszelkie pliki zamieniają się na postać “object binary” (można o tym myśleć jak o stanie przejściowym między assemblerem a kodem maszynowym).
Na końcu powstaje executable, czyli plik binarny, który można uruchomić na danym systemie pod danym procesorem.
Przykład
Rozważmy poniższy przykład, aby oswoić się bliżej z praktycznym działaniem kompilatora. Wykorzystamy do tego język C i kompilator gcc w systemie Linux.
Rysunek 4 – Jest to prosty program napisany w C. Program korzysta z funkcji, dwóch stałych i argumentu, żeby wypisać “Hello Alex” na ekranie.
Rysunek 5 – Output preprocesora po wykonaniu instrukcji z przełącznikami -E i -P.
Zatrzymujemy po preprocesorze i widzimy stałe rozwiązane na konkretne wartości. Zmienne są bez zmian. Dodatkowo nagłówki funkcji są przeklejane do pliku dyrektywą #include <stdio.h>.
Rysunek 6 – Kompilacja do języka assembly.
Aby przejść do kolejnego kroku z pomocą kompilatora GCC skompilujmy nasz program do assemblera. Opcja -S w GCC powoduje utworzenie pliku assemblerowego.
Rysunek 7 – Zawartość pliku test.s -> x86 assembler.
Skompilujmy “source C file” jeszcze raz do ASM (.s), podając nazwę pliku po parametrze “-o”. Następnie skompilujmy testASM.s do object file (test.o). Plik .o można więc skompilować zarówno z pliku assemblera jak i z pliku C z pomocą kompilatora GCC. Z pomocą opcji -c, przechodzimy krok dalej i kompilujemy nasz kod do pliku obiektowego “.o”.
Rysunek 8 – kompilacja do języka assembly, a następnie do pliku obiektowego.
Rysunek 9 – Zawartość test.o – Tekst jest nieczytelny w zwykłym edytorze.
Wygenerowane pliki możemy podejrzeć z pomocą hexeditora. Tutaj plik “test.o”. Ma on 100 linii
Rysunek 10 – plik obiektowy w edytorze heksadecymalnym.
Zlinkujmy teraz plik .o do pliku executable i spróbujmy uruchomić program. Linker rozwiązuje zależności poszczególnych plików i łączy wszystko w jeden plik wykonywalny.
Rysunek 11 – koniec procesu kompilacji, uruchomienie programu.
Z pomocą edytora binarnego można zobaczyć, że plik wykonywalny, ze względu na dołączane zależności ma około 1000 linii, co jest znacząco większe od pliku obiektowego mającego ledwo 100 linii.
Podsumowanie
Mamy nadzieję, że udało się nam przestawić Wam w zrozumiały sposób jak działa kompilacja. Za tydzień będziemy kontynuować tematy niskopoziomowego działania programów a także formatów plików. Jeśli jesteście zainteresowani, to zapraszamy do lektury!
Źródła:
- https://pl.wikipedia.org
- https://www.youtube.com/watch?v=QXjU9qTsYCc
- https://www.linkedin.com/pulse/how-computer-memory-works-prantik-sarkar
- https://stackoverflow.com/questions/31379245/compilation-flow-in-c