¿Qué hacen los enlazadores?

127

Siempre me lo he preguntado. Sé que los compiladores convierten el código que escribes en binarios, pero ¿qué hacen los enlazadores? Siempre han sido un misterio para mí.

Más o menos entiendo lo que es "vincular". Es cuando se agregan referencias a bibliotecas y marcos al binario. No entiendo nada más allá de eso. Para mí "simplemente funciona". También entiendo los conceptos básicos de la vinculación dinámica, pero nada demasiado profundo.

¿Alguien podría explicar los términos?

Kristina Brooks
fuente

Respuestas:

160

Para comprender los enlazadores, es útil entender primero qué sucede "bajo el capó" cuando convierte un archivo fuente (como un archivo C o C ++) en un archivo ejecutable (un archivo ejecutable es un archivo que puede ejecutarse en su máquina o la máquina de otra persona que ejecuta la misma arquitectura de máquina).

Bajo el capó, cuando se compila un programa, el compilador convierte el archivo fuente en código de bytes de objeto. Este código de bytes (a veces llamado código de objeto) son instrucciones mnemónicas que solo la arquitectura de su computadora comprende. Tradicionalmente, estos archivos tienen una extensión .OBJ.

Una vez creado el archivo objeto, el enlazador entra en juego. La mayoría de las veces, un programa real que haga algo útil deberá hacer referencia a otros archivos. En C, por ejemplo, un programa simple para imprimir su nombre en la pantalla consistiría en:

printf("Hello Kristina!\n");

Cuando el compilador compiló su programa en un archivo obj, simplemente pone una referencia alprintf función. El enlazador resuelve esta referencia. La mayoría de los lenguajes de programación tienen una biblioteca estándar de rutinas para cubrir las cosas básicas que se esperan de ese lenguaje. El vinculador vincula su archivo OBJ con esta biblioteca estándar. El vinculador también puede vincular su archivo OBJ con otros archivos OBJ. Puede crear otros archivos OBJ que tengan funciones a las que pueda llamar otro archivo OBJ. El enlazador funciona casi como copiar y pegar de un procesador de textos. "Copia" todas las funciones necesarias a las que hace referencia su programa y crea un solo ejecutable. A veces, otras bibliotecas que se copian dependen de otros archivos OBJ o de biblioteca. A veces un enlazador tiene que volverse bastante recursivo para hacer su trabajo.

Tenga en cuenta que no todos los sistemas operativos crean un solo ejecutable. Windows, por ejemplo, usa archivos DLL que mantienen todas estas funciones juntas en un solo archivo. Esto reduce el tamaño de su ejecutable, pero hace que su ejecutable dependa de estas DLL específicas. DOS solía usar cosas llamadas superposiciones (archivos .OVL). Esto tenía muchos propósitos, pero uno era mantener juntas las funciones de uso común en 1 archivo (otro propósito al que sirvió, en caso de que se lo pregunte, era poder ajustar programas grandes en la memoria. DOS tiene una limitación en la memoria y las superposiciones podrían ser "descargado" de la memoria y otras superposiciones podrían "cargarse" encima de esa memoria, de ahí el nombre, "superposiciones"). Linux ha compartido bibliotecas, que es básicamente la misma idea que las DLL (los chicos de Linux que conozco dirían que hay MUCHAS GRANDES diferencias).

Espero que esto te ayude a entender!

Icemanind
fuente
9
Gran respuesta. Además, la mayoría de los enlazadores modernos eliminarán el código redundante, como las instancias de plantillas.
Edward Strange el
1
¿Es este un lugar apropiado para repasar algunas de esas diferencias?
John P
2
Hola, suponga que mi archivo no hace referencia a ningún otro archivo. Supongamos que simplemente declaro e inicializo dos variables. ¿Este archivo fuente también irá al enlazador?
Mangesh Kherdekar
3
@MangeshKherdekar: Sí, siempre pasa por un enlazador. Es posible que el vinculador no vincule ninguna biblioteca externa, pero la fase de vinculación aún tiene que ocurrir para producir un ejecutable.
Icemanind
78

Ejemplo mínimo de reubicación de dirección

