Для чего в графических библиотеках используется компоновщик. Функции компоновщика и загрузчика

В типичной системе одновременно выполняется множество программ. Работа каждой программы зависит от множества функций, некоторые из которых входят в состав "стандартной" Си-библиотеки (например, printf() , malloc() , write() и т. д.).

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

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

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

Статически скомпонованные программы компонуются с архивами объектов (библиотеками ), которые обычно имеют расширение a. Примером такого набора объектов является стандартная Си-библиотека libc.a.

Динамическая компоновка Термин "динамически скомпонованный" (dynamically linked) означает, что программа и некоторая библиотека не были объединены с помощью компоновщика во время процесса компоновки. Вместо этого, компоновщик помещает информацию в исполняемый файл, который, в свою очередь, сообщает загрузчику, в каком разделяемом объектном модуле расположен код и какой динамический компоновщик (runtime linker) должен использоваться для поиска и компоновки ссылок. Это означает, что связь между программой и разделяемым объектом устанавливается во время выполнения программы, а именно, в самом начале выполнения производится поиск и компоновка необходимых разделяемых объектов.

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

Динамически скомпонованные программы компонуются с разделяемым объектами с расширением so. Примером такого объекта является разделяемая стандартная Си-библиотека libc.so.

Для того, чтобы сообщить комплекту инструментов о том, какой тип компоновки применяется - статический или динамический - используется соответствующая опция командной строки утилиты qcc. Эта опция затем определяет используемое расширение (a или so).

Добавление кода в процессе работы программы При таком подходе вызываемые из программы функции будут определены только на этапе исполнения. Это предоставляет дополнительные возможности.

Рассмотрим пример работы драйвера диска. Драйвер запускается, тестирует оборудование и обнаруживает жёсткий диск. Затем драйвер динамически загружает модуль io-blk, предназначенный для обработки дисковых блоков, т.к. было обнаружено блок-ориентированное устройство. После того как драйвер получает доступ к диску на блочном уровне, он обнаруживает на диске два раздела: раздел DOS и раздел QNX4. Чтобы не увеличивать размер драйвера жёсткого диска, в него вообще не включаются драйверы файловых систем. Во время работы системы драйвер может обнаружить эти два раздела (DOS и QNX4) и только после этого загрузить соответствующие модули файловых систем fs-dos.so и fs-qnx4.so.

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

Как используются разделяемые объекты Для того чтобы понять, как программа использует разделяемые объекты, необходимо сначала рассмотреть формат исполняемого модуля, а затем последовательность тех стадий, через которые программа проходит при запуске. Формат ELF В ОС QNX Neutrino используется так называемый двоичный формат исполняемых и компонуемых модулей (Executable and Linkable Format, ELF), который в настоящее время принят в системах SVR4 Unix. Формат ELF не только упрощает создание разделяемых библиотек, но также расширяет возможности динамической загрузки модулей во время работы программы.

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

В процессе компоновки программа или библиотека строится посредством слияния секций, имеющих одинаковые атрибуты, и преобразования их в сегменты. Как правило, все секции, содержащие данные, которые предназначены для исполнения или "только для чтения", компонуются в один сегмент text , а данные и BSS компонуются в сегмент data . Эти сегменты называются загрузочными сегментами (load segments) , потому что они должны быть загружены в память при создании процесса. Другие секции, как, например, информация об идентификаторах и отладочная информация, объединяются в т. н. незагружаемые сегменты (nonload segments) .


11. Принципы функционирования систем программирования. Функции текстовых редакторов в системах программирования. Компилятор как составная часть системы программирования.

Принципы функционирования систем программирования

Функции текстовых редакторов в системах программирования

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

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

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

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

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

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

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

Компилятор как составная часть системы программирования

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

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

Сейчас, работая с системой программирования, пользователь, как правило, име­ет дело только с ее интерфейсной частью, которую обычно представляет тексто­вый редактор с расширенными функциями. Запуск модуля компилятора и вся его работа происходят автоматически и скрытно от пользователя - разработ­чик видит только конечные результаты выполнения компилятора. Хотя многие современные системы программирования сохранили прежнюю возможность не­посредственного взаимодействия разработчика с компилятором (это и Makefile, и так называемый «интерфейс командной строки»), но пользуется этими средст­вами только узкий круг профессионалов. Большинство пользователей систем программирования сейчас редко непосредственно сталкиваются с компилято­рами.

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

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

