Was machen Linker?

127

Ich habe mich immer gefragt. Ich weiß, dass Compiler den von Ihnen geschriebenen Code in Binärdateien konvertieren, aber was machen Linker? Sie waren mir immer ein Rätsel.

Ich verstehe ungefähr, was "Verknüpfen" ist. Dies ist der Fall, wenn der Binärdatei Verweise auf Bibliotheken und Frameworks hinzugefügt werden. Darüber hinaus verstehe ich nichts. Für mich "funktioniert es einfach". Ich verstehe auch die Grundlagen der dynamischen Verknüpfung, aber nichts zu tiefes.

Könnte jemand die Begriffe erklären?

Kristina Brooks
quelle

Antworten:

159

Um Linker zu verstehen, ist es hilfreich, zunächst zu verstehen, was "unter der Haube" passiert, wenn Sie eine Quelldatei (z. B. eine C- oder C ++ - Datei) in eine ausführbare Datei konvertieren (eine ausführbare Datei ist eine Datei, die auf Ihrem Computer oder ausgeführt werden kann) die Maschine einer anderen Person, auf der dieselbe Maschinenarchitektur ausgeführt wird).

Unter der Haube konvertiert der Compiler beim Kompilieren eines Programms die Quelldatei in Objektbytecode. Dieser Bytecode (manchmal auch als Objektcode bezeichnet) ist eine mnemonische Anweisung, die nur Ihre Computerarchitektur versteht. Traditionell haben diese Dateien die Erweiterung .OBJ.

Nachdem die Objektdatei erstellt wurde, kommt der Linker ins Spiel. Meistens muss ein echtes Programm, das irgendetwas Nützliches tut, auf andere Dateien verweisen. In C besteht ein einfaches Programm zum Drucken Ihres Namens auf dem Bildschirm beispielsweise aus:

printf("Hello Kristina!\n");

Wenn der Compiler Ihr Programm in eine obj-Datei kompiliert hat, verweist er einfach auf die printfFunktion. Der Linker löst diese Referenz auf. Die meisten Programmiersprachen verfügen über eine Standardbibliothek von Routinen, um die grundlegenden Dinge abzudecken, die von dieser Sprache erwartet werden. Der Linker verknüpft Ihre OBJ-Datei mit dieser Standardbibliothek. Der Linker kann Ihre OBJ-Datei auch mit anderen OBJ-Dateien verknüpfen. Sie können andere OBJ-Dateien mit Funktionen erstellen, die von einer anderen OBJ-Datei aufgerufen werden können. Der Linker funktioniert fast wie das Kopieren und Einfügen eines Textverarbeitungsprogramms. Es "kopiert" alle notwendigen Funktionen, auf die Ihr Programm verweist, und erstellt eine einzige ausführbare Datei. Manchmal sind andere Bibliotheken, die kopiert werden, von anderen OBJ- oder Bibliotheksdateien abhängig. Manchmal muss ein Linker ziemlich rekursiv werden, um seine Arbeit zu erledigen.

Beachten Sie, dass nicht alle Betriebssysteme eine einzige ausführbare Datei erstellen. Windows verwendet beispielsweise DLLs, die alle diese Funktionen in einer einzigen Datei zusammenhalten. Dies reduziert die Größe Ihrer ausführbaren Datei, macht Ihre ausführbare Datei jedoch von diesen spezifischen DLLs abhängig. DOS verwendete früher Overlays (.OVL-Dateien). Dies hatte viele Zwecke, aber einer bestand darin, häufig verwendete Funktionen in einer Datei zusammenzuhalten (ein weiterer Zweck, falls Sie sich fragen, bestand darin, große Programme in den Speicher einpassen zu können. DOS hat eine Speicherbeschränkung und Überlagerungen können aus dem Speicher "entladen" werden und andere Überlagerungen könnten über diesen Speicher "geladen" werden, daher der Name "Überlagerungen"). Linux hat gemeinsam genutzte Bibliotheken, was im Grunde die gleiche Idee wie DLLs ist (Hardcore-Linux-Leute, die ich kenne, würden mir sagen, dass es VIELE GROSSE Unterschiede gibt).

Hoffe das hilft dir zu verstehen!

Icemanind
quelle
9
Gute Antwort. Darüber hinaus entfernen die meisten modernen Linker redundanten Code wie Vorlageninstanziierungen.
Edward Strange
1
Ist dies ein geeigneter Ort, um einige dieser Unterschiede zu besprechen?
John P
2
Hallo, Angenommen, meine Datei verweist auf keine andere Datei. Angenommen, ich deklariere und initialisiere einfach zwei Variablen. Wird diese Quelldatei auch an den Linker gesendet?
Mangesh Kherdekar
3
@ MangeshKherdekar - Ja, es geht immer über einen Linker. Der Linker verknüpft möglicherweise keine externen Bibliotheken, aber die Verknüpfungsphase muss noch stattfinden, um eine ausführbare Datei zu erstellen.
Icemanind
77

