Компилятор транслирует исходный код программы в машинный код, который понимает компьютер. Раньше говорили, что это нули и единицы — байты. Однако это практически невозможно интерпретировать человеком, поэтому с «бородатых» времен появился assembler. У компиляции в Rust есть особенность: она не только транслирует код, но и проверяет безопасность работы с памятью ещё до запуска программы. Для этого Rust использует ownership model, borrow checker, lifetime analysis и многоуровневую систему промежуточных представлений на базе LLVM (Low Level Virtual Machine).
Рассказываем про Rust-компилятор, разбор промежуточных представлени, проверку времени жизни переменных и полный дешугаринг кода.
Мы — инженеры «Фалькон Тех». Наша команда уже 8 лет разрабатывает решения в сфере видеоаналитики для умных городов и внедряет ИИ и машинное зрение. Для фронтенда мы используем ReactJS, TypeScript, а весь бэк у нас написан на Rust.
За это время мы создали систему из более чем 4 000 программно-аппаратных комплексов в Москве, поэтому можем рассказать о практических решениях и лайфхаках для разработчиков.
Начнем с того, что вообще делает компилятор.
Что делает компилятор: классическая схема
Компилятор — это программа, которая преобразует исходный код в представление, интерпретируемое машиной. Он создаёт исполняемый файл — набор инструкций для компьютера, чтобы тот мог понимать даже высокоуровневые языки разработки.
Исторически компиляторы были простыми: брали код на одном уровне абстракции и отображали его в другой. Но их роль поменялась.
Современный компилятор решает много дополнительных задач:
-
Ускоряет сборку проекта, например, поддерживает инкрементальную сборку, когда пересобираются только измененные части кода (это есть и в Rust).
-
Помогает встроить отладочную информацию, чтобы можно было шаг за шагом просматривать выполнение программы.
-
Исключает многие виды неопределённого поведения за счёт системы владения, borrow checker и строгой типизации.
-
Оптимизирует программы для лучшей производительности, увеличивая скорость выполнения и изменяя размер исполняемого файла.
Как устроен конвейер компиляции
Чтобы превратить код в исполняемый файл, компилятор проходит несколько этапов.
Они складываются в общую цепочку: лексический анализ → синтаксический анализ → семантический анализ → генерация IR → оптимизация → кодогенерация.
Разберём классический пример по порядку.
-
Лексический анализ. Компилятор получает сырые символы исходного кода и преобразует их в токены.
Например, выражение: position = a + b * 2 после лексического анализа разбивается на токены: position, =, a, +, b, *, 2.
Токены записываются в таблицу символов со всеми переменными и операторами. Кстати, благодаря таблицам можно сохранить отладочную информацию (debug info) или убрать её при сборке, указав соответствующие флаги.
-
Синтаксический анализ. Компилятор проверяет, соответствует ли код грамматике языка.
Если написать position ==, вместо position =, компилятор «даст по рукам».
На этом этапе строится синтаксическое дерево (AST, Abstract Syntax Tree). Оно отражает структуру программы: какие операции выполняются и как они связаны.
-
Семантический анализ. Компилятор переходит от структуры к смыслам.
Обработка над AST дополняется семантическим контекстом — типами переменных, областью видимости, зависимостями. На этом же шаге выполняется type inference (вывод типов).
-
Генерация IR (Intermediate Representation). Почти все компиляторы используют промежуточное представление (IR) — псевдокод, который проще анализировать. Они различаются именно по тому, какой IR и для каких задач создают.
IR нужен, чтобы отделить фронтенд (разбор исходного кода) от бэкенда (генерации машинного кода).
Например, из исходного AST можно получить IR, где каждое подвыражение записано в отдельную переменную. Это удобнее для машины и устраняет неоднозначность.
|
t1=int-to-float (60) t2=id3+t1 t3=id2+t2 id=t3 |
Иногда процесс идет в обратную сторону: IR строится из бинарного кода. Так работает динамическая инструментация, например, Google Sanitizers.
5. Машинно-независимая оптимизация. Когда компилятор получает IR, он начинает оптимизировать код без привязки к конкретной архитектуре.
Компилятор удаляет ненужные переменные, объединяет выражения, анализирует потоки данных. За счёт этого программа работает быстрее и занимает меньше места.
6. Кодогенерация. Промежуточное представление преобразуется в набор понятных процессору инструкций. На этом этапе допустима дополнительная оптимизация.
Что отличает Rust-компилятор: особый путь
Компиляция в Rust идет почти по классической схеме. Исходный код проходит те же этапы: лексический, синтаксический, семантический анализ, генерацию IR и оптимизацию.
Но у Rust есть две особенности:
-
Rust — не самостоятельный компилятор, а фронтенд над LLVM
Коротко: Rust строит план, LLVM его реализует.
Rust-компилятор построен поверх LLVM (Low Level Virtual Machine) — мощного SDK для создания компиляторов. LLVM предоставляет инструменты для:
-
генерации и оптимизации машинного кода;
-
анализа и трансформации промежуточных представлений;
-
поддержки множества архитектур процессов;
-
неимоверного количества всего остального.
Для описания компилятора есть два термина: backend и frontend.
Компилятор Rust — фронтенд. Он отвечает за разбор исходного кода, построение AST, Borrow Checker и генерацию промежуточных представлений. LLVM выполняет бэкенд. Она отвечает за оптимизацию и финальную генерацию машинного кода.
-
-
Rust — не самостоятельный компилятор, а фронтенд над LLVM
Как многие другие компиляторы, в процессе генерации IR Rust-компилятор не ограничивается одним Intermediate Representation и проходит три подэтапа.
Этап 1: HIR — High-Level IR
После построения AST-дерева компилятор создает High-Level Intermediate Representation. Получается та же структура, только с аннотациями, типами и контекстом. HIR решает задачи, связанные с Type Inference, Type Solving и Trait Solving. Он дополняет текущие AST необходимыми для следующего уровня данными.
Этап 2: THIR — Typed HIR
THIR преобразуется в MIR (Mid-Level Intermediate Representation). MIR ближе к машинному коду, хотя он остаётся достаточно абстрактным. MIR выполняет:
-
Мономорфизацию (monomorphization). Разворачивает дженерик-функции в конкретные типы;
-
World checking и lifetime analysis. Анализирует области видимости, времени жизни переменных и контекстных блоков код;
-
Data-flow-анализы. Проверяет код и делает Rust безопасным на уровне языка.
Другие оптимизации, включая dead code elimination, inlining и низкоуровневые улучшения, передаются LLVM.
В таблице собрали главное о промежуточных представлениях (IR) в Rust:
|
Уровень |
Назначение |
Ключевые задачи |
|
HIR (High-level IR) |
Высокоуровневая структура программы |
Type Inference, Type Solving и Trait Solving |
|
THIR (Typed HIR) |
Типизированная высокоуровневая структура программы |
Borrow Checker, проверка ссылок и корректности заимствований |
|
MIR (Mid-level IR) |
Приближенная к машинному коду среднеуровневая структура программ |
Мономорфизация, world checking, lifetime analysis, data-flow analysis |
Пример. Как Rust и LLVM делят работу
Мы написали простейшую программу, которая записывает две переменные и складывает их методом ‘add’.
Зачем? Было интересно посмотреть, как на каждом этапе промежуточного представления Rust-компилятор решает вопрос с дженериками и статическим полиморфизмом.
Затем идёт Typed HIR:
Компилятор выполняет финальные проверки типов и их разрешение (Type Solving и Type Inference).
Что происходит: каждое выражение, переменная и операция в коде получает свой тип. Если на предыдущих уровнях компилятор оперировал обобщёнными (generic) типами, то на THIR он фиксирует конкретные типы. Например, размечает так:
|
«Эта переменная — Integer, а этот метод будет сгенерирован только для Integer». |
Так компилятор готовит основу для мономорфизации, когда функции и структуры разворачиваются в реализации для каждого типа.
Компилятор строит IR, где операция x + y типизирована, создаёт список неявных значений и аргументов форматирования и в итоге вызывает print.
Mid-Level IR ближе к LLVM IR. Здесь происходит «полный дешугаринг кода» — синтаксический сахар (удобные конструкции языка вроде for, in range, unless) превращается в более примитивные операции.
До MIR код был в том виде, который не понимает машина, но может прочитать, проанализировать и улучшить человек. После — код становится понятным для машины, но менее читабельным для человека.
На уровне MIR выполняются ключевые проверки на безопасность кода (Borrow Checker и Lifetime Variables).
После MIR программа переходит к генерации LLVM IR.
Это уже язык, понятный универсальному слою, который знает, как превратить этот код в машинный бинарник. Если заглянуть, можно слегка испугаться, потому что LLVM IR уже содержит длинные инструкции, низкоуровневые операции, SSA-переменные.
Безопасность встроена в компиляцию: уникальный Rust
Исходный код на Rust в процессе компиляции проходит множество этапов преобразования — от высокоуровневых представлений к низкоуровневым, в результате которого получается семантически максимально схожее отображение.
Как и другие современные компиляторы, Rust преобразует код через несколько промежуточных представлений, а завершающие шаги и генерацию машинного кода выполняет LLVM backend. Плюс ко всему, он проверяет время жизни переменных, ссылки и заимствования. Использует мономорфизацию, world checking, lifetime analysis и data-flow analysis.
Отличительная черта Rust-компилятора в том, что он может гарантировать корректность работы программы до запуска. Он снимает часть ответственности с разработчика.
По нашему опыту, понимание устройства компилятора помогает писать более эффективный код. Потому что это помогает разобраться в том, как строгость языка делает код безопасным и предсказуемым. Существуют и свои недостатки, например, невысокая скорость самой разработки. Есть что обсудить, а как считаете Вы?
Если хотите строить бэкенд на Rust и изучать уникальные фичи этого языка с нами, заглядывайте на Хабр Карьеру — там мы публикуем вакансии и зовем в команду новые умы.
ссылка на оригинал статьи https://habr.com/ru/articles/1059550/