Go back

Using .NET 7 Native AOT to call .NET functionality in C++

#.NET #Csharp #C++

With the release of .NET 7, a new and exciting feature has been introduced - the Native AOT (Ahead of Time). This feature involves compiling an application's code into native machine code before it's executed. That is quite the difference from regular .NET compiled code!


One of the great advantages of using Native AOT in C++ is the ability to leverage the functionality written in C#. For example, with the use of System.Text.Json, developers can easily serialize and deserialize data in their C++ applications. In this tutorial, we will walk through a simple example of how to use this powerful feature to enhance the functionality of your C++ applications.

Overview

For this example I want to create a simple library, all it does is serialize and deserialize a class. However! The data should be accessible in my C++ console application. To start this off you will need a solution with 2 projects.

The C++ Console project and a .NET DLL project. All of which can be found in my repository.

project-setup

If you want to follow along from the start, open Visual Studio and start by creating a blank empty project(solution).

The Native AOT Library

With our blank solution open, lets create a .NET class library, make sure you target .NET 7.

I've renamed the Class.cs file to Library.cs and added a simple model for Person. For simplicity I'll stick to one file.

[JsonSerializable(typeof(Person), GenerationMode = JsonSourceGenerationMode.Metadata)]
internal partial class JsonContext : JsonSerializerContext { }

[StructLayout(LayoutKind.Sequential)]
public class Person
{
    public int Age { get; set; }
    public Gender Gender { get; set; }
}

public enum Gender
{
    Male,
    Female,
    Other
}

If you worked with .NET before, you'll likely notice some differences right of the bat. Lets start from the top.

[JsonSerializable(typeof(Person), GenerationMode = JsonSourceGenerationMode.Metadata)]
internal partial class JsonContext : JsonSerializerContext { }

As some features of .NET fully rely on runtime code generation, this may not work in a native application. What this statement does is, we tell it to enable a specific source generator. A source generator will generate code at during compilation instead of runtime, which is exactly what we need considering we are native.

The JsonSourceGenerationMode.Metadata mode is specifically for serialization and deserialization code at compile time. This metadata can then be used at runtime to perform the serialization and deserialization, without requiring runtime code generation.

[StructLayout(LayoutKind.Sequential)]

This attribute in C# is used to control the layout of the fields in a structure or class. When applied to a class, it specifies that the layout of the class's fields should be sequential, which means that the fields are laid out in the order they are declared in the class. I believe this in other words mean, the StructLayout is brittle. (The layout of the structure is fragile and can easily break if the code is modified)

In C++ this is super important, as they use a specific memory layout for its data structures, which may differ from the layout that is applied in C#. For that reason we apply [StructLayout(LayoutKind.Sequential)].

With that out of the way, lets continue!

UnmanagedCallersOnly methods

To export functionality we are going to use [UnmanagedCallersOnly(EntryPoint = "")]. This attribute is intended to be called from unmanaged code, which means code that was not written in C# and does not live in the .NET runtime environment. The EntryPoint parameter specifies the name of the method, which is what you want to call in your native code.

The serialization of data

In our Library.cs file lets add the following method:

private static readonly Person Person = new()
{
    Age = 100,
    Gender = Gender.Female
};

[UnmanagedCallersOnly(EntryPoint = "GetSerializedPerson")]
public static nint SerializedPerson()
{
    var serializedObject = JsonSerializer.Serialize(Person, JsonContext.Default.Person);
    var length = Encoding.UTF8.GetByteCount(serializedObject);

    // Allocate unmanaged memory block
    var bufferPtr = Marshal.AllocHGlobal(length + 1); // + 1 is due to the null terminator.

    // Write serialized object directly to unmanaged memory
    var buffer = new Span<byte>(bufferPtr.ToPointer(), length);
    Encoding.UTF8.GetBytes(serializedObject, buffer);

    // Add null terminator
    Marshal.WriteByte(bufferPtr, length, 0);

    return bufferPtr;
}

The method returns a pointer(32 or 64bit signed) to a block of unmanaged memory, which contains the serialized json. The memory section that we create is based on the length of the Person object, but we include an additional byte for the null terminator.

The null terminator is what is known as the end of a string. Which is very common in C or C++. This is important as we will use it later on.

the buffer represents a portion of the unmanaged memory block, using the bufferPtr.ToPointer() method to obtain a pointer to the starting address of the memory block. It then copies the serialized object to this unmanaged buffer using the Encoding.UTF8.GetBytes method, which writes the bytes directly to the unmanaged memory buffer.

We finish it by adding the null terminator to the end of the memory block.

Retrieving the length of a string, through a pointer?!

After storing data in memory, the next step is to determine the actual length of the string, which can be achieved using a simple method.

public static int GetStringLength(nint stringPtr)
{
    var ptr = (byte*)stringPtr;

    // Find the null terminator
    var length = 0;
    while (*(ptr + length) != 0)
    {
        length++;
    }

    return length;
}

This takes in a pointer, casts it to a byte pointer for it to work directly with the memory block pointing to stringPtr. We use the null terminator we previously discussed to figure out the end of the object. Upon reaching the null terminator byte it stops, and returns the length of the string.

The deserialization of data

Now that we have our length and pointer, we could go back to it once more and actually deserialize to the person class.

public static nint DeserializedPerson(nint stringPtr, int length)
{
    var serializedData = new Span<byte>((void*)stringPtr, length);

    var person = JsonSerializer.Deserialize(serializedData, JsonContext.Default.Person);

    // Allocate unmanaged memory block
    var bufferPtr = Marshal.AllocHGlobal(Marshal.SizeOf<Person>());

    Marshal.StructureToPtr(person, bufferPtr, false);

    return bufferPtr;
}