Компоновщик. Назначение и функции компоновщика.

12. Компоновщик. Назначение и функции компоновщика

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

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

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

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

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

13. Загрузчики и отладчики. Функции загрузчика

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

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

Однако загрузчик не всегда является составной частью системы программирова­ния, поскольку выполняемые им функции очень зависят от архитектуры целе­вой вычислительной системы, в которой выполняется результирующая программа, созданная системой программирования. На первых этапах развития ОС загруз­чики существовали в виде отдельных модулей, которые выполняли трансляцию адресов и готовили программу к выполнению - создавали так называемый «об­раз задачи». Такая схема была характерна для многих ОС (например, для ОСРВ на ЭВМ типа СМ-1, ОС RSX/11 или RAFOS на ЭВМ типа СМ-4 и т. п. ). Образ задачи можно было сохранить на внешнем носителе или же создавать его вновь всякий раз при подготовке программы к выполнению.

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

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

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

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


  • последовательное пошаговое выполнение результирующей программы на ос­
    нове шагов по машинным командам или по операторам входного языка;

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

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

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

  • появлением интегрированных сред разработки;

  • появление возможностей аппаратной поддержки средств отладки во многих
    вычислительных системах.
Первый из этих шагов дал возможность разработчикам программ работать не в терминах машинных команд, а в терминах исходного языка программирова­ния, что значительно сократило трудозатраты на отладку программного обес- печения. При этом отладчики перестали быть отдельными модулями и стал] интегрированной частью систем программирования, поскольку они должны был] теперь поддерживать работу с таблицами идентификаторов (см. раздел «Табли цы идентификаторов. Организация таблиц идентификаторов», глава 15) и вы поднять задачу, обратную идентификации лексических единиц языка (см. разде. «Семантический анализ и подготовка к генерации кода», глава 14). Это связано тем, что в такой среде отладка программы идет в терминах имен, данных поль зователем, а не в терминах внутренних имен, присвоенных компилятором. Сс ответствующие изменения потребовались также в функциях компиляторов компоновщиков, поскольку они должны были включать таблицу имен в соста объектных и исполняемых файлов для ее обработки отладчиком.

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

14. Библиотеки подпрограмм как составная часть систем программирования

