Co robią konsolidatorzy?

127

Zawsze się zastanawiałem. Wiem, że kompilatory konwertują kod, który piszesz na pliki binarne, ale co robią konsolidatory? Zawsze byli dla mnie tajemnicą.

Z grubsza rozumiem, co to jest „linkowanie”. Dzieje się tak, gdy do pliku binarnego są dodawane odwołania do bibliotek i struktur. Nie rozumiem nic poza tym. Dla mnie to „po prostu działa”. Rozumiem też podstawy dynamicznego linkowania, ale nic zbyt głębokiego.

Czy ktoś mógłby wyjaśnić warunki?

Kristina Brooks
źródło

Odpowiedzi:

160

Aby zrozumieć konsolidatory, warto najpierw zrozumieć, co dzieje się „pod maską”, gdy konwertujesz plik źródłowy (taki jak plik C lub C ++) na plik wykonywalny (plik wykonywalny to plik, który można wykonać na komputerze lub czyjś komputer z tą samą architekturą).

Pod maską, gdy program jest kompilowany, kompilator konwertuje plik źródłowy na kod bajtowy obiektu. Ten kod bajtowy (czasami nazywany kodem obiektowym) to instrukcje mnemoniczne zrozumiałe tylko dla architektury twojego komputera. Tradycyjnie te pliki mają rozszerzenie .OBJ.

Po utworzeniu pliku obiektowego do gry wchodzi konsolidator. Najczęściej prawdziwy program, który robi coś pożytecznego, będzie musiał odwoływać się do innych plików. Na przykład w C prosty program wyświetlający twoje imię na ekranie składałby się z:

printf("Hello Kristina!\n");

Gdy kompilator skompilował twój program do pliku obj, po prostu umieszcza odniesienie do pliku printf funkcji. Konsolidator rozwiązuje to odwołanie. Większość języków programowania ma standardową bibliotekę procedur, które obejmują podstawowe rzeczy, których oczekuje się od tego języka. Konsolidator łączy twój plik OBJ z tą standardową biblioteką. Konsolidator może również połączyć twój plik OBJ z innymi plikami OBJ. Możesz tworzyć inne pliki OBJ, które mają funkcje, które mogą być wywoływane przez inny plik OBJ. Linker działa prawie jak kopiowanie i wklejanie w edytorze tekstu. „Kopiuje” wszystkie niezbędne funkcje, do których program się odwołuje i tworzy pojedynczy plik wykonywalny. Czasami inne skopiowane biblioteki są zależne od jeszcze innych plików OBJ lub plików bibliotek. Czasami linker musi być dość rekurencyjny, aby wykonać swoją pracę.

Zwróć uwagę, że nie wszystkie systemy operacyjne tworzą jeden plik wykonywalny. Na przykład system Windows używa bibliotek DLL, które przechowują wszystkie te funkcje w jednym pliku. Zmniejsza to rozmiar pliku wykonywalnego, ale uzależnia plik wykonywalny od tych konkretnych bibliotek DLL. DOS używał rzeczy zwanych nakładkami (pliki .OVL). Miało to wiele celów, ale jednym z nich było przechowywanie powszechnie używanych funkcji w jednym pliku (innym celem, na wypadek gdybyś się zastanawiał, było umieszczanie dużych programów w pamięci. DOS ma ograniczenia pamięci i nakładki mogą być „wyładowane” z pamięci, a inne nakładki mogą być „załadowane” na wierzch tej pamięci, stąd nazwa „nakładki”). Linux ma współdzielone biblioteki, co jest zasadniczo tym samym pomysłem, co DLL (znani mi twardogłowi ludzie z Linuksa powiedzieliby mi, że jest WIELE DUŻYCH różnic).

Mam nadzieję, że to pomoże Ci zrozumieć!

