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

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

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

Стандартный конвейер компиляции C: В первом разделе мы рассмотрим стандартную компиляцию C, различные этапы конвейера и то, как они способствуют созданию конечного продукта из исходного кода C.
Препроцессор: в этом разделе , мы собираемся более подробно поговорить о компоненте препроцессора, который управляет этапом предварительной обработки.
Компилятор: В этом разделе мы более подробно рассмотрим компиляторы. Мы объясним, как компиляторы, управляя этапом компиляции, создают промежуточные представления из исходного кода, а затем переводят их на язык ассемблера.
Ассемблер: После компиляторов мы также поговорим об ассемблере, которые играют важную роль в переводе ассемблерных инструкций. , полученные от компилятора, в инструкции машинного уровня. Компонент ассемблера управляет этапом сборки.
Компоновщик: В последнем разделе мы более подробно обсудим компонент компоновщика, управляющий этапом компоновки. Компоновщик — это компонент сборки, который, наконец, создает фактические продукты проекта C. Существуют ошибки сборки, специфичные для этого компонента, и достаточное знание компоновщика поможет нам предотвратить и устранить их. Мы также обсудим различные конечные продукты проекта C и дадим несколько советов по дизассемблированию объектного файла и чтению его содержимого. Более того, мы кратко обсудим, что такое изменение имени C++ и как оно предотвращает определенные дефекты на этапе компоновки при построении кода C++.
Наши обсуждения в этой статье в основном посвящены Unix-подобным системам, но мы обсудим некоторые различия. в других операционных системах, таких как Microsoft Windows.

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

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

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

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

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

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

Примечание:

Полный список доступных компиляторов C можно найти на следующей странице Википедии: https://en.wikipedia.org/wiki/List_of_compilers#C_compilers.

Прежде чем говорить о платформе по умолчанию и компиляторе C, которые мы используем в этой статье, давайте немного поговорим о термине «платформа» и о том, что мы под ним подразумеваем.

Платформа — это комбинация операционной системы, работающей на определенном оборудовании (или архитектуре), и набор инструкций ее ЦП является наиболее важной ее частью. Операционная система является программным компонентом платформы, а архитектура определяет аппаратную часть. Например, у нас может быть Ubuntu, работающая на плате с процессором ARM, или Microsoft Windows, работающая на 64-битном процессоре AMD.

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

Некоторые компиляторы C, например, gcc и clang, являются кроссплатформенными — они могут генерировать код для разных платформ — а байт-код Java является переносимым.

Что касается C и C++, если мы говорим, что код C/C++ является переносимым, мы имеем в виду, что мы можем скомпилировать его для разных платформ без каких-либо изменений или с небольшой модификацией исходного кода. Однако это не означает, что окончательные объектные файлы переносимы.

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

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

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

Примечание:

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

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

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

ФАЙЛЫ ЗАГОЛОВКА ПРОТИВ ФАЙЛОВ ИСТОЧНИКОВ
Каждый проект C имеет исходный код или кодовую базу вместе с другими документами, связанными с описанием проекта и существующими стандартами. В базе кода C у нас обычно есть два типа файлов, содержащих код C:

Заголовочные файлы, имена которых обычно имеют расширение .h.
Исходные файлы, имеющие расширение .c.
Примечание.

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

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

C++ следует той же схеме, но в других языках программирования, таких как Java, элементы определяются там, где они объявлены. Хотя это отличная возможность как C, так и C++, поскольку она дает им возможность отделять объявления от определений, она также усложняет исходный код.

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

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

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

Примечание:

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

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

двойное среднее (int*, int);

Кодовое поле 2–1: объявление функции среднего

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

Как вы можете видеть в блоке кода 2–1, объявление функции заканчивается точкой с запятой «;». и у него нет тела, заключенного в фигурные скобки. Мы также должны принять во внимание, что параметры в объявлении функции не имеют связанных имен, и это допустимо в C, но только в объявлениях, а не в определениях. При этом рекомендуется указывать параметры даже в объявлениях.

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

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

двойное среднее (массив int *, длина int) {

если (длина ‹= 0) {

вернуть 0;

}

двойная сумма = 0,0;

for (int i = 0; i ‹ длина; i++) {

сумма += массив[i];

}

возвращаемая сумма/длина;

}

Кодовая вставка 2–2: определение средней функции

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

Если вы не совсем понимаете это сейчас, не беспокойтесь, так как это станет более очевидным по мере продвижения вперед.

Примечание:

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

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

ПРИМЕР ИСХОДНЫХ ФАЙЛОВ
В примере 2.1 у нас есть три файла, один из которых является файлом заголовка, а два других — исходными файлами, и все они находятся в одном каталоге. В примере требуется вычислить среднее значение массива с пятью элементами.

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

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

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

#ifndef EXTREMEC_EXAMPLES_article_2_1_H

#define EXTREMEC_EXAMPLES_article_2_1_Htypedef перечисление {

НИКТО,

НОРМАЛЬНЫЙ,

КВАДРАТ

} среднее_тип_t;

// Объявление функции

двойное среднее (целое*, целое, среднее_тип_t);

#endif

Блок кода 2–3 [ExtremeC_examples_article2_1.h]: файл заголовка как часть примера 2.1.

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

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

Следующий код показывает нам исходный файл, который на самом деле содержит определение функции avg:

#include «ExtremeC_examples_article2_1.h»

double avg (массив int*, длина int, тип medium_type_t) {

если (длина ‹= 0 || тип == НЕТ) {

вернуть 0;

}

двойная сумма = 0,0;

for (int i = 0; i ‹ длина; i++) {

если (тип == НОРМАЛЬНЫЙ) {

сумма += массив[i];

} иначе если (тип == КВАДРАТ) {

сумма += массив[i] * массив[i];

}

}

возвращаемая сумма/длина;

}

Блок кода 2–4 [ExtremeC_examples_article2_1.c]: исходный файл, содержащий определение функции avg.

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

Посмотрите на следующее поле кода, показывающее второй исходный файл, содержащий основную функцию:

