Что делают линкеры? - PullRequest
103 голосов
/ 24 июля 2010

Мне всегда было интересно.Я знаю, что компиляторы преобразуют код, который вы пишете, в двоичные файлы, но что делают компоновщики?Они всегда были для меня загадкой.

Я примерно понимаю, что такое "связывание".Это когда ссылки на библиотеки и фреймворки добавляются в двоичный файл.Я ничего не понимаю за этим.Для меня это "просто работает".Я также понимаю основы динамического связывания, но не слишком глубоко.

Может ли кто-нибудь объяснить термины?

Ответы [ 4 ]

132 голосов
/ 24 июля 2010

Чтобы понять компоновщики, сначала нужно понять, что происходит "под капотом", когда вы конвертируете исходный файл (такой как файл C или C ++) в исполняемый файл (исполняемый файл - это файл, который может быть выполнен на ваша машина или чужая машина работает под управлением той же архитектуры).

Под капотом, когда программа компилируется, компилятор преобразует исходный файл в объектный байт-код. Этот байт-код (иногда называемый объектным кодом) является мнемонической инструкцией, которую понимает только архитектура вашего компьютера. Традиционно эти файлы имеют расширение .OBJ.

После создания объектного файла в игру вступает компоновщик. Чаще всего настоящая программа, которая делает что-нибудь полезное, должна ссылаться на другие файлы. Например, в C простая программа для вывода вашего имени на экран будет состоять из:

printf("Hello Kristina!\n");

Когда компилятор компилирует вашу программу в файл obj, он просто помещает ссылку на функцию printf. Компоновщик разрешает эту ссылку. Большинство языков программирования имеют стандартную библиотеку процедур, чтобы охватить основные вещи, ожидаемые от этого языка. Компоновщик связывает ваш файл OBJ с этой стандартной библиотекой. Компоновщик также может связать ваш файл OBJ с другими файлами OBJ. Вы можете создавать другие файлы OBJ, у которых есть функции, которые могут быть вызваны другим файлом OBJ. Компоновщик работает почти как копирование и вставка текстового процессора. Он «копирует» все необходимые функции, на которые ссылается ваша программа, и создает один исполняемый файл. Иногда другие библиотеки, которые копируются, зависят от других OBJ или библиотечных файлов. Иногда компоновщик должен быть довольно рекурсивным, чтобы выполнять свою работу.

Обратите внимание, что не все операционные системы создают один исполняемый файл. Например, Windows использует библиотеки DLL, которые хранят все эти функции в одном файле. Это уменьшает размер вашего исполняемого файла, но делает ваш исполняемый файл зависимым от этих конкретных DLL. В DOS использовались вещи, называемые Overlays (файлы .OVL). У этого было много целей, но одна из них заключалась в том, чтобы объединить часто используемые функции в одном файле (другой целью, которая, к вашему сведению, служила), была возможность помещать большие программы в память. DOS имеет ограничение в памяти, и оверлеи могут быть «выгруженным» из памяти, а другие оверлеи могут быть «загружены» поверх этой памяти, отсюда и название «оверлеи»). В Linux есть общие библиотеки, что, в сущности, является той же идеей, что и библиотеки DLL (ребята с жестким ядром Linux, которые, как я знаю, скажут мне, есть МНОГО БОЛЬШИХ отличий).

Надеюсь, это поможет вам понять!

60 голосов

Пример минимального перемещения адреса

Перемещение адреса является одной из важнейших функций связывания.

Итак, давайте посмотрим, как это работает, с минимальным примером.

0) Введение

Резюме: перемещение редактирует секцию .text объектных файлов для перевода:

  • адрес объектного файла
  • в окончательный адрес исполняемого файла

Это должно сделать компоновщик, поскольку компилятор видит только один входной файл за раз, но мы должны знать обо всех объектных файлах одновременно, чтобы решить, как:

  • разрешить неопределенные символы, такие как объявленные неопределенные функции
  • не конфликтовать с несколькими .text и .data разделами нескольких объектных файлов

Предварительные условия: минимальное понимание:

Связывание не имеет ничего общего с C или C ++ конкретно: компиляторы просто генерируют объектные файлы.Затем компоновщик принимает их в качестве входных данных, даже не зная, на каком языке их скомпилировали.Это также может быть Fortran.

Итак, чтобы уменьшить корку, давайте изучим привет-мир 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

, скомпилированный и собранный с:

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

с NASM 2.10.09.

1) .текст .o

Сначала мы декомпилируем секцию .text объектного файла:

objdump -d hello_world.o

который дает:

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

ключевые строки:

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

, которые должны переместить адрес строки приветствия в регистр rsi, который передается в записьсистемный вызов.

Но подождите!Как может компилятор узнать, где "Hello world!" окажется в памяти при загрузке программы?

Ну, не может, особенно после того, как мы связали кучу файлов .o вместе с несколькими .data section.

Это может сделать только компоновщик, поскольку только у него будут все эти объектные файлы.

