Наши партнеры

UnixForum






Книги по Linux (с отзывами читателей)

Библиотека сайта rus-linux.net

На главную -> MyLDP -> Тематический каталог -> Программирование и алгоритмические языки в Linux

Компоновщики и загрузчики. Часть 1

Оригинал: Linkers and Loaders
Автор: Sandeep Grover
Дата: 26 ноября 2002
Перевод: Александр Тарасов aka oioki
Дата перевода: 2 декабря 2008

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

Эта статья представляет собой сжатый обзор всех аспектов связывания - от релокации и разрешения символов до поддержки разделяемых библиотек. Для простоты будем вести речь лишь об объектных файлах формата ELF (Executable and Linking Format - формат исполнения и связывания), архитектуре x86 (Linux) и будем использовать средства разработки GNU - компилятор gcc и компоновщик ld. Однако основные концепции связывания везде одинаковы, независимо от операционной системы, архитектуры процессора или формата объектных файлов.

Компилятор, компоновщик и загрузчик в действии. Основы

Рассмотрим две программы, хранящихся в файлах a.c и b.c. При вызове GCC на этих двух файлах происходят следующие вещи:

gcc a.c b.c
  • Файл a.c обрабатывается препроцессором, результат сохраняется в промежуточном файле.
    cpp другие_аргументы a.c /tmp/a.i
    
  • Файл a.i компилируется, получается код на языке ассемблера, в файле a.s
    cc1 другие_аргументы /tmp/a.i  -o /tmp/a.s
    
  • Файл a.s обрабатывается ассемблером, получается объектный файл a.o
    as другие_аргументы /tmp/a.s  -o /tmp/a.o
    

cpp, cc1 и as - соответственно, препроцессор, собственно компилятор и ассемблер. Эти программы являются стандартной частью GCC.

Аналогичным образом повторятся шаги для файла b.c. Мы получим другой объектный файл b.o. Задача компоновщика состоит в том, чтобы взять эти объектные файлы (a.o и b.o) и создать окончательный исполняемый файл:

ld другие_аргументы /tmp/a.o /tmp/b.o -o a.out

Теперь можно загружать полученный исполняемый файл (a.out) в память. Для запуска программы введите ее имя в командной строке:

./a.out

Будет вызван загрузчик execve, копирующий код и данные исполняемого файла a.out в память, и передающий управление на начало программы, к первой ее инструкции.

Имя файла a.out исторически означает Assembler OUTput (вывод ассемблера). С тех пор это имя так и используется, хотя формат объектных файлов существенно изменился.

Компоновщики и загрузчики

Компоновщики и загрузчики решают похожие, но концептуально разные задачи:

  • Загрузка программы. Это процесс копирования образа программы с жесткого диска в основную память. Таким образом программа переходит в состояние, когда она уже может работать. В некоторых случаях загрузку программы может сопровождать выделение места или сопоставление виртуальных адресов страницам на диске.

  • Релокация. Компиляторы и ассемблеры генерируют объектный код для каждого модуля, причем начальным адресом каждого из них считается нуль. Релокация - это процесс присвоения адресов загрузки различным частям программы при объединении ее секций одного типа в одну секцию. Также секции кода и данных выравниваются, и таким образом во время выполнения везде, где нужно имеются корректные адреса.

  • Разрешение символов. Программа состоит из множества подпрограмм; ссылка на одну из подпрограмм в другой осуществляется посредством символов. Задачей компоновщика является разрешение ссылок, путем запоминания места размещения символа и соответствующего изменения кода в вызывающем его объекте.

Между загрузчиками и компоновщиками есть существенная разница. Вкратце суть такова: загрузчик загружает программу; компоновщик выполняет разрешение символов; и оба могут выполнять релокацию.

Объектные файлы

Объектные файлы бывают трех видов:

  • Перемещаемый объектный файл. Содержит бинарный код и данные в форме, пригодной для связывания с другими перемещаемыми объектными файлами. В результате соединения таких файлов получается исполняемый файл.
  • Исполняемый объектный файл. Содержит бинарный код и данные в форме, пригодной для загрузки программы в память и ее исполнения.
  • Разделяемый объектный файл. Особый тип перемещаемого объекта. Его можно загрузить в память и связать динамически, во время загрузки или во время выполнения программы.

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

