Как С++-связь работает на практике?

Как работает С++-соединение? То, что я ищу, - это подробное объяснение того, как происходит соединение, а не какие команды делают связь.

У нас уже есть аналогичный вопрос о компиляции, который не слишком подробно описывает: Как работает процесс компиляции/компоновки?

Ответы

Ответ 1

EDIT: я переместил этот ответ в дубликат: fooobar.com/questions/6282/...

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

Минимальный пример будет использован для прояснения концепции.

0) Введение

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

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

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

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

Предпосылки: минимальное понимание:

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

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

Но подождите! Как компилятор может узнать, где "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, который указывает, что именно нужно сделать для перевода адреса.

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

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

Ответ 2

Собственно, можно сказать, что ссылка очень проста.

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

Очевидно, что компоновщику необходимо создать файл, который соответствует определенному формату (обычно это формат ELF в Unix) и будет разделять различные категории кода/данных на разные разделы файла, но это просто отправка.

Два осложнения, о которых я знаю, следующие:

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

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


  1: результат компиляции различных единиц перевода (грубо, предварительно обработанных исходных файлов)

Ответ 3

Помимо уже упомянутых "Linkers and Loaders", если вы хотите узнать, как работает настоящий и современный компоновщик, вы можете запустить .