#include ‹stdio.h›

#include «ExtremeC_examples_article2_1.h»

int main(int argc, char** argv) {

// Объявление массива

целый массив[5];

// Заполнение массива

массив[0] = 10;

массив[1] = 3;

массив[2] = 5;

массив[3] = -8;

массив[4] = 9;

// Расчет средних значений с помощью функции «avg»

двойное среднее = среднее (массив, 5, НОРМАЛЬНОЕ);

printf("Среднее: %f\n", среднее);

среднее = среднее (массив, 5, КВАДРАТ);

printf("Среднее в квадрате: %f\n", среднее);

вернуть 0;

}

Блок кода 2–5 [ExtremeC_examples_article2_1_main.c]: основная функция примера 2.1.

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

ПОСТРОЙКА ПРИМЕРА
После представления файлов примера 2.1 в предыдущем разделе нам нужно их собрать и создать окончательный исполняемый двоичный файл, который можно запустить как программу. Сборка проекта C/C++ означает, что мы будем компилировать все исходные коды в его кодовой базе, чтобы сначала создать некоторые перемещаемые объектные файлы (известные также как промежуточные объектные файлы) и, наконец, объединить эти перемещаемые объектные файлы для создания конечных продуктов, таких как статические библиотеки или исполняемые двоичные файлы.

Создание проекта на других языках программирования также очень похоже на создание проекта на C или C++, но промежуточный и конечный продукты имеют разные имена и, вероятно, разные форматы файлов. Например, в Java промежуточными продуктами являются файлы классов, содержащие байт-код Java, а конечными продуктами являются файлы JAR или WAR.

Примечание:

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

Прежде чем мы двинемся дальше, мы должны помнить два важных правила.

Правило 1: Мы компилируем только исходные файлы

Первое правило заключается в том, что мы компилируем только исходные файлы по причине того, что нет смысла компилировать заголовочный файл. Файлы заголовков не должны содержать никакого фактического кода C, кроме некоторых объявлений. Поэтому для примера 2.1 нам достаточно скомпилировать два исходных файла: ExtremeC_examples_article2_1.c и ExtremeC_examples_article2_1_main.c.

Правило 2: Мы компилируем каждый исходный файл отдельно

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

Примечание:

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

Следовательно, для проекта, состоящего из 100 исходных файлов, нам нужно скомпилировать каждый исходный файл отдельно, а это значит, что мы должны запускать компилятор 100 раз! Да, кажется, что это много, но именно так вы должны компилировать проект C или C++. Поверьте, вы столкнетесь с проектами, в которых нужно скомпилировать несколько тысяч файлов, прежде чем получится один исполняемый бинарник!

Примечание:

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

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

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

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

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

Примечание:

В единице перевода не может быть найдено никаких директив предварительной обработки. Напоминаем, что все директивы предварительной обработки в C (и C++) начинаются с #, например, #include и #define.

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

В следующем коде вы можете увидеть единицу перевода для ExtremeC_examples_article2_1.c, которая была сгенерирована gcc на нашей платформе по умолчанию:

$ gcc -E ExtremeC_examples_article2_1.c

# 1 «ExtremeC_examples_article2_1.c»

№ 1 «‹встроенный›»

# 1 «‹командная строка›»

# 31 «‹командная строка›»

# 1 «/usr/include/stdc-predef.h» 1 3 4

# 32 «‹командная строка›» 2

# 1 «ExtremeC_examples_article2_1.c»

# 1 «ExtremeC_examples_article2_1.h» 1

перечисление типов {

НИКТО,

НОРМАЛЬНЫЙ,

КВАДРАТ

} среднее_тип_t;

двойное среднее (целое*, целое, среднее_тип_t);

# 5 «ExtremeC_examples_article2_1.c» 2

double avg (массив int*, длина int, тип medium_type_t) {

если (длина ‹= 0 || тип == НЕТ) {

вернуть 0;

}

двойная сумма = 0;

for (int i = 0; i ‹ длина; i++) {

если (тип == НОРМАЛЬНЫЙ) {

сумма += массив[i];

} иначе если (тип == КВАДРАТ) {

сумма += массив[i] * массив[i];

}

}

возвращаемая сумма/длина;

}

$

Shell Box 2–1: созданная единица перевода при компиляции ExtremeC_examples_article2_1.c

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

Единица перевода для ExtremeC_examples_article2_1_main.c очень велика, поскольку включает заголовочный файл stdio.h.

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

Примечание:

Опция -E также работает для компилятора clang.

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

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

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

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

$ gcc -S ExtremeC_examples_article2_1.c

$ кошка ExtremeC_examples_article2_1.s

.файл «ExtremeC_examples_article2_1.c»

.текст

.globl среднее

.type среднее, @функция

в среднем:

.LFB0:

.cfi_startproc

pushq %rbp

.cfi_def_cfa_offset 16

.cfi_offset 6, -16

мовк %rsp, %rbp

.cfi_def_cfa_register 6

movq %rdi, -24(%rbp)

movl %esi, -28(%rbp)

movl %edx, -32(%rbp)

cmpl $0, -28(%rbp)

jle .L2

cmpl $0, -32(%rbp)

jne .L3

.L2:

пиксели %xmm0, %xmm0

джмп .l4

.L3:

.L8:

.L6:

.L7:

.L5:

.L4:

.LFE0:

.размер средний, .-средний

.ident «GCC: (Ubuntu 7.3.0–16ubuntu3) 7.3.0»

.section .note.GNU-stack», @progbits

$

Оболочка 2–2: созданный ассемблерный код при компиляции ExtremeC_examples_article2_1.c

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

Вставка оболочки 2-2 показывает ассемблерный код, сгенерированный для 64-битной архитектуры AMD и созданный gcc, работающим на 64-битной машине AMD. Следующая оболочка содержит ассемблерный код, созданный для 32-разрядной архитектуры ARM и созданный gcc, работающим на архитектуре Intel x86–64. Оба вывода сборки генерируются для одного и того же кода C:

$ кошка ExtremeC_examples_article2_1.s

