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

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

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

В основном это приводит к созданию так называемого абстрактного синтаксического дерева», которое является представлением нашего кода, но как абстрактное синтаксическое дерево.

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

Ниже приведен тот же пример из часть 1. У нас есть только функция console_log, которая определена в другом файле console.cpp.

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

Мы должны понимать, что C++ не заботится о файлах. Файлы - это не то, что существует в C++. Например, в Java Имя вашего класса должно быть привязано к имени вашего файла, а иерархия папок должна быть привязана к вашему пакету, потому что Java предполагает существование определенных файлов. В C++ это не так!!!

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

Теперь, конечно, если вы создадите файл с расширением .cpp, компилятор обработает его как файл C++. Точно так же, если я создам файл с расширением .h или .c, он будет рассматриваться как файл заголовка и файл C соответственно.
Это в основном просто принятые по умолчанию соглашения, вы можете переопределить любое из них, и именно так компилятор будет с этим работать, если вы не скажете, как с этим бороться.

Я могу создать файл .jay и попросить компилятор его скомпилировать, и это будет совершенно нормально, если я скажу компилятору: «эй, это файл C++, пожалуйста, скомпилируйте его как файл C++ ».

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

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

Теперь эти (объектные файлы) на самом деле довольно большие, вы можете видеть, что console.obj составляет 46 КБ, а outro.obj - всего 4 КБ, причина этого в том, что мы включаем iostream, и в нем много всего, поэтому они такие большие и из-за этого они довольно сложные, поэтому, прежде чем мы погрузимся и посмотрим, что на самом деле находится в файле, давайте создадим что-то более простое. Создайте новый файл с именем math.cpp

math.cpp будет иметь очень простую функцию умножения, которая умножает 2 числа вместе и возвращает результат.

Теперь скомпилируйте math.cpp и извлеките выходной каталог, в котором будет math.obj размером всего 4 КБ.

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

Итак, давайте взглянем на один из самых распространенных операторов предварительной обработки, который у нас есть #include.

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

Теперь создайте новый заголовочный файл с именем endbrace.h. Просто добавьте «}» в файл и сохраните его, вот и все.

Теперь перейдите к math.cpp, удалите закрывающую фигурную скобку «}» и скомпилируйте его. Вы получите ошибку. Теперь вместо того, чтобы решать это как обычный человек (добавляя закрывающую скобку), давайте включим наш заголовочный файл endbrace и теперь скомпилируем его.

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

На самом деле есть способ заставить компилятор вывести файл, содержащий результат всех вычислений препроцессора, которые произошли. Щелкните правой кнопкой мыши свой проект и перейдите в свойства -> перейдите в раздел C/C++ -> Препроцессор и измените препроцессор на файл на yes. (убедитесь, что вы делаете это с текущей конфигурацией и платформой, чтобы она применялась)

Теперь скомпилируйте его снова. Если мы откроем наш выходной каталог, вы увидите новый файл .i (math.i), который является нашим предварительно обработанным кодом C++.
Давайте откроем его в текстовом редакторе и посмотрим. Здесь вы можете увидеть, что на самом деле сгенерировал препроцессор. Вы можете видеть, что в нашем исходном коде (math.cpp) был #include «endbrace.h», но наш код препроцессора только что вставил конечную фигурную скобку, которая была в этом заголовочном файле. Довольно простые вещи!!

Теперь давайте добавим еще несколько операторов предварительной обработки, но сначала удалим этот #include”endbrace.h” из math.cpp (это раздражает). Теперь давайте определим INTEGER как int (не спрашивайте меня, зачем мне это делать -_-, это просто пример). Оператор определения препроцессора в основном просто выполняет поиск INTEGER и заменяет его на int . Итак, давайте заменим здесь int словом INTEGER. Нажмите скомпилировать !!

Теперь снова откройте файл math.i, и вы увидите, что произошло. Просто выглядит нормально. Давайте поиграем с этим еще немного.

