Руководство по компилятору

Большинство компиляторов имеют следующую архитектуру:

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

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

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

Введение

Сейчас я работаю над системным языком Krug, вдохновленным Rust и Go. В статье я буду обращаться к Krug в качестве примера для иллюстрации своих мыслей. Krug находится в стадии разработки, но уже доступен на https://github.com/krug-lang в репозиториях caasper и krug. Язык не совсем типичен по сравнению с обычной архитектурой компиляторов, что отчасти и вдохновило меня на написание статьи — но об этом позже.

Спешу сообщить, что я ни в коей степени не являюсь специалистом по компиляторам! У меня нет докторской степени, и я не проходил никакого формального обучения — все описанное в статье я изучил самостоятельно в свободное время. Также должен сказать, что я не описываю фактический, единственно верный подход к созданию компилятора, а, скорее, представляю базовые методы, пригодные для создания небольшого «игрушечного» компилятора.

Фронтенд

Вернемся к диаграмме выше: направленные к полю frontend стрелочки слева — известные и любимые нами языки вроде C. Фронтенд выглядит примерно так: лексический анализ -> парсер.

Лексический анализ

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

В случае компилятора на вход он получает написанную программу. Она считывается в строку из файла, а анализатор токенизирует ее исходный код.

enum TokenType {
  Identifier,
  Number,
};

struct Token {
  std::string Lexeme;
  TokenType type;
  // ...
  // It's also handy to store things in here
  // like the position of the token (start to end row:col)
};

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

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

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

Возьмем следующий кусочек кода на C:

int main() {
  printf("Hello world!n");
  return 0;
}

Считав его из файла в строку и проведя линейное сканирование, вы, возможно, сможете нарезать токены. Мы идентифицируем токены естественным образом — видя, что int — это «слово», а 0 в операторе возврата — «число». Лексический анализатор проделывает ту же процедуру, что и мы — позже мы разберемся в этом процессе детальнее. Например, проанализируем числа:

0xdeadbeef — HexNumber (шестнадцатеричное число)
1231234234 — WholeNumber (целое число)
3.1412 — FloatingNumber (число с плавающей запятой)
55.5555 — FloatingNumber (число с плавающей запятой)
0b0001 — BinaryNumber (двоичное число)

Определение слов может представлять сложность. Большинство языков определяют слово как последовательность букв и цифр, а идентификатор, как правило, должен начинаться с буквы или нижнего подчеркивания. Например:

123foobar := 3
person-age := 5
fmt.Println(123foobar)

В Go этот код не будет считаться правильным и будет разобран на следующие токены:

Number(123), Identifier(foobar), Symbol(:=), Number(3) ...

Большинство встречающихся идентификаторов выглядят так:

foo_bar
__uint8_t
fooBar123

Анализаторам придется решать и другие проблемы, связанные, например, с пробелами, многострочными и однострочными комментариями, идентификаторами, числами, системами счисления и форматированием чисел (например, 1_000_000) и кодировками (например, поддержкой UTF8 вместо ASCII).

И если вы думаете, что можете прибегнуть к регулярным выражениям — лучше не стоит. Гораздо проще написать анализатор с нуля, но я очень рекомендую прочесть эту статью от нашего царя и бога Роба Пайка. Причины, по которым нам не подойдет Regex, описаны во множестве других статей, так что этот момент я опущу. К тому же, писать анализатор гораздо интереснее, чем мучиться над длинными многословными выражениями, загруженными на regex101.com в 5:24 утра. В своем первом языке я использовал для токенизации функцию split(str) — и далеко не продвинулся.

Парсинг

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

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

Эти этапы можно представить в виде функций:

fn lex(string input) []Token {...}
fn parse(tokens []Token) AST {...}

let input = "int main() { return 0; }";
let tokens = lex(input);
let parse_tree = parse(tokens);
// ....

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

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

Деревья

Дерево парсинга

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

Абстрактное синтаксическое дерево

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

Предположим, в вашем дереве есть выражение типа ((5+5)-3)+2. В дереве парсинга вы хранили бы его полностью, вместе со скобками, операторами и значениями 5, 5, 3 и 2. Но с АСД можно просто провести ассоциации — нам нужно знать только значения, операторы и их порядок.

На картинке ниже показано дерево для выражения a+b/c.

АСД можно представить следующим образом:

interface Expression { ... };

struct UnaryExpression {
  Expression value;
  char op;
};

struct BinaryExpression {
  Expression lhand, rhand;
  string op; // string because the op could be more than 1 char.
};

interface Node { ... };

// or for something like a variable
struct Variable : Node {
  Token identifier;
  Expression value;
};

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

Node parseNode() {
  Token current = consume();
  switch (current.lexeme) {
  case "var":
    return parseVariableNode();
  // ...
  }
  panic("unrecognized input!");
}

Node n = parseNode();
if (n != null) {
  // append to some list of top level nodes?
  // or append to a block of nodes!
}

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

Грамматика

Проведение парсинга в АСД из набора токенов может оказаться непростым. Обычно вам следует начать с грамматики вашего языка. По сути, грамматика определяет структуру вашего языка. Существует несколько языков для определения языков, которые могут описать (или разобрать) сами себя.

Пример языка для определения языков — расширенная форма Бэкуса-Наура (РБНФ). Она представляет собой вариацию БНФ с меньшим количеством угловых скобок. Вот пример РБНФ из статьи Википедии:

digit excluding zero = "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" ;
digit                = "0" | digit excluding zero ;

Продукционные правила определены: они указывают, какой шаблон терминалов составляет «нетерминал». Терминалы — часть алфавита, например, токен if или 0 и 1 в примере выше — терминалы. Нетерминалы — их противоположность, они находятся в левой части продукционных правил, и их можно считать переменными или «именованными указателями» на группы терминалов и нетерминалов.