Lodziarz
źródło
9
Świetna odpowiedź. Ponadto większość nowoczesnych konsolidatorów usuwa nadmiarowy kod, taki jak wystąpienia szablonów.
Edward Strange
1
Czy to jest odpowiednie miejsce, aby omówić niektóre z tych różnic?
John P
2
Cześć, załóżmy, że mój plik nie odwołuje się do żadnego innego pliku. Załóżmy, że po prostu deklaruję i inicjalizuję dwie zmienne. Czy ten plik źródłowy również trafi do konsolidatora?
Mangesh Kherdekar
3
@MangeshKherdekar - Tak, zawsze przechodzi przez linker. Konsolidator może nie łączyć żadnych bibliotek zewnętrznych, ale nadal musi nastąpić faza łączenia, aby utworzyć plik wykonywalny.
Icemanind
78

Przykład minimalnego relokacji adresu

Przenoszenie adresów jest jedną z kluczowych funkcji linkowania.

Przyjrzyjmy się więc, jak to działa na minimalnym przykładzie.

0) Wprowadzenie

Podsumowanie: relokacja edytuje .textsekcję plików obiektowych do przetłumaczenia:

  • adres pliku obiektu
  • na końcowy adres pliku wykonywalnego

Musi to zrobić konsolidator, ponieważ kompilator widzi tylko jeden plik wejściowy naraz, ale musimy wiedzieć o wszystkich plikach obiektowych naraz, aby zdecydować, jak:

  • rozwiązać niezdefiniowane symbole, takie jak zadeklarowane niezdefiniowane funkcje
  • nie kolidują ze sobą wielu .texti .datasekcji wielu plików obiektowych

Wymagania wstępne: minimalne zrozumienie:

Łączenie nie ma w szczególności nic wspólnego z C lub C ++: kompilatory po prostu generują pliki obiektowe. Następnie konsolidator przyjmuje je jako dane wejściowe, nigdy nie wiedząc, w jakim języku je skompilował. Równie dobrze mógłby to być Fortran.

Aby zmniejszyć problem, przyjrzyjmy się NASM x86-64 ELF Linux hello world:

section .data
    hello_world db "Hello world!", 10
section .text
    global _start
    _start:

        ; sys_write
        mov rax, 1
        mov rdi, 1
        mov rsi, hello_world
        mov rdx, 13
        syscall

        ; sys_exit
        mov rax, 60
        mov rdi, 0
        syscall

skompilowany i złożony z:

nasm -o hello_world.o hello_world.asm
ld -o hello_world.out hello_world.o

z NASM 2.10.09.

1). Tekst .o

Najpierw dekompilujemy .textsekcję pliku obiektowego:

objdump -d hello_world.o

co daje:

0000000000000000 <_start>:
   0:   b8 01 00 00 00          mov    $0x1,%eax
   5:   bf 01 00 00 00          mov    $0x1,%edi
   a:   48 be 00 00 00 00 00    movabs $0x0,%rsi
  11:   00 00 00
  14:   ba 0d 00 00 00          mov    $0xd,%edx
  19:   0f 05                   syscall
  1b:   b8 3c 00 00 00          mov    $0x3c,%eax
  20:   bf 00 00 00 00          mov    $0x0,%edi
  25:   0f 05                   syscall

kluczowe linie to:

   a:   48 be 00 00 00 00 00    movabs $0x0,%rsi
  11:   00 00 00

który powinien przenieść adres ciągu hello world do rsirejestru, który jest przekazywany do wywołania systemowego write.

Ale poczekaj! Skąd kompilator może wiedzieć, gdzie "Hello world!"znajdzie się w pamięci po załadowaniu programu?

Cóż, nie może, szczególnie po połączeniu kilku .oplików z wieloma .datasekcjami.

Tylko konsolidator może to zrobić, ponieważ tylko on będzie miał wszystkie te pliki obiektowe.

Więc kompilator po prostu:

  • umieszcza wartość zastępczą 0x0na skompilowanym wyjściu
  • daje linkerowi dodatkowe informacje o tym, jak zmodyfikować skompilowany kod z poprawnymi adresami

Te „dodatkowe informacje” są zawarte w .rela.textsekcji pliku obiektowego

2) .rela.text

.rela.text oznacza „przeniesienie sekcji .text”.

Stosowane jest słowo relokacja, ponieważ linker będzie musiał przenieść adres z obiektu do pliku wykonywalnego.

Możemy zdemontować .rela.textsekcję za pomocą:

readelf -r hello_world.o

który zawiera;