Измените свой math.cpp на нормальный (замените INTEGER на int и удалите этот оператор определения). Теперь добавим #if . Оператор препроцессора IF позволяет нам включать или исключать код на основе заданного условия. Итак, в math.cpp напишите #if 1, что в основном означает true, а затем просто напишите #endif в конце функции. Идите вперед, скомпилируйте файл и снова откройте файл math.i. Вы увидите, что это точно так же

Теперь, если мы изменим #if 1 на #if 0, снова скомпилируем и посмотрим на файл math.i, у нас не будет кода.

Это еще один отличный пример того, как работает оператор препроцессора. Теперь я обещаю, что это будет последним :p. Теперь давайте включим iostream (массивный iostream). Скомпилируйте это.

Теперь взгляните на файл math.i (это почти 56 тысяч строк).

Это все iostream, теперь, конечно, iostream также включает в себя и другие файлы, так что это вроде рекурсии.

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

Вернитесь к math.cpp и удалите iostream, скомпилируйте его снова. Также вернитесь к свойствам вашего проекта и отключите этот препроцессор для файла. (нам нужно отключить его, чтобы мы могли получить наш файл .obj). Теперь создайте свой проект снова.

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

Есть несколько способов сделать это, но давайте сделаем это с помощью Visual Studio. Щелкните правой кнопкой мыши свой проект -> свойства -> раздел C/C++ -> выходные файлы -> измените вывод сборки на список только сборки и нажмите «ОК».

Теперь нажмите Ctrl+F7, чтобы скомпилировать math.cpp. Вы получите новый файл math.asm (Ctrl+F7 компилирует только определенный файл, который вы открыли, если вы создадите весь проект, вы получите больше файлов .asm)

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

Это фактические инструкции, которые наш ЦП будет выполнять, когда мы запускаем функцию. Мы не будем вдаваться в подробности всего этого ассемблерного кода, но если мы посмотрим, то увидим, что наша операция умножения на самом деле происходит в строке 44. Мы загружаем переменную a в наш регистр EAX, а затем выполняем инструкцию imul, которая представляет собой инструкцию умножения на переменную b и переменную. Затем мы сохраняем этот результат в переменной с именем result и перемещаем его обратно в EAX для возврата. Причина, по которой происходит это двойное перемещение, заключается в том, что мы создали переменную с именем result в нашем math.cpp, которая сохраняет результат умножения, а затем возвращает его. (что совершенно лишнее)

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

Вы увидите, что ваш math.asm будет выглядеть немного по-другому, потому что мы делаем imul и просто возвращаем результат. EAX будет содержать наше возвращаемое значение.

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

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

Давайте нажмем Ctrl+F7 и снова посмотрим на этот файл сборки. Ничего себе, это выглядит намного меньше, мы просто загрузили наши переменные в регистр, а затем умножили и все. Теперь у вас должно быть общее представление о том, что делает компилятор, когда вы говорите ему оптимизировать его, он оптимизирует его 😎

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

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

Итак, если мы снова взглянем на наш math.cpp, он существенно упростил наши 5 * 2 до 10, потому что нет необходимости делать что-то вроде 5 * 2 во время выполнения, это называется свертыванием констант. где все постоянное, что может быть обработано во время компиляции, обрабатывается.

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

Теперь давайте посмотрим, что сгенерировал компилятор. Прокрутите немного вниз, и вы увидите, что у нас есть функция Log, которая на самом деле мало что делает, а просто возвращает наше сообщение. Вы можете видеть, что он перемещает указатель нашего сообщения на EAX, который является нашим регистром возврата. Если мы прокручиваем, у нас также есть наша функция умножения, и в этой функции умножения у нас есть вызов log. Прямо перед тем, как мы на самом деле выполним наше умножение с помощью imul, мы на самом деле вызовем эту функцию журнала.

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

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

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

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

Часть 3: Линкеры

Есть предложения? Подключим Twitter, Github, LinkedIn