Библиотеки подпрограмм составляют существенную часть систем программир
Библиотеки подпрограмм входили в состав средств разработки, начиная с самь ранних этапов их развития. Даже когда компиляторы еще представляли собс отдельные программные модули, они уже были связаны с соответствующими би(лиотеками, поскольку компиляция так или иначе предусматривает связь програм со стандартными функциями исходного языка. Эти функции обязательно дол> ны входить в состав библиотек.

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

15. Лексический анализ «на лету». Система подсказок и справок.

Дополнительные возможности систем программирования

Лексический анализ «на лету». Система подсказок и справок

Лексический анализ «на лету» - это функция текстового редактора в составе системы программирования. Она заключается в поиске и выделении лексем вход­ного языка в тексте программы непосредственно в процессе ее создания разра­ботчиком.

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

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

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

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

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

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


  • справку по семантике и синтаксису используемого входного языка;

  • подсказку по работе с самой системой программирования;

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

16.Разработка программ в архитектуре «клиент-сервер»

Структура приложения, построенного в архитектуре «клиент - сервер».

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

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

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

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

Тогда сложилось понятие приложения, построенного на основе архитектуры «клиент-сервер». В первую (серверную) составляющую такого приложения от­носят все методы, связанные с доступом к данным. Чаще всего их реализует сер-

Вер БД (сервер данных) из соответствующей СУБД (системы управления база­ми данных) в комплекте с драйверами доступа к нему. Во вторую (клиентскую) часть приложения относят все методы обработки данных и представления их пользователю. Клиентская часть взаимодействует, с одной стороны, с сервером, получая от него данные, а с другой стороны - с пользователем, ресурсами при­ложения и ОС, осуществляя обработку данных и отображение результатов. Ре­зультаты обработки клиент опять-таки может сохранить в БД, воспользовав­шись функциями серверной части.

Кроме того, со временем на рынке СУБД стали доминировать несколько наибо­лее известных компаний-производителей. Они предлагали стандартизованные интерфейсы для доступа к создаваемым ими СУБД. На них, в свою очередь, ста­ли ориентироваться и разработчики прикладных программ. Такая ситуация оказала влияние и на структуру систем программирования. Мно­гие из них стали предлагать средства, ориентированные на создание приложений в архитектуре «клиент-сервер». Как правило, эти средства поставляются в соста­ве системы программирования и поддерживают возможность работы с широким диапазоном известных серверов данных через один или несколько доступных интерфейсов обмена данными. Разработчик прикладной программы выбирает одно из доступных средств плюс возможный тип сервера (или несколько воз- : можных типов), и тогда его задача сводится только к созданию клиентской части, ; приложения, построенной на основе выбранного интерфейса. Создав клиентскую часть, разработчик может далее использовать и распростра­нять ее только в комплексе с соответствующими средствами из состава системы программирования. Интерфейс обмена данными обычно входит в состав систе­мы программирования. Большинство систем программирования предоставляют возможность распространения средств доступа к серверной части без каких-либо дополнительных ограничений.

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

Более подробно об организации приложений на основе архитектуры «клиент-сервер» можно узнать в .



препроцессор компилятор компоновщик (7)

Я хочу понять, на какую часть компилятора программы он смотрит и на что ссылается линкер. Поэтому я написал следующий код:

#include using namespace std ; #include < class paramType > void FunctionTemplate (paramType val ) { i = val } }; void Test :: DefinedCorrectFunction (int val ) { i = val ; } void Test :: DefinedIncorrectFunction (int val ) { i = val } void main () { Test testObject (1 ); //testObject.NonDefinedFunction(2); //testObject.FunctionTemplate(2); }

У меня есть три функции:

  • DefinedCorrectFunction - это нормальная функция, объявленная и определенная правильно.
  • DefinedIncorrectFunction - эта функция объявлена ​​правильно, но реализация неверна (отсутствует;)
  • NonDefinedFunction - только объявление. Нет определения.
  • FunctionTemplate - шаблон функции.

    Теперь, если я скомпилирую этот код, я получаю ошибку компилятора для отсутствующего «;» в DefinedIncorrectFunction.
    Предположим, я исправить это, а затем прокомментировать testObject.NonDefinedFunction (2). Теперь я получаю ошибку компоновщика. Теперь закомментируйте testObject.FunctionTemplate (2). Теперь я получаю ошибку компилятора для отсутствующего «;».

Для шаблонов функций я понимаю, что они не тронуты компилятором, если они не вызываются в коде. Итак, недостающие ";" не жалуется компилятором, пока я не вызвал testObject.FunctionTemplate (2).

Для testObject.NonDefinedFunction (2) компилятор не жаловался, но компоновщик делал это. Насколько я понимаю, весь компилятор должен был знать, что объявлена ​​функция NonDefinedFunction. Он не заботился об осуществлении. Затем линкер жаловался, потому что не смог найти реализацию. Все идет нормально.

Поэтому я не совсем понимаю, что именно делает компилятор и что делает компоновщик. Мое понимание компонентов компоновщика ссылок со своими вызовами. Так что, когда NonDefinedFunction называется, он ищет скомпилированную реализацию NonDefinedFunction и жалуется. Но компилятор не заботился о реализации NonDefinedFunction, но это делалось для DefinedIncorrectFunction.

Я бы очень признателен, если кто-нибудь сможет объяснить это или дать некоторую ссылку.

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

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

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

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

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

  1. Заголовки - информация о файле
  2. Код объекта - код в машинном языке (этот код не может работать сам по себе в большинстве случаев)
  3. Информация о переезде. Каким частям кода необходимо будет изменить адреса при фактическом выполнении
  4. Символьная таблица - Символы, на которые ссылается код. Они могут быть определены в этом коде, импортированы из других модулей или определены компоновщиком
  5. Отладочная информация - используется отладчиками

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

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

Следует отметить

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

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

Так что в вашем конкретном случае -

  1. DefinedIncorrectFunction () - компилятор получает определение функции и начинает компилировать его для создания объектного кода и вставки соответствующей ссылки в таблицу символов. Ошибка компиляции из-за синтаксической ошибки, поэтому компилятор прерывается с ошибкой.
  2. NonDefinedFunction () - компилятор получает декларацию, но не имеет определения, поэтому он добавляет запись в таблицу символов и помещает компоновщик для добавления соответствующих значений (так как компоновщик обрабатывает кучу объектных файлов, возможно, это определение присутствует в каком-то другом объектном файле). В вашем случае вы не указываете какой-либо другой файл, поэтому компоновщик прерывается с ошибку undefined reference to NonDefinedFunction потому что он не может найти ссылку на соответствующую запись в таблице символов.

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

#include #include class Test { private : int i ; public : Test (int val ) { i = val ;} void DefinedCorrectFunction (int val ); void DefinedIncorrectFunction (int val ); void NonDefinedFunction (int val ); template < class paramType > void FunctionTemplate (paramType val ) { i = val ; } };

Файл try.cpp

#include "try.h" void Test :: DefinedCorrectFunction (int val ) { i = val ; } void Test :: DefinedIncorrectFunction (int val ) { i = val ; } int main () { Test testObject (1 ); testObject . NonDefinedFunction (2 ); //testObject.FunctionTemplate(2); return 0 ; }

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

$g ++ - c try . cpp - o try . o $

Этот шаг протекает без каких-либо проблем. Таким образом, у вас есть объектный код в try.o. Попробуем связать его

$g ++ try . o try . o : In function ` main ": try . cpp :(. text + 0x52 ): undefined reference to ` Test :: NonDefinedFunction (int ) " collect2 : ld returned 1 exit status

Вы забыли определить Test:: NonDefinedFunction. Давайте определим его в отдельном файле.

Файл-try1.cpp

#include "try.h" void Test :: NonDefinedFunction (int val ) { i = val ; }

Скомпилируем его в объектный код

$ g ++ - c try1 . cpp - o try1 . o $

Опять же, это успешно. Попробуем связать только этот файл

$ g ++ try1 . o / usr / lib / gcc / x86_64 - redhat - linux / 4.4 . 5 /../../../../ lib64 / crt1 . o : In function ` _start ": (. text + 0x20 ): undefined reference to ` main " collect2 : ld returned 1 exit status

Нет основной так выигранной!

Теперь у вас есть два отдельных объектных кода, в которых есть все необходимые компоненты. Просто передайте ОБОИХ из них в компоновщик, и пусть это сделает остальное

$ g ++ try . o try1 . o $

Нет ошибки!! Это связано с тем, что компоновщик находит определения всех функций (даже если они разбросаны в разных объектных файлах) и заполняет пробелы в объектных кодах соответствующими значениями

Скажите, что вы хотите съесть какой-нибудь суп, так что отправляйтесь в ресторан.

Вы ищете меню для супа. Если вы не найдете его в меню, вы покидаете ресторан. (вроде компилятора, жалующегося на то, что он не смог найти функцию). Если вы его найдете, что вы делаете?

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

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

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

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

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

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

Это несколько изменено в таких случаях, как встроенные функции, где вам разрешено повторять определение в каждой единицы перевода и чрезвычайно модифицировать шаблоны, поскольку многие ошибки не могут быть обнаружены до создания экземпляра. В случае шаблонов стандарт оставляет реализации большой свободой: по крайней мере, компилятор должен анализировать шаблон достаточно, чтобы определить, где заканчивается шаблон. Стандарт добавил такие вещи, как typename , однако, чтобы позволить намного больше разбора до создания экземпляра. Однако в зависимых контекстах некоторые ошибки не могут быть обнаружены до создания экземпляра, что может иметь место во время компиляции или в момент времени ранней реализации, благоприятствовавший созданию момента времени; компиляция момента времени доминирует сегодня и используется VC ++ и g ++.

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

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

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

Я считаю, что это ваш вопрос:

Там, где я запутался, компилятор жаловался на DefinedIncorrectFunction. Он не искал реализацию NonDefinedFunction, но прошел через DefinedIncorrectFunction.

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

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

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

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

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

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

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

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

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

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

Однако загрузчик не всегда является составной частью системы программирования, поскольку выполняемые им функции очень зависят от архитектуры целевой вычислительной системы, в которой выполняется результирующая программа, созданная системой программирования. На первых этапах развития ОС загрузчики существовали в виде отдельных модулей, которые выполняли трансляцию адресов и готовили программу к выполнению – создавали так называемый “образ задачи”. Такая схема была характерна для многих ОС (например, для ОСРВ на ЭВМ типа СМ-1, ОС RSX/11 или RAFOS на ЭВМ типа СМ-4 и т. п.). Образ задачи можно было сохранить на внешнем носителе или же создавать его вновь всякий раз при подготовке программы к выполнению.

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

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

Статьи к прочтению:

Как перейти на Miui 9 Stable Global с китайской прошивки? Разблокировка загрузчика

Цель данной статьи - помочь 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 файла. Если всё в , имеет для Вас смысл, то скорее всего Вы можете пропустить эту главу и сразу перейти к .

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

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

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

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

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

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

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

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

Подытожим:

Вероятно более лёгкий путь усвоить - это просто посмотреть на пример программы.
/* Определение неинициализированной глобальной переменной */ int x_global_uninit; /* Определение инициализированной глобальной переменной */ int x_global_init = 1; /* Определение неинициализированной глобальной переменной, к которой * можно обратиться по имени только в пределах этого C файла */ 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 инструкциям, написанных программистом: все эти if "ы и while "ы и даже goto . Эти заклинания должны манипулировать информацией определённого рода, а информация должна быть где-нибудь находится - для этого нам и нужны переменные. Код может также ссылаться на другой код (в частности на другие C функции в программе).

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

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

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

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

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

До сих пор мы рассматривали всё на высоком уровне. Однако полезно посмотреть, как это работает на практике. Основным инструментом для нас будет команда nm , которая выдаёт информацию о символах объектного файла на платформе UNIX. Для Windows команда dumpbin с опцией /symbols является приблизительным эквивалентом. Также есть инструменты 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 кода. Мы не будем заострять наше внимание на этом, так как это обычно часть внутреннего механизма компилятора, для того чтобы Ваша программа всё-таки смогла быть потом скомпонована.

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

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

Проиллюстрируем это на примере, рассматривая ещё один C файл в дополнение к тому, что .
/* Инициализированная глобальная переменная */ int z_global = 11; /* Вторая глобальная переменная с именем y_global_init, но они обе static */ 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); }

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

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