Relocation section '.rela.text' at offset 0x340 contains 1 entries:
  Offset          Info           Type           Sym. Value    Sym. Name + Addend
00000000000c  000200000001 R_X86_64_64       0000000000000000 .data + 0

Format tej sekcji jest udokumentowany pod adresem : http://www.sco.com/developers/gabi/2003-12-17/ch4.reloc.html

Każdy wpis mówi linkerowi o jednym adresie, który ma zostać przeniesiony, tutaj mamy tylko jeden dla łańcucha.

Trochę upraszczając, dla tej konkretnej linii mamy następujące informacje:

  • Offset = C: jaki jest pierwszy bajt .texttego wpisu.

    Jeśli spojrzymy wstecz na zdekompilowany tekst, znajduje się on dokładnie w środku krytycznym movabs $0x0,%rsi, a ci, którzy znają kodowanie instrukcji x86-64, zauważą, że koduje to 64-bitową część adresową instrukcji.

  • Name = .data: adres wskazuje na .datasekcję

  • Type = R_X86_64_64, który określa dokładnie, jakie obliczenia należy wykonać, aby przetłumaczyć adres.

    To pole jest w rzeczywistości zależne od procesora i dlatego jest udokumentowane w AMD64 System V ABI, sekcja 4.4 „Relokacja”.

    Ten dokument mówi, że R_X86_64_64tak:

    • Field = word64: 8 bajtów, czyli 00 00 00 00 00 00 00 00adres at0xC

    • Calculation = S + A

      • Sjest więc wartością pod adresem, który ma zostać przeniesiony00 00 00 00 00 00 00 00
      • Ajest dodatkiem, który jest 0tutaj. To jest pole wpisu relokacji.

      A więc S + A == 0zostaniemy przeniesieni pod pierwszy adres .datasekcji.

3). Tekst .out

Teraz spójrzmy na obszar tekstowy ldwygenerowanego dla nas pliku wykonywalnego :

objdump -d hello_world.out

daje:

00000000004000b0 <_start>:
  4000b0:   b8 01 00 00 00          mov    $0x1,%eax
  4000b5:   bf 01 00 00 00          mov    $0x1,%edi
  4000ba:   48 be d8 00 60 00 00    movabs $0x6000d8,%rsi
  4000c1:   00 00 00
  4000c4:   ba 0d 00 00 00          mov    $0xd,%edx
  4000c9:   0f 05                   syscall
  4000cb:   b8 3c 00 00 00          mov    $0x3c,%eax
  4000d0:   bf 00 00 00 00          mov    $0x0,%edi
  4000d5:   0f 05                   syscall

Więc jedyną rzeczą, która zmieniła się w pliku obiektowym, są linie krytyczne:

  4000ba:   48 be d8 00 60 00 00    movabs $0x6000d8,%rsi
  4000c1:   00 00 00

które teraz wskazują adres 0x6000d8( d8 00 60 00 00 00 00 00w little-endian) zamiast0x0 .

Czy to właściwa lokalizacja dla hello_world ciągu?

Aby zdecydować, musimy sprawdzić nagłówki programu, które mówią Linuksowi, gdzie załadować każdą sekcję.

Demontujemy je za pomocą:

readelf -l hello_world.out

co daje:

Program Headers:
  Type           Offset             VirtAddr           PhysAddr
                 FileSiz            MemSiz              Flags  Align
  LOAD           0x0000000000000000 0x0000000000400000 0x0000000000400000
                 0x00000000000000d7 0x00000000000000d7  R E    200000
  LOAD           0x00000000000000d8 0x00000000006000d8 0x00000000006000d8
                 0x000000000000000d 0x000000000000000d  RW     200000

 Section to Segment mapping:
  Segment Sections...
   00     .text
   01     .data

To mówi nam, że .datasekcja, która jest drugą, zaczyna się od VirtAddr= 0x06000d8.

Jedyną rzeczą w sekcji danych jest nasz ciąg hello world.

Poziom bonusowy

Ciro Santilli 郝海东 冠状 病 六四 事件 法轮功
źródło
1
Koleś, jesteś niesamowity. Odsyłacz do samouczka „globalna struktura pliku ELF” jest uszkodzony.
Adam Zahran
1
@AdamZahran thanks! Głupie adresy URL stron GitHub, które nie obsługują ukośników!
Ciro Santilli 郝海东 冠状 病 六四 事件 法轮功
15