Объектные файлы различаются для разных систем. В первой UNIX-системе использовался формат a.out. В ранних версиях System V использовался формат COFF (common object file format). В Windows NT использовалась модификация COFF под названием PE (portable executable - переносимый исполнимый код); в IBM существует свой формат IBM 360. В современных версиях UNIX, таких как Linux и Solaris, применяется формат UNIX ELF (executable and linking format - формат исполнения и связывания). В этой статье мы уделим внимание именно этому формату.

Следующий рисунок отображает формат типичного перемещаемого объектного файла.

Заголовок ELF
.text
.rodata
.data
.bss
.symtab
.rel.text
.rel.data
.debug
.line
.strtab

Заголовок ELF начинается с 4-байтовой последовательности \177ELF. Далее следуют такие секции перемещаемого объектного файла ELF:

  • .text, машинный код скомпилированной программы.
  • .rodata, данные только для чтения, такие как строки формата printf.
  • .data, инициализированные глобальные переменные.
  • .bss, неинициализированные глобальные переменные. BSS означает Black Storage Start (начало блока хранения). Эта секция не занимает места в объектном файле, это лишь "заполнитель".
  • .symtab, таблица символов с информацией о функциях и глобальных переменных, определенных и упоминаемых в программе. Эта таблица не содержит записей локальных переменных; они появляются и исчезают на стеке в процессе работы программы.
  • .rel.text, список мест в секции .text, которые нужно изменить при связывании этого объектного файла с другими объектными файлами.
  • .rel.data, информация о релокации глобальных переменных, упомянутых, но не определенных в текущем модуле.
  • .debug, таблица отладочных символов, содержащая записи для локальных и глобальных переменных. Эта секция присутствует лишь в том случае, если компилятор вызывался с опцией -g.
  • .line, таблица соответствия номеров строк исходного кода на C и машинных инструкций в секции .text. Эта информация необходима отладчикам.
  • .strtab, таблица строк для таблиц символов в секциях .symtab и .debug.

Символы и разрешение символов

В каждом перемещаемом объектном файле есть таблица символов и ассоциированные с ней символы. С точки зрения компоновщика, существуют следующие типы символов:

  • Глобальные символы, определенные в модуле и упоминаемые в других модулях. В эту категорию попадают все нестатические функции и глобальные переменные.
  • Глобальные символы, упоминаемые во входном модуле, но определенные где-то в другом месте. Сюда относятся все функции и переменные, определенные с ключевым словом extern.
  • Локальные символы, определенные в самом модуле и упоминаемые лишь в нем. Сюда относятся все статические функции и статические переменные.

Компоновщик разрешает ссылки на символ, ассоциируя каждую ссылку в точности с одним определением символа, определенного в таблице символов какого-либо объектного файла из представленного списка перемещаемых объектных файлов. Разрешение локальных символов внутри модуля выполняется просто, ведь модуль не может иметь несколько определений одного и того же локального символа. Однако разрешение ссылок на глобальные символы происходит сложнее. В процессе компиляции компилятор отмечает каждый символ как сильный или как слабый. Функции и инициализированные глобальные переменные приобретают сильный вес, в то время как глобальные неинициализированные переменные - слабый. Теперь компоновщик разрешает символы по следующим правилам:

  1. Не разрешено давать несколько сильных символов.
  2. Если дан один сильный символ и несколько слабых символов, выбрать сильный символ.
  3. Если дано несколько слабых символов, выбрать любой из слабых символов.

К примеру, связывание следующих двух программ приведет к ошибке:

/* foo.c */               /* bar.c */
int foo () {              int foo () {
   return 0;                 return 1;
}                         }
                          int main () {
                             foo ();
                          }

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

gcc foo.c bar.c
/tmp/ccM1DKre.o: In function 'foo':
/tmp/ccM1DKre.o(.text+0x0): multiple definition of 'foo'
/tmp/ccIhvEMn.o(.text+0x0): first defined here
collect2: ld returned 1 exit status

Collect2 - это обертка компоновщика вокруг ld, она вызывается компилятором GCC.

О том, как компоновщик работает с библиотеками, читайте в следующей части...