Во многих языках имеются спецификации, которые содержат грамматику. Например, для Go, Rust и D.

Анализаторы с рекурсивным спуском

Рекурсивный спуск — самый простой из многочисленных подходов к парсингу.

Анализаторы с рекурсивным спуском — нисходящие, основанные на рекурсивных процедурах. Гораздо проще написать парсер, ведь в вашей грамматике нет левой рекурсии. В большинстве «игрушечных» языков этой техники достаточна для парсинга. В GCC используется написанный вручную нисходящий парсер, хотя до того использовался YACC.

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

foo * bar

может быть интерпретировано как

int foo = 3;
int bar = 4;
foo * bar; // unused expression

или как

typedef struct {
int b;
} foo;
foo* bar;
bar.b = 3;

В реализации Clang также используется анализатор с рекурсивным спуском:

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

Также стоит обратить внимание на другие подходы:

  • нисходящий LL, рекурсивный спуск
  • восходящий LR, сдвиг, восходящий спуск

Парсер-генераторы

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

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

Пример генератора парсеров — ANTLR, есть и множество других.

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

Применение парсинга

Если вы еще не поняли сами. Даже фронтенд компилятора (lex/parse) может применяться и для решения других проблем:

  • подсветка синтаксиса
  • парсинг HTML/CSS для механизма визуализации
  • транспиляторы: TypeScript, CoffeeScript
  • компоновщики
  • REGEX
  • анализ интерфейсных данных
  • парсинг URL
  • форматирование инструментов типа gofmt
  • парсинг SQL и многое другое.

Середина

Семантический анализ! Анализ семантики языка — одна из сложнейших задач при создании компилятора.

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

Кроме того, компиляция программ невозможна без анализа правильности семантики на соответствующем этапе компиляции.

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

F: 20% M: 20%: B: 60%

Сегодня оно представляет собой что-то вроде

F: 5% M: 60% B: 35%

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

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

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

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

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

Семантические проходы

В ходе семантического анализа большинство компиляторов проводят большое количество «семантических проходов» по АСД или другой абстрактной форме выражения кода. В этой статье содержатся детали о большинстве проходов, производящихся в компиляторе .NET C#.

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

Объявление высшего уровня

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

Разрешение имени/символа

Компилятор проходит по всем блокам кода в функциях и т.п. и разрешает их — то есть, находит символы, требующие разрешения. Это распространенный проход, и именно отсюда, как правило, приходит ошибка No such symbol XYZ при компиляции кода Go.

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

Циклы можно определять, модифицируя DFS в диаграмме зависимостей, или воспользовавшись алгоритмом Тарьяна (как это сделано в Krug) для определения (множественных) циклов.

Выведение типов (Type Inference)

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

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

Типы реализованы в Krug так:

interface Type {};

struct IntegerType : Type {
  int width;
  bool signed;
};

struct FloatingType : Type {
  int width;
};

struct ArrayType : Type {
  Type base_type;
  uint64 length;
};

Также у вас может быть простое выведение типов, при котором вы будете присваивать тип узлам выражений, например, IntegerConstantNode может иметь тип IntegerType(64). А затем у вас может появиться функция unify(t1, t2), которая выберет самый широкий тип, который можно использовать для выведения типа более сложных выражений, скажем, бинарных. Так что это вопрос присвоения переменной слева значений приведённых типов справа.

Когда-то я написал простое приведение типов на Go, которое стало прототипом реализации для Krug.

Проход на изменяемость переменных (Mutability Pass)

Krug (как и Rust) по умолчанию является неизменяемым языком, то есть переменные остаются неизменными, если не задано иное:

let x = 3;
x = 4; // BAD!

mut y = 5;
y = 6; // OK!

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

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

Символьные таблицы

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

К этой информации относятся такие свойства, как имя символа, тип, признак изменяемости, наличие внешней связи, расположение в статичной памяти и прочее.

Область видимости

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

Хотя представление области видимости является интересной задачей для архитектуры компилятора, в большинстве С-подобных языков область видимости ведёт себя (или является) как стековая структура данных (stack data structure).

Обычно мы создаём и уничтожаем области видимости, и обычно они используются для управления именами, то есть позволяют нам скрывать (shadowing) переменные:

{ // push scope
  let x = 3;
  { // push scope
    let x = 4; // OK!
  } // pop scope
} // pop scope

Это можно представить иначе:

struct Scope {
  Scope* outer;
  SymbolTable symbols;
}

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

Системы типов

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

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

Владение

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

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

Я не могу сказать, как всё это устроено под капотом, но всё это является результатом статического анализа и замечательного исследования команды Mozilla и участников проекта Cyclone.

Графы потоков управления (Control Flow Graphs)

Для представления потоков программ мы используем графы потоков управления (CFG), которые содержат все пути, по которым может пойти исполнение программы. Это используется при семантическом анализе для исключения нерабочих участков кода, то есть блоков, функций и даже модулей, которые никогда не будут достигнуты в ходе исполнения программы. Также графы можно применять для выявления циклов, которые не могут прерваться. Или для поиска недоступного кода, например, когда вы вызываете «панику» (call a panic), или возвращаете в цикле, а код снаружи цикла не исполняется. Анализ потока данных играет важную роль в ходе семантической фазы работы компилятора, так что рекомендую почитать о тех видах анализа, которые вы можете выполнять, как они работают и какие оптимизации могут делать.

Бэкенд


Заключительная часть нашей схемы архитектуры.

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

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

Несколько слов о транспиляторах

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

Тем не менее, в истории компиляторов очень часто встречается преобразование в код на С. По сути, первый компилятор С++ — Cfront — транспилировал в код на C.

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

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

LLVM

Многие современные компиляторы обычно используют в качестве своего бэкенда LLVM: Rust, Swift, C/C++ (clang), D, Haskell.

