Основы Computer Science От кода к исполнению: компиляторы, интерпретаторы, ассемблер, машинный код
0%

От кода к исполнению: компиляторы, интерпретаторы, ассемблер, машинный код

От кода к исполнению: компиляторы, интерпретаторы, ассемблер, машинный код

В предыдущих статьях трека мы дошли снизу до процессора: собрали из вентилей сумматор и регистр (Булева логика и вентили), а потом увидели, что процессор в цикле достаёт из памяти очередное число-команду и выполняет его (Как работает процессор). Ключевой вывод оттуда: процессор понимает ровно один язык — машинный код своей ISA, поток двоичных чисел. Он никогда не видел ни Python, ни for, ни println. Он вообще не знает, что существуют языки программирования.

Но вы-то пишете print("привет"), а не 10110000 01100001. Между этими двумя мирами — человеческим текстом и числами, которые щёлкают транзисторами, — лежит целая индустрия переводчиков: ассемблеры, компиляторы, линковщики, интерпретаторы, виртуальные машины, JIT. Эта статья — про то, как ваш код превращается в исполнение. Не «магия компилятора», а понятная цепочка преобразований, каждое звено которой можно вызвать руками и рассмотреть глазами. Пойдём снизу вверх: от почти-машинного ассемблера к высокоуровневой компиляции, потом к интерпретации, а закончим спектром гибридов и тем, где эта башня абстракций предсказуемо протекает.

Лестница языков: три этажа над транзистором

Языки различаются по «высоте» над железом — тем, насколько они далеки от конкретного процессора и близки к тому, как думает человек.

  • Машинный код — тот самый поток чисел из статьи про процессор. Каждое число — команда: опкод плюс операнды. Читать его человеку почти невозможно.
  • Ассемблер — тонкая словесная оболочка над машинным кодом. Мнемоника 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). А обратный инструмент — дизассемблер — берёт готовый машинный код и восстанавливает мнемоники; именно им пользуются, когда изучают чужой бинарь или отлаживают без исходников.

Компиляция: перевод с пониманием

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

Пройдём фазы на строке x = a + b * 2:

  1. Лексический анализ (лексер). Поток символов разбивается на токены — неделимые лексемы: x, =, a, +, b, *, 2. Пробелы и комментарии отбрасываются. Лексер — это, по сути, конечный автомат (Теория вычислений объясняет, почему именно автомат): он опознаёт «это идентификатор», «это число».

  2. Синтаксический анализ (парсер). Из плоского потока токенов строится дерево — абстрактное синтаксическое дерево (AST), отражающее структуру и приоритеты. b * 2 вычисляется раньше +, поэтому умножение лежит глубже в дереве:

    Если токены не складываются в допустимое дерево (x = a +), парсер выдаёт синтаксическую ошибку. Именно здесь рождается большинство «красных подчёркиваний» в редакторе.

  3. Семантический анализ. Дерево проверяется на смысл: объявлены ли a и b, совместимы ли типы (можно ли складывать число со строкой), не присваиваем ли мы в константу. Здесь ловятся ошибки типов. В языках со статической типизацией эта фаза — мощный фильтр, который отбраковывает целые классы багов ещё до запуска.

  4. Промежуточное представление (IR). AST превращается в упрощённый, машинно-независимый «псевдо-ассемблер» — например, трёхадресный код: t1 = b * 2; t2 = a + t1; x = t2. IR — это лингва-франка компилятора: на нём удобно оптимизировать, и он не привязан к конкретному процессору. LLVM сделал такой IR (LLVM IR) отдельным продуктом, и поэтому один бэкенд LLVM обслуживает десятки языков-фронтендов (Clang, Rust, Swift).

  5. Оптимизация. Над IR прогоняются десятки преобразований: свёртка констант (b * 2 при известном b), инлайнинг мелких функций, удаление мёртвого кода, вынос инвариантов из циклов, векторизация. Именно эта фаза объясняет, почему один и тот же алгоритм в -O0 и -O2 может отличаться по скорости в разы.

  6. Кодогенерация и распределение регистров. 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, которые её цикл исполняет одну за другой. Граница между «компилятором» и «интерпретатором» уже здесь размывается: внутри почти каждого интерпретатора сидит маленький компилятор в байткод.

Спектр, а не два лагеря

«Компилируемый против интерпретируемого» — ложная дихотомия. Это спектр стратегий реализации, и большинство промышленных языков занимают в нём гибридную позицию. Показать этот спектр удобнее всего таксономией.

Три идеи из этого спектра стоит понять отдельно.

Виртуальная машина и байткод (Java, C#, Python). Исходник компилируется в переносимый байткод (.class, .dll, .pyc), а исполняет его ВМ, своя на каждой платформе. Отсюда лозунг Java «написал раз — запускай везде»: переносим байткод, а грязную работу под конкретный процессор берёт на себя ВМ. Цена — нужна установленная среда исполнения (JVM, .NET, интерпретатор Python).

JIT-компиляция (just-in-time). Комбинирует лучшее от обоих миров: программа стартует как интерпретируемый байткод (быстрый старт, портативность), но ВМ профилирует исполнение и, обнаружив «горячий» участок — цикл или функцию, которая выполняется миллионы раз, — на лету компилирует именно его в нативный машинный код. Дальше горячий код летит на скорости компилированного.

Три способа исполнить один и тот же код: компилятор, интерпретатор, JIT

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

Что дальше

Мы довели программу до момента запуска: загрузчик разложил её по памяти и передал управление процессору. Но кто раскладывает по памяти десятки программ одновременно, кто переключает процессор между ними, кто даёт им файлы и не даёт затирать чужую память? Всем этим заведует операционная система — следующий слой нашей башни абстракций.

Что делает операционная система: процессы, память, файлы — обзор

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов