Архитектура RayQuiro: от синтаксического дерева и байт-код ВМ до нативного машинного кода через LLVM

Привет, Хабр! Меня зовут mixitosik, я разрабатываю открытый язык программирования общего назначения RayQuiro.
Когда берешься за создание собственного языка, большинство гайдов и туториалов быстро упираются в тупик: они учат писать либо «игрушечные» древовидные интерпретаторы (Tree-walk AST), либо простые трансляторы в C. На практике же любой язык общего назначения требует решения жестких системных задач: проектирования компонуемой модульной системы, многоуровневого пайплайна исполнения (для мгновенных итераций и для продакшена), кроссплатформенного рантайма и интеграции легковесного GUI без раздувания зависимостей до размеров Electron.
В этой статье я по шагам разберу внутреннее устройство RayQuiro: структуру парсера и AST, архитектуру стековой виртуальной машины, генерацию IR для LLVM-бэкенда, а также инженерные грабли, на которые пришлось наступить при реализации динамического языка со строгим управлением памятью.
1. Концепция и Зачем: архитектурные уровни RayQuiro
Главная задача RayQuiro — дать выразительный синтаксис с динамической типизацией во время выполнения, но при этом сохранить нулевой порог входа и высокую производительность.
Поэтому среда исполнения разделена на три независимых уровня (Tiered Execution Pipeline):
┌───────────────────────┐
│ RayQuiro Source │
│ (*.rq) │
└──────────┬────────────┘
│ Lexer / Parser
▼
┌───────────────────────┐
│ AST Program │
└──────────┬────────────┘
│
┌────────────────────────┼────────────────────────┐
▼ ▼ ▼
┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ Tier 1: AST │ │ Tier 2: VM │ │ Tier 3: Native │
│ Interpreter │ │ Bytecode Engine │ │ LLVM / TCC AOT │
├──────────────────┤ ├──────────────────┤ ├──────────────────┤
│ Zero-startup │ │ High-throughput │ │ LLVM IR opt -O3 │
│ Tree-walk │ │ Stack Machine │ │ Bare-metal EXE │
│ Embedded scripts │ │ Hot Reloading │ │ Standalone dist │
└──────────────────┘ └──────────────────┘ └──────────────────┘
Tier 1 (AST Interpreter): прямое исполнение узлов AST обходом дерева. Дает минимальный запуск (< 2 мс), используется во встраиваемых сценариях (
rqio_core) и как эталонная реализация семантики.Tier 2 (Bytecode VM): трансляция AST в компактный линейный байт-код с исполнением на регистрово-стековой виртуальной машине. Оптимален для быстрой разработки, поддерживает бинарные контейнеры (
.rqb) и hot-reloading (rqio run --watch).Tier 3 (AOT Native Compiler): трансляция семантического графа в оптимизированный C++ / LLVM IR. Сборка идет либо через встроенный TinyCC (сверхбыстрые билды за ~10 мс), либо через Clang/LLVM с проходами
-O3(векторизация, инлайнинг, устранение мертвого кода) для продакшена.
2. Устройство Лексера и Парсера: от потока символов к AST
Компилятор RayQuiro написан на C++20. Пайплайн обработки текста начинается с лексического анализатора (Lexer.h), который переводит поток UTF-8 символов в типизированные токены Token.
Лексер: контекстные токены и операторы
Лексер распознает стандартный набор ключевых слов (fn, var, let, struct, match, try, catch, finally, throw, async, await), составные присваивания (+=, -=, *=, /=) и операторы безопасной навигации (?. — optional chaining, ?? — null coalescing, ... — variadic parameter):
C++
enum class TokenType {
IDENTIFIER, NUMBER, STRING,
VAR, LET, CONST, FN, STRUCT, ENUM, INTERFACE,
IF, ELSE, WHILE, FOR, IN, RETURN, BREAK, CONTINUE,
TRY, CATCH, FINALLY, THROW, MATCH,
QUESTION_DOT, // ?.
QUESTION_QUESTION, // ??
DOT_DOT_DOT, // ...
ARROW, // =>
// ...
};
Для операторов ?, ?. и ?? в лексере задействован предиктивный просмотр на один символ вперед (peekNext()):
C++
if (c == '?' && peekNext() == '?') {
advance(); advance();
addToken(tokens, TokenType::QUESTION_QUESTION, "??", startLine, startCol);
continue;
}
if (c == '?' && peekNext() == '.') {
advance(); advance();
addToken(tokens, TokenType::QUESTION_DOT, "?.", startLine, startCol);
continue;
}
Парсер: рекурсивный спуск и приоритеты операций
Парсер (Parser.h) строит абстрактное синтаксическое дерево (AST) методом рекурсивного спуска (Recursive Descent) с учетом приоритетов бинарных операций (Precedence Climbing):
Assignment (=, +=, -=)
└── Null Coalescing (??)
└── Logical OR (||)
└── Logical AND (&&)
└── Equality (==, !=)
└── Comparison (<, <=, >, >=)
└── Term (+, -)
└── Factor (*, /, %)
└── Unary (!, -)
└── Postfix (.field, [index], ?., (args))
└── Primary (literal, ident, grouping)
Каждый узел выражения наследуется от базовой структуры Expr, а оператор — от Stmt:
C++
struct Expr {
virtual ~Expr() = default;
};
struct Stmt {
virtual ~Stmt() = default;
};
struct NullCoalesceExpr : Expr {
std::unique_ptr<Expr> left;
std::unique_ptr<Expr> right;
};
struct OptionalChainExpr : Expr {
std::unique_ptr<Expr> object;
std::string field;
std::unique_ptr<Expr> index;
};
struct ReturnStmt : Stmt {
std::vector<std::unique_ptr<Expr>> values;
};
Парсер из коробки поддерживает множественные возвращаемые значения и деструктуризацию. При вызове return a, b, c; все выражения упаковываются в вектор values структуры ReturnStmt:
C++
std::unique_ptr<Stmt> parseReturn() {
auto node = std::make_unique<ReturnStmt>();
if (!check(TokenType::SEMICOLON) && !check(TokenType::RBRACE) && !isAtEnd()) {
node->values.push_back(parseAssignment());
while (match({TokenType::COMMA})) {
node->values.push_back(parseAssignment());
}
}
match({TokenType::SEMICOLON});
return node;
}
3. Виртуальная Машина (Bytecode VM): модель данных, стек и опкоды
Виртуальная машина RayQuiro спроектирована по классической стековой модели с плоским представлением инструкций и локальных фреймов.
Универсальное значение: VMValue
Для представления динамических данных используется структура VMValue поверх std::variant:
C++
struct VMValue {
using Array = std::vector<VMValue>;
using Object = std::map<std::string, VMValue>;
std::variant<std::monostate, double, std::string, bool, Array, Object> data;
VMValue() : data(std::monostate{}) {}
VMValue(double value) : data(value) {}
VMValue(const std::string& value) : data(value) {}
VMValue(bool value) : data(value) {}
VMValue(const Array& value) : data(value) {}
VMValue(const Object& value) : data(value) {}
};
std::monostate заменяет null. Числовые значения хранятся как IEEE 754 64-bit float (double), что избавляет от накладных издержек на жесткое разделение целых и вещественных типов в критическом цикле ВМ.
Набор инструкций (Instruction Set Architecture)
Каждая инструкция фиксирована по размеру и содержит опкод плюс два 32-битных аргумента:
C++
struct Instruction {
OpCode op = OpCode::Return;
std::int32_t a = 0;
std::int32_t b = 0;
};
Система команд разбита на группы:
Категория | Опкоды | Описание |
Стек и литералы |
| Загрузка констант, дублирование и сброс верхушки стека. |
Память |
| Чтение и запись в локальные слоты стекового кадра или глобальный хэш-мап. |
Коллекции |
| Создание контейнеров и доступ по индексу. |
Арифметика |
| Базовые математические операции с приведением типов. |
Сравнение и логика |
| Предикаты и логические инверсии. |
Поток управления |
| Переходы со смещением указателя инструкций ( |
Исключения |
| Регистрация обработчиков и раскрутка стека. |
Байт-код компилятор: разрешение слотов переменных
BytecodeCompiler транслирует AST в BytecodeProgram. Локальные переменные разрешаются на этапе компиляции в фиксированные индексы слотов стека (GetLocal a, SetLocal a), полностью исключая поиск имен по строкам во время исполнения функций:
C++
int resolveLocal(const std::string& name) {
for (auto it = scopes_.rbegin(); it != scopes_.rend(); ++it) {
auto found = it->locals.find(name);
if (found != it->locals.end()) return found->second;
}
return -1; // Не локальная переменная -> поиск в глобалях
}
Цикл диспетчеризации (Dispatch Loop)
Исполнение функций инкапсулировано в VM::executeFunction. Стек вызовов управляется через CallFrame:
C++
struct CallFrame {
const BytecodeFunction* function = nullptr;
std::size_t ip = 0;
std::size_t stackOffset = 0;
};
Основной цикл интерпретатора использует классический switch-case по опкодам:
C++
while (frame->ip < frame->function->code.size()) {
const Instruction& inst = frame->function->code[frame->ip++];
switch (inst.op) {
case OpCode::Constant:
push(frame->function->constants[inst.a]);
break;
case OpCode::GetLocal:
push(stack_[frame->stackOffset + inst.a]);
break;
case OpCode::SetLocal:
stack_[frame->stackOffset + inst.a] = peek(0);
break;
case OpCode::Add: {
VMValue b = pop();
VMValue a = pop();
if (std::holds_alternative<std::string>(a.data) || std::holds_alternative<std::string>(b.data)) {
push(VMValue(toString(a) + toString(b)));
} else {
push(VMValue(toNumber(a) + toNumber(b)));
}
break;
}
case OpCode::JumpIfFalse: {
if (!isTruthy(pop())) {
frame->ip += inst.a;
}
break;
}
// ...
}
}
4. LLVM и TCC AOT-бэкенд: компиляция в машинный код
Для генерации максимального перформанса RayQuiro использует транслятор Generator.h и систему компиляции Compiler.h.
Стратегия кодогенерации
Вместо динамической диспетчеризации через тяжелый интерпретатор, генератор преобразует AST в семантически эквивалентный код на C++ с прямыми типами rq::Value и оптимизированным выделением памяти под структуры. Далее полученный код компилируется:
TinyCC (TCC): В состав RayQuiro встроен легкий тулчейн TCC (
tools/tcc/на Windows или системный пакет на Linux). Это дает сборку в нативный PE/ELF-бинарник за 10–15 миллисекунд без необходимости тащить тяжелые компиляторы вроде MSVC или GCC.Clang / LLVM Backend (
--release): При флаге--releaseвключается тяжелый оптимизирующий пайплайн LLVM:-O3оптимизации.Автоматическая векторизация циклов (Loop Vectorization).
Инлайнинг горячих функций и Dead Code Elimination (DCE).
Проход
mem2regдля выталкивания локальных переменных из стека памяти прямо на регистры процессора.
Кросс-компиляция
За счет поддержки LLVM Target Triples, кросс-сборка под разные ОС делается одной командой:
Bash
rqio build app.rq --target linux-x64 -o bin/app_linux
rqio build app.rq --target win-x64 -o bin/app.exe
rqio build app.rq --target macos-arm64 -o bin/app_macos
При линковке на Linux компилятор автоматом цепляет системные библиотеки (-lGL -lm -lpthread -ldl -lrt -lX11), позволяя собирать десктопные GUI-приложения «из коробки».
5. Примеры кода на RayQuiro
Синтаксис языка проектировался лаконичным, строгим и безопасным в плане работы с null.
Объявление структур, функций и деструктуризация
Фрагмент кода
struct Vector2D {
x: 0,
y: 0
}
fn divmod(dividend, divisor) {
if (divisor == 0) {
throw "Division by zero!";
}
var q = floor(dividend / divisor);
var r = dividend % divisor;
return q, r;
}
var [quotient, remainder] = divmod(17, 5);
print("Quotient: " + str(quotient) + ", Remainder: " + str(remainder));
var vec = Vector2D { x: 10, y: 25 };
print("Vector point: " + str(vec["x"]) + ", " + str(vec["y"]));
Безопасная навигация и сопоставление с образцом (Pattern Matching)
Фрагмент кода
var config = {
"network": {
"port": 8080
}
};
// Безопасное чтение через optional chaining и null coalescing:
var host = config?.["network"]?.["host"] ?? "127.0.0.1";
var port = config?.["network"]?.["port"] ?? 80;
print("Connecting to: " + host + ":" + str(port));
// Матчинг без проваливания (no fallthrough):
var statusCode = 404;
var statusMessage = "";
match statusCode {
200 => statusMessage = "OK",
404 => statusMessage = "Not Found",
500 => statusMessage = "Internal Server Error",
_ => statusMessage = "Unknown Status"
}
print("HTTP Status: " + statusMessage);
Разделение app и ui: оконный интерфейс
В RayQuiro разделена логика и стилизация: модуль app отвечает за структуру окна и виджеты (DOM), а модуль ui — за CSS-подобное оформление:
Фрагмент кода
import "rayquiro.app";
import "rayquiro.ui";
app.init("Системный монитор", 480, 320);
ui.style("background: #18181b; color: #fafafa; font-size: 14;");
var label = app.text("Статус демона: активен");
var check = app.checkbox("Автозапуск при старте", true);
var btn = app.button("Перезагрузить сервис");
btn = ui.apply(btn, {
"background": "#2563eb",
"color": "#ffffff",
"radius": 6,
"padding": "6px 12px"
});
app.run();
6. Архитектурные вызовы и их решения
При реализации ядра языка пришлось столкнуться с несколькими интересными инженерными задачами.
Вызов 1: Исключения и раскрутка стека в виртуальной машине
Проблема: В AST-интерпретаторе исключения легко заворачиваются в стандартный C++ try/catch. Но в байт-код машине нативный C++ механизм дает огромный оверхед, портит локальность кэша инструкций и не дает нормально восстанавливать ip (указатель инструкций) вместе с глубиной стека операндов.
Решение: В ВМ был добавлен отдельный стек контекстов для перехвата исключений:
C++
struct TryHandler {
std::size_t catchIp;
std::size_t stackDepth;
std::string errorParam;
};
std::vector<TryHandler> tryStack_;
Опкод TryBegin сохраняет точку возврата catch и текущую глубину стека данных. При срабатывании Throw машина мгновенно откатывает стек операндов до stackDepth, извлекает значение ошибки, прокидывает его в переменную errorParam и делает переход на catchIp. Если стек tryStack_ пуст, ошибка подымается на уровень хост-приложения.
Вызов 2: Lvalue-присваивание по вычисляемому индексу
Проблема: В ряде простых интерпретаторов выражение table[key] = value парсится как бинарный оператор =, у которого левая часть — IndexExpr. Но IndexExpr при вычислении возвращает обычное значение (rvalue), полностью теряя ссылку на исходный контейнер и ключ.
Решение: В AST был выделен специализированный узел SetIndexExpr, а в байт-код добавлены точечные инструкции:
SetIndexдля универсальных случаев.SetLocalIndexиSetGlobalIndexдля прямого изменения полей объектов и массивов, лежащих в слотах локальных или глобальных переменных, без лишнего копирования.
Вызов 3: Кроссплатформенная графика без тяжелых зависимостей
Проблема: Попытка прикрутить к языку десктопный GUI обычно тащит за собой монструозные либы вроде Qt, Electron или GTK, что превращает развертывание под чистой ОС в ад.
Решение: Для модуля rayquiro.app и графической подсистемы мы упаковали кастомизированный Raylib прямо в ядро рантайма. На Linux графический бэкенд общается с X11 и Wayland (через XWayland) по OpenGL, используя встроенные векторные шрифты для рендеринга текста без зависимости от системных шрифтовых демонов.
Заключение
RayQuiro развивается как полностью самостоятельный инструмент, сочетающий удобство скриптового синтаксиса с предсказуемостью нативной компиляции.
Проект открытый, код доступен сообществу:
Репозиторий ядра: GitHub — RayQuiro
Документация: rayquiro-docs
Плагин для VS Code: rayquiro-vscode
Буду рад конструктивной критике в комментариях, обсуждению деталей реализации ВМ и пулл-реквестам!
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.