Я постарался охватить только основы, но текст всё равно получился очень длинным.

libriscv — это зрелый эмулятор RISC-V, который в настоящее время используется в игровых движках. Насколько мне известно, это единственный эмулятор, в котором основной акцент делается на обработке задержек, а также предоставляются специализированные решения и инструменты для выполнения быстрых вызовов при обращении с функциями — как входящих, так и исходящих. Причём, всё это заключено в безопасной песочнице. Задержки, наблюдаемые в libriscv,  гораздо ниже, чем в эталонных эмуляторах.

Меня многие спрашивали, как им пользоваться, но здесь интереснее то, как вообще может прийти в голову мысль писать скрипты на C++ — не слишком ли сложно это будет? Оказывается, нет, не очень. Вот уже несколько лет я пишу на C++ скрипты для одной большой и одной не очень большой игры, и меня почти не посещало ощущение, что виной каким-то возникающим при этом проблемам являются язык C++ или связанные с ним скриптовые API. Я много лет программирую на Lua, а до этого пользовался обычным C. Но сейчас современный идиоматический C++ — то, что мне нужно. Причём, я могу писать на этом языке как в самом игровом движке, так и за его пределами, при этом опираясь (буквально) на одни и те же абстракции и оперируя одинаковыми структурами данных. Наконец, C++ просто очень мощный. Правда, я признаю, что о вкусах не спорят, и при работе с C++ также не обойтись без компромиссов.

Как же практически использовать C++ в таком качестве?

1. Импортируем libriscv в наш проект

Импорт библиотеки CMake не составляет труда:

cmake_minimum_required(VERSION 3.14)
project(example LANGUAGES CXX)

include(FetchContent)
FetchContent_Declare(libriscv
  GIT_REPOSITORY https://github.com/fwsGonzo/libriscv
  GIT_TAG        master
  )
FetchContent_Declare(libfmt
  GIT_REPOSITORY https://github.com/fmtlib/fmt
  GIT_TAG        master
  )

FetchContent_MakeAvailable(libriscv)
FetchContent_MakeAvailable(libfmt)

add_executable(example example.cpp script.cpp)
target_link_libraries(example riscv fmt)

Этот код подтянет новейшие версии fmtlib и libriscv и свяжет их с исполняемым файлом нашего проекта. CMake также позаботится о том, чтобы включить все необходимые каталоги и т.п. fmtlib — инструмент, при помощи которого удобно форматировать текст и выводить его на экран. А libriscv — это песочница.

В этом руководстве мы разбираем пример на разработку игр, взятый из репозитория libriscv. Поэтому, если вы не хотите прослеживать всё, что здесь описано, а предпочитаете проверить всё сами — в разделе «examples» репозитория libriscv для вас найдутся как простые, так и продвинутые примеры.

У нас есть всё необходимое, чтобы собрать и связать проект под Linux, Mac и MinGW. Я знаю, что при использовании CMake libriscv также собирается под MSVC, но пока не могу протестировать такой вариант.

2. Установка компилятора RISC-V

Под Linux это не составляет труда. Обычно он упакован в пакет. Например, под Ubuntu 20.04 можно написать sudo apt install g++-10-riscv64-linux-gnu, а под Ubuntu 22.04 — sudo apt install g++-12-riscv64-linux-gnu. Заглянув в Launchpad, вижу, что в версии 24.04 есть g++-14. Если у вас такая опция недоступна, то можете собрать компилятор прямо из исходников — о том, как это делается, я рассказал здесь. При сборке из исходников есть ещё один бонус — в таком случае у вас получится самый быстрый компилятор для работы именно с моим эмулятором — и соответственно, наилучшие результаты. Под Windows есть WSL2, работающий с теми же пакетами, что упомянуты выше. То есть, при работе с ним нужно набрать ровно ту же команду.

3. Выполняем функцию main(), заключённую в песочницу

Создать экземпляр эмулятора можно из обычного двоичного файла, хранящегося в оперативной памяти:

#include <fstream>
#include <iostream>
#include <libriscv/machine.hpp>
using namespace riscv;

