Чтобы понять компоновщики, сначала нужно понять, что происходит «под капотом», когда вы конвертируете исходный файл (например, файл 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, работающие с жестким ядром, скажут мне, что есть МНОГИЕ БОЛЬШИЕ различия).
Надеюсь, это поможет вам понять!
Минимальный пример перемещения адреса
Перемещение адреса - одна из важнейших функций связывания.
Итак, давайте посмотрим, как это работает, на минимальном примере.
0) Введение
Резюме: перемещение редактирует
.text
раздел объектных файлов для перевода:Это должно быть сделано компоновщиком, потому что компилятор видит только один входной файл за раз, но мы должны знать обо всех объектных файлах сразу, чтобы решить, как:
.text
и.data
разделами нескольких объектных файловПредпосылки: минимальное понимание:
Связывание не имеет ничего общего с C или C ++ конкретно: компиляторы просто генерируют объектные файлы. Затем компоновщик принимает их в качестве входных данных, даже не зная, на каком языке они были скомпилированы. С таким же успехом это мог быть Фортран.
Итак, чтобы уменьшить корку, давайте изучим привет мир NASM x86-64 ELF Linux:
скомпилирован и собран с помощью:
с NASM 2.10.09.
1) .text из .o
Сначала декомпилируем
.text
раздел объектного файла:который дает:
ключевые строки:
который должен переместить адрес строки hello world в
rsi
регистр, который передается в системный вызов write.Но ждать! Как компилятор может знать, где
"Hello world!"
окажется в памяти при загрузке программы?Ну, не может, особенно после того, как мы свяжем кучу
.o
файлов вместе с несколькими.data
разделами.Только компоновщик может это сделать, поскольку только он будет иметь все эти объектные файлы.
Итак, компилятор просто:
0x0
в скомпилированный выводЭта «дополнительная информация» содержится в
.rela.text
разделе объектного файла.2) .rela.text
.rela.text
означает «перемещение раздела .text».Слово «перемещение» используется потому, что компоновщику придется переместить адрес из объекта в исполняемый файл.
Мы можем разобрать
.rela.text
секцию с помощью:который содержит;
Формат этого раздела зафиксирован и задокументирован по адресу: 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
созданного для нас:дает:
Итак, единственное, что изменилось в объектном файле, - это критические строки:
которые теперь указывают на адрес
0x6000d8
(d8 00 60 00 00 00 00 00
с прямым порядком байтов) вместо0x0
.Это правильное место для
hello_world
строки?Чтобы принять решение, мы должны проверить заголовки программ, которые сообщают Linux, куда загружать каждый раздел.
Разбираем их с помощью:
который дает:
Это говорит нам о том
.data
, что второй раздел начинается сVirtAddr
=0x06000d8
.И единственное, что находится в разделе данных, - это наша строка hello world.
Бонусный уровень
PIE
связывание: что такое параметр -fPIE для независимых от позиции исполняемых файлов в gcc и ld?источник
В таких языках, как 'C', отдельные модули кода традиционно компилируются отдельно в капли объектного кода, который готов к выполнению во всех отношениях, кроме тех, что все ссылки, которые модуль делает вне себя (то есть на библиотеки или другие модули), имеют еще не решены (т.е. они пустые, ожидая, что кто-то придет и установит все связи).
Что делает компоновщик, так это смотрит на все модули вместе, на то, что каждый модуль должен подключать к себе, и на все то, что он экспортирует. Затем он все это исправляет и создает окончательный исполняемый файл, который затем можно запустить.
Там, где также происходит динамическое связывание, вывод компоновщика все еще не может быть запущен - все еще есть некоторые ссылки на внешние библиотеки, которые еще не разрешены, и они разрешаются ОС во время загрузки приложения (или, возможно, даже позже во время пробега).
источник
Когда компилятор создает объектный файл, он включает записи для символов, которые определены в этом объектном файле, и ссылки на символы, которые не определены в этом объектном файле. Компоновщик берет их и объединяет так (когда все работает правильно) все внешние ссылки из каждого файла удовлетворяются символами, которые определены в других объектных файлах.
Затем он объединяет все эти объектные файлы вместе и назначает адреса каждому из символов, а если один объектный файл имеет внешнюю ссылку на другой объектный файл, он заполняет адрес каждого символа везде, где он используется другим объектом. В типичном случае он также будет строить таблицу любых используемых абсолютных адресов, поэтому загрузчик может / будет «исправлять» адреса при загрузке файла (т.е. он добавит адрес базовой загрузки к каждому из этих адресов). адреса, поэтому все они относятся к правильному адресу памяти).
Многие современные компоновщики также могут выполнять некоторые (в некоторых случаях ) другие "вещи", такие как оптимизация кода способами, которые возможны только тогда, когда все модули видны (например, удаление функций, которые были включены потому что было возможно, что какой-то другой модуль мог бы их вызвать, но когда все модули собраны вместе, становится очевидно, что их никто никогда не вызывает).
источник