open atlas
← Все проекты

fullstack · advanced · 9d

Крошечная стековая виртуальная машина

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

Почти всё, что ты запускаешь, стоит поверх виртуальной машины, которую ты никогда не видишь, — JVM, CLR, интерпретаторов байт-кода внутри Python, Ruby и Lua, движка WebAssembly в твоём браузере. Они куда крупнее этой, но сделаны ровно из тех же частей: набор инструкций, цикл «выборка-декодирование-исполнение», стек, кадры вызова и куча. Сборка крошечной снимает покров тайны. После этого трассировка стека — это структура данных, которую ты реализовал, segfault — это проверка границ, которую ты забыл написать, а «компиляция» — это двухпроходный обход текста, проделанный твоими руками. Ты перестаёшь пользоваться этими абстракциями на веру и начинаешь понимать их снизу вверх — а в этом и есть весь смысл информатики с нуля.

Результат

Работающая ВМ: ассемблер, компилирующий текстовую программу из мнемоник в плоский массив байт-кода, и интерпретатор, исполняющий этот байт-код на стеке значений — с арифметикой, условными и безусловными переходами, а также call/ret с настоящими кадрами функций.

Этапы

0/6 · 0%
  1. 01Спроектируй набор инструкций

    Прежде чем написать хоть строчку ВМ, реши, что умеет твоя машина. Набор инструкций — это контракт: каждый опкод — число, и часть опкодов несёт операнд (PUSH нужно значение, ADD не нужно ничего). Выбери горстку — для начала PUSH, POP, ADD, SUB, JMP, JZ, HALT — и запиши на бумаге или в комментарии, как именно каждая меняет стек и указатель инструкций. Это самое сложное размышление во всём проекте именно потому, что выглядит пустяком: в момент, когда ты решаешь, лежат ли операнды встроенными байтами или отдельным потоком, растёт ли стек вверх или вниз и что значит HALT, ты фиксируешь форму всего, что будет дальше. Мутный ISA рождает мутный интерпретатор.

    Критерии готовности
    • У каждого опкода есть фиксированный числовой код и однострочное описание его влияния на стек и указатель инструкций.
    • Для любого опкода ты можешь сказать, сколько операндов он берёт из байт-кода и сколько значений снимает и кладёт на стек.
  2. 02Цикл интерпретатора

    Это бьющееся сердце: цикл с указателем инструкций, который читает опкод по указателю, понимает, что он значит, выполняет его и сдвигается дальше. Сначала реализуй арифметические и стековые опкоды — скорми ему написанный вручную массив байт-кода (ассемблера ещё нет) и посмотри, как PUSH 2, PUSH 3, ADD оставляют 5 на стеке. Здесь важна дисциплина: указатель инструкций — это всего лишь индекс в массиве, и каждая инструкция обязана оставить указатель там, где должна произойти следующая выборка. Сделай так, чтобы опустошение стека падало громко, а не молча читало мусор: пустой стек на ADD — это ошибка в исполняемой программе, и твоя ВМ должна сказать об этом, а не загадочно упасть тремя инструкциями позже.

    Критерии готовности
    • Собранный вручную массив байт-кода из PUSH/POP/ADD/SUB доходит до HALT и оставляет ожидаемое значение на вершине стека.
    • Снятие со стека при его пустоте порождает понятную ошибку с указанием позиции указателя инструкций, а не молчаливый неверный результат.
  3. 03Ассемблер с метками

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

    Критерии готовности
    • Текстовая программа с помеченной целью перехода ассемблируется в байт-код, где операнд перехода — разрешённый адрес.
    • Переход на неопределённую метку падает на этапе ассемблирования с именем метки, а не во время выполнения с диким указателем.
  4. 04Ветвления и циклы

    Теперь сделай поток управления настоящим. JMP напрямую задаёт указатель инструкций; JZ снимает значение и прыгает только тогда, когда оно ноль, — одного этого условного перехода достаточно, чтобы выразить любой if и любой цикл, ровно как в настоящем машинном коде. Напиши программу, которая считает вниз от N и останавливается, дойдя до нуля, и проследи её вручную по поведению своей ВМ: каждая итерация — это прыжок указателя назад, к началу цикла. Коварная ошибка, которую надо выловить, — это смещение на единицу между «сдвинь указатель, потом, может быть, прыгни» и «прыжок заменяет сдвиг»: реши, какой вариант использует твой цикл, и держись его, иначе циклы будут делать на одну итерацию больше или вовсе пропускать своё тело.

    Критерии готовности
    • Цикл обратного отсчёта, написанный на твоём ассемблере, делает верное число итераций и останавливается.
    • JZ прыгает только при нуле, иначе проваливается дальше, и оба пути оставляют стек в состоянии, обещанном твоей спецификацией.
  5. 05Функции с настоящими кадрами

    Здесь калькулятор превращается в компьютер. CALL прыгает на адрес функции, но сначала кладёт адрес возврата; RET снимает этот адрес и прыгает назад. Как только ты поддержишь вложенные вызовы, тебе понадобится стек кадров вызова, где каждый кадр помнит, куда возвращаться и где живут локальные переменные и аргументы этой функции. Осознанно выбери соглашение о вызовах: кто кладёт аргументы, кто их убирает, где оказывается возвращаемое значение. Это ровно тот стек вызовов, о котором ты читал в трассировках, теперь собранный твоими руками, — и в момент, когда заработает рекурсия, ведь у каждого вызова свой кадр, ты поймёшь, почему неуправляемую рекурсию называют переполнением стека, а не как-то расплывчатее.

    Критерии готовности
    • Программа определяет функцию, вызывает её через CALL с аргументом, и RET возвращает управление и результат вызывающему.
    • Рекурсивная функция (например, факториал) даёт верный ответ, а бесконечная рекурсия падает как обнаруженное переполнение стека, а не как крах среды-носителя.
  6. 06Плоская адресуемая куча

    Стек хорош для недолговечных значений, но данным, которые должны пережить вызов, нужна куча. Смоделируй её как самое простое работающее решение: один большой массив ячеек, где ALLOC выдаёт базовый индекс и размер, а LOAD/STORE читают и пишут по адресу. Без сборщика мусора — это bump-аллокатор, поэтому освобождённая память просто никогда не возвращается, и это намеренный учебный выбор. Сборка этого руками делает осязаемыми две абстракции, которые большинство программистов лишь арендуют: что указатель — это просто целочисленный индекс в памяти и что массивы и объекты — это раскладка поверх непрерывных ячеек. Сделай так, чтобы STORE по адресу за границами был пойманной ошибкой ВМ, и ты проведёшь чёткую границу между контролируемым сбоем и неопределённым поведением, которое выдала бы настоящая машина.

    Критерии готовности
    • Программа выделяет блок через ALLOC, пишет в него значения через STORE, читает их обратно через LOAD, и значения возвращаются без искажений.
    • LOAD или STORE вне любого выделенного блока сообщается как ошибка ВМ с указанием неверного адреса, а не как тихое повреждение данных.

Стартер

  • README.md
  • src/vm.ts
  • test/vm.test.ts
Скачать стартер (.zip)

Распакуй, реализуй заглушки, затем гоняй тесты, пока не позеленеют: 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 → байт-код → результат и почувствовать, где на самом деле решаются приоритет и порядок вычислений.

Навыки

designing a bytecode instruction setwriting a two-pass assembler with label resolutionimplementing a fetch-decode-execute loopmanaging a value stack and an instruction pointerimplementing call/ret with stack framesmodelling a flat addressable heap

Рекомендуемый стек

any language with arrays and integers (JS/TS, Python, Go, or C)a plain text editor for .asm test programs