Однако, компоновщики должны уметь обходится также и с другими языками кроме C и C++, для которых правило одного определения не обязательно соблюдается. Например, для Fortran"а является нормальным иметь копию каждой глобальной переменной в каждом файле, который на неё ссылается. Компоновщику необходимо тогда убрать дубликаты, выбрав одну копию (самого большого представителя, если они отличаются в размере) и выбросить все остальные. Эта модель иногда называется «общей моделью» компоновки из-за ключевого слова COMMON (общий) языка Fortran.

Как результат, вполне распространённо для UNIX компоновщиков не ругаться на наличие повторяющихся символов, по крайней мере, если это повторяющиеся символы неинициализированных глобальных переменных (эта модель компоновки иногда называется «моделью с ослабленной связью» [прим. перев. это мой вольный перевод relaxed ref/def model. Более удачные предложения приветствуются]). Если это Вас волнует (вероятно и должно волновать), обратитесь к документации Вашего компоновщика, чтобы найти опцию --работай-правильно, которая усмиряет его поведение. Например, в GNU тулчейне опция компилятора -fno-common заставляет поместить неинициализированную переменную в сегмент BBS вместо генерирования общих (COMMON) блоков.

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

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

Запуск программы разумеется влечёт за собой выполнение машинного кода, т.е. ОС очевидно должна перенести машинный код исполняемого файла с жёстокого диска в операционную память, откуда CPU сможет его забрать. Эти порции называются сегментом кода (code segment или text segment).

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

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

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

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

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

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

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

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

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

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

