Cosa fanno i linker?


127

Mi sono sempre chiesto. So che i compilatori convertono il codice che scrivi in ​​binari, ma cosa fanno i linker? Sono sempre stati un mistero per me.

Capisco approssimativamente cosa sia il "collegamento". È quando i riferimenti a librerie e framework vengono aggiunti al binario. Non capisco niente oltre a questo. Per me "funziona e basta". Capisco anche le basi del collegamento dinamico, ma niente di troppo profondo.

Qualcuno potrebbe spiegare i termini?

Risposte:


160

Per capire i linker, è utile capire prima cosa succede "sotto il cofano" quando converti un file sorgente (come un file C o C ++) in un file eseguibile (un file eseguibile è un file che può essere eseguito sulla tua macchina o la macchina di qualcun altro che esegue la stessa architettura della macchina).

Dietro le quinte, quando un programma viene compilato, il compilatore converte il file sorgente in codice byte oggetto. Questo codice byte (a volte chiamato codice oggetto) è istruzioni mnemoniche che solo l'architettura del computer comprende. Tradizionalmente, questi file hanno l'estensione .OBJ.

Dopo aver creato il file oggetto, entra in gioco il linker. Il più delle volte, un vero programma che fa qualcosa di utile avrà bisogno di fare riferimento ad altri file. In C, ad esempio, un semplice programma per stampare il tuo nome sullo schermo sarebbe composto da:

printf("Hello Kristina!\n");

Quando il compilatore ha compilato il programma in un file obj, inserisce semplicemente un riferimento alla printffunzione. Il linker risolve questo riferimento. La maggior parte dei linguaggi di programmazione ha una libreria standard di routine per coprire le cose di base che ci si aspetta da quel linguaggio. Il linker collega il tuo file OBJ a questa libreria standard. Il linker può anche collegare il tuo file OBJ con altri file OBJ. È possibile creare altri file OBJ con funzioni che possono essere richiamate da un altro file OBJ. Il linker funziona quasi come il copia e incolla di un elaboratore di testi. "Copia" tutte le funzioni necessarie a cui fa riferimento il programma e crea un unico eseguibile. A volte altre librerie che vengono copiate dipendono da altri OBJ o file di libreria. A volte un linker deve diventare piuttosto ricorsivo per fare il suo lavoro.

Notare che non tutti i sistemi operativi creano un singolo eseguibile. Windows, ad esempio, utilizza DLL che mantengono tutte queste funzioni insieme in un unico file. Ciò riduce la dimensione del tuo eseguibile, ma rende il tuo eseguibile dipendente da queste DLL specifiche. Il DOS usava cose chiamate Overlay (file .OVL). Questo aveva molti scopi, ma uno era quello di mantenere le funzioni comunemente usate insieme in un file (un altro scopo che serviva, nel caso ve lo stiate chiedendo, era quello di essere in grado di adattare grandi programmi in memoria.Il DOS ha una limitazione nella memoria e gli overlay potrebbero essere "scaricato" dalla memoria e altri overlay potrebbero essere "caricati" sopra quella memoria, da cui il nome, "overlays"). Linux ha librerie condivise, che è fondamentalmente la stessa idea delle DLL (i ragazzi Linux hard core che conosco mi direbbero che ci sono MOLTE GRANDI differenze).

Spero che questo ti aiuti a capire!


9
Bella risposta. Inoltre, la maggior parte dei linker moderni rimuoverà il codice ridondante come le istanze dei modelli.
Edward Strange

1
È questo un posto appropriato per esaminare alcune di queste differenze?
John P

2
Salve, supponiamo che il mio file non faccia riferimento a nessun altro file. Supponiamo che io dichiari e inizializzi semplicemente due variabili. Anche questo file sorgente andrà al linker?
Mangesh Kherdekar

3
@MangeshKherdekar - Sì, passa sempre attraverso un linker. Il linker potrebbe non collegare alcuna libreria esterna, ma la fase di collegamento deve ancora avvenire per produrre un eseguibile.
Icemanind

78

Esempio minimo di trasferimento dell'indirizzo

Il trasferimento degli indirizzi è una delle funzioni cruciali del collegamento.