Это можно считать «простым путём», потому что за вас проделали большую часть работы по поддержке широкого спектра архитектур, и вам доступны оптимизации высочайшего уровня. По сравнению с вышеупомянутой транспиляцией, LLVM предоставляет и большие возможности по управлению. Уж точно больше, чем если бы вы компилировали в С. К примеру, вы можете решать, насколько большими должны быть типы, скажем, 1, 4, 8 или 16-битные. В С это сделать не так просто, иногда невозможно, а для каких-то платформ даже нельзя определить.

Генерирование ассемблер-кода

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

Go — это пример современного языка, который не пользуется преимуществами фреймворка LLVM (на момент написания этой статьи). Go генерирует код для нескольких платформ, в том числе Windows, Linux и MacOS. Забавно, что в прототипе Krug раньше тоже генерировался ассемблер-код.

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

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

Но попытаться всё равно приятно. И к тому же будет интересно, если вы захотите узнать больше о программировании на ассемблере, или о том, как языки программирования работают на нижних уровнях. Проще всего открыть АСД или сгенерированный IR (если у вас он есть) и «выдать» инструкции ассемблера в файл с помощью fprintf или другой утилиты. Так работает 8cc.

Генерирование байткода

Также вы можете генерировать байткод для виртуальной машины определённого вида или интерпретатора байткода. Яркий пример — Java: по сути, JVM породила целое семейство генерирующих для неё байткод языков, например, Kotlin.

У генерирования байткода много преимуществ, и для Java главным была портируемость. Если вы можете где угодно запускать свою виртуальную машину, то любой выполняемый на ней код тоже будет работать где угодно. К тому же гораздо проще запускать на машинах абстрактный набор байткодовых инструкций, чем генерировать код по стопицот компьютерных архитектур.
Насколько я знаю, JVM с помощью JIT превращает часто используемый код в нативные функции, а также применяет другие JIT-ухищрения, чтобы выжать ещё больше производительности.

Оптимизации

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

Если вы когда либо писали компилятор, то можете начать с создания простой программы на С, выключить все оптимизации и символы отладки (strip the debug symbols), и посмотрите, что сгенерирует GCC. Можете потом использовать как памятку, если когда-нибудь возникнут затруднения.

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

В комментариях к этой статье на другом ресурсе пользователь rwmj заметил, что достаточно всего 8 оптимизирующих проходов, чтобы получить 80% от максимальной производительности вашего компилятора. И все эти оптимизации были описаны в 1971-м! Речь идёт о публикации Грейдона Хоара, вдохновителя Rust.

IR

Промежуточное представление (intermediate representation, IR) не обязательно, но полезно. Вы можете генерировать код из АСД, хотя это может быть довольно утомительно и неаккуратно, а результат сложно будет оптимизировать.

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

Есть конкретные виды IR, или «формы», которые вы можете создать с помощью IR для упрощения оптимизаций. Например, SSA — Static Single Assignment, единственное статическое присваивание, при котором каждая переменная присваивается лишь один раз.

В Go перед генерированием кода строится IR на основе SSA. IR в LLVM основан на SSA, чтобы обеспечить его оптимизации.

Изначально SSA предоставляет несколько оптимизаций, к примеру, подстановка констант (constant propagation), исключение нерабочего кода и (очень важное) распределение регистров.

Распределение регистров

Это не требование для генерирования кода, а оптимизация. Одна абстракция, которую мы считаем данностью, заключается в том, что мы можем определять столько переменных, сколько нужно нашим программам. Однако в ассемблере нам доступно конечное количество регистров (обычно от 16 до 32), которые нужно держать в голове, или мы можем воспользоваться стеком (spill to the stack).

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

Есть несколько алгоритмов распределения регистров:

  • Раскрашивание графов (graph colouring) — вычислительно сложен (NP-полная задача). Требуется представлять код в виде графа, чтобы вычислять диапазон жизни (liveness ranges) переменных.
  • Линейное сканирование — просматривает переменные и определяет их диапазоны жизни.

О чём нужно помнить

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

Искажение имён (Name Mangling)

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

fn main() int {
  let x = 0;
  {
    let x = 0;
    {
      let x = 0;
    }
  }
  return 0;
}

Например, в этом коде (если переменные каким-либо образом не оптимизированы :) ) вам придётся искажать имена этих символов, чтобы в ассемблере они не конфликтовали. Также искажение обычно используется для обозначения типа информации, или оно может содержать информацию о пространстве имён.

Отладочная информация

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

Интерфейс внешних функций (Foreign Function Interface, FFI)

Обычно от libc никуда не деться, вам нужно почитать об этой библиотеке и подумать, как встроить её в свой язык. Как вы подключитесь к коду на С, или как вы откроете свой код для С?

Линкер

Написание линкера — отдельная задача. Когда ваш компилятор генерирует код, то он генерирует машинные инструкции (в файл .s/.asm)? Он пишет код напрямую в файл объекта? Например, в языке программирования Jai весь код предположительно пишется в один файл объекта. Существуют разные варианты, для которых характерны свои компромиссы.

Компилятор как сервис (CaaS)

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

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

Мало какие production-компиляторы используются подход CaaS. На память приходит Microsofts Roslyn, хотя я мало знаю об этом компиляторе, так что изучите его самостоятельно. И я могу ошибаться, но, похоже, во многих компиляторах реализован этот подход, но их авторы пишут API-машруты, которые подключаются к существующим компиляторам, например, в Rust есть RLS.

В моём языке Krug — который ещё активно разрабатывается и работает неустойчиво — в компиляторе Caasper используется CaaS-архитектура.

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

Фронтенд для Krug реализован на JavaScript, хотя будут и альтернативные реализации на Go*, а также, надеюсь, на самом Krug. JavaScript был выбран за его доступность, его можно скачать с очень популярными менеджерами пакетов yarn/npm.