La reubicación de direcciones es una de las funciones cruciales de la vinculación.

Así que echemos un vistazo a cómo funciona con un ejemplo mínimo.

0) Introducción

Resumen: la reubicación edita la .textsección de archivos de objeto para traducir:

  • dirección de archivo de objeto
  • en la dirección final del ejecutable

El vinculador debe hacer esto porque el compilador solo ve un archivo de entrada a la vez, pero debemos conocer todos los archivos de objetos a la vez para decidir cómo:

  • resolver símbolos indefinidos como funciones indefinidas declaradas
  • no choque múltiples .texty .datasecciones de múltiples archivos de objetos

Requisitos previos: comprensión mínima de:

La vinculación no tiene nada que ver específicamente con C o C ++: los compiladores solo generan los archivos de objetos. El enlazador los toma como entrada sin saber qué idioma los compiló. Bien podría ser Fortran.

Entonces, para reducir la corteza, estudiemos un mundo hola Linux de NASM x86-64 ELF:

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 y ensamblado con:

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

con NASM 2.10.09.

1) .texto de .o

Primero descompilamos la .textsección del archivo objeto:

objdump -d hello_world.o

lo que da:

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

Las líneas cruciales son:

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

que debe mover la dirección de la cadena hello world al rsiregistro, que se pasa a la llamada al sistema de escritura.

¡Pero espera! ¿Cómo puede saber el compilador dónde "Hello world!"terminará en la memoria cuando se cargue el programa?

Bueno, no puede, especialmente después de vincular un montón de .oarchivos junto con varias .datasecciones.

Solo el enlazador puede hacer eso, ya que solo él tendrá todos esos archivos de objetos.

Entonces el compilador solo:

  • pone un valor de marcador de posición 0x0en la salida compilada
  • proporciona información adicional al vinculador sobre cómo modificar el código compilado con las buenas direcciones

Esta "información adicional" está contenida en el .rela.text sección del archivo objeto

2) .rela.text

.rela.text significa "reubicación de la sección .text".

La palabra reubicación se usa porque el vinculador tendrá que reubicar la dirección del objeto en el ejecutable.

Podemos desmontar la .rela.textsección con:

readelf -r hello_world.o

que 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

El formato de esta sección está documentado de forma fija en: http://www.sco.com/developers/gabi/2003-12-17/ch4.reloc.html

Cada entrada le dice al enlazador sobre una dirección que necesita ser reubicada, aquí solo tenemos una para la cadena.

Simplificando un poco, para esta línea en particular tenemos la siguiente información:

  • Offset = C: cuál es el primer byte del .textque cambia esta entrada.

    Si miramos hacia atrás al texto descompilado, está exactamente dentro de lo crítico movabs $0x0,%rsi, y aquellos que conocen la codificación de instrucciones x86-64 notarán que esto codifica la parte de la dirección de 64 bits de la instrucción.

  • Name = .data: la dirección apunta a la .datasección

  • Type = R_X86_64_64, que especifica exactamente qué cálculo se debe hacer para traducir la dirección.

    Este campo en realidad depende del procesador y, por lo tanto, está documentado en la extensión 4.4 ABI del Sistema V de AMD64 Sección 4.4 "Reubicación".

    Ese documento dice que R_X86_64_64sí:

    • Field = word64: 8 bytes, por lo tanto la 00 00 00 00 00 00 00 00dirección at0xC

    • Calculation = S + A

      • Ses el valor en la dirección que se reubica, por lo tanto00 00 00 00 00 00 00 00
      • Aes el agregado que está 0aquí. Este es un campo de la entrada de reubicación.

      Entonces, S + A == 0nos trasladaremos a la primera dirección de la .datasección.

3) .texto de .out

Ahora veamos el área de texto del ejecutable ldgenerado para nosotros:

objdump -d hello_world.out

da:

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

Entonces, lo único que cambió del archivo de objeto son las líneas críticas:

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

que ahora apuntan a la dirección 0x6000d8( d8 00 60 00 00 00 00 00en little-endian) en lugar de 0x0.

¿Es esta la ubicación correcta para la hello_worldcadena?

