Jak działa proces kompilacji / łączenia?


416

Jak działa proces kompilacji i łączenia?

(Uwaga: ma to być wpis do często zadawanych pytań na temat C ++ w programie Stack Overflow . Jeśli chcesz skrytykować pomysł podania w tym formularzu odpowiedzi na najczęściej zadawane pytania, to miejsce na publikację na meta, które to wszystko rozpoczęło, byłoby odpowiednim miejscem. Odpowiedzi na to pytanie jest monitorowane w czacie C ++ , gdzie pomysł FAQ powstał w pierwszej kolejności, więc twoje odpowiedzi prawdopodobnie zostaną przeczytane przez tych, którzy wpadli na ten pomysł).

Odpowiedzi:


554

Kompilacja programu C ++ obejmuje trzy kroki:

  1. Przetwarzanie wstępne: preprocesor pobiera plik kodu źródłowego C ++ i zajmuje się #includes, #definesi innymi dyrektywami preprocesora. Wyjściem tego kroku jest „czysty” plik C ++ bez dyrektyw przedprocesowych.

  2. Kompilacja: kompilator pobiera dane wyjściowe procesora i tworzy z niego plik obiektowy.

  3. Łączenie: konsolidator pobiera pliki obiektów wygenerowane przez kompilator i tworzy bibliotekę lub plik wykonywalny.

Przetwarzanie wstępne

Preprocesor obsługuje dyrektywy preprocesora , takie jak #includei #define. Jest niezależny od składni języka C ++, dlatego należy go używać ostrożnie.

To działa na jednym pliku źródłowym C ++ w czasie, poprzez zastąpienie #includedyrektyw z treścią odpowiednich plików (zwykle jest to tylko deklaracje), robi wymianę makr ( #define), a następnie wybierając różne fragmenty tekstu w zależności od #if, #ifdefi #ifndefwskazówki.