* Изначально фронтенд был написан на Go и оказался (ожидаемо) значительно быстрее, чем вариант на JS.

Исходный код компилятора Caasper лежит здесь. В моём личном Github лежит прототип Krug, он написан на D и компилируется в LLVM. Также можете посмотреть демо на моём YouTube-канале.

Руководство по Krug (промежуточное) лежит здесь.

Полезные ссылки

  • Jack Crenshaw — моя личная дверь в мир реализации языков программирования.
  • Crafting Interpreters
  • Введение в LLVM (с Go) — я!
  • PL/0
  • The Dragon Book — классическая книга, в которой есть всё.
  • 8cc

A compiler is software that translates or converts a program written in a high-level language (Source Language) into a low-level language (Machine Language). Compiler design is the process of developing a program or software that converts human-written code into machine code. It involves many stages like lexical analysis, parsing, semantic analysis, code generation, optimization, etc. The Key objective of compiler design is to automate the translation process, the correctness of output, and reporting errors in source code. The compiler is used by programming languages such as C, C++, C#, Java, etc.

In this compiler design tutorial, all the basic to advanced topics are included like lexical analysis, code generation, and optimization, runtime environment, etc.

Why do we learn compiler design?

A computer is a logical assembly of software and hardware. The hardware understands a language that is hard for humans to understand. So, we write programs in a high-level language, that is less complex for humans to understand and maintain in thoughts. Now, a series of transformations have been applied to high-level language programs to convert them into machine language.

Recent Articles on Compiler Design !

  • Introduction
  • Lexical Analysis
  • Syntax Analysis
  • Syntax Directed Translation
  • Code Generation and Optimization
  • Runtime Environments
  • Quick Links

Introduction :

  1. Introduction of Compiler design
  2. Compiler construction tools
  3. Phases of a Compiler
  4. Symbol Table in Compiler
  5. C++ Program to implement Symbol Table
  6. Error detection and Recovery in Compiler
  7. Error Handling in Compiler Design
  8. Language Processors: Assembler, Compiler and Interpreter
  9. Generation of Programming Languages

Lexical Analysis :

Syntax Analysis :

  1. Introduction to Syntax Analyses
  2. Why FIRST and FOLLOW?
  3. FIRST Set in Syntax Analyses
  4. FOLLOW Set in Syntax Analyses
  5. Program to calculate First and Follow sets of given grammar
  6. Classification of Context Free Grammars(CFG)
  7. Ambiguous Grammar
  8. Parsing | Set 1 (Introduction, Ambiguity and Parsers)
  9. Classification of top down parsers
  10. Parsing | Set 2 (Bottom Up or Shift Reduce Parsers)
  11. Shift Reduce Parser in Compiler
  12. Parsing | Set 3 (SLR, CLR and LALR Parsers)
  13. Theory of Computation | Operator grammar and precedence parser

Syntax Directed Translation :

Code Generation and Optimization :

Runtime Environments :

  1. Static and Dynamic Scoping
  2. Compiler Design | Runtime Environments
  3. Compiler Design | Linker
  4. Loader in C/C++
  5. Developing a Linux based shell

FAQs on Compiler Design

Q.1 Write types of compilers?

Answer:

There are three types of compilers given below:

  1. Single-Pass Compilers
  2. Two-Pass Compilers
  3. Multi-pass Compilers

Q.2 Difference between compiler and assembler?

Answer:

Compiler Assembler
Compiler converts the source code which is written by the programmer to machine level language. Assembler converts the assembly code into the machine code.
Compiler converts the whole code into machine code. Assembler converts the code one by one.
It takes less execution time in conversion compared to an assembler. It takes more time than a compiler.
Input is the source code in a high-level language. Assembly level code as an input.
Examples: C, C++, Java compilers, etc. Examples: GAS, GNU assemblers.

Q.3 Discuss the various phases of a compiler?

Answer:

The various phases of the compiler are given below:

  • Lexical Analyzer
  • Syntax Analyzer
  • Semantic Analyzer
  • Intermediate code generator
  • Code optimizer
  • Code generator

Q.4 What are assembler?

Answer:

Assembler is a program that interprets assembly language written software programs into machine language that is known to the computer.

Quick Links :

Кратчайшее введение в создание компилятора

Давайте сделаем компилятор арифметических выражений. Такой, который переведет исходный текст в обратной польской форме записи (ее еще называют RPN или ПОЛИЗ) в промежуточный код стековой машины. Но у нас не будет интерпретатора стекового кода, как вы, возможно, могли подумать. Далее мы сразу переведем результат в представление на языке Си. То есть у нас получится компилятор RPN в Си.

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

Реализация на F# (автор gsomix):
первая версия,
SSA-версия

Начнем с синтаксического анализа

def scan(source):
    tokens = source.split()
    return [(('Push', int(x)) if x[0].isdigit() else ('Op', x))
            for x in tokens]

Что мы здесь сделали? Функция scan получает от пользователя строку в обратной польской форме записи («2 2 +»).
А на выходе мы получаем промежуточное представление. Вот такое, например:

[('Push', 2), ('Push', 2), ('Op', '+')]

Итак, мы уже получили компилятор. Но уж очень он несерьезный. Вспомним, что изначально речь шла о коде на Си.

Займемся трансляцией в Си

def trans(ir):
    code = []
    for (tag, val) in ir:
        if tag == 'Push':
            code.append('st[sp] = %d;' % val)
            code.append('sp += 1;')
        elif tag == 'Op':
            code.append('st[sp - 2] = st[sp - 2] %s st[sp - 1];' % val)
            code.append('sp -= 1;')
    return 'n'.join(code)

Что здесь происходит? Давайте посмотрим на вывод данной функции (на том же примере с «2 2 +»).

st[sp] = 2;
sp += 1;
st[sp] = 2;
sp += 1;
st[sp - 2] = st[sp - 2] + st[sp - 1];
sp -= 1;