Para decidir tenemos que verificar los encabezados del programa, que le dicen a Linux dónde cargar cada sección.

Los desmontamos con:

readelf -l hello_world.out

lo que da:

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

Esto nos dice que la .datasección, que es la segunda, comienza en VirtAddr= 0x06000d8.

Y lo único en la sección de datos es nuestra cadena hello world.

Nivel de bonificación

Ciro Santilli 郝海东 冠状 病 六四 事件 法轮功
fuente
1
Amigo, eres increíble. El enlace al tutorial 'estructura global de un archivo ELF' está roto.
Adam Zahran
1
@AdamZahran gracias! ¡Estúpidas URL de páginas de GitHub que no pueden lidiar con barras!
Ciro Santilli 郝海东 冠状 病 六四 事件 法轮功
15

En lenguajes como 'C', los módulos individuales de código se compilan tradicionalmente por separado en blobs de código objeto, que está listo para ejecutarse en todos los aspectos además de que todas las referencias que ese módulo hace fuera de sí mismo (es decir, a bibliotecas u otros módulos) tienen aún no se han resuelto (es decir, están en blanco, a la espera de que alguien venga y haga todas las conexiones).

Lo que hace el enlazador es mirar todos los módulos juntos, ver lo que cada módulo necesita para conectarse al exterior, y ver todas las cosas que está exportando. Luego lo arregla todo y produce un ejecutable final, que luego se puede ejecutar.

Donde también se está ejecutando el enlace dinámico, la salida del enlazador todavía no se puede ejecutar: todavía hay algunas referencias a bibliotecas externas que aún no se han resuelto, y el SO las resuelve en el momento en que carga la aplicación (o posiblemente incluso más tarde durante la carrera).

Will Dean
fuente
Vale la pena señalar que algunos ensambladores o compiladores pueden generar un archivo ejecutable directamente si el compilador "ve" todo lo necesario (generalmente en un solo archivo fuente más cualquier cosa que # incluya). Algunos compiladores, generalmente para micros pequeños, tienen eso como su único modo de operación.
supercat
Sí, traté de dar una respuesta a mitad de camino. Por supuesto, al igual que su caso, lo contrario también es cierto, en el sentido de que algunos tipos de archivos de objetos ni siquiera tienen completa la generación de código; eso lo hace el enlazador (así es como funciona la optimización de todo el programa MSVC).
Will Dean
@WillDean y Link-Time Optimization de GCC, por lo que puedo decir, transmite todo el 'código' como lenguaje intermedio GIMPLE con metadatos requeridos, lo pone a disposición del enlazador y lo optimiza de una vez al final. (A pesar de lo que implica la documentación obsoleta, solo GIMPLE ahora se transmite de forma predeterminada, en lugar del antiguo modo 'gordo' con ambas representaciones del código objeto).
underscore_d
10

Cuando el compilador produce un archivo de objeto, incluye entradas para símbolos que están definidos en ese archivo de objeto y referencias a símbolos que no están definidos en ese archivo de objeto. El vinculador los toma y los junta para que (cuando todo funcione bien) todas las referencias externas de cada archivo se satisfagan mediante símbolos que se definen en otros archivos de objetos.

Luego combina todos esos archivos de objetos y asigna direcciones a cada uno de los símbolos, y cuando un archivo de objeto tiene una referencia externa a otro archivo de objeto, completa la dirección de cada símbolo donde sea que lo use otro objeto. En un caso típico, también creará una tabla de las direcciones absolutas utilizadas, por lo que el cargador puede "arreglar" las direcciones cuando se carga el archivo (es decir, agregará la dirección de carga base a cada una de esas direcciones). direcciones para que todos se refieran a la dirección de memoria correcta).

Unos cuantos enlazadores modernos también pueden llevar a cabo algunas (en algunos casos muchas ) otras "cosas", como optimizar el código de maneras que solo son posibles una vez que todos los módulos son visibles (por ejemplo, eliminar funciones incluidas) porque era posible que algún otro módulo los llamara, pero una vez que todos los módulos están juntos, es evidente que nada los llama).

Jerry Coffin
fuente