Компоновщики, как и загрузчики, не являются самыми интересными для изучения, с точки зрения очень многих. Часто их воспринимают как просто еще один лишний шаг, который отделяет только что откомпилированную программу от запуска. Но это мнение ошибочно. Компоновщик это очень интересно! Не верите? Тогда давайте начнем разбираться.
Зачем вообще нужен компоновщик? Почему недостаточно просто компилятора?
Между идеей, возникшей в голове автора программы, и выполняющейся ЭВМ программой дистанция огромного размера. И в этой дистанции есть несколько семантических разрывов. Что же это такое?
Семантика это смысл, или суть. Человек мыслит абстрактными категориями. "Возьмем картинку и добавим ей красок". Но ЭВМ не знает сущностей "картинка", "краска". Поэтому надо проложить мост между понятиями человека и смыслом, которые он в них вкладывает, и понятиями машины. И смыслом, который вложил в машинные понятия ее разработчик.
Вот это и есть семантический разрыв. Понятийный. Смысловой. В конечном итоге человек переводит нужные прикладные сущности в сущности языка программирования. И картинка становится файлом на диске. Массивом в памяти. А действие "добавим красок" становится функцией, которая изменяет характеристические кривые цветопередачи. И кривые здесь это еще одна сущность. Которая лежит между мыслью и программой. Заполняя собой еще один семантический разрыв.
Компилятор формирует из исходных текстов программы набор объектных файлов. Для каждого исходного файла есть соответствующий объектный. Почему файлы называются объектными? Дело в том, в этих файлах находятся объекты (сущности) машины, зачастую не реальной, а абстрактной, в которые компилятор превратил сущности используемые языком программирования и программистом (из прикладной области).
Но не только объекты, но и правила работы с этими объектами. К таким правилам относится машинный код и правила размещения этих объектов. И правила манипулирования ими.
Зачем так много всего, помимо кода и объектов? Дело в том, что компилятор, обрабатывая очередной исходный файл, имеет далеко не всю информацию об объектах расположенных в других исходных файлах и библиотеках.
Да, какая то информация у него есть. Из тех же заголовочных файлов языков С и С++, например. Но этого не достаточно. Например, компилятор знает, что какая то функция или переменная располагается за пределами текущего исходного файла. Но он не знает, где именно и как именно.
И это первая проблема. К этой же проблеме относится то, что библиотечные функции могут тоже обращаться к каким то объектам, исходных текстов для которых просто нет. Или к сервисам операционной системы.
Вторая проблема заключается в том, что компилятор не знает, как, когда, куда, будет загружена на выполнение написанная программистом программа. Будет ли она перемещаться по памяти. Будет ли виртуальная память и выгрузка. Будут ли параллельно выполняться другие программы. И какие именно. И сколько их всего будет.
Да и реальная машина (ЭВМ) может очень сильно отличаться от абстрактной, для которой и сформировал код компилятор. И это еще один семантический разрыв. Этот разрыв заполняет среда времени выполнения (run-time), которая обычно является набором библиотек. Причем не редко разделяемых. И сервисы ОС часто бывают обернуты в разделяемые библиотеки.
Итак, компилятор действительно не может сам собрать все необходимое в единый исполняемый файл. За редким исключением. И совершенно не важно, какой (или какие) язык программирования используется. И совершенно не важно, что за ЭВМ используется. Причем это может быть не привычный всем ПК, а сервер, встраиваемая система, микроконтроллер. Суть от этого не меняется.
Поэтому я сегодня не буду делать акцент на какой либо одной машине или ситуации. Разговор будет о том, как все это работает в целом. В примерах я буду использовать язык С. Но это не означает, что для другого языка все будет по другому. От языка программирования здесь зависит не многое. Хотя многое зависит от того, выполняется ли программа интерпретатором, или непосредственно машиной. Интерпретаторы я не буду рассматривать.
Компоновщик и его основная функция
Интересующая нас сегодня часть целого обведена на иллюстрации красным пунктирным прямоугольником.
Компоновщик это русскоязычный термин. И это программа, которая выполняет сборку в единый исполняемый файл всех сущностей из всех объектный файлов, которые сформировал компилятор из исходного кода, все сущностей из библиотек. По тем правилам, которые записаны в этих объектных файлах. И с использованием информации о машине. Что это за информация я расскажу чуть позже.
Мне не нравится называть компоновщик линкером, хотя это побуквенная транслитерация англоязычного термина. Но я кратко перечислю некоторые имена, которые вы возможно встречали или встретите.
- LINK, сокращение LNK - это весьма распространенные названия. Отсюда и термин линкер.
- LNKEDT - связывающий редактор. Сегодня это название редкость. Оно использовалось в ДОС ЕС и ОС ЕС для машин серии ЕС, копиях машин IBM 360/370
- TKB - построитель задач. Это тоже старое название. Оно использовалось в ОС-РВ для машин серии СМ, копиях машин DEC PDP-11.
Выходом компоновщика является двоичный файл, который может быть загружен в память машины и запущен на исполнение. В разных системах, и для разных ЭВМ, эти двоичные файлы могут называться по разному.
С точки зрения большинства обычных пользователей это программа, которую можно запустить. Отсюда и расширение имени (тип) файла .exe. Можно встретить и другой, тоже весьма популярный термин, приложение. Отсюда и другое расширение имени, .app. Мне больше нравится термин задача, или задание. Отсюда и расширение имени .tsk. Это термины, которые использовались давно, задолго до появления ПК. И используются по сию пору. Просто вспомните термин "переключение задач", который можно встретить во многих книгах и статьях про операционные системы.
Итак, компоновщик собирает в файл задачи, или просто задачу, все объектные файлы, которые ему указаны как входные. Кроме того, он выполняет поиск в библиотеках объектных файлов (модулей), которые могут предоставить информацию о сущностях отсутствующих в объектных файлах, но используемых ими.
Но для этого компоновщик должен обладать некоторой информацией о машине и системе. Например, о доступных областях памяти и их размерах, об управлении памятью.
И компоновщик вносит изменения в двоичный код, который сформировал компилятор. Причем эти изменения могут быть значительными. Например, часть оптимизации выполняется именно компоновщиком.
Но при всех своих возможностях даже компоновщик не в состоянии создать файл задачи, который можно просто переместить в память и исполнить.
Дело в том, что некоторые сущности предоставляются разделяемыми библиотеками, которые тоже можно назвать задачами, только специфическими. Об этом еще будет время поговорить. А часть сущностей предоставляется операционной системой.
Поэтому даже в исполняемом двоичном файле связь с некоторыми внешними сущностями оказывается неразрешенной. Это и приводит к необходимости использования специальной программы - загрузчика. Загрузчик кажется в чем то похожим на компоновщик, но его задача проще. Он только устанавливает связь между двоичным кодом программы и внешними сущностями. О загрузчике сегодня разговор идти не будет.
Кстати, доказательством того, что работа компоновщика не зависит, за редким исключением, от языка программирования и компилятора, является тот факт, что не редко компоновщик един для всех компиляторов. И предоставляется в рамках операционной системы. Хотя развитие ПК привнесло существенные корректировки в этот вопрос.
Скрытые от посторонних глаз тонкости и сложности
Пример содержащейся в объектном файле информации, и пример его формата, будет в следующей статье. Но сегодня мы, хотя бы в общих чертах посмотрим, что именно представляет проблему для компилятора, с чем приходится разбираться компоновщику.
Сразу надо сказать, что некоторой информацией о машине, на которой будет выполняться программа, компилятор безусловно обладает. Как минимум, требуется знание машинных команд и методов адресации операндов.
Давайте взглянем на небольшой пример
Здесь два отдельных файла с исходными текстами. И компилируются они раздельно.
У компилятора не вызовет никаких трудностей работа с переменными var и var1. Они размещаются в стеке и можно легко сформировать необходимый код и для выделения места в стеке, и для очистки стека, и для доступа к переменным. При этом компилятору даже не требуется знать, где и как размещается стек.
А вот дальше начинаются сплошные проблемы. Давайте сначала посмотрим на классический случай, которым обычно иллюстрируют необходимость применения компоновщика.
Доступ к сущностям определенным в другом исходном файле
В файле main.c у нас используются переменная ext_var и функция func, которые определены в другом файле. Компилятор при обработке файла main.c обладает некоторой информацией об этих сущностях. Поэтому он в состоянии проверить соответствие типов и частично сформировать машинный код. Однако, адреса их размещения компилятору не известны.
При этом известно, что func размещается где то в области памяти для выполняемого кода, а ext_var в области памяти для данных, причем точно не в стеке.
Но это не единственное затруднение. Дело в том, что длина адреса может быть разной. Для некоторых машин это необходимо учитывать в поле адреса операнда в команде, а для других использовать и разные машинные команды. Что бы снизить неоднозначность может использоваться понятие модели памяти (medium, compact, large, и т.д.) или специальные модификаторы (near, far, и т.д.).
Если же неоднозначность сохраняется, компилятору приходится использовать максимально общий вариант для которого резервируется наибольшее место. И компоновщику придется не только вставить в команду необходимый адрес, но и, возможно, вмещаться в сгенерированный компилятором код. И мы это еще увидим.
Но на самом то деле, проблемы только начинаются! Давайте теперь посмотрим на то, что не слишком часто бросается в глаза.
Где будем размещать код и данные?
Вопрос не так прост, как может показаться. Компилятор действительно не знает, где будут размещаться код, данные, стек, и т.д!
На последней иллюстрации у нас есть две переменные, st_var и ext_var. Не смотря на то, что области видимости этих переменных разные, они обе имеют время жизни равное времени выполнения программы. А значит, для них будут выделены фиксированные адреса в памяти данных.
Но компилятор понятия не имеет, сколько еще различных исходных (и объектных) файлов будет включать в себя полная программа. Именно по этой причине компилятор может лишь описать размещение определенных программистом сущностей в пределах формируемого объектного файла.
Так и появляются те самые программные секции, о которых я очень кратко рассказывал в статье
Компилятор просто создает, например, программную секцию code_main, для первого файла из нашего примера, и секцию code_func, для второго. И размещает сгенерированный код начиная с адреса 0 внутри секции. А компоновщик потом соберет эти секции в единое целое и задаст начальные адреса этих секций, а адреса внутри секций откорректирует.
Но сделано это будет лишь в пределах общей секции кода программы. Даже компоновщику не всегда бывает известен адрес физической памяти, по которому программа будет загружена. Правда сегодня все стало значительно проще, так как аппаратное управление памятью позволяет программе работать с логическими адресами, а не с физическими.
Аналогично все происходит и для размещения переменных. Точно так же создаются программные секции, которые потом размещает компоновщик. Но для данных есть еще одна особенность, ведь кроме переменных есть еще и константы.
Выполнение программы начинается не с main
Даже если наша программа состоит из единственного исходного файла, без компоновщика не обойтись.
Дело в том, что большинство языков программирования высокого уровня предполагают, что программа выполняется на некой абстрактной, виртуальной, машине. И наша реальная машина, реальный процессор, должны определенным образом отображаться на эту абстрактную машину.
Обычно такое отображение выполняется специальной программной (кодовой) прослойкой, которая может быть и очень простой, и очень сложной. И после загрузки программы в память управление получает вовсе не функция main (для языка С), а процедура начальной настройки абстрактной машины. И уже она вызывает нашу main.
Поэтому компоновщику все равно приходится собирать исполняемый образ нашей программы из множества кусочков. Даже если вся наша программа уместилась в единственном исходном файле. Среда времени выполнения все равно никуда не девается.
Это остается верным даже для простых программ и простых микроконтроллеров. Но в самых простых случаях (для простых микроконтроллеров) компилятор может просто вставить несколько машинных команд, которые будут выполняться перед main и после ее завершения, а не вызывать процедуры пролога/эпилога из внешних библиотек.
Сервисы операционной системы и разделяемых библиотек
Не все сущности определяются в самой прикладной программе. Некоторые могут определяться в разделяемых библиотеках (dll, so) или даже в недрах операционной системы. С точки зрения компилятора нет никакой разницы между внешней ссылкой определенной где то в программе и ссылкой определенной в разделяемой библиотеке или ОС. Во всяком случае, в большинстве случаев.
Необходимый дополнительный код для загрузки разделяемой библиотеки в память будет добавлен компоновщиком. А для ОС и загружать ничего не надо.
Заключение
На этом наше небольшая вводная статья заканчивается. В следующей статье мы подробнее рассмотрим структуру объектных файлов. В особенности, ту служебную информацию, которую компилятор добавляет для компоновщика. А там есть очень много интересного!