Да, это уже похоже на код на Си. Массив st играет роль стека, а sp — его указатель. Обычно с этими вещами работают виртуальные стековые машины.
Вот только самой машины — интерпретатора у нас-то и нет. Есть компилятор. Что нам осталось? Надо добавить необходимое обрамление для программы на Си.

Наш первый компилятор в готовом виде

ST_SIZE = 100
C_CODE = r"""#include <stdio.h>
int main(int argc, char** argv) {
int st[%d], sp = 0;
%s
printf("%%dn", st[sp - 1]);
return 0;
}"""


def scan(source):
    tokens = source.split()
    return [(('Push', int(x)) if x[0].isdigit() else ('Op', x))
            for x in tokens]


def trans(ir):
    code = []
    for (tag, val) in ir:
        if tag == 'Push':
            code.append('st[sp] = %d;' % val)
            code.append('sp += 1;')
        elif tag == 'Op':
            code.append('st[sp - 2] = st[sp - 2] %s st[sp - 1];' % val)
            code.append('sp -= 1;')
    return 'n'.join(code)


def rpn_to_c(source):
    return C_CODE % (ST_SIZE, trans(scan(source)))


print rpn_to_c('2 2 +')

Остается скомпилировать вывод данной программы компилятором Си.

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

Компилятор с использованием формы SSA

Вам нравится заголовок? SSA — это звучит очень солидно для любого компиляторщика. А мы уже сейчас будем использовать эту самую SSA. Что это такое? Давайте двигаться по порядку.

Мы генерируем в данный момент код на Си, безо всяких виртуальных машин. Но зачем нам тогда рудимент в виде операций со стеком? Давайте заменим эти операции работой с обычными переменными из Си. Причем, мы не будем экономить переменные — для каждого выражения заведем новое имя. Пусть компилятор Си сам со всем этим разбирается. Получается, что у нас каждой переменной значение присваивается лишь однажды. А это, кстати говоря, и есть форма SSA.

Вот наш новый компилятор.

C_CODE = r"""#include <stdio.h>
int main(int argc, char** argv) {
%s
printf("%%dn", %s);
return 0;
}"""


def scan(source):
    tokens = source.split()
    return [(('Push', int(x)) if x[0].isdigit() else ('Op', x))
            for x in tokens]


def trans(ir):
    (stack, code) = ([], [])
    name_cnt = 0
    for (tag, val) in ir:
        if tag == 'Push':
            code.append('int t%d = %d;' % (name_cnt, val))
            stack.append('t%d' % name_cnt)
            name_cnt += 1
        elif tag == 'Op':
            (a, b) = (stack.pop(), stack.pop())
            code.append('int t%d = %s %s %s;' % (name_cnt, b, val, a))
            stack.append('t%d' % name_cnt)
            name_cnt += 1
    return ('n'.join(code), stack.pop())


def rpn_to_c(source):
    return C_CODE % trans(scan(source))


print rpn_to_c('2 2 +')

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

Вот окончательный результат:

#include <stdio.h>
int main(int argc, char** argv) {
int t0 = 2;
int t1 = 2;
int t2 = t0 + t1;
printf("%dn", t2);
return 0;
}

Похоже, пришла пора расширять возможности нашего входного языка, как вы считаете? И двигаться, на мой взгляд, здесь можно в направлении стековых языков, таких как Forth, Postscript, Joy или Factor. Конечно же, можно и пойти путем усложнения входного синтаксиса. Но все эти вопросы давайте оставим для следующих заметок. Успехов в построении компиляторов!

В этой главе описываются основные сведения о наборе компиляторов GCC и приводятся примеры для демонстрации их работы. Для получения дополнительной информации о GCC выполните команду man gcc.

[[toc]]

Обзор

GNU Compiler Collection (GCC) — это набор мощных и высокопроизводительных многоплатформенных компиляторов для различных языков программирования, разработанный в рамках проекта GNU. Компиляторы GCC компилируют и компонуют программы-источники, программы сборки и целевые программы на языках C и C++ в исполняемые файлы. По умолчанию в ОС openEuler установлен пакет программного обеспечения GCC.

Основные сведения

Тип файла

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

Табл. 1 Стандартные типы файлов GCC

Расширение (суффикс) Описание
.c Файл с исходным кодом на языке C.
.C,.cc или .cxx Файл с исходным кодом на языке C++.
.m Файл с исходным кодом на языке Objective-C.
.s Файл с исходным кодом на языке Ассемблер.
.i Файл с предварительно обработанным исходным кодом на языке C.
.ii Файл с предварительно обработанным исходным кодом на языке C++.
.S Файл с предварительно обработанным исходным кодом на языке Ассемблер.
.h Файл заголовка, содержащийся в программе.
.o Целевой файл после компиляции.
.so Динамическая библиотека ссылок в виде специального целевого файла.
.a Библиотека статических ссылок.
.out Исполняемые файлы, не имеющие фиксированного суффикса. Система отличает исполняемые файлы от неисполняемых файлов по их атрибутам. Если имя исполняемого файла не указано, GCC генерирует файл с именем a.out.

Процесс компиляции

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

  1. Предварительная обработка: данная операция выполняется с исходной программой (например, файлом .c) с целью создания файла .i.
  2. Компиляция: предварительно обработанный код файла .i компилируется на языке ассемблера и создается файл .s.
  3. Сборка: сборка файла на языке ассемблера и создание целевого файла .o.
  4. Связывание: файлы .o каждого модуля связываются, и создается исполняемый файл программы.

Файлы .i, .s и .o являются промежуточными или временными файлами. Если компилятор GCC используется также и для компиляции программ на языке C, эти файлы удаляются.

Параметры команды компиляции

Формат команды компиляции, выполняемой с помощью GCC: gcc [options] [filenames]

