Как работает связывание C++ на практике?
Как работает связывание C++ на практике? То, что я ищу, - это подробное объяснение того, как происходит связывание, а не какие команды выполняют связывание.
Уже есть похожий вопрос о компиляции, который не вдавается в подробности: как работает процесс компиляции / компоновки?
3 ответа
РЕДАКТИРОВАТЬ: я переместил этот ответ в дубликат: /questions/14358707/chto-delayut-linkeryi/14358720#14358720
Этот ответ сфокусирован на перемещении адресов, которое является одной из важнейших функций связывания.
Минимальный пример будет использован для пояснения концепции.
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
,
И единственное, что есть в разделе данных, это наша строка приветствия.
На самом деле, можно сказать, что связывание является относительно простым.
В простейшем смысле, это просто сборка объектных файлов1, поскольку они уже содержат испущенную сборку для каждой из функций / глобальных переменных / данных..., содержащихся в их соответствующем источнике. Компоновщик может быть очень тупым и просто воспринимать все как символ (имя) и его определение (или содержание).
Очевидно, что компоновщик должен создать файл, который соответствует определенному формату (формат ELF, как правило, в Unix) и будет разделять различные категории кода / данных на разные части файла, но это всего лишь диспетчеризация.
Мне известны два осложнения:
необходимость дедупликации символов: некоторые символы присутствуют в нескольких объектных файлах, и только один должен сделать это в создаваемой результирующей библиотеке / исполняемом файле; это работа компоновщика, чтобы включить только одно из определений
Оптимизация во время компоновки: в этом случае объектные файлы содержат не излучаемую сборку, а промежуточное представление, и компоновщик объединяет все объектные файлы вместе, применяет проходы оптимизации (например, встраивание), компилирует это в сборку и, наконец, выдает свой результат,
1: результат компиляции различных единиц перевода (грубо, предварительно обработанные исходные файлы)
Помимо уже упомянутых " компоновщиков и загрузчиков", если вы хотите узнать, как работает настоящий и современный компоновщик, вы можете начать здесь.