WiLang – это компактная экспериментальная виртуальная машина (VM) для исполнения байткода простого языка программирования. Проект нацелен на энтузиастов-разработчиков, интересующихся внутренним устройством интерпретаторов и виртуальных машин. WiLang реализует собственный набор инструкций (байткод) и модель выполнения, позволяющую выполнять базовые арифметические операции, управление потоком исполнения, работу с переменными, простейший ввод-вывод и даже поддерживает функции. Цель виртуальной машины – предоставить простую платформу, на которой можно изучать принципы работы стековых машин и создавать программы на игрушечном языке WiLang в байткоде. Проект находится в стадии разработки, но уже демонстрирует ключевые идеи архитектуры виртуальной машины и исполнения пользовательских программ.
WiLang написан на языке C# и требует установленной платформы .NET для сборки и запуска. Проект нацеливается на современные версии .NET (например, .NET 8.0 и выше). Для запуска виртуальной машины выполните следующие шаги:
-
Получение исходного кода: Убедитесь, что у вас есть файлы проекта WiLang (репозиторий содержит решение Visual Studio и исходники).
-
Сборка проекта: Откройте файл решения
WiLang.slnв Visual Studio 2022 (или более новой) либо соберите проект с помощью командной строки. Для сборки через CLI перейдите в директориюWiLang/WiLangи выполните команду:dotnet build
Убедитесь, что установлен соответствующий SDK .NET (проект может требовать .NET 8.0 или .NET 9.0 в зависимости от конфигурации). При успешной сборке появится исполняемый файл
WiLang.dll(или exe) в директорииWiLang/WiLang/bin/Debug/net8.0/(или аналогичной под вашу целевую платформу .NET). -
Запуск виртуальной машины: После сборки вы можете запустить примеры. Проект содержит точку входа (класс
Program), которая демонстрирует работу VM. Выполните:dotnet run --project WiLang/WiLang/WiLang.csproj
По умолчанию запускается встроенный пример (подробно о нем в следующем разделе). Вы должны увидеть вывод в консоли, например измерение времени выполнения цикла.
При необходимости вы можете изменить или добавить собственный код, вызывающий виртуальную машину, и снова выполнить сборку. Внешних зависимостей у проекта нет – достаточно установленного .NET SDK.
На данный момент WiLang не имеет полного высокоуровневого синтаксического фронтенда (парсера языка), поэтому программы для этой VM задаются непосредственно в виде последовательности инструкций байткода. Проще говоря, чтобы выполнить "программу", нужно вручную подготовить массив инструкций Instruction и передать его виртуальной машине на исполнение.
Рассмотрим минимальный пример: сложение двух чисел и вывод результата. Мы создадим набор инструкций, которые складывают 5 и 3, печатают сумму и останавливают выполнение:
using WiLang;
Instruction[] program = {
new Instruction(It.PUSH, 5), // Поместить число 5 на стек
new Instruction(It.PUSH, 3), // Поместить число 3 на стек
new Instruction(It.ADD), // Снять два верхних числа со стека, сложить и результат положить на стек
new Instruction(It.PRINT), // Взять верхнее значение со стека и вывести на консоль
new Instruction(It.HALT) // Остановить выполнение программы
};
var vm = new VM();
vm.Execv(program);Запустив такой код, виртуальная машина выполнит следующие шаги:
- PUSH 5: кладет число 5 в стек VM. Сейчас на вершине стека значение 5.
- PUSH 3: кладет число 3 в стек. Теперь стек содержит 5 (внизу) и 3 (на вершине).
- ADD: снимает со стека два верхних значения (5 и 3), складывает их (получается 8) и результат 8 помещает обратно в стек. Теперь вершина стека равна 8.
- PRINT: берет верхнее значение из стека (8) и выводит его на экран (консоль). Стек при этом обычно остаётся с этим значением (команда PRINT заглядывает в вершину без удаления либо удаляет – в данной реализации значение просто считывается без необходимости повторного использования). Вы увидите в консоли напечатанное число
8. - HALT: останавливает работу виртуальной машины. Цикл исполнения прерывается, программа завершена.
Этот простой пример показывает, как можно вручную задать последовательность команд для VM WiLang. Несмотря на отсутствие парсера, подобным образом можно конструировать и более сложные "программы" – например, циклы или условия – оперируя на уровне байткода. Ниже мы рассмотрим, как именно виртуальная машина работает внутри, и какие инструкции доступны.
Виртуальная машина WiLang реализована по классической схеме стековой машины. Ниже описаны основные компоненты и принципы работы VM:
- Байткод и инструкции: Программа для WiLang представляется массивом структур
Instruction. Каждая инструкция содержит код операции (opcode), определенный в перечисленииIt(Instruction Type), и необязательный аргумент. Аргумент (операнд) может быть значением (числом, строкой и т.д.), адресом перехода или именем переменной – в зависимости от конкретной инструкции. В текущей реализации байткод хранится в виде объектов C# (а не бинарного формата), но концептуально это последовательность команд виртуального процессора. - Цикл исполнения: VM читает и выполняет инструкции последовательно с помощью счетчика инструкций (
ip– instruction pointer). Внутри метода исполнения (Execv) организован цикл: пока указательipне достиг конца программы или явной остановки, VM берет очередную инструкцию, декодирует ее и выполняет соответствующее действие. Для реализации выбора действия используется большойswitchпо коду операции. После выполнения командыipобычно увеличивается на 1, переходя к следующей инструкции, либо изменяется явным образом в случае команд перехода/вызова. - Стек операций: Основной механизм вычислений – стек. WiLang VM имеет внутренний стек (структура
WiStack<Variable>) для хранения временных значений во время исполнения программы. Инструкции типаPUSHпомещают данные на стек, арифметические и логические операции снимают операнды со стека и кладут результат обратно. Стек работает по принципу LIFO (последним пришел – первым вышел). Размер стека по умолчанию ограничен (например, 1024 элементов), что можно изменить в коде, но при переполнении или недоступности нужного количества элементов VM выбросит исключение. - Представление данных: WiLang поддерживает несколько типов значений: целые числа, числа с плавающей запятой, строки и списки. Все значения в стеке и переменных хранятся в унифицированном формате
Variable– это структура, содержащая поле типа (VarType– код типа из enumTypes, например TInteger, TFloat, TString, TList) и собственно значение (полеobject Value, которое указывает на реальный объект, напримерint,float,stringилиList<Variable>). Такой вариант можно считать простой динамической типизацией на уровне VM: каждое значение несет информацию о своем типе, и операции в рантайме проверяют типы операндов. Для удобства, в коде определены обобщенные типы-обертки (WiNumber,WiFloat,WiString,WiList<T>) и соответствующие производные отWiValue(например,WiNumberValue,WiStringValueи т.д.) с перегрузкой операторов неявного преобразования. Благодаря этому, при создании инструкции можно просто передать стандартный тип (например,intили строку) – он автоматически будет упакован в соответствующий WiLang-тип. Внутри VM же вся логика оперирует через структуруVariableи перечисление типов. - Арифметика и логика: Виртуальная машина реализует основные арифметические операции над числами. Интересной особенностью является автоматическое приведение типов при некоторых операциях. Например, если сложить целое и число с плавающей точкой, VM приведет целое к дробному и получит результат как
float. Операция сложения (ADD) перегружена также для строк и списков: сложение строк приводит к их конкатенации, а сложение списка с чем-либо добавляет элемент в список (или объединяет два списка). Такие возможности заложены перегрузкой операторов для структурыVariable– например,operator+проверяет типы операндов и выполняет соответствующее действие. В случае недопустимой операции (например, попытка поделить строку на число) VM выбросит исключение с сообщением об ошибке. - Память переменных: Помимо стека, VM имеет простейшую память для хранения переменных. Это словарь
Dictionary<string, Variable>– по сути, ассоциативный массив, где ключом выступает имя переменной (строка), а значением – структура Variable (значение с типом). ИнструкцияSTOREсохраняет верхнее значение из стека в переменную с заданным именем (берет имя как аргумент), а инструкцияLOADзагружает значение переменной в стек (по имени из аргумента). Переменные в WiLang являются глобальными и типизируются динамически по значению (то есть можно в одну переменную присвоить число, потом строку – тип хранится вместе со значением). Если попытаться загрузить неинициализированную переменную, VM сообщит об ошибке. - Вызовы функций: Для поддержки подпрограмм WiLang предоставляет инструкции
CALLиRET. Реализован простой механизм без параметров вызова: инструкцияCALLпринимает в качестве аргумента адрес (индекс) начала функции в массиве байткода и осуществляет переход на этот адрес, предварительно сохранив адрес возврата (текущийip + 1) в специальном стеке возвратов. Стек вызовов (callStack) хранит номера инструкций, на которые нужно вернуться после окончания функции. ИнструкцияRETвыполняет возврат – извлекает последний сохраненный адрес из стека вызовов и устанавливает его как текущийip, продолжая выполнение уже после места вызова. Таким образом, можно организовывать простые функции: например, основная программа вызываетCALLна набор инструкций, оканчивающихсяRET. Аргументы и возвращаемое значение функции можно передавать через основной стек: поместив требуемые данные перед вызовом и прочитав результат из стека после возврата (аналогично соглашению вызова через стек). Следует отметить, что локальных переменных или изолированного пространства имен для функций нет – используется общий стек и общие глобальные переменные. - Управление потоком (переходы): В VM имеются инструкции условных и безусловных переходов для реализации логики ветвлений и циклов.
JUMPвыполняет безусловный переход на указанный в аргументе номер инструкции (изменяетipна новое значение, продолжая исполнение оттуда).JZиJNZ– условные переходы, зависящие от флага/значения на вершине стека. Эти инструкции извлекают верхнее значение из стека (обычно это результат какого-то сравнения или вычисления) и интерпретируют его как условие:JZ(Jump if Zero) прыгает на заданный адрес, если извлеченное значение равно нулю (или эквивалентно "false"), аJNZ(Jump if Not Zero) – если значение ненулевое ("true"). Таким образом реализуются конструкции вродеif(проверкой значения и переходом через блок кода) илиwhile(прыжком назад к началу цикла). В текущей реализации булевых типов как таковых нет, поэтому за ложь/истину отвечают числовые значения (0 как ложь, 1 или любое ненулевое как истина), получаемые, например, результатом сравнения. Инструкции сравнения (LT,GTи пр.) как правило оставляют на стеке 0 или 1, подходящие для последующегоJZ/JNZ. При некорректном использовании (например, если аргумент перехода не указан или не число) VM бросит исключение. КомандаHALTбезусловно останавливает выполнение программы, мгновенно выходя из цикла исполнения.
Архитектура WiLang довольно минималистична: в ней отсутствуют сложные оптимизации или JIT-компиляция – каждая инструкция интерпретируется последовательно. Тем не менее, благодаря использованию неявных преобразований и агрессивногоInlining (атрибуты MethodImplOptions.AggressiveInlining в коде) выполнение работает достаточно быстро для учебных примеров. Главным выигрышем простоты архитектуры является ее прозрачность – разработчики могут легко проследить, как управляются данные через стек, как хранятся переменные и выполняются функции.
Несмотря на статус "work in progress", виртуальная машина WiLang уже поддерживает существенный набор возможностей, позволяющий создавать нетривиальные программы. Ниже перечислены основные инструкции байткода и их назначение (разбитые по категориям):
-
Арифметические операции: –
ADD– сложение: снимает два верхних числа со стека, складывает и помещает результат обратно. Поддерживает целые и вещественные числа, а также конкатенацию строк и объединение списков. –SUB– вычитание: вычитает второе число в стеке из первого (то есть выполняетa - b, гдеaбыло нижеbв стеке). –MUL– умножение двух чисел (аналогично, берет два верхних числа). –DIV– деление одного числа на другое. Если оба операнда целые, выполняется целочисленное деление. При наличии дробного операнда выполняется деление с плавающей точкой. Деление на ноль не обрабатывается специально (приведет к исключению .NET). –MOD– остаток от деления (только для целых чисел). –POW– возведение в степень. (Эта инструкция объявлена в коде, но пока не реализована в VM.) –SQRT– извлечение квадратного корня: берет число (int или float) с вершины стека, вычисляетsqrtи кладет результат (тип результата всегда дробный,TFloat). Если попытаться взять корень из строкового или другого недопустимого типа, возникнет ошибка. -
Инкремент/декремент: –
INC– увеличить на 1: снимает верхний элемент-число, добавляет 1 (сохраняя тип – целые увеличиваются как целые, вещественные как вещественные) и возвращает в стек. –DEC– уменьшить на 1: аналогично, вычитает 1 из числа. -
Операции сравнения: –
LT,GT– сравнение "<" (меньше) и ">" (больше). Берут два значения из стека (aиb) и сравниваютaсb. Если условие выполняется, на стек кладется 1 (истина), иначе 0 (ложь). Например, послеLTвершиной стека будет 1, еслиa < b. Поддерживается сравнение чисел (целых и дробных; при необходимости целые автоматически сравниваются как дробные). Для строк сравнение выполняется лексикографически. Если сравнение неприменимо (например, список с числом), может быть ошибка. –LE,GE– сравнение "≤" (меньше либо равно) и "≥" (больше либо равно). Работают аналогично, возвращая 1 или 0 на вершину стека. –EQ,NE– проверка равенства и неравенства. Сравнивают два верхних значения на стеке. Возвращают 1, если равны (EQ) или если не равны (NE). Для разных типов значений неявного приведения нет – разные типы считаются не равными (например, число 5 и строка "5" будут не равны без предварительного приведения). -
Работа со стеком: –
PUSH– поместить значение в стек. Аргументом может быть литерал (число, строка и т.д.) или сложный объект. Например,PUSH 10кладет 10,PUSH "hello"кладет строку,PUSHс аргументом-списком кладет список. Без аргумента использование недопустимо. –POP– удалить верхнее значение из стека (без использования его). Применяется, например, чтобы отбросить ненужный результат. Если стек пуст, возникает ошибка. –DUP– дублировать вершину стека. Берет текущий верхний элемент и помещает его копию наверх. ПослеDUPстек увеличится на один элемент, причем два верхних будут идентичны. Это полезно, например, для повторного использования значения без его удаления. -
Переменные и память: –
STORE– сохранение переменной. Берет верхнее значение из стека и сохраняет его в переменную с именем, указанным в аргументе инструкции. Пример:STORE "x"вынимает из стека значение и кладет его в переменнуюx. После этого стек на единицу короче, а переменнаяxхранит сохраненное значение (с его типом). –LOAD– загрузка переменной. Аргументом является имя переменной. Инструкция находит значение переменной в словаре и помещает копию этого значения на вершину стека. Например,LOAD "x"найдет переменнуюxи загрузит ее значение в стек. Если переменная не определена, выполнение прерывается с ошибкой. -
Управление потоком выполнения: –
JUMP– безусловный переход. Аргумент – индекс инструкции (обычно рассчитывается от начала программы, начиная с 0). Когда VM встречаетJUMP, она устанавливает счетчикipна заданное значение, и следующий шаг исполнения продолжится с этой инструкции. Используется для переходов (goto) или организации циклов. –JZ– условный переход (Jump if Zero). Проверяет состояние вершины стека: если значение равно 0 (либо считается ложным), то выполняется переход на адрес, указанный в аргументе. Иначе исполнение просто продолжается со следующей командой. Обычно передJZставится инструкция сравнения или вычисления условия, оставляющая 0 или 1 на стеке. Обратите внимание:JZпотребляет значение условия из стека (оно извлекается). –JNZ– условный переход (Jump if Not Zero). ПротивоположностьJZ: переходит по указанному адресу, если снятое со стека значение не равно 0 (истина). Если значение 0, переход не выполняется. Эта инструкция позволяет организовать, например, циклыwhile(прыжок назад, пока условие истинно). (В текущей версии VM инструкцияJNZприсутствует в enum, однако реализация может быть не завершена, поэтому для условного перехода используется в основномJZс инверсией логики условия.) –HALT– останов. Безусловно завершает выполнение программы. При выполнении этой инструкции VM прерывает цикл интерпретации независимо отip. Рекомендуется всегда ставитьHALTв конец программы (если, конечно, программа не заканчивается переходом в другое место), чтобы гарантировать корректный выход. -
Вызовы функций: –
CALL– вызов подпрограммы. Аргументом служит адрес (индекс) начала функции в байткоде. При выполненииCALLвиртуальная машина помещает в стек вызовов адрес следующей инструкции (то есть точку, куда нужно вернуться), затем устанавливаетipна значение аргумента – начинается выполнение кода функции. По сути,CALL– это прыжок с сохранением точки возврата. –RET– возврат из подпрограммы. Берет последний сохраненный адрес из стека вызовов и устанавливает его как текущийip. Таким образом, послеRETвыполнение продолжится с инструкции, следующей за темCALL, из которого произошел переход. Если стек вызовов пуст (нет адреса для возврата), происходит ошибка. Обычно каждая функция должна завершаться инструкциейRET, чтобы вернуть управление. -
Ввод-вывод: –
PRINT– вывести значение. Берет верхний элемент стека (не удаляя его) и печатает его на консоль. Для чисел происходит вывод числа, для строки – вывод текста без кавычек, для списка – выводится его текстовое представление (например,[1, 2, 3]). После PRINT значение остается на стеке (в реализации WiLang оно просто читается). –INPUT– ввод строки. Останавливает исполнение, ожидая ввод текста пользователем (из стандартного ввода, консоли). Считанная строка (без символа новой строки) помещается как значение типа TString на вершину стека. Если пользователь просто нажал Enter, вернется пустая строка"". С помощьюINPUTможно организовать простейшие интерактивные программы, где, например, введенное значение потом преобразуется в число (черезCASTI) и участвует в вычислениях. -
Преобразование типов: –
CASTI– (cast to int) преобразовать в целое. Снимает верхнее значение стека и пытается привести его к типу Integer. Если значение было числом с плавающей точкой, оно округляется/отсекается до целого; если строка – парсится как число (поддерживается только формат целого числа); если список или несовместимый тип – произойдет ошибка. Результат (целое число) кладется на стек. –CASTF– преобразовать в число с плавающей запятой (Float). Работает аналогично: например, строку пытается разобрать как число с десятичной точкой, целое преобразует к дробному (5 -> 5.0). –CASTS– преобразовать в строку. Любое значение превращается в строковое представление. Числа станут строками их десятичного представления, булевые 0/1 – "0" или "1", списки – форматом вроде[elem1, elem2, ...]. Эта операция удобна для вывода на печать сложных значений или конкатенации черезADD(сложение строк). -
Операции со списками: (Поддержка списков находится в начальной стадии, и инструкции могут быть недоработаны.) –
LISTGET– получить элемент списка по индексу. Предполагается, что эта инструкция возьмет из стека индекс (целое число) и сам список, и вернет запрошенный элемент. Например,LISTGETпри вершине стека0и под ним списке[10,20,30]должна поместить в стек число10(элемент под индексом 0). На данный момент реализация может отсутствовать или работать не полностью. –LISTPUT– поместить элемент в список по индексу. Ожидаемое действие: взять из стека значение и список, поместить значение в список по заданному индексу (из аргумента или из стека) или в конец. В текущей версии не реализовано. –LISTRM– удалить элемент из списка. Должна удалять элемент по индексу или совпадающий со значением. Также, скорее всего, пока не реализовано.
Как видно, набор возможностей достаточно широк для экспериментов: можно создавать арифметические вычисления, использовать условия (EQ/JZ и прочие), циклы (JUMP назад и условие), хранить и считывать переменные, определять простейшие функции и вызывать их, работать со строками (ввод/вывод, конкатенация) и даже начинать работу со структурами данных (списки).
Стоит подчеркнуть, что WiLang – стековая виртуальная машина: большинство команд берут операнды из стека и возвращают результат в стек, что упрощает модель исполнения (не нужно адресовать регистры напрямую). Это приближает WiLang к классическому байткоду, наподобие байткода JVM или .NET IL, хотя набор команд гораздо проще.
Проект WiLang находится в стадии разработки, поэтому имеются определенные ограничения и области для будущего улучшения:
-
Отсутствие полноценного языка и парсера: На данный момент нет реализованного компилятора или интерпретатора высокого уровня для WiLang. Пользователь не может писать код на специальном синтаксисе и запускать его напрямую. Существуют заделы под лексер/токенайзер (в коде есть пространство имен
Tokenizer), но они помечены как TODO. В планах разработчиков – реализовать разбор исходного текста языка WiLang (возможно, синтаксис будет похож на Си-подобный или псевдокод), который будет транслироваться в байткод для виртуальной машины. Пока этого нет, программы приходится составлять вручную в виде последовательности инструкций, как показано в примерах. -
Не полностью реализованные инструкции: Некоторые инструкции, заявленные в перечислении opcode, еще не имеют реализации или не отлажены. Например,
JNZупомянут, но логика условия "не ноль" может не обрабатываться (в текущей версии можно обходиться комбинациейEQиJZдля аналогичного эффекта). ТакжеPOW(возведение в степень) и операции со списками (LISTGET,LISTPUT,LISTRM) пока фактически не работают или не проверены. Эти инструкции обозначены на будущее – очевидно, планируется добавить поддержку операций над списками (доступ по индексу, модификация) и математическую функцию возведения в степень. В текущей версии при попытке их использования VM скорее всего выбросит исключение "неизвестная инструкция" или просто не имеет соответствующего кейса обработки. -
Ограниченный набор типов: WiLang поддерживает лишь четыре базовых типа данных (integer, float, string, list). Отсутствуют, например, булевый тип (вместо него используются числовые 0/1), символы/char, словари/ассоциативные массивы (хотя списки могут частично заменить). Расширение набора типов возможно в будущем – например, введение отдельного boolean-типа или поддержка пользовательских структур – но это потребует изменений в системе
Variableи инструкциях. Пока упор сделан на минимально достаточный набор типов для демонстрации работы VM. -
Глобальные переменные и отсутствие областей видимости: Все переменные хранятся в одном глобальном словаре. Виртуальная машина не разделяет область видимости между функциями или блоками – любой
STORE/LOADвсегда относится к глобальному имени. Это упрощение, подходящее для малого языка, но в будущем, с развитием проекта, возможно появление локальных переменных или передачи параметров в функции через стек. Это может потребовать реализации активационных записей (stack frame) или другой модели, если проект усложнится. -
Обработка ошибок и стабильность: В текущем состоянии WiLang в основном полагается на выбрасывание исключений при неверном использовании (например, стек пуст, неправильный тип операнда, неизвестное имя переменной и т.д.). Эти сообщения полезны разработчику, но пользовательского механизма обработки ошибок нет. В планах может быть улучшение сообщений об ошибках, ввод проверок (например, предотвращение выхода за границы списка, проверка деления на ноль) и, возможно, реализация инструкций для безопасных операций.
-
Производительность и оптимизации: Хотя VM уже способна выполнять миллионы инструкций (цикл на 10 млн итераций демонстрируется в примере
Program.csи работает довольно быстро), есть пространство для оптимизации. Например, можно реализовать более эффективную технику диспетчеризации команд (вместо большогоswitch, который JIT-компилятор .NET, правда, уже оптимизирует, можно рассмотреть таблицу делегатов или прямую адресацию – т.н. threaded code). Также, возможно, планируется оптимизация работы со стеком (например, динамическое расширение вместо фиксированного размера, или использование Span<T> для операций) и с переменными (введение адресации переменных по индексу вместо имени для ускорения, хотя для маленьких программ это не критично). В исходном README проекта упомянуты "Some optimizations" в списке TODO – вероятно, автор рассматривает подобные улучшения производительности. -
Документация и примеры: Как и многие молодые проекты, WiLang пока не имеет обширной документации. Данный README призван заполнить этот пробел. В будущем, по мере добавления новых функций (например, когда появится парсер языка), необходимо будет описать синтаксис WiLang, добавить больше примеров использования, возможно, написать подробное руководство или wiki. На данный момент энтузиастам предлагается изучать исходный код VM (который достаточно компактный и читаемый) и экспериментировать, создавая собственные последовательности инструкций.
В заключение, WiLang – это увлекательный учебный проект, демонстрирующий принципы построения виртуальной машины. Несмотря на текущие ограничения, структура проекта позволяет легко расширять функциональность. План развития очевиден: завершение поддержки всех задуманных инструкций, разработка фронтенда для удобного написания программ, и улучшение надежности. Для разработчиков, интересующихся тем, "как это сделано", WiLang предоставляет отличную возможность заглянуть под капот виртуальной машины и даже поучаствовать в ее совершенствовании. Проект открыт под лицензией MIT, так что вы можете экспериментировать с кодом, вносить улучшения и использовать VM WiLang в своих учебных целях. Удачного погружения во внутреннее устройство виртуальных машин!
Tokens, Lexer, Some optimizations.