Preprocesor działa na strumieniu tokenów przetwarzania wstępnego. Makropodstawienie definiuje się jako zastępowanie tokenów innymi tokenami (operator ##umożliwia scalenie dwóch tokenów, gdy ma to sens).

Po tym wszystkim preprocesor wytwarza pojedyncze wyjście, które jest strumieniem tokenów wynikających z transformacji opisanych powyżej. Dodaje także specjalne znaczniki, które informują kompilator, skąd pochodzi każda linia, aby mógł używać tych znaków do generowania rozsądnych komunikatów o błędach.

Na tym etapie można popełnić pewne błędy dzięki sprytnemu zastosowaniu dyrektyw #ifi #error.

Kompilacja

Etap kompilacji wykonywany jest na każdym wyjściu preprocesora. Kompilator analizuje czysty kod źródłowy C ++ (teraz bez dyrektyw preprocesora) i konwertuje go na kod asemblera. Następnie wywołuje bazowe zaplecze (asembler w toolchain), które składa ten kod w kod maszynowy, tworząc rzeczywisty plik binarny w jakimś formacie (ELF, COFF, a.out, ...). Ten plik obiektowy zawiera skompilowany kod (w formie binarnej) symboli zdefiniowanych na wejściu. Symbole w plikach obiektowych są określane według nazwy.

Pliki obiektowe mogą odnosić się do symboli, które nie są zdefiniowane. Dzieje się tak, gdy używasz deklaracji i nie podajesz jej definicji. Kompilatorowi to nie przeszkadza i chętnie utworzy plik obiektowy, o ile kod źródłowy jest poprawnie sformułowany.

Kompilatory zwykle pozwalają zatrzymać kompilację w tym momencie. Jest to bardzo przydatne, ponieważ dzięki niemu możesz skompilować każdy plik kodu źródłowego osobno. Zaletą tego jest to, że nie trzeba ponownie kompilować wszystkiego, jeśli zmienisz tylko jeden plik.

Wytworzone pliki obiektowe można umieścić w specjalnych archiwach zwanych bibliotekami statycznymi, aby ułatwić ich późniejsze użycie.

Na tym etapie zgłaszane są „zwykłe” błędy kompilatora, takie jak błędy składniowe lub błędy rozwiązywania przeciążenia.

Łączenie

Linker jest tym, co wytwarza ostateczne dane wyjściowe kompilacji z plików obiektowych utworzonych przez kompilator. To wyjście może być biblioteką współdzieloną (lub dynamiczną) (i chociaż nazwa jest podobna, nie mają wiele wspólnego z bibliotekami statycznymi wspomnianymi wcześniej) ani plikiem wykonywalnym.

Łączy wszystkie pliki obiektowe, zastępując odwołania do niezdefiniowanych symboli poprawnymi adresami. Każdy z tych symboli można zdefiniować w innych plikach obiektowych lub w bibliotekach. Jeśli są zdefiniowane w bibliotekach innych niż biblioteka standardowa, musisz o nich powiedzieć linkerowi.

Na tym etapie najczęstszymi błędami są brakujące definicje lub duplikaty definicji. To pierwsze oznacza, że ​​albo definicje nie istnieją (tzn. Nie są zapisane), albo że pliki obiektów lub biblioteki, w których się znajdują, nie zostały przekazane linkerowi. To ostatnie jest oczywiste: ten sam symbol został zdefiniowany w dwóch różnych plikach obiektowych lub bibliotekach.


39
Etap kompilacji wywołuje również asembler przed konwersją do pliku obiektowego.
manav mn

3
Gdzie są stosowane optymalizacje? Na pierwszy rzut oka wydaje się, że byłoby to zrobione na etapie kompilacji, ale z drugiej strony mogę sobie wyobrazić, że odpowiedniej optymalizacji można dokonać tylko po połączeniu.
Bart van Heukelom

6
@BartvanHeukelom tradycyjnie robiono to podczas kompilacji, ale współczesne kompilatory obsługują tak zwaną „optymalizację czasu łącza”, która ma tę zaletę, że może optymalizować w różnych jednostkach tłumaczeniowych.
R. Martinho Fernandes

3
Czy C ma takie same kroki?
Kevin Zhu,

6
Jeśli linker konwertuje symbole odnoszące się do klas / metody w bibliotekach na adresy, czy to oznacza, że ​​pliki binarne biblioteki są przechowywane w adresach pamięci, które system operacyjny utrzymuje na stałym poziomie? Jestem tylko zdezorientowany co do tego, w jaki sposób linker mógłby znać dokładny adres, powiedzmy, pliku binarnego stdio dla wszystkich systemów docelowych. Ścieżka do pliku zawsze będzie taka sama, ale dokładny adres może się zmienić, prawda?
Dan Carter,

42

Temat ten jest omawiany w CProgramming.com:
https://www.cprogramming.com/compilingandlinking.html

Oto, co napisał tam autor:

Kompilacja nie jest tym samym, co tworzenie pliku wykonywalnego! Zamiast tego tworzenie pliku wykonywalnego jest wieloetapowym procesem podzielonym na dwa składniki: kompilację i łączenie. W rzeczywistości, nawet jeśli program „dobrze się kompiluje”, może nie działać z powodu błędów w fazie łączenia. Całkowity proces przejścia od plików kodu źródłowego do pliku wykonywalnego można lepiej nazwać kompilacją.

Kompilacja

Kompilacja odnosi się do przetwarzania plików kodu źródłowego (.c, .cc lub .cpp) i tworzenia pliku „obiektowego”. Ten krok nie tworzy niczego, co użytkownik może uruchomić. Zamiast tego kompilator tworzy jedynie instrukcje języka maszynowego odpowiadające skompilowanemu plikowi kodu źródłowego. Na przykład, jeśli skompilujesz (ale nie połączysz) trzy osobne pliki, utworzysz trzy pliki obiektowe jako dane wyjściowe, każdy o nazwie .o lub .obj (rozszerzenie będzie zależeć od twojego kompilatora). Każdy z tych plików zawiera tłumaczenie pliku kodu źródłowego na plik języka maszynowego - ale nie można ich jeszcze uruchomić! Musisz zamienić je w pliki wykonywalne, z których może korzystać system operacyjny. Tam właśnie wchodzi linker.

Łączenie

Łączenie odnosi się do utworzenia jednego pliku wykonywalnego z wielu plików obiektowych. Na tym etapie często linker narzeka na niezdefiniowane funkcje (zwykle sam główny). Podczas kompilacji, jeśli kompilator nie może znaleźć definicji dla określonej funkcji, po prostu zakłada, że ​​funkcja została zdefiniowana w innym pliku. Jeśli tak nie jest, kompilator nie będzie wiedział - nie przegląda zawartości więcej niż jednego pliku na raz. Natomiast linker może patrzeć na wiele plików i próbować znaleźć referencje dla funkcji, o których nie wspomniano.

Możesz zapytać, dlaczego istnieją osobne kroki kompilacji i łączenia. Po pierwsze, prawdopodobnie łatwiej jest zaimplementować takie rozwiązania. Kompilator robi swoje, a linker robi swoje - dzięki oddzieleniu funkcji zmniejsza się złożoność programu. Kolejną (bardziej oczywistą) zaletą jest to, że pozwala na tworzenie dużych programów bez konieczności powtarzania kroku kompilacji za każdym razem, gdy plik jest zmieniany. Zamiast tego, używając tak zwanej „kompilacji warunkowej”, konieczne jest skompilowanie tylko tych plików źródłowych, które uległy zmianie; w pozostałych przypadkach pliki obiektowe stanowią wystarczającą ilość danych wejściowych dla konsolidatora. Wreszcie, ułatwia to implementację bibliotek wstępnie skompilowanego kodu: wystarczy utworzyć pliki obiektowe i połączyć je tak jak każdy inny plik obiektowy.

Aby uzyskać pełne korzyści z kompilacji warunków, prawdopodobnie łatwiej jest uzyskać program, który Ci pomoże, niż próbować zapamiętać, które pliki zmieniłeś od czasu ostatniej kompilacji. (Oczywiście możesz po prostu ponownie skompilować każdy plik, którego sygnatura czasowa jest większa niż sygnatura czasowa odpowiedniego pliku obiektowego.) Jeśli pracujesz w zintegrowanym środowisku programistycznym (IDE), może już to załatwić. Jeśli używasz narzędzi wiersza poleceń, istnieje fajne narzędzie o nazwie make, które jest dostarczane z większością dystrybucji * nix. Oprócz kompilacji warunkowej ma kilka innych przydatnych funkcji do programowania, takich jak umożliwianie różnych kompilacji programu - na przykład, jeśli masz wersję produkującą pełne dane wyjściowe do debugowania.

Znajomość różnicy między fazą kompilacji a fazą łącza może ułatwić wyszukiwanie błędów. Błędy kompilatora mają zazwyczaj charakter składniowy - brakujący średnik, dodatkowy nawias. Błędy łączenia zwykle dotyczą brakujących lub wielu definicji. Jeśli pojawi się błąd polegający na tym, że funkcja lub zmienna jest definiowana wiele razy z linkera, to dobra wskazówka, że ​​błąd polega na tym, że dwa pliki kodu źródłowego mają tę samą funkcję lub zmienną.


1
Nie rozumiem, że jeśli preprocesor zarządza takimi rzeczami, jak #include, aby utworzyć jeden super plik, to na pewno nie ma już po co linkować?
binarysmacker

@ binarysmacer Sprawdź, czy to, co napisałem poniżej, ma dla ciebie jakiś sens. Próbowałem opisać problem od wewnątrz.
Widok eliptyczny

3
@ binarysmacker Jest za późno na komentowanie tego, ale inni mogą uznać to za przydatne. youtu.be/D0TazQIkc8Q Zasadniczo dołączasz pliki nagłówkowe, a te pliki nagłówkowe zwykle zawierają tylko deklaracje zmiennych / funkcji, a nie definicje, definicje mogą znajdować się w osobnym pliku źródłowym. Tak więc preprocesor zawiera tylko deklaracje, a nie definicje, tutaj linker helps.Łączymy plik źródłowy, który używa zmiennej / funkcji z plikiem źródłowym, który je definiuje.
Karan Joisher

24

Na standardowym froncie:

  • jednostka tłumaczenie jest kombinacja plików źródłowych, zawartych nagłówków i pliki źródłowe pomniejszonych o liniach źródłowych pominiętych przez włączenie warunkowego dyrektywy preprocesora.

  • standard określa 9 etapów tłumaczenia. Pierwsze cztery odpowiadają przetwarzaniu wstępnemu, następne trzy to kompilacja, następne to tworzenie szablonów ( tworzenie jednostek tworzenia ), a ostatnie to łączenie.

W praktyce ósma faza (tworzenie szablonów) jest często wykonywana podczas procesu kompilacji, ale niektóre kompilatory opóźniają ją do fazy łączenia, a niektóre rozkładają na dwie części.


14
Czy możesz wymienić wszystkie 9 faz? Myślę, że to byłby miły dodatek do odpowiedzi. :)
lipiec


