Using C++ as a Scripting Language, part 14

fwsGonzo
7 min readJan 6, 2025

--

Working with C++ strings and vectors

It’s 2025 and I haven’t been writing about my C++ shenanigans in a long time. Last time I wrote An Introduction to Low-Latency Scripting for Game Engines. It was quite well received, actually. Many appear to be using my emulator to do all kinds of things now, but I suspect that many find it complex to use as it is not at all like interactions with a dynamic language. I also made a repository with a multi-tenancy integration for Drogon public, where I create, execute and destroy VMs in ~1 microsecond on every request, reaching 2M req/s on my computer. Anyway, this time it’s about low-latency emulation.

RISC-V is a register machine architecture. It has 32 integer and 32 float registers. When we make a function call into a program we have to follow the RISC-V ABI.

The binary interface

Here’s a quick example: The ABI says to use A0 — A7 for integral arguments. What does that mean? Well, integers are integers, and pointers (memory addresses) are integers too. So if I were to call a function with a string and a length I would use two registers: One for the pointer, and one of the length. So, I would push the string data on stack, and put the stack pointers address as the first argument. That would be in register A0, for argument 0. And then the length in register A1, argument 1. Let’s pseudo-code it:

// Push the entire string on the current stack, returns the address
auto addr = machine.stack_push(my_string.data(), my_string.size());
machine.cpu.reg(REG_ARG0) = addr; // Location of string
machine.cpu.reg(REG_ARG1) = my_string.size(); // Length of string
machine.cpu.jump(function_address);
// Geronimo
machine.simulate();

So, making a function call with data can be very simple as long as we know the ABI. Finally, after 8 argument registers have been used, the arguments overflow to the stack. Floating-point values have their own argument registers, so it gets complicated fast. This is why we have vmcall("guest_function", ...) which makes it easy to make a function call into a sandboxed program. It sets up the arguments and handles the function call for us!

const std::string my_string = "My String!";
machine.vmcall("function", my_string, my_string.size());

The machine.vmcall() function does exactly what we want: It pushes the string zero-terminated on the stack, but doesn’t automatically use a register for the length. That we can do ourselves if we want to, which we do in the example above. In the sandboxed program we could write our function like this:

void function(const char* str, size_t len) {
fmt::print("Got a string: {}\n", std::string_view(str, len));
}

It can also push structs, strings and even vectors for the function call, but what if we wanted to pass std::string and std::vector to the guest directly? In that case we have to look at the implementations of those classes. In our case we only use one standard library, so we only care about its implementation, but just so you know: std::vector is implemented the same way in both stdlibc++ (GNU) and libc++ (LLVM), while std::string has a small-string-optimization (SSO) in both libraries, but they differ.

std::string in GNU stdlibc++

The std::string class is 32 bytes (on 64-bit) and has up to 15 byte string embedded in the structure, avoiding allocations:

struct string {
static constexpr std::size_t SSO = 15;
gaddr_t ptr;
gaddr_t size;
union {
char data[SSO + 1];
gaddr_t capacity;
};
};

Both ptr and size are directly accessible without dependent calculations, a trade-off for performance vs packing data for higher storage. Meanwhile, in LLVM libc++ std::string the embedded storage is up to 22 characters without heap allocations, and the whole class is only 24 bytes! Some calculations are required in order to access the data, though. Anyway, we will not go further into detail about the LLVM implementation.

std::vector in GNU stdlibc++

The stdlibc++ vector is 24 bytes in size (on 64-bit), and stores everything interally as byte ranges:

struct vector {
gaddr_t ptr_begin;
gaddr_t ptr_end;
gaddr_t ptr_capacity;
};

Making a wrapper for it is very straight-forward and it also covers LLVM libc++ as they have the same layout. Vector stores the ranges in bytes and calculates size and capacity using the compile-time template type.

Implementing support in libriscv

Using both of these structures I’ve written a guest datatypes header file in the libriscv repository that provides an abstraction that assumes these are stored in or allocated for a guest program.

Using a very basic variation was simpler than I thought it would be, however I had to completely rewrite everything once I wanted to try using them as arguments in VM calls. The architectural issue I had was that you just can’t pass these structures by value to the guest. If you do, the guest program won’t call destructors, and you’ll end up with a difference in allocations vs deallocations: leaking memory.

On the other hand, if you allocate the main structures on the heap, you can pass them by address (pointer or reference), let the guest modify them, and then inspect or use them after the call ends. The guest can also safely move from the data structures, leaving them in a well-defined state where the host can see that it doesn’t need to free anything. So, that’s what I did:

