Библиотека сайта rus-linux.net
Компоновщики и загрузчики. Часть 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.
- Локальные символы, определенные в самом модуле и упоминаемые лишь в нем. Сюда относятся все статические функции и статические переменные.
Компоновщик разрешает ссылки на символ, ассоциируя каждую ссылку в точности с одним определением символа, определенного в таблице символов какого-либо объектного файла из представленного списка перемещаемых объектных файлов. Разрешение локальных символов внутри модуля выполняется просто, ведь модуль не может иметь несколько определений одного и того же локального символа. Однако разрешение ссылок на глобальные символы происходит сложнее. В процессе компиляции компилятор отмечает каждый символ как сильный или как слабый. Функции и инициализированные глобальные переменные приобретают сильный вес, в то время как глобальные неинициализированные переменные - слабый. Теперь компоновщик разрешает символы по следующим правилам:
- Не разрешено давать несколько сильных символов.
- Если дан один сильный символ и несколько слабых символов, выбрать сильный символ.
- Если дано несколько слабых символов, выбрать любой из слабых символов.
К примеру, связывание следующих двух программ приведет к ошибке:
/* 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.
О том, как компоновщик работает с библиотеками, читайте в следующей части...