Сегодня мы рассмотрим структуру абстрактного объектного файла. То есть, фактически результат работы компилятора. Именно эта информация и попадает на вход компоновщика.
Рассматривать будем некий абстрактный формат для не менее абстрактной машины. По той простой причине, что кроме привычных всем 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).
Где именно, и в каком именно виде, задается информация о машине мы не будем рассматривать. Здесь возможна масса различных вариантов.
Заключение
Сегодня мы кратко познакомились с той информацией, которую готовит компилятор. И которую компоновщик получит на входе. Как компоновщик все это обрабатывает мы познакомимся в следующей статье.