Quindi diamo un'occhiata a come funziona con un esempio minimo.

0) Introduzione

Riepilogo: il riposizionamento modifica la .textsezione dei file oggetto da tradurre:

  • indirizzo del file oggetto
  • nell'indirizzo finale dell'eseguibile

Questo deve essere fatto dal linker perché il compilatore vede solo un file di input alla volta, ma dobbiamo conoscere tutti i file oggetto contemporaneamente per decidere come:

  • risolvere simboli indefiniti come funzioni dichiarate indefinite
  • non creare conflitti tra più .texte .datasezioni di più file oggetto

Prerequisiti: conoscenza minima di:

Il collegamento non ha nulla a che fare con C o C ++ in particolare: i compilatori generano semplicemente i file oggetto. Il linker quindi li prende come input senza mai sapere quale lingua li ha compilati. Potrebbe anche essere Fortran.

Quindi, per ridurre la crosta, studiamo un ciao mondo 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

compilato e assemblato con:

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

con NASM 2.10.09.

1) .testo di .o

Per prima cosa decompiliamo la .textsezione del file oggetto:

objdump -d hello_world.o

che dà:

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

le linee cruciali sono:

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

che dovrebbe spostare l'indirizzo della stringa hello world nel rsiregistro, che viene passato alla chiamata di sistema di scrittura.

Ma aspetta! Come può il compilatore sapere dove "Hello world!"andrà a finire in memoria quando il programma viene caricato?

Bene, non può, specialmente dopo aver collegato un gruppo di .ofile insieme a più .datasezioni.

Solo il linker può farlo poiché solo lui avrà tutti quei file oggetto.

Quindi il compilatore semplicemente:

  • inserisce un valore segnaposto 0x0sull'output compilato
  • fornisce alcune informazioni extra al linker su come modificare il codice compilato con gli indirizzi validi

Queste "informazioni aggiuntive" sono contenute nella .rela.textsezione del file oggetto

2) .rela.text

.rela.text sta per "trasferimento della sezione .text".

La parola rilocazione viene utilizzata perché il linker dovrà riposizionare l'indirizzo dall'oggetto nell'eseguibile.

Possiamo smontare la .rela.textsezione con:

readelf -r hello_world.o

che contiene;

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

Il formato di questa sezione è documentato fisso su: http://www.sco.com/developers/gabi/2003-12-17/ch4.reloc.html

Ogni voce indica al linker un indirizzo che deve essere riposizionato, qui ne abbiamo solo uno per la stringa.

Semplificando un po ', per questa particolare riga abbiamo le seguenti informazioni:

  • Offset = C: qual è il primo byte del .textche questa voce cambia.

    Se guardiamo indietro al testo decompilato, è esattamente all'interno del critico movabs $0x0,%rsi, e coloro che conoscono la codifica dell'istruzione x86-64 noteranno che questo codifica la parte dell'indirizzo a 64 bit dell'istruzione.

  • Name = .data: l'indirizzo punta alla .datasezione

  • Type = R_X86_64_64, che specifica esattamente quale calcolo deve essere fatto per tradurre l'indirizzo.

    Questo campo è effettivamente dipendente dal processore e quindi documentato nella sezione 4.4 "Trasferimento" dell'estensione ABI di AMD64 System V.

    Quel documento dice che R_X86_64_64:

    • Field = word64: 8 byte, quindi l' 00 00 00 00 00 00 00 00indirizzo0xC

    • Calculation = S + A

      • Sè il valore all'indirizzo che viene trasferito, quindi00 00 00 00 00 00 00 00
      • Aè l'addend che è 0qui. Questo è un campo della voce del trasferimento.

      Quindi S + A == 0verremo trasferiti al primo indirizzo della .datasezione.

3) .testo di .out

Ora guardiamo l'area di testo dell'eseguibile ldgenerato per noi:

objdump -d hello_world.out

dà:

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

Quindi l'unica cosa che è cambiata dal file oggetto sono le righe critiche:

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

che ora puntano all'indirizzo 0x6000d8( d8 00 60 00 00 00 00 00in little-endian) invece di 0x0.

È questa la posizione giusta per la hello_worldstringa?