Так что компилятор просто:

  • помещает значение-заполнитель 0x0 на скомпилированном выводе
  • дает компоновщику дополнительную информацию о том, как модифицировать скомпилированный код с правильными адресами

Эта "дополнительная информация" содержится в .rela.text раздел объектного файла

2) .rela.text

.rela.text расшифровывается как «перемещение раздела .text».

Слово перемещение используется потому, чтокомпоновщик должен будет переместить адрес из объекта в исполняемый файл.

Мы можем разобрать раздел .rela.text с помощью:

readelf -r hello_world.o

, который содержит;

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

Формат этого раздела зафиксирован документальноat: http://www.sco.com/developers/gabi/2003-12-17/ch4.reloc.html

Каждая запись сообщает компоновщику об одном адресе, который необходимо переместить, здесь у нас есть только один адрес для строки.

Немного упрощение, для этой конкретной строки мы имеемследующая информация:

  • Offset = C: каков первый байт .text, который изменяется в этой записи.

    Если мы оглянемся на декомпилированный текст,он точно находится внутри критического movabs $0x0,%rsi, и те, кто знает кодировку команд x86-64, заметят, что это кодирует 64-битную адресную часть инструкции.

  • Name = .data:адрес указывает на секцию .data

  • Type = R_X86_64_64, в которой указывается, что именно нужно сделать для перевода адреса.

    Это поле фактическизависит от процессора и, таким образом, документировано для расширения AMD64 System V ABI , раздел 4.4 «Перемещение».

    В этом документе говорится, что R_X86_64_64 делает:

    • Field = word64: 8 байтов, таким образом 00 00 00 00 00 00 00 00 по адресу 0xC

    • Calculation = S + A

      • S равно значению по перемещаемому адресу, таким образом 00 00 00 00 00 00 00 00
      • A - это добавление, которое здесь 0.Это поле записи перемещения.

      Итак, S + A == 0 и мы переместимся на самый первый адрес секции .data.

3). Текст из .out

Теперь давайте посмотрим на текстовую область исполняемого файла ld, сгенерированного для нас:

objdump -d hello_world.out

дает:

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

Таким образом, единственное, что изменилось в объектном файле, это критические строки:

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

которые теперь указывают на адрес 0x6000d8 (d8 00 60 00 00 00 00 00 в младшем порядке) вместо 0x0.

Это правильное место для строки hello_world?

Чтобы принять решение, мы должны проверить заголовки программы, которые сообщают Linux, куда загружать каждый раздел.

Мы разбираем их с помощью:

readelf -l hello_world.out

, что дает:

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

Это говорит нам о том, что второй раздел .data начинается с VirtAddr = 0x06000d8.

И единственное, что есть в разделе данных, - это наша строка приветствия.

Бонусный уровень

15 голосов
/ 24 июля 2010

В таких языках, как 'C', отдельные модули кода традиционно компилируются отдельно в двоичные объекты объектного кода, которые готовы выполнять во всех отношениях, за исключением того, что все ссылки, которые этот модуль делает вне себя (то есть на библиотеки или на другие модули) еще не были решены (то есть они пусты, ожидая, что кто-то придет и сделает все соединения).

Что делает компоновщик, так это просматривает все модули вместе, смотрит на то, что каждый модуль должен подключать к себе, и смотрит на все, что он экспортирует. Затем он исправляет все это и создает окончательный исполняемый файл, который затем можно запустить.

Там, где также происходит динамическое связывание, вывод компоновщика все еще не может быть запущен - все еще есть некоторые ссылки на внешние библиотеки, которые еще не разрешены, и они разрешаются ОС при время, когда оно загружает приложение (или, возможно, даже позже, во время выполнения).

10 голосов
/ 24 июля 2010

Когда компилятор создает объектный файл, он включает записи для символов, которые определены в этом объектном файле, и ссылки на символы, которые не определены в этом объектном файле. Компоновщик берет их и объединяет, чтобы (когда все работает правильно) все внешние ссылки из каждого файла удовлетворялись символами, которые определены в других объектных файлах.

Затем он объединяет все эти объектные файлы вместе и присваивает адреса каждому из символов, а там, где один объектный файл имеет внешнюю ссылку на другой объектный файл, он заполняет адрес каждого символа везде, где он используется другим объектом. В типичном случае он также создаст таблицу любых используемых абсолютных адресов, поэтому загрузчик может / «исправит» адреса при загрузке файла (т. Е. Он добавит адрес базовой загрузки к каждому из этих адреса, поэтому все они ссылаются на правильный адрес памяти).

Многие современные компоновщики могут также выполнять некоторые (в некоторых случаях много ) другие "вещи", такие как оптимизация кода способами, которые возможны только после того, как все модули видимый (например, удаление функций, которые были включены, потому что было возможно , что какой-то другой модуль мог бы вызвать их, но как только все модули собраны вместе, очевидно, что ничто их никогда не вызывает).

...