Параметры:

options: параметры компиляции.

filenames: имя файла.

GCC — это мощный компилятор. В компиляторе предусмотрен ряд параметров, указываемых в поле options, но большинство из них используются редко. В Табл. 2 приведено описание часто используемых параметров.

Табл. 2 Часто используемые параметры GCC

Параметр Описание Пример
-c Компиляция и сборка указанных исходных файлов с целью создания целевых файлов без их связывания. Обычно этот параметр используется для компиляции файлов подпрограмм. # В приведенном примере с использованием параметра -c компилируются исходные файлы test1.c и test2.c.
gcc -c test1.c test2.c
-S Компиляция указанных исходных файлов с целью создания файла на языке ассемблера с суффиксом .s, но без сборки. # В приведенном примере компилятор используется для предварительной обработки circle.c, его трансляции на язык ассемблера и сохранения результата в файле circle.c.
gcc -S circle.c
-E Предварительная обработка указанных исходных файлов без их компиляции.
По умолчанию выходные данные команды, выполняемой препроцессором, импортируются в стандартный поток результатов, например, для отображения на дисплее. Для импорта в файл выходных данных можно использовать параметр -o.
# В приведенном примере результаты предварительной обработки экспортируются в файл circle.i.
gcc -E circle.c -o circle.i
-o файл Генерация указанного файла с выходными данными команды при создании исполняемого файла. Имя должно отличаться от имени исходного файла. Если этот параметр не указан, GCC генерирует предустановленный исполняемый файл a.out. # В приведенном примере исходный файл используется в качестве входного файла, а исполняемый файл в качестве выходного файла. То есть, компилируется вся программа.
gcc main.c func.c -o app.out
-g Данный параметр содержит стандартную информацию отладки в исполняемых программах.
-L путь_к библиотеке Данный параметр добавляет путь к библиотеке в список путей для поиска файлов библиотеки.
-l путь_к библиотеке Поиск указанной библиотеки функций во время операции связывания.
Если компилятор GCC используется для компиляции и связывания программ, GCC по умолчанию привязывает библиотеку libc.a или libc.so. Однако другие библиотеки (например, нестандартные библиотеки и библиотеки сторонних разработчиков) добавляются вручную.
# В приведенном примере параметр -l используется для связывания математической библиотеки.
gcc main.c -o main.out -lm
ПРИМЕЧАНИЕ:
Имя файла математической библиотеки — libm.a. Префикс lib и суффикс .a являются стандартными, а m — это базовое имя. GCC автоматически добавляет эти префиксы и суффиксы к базовому имени после параметра -l. В этом примере используется базовое имя m.
-I путь_к заголовку Добавляет путь к заголовку в список путей поиска файла заголовка.
-static Данный параметр служит для выполнения статической компиляции и связывания статических библиотек. Не используется для связывания динамических библиотек.
-shared Параметр по умолчанию, который может быть пропущен.
Можно создать динамический файл библиотеки.
Во время динамической компиляции связывается преимущественно динамическая библиотека. Статическая библиотека с тем же именем связывается только при отсутствии динамической библиотеки.
-fPIC(или -fpic) Генерация независимого от местоположения целевого кода, для которого используется относительный адрес. Как правило, параметр -static используется для создания файла динамической библиотеки из целевого файла PIC.

Компиляция нескольких файлов

Компиляция нескольких исходных файлов выполняется двумя способами.

  • Одновременно компилируются несколько исходных файлов. Во время компиляции все файлы проходят повторную компиляцию.

    Пример. В этом примере файлы test1.c и test2.c компилируются и привязываются к исполняемому файлу test.

    $ gcc test1.c test2.c -o test
    
  • Компилируется отдельно каждый исходный файл и затем связываются целевые файлы, сгенерированные после компиляции. Повторному процессу компиляции подвергаются только измененные файлы.

    В приведенном примере компилируются файлы test1.c и test2.c и далее целевые файлы test1.o и test2.o привязываются к исполняемому файлу test.

    $ gcc -c test1.c
    $ gcc -c test2.c
    $ gcc -o test1.o test2.o -o test
    

Библиотеки

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

Имя файла библиотеки имеет префикс lib и суффикс .so (динамическая библиотека) или .a (статическая библиотека). Между префиксом и суффиксом пользователь указывает имя файла библиотеки, например libfoo.so или libfoo.a. Поскольку все файлы библиотеки соответствуют одним спецификациям, то если параметром -l задать имя связанного файла библиотеки, префикс lib можно опустить. То есть, когда GCC обрабатывает -lfoo, автоматически связывается файл библиотеки libfoo.so или libfoo.a. При создании библиотеки необходимо указать полное имя файла libfoo.so или libfoo.a.

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

  • Отличается объем занимаемых ресурсов.

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

  • Отличаются характеристики масштабируемости и совместимости.

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

  • Отличаются зависимости.

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

  • Отличаются скорости загрузки.

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

Библиотека динамических ссылок

Используя параметры -shared и -fPIC, можно создать библиотеку динамических ссылок (DLL) для связывания с исходным файлом, файлом сборки или целевым файлом. На этапе компиляции используется параметр -fPIC. Этот параметр требуется для генерации независимого от местоположения кода во время создания целевого файла.

Пример 1. В данном примере библиотека DLL создается из исходного файла.

$ gcc -fPIC -shared test.c -o libtest.so

Пример 2. В данном примере библиотека DLL создается из целевого файла.

$ gcc -fPIC -c test.c -o test.o
$ gcc -shared test.o -o libtest.so

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

В данном примере файлы main.c и libtest.so компилируются в app.out. Библиотека ссылок libtest.so динамически загружается во время исполнения файла app.out.

$ gcc main.c libtest.so -o app.out