Техническое отступление: Эта глава полностью опускает важное свойство компоновщика: переадресация (relocation). Разные программы имеют различные размеры, т.е. если разделяемая библиотека отображается в адресное пространство различных программ, она будет иметь различные адреса. Это в свою очередь означает, что все функции и переменные в библиотеке будут на различных местах. Теперь, если все обращения к адресам относительные («значение +1020 байта отсюда») нежели абсолютные («значение в 0x102218BF»), то это не проблема, однако так бывает не всегда. В таких случаях всем абсолютным адресам необходимо прибавить подходящий офсет - это и есть relocation . Я не собираюсь возвращается к этой теме снова, однако добавлю, что так как это практически всегда скрыто от C/C++ программиста - очень редко проблемы компоновки вызваны трудностями переадресации.

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

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

В системах UNIX командой для сборки статичной библиотеки обычно является ar , и библиотечный файл, который при этом получается, имеет расширение *.a. Также эти файлы обычно имеют префикс «lib» в своём названии и они передаются компоновщику с опцией "-l" с последующим именем библиотеки без префикса и расширения (т.е. "-lfred" подхватит файл «libfred.a»).
(Раньше программа, называемая ranlib , также была нужна для статических библиотек, чтобы сгенерировать список символов вначале библиотеки. В наши дни инструменты ar делают это сами.)

