Что делают линкеры?

105

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

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

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

Теги:
linker
dynamic-linking

4 ответа

133
Лучший ответ

Чтобы понять компоновщики, это помогает сначала понять, что происходит "под капотом", когда вы конвертируете исходный файл (например, файл 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-ребята, которых я знаю, скажут, что существуют МНОГО БОЛЬШИЕ различия).

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

  • 9
    Отличный ответ. Кроме того, большинство современных компоновщиков удаляют избыточный код, такой как создание шаблона.
  • 1
    Это подходящее место, чтобы обсудить некоторые из этих различий?
Показать ещё 2 комментария
53

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

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

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

0) Введение

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

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

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

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

Пререквизиты: минимальное понимание:

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

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

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

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

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

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

  • помещает значение заполнителя 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

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

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

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

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

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

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

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

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

    Этот документ говорит, что 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

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

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

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

  • 0
    Стоит отметить, что некоторые ассемблеры или компиляторы могут выводить исполняемый файл напрямую, если компилятор «видит» все необходимое (обычно в одном исходном файле плюс все, что он включает в себя #include). Некоторые компиляторы, как правило, для небольших микро-систем, имеют этот единственный режим работы.
  • 0
    Да, я пытался дать ответ посреди дороги. Конечно, как и в вашем случае, верно и обратное: некоторые типы объектных файлов даже не выполняют полную генерацию кода; это делается компоновщиком (так работает оптимизация всей программы MSVC).
Показать ещё 1 комментарий
10

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

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

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

Ещё вопросы

Сообщество Overcoder
Наверх
Меню