int main(int argc, char** argv)
{
 if (argc < 2) {
  std::cout << argv[0] << ": [program file] [arguments ...]" << std::endl;
  return -1;
 }

 // Считываем программу RISC-V в std::vector:
 std::ifstream stream(argv[1], std::ios::in | std::ios::binary);
 if (!stream) {
  std::cout << argv[1] << ": File not found?" << std::endl;
  return -1;
 }
 const std::vector<uint8_t> binary(
  (std::istreambuf_iterator<char>(stream)),
  std::istreambuf_iterator<char>());

 // Создаём новую 64-разрядную машину RISC-V 
 Machine<RISCV64> machine{binary, {.memory_max = 64UL << 20}};
 ...

При работе с типичной программой для командной строки такого кода практически достаточно, чтобы прогнать в ней простой пример hello world. Загружаем программу из файла в память и на основе двоичного файла создаём экземпляр эмулятора.

Чтобы можно было выполнять различные программы Linux/POSIX, вызовем setup_linux(...). Так мы сможем настроить среду выполнения с аргументами программы, переменными окружения и вспомогательный вектор. Наконец, вызовем setup_linux_syscalls(false, false), которая создаст экземпляры обработчиков системных вызовов, относящихся к Linux, а также отключит файловую систему и возможности работы по сети. Всё равно, что включить строгий режим песочницы:

// Используем вектор строк как набор аргументов для программы RISC-V 
machine.setup_linux(
  {"micro", "Hello World!"},
  {"LC_TYPE=C", "LC_ALL=C", "USER=groot"});
machine.setup_linux_syscalls(false, false);

Теперь, когда окружение подготовлено, можно переходить к выполнению программы:

try {
  // Выполняем всю main(), но с задержкой после инструкций 32mn 
  machine.simulate(32'000'000ull);
} catch (const std::exception& e) {
  std::cout << "Program error: " << e.what() << std::endl;
  return -1;
}

Это всё, что потребуется для выполнения обычной программы.

4. Песочница: простой пример

Вот полный код программы simple_example:

#include <fstream>
#include <iostream>
#include <libriscv/machine.hpp>
using namespace riscv;

int main(int argc, char** argv)
{
  if (argc < 2) {
    std::cout << argv[0] << ": [program file] [arguments ...]" << std::endl;
    return -1;
  }

  // Считываем программу RISC-V в std::vector:
  std::ifstream stream(argv[1], std::ios::in | std::ios::binary);
  if (!stream) {
    std::cout << argv[1] << ": File not found?" << std::endl;
    return -1;
  }
  const std::vector<uint8_t> binary(
     (std::istreambuf_iterator<char>(stream)),
     std::istreambuf_iterator<char>());

  // Создаём новую 64-разрядную машину RISC-V 
  Machine<RISCV64> machine{binary, {.memory_max = 64UL << 20}};

  // Используем вектор строк как набор аргументов для программы RISC-V
  machine.setup_linux(
    {"micro", "Hello World!"},
    {"LC_TYPE=C", "LC_ALL=C", "USER=groot"});
  machine.setup_linux_syscalls(false, false);

  try {
    // Выполняем всю main(), но с задержкой после инструкций 32mn
    machine.simulate(32'000'000ull);
  } catch (const std::exception& e) {
    std::cout << "Program error: " << e.what() << std::endl;
    return -1;
  }

  std::cout << "Program exited with status: " << machine.return_value<int>() << std::endl;
  return 0;
}

То есть, создаём экземпляр, настраиваем, симулируем.

5. Выполняем тестовую программу

Соберём простую программу hello world при помощи любого компилятора RISC-V:

#include <stdexcept>
#include <iostream>

int main(int, char** argv)
{
    try {
        throw std::runtime_error(argv[1]);
    } catch (const std::exception& e) {
        std::cout << e.what() << std::endl;
        return 0;
    }
    return 1;
}

Статически собираем её, а затем выполняем simple_example test :

gamedev$ riscv64-linux-gnu-g++-10 -static -O2 test.cpp -o test
gamedev$ .build/simple_example test
Hello World!
Program exited with status: 0

Тест глупенький, но он нужен нам только для того, чтобы проверить, насколько хороша получилась наша рабочая среда. Программа полностью выполняет функцию main(), выбрасывает исключение со вторым аргументом программы, а именно Hello World!, захватывает его и выводит в консоль. Затем штатно выходит. Тем самым мы убеждаемся, что рабочее окружение в порядке.

Теперь можно переходить к реализации скриптинга.

6. Вызов функции в виртуальной машине

Вызов функции в виртуальной машине называется vmcall. С его помощью можно вызывать любой публичный символ, но всем будет проще, если придерживаться соглашения о вызовах, действующего в RISC-V ABI. В C++ при реализации этой функции можно просто воспользоваться extern "C" :

extern "C"
int my_function(const char* str)
{
    std::cout << str << std::endl;
    return 1234;
}

Добавив эту функцию и вновь собрав test.cpp, можно вызвать её непосредственно после simulate():

machine.vmcall("my_function", "Hello Sandboxed World!");

std::cout << "Program exited with status: " << machine.return_value<int>() << std::endl;
return 0;

Если теперь выполнить simple_example, то на экране должно отобразиться Hello Sandboxed World!, а затем вывестись сообщение о том, что программа завершилась со статусом 1234 — именно эту информацию мы и вернули из вызова функции!

Hello World!
Hello Sandboxed World!
Program exited with status: 1234

Правда, нет гарантии, что эта операция получится, так как ранее мы вернулись из функции main(), которая обычно опустошает и закрывает все открытые файлы. Но именно в данном случае всё сработало. Рекомендую вам реализовывать вашу собственную функцию вывода текста, которая будет напрямую вызывать write(), или даже использовать для вывода текста в консоль специальный системный вызов. Также есть несколько способов просто не возвращаться из main().

Теперь, когда у нас появилась возможность делать входящие в программу вызовы, как нам научиться делать исходящие? Обычно эта задача решается при помощи системных вызовов, в которые мы здесь вдаваться не будем. Это большой кусок кода на ассемблере RISC-V и т.п. Лучше разберём далее некоторые вспомогательные функции, которые я написал для нашего примера на разработку игр. Я писал вспомогательные функции так, чтобы пользователь не рассчитывал досконально их понять, но зато они были бы просты в обращении.

7. Функции, вызываемые на хосте

Чтобы делать исходящие вызовы из песочницы и приказывать игровому движку что-то для нас делать, перейдём от простого примера к gamedev example.cpp.

В файле example.cpp легко заметить ссылку на ScriptCallable:

// ScriptCallable — это функция, которую можно запросить из скрипта
using ScriptCallable = std::function<void(Script&)>;
// Словарь хост-функций, которые можно вызывать из скрипта  
static std::array<ScriptCallable, 64> g_script_functions {};
static void register_script_function(uint32_t number, ScriptCallable&& fn) {
  g_script_functions.at(number) = std::move(fn);
}

Это функции, которые может вызывать программа, заключённая в песочнице. Таким образом, когда эта программа работает, она в любой момент может запросить: «вызови номер 1 с этими аргументами». Значение каждой функции выбирает тот, кто проектировал API. То есть, вы! Сами по себе числа не имеют смысла, они используются просто для идентификации вызываемых функций.

Прямо внутри функции main() в файле example.cpp видим, что первая доступная для вызова функция реализуется так:

// Зарегистрируем собственную функцию, которую можно вызывать из скрипта 
// Это обработчик для dyncall1
register_script_function(1, [](Script& script) {
  auto [arg] = script.machine().sysargs<int>();

  fmt::print("dyncall1 called with argument: 0x{:x}\n", arg);

  script.machine().set_result(42);
});

Итак, у первой доступной для вызова функции — всего один целочисленный аргумент ( machine().sysargs<int> ), и он, по-видимому, устанавливает совокупный результат в виде своеобразного возвращаемого значения machine().set_result(42). Таким образом, нам нужно сделать что-то вроде int myfunc(int arg) из пределов песочницы, верно? Рассмотрим script_program/program.cpp:

// Динамический вызов для тестирования целочисленных аргументов и возвращаемых значений 
DEFINE_DYNCALL(1, dyncall1, int(int));

Действительно: int(int)— это тип функции. Также он используется в программе подобным образом:

 // Вызываем функцию, которая была зарегистрирована как динамический вызов
 const int result = dyncall1(0x12345678);
 printf("dyncall1(1) = %d\n", result);

Вторая доступная для вызова функция, по-видимому, принимает аргументы std::string_view и std::string:

// Это обработчик dyncall2
register_script_function(2, [](Script& script) {
  // string_view потребляет 2 регистра в качестве аргументов: в первом находится указатель, во втором указывается длина 
  // Иначе обстоит ситуация с std::string, потребляющим всего 1 регистр (строковый указатель, завершаемый нулём)
  auto [view, str] = script.machine().sysargs<std::string_view, std::string>();

  fmt::print("dyncall2 called with arguments: '{}' and '{}'\n", view, str);
});

Итак, первый аргумент позволяет заглянуть, как будет выглядеть память с программами при политике нулевого копирования, тогда как аргумент str — это std::string, владеющий данными программы. В программе он определяется так:

// динамический вызов, чтобы протестировать строковые аргументы
DEFINE_DYNCALL(2, dyncall2, void(const char*, size_t, const char*));

И вызывается из функции main():

 // Вызов функции, передающей строку (с указанием длины)
 dyncall2("Hello, Vieworld!", 16, "A zero-terminated string!");

Чтобы std::string_view работала быстро, она должна знать длину, так что мы сообщаем ей как указатель на строку, так и длину. Можно сказать, что аргумент к std::string_view потребляет 2 регистра. В то же время, строка, завершаемая нулём, потребляет всего один аргумент.

Для работы с обычными данными можно вызвать другую функцию:

// Это обработчик для dyncall_data
register_script_function(4, [](Script& script) {
  struct MyData {
   char buffer[32];
  };
  auto [data_span, data] = script.machine().sysargs<std::span<MyData>, const MyData*>();

  fmt::print("dyncall_data called with args: '{}' and '{}'\n", data_span[0].buffer, data->buffer);
});

Итак, по-видимому, эта вызываемая функция принимает в качестве аргументов span<MyData> и const MyData*. Поскольку span<MyData> является динамическим, функции приходится потреблять два аргумента, чтобы знать как указатель, так и количество элементов. const MyData* действует как span<MyData, 1> с фиксированным размером, и поэтому потребляет всего один аргумент. В обоих случаях данные не копируются, и можно свободно просматривать память в песочнице, работа которой приостановлена.

В программе динамическая функция определяется так:

// Динамический вызов, передающий представление сложным данным
struct MyData {
  char buffer[32];
};
DEFINE_DYNCALL(4, dyncall_data, void(const MyData*, size_t, const MyData&));

Она вызывается из тестовой функции:

PUBLIC(void test5())
{
  std::vector<MyData> vec;
  vec.push_back(MyData{ "Hello, World!" });
  MyData data = { "Second data!" };

  dyncall_data(vec.data(), vec.size(), data);
}

Итак, для динамического случая с std::span<MyData> действительно требовалось два аргумента, тогда как для const MyData* фиксированного размера требовался всего один. Это логично.

Выполнив программу, получаю на моей машине следующее:

dyncall1 called with argument: 0x12345678
dyncall2 called with arguments: 'Hello, Vieworld!' and 'A zero-terminated string!'
Hello, World from a RISC-V virtual machine!
dyncall1(1) = 42
>>> myscript initialized.
test1 returned: 10
Call overhead: 5ns
Benchmark: std::make_unique[1024] alloc+free  Elapsed time: 14ns
test1(1, 2, 3, 4)
Caught exception: Oh, no! An exception!
Data: 1 2 3 4 5.000000 6.000000 7.000000 8.000000 9.000000 10.000000 11.000000 12.000000 Hello, World!
Benchmark: Overhead of dynamic calls  Elapsed time: 4ns
dyncall_data called with args: 'Hello, World!' and 'Second data!'

Динамические вызовы не рассчитаны на то, чтобы бить рекорды по минимальной задержке, и дело здесь в основном в функции std::function. Но удобно иметь хранилище для снимков состояния и такую проверку, которая не проходится, пока не установлен ни один обработчик. Все мы любим упрощать себе жизнь.

Надеюсь, этот приведённый в качестве примера код вам вполне понятен. Все эти API сразу проектируются с расчётом на малую задержку, и их не следует проверять с пристрастием на стороне хоста. Приведу пример: если вы читаете на хосте std::string, переданную вам из программы, выполняемой на виртуальной машине, то хост сразу же прекратит синтаксический разбор строки, если сочтёт её слишком длинной. Думаю, по умолчанию задан предел в 1 6 МБ, но можно задавать пределы на своё усмотрение, причём, исходя из ситуации. То же справедливо при любом другом способе просмотра памяти хоста или при извлечении данных из неё, за исключением machine.copy_to_guest и machine.copy_from_guest. Для двух этих случаев требуется указывать длины/пределы примерно как при работе с memcpy.

Продвинутые примеры, гостевые операции выделения памяти, RPC

1. Продвинутое проектирование API и куча, управляемая с хоста

Гостевая куча будет полностью контролироваться из-за пределов песочницы. Гость выделяет и высвобождает память, выполняя известные системные вызовы, отвечающие, соответственно, за выделение и высвобождение памяти. Также поддерживаются вызовы calloc и realloc.

Создадим лёгкую абстракцию, чтобы продемонстрировать, как с этим можно работать. Только для демонстрации концепции обзаведёмся данными, прикреплёнными к некоторому местоположению. В таком духе:

struct LocationData {
   int x, y, z;
   std::unique_ptr<uint8_t[]> data = nullptr;
   std::size_t size = 0;
};

Само местоположение здесь не так важно, просто учитывайте, что эта структура релевантна и находится где-то у нас в программе. Прежде, чем пойти далее, обозначим некоторые вещи.

  1. Все отрезки памяти, выделяемые в куче, выравниваются по границе в 64 разряда. Мы этим управляем.

  2. Мы хотим иметь возможность модифицировать данные и фиксировать их только тогда, когда вся работа будет полностью выполнена.

  3. … итак, нам нужна копия данных в нашей скриптовой программе.

Для минимального API нам потребуются две функции:

// Функция, извлекающая содержимое, расположенное в (x, y, z)
// или возвращающая (nullptr, 0), если такое местоположение не найдено.
struct LocationGet {
  uint8_t* data;
  size_t size = 0;
};
DEFINE_DYNCALL(10, location_get, LocationGet(int, int, int));
// Функция, фиксирующая содержимое, расположенное определённой локации
// Вернуть ошибку она не может, вместо этого она будет выбрасывать исключение.
DEFINE_DYNCALL(11, location_commit, void(int, int, int, const void*, size_t));

ABI постулирует, что можно вернуть прямо в регистры структуру, состоящую из двух элементов, это эффективно. Следовательно, мы будем выдавать дату и размер напрямую как значения, возвращаемые от хоста. Также постараемся держать x, y и z в регистрах, передавая их все как аргументы.

Чтобы всё это было условно удобно использовать, создадим простой класс:

#include <span>
struct LocationData {
  LocationData(int x, int y, int z)
    : x(x), y(y), z(z)
  {
    auto res = location_get(x, y, z);
    if (res.data) {
       m_data.reset(res.data);
       m_size = res.size;
    }
  }
  void commit() {
    location_commit(x, y, z, m_data.get(), m_size);
  }

  bool empty() const noexcept {
    return m_data == nullptr || m_size == 0;
  }
  std::span<uint8_t> data() {
    return { m_data.get(), m_size };
  }
  void assign(const uint8_t* data, size_t size) {
    m_data = std::make_unique<uint8_t[]>(size);
    std::copy(data, data + size, m_data.get());
    m_size = size;
  }

  const int x, y, z;
private:
  std::unique_ptr<uint8_t[]> m_data = nullptr;
  std::size_t m_size = 0;
};

Этот незамысловатый класс будет управлять нашими данными, расположенными в указанном месте. Собирая его, мы пытаемся заполнить пробелы, а если он будет пуст, мы сможем присвоить ему те данные, которые захотим. Фиксируя данные, мы тем самым затираем уже имеющиеся у нас данные, полученные от движка, независимо от их размера.

На стороне игрового движка всё это можно реализовать очень быстро:

struct Location {
  int x = 0, y = 0, z = 0;

  bool operator==(const Location& other) const {
    return x == other.x && y == other.y && z == other.z;
  }
};
namespace std {
  template<> struct hash<Location> {
    std::size_t operator()(const Location& loc) const {
      return std::hash<int>()(loc.x) ^ std::hash<int>()(loc.y) ^ std::hash<int>()(loc.z);
    }
  };
}
struct LocationData
{
  std::vector<uint8_t> data;
};
static std::unordered_map<Location, LocationData> locations;

А вот обратные вызовы для location_get и location_commit, они находятся в игровом движке:

// Это обратный вызов для sys_location_get
register_script_function(10, [](Script& script) {
  auto [x, y, z] = script.machine().sysargs<int, int, int>();
  auto it = locations.find(Location(x, y, z));
  if (it != locations.end()) {
    auto alloc = script.guest_alloc(it->second.data.size());
    script.machine().copy_to_guest(alloc, it->second.data.data(), it->second.data.size());
    script.machine().set_result(alloc, it->second.data.size());
  } else {
    script.machine().set_result(0, 0);
  }
});
// Это обратный вызов для sys_location_commit
register_script_function(11, [](Script& script) {
  auto [x, y, z, data] = script.machine().sysargs<int, int, int, std::span<uint8_t>>();
  // Таким образом мы создаём новое местоположение или обновляем уже существующее 
  auto& loc = locations[Location(x, y, z)];
  loc.data = std::vector<uint8_t>(data.begin(), data.end());
});

Здесь сделаем паузу и немного объясним, что к чему. Обратные вызовы в игровом движке извлекают аргументы из вызовов, выполняемых программой, а затем выполняют ту или иную полезную работу в зависимости от того, какая функция была вызвана. Предположим, в программе происходит следующее:

LocationData loc(1, 2, 3);
if (!loc.empty()) {
  printf("Location (1, 2, 3) contains %zu bytes\n", loc.data().size());
  location_commit(1, 2, 3, loc.data().data(), loc.data().size());
} else {
  printf("LocationGet(1, 2, 3) was empty!\n");
}

std::vector<uint8_t> data = { 0x01, 0x02, 0x03, 0x04 };
loc.assign(data.data(), data.size());
loc.commit();

LocationData loc2(1, 2, 3);
if (!loc2.empty()) {
  printf("Location (1, 2, 3) contains %zu bytes\n", loc2.data().size());
  location_commit(1, 2, 3, loc2.data().data(), loc2.data().size());
} else {
  printf("LocationGet(1, 2, 3) was empty!\n");
}

Вывод ожидаемый:

LocationGet(1, 2, 3) was empty!
Location (1, 2, 3) contains 4 bytes

Итак, программа сначала создаёт объект LocationData по адресу (1, 2, 3), а затем проверяет, пуст ли он. Он пуст. Тогда программа присваивает ему значения, взятые из 4-байтного вектора, фиксирует эту информацию, создаёт новый объект  LocationData по адресу (1, 2, 3) — и мы видим, что в нём содержится 4 байта.

Вот конструктор LocationData:

 auto res = location_get(x, y, z);
 if (res.data) {
   m_data.reset(res.data);
   m_size = res.size;
 }

Он приказывает игровому движку выполнить location_get, это обратный вызов, которому мы присвоили номер 10. Затем обратный вызов 10 выполняется, после чего сразу же извлекает три первых аргумента в виде целых чисел, соответствующих x, y и z. После этого он пытается найти данную информацию в словаре местоположений, и, если не находит, то возвращает (0, 0). Можете считать, что при этом два регистра устанавливаются в два нуля, и один из этих регистров соответствует указателю на другой стороне, а второй — целому числу.

После этого LocationData опустошается и в таком виде выводится на экран. Далее мы присваиваем ему данные и вызываем commit(). Этот коммит выполняет обратный вызов location_commit, который имеет номер 11. Можете проверить этот обратный вызов и убедиться, что он считывает четыре аргумента: x, y, z и span<uint8_t>. Это динамический диапазон, поэтому он потребляет два регистра, и всего регистров получается 5, что соответствует определению программы: void(int, int, int, const void*, size_t). Таким образом, первый регистр отводится под указатель, а второй — под размер данных. После этого данные копируются в словарь, соответствующий конкретному местоположению.

Наконец, мы вновь создаём LocationData для (1, 2, 3), и на сей раз данные найдены. 4 байта были сохранены и потом вновь вовлечены в программу, а сами данные и их размер переданы в виде регистров в рамках двучленной возвращаемой структуры. В рамках обратного вызова на стороне хоста прямо в этот момент происходят и другие вещи:

  1. Под скриптовую программу выделяется куча, это делается при помощи script.guest_alloc(bytes).

  2. Затем мы копируем наши данные в адрес, полученный по результатам выделения кучи, это делается при помощи script.machine().copy_to_guest(…).

  3. Наконец, возвращаем адрес и размер.

На этом завершается первый пример продвинутого скриптинга.

Последнее, что хотелось бы отметить об API из этого примера — он очень хорошо защищён от злоупотребления. Если мы ожидаем антагонистического поведения, то должны сами ограничить количество местоположений, которые можно создать. Но, дополнительно к этому, API libriscv и сам хорошо предотвращает возникновение экстремальных значений. Например, если он заметит огромный диапазон, то немедленно прекратит выполнение. Также будет отказано в дальнейшем выполнении программы при недопустимых операциях чтения и записи. Выполнение прекращается и при попытке надолго его застопорить. При этом нужно отметить: бывало в моей практике и такое, что я ухитрялся сам себя загнать в бесконечный цикл, но на моей игре это никогда не отражалось. Она поработает немного, потом автоматически отказывает, а вам затем сообщает, на чём вы остановились.

2. Вызовы удалённых процедур

Если один и тот же код выполняется сразу в двух отдельных местах или в разных системах, то правильное поведение обеспечивается просто за счёт корректной эмуляции. Далее, если предположить, что программы одинаковы, то мы можем даже просто описать вызов функции и добиться, чтобы он в обоих случаях работал одинаково. Итак, давайте научимся делать вызовы удалённых процедур, но только в целях тестирования. Сделаем это в двух программах, работающих в разных песочницах.

Первым делом мы должны определить, как именно хотим выполнять функцию в другом месте. Например, можно было бы скопировать все регистры с одной виртуальной машины на другую, а затем просто расплывчато указать, что все находящиеся в регистрах аргументы будут передаваться удалённой функции. Затем будем выполнять одну и ту же функцию, исходя из того, что программы полностью одинаковы. В таком случае и все их функции одинаковы.

Но ниже мы поступим иначе, и именно такой способ разберём подробно. Воспользовавшись захватывающим лямбда-выражения фиксированного размера, можно скопировать в лямбда-выражение все интересующие нас вещи (по значению!), перенести эту информацию в удалённое место и передать её в качестве аргумента функции-трамплину. Из этого трамплина можно вызывать лямбда-выражение со всей захваченной информацией — вот вам и вызовы удалённых процедур.

Создадим простой API такого рода:

int x = 42;
rpc([x] {
  printf("Hello from a remote virtual machine!\n");
  printf("x = %d\n", x);
  fflush(stdout);
});

В реальной ситуации у функции вызова удалённых процедур (RPC) будет много режимов. В частности, она сможет широковещательно передавать информацию ближним игрокам или игрокам в пределах заданного региона. Но для нашего примера и так сойдёт. Чтобы добиться этого, нам потребуется реализовать функцию захвата с фиксированным размером (это ссылка). Воспользовавшись ею, можно создать вспомогательную функцию, использующую вызываемую функцию в качестве трамплина, вот так:

DEFINE_DYNCALL(12, remote_lambda, void(void(*)(void*), const void *, size_t));

static void rpc(riscv::Function<void()> func)
{
  remote_lambda(
  [](void* data) {
    auto func = reinterpret_cast<riscv::Function<void()>*>(data);
    (*func)();
  },
  &func, sizeof(func));
}

В данном случае мы вызываем remote_lambda при помощи встроенной функции, которую можно преобразовать в указатель функции. Она принимает указатель в качестве аргумента, и этот аргумент можно преобразовать в функцию захвата, имеющую фиксированный размер. Затем вызываем её. Второй и третий аргумент — это сама функция и её размер. Итак, всего три аргумента: указатель функции, указатель  Function<void()> и размер функции.

На хосте читаем хранилище с той информацией, которая была захвачена, и извлекаем адрес в виде целого числа:

static Script::gaddr_t         remote_addr;
static std::array<uint8_t, 32> remote_capture;
...
register_script_function(12, [](Script& script) {
  auto [addr, capture] = script.machine().sysargs<Script::gaddr_t, std::array<uint8_t, 32>*>();

  remote_addr = addr;
  remote_capture = *capture;
});

Здесь мы извлекаем адрес указателя на функцию, а затем получаем указатель с нулевым копированием на 32-байтный std::array. Внутри системы здесь создаётся диапазон с фиксированным размером в 1 элемент — он нужен нам, чтобы удостовериться, что мы не выходим за границы памяти. Затем, после всех проверок выравнивания по границам он преобразуется в наш массив.

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

auto script2 = script.clone("myscript2");

// Вызвать удалённую функцию, в стек которой помещена захваченная ею информация 
script2.call(remote_addr, remote_capture);

При срабатывании remote_addr с сохранённой захваченной информацией, записанной в стек функции, получаем успешный вызов удалённой процедуры:

Hello from a remote virtual machine!
x = 42

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

3. Реализация обратных вызовов

Можно двумя способами выполнять обратные вызовы в скрипт, который вы хотите вызвать позже, когда произойдёт некоторое событие.

Сначала просто поищем функцию по её имени и будем вызывать заранее согласованные аргументы. Это самый простой способ, который, однако, немного чреват ошибками:

myscript.call("my_function", 1, 2, 3, "four");

Чтобы этот метод сработал, нам только и требуется реализовать функцию my_function в рамках скрипта как extern "C". Поскольку она видима (для неё есть запись в символьной таблице), её можно вызвать. Но мы не знаем, в самом ли деле функция принимает эти аргументы. Действовать таким способом очень легко, например, из JSON.

Второй метод — получить указатель на функцию из самого скрипта, но для этого нужно создать функцию регистрации обратных вызовов, вот так:

DEFINE_DYNCALL(13, my_callback, void(const char*, void(*)(int)));

Просто в качестве примера: сначала возьмём строку, позволяющую идентифицировать, например, объект в игре, а потом указатель функции. Указатель функции будет вызываться, когда произойдёт некоторое событие. Далее функция будет передавать целое число, являющееся идентификатором нужного объекта. Обработчик реализуется на хосте так:

register_script_function(13, [](Script& script) {
  auto [name, func] = script.machine().sysargs<std::string, Script::gaddr_t>();

  // Находим объект по имени
  auto& ent = entities.at(name);
  // Регистрируем некоторый обработчик событий для взаимодействия с этим объектом 
  ent.on_event(
  [func, &script] (auto& ent) {
    // Вызываем функцию с идентификатором объекта в качестве аргумента
    script.call(func, ent.getID());
  });
});

Здесь Script::gaddr_t — это беззнаковое целое число, указывающее размер указателя внутри песочницы. Разумеется, предпочтительно подбирать подходящий размер указателя, чтобы не возникало проблем при передаче структур  (например, отличий в size_t).

Итак, когда этот обратный вызов есть на хосте, он действует так: сработав, он найдёт объект, настроит для него обратный вызов on_event, сделав таким образом вызов скриптовой функции, воспользовавшись при этом заранее предоставленным на неё указателем. Потом он передаст этой функции в качестве аргумента ID объекта.

Далее внутри скрипта мы можем вот так создать обработчик событий:

my_callback("entity1", [] (int id) {
  printf("Callback from entity %s\n", Entity{id}.getName().c_str());
});

В данном примере предположим, что у нас есть обёртка для объекта, в которой содержится только ID. В таком случае мы могли бы, например, узнать имя объекта прямо из этой обёртки.

Третий и последний метод работы с обратными вызовами такой же, как и выше, но мы используем в скрипте Function<void()>. Хранилище с захваченной информацией мы передаём обработчику событий, копируя его в лямбда-выражение на хосте. Впоследствии, когда событие происходит, мы во время вызова функции вносим хранилище с захваченной информацией в её стек. Примерно, как в случае с RPC. Сделав это, можно увязать события с хранилищем захваченной информации. Очень удобно!  

Наконец, реализуем это шаг за шагом. Сначала изменим определение скрипта, чтобы он принимал дополнительные const void*, size_t которые характеризуют хранилище с захваченной информацией. Далее модифицируем указатель функции, прикрепив к нему аргумент void*. Именно через этот аргумент мы сможем впоследствии вновь вернуть в функцию хранилище с захваченной информацией:

DEFINE_DYNCALL(13, my_callback, void(const char*, void(*)(int, void*), const void*, size_t));

Уже сложно, но, если учесть, что здесь два шага, то становится проще, ведь второй шаг всегда одинаков. Вы добавляете к вызываемой функции const void*, size_t и прикрепляете void* к указателю на функцию обратного вызова. Чтобы было можно вызывать эту функцию, следует дополнительно создать вспомогательную функцию:

static void entity_on_event(const char* name, riscv::Function<void(int)> callback)
{
  my_callback(name,
  [] (int id, void* data) {
    auto callback = reinterpret_cast<riscv::Function<void(int)>*>(data);
    (*callback)(id);
  },
  &callback, sizeof(callback));
}

Вспомогательная функция упрощает сложный вызов, так как добавляет функцию-посредник, которая приводит функцию обратно к самой себе и вызывает её с обычными аргументами. Она также может передавать обратно возвращаемые значения.

int x = 42;
entity_on_event("entity1",
[x] (int id) {
  printf("Callback from entity %s\n", Entity{id}.getName().c_str());
  printf("x = %d\n", x);
});

Точно как и в примере с RPC выводим на экран x = 42. Чтобы это получилось, теперь нужно расширить код и на стороне хоста:

register_script_function(13, [](Script& script) {
  auto [name, func, capture] = script.machine().sysargs<std::string, Script::gaddr_t, std::array<uint8_t, 32>*>();

  // Находим объект по имени
  auto& ent = entities.at(name);
  // Регистрируем некоторый обработчик событий для взаимодействия с этим объектом 
  ent.on_event(
  [func, &script, capture = *capture] (auto& ent) {
    // Вызываем функцию с идентификатором объекта в качестве аргумента
    script.call(func, ent.getID(), capture);
  });
});

Осталось внести всего одно изменение: мы копируем хранилище с захваченной информацией по значению в лямбда-выражение on_event, а затем ставим его в качестве последнего аргумента для последующей записи в стек. Это аргумент void*. Фактически, в данном примере я извлекаю массив как указатель: std::array<uint8_t, 32>*. Так мы получаем указатель на нужные нам данные, и при этом обходимся нулевым копированием. Но захватывать его необходимо по значению.

Спасибо за внимание.

Комментарии (8)


  1. Kelbon
    18.06.2025 15:25

    По-моему здесь пропущено кое-что. Ну то есть почему бы просто не компилировать С++ код на рантайме и зачем тут эмулятор risc-v

    Насколько я понял смысл в том, чтобы скрипт был полностью изолирован от системы, непонятно насколько он изолирован от остальной программы, но в общем скрипт не может пойти и удалить какой-то файл или что-то такое


    1. bodyawm
      18.06.2025 15:25

      В играх цели изоляции обычно не стоит, скорее стабильности чтобы не попортить стек с памятью и иметь хорошие, читабельные стектрейсы и возможность отладки.

      А еще в скриптах есть полезные фишки на уровне языка - например виртуальные потоки с переключением контекстов прямо в while(true) , корутины, возможность сохранить и загрузить скриптовый контекст как есть


      1. Kelbon
        18.06.2025 15:25

        как по мне моды в игре должны быть такие, чтобы ты не удалил кому-то системник и не распространял вирусы через steam другим людям через невинные стрелялки


  1. Mike_666
    18.06.2025 15:25

    Т.е. создаем виртуальную машину с архитектурой весьма вероятно отличающейся от хостовой, запускаем в ней слой эмуляции linux и уже в нем запускаем свои скрипты и говорим что нас интересуют минимальные задержки?


    1. JordanCpp
      18.06.2025 15:25

      Т.е. создаем виртуальную машину с архитектурой весьма вероятно отличающейся от хостовой, запускаем в ней слой эмуляции linux и уже в нем запускаем свои скрипты и говорим что нас интересуют минимальные задержки?

      Вы не понимаете, это другое:)


  1. JordanCpp
    18.06.2025 15:25

    А если сразу писать скрипты на С++? Минуя вот это вот всё?


  1. JordanCpp
    18.06.2025 15:25

    Как пример скриптования с низкими задержками. Это игра Fallout 1 и Fallout 2. Скрипты пишутся на смеси С и Pascal. Язык называется SSL - Star trek script language. Скрипт переводится в свой байткод, игра его интерпретирует. Ну и оригинальные игры спокойно работают на железе pentium 75 mhz, без risc-v и смс:)

    Умели же.


  1. navrocky
    18.06.2025 15:25

    Получается, чтобы выполнять скрипты их сперва надо скомпилить, а для этого на хостовой машине должен стоять жирнючий компилятор. Ну и сколько сам riscv эмулятор добавляет к бинарю не сказано, но полагаю немало. Такое себе решение для скриптования, нишевое. Гораздо лучше и проще взать какой-нибудь quickjs для скриптов.