В этом режиме используется файл libtest.so в текущем каталоге.

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

  • Сохраните библиотеку DLL в стандартном каталоге, например /usr/lib.

  • Добавьте путь libaryDIR к библиотеке DLL к переменной среды LD_LIBRARY_PATH.

    $ export LD_LIBRARY_PATH=libraryDIR:$LD_LIBRARY_PATH

    ПРИМЕЧАНИЕ:
    LD_LIBRARY_PATH — это переменная среды библиотеки DLL. Данная переменная указывается, если DLL находится в других каталогах, а не в каталогах по умолчанию (/lib и /usr/lib).

  • Добавьте путь libaryDIR к библиотеке DLL в файл /etc/ld.so.conf и выполните команду ldconfig или используйте путь libaryDIR к библиотеке DLL в качестве параметра для выполнения ldconfig.

$ gcc main.c -L libraryDIR -ltest -o app.out
$ export LD_LIBRARY_PATH=libraryDIR:$LD_LIBRARY_PATH

Библиотека статических ссылок

Чтобы создать библиотеку статических ссылок (SLL), необходимо скомпилировать исходный файл в целевой файл, а затем выполнить команду ar, чтобы сжать целевой файл в SLL.

В данном примере компилируются и сжимаются исходные файлы test1.c, test2.c и test3.c в библиотеку SLL.

$ gcc -c test1.c test2.c test3.c
$ ar rcs libtest.a test1.o test2.o test3.o

Команда ar используется для сжатия резервных копий. Можно сжать несколько файлов в один резервный файл (также называемый архивным файлом) или извлечь файлы-участники из резервного файла. Чаще всего команда ar используется для сжатия целевых файлов в SLL.

Формат команды ar для сжатия целевых файлов в SLL:

ar rcs Sllfilename Targetfilelist

  • Sllfilename: имя файла библиотеки статических ссылок.
  • Targetfilelist: список целевых файлов.
  • r: заменяет существующий целевой файл в библиотеке или добавляет новый целевой файл.
  • c: создает библиотеку независимо от ее существования.
  • s: создает индекс целевого файла. Скорость более оптимальна при создании большой библиотеки.

В данном примере создается файл main.c для использования библиотеки SLL.

$ gcc main.c -L libraryDIR -ltest -o test.out

Параметр libraryDIR задает путь к библиотеке libtest.a.

Примеры

Пример использования GCC для компиляции программ на языке C

  1. Выполните команду cd, чтобы перейти в каталог хранения кода. В примере используется каталог ~/code. Команда выглядит следующим образом:

    $ cd ~/code 
    
  2. Скомпилируйте программу Hello World и сохраните ее в виде helloworld.c. Далее приведен пример с программой Hello World. Команда выглядит следующим образом:

    $ vi helloworld.c
    

    Пример кода:

    #include <stdio.h>    
    int main()    
    {    
           printf("Hello World!n");       
           return 0;    
    }
    
  3. Выполните следующую команду, чтобы скомпилировать код в каталог хранения кода:

    $ gcc helloworld.c -o helloworld
    

    Если ошибок нет, команда выполнена успешно.

  4. После завершения компиляции генерируется файл helloworld. Проверьте результат компиляции. Пример:

    $ ./helloworld
    Hello World!
    

Пример создания и использования библиотеки DLL с помощью компилятора GCC

  1. Выполните команду cd, чтобы перейти в каталог хранения кода. В примере используется каталог ~/code. В каталоге создайте подкаталоги src, lib и include для сохранения в них исходного файла, файла библиотеки DLL и файла заголовка соответственно.

    $ cd ~/code
    $ mkdir src lib include
    
  2. Выполните команду cd, чтобы перейти в каталог ~/code/src, и создайте две функции —add.c для добавления и sub.c для вычитания.

    $ cd ~/code/src
    $ vi add.c
    $ vi sub.c
    

    Пример кода add.c:

    #include "math.h"
    int add(int a, int b)
    {
            return a+b;
    }
    

    Пример кода sub.c:

    #include "math.h"
    int sub(int a, int b)
    {
            return a-b;
    }
    
  3. Скомпилируйте исходные файлы add.c и sub.c в DLL-библиотеку libmath.so и сохраните данную библиотеку в каталоге ~/code/lib.

    $ gcc -fPIC -shared add.c sub.c -o ~/code/lib/libmath.so
    
  4. Перейдите в каталог ~/code/include, создайте файл заголовка math.h и объявите его как файл заголовка функции.

    $ cd ~/code/include
    $ vi math.h
    

    Пример кода math.h:

    #ifndef __MATH_H_
    #define __MATH_H_
    int add(int a, int b);
    int sub(int a, int b);
    #endif
    
  5. Выполните команду cd, чтобы перейти в каталог ~/code/src, и создайте функцию main.c, которая будет инициировать операции добавления add() и вычитания sub().

    $ cd ~/code/src
    $ vi main.c
    

    Пример кода math.c:

    #include <stdio.h>
    #include "math.h"
    int main()
    {
            int a, b;
            printf("Please input a and b:n");
            scanf("%d %d", &a, &b);
            printf("The add: %dn", add(a,b));
            printf("The sub: %dn", sub(a,b));
            return 0;
    }
    
  6. Скомпилируйте main.c и libtest.so в код math.out.

    $ gcc main.c -I ~/code/include -L ~/code/lib -lmath -o math.out
    
  7. Добавьте путь к библиотеке DLL в переменную среды.

    $ export LD_LIBRARY_PATH=~/code/lib:$LD_LIBRARY_PATH
    
  8. Запустите на исполнение код math.out, выполнив следующую команду:

    $ ./math.out
    

    Выходные данные команды выглядят следующим образом:

    Please input a and b:
    9 2
    The add: 11
    The sub: 7
    

