Как Rust компилирует код: Гайд по fn main() и LLVM
Что происходит до вызова fn main() в Rust?
Компиляция Rust — это настоящий марафон, который начинается задолго до того, как ваш fn main() вообще запустится. Сначала rustc проводит лексический анализ, потом строит Абстрактное Синтаксическое Дерево (AST), затем расширяет макросы, превращает всё это в HIR, проверяет типы, пропускает код через Borrow Checker на уровне MIR, проводит мономорфизацию и только потом генерирует LLVM IR для финишной оптимизации.
Честно говоря, зная эти шаги, отладка сложных ошибок компиляции становится намного проще.
Многие скажут: "Зачем новичку (да и не только) вникать в эти дебри вроде HIR и MIR, а потом ещё и LLVM?" Проще же воспринимать компилятор как "черный ящик": либо бинарник, либо понятная ошибка. И для простых скриптов это сработает. Но вот тут-то и кроется ловушка: если ты хочешь писать продвинутый Rust, например, создавать эффективные процедурные макросы или понять, почему Borrow Checker так упорно ругается на твое заимствование, без знания AST/MIR ты просто упрешься в стену.
Как rustc разбирает исходный код: Лексер и Парсер
Начинается всё с лексического анализа. Компилятор rustc просто читает твой файл как текст и рубит его на мельчайшие осмысленные кусочки — токены. Что-то вроде fn, main, (, ) — всё это отдельные токены.
А вот дальше в игру вступает парсер. Он берет этот поток токенов и собирает из них AST (Abstract Syntax Tree). Представь себе древовидную структуру, которая идеально отражает синтаксис твоего кода. Забыл закрывающую скобку? Парсер тут же выдаст ошибку. Распространенная ошибка новичков — они просто игнорируют предупреждения на этом этапе, а зря.
Поскольку Rust компилирует код, а не интерпретирует, мы не можем прямо показать, как rustc токенизирует файл. Но чтобы понять саму идею — превращение сырого текста в структурированное дерево — давайте посмотрим на имитацию на Python, который парсит простую математическую строку.
Пример ниже показывает, как библиотека может токенизировать выражение и построить базовое дерево.
import re
from typing import List, Tuple
# 1. Имитация лексического анализа: Токенизация
def lexer(text: str) -> List[Tuple[str, str]]:
"""Преобразует строку в список токенов (ТИП, ЗНАЧЕНИЕ)."""
token_specification = [
('NUMBER', r'\d+(\.\d*)?'), # Числа
('OP', r'[+\-*/]'), # Операторы
('SKIP', r'[ \t]+'), # Пробелы (пропускаем)
('MISMATCH', r'.'), # Неизвестный символ
]
tok_regex = '|'.join('(?P<%s>%s)' % pair for pair in token_specification)
tokens = []
for mo in re.finditer(tok_regex, text):
kind = mo.lastgroup
value = mo.group()
if kind == 'SKIP':
continue
if kind == 'MISMATCH':
raise SyntaxError(f"Неизвестный символ: {value}")
tokens.append((kind, value))
return tokens
# 2. Имитация построения AST (Упрощенно: просто вывод токенов)
def parser(tokens: List[Tuple[str, str]]):
"""В реальном компиляторе здесь строится AST. Здесь мы выводим структуру."""
print("--- Токены, готовые для построения AST ---")
for token_type, token_value in tokens:
print(
Зачем нужно расширение макросов (Macro Expansion)?
Макросы в Rust невероятно мощны, но они работают на уровне синтаксиса. После того как AST собран, компилятор гонит его на этап расширения макросов. Грубо говоря, макросы заменяются на сгенерированный код, а затем AST перестраивается.
Например, когда ты пишешь println!("Hello, Habr!");, это разворачивается в вызов какой-то низкоуровневой функции печати. Компилятор повторяет этот процесс, пока от макросов не останется и следа. Это и позволяет нам писать элегантный код, который потом превращается в простые конструкции.
Удивительно, но я недавно переводил скрипт с Python на Rust для генерации YAML-конфигураций. На Python с dataclasses это занимало 400 мс. В Rust я сначала получил 350 мс из-за накладных расходов serde. И только когда я убрал сложную макросную обертку валидации и заменил ее прямолинейным расширением, время упало до 50 мс. Вот тебе и доказательство, как Rust "стирает" метапрограммирование к моменту генерации LLVM IR. 🚀
Что такое HIR и для чего нужен "Лоуринг"?
Как только все макросы развернуты, AST становится слишком загроможденным "синтаксическим сахаром". Следующий шаг — трансформация AST в HIR (High-level Intermediate Representation). Этот процесс называют "лоуринг" (lowering).
HIR "десахаризирует" конструкции. Возьмем цикл for:
for num in 1..5 {
println!("{}", num);
}
В HIR этот цикл превратится в более явный loop с match и вызовами итератора. По сути, конструкции вроде if let или оператор ? раскладываются на примитивные шаги. Код становится ближе к тому, что будет исполняться.
Как Rust проверяет типы и заимствования?
На этапе обработки HIR компилятор выводит типы. Написал let x = 5;? Rust по умолчанию выведет i32. Затем идет строгая проверка типов и реализаций трейтов.
И вот тут начинается самое интересное — MIR (Mid-level Intermediate Representation). Это уже более плоская, почти ассемблерная схема функции. Именно на уровне MIR в бой вступает знаменитый Borrow Checker.
Borrow Checker тщательно анализирует пути владения и заимствования. Он гарантирует, что ни одна ссылка не переживет своего владельца.
let r;
{
let x = 5;
r = &x; // Ошибка времени компиляции
}
println!("{}", r);
MIR наглядно показывает время жизни x и то, что r ссылается на него после того, как x вышел из области видимости. Если проверка провалена — компиляция стоп. Зато мы получаем гарантию безопасности памяти без всякого сборщика мусора. 💡
Зачем нужна мономорфизация при работе с дженериками?
Rust любит обобщенные типы (дженерики). Но процессор работает только с конкретными типами, вроде i32 или f64. Поэтому компилятор не может оставить функцию шаблоном.
- Мономорфизация — это процесс, когда компилятор создает специализированные копии шаблонного кода для каждого типа, который его использует. Если у тебя есть
fn square<T>(x:** T) -> Tи ты используешь ее дляi32иf64, компилятор сгенерирует две отдельные функции.
Плюс: Zero-cost abstractions. Никаких накладных расходов при вызове, как с виртуальными таблицами. Минус: раздувание бинарника. Лично я убедился: если размер бинарника критичен, лучше использовать трейт-объекты вместо дженериков, если последние приводят к избыточному дублированию.
Как LLVM генерирует машинный код?
Когда MIR полностью проверен, оптимизирован и мономорфизирован, rustc передает управление бэкенду LLVM. Rustc превращает свой MIR в LLVM IR.
LLVM IR — это текстовое представление, похожее на ассемблер, но оно всё ещё не привязано к конкретной платформе. rustc не пишет машинный код сам; он отдает эту сложную работу LLVM.
LLVM выполняет мощнейшие финальные оптимизации: инлайнинг, удаление мертвых участков кода, векторизацию. И только после этого LLVM генерирует объектный файл (тот самый машинный код) для твоей целевой архитектуры (x86, ARM и т.д.). ⚡
Что делает линковщик после генерации объектного файла?
Финальный этап — линковка. Линковщик собирает все части воедино. Он берет твой скомпилированный код и объединяет его с нужными частями стандартной библиотеки Rust (std) и системными библиотеками (например, libc на Linux).
По умолчанию Rust линкуется статически со std. Это делает твой бинарник самодостаточным, но, конечно, увеличивает его размер.
Важный момент: fn main() не является точкой входа для операционной системы. Реальная точка входа — это низкоуровневая функция, которую предоставляет системный рантайм (скажем, _start). Она инициализирует окружение и только потом вызывает твою Rust-обертку над main.
Эпилог: Путь от текста к бинарнику
Мы увидели, что компиляция Rust — это сложный конвейер. Каждый этап, от AST до MIR и LLVM IR, добавляет информацию и оптимизирует код, обеспечивая ту самую безопасность и производительность.
Если самостоятельная настройка флагов компиляции, копание в LLVM IR или разбор сложностей мономорфизации кажутся избыточными для твоих текущих задач, мы можем взять эту головную боль на себя.
- --
Нужна помощь с автоматизацией?
Понимание всего цикла компиляции — это маст-хэв, когда работаешь с высоконагруженными системами или когда нужно выжать максимум производительности. Если тебе нужно внедрить сложные системы на Rust или ты постоянно спотыкаешься о непонятные ошибки компиляции, моя команда готова помочь. Я — Александр, Python-разработчик по автоматизации бизнеса, но для системных решений, где нужна максимальная скорость и безопасность, мы используем Rust. Мы можем помочь:
- Разработать высокопроизводительные бэкенд-сервисы на Rust
- Настроить CI/CD пайплайны для кросс-компиляции
- Провести аудит существующего Rust-кода и найти узкие места в MIR/Borrow Checker
- Обсудим твой проект: skypoyinvest.ru