O que os vinculadores fazem?

127

Eu sempre me perguntei. Eu sei que os compiladores convertem o código que você escreve em binários, mas o que os vinculadores fazem? Eles sempre foram um mistério para mim.

Eu compreendo aproximadamente o que é 'ligar'. É quando referências a bibliotecas e estruturas são adicionadas ao binário. Eu não entendo nada além disso. Para mim, "simplesmente funciona". Eu também entendo o básico da vinculação dinâmica, mas nada muito profundo.

Alguém poderia explicar os termos?

Kristina Brooks
fonte

Respostas:

160

Para entender os vinculadores, é melhor entender primeiro o que acontece "oculto" quando você converte um arquivo de origem (como um arquivo C ou C ++) em um arquivo executável (um arquivo executável é um arquivo que pode ser executado em sua máquina ou em outro computador). máquina de outra pessoa executando a mesma arquitetura de máquina).

Sob o capô, quando um programa é compilado, o compilador converte o arquivo de origem em código de byte do objeto. Esse código de byte (às vezes chamado de código de objeto) é instruções mnemônicas que apenas a arquitetura do computador entende. Tradicionalmente, esses arquivos têm uma extensão .OBJ.

Depois que o arquivo de objeto é criado, o vinculador entra em ação. Na maioria das vezes, um programa real que faça qualquer coisa útil precisará fazer referência a outros arquivos. Em C, por exemplo, um programa simples para imprimir seu nome na tela consistiria em:

printf("Hello Kristina!\n");

Quando o compilador compilou seu programa em um arquivo obj, ele simplesmente coloca uma referência à printffunção. O vinculador resolve essa referência. A maioria das linguagens de programação possui uma biblioteca padrão de rotinas para cobrir o material básico esperado dessa linguagem. O vinculador vincula seu arquivo OBJ a esta biblioteca padrão. O vinculador também pode vincular seu arquivo OBJ com outros arquivos OBJ. Você pode criar outros arquivos OBJ que possuem funções que podem ser chamadas por outro arquivo OBJ. O vinculador funciona quase como copiar e colar de um processador de texto. Ele "copia" todas as funções necessárias que o seu programa faz referência e cria um único executável. Às vezes, outras bibliotecas copiadas dependem de outros arquivos OBJ ou de biblioteca. Às vezes, um vinculador precisa ficar bastante recursivo para fazer seu trabalho.

Observe que nem todos os sistemas operacionais criam um único executável. O Windows, por exemplo, usa DLLs que mantêm todas essas funções juntas em um único arquivo. Isso reduz o tamanho do seu executável, mas o torna dependente dessas DLLs específicas. O DOS costumava usar coisas chamadas Sobreposições (arquivos .OVL). Isso tinha muitos propósitos, mas um era manter as funções comumente usadas juntas em um arquivo (outro objetivo que serviu, caso você esteja se perguntando, era conseguir encaixar programas grandes na memória. O DOS tem uma limitação na memória e sobreposições) ser "descarregado" da memória e outras sobreposições podem ser "carregadas" na parte superior da memória, daí o nome "sobreposições"). O Linux compartilhou bibliotecas, o que é basicamente a mesma idéia que as DLLs (os caras mais importantes do Linux que eu conheço me diriam que existem MUITAS GRANDES diferenças).

Espero que isso ajude você a entender!

Icemanind
fonte
9
Ótima resposta. Além disso, a maioria dos vinculadores modernos removerá código redundante, como instanciações de modelo.
Edward Strange
1
Este é um local apropriado para analisar algumas dessas diferenças?
John P1
2
Oi, Suponha que meu arquivo não faça referência a nenhum outro arquivo. Suponha que eu simplesmente declare e inicialize duas variáveis. Esse arquivo de origem também irá para o vinculador?
Mangesh Kherdekar
3
@MangeshKherdekar - Sim, ele sempre passa por um vinculador. O vinculador pode não vincular nenhuma biblioteca externa, mas a fase de vinculação ainda precisa ocorrer para produzir um executável.
Icemanind
78