.arch armv5t

.fpu

.eabi_attribute 20, 1

.eabi_attribute 21, 1

.eabi_attribute 23, 3

.eabi_атрибут 24, 1

.eabi_attribute 25, 1

.eabi_attribute 26, 2

.eabi_attribute 30, 6

.eabi_attribute 34, 0

.eabi_attribute 18, 4

.файл «ExtremeC_examples_article2_1.s»

.global __aeabi_i2d

.global __aeabi_dadd

.global __aeabi_ddiv

.текст

.выровнять 2

.глобальное среднее

.синтаксис унифицирован

.рука

.type среднее, %функция

в среднем:

@ args = 0, притворяться = 0, кадр = 32

@frame_needed = 1, uses_anonymous_args = 0

нажать {r4, fp, lr}

добавить фп, сп, #8

саб сп, сп, #36

ул r0, [fp, #-32]

ул r1, [fp, #-36]

ул r2, [fp, #-40]

ldr r3, [fp, #-36]

смр r3, #0

бле .L2

ldr r3, [fp, #-40]

смр r3, #0

бнэ .L3

.L2:

.L3:

.L8:

.L6:

.L7:

.L5:

.L4:

движение r0, r3

движение r1, r4

саб сп, фп, #8

@сп нужен

поп {г4, фп, пк}

.размер средний, .-средний

.ident «GCC: (Ubuntu/Linaro 5.4.0–6ubuntu1~16.04.9) 5.4.0 20160609»

.section .note.GNU-stack",",%progbits

$

Оболочка 2–3: Ассемблерный код, созданный при компиляции ExtremeC_examples_article2_1.c для 32-разрядной архитектуры ARM.

Как вы можете видеть в блоках оболочки 2–2 и 2–3, сгенерированный ассемблерный код отличается для двух архитектур. И это несмотря на то, что они генерируются для одного и того же кода C. Для последнего ассемблерного кода мы использовали компилятор arm-linux-gnueabi-gcc на аппаратном наборе Intel x64–86 под управлением Ubuntu 16.04.

Примечание:

Целевая (или хостовая) архитектура — это архитектура, для которой исходный код компилируется и на которой он будет выполняться. Архитектура сборки — это архитектура, которую мы используем для компиляции исходного кода. Они могут быть разными. Например, вы можете скомпилировать исходный код C для 64-разрядного оборудования AMD на 32-разрядной машине ARM.

Создание ассемблерного кода из кода C — самый важный шаг в конвейере компиляции.

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

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

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

Примечание:

Перемещаемые объектные файлы можно назвать промежуточными объектными файлами.

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

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

Примечание:

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

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

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

В следующем окне оболочки вы можете увидеть, как as используется для создания перемещаемого объектного файла для ExtremeC_examples_article2_1_main.s:

$ as ExtremeC_examples_article2_1.s -o ExtremeC_examples_article2_1.o

$

Оболочка 2–4: создание объектного файла из сборки одного из исходников в примере 2.1.

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

Содержимое объектного файла, будь то .o или .obj, не является текстовым, поэтому вы не сможете его прочитать как человек. Поэтому принято говорить, что объектный файл имеет двоичное содержимое.

Несмотря на то, что ассемблер можно использовать напрямую, как мы это делали в Shell Box 2–4, делать это не рекомендуется. Вместо этого рекомендуется использовать сам компилятор для косвенного вызова as для создания перемещаемого объектного файла.

Примечание:

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

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

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

$ gcc -c ExtremeC_examples_article2_1.c

$

Оболочка 2–5: компиляция одного из исходных кодов в примере 2.1 и создание соответствующего перемещаемого объектного файла.

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

ВАЖНЫЙ:

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

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

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

$ gcc -c ExtremeC_examples_article2_1.c -o импл.o

$ gcc -c ExtremeC_examples_article2_1_main.c -o main.o

$

Оболочка 2–6: создание перемещаемых объектных файлов для источников в примере 2.1.

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

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

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

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

ПОДДЕРЖКА НОВЫХ АРХИТЕКТУР
Мы знаем, что в каждой архитектуре есть серия производимых процессоров и что каждый процессор может выполнять определенный набор инструкций.

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

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

Язык ассемблера известен.
Необходимый ассемблерный инструмент (или программа), разработанный компанией-поставщиком, должен быть под рукой. Это позволяет нам транслировать ассемблерный код в эквивалентные инструкции машинного уровня.
Как только эти предпосылки будут выполнены, можно будет генерировать инструкции машинного уровня из исходного кода C. Только тогда мы можем хранить сгенерированные инструкции машинного уровня в объектных файлах, используя формат объектных файлов. Например, это может быть либо ELF, либо Mach-O.

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

Два непосредственных инструмента, необходимых для новой архитектуры, следующие:

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

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

ПОДРОБНОЕ ОПИСАНИЕ ШАГА
Из всего, что было сказано ранее, мы знаем, что платформы, использующие Unix-подобные операционные системы, должны иметь ранее обсужденные обязательные инструменты, такие как ассемблер и компоновщик, чтобы работать. Помните, что ассемблер и компоновщик можно запускать отдельно от компилятора.

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

$ ld impl.o main.o

ld: предупреждение: не удается найти символ входа _start; по умолчанию 00000000004000e8

main.o: В функции «main»:

ExtremeC_examples_article3_1_main.c:(.text+0x7a): неопределенная ссылка на ‘printf’

ExtremeC_examples_article3_1_main.c:(.text+0xb7): неопределенная ссылка на ‘printf’

ExtremeC_examples_article3_1_main.c:(.text+0xd0): неопределенная ссылка на ‘__stack_chk_fail’

$

Оболочка 2–7: Попытка напрямую связать объектные файлы с помощью утилиты ld

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

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

Как мы уже говорили ранее, компоновщик по умолчанию, ld, сгенерировал эти ошибки, потому что не смог найти определения этих функций. Логически это имеет смысл и верно, потому что мы сами не определили printf и __stack_chk_fail в примере 2.1.

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

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

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

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

$ gcc импл.о main.o

$ ./a.out

В среднем: 3.800000

Среднее в квадрате: 55,800000

$

Оболочка 2–8: Использование gcc для связывания объектных файлов

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

Примечание:

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

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

Хотя этот процесс будет одинаковым для любой базы кода C/C++, разница будет заключаться в том, сколько раз вам нужно скомпилировать исходные коды, что само по себе зависит от количества исходных файлов в вашем проекте.

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

Для начала мы сосредоточимся на компоненте препроцессора.

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

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

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

#include ‹stdio.h›

# определить файл 1000

Здравствуйте, это всего лишь простой текстовый файл, но с расширением .c!

Это точно не C-файл!

Но мы можем предварительно обработать его!

Блок кода 2–6: код C, содержащий текст!

Имея предыдущий код, давайте предварительно обработаем файл с помощью gcc. Обратите внимание, что некоторые части следующей оболочки были удалены. Это связано с тем, что включение stdio.h делает единицу перевода очень большой:

$ gcc -E пример.с

# 1 «образец.с»

№ 1 «‹встроенный›» 1

№ 1 «‹встроенный›» 3

# 341 «‹встроенный›» 3

# 1 «‹командная строка›» 1

# 1 «‹встроенный›» 2

# 1 «образец.с» 2

# 1 «/usr/include/stdio.h» 1 3 4

# 64 «/usr/include/stdio.h» 3 4

# 1 «/usr/include/_stdio.h» 1 3 4

# 68 «/usr/include/_stdio.h» 3 4

# 1 «/usr/include/sys/cdefs.h» 1 3 4

# 587 «/usr/include/sys/cdefs.h» 3 4

# 1 «/usr/include/sys/_symbol_aliasing.h» 1 3 4

# 588 «/usr/include/sys/cdefs.h» 2 3 4

# 653 «/usr/include/sys/cdefs.h» 3 4

extern int __vsnprintf_chk (char * limited, size_t, int, size_t,

const char * ограничение, va_list);

# 412 «/usr/include/stdio.h» 2 3 4

# 2 «образец.с» 2

Здравствуйте, это просто текст 1000, но заканчивающийся расширением .c!

Это точно не С 1000!

Но мы можем предварительно обработать его!

$

Блок оболочки 2–9: предварительно обработанный пример кода C, показанный в блоке кода 2–6.

Как видно из предыдущей оболочки, содержимое stdio.h копируется перед текстом.

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

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

Примечание:

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

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

Примечание:

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

Внутреннее устройство препроцессора GNU C — http://www.chiark.greenend.org.uk/doc/cpp-4.3-doc/cppinternals.html — отличный источник дополнительной информации о препроцессоре gcc. Этот документ является официальным источником, в котором описывается, как работает препроцессор GNU C. Препроцессор GNU C используется компилятором gcc для предварительной обработки исходных файлов.

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

В большинстве Unix-подобных операционных систем есть инструмент под названием cpp, что означает C Pre-Processor, а не C Plus Plus! cpp является частью пакета разработки C, который поставляется с каждой разновидностью Unix. Его можно использовать для предварительной обработки файла C. В фоновом режиме этот инструмент используется компилятором C, таким как gcc, для предварительной обработки файла C. Если у вас есть исходный файл, вы можете использовать его аналогично тому, что мы сделали далее, для предварительной обработки исходного файла:

$ cpp ExtremeC_examples_article2_1.c

# 1 «ExtremeC_examples_article2_1.c»

№ 1 «‹встроенный›» 1

№ 1 «‹встроенный›» 3

# 340 «‹встроенный›» 3

# 1 «‹командная строка›» 1

# 1 «‹встроенный›» 2

# 5 «ExtremeC_examples_article2_1.c» 2

double avg (массив int*, длина int, тип medium_type_t) {

если (длина ‹= 0 || тип == НЕТ) {

вернуть 0;

}

двойная сумма = 0;

for (int i = 0; i ‹ длина; i++) {

если (тип == НОРМАЛЬНЫЙ) {

сумма += массив[i];

} иначе если (тип == КВАДРАТ) {

сумма += массив[i] * массив[i];

}

}

возвращаемая сумма/длина;

}

$

Оболочка 2–10: Использование утилиты cpp для предварительной обработки исходного кода

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

Если вы настаиваете на запуске препроцессора C для файла с расширением .i, вы получите следующее предупреждающее сообщение. Обратите внимание, что компилятор clang создает следующую оболочку:

$ clang -E ExtremeC_examples_article2_1.c › ex2_1.i

$ лязг -E ex2_1.i

clang: предупреждение: ex2_1.i: предварительно обработанный ввод

[-Wunused-командная-строка-аргумент]

$

Блок оболочки 2–11: передача уже предварительно обработанного файла с расширением .i компилятору clang.

Как видите, clang предупреждает нас о том, что файл уже прошел предварительную обработку.

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

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

В качестве примера мы говорили об as и ld как о двух примерах среди множества доступных в Unix инструментов для разработки на C. Эти инструменты в основном используются для создания объектных файлов, совместимых с платформой. Эти инструменты обязательно существуют вне gcc или любого другого компилятора. Под существованием вне какого-либо компилятора мы на самом деле подразумеваем, что они не разрабатываются как часть gcc (мы выбрали gcc в качестве примера) и должны быть доступны на любой платформе, даже без установленной gcc. gcc использует их только в своем конвейере компиляции, и они не встроены в gcc.

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

Одной из проблем при компиляции C является создание правильных ассемблерных инструкций, которые могут быть приняты целевой архитектурой. Можно использовать gcc для компиляции одного и того же кода C для различных архитектур, таких как ARM, Intel x86, AMD и многих других. Как мы обсуждали ранее, каждая архитектура имеет набор инструкций, который принимается ее процессором, и gcc (или любой компилятор C) является единственной ответственной сущностью, которая должна генерировать правильный ассемблерный код для конкретной архитектуры.

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

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

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

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

Этого достаточно, чтобы изменить интерфейс компилятора для поддержки других языков. Именно поэтому вы можете найти коллекцию компиляторов GNU (GCC), частью которой является gcc, как компилятор C, или низкоуровневую виртуальную машину (LLVM), частью которой clang является как компилятор C, как набор компиляторы для многих языков помимо C и C++, таких как Java, Fortran и так далее.

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

интервал основной () {

интервал переменная1 = 1;

двойная переменная2 = 2,5;

интервал переменная3 = переменная1 + переменная2;

вернуть 0;

}

Блок кода 2–7 [ExtremeC_examples_article2_2.c]: простой код C, для которого будет создан AST.

Следующим шагом является использование clang для создания дампа AST в предыдущем коде. На следующем рисунке Рисунок 2-1 вы можете увидеть AST:

Рис. 2–1. Сгенерированный и выгруженный AST для примера 2.2

До сих пор мы использовали clang в разных местах в качестве компилятора C, но давайте представим его правильно. clang — это внешний интерфейс компилятора C, разработанный LLVM Developer Group для внутреннего интерфейса компилятора llvm. Проект инфраструктуры компилятора LLVM использует промежуточное представление — или LLVM IR — в качестве своей абстрактной структуры данных, используемой между его интерфейсом и сервером. LLVM известен своей способностью создавать дамп своей структуры данных IR для исследовательских целей. Предыдущий древовидный вывод — это IR, сгенерированный из исходного кода примера 2.2.

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

Однако, если вы обратите внимание на рисунок выше, вы можете найти строку, начинающуюся с -FunctionDecl. Это представляет основную функцию. Перед этим вы можете найти метаинформацию о единице перевода, переданной компилятору.

Если вы продолжите после FunctionDecl, вы найдете записи дерева — или узлы — для операторов объявления, операторов бинарных операторов, оператора return и даже операторов неявного приведения. В AST содержится много интересного, и есть чему поучиться!

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

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

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

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

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

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

В завершение этого раздела мы обычно говорим, что объектные файлы, а значит, и ассемблер, генерирующий их, зависят от платформы. В Linux мы используем Executable and Linking Format (ELF). Как следует из названия, все исполняемые файлы, объектные файлы и общие библиотеки должны использовать этот формат. Другими словами, в Linux ассемблер создает объектные файлы ELF. В следующей статье «Объектные файлы» мы более подробно обсудим объектные файлы и их форматы.

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

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

Проект C/C++ может привести к следующим продуктам:

Ряд исполняемых файлов, которые обычно имеют расширение .out в большинстве Unix-подобных операционных систем. Эти файлы обычно имеют расширение .exe в Microsoft Windows.
Ряд статических библиотек, которые обычно имеют расширение .a в большинстве Unix-подобных операционных систем. Эти файлы имеют расширение .lib в Microsoft Windows.
Ряд динамических библиотек или общих объектных файлов, которые обычно имеют расширение .so в большинстве Unix-подобных операционных систем. Эти файлы имеют расширение .dylib в macOS и .dll в Microsoft Windows.
Перемещаемые объектные файлы не считаются одним из этих продуктов; следовательно, вы не можете найти их в предыдущем списке. Перемещаемые объектные файлы являются временными продуктами просто потому, что они участвуют только в этапе связывания для создания предыдущих продуктов, и после этого они нам больше не нужны. Компонент компоновщика несет исключительную ответственность за создание предыдущих продуктов из заданных перемещаемых объектных файлов.

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

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

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

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

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

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

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

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

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

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

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

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

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

среднее значение (int a, int b) {

возврат (а + б) / 2;

}

целая сумма (целые * числа, целые числа) {

целая сумма = 0;

for (int i = 0; i ‹ count; i++) {

сумма += числа[i];

}

сумма возврата;

}

Блок кода 2–8 [ExtremeC_examples_article2_3.c]: код с двумя определениями функций.

Во-первых, нам нужно скомпилировать предыдущий код, чтобы создать соответствующий объектный файл. Следующая команда создает объектный файл target.o. Мы компилируем код на нашей платформе по умолчанию:

$ gcc -c ExtremeC_examples_article2_3.c -o target.o

$

Shell Box 2–12: Компиляция исходного файла в примере 2.3

Затем мы используем утилиту nm для просмотра объектного файла target.o. Утилита nm позволяет нам увидеть символы, которые можно найти внутри объектного файла:

$ nm target.o

0000000000000000 Т в среднем

000000000000001d T сумма

$

Окно оболочки 2–13: Использование утилиты nm для просмотра определенных символов в перемещаемом объектном файле

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

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

$ readelf -s target.o

Таблица символов ‘.symtab’ содержит 10 записей:

Num: Значение Размер Тип Bind Vis Ndx Name

0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND

1: 0000000000000000 0 ФАЙЛ ЛОКАЛЬНЫЙ ПО УМОЛЧАНИЮ ABS ExtremeC_examples_article

2: 0000000000000000 0 СЕКЦИЯ ЛОКАЛЬНАЯ ПО ​​УМОЛЧАНИЮ 1

3: 0000000000000000 0 СЕКЦИЯ ЛОКАЛЬНАЯ ПО ​​УМОЛЧАНИЮ 2

4: 0000000000000000 0 СЕКЦИЯ ЛОКАЛЬНАЯ ПО ​​УМОЛЧАНИЮ 3

5: 0000000000000000 0 СЕКЦИЯ ЛОКАЛЬНАЯ ПО ​​УМОЛЧАНИЮ 5

6: 0000000000000000 0 СЕКЦИЯ ЛОКАЛЬНАЯ ПО ​​УМОЛЧАНИЮ 6

7: 0000000000000000 0 СЕКЦИЯ ЛОКАЛЬНАЯ ПО ​​УМОЛЧАНИЮ 4

8: 0000000000000000 29 FUNC GLOBAL DEFAULT 1 среднее

9: 000000000000001d 69 FUNC GLOBAL DEFAULT 1 сумма

$

Окно оболочки 2–14: использование утилиты readelf для просмотра таблицы символов перемещаемого объектного файла.

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

Если вы хотите увидеть дизассемблирование инструкций машинного уровня под каждым символом функции, вы можете использовать инструмент objdump:

$ objdump -d target.o

target.o: формат файла elf64-x86–64

Разборка раздела .text:

0000000000000000 ‹среднее›:

0: 55 нажать %rbp

1:48 89 e5 мов %rsp,%rbp

4: 89 7d fc mov %edi,-0x4(%rbp)

7: 89 75 f8 мов %esi,-0x8(%rbp)

а: 8b 55 fc mov -0x4(%rbp),%edx

д: 8b 45 f8 mov -0x8(%rbp),%eax

10:01 d0 добавить %edx,%eax

12: 89 c2 mov %eax,%edx

14: c1 ea 1f shr $0x1f,%edx

17:01 d0 добавить %edx,%eax

19: d1 f8 sar %eax

1b: 5д поп %rbp

1c: c3 ретк

000000000000001d ‹сумма›:

1д: 55 толчок %rbp

1e: 48 89 e5 мов %rsp,%rbp

21: 48 89 7d e8 mov %rdi,-0x18(%rbp)

25: 89 75 e4 mov %esi,-0x1c(%rbp)

28: c7 45 f8 00 00 00 00 movl $0x0,-0x8(%rbp)

2f: c7 45 fc 00 00 00 00 movl $0x0,-0x4(%rbp)

36: eb 1d jmp 55 ‹сумма+0x38›

38: 8b 45 fc mov -0x4(%rbp),%eax

3b: 48 98 cltq

3d: 48 8d 14 85 00 00 00 lea 0x0(,%rax,4),%rdx

44: 00

45: 48 8b 45 e8 mov -0x18(%rbp),%rax

49: 48 01 d0 добавить %rdx,%rax

4c: 8b 00 мов (%rax),%eax

4e: 01 45 f8 добавить %eax,-0x8(%rbp)

51: 83 45 fc 01 addl $0x1,-0x4(%rbp)

55: 8b 45 fc mov -0x4(%rbp),%eax

58: 3b 45 e4 cmp -0x1c(%rbp),%eax

5b: 7c db jl 38 ‹сумма+0x1b›

5d: 8b 45 f8 mov -0x8(%rbp),%eax

60: 5d поп %rbp

61: с3 возврат

$

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

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

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

Пример 2.4 состоит из четырех файлов C — трех исходных файлов и одного заголовочного файла. В заголовочном файле мы объявили две функции, каждая из которых определена в собственном исходном файле. Третий исходный файл содержит основную функцию.

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

В следующем поле кода показан заголовочный файл:

#ifndef EXTREMEC_EXAMPLES_article_2_4_DECLS_H

#define EXTREMEC_EXAMPLES_article_2_4_DECLS_H

инт добавить (интервал, инт);

целое умножить (целое, целое);

#endif

Блок кода 2–9 [ExtremeC_examples_article2_4_decls.h]: объявление функций в примере 2.4.

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

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

int добавить (int a, int b) {

вернуть а + б;

}

Блок кода 2–10 [ExtremeC_examples_article2_4_add.c]: определение функции добавления

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

Как мы видим дальше, второй исходный файл похож на первый. Этот содержит определение функции умножения:

int умножить (int a, int b) {

вернуть а * б;

}

Кодовое поле 2–11 [ExtremeC_examples_article2_4_multiply.c]: определение функции умножения

Теперь мы можем перейти к третьему исходному файлу, который содержит основную функцию:

#include «ExtremeC_examples_article2_4_decls.h»

int main(int argc, char** argv) {

int х = добавить (4, 5);

int y = умножить (9, х);

вернуть 0;

}

Блок кода 2–12 [ExtremeC_examples_article2_4_main.c]: основная функция примера 2.4.

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

Кроме того, функция main ничего не знает об определениях ни сложения, ни умножения. Поэтому нам нужно задать важный вопрос: как функция main находит эти определения, когда она даже не знает о других исходных файлах? Обратите внимание, что файл, показанный в поле кода 2–12, включает только один заголовочный файл и, следовательно, не имеет отношения к двум другим исходным файлам.

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

Примечание:

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

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

$ gcc -c ExtremeC_examples_article2_4_add.c -o add.o

$ gcc -c ExtremeC_examples_article2_4_multiply.c -o умножить.o

$ gcc -c ExtremeC_examples_article2_4_main.c -o main.o

$

Оболочка 2–16: компиляция всех исходных кодов в примере 2.4 в соответствующие перемещаемые объектные файлы.

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

$ нм доп.о

0000000000000000 Т добавить

$

Shell Box 2–17: список символов, определенных в add.o

Как видите, символ добавления определен. Следующий объектный файл:

$ nm умножить.o

0000000000000000 T умножить

$

Shell Box 2–18: Список символов, определенных вmultiple.o

То же самое происходит с символом умножения внутри умножения.о. И окончательный объектный файл:

$ нм main.o

ты добавь

U _GLOBAL_OFFSET_TABLE_

0000000000000000 Т основной

U умножить

$

Shell Box 2–19: список символов, определенных в main.o

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

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

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

Если компоновщик не сможет найти определение неразрешенного символа, он потерпит неудачу и сообщит нам об ошибке компоновки.

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

$ gcc add.o умножить.o main.o

$

Оболочка 2–20: связывание всех объектных файлов вместе

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

Чтобы проверить, что произойдет, если компоновщику не удастся найти правильные определения, мы собираемся предоставить компоновщику только два промежуточных объектных файла, main.o и add.o:

$ gcc add.o main.o

main.o: В функции «main»:

ExtremeC_examples_article2_4_main.c:(.text+0x2c): неопределенная ссылка на «умножить»

collect2: ошибка: ld вернул 1 статус выхода

$

Оболочка 2–21: Связывание только двух объектных файлов: add.o и main.o

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

Двигаясь дальше, давайте предоставим два других объектных файла, main.o иmultiple.o:

$ gcc main.o умножить.o

main.o: В функции «main»:

ExtremeC_examples_article2_4_main.c:(.text+0x1a): неопределенная ссылка на «добавить»

collect2: ошибка: ld вернул 1 статус выхода

$

Блок оболочки 2–22: Связывание только двух объектных файлов,multiple.o и main.o

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

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

$ gcc add.o умножить.o

/usr/lib/gcc/x86_64-linux-gnu/7/../../../x86_64-linux-gnu/Scrt1.o: В функции ‘_start’:

(.text+0x20): неопределенная ссылка на «основной»

collect2: ошибка: ld вернул 1 статус выхода

$

Оболочка 2–23: Связывание только двух объектных файлов, add.o иmultiple.o

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

Здесь — и я не могу не подчеркнуть этого — обратите внимание на то место, где была сделана ссылка на основной символ. Это было сделано в функции _start в файле, расположенном по адресу /usr/lib/gcc/x86_64-Linux-gnu/7/../../../x86_64-Linux-gnu/Scrt1.o.

Файл Scrt1.o, похоже, представляет собой перемещаемый объектный файл, созданный не нами. Scrt1.o на самом деле является файлом, который является частью группы объектных файлов C по умолчанию. Эти объектные файлы по умолчанию были скомпилированы для Linux как часть пакета gcc и связаны с любой программой, чтобы сделать ее работоспособной.

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

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

Пример 2.5 основан на неверном определении, которое было собрано компоновщиком и помещено в окончательный исполняемый объектный файл.

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

int add (int a, int b, int c, int d) {

возврат a + b + c + d;

}

Блок кода 2–13 [ExtremeC_examples_article2_5_add.c]: определение функции добавления в примере 2.5.

И вот второй исходный файл:

#include ‹stdio.h›

инт добавить (интервал, инт);

int main(int argc, char** argv) {

интервал х = добавить (5, 6);

printf("Результат: %d\n", x);

вернуть 0;

}

Блок кода 2–14 [ExtremeC_examples_article2_5_main.c]: основная функция в примере 2.5.

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

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

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

$ gcc -c ExtremeC_examples_article2_5_add.c -o add.o

$ gcc -c ExtremeC_examples_article2_5_main.c -o main.o

$ gcc add.o main.o -o ex2_5.out

$

Оболочка 2–24: Пример построения 2.5

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

$ ./ex2_5.out

Результат: -1885535197

$ ./ex2_5.out

Результат: 1679625283

$

Shell Box 2–25: запуск примера 2.5 дважды и странные результаты!

Как видите, вывод неверный; он даже меняется в разных прогонах! Этот пример показывает, что могут произойти плохие вещи, когда компоновщик выбирает неправильную версию символа. Что касается символов функций, то они являются просто именами и не несут никакой информации о сигнатуре соответствующей функции. Аргументы функций — это не что иное, как концепция C; на самом деле они не существуют ни в ассемблерном коде, ни в инструкциях машинного уровня.

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

Чтобы изучить это, мы будем работать с идеей, что у нас есть следующие исходные файлы в примере 2.6:

int add (int a, int b, int c, int d) {

возврат a + b + c + d;

}

Блок кода 2–15 [ExtremeC_examples_article2_6_add_1.c]: первое определение add в примере 2.6.

Следующий код является другим исходным файлом:

int добавить (int a, int b) {

вернуть а + б;

}

Блок кода 2–16 [ExtremeC_examples_article2_6_add_2.c]: второе определение add в примере 2.6.

Первым шагом, как и прежде, является компиляция обоих исходных файлов:

$ gcc -c ExtremeC_examples_article2_6_add_1.c -o add_1.o

$ gcc -c ExtremeC_examples_article2_6_add_2.c -o add_2.o

$

Оболочка 2–26: компиляция исходных файлов в примере 2.6 в соответствующие объектные файлы.

Затем нам нужно взглянуть на разборку символа добавления в разных объектных файлах. Поэтому начнем с объектного файла add_1.o:

$ objdump -d add_1.o

add_1.o: формат файла elf64-x86–64

Разборка раздела .text:

0000000000000000 ‹добавить›:

0: 55 нажать %rbp

1:48 89 e5 мов %rsp,%rbp

4: 89 7d fc mov %edi,-0x4(%rbp)

7: 89 75 f8 мов %esi,-0x8(%rbp)

a: 89 55 f4 mov %edx,-0xc(%rbp)

d: 89 4d f0 mov %ecx,-0x10(%rbp)

10: 8b 55 fc mov -0x4(%rbp),%edx

13: 8b 45 f8 mov -0x8(%rbp),%eax

16: 01 c2 добавить %eax,%edx

18: 8b 45 f4 mov -0xc(%rbp),%eax

1b: 01 c2 добавить %eax,%edx

1d: 8b 45 f0 mov -0x10(%rbp),%eax

20: 01 d0 добавить %edx,%eax

22: 5д поп %rbp

23: c3

$

Оболочка 2–27: Использование objdump для просмотра разборки символа добавления в add_1.o

В следующем окне оболочки показана разборка символа добавления, найденного в другом объектном файле, add_2.o:

$ objdump -d add_2.o

add_2.o: формат файла elf64-x86–64

Разборка раздела .text:

0000000000000000 ‹добавить›:

0: 55 нажать %rbp

1:48 89 e5 мов %rsp,%rbp

4: 89 7d fc mov %edi,-0x4(%rbp)

7: 89 75 f8 мов %esi,-0x8(%rbp)

а: 8b 55 fc mov -0x4(%rbp),%edx

д: 8b 45 f8 mov -0x8(%rbp),%eax

10:01 d0 добавить %edx,%eax

12: 5d поп %rbp

13: с3 возврат

$

Оболочка 2–28: Использование objdump для просмотра разборки символа добавления в add_2.o

Когда происходит вызов функции, поверх стека создается новый кадр стека. Этот кадр стека содержит как аргументы, переданные функции, так и адрес возврата. Подробнее о механизме вызова функций вы узнаете из статьи 4 «Структура памяти процесса» и статьи 5 «Стек и куча».

В блоках оболочки 2–27 и 2–28 вы можете ясно видеть, как аргументы собираются из кадра стека. В разборке add_1.o, Shell Box 2–27 можно увидеть следующие строки:

4: 89 7d fc mov %edi,-0x4(%rbp)

7: 89 75 f8 мов %esi,-0x8(%rbp)

a: 89 55 f4 mov %edx,-0xc(%rbp)

d: 89 4d f0 mov %ecx,-0x10(%rbp)

Блок кода 2–17: Инструкции по сборке для копирования аргументов из кадра стека в регистры для первой функции добавления.

Эти инструкции копируют четыре значения из адресов памяти, на которые указывает регистр %rbp, и помещают их в локальные регистры.

Примечание:

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

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

4: 89 7d fc mov %edi,-0x4(%rbp)

7: 89 75 f8 мов %esi,-0x8(%rbp)

Блок кода 2–18: Инструкции по сборке для копирования аргументов из кадра стека в регистры для второй функции добавления.

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

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

Изменение имен C++
Чтобы показать, как работает изменение имен в C++, мы собираемся скомпилировать пример 2.6 с помощью компилятора C++. Поэтому для этой цели мы будем использовать компилятор GNU C++ g++.

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

Как мы уже отмечали ранее, конвейеры компиляции C и C++ очень похожи. Следовательно, в результате компиляции C++ мы можем ожидать наличия перемещаемых объектных файлов. Давайте посмотрим на оба объектных файла, созданных как часть компиляции примера 2.6:

$ g++ -c ExtremeC_examples_article2_6_add_1.o

$ g++ -c ExtremeC_examples_article2_6_add_2.o

$ readelf -s ExtremeC_examples_article2_6_add_1.o

Таблица символов ‘.symtab’ содержит 9 записей:

Num: Значение Размер Тип Bind Vis Ndx Name

0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND

1: 0000000000000000 0 ФАЙЛ ЛОКАЛЬНЫЙ ПО УМОЛЧАНИЮ ABS ExtremeC_examples_article

2: 0000000000000000 0 СЕКЦИЯ ЛОКАЛЬНАЯ ПО ​​УМОЛЧАНИЮ 1

3: 0000000000000000 0 СЕКЦИЯ ЛОКАЛЬНАЯ ПО ​​УМОЛЧАНИЮ 2

4: 0000000000000000 0 СЕКЦИЯ ЛОКАЛЬНАЯ ПО ​​УМОЛЧАНИЮ 3

5: 0000000000000000 0 СЕКЦИЯ ЛОКАЛЬНАЯ ПО ​​УМОЛЧАНИЮ 5

6: 0000000000000000 0 СЕКЦИЯ ЛОКАЛЬНАЯ ПО ​​УМОЛЧАНИЮ 6

7: 0000000000000000 0 СЕКЦИЯ ЛОКАЛЬНАЯ ПО ​​УМОЛЧАНИЮ 4

8: 0000000000000000 36 FUNC GLOBAL DEFAULT 1 _Z3addiiii

$ readelf -s ExtremeC_examples_article2_6_add_2.o

Таблица символов ‘.symtab’ содержит 9 записей:

Num: Значение Размер Тип Bind Vis Ndx Name

0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND

1: 0000000000000000 0 ФАЙЛ ЛОКАЛЬНЫЙ ПО УМОЛЧАНИЮ ABS ExtremeC_examples_article

2: 0000000000000000 0 СЕКЦИЯ ЛОКАЛЬНАЯ ПО ​​УМОЛЧАНИЮ 1

3: 0000000000000000 0 СЕКЦИЯ ЛОКАЛЬНАЯ ПО ​​УМОЛЧАНИЮ 2

4: 0000000000000000 0 СЕКЦИЯ ЛОКАЛЬНАЯ ПО ​​УМОЛЧАНИЮ 3

5: 0000000000000000 0 СЕКЦИЯ ЛОКАЛЬНАЯ ПО ​​УМОЛЧАНИЮ 5

6: 0000000000000000 0 СЕКЦИЯ ЛОКАЛЬНАЯ ПО ​​УМОЛЧАНИЮ 6

7: 0000000000000000 0 СЕКЦИЯ ЛОКАЛЬНАЯ ПО ​​УМОЛЧАНИЮ 4

8: 0000000000000000 20 FUNC GLOBAL DEFAULT 1 _Z3addii

$

Вставка оболочки 2–29: с помощью readelf просмотрите таблицы символов объектных файлов, созданных компилятором C++.

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

Каждая буква i в имени символа относится к одному из входных целочисленных параметров.

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

Резюме
В этой статье мы рассмотрели основные шаги и компоненты, необходимые для создания проекта C. Не зная, как построить проект, бессмысленно просто писать код. В этой статье:

Мы рассмотрели конвейер компиляции C и его различные этапы. Мы обсудили каждый шаг и описали входные и выходные данные.
Мы определили термин «платформа» и то, как разные ассемблеры могут привести к разным инструкциям машинного уровня для одной и той же программы на C.
Мы продолжили обсуждение каждого шага и компонент, управляющий этим шагом, более подробно.
В рамках компонента компилятора мы объяснили, что такое внешние и внутренние интерфейсы компилятора, и как GCC и LLVM используют это разделение для поддержки многих языков.
Как часть Из нашего обсуждения компонента ассемблера мы увидели, что объектные файлы зависят от платформы и должны иметь точный формат файла.
В рамках компонента компоновщика мы обсудили, что делает компоновщик и как он использует символы для найти недостающие определения, чтобы собрать их вместе и сформировать конечный продукт. Мы также объяснили различные возможные продукты проекта C. Мы объяснили, почему перемещаемые (или промежуточные) объектные файлы не следует рассматривать как продукты.
Мы продемонстрировали, как можно обмануть компоновщика, если символ предоставляется с неправильным определением. Мы показали это в примере 2.5.
Мы объяснили функцию изменения имени C++ и то, как из-за этого можно предотвратить проблемы, подобные тем, что мы видели в примере 2.5.
Мы продолжим обсуждение объектных файлов и их внутренних в следующей статье «Объектные файлы».