Minimales Beispiel für Adressverlagerung

Die Adressverlagerung ist eine der entscheidenden Funktionen der Verknüpfung.

Schauen wir uns also anhand eines minimalen Beispiels an, wie es funktioniert.

0) Einleitung

Zusammenfassung: Beim Verschieben wird der .textAbschnitt der zu übersetzenden Objektdateien bearbeitet :

  • Objektdatei Adresse
  • in die endgültige Adresse der ausführbaren Datei

Dies muss vom Linker durchgeführt werden, da der Compiler jeweils nur eine Eingabedatei sieht. Wir müssen jedoch alle Objektdateien gleichzeitig kennen, um entscheiden zu können, wie:

  • Undefinierte Symbole wie deklarierte undefinierte Funktionen auflösen
  • nicht mehrere .textund .dataAbschnitte mehrerer Objektdateien kollidieren

Voraussetzungen: minimales Verständnis von:

Das Verknüpfen hat nichts mit C oder C ++ zu tun: Compiler generieren nur die Objektdateien. Der Linker nimmt sie dann als Eingabe, ohne jemals zu wissen, welche Sprache sie zusammengestellt hat. Es könnte genauso gut Fortran sein.

Um die Kruste zu verringern, untersuchen wir eine Hallo-Welt für NASM x86-64 ELF Linux:

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

zusammengestellt und zusammengestellt mit:

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

mit NASM 2.10.09.

1) .text von .o

Zuerst dekompilieren wir den .textAbschnitt der Objektdatei:

objdump -d hello_world.o

was gibt:

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

Die entscheidenden Zeilen sind:

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

Dadurch sollte die Adresse des Hallo-Welt-Strings in das rsiRegister verschoben werden , das an den Schreibsystemaufruf übergeben wird.

Aber warte! Wie kann der Compiler möglicherweise wissen, wo "Hello world!"er beim Laden des Programms im Speicher landet?

Nun, es kann nicht, besonders nachdem wir eine Reihe von .oDateien mit mehreren .dataAbschnitten verknüpft haben .

Dies kann nur der Linker tun, da nur er alle diese Objektdateien hat.

Also der Compiler einfach:

  • Setzt einen Platzhalterwert 0x0auf die kompilierte Ausgabe
  • gibt dem Linker einige zusätzliche Informationen darüber, wie der kompilierte Code mit den guten Adressen geändert werden kann

Diese "zusätzlichen Informationen" sind im .rela.textAbschnitt der Objektdatei enthalten

2) .rela.text

.rela.text steht für "Verlagerung des .text-Abschnitts".

Das Wort Relocation wird verwendet, da der Linker die Adresse vom Objekt in die ausführbare Datei verschieben muss.

Wir können den .rela.textAbschnitt zerlegen mit:

readelf -r hello_world.o

was beinhaltet;

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

Das Format dieses Abschnitts ist fest dokumentiert unter: http://www.sco.com/developers/gabi/2003-12-17/ch4.reloc.html

Jeder Eintrag teilt dem Linker eine Adresse mit, die verschoben werden muss. Hier haben wir nur eine für die Zeichenfolge.

Um es ein wenig zu vereinfachen, für diese bestimmte Zeile haben wir die folgenden Informationen:

  • Offset = C: Was ist das erste Byte von, das .textdieser Eintrag ändert.

    Wenn wir auf den dekompilierten Text zurückblicken, befindet er sich genau im kritischen Bereich movabs $0x0,%rsi, und diejenigen, die die x86-64-Befehlskodierung kennen, werden feststellen, dass dies den 64-Bit-Adressenteil des Befehls codiert.

  • Name = .data: Die Adresse zeigt auf den .dataAbschnitt

  • Type = R_X86_64_64, die genau angibt, welche Berechnung durchgeführt werden muss, um die Adresse zu übersetzen.

    Dieses Feld ist tatsächlich prozessorabhängig und daher in der AMD64 System V ABI-Erweiterung Abschnitt 4.4 "Relocation" dokumentiert .

    In diesem Dokument heißt R_X86_64_64es:

    • Field = word64: 8 Bytes, also die 00 00 00 00 00 00 00 00at-Adresse0xC

    • Calculation = S + A

      • Sist also der Wert an der Adresse, die verschoben wird00 00 00 00 00 00 00 00
      • Aist der Zusatz, der 0hier ist. Dies ist ein Feld des Umzugseintrags.

      Also S + A == 0und wir werden an die allererste Adresse des .dataAbschnitts verlegt.

3) .text von .out

Schauen ldwir uns nun den Textbereich der für uns generierten ausführbaren Datei an:

objdump -d hello_world.out

gibt:

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

Das einzige, was sich gegenüber der Objektdatei geändert hat, sind die kritischen Zeilen:

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

die jetzt auf die Adresse 0x6000d8( d8 00 60 00 00 00 00 00in Little-Endian) anstelle von zeigen 0x0.

Ist dies der richtige Ort für die hello_worldZeichenfolge?

