Привет!

Хочу вместе с вами разобрать, как же код на Rust превращается в готовый исполняемый файл. Мы пишем программу, например, fn main() { println!("Hello, Habr!"); }, компилируем, и на выходе получаем бинарник. Что происходит под капотом компилятора Rust в этот момент? Давайте аккуратненько заглянем внутрь этого таинственного процесса.

Лексический анализ и синтаксическое дерево

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

fn main() {
    println!("Hello, Habr!");
}

лексер выделит токены примерно такие: fn, main, (, ), {, println, !, (, "Hello, Habr!", ), ;, }. На этом этапе еще не понимается смысл программы, по сути мы просто дробим текст на минимальные значимые фрагменты.

Следом за лексером работает парсер. Он берет поток токенов и строит из них древовидную структуру — AST, абстрактное синтаксическое дерево. Узлы AST соответствуют конструкциям языка: функция, блок кода, вызов макроса, литерал строки и т.д. Если лексер представить как разрезание предложения на слова, то парсер составляет из этих слов осмысленные фразы по правилам грамматики Rust. В итоге получается дерево, отражающее структуру программы, почти один-в-один как мы написали в коде.

На этапе парсинга компилятор уже может выявить базовые синтаксические ошибки. Если вы случайно забудете закрывающую фигурную скобку или напишете что-то вроде fn main( {, парсер поймает это и бросит ошибку, ожидается то-то и то-то. Rustc старается даже восстанавливаться после ошибок и продолжать разбор, чтобы вывести побольше полезных сообщений за один проход.

После парсинга запускается отдельный этап macro expansion, компилятор постепенно разворачивает все вызовы макросов, итеративно переписывая AST, пока не получит дерево без макросов. В нашем примере присутствует вызов макроса println!.

Компилятор подставляет вместо этого макроса сгенерированный им код, переписывая уже построенное AST на этапе расширения макросов. Макросы в Rust работают на уровне синтаксического дерева, то есть println!("Hello, Habr!") превратится в вызов определенной функции печати с подготовленными аргументами.

Упрощенно говоря, наш println! развернется в нечто вроде:

{
    use std::io::_print;
    _print(format_args!("Hello, Habr!\n"));
}

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

Компилятор Rust многократно запускает процесс расширения макросов, пока все макросы (включая процедурные) не будут обработаны. Только после этого синтаксическое дерево считается окончательно построенным.

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

HIR: высокоуровневое промежуточное представление

Получив на вход полное AST, rustc переводит его в так называемый HIR (High-level Intermediate Representation), высокоуровневое промежуточное представление программы. Этот этап называется «лоуринг» (lowering) AST. В общем компилятор берет абстрактное дерево и упрощает, десахаризирует его, тем самым разворачивает удобный синтаксис в базовые конструкции.

Например, возьмем цикл for в Rust. Можно написать так:

for num in 1..5 {
    println!("{}", num);
}

В исходном AST это один узел ExprKind::ForLoop, очень понятный для программиста.

Однако компилятору для анализа удобнее представить этот цикл как обычный loop + вызовы методов итератора. При переводе в HIR цикл for разворачивается в такой простой код:

{
    let mut iter = (1..5).into_iter();
    loop {
        match iter.next() {
            Some(num) => println!("{}", num),
            None => break,
        }
    }
}

То есть неявно созданный итератор, цикл loop и match на результат. В HIR уже нет синтаксического сахара, конструкции вроде for, if let, ?-оператора, даже async/await, всё это разложено на более примитивные узлы. HIR ближе к тому, что реально будет выполняться, хотя еще достаточно похож на исходный код.

Кроме дешугаринга, на этапе HIR компилятор вставляет некоторые неявные вещи.

Например, в Rust есть прелюдия (prelude), автоматически подключаемый модуль, где определены базовые вещи вроде типа Option или макроса println!. На стадии разрешения имён компилятор автоматически добавляет в каждый модуль прелюдию, как если бы там стояло use std::prelude::v1::*;, поэтому базовые типы и трейты всегда находятся в области видимости.

Выведение и проверка типов. Rust сам по себе типичный статически типизированный язык, и компилятор скрупулезно вычисляет тип каждого выражения еще до генерации кода. На этапе HIR запускается выведение типов: где вы не указали явно тип переменной или возвращаемое значение функции, компилятор попытается его вывести из контекста. Например, если вы пишете let x = 2 + 2;, ни один тип явно не назван, но из литералов 2 понятно, что это целые числа типа i32 (по дефолту). Компилятор присвоит x тип i32. Если где-то типов не хватает или возникает неоднозначность, вы получите ошибку компиляции.

Затем следует проверка типов и связанная с ней проверка реализации трейтов.

Компилятор проходит по HIR и удостоверяется, что каждый операнд имеет подходящий тип для данной операции, что вы не пытаетесь, скажем, сложить число с строкой, вызвать несущийся метод и т.п. Тут же проверяются и ограничения трейтов: например, если функция шаблонная и требует T: Display, компилятор на этапе проверки убедится, что для конкретного типа T действительно реализован трейt Display. Если что-то не сходится, вы увидите ошибку вроде «method not found» или «the trait Display is not implemented for type X».

Скажу больше, именно на этой стадии компилятор находит большую часть глупых ошибок: несоответствие типов, отсутствие нужных методов, забытую mut при изменении переменной, попытку передать String там, где ожидается &str, и прочие огорчения. Опытные ребята уже знают, что до тех пор, пока код не пройдёт проверку типов и заимствований, рано радоваться, но если прошёл, то программа с высокой вероятностью будет работать без целого класса ошибок.

После успешной проверки типов HIR обогащается информацией о типах каждого узла. На самом деле, внутри rustc есть ещё один промежуточный слой, связанный с HIR, THIR (Typed HIR), ещё более вычищенное и типизированное представление программы. THIR используется для некоторых дополнительных проверок вроде исчерпывающего анализа match и проверки безопасного/небезопасного кода. Мы не будем подробно на этом останавливаться, а перейдём к следующему ключевому этапу.

Самостоятельно про это можно глянуть по ссылке.

Borrow Checker и MIR

Помимо типов, визитная карточка Rust — система заимствования и владения.

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

Тут занимательно, что проверка заимствований происходит не на HIR, а на следующем представлении — MIR (Mid-level Intermediate Representation). Когда типы успешно провалидированы, Rustc транслирует высокоуровневое представление в среднеуровневое промежуточное представление.

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

Анализ на уровне MIR значительно удобнее для многих задач, включая проверку заимствований. В HIR программа всё ещё содержит сложные конструкции, и проверять все возможные пути выполнения там затруднительно. MIR же представляет каждый возможный путь исполнения явным образом. Например, операторы if/else или match в MIR уже превращены в простые условные переходы, опция Option<T> при распаковке через ? разворачивается в понятный последовательный код с проверкой на None; сложные временные объекты разбиты на шаги. Благодаря этому borrow checker может проследить все ветви и убедиться, что нигде мы не нарушаем правил владения.

Представим небольшой пример нарушения правил, чтобы понять, что ловит borrow checker. Код:

let r;
{
    let x = 5;
    r = &x;
} // здесь x выходит из области видимости
println!("{}", r);

Компилятор на этапе проверки заимствований увидит, что ссылка r на x пережила своего хозяина x. MIR для этого кода явно отразит время жизни x и использование r после, и borrow checker сгенерирует ошибку: «x does not live long enough». Ни один байт машинного кода не будет сгенерирован, пока мы не исправим такой код.

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

В новом дизайне проверяющего заимствования (проект Polonius) эти принципы те же, меняется лишь реализация на более мощный алгоритм, но пока по дефолту используется классический borrow checker. Так или иначе, на выходе этого этапа мы получаем проверенный на корректность MIR, и можем двигаться дальше.

Оптимизации на уровне MIR

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

Другой пример в упрощение некоторых паттернов, специфичных для Rust. Есть интересный случай с оператором ?,раньше LLVM не умел оптимизировать некоторый шаблон кода с match на Result, поэтому в Rust сделали специальную MIR‑оптимизацию SimplifyCfg (simplify_try), которая сама упрощает цепочки match после оператора ?. Таких мелких оптимизаций довольно много.

Например, встроенное вычисление констант на этом этапе позволяет просчитать сразу результаты константных выражений. Если у вас есть const N: usize = 1 + 2 * 3;, то на этапе MIR компилятор просто заменит это на const N: usize = 7;. Но это касается не только явно объявленных const. Компилятор может выполнить и более хитренькие вещи во время компиляции (через механизм const fn и интерпретатор Miri), вплоть до вычисления сложных константных выражений. Все, что вычислено на этапе компиляции, не будет тратить время при запуске программы.

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

Мономорфизация

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

Мономорфизация — странное слово, но суть простая. Допустим, вы написали обобщённую функцию:

fn square<T: std::ops::Mul<Output=T> + Copy>(x: T) -> T {
    x * x
}

Она умеет возводить в квадрат значение любого типа T, для которого определён оператор умножения (и который можно копировать). Мы можем вызвать square(5) для i32 и square(3.14) для f64. В финальном исполняемом файле не существует «абстрактной» функции square для всех T, ведь процессор работает с конкретными типами (конкретными размерами памяти). Поэтому компилятор создаст две отдельных версии функции, одну для i32, другую для f64. Каждый вариант мономорфизированная реализация, заточенная под свой тип.

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

В результате после мономорфизации наш MIR больше не содержит всякие дженерики, только конкретные типы.

Благодаря этому подходу вызовы обобщённых функций в Rust не имеют накладных расходов на абстракцию. За абстракцию платится увеличением размера скомпилированного кода, но на исполнении это обычный прямой вызов конкретной функции без каких-либо виртуальных таблиц. Zero-cost abstractions во всей красе!

Мономорфизация, конечно, размножает код, что может сказаться на размере бинарника.

Но оптимизатор часто умеет сглаживать это раздувание, выкидывая неиспользуемые специализации и дублирующиеся функции. Да и вы сами можете повлиять, вынеся повторяющуюся логику в не‑обобщённую функцию или используя динамическую диспетчеризацию (трейты объекты) вместо дженериков там, где важен размер. Здесь важно понимать сам принцип, обобщённый код превращается в множество конкретных реализаций. Без этого шагa мы не смогли бы получить машинный код, ведь CPU нужны точные операции над конкретными типами (зная, сколько байт занимают, как с ними работать и тп).

Теперь у компилятора на руках полностью определённый, специализированный и проверенный MIR. Наконец‑то пришло время генерировать машинный код! Этим займётся могучий бэкенд компилятора LLVM.

Генерация LLVM IR и низкоуровневая оптимизация

Rust-компилятор не пишет непосредственно двоичные инструкции x86 или ARM сам, он полагается на инфраструктуру LLVM. Это библиотека-компилятор, которая умеет генерировать оптимальный машинный код под множество архитектур. Rustc передаёт подготовленную программу в LLVM в виде LLVM IR, промежуточного представления LLVM.

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

Итак, rustc преобразует MIR в LLVM IR, попутно мономорфизируя дженерики, если это не сделано чуть раньше. Каждый Rust-функции соответствует набор инструкций LLVM IR.

Например, простая функция сложения:

fn add(a: i32, b: i32) -> i32 {
    a + b
}

может превратиться в такой фрагмент LLVM IR (я опущу много детлeй для краткости):

define i32 @add(i32 %a, i32 %b) {
entry:
    %0 = add nsw i32 %a, %b
    ret i32 %0
}

Видим, что функция add получила имя @add (в реальности компилятор сделает имя посложнее), параметры a и b стали %a и %b внутри, и выполняется одна операция add nsw i32 (сложение 32битных int с проверкой переполнения), результат которой возвращается. Это LLVM IR — человекочитаемая форма, но достаточно близкая к тому, что пойдет на железо.

Далее в дело вступает оптимизатор LLVM. Он выполняет массу трансформаций над LLVM IR: инлайнинг функций, распространение констант, удаление мёртвого кода (снова, уже на более низком уровне), развёртывание циклов, авто-векторизацию и многое другое.

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

Когда оптимизации завершены, LLVM наконец-то генерирует машинный код под целевую архитектуру. Это означает, что компилятор получает объектный файл (.o или .obj в зависимости от платформы) с набором двоичных инструкций процессора. По сути, это уже перевод вашего кода на язык железа, но ещё не совсем готовый самостоятельный исполняемый файл. Потому что обычно программе нужны и другие части: стандартная библиотека, рантайм, да и сам файл должен иметь определенный формат (ELF, PE, Mach-O ...) с заголовками, таблицами символов и прочими служебными структурами.

Тут поможет линковка!

Линковка

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

Например, когда вы пишете программу на Rust и используете println! или любой функционал из std, ваш код ссылается на функции, реализованные в стандартной библиотеке. std уже скомпилирована заранее в виде релокационных библиотек (.rlib — фактически архив .o объектов) и поставляется вместе с компилятором. Линковщик знает, где взять эти объекты, и включит необходимые части std внутрь вашего exe-шника. Rust по дефолту статически компилирует стандартную библиотеку в ваш бинарник. Это одна из причин, почему «Hello world» на Rust весит немало, он содержит внутри нужные части runtime. Зато потом такой бинарник не зависит ни от каких внешних Rust-библиотек, он самодостаточный.

Однако системные библиотеки, вроде libc на Linux или WinAPI на Windows, могут линковаться динамически. Rust компилятор обычно доверяет линковщику связать ваш код с libc.dll или аналогом. Поэтому на Linux исполняемый файл Rust по дефолту потребует libc.so.6 (glibc), если вы не собирали под musl для полной статики. В винде же ваш .exe подтянет system DLLs. Это нюансы, но общая картина такая: линковщик склеивает все необходимые объектные файлы и библиотеки, проставляет адреса вызовов, разрешает символы (решает, какой именно кусок кода соответствует каждому имени) и формирует единый файл нужного формата (ELF, PE/COFF или Mach-O, в зависимости от операционки).

На этапе линковки происходит и последний штрих: создание точки входа программы. Многие думают, что fn main() и есть точка входа. В контексте языка да, но на уровне операционной системы входная точка — это уже функция с конкретным именем, обычно _start или mainCRTStartup, которую вызывают загрузчик ОС или C-runtime.

В финальном бинарнике точка входа — это низкоуровневая функция start или mainCRTStartup, которую предоставляет системный рантайм. Она инициализирует окружение и вызывает создаваемую Rust-обёртку над main, которая затем передаёт управление в std::rt::langstart и далее в ваш fn main().

В #![no_std] (и особенно #![no_main]) вы обычно обязаны самостоятельно определить функцию _start, потому что стандартный рантайм отсутствует. В любом случае, main сам по себе не является OS-ENTRY, его вызывают из специального кода рантайма и библиотек, который попадает в бинарник на этапе линковки. Обычно нам не нужно об этом думать, но для полного понимания процесса упомянуть стоит.

После успешной линковки мы наконец-то видим заветный исполняемый файл на диске (в каталоге target/debug/ или target/release/ при использовании Cargo). Можно радостно запустить его и увидеть результат работы программы.


Эпилог

Компилятор Rust проделывает колоссальную работу, прежде чем ваша программа заработает, но награда того стоит, вы же получаете быстрый и безопасный код (вас же правда это устраивает?).

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

Спасибо, что дочитали до конца. Если у вас остались вопросы или вы хотите, чтобы я раскрыл какие-то нюансы глубже (например, про Polonius), смело задавайте в комментариях, а так же делитесь своим опытом работы с компилятором. А у меня на этом всё. До встречи и пусть ваш код всегда компилируется с первого раза!

Тут все подробности

Комментарии (1)


  1. Nuflyn
    03.12.2025 07:29

    Раст это мой основной язык, но черт возьми, почему компилятор раста такой медленный и скорость не очень скейлится от числа процессоров?