Per decidere dobbiamo controllare gli header del programma, che dicono a Linux dove caricare ogni sezione.

Li smontiamo con:

readelf -l hello_world.out

che dà:

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

Questo ci dice che la .datasezione, che è la seconda, inizia da VirtAddr= 0x06000d8.

E l'unica cosa nella sezione dati è la nostra stringa hello world.

Livello bonus


1
Amico, sei fantastico. Il collegamento al tutorial "struttura globale di un file ELF" è interrotto.
Adam Zahran

1
@AdamZahran grazie! Stupidi URL di pagine GitHub che non possono gestire le barre!
Ciro Santilli 郝海东 冠状 病 六四 事件 法轮功

15

In linguaggi come 'C', i singoli moduli di codice sono tradizionalmente compilati separatamente in blob di codice oggetto, che è pronto per eseguire sotto ogni aspetto diverso da quello che hanno tutti i riferimenti che il modulo fa al di fuori di se stesso (cioè alle librerie o ad altri moduli) non ancora risolti (ovvero sono vuoti, in attesa che qualcuno si avvicini e faccia tutti i collegamenti).

Quello che fa il linker è guardare tutti i moduli insieme, guardare a cosa ogni modulo deve connettersi al di fuori di se stesso e guardare tutte le cose che sta esportando. Quindi risolve tutto e produce un eseguibile finale, che può quindi essere eseguito.

Dove è in corso anche il collegamento dinamico, l'output del linker non è ancora in grado di essere eseguito: ci sono ancora alcuni riferimenti a librerie esterne non ancora risolti e vengono risolti dal sistema operativo nel momento in cui carica l'app (o forse anche più tardi durante la corsa).


Vale la pena notare che alcuni assemblatori o compilatori possono produrre direttamente un file eseguibile se il compilatore "vede" tutto il necessario (tipicamente in un unico file sorgente più tutto ciò che #include). Alcuni compilatori, tipicamente per micro piccoli, hanno questa come unica modalità di funzionamento.
supercat

Sì, ho provato a dare una risposta a metà strada. Naturalmente, così come nel tuo caso, è vero anche il contrario, in quanto alcuni tipi di file oggetto non hanno nemmeno la generazione completa del codice; questo viene fatto dal linker (è così che funziona l'ottimizzazione dell'intero programma MSVC).
Will Dean

@WillDean e GCC's Link-Time Optimization, per quanto ne so - trasmette tutto il "codice" come linguaggio intermedio di GIMPLE con i metadati richiesti, lo rende disponibile al linker e ottimizza in una volta sola alla fine. (Nonostante ciò che implica la documentazione obsoleta, solo GIMPLE è ora trasmesso in streaming per impostazione predefinita, piuttosto che la vecchia modalità "fat" con entrambe le rappresentazioni del codice oggetto.)
underscore_d

10

Quando il compilatore produce un file oggetto, include voci per i simboli definiti in quel file oggetto e riferimenti a simboli che non sono definiti in quel file oggetto. Il linker li prende e li mette insieme in modo che (quando tutto funziona correttamente) tutti i riferimenti esterni da ogni file siano soddisfatti da simboli definiti in altri file oggetto.

Quindi combina tutti quei file oggetto insieme e assegna gli indirizzi a ciascuno dei simboli e, laddove un file oggetto ha un riferimento esterno a un altro file oggetto, inserisce l'indirizzo di ogni simbolo ovunque sia utilizzato da un altro oggetto. In un caso tipico, creerà anche una tabella di tutti gli indirizzi assoluti utilizzati, quindi il caricatore può / correggerà gli indirizzi quando il file viene caricato (cioè aggiungerà l'indirizzo di caricamento di base a ciascuno di questi indirizzi in modo che facciano tutti riferimento all'indirizzo di memoria corretto).

Un bel po 'di linker moderni possono anche eseguire alcune (in pochi casi molte ) altre "cose", come ottimizzare il codice in modi che sono possibili solo una volta che tutti i moduli sono visibili (ad esempio, rimuovere le funzioni che erano incluse perché era possibile che qualche altro modulo li chiamasse, ma una volta che tutti i moduli sono stati messi insieme è evidente che niente li chiama mai).

Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.