Что делают линкеры?
Я всегда задавался вопросом. Я знаю, что компиляторы преобразуют код, который вы пишете, в двоичные файлы, но что делают компоновщики? Они всегда были для меня загадкой.
Я примерно понимаю, что такое "связывание". Это когда ссылки на библиотеки и фреймворки добавляются в двоичный файл. Я ничего не понимаю за этим. Для меня это "просто работает". Я также понимаю основы динамического связывания, но не слишком глубоко.
Может кто-нибудь объяснить условия?
5 ответов
Чтобы понять компоновщики, сначала необходимо понять, что происходит "под капотом", когда вы конвертируете исходный файл (такой как файл 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, которых я знаю, скажут мне, что есть МНОГО БОЛЬШИХ отличий).
Надеюсь, это поможет вам понять!
Пример минимального перемещения адреса
Перемещение адреса является одной из важнейших функций связывания.
Итак, давайте посмотрим, как это работает, с минимальным примером.
0) Введение
Резюме: перемещение редактирует .text
раздел объектных файлов для перевода:
- адрес объектного файла
- в окончательный адрес исполняемого файла
Это должен сделать компоновщик, потому что компилятор видит только один входной файл за раз, но мы должны знать обо всех объектных файлах одновременно, чтобы решить, как:
- разрешить неопределенные символы, такие как объявленные неопределенные функции
- не конфликтовать несколько
.text
а также.data
разделы нескольких объектных файлов
Пререквизиты: минимальное понимание:
- сборка x86-64 или IA-32
- глобальная структура файла ELF. Я сделал учебник для этого
Связывание не имеет ничего общего с 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
и те, кто знает кодировку команд 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
,
И единственное, что есть в разделе данных, это наша строка приветствия.
ОТКАЗ ОТ ОТВЕТСТВЕННОСТИ: Я ответил на двойной вопрос об этом, и только нашел этот сейчас. Я проголосовал, чтобы закрыть дупе: как работает связывание C++ на практике?
В таких языках, как 'C', отдельные модули кода традиционно компилируются отдельно в двоичные объекты объектного кода, который готов к выполнению во всех отношениях, за исключением того, что все ссылки, которые модуль делает вне себя (то есть на библиотеки или на другие модули), имеют еще не решен (то есть они пустые, ожидая, что кто-то придет и сделает все соединения).
Что делает компоновщик, так это просматривает все модули вместе, смотрит на то, что каждый модуль должен подключить к самому себе, и смотрит на все, что он экспортирует. Затем он исправляет все это и создает окончательный исполняемый файл, который затем можно запустить.
Там, где также происходит динамическое связывание, выходные данные компоновщика все еще не могут быть запущены - все еще есть некоторые ссылки на внешние библиотеки, которые еще не разрешены, и они разрешаются ОС во время загрузки приложения (или, возможно, еще позже во время бега).
Когда компилятор создает объектный файл, он включает записи для символов, которые определены в этом объектном файле, и ссылки на символы, которые не определены в этом объектном файле. Компоновщик берет их и объединяет, чтобы (когда все работает правильно) все внешние ссылки из каждого файла удовлетворялись символами, которые определены в других объектных файлах.
Затем он объединяет все эти объектные файлы вместе и присваивает адреса каждому из символов, а там, где один объектный файл имеет внешнюю ссылку на другой объектный файл, он заполняет адрес каждого символа везде, где он используется другим объектом. В типичном случае он также создаст таблицу любых используемых абсолютных адресов, поэтому загрузчик может / "исправит" адреса при загрузке файла (т. Е. Он добавит адрес базовой загрузки к каждому из этих адреса, поэтому все они ссылаются на правильный адрес памяти).
Многие современные компоновщики могут также выполнять некоторые (в некоторых случаях много) другие "вещи", такие как оптимизация кода способами, которые возможны только после того, как все модули видны (например, удаление функций, которые были включены потому что было возможно, что какой-то другой модуль мог бы вызывать их, но как только все модули собраны вместе, становится очевидным, что ничто их не вызывает).
Компоновщик связывает ваш файл OBJ с этой стандартной библиотекой.