Как язык программирования работает под капотом

Вы когда-нибудь задумывались, как это работает под капотом языка программирования? Как компилятору удается правильно понимать наш код? Как машинам (компьютерам) удается правильно и точно выполнять наш код?

«Правильно, правильно и точно» подразумевается без ошибок, без потерь и без модификаций.

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

Но прежде начнем с конца истории: какой код может понять машина (компьютер)?

На каком языке говорит машина?

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

Итак, как мы можем связать программное обеспечение с аппаратным обеспечением?
Это легко благодаря двум магическим числам (битам) 1 и 0, которые являются синонимами включения и выключения цепей в компьютере (Алгебра булева) .

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

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

Однако в наши дни редко кому приходится программировать в числовом машинном коде из-за сложности работы с ним.

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

В качестве первого уровня абстракции можно привести ассемблерный код:

На этом уровне мы поняли, что понимает машина (компьютер), и я думаю, понятно, что будет делать компилятор, не так ли?

Роль компилятора состоит в том, чтобы сделать человеческий мир и мир машин совместимыми. Чтобы машина понимала инструкции (желания) человека. Идеальный!

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

Определение языка

Мы собираемся разработать небольшой язык программирования под названием H#:

  • Как и человеческий язык, язык программирования состоит из dictionary и grammar.
  • dictionary определяет набор слов (токенов), из которых состоит язык.
  • grammar проверяет правильность сборки слов.

В настоящее время наш текущий язык позволяет только:

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

Другие характеристики:

  • разделитель слов будет space
  • разделителем операторов будет точка с запятой ;
  • каждый оператор должен начинаться с новой строки
  • с учетом регистра
  • комментарии начинаются с //
  • отступ не важен

Вот пример программы, написанной на H#:

int value1 = 5;
int value2 = 2;
// calculate the sum of two integers
int function sum(x: int, y: int) {
   return x + y;
}
int result = sum(value1, value2);
print('The result is : ', result);

H# будет разработан на основе языка C, конечным результатом будет код на ассемблере:

Словарное определение (токены)

Токены — это наш словарь общепринятых слов.

Практическое представление может быть a map or a dictionary:

Определение грамматики

Правило 1: объявление переменной

Правило 2: объявление функции

Правило 3: вызов функции

Определение ошибок

Лексические ошибки:

Ошибка 1: неизвестный идентификатор.

Ошибка 2: зарезервированные ключевые слова.

Синтаксические ошибки:

Ошибка 1: несоответствие декларации.

Ошибка 2: отсутствует открывающая/закрывающая скобка.

Мы готовы понять, что происходит внутри компилятора!

Что такое компилятор?

Компилятор — это инструмент, который переводит программное обеспечение, написанное на человеко-ориентированном языке, в машинно-ориентированный язык.

Типичным «исходным» языком может быть C, C++, Fortran, Java или ML. «Целевой» язык обычно представляет собой набор инструкций некоторого процессора.

Основные характеристики компилятора:

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

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

  • Программный анализ (внешний интерфейс): производит промежуточное представление (IR).
  • Оптимизация: преобразует IR в эквивалентную IR, которая работает более
    эффективно.
  • Машинный таргетинг (бэкенд): преобразует IR в нативный код (машинный код).

Давайте погрузимся глубже!

Этапы анализа программы (интерфейс)

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

Таким образом, некоторые предложат следующие шаги:

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

Компилятор выполнит те же шаги, чтобы понять язык ввода:

Шаги выглядят так, как мы переводим предложение выше, не так ли?

Давайте углубимся в каждый шаг вместе!

Сканер

Сканер проанализирует поток символов исходной программы и превратит его в поток токенов. Это как искать слово в словаре посимвольно. Он также удаляет пробелы и комментарии.

Ошибки, которые могут возникнуть при сканировании, называются лексическими ошибками:

  • Сканер обнаружит все неизвестные слова и выдаст an unknown identifier error.
  • Если ключевое слово использовано неправильно, сканер a reserved keywords error .

Простейшим алгоритмом распознавания сканера может быть посимвольный анализатор.

Например, предположим, что у нас есть этот входной исходный код:

int value1 = 5;
int value2 = 2;

// calculate the sum of two integers
int function sum(x: int, y: int) {
   return x + y;
}

После фазы сканирования вывод будет:

Эти случаи будут генерировать лексические ошибки:

integer value1 = 5; // unknown token integer 
int function = 5; // function is a reserved keyword
INT value1 = 5; // unknown token INT, H# is case sensitive
println() // unknown token println

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

💡 Советы: Lex и Flex — это инструменты для создания сканеров.

Однако этого недостаточно, нам нужно следить за правильностью сборки токенов. Это все равно, что убедиться, что предложение грамматически правильно составлено (выражение: подлежащее + глагол). Это будет роль парсера. Давайте взглянем!

Парсер

Сканер проверяет правильность лексики, парсер проверяет правильность грамматики.

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

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

Хорошо, но как парсер может это сделать? Чтобы понять это, я собираюсь спросить вас, если бы вы были в школе, и я попросил бы вас рассчитать эту операцию (17 + 19) * (8 + 96), как бы вы это сделали?

Более простой способ расчета состоит в том, чтобы сделать это:

Косвенно вы проанализировали выражение, как компилятор: вы определили токены (операнды и операторы) и их отношения!

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

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

Ошибки, которые могут возникнуть во время синтаксического анализа, называются синтаксическими ошибками.

Эти случаи будут генерировать лексические ошибки:

int j = 4 * (6 − x; // missing a closing parenthesis
int i = /5; // missing the first value
int 42 = x * 3 // missing a semicolon, 42 can't be a variable name
int value1 = 1, value2 = 1; // the language definition state that we must have a declaration per ligne

🔵 Полезно знать: некоторые инструменты Javascript, такие как Babel и ESLint, могут манипулировать AST. Вы также можете визуализировать AST любого кода Javascript с помощью AST Explorer.

💡 Советы: Yacc и Bison — это инструменты для создания парсеров.

Семантический анализатор

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

Правила, которые мы можем проверить на этом этапе:

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

Средство проверки типов проверяет статическую семантику каждого узла AST:

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

Ошибки, возникающие на этом этапе, называются статическими семантическими ошибками.

Подведем итоги!

Сканер:

  • ввод: исходный код.
  • вывод: токены.
  • Цель: проверка словарного запаса.
  • лексические ошибки: неизвестный токен, зарезервированные ключевые слова, …

Парсер:

  • ввод: жетоны.
  • выход: АСТ.
  • Цель: проверка синтаксиса (формулировка предложения).
  • синтаксические ошибки: отсутствие закрывающей скобки, отсутствие точки с запятой, неправильное имя переменной, …

Семантический анализатор:

  • ввод: АСТ.
  • вывод: Аннотированный AST.
  • цель: смысловая проверка (смысл).
  • семантические ошибки: ошибки типов, ошибки объявления, ошибки аргументов, ошибки ссылок и т. д.

Промежуточные представления

Промежуточное представление — это независимая от машины и языка версия исходного кода.

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

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

Этапы таргетинга на компьютер (серверная часть)

Цель бэкенд-этапов — сгенерировать машинный код:

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

Заключение

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

Язык программирования регулируется: лексикой, грамматикой и семантикой.

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

Код внутри компилятора проходит несколько этапов: лексический анализ, синтаксический анализ, затем семантический анализ. Корректность является обязательной характеристикой и поведением компилятора.

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

Спасибо, что прочитали мою статью.

Want to Connect?
You can find me at GitHub: https://github.com/helabenkhalfallah