Как понять, что делает Rust-компилятор: визуализация AST, MIR и LLVM IR

от автора

Компилятор транслирует исходный код программы в машинный код, который понимает компьютер. Раньше говорили, что это нули и единицы — байты. Однако это практически невозможно интерпретировать человеком, поэтому с «бородатых» времен появился 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 → оптимизация → кодогенерация.

Разберём классический пример по порядку.

  1. Лексический анализ. Компилятор получает сырые символы исходного кода и преобразует их в токены.

    Например, выражение: position = a + b * 2 после лексического анализа разбивается на токены: position, =, a, +, b, *, 2.

    Токены записываются в таблицу символов со всеми переменными и операторами. Кстати, благодаря таблицам можно сохранить отладочную информацию (debug info) или убрать её при сборке, указав соответствующие флаги.

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

    Если написать position ==, вместо position =, компилятор «даст по рукам».

    На этом этапе строится синтаксическое дерево (AST, Abstract Syntax Tree). Оно отражает структуру программы: какие операции выполняются и как они связаны.

  3. Семантический анализ. Компилятор переходит от структуры к смыслам.

    Обработка над AST дополняется семантическим контекстом — типами переменных, областью видимости, зависимостями. На этом же шаге выполняется type inference (вывод типов).

  4. Генерация 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 есть две особенности:

  1. Rust — не самостоятельный компилятор, а фронтенд над LLVM

    Коротко: Rust строит план, LLVM его реализует.

    Rust-компилятор построен поверх LLVM (Low Level Virtual Machine) — мощного SDK для создания компиляторов. LLVM предоставляет инструменты для:

    • генерации и оптимизации машинного кода;

    • анализа и трансформации промежуточных представлений;

    • поддержки множества архитектур процессов;

    • неимоверного количества всего остального.

    Для описания компилятора есть два термина: backend и frontend.

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

  2. Rust — не самостоятельный компилятор, а фронтенд над LLVM

    Как многие другие компиляторы, в процессе генерации IR Rust-компилятор не ограничивается одним Intermediate Representation и проходит три подэтапа.

Этапы IR Rust-компилятора

Этапы IR Rust-компилятора

Этап 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».

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

Typed HIR

Typed HIR

Компилятор строит IR, где операция x + y типизирована, создаёт список неявных значений и аргументов форматирования и в итоге вызывает print.

AST-разбор объявления переменной в Rust

AST-разбор объявления переменной в Rust

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/