Пример создания и использования библиотеки SLL с помощью компилятора GCC

  1. Выполните команду cd, чтобы перейти в каталог хранения кода. В примере используется каталог ~/code. В каталоге создайте подкаталоги src, lib и include для сохранения в них исходного файла, файла библиотеки SLL и файла заголовка соответственно.

    $ cd ~/code
    $ mkdir src lib include
    
  2. Выполните команду cd, чтобы перейти в каталог ~/code/src, и создайте две функции —add.c для добавления и sub.c для вычитания.

    $ cd ~/code/src
    $ vi add.c
    $ vi sub.c
    

    Пример кода add.c:

    #include "math.h"
    int add(int a, int b)
    {
            return a+b;
    }
    

    Пример кода sub.c:

    #include "math.h"
    int sub(int a, int b)
    {
            return a-b;
    }
    
  3. Скомпилируйте исходные файлы add.c и sub.c в целевые файлы add.o и sub.o.

    $ gcc -c add.c sub.c
    
  4. Выполните команду ar, чтобы сжать целевые файлы add.o и sub.o в SLL-библиотеку libmath.a и сохраните эту библиотеку в каталоге ~/code/lib.

    $ ar rcs ~/code/lib/libmath.a add.o sub.o
    
  5. Перейдите в каталог ~/code/include, создайте файл заголовка math.h и объявите его как файл заголовка функции.

    $ cd ~/code/include
    $ vi math.h
    

    Пример кода math.h:

    #ifndef __MATH_H_
    #define __MATH_H_
    int add(int a, int b);
    int sub(int a, int b);
    #endif
    
  6. Выполните команду cd, чтобы перейти в каталог ~/code/src, и создайте функцию main.c, которая будет инициировать операции добавления add() и вычитания sub().

    $ cd ~/code/src
    $ vi main.c
    

    Пример кода math.c:

    #include <stdio.h>
    #include "math.h"
    int main()
    {
            int a, b;
            printf("Please input a and b:n");
            scanf("%d %d", &a, &b);
            printf("The add: %dn", add(a,b));
            printf("The sub: %dn", sub(a,b));
            return 0;
    }
    
  7. Скомпилируйте main.c и libmath.a в math.out.

    $ gcc main.c -I ~/code/include -L ~/code/lib -lmath -o math.out
    
  8. Запустите на исполнение код math.out, выполнив следующую команду:

    $ ./math.out
    

    Выходные данные команды выглядят следующим образом:

    Please input a and b:
    9 2
    The add: 11
    The sub: 7
    

Обложка: Подборка книг о компиляторах и обо всем, что с ними связано

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


Обложка книги Advanced Compiler Design and Implementation

Advanced Compiler Design and Implementation

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


Обложка книги Building an Optimizing Compiler

Building an Optimizing Compiler

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


Обложка книги GNU Compiler Collection (GCC) Internals

GNU Compiler Collection (GCC) Internals

Данная книга — официальное руководство от Free Software Foundation по «изнанке» компиляторов GNU. В нем содержатся следующие материалы: как поучаствовать в разработке компилятора GCC, разбор необходимых характеристик для хоста и целевой машины, поддерживаемых GCC, а также описание древовидной структуры исходного кода GCC и системы сборки. Сами авторы этой книги преподносят ее как справочник, в котором можно найти множество информации по внутренней структуре GCC.


Обложка книги Engineering a compiler

Engineering a compiler

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


Обложка книги Modern Compiler Implementation in ML

Modern Compiler Implementation in ML

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


Обложка книги Modern Compiler Implementation in C

Modern Compiler Implementation in C

Вторая книга из серии «Modern Compiler Implementation», содержащая в себе примеры кода на языке Си. Рассматриваются те же темы, что и в предыдущей книге.


Обложка книги Modern Compiler Implementation in Java

Modern Compiler Implementation in Java

Третья книга из серии «Modern Compiler Implementation», проиллюстрированная частями кода, написанного на языке Java.


Обложка книги Introduction to Compiler Design

Introduction to Compiler Design

Цель книги «Introduction to Compiler Design» — обучить читателя создавать компиляторы для простых языков программирования. Хоть материал руководства и не охватывает методы оптимизации компилируемого кода, зато в книге даются некоторые наработки, которые используются в реальных компиляторах и помогут лучше разобраться в самой их концепции. В руководстве освещаются все этапы, необходимые для компиляции кода, написанного на языке высокого уровня, в машинный язык: разделение кода на лексические составляющие, его разбор, создание промежуточного кода, генерация машинного кода и использование регистров. Примеры кода представлены на псевдоязыке.


Обложка книги Компиляторы. Принципы, технологии и инструментарий

Компиляторы. Принципы, технологии и инструментарий

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


Обложка книги Modern Compiler Design

Modern Compiler Design

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


Обложка книги Implementing Programming Languages: An Introduction to Compilers and Interpreters

Implementing Programming Languages: An Introduction to Compilers and Interpreters

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


Обложка книги The Compiler Design Handbook: Optimizations & Machine Code Generation

The Compiler Design Handbook: Optimizations & Machine Code Generation

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


Обложка книги Compiler Construction

Compiler Construction

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


Обложка книги The Formal Semantics of Programming Languages — An Introduction

The Formal Semantics of Programming Languages — An Introduction

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


Обложка книги Алгоритмические трюки для программистов

Алгоритмические трюки для программистов

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


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

Понравилась статья? Поделить с друзьями:

А вот и еще наши интересные статьи:

  • Уаз патриот 2005 инструкция по эксплуатации
  • Папаверин в таблетках инструкция по применению взрослым
  • Мед препарат румалон инструкция по применению цена отзывы
  • Театр моссовета руководство театра
  • Олимп 005 с 1 инструкция по ремонту

  • 0 0 голоса
    Рейтинг статьи
    Подписаться
    Уведомить о
    guest

    0 комментариев
    Старые
    Новые Популярные
    Межтекстовые Отзывы
    Посмотреть все комментарии