В системе Windows статические библиотеки имеют расширение.LIB и собираются инструментами LIB, однако этот факт может ввести в заблуждение, так как такое же расширение используется и для «import library», которая содержит в себе только список того, что имеется в DLL - смотрите

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

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

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

Приведём пример, чтоб прояснить ситуацию; предположим у нас есть следующие объектные файлы и строка команды компоновки, которая содержит a.o, b.o, -lx и -ly .


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

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

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

Заметьте, что ситуация несколько изменяется, если скажем b.o тоже имел бы ссылку на y32 . Если это было бы так, то компоновка libx.a происходила бы также, но обработка liby.a повлекла бы включение y3.o . Включением этого объекта мы добавим x31 к списку неразрешённых символов и эта ссылка останется неразрешённой - на этой стадии компоновщик уже завершил обработку libx.a и поэтому уже не найдёт определение этого символа (в x3.o).

(Между прочим этот пример имеет циклическую зависимость между библиотеками libx.a и liby.a ; обычно это плохо )

Динамические разделяемые библиотеки

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

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

Чтоб избавиться от этих и других проблем, были представлены динамически разделяемые библиотеки (обычно они имеют расширение.so или.dll в Windows и.dylib в Mac OS X). Для этого типа библиотек компоновщик не обязательно соединяет все точки. Вместо этого компоновщик выдаёт купон типа «IOU» (I owe you = я тебе должен) и откладывает обналичивание этого купона до момента запуска программы.

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

Когда программа вызывается на исполнение, ОС заботится о том, чтобы оставшиеся части процесса компоновки были выполнены вовремя до начала работы программы. Прежде чем будет вызвана функция main , малая версия компоновщика (часто называемая ld.so) проходится по списку обещания и выполняет последний акт компоновки прямо на месте - помещает код библиотеки и соединяет все точки.

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

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

Сформулируем иначе, разделяемые библиотеки сами получаются как результат работы компоновщика (а не как формирование большой кучи объектов, как это делает ar), содержащий ссылки между объектами в самой библиотеке. Повторю ещё, nm - полезный инструмент для иллюстрации происходящего: для он выдаст множество исходов для каждого объектного файла в отдельности, если этот инструмент запустить на статической версии библиотеки, но для разделяемой версии библиотеки liby.so имеет только один неопределённый символ x31 . Также в примере с порядком включения библиотек в конце тоже никаких проблем не будет: добавление ссылки на y32 в b.c не повлечёт никаких изменений, так как всё содержимое 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)
Причина большей гранулярности заключается в том, что современные операционные системы достаточно интеллигентны, чтобы позволить делать больше, чем просто сэкономить сохранение повторяющихся элементов на диске, чем страдают статические библиотеки. Различные исполняемые процессы, которые используют одну и туже разделяемую библиотеку, также могут совместно использовать сегмент кода (но не сегмент данных или сегмент bss - например, два различных процесса могут находится в различных местах при использовании, скажем, strtok). Чтобы этого достичь, вся библиотека должна быть адресована одним махом, чтобы все внутренние ссылки были выстроены однозначным образом. Действительно, если один процесс подхватывает a.o и c.o , а другой b.o и c.o , то ОС не сможет использовать никаких совпадений.

