Что такое компилятор
Компилятор — это программа, преобразующая команды, созданные на языке программирования, в машинные кодов (понятных компьютерам). Или собирает разные модули в общий файл для исполнения алгоритмов последовательно.
Для какой цели нужен
Процессор компьютера интерпретирует входящую информацию и выполняет операции, но может понять только машинный код, состоящий из 0 и 1. Первые программы для вычисления записывались на перфокартах, где цифры обозначались отверстиями. Считывание программы занимало много времени, а процесс разработки был значительно сложнее, чем сейчас. Чтобы упростить работу программистов, были создали языки программирования и компиляторы, которые преобразуют близкий к человеческому язык программирования в машинный формат, понятный компьютеру.
Разница между компилятором и интерпретатором
Компилятор, интерпретатор и байт-код — разные способы перевода команд в набор нулей и единиц. Принцип работы компилятора описан выше.
Интерпретатор выполняют код построчно (без сборки модулей в файл). Языки программирования, основанные на работе интерпретаторов, называют интерпретируемыми. Байт-код — связующее звено между этими двумя видами. Его используют в языках, запускаемых в виртуальной машине, например, в Java.
Какие языки используют компиляторы
Языки могут быть компилируемыми или транслируемыми в байт-код. К примерам компилируемых относятся Pascal, Swift, C и C ++, Haskell, Rust, Lisp и Prolog, а транслируемые — C#, Java, Scala и семейство .NET.
Почему не всегда в одном языке один компилятор
Наличие нескольких компиляторов для одного языка программирования объясняется различиями в платформах, стандартах, функциях и вендорах. Разные компиляторы могут быть оптимизированы для разных платформ, реализовывать стандарты по-разному и иметь различные функции. Это полезно для разработчиков: они выбрать наиболее подходящий компилятор под конкретную задачу.
Недостатки компилируемых языков
Среди недостатков компилируемых языков программирования: сложность разработки, длительное время компиляции, ограниченную портируемость между платформами, сложность отладки и необходимость перекомпиляции при изменении кода программы.
Как работать с компилятором
Новички не работают с конвертерами кода напрямую, так как IDE (интегрированная среда разработки) автоматически запускает его. Компилятор полезен, когда нужно обойтись без среды разработки (например, командная строка). Стоит ознакомиться с документацией компоновщика до начала работы. Курсы программирования позволят Вам полностью разобраться в вопросе и стать пользующимся спросом IT-специалистом.
Elbrus Bootcamp
Вам может также понравиться.
Пайплайн в разработке
9 сент. 2023 г.
Итоги конкурса: 100 000 рублей на обучение от Эльбрус Буткемп
1 сент. 2023 г.
Процесс компиляции программ на C++
В данной статье я хочу рассказать о том, как происходит компиляция программ, написанных на языке C++, и описать каждый этап компиляции. Я не преследую цель рассказать обо всем подробно в деталях, а только дать общее видение. Также данная статья — это необходимое введение перед следующей статьей про статические и динамические библиотеки, так как процесс компиляции крайне важен для понимания перед дальнейшим повествованием о библиотеках.
Все действия будут производиться на Ubuntu версии 16.04.
Используя компилятор g++ версии:
$ g++ --version g++ (Ubuntu 5.4.0-6ubuntu1~16.04.9) 5.4.0 20160609
Состав компилятора g++
Мы не будем вызывать данные компоненты напрямую, так как для того, чтобы работать с C++ кодом, требуются дополнительные библиотеки, позволив все необходимые подгрузки делать основному компоненту компилятора — g++.
Зачем нужно компилировать исходные файлы?
Исходный C++ файл — это всего лишь код, но его невозможно запустить как программу или использовать как библиотеку. Поэтому каждый исходный файл требуется скомпилировать в исполняемый файл, динамическую или статическую библиотеки (данные библиотеки будут рассмотрены в следующей статье).
Этапы компиляции:
Перед тем, как приступать, давайте создадим исходный .cpp файл, с которым и будем работать в дальнейшем.
driver.cpp:
#include using namespace std; #define RETURN return 0 int main()
1) Препроцессинг
Самая первая стадия компиляции программы.
Препроцессор — это макро процессор, который преобразовывает вашу программу для дальнейшего компилирования. На данной стадии происходит происходит работа с препроцессорными директивами. Например, препроцессор добавляет хэдеры в код (#include), убирает комментирования, заменяет макросы (#define) их значениями, выбирает нужные куски кода в соответствии с условиями #if, #ifdef и #ifndef.
Хэдеры, включенные в программу с помощью директивы #include, рекурсивно проходят стадию препроцессинга и включаются в выпускаемый файл. Однако, каждый хэдер может быть открыт во время препроцессинга несколько раз, поэтому, обычно, используются специальные препроцессорные директивы, предохраняющие от циклической зависимости.
Получим препроцессированный код в выходной файл driver.ii (прошедшие через стадию препроцессинга C++ файлы имеют расширение .ii), используя флаг -E, который сообщает компилятору, что компилировать (об этом далее) файл не нужно, а только провести его препроцессинг:
g++ -E driver.cpp -o driver.ii
Взглянув на тело функции main в новом сгенерированном файле, можно заметить, что макрос RETURN был заменен:
int main()
В новом сгенерированном файле также можно увидеть огромное количество новых строк, это различные библиотеки и хэдер iostream.
2) Компиляция
На данном шаге g++ выполняет свою главную задачу — компилирует, то есть преобразует полученный на прошлом шаге код без директив в ассемблерный код. Это промежуточный шаг между высокоуровневым языком и машинным (бинарным) кодом.
Ассемблерный код — это доступное для понимания человеком представление машинного кода.
Используя флаг -S, который сообщает компилятору остановиться после стадии компиляции, получим ассемблерный код в выходном файле driver.s:
$ g++ -S driver.ii -o driver.s
driver.s
.file "driver.cpp" .local _ZStL8__ioinit .comm _ZStL8__ioinit,1,1 .section .rodata .LC0: .string "Hello, world!" .text .globl main .type main, @function main: .LFB1021: .cfi_startproc pushq %rbp .cfi_def_cfa_offset 16 .cfi_offset 6, -16 movq %rsp, %rbp .cfi_def_cfa_register 6 movl $.LC0, %esi movl $_ZSt4cout, %edi call _ZStlsISt11char_traitsIcEERSt13basic_ostreamIcT_ES5_PKc movl $_ZSt4endlIcSt11char_traitsIcEERSt13basic_ostreamIT_T0_ES6_, %esi movq %rax, %rdi call _ZNSolsEPFRSoS_E movl $0, %eax popq %rbp .cfi_def_cfa 7, 8 ret .cfi_endproc .LFE1021: .size main, .-main .type _Z41__static_initialization_and_destruction_0ii, @function _Z41__static_initialization_and_destruction_0ii: .LFB1030: .cfi_startproc pushq %rbp .cfi_def_cfa_offset 16 .cfi_offset 6, -16 movq %rsp, %rbp .cfi_def_cfa_register 6 subq $16, %rsp movl %edi, -4(%rbp) movl %esi, -8(%rbp) cmpl $1, -4(%rbp) jne .L5 cmpl $65535, -8(%rbp) jne .L5 movl $_ZStL8__ioinit, %edi call _ZNSt8ios_base4InitC1Ev movl $__dso_handle, %edx movl $_ZStL8__ioinit, %esi movl $_ZNSt8ios_base4InitD1Ev, %edi call __cxa_atexit .L5: nop leave .cfi_def_cfa 7, 8 ret .cfi_endproc .LFE1030: .size _Z41__static_initialization_and_destruction_0ii, .-_Z41__static_initialization_and_destruction_0ii .type _GLOBAL__sub_I_main, @function _GLOBAL__sub_I_main: .LFB1031: .cfi_startproc pushq %rbp .cfi_def_cfa_offset 16 .cfi_offset 6, -16 movq %rsp, %rbp .cfi_def_cfa_register 6 movl $65535, %esi movl $1, %edi call _Z41__static_initialization_and_destruction_0ii popq %rbp .cfi_def_cfa 7, 8 ret .cfi_endproc .LFE1031: .size _GLOBAL__sub_I_main, .-_GLOBAL__sub_I_main .section .init_array,"aw" .align 8 .quad _GLOBAL__sub_I_main .hidden __dso_handle .ident "GCC: (Ubuntu 5.4.0-6ubuntu1~16.04.9) 5.4.0 20160609" .section .note.GNU-stack,"",@progbits
Мы можем все также посмотреть и прочесть полученный результат. Но для того, чтобы машина поняла наш код, требуется преобразовать его в машинный код, который мы и получим на следующем шаге.
3) Ассемблирование
Так как x86 процессоры исполняют команды на бинарном коде, необходимо перевести ассемблерный код в машинный с помощью ассемблера.
Ассемблер преобразовывает ассемблерный код в машинный код, сохраняя его в объектном файле.
Объектный файл — это созданный ассемблером промежуточный файл, хранящий кусок машинного кода. Этот кусок машинного кода, который еще не был связан вместе с другими кусками машинного кода в конечную выполняемую программу, называется объектным кодом.
Далее возможно сохранение данного объектного кода в статические библиотеки для того, чтобы не компилировать данный код снова.
Получим машинный код с помощью ассемблера (as) в выходной объектный файл driver.o:
$ as driver.s -o driver.o
Но на данном шаге еще ничего не закончено, ведь объектных файлов может быть много и нужно их всех соединить в единый исполняемый файл с помощью компоновщика (линкера). Поэтому мы переходим к следующей стадии.
4) Компоновка
Компоновщик (линкер) связывает все объектные файлы и статические библиотеки в единый исполняемый файл, который мы и сможем запустить в дальнейшем. Для того, чтобы понять как происходит связка, следует рассказать о таблице символов.
Таблица символов — это структура данных, создаваемая самим компилятором и хранящаяся в самих объектных файлах. Таблица символов хранит имена переменных, функций, классов, объектов и т.д., где каждому идентификатору (символу) соотносится его тип, область видимости. Также таблица символов хранит адреса ссылок на данные и процедуры в других объектных файлах.
Именно с помощью таблицы символов и хранящихся в них ссылок линкер будет способен в дальнейшем построить связи между данными среди множества других объектных файлов и создать единый исполняемый файл из них.
Получим исполняемый файл driver:
$ g++ driver.o -o driver // также тут можно добавить и другие объектные файлы и библиотеки
5) Загрузка
Последний этап, который предстоит пройти нашей программе — вызвать загрузчик для загрузки нашей программы в память. На данной стадии также возможна подгрузка динамических библиотек.
Запустим нашу программу:
$ ./driver // Hello, world!
Заключение
В данной статье были рассмотрены основы процесса компиляции, понимание которых будет довольно полезно каждому начинающему программисту. В скором времени будет опубликована вторая статья про статические и динамические библиотеки.
Введение
Таким образом, то, что вы можете назвать языком программирования, на самом деле представляет собой просто программное обеспечение, называемое компилятором, которое читает текстовый файл, много обрабатывает его и генерирует двоичный файл.Поскольку компьютер может читать только 1 и 0, а люди пишут лучше, чем Rust, чем двоичные файлы, были созданы компиляторы, чтобы превратить этот читаемый человеком текст в читаемый компьютером.Машинный код,
Компилятором может быть любая программа, которая переводит один текст в другой. Например, вот компилятор, написанный на Rust, который превращает 0 в 1, а 1 в 0:
Что такое переводчик
интерпретаторы очень похожи на компиляторы в том, что они читают язык и обрабатывают его. Хотя,интерпретаторы пропускают генерацию кода и выполняют AST вовремя ,Самое большое преимущество для интерпретаторов — это время, необходимое для запуска вашей программы во время отладки. Компилятору может потребоваться от секунды до нескольких минут, чтобы скомпилировать программу перед выполнением, в то время как интерпретатор начинает выполнение немедленно, без компиляции. Самым большим недостатком переводчика является то, что он должен быть установлен на компьютере пользователя, прежде чем программа может быть выполнена.
Эта статья в основном относится к компиляторам, но должно быть ясно, как они различаются и как соотносятся компиляторы.
1. Лексический анализ
Первый шаг — разделить входные данные символ за символом. Этот шаг называется лексический анализ или токенизация. Основная идея заключается в том, чтомы группируем символы вместе, чтобы сформировать наши слова, идентификаторы, символы и многое другое.Лексический анализ в основном не имеет ничего общего с решением 2+2 — было бы просто сказать, что есть три жетоны: число: 2 , знак плюс, а затем еще один номер: 2 ,
Допустим, вы лексировали строку как 12+3 : это будет читать символы 1 , 2 , + , а также 3 , У нас есть отдельные персонажи, но мы должны сгруппировать их; одна из главных задач токенизатора. Например, мы получили 1 а также 2 как отдельные буквы, но нам нужно сложить их и проанализировать как одно целое число. + также должен быть признан как знак плюс, а не его буквальное значение символа — код символа 43.
Если вы можете видеть код и таким образом придавать ему большее значение, то следующий токенайзер Rust может сгруппировать цифры в 32-разрядные целые числа и знаки плюс в качестве Token ценность Plus
Rust Playground
play.rust-lang.org
Вы можете нажать кнопку «Выполнить» в верхнем левом углу Rust Playground, чтобы скомпилировать и выполнить код в вашем браузере.
В компиляторе для языка программирования лексеру может потребоваться несколько различных типов токенов. Например: символы, числа, идентификаторы, строки, операторы и т. Д. От самого языка зависит, какие именно токены вам нужно извлечь из исходного кода.
Дерево, которое генерирует парсер при разборе, называется абстрактное синтаксическое дерево или АСТ.AST содержит все операции. Парсер не вычисляет операции, он просто собирает их в правильном порядке.
Я добавил к нашему коду лексера ранее, чтобы он соответствовал нашей грамматике и мог генерировать AST, как на диаграмме. Я отметил начало и конец нового кода парсера с комментариями // BEGIN PARSER // а также // END PARSER // ,
Rust Playground
play.rust-lang.org
Мы можем пойти гораздо дальше. Скажем, мы хотим поддерживать входные данные, которые являются просто числами без операций, или добавлением умножения и деления, или даже добавлением приоритета. Это все возможно благодаря быстрой смене файла грамматики и настройке, чтобы отразить его внутри нашего кода синтаксического анализатора.
3. Генерация кода
генератор кода берет AST и испускает эквивалент в коде или сборке.Генератор кода должен перебирать каждый отдельный элемент в AST в порядке рекурсивного спуска — очень похоже на работу синтаксического анализатора — и затем выдавать эквивалент, но в коде.
Compiler Explorer — Rust (rustc 1.29.0)
pub fn main ()
godbolt.org
Если вы откроете ссылку выше, вы можете увидеть сборку, созданную в примере кода слева. Строки 3 и 4 кода сборки показывают, как компилятор генерировал код для констант, когда он встретил их в AST.
Godbolt Compiler Explorer — отличный инструмент, позволяющий писать код на языке программирования высокого уровня и видеть его сгенерированный код сборки. Вы можете поэкспериментировать с этим и посмотреть, какой код должен быть сделан, но не забудьте добавить флаг оптимизации в компилятор вашего языка, чтобы увидеть, насколько он умный ( -O для ржавчины)
Если вас интересует, как компилятор сохраняет локальную переменную в памяти в ASM, Эта статья (раздел «Генерация кода») объясняет стек в деталях. В большинстве случаев продвинутые компиляторы выделяют память для переменных в куче и сохраняют их там, а не в стеке, когда переменные не являются локальными. Вы можете прочитать больше о хранении переменных в этот ответ StackOverflow,
Поскольку сборка — это совершенно другой, сложный предмет, я не буду особо говорить об этом. Я просто хочу подчеркнуть важность и работу генератора кода. Кроме того, генератор кода может производить больше, чем просто сборка. Haxe компилятор имеет бэкенд который может генерировать более шести различных языков программирования; в том числе C ++, Java и Python.
Backend относится к генератору или оценщику кода компилятора; поэтому передний конец — это лексер и парсер. Существует также средний конец, который в основном связан с оптимизацией и IR, описанными далее в этом разделе. Задний конец в основном не связан с внешним интерфейсом и заботится только о AST, который он получает. Это означает, что можно использовать один и тот же бэкэнд для нескольких разных интерфейсов или языков. Это случай с пресловутым Коллекция компиляторов GNU ,
У меня не может быть лучшего примера генератора кода, чем у моего компилятора C; ты можешь найти это Вот ,
После того, как сборка произведена, она будет записана в новый файл сборки ( .s или .asm ). Затем этот файл будет передан через ассемблер, который является компилятором для сборки, и сгенерирует эквивалент в двоичном виде. Затем двоичный код будет записан в новый файл, называемый объектным файлом ( .o ).
Объектные файлы являются машинным кодом, но они не являются исполняемыми.Чтобы они стали исполняемыми, объектные файлы должны быть связаны друг с другом. Компоновщик берет этот общий машинный код и делает его исполняемым, общая библиотека или статическая библиотека,Подробнее о компоновщиках Вот ,
Линкеры — это служебные программы, которые различаются в зависимости от операционной системы. Один сторонний компоновщик должен иметь возможность компилировать объектный код, который генерирует ваш бэкэнд. При создании компилятора не должно быть необходимости создавать собственный компоновщик.
Компилятор может иметь промежуточное представление или IR.IR — это представление оригинальных инструкций без потерь для оптимизации или перевода на другой язык.IR не является исходным кодом; IR — это упрощение без потерь для поиска потенциальных оптимизаций в коде. Разматывание петли а также векторизации сделано с помощью ИК. Больше примеров оптимизации, связанной с ИК, можно найти в этот PDF,
Вывод
Когда вы понимаете компиляторы, вы можете более эффективно работать с языками программирования. Может быть, когда-нибудь вы захотите создать свой собственный язык программирования? Я надеюсь, что это помогло вам.
Ресурсы и дальнейшее чтение
- http://craftinginterpreters.com/ — поможет вам сделать переводчик в C и Java.
- https://norasandler.com/2017/11/29/Write-a-Compiler.html — вероятно, самый полезный для меня учебник по «написанию компилятора».
- Мой компилятор C и парсер научного калькулятора можно найти Вот а также Вот,
- Можно найти пример другого типа парсера, называемого парсером с повышением приоритета. Вот, Предоставлено: Уэсли Норрис.
При подготовке материала использовались источники:
https://elbrusboot.camp/blog/chto-takoie-kompiliator/
https://habr.com/ru/articles/478124/
https://machinelearningmastery.ru/understanding-compilers-for-humans-version-2-157f0edb02dd/