Функции компоновщика. Компоновщик. Его назначение и функции. Загрузчики и отладчики. Функции загрузчика

[из методы]

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

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

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

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

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

Компоновщик (также реда́ктор свя́зей, линкер - от англ. link editor, linker) - программа, которая производит компоновку - принимает на вход один или несколько объектных модулей и собирает по ним исполнимый модуль.

Для связывания модулей компоновщик использует таблицы имён, созданные компилятором в каждом из объектных модулей. Такие имена могут быть двух типов:

Определённые или экспортируемые имена - функции и переменные, определённые в данном модуле и предоставляемые для использования другим модулям

Неопределённые или импортируемые имена - функции и переменные, на которые ссылается модуль, но не определяет их внутри себя

Работа компоновщика заключается в том, чтобы в каждом модуле разрешить ссылки на неопределённые имена. Для каждого импортируемого имени находится его определение в других модулях, упоминание имени заменяется на его адрес.

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

David Drysdale, Beginner"s guide to linkers

(http://www.lurklurk.org/linkers/linkers.html).

Цель данной статьи - помочь C и C++ программистам понять сущность того, чем занимается компоновщик. За последние несколько лет я объяснил это большому количеству коллег и наконец решил, что настало время перенести этот материал на бумагу, чтоб он стал более доступным (и чтоб мне не пришлось объяснять его снова). [Обновление в марте 2009: добавлена дополнительная информация об особенностях компоновки в Windows, а также более подробно расписано правило одного определения (one-definition rule).

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

g++ -o test1 test1a.o test1b.o

test1a.o(.text+0x18): In function `main":

: undefined reference to `findmax(int, int)"

collect2: ld returned 1 exit status

Если Ваша реакция - "наверняка забыл extern «C»", то Вы скорее всего знаете всё, что приведено в этой статье.

  • Определения: что находится в C файле?
  • Что делает C компилятор
  • Что делает компоновщик: часть 1
  • Что делает операционная система
  • Что делает компоновщик: часть 2
  • C++ для дополнения картины
  • Динамически загружаемые библиотеки
  • Дополнительно

Определения: что находится в C файле?

Эта глава - краткое напоминание о различных составляющих C файла. Если всё в листинге, приведённом ниже, имеет для Вас смысл, то скорее всего Вы можете пропустить эту главу и сразу перейти к следующей.

Сперва надо понять разницу между объявлением и определением.

Определение связывает имя с реализацией, что может быть либо кодом либо данными:

  • Определение переменной побуждает компилятор зарезервировать некоторую область памяти, возможно задав ей некоторое определённое значение.
  • Определение функции заставляет компилятор сгенерировать код для этой функции

Объявление говорит компилятору, что определение функции или переменной (с определённым именем) существует в другом месте программы, вероятно в другом C файле. (Заметьте, что определение также является объявлением - фактически это объявление, в котором «другое место» программы совпадает с текущим).

Для переменных существует определения двух видов:

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

При этом под термином «доступны» следует понимать «можно обратиться по имени, ассоциированным с переменной в момент определения».

Существует пара частных случаев, которые с первого раза не кажутся очевидными:

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

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

Для глобальных и локальных переменных, мы можем различать инициализирована переменная или нет, т.е. будет ли пространство, отведённое для переменной в памяти, заполнено определённым значением.

И наконец, мы можем сохранять информацию в памяти, которая динамически выделена по средством malloc или new . В данном случае нет возможности обратится к выделенной памяти по имени, поэтому необходимо использовать указатели - именованные переменные, содержащие адрес неименованной области памяти. Эта область памяти может быть также освобождена с помощью free или delete . В этом случае мы имеем дело с «динамическим размещением».

Подытожим:

Глобальные

Локальные

Динамические

Неинициа-

Неинициа-

Объяв-ление

int fn(int x);

extern int x;

extern int x;

Опреде-ление

int fn(int x)

{ ... }

int x = 1;

(область действия

Файл)

int x;

(область действия - файл)

int x = 1;

(область действия - функция)

int x;

(область действия - функция)

int* p = malloc(sizeof(int));

Вероятно более лёгкий путь усвоить - это просто посмотреть на пример программы.

/* Определение неинициализированной глобальной переменной */

int x_global_uninit;

/* Определение инициализированной глобальной переменной */

int x_global_init = 1;

/* Определение неинициализированной глобальной переменной, к которой

static int y_global_uninit;

/* Определение инициализированной глобальной переменной, к которой

* можно обратиться по имени только в пределах этого C файла */

static int y_global_init = 2;

/* Объявление глобальной переменной, которая определена где-нибудь

* в другом месте программы */

extern int z_global;

/* Объявлени функции, которая определена где-нибудь другом месте

* программы (Вы можете добавить впереди "extern", однако это

* необязательно) */

int fn_a(int x, int y);

/* Определение функции. Однако будучи помеченной как static, её можно

* вызвать по имени только в пределах этого C файла. */

static int fn_b(int x)

Return x+1;

/* Определение функции. */

/* Параметр функции считается локальной переменной. */

int fn_c(int x_local)

/* Определение неинициализированной локальной переменной */

Int y_local_uninit;

/* Определение инициализированной локальной переменной */

Int y_local_init = 3;

/* Код, который обращается к локальным и глобальным переменным,

* а также функциям по имени */

X_global_uninit = fn_a(x_local, x_global_init);

Y_local_uninit = fn_a(x_local, y_local_init);

Y_local_uninit += fn_b(z_global);

Return (x_global_uninit + y_local_uninit);

Что делает C компилятор

Работа компилятора C заключается в конвертировании текста, (обычно) понятному человеку, в нечто, что понимает компьютер. На выходе компилятор выдаёт объектный файл. На платформах UNIX эти файлы имеют обычно суффикс.o; в Windows - суффикс.obj. Содержание объектного файла - в сущности две вещи:

код, соответствующий определению функции в C файле

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

Код и данные, в данном случае, будут иметь ассоциированные с ними имена - имена функций или переменных, с которыми они связаны определением.

Объектный код - это последовательность (подходящим образом составленных) машинных инструкций, которые соответствуют C инструкциям, написанных программистом: все эти if"ы и while"ы и даже goto. Эти заклинания должны манипулировать информацией определённого рода, а информация должна быть где-нибудь находится - для этого нам и нужны переменные. Код может также ссылаться на другой код (в частности на другие C функции в программе).

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

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

По существу компилятор оставляет пустые места. Пустое место (ссылка) имеет имя, но значение соответствующее этому имени пока не известно.

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

Анализирование объектного файла

До сих пор мы рассматривали всё на высоком уровне. Однако полезно посмотреть, как это работает на практике. Основным инструментом для нас будет команда nm, которая выдаёт информацию о символах объектного файла на платформе UNIX. Для Windows команда dumpbin с опцией /symbols является приблизительным эквивалентом. Также есть портированные под Windows инструменты GNU binutils, которые включают nm.exe.

Давайте посмотрим, что выдаёт nm для объектного файла, полученного из нашего примера выше:

Symbols from c_parts.o:

Name Value Class Type Size Line Section

fn_a | | U | NOTYPE| | |*UND*

z_global | | U | NOTYPE| | |*UND*

fn_b |00000000| t | FUNC|00000009| |.text

x_global_init |00000000| D | OBJECT|00000004| |.data

y_global_uninit |00000000| b | OBJECT|00000004| |.bss

x_global_uninit |00000004| C | OBJECT|00000004| |*COM*

y_global_init |00000004| d | OBJECT|00000004| |.data

fn_c |00000009| T | FUNC|00000055| |.text

Результат может выглядеть немного по разному на разных платформах (обратитесь к man"ам, чтобы получить соответствующую информацию), но ключевыми сведениями являются класс каждого символа и его размер (если присутствует). Класс может иметь различны значения:

  • Класс U обозначает неопределённые ссылки, те самые «пустые места», упомянутые выше. Для этого класса существует два объекта: fn_a и z_global. (Некоторые версии nm могут выводить секцию, которая была бы *UND* или UNDEF в этом случае.)
  • Классы t и T указывают на код, который определён; различие между t и T заключается в том, является ли функция локальной (t) в файле или нет (T), т.е. была ли функция объявлена как static. Опять же в некоторых системах может быть показана секция, например.text.
  • Классы d и D содержат инициализированные глобальные переменные. При этом статичные переменные принадлежат классу d. Если присутствует информация о секции, то это будет.data.
  • Для неинициализированных глобальных переменных, мы получаем b, если они статичные и B или C иначе. Секцией в этом случае будет скорее всего.bss или *COM*.

Также можно увидеть символы, которые не являются частью исходного C кода. Мы не будем заострять наше внимание на этом, так как это обычно часть внутреннего механизма компилятора, для того чтобы Ваша программа всё-таки смогла быть потом скомпонована.

Данной статьи.

Организация таблицы символьных имен в ассемблере.

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

Последовательное ассемблирование.

Таблица символьных имен представляется как результат первого прохода в виде массива пар имя - значе­ние. Поиск требуемого символа осуществляется последовательным просмотром таблицы до тех пор, пока не будет определено соответствие. Такой способ до­вольно легко программируется, но медленно работает.

Сортировка по именам.

Имена предварительно сортируется по алфавиту. Для поиска имен используется алгоритм двоичного отсечения, по которому тре­буемое имя сравнивается с именем среднего элемента таблицы. Если нужный символ расположен по алфавиту ближе среднего элемента, то он находится в первой половине таблицы, а если дальше, то во второй половине таблицы. Если нужное имя совпадаете именем среднего элемента, то поиск завершается.

Алго­ритм двоичного отсечения работает быстрее, чем последовательный просмотр таблицы, однако элементы таблицы необходимо располагать в алфавитном по­рядке.

Кэш–кодирование.

При этом способе на основании исходной таблицы строится кэш–функция, которая отображает имена в целые числа в промежутке от О до k–1 (рис. 5.2.1, а). Кэш–функцией может быть, например, функция перемно­жения всех разрядов имени, представленного кодом ASCII, или любая другая функция, которая дает равномерное распределение значений. После этого со­здается кэш–таблица, которая содержит к строк (слотов). В каждой строке распо­лагаются (например, в алфавитном порядке) имена, имеющие одинаковые значе­ния кэш–функции (рис. 5.2.1, б), или номер слота. Если в кэш–таблице содержится п символьных имен, то среднее количество имен в каждом слоте составляет n/k. При n = k для нахождения нужного символь­ного имени в среднем потребуется всего один поиск. Путем изменения к можно варьировать размер таблицы (число слотов) и скорость поиска. Связывание и загрузка. Программу можно представить как совокупность процедур (подпрограмм). Ассемблер поочередно транслируют одну процедуру за другой, создавая объектные модули и размещая их в памяти. Для получения исполняемого двоичного кода должны быть найдены и связаны все оттранслиро­ванные процедуры.

Функции связывания и загрузки выполняют специальные про­граммы, называемые компоновщиками, связывающими загрузчиками, редакто­рами связей или линкерами.


Таким образом, для полной готовности к исполнению исходной программы требуется два шага (рис. 5.2.2):

● трансляция, реализуемая компилятором или ассемблером для каждой исход­ной процедуры с целью получения объектного модуля. При трансляции осу­ществляется переход с исходного языка на выходной язык, имеющий разные команды и запись;

● связывание объектных модулей, выполняемое компоновщиком для получения исполняемого двоичного кода. Отдельная трансляция процедур вызвана возможными ошибками или необхо­димостью изменения процедур. В этих случаях понадобится заново связать все объектные модули. Так как связывание происходит гораздо быстрее, чем транс­ляция, то выполнение этих двух шагов (трансляции и связывания) сэкономит вре­мя при доработке программы. Это особенно важно для программ, которые со­держат сотни или тысячи модулей. В операционных системах MS–DOS, Windows и NT объектные моду­ли имеют расширение «.obj», а исполняемые двоичные программы - расширение «.ехе». В системе UNIX объектные модули имеют расширение «.о», а исполняемые двоичные программы не имеют расширения.

Функции компоновщика.

Перед началом первого прохода ассемблирования счетчик адреса команды устанавливается на 0. Этот шаг эквивалентен предполо­жению, что объектный модуль во время выполнения будет находиться в ячейке с адресом 0.

Цель компоновки - создать точное отображение виртуального адресного про­странства исполняемой программы внутри компоновщика и разместить все объектные модули по соответствующим адресам.


Рассмотрим особенности компоновки четырех объектных модулей (рис. 5.2.3, а), полагая при этом, что каждый из них находится в ячейке с адресом 0 и начинает­ся с команды перехода BRANCH к команде MOVE в том же модуле. Перед запуском программы компоновщик помещает объектные модули в ос­новную память, формируя отображение исполняемого двоичного кода. Обычно небольшой раздел памяти, начинающийся с нулевого адреса, используется для векторов прерывания, взаимодействия с операционной системой и других целей.

Поэтому, как показано на рис. 5.2.3, б, программы начинаются не с нулевого ад­реса, а с адреса 100. Поскольку каждый объектный модуль на рис. 5.2.3, а занимает отдельное ад­ресное пространство, возникает проблема перераспределения памяти. Все ко­манды обращения к памяти не будут выполнены по причине некорректной адре­сации. Например, команда вызова объектного модуля B (рис. 5.2.3, б), указанная в ячейке с адресом 300 объектного модуля А (рис. 5.2.3, а), не выполнится по двум причинам:

● команда CALL B находится в ячейке с другим адресом (300, а не 200); ● поскольку каждая процедура транслируется отдельно, ассемблер не может определить, какой адрес вставлять в команду CALL В. Адрес объектного мо­дуля В не известен до связывания. Такая проблема называется проблемой внешней ссылки. Обе причины устраняются с помощью компоновщика, который сливает отдель­ные адресные пространства объектных модулей в единое линейное адресное пространство, для чего:

● строит таблицу объектных модулей и их длин;

● на основе этой таблицы приписывает начальные адреса каждому объектному модулю;

к памяти, и прибавляет к каждой из них константу перемещения, которая равна начальному адресу этого мо­дуля (в рассматриваемом случае 100);

● находит все команды, которые обращаются к процедурам, и вставляет в них адрес этих процедур.
Ниже приведена таблица объектных модулей (табл. 5.2.6), построенная на первом шаге. В ней дается имя, длина и начальный адрес каждого модуля. Адресное пространство после выполнения компоновщиком всех шагов пока­зано в табл. 5.2.6 и на рис. 5.2.3, в. Структура объектного модуля. Объектные модули состоят из следующих частей:

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

список определенных в модуле символов (символьных имен) вместе с их зна­чениями. К этим символам могут обращаться другие модули. Программист на языке ассемблера с помощью директивы PUBLIC указывает, какие символь­ные имена считаются точками входа;

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

машинные команды и константы;

словарь перемещений. К командам, которые содержат адреса памяти, долж­на прибавляться константа перемещения (см. рис. 5.2.3). Компоновщик сам не может определить, какие слова содержат машинные команды, а какие - константы. Поэтому в этой таблице содержится информация о том, какие ад­реса нужно переместить. Это может быть битовая таблица, где на каждый бит приходится потенциально перемещаемый адрес, либо явный список адресов, которые нужно переместить;

конец модуля, адрес начала выполнения, а также контрольная сумма для оп­ределения ошибок, сделанных во время чтения модуля. Отметим, что машинные команды и константы единственная часть объектного модуля, которая будет загружаться в память для выполнения. Остальные части используются и отбрасываются компоновщиком до начала выполнения программы. Большинство компоновщиков используют два прохода:

● сначала считываются все объектные модули и строится таблица имен и длин модулей, а также таблица символов, которая состоит из всех точек входа и внешних ссылок;

● затем модули еще раз считываются, перемещаются в памяти и связываются. О перемещении программ. Проблема перемещения связанных и находя­щихся в памяти программ обусловлена тем, что после их перемещения хранящи­еся в таблицах адреса становятся ошибочными. Для принятия решения о перемещении программ необходимо знать момент времени финального связывания символических имен с абсолютными адресами физической памяти.

Временем принятия решения называется момент определения адреса в ос­новной памяти, соответствующего символическому имени. Существуют различные варианты для времени принятия решения относитель­но связывания: когда пишется программа, когда программа транслируется, ком­понуется, загружается или когда команда, содержащая адрес, выполняется. Рассмотренный выше метод связывает символические имена с абсолютными физическими адресами. По этой причине перемещать программы после связывания нельзя.

При связывании можно выделить два этапа:

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

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

● разбиение на страницы. Адресное пространство, изображенное на рис. 5.2.3, в, содержит виртуальные адреса, которые уже определены и соответствуют символическим именам А, В, С и D. Их физические адреса будут зависеть от содержания таблицы страниц. Поэтому для перемещения программы в ос­новной памяти достаточно изменить только ее таблицу страниц, но не саму программу;

● использование регистра перемещения. Этот регистр указывает на физичес­кий адрес начала текущей программы, загружаемый операционной системой перед перемещением программы. С помощью аппаратных средств содержи­мое регистра перемещения прибавляется ко всем адресам памяти, прежде чем они загружаются в память. Процесс перемещения является «прозрач­ным» для каждой пользовательской программы. Особенность механизма: в отличие от разбиения на страницы должна перемещаться вся программа целиком. Если имеются отдельные регистры (или сегменты памяти как, на­пример, в процессорах Intel) для перемещения кода и перемещения данных, то в этом случае программу нужно перемещать как два компонента;

● механизм обращения к памяти относительно счетчика команд. При использо­вании этого механизма при перемещении программы в основной памяти об­новляется только счетчик команд. Программа, все обращения к памяти кото­рой связаны со счетчиком команд (либо абсолютны как, например, обраще­ния к регистрам устройств ввода–вывода в абсолютных адресах), называется позиционно–независимой программой. Такую программу можно поместить в любом месте виртуального адресного пространства без настройки адресов. Динамическое связывание.

Рассмотренный выше способ связывания имеет одну особенность: связь со всеми процедурами, нужными программе, устанавли­вается до начала работы программы. Более рациональный способ связывания от­дельно скомпилированных процедур, называемый динамическим связыванием, состоит в установлении связи с каждой процедурой во время первого вызова. Впервые он был применен в системе MULTICS.

Динамическое связывание в системе MULTICS . За каждой программой закреплен сегмент связывания, содержащий блок информации для каж­дой процедуры (рис. 5.2.4).

Информация включает:

● слово «Косвенный адрес», зарезервированное для виртуального адреса про­цедуры;

● имя процедуры (EARTH, FIRE и др.), которое сохраняется в виде цепочки сим­волов. При динамическом связывании вызовы процедур во входном языке трансли­руются в команды, которые с помощью косвенной адресации обращаются к слову «Косвенный адрес» соответствующего блока (рис. 5.2.4). Компилятор заполняет это слово либо недействительным адресом, либо специальным набором бит, ко­торый вызывает системное прерывание (типа ловушки). После этого:

● компоновщик находит имя процедуры (например, EARTH) и приступает к по­иску пользовательской директории для скомпилированной процедуры с таким именем;

● найденной процедуре приписывается виртуальный адрес «Адрес EARTH» (обычно в ее собственном сегменте), который записывается поверх недей­ствительного адреса, как показано на рис. 5.2.4;

● затем команда, которая вызвала ошибку, выполняется заново. Это позволяет программе продолжать работу с того места, где она находилась до систем­ного прерывания. Все последующие обращения к процедуре EARTH будут выполняться без оши­бок, поскольку в сегменте связывания вместо слова «Косвенный адрес» теперь указан действительный виртуальный адрес «Адрес EARTH». Таким образом, ком­поновщик задействован только тогда, когда некоторая процедура вызывается впервые. После этого вызывать компоновщик не требуется.

Динамическое связывание в системе Windows.

Для связывания ис­пользуются динамически подключаемые библиотеки (Dynamic Link Library - DLL), которые содержат процедуры и (или) данные. Библиотеки оформляются в виде файлов с расширениями «.dll», «.drv» (для библиотек драйверов - driver libraries) и «.fon» (для библиотек шрифтов - font libraries). Они позволяют свои процедуры и данные разделять между несколькими программами (процессами). Поэтому са­мой распространенной формой DLL является библиотека, состоящая из набора загружаемых в память процедур, к которым имеют доступ несколько программ одновременно. В качестве примера на рис. 5.2.5 показаны четыре процесса, ко­торые разделяют файл DLL, содержащий процедуры А, В, С и D. Программы 1 и 2 использует процедуру А; программа 3 - процедуру D, программа 4 - процедуру В.
Файл DLL строится компоновщиком из набора входных файлов. Принцип пост­роения подобен построению исполняемого двоичного кода. Отличие проявляется в том, что при построении файла DLL компоновщику передается специальный флаг для сообщения о создании DLL. Файлы DLL обычно конструируются из набо­ра библиотечных процедур, которые могут понадобиться нескольким процессо­рам. Типичными примерами файлов DLL являются процедуры сопряжения с биб­лиотекой системных вызовов Windows и большими графическими библиотеками. Использование файлов DDL позволяет:

● сэкономить пространство в памяти и на диске. Например, если какая–то биб­лиотека была связана с каждой использующей ее программой, то эта библио­тека будет появляться во многих исполняемых двоичных программах в памя­ти и на диске. Если же использовать файлы DLL, то каждая библиотека будет появляться один раз на диске и один раз в памяти;

●упростить обновление библиотечных процедур и, кроме того, осуществить обновление, даже после того как программы, использующие их, были ском­пилированы и связаны;

● исправлять обнаруженные ошибки путем распространения новых файлов DLL (например, по Интернету). При этом не требуется производить никаких изме­нений в основных бинарных программах. Основное различие между файлом DLL и исполняемой двоичной програм­мой состоит в том, что файл DLL:

● не может запускаться и работать сам по себе, поскольку у него нет ведущей программы;

● содержит другую информацию в заголовке;

● имеет несколько дополнительных процедур, не связанных с процедурами в библиотеке, например, процедуры для выделения памяти и управления дру­гими ресурсами, которые необходимы файлу DLL. Программа может установить связь с файлом DLL двумя способами: с помощью неявного связывания и с помощью явного связывания. При неявном связывании пользовательская программа статически связывает­ся со специальным файлом, называемым библиотекой импорта.

Эта библиотека создается обслуживающей программой, или утилитой, путем извлечения определенной информации из файла DLL. Библиотека импорта через связующий элемент позволяет пользовательской программе получать доступ к файлу DLL, при этом она может быть связана с несколькими библиотеками импорта. Система Windows при неявном связывании контролирует загружаемую для выполнения программу. Система выявляет, какие файлы DLL будет использовать программа, и все ли тре­буемые файлы уже находятся в памяти. Отсутствующие файлы немедленно загру­жаются в память.

Затем производятся соответствующие изменения в структурах данных библиотек импорта для того, чтобы можно было определить местополо­жение вызываемых процедур. Эти изменения отображаются в виртуальное адрес­ное пространство программы, после чего пользовательская программа может вызывать процедуры в файлах DLL, как будто они статически связаны с ней, и ее запускают.

При явном связывании не требуются библиотеки импорта и не нужно загру­жать файлы DLL одновременно с пользовательской программой. Вместо этого пользовательская программа:

● делает явный вызов прямо во время работы, чтобы установить связь с файлом DLL;

● затем совершает дополнительные вызовы, чтобы получить адреса процедур, которые ей требуются;

● после этого программа совершает финальный вызов, чтобы разорвать связь с файлом DLL;

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

UTM-метки - набор данных, добавляемых к URL с целью получения дополнительной информации в рамках оценки продуктивности маркетинговых кампаний. UTM-tags были разработаны компанией Urchin Software, поглощенной Google. Пять предлагаемых этими тегами параметров позволяют оценить, насколько успешно то или иное объявление. Данные, получаемые в результате таких GET-запросов, обрабатываются в различных сервисах аналитики, среди которых самые востребованные - Google Analytics и Яндекс.Метрика.

Постановка метки - процесс простой и не требующий специфических знаний (кроме тех, о которых я расскажу в данной статье).

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

  • источник перехода (Google, e-mail и т.д.);
  • тип трафика (например, PPC или КМС);
  • наименование кампании, обеспечившей переход;
  • ключ;
  • дополнительные сведения для различия объявлений.

Первые три параметра из приведенного списка являются для меток обязательными, а следующие, соответственно, необязательными. Теперь познакомимся с ними подробнее. Независимо от того, в какой сети создана кампания, применяются одни и те же метки:

  • utm_source;
  • utm_medium;
  • utm_campaign;
  • utm_term;

Каждая являет собой единство двух компонентов: параметра из числа приведенных выше и соответствующего ему значения. Между ними ставится знак равенства. Поскольку меток в каждом URL минимум три, их необходимо разделить между собой, для чего используется символ &.

Теперь, собственно, образец:

domen.com/?utm_source =google&utm_medium =cpc&utm_campaign =my_sale

Значение устанавливается так, чтобы маркетолог при анализе с помощью систем аналитики имел возможность оперативно оценивать источники переходов (например, utm_ source =adwords или utm_ source = vk) , и типы трафика (utm-medium =cpc или utm-medium =ppc) .

Как видите, никаких сложностей с созданием ссылки нет, достаточно лишь задать необходимые параметры. Чтобы не ошибиться при написании ссылки или просто облегчить себе работу, когда требуется их большое количество, можно использовать специальные сервисы - генераторы UTM-меток.

Теги: Линкер, компоновщик, объектный файл, статическая библиотека, динамическая библиотека, исполнение программы, определение, объявление

Гид по линкерам для начинающих. Часть 1

Перевод статьи Beginner"s guide to linkers с примерами и дополнениями.

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

Именование составных частей: что внутри Си файла

Во-первых, необходимо понять разницу между объявление и определением. Определение связывает имя с реализацией этого имени, которая может быть как данными, так и кодом:

  • Определение переменной приводит к тому, что компилятор выделяет под неё память и возможно заполняет каким-то начальным значением
  • Определение функции приводит к тому, что компилятор генерирует код для этой функции

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

Для переменных, определение бывает двух типов

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

Для ясности, «доступный» значит, что на переменную можно ссылаться по имени, которое связано с её определением.

Есть пара случаев, когда всё не так очевидно

  • Статические локальные переменные на самом деле глобальные, потому что существуют всё время жизни программы, хотя и доступны внутри одной функции
  • Как и статические переменные, глобальные переменные, доступные только внутри одного файла, где они объявлены, тоже глобальные

Стоит сразу напомнить о том, что объявление функции статической уменьшает её область видимости до того файла, в котором она определена (именно, к ней могут обратиться функции из этого файла).

Локальных и глобальных переменные также можно разделить на неинициализированные и инициализированные (которые предварительно заполнены каким-то значением).

В конце концов, мы можем работать с переменными, созданными динамически с помощью функции malloc (или оператора new в С++). К области памяти по имени обратиться нельзя, поэтому мы используем указатели – именованные переменные, которые хранят адрес неименованного участка памяти. Этот участок может быть также освобождён с помощью free (или delete), поэтому считают, что память имеет динамическое размещение.

Соберём теперь всё вместе

Code Data
Global Local Dynamic
Initialized Uninitialized Initialized Uninitialized
Declaration int fn(int x); extern int x; extern int x; N/A N/A N/A
Definition int fn(int x) { ... } int x = 1;
(at file scope)
int x;
(at file scope)
int x = 1;
(at function scope)
int x;
(at function scope)
(int* p = malloc(sizeof(int));)

Проще посмотреть на эту программу

/* Это определение неинициализированной глобальной переменной */ int x_global_uninit; /* Это определение инициализированной глобальной переменной */ int x_global_init = 1; /* Это определение неинициализированной глобальной переменной, но к ней можно обратиться по имени только из этого же си файла */ static int y_global_uninit; /* Это определение инициализированной глобальной переменной, но к ней можно обратиться по имени только из этого же си файла */ static int y_global_init = 2; /* Это объявление глобальной переменной, которая существует где-то в другом месте программы */ extern int z_global; /* Это объявление функции, которая определена где-то в другом месте программы. Можно добавть служебное слово extern. Но это не имеет значения */ int fn_a(int x, int y); /* Это определение функции, но так как она определена со словом static, то доступна только в этом же си файле */ static int fn_b(int x) { return x+1; } /* Это определение функции. Её параметтры рассматриваются как локальные переменные */ int fn_c(int x_local) { /* Это определение неинициализированной локальной переменной */ int y_local_uninit; /* Это определение инициализированной локальной переменной */ int y_local_init = 3; /* Этот код ссылает на локальные и глобальные переменные и функции по имени */ x_global_uninit = fn_a(x_local, x_global_init); y_local_uninit = fn_a(x_local, y_local_init); y_local_uninit += fn_b(z_global); return (y_global_uninit + y_local_uninit); }

Пусть этот файл называется file.c. Собираем так

Cc -g -O -c file.c

Получим объектный файл file.o

Что делает компилятор Си

Работа компилятора си в том, чтобы превратить файл с кодом из понимаемого (иногда) человеком в нечто, понимаемое компьютером. На выходе компилятор даёт объектный файл, который на платформе UNIX имеет обычно расширение.o, а на windows .obj. Содержимое объектного файла это, по сути, два типа объектов

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

Экземпляры этих объектов будут иметь имена, связанные с ними – имена переменных или функций, определения которых привели к их генерации.

Объектный код – это последовательность (подходяще закодированных) машинных инструкций, соответствующих инструкциям на языке Си – всем этим if, while и даже goto. Все эти команды оперируют разного рода информацией, и эта информация должны быть где-то сохранена (для этого нужны переменные). Кроме того, они могут обращаться к другим кускам кода, который определён в файле.

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

Работа компоновщика (линкера) в исполнении этих обещаний, но что делать компилятору, когда он сталкивается с неопределёнными сущностями?

По сути, компилятор просто оставляет заглушку. Заглушка (ссылка) имеет имя, но значения, связанного с ним, ещё не известно.

Теперь мы можем примерно описать, как будет выглядеть наша программа

Анализ объектного файла

Пока мы работали с абстрактной программой; теперь важно посмотреть,как она выглядит на практике. На платформе UNIX можно воспользоваться утилитой nm. На Windows примерным аналогом служит dumpbin с флагом /symbols, хотя есть и порт GNU binutils, который включает nm.exe.

Посмотрим, что нам выдаст для написанной выше программы nm:

00000000 b .bss 00000000 d .data 00000000 N .debug_abbrev 00000000 N .debug_aranges 00000000 N .debug_info 00000000 N .debug_line 00000000 N .debug_loc 00000000 i .drectve 00000000 r .eh_frame 00000000 r .rdata$zzz 00000000 t .text U _fn_a 00000000 T _fn_c 00000000 D _x_global_init 00000004 C _x_global_uninit U _z_global

Для ранее скомпилированного файла file.o

Nm file.o

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

  • Класс U означает неизвестный (unknown), или заглушку, как было сказано выше. Всего два таких объекта: fn_a и z_global (некоторые версии nm могут также вывести section, которая в данном случае будет *UND* или UNDEF)
  • Класс t или T обозначает, что код определён – t локально или T – это статическая функция. Также может быть выведена секция.text
  • Класс d и D обозначают инициализированную глобальную переменную, d – локальную, D – не локальную. Сегмент для данных переменных обычно.data
  • Для неинициализированных глобальных переменных используется класс b, если статическая/локальная или B и C, если нет. Обычно это сегмент.bss или *COM*

Есть и другие гнусные классы, представляющие какие-то внутренние механизмы компилятора.

Что делает компоновщик. Часть 1

Как мы уже определились ранее, объявление переменной или функции – это обещание компилятору, что где-то есть определение этой переменной или функции, и что работа линкера заключается в том, чтобы исполнять эти обещания. На нашей диаграммой объектного файла это также может быть названо «заполнением пустот».

Для иллюстрации этого вот вам ещё один си файл в дополнение к первому

/* Инициализированная глобальная переменная */ int z_global = 11; /* Вторая глобальная переменная с именем y_global_init, но обе они статические */ static int y_global_init = 2; /* Объявление ещё одной глобальной переменной */ extern int x_global_init; int fn_a(int x, int y) { return(x+y); } int main(int argc, char *argv) { const char *message = "Hello, world"; return fn_a(11,12); }

Пусть этот файл называется main.c. Компилируем его как и ранее

Cc –g –O –c main.c

С этими двумя диаграммами теперь мы видим, что все точки могут быть соединены (а если нет, то компоновщик выдаст ошибку). У каждой вещи своё место, и у каждого места есть вещь, и компоновщик может заменить все заглушки, как показано на рисунке.


Для ранее скомпилированных main.o и file.o сборка исполняемого файла

Cc -o out.exe main.o file.o

Вывод nm для исполняемого файла (в нашем случае out.exe):

Symbols from sample1.exe: Name Value Class Type Size Line Section _Jv_RegisterClasses | | w | NOTYPE| | |*UND* __gmon_start__ | | w | NOTYPE| | |*UND* [email protected]@GLIBC_2.0| | U | FUNC|000001ad| |*UND* _init |08048254| T | FUNC| | |.init _start |080482c0| T | FUNC| | |.text __do_global_dtors_aux|080482f0| t | FUNC| | |.text frame_dummy |08048320| t | FUNC| | |.text fn_b |08048348| t | FUNC|00000009| |.text fn_c |08048351| T | FUNC|00000055| |.text fn_a |080483a8| T | FUNC|0000000b| |.text main |080483b3| T | FUNC|0000002c| |.text __libc_csu_fini |080483e0| T | FUNC|00000005| |.text __libc_csu_init |080483f0| T | FUNC|00000055| |.text __do_global_ctors_aux|08048450| t | FUNC| | |.text _fini |08048478| T | FUNC| | |.fini _fp_hw |08048494| R | OBJECT|00000004| |.rodata _IO_stdin_used |08048498| R | OBJECT|00000004| |.rodata __FRAME_END__ |080484ac| r | OBJECT| | |.eh_frame __CTOR_LIST__ |080494b0| d | OBJECT| | |.ctors __init_array_end |080494b0| d | NOTYPE| | |.ctors __init_array_start |080494b0| d | NOTYPE| | |.ctors __CTOR_END__ |080494b4| d | OBJECT| | |.ctors __DTOR_LIST__ |080494b8| d | OBJECT| | |.dtors __DTOR_END__ |080494bc| d | OBJECT| | |.dtors __JCR_END__ |080494c0| d | OBJECT| | |.jcr __JCR_LIST__ |080494c0| d | OBJECT| | |.jcr _DYNAMIC |080494c4| d | OBJECT| | |.dynamic _GLOBAL_OFFSET_TABLE_|08049598| d | OBJECT| | |.got.plt __data_start |080495ac| D | NOTYPE| | |.data data_start |080495ac| W | NOTYPE| | |.data __dso_handle |080495b0| D | OBJECT| | |.data p.5826 |080495b4| d | OBJECT| | |.data x_global_init |080495b8| D | OBJECT|00000004| |.data y_global_init |080495bc| d | OBJECT|00000004| |.data z_global |080495c0| D | OBJECT|00000004| |.data y_global_init |080495c4| d | OBJECT|00000004| |.data __bss_start |080495c8| A | NOTYPE| | |*ABS* _edata |080495c8| A | NOTYPE| | |*ABS* completed.5828 |080495c8| b | OBJECT|00000001| |.bss y_global_uninit |080495cc| b | OBJECT|00000004| |.bss x_global_uninit |080495d0| B | OBJECT|00000004| |.bss _end |080495d4| A | NOTYPE| | |*ABS*

Здесь собраны все символы обоих объектов, и все неопределённые ссылки были вычищены. Символы также были переупорядочены, чтобы одинаковые классы располагались вместе, и было добавлено несколько дополнительных сущностей, чтобы помочь операционной системе работать со всем этим добром как с исполняемой программой.

Для очистки вывода в UNIX можно убрать всё, что начинается с подчерка.

Дублирование символов

В предыдущем разделе мы уяснили, что если компоновщик не может найти определения для объявленного символа, то он выбросит ошибку. Что случится в том случае, если будет найдено два определения для символа во время линковки?

В Си++ вся просто – по стандарту у символа должно быть всегда одно определение (т.н. правило одного определения) секции 3.2 стандарта языка.

Для си всё менее ясно. У функции или инициализированной глобальной переменной всегда должно быть только одно определение. Но определение неинициализированной глобальной переменной может быть рассмотрено как предварительное. Си в таком случае позволяет (по крайней мере, не запрещает) разным файлам кода иметь свои предварительные определения для того же объекта.

Тем не менее, линкерам также приходится иметь дело и с другими языками программирования, для которых правило одного определения не подходит. Например, для Фортрана вполне нормально иметь копию каждой глобальной переменной в каждом файле, где к ней обращаются. Компоновщик вынужден избавляться от всех копий, выбирая одну (обычно, самую старшую версию, если у них разный размер) и выбрасывая остальные. Эта модель часто называется общей (COMMON) моделью сборки, из-за служебного слова COMMON языка FORTRAN.

В результате, UNIX компоновщик обычно не жалуется на дублирование дефиниций символа, по крайней мере, пока дублированный символ неинициализированная глобальная переменная (такая модель известна как ослабленная модель – relaxed ref/def model линковки). Если это вас беспокоит (а должно!) найдите в документации к своему компилятору ключ, который делает поведение более строгим. Например –fno-common для GNU компилятора заставляет помещать неинициализированные переменные в BSS сегмент, вместо генерации общих блоков.

Что делает операционная система

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

Запуск программы приводит к исполнению машинного кода, поэтому, очевидно, нужно переместить программу с жёсткого диска в операционную память, где центральный процессор уже сможет с ней работать. Кусок памяти под программу называется сегментом кода или текстовым сегментом.

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

Для неинициализированных переменных ОС не будет копировать значений из памяти (т.к. их нет) и заполнит всё нулями. Кусок памяти, инициализированный 0 называют bss сегментом.

Начальное значение инициализированных переменных хранится на диске, в исполняемом файле; для неинициализированных переменных хранится их размер.


Заметьте, что мы всё это время говорим только о глобальных переменных и ни разу не упомянули локальные или динамически созданные объекты.

Эти данные не нуждаются в работе линкера, потому что время их жизни начинается с того момента, как запустится программа – задолго после того, как компоновщик закончит свою работу. Тем не менее, для полноты картины всё-таки укажем ещё раз

  • Локальные переменные располагаются на участке памяти, известном как стек, который растёт и уменьшается, когда начинает исполняться или заканчивает работу функция
  • Динамическая память выделяется на участке, известно как куча; выделением заведут функция malloc

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


Что делает компоновщик. Часть 2

После изучения основ работы компоновщика, начнём пробираться дальше и изучать его дополнительные возможности, в том порядке, в котором они исторически возникали и добавлялись в линкер.

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

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

Статические библиотеки

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

На UNIX системах статическая библиотека обычно генерируется командой ar, а сам библиотечный файл имеет расширение.a. Также обычно эти файлы начинаются с префикса lib и передаются линкеру с флагом –l, за которым следует имя библиотеки без префикса lib и без расширения (например, для файла libfred.a надо добавить -lfred).

Ar rcs libfile.a file.o gcc main.o libfile.a -o out.exe

Более сложный пример, пусть у нас имеются три файла

A.c int a_f(int a) { return a + 1; } b.c int b_f(int a) { return a + 1; } c.c int c_f(int a) { return a + 1; }

И главный файл

Abc.c #include int main() { int a = a_f(0); int b = a_f(1); int c = a_f(2); printf("%d %d %d", a, b, c); return 0; }

Соберём a.c, b.c и c.c в библиотеку libabc.a. Сначала скомпилируем все файл (можно и по-отдельности, вместе быстрее)

Gcc –g –O –c a.c b.c c.c abc.c

Получим четыре объектных файла. После этого соберём a, b и c в один файл

Ar rcs libabc.a a.o b.o c.o

и теперь можем скомпилировать программу

Gcc -o abc.exe libabc.a abc.o

Заметьте, что попытка собрать так

Gcc -o abc.exe abc.o libabc.a

приведёт к ошибке – компоновщик начнёт жаловаться на неразрешённые символы.

На windows статические библиотеки обычно имеют расширение.lib и генерируются утилитой LIB, но путаницу вносит то, что такое же расширение и у библиотек импорта, которые просто содержат список доступных в динамической библиотеке (dll) вещей.

Пока линкер проходит по коллекции объектных файлов, чтобы собрать их вместе, он составляет список ещё не разрешённых символов. После обработки списка всех явно объявленных объектов у компоновщика теперь есть ещё одно место, где можно поискать необъявленные символы – в библиотеке. Если неразрешённый объект находится в библиотеке, то он добавляется, точно как если бы пользователь указал его в командной строке.

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

Другая важная деталь – порядок событий. Библиотеки опрашиваются только после того, как была проведена нормальная компоновка, и они обрабатываются по порядку, слева направо. Это значит, что если библиотека требует символ, который был ранее в предыдущей подключённой библиотеке, то линкер не сможет его автоматически найти.

Пример должен помочь разобраться в этом более подробно. Пусть у нас имеются объектные файлы a.o, b.o и библиотеки libx.a, liby.b.

File a.o b.o libx.a liby.a
Object a.o b.o x1.o x2.o x3.o y1.o y2.o y3.o
Definitions a1, a2, a3 b1, b2 x11, x12, x13 x21, x22, x23 x31, x32 y11, y12 y21, y22 y31, y32
Undefined references b2, x12 a3, y22 x23, y12 y11 y21 x31

После обработки файлов a.o и b.o линкер разрешит ссылки b2 и a3, оставив неопределёнными x12 и y22. В этом месте линкер начинает проверять первую библиотеку libx.a и узнаёт, что может вытащить x1.o, в котором определён символ x12; сделав это, линкер получает в нагрузку неопределённые символы x23 и y12, объявленные в x1.o (т.о. в списке неопределённых значатся y22, x23 и y23).

Линкер всё ещё проверяет libx.a, поэтому без труда разрешает символ x23, вытащив его из x2.o библиотеки libx.a. Но этот x2.o добавляет y11 (который теперь состоит из y11, y22 и y12). Ни один из них далее не может быть разрешён с помощью библиотеки libx.a, поэтому линкер переходи к файлу liby.a.

Здесь происходит примерно то же самое, и компоновщик вытаскивает y1.o и y2.o. Первый добавляет y21, но он легко разрешается, так как уже вытащен на свет y2.o. Результатом всей работы становится то, что компоновщик смог разрешить все символы и достал почти все объектные файлы, которые будут помещены в конечный исполняемый файл.

Заметьте, что если бы b.o, например, содержало ссылку на y32, то всё пошло по другому сценарию. Обработка libx.a была бы такой же, но вот обработка liby.a вытащила y3.o, содержащий ссылку x31, которая определена в libx.a. Так как обработка libx.a уже закончена, то компоновщик бы выдал ошибку.

Это пример циклической зависимости двух библиотек libx и liby.

Разделяемые библиотеки

У популярных стандартных библиотек Си (обычно libc) есть очевидный недостаток – каждый исполняемый файл будет иметь свою копию одного и того же когда. Если каждая программа имеет копию printf, fopen и тому подобного, то много дискового пространства будет потрачено впустую.

Другой, менее очевидный недостаток заключается в том, что после статической компоновки код программы неизменен. Если кто-то найдёт и исправит баг в printf, то все программы, использующие эту библиотеку, нужно будет пересобрать.

Для того чтобы обойти эти проблемы, были внедрены разделяемы библиотеки (обычно они имеют расширение.so или.dll по Windows, или.dylib под Mac OS X). При работе с такими библиотеками линкер не обязан объединять все элементы в одну картинку. Вместо этого он оставляет что-то вроде долговой расписки и переносит выплату на тот момент, когда программа будет запущена.

Говоря короче: если линкер узнаёт, что неопределённый символ в разделяемой библиотеке, то он не добавляет определения в исполняемый файл. Вместо этого компоновщик записывает в программу имя символа и библиотеки, в которой он предполагаемо определён.

Во время исполнения программы операционная система определяет, что эти пропущенные биты линкуются “just in time” – во время выполнения. Перед запуском функции main уменьшенная версия линкера (часто это ld.so) проходит по спискам должников и доделывает финальную часть работы – вытаскивает код из библиотеки и собирает пазл.

Это значит, что ни у одной из исполняемых программ нет копии printf. Новая исправленная версия libc может просто подменить старую, и она будет подхвачена каждой из программ при новом запуске.

Есть и другое важное отличие динамической библиотеки от статической, и это отражается в степени детализации ссылок. Если определённый символ достаётся из разделяемой библиотеки (например, printf из libc), всё содержимое этой библиотеки отображается в адресное пространство. Это сильно отличается от статической библиотеки, из которой вытаскивается только тот объектный файл, который содержит определение объявленного символа.

Другими словами, разделяемая библиотека это результат работы компоновщика (а не просто собранные в кучу с помощью ar объектные файлы) с разрешёнными ссылками внутри объектов этого файла. Ещё раз: nm удачно это иллюстрирует. Для статической библиотеки nm покажет набор отдельных объектных файлов. Для разделяемой библиотеки liby.so укажет только неопределённый символ x31. Также, для нашего примера с порядком компоновки и циклической ссылкой, проблем не будет, потому что весь контент y3.o и x3.o так и так уже вытащен.

Есть также другой полезный инструмент ldd. Он показывает все разделяесые библиотеки, от которых зависит исполняемый файл или библиотека с информацией о том, где её можно найти. Чтобы программа могла удачно запуститься, нужно, чтобы все эти библиотеки были найдены, вместе со всеми их зависимостями (обычно, на UNIX системах загрузчик ищет библиотеки в списке папок, который хранится в переменной окружения LD_LIBRARY_PATH).

/usr/bin:ldd xeyes linux-gate.so.1 => (0xb7efa000) libXext.so.6 => /usr/lib/libXext.so.6 (0xb7edb000) libXmu.so.6 => /usr/lib/libXmu.so.6 (0xb7ec6000) libXt.so.6 => /usr/lib/libXt.so.6 (0xb7e77000) libX11.so.6 => /usr/lib/libX11.so.6 (0xb7d93000) libSM.so.6 => /usr/lib/libSM.so.6 (0xb7d8b000) libICE.so.6 => /usr/lib/libICE.so.6 (0xb7d74000) libm.so.6 => /lib/libm.so.6 (0xb7d4e000) libc.so.6 => /lib/libc.so.6 (0xb7c05000) libXau.so.6 => /usr/lib/libXau.so.6 (0xb7c01000) libxcb-xlib.so.0 => /usr/lib/libxcb-xlib.so.0 (0xb7bff000) libxcb.so.1 => /usr/lib/libxcb.so.1 (0xb7be8000) libdl.so.2 => /lib/libdl.so.2 (0xb7be4000) /lib/ld-linux.so.2 (0xb7efb000) libXdmcp.so.6 => /usr/lib/libXdmcp.so.6 (0xb7bdf000)

На Windows, например

Ldd C:\Windows\System32\rundll32.exe ntdll.dll => /c/WINDOWS/SYSTEM32/ntdll.dll (0x77100000) KERNEL32.DLL => /c/WINDOWS/System32/KERNEL32.DLL (0x763a0000) KERNELBASE.dll => /c/WINDOWS/System32/KERNELBASE.dll (0x73e10000) apphelp.dll => /c/WINDOWS/system32/apphelp.dll (0x71ec0000) AcLayers.DLL => /c/WINDOWS/AppPatch/AcLayers.DLL (0x78830000) msvcrt.dll => /c/WINDOWS/System32/msvcrt.dll (0x74ef0000) USER32.dll => /c/WINDOWS/System32/USER32.dll (0x76fb0000) win32u.dll => /c/WINDOWS/System32/win32u.dll (0x74060000) GDI32.dll => /c/WINDOWS/System32/GDI32.dll (0x74b00000) gdi32full.dll => /c/WINDOWS/System32/gdi32full.dll (0x741e0000) SHELL32.dll => /c/WINDOWS/System32/SHELL32.dll (0x74fc0000) cfgmgr32.dll => /c/WINDOWS/System32/cfgmgr32.dll (0x74900000) windows.storage.dll => /c/WINDOWS/System32/windows.storage.dll (0x74390000) combase.dll => /c/WINDOWS/System32/combase.dll (0x76490000) ucrtbase.dll => /c/WINDOWS/System32/ucrtbase.dll (0x74100000) RPCRT4.dll => /c/WINDOWS/System32/RPCRT4.dll (0x76b50000) bcryptPrimitives.dll => /c/WINDOWS/System32/bcryptPrimitives.dll (0x74940000) powrprof.dll => /c/WINDOWS/System32/powrprof.dll (0x73c20000) advapi32.dll => /c/WINDOWS/System32/advapi32.dll (0x76ad0000) sechost.dll => /c/WINDOWS/System32/sechost.dll (0x76440000) shlwapi.dll => /c/WINDOWS/System32/shlwapi.dll (0x76d30000) kernel.appcore.dll => /c/WINDOWS/System32/kernel.appcore.dll (0x73c10000) shcore.dll => /c/WINDOWS/System32/shcore.dll (0x76c20000) profapi.dll => /c/WINDOWS/System32/profapi.dll (0x73c70000) OLEAUT32.dll => /c/WINDOWS/System32/OLEAUT32.dll (0x76e20000) msvcp_win.dll => /c/WINDOWS/System32/msvcp_win.dll (0x74080000) SETUPAPI.dll => /c/WINDOWS/System32/SETUPAPI.dll (0x766c0000) MPR.dll => /c/WINDOWS/SYSTEM32/MPR.dll (0x6cac0000) sfc.dll => /c/WINDOWS/SYSTEM32/sfc.dll (0x2380000) WINSPOOL.DRV => /c/WINDOWS/SYSTEM32/WINSPOOL.DRV (0x6f2f0000) bcrypt.dll => /c/WINDOWS/SYSTEM32/bcrypt.dll (0x73b70000) sfc_os.DLL => /c/WINDOWS/SYSTEM32/sfc_os.DLL (0x68e00000) IMM32.DLL => /c/WINDOWS/System32/IMM32.DLL (0x76d90000) imagehlp.dll => /c/WINDOWS/System32/imagehlp.dll (0x749a0000)

Причиной этого большего дробления связана с тем, что операционная система достаточно умная и вы можете дублировать дисковое пространство не только статическими библиотеками. Разные исполняемые процессы могут расшаривать также один сегмент кода (но не data/bss сегменты). Чтобы это сделать, вся библиотека должна быть отображена в один проход, чтобы все внутренние ссылки выстроились в один ряд: если один процесс вытащил a.o и c.o, а второй b.o и c.o, для операционной системы никакого совпадения не будет.