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

127

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

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

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

Кристина Брукс
источник

Ответы:

161

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

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

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

printf("Hello Kristina!\n");

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

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

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

Icemanind
источник
9
Отличный ответ. Кроме того, большинство современных компоновщиков удаляют избыточный код, такой как экземпляры шаблонов.
Эдвард Стрэндж
1
Это подходящее место, чтобы рассмотреть некоторые из этих различий?
Джон П.
2
Привет! Предположим, мой файл не ссылается ни на один другой файл. Предположим, я просто объявляю и инициализирую две переменные. Будет ли этот исходный файл также отправлен компоновщику?
Мангеш Хердекар
3
@MangeshKherdekar - Да, всегда через компоновщик. Компоновщик может не связывать какие-либо внешние библиотеки, но фаза компоновки все равно должна произойти для создания исполняемого файла.
Icemanind
78

Минимальный пример перемещения адреса

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

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

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) .text из .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

который должен переместить адрес строки hello world в 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, и те, кто знает кодировку инструкций x86-64, заметят, что она кодирует 64-битную адресную часть инструкции.

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

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

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

    В этом документе говорится, что 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) .text из .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.

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

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

Чиро Сантилли 郝海东 冠状 病 六四 事件 法轮功
источник
1
Чувак, ты классный. Ссылка на учебник «Глобальная структура файла ELF» не работает.
Адам Захран
1
@AdamZahran, спасибо! Глупые URL-адреса страниц GitHub, которые не могут работать с косой чертой!
Чиро Сантилли 郝海东 冠状 病 六四 事件 法轮功
15

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

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

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

Уилл Дин
источник
Стоит отметить, что некоторые ассемблеры или компиляторы могут выводить исполняемый файл напрямую, если компилятор «видит» все необходимое (обычно в одном исходном файле плюс все, что он # включает). В некоторых компиляторах, обычно для небольших микропрограмм, это единственный режим работы.
supercat
Да, я попытался дать средний ответ. Конечно, как и в вашем случае, верно и обратное, поскольку некоторые типы объектных файлов даже не имеют полной генерации кода; это делается компоновщиком (так работает оптимизация всей программы MSVC).
Уилл Дин
Насколько я могу судить, @WillDean и оптимизация времени компоновки GCC - он передает весь «код» как промежуточный язык GIMPLE с необходимыми метаданными, делает их доступными для компоновщика и оптимизирует за один раз в конце. (Несмотря на то, что подразумевается в устаревшей документации, по умолчанию теперь транслируется только GIMPLE, а не старый «толстый» режим с обоими представлениями объектного кода.)
underscore_d
10

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

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

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

Джерри Гроб
источник