@jalf, po prostu dodaj instancję szablonu tuż przed ostatnią fazą w odpowiedzi wskazanej przez @sbi. IIRC istnieją subtelne różnice w dokładnym sformułowaniu w obsłudze szerokich znaków, ale nie sądzę, że pojawiają się one na etykietach diagramów.
AProgrammer

2
@sbi tak, ale to powinno być pytanie FAQ, prawda? Czy ta informacja nie powinna być dostępna tutaj ? ;)
jalf

3
@AProgrammmer: wystarczy wymienić je według nazwy. Wtedy ludzie wiedzą, czego szukać, jeśli chcą więcej szczegółów. Zresztą + 1'ed swoją odpowiedź w każdym razie :)
jalf

14

Chude jest to, że procesor ładuje dane z adresów pamięci, przechowuje dane na adresy pamięci i wykonuje instrukcje sekwencyjnie poza adresami pamięci, z pewnymi warunkowymi skokami w sekwencji przetwarzanych instrukcji. Każda z tych trzech kategorii instrukcji wymaga obliczenia adresu do komórki pamięci, która ma być użyta w instrukcji maszyny. Ponieważ instrukcje maszynowe mają zmienną długość w zależności od konkretnej instrukcji, a ponieważ podczas tworzenia naszego kodu maszynowego łączymy je ze sobą o zmiennej długości, proces obliczania i budowania adresów wymaga dwuetapowego procesu.

