Найти в Дзене
Разумный мир

Компоновщик. § 2. А что у нас в объектном файле?

Оглавление

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

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

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

Предыдущая статья

Компоновщик. § 1. Между компилятором и загрузчиком.

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

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

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

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

Определения программных секций

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

Программная секция имеет несколько атрибутов:

  • Имя секции. Фактически, это произвольный текст с ограничением на длину. Практически, компиляторы используют свои внутренние соглашения для именования секций. Например, секции с машинным кодом могут именоваться "TEXT" или "имя_функции_CODE". Зачастую программист может определить и свои собственные секции с любым именем.
  • Тип данных. Секция, как область памяти, могут содержать информацию разного типа. Наиболее известное деление это код и данные. Со строгой точки зрения невозможно прочитать или модифицировать информацию в секции кода при выполнении программы. И невозможно передать управление в секцию данных. Дополнительно можно определить секцию констант, которая отличается от секции данных тем, что запись в нее не возможна. А для микроконтроллеров может иметь смысл выделить секцию данных для энергонезависимой памяти.
  • Тип размещения и объединения. Одноименные секции из разных файлов можно размещать последовательно, друг за другом. Общий размер итоговой секции будет равен сумме размеров одноименных секций из всех файлов. А можно размещать с одного и того же адреса, накладывая одну на другую. И размер итоговой секции будет равен размеру наибольшей секции из всех файлов. При этом размещение объектов в секциях обычно идет в направлении увеличения адресов. Но есть еще и стек, в котором объекты размещаются в направлении уменьшения адресов.
  • Адрес размещения секции. Если необходимо разместить секцию по строго определенному адресу в памяти машины, то используют специальный тип размещения - абсолютный. Для других типов размещения этот атрибут заполняется не компилятором, а компоновщиков. Мы рассмотрим это в следующей статье.
  • Размер секции. Имеется ввиду размер секции в данном объектном файле. Этот размер вычисляется компилятором.

Теперь мы можем представить запись объектного файла о программной секции примерно в таком виде:

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

У программных секций могут быть и другие атрибуты, например, выравнивание по границам слов или двойных слов. Я не буду в данном цикле статей рассматривать эти атрибуты.

Определения символов

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

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

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

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

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

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

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

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

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

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

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

person.age

которые и сами могут быть агрегатами данных

person.name[20]

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

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

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

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

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

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

Поэтому я буду рассматривать лишь вариант размещения полей структур как отдельных переменных. Для нас это будет достаточным. Теперь мы модем составить список основных атрибутов символов:

  • Имя программной секции. В этой секции и размещается сущность, которая соответствует символу. На самом деле, слишком расточительно каждый раз задавать полное имя секции. Обычно, имя заменяется на идентификатор, который может либо в явном виде указываться компилятором в записи определения секции, либо вычисляться как хэш-функция от имени секции.
  • Имя символа. Это имя переменной, функции, полное имя поля структуры. Например, именем может быть person.name.first.
  • Начальный адрес размещения сущности внутри секции. Фактически, это адрес переменной. И этот адрес будет впоследствии уточняться компоновщиком.
  • Размер. Размер переменной. С одной стороны, размер компоновщику не требуется, достаточно адреса. С другой стороны, размер в некоторых случаях оказывается полезным.

И у нас получается примерно такая запись с определением символа:

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

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

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

Я допустил оплошность? Нет. Просто различные записи в объектном файле могут группироваться, образуя таким "каталоги". И одним из используемых методов как раз и является создание трех каталогов символов: каталог внутренних символов, каталог глобальных символов, каталог внешних ссылок (неопределенных символов).

Могут использоваться и другие методы. Например специальный атрибут. Или специальное изменение имени символа (внутренние символы, например, начинаются с !).

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

Машинный код

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

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

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

Зачем здесь поле адреса? Все просто, запись о машинном коде может охватывать лишь часть всего кода, например, лишь одну процедуру. Поэтому блок кода может размещаться внутри секции по адресу отличному от 0. Программная секция с атрибутом CODE может включать в себя несколько блоков кода.

Если блок кода связан с секцией CONST (или FLASH), то он будет определять не коды машинных команд, а данные, константы. И это абсолютно корректное использование такого типа записи объектного файла.

Каталог перемещений

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

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

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

Размер адреса может быть разным. И даже указание модели памяти (помните medium, compact, large, tiny?) не всегда спасает, так как программисту доступны модификаторы вроде far и near.

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

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

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

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

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

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

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

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

Кратко о структуре объектного файла

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

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

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

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

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

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

Где именно, и в каком именно виде, задается информация о машине мы не будем рассматривать. Здесь возможна масса различных вариантов.

Заключение

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

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

Наука
7 млн интересуются