Exemplo mínimo de realocação de endereços

A realocação de endereços é uma das funções cruciais da vinculação.

Então, vamos dar uma olhada em como funciona com um exemplo mínimo.

0) Introdução

Resumo: a realocação edita a .textseção dos arquivos de objeto para traduzir:

  • endereço do arquivo de objeto
  • para o endereço final do executável

Isso deve ser feito pelo vinculador, porque o compilador vê apenas um arquivo de entrada por vez, mas precisamos conhecer todos os arquivos de objetos de uma só vez para decidir como:

  • resolver símbolos indefinidos como funções indefinidas declaradas
  • não colidir múltiplos .texte .dataseções de múltiplos arquivos de objeto

Pré-requisitos: entendimento mínimo de:

A vinculação não tem nada a ver com C ou C ++ especificamente: os compiladores apenas geram os arquivos de objeto. O vinculador as aceita como entrada, sem nunca saber qual idioma as compilou. Pode muito bem ser Fortran.

Então, para reduzir a crosta, vamos estudar um olá mundo 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

compilado e montado com:

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

com NASM 2.10.09.

1) .texto de .o

Primeiro, descompilamos a .textseção do arquivo de objeto:

objdump -d hello_world.o

que 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

as linhas cruciais são:

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

que deve mover o endereço da cadeia hello world para o rsiregistro, que é passado para a chamada do sistema de gravação.

Mas espere! Como o compilador pode saber onde "Hello world!"ficará a memória quando o programa for carregado?

Bem, não pode, especialmente depois de vincularmos um monte de .oarquivos a várias .dataseções.

Somente o vinculador pode fazer isso, pois somente ele terá todos esses arquivos de objeto.

Então, o compilador apenas:

  • coloca um valor de espaço reservado 0x0na saída compilada
  • fornece algumas informações extras ao vinculador sobre como modificar o código compilado com os bons endereços

Esta "informação extra" está contida na .rela.textseção do arquivo de objeto

2) .rela.text

.rela.text significa "realocação da seção .text".

A palavra realocação é usada porque o vinculador precisará realocar o endereço do objeto para o executável.

Podemos desmontar a .rela.textseção com:

readelf -r hello_world.o

que contém;

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

O formato desta seção é corrigido documentado em: http://www.sco.com/developers/gabi/2003-12-17/ch4.reloc.html

Cada entrada informa ao vinculador sobre um endereço que precisa ser realocado, aqui temos apenas um para a sequência.

Simplificando um pouco, para esta linha em particular, temos as seguintes informações:

  • Offset = C: qual é o primeiro byte do .textque esta entrada altera.

    Se olharmos para o texto descompilado, ele estará exatamente dentro do crítico movabs $0x0,%rsie aqueles que conhecem a codificação da instrução x86-64 perceberão que isso codifica a parte do endereço de 64 bits da instrução.

  • Name = .data: o endereço aponta para a .dataseção

  • Type = R_X86_64_64, que especifica exatamente o que cálculo deve ser feito para converter o endereço.

    Esse campo é realmente dependente do processador e, portanto, documentado na seção 4.464 "Relocação" da extensão AMD64 System V ABI .

    Esse documento diz que R_X86_64_64:

    • Field = word64: 8 bytes, portanto, o 00 00 00 00 00 00 00 00endereço0xC

    • Calculation = S + A

      • Sé o valor no endereço que está sendo realocado,00 00 00 00 00 00 00 00
      • Aé o adendo que está 0aqui. Este é um campo da entrada de realocação.

      Então S + A == 0, seremos realocados para o primeiro endereço da .dataseção.

3). Texto de .out

Agora vamos ver a área de texto do executável ldgerado para nós:

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

Portanto, a única coisa que mudou no arquivo de objeto são as linhas críticas:

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

que agora apontam para o endereço 0x6000d8( d8 00 60 00 00 00 00 00em little-endian) em vez de 0x0.