Najpierw ustalamy przydział pamięci najlepiej, jak potrafimy, zanim będziemy mogli dowiedzieć się, co dokładnie dzieje się w każdej komórce. Rozumiemy bajty, słowa lub cokolwiek, co tworzy instrukcje, literały i wszelkie dane. Po prostu zaczynamy przydzielać pamięć i budować wartości, które będą tworzyć program w miarę upływu czasu, i zanotuj miejsce, w którym musimy wrócić i naprawić adres. W tym miejscu umieszczamy manekina, aby po prostu wstawić lokalizację, abyśmy mogli nadal obliczać rozmiar pamięci. Na przykład nasz pierwszy kod maszynowy może zająć jedną komórkę. Następny kod maszynowy może zająć 3 komórki, w tym jedną komórkę kodu maszynowego i dwie komórki adresowe. Teraz naszym wskaźnikiem adresu jest 4. Wiemy, co dzieje się w komórce maszyny, która jest kodem operacyjnym, ale musimy poczekać, aby obliczyć, co idzie w komórkach adresowych, aż będziemy wiedzieć, gdzie te dane będą znajdować się, tj.

Gdyby istniał tylko jeden plik źródłowy, kompilator mógłby teoretycznie wytwarzać w pełni wykonywalny kod maszynowy bez linkera. W procesie dwuprzebiegowym może obliczyć wszystkie rzeczywiste adresy do wszystkich komórek danych, do których odwołuje się dowolne obciążenie maszyny lub instrukcje przechowywania. I może obliczyć wszystkie adresy bezwzględne, do których odnoszą się instrukcje bezwzględnego skoku. Tak działają prostsze kompilatory, jak ten w Forth, bez linkera.