using CppString = GuestStdString<Script::MARCH>;
using CppVector = GuestStdVector<Script::MARCH>;

// Define the test6 function, which has a std::string& argument in the guest,
// a std::vector<int>& and a std::vector<std::string>&.
Event<void(ScopedCppString&, ScopedCppVector<int>&, ScopedCppVector<CppString>&)> test6(script, "test6");

// Allocate a GuestStdString object with a string
ScopedCppString str(script.machine(), "C++ World ..SSO..");
// Allocate a GuestStdVector object with a vector of integers
ScopedCppVector<int> ivec(script.machine(), std::vector<int>{ 1, 2, 3, 4, 5 });
// Allocate a vector of strings (using CppString)
ScopedCppVector<CppString> svec(script.machine(),
std::vector<std::string>{ "Hello,", "World!", "This string is long :)" });

// Call the test6 function with the std::string managed by the arena
if (auto ret = test6(str, ivec, svec); !ret)
throw std::runtime_error("Failed to call test6!?");

The Event<void()> class is my abstraction over VM calls, to enforce the function type as well as to cache the function address. These wrappers are allocated on the guest heap and passed by address so that they become references in the guest function. For example, you can read the integer vector as std::vector<int>& or const std::vector<int>&.

In the example above we pass one std::string and two std::vectors to the guest programs function test6. We could define our test6 function like this:

void test6(const std::string& str,
const std::vector<int>& ints,
const std::vector<std::string>& strings)
{
std::string result = "Hello, " + str + "! Integers:";
for (auto i : ints)
result += " " + std::to_string(i);
result += " Strings:";
for (const auto& s : strings)
result += " " + s;

fmt::print("{}\n", result);
}

The result is a long string:

Hello, C++ World ..SSO..! Integers: 1 2 3 4 5 Strings: Hello, World! This string is long :)

But, how does this work? Well, if you’ve been following me you know the heap is host-managed. There are 3 wrapper classes right now:

  1. GuestStdString wraps a guest-side std::string
  2. GuestStdVector<T> wraps a guest-side std::vector<T>
  3. ScopedArenaObject<T> manages any class on the guest-side heap

In the example above we are using ScopedArenaObject<GuestStdString>. That is, we’re managing a heap-allocated guest-side std::string, which is freed once the scope ends. Quite handy. As mentioned, when these objects are passed by reference to the guest, it can modify them and once the function ends we can use that modified data as a way to return complex results.

I designed the wrappers to support themselves being allocated recursively. So you can have a guest-side vector of vector of strings. Why go through all that trouble? It’s largely because when I designed the dialogue system in my game I had to create an easily-serializable version that could be produced in the guest and handed safely to the host. Not only was it a lot of duplication, but it ended up being pretty smelly too.

With our new wrappers we can just pass the entire dialogue state zero-copy to the host and read it like a book.

Benchmarks

I like making some benchmarks and punching up a little. What’s all this work for if we can’t show it breaks new ground?

Benchmarking a function call with a guest-managed std::string

So, a single string written by value to stack is fairly cheap, and as we make a scoped object (putting it on the heap), it gets more expensive but not enough to care. The fourth column is a VM function call with a single integer argument in another emulator for reference. I don’t exactly know how to pass a std::string in that emulator, but we’re okay with something that is definitely less work.

I think I’m happy with the results, and I’m very happy that I can “talk” much more easily with the engine from my games script now. So, what we found is that we can use the GuestStdString and GuestStdVector as arguments in system call handlers in order to zero-copy read strings and vectors in the guest. So, if the guest says “here’s the full dialogue state”, we can now take that and safely read it without a translation layer.

Second, we can now call functions in the guest program using strings and vectors. I suspect I will use that much less, but it’s definitely nice to have. A hidden third benefit is that the scoped arena object class also works on arbitrary structs, so you can call a guest function with your struct as input, and then have the guest program modify it as another way to return results. Something like this:

struct MyData { int result; };
ScopedArenaObject<MyData> data(machine, MyData{ .result = 0 });

machine.vmcall("my_function", data); // It's passed by reference

fmt::print("The guest returned: {}\n", data->result);

That’s nice, no?

As is tradition, I attach a screenshot of my game. Testing hats. It’s quite playable now!

-gonzo

--

--

fwsGonzo
fwsGonzo

No responses yet