Esse é o local certo para a hello_worldstring?

Para decidir, precisamos verificar os cabeçalhos do programa, que informam ao Linux onde carregar cada seção.

Nós os desmontamos com:

readelf -l hello_world.out

que 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

Isso nos diz que a .dataseção, que é a segunda, começa em VirtAddr= 0x06000d8.

E a única coisa na seção de dados é a nossa string hello world.

Nível de bônus

Ciro Santilli adicionou uma nova foto
fonte
1
Cara, você é demais. O link para o tutorial 'estrutura global de um arquivo ELF' está quebrado.
Adam Zahran
1
@AdamZahran thanks! Estúpido GitHub páginas URLs que não podem lidar com barras!
Ciro Santilli escreveu
15

Em linguagens como 'C', módulos de código individuais são tradicionalmente compilados separadamente em blobs de código de objeto, prontos para serem executados em todos os aspectos que não sejam todas as referências que o módulo faz fora de si (por exemplo, para bibliotecas ou outros módulos). ainda não foi resolvido (ou seja, eles estão em branco, aguardando alguém aparecer e fazer todas as conexões).

O que o vinculador faz é olhar para todos os módulos juntos, para o que cada módulo precisa se conectar com fora de si e para todas as coisas que está exportando. Em seguida, ele corrige tudo e produz um executável final, que pode ser executado.

Onde a vinculação dinâmica também está em andamento, a saída do vinculador ainda não pode ser executada - ainda existem algumas referências a bibliotecas externas ainda não resolvidas e elas são resolvidas pelo SO no momento em que carrega o aplicativo (ou possivelmente até mais tarde durante a corrida).

Will Dean
fonte
Vale a pena notar que alguns montadores ou compiladores podem gerar um arquivo executável diretamente se o compilador "vê" tudo o necessário (normalmente em um único arquivo de origem mais o que ele inclui). Alguns compiladores, normalmente para micros pequenos, têm esse como seu único modo de operação.
Super23
Sim, tentei dar uma resposta no meio da estrada. Obviamente, assim como no seu caso, o oposto também é verdadeiro, pois alguns tipos de arquivos de objetos nem sequer têm a geração completa de código; isso é feito pelo vinculador (é assim que a otimização de todo o programa da MSVC funciona).
Will Dean
O @WillDean e o Link-Time Optimization do GCC, tanto quanto eu posso dizer - ele transmite todo o 'código' como linguagem intermediária do GIMPLE com os metadados necessários, torna isso disponível para o vinculador e otimiza de uma só vez no final. (Apesar de que documentação desatualizada implica, única GIMPLE é agora transmitido por padrão, em vez do velho modo de 'gordo' com as duas representações do código de objeto.)
underscore_d
10

Quando o compilador produz um arquivo de objeto, ele inclui entradas para símbolos definidos nesse arquivo de objeto e referências a símbolos que não são definidos nesse arquivo de objeto. O vinculador as pega e as reúne para que (quando tudo funcione corretamente) todas as referências externas de cada arquivo sejam satisfeitas pelos símbolos definidos em outros arquivos de objetos.

Em seguida, combina todos esses arquivos de objeto e atribui endereços a cada um dos símbolos, e onde um arquivo de objeto tem uma referência externa a outro arquivo de objeto, ele preenche o endereço de cada símbolo sempre que for usado por outro objeto. Em um caso típico, ele também criará uma tabela com todos os endereços absolutos usados, para que o carregador possa / conserte os endereços quando o arquivo for carregado (ou seja, adicionará o endereço de carregamento base a cada um desses endereços para que todos se refiram ao endereço de memória correto).

Alguns linkers modernos também podem executar algumas (em alguns casos, muito ) outras "coisas", como otimizar o código de maneiras que só são possíveis quando todos os módulos estiverem visíveis (por exemplo, removendo funções incluídas) porque era possível que algum outro módulo os chamasse, mas depois que todos os módulos estiverem reunidos, é evidente que nada os chama).

Jerry Coffin
fonte