fullstack · advanced · 9d
Крошечная стековая виртуальная машина
Собери машину, которая исполняет программы. Ты определишь небольшой набор инструкций в виде байт-кода, напишешь ассемблер, превращающий читаемые мнемоники в байты, и напишешь цикл интерпретатора, который по одной достаёт, декодирует и исполняет их. К концу ты разберёшь по косточкам весь путь от строки исходника до работающей программы — потому что каждый его слой написал сам.
Результат
Работающая ВМ: ассемблер, компилирующий текстовую программу из мнемоник в плоский массив байт-кода, и интерпретатор, исполняющий этот байт-код на стеке значений — с арифметикой, условными и безусловными переходами, а также call/ret с настоящими кадрами функций.
Этапы
0/6 · 0%- 01Спроектируй набор инструкций
Прежде чем написать хоть строчку ВМ, реши, что умеет твоя машина. Набор инструкций — это контракт: каждый опкод — число, и часть опкодов несёт операнд (PUSH нужно значение, ADD не нужно ничего). Выбери горстку — для начала PUSH, POP, ADD, SUB, JMP, JZ, HALT — и запиши на бумаге или в комментарии, как именно каждая меняет стек и указатель инструкций. Это самое сложное размышление во всём проекте именно потому, что выглядит пустяком: в момент, когда ты решаешь, лежат ли операнды встроенными байтами или отдельным потоком, растёт ли стек вверх или вниз и что значит HALT, ты фиксируешь форму всего, что будет дальше. Мутный ISA рождает мутный интерпретатор.
Критерии готовности- У каждого опкода есть фиксированный числовой код и однострочное описание его влияния на стек и указатель инструкций.
- Для любого опкода ты можешь сказать, сколько операндов он берёт из байт-кода и сколько значений снимает и кладёт на стек.
- 02Цикл интерпретатора
Это бьющееся сердце: цикл с указателем инструкций, который читает опкод по указателю, понимает, что он значит, выполняет его и сдвигается дальше. Сначала реализуй арифметические и стековые опкоды — скорми ему написанный вручную массив байт-кода (ассемблера ещё нет) и посмотри, как PUSH 2, PUSH 3, ADD оставляют 5 на стеке. Здесь важна дисциплина: указатель инструкций — это всего лишь индекс в массиве, и каждая инструкция обязана оставить указатель там, где должна произойти следующая выборка. Сделай так, чтобы опустошение стека падало громко, а не молча читало мусор: пустой стек на ADD — это ошибка в исполняемой программе, и твоя ВМ должна сказать об этом, а не загадочно упасть тремя инструкциями позже.
Критерии готовности- Собранный вручную массив байт-кода из PUSH/POP/ADD/SUB доходит до HALT и оставляет ожидаемое значение на вершине стека.
- Снятие со стека при его пустоте порождает понятную ошибку с указанием позиции указателя инструкций, а не молчаливый неверный результат.
- 03Ассемблер с метками
Кодировать байт-код руками быстро надоедает, а переходы делают это невыносимым: JMP нужен числовой адрес цели, но адреса неизвестны, пока не разложишь всю программу. Классическое решение — двухпроходный ассемблер. Первый проход идёт по исходнику, назначает каждой инструкции адрес и запоминает, куда указывает каждая метка. Второй проход выдаёт байты, уже умея заменить «JMP loop» настоящим смещением. Это ровно та задача, которую в миниатюре решают настоящие ассемблеры и компоновщики, — предварительные ссылки. Когда заработает, твои переходы перестанут быть магическими числами и станут именами, и ты наконец сможешь написать цикл на собственном языке.
Критерии готовности- Текстовая программа с помеченной целью перехода ассемблируется в байт-код, где операнд перехода — разрешённый адрес.
- Переход на неопределённую метку падает на этапе ассемблирования с именем метки, а не во время выполнения с диким указателем.
- 04Ветвления и циклы
Теперь сделай поток управления настоящим. JMP напрямую задаёт указатель инструкций; JZ снимает значение и прыгает только тогда, когда оно ноль, — одного этого условного перехода достаточно, чтобы выразить любой if и любой цикл, ровно как в настоящем машинном коде. Напиши программу, которая считает вниз от N и останавливается, дойдя до нуля, и проследи её вручную по поведению своей ВМ: каждая итерация — это прыжок указателя назад, к началу цикла. Коварная ошибка, которую надо выловить, — это смещение на единицу между «сдвинь указатель, потом, может быть, прыгни» и «прыжок заменяет сдвиг»: реши, какой вариант использует твой цикл, и держись его, иначе циклы будут делать на одну итерацию больше или вовсе пропускать своё тело.
Критерии готовности- Цикл обратного отсчёта, написанный на твоём ассемблере, делает верное число итераций и останавливается.
- JZ прыгает только при нуле, иначе проваливается дальше, и оба пути оставляют стек в состоянии, обещанном твоей спецификацией.
- 05Функции с настоящими кадрами
Здесь калькулятор превращается в компьютер. CALL прыгает на адрес функции, но сначала кладёт адрес возврата; RET снимает этот адрес и прыгает назад. Как только ты поддержишь вложенные вызовы, тебе понадобится стек кадров вызова, где каждый кадр помнит, куда возвращаться и где живут локальные переменные и аргументы этой функции. Осознанно выбери соглашение о вызовах: кто кладёт аргументы, кто их убирает, где оказывается возвращаемое значение. Это ровно тот стек вызовов, о котором ты читал в трассировках, теперь собранный твоими руками, — и в момент, когда заработает рекурсия, ведь у каждого вызова свой кадр, ты поймёшь, почему неуправляемую рекурсию называют переполнением стека, а не как-то расплывчатее.
Критерии готовности- Программа определяет функцию, вызывает её через CALL с аргументом, и RET возвращает управление и результат вызывающему.
- Рекурсивная функция (например, факториал) даёт верный ответ, а бесконечная рекурсия падает как обнаруженное переполнение стека, а не как крах среды-носителя.
- 06Плоская адресуемая куча
Стек хорош для недолговечных значений, но данным, которые должны пережить вызов, нужна куча. Смоделируй её как самое простое работающее решение: один большой массив ячеек, где ALLOC выдаёт базовый индекс и размер, а LOAD/STORE читают и пишут по адресу. Без сборщика мусора — это bump-аллокатор, поэтому освобождённая память просто никогда не возвращается, и это намеренный учебный выбор. Сборка этого руками делает осязаемыми две абстракции, которые большинство программистов лишь арендуют: что указатель — это просто целочисленный индекс в памяти и что массивы и объекты — это раскладка поверх непрерывных ячеек. Сделай так, чтобы STORE по адресу за границами был пойманной ошибкой ВМ, и ты проведёшь чёткую границу между контролируемым сбоем и неопределённым поведением, которое выдала бы настоящая машина.
Критерии готовности- Программа выделяет блок через ALLOC, пишет в него значения через STORE, читает их обратно через LOAD, и значения возвращаются без искажений.
- LOAD или STORE вне любого выделенного блока сообщается как ошибка ВМ с указанием неверного адреса, а не как тихое повреждение данных.
Стартер
- README.md
- src/vm.ts
- test/vm.test.ts
Распакуй, реализуй заглушки, затем гоняй тесты, пока не позеленеют: bun test
Рубрика
| Джуниор | Миддл | Сеньор | |
|---|---|---|---|
| Проектирование набора инструкций | Определяет горстку опкодов с числовыми кодами и реализует PUSH, POP, ADD, SUB и HALT; спецификация неявная (обнаруживается чтением исходника интерпретатора). | Пишет однострочную формальную спецификацию для каждого опкода (эффект на стек и дельту IP до написания интерпретатора), разделяет встроенные операнды и стековые, и умеет объяснить компромисс кодировки: инструкции фиксированной ширины проще декодировать, но тратят место; переменной ширины экономят байты, но требуют таблицы длин. | Рассуждает о накладных расходах dispatch: цикл switch/case имеет ветку на каждый опкод; таблица computed-goto (или её эквивалент) превращает dispatch в косвенный прыжок. Умеет оценить стоимость dispatch инструкций как долю суммарного времени выполнения в плотном арифметическом цикле и знает, когда bytecode dispatch становится узким местом относительно стоимости самих операций. |
| Дисциплина стека и обработка ошибок | ВМ падает с исключением выхода за границы индекса от языка-носителя при опустошении стека вместо сообщения ошибки уровня ВМ. | Перехватывает опустошение и переполнение стека на уровне ВМ, сообщает об указателе инструкций и опкоде при сбое и различает баг реализации ВМ и ошибку программы (опустошение — всегда ошибка гостевой программы, а не ВМ). | Обнаруживает нарушения глубины стека на уровне кадров вызова: неограниченная рекурсия сообщается как переполнение стека (превышено число кадров) до того, как сам хост-процесс переполнится. Умеет описать режим отказа при отсутствии проверки: гостевая программа может переполнить стек вызовов хоста, обвалив интерпретатор с неконтролируемым исключением, утекающим внутрь ВМ. |
| Двухпроходный ассемблер и разрешение меток | Ассемблирует программы без предварительных ссылок, требуя определения меток до их использования; переходы назад работают, вперёд — нет. | Реализует двухпроходный ассемблер: первый проход назначает адреса и строит таблицу меток; второй выдаёт байты с разрешёнными адресами переходов. Переходы по предварительным ссылкам работают, неопределённая метка вызывает ошибку при ассемблировании с именем метки в сообщении, а не во время выполнения с диким адресом. | Умеет объяснить, почему настоящие компоновщики используют ту же двухпроходную модель и где вступают перемещения: в многообъектном сценарии каждый объект ассемблируется независимо, создавая таблицу перемещений с неразрешёнными ссылками; компоновщик — это проход, заполняющий их по глобальной таблице символов. Знает, почему абсолютный адрес в байт-коде нарушает позиционно-независимый код, и умеет схематично описать, как запись перемещения во время загрузки это решает. |
Эталонный разбор (спойлер)
Модель стековой машины: операнды помещаются на стек значений; инструкции потребляют с вершины и кладут результаты обратно. Нет именованных регистров — каждое промежуточное значение живёт на стеке. Это делает интерпретатор тривиально простым (ADD снимает два, кладёт сумму) и кодировку инструкций минимальной (нет полей регистров), но вводит косвенность: даже простое 'a + b * c' требует четырёх стековых операций против одной регистр-регистр на RISC-машине.
Раскладка кадра вызова: при выполнении CALL на стек кладутся адрес возврата и указатель кадра, выделяется место для локальных переменных и передаётся управление. RET восстанавливает указатель кадра, снимает адрес возврата и прыгает назад. Кадр — единица рекурсии: каждый рекурсивный вызов получает свой кадр, поэтому бесконечная рекурсия переполняет стек вызовов (каждый кадр имеет ненулевой размер), а не зацикливается вечно.
Накладные расходы dispatch байт-кода: наивный интерпретатор switch/case тратит промах предсказания ветвей на каждом переходе между опкодами. Основной eval-цикл CPython использует таблицу computed-goto (расширение GCC), превращая каждый dispatch в прямой косвенный прыжок, снижая штраф за промах. HotSpot JVM использует inline caching и в конечном счёте JIT-компилирует горячие последовательности байт-кода в нативный код, делая накладные расходы dispatch асимптотически нулевыми для горячих путей.
Указатель как целое число: в этой ВМ адрес кучи — просто целочисленный индекс в массиве кучи. Именно так работают настоящие указатели в аппаратуре — указатель это целое число, которое контроллер памяти использует для адресации ячейки. Абстракция «указатели — особый тип» навязывается языком, а не аппаратурой. Проверка границ перед каждым LOAD/STORE — это то, что отделяет контролируемую ошибку ВМ ('bad address') от неопределённого поведения разыменования указателя за границами на настоящей машине.
Сделай по-сеньорски
- Добавь дизассемблер, превращающий байт-код обратно в читаемые мнемоники, и пошаговый режим трассировки, печатающий после каждой инструкции указатель инструкций, опкод и весь стек, — собственный отладчик для собственной машины.
- Замени bump-аллокатор настоящим списком свободных блоков: опкод FREE возвращает блоки в пул, а ALLOC переиспользует их; затем намеренно фрагментируй кучу и посмотри, как выделение начинает срываться, — первый урок о том, почему управление памятью так трудно.
- Напиши крошечный фронтенд компилятора, превращающий однострочный язык выражений (например, «2 + 3 * 4») в твой байт-код, чтобы получить полный конвейер исходник → AST → байт-код → результат и почувствовать, где на самом деле решаются приоритет и порядок вычислений.