Skip to main content
Test Double company logo
Services
Services Overview
Holistic software investment consulting
Software Delivery
Accelerate quality software development
Product Management
Launch modern product orgs
Legacy Modernization
Renovate legacy software systems
DevOps
Scale infrastructure smoothly
Upgrade Rails
Update Rails versions seamlessly
Technical Recruitment
Build tech & product teams
Technical Assessments
Uncover root causes & improvements
Case Studies
Solutions
Accelerate Quality Software
Software Delivery, DevOps, & Product Delivery
Maximize Software Investments
Product Performance, Product Scaling, & Technical Assessments
Future-Proof Innovative Software
Legacy Modernization, Product Transformation, Upgrade Rails, Technical Recruitment
About
About
What's a test double?
Approach
Meeting you where you are
Founder's Story
The origin of our mission
Culture
Culture & Careers
Double Agents decoded
Great Causes
Great code for great causes
EDI
Equity, diversity & inclusion
Insights
All Insights
Hot takes and tips for all things software
Leadership
Bold opinions and insights for tech leaders
Developer
Essential coding tutorials and tools
Product Manager
Practical advice for real-world challenges
Say Hello
Test Double logo
Menu
Services
BackGrid of dots icon
Services Overview
Holistic software investment consulting
Software Delivery
Accelerate quality software development
Product Management
Launch modern product orgs
Legacy Modernization
Renovate legacy software systems
Cycle icon
DevOps
Scale infrastructure smoothly
Upgrade Rails
Update Rails versions seamlessly
Technical Recruitment
Build tech & product teams
Technical Assessments
Uncover root causes & improvements
Case Studies
Solutions
Solutions
Accelerate Quality Software
Software Delivery, DevOps, & Product Delivery
Maximize Software Investments
Product Performance, Product Scaling, & Technical Assessments
Future-Proof Innovative Software
Legacy Modernization, Product Transformation, Upgrade Rails, Technical Recruitment
About
About
About
What's a test double?
Approach
Meeting you where you are
Founder's Story
The origin of our mission
Culture
Culture
Culture & Careers
Double Agents decoded
Great Causes
Great code for great causes
EDI
Equity, diversity & inclusion
Insights
Insights
All Insights
Hot takes and tips for all things software
Leadership
Bold opinions and insights for tech leaders
Developer
Essential coding tutorials and tools
Product Manager
Practical advice for real-world challenges
Say hello
Developers
Developers
Developers
Software tooling & tips

Guide to zero-copy FFI with Rust and Unity

Michael Schoonmaker
|
July 30, 2018
Thank you! Your submission has been received!
Oops! Something went wrong while submitting the form.

As promised in "Getting started with FFI: Rust & Unity," the next steps for any FFI binding is a "zero-copy" bridge for complex data types.

In other words our current goal is to pass classes, objects, or structs between the two languages. Though a binding between Rust and Unity is beneficial in and of itself, I want to go another step further, and show the way to approach this given any pair of languages.