Linker to coś, co pozwala na osobne kompilowanie bloków kodu. Może to przyspieszyć cały proces budowania kodu i pozwala na pewną elastyczność przy późniejszym użyciu bloków, innymi słowy można je przenieść do pamięci, na przykład dodając 1000 do każdego adresu, aby przeskoczyć blok o 1000 komórek adresu.

Tak więc to, co generuje kompilator, to nieobrobiony kod maszynowy, który nie jest jeszcze w pełni zbudowany, ale jest tak ułożony, abyśmy znali rozmiar wszystkiego, innymi słowy, abyśmy mogli zacząć obliczać, gdzie będą znajdować się wszystkie adresy bezwzględne. kompilator wyświetla również listę symboli, które są parami nazwa / adres. Symbole odnoszą się do przesunięcia pamięci w kodzie maszynowym w module o nazwie. Przesunięcie jest bezwzględną odległością do miejsca pamięci symbolu w module.

Tam dochodzimy do linkera. Linker najpierw uderza wszystkie te bloki kodu maszynowego razem od końca do końca i zapisuje, gdzie zaczyna się każdy z nich. Następnie oblicza adresy, które mają zostać naprawione, sumując względne przesunięcie w module i bezwzględną pozycję modułu w większym układzie.

Oczywiście uprościłem to, abyś mógł to zrozumieć, i celowo nie użyłem żargonu plików obiektowych, tablic symboli itp., Co jest dla mnie częścią zamieszania.


13

GCC kompiluje program C / C ++ w plik wykonywalny w 4 krokach.

Na przykład gcc -o hello hello.cprzeprowadza się w następujący sposób:

1. Wstępne przetwarzanie

Przetwarzanie wstępne za pomocą GNU C Preprocessor ( cpp.exe), który obejmuje nagłówki ( #include) i rozwija makra ( #define).

cpp hello.c > hello.i

Wynikowy plik pośredni „hello.i” zawiera rozszerzony kod źródłowy.

2. Kompilacja

Kompilator kompiluje wstępnie przetworzony kod źródłowy w kod zestawu dla określonego procesora.

gcc -S hello.i

Opcja -S określa wytwarzanie kodu asemblera zamiast kodu obiektowego. Wynikowy plik zestawu to „hello.s”.

3. Zgromadzenie

as.exeAsembler ( ) konwertuje kod asemblera na kod maszynowy w pliku obiektowym „hello.o”.

as -o hello.o hello.s

4. Linker

Na koniec linker ( ld.exe) łączy kod obiektu z kodem biblioteki, aby utworzyć plik wykonywalny „hello”.

    ld -o hello hello.o ... biblioteki ...

9

Spójrz na adres URL: http://faculty.cs.niu.edu/~mcmahon/CS241/Notes/compile.html
Kompletny proces kompilacji C ++ został wyraźnie przedstawiony w tym adresie URL.


2
Dzięki za udostępnienie tego, jest to tak proste i łatwe do zrozumienia.
Mark

Dobrze, zasoby, czy możesz tutaj podać podstawowe wyjaśnienie procesu, algorytm zaznacza odpowiedź jako niskiej jakości, ponieważ jest krótka i zawiera tylko adres URL.
JasonB

Miły krótki samouczek, który znalazłem: calleerlandsson.com/the-four-stages-of-compiling-ac-program
Guy Avraham
Korzystając z naszej strony potwierdzasz, że przeczytałeś(-aś) i rozumiesz nasze zasady używania plików cookie i zasady ochrony prywatności.
Licensed under cc by-sa 3.0 with attribution required.