PunchBruno Fernandes’ home burgled while in Italy for Como matchEl ComercioMaría Corina Machado redobla la presión en Venezuela: protestas, elecciones, presos políticos y su regreso al paísInquirerClasses suspended in Pagudpud due to Typhoon RosalRTP DesportoI Liga: Jornada abre em GuimarãesDaily MaverickClaude Monet’s ‘Iris’ becomes most expensive artwork sold at auction in France, Sotheby’s saysBollywood HungamaNo football, no gym, no restaurants? Tiger Shroff shooting raises a FRIGHTENING question: Are Bollywood stars becoming PRISONERS of their own fame?ZDF heuteEntdecken Sie das ZDF-NachrichtenstudioRapplerRain eases in Batanes as Typhoon Rosal starts moving awayThe Jerusalem PostIsrael’s election is being cast as a historic showdown - but four trends tell a different storyVanguard2027: Adeleke backs Tinubu; maintains support for Accord legislative candidatesESPNCaleb Williams, Bears find just enough to hold off PatsRai NewsTG3 Fuori TG del 23/10/2026
The Daily Newsstand · Free, Always
Friday, October 23, 2026

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

Translate

Привет, Хабр! Меня зовут 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  │
└──────────────────┘    └──────────────────┘    └──────────────────┘
  1. Tier 1 (AST Interpreter): прямое исполнение узлов AST обходом дерева. Дает минимальный запуск (< 2 мс), используется во встраиваемых сценариях (rqio_core) и как эталонная реализация семантики.

  2. Tier 2 (Bytecode VM): трансляция AST в компактный линейный байт-код с исполнением на регистрово-стековой виртуальной машине. Оптимален для быстрой разработки, поддерживает бинарные контейнеры (.rqb) и hot-reloading (rqio run --watch).

  3. 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;
};

Система команд разбита на группы:

Категория

Опкоды

Описание

Стек и литералы

Constant, Null, True, False, Pop, Dup

Загрузка констант, дублирование и сброс верхушки стека.

Память

GetLocal, SetLocal, GetGlobal, SetGlobal

Чтение и запись в локальные слоты стекового кадра или глобальный хэш-мап.

Коллекции

BuildArray, BuildObject, GetIndex, SetIndex

Создание контейнеров и доступ по индексу.

Арифметика

Add, Subtract, Multiply, Divide, Modulo, Negate

Базовые математические операции с приведением типов.

Сравнение и логика

Equal, NotEqual, Less, LessEqual, Greater, Not

Предикаты и логические инверсии.

Поток управления

Jump, JumpIfFalse, Loop, Call, Return

Переходы со смещением указателя инструкций (ip).

Исключения

TryBegin, TryEnd, Throw

Регистрация обработчиков и раскрутка стека.

Байт-код компилятор: разрешение слотов переменных

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 и оптимизированным выделением памяти под структуры. Далее полученный код компилируется:

  1. TinyCC (TCC): В состав RayQuiro встроен легкий тулчейн TCC (tools/tcc/ на Windows или системный пакет на Linux). Это дает сборку в нативный PE/ELF-бинарник за 10–15 миллисекунд без необходимости тащить тяжелые компиляторы вроде MSVC или GCC.

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

Проект открытый, код доступен сообществу:

Буду рад конструктивной критике в комментариях, обсуждению деталей реализации ВМ и пулл-реквестам!

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.