Windows DLL

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

Экспортируемые символы

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

Есть три способа как экспортировать символ и Windows DLL (и все эти три способа можно перемешивать в одной и той же библиотеке).

  • В исходном коде объявить символ как __declspec(dllexport) , примерно так:
    __declspec(dllexport) int my_exported_function(int x, double y)
  • При выполнении команды компоновщика использовать опцию LINK.EXE export: symbol_to_export
    LINK.EXE /dll /export:my_exported_function
  • Скормить компоновщику файл определения модуля (DEF) (используя опцию /DEF: def_file ), включив в этот файл секцию EXPORT , которая содержит символы, подлежащие экспортированию.
    EXPORTS my_exported_function my_other_exported_function
Как только к этой мешанине подключается C++, первая из этих опций становится самой простой, так как в этом случае компилятор берёт на себя обязательства позаботиться о

.LIB и другие относящиеся к библиотеке файлы

Мы подошли ко второй трудности, связанной с библиотеками Windows: информация об экспортируемых символах, которые компоновщик должен связать с остальными символам, не содержится в самом DLL . Вместо этого данная информация содержится в соответствующем.LIB файле.

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

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

На самом деле существует целый ряд различных файлов, которые могут относиться каким-либо образом к библиотекам Windows. Наряду с.LIB файлом, а также (опциональным) .DEF файлом Вы можете увидеть все нижеперечисленные файлы, ассоциированные с Вашей Windows библиотекой.

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

Импортируемые символы

Вместе с требованием к DLL явно объявлять , Windows также разрешает бинарникам, которые используют код библиотеки, явно объявлять символы, подлежащие импортированию. Это не является обязательным, но даёт некоторую оптимизацию по скорости, вызванную историческими свойствами 16-ти битных окон .

Мы можем проследить за этими списками, опять же прибегнув к помощи nm . Рассмотрим следующий C++ файл:
class Fred { private: int x; int y; public: Fred() : x(1), y(2) {} Fred(int z): x(z), y(3) {} }; Fred theFred; Fred theOtherFred(55);
Для этого кода (не декорированный) вывод nm выглядит так:
Symbols from global_obj.o: Name Value Class Type Size Line Section __gxx_personality_v0| | U | NOTYPE| | |*UND* __static_initialization_and_destruction_0(int, int) |00000000| t | FUNC|00000039| |.text Fred::Fred(int) |00000000| W | FUNC|00000017| |.text._ZN4FredC1Ei Fred::Fred() |00000000| W | FUNC|00000018| |.text._ZN4FredC1Ev theFred |00000000| B | OBJECT|00000008| |.bss theOtherFred |00000008| B | OBJECT|00000008| |.bss global constructors keyed to theFred |0000003a| t | FUNC|0000001a| |.text
Как обычно, мы можем увидеть здесь кучу разных вещей, но одна из них наиболее интересна для нас это записи с классом W (что означает «слабый» символ («weak» symbol)) а также записи именем секции типа ".gnu.linkonce.t.stuff ". Это маркеры для конструкторов глобальных объектов и мы видим, что соответствующее поле «Name» показывает то, что мы собственно и могли там ожидать - каждый из двух конструкторов задействованы.

Шаблоны

Ранее мы приводили с тремя различными реализациями функции max , каждая из которых принимала аргументы различных типов. Однако, мы видим, что код тела функции во всех трёх случаях идентичен. А мы знаем, что дублировать один и тот же код - это дурной тон программирования.

