Добавить в корзинуПозвонить
Найти в Дзене
Разумный мир

Компоновщик. § 3. И как же все это работает?

Теперь мы готовы рассмотреть собственно процесс сборки, который выполняет компоновщик для получения выполняемого/загружаемого образа нашей программы. Но сначала нам нужно определиться с тем, какой информацией о целевой машине должен обладать компоновщик. И что это вообще за машина такая. То есть, недостаточно знания только процессора. Важны и структура памяти, и налагаемые операционной системой ограничения (или наоборот расширения). Даже если наша программа будет загружаться в микроконтроллер, где ОС отсутствует, остаются требования программы загрузчика, который может размещаться в микроконтроллере. Во всех, без каких либо исключений, случаях память машины не является неким аморфным массивом ячеек. Физическая память обычно состоит из нескольких блоков, или областей. В микроконтроллерах это может быть ОЗУ данных, ПЗУ программ, область регистров периферийных модулей. В микропроцессорах и больших ЭВМ это могут быть область служебного ПЗУ (BIOS, например), ОЗУ программ и данных, область р
Оглавление

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

Но сначала нам нужно определиться с тем, какой информацией о целевой машине должен обладать компоновщик. И что это вообще за машина такая.

Целевая машина это совокупность аппаратных ресурсов и ресурсов ОС

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

Структура памяти

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

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

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

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

Влияние ОС

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

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

Процессор

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

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

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

Наша абстрактная целевая машина

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

Структура памяти нашей абстрактной машины. Иллюстрация моя
Структура памяти нашей абстрактной машины. Иллюстрация моя

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

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

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

  • Векторы прерываний. Эту область можно было не выделять отдельно, а отнести с области ОС. Однако, программы для микроконтроллеров часто работают с этой областью напрямую. Векторы прерываний обеспечивают передачу управления процедурам обработки прерываний.
  • Область загрузчика или ОС. Здесь располагается операционная система, на обычных ЭВМ, или программа-загрузчик, в микроконтроллерах. Если кто то еще помнит самодельные ЭВМ на базе 580ИК80, то там это называлось "Монитор". В ту область наша программа может обращаться для получения обслуживания, но не более того.
  • Область прикладной программы. Именно сюда будет загружаться наша программа для выполнения. В больших системах может быть множество параллельно работающих программ, поэтому AppStart не всегда является адресом загрузки программы, зачастую это адрес начала области программ.
  • Область памяти внешних устройств. Сюда отображается собственная память различных устройств. Например, видеопамять. Наша программа может работать с этой областью. Если разрешит ОС, конечно.
  • Регистры оборудования. Сюда входят только регистры отображаемые на память. Если регистры располагаются в адресном пространстве ввода-вывода (работа через команды IN и OUT), то для нас они сегодня интереса не представляют.

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

  • Область исполняемого кода. Машинные команды могут размещаться только в этой области. В других областях выполнение кода запрещено. В обычных ЭВМ это реализуется аппаратурой управления памятью под контролем ОС. В микроконтроллерах это будет просто ПЗУ программ.
  • Разделяемая память. Не надо считать эту область памяти относящейся только к данным, общим для нескольких программ! Здесь могут располагаться шлюзы обращения к ОС. Сюда могут отображаться регистры оборудования, которые ОС сочтет доступными для программы. Сюда могут отображаться допустимы векторы прерываний. Другими словами, эта область памяти для "выхода за пределы" памяти программы. В микроконтроллерах эта область отсутствует, в большинстве случаев.
  • Память данных. Здесь размещаются переменные нашей программы. В микроконтроллерах это ОЗУ данных.
  • Динамически распределяемая память. Здесь располагаются переменные и объекты, которые создаются вызовами malloc, free, new, delete, LoadLibrary, и тому подобными.
  • Стек. Собственно говоря, это просто стек.

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

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

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

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

Для физических адресов пришлось бы использовать AppStart+DataStart.

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

Теперь мы готовы перейти к рассмотрению подробностей работы компоновщика. Но сначала...

Абсолютные секции и абсолютные символы

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

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

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

Но тут то и возникает затруднение. Ведь программные секции содержат данные и код, поэтому они занимают в памяти место, которое не может быть распределено для других секций. А у нас секция _ABS_ занимает всю память, больше места не остается. Как же быть?

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

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

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

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

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

Казалось бы, небольшая разница, размещать секцию или процедуру. Но как правило в такие секции собирается несколько процедур, что упрощает размещение объектов в памяти.

Шаг первый, читаем заданные объектные файлы и собираем информацию о символах

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

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

Пример на языке С из первой статьи цикла. Иллюстрация моя
Пример на языке С из первой статьи цикла. Иллюстрация моя

При считывании файла main.obj компоновщик поместит в каталог определенных символов st_var и main. А в каталог неопределенных символов func и ext_var. Почему компоновщик даже не узнает о символе var я уже рассказывал.

Каталоги символов, которые создает компоновщик, являются общими для всего процесса сборки. Поэтому при считывании func.obj компоновщик обнаружит, что символы func и ext_var стали определенными. А значит, они будут удалены из каталога неопределенных символов, о появятся в каталоге определенных.

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

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

Шаг второй, выполняем поиск символов в библиотеках

Библиотеки могут быть указаны при запуске компоновщика или быть "стандартными", входящими к комплект поставки компилятора/компоновщика/ОС/и т.д.

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

Возможный вариант объектной библиотеки. Иллюстрация моя
Возможный вариант объектной библиотеки. Иллюстрация моя

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

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

Шаг третий, размещаем программные секции

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

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

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

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

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

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

Упаковка собранных секций в область памяти программы. Иллюстрация моя
Упаковка собранных секций в область памяти программы. Иллюстрация моя

Секция _TEXT, которая содержит исполняемый код, размещается в области исполняемого кода. Секция _DATA_ в области данных. _STACK_ в области стека. Тут все очевидно. Секция _OS_SVC_ определена в некой библиотеке и связана с процедурой OS_call, будем считать это запросом к операционной системе.

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

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

После размещения секций компоновщик может скорректировать начальные адреса каждого фрагмента каждой секции.

Корректировка адресов размещения фрагментов секций после размещения секций. Иллюстрация моя
Корректировка адресов размещения фрагментов секций после размещения секций. Иллюстрация моя

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

Шаг четвертый, корректировка адресов символов

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

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

Например, если компилятор задал для символа ext_var определен в func.obj) адрес @ext_var относительно начала секции _DATA_, то откорректированный адрес будет равен @ext_var+DataStart. А для символа st_var (определен в main.obj) откорректированный адрес будет равен @st_var+DataStart+SIZE_4.

Шаг пятый, копирование данных в секции

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

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

Шаг шестой, корректировка адресов в программных секциях (перемещение)

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

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

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

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

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

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

Шаг седьмой, запись двоичного кода программы

После завершения корректировок у компоновщика оказывается готовый к загрузке двоичный образ программы. Однако, нельзя просто взять и записать его на диск или загрузить в память микроконтроллера. В большинстве случаев требуется преобразовать его в определенный формат. Это может быть Intel HEX32, exe-файл, elf-файл, или что то иное.

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

Заключение

Это очень упрощенное описание работы компоновщика. "За кадром" осталось очень многое. Поэтому может сложиться впечатление (обманчивое впечатление!), что работа компоновщика очень проста, а написать его легко. Реальность гораздо более сурова.

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

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

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

До новых встреч!