[NOTE: If you have not read the original post, please do that first. We'll be building off of those concepts here.]

The finished project

Just as we did the first time, the results of this work can be found on GitHub. The finished project is a simple app that synchronizes the position of "sparkles". When you press the left mouse button, your sparkle moves, and all other connected clients see it move, too.

Jumping in

In the basic project, we were already using a structure for complex state: the "baton." The issue with the baton is that it's an "opaque" pointer: though Rust had all the information it needed, Unity cannot retrieve any of that information directly.

What we might try, then, is to extend one of our Rust methods to include a pointer to an object, either as an argument:

// If you're going to be changing anything, don't forget to make the pointer
// mutable with `*mut`.
#[no_mangle]
pub extern "C" fn pointer_argument(ptr: *mut Baton, data: *mut Something) -> bool {

‍

[DllImport("libffi_example")]
private static extern bool pointer_argument(IntPtr baton, Something data);

... as a return value:

#[no_mangle]
pub extern "C" fn pointer_returned(ptr: *mut Baton) -> *mut Something {
[DllImport("libffi_example")]
private static extern Something pointer_returned(IntPtr baton);

... or both. This will fail. At best, your compiler will yell at you. At worst, this will seem to work—until it starts behaving mysteriously and incorrectly. The most likely outcome is that your program will suddenly and unhelpfully crash.

Compilers, Languages, and Memory

Before we go any further, I want to review a little bit about computer languages.

Most conventional languages today like C# or JavaScript are written in text files, and run on a computer with a big block of memory and a processor that follows extremely basic instructions like "put a 42 here" or "add these two numbers". This is quite the gross oversimplification, but it already presents a problem: how are more sophisticated languages possible if the processor cannot understand them? On the memory front, how is a "struct" or "object" represented in this big blob of memory?

This, dear reader, is the purview of computer science and the purpose of a compiler and an interpreter. I don't intend to teach you how either of these work today, but their domain is important: mapping language-specific concepts (e.g. an object in C#) to a consistent storage format.

Using JavaScript and C# as our examples, let's take the example of an object with two keys: a numerical id and a text description:

class Example {
  UInt32 id = 0;
  string description = "Initial description";
}
Example one = new Example();
var one = {
  id: 0,
  description: 'Initial description'
}

At this point, sadly, there is no guarantee how these are laid out in memory. C# uses the LayoutKind.Auto format—described here—for objects by default, but explicitly does not make guarantees around how memory is used. JavaScript doesn't have a definition, either, leaving it to interpreter. In V8's case, for example, objects are, in the best case, laid out according to "hidden classes"—described here—based on which properties have been added.

TL;DR: We need to explicitly define how the languages we use—both the "host" language and the "embedded" language—lay out shared structures in memory. Once we do, we should be able to use native language features to access these structures on both sides of the bridge.

To make this easier, the C programming language is, for one reason or another, the lingua franca of memory layouts for languages. So much so that we can often tell other languages "just use C's rules", and get the consistency we require.

C-style in C#: LayoutKind.Sequential

To get a "C-style" class in C#, apply the LayoutKind.Sequential struct layout to the class:

[StructLayout(LayoutKind.Sequential)]
public class Example {
  ...

‍
C-style in Rust: repr(C)

To get a "C-style" data type in Rust, apply the C representation to the struct or enum:

#[repr(C)]
pub struct Example {
  ...

‍
Also, strings

While we won't go through every possible higher-level language feature and how to fit it through an FFI bridge, let's look at strings. Strings are often considered "primitives" in languages like JavaScript and C#, but that's unfortunately naïve. A string is a collection type, just like an array, though it is a nicely strict collection: it only contains characters.

Even the "simple" collection presents the same problem: how big are these characters? Are they stored as US-ASCII, ISO 8859, or some Unicode encoding?

Just as we did with objects, we'll need to indicate explicitly how to treat strings. Fortunately for our example, Rust is already specific about its standard String type: it is UTF-8 encoded. The only work we need to do, then, is be explicit on the C# side (sample taken from the example project):

#[no_mangle]
pub extern "C" fn connect_to_server(ptr: *mut *const Baton, url: *const c_char) -> bool {
  // NOTE: We still have to explicitly type-cast the string, but
  // this won't copy anything.
  let url = unsafe { CStr::from_ptr(url) };
[DllImport("libffi_example")]
private static extern bool connect_to_server(out IntPtr baton, byte[] url);
public static bool connectToServer(string url)
{
    return connect_to_server(out _baton, Encoding.UTF8.GetBytes(url));
}

This same strategy applies to any higher-order structure you want to use in your work: trees, arrays, etc. Find a way to make their layout explicit in both the host and the embedded language, enabling you to pass a pointer between the two.

Memory ownership

The last precaution we need to follow is ownership: if we allocate memory in one language and pass a pointer to that memory to the other language, who owns that memory?

While some languages (:cough: C :cough:) play fast-and-loose with the program's (and programmer's) ability to leak memory, most modern languages are more restrictive with the lifespan of any allocated block of memory. What this means for our FFI work is that memory allocated in Rust, for example, is owned by the Rust library, and it's the Rust library's job to ensure that memory is preserved. If a pointer to that block of memory is passed to C#, and Rust allows that memory to be freed, any access to that memory from C# will result in a segmentation fault and a crash.

Each set of languages and libraries you use will present a different set of options, so I'll leave you with the following general advice: if one your project's languages makes explicit memory management (e.g. allocating, freeing, pointer math) more difficult, that language should own any shared memory. In our example, that language is C#: it is much easier to use an unsafe block in Rust and do whatever pointer trickery we require, so we'll allocate objects in C# that are written to in Rust, rather than trying to pass Rust-allocated memory to C#.

Working through our example

Here are a couple interesting tidbits from the worked example:

Unity-to-Rust: separate arguments

There are two strategies provided in the bindings of our sample project. The first breaks each "field" of the call into a separate argument. Both sides of the binding use an enum to restrict the values of those arguments at compile-time, but the relationship between these values is very loose:

public enum StatusUpdate
{
    Ping = 0,
    ...
}

// Just as with the basic example, notice that we define both a private
// method that binds to the Rust library and a public method that makes
// it nicer to call from managed code.
[DllImport("libffi_example")]
private static extern void send_status_update(IntPtr baton, UInt32 id, byte status);
public static void sendStatusUpdate(StatusUpdate status)
{
    send_status_update(_baton, _id, (byte)status);
}

‍‍

impl<'a> Baton {
    ...
    fn send_status_update(&mut self, id: u32, status: StatusUpdate) -> Result<(), String> {
    ...
}

#[repr(u8)]
enum StatusUpdate {
    Ping,
    ...
}

impl StatusUpdate {
    ...
    fn from_byte(byte: u8) -> StatusUpdate {
        match byte {
            0 => StatusUpdate::Ping,
            ...
        }
    }
}

// Likewise, we have a type in Rust to manage the behaviour we care about,
// and a set of extern functions to handle communication with the host.
#[no_mangle]
pub extern "C" fn send_status_update(ptr: *mut Baton, id: u32, status: u8) -> bool {
    if !ptr.is_null() {
        match Baton::from_ptr(ptr).send_status_update(id, StatusUpdate::from_byte(status)) {
    ...
}

Unity-to-Rust: combined object

The second strategy for communicating from Unity to Rust uses a Rust struct and a C# class to pass multiple fields at once. This provides a strong semantic coupling, improves comprehension, and makes maintenance and extension easier in the future. As mentioned above, take notice of the StructLayout and repr instructions telling each compiler to consistently and compatibly lay out the structures in memory:

[StructLayout(LayoutKind.Sequential)]
private class rPositionUpdate {
    public UInt32 id;
    public Int32 x;
    public Int32 y;
}

[DllImport("libffi_example")]
private static extern void send_position_update(IntPtr baton, rPositionUpdate data);
public static void sendPositionUpdate(Vector3 position)
{
    rPositionUpdate update = new rPositionUpdate();

    update.id = _id; // Our ID, created randomly at start time.
    update.x = (Int32)Mathf.RoundToInt(position.x * 10000);
    update.y = (Int32)Mathf.RoundToInt(position.y * 10000);

    send_position_update(_baton, update);
}

‍

impl<'a> Baton {
    ...
    fn send_position_update(&mut self, data: &mut PositionUpdate) -> Result<(), String> {
        debug!("Position update received: {:}, {:}", data.x, data.y);
    ...
}

#[derive(Debug)]
#[repr(C)]
pub struct PositionUpdate {
    pub id: u32,
    pub x: i32,
    pub y: i32,
}

#[no_mangle]
pub extern "C" fn send_position_update(ptr: *mut Baton, data: *mut PositionUpdate) -> bool {
    if !ptr.is_null() && !data.is_null() {
        match Baton::from_ptr(ptr).send_position_update(PositionUpdate::from_ptr(data)) {
        ...
}

Why are X and Y multiplied by 10000? Just as there are conflicting ways to store strings, there are different ways to represent numbers. Rather than get C# and Rust—not to mention the UDP protocol—to agree on a representation for floating-point numbers, I decided to represent positions as 32-bit integers.

Rust-to-Unity

The structures for communicating from Rust to Unity look much the same, though logically there's another layer: the Update type includes an extra enum for the "kind" of update (e.g. did a new client connect, or did a client's position update?). This "kind" can be switch-ed on for behaviour.

public enum UpdateType {
    None = 0,
    Connect,
    Disconnect,
    Position,
}

public class Update {
    public UpdateType type;
    public UInt32 id;
    public Vector3 position;
}

[StructLayout(LayoutKind.Sequential)]
private class rIncomingUpdate {
    public byte type = (byte)UpdateType.None;
    public UInt32 id = 0;
    public Int32 x = 0;
    public Int32 y = 0;
}

[DllImport("libffi_example")]
private static extern void read_next_update(IntPtr baton, rIncomingUpdate update);
public static Update readNextUpdate()
{
    rIncomingUpdate incoming = new rIncomingUpdate();

    read_next_update(_baton, incoming);

    Update update = new Update();
    update.type = (UpdateType)incoming.type;
    update.id = incoming.id;
    update.position = new Vector3(incoming.x / 10000.0f, incoming.y / 10000.0f, 0.0f);

    return update;
}
impl<'a> Baton {
    ...
    fn read_next_update(&mut self, data: *mut IncomingUpdate) -> Result<(), String> {
        // Check out the full project for all the gory networking bits.
        ...
        let update_type = cursor.read_u8().unwrap();
        ...
        if update_type == 3 {
            unsafe {
                (*data).x = cursor.read_i32::<NetworkEndian>().unwrap();
                (*data).y = cursor.read_i32::<NetworkEndian>().unwrap();
            }
        }

        Ok(())
    }
}

#[no_mangle]
pub extern "C" fn read_next_update(ptr: *mut Baton, data: *mut IncomingUpdate) -> bool {
    if !ptr.is_null() {
        match Baton::from_ptr(ptr).read_next_update(data) {
        ...
}

The alternative to this is to create many individual calls for each type of event, and an initial check for the "kind". This is more memory efficient (you only send what you need), but results in more calls across the FFI bridge, which is more computationally expensive. Trade-offs.

Final notes

The principles I've laid out are the same for any two languages, whether you're writing a native C++ library for Node or Ruby, or if you're trying to write Lua bindings for an Erlang project. The way you apply them may be more sophisticated (like using a custom C++ type to represent the undefined value from V8) but the pattern of being explicit with the relationship between two languages remains.

Finally, I'll add a note I should have started this series with: these FFI bindings are about directly invoking code written in one language from code written in another. Though it might be better for performance or maintenance or comprehension, your mileage may vary. It may be easier to communicate in other ways, such as:

  • Shelling out to a legacy Ruby program from your new C project.
  • Starting an HTTP server inside of the Unity Editor and making requests to it from Go so you can access editor state from your Go CLI.
  • Offloading processing power to a server running Scala, using ZeroMQ to coordinate work with your local Swift app.

Related Insights

🔗
Getting started with FFI: Rust & Unity

Explore our insights

See all insights
Developers
Developers
Developers
C# and .NET tools and libraries for the modern developer

C# has a reputation for being used in legacy projects and is not often talked about related to startups or other new business ventures. This article aims to break a few of the myths about .NET and C# and discuss how it has evolved to be a great fit for almost any kind of software.

by
Patrick Coakley
Leadership
Leadership
Leadership
Turning observability into a team strength without a big overhaul

By addressing observability pain points one at a time, we built systems and practices that support rapid troubleshooting and collaboration.

by
Gabriel Côté-Carrier
Developers
Developers
Developers
Why I actually enjoy PR reviews (and you should, too)

PR reviews don't have to be painful. Discover practical, evidence-based approaches that turn code reviews into team-building opportunities while maintaining quality and reducing development friction.

by
Robert Komaromi
Letter art spelling out NEAT

Join the conversation

Technology is a means to an end: answers to very human questions. That’s why we created a community for developers and product managers.

Explore the community
Test Double Executive Leadership Team

Learn about our team

Like what we have to say about building great software and great teams?

Get to know us
Test Double company logo
Improving the way the world builds software.
What we do
Services OverviewSoftware DeliveryProduct ManagementLegacy ModernizationDevOpsUpgrade RailsTechnical RecruitmentTechnical Assessments
Who WE ARE
About UsCulture & CareersGreat CausesEDIOur TeamContact UsNews & AwardsN.E.A.T.
Resources
Case StudiesAll InsightsLeadership InsightsDeveloper InsightsProduct InsightsPairing & Office Hours
NEWSLETTER
Sign up hear about our latest innovations.
Your email has been added!
Oops! Something went wrong while submitting the form.
Standard Ruby badge
614.349.4279hello@testdouble.com
Privacy Policy
© 2020 Test Double. All Rights Reserved.