От кода к исполнению: компиляторы, интерпретаторы, ассемблер, машинный код
В предыдущих статьях трека мы дошли снизу до процессора: собрали из вентилей сумматор и
регистр (Булева логика и вентили), а потом
увидели, что процессор в цикле достаёт из памяти очередное число-команду и выполняет его
(Как работает процессор). Ключевой вывод оттуда:
процессор понимает ровно один язык — машинный код своей ISA, поток двоичных чисел. Он
никогда не видел ни Python, ни for, ни println. Он вообще не знает, что существуют
языки программирования.
Но вы-то пишете print("привет"), а не 10110000 01100001. Между этими двумя мирами —
человеческим текстом и числами, которые щёлкают транзисторами, — лежит целая индустрия
переводчиков: ассемблеры, компиляторы, линковщики, интерпретаторы, виртуальные машины,
JIT. Эта статья — про то, как ваш код превращается в исполнение. Не «магия компилятора», а
понятная цепочка преобразований, каждое звено которой можно вызвать руками и рассмотреть
глазами. Пойдём снизу вверх: от почти-машинного ассемблера к высокоуровневой компиляции,
потом к интерпретации, а закончим спектром гибридов и тем, где эта башня абстракций
предсказуемо протекает.
Лестница языков: три этажа над транзистором
Языки различаются по «высоте» над железом — тем, насколько они далеки от конкретного процессора и близки к тому, как думает человек.
x = a + b * 2
переносим, близок к человеку"] ASM["Ассемблер
MUL R2, R1, #2 · ADD R0, R3, R2
мнемоники, 1:1 с машкодом, привязан к ISA"] MC["Машинный код
0xE0810082 …
числа, которые исполняет процессор"] HW["Транзисторы
напряжения на затворах"] HL -->|"компилятор / интерпретатор"| ASM ASM -->|"ассемблер"| MC MC -->|"декодер команд + АЛУ"| HW
- Машинный код — тот самый поток чисел из статьи про процессор. Каждое число — команда: опкод плюс операнды. Читать его человеку почти невозможно.
- Ассемблер — тонкая словесная оболочка над машинным кодом. Мнемоника
ADDвместо опкода0010, имя регистра вместо его номера, меткаloop:вместо адреса. Почти каждая строка ассемблера соответствует ровно одной машинной команде. Ассемблер привязан к конкретной ISA: программа на ассемблере x86-64 бессмысленна для ARM. - Высокоуровневый язык (C, Python, Go, JavaScript) оперирует переменными, функциями,
циклами и типами, ничего не зная о регистрах и опкодах. Одна строка
x = a + b * 2разворачивается в несколько машинных команд. За переносимость и удобство платят тем, что между текстом и железом появляется нетривиальный переводчик.
Дальше разберём этих переводчиков по очереди, начиная с самого простого.
Ассемблер: перевод один-к-одному
Ассемблер (программа-транслятор) — самый прямолинейный переводчик: он берёт текстовые
мнемоники и заменяет их на числовые опкоды по таблице ISA. Здесь почти нет «умственной
работы»: ADD R1, R2 всегда даёт один и тот же набор бит. Единственное, что ассемблер делает
неочевидного, — разрешает символы: превращает человеческие метки в числовые адреса.
; посчитать 7 + 3 и записать результат в память
LOADI R1, 7 ; R1 <- 7
LOADI R2, 3 ; R2 <- 3
ADD R1, R2 ; R1 <- R1 + R2
STORE R1, result ; память[result] <- R1
HALT
result: .word 0 ; ячейка под ответ; метка = её адрес
Программист пишет result, а не «ячейка по адресу 0x40». Ассемблер за два прохода
(two-pass) сначала вычисляет, по какому адресу окажется каждая метка, а на втором проходе
подставляет эти адреса в команды. Это первое появление идеи, которая пронизывает весь стек
трансляции: символические имена, которые потом связываются с числовыми адресами. Ту же
идею мы встретим в линковщике и в динамической загрузке.
Важно не путать: ассемблер — это и язык (мнемоники ISA), и программа, которая его переводит
(as, nasm). А обратный инструмент — дизассемблер — берёт готовый машинный код и
восстанавливает мнемоники; именно им пользуются, когда изучают чужой бинарь или отлаживают
без исходников.
Компиляция: перевод с пониманием
Компилятор переводит высокоуровневый язык в низкоуровневый (обычно в машинный код или ассемблер) целиком и заранее, до запуска программы. В отличие от ассемблера, тут перевод не один-к-одному: компилятор должен понять структуру программы, проверить её на осмысленность и сгенерировать эффективный код. Это делается по конвейеру фаз, который принято делить на фронтенд (понять исходник), среднюю часть (оптимизировать) и бэкенд (сгенерировать код под целевую машину).
текст → токены"] LEX --> PAR["Парсер
токены → AST"] PAR --> SEM["Семантика
типы, области видимости"] end subgraph MID["Середина — улучшить"] direction TB IR["Промежуточное
представление (IR)"] --> OPT["Оптимизатор
свёртка, инлайнинг,
удаление мёртвого кода"] end subgraph BE["Бэкенд — сгенерировать"] direction TB CG["Кодогенерация
выбор команд"] --> RA["Распределение
регистров"] --> OUT["машкод / ассемблер"] end SEM --> IR OPT --> CG
Пройдём фазы на строке x = a + b * 2:
-
Лексический анализ (лексер). Поток символов разбивается на токены — неделимые лексемы:
x,=,a,+,b,*,2. Пробелы и комментарии отбрасываются. Лексер — это, по сути, конечный автомат (Теория вычислений объясняет, почему именно автомат): он опознаёт «это идентификатор», «это число». -
Синтаксический анализ (парсер). Из плоского потока токенов строится дерево — абстрактное синтаксическое дерево (AST), отражающее структуру и приоритеты.
b * 2вычисляется раньше+, поэтому умножение лежит глубже в дереве:flowchart TB A["="] --> X["x"] A --> P["+"] P --> AV["a"] P --> M["*"] M --> BV["b"] M --> C["2"]Если токены не складываются в допустимое дерево (
x = a +), парсер выдаёт синтаксическую ошибку. Именно здесь рождается большинство «красных подчёркиваний» в редакторе. -
Семантический анализ. Дерево проверяется на смысл: объявлены ли
aиb, совместимы ли типы (можно ли складывать число со строкой), не присваиваем ли мы в константу. Здесь ловятся ошибки типов. В языках со статической типизацией эта фаза — мощный фильтр, который отбраковывает целые классы багов ещё до запуска. -
Промежуточное представление (IR). AST превращается в упрощённый, машинно-независимый «псевдо-ассемблер» — например, трёхадресный код:
t1 = b * 2; t2 = a + t1; x = t2. IR — это лингва-франка компилятора: на нём удобно оптимизировать, и он не привязан к конкретному процессору. LLVM сделал такой IR (LLVM IR) отдельным продуктом, и поэтому один бэкенд LLVM обслуживает десятки языков-фронтендов (Clang, Rust, Swift). -
Оптимизация. Над IR прогоняются десятки преобразований: свёртка констант (
b * 2при известномb), инлайнинг мелких функций, удаление мёртвого кода, вынос инвариантов из циклов, векторизация. Именно эта фаза объясняет, почему один и тот же алгоритм в-O0и-O2может отличаться по скорости в разы. -
Кодогенерация и распределение регистров. IR превращается в конкретные команды целевой ISA. Отдельная трудная задача — распределение регистров: переменных в программе тысячи, а регистров у процессора единицы (статья про CPU), поэтому компилятор решает NP-трудную по сути задачу, кого держать в регистре, а кого «сбросить» (spill) в память.
Результат — ассемблерный, а затем машинный код. Увидеть его проще всего на
Compiler Explorer: вставляете C или Rust слева, справа мгновенно
видите сгенерированный ассемблер. Это лучший способ прочувствовать, во что разворачивается
ваш код. Локально то же даёт gcc -S file.c (получить ассемблер) и objdump -d a.out
(дизассемблировать готовый бинарь).
Сборка: от исходника до исполняемого файла
Компиляция одного файла — это ещё не работающая программа. Реальный проект состоит из многих
файлов и опирается на библиотеки (тот же printf живёт в стандартной библиотеке C, а не в
вашем коде). Чтобы из этого получился запускаемый бинарь, компилятор запускает целый конвейер
инструментов.
- Препроцессор (в C/C++) механически обрабатывает текст до компиляции: подставляет
#include, разворачивает#define, вырезает ветки#ifdef. Результат — по-прежнему текст, но уже «раскрытый». - Компилятор переводит каждый исходный файл в объектный файл (
.o): это уже машинный код, но неполный. В нём есть «дырки» — ссылки на функции из других файлов и библиотек (printf), которые компилятор не мог разрешить, потому что не видел их кода. Такие неразрешённые имена хранятся в таблице символов объектного файла. - Компоновщик (линковщик) сшивает объектные файлы и библиотеки в один исполняемый файл:
находит, где на самом деле лежит
printf, и подставляет правильные адреса в места вызова. Это снова та же идея «символ → адрес», что и в ассемблере, но между файлами. Если символ нигде не найден — знаменитая ошибкаundefined reference to 'foo'; если объявлен дважды —multiple definition.
Линковка бывает двух видов, и разница между ними — источник массы проблем «работает у меня»:
| Статическая линковка | Динамическая линковка | |
|---|---|---|
| Код библиотеки | вшивается в бинарь при сборке | лежит в общей .so/.dll, подгружается при запуске |
| Размер файла | больше | меньше |
| Обновление библиотеки | нужна пересборка | достаточно заменить .so |
| Переносимость | самодостаточен | зависит от наличия и версии библиотеки в системе |
| Кто дорабатывает связи | линковщик заранее | динамический загрузчик при старте |
Наконец, когда вы запускаете файл, вступает загрузчик операционной системы: он читает исполняемый файл (формат ELF в Linux, PE в Windows, Mach-O в macOS), раскладывает его секции (код, данные) по памяти, подтягивает динамические библиотеки и передаёт управление на точку входа. С этого момента процессор начинает свой цикл fetch-decode-execute уже над вашим кодом. Всё, что происходит после запуска, — процессы, память, файлы — тема следующей статьи (Что делает ОС).
Интерпретация: исполнять, не переводя заранее
У компиляции есть альтернатива. Интерпретатор не производит отдельный машинный код —
он сам является программой, которая читает ваш код и исполняет его действие немедленно,
конструкцию за конструкцией. Отдельного .exe не появляется вовсе; артефакт исполнения — это
поведение самого интерпретатора.
Простейший вид — интерпретатор, обходящий дерево (tree-walking): построил AST и рекурсивно «прошёлся» по нему, на каждом узле выполняя соответствующее действие.
# Игрушечный интерпретатор арифметики: обходим AST и сразу считаем.
# Узел — это кортеж: ('num', 7) либо ('+', левый, правый).
def evaluate(node):
kind = node[0]
if kind == 'num':
return node[1] # лист: просто значение
left = evaluate(node[1]) # рекурсивно считаем поддеревья
right = evaluate(node[2])
if kind == '+': return left + right
if kind == '*': return left * right
raise ValueError(f"неизвестный узел: {kind}")
# (2 + 3) * 4 → дерево ниже
tree = ('*', ('+', ('num', 2), ('num', 3)), ('num', 4))
print(evaluate(tree)) # 20
Никакого машинного кода — Python-функция evaluate и есть исполнение. Именно так устроены
многие DSL, шаблонизаторы и учебные языки. Плата очевидна: чтобы сложить два числа,
интерпретатор каждый раз заново разбирает узел дерева, проверяет его тип, делает ветвление —
на порядок больше работы, чем одна машинная команда ADD.
Большинство «интерпретируемых» языков поэтому используют промежуточный шаг — байткод.
Исходник один раз компилируется (да, компилируется!) в компактный внутренний набор команд
виртуальной машины, а дальше цикл ВМ гоняет уже байткод, а не дерево. CPython делает ровно
это: .py превращается в байткод (кэшируется в __pycache__/*.pyc), который исполняет
стековая виртуальная машина. Увидеть байткод можно прямо из стандартной библиотеки:
import dis
def f(a, b):
return a + b * 2
dis.dis(f)
# LOAD_FAST a
# LOAD_FAST b
# LOAD_CONST 2
# BINARY_OP *
# BINARY_OP +
# RETURN_VALUE
Это не машинный код процессора — это команды виртуальной стековой машины CPython, которые её цикл исполняет одну за другой. Граница между «компилятором» и «интерпретатором» уже здесь размывается: внутри почти каждого интерпретатора сидит маленький компилятор в байткод.
Спектр, а не два лагеря
«Компилируемый против интерпретируемого» — ложная дихотомия. Это спектр стратегий реализации, и большинство промышленных языков занимают в нём гибридную позицию. Показать этот спектр удобнее всего таксономией.
программу)) Компиляция заранее (AOT) в машинный код C, C++, Rust, Go в машкод из байткода GraalVM native-image Интерпретация обход дерева (AST) учебные языки, DSL байткод + виртуальная машина CPython, Ruby MRI Гибрид JIT байткод → машкод на лету JVM, V8, .NET CLR, PyPy Трансляция в другой язык транспиляторы TypeScript → JS, Babel
Три идеи из этого спектра стоит понять отдельно.
Виртуальная машина и байткод (Java, C#, Python). Исходник компилируется в переносимый
байткод (.class, .dll, .pyc), а исполняет его ВМ, своя на каждой платформе. Отсюда
лозунг Java «написал раз — запускай везде»: переносим байткод, а грязную работу под конкретный
процессор берёт на себя ВМ. Цена — нужна установленная среда исполнения (JVM, .NET, интерпретатор
Python).
JIT-компиляция (just-in-time). Комбинирует лучшее от обоих миров: программа стартует как интерпретируемый байткод (быстрый старт, портативность), но ВМ профилирует исполнение и, обнаружив «горячий» участок — цикл или функцию, которая выполняется миллионы раз, — на лету компилирует именно его в нативный машинный код. Дальше горячий код летит на скорости компилированного.
Жизненный цикл кода в JIT — это переход по «уровням» (tiers), и его удобно представить как машину состояний:
Ключевая тонкость — деоптимизация. JIT оптимизирует спекулятивно: предполагает, что переменная всегда целое число, и генерирует быстрый код под это допущение. Если в рантайме вдруг придёт строка, спекуляция ломается, и ВМ откатывается к безопасному байткоду. Из-за этого JIT-языки имеют разогрев: первые секунды работы медленные (код ещё интерпретируется и профилируется), и это регулярно портит наивные микробенчмарки — вы меряете разогрев, а не установившуюся скорость.
Трансляция из языка в язык (транспиляция). Иногда «целевая машина» — это другой высокоуровневый язык. TypeScript компилируется в JavaScript, Babel переводит новый JS в старый, многие языки компилируются в C. Технически это полноценные компиляторы с фронтендом и бэкендом — просто на выходе не машкод, а текст на другом языке.
Компилятор против интерпретатора: честный размен
Ни одна стратегия не лучше «вообще» — у каждой свой профиль компромиссов.
| Критерий | Компиляция (AOT) | Интерпретация |
|---|---|---|
| Скорость исполнения | высокая (машкод напрямую) | ниже (перевод на каждом шаге) |
| Старт программы | мгновенный | быстрый, но каждый запуск переразбирает исходник |
| Цикл разработки | правка → пересборка → запуск | правка → сразу запуск |
| Когда ловятся ошибки | многие — на этапе компиляции | в основном в рантайме, при исполнении строки |
| Переносимость артефакта | бинарь под конкретную ISA/ОС | исходник/байткод переносим, нужна среда |
| Распространение | самодостаточный файл | нужен интерпретатор у пользователя |
| Оптимизация | видит всю программу заранее | видит рантайм-профиль (козырь JIT) |
Грубая интуиция: компиляция вкладывается в скорость исполнения ценой скорости разработки и переносимости; интерпретация — наоборот. JIT пытается забрать оба выигрыша, платя сложностью рантайма, потреблением памяти и разогревом. Выбор языка под задачу во многом и есть выбор точки на этом спектре: системный код под жёсткие требования к латентности — ближе к AOT, скрипт-однодневка или прототип — ближе к интерпретации. Подробнее про то, как разные семьи языков воплощают эти идеи, — в треке функционального программирования и в языковых треках портала.
Где эта башня протекает
Трансляция кода — прекрасная абстракция: обычно можно писать на C или Python, не думая ни о линковщике, ни о байткоде. Но, как и всякая абстракция в этом курсе, она протекает — и знать эти места полезно, потому что они превращаются в самые запутанные баги.
- Оптимизатор и неопределённое поведение (UB). Компилятор оптимизирует, опираясь на
«абстрактную машину» стандарта языка, а не на ваш конкретный процессор. Если код содержит
UB (переполнение знакового целого, разыменование
NULL, выход за границу массива), стандарт разрешает компилятору предположить, что этого не бывает, — и он может выкинуть ваши проверки. Отсюда классика: код, который «работал» на-O0, ломается на-O2. Дело не в «баге компилятора», а в том, что программа опиралась на поведение, которого абстракция не гарантировала (см. серию Джона Регера «A Guide to Undefined Behavior»). - Разный код в debug и release. Из-за оптимизаций release-сборка переставляет и инлайнит команды. Поэтому баг, воспроизводимый в release, может исчезать под отладчиком, а трассировки стека — «схлопывать» заинлайненные функции.
- «Работает у меня» = динамическая линковка. Бинарь запускается на вашей машине, но падает
у коллеги с
error while loading shared libraries— потому что там нет нужной версии.so. Абстракция «просто запусти файл» протекла на уровне версий системных библиотек; отсюда популярность статической линковки (Go по умолчанию так и делает) и контейнеров. - Разогрев JIT искажает измерения. Меряя скорость JIT-языка «в лоб», вы застаёте код на стадии интерпретации. Корректный бенчмарк требует прогрева и стабилизации — иначе выводы о производительности будут ложными.
- Компилятор ≠ порядок вашего кода. Оптимизатор вправе переупорядочивать вычисления, если наблюдаемый результат не меняется. В однопоточной программе это незаметно, но во многопоточной внезапно становится важным — почему, разбирает Многозадачность и параллелизм.
Общий смысл всех протечек один: между вашим текстом и исполнением стоит не буквальный переводчик, а оптимизирующий, который сохраняет смысл программы (как его определяет стандарт языка), но волен как угодно менять форму. Пока вы остаётесь в рамках гарантированного поведения, это невидимо; шаг за рамки — и абстракция мстит.
Как это увидеть своими руками
Лучший способ перестать бояться «магии компилятора» — посмотреть на артефакты. Все инструменты стандартны и бесплатны:
# C: посмотреть каждый шаг конвейера сборки по отдельности
gcc -E hello.c -o hello.i # только препроцессор → раскрытый текст
gcc -S hello.c -o hello.s # + компилятор → ассемблер
gcc -c hello.c -o hello.o # + ассемблер → объектный файл
objdump -d hello.o # дизассемблировать машкод обратно в мнемоники
nm hello.o # таблица символов: что определено, что нужно извне
ldd a.out # от каких динамических библиотек зависит бинарь
# Python: увидеть байткод виртуальной машины
python -m dis hello.py # разобрать модуль на команды ВМ
А для сравнения языков и уровней оптимизации в один клик — Compiler Explorer:
меняете -O0 на -O2 и наблюдаете, как двадцать строк ассемблера схлопываются в три. Пары
часов с этими инструментами хватает, чтобы «код → исполнение» перестал быть чёрным ящиком.
Типичные заблуждения
- «Компилируемые языки всегда быстрее интерпретируемых». Скорее «обычно», и всё сложнее: хорошая JIT-ВМ (JVM, V8) на горячем коде соперничает с C, а плохо написанный C-код проиграет. Стратегия реализации важна, но алгоритм и работа с памятью важнее (Иерархия памяти, Алгоритмы и сложность).
- «Python не компилируется». Компилируется — в байткод. Просто не в машинный код и не заранее в отдельный файл. «Интерпретируемый» описывает модель запуска, а не отсутствие компиляции внутри.
- «Компилятор переводит мой код буквально». Нет: оптимизирующий компилятор сохраняет наблюдаемый смысл, а форму меняет радикально — вплоть до полного удаления кода, результат которого не используется.
.exe— это и есть машинный код». Не только: это контейнер (ELF/PE/Mach-O) с секциями кода и данных, таблицами символов, ссылками на библиотеки и метаданными для загрузчика.- «Ассемблер и машинный код — одно и то же». Ассемблер — человекочитаемый текст; машинный код — числа. Их связывает ассемблер-транслятор (и обратно — дизассемблер).
Мини-итог
Между строкой вашего кода и щелчками транзисторов стоит слой переводчиков, и каждый из них понятен. Ассемблер переводит мнемоники в опкоды почти один-к-одному, впервые связывая символические имена с адресами. Компилятор переводит высокоуровневый язык заранее и целиком, проходя фазы лексера, парсера, семантики, IR, оптимизации и кодогенерации; отдельный конвейер сборки (препроцессор → компилятор → ассемблер → линковщик → загрузчик) превращает исходники и библиотеки в запускаемый процесс. Интерпретатор исполняет код немедленно, чаще всего через промежуточный байткод и виртуальную машину. А между этими полюсами лежит целый спектр — байткод-ВМ, JIT со спекуляцией и деоптимизацией, транспиляторы — и выбор языка есть выбор точки на нём. Абстракция трансляции надёжна ровно до границ гарантированного поведения; за ними — UB, разные debug/release, ад динамической линковки и разогрев JIT. Понимаете эту цепочку — и «магия», превращающая текст в исполнение, становится набором инструментов, каждый из которых можно вызвать и рассмотреть.
Что дальше
Мы довели программу до момента запуска: загрузчик разложил её по памяти и передал управление процессору. Но кто раскладывает по памяти десятки программ одновременно, кто переключает процессор между ними, кто даёт им файлы и не даёт затирать чужую память? Всем этим заведует операционная система — следующий слой нашей башни абстракций.
Что делает операционная система: процессы, память, файлы — обзор