Um zu entscheiden, müssen wir die Programm-Header überprüfen, die Linux mitteilen, wo die einzelnen Abschnitte geladen werden sollen.

Wir zerlegen sie mit:

readelf -l hello_world.out

was gibt:

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

Dies sagt uns, dass der .dataAbschnitt, der der zweite ist, bei VirtAddr= beginnt 0x06000d8.

Und das einzige, was im Datenbereich steht, ist unser Hallo-Welt-String.

Bonuslevel

Ciro Santilli 郝海东 冠状 病 六四 事件 法轮功
quelle
1
Alter, du bist großartig. Der Link zum Lernprogramm "Globale Struktur einer ELF-Datei" ist unterbrochen.
Adam Zahran
1
@AdamZahran danke! Dumme GitHub-Seiten-URLs, die mit Schrägstrichen nicht umgehen können!
Ciro Santilli 法轮功 冠状 病 六四 事件 17
15

In Sprachen wie 'C' werden einzelne Codemodule traditionell separat zu Blobs von Objektcode kompiliert, die in jeder Hinsicht ausgeführt werden können, außer dass alle Verweise, die das Modul außerhalb von sich selbst macht (dh auf Bibliotheken oder andere Module) noch nicht gelöst (dh sie sind leer, bis jemand vorbeikommt und alle Verbindungen herstellt).

Was der Linker tut, ist, alle Module zusammen zu betrachten, zu sehen, was jedes Modul benötigt, um sich mit außerhalb von sich selbst zu verbinden, und alle Dinge zu betrachten, die es exportiert. Anschließend wird das Problem behoben und eine endgültige ausführbare Datei erstellt, die dann ausgeführt werden kann.

Wenn auch eine dynamische Verknüpfung stattfindet, kann die Ausgabe des Linkers immer noch nicht ausgeführt werden. Es gibt noch einige Verweise auf externe Bibliotheken, die noch nicht aufgelöst wurden, und sie werden vom Betriebssystem zum Zeitpunkt des Ladens der App (oder möglicherweise) aufgelöst noch später während des Laufs).

Will Dean
quelle
Es ist erwähnenswert, dass einige Assembler oder Compiler eine ausführbare Datei direkt ausgeben können, wenn der Compiler alles Notwendige "sieht" (normalerweise in einer einzelnen Quelldatei plus allem, was darin enthalten ist). Einige Compiler, typischerweise für kleine Mikros, haben dies als ihre einzige Betriebsart.
Supercat
Ja, ich habe versucht, mitten auf der Straße eine Antwort zu geben. Natürlich ist neben Ihrem Fall auch das Gegenteil der Fall, da bei einigen Arten von Objektdateien nicht einmal die vollständige Codegenerierung durchgeführt wird. Das macht der Linker (so funktioniert die MSVC-Gesamtprogrammoptimierung).
Will Dean
Soweit ich das beurteilen kann, wird die Link-Time-Optimierung von @WillDean und GCC als GIMPLE-Zwischensprache mit den erforderlichen Metadaten gestreamt, dem Linker zur Verfügung gestellt und am Ende auf einmal optimiert. (Ungeachtet dessen, was veraltete Dokumentation impliziert, wird jetzt standardmäßig nur GIMPLE gestreamt und nicht mehr der alte 'Fat'-Modus mit beiden Darstellungen des Objektcodes.)
underscore_d
10

Wenn der Compiler eine Objektdatei erstellt, enthält er Einträge für Symbole, die in dieser Objektdatei definiert sind, sowie Verweise auf Symbole, die in dieser Objektdatei nicht definiert sind. Der Linker nimmt diese und setzt sie zusammen, sodass (wenn alles richtig funktioniert) alle externen Referenzen aus jeder Datei durch Symbole erfüllt werden, die in anderen Objektdateien definiert sind.

Anschließend werden alle diese Objektdateien miteinander kombiniert und jedem der Symbole Adressen zugewiesen. Wenn eine Objektdatei einen externen Verweis auf eine andere Objektdatei enthält, wird die Adresse jedes Symbols dort ausgefüllt, wo sie von einem anderen Objekt verwendet wird. In einem typischen Fall wird auch eine Tabelle mit allen verwendeten absoluten Adressen erstellt, sodass der Loader die Adressen beim Laden der Datei "reparieren" kann / wird (dh er fügt jeder dieser Adressen die Basisladeadresse hinzu Adressen, damit sie sich alle auf die richtige Speicheradresse beziehen).

Nicht wenige moderne Linker können auch einige (in einigen Fällen viele ) andere "Dinge" ausführen, z. B. die Optimierung des Codes auf eine Weise, die nur möglich ist, wenn alle Module sichtbar sind (z. B. Entfernen der enthaltenen Funktionen) weil es möglich war , dass ein anderes Modul sie aufruft, aber sobald alle Module zusammengesetzt sind, ist es offensichtlich, dass nichts sie jemals aufruft).

Jerry Sarg
quelle