Базовый CS с нуля
От исходного кода до работающей программы
В этом разделе ты изучил четыре идеи последовательно. Язык ассемблера даёт машинному коду читаемые имена (а ассемблер переводит их, один к одному). Языки высокого уровня позволяют одному оператору заменить множество машинных инструкций и переносимы между CPU (но требуют транслятора). Компиляторы переводят всю программу в машинный код до её запуска; интерпретаторы переводят и выполняют оператор за оператором во время выполнения. Среда выполнения — это вспомогательный механизм: сборщик мусора, стек вызовов, стандартная библиотека, виртуальная машина — всё это работает рядом с твоим кодом.
Теперь пора связать все четыре идеи в единую непрерывную цепочку. Когда ты сохраняешь исходный файл и нажимаешь «Запустить» — или вводишь команду в терминале — что на самом деле происходит, шаг за шагом, до того момента, пока CPU не выполняет твою логику? Где начинается путь и где он заканчивается?
Этот урок прослеживает этот путь для маленькой программы. К концу урока ты увидишь путь, которым следует каждая программа — независимо от её размера — с момента, когда ты закончил её писать, до момента, когда CPU выполняет первую инструкцию.
После этого урока ты сможешь описать каждый этап конвейера от исходного кода до выполнения по порядку (исходный текст → трансляция → компоновка → загрузка → запуск среды выполнения → выполнение на CPU), объяснить роль компоновщика и загрузчика ОС, а также проследить небольшую программу полностью через весь конвейер.
Этап 1: ты пишешь исходный текст. Всё начинается с тебя. Ты создаёшь текстовый
файл — файл .ts, .py или .c. Его содержимое — человекочитаемые символы: буквы,
цифры, знаки препинания, пробелы. CPU не может запустить этот текст. Это исходный
материал, а не готовый продукт.
Исходный файл кодирует твой замысел с использованием словаря и грамматики языка программирования. Это представление программы самого высокого уровня — та форма, которую люди создают и читают. Всё, что следует дальше, — это серия преобразований, которые в конечном итоге производят битовые паттерны, которые может выполнять CPU.
Этап 2: трансляция. Исходный текст должен быть преобразован в машинный код. Какой механизм выполняет это преобразование, зависит от языка:
-
Компилируемый язык (C, Rust, Go): Компилятор читает исходный файл, разбирает его, проверяет типы, оптимизирует и генерирует машинный код в объектный файл (
.oили.obj). Объектный файл содержит машинный код для функций, определённых в этом исходном файле, плюс список внешних символов, которые файл использует, но не определяет (например, функции из других файлов или стандартной библиотеки). -
Язык ассемблера (ручной ассемблер): Ассемблер читает исходный файл, ищет каждый мнемоник в таблице и генерирует машинный код в объектный файл. Один мнемоник — одна инструкция.
-
Интерпретируемый язык (Python, Ruby): Здесь нет этапа опережающей трансляции. Интерпретатор загружает и выполняет исходный файл во время выполнения (возможно, компилируя в байт-код как внутреннюю оптимизацию). Для целей этого конвейера «исходный текст» — это то, что интерпретатор получает во время выполнения.
-
Язык с байт-кодом (Java, Kotlin, C#): Компилятор читает исходный текст и генерирует байт-код — компактный, переносимый промежуточный двоичный файл — в файл
.class(Java) или.dll/.exe(C#). Байт-код позже запускается виртуальной машиной (JVM, CLR).
Этап 3: компоновка. Реальная программа почти никогда не состоит из одного
объектного файла. Она вызывает библиотечные функции (из стандартной библиотеки и
сторонних пакетов), а эти функции живут в отдельных объектных файлах. Компоновщик —
программа, которая объединяет все объектные файлы (и архивы библиотек) в один
исполняемый файл. Он разрешает все ссылки на внешние символы: если твой код
вызывает printf, компоновщик находит скомпилированный машинный код printf в
архиве стандартной библиотеки C, включает его в выходной файл и патчит адрес вызова,
чтобы вызов попал в нужное место.
После компоновки исполняемый файл — это единый двоичный файл, содержащий весь
машинный код, необходимый программе (статическая компоновка), или файл, в котором
перечислены разделяемые библиотеки, которые нужно загрузить во время выполнения
(динамическая компоновка). Динамическая компоновка распространена шире: машинный код
printf находится в разделяемой библиотеке libc.so на диске, и исполняемый файл
просто содержит ссылку на неё. ОС загружает libc.so во время выполнения, когда
программа запускается.
Для интерпретируемых языков явного этапа компоновки нет — интерпретатор разрешает импорты модулей во время выполнения.
Этап 4: загрузка. Когда ты запускаешь исполняемый файл — двойным щелчком или набрав его имя в терминале — загрузчик операционной системы берёт управление. Загрузчик:
- Читает исполняемый файл с диска.
- Создаёт новый процесс — изолированную среду выполнения с собственным виртуальным адресным пространством (иллюзия частной памяти, создаваемая ОС).
- Копирует байты машинного кода программы из исполняемого файла в ячейки памяти в адресном пространстве процесса, начиная с адреса, назначенного компоновщиком.
- Загружает необходимые разделяемые библиотеки в адресное пространство процесса.
- Настраивает стек вызовов в памяти (область, зарезервированная для кадров вызовов функций).
- Устанавливает счётчик команд (PC) на адрес первой инструкции программы.
- Передаёт управление: CPU начинает выборку с этого адреса.
После шага 7 CPU работает. Работа загрузчика выполнена.
Этап 5: запуск среды выполнения и твой код. До запуска твоей функции main (или
твоего скрипта верхнего уровня) среда выполнения выполняет собственную инициализацию:
- Для C: стартовый код (называемый
_startилиcrt0) настраивает стек вызовов, инициализирует глобальные переменные, а затем вызываетmain. - Для Python: интерпретатор CPython запускается, инициализирует Python VM, импортирует нужные модули стандартной библиотеки и начинает выполнять твой скрипт с первого оператора.
- Для Java: JVM запускается, загружает байт-код твоего класса, инициализирует среду
выполнения (GC, JIT-компилятор, загрузчик классов) и вызывает твой метод
main. - Для Node.js: движок V8 запускается, среда выполнения Node.js инициализирует цикл событий и стандартную библиотеку, после чего начинает выполнять твой файл JavaScript.
После инициализации среды выполнения твой код начинает работу. Каждый вызов функции, каждое обращение к переменной, каждое выделение памяти с этого момента обрабатывается совместно твоим кодом и средой выполнения.
Этап 6: цикл выборка-декодирование-исполнение на CPU — бесконечно. С момента установки счётчика команд на первую инструкцию CPU запускает свой цикл без остановок:
- Выборка: прочитать байты инструкции по адресу в счётчике команд из памяти.
- Декодирование: интерпретировать битовый паттерн — какой опкод? какие регистры? какой адрес?
- Исполнение: выполнить операцию (сложение, загрузка, сохранение, переход, сравнение…).
- Сдвиг PC: переместить счётчик команд на следующую инструкцию (или на адрес перехода).
- Вернуться к шагу 1.
CPU не знает и не заботится о том, что инструкция когда-то была строкой TypeScript или Python. К тому моменту, как она попадает на CPU, это просто битовый паттерн в ячейке памяти. CPU запускает свой цикл. Логика программы разворачивается как следствие конкретных битовых паттернов, которые конвейер трансляции поместил в память.
Цикл завершается, когда программа делает системный вызов ОС «exit», когда процесс уничтожается извне, или когда необработанное исключение приводит к краху.
Почему это работает
Почему конвейер разбит на так много этапов? Каждый этап выполняет свою отдельную роль, которую другие не могут выполнить. Компилятор понимает семантику языка, но не знает адресов памяти (их разрешает компоновщик). Компоновщик объединяет объектные файлы, но не создаёт процессы (это делает загрузчик ОС). Загрузчик помещает код в память, но не инициализирует языковую среду выполнения (это делает стартовый код среды выполнения). CPU выполняет инструкции, но не понимает твой исходный текст. Разделение конвейера на этапы позволяет каждому инструменту хорошо выполнять одну работу и повторно использоваться в разных проектах.
Прослеживание маленькой программы на C через полный конвейер.
Исходный файл add.c:
#include <stdio.h>
int main(void) {
int a = 3;
int b = 4;
int result = a + b;
printf("Result: %d\n", result);
return 0;
}Этап 1 — Исходный текст. Ты сохраняешь add.c. Это 95 байт ASCII-текста. CPU не
может его запустить.
Этап 2 — Компиляция. Ты запускаешь gcc -c add.c -o add.o. Компилятор:
- Разбирает исходный текст в AST.
- Определяет, что
a,bиresult— локальные целочисленные переменные (размещаются на стеке вызовов, а не в куче). - Генерирует машинный код x86-64 для тела: инструкции для создания кадра стека,
перемещения констант 3 и 4 в регистры, их сложения, вызова
printf, очистки кадра, возврата. - Записывает неразрешённую ссылку на
printf(определена в libc, не вadd.c). - Записывает машинный код и таблицу символов в
add.o.
Этап 3 — Компоновка. Ты запускаешь gcc add.o -o add. Компоновщик:
- Берёт
add.oи стандартную библиотеку C. - Находит определение
printfвlibc. - При динамической компоновке записывает, что
printfберётся изlibc.so.6, и патчит место вызова, чтобы при запуске динамический компоновщик его разрешил. - Генерирует финальный исполняемый файл
add.
Этап 4 — Загрузка. Ты запускаешь ./add. Загрузчик ОС:
- Читает ELF-заголовок
add, чтобы найти адрес и размер секции кода. - Создаёт процесс, отображает секцию кода в память (например, начиная с адреса 0x401000).
- Отображает
libc.so.6в адресное пространство процесса. - Настраивает стек вызовов по высокому виртуальному адресу.
- Устанавливает PC = 0x401040 (адрес
_start, точки входа стартового кода среды выполнения).
Этап 5 — Запуск среды выполнения. Запускается стартовый код среды выполнения C:
настраивает argc/argv, вызывает глобальные конструкторы (в данном случае их нет),
затем вызывает main. PC теперь указывает на первую инструкцию main.
Этап 6 — Выборка-декодирование-исполнение. CPU выполняет:
- Инструкция по адресу 0x401040: создание кадра стека (выделение места для
a,b,result). - Инструкция по адресу 0x401044: запись константы 3 в ячейку памяти
aна стеке. - Инструкция по адресу 0x401048: запись константы 4 в ячейку памяти
bна стеке. - Инструкция по адресу 0x40104c: загрузка
aв регистр eax, загрузкаbв edx, выполнение ADD eax, edx → eax = 7. - Инструкция по адресу 0x401054: сохранение eax (7) в ячейку стека
result. - Инструкция по адресу 0x401058: загрузка адреса строки формата и значения 7, вызов
printf. Среда выполнения разрешает адресprintf(через динамический компоновщик) и переходит туда. - Внутри
printfсреда выполнения форматирует «Result: 7\n» и делает системный вызов ОСwriteдля отправки в stdout. printfвозвращает управление.mainвыполняетreturn 0. Среда выполнения вызываетexit(0). ОС завершает процесс и освобождает его память.
От 95 байт исходного кода на C до строки «Result: 7» в терминале — вот и весь конвейер.
Конвейер состоит из шести основных этапов. Какой этап преобразует исходный текст в объектные файлы с машинным кодом? Введи 1 для «трансляция (компиляция/сборка)» или 2 для «загрузка».
Компоновщик разрешает ссылки на внешние символы между объектными файлами. Если твой код вызывает printf, но printf определён в стандартной библиотеке C, какой этап позволяет программе на самом деле вызвать printf во время выполнения? Введи 1 для «компиляция» или 2 для «компоновка».
Загрузчик ОС создаёт новый процесс и копирует байты программы с диска в память. После завершения загрузчика какой регистр определяет, какую инструкцию CPU выполнит первой? Введи 1 для «счётчик команд (PC)» или 2 для «регистр общего назначения».
В цикле выборка-декодирование-исполнение CPU выбирает байты инструкций из памяти. Когда оператор Python достигает CPU в виде машинного кода (через интерпретатор CPython), знает ли CPU, что он изначально был Python-кодом? Введи 1 для да, 0 для нет.
Для интерпретируемых языков, таких как Python, есть ли явный этап компоновки, объединяющий объектные файлы до запуска программы? Введи 1 для да, 0 для нет.
Какова роль загрузчика ОС в конвейере от исходного кода до выполнения?
Каждая программа проходит один и тот же шестиэтапный конвейер от исходного текста до выполнения на CPU:
- Исходный текст — ты пишешь человекочитаемый код в файл.
- Трансляция — компилятор, ассемблер или (во время выполнения) интерпретатор преобразует исходный текст в машинный код или байт-код.
- Компоновка — компоновщик объединяет объектные файлы и разрешает ссылки на библиотечные функции, создавая полный исполняемый файл (отсутствует для интерпретируемых языков).
- Загрузка — загрузчик ОС читает исполняемый файл с диска, создаёт процесс, копирует байты машинного кода в память и устанавливает счётчик команд на первую инструкцию.
- Запуск среды выполнения — языковая среда выполнения инициализируется (GC, виртуальная машина, стандартная библиотека) и вызывает точку входа программы.
- Выборка-декодирование-исполнение — CPU выполняет свой цикл: выбирает байты инструкции по адресу в счётчике команд, декодирует опкод и операнды, выполняет операцию, сдвигает счётчик команд, повторяет.
К моменту, когда CPU выполняет какую-либо инструкцию, исходного текста больше нет — он заменён сырыми битовыми паттернами в ячейках памяти. CPU не знает, выполняет ли он Python, C или ассемблер: он просто выполняет свой цикл. Полный смысл программы разворачивается из конкретных паттернов, которые конвейер трансляции поместил в память.