A span is often used when working with unmanaged memory in C#. While the first line might look confusing it is just a Span object that represents the serialized data. The void* pointer that is passed in as the first argument is cast to a pointer to byte

The rest of the code should already start to look familiar, instead of serializing data, we deserialize it to given Person object. Proceed to allocate a memory block and then use Marshal.StructureToPtr to copy data from the Person object to the unmanaged memory block.

Building the Native AOT library.

To build the library we want to run the following command in a CLI.

dotnet publish -r win-x64 -c Release. Inside the release folder > x64 > publish you should find the *.dll file.

Using the Native AOT library in our C++ application

In our solution lets create a new project, this time a C++ console application.

The person model

From here lets start with the model again. In our C# library we have the Person class, so lets create a header file Person.h and fill it with our data types.

#pragma once

enum class Gender {
    Male,
    Female,
    Other
};

class Person {
public:
    int Age;
    Gender Gender;
};

Main application

In the main method of our console application we want to first retrieve an instance of our recently compiled dll.

int main()
{
    // For simplicity, just drop the native DLL in build directory.
    const HMODULE lib = LoadLibraryA("NativeDll.dll");
}

Then create our typedef statements, the typedef statements are being used to create function pointers for three different functions that we will use.

  • fnSerializedPerson is a function pointer that points to a function that returns a uintptr_t value. This function is responsible for serializing a person object, which means converting it into a format that can be stored or transmitted.

  • fnDerserializePerson is a function pointer that points to a function that takes a uintptr_t value and an int32_t value as parameters, and returns a uintptr_t value. This function is responsible for deserializing a person object, which means converting it from a serialized format back into an object.

  • fnStringLength is a function pointer that points to a function that takes a uintptr_t value as a parameter and returns an int32_t value. This function is responsible for calculating the length of a string.

// Creating a definition of the function object.
typedef uintptr_t(*fnSerializedPerson)();
typedef uintptr_t(*fnDerserializePerson)(uintptr_t, int32_t);
typedef int32_t(*fnStringLength)(uintptr_t);

Here we define our variables and initialize it with the address of a function e.g. GetSerializedPerson that is located in a dynamic-link library (DLL).

// The function objects.
auto serializePerson = fnSerializedPerson(GetProcAddress(lib, "GetSerializedPerson"));
auto getStringLength = fnStringLength(GetProcAddress(lib, "GetStringLength"));
auto deserializePerson = fnDerserializePerson(GetProcAddress(lib, "GetDeserializedPerson"));

Now that we have all the functionality available, it is time to use them.

  1. Call serializePerson to obtain a pointer to a serialized person object and assign it to the personStringPtr variable.

  2. Call getStringLength with personStringPtr as a parameter to obtain the length of the serialized object, and assign the result to the length variable.

  3. Create a std::string object personJson using the constructor that takes a const char* and a size_t as parameters. The const char* parameter is obtained by casting personStringPtr to a const char* using the reinterpret_cast operator, and the size_t parameter is set to length.

  4. Call deserializePerson with personStringPtr and length as parameters to obtain a pointer to a deserialized person object, and assign it to the personPtr variable.

  5. Cast personPtr to a Person* object using the reinterpret_cast operator and assign it to the person pointer variable. This creates a Person object from the deserialized data.

// Call the person functions and convert the returned pointer to a string
uintptr_t personStringPtr = serializePerson();
int32_t length = getStringLength(personStringPtr);
std::string personJson(reinterpret_cast<const char*>(personStringPtr), length);

// Call the function and convert the returned pointer to a Person object
uintptr_t personPtr = deserializePerson(personStringPtr, length);
Person* person = reinterpret_cast<Person*>(personPtr);

Full method

#include "Windows.h"
#include <iostream>
#include "Person.h"


int main()
{
    // For simplicity, just drop the native DLL in build directory.
    const HMODULE lib = LoadLibraryA("NativeDLL.dll");

    // Creating a definition of the function object.
    typedef uintptr_t(*fnSerializedPerson)();
    typedef uintptr_t(*fnDerserializePerson)(uintptr_t, int32_t);
    typedef int32_t(*fnStringLength)(uintptr_t);

    // The function objects.
    auto serializePerson = fnSerializedPerson(GetProcAddress(lib, "GetSerializedPerson"));
    auto getStringLength = fnStringLength(GetProcAddress(lib, "GetStringLength"));
    auto deserializePerson = fnDerserializePerson(GetProcAddress(lib, "GetDeserializedPerson"));

    // Call the person functions and convert the returned pointer to a string
    uintptr_t personStringPtr = serializePerson();
    int32_t length = getStringLength(personStringPtr);
    std::string personJson(reinterpret_cast<const char*>(personStringPtr), length);

    // Call the function and convert the returned pointer to a Person object
    uintptr_t personPtr = deserializePerson(personStringPtr, length);
    Person* person = reinterpret_cast<Person*>(personPtr);

    std::cout << "Json: " << personJson << std::endl;
    std::cout << "Age: " << person->Age << std::endl;
    std::cout << "Gender: " << static_cast<int>(person->Gender) << std::endl;
}

Please be aware that this is just an example and does not contain any checks for a list of things. It is up to you to implement these, some ideas:

  • LoadLibraryA - Might fail to load the specified DLL and does not check if NULL was returned.
  • GetProcAddress - Will fail if LoadLibraryA failed.
  • The function objects might be null pointers.
  • Providing invalid input or lengths while using one of the functions.

Friendly reminder that you need to release every GCHandle. So if you return objects from one of the Native AOT methods, make sure to free them as well!

etc..

Result

build-result

Resources