W językach takich jak `` C '' poszczególne moduły kodu są tradycyjnie kompilowane osobno w bloki kodu obiektowego, który jest gotowy do wykonania pod każdym innym względem, poza tym, że wszystkie odniesienia, które moduł tworzy poza sobą (tj. Do bibliotek lub innych modułów) mają nie zostały jeszcze rozwiązane (tj. są puste, oczekują, że ktoś przyjdzie i dokona wszystkich połączeń).

To, co robi konsolidator, to przeglądanie wszystkich modułów razem, sprawdzanie, co każdy moduł musi łączyć się ze sobą, i patrzenie na wszystkie rzeczy, które eksportuje. Następnie naprawia to wszystko i tworzy ostateczny plik wykonywalny, który można następnie uruchomić.

Tam, gdzie zachodzi również dynamiczne łączenie, dane wyjściowe konsolidatora nadal nie mogą zostać uruchomione - nadal istnieją pewne odniesienia do bibliotek zewnętrznych, które nie zostały jeszcze rozwiązane i są one rozwiązywane przez system operacyjny w momencie ładowania aplikacji (lub prawdopodobnie nawet później podczas biegu).

Will Dean
źródło
Warto zauważyć, że niektóre asemblery lub kompilatory mogą bezpośrednio wyprowadzać plik wykonywalny, jeśli kompilator „widzi” wszystko, co niezbędne (zazwyczaj w jednym pliku źródłowym plus wszystko, co # zawiera). Kilka kompilatorów, zazwyczaj dla małych mikroukładów, używa tego jako jedynego trybu działania.
supercat
Tak, starałem się udzielić odpowiedzi pośrodku drogi. Oczywiście, podobnie jak w twoim przypadku, jest też odwrotnie, ponieważ niektóre rodzaje plików obiektowych nie mają nawet pełnego generowania kodu; robi to konsolidator (tak działa optymalizacja całego programu MSVC).
Will Dean,
@WillDean i GCC's Link-Time Optimization, o ile wiem - przesyła cały `` kod '' jako język pośredni GIMPLE z wymaganymi metadanymi, udostępnia go linkerowi i optymalizuje za jednym razem na końcu. (Pomimo tego, co sugeruje nieaktualna dokumentacja, domyślnie przesyłany jest teraz tylko GIMPLE, a nie stary tryb `` gruby '' z obiema reprezentacjami kodu wynikowego.)
underscore_d
10

Gdy kompilator tworzy plik obiektu, zawiera wpisy dla symboli, które są zdefiniowane w tym pliku obiektowym, oraz odwołania do symboli, które nie są zdefiniowane w tym pliku obiektów. Konsolidator bierze je i składa razem, więc (kiedy wszystko działa poprawnie) wszystkie odniesienia zewnętrzne z każdego pliku są obsługiwane przez symbole zdefiniowane w innych plikach obiektowych.

Następnie łączy wszystkie te pliki obiektowe razem i przypisuje adresy do każdego z symboli, a gdy jeden plik obiektowy ma odniesienie zewnętrzne do innego pliku obiektowego, wypełnia adres każdego symbolu wszędzie tam, gdzie jest używany przez inny obiekt. W typowym przypadku utworzy również tabelę wszystkich użytych adresów bezwzględnych, więc program ładujący może / będzie "naprawiać" adresy podczas ładowania pliku (tj. Doda adres podstawowego obciążenia do każdego z nich adresy, więc wszystkie odnoszą się do prawidłowego adresu pamięci).

Całkiem sporo nowoczesnych linkerów może również wykonywać część (w kilku przypadkach dużo ) innych "rzeczy", takich jak optymalizacja kodu w sposób, który jest możliwy tylko wtedy, gdy wszystkie moduły są widoczne (np. Usuwanie funkcji, które zostały dołączone ponieważ było możliwe, że jakiś inny moduł może je wywołać, ale po złożeniu wszystkich modułów widać, że nic ich nie wywołuje).

Jerry Coffin
źródło