C++ вводит понятия шаблона (templates), который позволяет использовать код, приведённый ниже, сразу для всех случаев. Мы можем создать заголовочный файл max_template.h с только одной копией кода функции max:
template T max(T x, T y) { if (x>y) return x; else return y; }
и включим этот файл в исходный файл, чтобы испробовать шаблонную функцию:
#include "max_template.h" int main() { int a=1; int b=2; int c; c = max(a,b); // Компилятор автоматически определяет, что нужно именно max(int,int) double x = 1.1; float y = 2.2; double z; z = max(x,y); // Компилятор не может определить, поэтому требуем max(double,double) return 0; }
Этот написанный на C++ код использует max(int,int) и max(double,double) . Однако, какой-нибудь другой код мог бы использовать и другие инстанции этого шаблона. Ну, скажем, max(float,float) или даже max(MyFloatingPointClass,MyFloatingPointClass) .

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

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

Способ прореживания повторяющихся инстанций подразумевает, что каждый объектный файл содержит код всех повстречавшихся шаблонов. Например, для приведённого выше файла, содержимое объектного файла выглядит так:
Symbols from max_template.o: Name Value Class Type Size Line Section __gxx_personality_v0 | | U | NOTYPE| | |*UND* double max(double, double) |00000000| W | FUNC|00000041| |.text _Z3maxIdET_S0_S0_ int max(int, int) |00000000| W | FUNC|00000021| |.text._Z3maxIiET_S0_S0_ main |00000000| T | FUNC|00000073| |.text
И мы видим присутствие обоих инстанций max(int,int) и max(double,double) .

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

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

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

Динамически загружаемые библиотеки

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

Это осуществляется парой системных вызовов dlopen и dlsym (примерные эквиваленты в Windows соответственно называются LoadLibrary и GetProcAddress). Первый берёт имя разделяемой библиотеки и догружает её в адресное пространство запущенного процесса. Конечно, эта библиотека может также иметь неразрешённые символы, поэтому вызов dlopen может повлечь за собой подгрузку других разделяемых библиотек.

Dlopen предлагает на выбор либо ликвидировать все неразрешённости сразу, как только библиотека загружена, (RTLD_NOW) либо разрешать символы по мере необходимости (RTLD_LAZY). Первый способ означает, что вызов dlopen может занять достаточно времени, однако второй способ закладывает определённый риск, что во время выполнения программы будет обнаружена неопределённая ссылка, которая не может быть разрешена - в этот момент программа будет завершена.

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

Взаимодействие с C++

Процесс динамической загрузки достаточно прямолинеен, но как он взаимодействует с различными особенностями C++, которые воздействуют на всё поведение компоновщика?

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

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

Подводя итог изложенному выше, отметим следующее: обычно лучше иметь одну заключённую в extern "C" точку вхождения, которая может быть найдена dlsym "ом. Эта точка вхождения может быть фабричным методом, который возвращает указатели на все инстанции C++ класса, разрешая доступ ко всем прелестям C++.

Компилятор вполне может разобраться с конструкторами глобальных объектов в библиотеке, подгружаемой dlopen , так как есть парочка специальных символов, которые могут быть добавлены в библиотеку, и которые будут вызваны компоновщиком (неважно во время загрузки или исполнения), если библиотека динамически догружается или выгружается - то есть необходимые вызовы конструкторов или деструкторов могут произойти здесь. В Unix это функции _init и _fini , или для более новых систем, использующих GNU инструментарий существуют функции, маркированные как __attribute__((constructor)) или __attribute__((destructor)) . В Windows соответствующая функция - DllMain с параметром DWORD fdwReason равным DLL_PROCESS_ATTACH или DLL_PROCESS_DETACH .

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

Дополнительно

В этой статье были намеренно пропущены многие детали о том, как компоновщик работает, потому что я считаю, что содержимое написанного покрывает 95% повседневных проблем, с которыми программист имеет дело при компоновке своей программы.

Если Вы хотите узнать больше, то можно почерпнуть информацию из ниже приведённых ссылок:

Many thanks to Mike Capp and Ed Wilson for useful suggestions about this page.

Copyright 2004-2005,2009-2010 David Drysdale

Permission is granted to copy, distribute and/or modify this document under the terms of the GNU Free Documentation License, Version 1.1 or any later version published by the Free Software Foundation; with no Invariant Sections, with no Front-Cover Texts, and with no Back-Cover Texts. A copy of the license is available .

Теги: Добавить метки

  • Сергей Савенков

    какой то “куцый” обзор… как будто спешили куда то